dify/web/features/skills/detail/builder-panel.tsx

1126 lines
44 KiB
TypeScript

'use client'
/* oxlint-disable eslint-react/set-state-in-effect -- The builder resets its local transcript when the authoritative detail snapshot changes. */
import type {
SkillDetailResponse,
SkillFileResponse,
} from '@dify/contracts/api/console/workspaces/types.gen'
import type { BuilderChatMessage, SkillBuilderAttachment, SkillBuilderModel } from './shared'
import type {
FormValue,
Model,
} from '@/app/components/header/account-setting/model-provider-page/declarations'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { toast } from '@langgenius/dify-ui/toast'
import { useQueryClient } from '@tanstack/react-query'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Markdown } from '@/app/components/base/markdown'
import {
ModelStatusEnum,
ModelTypeEnum,
} from '@/app/components/header/account-setting/model-provider-page/declarations'
import {
useDefaultModel,
useModelList,
} from '@/app/components/header/account-setting/model-provider-page/hooks'
import ModelParameterModal from '@/app/components/header/account-setting/model-provider-page/model-parameter-modal'
import { ModelSelector } from '@/app/components/header/account-setting/model-provider-page/model-selector'
import { sendSkillAssistMessage, uploadSkillFile } from '../client'
import { SkillBuilderGridTexture } from './builder-grid-texture'
import {
findFileByPath,
isAllowedSkillBuilderAttachment,
isDefaultSkillBuilderDraft,
isRecord,
isTextFile,
setSkillDetailCache,
skillBuilderAttachmentAccept,
skillBuilderMaxAttachmentBytes,
skillBuilderMaxAttachments,
skillBuilderMaxImageAttachmentBytes,
} from './shared'
const skillBuilderProgressStages = [
'reading_draft',
'generating_plan',
'applying_changes',
'updating_editor',
] as const
type SkillBuilderProgressStage = (typeof skillBuilderProgressStages)[number]
function isSkillBuilderProgressStage(stage: unknown): stage is SkillBuilderProgressStage {
return (
typeof stage === 'string' &&
skillBuilderProgressStages.includes(stage as SkillBuilderProgressStage)
)
}
function SkillBuilderProgressStageLabel({ stage }: { stage: SkillBuilderProgressStage }) {
const { t } = useTranslation('skill')
if (stage === 'reading_draft')
return <>{t(($) => $['skillManagement.detail.builder.progress.readingDraft'])}</>
if (stage === 'generating_plan')
return <>{t(($) => $['skillManagement.detail.builder.progress.generatingPlan'])}</>
if (stage === 'applying_changes')
return <>{t(($) => $['skillManagement.detail.builder.progress.applyingChanges'])}</>
return <>{t(($) => $['skillManagement.detail.builder.progress.updatingEditor'])}</>
}
function BuilderModelSelector({
isLoading,
modelList,
selectedModel,
onSelect,
}: {
isLoading: boolean
modelList: Model[]
selectedModel: SkillBuilderModel | undefined
onSelect: (model: SkillBuilderModel) => void
}) {
const { t } = useTranslation()
return (
<div className="flex w-fit max-w-full min-w-0 items-center gap-px">
{isLoading ? (
<div className="h-6 w-20 rounded-md bg-state-base-hover" />
) : (
<ModelSelector
value={
selectedModel
? { provider: selectedModel.provider, model: selectedModel.model }
: undefined
}
models={modelList}
popupClassName="h-[480px]! max-h-[480px]! w-80! max-w-80!"
showModelMeta={false}
className="h-8! w-fit! max-w-full bg-transparent! p-1! hover:bg-state-base-hover! [&>div:first-child]:hidden [&>div:nth-child(2)]:px-0"
onValueChange={({ model, provider }) => {
onSelect({
...selectedModel,
provider,
model,
})
}}
/>
)}
{!isLoading && (
<ModelParameterModal
isAdvancedMode
hideDebugWithMultipleModel
modelId={selectedModel?.model ?? ''}
provider={selectedModel?.provider ?? ''}
completionParams={(selectedModel?.model_settings ?? { temperature: 0.7 }) as FormValue}
modelList={modelList}
modelSelectorPopupClassName="h-[480px]! max-h-[480px]! w-80! max-w-80!"
showModelMeta={false}
placement="bottom-start"
trigger={
<button
type="button"
aria-label={t(($) => $['modelProvider.modelSettings'], { ns: 'common' })}
className="flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-lg text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary"
>
<span aria-hidden className="i-ri-equalizer-2-line size-4" />
</button>
}
setModel={({ modelId, provider }) => {
onSelect({
...selectedModel,
provider,
model: modelId,
})
}}
onCompletionParamsChange={(modelSettings) => {
if (!selectedModel) return
onSelect({
...selectedModel,
model_settings: modelSettings,
})
}}
/>
)}
</div>
)
}
function SkillBuilderThinkingMessage({
progressStages,
reasoningContent,
seconds,
}: {
progressStages?: string[]
reasoningContent?: string
seconds: number
}) {
const { t } = useTranslation('skill')
const minutes = Math.floor(seconds / 60)
const remainingSeconds = seconds % 60
const duration = minutes > 0 ? `${minutes}m${remainingSeconds}s` : `${remainingSeconds}s`
const reasoning = reasoningContent?.trim()
const visibleProgressStages = Array.from(new Set(progressStages ?? [])).filter(
isSkillBuilderProgressStage,
)
const [open, setOpen] = useState(false)
const content = (
<>
<span>{t(($) => $['skillManagement.detail.builder.thinking'])}</span>
<span aria-hidden className="font-normal text-text-quaternary">
·
</span>
<span>{duration}</span>
</>
)
return (
<div className="px-1 text-text-tertiary">
<button
type="button"
aria-expanded={open}
className="flex h-6 cursor-pointer items-center gap-1 system-xs-medium outline-hidden hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid"
onClick={() => setOpen((current) => !current)}
>
{content}
<span
aria-hidden
className={cn('i-ri-arrow-down-s-line size-4 transition-transform', open && 'rotate-180')}
/>
</button>
{open && (
<div className="mt-1 max-h-40 overflow-y-auto rounded-lg bg-background-section p-2 system-xs-regular text-text-secondary">
{reasoning ? (
<Markdown
content={reasoning}
className="text-[12px]! leading-4.5! [&_p]:my-0 [&_p]:text-[12px]! [&_p]:leading-4.5!"
/>
) : visibleProgressStages.length ? (
<ol className="flex flex-col gap-1">
{visibleProgressStages.map((stage) => (
<li key={stage} className="flex items-center gap-2">
<span aria-hidden className="size-1.5 rounded-full bg-text-tertiary" />
<span>
<SkillBuilderProgressStageLabel stage={stage} />
</span>
</li>
))}
</ol>
) : (
<Markdown
content={t(($) => $['skillManagement.detail.builder.thinkingUnavailable'])}
className="text-[12px]! leading-4.5! [&_p]:my-0 [&_p]:text-[12px]! [&_p]:leading-4.5!"
/>
)}
</div>
)}
</div>
)
}
function SkillBuilderMessageAttachments({
attachments,
}: {
attachments: SkillBuilderAttachment[]
}) {
return (
<div className="flex max-w-full flex-wrap justify-end gap-1.5">
{attachments.map((attachment) =>
attachment.previewUrl && attachment.mimeType.startsWith('image/') ? (
<img
key={attachment.id}
src={attachment.previewUrl}
alt={attachment.name}
className="max-h-40 max-w-56 rounded-lg border border-divider-subtle object-contain"
/>
) : (
<div
key={attachment.id}
className="flex max-w-56 items-center gap-2 rounded-lg border border-divider-subtle bg-background-default px-2.5 py-2 text-left text-text-secondary shadow-xs"
title={attachment.name}
>
<span aria-hidden className="i-ri-file-line size-4 shrink-0 text-text-tertiary" />
<span className="truncate system-xs-medium">{attachment.name}</span>
</div>
),
)}
</div>
)
}
const skillBuilderEmptyIconCellOpacities = [
'0 0 0.093 0.166 0 0 0.155 0',
'0 0.159 0.145 0.159 0.135 0.179 0.128 0.105',
'0.091 0 0.161 0.187 0.102 0 0.111 0',
'0.148 0.159 0 0 0.195 0.158 0.342 0.128',
'0.169 0.132 0 0.115 0.112 0.319 0.218 0.199',
'0.241 0.206 0.124 0.181 0.212 0.211 0.315 0.127',
'0.133 0.21 0.166 0.476 0.167 0.22 0.136 0.246',
'0 0.132 0.151 0.146 0.276 0.256 0.269 0',
].flatMap((row) => row.split(' ').map(Number))
const skillBuilderEmptyIconCells = skillBuilderEmptyIconCellOpacities.map((opacity, index) => ({
id: `skill-builder-icon-cell-${Math.floor(index / 8)}-${index % 8}`,
opacity,
}))
function SkillBuilderEmptyIcon() {
return (
<div className="dify-blue-glass-surface relative flex h-12.5 w-12 items-center justify-center rounded-xl p-2 shadow-lg backdrop-blur-[5px]">
<div
aria-hidden
className="absolute inset-x-px inset-y-0.5 grid grid-cols-[repeat(8,4px)] grid-rows-[repeat(8,4px)] gap-0.5 opacity-25"
>
{skillBuilderEmptyIconCells.map((cell) => (
<span
key={cell.id}
className={cell.opacity > 0 ? 'rounded-[1px] bg-[#98A2B2]' : 'invisible'}
style={{ opacity: cell.opacity }}
/>
))}
</div>
<span
aria-hidden
className="relative i-custom-vender-agent-v2-building-blocks size-5 text-[#0033FF] drop-shadow-[0_0_4px_rgba(49,70,255,0.18)]"
/>
</div>
)
}
export function SkillBuilderPanel({
detail,
onDraftDetailChange,
onClose,
selectedFile,
skillId,
}: {
detail: SkillDetailResponse
onDraftDetailChange: (detail: SkillDetailResponse) => void
onClose: () => void
selectedFile: SkillFileResponse | undefined
skillId: string
}) {
const { t } = useTranslation('skill')
const queryClient = useQueryClient()
const [prompt, setPrompt] = useState('')
const initialBuilderModeRef = useRef({
isEditMode: !isDefaultSkillBuilderDraft(detail),
skillId,
})
if (initialBuilderModeRef.current.skillId !== skillId) {
initialBuilderModeRef.current = {
isEditMode: !isDefaultSkillBuilderDraft(detail),
skillId,
}
}
const isEditMode = initialBuilderModeRef.current.isEditMode
const initialMessages = useMemo<BuilderChatMessage[]>(
() =>
isEditMode
? [
{
id: `assistant-${skillId}-intro`,
role: 'assistant',
content: t(($) => $['skillManagement.detail.builder.editIntro']),
},
]
: [],
[isEditMode, skillId, t],
)
const [messages, setMessages] = useState<BuilderChatMessage[]>(initialMessages)
const messagesRef = useRef<BuilderChatMessage[]>(initialMessages)
const rawAssistantMessagesRef = useRef(new Map<string, string>())
const [attachments, setAttachments] = useState<SkillBuilderAttachment[]>([])
const [isUploadingAttachment, setIsUploadingAttachment] = useState(false)
const attachmentInputRef = useRef<HTMLInputElement>(null)
const attachmentUploadGenerationRef = useRef(0)
const [isSending, setIsSending] = useState(false)
const [thinkingElapsedSeconds, setThinkingElapsedSeconds] = useState(0)
const thinkingElapsedSecondsRef = useRef(0)
const isSendingRef = useRef(false)
const isComposingRef = useRef(false)
const detailRef = useRef(detail)
const selectedFileRef = useRef(selectedFile)
const assistAbortControllerRef = useRef<AbortController | null>(null)
const { data: defaultTextGenerationModel } = useDefaultModel(ModelTypeEnum.textGeneration)
const { data: textGenerationModelList, isLoading: isTextGenerationModelListLoading } =
useModelList(ModelTypeEnum.textGeneration)
const fallbackModel = useMemo<SkillBuilderModel | undefined>(() => {
for (const provider of textGenerationModelList) {
if (provider.status !== ModelStatusEnum.active) continue
const model = provider.models.find((model) => model.status === ModelStatusEnum.active)
if (model) {
return {
provider: provider.provider,
model: model.model,
model_settings: { temperature: 0.7 },
}
}
}
return undefined
}, [textGenerationModelList])
const defaultBuilderModel = defaultTextGenerationModel
? {
provider: defaultTextGenerationModel.provider.provider,
model: defaultTextGenerationModel.model,
model_settings: { temperature: 0.7 },
}
: undefined
const [selectedModel, setSelectedModel] = useState<SkillBuilderModel | undefined>()
const activeSelectedModel = selectedModel ?? defaultBuilderModel ?? fallbackModel
const canSendBuilderMessage = !!activeSelectedModel?.provider && !!activeSelectedModel?.model
const suggestions = [
t(($) => $['skillManagement.detail.builder.exampleIssueTriage']),
t(($) => $['skillManagement.detail.builder.exampleSalesFollowUp']),
t(($) => $['skillManagement.detail.builder.exampleOnboarding']),
]
const inputPlaceholder =
messages.length > 0
? t(($) => $['skillManagement.detail.builder.modifyPlaceholder'])
: t(($) => $['skillManagement.detail.builder.placeholder'])
const updateMessages = (
updater: (currentMessages: BuilderChatMessage[]) => BuilderChatMessage[],
) => {
setMessages((currentMessages) => {
const nextMessages = updater(currentMessages)
messagesRef.current = nextMessages
return nextMessages
})
}
const replaceAssistantMessageWithError = (assistantMessageId: string, errorMessage: string) => {
rawAssistantMessagesRef.current.set(assistantMessageId, errorMessage)
updateMessages((currentMessages) =>
currentMessages.map((message) =>
message.id === assistantMessageId
? {
...message,
content: errorMessage,
rawContent: errorMessage,
tone: 'error',
}
: message,
),
)
}
useEffect(() => {
detailRef.current = detail
}, [detail])
useEffect(() => {
selectedFileRef.current = selectedFile
}, [selectedFile])
useEffect(() => {
messagesRef.current = initialMessages
rawAssistantMessagesRef.current.clear()
setMessages(initialMessages)
}, [initialMessages])
useEffect(() => {
messagesRef.current = messages
}, [messages])
useEffect(() => {
return () => {
assistAbortControllerRef.current?.abort()
attachmentUploadGenerationRef.current += 1
}
}, [])
useEffect(() => {
if (!isSending) return
const timer = window.setInterval(() => {
setThinkingElapsedSeconds((currentSeconds) => {
const nextSeconds = currentSeconds + 1
thinkingElapsedSecondsRef.current = nextSeconds
return nextSeconds
})
}, 1000)
return () => window.clearInterval(timer)
}, [isSending])
const handleRestart = () => {
assistAbortControllerRef.current?.abort()
assistAbortControllerRef.current = null
attachmentUploadGenerationRef.current += 1
setPrompt('')
messagesRef.current = initialMessages
rawAssistantMessagesRef.current.clear()
setMessages(initialMessages)
setAttachments([])
setIsUploadingAttachment(false)
if (attachmentInputRef.current) attachmentInputRef.current.value = ''
setIsSending(false)
setThinkingElapsedSeconds(0)
thinkingElapsedSecondsRef.current = 0
isSendingRef.current = false
}
const handleClose = () => {
assistAbortControllerRef.current?.abort()
assistAbortControllerRef.current = null
attachmentUploadGenerationRef.current += 1
isSendingRef.current = false
setIsSending(false)
setThinkingElapsedSeconds(0)
thinkingElapsedSecondsRef.current = 0
onClose()
}
const handleCompositionStart = () => {
isComposingRef.current = true
}
const handleCompositionEnd = () => {
// Safari can fire compositionend before the Enter keydown that confirms the composition.
window.setTimeout(() => {
isComposingRef.current = false
}, 50)
}
const handleAttachmentChange = async (file: File | undefined) => {
if (!file || isUploadingAttachment) return
if (attachments.length >= skillBuilderMaxAttachments) {
toast.error(t(($) => $['skillManagement.detail.builder.attachLimit']))
if (attachmentInputRef.current) attachmentInputRef.current.value = ''
return
}
const maxBytes = file.type.startsWith('image/')
? skillBuilderMaxImageAttachmentBytes
: skillBuilderMaxAttachmentBytes
if (file.size > maxBytes) {
toast.error(t(($) => $['skillManagement.detail.builder.attachTooLarge']))
if (attachmentInputRef.current) attachmentInputRef.current.value = ''
return
}
if (!isAllowedSkillBuilderAttachment(file)) {
toast.error(t(($) => $['skillManagement.detail.builder.attachUnsupported']))
if (attachmentInputRef.current) attachmentInputRef.current.value = ''
return
}
setIsUploadingAttachment(true)
const uploadGeneration = attachmentUploadGenerationRef.current
const previewUrl = file.type.startsWith('image/') ? URL.createObjectURL(file) : undefined
try {
const uploadedFile = await uploadSkillFile(file)
if (uploadGeneration !== attachmentUploadGenerationRef.current) {
if (previewUrl) URL.revokeObjectURL(previewUrl)
return
}
setAttachments((currentAttachments) => [
...currentAttachments,
{
id: uploadedFile.id,
mimeType: uploadedFile.mime_type || file.type || 'application/octet-stream',
name: uploadedFile.name || file.name,
previewUrl,
size: uploadedFile.size ?? file.size,
toolFileId: uploadedFile.id,
},
])
} catch (error) {
if (uploadGeneration !== attachmentUploadGenerationRef.current) {
if (previewUrl) URL.revokeObjectURL(previewUrl)
return
}
if (previewUrl) URL.revokeObjectURL(previewUrl)
toast.error(
error instanceof Error
? error.message
: t(($) => $['skillManagement.detail.builder.attachFailed']),
)
} finally {
if (uploadGeneration === attachmentUploadGenerationRef.current) {
setIsUploadingAttachment(false)
if (attachmentInputRef.current) attachmentInputRef.current.value = ''
}
}
}
const removeAttachment = (attachmentId: string) => {
setAttachments((currentAttachments) => {
const attachment = currentAttachments.find((item) => item.id === attachmentId)
if (attachment?.previewUrl) URL.revokeObjectURL(attachment.previewUrl)
return currentAttachments.filter((item) => item.id !== attachmentId)
})
}
const getBuilderTargetFile = (currentDetail: SkillDetailResponse) => {
const currentSelectedFile = selectedFileRef.current
if (currentSelectedFile && isTextFile(currentSelectedFile)) {
const latestSelectedFile = findFileByPath(currentDetail.files ?? [], currentSelectedFile.path)
if (latestSelectedFile && isTextFile(latestSelectedFile)) return latestSelectedFile
}
const skillMd = findFileByPath(currentDetail.files ?? [], 'SKILL.md')
return skillMd && isTextFile(skillMd) ? skillMd : undefined
}
const handleSend = (
messageText = prompt,
messageAttachments: SkillBuilderAttachment[] = attachments,
) => {
const trimmedPrompt = messageText.trim()
const attachedFiles = messageAttachments
if (
!canSendBuilderMessage ||
(!trimmedPrompt && attachedFiles.length === 0) ||
isUploadingAttachment ||
isSendingRef.current
)
return
isSendingRef.current = true
const requestMessage =
trimmedPrompt || t(($) => $['skillManagement.detail.builder.attachmentOnlyMessage'])
const conversationHistory = messagesRef.current
.filter((message) => message.content.trim() && message.id !== `assistant-${skillId}-intro`)
.slice(-12)
.map(({ content, role, suggestedDisplayName, suggestedName }) => ({
content,
role,
suggested_display_name: suggestedDisplayName,
suggested_name: suggestedName,
}))
const userMessage: BuilderChatMessage = {
attachments: attachedFiles,
id: `user-${Date.now()}`,
role: 'user',
content: requestMessage,
}
const assistantMessageId = `assistant-${Date.now()}`
const assistantMessage: BuilderChatMessage = {
id: assistantMessageId,
role: 'assistant',
content: '',
thinkingDurationSeconds: 0,
}
updateMessages((currentMessages) => [
...currentMessages
.map((message) => ({ ...message, suggestions: undefined }))
.filter(
(message) =>
message.id !== `assistant-${skillId}-intro` ||
currentMessages.some((currentMessage) => currentMessage.role === 'user'),
),
userMessage,
assistantMessage,
])
setPrompt('')
setAttachments([])
setIsSending(true)
setThinkingElapsedSeconds(0)
thinkingElapsedSecondsRef.current = 0
void sendSkillAssistMessage({
skillId,
history: conversationHistory,
attachments: attachedFiles.map((attachment) => ({
mime_type: attachment.mimeType,
name: attachment.name,
size: attachment.size,
tool_file_id: attachment.toolFileId,
})),
message: requestMessage,
model: activeSelectedModel,
targetPath: getBuilderTargetFile(detailRef.current)?.path,
getAbortController: (abortController) => {
assistAbortControllerRef.current = abortController
},
onData: (chunk) => {
if (!chunk) return
const rawAssistantContent = `${
rawAssistantMessagesRef.current.get(assistantMessageId) ?? ''
}${chunk}`
rawAssistantMessagesRef.current.set(assistantMessageId, rawAssistantContent)
updateMessages((currentMessages) =>
currentMessages.map((message) =>
message.id === assistantMessageId
? {
...message,
content: rawAssistantContent,
rawContent: rawAssistantContent,
}
: message,
),
)
},
onUnhandledEvent: (event) => {
if (event.event === 'skill_assistant_progress') {
const stage = event.stage
if (!isSkillBuilderProgressStage(stage)) return
updateMessages((currentMessages) =>
currentMessages.map((message) =>
message.id === assistantMessageId
? {
...message,
progressStages: [...(message.progressStages ?? []), stage],
}
: message,
),
)
return
}
if (event.event === 'skill_assistant_reasoning_chunk') {
const reasoning = typeof event.reasoning === 'string' ? event.reasoning : ''
if (!reasoning) return
updateMessages((currentMessages) =>
currentMessages.map((message) =>
message.id === assistantMessageId
? {
...message,
reasoningContent: `${message.reasoningContent ?? ''}${reasoning}`,
}
: message,
),
)
return
}
if (event.event === 'skill_assistant_name_suggestion') {
const suggestedName = typeof event.name === 'string' ? event.name : undefined
const suggestedDisplayName =
typeof event.display_name === 'string' ? event.display_name : undefined
if (!suggestedName && !suggestedDisplayName) return
updateMessages((currentMessages) =>
currentMessages.map((message) =>
message.id === assistantMessageId
? { ...message, suggestedName, suggestedDisplayName }
: message,
),
)
return
}
if (event.event === 'skill_assistant_suggestions') {
const nextSuggestions = Array.isArray(event.suggestions)
? event.suggestions.filter(
(suggestion): suggestion is string => typeof suggestion === 'string',
)
: []
updateMessages((currentMessages) =>
currentMessages.map((message) =>
message.id === assistantMessageId
? {
...message,
suggestions: nextSuggestions,
}
: message,
),
)
return
}
if (event.event !== 'skill_detail_updated' || !isRecord(event.detail)) return
const nextDetail = event.detail as SkillDetailResponse
detailRef.current = nextDetail
setSkillDetailCache(queryClient, skillId, nextDetail)
onDraftDetailChange(nextDetail)
},
onCompleted: (hasError, errorMessage) => {
const thinkingDurationSeconds = thinkingElapsedSecondsRef.current
updateMessages((currentMessages) =>
currentMessages.map((message) =>
message.id === assistantMessageId ? { ...message, thinkingDurationSeconds } : message,
),
)
setIsSending(false)
setThinkingElapsedSeconds(0)
thinkingElapsedSecondsRef.current = 0
isSendingRef.current = false
assistAbortControllerRef.current = null
if (hasError && errorMessage) {
replaceAssistantMessageWithError(assistantMessageId, errorMessage)
toast.error(errorMessage)
}
},
onError: (errorMessage) => {
const thinkingDurationSeconds = thinkingElapsedSecondsRef.current
updateMessages((currentMessages) =>
currentMessages.map((message) =>
message.id === assistantMessageId ? { ...message, thinkingDurationSeconds } : message,
),
)
setIsSending(false)
setThinkingElapsedSeconds(0)
thinkingElapsedSecondsRef.current = 0
isSendingRef.current = false
assistAbortControllerRef.current = null
if (errorMessage) {
replaceAssistantMessageWithError(assistantMessageId, errorMessage)
toast.error(errorMessage)
}
},
}).catch((error: unknown) => {
const thinkingDurationSeconds = thinkingElapsedSecondsRef.current
updateMessages((currentMessages) =>
currentMessages.map((message) =>
message.id === assistantMessageId ? { ...message, thinkingDurationSeconds } : message,
),
)
setIsSending(false)
setThinkingElapsedSeconds(0)
thinkingElapsedSecondsRef.current = 0
isSendingRef.current = false
assistAbortControllerRef.current = null
toast.error(
error instanceof Error
? error.message
: t(($) => $['skillManagement.detail.builder.sendFailed']),
)
})
}
const handleCopyMessage = async (content: string) => {
await navigator.clipboard.writeText(content)
toast.success(t(($) => $['skillManagement.detail.builder.copySuccess']))
}
const handleReadMessage = (content: string) => {
window.speechSynthesis.cancel()
window.speechSynthesis.speak(new SpeechSynthesisUtterance(content))
}
const handleRetryMessage = (messageIndex: number) => {
const previousUserMessage = messages
.slice(0, messageIndex)
.reverse()
.find((message) => message.role === 'user')
if (previousUserMessage)
handleSend(previousUserMessage.content, previousUserMessage.attachments ?? [])
}
return (
<aside className="relative my-1 mr-1 flex w-99 shrink-0 flex-col overflow-hidden rounded-lg inset-ring-[0.5px] inset-ring-divider-subtle">
<div
aria-hidden
className="pointer-events-none absolute inset-0 z-0 bg-linear-to-b from-background-gradient-bg-fill-chat-bg-1 to-background-gradient-bg-fill-chat-bg-2"
/>
<SkillBuilderGridTexture
aria-hidden
className="pointer-events-none absolute top-0 left-0 z-2"
/>
<SkillBuilderGridTexture
aria-hidden
className="pointer-events-none absolute bottom-0 left-0 z-1 origin-center scale-y-[-1]"
/>
<div className="relative z-10 flex h-12 shrink-0 items-center justify-between gap-2 pr-3 pl-4">
<h2 className="system-xs-semibold-uppercase text-text-secondary">
{t(($) => $['skillManagement.detail.builder.title'])}
</h2>
<div className="flex h-8 items-center gap-1">
<button
type="button"
aria-label={t(($) => $['skillManagement.detail.builder.restart'])}
className="flex size-8 cursor-pointer items-center justify-center rounded-lg text-text-tertiary outline-hidden hover:bg-state-base-hover hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid"
onClick={handleRestart}
>
<span aria-hidden className="i-ri-restart-line size-4" />
</button>
<span aria-hidden className="flex h-8 w-2.25 items-center justify-center">
<span className="h-3.5 w-px bg-divider-subtle" />
</span>
<button
type="button"
aria-label={t(($) => $['skillManagement.detail.builder.close'])}
className="flex size-8 cursor-pointer items-center justify-center rounded-lg text-text-tertiary outline-hidden hover:bg-state-base-hover hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid"
onClick={handleClose}
>
<span aria-hidden className="i-ri-close-line size-4" />
</button>
</div>
</div>
<div
className={cn(
'relative z-10 flex min-h-0 flex-1 flex-col',
messages.length === 0 && 'justify-center',
)}
>
<div
className={cn(
'min-h-0 scrollbar-thin overflow-y-auto px-4 pt-4 pb-2.75',
messages.length > 0 ? 'flex-1' : 'shrink-0',
)}
>
{messages.length > 0 ? (
<div className="flex flex-col gap-3">
{messages.map((message, messageIndex) =>
message.role === 'user' ? (
<div
key={message.id}
className="flex w-full max-w-180 flex-col items-end gap-1 self-end pl-8"
>
{!!message.attachments?.length && (
<SkillBuilderMessageAttachments attachments={message.attachments} />
)}
<div className="flex max-w-72 flex-col items-end rounded-2xl bg-background-default-dimmed px-2 py-2">
<Markdown
content={message.content}
className="px-2 py-1 text-[14px]! leading-5! tracking-[-0.07px] [&_p]:my-0 [&_p]:text-[14px]! [&_p]:leading-5!"
/>
</div>
<div aria-hidden className="h-4 w-full" />
</div>
) : (
<div
key={message.id}
className={cn(
'flex w-full max-w-180 flex-col items-start gap-1 text-text-secondary',
message.tone === 'error' &&
'rounded-xl border border-state-destructive-border bg-state-destructive-hover px-3 py-2 text-text-destructive',
)}
>
{message.thinkingDurationSeconds !== undefined && (
<SkillBuilderThinkingMessage
progressStages={message.progressStages}
reasoningContent={message.reasoningContent}
seconds={
isSending && messageIndex === messages.length - 1
? thinkingElapsedSeconds
: message.thinkingDurationSeconds
}
/>
)}
{message.content ? (
<>
<Markdown
content={message.content}
className="px-1 text-[14px]! leading-5! tracking-[-0.07px] [&_p]:my-0 [&_p]:text-[14px]! [&_p]:leading-5! [&_p+p]:mt-2"
/>
<div className="mt-1 flex h-7 items-center gap-0.5 px-0.5 text-text-tertiary">
<button
type="button"
aria-label={t(($) => $['skillManagement.detail.builder.readAloud'])}
className="flex size-7 cursor-pointer items-center justify-center rounded-md outline-hidden hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid"
onClick={() => handleReadMessage(message.content)}
>
<span aria-hidden className="i-ri-volume-up-line size-4" />
</button>
<button
type="button"
aria-label={t(($) => $['skillManagement.detail.builder.copyResponse'])}
className="flex size-7 cursor-pointer items-center justify-center rounded-md outline-hidden hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid"
onClick={() => void handleCopyMessage(message.content)}
>
<span aria-hidden className="i-ri-clipboard-line size-4" />
</button>
<button
type="button"
aria-label={t(($) => $['skillManagement.detail.builder.retryResponse'])}
className="flex size-7 cursor-pointer items-center justify-center rounded-md outline-hidden hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid disabled:cursor-not-allowed disabled:opacity-30"
disabled={
isSending ||
!messages
.slice(0, messageIndex)
.some((currentMessage) => currentMessage.role === 'user')
}
onClick={() => handleRetryMessage(messageIndex)}
>
<span aria-hidden className="i-ri-restart-line size-4" />
</button>
</div>
{!!message.suggestions?.length && (
<div className="mt-3 flex w-full flex-wrap items-end justify-end gap-1 py-2">
{message.suggestions.map((suggestion) => (
<button
key={suggestion}
type="button"
className="max-w-full cursor-pointer rounded-md border-[0.5px] border-divider-subtle bg-background-default px-2 py-1 text-right system-xs-medium text-text-secondary shadow-xs outline-hidden hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid disabled:cursor-not-allowed disabled:opacity-50"
disabled={
isSending || isUploadingAttachment || !canSendBuilderMessage
}
onClick={() => handleSend(suggestion)}
>
{suggestion}
</button>
))}
</div>
)}
</>
) : message.thinkingDurationSeconds === undefined ? (
<SkillBuilderThinkingMessage
progressStages={message.progressStages}
reasoningContent={message.reasoningContent}
seconds={thinkingElapsedSeconds}
/>
) : null}
</div>
),
)}
</div>
) : (
<div className="flex flex-col gap-3 px-3 text-left">
<div className="flex flex-col items-start gap-3">
<SkillBuilderEmptyIcon />
<div className="flex w-full flex-col gap-1">
<h3 className="system-md-medium text-text-secondary">
{t(($) => $['skillManagement.detail.builder.promptTitle'])}
</h3>
<p className="body-sm-regular text-text-tertiary">
{t(($) => $['skillManagement.detail.builder.promptDescription'])}
</p>
</div>
</div>
<div className="space-y-2 pt-3">
<div className="flex items-center gap-2">
<p className="shrink-0 system-2xs-medium-uppercase text-text-tertiary">
{t(($) => $['skillManagement.detail.builder.tryExample'])}
</p>
<span aria-hidden className="h-px flex-1 bg-divider-subtle" />
</div>
<div className="flex flex-wrap items-start gap-1">
{suggestions.map((suggestion) => (
<button
key={suggestion}
type="button"
className="inline-flex min-h-6 max-w-full cursor-pointer items-center rounded-md border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg px-1.5 py-1 text-left shadow-xs outline-hidden hover:bg-components-button-secondary-bg-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid disabled:cursor-not-allowed disabled:opacity-50"
disabled={isSending || isUploadingAttachment || !canSendBuilderMessage}
onClick={() => handleSend(suggestion)}
>
<span className="truncate px-1.5 system-xs-medium text-components-button-secondary-text">
{suggestion}
</span>
</button>
))}
</div>
</div>
</div>
)}
</div>
<div
className={cn(
'relative flex shrink-0 items-end justify-end px-4 pt-3 pb-4',
messages.length > 0 &&
'bg-linear-to-b from-components-chat-input-bg-mask-1 to-components-chat-input-bg-mask-2',
)}
>
<div className="flex w-full flex-col items-end justify-end">
<div className="relative flex w-full flex-col items-start overflow-hidden rounded-xl border border-components-chat-input-border bg-background-default p-3 shadow-lg">
<input
ref={attachmentInputRef}
type="file"
accept={skillBuilderAttachmentAccept}
className="hidden"
onChange={(event) => {
void handleAttachmentChange(event.currentTarget.files?.[0])
}}
/>
{attachments.length > 0 && (
<div className="mb-2 flex flex-wrap gap-1.5">
{attachments.map((attachment) => (
<span
key={attachment.id}
className="flex max-w-full min-w-0 items-center gap-1 rounded-md border border-divider-subtle bg-background-section px-2 py-1 system-xs-regular text-text-secondary"
>
<span
aria-hidden
className="i-ri-attachment-line size-3.5 shrink-0 text-text-tertiary"
/>
<span className="min-w-0 truncate">{attachment.name}</span>
<button
type="button"
aria-label={t(($) => $['skillManagement.detail.builder.removeAttachment'], {
name: attachment.name,
})}
className="flex size-4 shrink-0 cursor-pointer items-center justify-center rounded text-text-quaternary outline-hidden hover:bg-state-base-hover hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid"
disabled={isSending}
onClick={() => removeAttachment(attachment.id)}
>
<span aria-hidden className="i-ri-close-line size-3" />
</button>
</span>
))}
</div>
)}
<textarea
value={prompt}
rows={1}
className="field-sizing-content max-h-40 min-h-6 w-full resize-none bg-transparent px-0 py-0 body-md-regular text-text-secondary outline-hidden placeholder:text-text-quaternary"
placeholder={inputPlaceholder}
disabled={isSending}
onChange={(event) => setPrompt(event.target.value)}
onCompositionStart={handleCompositionStart}
onCompositionEnd={handleCompositionEnd}
onKeyDown={(event) => {
if (event.key !== 'Enter' || event.shiftKey || event.metaKey || event.ctrlKey)
return
if (event.nativeEvent.isComposing || isComposingRef.current) return
event.preventDefault()
handleSend()
}}
/>
<div className="mt-3 flex min-h-8 w-full min-w-0 items-center justify-between gap-2">
<div className="min-w-0 flex-1 overflow-hidden">
<BuilderModelSelector
isLoading={isTextGenerationModelListLoading}
modelList={textGenerationModelList}
selectedModel={activeSelectedModel}
onSelect={setSelectedModel}
/>
</div>
<div className="ml-auto flex shrink-0 items-center gap-2">
<div className="flex items-center gap-0.5">
<button
type="button"
aria-label={t(($) => $['skillManagement.detail.builder.attach'])}
className="flex size-8 cursor-pointer items-center justify-center rounded-lg text-text-tertiary outline-hidden hover:bg-state-base-hover hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid disabled:cursor-not-allowed disabled:opacity-50"
disabled={isSending || isUploadingAttachment}
onClick={() => attachmentInputRef.current?.click()}
>
<span
aria-hidden
className={cn(
isUploadingAttachment
? 'i-ri-loader-4-line animate-spin'
: 'i-ri-attachment-line',
'size-4',
)}
/>
</button>
</div>
<Button
aria-label={t(($) => $['skillManagement.detail.builder.send'])}
variant="primary"
className="size-8 px-0 focus-visible:ring-inset"
disabled={
!canSendBuilderMessage ||
(!prompt.trim() && attachments.length === 0) ||
isSending ||
isUploadingAttachment
}
onClick={() => handleSend()}
>
<span
aria-hidden
className={cn(
isSending
? 'i-ri-loader-2-line animate-spin motion-reduce:animate-none'
: 'i-ri-send-plane-2-fill',
'size-4',
)}
/>
</Button>
</div>
</div>
</div>
</div>
</div>
</div>
</aside>
)
}