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 (
-