diff --git a/web/features/skills/__tests__/detail-page.spec.tsx b/web/features/skills/__tests__/detail-page.spec.tsx index c5e69299f86..ce0cc53cfb9 100644 --- a/web/features/skills/__tests__/detail-page.spec.tsx +++ b/web/features/skills/__tests__/detail-page.spec.tsx @@ -72,12 +72,9 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () }), })) -vi.mock( - '@/app/components/header/account-setting/model-provider-page/model-parameter-modal', - () => ({ - default: () => , - }), -) +vi.mock('@/app/components/header/account-setting/model-provider-page/model-selector', () => ({ + default: () => , +})) vi.mock('@/app/components/workflow/nodes/_base/components/editor/code-editor', () => ({ default: ({ onChange, value }: { onChange?: (value: string) => void; value: string }) => ( @@ -1084,6 +1081,30 @@ describe('SkillDetailPage', () => { ).not.toBeInTheDocument() }) + it('moves the collapsed Skill Builder entry into the file tab header', async () => { + const user = userEvent.setup() + renderSkillDetailPage() + + await user.click( + await screen.findByRole('button', { + name: 'skill.skillManagement.detail.builder.close', + }), + ) + + const openBuilderButton = screen.getByRole('button', { + name: 'skill.skillManagement.detail.builder.open', + }) + expect(openBuilderButton).toHaveClass('h-8', 'w-[133px]') + expect(openBuilderButton.closest('main')).toBeInTheDocument() + + await user.click(openBuilderButton) + expect( + await screen.findByRole('button', { + name: 'skill.skillManagement.detail.builder.close', + }), + ).toBeInTheDocument() + }) + it('does not render the code editor when external file content fails to load', async () => { mocks.fetchSkillFileBlob.mockRejectedValue(new Error('content unavailable')) mocks.skillDetail = createSkillDetail({ @@ -1949,6 +1970,9 @@ describe('SkillDetailPage', () => { name: 'skill.skillManagement.detail.builder.send', }), ) + expect( + screen.queryByText('skill.skillManagement.detail.builder.editIntro'), + ).not.toBeInTheDocument() await waitFor(() => { expect(mocks.sendSkillAssistMessage).toHaveBeenCalledWith( @@ -3022,8 +3046,9 @@ describe('SkillDetailPage', () => { ) }) expect( - await screen.findByText('skill.skillManagement.detail.builder.thinking:{"seconds":0}'), + await screen.findByText('skill.skillManagement.detail.builder.thinking'), ).toBeInTheDocument() + expect(screen.getByText('0s')).toBeInTheDocument() expect( await screen.findByPlaceholderText('skill.skillManagement.detail.builder.modifyPlaceholder'), ).toBeDisabled() @@ -3127,6 +3152,8 @@ describe('SkillDetailPage', () => { ) expect(await screen.findByText('I can create that reference file.')).toBeInTheDocument() + expect(screen.getByText('skill.skillManagement.detail.builder.thinking')).toBeInTheDocument() + expect(screen.getByText('0s')).toBeInTheDocument() expect(mocks.saveDraftFileMutationFn).not.toHaveBeenCalled() }) diff --git a/web/features/skills/detail/builder-grid-texture.tsx b/web/features/skills/detail/builder-grid-texture.tsx new file mode 100644 index 00000000000..addc8148dc5 --- /dev/null +++ b/web/features/skills/detail/builder-grid-texture.tsx @@ -0,0 +1,57 @@ +import type { ComponentPropsWithoutRef } from 'react' +import { cn } from '@langgenius/dify-ui/cn' + +const gridColumnCount = 384 +const gridRowCount = 32 + +function getGridCellOpacity(row: number, column: number) { + const seed = Math.sin((row + 1) * 12.9898 + (column + 1) * 78.233) * 43758.5453 + const noise = seed - Math.floor(seed) + const verticalProgress = row / (gridRowCount - 1) + const densityThreshold = 0.26 + verticalProgress * 0.72 + const horizontalWeight = Math.min(1, column / 160) + const verticalWeight = (1 - verticalProgress) ** 1.7 + + if (noise < densityThreshold) return 0 + + return Number( + Math.min(0.272, (0.032 + noise * 0.058 + horizontalWeight * 0.09) * verticalWeight).toFixed(3), + ) +} + +const gridCells = Array.from({ length: gridColumnCount * gridRowCount }, (_, index) => { + const row = Math.floor(index / gridColumnCount) + const column = index % gridColumnCount + const opacity = getGridCellOpacity(row, column) + + return { + id: `skill-builder-grid-cell-${row}-${column}`, + column: column + 1, + opacity, + row: row + 1, + } +}).filter((cell) => cell.opacity > 0) + +export function SkillBuilderGridTexture({ className, ...props }: ComponentPropsWithoutRef<'div'>) { + return ( +
+ {gridCells.map((cell) => ( + + ))} +
+ ) +} diff --git a/web/features/skills/detail/builder-panel.tsx b/web/features/skills/detail/builder-panel.tsx index e4904ad2850..b0c341c1bc7 100644 --- a/web/features/skills/detail/builder-panel.tsx +++ b/web/features/skills/detail/builder-panel.tsx @@ -7,10 +7,7 @@ import type { 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 type { Model } from '@/app/components/header/account-setting/model-provider-page/declarations' import { cn } from '@langgenius/dify-ui/cn' import { toast } from '@langgenius/dify-ui/toast' import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' @@ -26,8 +23,9 @@ 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, @@ -50,32 +48,25 @@ function BuilderModelSelector({ onSelect: (model: SkillBuilderModel) => void }) { return ( -
+
{isLoading ? (
) : ( - { + popupClassName="h-[480px]! max-h-[480px]! w-80! max-w-80!" + showModelMeta={false} + triggerClassName="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" + onSelect={({ model, provider }) => { onSelect({ ...selectedModel, provider, - model: modelId, - }) - }} - onCompletionParamsChange={(modelSettings) => { - if (!selectedModel) return - - onSelect({ - ...selectedModel, - model_settings: modelSettings, + model, }) }} /> @@ -86,23 +77,60 @@ function BuilderModelSelector({ function SkillBuilderThinkingMessage({ seconds }: { 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` return (
- - {t(($) => $['skillManagement.detail.builder.thinking'], { seconds })} - - {[0, 1, 2].map((index) => ( + {t(($) => $['skillManagement.detail.builder.thinking'])} + + · + + {duration} + +
+ ) +} + +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 ( +
+
+ {skillBuilderEmptyIconCells.map((cell) => ( 0 ? 'rounded-[1px] bg-[#98A2B2]' : 'invisible'} + style={{ opacity: cell.opacity }} /> ))} - +
+
) } @@ -155,6 +183,7 @@ export function SkillBuilderPanel({ const attachmentInputRef = useRef(null) 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) @@ -201,6 +230,7 @@ export function SkillBuilderPanel({ messages.length > 0 ? t(($) => $['skillManagement.detail.builder.modifyPlaceholder']) : t(($) => $['skillManagement.detail.builder.placeholder']) + const hasBuilderConversation = messages.some((message) => message.role === 'user') const updateMessages = ( updater: (currentMessages: BuilderChatMessage[]) => BuilderChatMessage[], @@ -240,7 +270,11 @@ export function SkillBuilderPanel({ if (!isSending) return const timer = window.setInterval(() => { - setThinkingElapsedSeconds((currentSeconds) => currentSeconds + 1) + setThinkingElapsedSeconds((currentSeconds) => { + const nextSeconds = currentSeconds + 1 + thinkingElapsedSecondsRef.current = nextSeconds + return nextSeconds + }) }, 1000) return () => window.clearInterval(timer) @@ -257,6 +291,7 @@ export function SkillBuilderPanel({ setIsUploadingAttachment(false) setIsSending(false) setThinkingElapsedSeconds(0) + thinkingElapsedSecondsRef.current = 0 isSendingRef.current = false } @@ -266,6 +301,7 @@ export function SkillBuilderPanel({ isSendingRef.current = false setIsSending(false) setThinkingElapsedSeconds(0) + thinkingElapsedSecondsRef.current = 0 onClose() } @@ -355,13 +391,23 @@ export function SkillBuilderPanel({ id: assistantMessageId, role: 'assistant', content: '', + thinkingDurationSeconds: 0, } - updateMessages((currentMessages) => [...currentMessages, userMessage, assistantMessage]) + updateMessages((currentMessages) => [ + ...currentMessages.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, @@ -406,22 +452,43 @@ export function SkillBuilderPanel({ 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) 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) 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( @@ -432,68 +499,148 @@ export function SkillBuilderPanel({ }) } + 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) + } + return ( -