mirror of
https://github.com/langgenius/dify.git
synced 2026-05-03 17:08:03 +08:00
refactor: Refactor context generation modal into composable components
This commit is contained in:
@ -771,7 +771,9 @@ describe('EmptyDatasetCreationModal', () => {
|
||||
|
||||
// 3. These should NOT happen on error
|
||||
expect(mockInvalidDatasetList).not.toHaveBeenCalled()
|
||||
expect(mockOnHide).not.toHaveBeenCalled()
|
||||
// Dialog onClose can pass false; ensure submit didn't call onHide directly.
|
||||
const submitHideCalls = mockOnHide.mock.calls.filter(call => call.length === 0)
|
||||
expect(submitHideCalls).toHaveLength(0)
|
||||
expect(mockPush).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import type { BuiltInMetadataItem, MetadataItemWithValueLength } from '../types'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { DataType } from '../types'
|
||||
import DatasetMetadataDrawer from './dataset-metadata-drawer'
|
||||
@ -270,22 +270,22 @@ describe('DatasetMetadataDrawer', () => {
|
||||
fireEvent.click(svgs[0])
|
||||
}
|
||||
|
||||
// Change name and save
|
||||
let renameModal: HTMLElement | undefined
|
||||
await waitFor(() => {
|
||||
const inputs = document.querySelectorAll('input')
|
||||
expect(inputs.length).toBeGreaterThan(0)
|
||||
const dialogs = screen.getAllByRole('dialog')
|
||||
renameModal = dialogs.find(dialog => dialog.querySelector('input')) as HTMLElement | undefined
|
||||
expect(renameModal).toBeTruthy()
|
||||
})
|
||||
|
||||
const inputs = document.querySelectorAll('input')
|
||||
fireEvent.change(inputs[0], { target: { value: 'renamed_field' } })
|
||||
const modal = within(renameModal as HTMLElement)
|
||||
const input = modal.getByRole('textbox')
|
||||
fireEvent.change(input, { target: { value: 'renamed_field' } })
|
||||
|
||||
// Find and click save button
|
||||
const saveBtns = screen.getAllByText(/save/i)
|
||||
const primaryBtn = saveBtns.find(btn =>
|
||||
btn.closest('button')?.classList.contains('btn-primary'),
|
||||
const saveButton = modal.getAllByRole('button').find(button =>
|
||||
button.classList.contains('btn-primary'),
|
||||
)
|
||||
if (primaryBtn)
|
||||
fireEvent.click(primaryBtn)
|
||||
expect(saveButton).toBeTruthy()
|
||||
fireEvent.click(saveButton as HTMLButtonElement)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onRename).toHaveBeenCalled()
|
||||
|
||||
@ -149,6 +149,16 @@ vi.mock('@/service/base', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
const mockMarketplaceClient = vi.hoisted(() => ({
|
||||
collectionPlugins: vi.fn().mockResolvedValue({ data: { plugins: [] } }),
|
||||
collections: vi.fn().mockResolvedValue({ data: { collections: [] } }),
|
||||
searchAdvanced: vi.fn().mockResolvedValue({ data: { plugins: [], bundles: [], total: 0 } }),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/client', () => ({
|
||||
marketplaceClient: mockMarketplaceClient,
|
||||
}))
|
||||
|
||||
// Mock config
|
||||
vi.mock('@/config', () => ({
|
||||
API_PREFIX: '/api',
|
||||
@ -1490,12 +1500,9 @@ describe('Async Utils', () => {
|
||||
{ type: 'plugin', org: 'test', name: 'plugin2' },
|
||||
]
|
||||
|
||||
globalThis.fetch = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ data: { plugins: mockPlugins } }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
)
|
||||
mockMarketplaceClient.collectionPlugins.mockResolvedValueOnce({
|
||||
data: { plugins: mockPlugins },
|
||||
})
|
||||
|
||||
const { getMarketplacePluginsByCollectionId } = await import('./utils')
|
||||
const result = await getMarketplacePluginsByCollectionId('test-collection', {
|
||||
@ -1504,12 +1511,12 @@ describe('Async Utils', () => {
|
||||
type: 'plugin',
|
||||
})
|
||||
|
||||
expect(globalThis.fetch).toHaveBeenCalled()
|
||||
expect(mockMarketplaceClient.collectionPlugins).toHaveBeenCalled()
|
||||
expect(result).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('should handle fetch error and return empty array', async () => {
|
||||
globalThis.fetch = vi.fn().mockRejectedValue(new Error('Network error'))
|
||||
mockMarketplaceClient.collectionPlugins.mockRejectedValueOnce(new Error('Network error'))
|
||||
|
||||
const { getMarketplacePluginsByCollectionId } = await import('./utils')
|
||||
const result = await getMarketplacePluginsByCollectionId('test-collection')
|
||||
@ -1519,25 +1526,23 @@ describe('Async Utils', () => {
|
||||
|
||||
it('should pass abort signal when provided', async () => {
|
||||
const mockPlugins = [{ type: 'plugins', org: 'test', name: 'plugin1' }]
|
||||
globalThis.fetch = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ data: { plugins: mockPlugins } }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
)
|
||||
mockMarketplaceClient.collectionPlugins.mockResolvedValueOnce({
|
||||
data: { plugins: mockPlugins },
|
||||
})
|
||||
|
||||
const controller = new AbortController()
|
||||
const { getMarketplacePluginsByCollectionId } = await import('./utils')
|
||||
await getMarketplacePluginsByCollectionId('test-collection', {}, { signal: controller.signal })
|
||||
|
||||
// oRPC uses Request objects, so check that fetch was called with a Request containing the right URL
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
expect.any(Request),
|
||||
expect.any(Object),
|
||||
expect(mockMarketplaceClient.collectionPlugins).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
params: { collectionId: 'test-collection' },
|
||||
body: {},
|
||||
}),
|
||||
expect.objectContaining({
|
||||
signal: controller.signal,
|
||||
}),
|
||||
)
|
||||
const call = vi.mocked(globalThis.fetch).mock.calls[0]
|
||||
const request = call[0] as Request
|
||||
expect(request.url).toContain('test-collection')
|
||||
})
|
||||
})
|
||||
|
||||
@ -1548,23 +1553,11 @@ describe('Async Utils', () => {
|
||||
]
|
||||
const mockPlugins = [{ type: 'plugins', org: 'test', name: 'plugin1' }]
|
||||
|
||||
let callCount = 0
|
||||
globalThis.fetch = vi.fn().mockImplementation(() => {
|
||||
callCount++
|
||||
if (callCount === 1) {
|
||||
return Promise.resolve(
|
||||
new Response(JSON.stringify({ data: { collections: mockCollections } }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
)
|
||||
}
|
||||
return Promise.resolve(
|
||||
new Response(JSON.stringify({ data: { plugins: mockPlugins } }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
)
|
||||
mockMarketplaceClient.collections.mockResolvedValueOnce({
|
||||
data: { collections: mockCollections },
|
||||
})
|
||||
mockMarketplaceClient.collectionPlugins.mockResolvedValueOnce({
|
||||
data: { plugins: mockPlugins },
|
||||
})
|
||||
|
||||
const { getMarketplaceCollectionsAndPlugins } = await import('./utils')
|
||||
@ -1578,7 +1571,7 @@ describe('Async Utils', () => {
|
||||
})
|
||||
|
||||
it('should handle fetch error and return empty data', async () => {
|
||||
globalThis.fetch = vi.fn().mockRejectedValue(new Error('Network error'))
|
||||
mockMarketplaceClient.collections.mockRejectedValueOnce(new Error('Network error'))
|
||||
|
||||
const { getMarketplaceCollectionsAndPlugins } = await import('./utils')
|
||||
const result = await getMarketplaceCollectionsAndPlugins()
|
||||
@ -1588,12 +1581,9 @@ describe('Async Utils', () => {
|
||||
})
|
||||
|
||||
it('should append condition and type to URL when provided', async () => {
|
||||
globalThis.fetch = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ data: { collections: [] } }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
)
|
||||
mockMarketplaceClient.collections.mockResolvedValueOnce({
|
||||
data: { collections: [] },
|
||||
})
|
||||
|
||||
const { getMarketplaceCollectionsAndPlugins } = await import('./utils')
|
||||
await getMarketplaceCollectionsAndPlugins({
|
||||
@ -1601,11 +1591,17 @@ describe('Async Utils', () => {
|
||||
type: 'bundle',
|
||||
})
|
||||
|
||||
// oRPC uses Request objects, so check that fetch was called with a Request containing the right URL
|
||||
expect(globalThis.fetch).toHaveBeenCalled()
|
||||
const call = vi.mocked(globalThis.fetch).mock.calls[0]
|
||||
const request = call[0] as Request
|
||||
expect(request.url).toContain('condition=category%3Dtool')
|
||||
expect(mockMarketplaceClient.collections).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
query: expect.objectContaining({
|
||||
condition: 'category=tool',
|
||||
type: 'bundle',
|
||||
page: 1,
|
||||
page_size: 100,
|
||||
}),
|
||||
}),
|
||||
expect.any(Object),
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -0,0 +1,193 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { ContextGenerateChatMessage } from '../hooks/use-context-generate'
|
||||
import type { FormValue } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import type { TriggerProps } from '@/app/components/header/account-setting/model-provider-page/model-parameter-modal/trigger'
|
||||
import type { Model } from '@/types/app'
|
||||
import { RiArrowDownSLine, RiArrowRightLine, RiSendPlaneLine, RiSparklingLine } from '@remixicon/react'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Button from '@/app/components/base/button'
|
||||
import LoadingAnim from '@/app/components/base/chat/chat/loading-anim'
|
||||
import { CodeAssistant } from '@/app/components/base/icons/src/vender/line/general'
|
||||
import ModelParameterModal from '@/app/components/header/account-setting/model-provider-page/model-parameter-modal'
|
||||
import { cn } from '@/utils/classnames'
|
||||
|
||||
type VersionOption = {
|
||||
index: number
|
||||
label: string
|
||||
}
|
||||
|
||||
type ChatViewProps = {
|
||||
promptMessages: ContextGenerateChatMessage[]
|
||||
versionOptions: VersionOption[]
|
||||
currentVersionIndex: number
|
||||
onSelectVersion: (index: number) => void
|
||||
defaultAssistantMessage: string
|
||||
isGenerating: boolean
|
||||
inputValue: string
|
||||
onInputChange: (value: string) => void
|
||||
onGenerate: () => void
|
||||
model: Model
|
||||
onModelChange: (newValue: { modelId: string, provider: string, mode?: string, features?: string[] }) => void
|
||||
onCompletionParamsChange: (newParams: FormValue) => void
|
||||
renderModelTrigger: (params: TriggerProps) => ReactNode
|
||||
}
|
||||
|
||||
const ChatView = ({
|
||||
promptMessages,
|
||||
versionOptions,
|
||||
currentVersionIndex,
|
||||
onSelectVersion,
|
||||
defaultAssistantMessage,
|
||||
isGenerating,
|
||||
inputValue,
|
||||
onInputChange,
|
||||
onGenerate,
|
||||
model,
|
||||
onModelChange,
|
||||
onCompletionParamsChange,
|
||||
renderModelTrigger,
|
||||
}: ChatViewProps) => {
|
||||
const { t } = useTranslation()
|
||||
const chatListRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!chatListRef.current)
|
||||
return
|
||||
if (promptMessages.length === 0 && !isGenerating)
|
||||
return
|
||||
chatListRef.current.scrollTop = chatListRef.current.scrollHeight
|
||||
}, [isGenerating, promptMessages.length])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
ref={chatListRef}
|
||||
className="flex-1 overflow-y-auto px-4 py-2"
|
||||
>
|
||||
<div className="flex w-full flex-col items-end gap-4 pt-3">
|
||||
{(() => {
|
||||
let assistantIndex = -1
|
||||
return promptMessages.map((message, index) => {
|
||||
if (message.role === 'assistant')
|
||||
assistantIndex += 1
|
||||
const versionMeta = message.role === 'assistant' ? versionOptions[assistantIndex] : null
|
||||
const isSelected = versionMeta?.index === currentVersionIndex
|
||||
const showThoughtProcess = message.role === 'assistant' && message.content !== defaultAssistantMessage
|
||||
const durationLabel = message.role === 'assistant' && message.durationMs
|
||||
? `${(message.durationMs / 1000).toFixed(1)}s`
|
||||
: null
|
||||
return (
|
||||
<div
|
||||
key={`${message.role}-${index}`}
|
||||
className={cn('flex w-full', message.role === 'user' ? 'justify-end' : 'justify-start')}
|
||||
>
|
||||
{message.role === 'user'
|
||||
? (
|
||||
<div className="max-w-[320px] whitespace-pre-wrap rounded-xl bg-util-colors-blue-brand-blue-brand-500 px-3 py-2 text-sm leading-5 text-text-primary-on-surface">
|
||||
{message.content}
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<div className="flex w-full flex-col items-start gap-2">
|
||||
{showThoughtProcess && (
|
||||
<div className="flex w-full items-center gap-1 rounded-xl bg-background-gradient-bg-fill-chat-bubble-bg-2 px-2 py-2 text-[13px] text-text-secondary">
|
||||
<div className="flex h-5 w-5 items-center justify-center">
|
||||
<RiSparklingLine className="h-4 w-4 text-text-secondary" />
|
||||
</div>
|
||||
<span className="flex-1 truncate">
|
||||
{message.content}
|
||||
</span>
|
||||
{durationLabel && (
|
||||
<span className="text-xs text-text-tertiary">
|
||||
{durationLabel}
|
||||
</span>
|
||||
)}
|
||||
<RiArrowDownSLine className="h-4 w-4 -rotate-90 text-text-secondary" />
|
||||
</div>
|
||||
)}
|
||||
<div className="whitespace-pre-wrap px-2 text-sm leading-5 text-text-primary">
|
||||
{showThoughtProcess ? defaultAssistantMessage : message.content}
|
||||
</div>
|
||||
{versionMeta && (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'flex min-h-[40px] w-full items-center gap-2 rounded-[12px] border-[0.5px] bg-components-card-bg px-3 py-2 text-left',
|
||||
isSelected
|
||||
? 'border-[1.5px] border-components-option-card-option-selected-border'
|
||||
: 'border-components-panel-border-subtle',
|
||||
)}
|
||||
onClick={() => onSelectVersion(versionMeta.index)}
|
||||
>
|
||||
<div className="flex h-4 w-4 items-center justify-center rounded-[5px] border-[0.5px] border-divider-subtle bg-util-colors-blue-blue-500 p-[2px] shadow-xs">
|
||||
<CodeAssistant className="h-3 w-3 text-text-primary-on-surface" />
|
||||
</div>
|
||||
<span className="flex-1 text-[13px] font-medium text-text-primary">
|
||||
{versionMeta.label}
|
||||
</span>
|
||||
<RiArrowRightLine className="h-4 w-4 text-text-tertiary" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
})()}
|
||||
{isGenerating && (
|
||||
<div className="flex w-full items-center gap-2 rounded-xl bg-background-gradient-bg-fill-chat-bubble-bg-2 px-2 py-2 text-xs text-text-secondary">
|
||||
<LoadingAnim type="text" />
|
||||
<span>{t('nodes.tool.contextGenerate.generating', { ns: 'workflow' })}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-gradient-to-b from-[rgba(255,255,255,0.01)] to-background-body px-1 pb-1 pt-3">
|
||||
<div className="flex min-h-[112px] flex-col justify-between overflow-hidden rounded-xl border-[0.5px] border-components-input-border-active bg-components-panel-bg shadow-shadow-shadow-5 backdrop-blur-[5px]">
|
||||
<div className="flex min-h-[64px] px-3 pb-1 pt-2.5">
|
||||
<textarea
|
||||
value={inputValue}
|
||||
onChange={e => onInputChange(e.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault()
|
||||
onGenerate()
|
||||
}
|
||||
}}
|
||||
placeholder={t('nodes.tool.contextGenerate.inputPlaceholder', { ns: 'workflow' }) as string}
|
||||
className="w-full resize-none bg-transparent text-sm leading-5 text-text-primary placeholder:text-text-quaternary focus:outline-none"
|
||||
disabled={isGenerating}
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-end gap-2 p-2">
|
||||
<ModelParameterModal
|
||||
popupClassName="!w-[520px]"
|
||||
portalToFollowElemContentClassName="z-[1000]"
|
||||
isAdvancedMode={true}
|
||||
provider={model.provider}
|
||||
completionParams={model.completion_params}
|
||||
modelId={model.name}
|
||||
setModel={onModelChange}
|
||||
onCompletionParamsChange={onCompletionParamsChange}
|
||||
hideDebugWithMultipleModel
|
||||
renderTrigger={renderModelTrigger}
|
||||
/>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="small"
|
||||
className="!h-8 !w-8 shrink-0 !rounded-lg !px-0"
|
||||
disabled={!inputValue.trim() || isGenerating}
|
||||
onClick={onGenerate}
|
||||
>
|
||||
<RiSendPlaneLine className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default ChatView
|
||||
@ -0,0 +1,221 @@
|
||||
import type { ContextGenerateChatMessage } from '../hooks/use-context-generate'
|
||||
import type { FormValue } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import type { TriggerProps } from '@/app/components/header/account-setting/model-provider-page/model-parameter-modal/trigger'
|
||||
import type { Model } from '@/types/app'
|
||||
import { RiArrowDownSLine, RiRefreshLine, RiSendPlaneLine } from '@remixicon/react'
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import ActionButton from '@/app/components/base/action-button'
|
||||
import Button from '@/app/components/base/button'
|
||||
import { SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton'
|
||||
import ModelIcon from '@/app/components/header/account-setting/model-provider-page/model-icon'
|
||||
import ModelParameterModal from '@/app/components/header/account-setting/model-provider-page/model-parameter-modal'
|
||||
import { renderI18nObject } from '@/i18n-config'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import ChatView from './chat-view'
|
||||
|
||||
type VersionOption = {
|
||||
index: number
|
||||
label: string
|
||||
}
|
||||
|
||||
type LeftPanelProps = {
|
||||
isInitView: boolean
|
||||
isGenerating: boolean
|
||||
inputValue: string
|
||||
onInputChange: (value: string) => void
|
||||
onGenerate: () => void
|
||||
onReset: () => void
|
||||
suggestedQuestions: string[]
|
||||
hasFetchedSuggestions: boolean
|
||||
model: Model
|
||||
onModelChange: (newValue: { modelId: string, provider: string, mode?: string, features?: string[] }) => void
|
||||
onCompletionParamsChange: (newParams: FormValue) => void
|
||||
promptMessages: ContextGenerateChatMessage[]
|
||||
versionOptions: VersionOption[]
|
||||
currentVersionIndex: number
|
||||
onSelectVersion: (index: number) => void
|
||||
defaultAssistantMessage: string
|
||||
}
|
||||
|
||||
const LeftPanel = ({
|
||||
isInitView,
|
||||
isGenerating,
|
||||
inputValue,
|
||||
onInputChange,
|
||||
onGenerate,
|
||||
onReset,
|
||||
suggestedQuestions,
|
||||
hasFetchedSuggestions,
|
||||
model,
|
||||
onModelChange,
|
||||
onCompletionParamsChange,
|
||||
promptMessages,
|
||||
versionOptions,
|
||||
currentVersionIndex,
|
||||
onSelectVersion,
|
||||
defaultAssistantMessage,
|
||||
}: LeftPanelProps) => {
|
||||
const { t, i18n } = useTranslation()
|
||||
const language = useMemo(() => (i18n.language || 'en-US').replace('-', '_'), [i18n.language])
|
||||
const shouldShowSuggestedSkeleton = isInitView && !hasFetchedSuggestions
|
||||
const suggestedSkeletonItems = useMemo(() => ([0, 1, 2]), [])
|
||||
|
||||
const renderModelTrigger = useCallback((params: TriggerProps) => {
|
||||
const label = params.currentModel?.label
|
||||
? renderI18nObject(params.currentModel.label, language)
|
||||
: (params.currentModel?.model || params.modelId || model.name)
|
||||
const modelName = params.currentModel?.model || params.modelId || model.name
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-1 rounded-lg px-1.5 py-1 text-xs text-text-tertiary',
|
||||
params.disabled ? 'cursor-not-allowed opacity-50' : 'cursor-pointer hover:bg-state-base-hover',
|
||||
)}
|
||||
>
|
||||
<ModelIcon
|
||||
provider={params.currentProvider}
|
||||
modelName={modelName}
|
||||
className="!h-4 !w-4"
|
||||
iconClassName="!h-4 !w-4"
|
||||
/>
|
||||
<span className="max-w-[200px] truncate font-medium text-text-tertiary">
|
||||
{label}
|
||||
</span>
|
||||
<RiArrowDownSLine className="h-3.5 w-3.5 text-text-tertiary" />
|
||||
</div>
|
||||
)
|
||||
}, [language, model.name])
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-full w-[400px] shrink-0 flex-col border-r border-divider-regular bg-background-body',
|
||||
isInitView ? 'justify-center pb-20' : 'justify-start',
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'bg-gradient-to-b from-background-body to-transparent backdrop-blur-[4px]',
|
||||
isInitView ? 'px-5 py-4' : 'px-4 pb-4 pt-3',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col">
|
||||
<div className="title-2xl-semi-bold bg-gradient-to-r from-[rgba(11,165,236,0.95)] to-[rgba(21,90,239,0.95)] bg-clip-text text-transparent">
|
||||
{t('nodes.tool.contextGenerate.title', { ns: 'workflow' })}
|
||||
</div>
|
||||
{isInitView && (
|
||||
<div className="mt-1 text-[13px] italic leading-4 text-text-tertiary">
|
||||
{t('nodes.tool.contextGenerate.subtitle', { ns: 'workflow' })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!isInitView && (
|
||||
<ActionButton
|
||||
size="m"
|
||||
className={cn('!h-8 !w-8', isGenerating && 'pointer-events-none opacity-50')}
|
||||
onClick={onReset}
|
||||
>
|
||||
<RiRefreshLine className="h-4 w-4 text-text-tertiary" />
|
||||
</ActionButton>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isInitView
|
||||
? (
|
||||
<div className="flex w-full flex-col gap-1 px-2">
|
||||
<div className="bg-gradient-to-b from-[rgba(255,255,255,0.01)] to-background-body px-2 pb-2 pt-3">
|
||||
<div className="flex h-[120px] flex-col justify-between overflow-hidden rounded-xl border-[0.5px] border-components-input-border-active bg-components-panel-bg shadow-shadow-shadow-5 backdrop-blur-[5px]">
|
||||
<div className="flex min-h-[64px] px-3 pb-1 pt-2.5">
|
||||
<textarea
|
||||
value={inputValue}
|
||||
onChange={e => onInputChange(e.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault()
|
||||
onGenerate()
|
||||
}
|
||||
}}
|
||||
placeholder={t('nodes.tool.contextGenerate.initPlaceholder', { ns: 'workflow' })}
|
||||
className="w-full resize-none bg-transparent text-sm leading-5 text-text-primary placeholder:text-text-quaternary focus:outline-none"
|
||||
disabled={isGenerating}
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-end gap-2 p-2">
|
||||
<ModelParameterModal
|
||||
popupClassName="!w-[520px]"
|
||||
portalToFollowElemContentClassName="z-[1000]"
|
||||
isAdvancedMode={true}
|
||||
provider={model.provider}
|
||||
completionParams={model.completion_params}
|
||||
modelId={model.name}
|
||||
setModel={onModelChange}
|
||||
onCompletionParamsChange={onCompletionParamsChange}
|
||||
hideDebugWithMultipleModel
|
||||
renderTrigger={renderModelTrigger}
|
||||
/>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="small"
|
||||
className="ml-auto !h-8 !w-8 shrink-0 !rounded-lg !px-0"
|
||||
disabled={!inputValue.trim() || isGenerating}
|
||||
onClick={onGenerate}
|
||||
>
|
||||
<RiSendPlaneLine className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-px px-2">
|
||||
<div className="flex items-center px-3 pb-2 pt-4">
|
||||
<span className="text-xs font-semibold uppercase text-text-tertiary">
|
||||
{t('nodes.tool.contextGenerate.suggestedQuestionsTitle', { ns: 'workflow' })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 px-3">
|
||||
{shouldShowSuggestedSkeleton && suggestedSkeletonItems.map(item => (
|
||||
<SkeletonRow key={item} className="py-1">
|
||||
<div className="h-4 w-4 rounded-sm bg-divider-subtle opacity-60" />
|
||||
<SkeletonRectangle className="h-3 w-[260px]" />
|
||||
</SkeletonRow>
|
||||
))}
|
||||
{!shouldShowSuggestedSkeleton && suggestedQuestions.map((question, index) => (
|
||||
<button
|
||||
key={`${question}-${index}`}
|
||||
type="button"
|
||||
className="flex items-start gap-2 rounded-lg px-2 py-1 text-left text-sm text-text-secondary transition hover:bg-state-base-hover"
|
||||
onClick={() => onInputChange(question)}
|
||||
>
|
||||
<span className="mt-1 h-1.5 w-1.5 shrink-0 rounded-full bg-divider-regular" />
|
||||
<span className="flex-1 whitespace-pre-wrap">{question}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<ChatView
|
||||
promptMessages={promptMessages}
|
||||
versionOptions={versionOptions}
|
||||
currentVersionIndex={currentVersionIndex}
|
||||
onSelectVersion={onSelectVersion}
|
||||
defaultAssistantMessage={defaultAssistantMessage}
|
||||
isGenerating={isGenerating}
|
||||
inputValue={inputValue}
|
||||
onInputChange={onInputChange}
|
||||
onGenerate={onGenerate}
|
||||
model={model}
|
||||
onModelChange={onModelChange}
|
||||
onCompletionParamsChange={onCompletionParamsChange}
|
||||
renderModelTrigger={renderModelTrigger}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default LeftPanel
|
||||
@ -0,0 +1,287 @@
|
||||
import type { PointerEvent, RefObject } from 'react'
|
||||
import type { ContextGenerateResponse } from '@/service/debug'
|
||||
import { RiArrowDownSLine, RiCheckLine, RiCloseLine } from '@remixicon/react'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import ActionButton from '@/app/components/base/action-button'
|
||||
import Button from '@/app/components/base/button'
|
||||
import { CopyFeedbackNew } from '@/app/components/base/copy-feedback'
|
||||
import { CodeAssistant } from '@/app/components/base/icons/src/vender/line/general'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '@/app/components/base/portal-to-follow-elem'
|
||||
import CodeEditor from '@/app/components/workflow/nodes/_base/components/editor/code-editor'
|
||||
import { CodeLanguage } from '@/app/components/workflow/nodes/code/types'
|
||||
import { cn } from '@/utils/classnames'
|
||||
|
||||
type VersionOption = {
|
||||
index: number
|
||||
label: string
|
||||
}
|
||||
|
||||
type DisplayOutputData = {
|
||||
variables: ContextGenerateResponse['variables']
|
||||
outputs: ContextGenerateResponse['outputs']
|
||||
}
|
||||
|
||||
type RightPanelProps = {
|
||||
isInitView: boolean
|
||||
isGenerating: boolean
|
||||
displayVersion: ContextGenerateResponse | null
|
||||
displayCodeLanguage: CodeLanguage
|
||||
displayOutputData: DisplayOutputData | null
|
||||
rightContainerRef: RefObject<HTMLDivElement | null>
|
||||
resolvedCodePanelHeight: number
|
||||
onResizeStart: (event: PointerEvent<HTMLButtonElement>) => void
|
||||
versionOptions: VersionOption[]
|
||||
currentVersionIndex: number
|
||||
currentVersionLabel: string
|
||||
onSelectVersion: (index: number) => void
|
||||
onRun: () => void
|
||||
onApply: () => void
|
||||
canRun: boolean
|
||||
canApply: boolean
|
||||
isRunning: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const RightPanel = ({
|
||||
isInitView,
|
||||
isGenerating,
|
||||
displayVersion,
|
||||
displayCodeLanguage,
|
||||
displayOutputData,
|
||||
rightContainerRef,
|
||||
resolvedCodePanelHeight,
|
||||
onResizeStart,
|
||||
versionOptions,
|
||||
currentVersionIndex,
|
||||
currentVersionLabel,
|
||||
onSelectVersion,
|
||||
onRun,
|
||||
onApply,
|
||||
canRun,
|
||||
canApply,
|
||||
isRunning,
|
||||
onClose,
|
||||
}: RightPanelProps) => {
|
||||
const { t } = useTranslation()
|
||||
const rightPlaceholderLines = useMemo(() => {
|
||||
const placeholder = t('nodes.tool.contextGenerate.rightSidePlaceholder', { ns: 'workflow' })
|
||||
return String(placeholder).split('\n').filter(Boolean)
|
||||
}, [t])
|
||||
|
||||
const [isVersionMenuOpen, setVersionMenuOpen] = useState(false)
|
||||
const handleVersionMenuOpen = useCallback((open: boolean) => {
|
||||
if (versionOptions.length > 1)
|
||||
setVersionMenuOpen(open)
|
||||
else
|
||||
setVersionMenuOpen(false)
|
||||
}, [versionOptions.length])
|
||||
|
||||
const handleVersionMenuToggle = useCallback(() => {
|
||||
if (versionOptions.length > 1)
|
||||
setVersionMenuOpen(value => !value)
|
||||
}, [versionOptions.length])
|
||||
|
||||
const codeLanguageLabel = displayCodeLanguage === CodeLanguage.javascript
|
||||
? t('nodes.tool.contextGenerate.codeLanguage.javascript', { ns: 'workflow' })
|
||||
: t('nodes.tool.contextGenerate.codeLanguage.python3', { ns: 'workflow' })
|
||||
|
||||
const emptyPanelClassName = cn(
|
||||
'flex h-full flex-col',
|
||||
isInitView
|
||||
? 'rounded-l-xl bg-components-panel-bg pb-1 pl-1'
|
||||
: 'rounded-[10px] bg-components-panel-bg',
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-full w-0 grow flex-col bg-background-body',
|
||||
isInitView ? 'py-1' : 'pt-1',
|
||||
)}
|
||||
>
|
||||
{isInitView && (
|
||||
<div className="flex h-10 items-center justify-end px-3 py-1">
|
||||
<ActionButton size="m" className="!h-8 !w-8" onClick={onClose}>
|
||||
<RiCloseLine className="h-4 w-4 text-text-tertiary" />
|
||||
</ActionButton>
|
||||
</div>
|
||||
)}
|
||||
{!isInitView && (
|
||||
<div className="flex shrink-0 items-center justify-between px-3 py-2">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="text-[13px] font-semibold uppercase text-text-secondary">
|
||||
{t('nodes.tool.contextGenerate.generatedCode', { ns: 'workflow' })}
|
||||
</div>
|
||||
<PortalToFollowElem
|
||||
placement="bottom-start"
|
||||
offset={{
|
||||
mainAxis: 6,
|
||||
crossAxis: -4,
|
||||
}}
|
||||
open={isVersionMenuOpen}
|
||||
onOpenChange={handleVersionMenuOpen}
|
||||
>
|
||||
<PortalToFollowElemTrigger asChild onClick={handleVersionMenuToggle}>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'flex items-center gap-1 text-xs font-medium text-text-tertiary',
|
||||
versionOptions.length > 1 ? 'cursor-pointer' : 'cursor-default',
|
||||
)}
|
||||
>
|
||||
<span>{currentVersionLabel}</span>
|
||||
{versionOptions.length > 1 && <RiArrowDownSLine className="h-3.5 w-3.5" />}
|
||||
</button>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className="z-[1010]">
|
||||
<div className="w-[208px] rounded-xl border border-components-panel-border bg-components-panel-bg-blur p-1 shadow-lg">
|
||||
<div className="system-xs-medium-uppercase flex h-[22px] items-center px-3 text-text-tertiary">
|
||||
{t('generate.versions', { ns: 'appDebug' })}
|
||||
</div>
|
||||
{versionOptions.map(option => (
|
||||
<button
|
||||
key={option.index}
|
||||
type="button"
|
||||
className={cn(
|
||||
'flex h-7 w-full items-center rounded-lg px-2 text-[13px] text-text-secondary',
|
||||
option.index === currentVersionIndex
|
||||
? 'bg-state-base-hover'
|
||||
: 'hover:bg-state-base-hover',
|
||||
)}
|
||||
onClick={() => {
|
||||
onSelectVersion(option.index)
|
||||
setVersionMenuOpen(false)
|
||||
}}
|
||||
>
|
||||
<span className="flex-1 truncate text-left">{option.label}</span>
|
||||
{option.index === currentVersionIndex && (
|
||||
<RiCheckLine className="h-4 w-4 text-text-accent" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{isRunning
|
||||
? (
|
||||
<div className="flex h-8 items-center gap-2 rounded-lg border-[0.5px] border-components-panel-border-subtle bg-components-panel-bg px-3 text-xs font-medium text-text-secondary">
|
||||
<span className="h-2 w-2 rounded-full bg-util-colors-blue-blue-500" />
|
||||
{t('nodes.tool.contextGenerate.running', { ns: 'workflow' })}
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<Button
|
||||
size="small"
|
||||
onClick={onRun}
|
||||
disabled={!canRun || isGenerating}
|
||||
>
|
||||
{t('nodes.tool.contextGenerate.run', { ns: 'workflow' })}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="primary"
|
||||
size="small"
|
||||
onClick={onApply}
|
||||
disabled={!canApply || isGenerating}
|
||||
>
|
||||
{t('nodes.tool.contextGenerate.apply', { ns: 'workflow' })}
|
||||
</Button>
|
||||
<div className="mx-1 h-4 w-px bg-divider-regular" />
|
||||
<ActionButton size="m" className="!h-8 !w-8" onClick={onClose}>
|
||||
<RiCloseLine className="h-4 w-4 text-text-tertiary" />
|
||||
</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
ref={rightContainerRef}
|
||||
className={cn(
|
||||
'flex h-full flex-col overflow-hidden',
|
||||
isInitView ? 'px-0 pb-0' : 'px-3 pb-3',
|
||||
)}
|
||||
>
|
||||
{isGenerating && !displayVersion && (
|
||||
<div className={cn(emptyPanelClassName, 'items-center justify-center')}>
|
||||
<Loading />
|
||||
<div className="mt-3 text-[13px] text-text-tertiary">
|
||||
{t('nodes.tool.contextGenerate.generating', { ns: 'workflow' })}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!isGenerating && !displayVersion && (
|
||||
<div className={emptyPanelClassName}>
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-2 pb-20 text-center">
|
||||
<CodeAssistant className="h-8 w-8 text-divider-regular" />
|
||||
<div className="text-xs leading-4 text-text-quaternary">
|
||||
{rightPlaceholderLines.map((line, index) => (
|
||||
<p key={`${line}-${index}`}>{line}</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{displayVersion && (
|
||||
<div className="flex h-full flex-col overflow-hidden">
|
||||
<div
|
||||
className="flex min-h-[80px] flex-col overflow-hidden rounded-[10px] bg-components-input-bg-normal"
|
||||
style={{ height: resolvedCodePanelHeight }}
|
||||
>
|
||||
<div className="flex items-center border-b border-divider-subtle px-2 py-1">
|
||||
<div className="flex flex-1 items-center px-1 py-0.5">
|
||||
<span className="text-xs font-semibold uppercase text-text-secondary">
|
||||
{codeLanguageLabel}
|
||||
</span>
|
||||
</div>
|
||||
<CopyFeedbackNew content={displayVersion.code || ''} className="!h-6 !w-6" />
|
||||
</div>
|
||||
<div className="flex-1 overflow-hidden px-3 pb-3 pt-2">
|
||||
<CodeEditor
|
||||
noWrapper
|
||||
isExpand
|
||||
readOnly
|
||||
language={displayCodeLanguage}
|
||||
value={displayVersion.code || ''}
|
||||
className="h-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-4 w-full cursor-row-resize items-center px-2"
|
||||
aria-label={t('nodes.tool.contextGenerate.resizeHandle', { ns: 'workflow' })}
|
||||
onPointerDown={onResizeStart}
|
||||
>
|
||||
<div className="h-[2px] w-full rounded-full bg-divider-subtle" />
|
||||
</button>
|
||||
<div className="flex min-h-[80px] flex-1 flex-col overflow-hidden rounded-[10px] bg-components-input-bg-normal">
|
||||
<div className="flex items-center border-b border-divider-subtle px-2 py-1">
|
||||
<div className="flex flex-1 items-center px-1 py-0.5">
|
||||
<span className="text-xs font-semibold uppercase text-text-secondary">
|
||||
{t('nodes.tool.contextGenerate.output', { ns: 'workflow' })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-hidden px-3 pb-3 pt-2">
|
||||
<CodeEditor
|
||||
noWrapper
|
||||
isExpand
|
||||
readOnly
|
||||
isJSONStringifyBeauty
|
||||
language={CodeLanguage.json}
|
||||
value={displayOutputData ?? { variables: [], outputs: {} }}
|
||||
className="h-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default RightPanel
|
||||
@ -0,0 +1,376 @@
|
||||
import type { FormValue } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import type { CodeNodeType } from '@/app/components/workflow/nodes/code/types'
|
||||
import type { ContextGenerateMessage, ContextGenerateResponse } from '@/service/debug'
|
||||
import type { CompletionParams, Model, ModelModeType } from '@/types/app'
|
||||
import { useSessionStorageState } from 'ahooks'
|
||||
import useBoolean from 'ahooks/lib/useBoolean'
|
||||
import { useCallback, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import { useModelListAndDefaultModelAndCurrentProviderAndModel } from '@/app/components/header/account-setting/model-provider-page/hooks'
|
||||
import { CodeLanguage } from '@/app/components/workflow/nodes/code/types'
|
||||
import { languages } from '@/i18n-config/language'
|
||||
import { fetchContextGenerateSuggestedQuestions, generateContext } from '@/service/debug'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import useContextGenData from '../use-context-gen-data'
|
||||
|
||||
export type ContextGenerateChatMessage = ContextGenerateMessage & {
|
||||
durationMs?: number
|
||||
}
|
||||
|
||||
const defaultCompletionParams: CompletionParams = {
|
||||
temperature: 0.7,
|
||||
max_tokens: 0,
|
||||
top_p: 0,
|
||||
echo: false,
|
||||
stop: [],
|
||||
presence_penalty: 0,
|
||||
frequency_penalty: 0,
|
||||
}
|
||||
|
||||
export const normalizeCodeLanguage = (value?: string) => {
|
||||
if (value === CodeLanguage.javascript)
|
||||
return CodeLanguage.javascript
|
||||
if (value === CodeLanguage.python3)
|
||||
return CodeLanguage.python3
|
||||
return CodeLanguage.python3
|
||||
}
|
||||
|
||||
type UseContextGenerateOptions = {
|
||||
storageKey: string
|
||||
flowId: string
|
||||
toolNodeId: string
|
||||
paramKey: string
|
||||
codeNodeData?: CodeNodeType
|
||||
}
|
||||
|
||||
type VersionOption = {
|
||||
index: number
|
||||
label: string
|
||||
}
|
||||
|
||||
type UseContextGenerateResult = {
|
||||
versions: ContextGenerateResponse[]
|
||||
current: ContextGenerateResponse | undefined
|
||||
currentVersionIndex: number
|
||||
setCurrentVersionIndex: (index: number) => void
|
||||
promptMessages: ContextGenerateChatMessage[]
|
||||
inputValue: string
|
||||
setInputValue: (value: string) => void
|
||||
suggestedQuestions: string[]
|
||||
hasFetchedSuggestions: boolean
|
||||
isGenerating: boolean
|
||||
model: Model
|
||||
handleModelChange: (newValue: { modelId: string, provider: string, mode?: string, features?: string[] }) => void
|
||||
handleCompletionParamsChange: (newParams: FormValue) => void
|
||||
handleGenerate: () => Promise<void>
|
||||
handleReset: () => void
|
||||
handleFetchSuggestedQuestions: () => Promise<void>
|
||||
abortSuggestedQuestions: () => void
|
||||
defaultAssistantMessage: string
|
||||
versionOptions: VersionOption[]
|
||||
currentVersionLabel: string
|
||||
isInitView: boolean
|
||||
}
|
||||
|
||||
const useContextGenerate = ({
|
||||
storageKey,
|
||||
flowId,
|
||||
toolNodeId,
|
||||
paramKey,
|
||||
codeNodeData,
|
||||
}: UseContextGenerateOptions): UseContextGenerateResult => {
|
||||
const { t, i18n } = useTranslation()
|
||||
const {
|
||||
versions,
|
||||
addVersion,
|
||||
current,
|
||||
currentVersionIndex,
|
||||
setCurrentVersionIndex,
|
||||
clearVersions,
|
||||
} = useContextGenData({
|
||||
storageKey,
|
||||
})
|
||||
|
||||
const [promptMessages, setPromptMessages] = useSessionStorageState<ContextGenerateChatMessage[]>(
|
||||
`${storageKey}-messages`,
|
||||
{ defaultValue: [] },
|
||||
)
|
||||
|
||||
const [suggestedQuestions, setSuggestedQuestions] = useSessionStorageState<string[]>(
|
||||
`${storageKey}-suggested-questions`,
|
||||
{ defaultValue: [] },
|
||||
)
|
||||
|
||||
const [hasFetchedSuggestions, setHasFetchedSuggestions] = useSessionStorageState<boolean>(
|
||||
`${storageKey}-suggested-questions-fetched`,
|
||||
{ defaultValue: false },
|
||||
)
|
||||
|
||||
const [isFetchingSuggestions, { setTrue: setFetchingSuggestionsTrue, setFalse: setFetchingSuggestionsFalse }] = useBoolean(false)
|
||||
const suggestedQuestionsAbortControllerRef = useRef<AbortController | null>(null)
|
||||
|
||||
const promptLanguage = useMemo(() => {
|
||||
const matched = languages.find(item => item.value === i18n.language)
|
||||
return matched?.prompt_name || 'English'
|
||||
}, [i18n.language])
|
||||
|
||||
const [inputValue, setInputValue] = useState('')
|
||||
const [isGenerating, { setTrue: setGeneratingTrue, setFalse: setGeneratingFalse }] = useBoolean(false)
|
||||
const [modelOverride, setModelOverride] = useState<Model | null>(() => {
|
||||
const stored = localStorage.getItem('auto-gen-model')
|
||||
if (!stored)
|
||||
return null
|
||||
const parsed = JSON.parse(stored) as Model
|
||||
return {
|
||||
...parsed,
|
||||
completion_params: {
|
||||
...defaultCompletionParams,
|
||||
...parsed.completion_params,
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
const {
|
||||
defaultModel,
|
||||
} = useModelListAndDefaultModelAndCurrentProviderAndModel(ModelTypeEnum.textGeneration)
|
||||
|
||||
const model = useMemo<Model>(() => {
|
||||
if (modelOverride)
|
||||
return modelOverride
|
||||
if (!defaultModel) {
|
||||
return {
|
||||
name: '',
|
||||
provider: '',
|
||||
mode: AppModeEnum.CHAT as unknown as ModelModeType.chat,
|
||||
completion_params: defaultCompletionParams,
|
||||
}
|
||||
}
|
||||
return {
|
||||
name: defaultModel.model,
|
||||
provider: defaultModel.provider.provider,
|
||||
mode: AppModeEnum.CHAT as unknown as ModelModeType.chat,
|
||||
completion_params: defaultCompletionParams,
|
||||
}
|
||||
}, [defaultModel, modelOverride])
|
||||
|
||||
const handleModelChange = useCallback((newValue: { modelId: string, provider: string, mode?: string, features?: string[] }) => {
|
||||
const newModel = {
|
||||
...model,
|
||||
provider: newValue.provider,
|
||||
name: newValue.modelId,
|
||||
mode: newValue.mode as ModelModeType,
|
||||
}
|
||||
setModelOverride(newModel)
|
||||
localStorage.setItem('auto-gen-model', JSON.stringify(newModel))
|
||||
}, [model])
|
||||
|
||||
const handleCompletionParamsChange = useCallback((newParams: FormValue) => {
|
||||
const newModel = {
|
||||
...model,
|
||||
completion_params: newParams as CompletionParams,
|
||||
}
|
||||
setModelOverride(newModel)
|
||||
localStorage.setItem('auto-gen-model', JSON.stringify(newModel))
|
||||
}, [model])
|
||||
|
||||
const promptMessageCount = promptMessages?.length ?? 0
|
||||
const hasHistory = (versions?.length ?? 0) > 0 || promptMessageCount > 0
|
||||
const isInitView = !isGenerating && !hasHistory
|
||||
const defaultAssistantMessage = t('nodes.tool.contextGenerate.defaultAssistantMessage', { ns: 'workflow' })
|
||||
|
||||
const versionOptions = useMemo<VersionOption[]>(() => {
|
||||
const latestSuffix = t('generate.latest', { ns: 'appDebug' })
|
||||
const versionPrefix = t('generate.version', { ns: 'appDebug' })
|
||||
return versions.map((_, index) => ({
|
||||
index,
|
||||
label: `${versionPrefix} ${index + 1}${index === versions.length - 1 ? ` · ${latestSuffix}` : ''}`,
|
||||
}))
|
||||
}, [t, versions])
|
||||
|
||||
const currentVersionIndexSafe = currentVersionIndex ?? 0
|
||||
const currentVersionLabel = versionOptions[currentVersionIndexSafe]?.label
|
||||
?? `${t('generate.version', { ns: 'appDebug' })} ${currentVersionIndexSafe + 1}`
|
||||
|
||||
const handleReset = useCallback(() => {
|
||||
if (isGenerating)
|
||||
return
|
||||
setPromptMessages([])
|
||||
setInputValue('')
|
||||
clearVersions()
|
||||
}, [clearVersions, isGenerating, setPromptMessages])
|
||||
|
||||
const handleFetchSuggestedQuestions = useCallback(async () => {
|
||||
if (!flowId || !toolNodeId || !paramKey)
|
||||
return
|
||||
if (!model.name || !model.provider)
|
||||
return
|
||||
if (hasFetchedSuggestions || isFetchingSuggestions || !isInitView)
|
||||
return
|
||||
|
||||
setFetchingSuggestionsTrue()
|
||||
let shouldMarkFetched = true
|
||||
suggestedQuestionsAbortControllerRef.current?.abort()
|
||||
try {
|
||||
const response = await fetchContextGenerateSuggestedQuestions({
|
||||
workflow_id: flowId,
|
||||
node_id: toolNodeId,
|
||||
parameter_name: paramKey,
|
||||
language: promptLanguage,
|
||||
model_config: {
|
||||
provider: model.provider,
|
||||
name: model.name,
|
||||
completion_params: model.completion_params,
|
||||
},
|
||||
}, (abortController) => {
|
||||
suggestedQuestionsAbortControllerRef.current = abortController
|
||||
})
|
||||
|
||||
if (response.error) {
|
||||
shouldMarkFetched = false
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message: t('modal.errors.networkError', { ns: 'pluginTrigger' }),
|
||||
})
|
||||
setSuggestedQuestions([])
|
||||
return
|
||||
}
|
||||
|
||||
const nextQuestions = (response.questions || []).filter(question => question && question.trim())
|
||||
setSuggestedQuestions(nextQuestions)
|
||||
}
|
||||
catch (error) {
|
||||
if (String(error).includes('AbortError')) {
|
||||
shouldMarkFetched = false
|
||||
return
|
||||
}
|
||||
shouldMarkFetched = false
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message: t('modal.errors.networkError', { ns: 'pluginTrigger' }),
|
||||
})
|
||||
setSuggestedQuestions([])
|
||||
}
|
||||
finally {
|
||||
if (shouldMarkFetched)
|
||||
setHasFetchedSuggestions(true)
|
||||
setFetchingSuggestionsFalse()
|
||||
}
|
||||
}, [
|
||||
flowId,
|
||||
hasFetchedSuggestions,
|
||||
isFetchingSuggestions,
|
||||
isInitView,
|
||||
model.completion_params,
|
||||
model.name,
|
||||
model.provider,
|
||||
paramKey,
|
||||
promptLanguage,
|
||||
setFetchingSuggestionsFalse,
|
||||
setFetchingSuggestionsTrue,
|
||||
setHasFetchedSuggestions,
|
||||
setSuggestedQuestions,
|
||||
t,
|
||||
toolNodeId,
|
||||
])
|
||||
|
||||
const abortSuggestedQuestions = useCallback(() => {
|
||||
suggestedQuestionsAbortControllerRef.current?.abort()
|
||||
}, [])
|
||||
|
||||
const generateStartRef = useRef<number | null>(null)
|
||||
const handleGenerate = useCallback(async () => {
|
||||
const trimmed = inputValue.trim()
|
||||
if (!trimmed || isGenerating)
|
||||
return
|
||||
if (!flowId || !toolNodeId || !paramKey)
|
||||
return
|
||||
|
||||
const userMessage: ContextGenerateChatMessage = { role: 'user', content: trimmed }
|
||||
const nextMessages: ContextGenerateChatMessage[] = [...(promptMessages ?? []), userMessage]
|
||||
setPromptMessages(nextMessages)
|
||||
setInputValue('')
|
||||
setGeneratingTrue()
|
||||
generateStartRef.current = Date.now()
|
||||
try {
|
||||
const response = await generateContext({
|
||||
workflow_id: flowId,
|
||||
node_id: toolNodeId,
|
||||
parameter_name: paramKey,
|
||||
language: normalizeCodeLanguage(current?.code_language || codeNodeData?.code_language) as 'python3' | 'javascript',
|
||||
prompt_messages: nextMessages.map(({ role, content }) => ({ role, content })),
|
||||
model_config: {
|
||||
provider: model.provider,
|
||||
name: model.name,
|
||||
completion_params: model.completion_params,
|
||||
},
|
||||
})
|
||||
|
||||
if (response.error) {
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message: response.error,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const assistantMessage = response.message || defaultAssistantMessage
|
||||
const durationMs = generateStartRef.current ? Date.now() - generateStartRef.current : undefined
|
||||
const assistantEntry: ContextGenerateChatMessage = {
|
||||
role: 'assistant',
|
||||
content: assistantMessage,
|
||||
durationMs,
|
||||
}
|
||||
setPromptMessages([...nextMessages, assistantEntry])
|
||||
addVersion(response)
|
||||
}
|
||||
finally {
|
||||
setGeneratingFalse()
|
||||
generateStartRef.current = null
|
||||
}
|
||||
}, [
|
||||
addVersion,
|
||||
codeNodeData?.code_language,
|
||||
current?.code_language,
|
||||
defaultAssistantMessage,
|
||||
flowId,
|
||||
inputValue,
|
||||
isGenerating,
|
||||
model.completion_params,
|
||||
model.name,
|
||||
model.provider,
|
||||
paramKey,
|
||||
promptMessages,
|
||||
setPromptMessages,
|
||||
setGeneratingFalse,
|
||||
setGeneratingTrue,
|
||||
toolNodeId,
|
||||
])
|
||||
|
||||
return {
|
||||
versions,
|
||||
current,
|
||||
currentVersionIndex: currentVersionIndexSafe,
|
||||
setCurrentVersionIndex,
|
||||
promptMessages: promptMessages ?? [],
|
||||
inputValue,
|
||||
setInputValue,
|
||||
suggestedQuestions: suggestedQuestions ?? [],
|
||||
hasFetchedSuggestions: hasFetchedSuggestions ?? false,
|
||||
isGenerating,
|
||||
model,
|
||||
handleModelChange,
|
||||
handleCompletionParamsChange,
|
||||
handleGenerate,
|
||||
handleReset,
|
||||
handleFetchSuggestedQuestions,
|
||||
abortSuggestedQuestions,
|
||||
defaultAssistantMessage,
|
||||
versionOptions,
|
||||
currentVersionLabel,
|
||||
isInitView,
|
||||
}
|
||||
}
|
||||
|
||||
export default useContextGenerate
|
||||
@ -0,0 +1,67 @@
|
||||
import type { PointerEvent } from 'react'
|
||||
import { useEventListener, useSize } from 'ahooks'
|
||||
import { useCallback, useMemo, useRef, useState } from 'react'
|
||||
|
||||
const minCodeHeight = 80
|
||||
const minOutputHeight = 80
|
||||
const splitHandleHeight = 4
|
||||
const defaultCodePanelHeight = 556
|
||||
|
||||
const useResizablePanels = () => {
|
||||
const rightContainerRef = useRef<HTMLDivElement>(null)
|
||||
const rightContainerSize = useSize(rightContainerRef)
|
||||
const [codePanelHeight, setCodePanelHeight] = useState(defaultCodePanelHeight)
|
||||
const draggingRef = useRef(false)
|
||||
const dragStartRef = useRef({ startY: 0, startHeight: 0 })
|
||||
|
||||
const maxCodePanelHeight = useMemo(() => {
|
||||
const containerHeight = rightContainerSize?.height ?? 0
|
||||
if (!containerHeight)
|
||||
return null
|
||||
return Math.max(minCodeHeight, containerHeight - minOutputHeight - splitHandleHeight)
|
||||
}, [rightContainerSize?.height])
|
||||
|
||||
const resolvedCodePanelHeight = useMemo(() => {
|
||||
if (!maxCodePanelHeight)
|
||||
return codePanelHeight
|
||||
// Reason: Clamp the panel height so the output area always has space.
|
||||
return Math.min(codePanelHeight, maxCodePanelHeight)
|
||||
}, [codePanelHeight, maxCodePanelHeight])
|
||||
|
||||
const handleResizeStart = useCallback((event: PointerEvent<HTMLButtonElement>) => {
|
||||
draggingRef.current = true
|
||||
dragStartRef.current = {
|
||||
startY: event.clientY,
|
||||
startHeight: resolvedCodePanelHeight,
|
||||
}
|
||||
document.body.style.userSelect = 'none'
|
||||
}, [resolvedCodePanelHeight])
|
||||
|
||||
useEventListener('mousemove', (event) => {
|
||||
if (!draggingRef.current)
|
||||
return
|
||||
|
||||
const containerHeight = rightContainerRef.current?.offsetHeight || 0
|
||||
if (!containerHeight)
|
||||
return
|
||||
const maxHeight = Math.max(minCodeHeight, containerHeight - minOutputHeight - splitHandleHeight)
|
||||
const delta = event.clientY - dragStartRef.current.startY
|
||||
const nextHeight = Math.min(Math.max(dragStartRef.current.startHeight + delta, minCodeHeight), maxHeight)
|
||||
setCodePanelHeight(nextHeight)
|
||||
})
|
||||
|
||||
useEventListener('mouseup', () => {
|
||||
if (!draggingRef.current)
|
||||
return
|
||||
draggingRef.current = false
|
||||
document.body.style.userSelect = ''
|
||||
})
|
||||
|
||||
return {
|
||||
rightContainerRef,
|
||||
resolvedCodePanelHeight,
|
||||
handleResizeStart,
|
||||
}
|
||||
}
|
||||
|
||||
export default useResizablePanels
|
||||
@ -1,41 +1,18 @@
|
||||
'use client'
|
||||
import type { FormValue } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import type { TriggerProps } from '@/app/components/header/account-setting/model-provider-page/model-parameter-modal/trigger'
|
||||
import type { CodeNodeType, OutputVar } from '@/app/components/workflow/nodes/code/types'
|
||||
import type { ContextGenerateMessage, ContextGenerateResponse } from '@/service/debug'
|
||||
import type { CompletionParams, Model, ModelModeType } from '@/types/app'
|
||||
import { RiArrowDownSLine, RiArrowRightLine, RiCheckLine, RiCloseLine, RiRefreshLine, RiSendPlaneLine, RiSparklingLine } from '@remixicon/react'
|
||||
import { useEventListener, useSessionStorageState, useSize } from 'ahooks'
|
||||
import useBoolean from 'ahooks/lib/useBoolean'
|
||||
import type { ContextGenerateResponse } from '@/service/debug'
|
||||
import * as React from 'react'
|
||||
import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import ActionButton from '@/app/components/base/action-button'
|
||||
import Button from '@/app/components/base/button'
|
||||
import LoadingAnim from '@/app/components/base/chat/chat/loading-anim'
|
||||
import { CopyFeedbackNew } from '@/app/components/base/copy-feedback'
|
||||
import { CodeAssistant } from '@/app/components/base/icons/src/vender/line/general'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import { forwardRef, useCallback, useImperativeHandle, useMemo } from 'react'
|
||||
import Modal from '@/app/components/base/modal'
|
||||
import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '@/app/components/base/portal-to-follow-elem'
|
||||
import { SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import { useModelListAndDefaultModelAndCurrentProviderAndModel } from '@/app/components/header/account-setting/model-provider-page/hooks'
|
||||
import ModelIcon from '@/app/components/header/account-setting/model-provider-page/model-icon'
|
||||
import ModelParameterModal from '@/app/components/header/account-setting/model-provider-page/model-parameter-modal'
|
||||
import { useHooksStore } from '@/app/components/workflow/hooks-store'
|
||||
import { useNodeDataUpdate } from '@/app/components/workflow/hooks/use-node-data-update'
|
||||
import CodeEditor from '@/app/components/workflow/nodes/_base/components/editor/code-editor'
|
||||
import { CodeLanguage } from '@/app/components/workflow/nodes/code/types'
|
||||
import { useStore, useWorkflowStore } from '@/app/components/workflow/store'
|
||||
import { NodeRunningStatus, VarType } from '@/app/components/workflow/types'
|
||||
import { renderI18nObject } from '@/i18n-config'
|
||||
import { languages } from '@/i18n-config/language'
|
||||
import { fetchContextGenerateSuggestedQuestions, generateContext } from '@/service/debug'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import useContextGenData from './use-context-gen-data'
|
||||
import LeftPanel from './components/left-panel'
|
||||
import RightPanel from './components/right-panel'
|
||||
import useContextGenerate, { normalizeCodeLanguage } from './hooks/use-context-generate'
|
||||
import useResizablePanels from './hooks/use-resizable-panels'
|
||||
|
||||
type Props = {
|
||||
isShow: boolean
|
||||
@ -45,36 +22,10 @@ type Props = {
|
||||
codeNodeId: string
|
||||
}
|
||||
|
||||
type ContextGenerateChatMessage = ContextGenerateMessage & {
|
||||
durationMs?: number
|
||||
}
|
||||
|
||||
export type ContextGenerateModalHandle = {
|
||||
onOpen: () => void
|
||||
}
|
||||
|
||||
const minCodeHeight = 80
|
||||
const minOutputHeight = 80
|
||||
const splitHandleHeight = 4
|
||||
const defaultCodePanelHeight = 556
|
||||
const defaultCompletionParams: CompletionParams = {
|
||||
temperature: 0.7,
|
||||
max_tokens: 0,
|
||||
top_p: 0,
|
||||
echo: false,
|
||||
stop: [],
|
||||
presence_penalty: 0,
|
||||
frequency_penalty: 0,
|
||||
}
|
||||
|
||||
const normalizeCodeLanguage = (value?: string) => {
|
||||
if (value === CodeLanguage.javascript)
|
||||
return CodeLanguage.javascript
|
||||
if (value === CodeLanguage.python3)
|
||||
return CodeLanguage.python3
|
||||
return CodeLanguage.python3
|
||||
}
|
||||
|
||||
const normalizeOutputs = (outputs?: Record<string, { type: string }>) => {
|
||||
const next: OutputVar = {}
|
||||
Object.entries(outputs || {}).forEach(([key, value]) => {
|
||||
@ -104,7 +55,6 @@ const ContextGenerateModal = forwardRef<ContextGenerateModalHandle, Props>(({
|
||||
paramKey,
|
||||
codeNodeId,
|
||||
}, ref) => {
|
||||
const { t, i18n } = useTranslation()
|
||||
const configsMap = useHooksStore(s => s.configsMap)
|
||||
const nodes = useStore(s => s.nodes)
|
||||
const workflowStore = useWorkflowStore()
|
||||
@ -138,226 +88,38 @@ const ContextGenerateModal = forwardRef<ContextGenerateModalHandle, Props>(({
|
||||
}, [codeNodeData])
|
||||
|
||||
const {
|
||||
versions,
|
||||
addVersion,
|
||||
current,
|
||||
currentVersionIndex,
|
||||
setCurrentVersionIndex,
|
||||
clearVersions,
|
||||
} = useContextGenData({
|
||||
storageKey,
|
||||
})
|
||||
|
||||
const [promptMessages, setPromptMessages] = useSessionStorageState<ContextGenerateChatMessage[]>(
|
||||
`${storageKey}-messages`,
|
||||
{ defaultValue: [] },
|
||||
)
|
||||
|
||||
const [suggestedQuestions, setSuggestedQuestions] = useSessionStorageState<string[]>(
|
||||
`${storageKey}-suggested-questions`,
|
||||
{ defaultValue: [] },
|
||||
)
|
||||
const [hasFetchedSuggestions, setHasFetchedSuggestions] = useSessionStorageState<boolean>(
|
||||
`${storageKey}-suggested-questions-fetched`,
|
||||
{ defaultValue: false },
|
||||
)
|
||||
const [isFetchingSuggestions, { setTrue: setFetchingSuggestionsTrue, setFalse: setFetchingSuggestionsFalse }] = useBoolean(false)
|
||||
const suggestedQuestionsAbortControllerRef = useRef<AbortController | null>(null)
|
||||
|
||||
const language = useMemo(() => (i18n.language || 'en-US').replace('-', '_'), [i18n.language])
|
||||
const promptLanguage = useMemo(() => {
|
||||
const matched = languages.find(item => item.value === i18n.language)
|
||||
return matched?.prompt_name || 'English'
|
||||
}, [i18n.language])
|
||||
const [inputValue, setInputValue] = useState('')
|
||||
const [isGenerating, { setTrue: setGeneratingTrue, setFalse: setGeneratingFalse }] = useBoolean(false)
|
||||
const [modelOverride, setModelOverride] = useState<Model | null>(() => {
|
||||
const stored = localStorage.getItem('auto-gen-model')
|
||||
if (!stored)
|
||||
return null
|
||||
const parsed = JSON.parse(stored) as Model
|
||||
return {
|
||||
...parsed,
|
||||
completion_params: {
|
||||
...defaultCompletionParams,
|
||||
...parsed.completion_params,
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
const {
|
||||
defaultModel,
|
||||
} = useModelListAndDefaultModelAndCurrentProviderAndModel(ModelTypeEnum.textGeneration)
|
||||
|
||||
const model = useMemo<Model>(() => {
|
||||
if (modelOverride)
|
||||
return modelOverride
|
||||
if (!defaultModel) {
|
||||
return {
|
||||
name: '',
|
||||
provider: '',
|
||||
mode: AppModeEnum.CHAT as unknown as ModelModeType.chat,
|
||||
completion_params: defaultCompletionParams,
|
||||
}
|
||||
}
|
||||
return {
|
||||
name: defaultModel.model,
|
||||
provider: defaultModel.provider.provider,
|
||||
mode: AppModeEnum.CHAT as unknown as ModelModeType.chat,
|
||||
completion_params: defaultCompletionParams,
|
||||
}
|
||||
}, [defaultModel, modelOverride])
|
||||
|
||||
const handleModelChange = useCallback((newValue: { modelId: string, provider: string, mode?: string, features?: string[] }) => {
|
||||
const newModel = {
|
||||
...model,
|
||||
provider: newValue.provider,
|
||||
name: newValue.modelId,
|
||||
mode: newValue.mode as ModelModeType,
|
||||
}
|
||||
setModelOverride(newModel)
|
||||
localStorage.setItem('auto-gen-model', JSON.stringify(newModel))
|
||||
}, [model])
|
||||
|
||||
const handleCompletionParamsChange = useCallback((newParams: FormValue) => {
|
||||
const newModel = {
|
||||
...model,
|
||||
completion_params: newParams as CompletionParams,
|
||||
}
|
||||
setModelOverride(newModel)
|
||||
localStorage.setItem('auto-gen-model', JSON.stringify(newModel))
|
||||
}, [model])
|
||||
|
||||
const promptMessageCount = promptMessages?.length ?? 0
|
||||
const hasHistory = (versions?.length ?? 0) > 0 || promptMessageCount > 0
|
||||
const isInitView = !isGenerating && !hasHistory
|
||||
const defaultAssistantMessage = t('nodes.tool.contextGenerate.defaultAssistantMessage', { ns: 'workflow' })
|
||||
const shouldShowSuggestedSkeleton = isInitView && !hasFetchedSuggestions
|
||||
const suggestedQuestionsSafe = suggestedQuestions ?? []
|
||||
const suggestedSkeletonItems = useMemo(() => ([
|
||||
0,
|
||||
1,
|
||||
2,
|
||||
]), [])
|
||||
const versionOptions = useMemo(() => {
|
||||
const latestSuffix = t('generate.latest', { ns: 'appDebug' })
|
||||
const versionPrefix = t('generate.version', { ns: 'appDebug' })
|
||||
return versions.map((_, index) => ({
|
||||
index,
|
||||
label: `${versionPrefix} ${index + 1}${index === versions.length - 1 ? ` · ${latestSuffix}` : ''}`,
|
||||
}))
|
||||
}, [t, versions])
|
||||
const currentVersionIndexSafe = currentVersionIndex ?? 0
|
||||
const currentVersionLabel = versionOptions[currentVersionIndexSafe]?.label
|
||||
?? `${t('generate.version', { ns: 'appDebug' })} ${currentVersionIndexSafe + 1}`
|
||||
|
||||
const rightPlaceholderLines = useMemo(() => {
|
||||
const placeholder = t('nodes.tool.contextGenerate.rightSidePlaceholder', { ns: 'workflow' })
|
||||
return String(placeholder).split('\n').filter(Boolean)
|
||||
}, [t])
|
||||
|
||||
const [isVersionMenuOpen, setVersionMenuOpen] = useState(false)
|
||||
const handleVersionMenuOpen = useCallback((open: boolean) => {
|
||||
if (versions.length > 1)
|
||||
setVersionMenuOpen(open)
|
||||
else
|
||||
setVersionMenuOpen(false)
|
||||
}, [versions.length])
|
||||
const handleVersionMenuToggle = useCallback(() => {
|
||||
if (versions.length > 1)
|
||||
setVersionMenuOpen(value => !value)
|
||||
}, [versions.length])
|
||||
|
||||
const handleReset = useCallback(() => {
|
||||
if (isGenerating)
|
||||
return
|
||||
setPromptMessages([])
|
||||
setInputValue('')
|
||||
clearVersions()
|
||||
}, [clearVersions, isGenerating, setPromptMessages])
|
||||
|
||||
const handleSuggestedQuestionClick = useCallback((question: string) => {
|
||||
setInputValue(question)
|
||||
}, [])
|
||||
|
||||
const handleFetchSuggestedQuestions = useCallback(async () => {
|
||||
if (!flowId || !toolNodeId || !paramKey)
|
||||
return
|
||||
if (!model.name || !model.provider)
|
||||
return
|
||||
if (hasFetchedSuggestions || isFetchingSuggestions || !isInitView)
|
||||
return
|
||||
|
||||
setFetchingSuggestionsTrue()
|
||||
let shouldMarkFetched = true
|
||||
suggestedQuestionsAbortControllerRef.current?.abort()
|
||||
try {
|
||||
const response = await fetchContextGenerateSuggestedQuestions({
|
||||
workflow_id: flowId,
|
||||
node_id: toolNodeId,
|
||||
parameter_name: paramKey,
|
||||
language: promptLanguage,
|
||||
model_config: {
|
||||
provider: model.provider,
|
||||
name: model.name,
|
||||
completion_params: model.completion_params,
|
||||
},
|
||||
}, (abortController) => {
|
||||
suggestedQuestionsAbortControllerRef.current = abortController
|
||||
})
|
||||
|
||||
if (response.error) {
|
||||
shouldMarkFetched = false
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message: t('modal.errors.networkError', { ns: 'pluginTrigger' }),
|
||||
})
|
||||
setSuggestedQuestions([])
|
||||
return
|
||||
}
|
||||
|
||||
const nextQuestions = (response.questions || []).filter(question => question && question.trim())
|
||||
setSuggestedQuestions(nextQuestions)
|
||||
}
|
||||
catch (error) {
|
||||
if (String(error).includes('AbortError')) {
|
||||
shouldMarkFetched = false
|
||||
return
|
||||
}
|
||||
shouldMarkFetched = false
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message: t('modal.errors.networkError', { ns: 'pluginTrigger' }),
|
||||
})
|
||||
setSuggestedQuestions([])
|
||||
}
|
||||
finally {
|
||||
if (shouldMarkFetched)
|
||||
setHasFetchedSuggestions(true)
|
||||
setFetchingSuggestionsFalse()
|
||||
}
|
||||
}, [
|
||||
flowId,
|
||||
promptMessages,
|
||||
inputValue,
|
||||
setInputValue,
|
||||
suggestedQuestions,
|
||||
hasFetchedSuggestions,
|
||||
isFetchingSuggestions,
|
||||
isGenerating,
|
||||
model,
|
||||
handleModelChange,
|
||||
handleCompletionParamsChange,
|
||||
handleGenerate,
|
||||
handleReset,
|
||||
handleFetchSuggestedQuestions,
|
||||
abortSuggestedQuestions,
|
||||
defaultAssistantMessage,
|
||||
versionOptions,
|
||||
currentVersionLabel,
|
||||
isInitView,
|
||||
model.completion_params,
|
||||
model.name,
|
||||
model.provider,
|
||||
paramKey,
|
||||
promptLanguage,
|
||||
setFetchingSuggestionsFalse,
|
||||
setFetchingSuggestionsTrue,
|
||||
setHasFetchedSuggestions,
|
||||
setSuggestedQuestions,
|
||||
t,
|
||||
} = useContextGenerate({
|
||||
storageKey,
|
||||
flowId,
|
||||
toolNodeId,
|
||||
])
|
||||
paramKey,
|
||||
codeNodeData,
|
||||
})
|
||||
|
||||
const handleCloseModal = useCallback(() => {
|
||||
suggestedQuestionsAbortControllerRef.current?.abort()
|
||||
abortSuggestedQuestions()
|
||||
onClose()
|
||||
}, [onClose])
|
||||
}, [abortSuggestedQuestions, onClose])
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
onOpen: () => {
|
||||
@ -365,119 +127,14 @@ const ContextGenerateModal = forwardRef<ContextGenerateModalHandle, Props>(({
|
||||
},
|
||||
}), [handleFetchSuggestedQuestions])
|
||||
|
||||
const renderModelTrigger = useCallback((params: TriggerProps) => {
|
||||
const label = params.currentModel?.label
|
||||
? renderI18nObject(params.currentModel.label, language)
|
||||
: (params.currentModel?.model || params.modelId || model.name)
|
||||
const modelName = params.currentModel?.model || params.modelId || model.name
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-1 rounded-lg px-1.5 py-1 text-xs text-text-tertiary',
|
||||
params.disabled ? 'cursor-not-allowed opacity-50' : 'cursor-pointer hover:bg-state-base-hover',
|
||||
)}
|
||||
>
|
||||
<ModelIcon
|
||||
provider={params.currentProvider}
|
||||
modelName={modelName}
|
||||
className="!h-4 !w-4"
|
||||
iconClassName="!h-4 !w-4"
|
||||
/>
|
||||
<span className="max-w-[200px] truncate font-medium text-text-tertiary">
|
||||
{label}
|
||||
</span>
|
||||
<RiArrowDownSLine className="h-3.5 w-3.5 text-text-tertiary" />
|
||||
</div>
|
||||
)
|
||||
}, [language, model])
|
||||
|
||||
const chatListRef = useRef<HTMLDivElement>(null)
|
||||
useEffect(() => {
|
||||
if (!chatListRef.current)
|
||||
return
|
||||
if (promptMessageCount === 0 && !isGenerating)
|
||||
return
|
||||
chatListRef.current.scrollTop = chatListRef.current.scrollHeight
|
||||
}, [promptMessageCount, isGenerating])
|
||||
|
||||
const generateStartRef = useRef<number | null>(null)
|
||||
const handleGenerate = useCallback(async () => {
|
||||
const trimmed = inputValue.trim()
|
||||
if (!trimmed || isGenerating)
|
||||
return
|
||||
if (!flowId || !toolNodeId || !paramKey)
|
||||
return
|
||||
|
||||
const userMessage: ContextGenerateChatMessage = { role: 'user', content: trimmed }
|
||||
const nextMessages: ContextGenerateChatMessage[] = [...(promptMessages ?? []), userMessage]
|
||||
setPromptMessages(nextMessages)
|
||||
setInputValue('')
|
||||
setGeneratingTrue()
|
||||
generateStartRef.current = Date.now()
|
||||
try {
|
||||
const response = await generateContext({
|
||||
workflow_id: flowId,
|
||||
node_id: toolNodeId,
|
||||
parameter_name: paramKey,
|
||||
language: normalizeCodeLanguage(current?.code_language || codeNodeData?.code_language) as 'python3' | 'javascript',
|
||||
prompt_messages: nextMessages.map(({ role, content }) => ({ role, content })),
|
||||
model_config: {
|
||||
provider: model.provider,
|
||||
name: model.name,
|
||||
completion_params: model.completion_params,
|
||||
},
|
||||
})
|
||||
|
||||
if (response.error) {
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message: response.error,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const assistantMessage = response.message || defaultAssistantMessage
|
||||
const durationMs = generateStartRef.current ? Date.now() - generateStartRef.current : undefined
|
||||
const assistantEntry: ContextGenerateChatMessage = {
|
||||
role: 'assistant',
|
||||
content: assistantMessage,
|
||||
durationMs,
|
||||
}
|
||||
setPromptMessages([...nextMessages, assistantEntry])
|
||||
addVersion(response)
|
||||
}
|
||||
finally {
|
||||
setGeneratingFalse()
|
||||
generateStartRef.current = null
|
||||
}
|
||||
}, [
|
||||
addVersion,
|
||||
codeNodeData?.code_language,
|
||||
current?.code_language,
|
||||
defaultAssistantMessage,
|
||||
flowId,
|
||||
inputValue,
|
||||
isGenerating,
|
||||
model.completion_params,
|
||||
model.name,
|
||||
model.provider,
|
||||
paramKey,
|
||||
promptMessages,
|
||||
setPromptMessages,
|
||||
setGeneratingFalse,
|
||||
setGeneratingTrue,
|
||||
toolNodeId,
|
||||
])
|
||||
|
||||
const displayVersion = isInitView ? null : (current || fallbackVersion)
|
||||
const displayCodeLanguage = normalizeCodeLanguage(displayVersion?.code_language)
|
||||
const codeLanguageLabel = displayCodeLanguage === CodeLanguage.javascript
|
||||
// fixme: do not use i18n to display
|
||||
? t('nodes.tool.contextGenerate.codeLanguage.javascript', { ns: 'workflow' })
|
||||
: t('nodes.tool.contextGenerate.codeLanguage.python3', { ns: 'workflow' })
|
||||
const displayOutputData = useMemo(() => {
|
||||
const displayOutputData = useMemo<{
|
||||
variables: ContextGenerateResponse['variables']
|
||||
outputs: ContextGenerateResponse['outputs']
|
||||
} | null>(() => {
|
||||
if (!displayVersion)
|
||||
return {}
|
||||
return null
|
||||
return {
|
||||
variables: displayVersion.variables,
|
||||
outputs: displayVersion.outputs,
|
||||
@ -527,60 +184,9 @@ const ContextGenerateModal = forwardRef<ContextGenerateModalHandle, Props>(({
|
||||
return target?.data?._singleRunningStatus === NodeRunningStatus.Running
|
||||
}, [codeNodeId, nodes])
|
||||
|
||||
const rightContainerRef = useRef<HTMLDivElement>(null)
|
||||
const rightContainerSize = useSize(rightContainerRef)
|
||||
const [codePanelHeight, setCodePanelHeight] = useState(defaultCodePanelHeight)
|
||||
const draggingRef = useRef(false)
|
||||
const dragStartRef = useRef({ startY: 0, startHeight: 0 })
|
||||
const maxCodePanelHeight = useMemo(() => {
|
||||
const containerHeight = rightContainerSize?.height ?? 0
|
||||
if (!containerHeight)
|
||||
return null
|
||||
return Math.max(minCodeHeight, containerHeight - minOutputHeight - splitHandleHeight)
|
||||
}, [rightContainerSize?.height])
|
||||
const resolvedCodePanelHeight = useMemo(() => {
|
||||
if (!maxCodePanelHeight)
|
||||
return codePanelHeight
|
||||
// Reason: Clamp the panel height so the output area always has space.
|
||||
return Math.min(codePanelHeight, maxCodePanelHeight)
|
||||
}, [codePanelHeight, maxCodePanelHeight])
|
||||
|
||||
const handleResizeStart = useCallback((event: React.PointerEvent<HTMLButtonElement>) => {
|
||||
draggingRef.current = true
|
||||
dragStartRef.current = {
|
||||
startY: event.clientY,
|
||||
startHeight: resolvedCodePanelHeight,
|
||||
}
|
||||
document.body.style.userSelect = 'none'
|
||||
}, [resolvedCodePanelHeight])
|
||||
|
||||
useEventListener('mousemove', (event) => {
|
||||
if (!draggingRef.current)
|
||||
return
|
||||
|
||||
const containerHeight = rightContainerRef.current?.offsetHeight || 0
|
||||
if (!containerHeight)
|
||||
return
|
||||
const maxHeight = Math.max(minCodeHeight, containerHeight - minOutputHeight - splitHandleHeight)
|
||||
const delta = event.clientY - dragStartRef.current.startY
|
||||
const nextHeight = Math.min(Math.max(dragStartRef.current.startHeight + delta, minCodeHeight), maxHeight)
|
||||
setCodePanelHeight(nextHeight)
|
||||
})
|
||||
|
||||
useEventListener('mouseup', () => {
|
||||
if (!draggingRef.current)
|
||||
return
|
||||
draggingRef.current = false
|
||||
document.body.style.userSelect = ''
|
||||
})
|
||||
|
||||
const { rightContainerRef, resolvedCodePanelHeight, handleResizeStart } = useResizablePanels()
|
||||
const canRun = !!displayVersion?.code || !!codeNodeData?.code
|
||||
const emptyPanelClassName = cn(
|
||||
'flex h-full flex-col',
|
||||
isInitView
|
||||
? 'rounded-l-xl bg-components-panel-bg pb-1 pl-1'
|
||||
: 'rounded-[10px] bg-components-panel-bg',
|
||||
)
|
||||
const canApply = !!current
|
||||
|
||||
return (
|
||||
<Modal
|
||||
@ -592,431 +198,44 @@ const ContextGenerateModal = forwardRef<ContextGenerateModalHandle, Props>(({
|
||||
)}
|
||||
>
|
||||
<div className="relative flex h-[720px] max-h-[calc(100vh-32px)] flex-wrap">
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-full w-[400px] shrink-0 flex-col border-r border-divider-regular bg-background-body',
|
||||
isInitView ? 'justify-center pb-20' : 'justify-start',
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'bg-gradient-to-b from-background-body to-transparent backdrop-blur-[4px]',
|
||||
isInitView ? 'px-5 py-4' : 'px-4 pb-4 pt-3',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col">
|
||||
<div className="title-2xl-semi-bold bg-gradient-to-r from-[rgba(11,165,236,0.95)] to-[rgba(21,90,239,0.95)] bg-clip-text text-transparent">
|
||||
{t('nodes.tool.contextGenerate.title', { ns: 'workflow' })}
|
||||
</div>
|
||||
{isInitView && (
|
||||
<div className="mt-1 text-[13px] italic leading-4 text-text-tertiary">
|
||||
{t('nodes.tool.contextGenerate.subtitle', { ns: 'workflow' })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!isInitView && (
|
||||
<ActionButton
|
||||
size="m"
|
||||
className={cn('!h-8 !w-8', isGenerating && 'pointer-events-none opacity-50')}
|
||||
onClick={handleReset}
|
||||
>
|
||||
<RiRefreshLine className="h-4 w-4 text-text-tertiary" />
|
||||
</ActionButton>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isInitView
|
||||
? (
|
||||
<div className="flex w-full flex-col gap-1 px-2">
|
||||
<div className="bg-gradient-to-b from-[rgba(255,255,255,0.01)] to-background-body px-2 pb-2 pt-3">
|
||||
<div className="flex h-[120px] flex-col justify-between overflow-hidden rounded-xl border-[0.5px] border-components-input-border-active bg-components-panel-bg shadow-shadow-shadow-5 backdrop-blur-[5px]">
|
||||
<div className="flex min-h-[64px] px-3 pb-1 pt-2.5">
|
||||
<textarea
|
||||
value={inputValue}
|
||||
onChange={e => setInputValue(e.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault()
|
||||
handleGenerate()
|
||||
}
|
||||
}}
|
||||
placeholder={t('nodes.tool.contextGenerate.initPlaceholder', { ns: 'workflow' }) as string}
|
||||
className="w-full resize-none bg-transparent text-sm leading-5 text-text-primary placeholder:text-text-quaternary focus:outline-none"
|
||||
disabled={isGenerating}
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-end gap-2 p-2">
|
||||
<ModelParameterModal
|
||||
popupClassName="!w-[520px]"
|
||||
portalToFollowElemContentClassName="z-[1000]"
|
||||
isAdvancedMode={true}
|
||||
provider={model.provider}
|
||||
completionParams={model.completion_params}
|
||||
modelId={model.name}
|
||||
setModel={handleModelChange}
|
||||
onCompletionParamsChange={handleCompletionParamsChange}
|
||||
hideDebugWithMultipleModel
|
||||
renderTrigger={renderModelTrigger}
|
||||
/>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="small"
|
||||
className="!h-8 !w-8 shrink-0 !rounded-lg !px-0"
|
||||
disabled={!inputValue.trim() || isGenerating}
|
||||
onClick={handleGenerate}
|
||||
>
|
||||
<RiSendPlaneLine className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-px px-2">
|
||||
<div className="flex items-center px-3 pb-2 pt-4">
|
||||
<span className="text-xs font-semibold uppercase text-text-tertiary">
|
||||
{t('nodes.tool.contextGenerate.suggestedQuestionsTitle', { ns: 'workflow' })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 px-3">
|
||||
{shouldShowSuggestedSkeleton && suggestedSkeletonItems.map(item => (
|
||||
<SkeletonRow key={item} className="py-1">
|
||||
<div className="h-4 w-4 rounded-sm bg-divider-subtle opacity-60" />
|
||||
<SkeletonRectangle className="h-3 w-[260px]" />
|
||||
</SkeletonRow>
|
||||
))}
|
||||
{!shouldShowSuggestedSkeleton && suggestedQuestionsSafe.map((question, index) => (
|
||||
<button
|
||||
key={`${question}-${index}`}
|
||||
type="button"
|
||||
className="flex items-start gap-2 rounded-lg px-2 py-1 text-left text-sm text-text-secondary transition hover:bg-state-base-hover"
|
||||
onClick={() => handleSuggestedQuestionClick(question)}
|
||||
>
|
||||
<span className="mt-1 h-1.5 w-1.5 shrink-0 rounded-full bg-divider-regular" />
|
||||
<span className="flex-1 whitespace-pre-wrap">{question}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<>
|
||||
<div
|
||||
ref={chatListRef}
|
||||
className="flex-1 overflow-y-auto px-4 py-2"
|
||||
>
|
||||
<div className="flex w-full flex-col items-end gap-4 pt-3">
|
||||
{(() => {
|
||||
let assistantIndex = -1
|
||||
return (promptMessages || []).map((message, index) => {
|
||||
if (message.role === 'assistant')
|
||||
assistantIndex += 1
|
||||
const versionMeta = message.role === 'assistant' ? versionOptions[assistantIndex] : null
|
||||
const isSelected = versionMeta?.index === currentVersionIndexSafe
|
||||
const showThoughtProcess = message.role === 'assistant' && message.content !== defaultAssistantMessage
|
||||
const durationLabel = message.role === 'assistant' && message.durationMs
|
||||
? `${(message.durationMs / 1000).toFixed(1)}s`
|
||||
: null
|
||||
return (
|
||||
<div
|
||||
key={`${message.role}-${index}`}
|
||||
className={cn('flex w-full', message.role === 'user' ? 'justify-end' : 'justify-start')}
|
||||
>
|
||||
{message.role === 'user'
|
||||
? (
|
||||
<div className="max-w-[320px] whitespace-pre-wrap rounded-xl bg-util-colors-blue-brand-blue-brand-500 px-3 py-2 text-sm leading-5 text-text-primary-on-surface">
|
||||
{message.content}
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<div className="flex w-full flex-col items-start gap-2">
|
||||
{showThoughtProcess && (
|
||||
<div className="flex w-full items-center gap-1 rounded-xl bg-background-gradient-bg-fill-chat-bubble-bg-2 px-2 py-2 text-[13px] text-text-secondary">
|
||||
<div className="flex h-5 w-5 items-center justify-center">
|
||||
<RiSparklingLine className="h-4 w-4 text-text-secondary" />
|
||||
</div>
|
||||
<span className="flex-1 truncate">
|
||||
{message.content}
|
||||
</span>
|
||||
{durationLabel && (
|
||||
<span className="text-xs text-text-tertiary">
|
||||
{durationLabel}
|
||||
</span>
|
||||
)}
|
||||
<RiArrowDownSLine className="h-4 w-4 -rotate-90 text-text-secondary" />
|
||||
</div>
|
||||
)}
|
||||
<div className="whitespace-pre-wrap px-2 text-sm leading-5 text-text-primary">
|
||||
{showThoughtProcess ? defaultAssistantMessage : message.content}
|
||||
</div>
|
||||
{versionMeta && (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'flex min-h-[40px] w-full items-center gap-2 rounded-[12px] border-[0.5px] bg-components-card-bg px-3 py-2 text-left',
|
||||
isSelected
|
||||
? 'border-[1.5px] border-components-option-card-option-selected-border'
|
||||
: 'border-components-panel-border-subtle',
|
||||
)}
|
||||
onClick={() => setCurrentVersionIndex(versionMeta.index)}
|
||||
>
|
||||
<div className="flex h-4 w-4 items-center justify-center rounded-[5px] border-[0.5px] border-divider-subtle bg-util-colors-blue-blue-500 p-[2px] shadow-xs">
|
||||
<CodeAssistant className="h-3 w-3 text-text-primary-on-surface" />
|
||||
</div>
|
||||
<span className="flex-1 text-[13px] font-medium text-text-primary">
|
||||
{versionMeta.label}
|
||||
</span>
|
||||
<RiArrowRightLine className="h-4 w-4 text-text-tertiary" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
})()}
|
||||
{isGenerating && (
|
||||
<div className="flex w-full items-center gap-2 rounded-xl bg-background-gradient-bg-fill-chat-bubble-bg-2 px-2 py-2 text-xs text-text-secondary">
|
||||
<LoadingAnim type="text" />
|
||||
<span>{t('nodes.tool.contextGenerate.generating', { ns: 'workflow' })}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-gradient-to-b from-[rgba(255,255,255,0.01)] to-background-body px-1 pb-1 pt-3">
|
||||
<div className="flex min-h-[112px] flex-col justify-between overflow-hidden rounded-xl border-[0.5px] border-components-input-border-active bg-components-panel-bg shadow-shadow-shadow-5 backdrop-blur-[5px]">
|
||||
<div className="flex min-h-[64px] px-3 pb-1 pt-2.5">
|
||||
<textarea
|
||||
value={inputValue}
|
||||
onChange={e => setInputValue(e.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault()
|
||||
handleGenerate()
|
||||
}
|
||||
}}
|
||||
placeholder={t('nodes.tool.contextGenerate.inputPlaceholder', { ns: 'workflow' }) as string}
|
||||
className="w-full resize-none bg-transparent text-sm leading-5 text-text-primary placeholder:text-text-quaternary focus:outline-none"
|
||||
disabled={isGenerating}
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-end gap-2 p-2">
|
||||
<ModelParameterModal
|
||||
popupClassName="!w-[520px]"
|
||||
portalToFollowElemContentClassName="z-[1000]"
|
||||
isAdvancedMode={true}
|
||||
provider={model.provider}
|
||||
completionParams={model.completion_params}
|
||||
modelId={model.name}
|
||||
setModel={handleModelChange}
|
||||
onCompletionParamsChange={handleCompletionParamsChange}
|
||||
hideDebugWithMultipleModel
|
||||
renderTrigger={renderModelTrigger}
|
||||
/>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="small"
|
||||
className="!h-8 !w-8 shrink-0 !rounded-lg !px-0"
|
||||
disabled={!inputValue.trim() || isGenerating}
|
||||
onClick={handleGenerate}
|
||||
>
|
||||
<RiSendPlaneLine className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-full w-0 grow flex-col bg-background-body',
|
||||
isInitView ? 'py-1' : 'pt-1',
|
||||
)}
|
||||
>
|
||||
{isInitView && (
|
||||
<div className="flex h-10 items-center justify-end px-3 py-1">
|
||||
<ActionButton size="m" className="!h-8 !w-8" onClick={handleCloseModal}>
|
||||
<RiCloseLine className="h-4 w-4 text-text-tertiary" />
|
||||
</ActionButton>
|
||||
</div>
|
||||
)}
|
||||
{!isInitView && (
|
||||
<div className="flex shrink-0 items-center justify-between px-3 py-2">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="text-[13px] font-semibold uppercase text-text-secondary">
|
||||
{t('nodes.tool.contextGenerate.generatedCode', { ns: 'workflow' })}
|
||||
</div>
|
||||
<PortalToFollowElem
|
||||
placement="bottom-start"
|
||||
offset={{
|
||||
mainAxis: 6,
|
||||
crossAxis: -4,
|
||||
}}
|
||||
open={isVersionMenuOpen}
|
||||
onOpenChange={handleVersionMenuOpen}
|
||||
>
|
||||
<PortalToFollowElemTrigger asChild onClick={handleVersionMenuToggle}>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'flex items-center gap-1 text-xs font-medium text-text-tertiary',
|
||||
versions.length > 1 ? 'cursor-pointer' : 'cursor-default',
|
||||
)}
|
||||
>
|
||||
<span>{currentVersionLabel}</span>
|
||||
{versions.length > 1 && <RiArrowDownSLine className="h-3.5 w-3.5" />}
|
||||
</button>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className="z-[1010]">
|
||||
<div className="w-[208px] rounded-xl border border-components-panel-border bg-components-panel-bg-blur p-1 shadow-lg">
|
||||
<div className="system-xs-medium-uppercase flex h-[22px] items-center px-3 text-text-tertiary">
|
||||
{t('generate.versions', { ns: 'appDebug' })}
|
||||
</div>
|
||||
{versionOptions.map(option => (
|
||||
<button
|
||||
key={option.index}
|
||||
type="button"
|
||||
className={cn(
|
||||
'flex h-7 w-full items-center rounded-lg px-2 text-[13px] text-text-secondary',
|
||||
option.index === currentVersionIndexSafe
|
||||
? 'bg-state-base-hover'
|
||||
: 'hover:bg-state-base-hover',
|
||||
)}
|
||||
onClick={() => {
|
||||
setCurrentVersionIndex(option.index)
|
||||
setVersionMenuOpen(false)
|
||||
}}
|
||||
>
|
||||
<span className="flex-1 truncate text-left">{option.label}</span>
|
||||
{option.index === currentVersionIndexSafe && (
|
||||
<RiCheckLine className="h-4 w-4 text-text-accent" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{isRunning
|
||||
? (
|
||||
<div className="flex h-8 items-center gap-2 rounded-lg border-[0.5px] border-components-panel-border-subtle bg-components-panel-bg px-3 text-xs font-medium text-text-secondary">
|
||||
<span className="h-2 w-2 rounded-full bg-util-colors-blue-blue-500" />
|
||||
{t('nodes.tool.contextGenerate.running', { ns: 'workflow' })}
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<Button
|
||||
size="small"
|
||||
onClick={handleRun}
|
||||
disabled={!canRun || isGenerating}
|
||||
>
|
||||
{t('nodes.tool.contextGenerate.run', { ns: 'workflow' })}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="primary"
|
||||
size="small"
|
||||
onClick={() => applyToNode(true)}
|
||||
disabled={!current || isGenerating}
|
||||
>
|
||||
{t('nodes.tool.contextGenerate.apply', { ns: 'workflow' })}
|
||||
</Button>
|
||||
<div className="mx-1 h-4 w-px bg-divider-regular" />
|
||||
<ActionButton size="m" className="!h-8 !w-8" onClick={handleCloseModal}>
|
||||
<RiCloseLine className="h-4 w-4 text-text-tertiary" />
|
||||
</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
ref={rightContainerRef}
|
||||
className={cn(
|
||||
'flex h-full flex-col overflow-hidden',
|
||||
isInitView ? 'px-0 pb-0' : 'px-3 pb-3',
|
||||
)}
|
||||
>
|
||||
{isGenerating && !displayVersion && (
|
||||
<div className={cn(emptyPanelClassName, 'items-center justify-center')}>
|
||||
<Loading />
|
||||
<div className="mt-3 text-[13px] text-text-tertiary">
|
||||
{t('nodes.tool.contextGenerate.generating', { ns: 'workflow' })}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!isGenerating && !displayVersion && (
|
||||
<div className={emptyPanelClassName}>
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-2 pb-20 text-center">
|
||||
<CodeAssistant className="h-8 w-8 text-divider-regular" />
|
||||
<div className="text-xs leading-4 text-text-quaternary">
|
||||
{rightPlaceholderLines.map((line, index) => (
|
||||
<p key={`${line}-${index}`}>{line}</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{displayVersion && (
|
||||
<div className="flex h-full flex-col overflow-hidden">
|
||||
<div
|
||||
className="flex min-h-[80px] flex-col overflow-hidden rounded-[10px] bg-components-input-bg-normal"
|
||||
style={{ height: resolvedCodePanelHeight }}
|
||||
>
|
||||
<div className="flex items-center border-b border-divider-subtle px-2 py-1">
|
||||
<div className="flex flex-1 items-center px-1 py-0.5">
|
||||
<span className="text-xs font-semibold uppercase text-text-secondary">
|
||||
{codeLanguageLabel}
|
||||
</span>
|
||||
</div>
|
||||
<CopyFeedbackNew content={displayVersion.code || ''} className="!h-6 !w-6" />
|
||||
</div>
|
||||
<div className="flex-1 overflow-hidden px-3 pb-3 pt-2">
|
||||
<CodeEditor
|
||||
noWrapper
|
||||
isExpand
|
||||
readOnly
|
||||
language={displayCodeLanguage}
|
||||
value={displayVersion.code || ''}
|
||||
className="h-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-4 w-full cursor-row-resize items-center px-2"
|
||||
aria-label={t('nodes.tool.contextGenerate.resizeHandle', { ns: 'workflow' })}
|
||||
onPointerDown={handleResizeStart}
|
||||
>
|
||||
<div className="h-[2px] w-full rounded-full bg-divider-subtle" />
|
||||
</button>
|
||||
<div className="flex min-h-[80px] flex-1 flex-col overflow-hidden rounded-[10px] bg-components-input-bg-normal">
|
||||
<div className="flex items-center border-b border-divider-subtle px-2 py-1">
|
||||
<div className="flex flex-1 items-center px-1 py-0.5">
|
||||
<span className="text-xs font-semibold uppercase text-text-secondary">
|
||||
{t('nodes.tool.contextGenerate.output', { ns: 'workflow' })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-hidden px-3 pb-3 pt-2">
|
||||
<CodeEditor
|
||||
noWrapper
|
||||
isExpand
|
||||
readOnly
|
||||
isJSONStringifyBeauty
|
||||
language={CodeLanguage.json}
|
||||
value={displayOutputData}
|
||||
className="h-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<LeftPanel
|
||||
isInitView={isInitView}
|
||||
isGenerating={isGenerating}
|
||||
inputValue={inputValue}
|
||||
onInputChange={setInputValue}
|
||||
onGenerate={handleGenerate}
|
||||
onReset={handleReset}
|
||||
suggestedQuestions={suggestedQuestions}
|
||||
hasFetchedSuggestions={hasFetchedSuggestions}
|
||||
model={model}
|
||||
onModelChange={handleModelChange}
|
||||
onCompletionParamsChange={handleCompletionParamsChange}
|
||||
promptMessages={promptMessages}
|
||||
versionOptions={versionOptions}
|
||||
currentVersionIndex={currentVersionIndex}
|
||||
onSelectVersion={setCurrentVersionIndex}
|
||||
defaultAssistantMessage={defaultAssistantMessage}
|
||||
/>
|
||||
<RightPanel
|
||||
isInitView={isInitView}
|
||||
isGenerating={isGenerating}
|
||||
displayVersion={displayVersion}
|
||||
displayCodeLanguage={displayCodeLanguage}
|
||||
displayOutputData={displayOutputData}
|
||||
rightContainerRef={rightContainerRef}
|
||||
resolvedCodePanelHeight={resolvedCodePanelHeight}
|
||||
onResizeStart={handleResizeStart}
|
||||
versionOptions={versionOptions}
|
||||
currentVersionIndex={currentVersionIndex}
|
||||
currentVersionLabel={currentVersionLabel}
|
||||
onSelectVersion={setCurrentVersionIndex}
|
||||
onRun={handleRun}
|
||||
onApply={() => applyToNode(true)}
|
||||
canRun={canRun}
|
||||
canApply={canApply}
|
||||
isRunning={isRunning}
|
||||
onClose={handleCloseModal}
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user