mirror of
https://github.com/langgenius/dify.git
synced 2026-09-21 13:20:52 +08:00
chore(agent-v2): sync daily changes (#38162)
Co-authored-by: yunlu.wen <yunlu.wen@dify.ai> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Yunlu Wen <wylswz@163.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: Joel <iamjoel007@gmail.com> Co-authored-by: Yanli 盐粒 <yanli@dify.ai> Co-authored-by: 盐粒 Yanli <beautyyuyanli@gmail.com> Co-authored-by: zyssyz123 <916125788@qq.com> Co-authored-by: 盐粒 Yanli <mail@yanli.one>
This commit is contained in:
co-authored by
yunlu.wen
autofix-ci[bot]
Yunlu Wen
Copilot Autofix powered by AI
Joel
Yanli 盐粒
盐粒 Yanli
zyssyz123
盐粒 Yanli
parent
f816ae2e95
commit
0923ebaf88
@@ -1,12 +1,42 @@
|
||||
import type { AgentSoulConfig } from '@dify/contracts/api/console/agent/types.gen'
|
||||
import type { AgentSoulConfigWithFiles } from '../conversions'
|
||||
import { createStore } from 'jotai'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { agentSoulConfigToFormState, formStateToAgentSoulConfig } from '../conversions'
|
||||
import { defaultAgentSoulConfigFormState } from '../form-state'
|
||||
import {
|
||||
agentComposerDraftAtom,
|
||||
agentComposerOriginalConfigAtom,
|
||||
agentComposerOriginalDraftAtom,
|
||||
agentComposerPublishedDraftAtom,
|
||||
rebaseAgentComposerDraftAtom,
|
||||
} from '../store'
|
||||
|
||||
describe('agent composer store conversions', () => {
|
||||
it('rebases draft baselines through the composer state action', () => {
|
||||
const store = createStore()
|
||||
const nextDraft = {
|
||||
...defaultAgentSoulConfigFormState,
|
||||
prompt: 'Build draft prompt',
|
||||
}
|
||||
const originalConfig = {
|
||||
prompt: {
|
||||
system_prompt: 'Build draft prompt',
|
||||
},
|
||||
} satisfies AgentSoulConfig
|
||||
|
||||
store.set(rebaseAgentComposerDraftAtom, {
|
||||
draft: nextDraft,
|
||||
originalConfig,
|
||||
})
|
||||
|
||||
expect(store.get(agentComposerDraftAtom).prompt).toBe('Build draft prompt')
|
||||
expect(store.get(agentComposerOriginalDraftAtom)?.prompt).toBe('Build draft prompt')
|
||||
expect(store.get(agentComposerPublishedDraftAtom)?.prompt).toBe('Build draft prompt')
|
||||
expect(store.get(agentComposerOriginalConfigAtom)?.prompt?.system_prompt).toBe('Build draft prompt')
|
||||
})
|
||||
|
||||
it('should hydrate editable form state from an AgentSoulConfig and preserve it in the config snapshot', () => {
|
||||
const baseConfig: AgentSoulConfigWithFiles = {
|
||||
const baseConfig: AgentSoulConfig = {
|
||||
app_features: {
|
||||
opening_statement: 'Hello',
|
||||
suggested_questions: ['What changed?'],
|
||||
@@ -53,29 +83,30 @@ describe('agent composer store conversions', () => {
|
||||
},
|
||||
],
|
||||
},
|
||||
files: {
|
||||
skills: [
|
||||
{
|
||||
id: 'tender-analyzer',
|
||||
name: 'Tender Analyzer',
|
||||
description: 'Parses RFPs.',
|
||||
path: 'tender-analyzer',
|
||||
skill_md_key: 'tender-analyzer/SKILL.md',
|
||||
full_archive_key: 'tender-analyzer/.DIFY-SKILL-FULL.zip',
|
||||
},
|
||||
],
|
||||
files: [
|
||||
{
|
||||
id: 'files/sample.pdf',
|
||||
file_id: 'drive-file-1',
|
||||
name: 'sample.pdf',
|
||||
drive_key: 'files/sample.pdf',
|
||||
},
|
||||
],
|
||||
},
|
||||
config_skills: [
|
||||
{
|
||||
name: 'Tender Analyzer',
|
||||
description: 'Parses RFPs.',
|
||||
file_id: 'tool-file-1',
|
||||
file_kind: 'tool_file',
|
||||
},
|
||||
],
|
||||
config_files: [
|
||||
{
|
||||
file_id: 'drive-file-1',
|
||||
file_kind: 'upload_file',
|
||||
name: 'sample.pdf',
|
||||
mime_type: 'application/pdf',
|
||||
},
|
||||
],
|
||||
config_note: 'Read the proposal first.',
|
||||
model: {
|
||||
model: 'gpt-4.1',
|
||||
model_provider: 'openai',
|
||||
model_settings: {
|
||||
temperature: 0.2,
|
||||
max_tokens: 1024,
|
||||
},
|
||||
plugin_id: 'openai',
|
||||
},
|
||||
prompt: {
|
||||
@@ -126,6 +157,10 @@ describe('agent composer store conversions', () => {
|
||||
prompt: 'Be precise.',
|
||||
model: {
|
||||
model: 'gpt-4.1',
|
||||
model_settings: {
|
||||
temperature: 0.2,
|
||||
max_tokens: 1024,
|
||||
},
|
||||
provider: 'openai',
|
||||
plugin_id: 'openai',
|
||||
},
|
||||
@@ -152,16 +187,19 @@ describe('agent composer store conversions', () => {
|
||||
],
|
||||
skills: [
|
||||
expect.objectContaining({
|
||||
id: 'Tender Analyzer',
|
||||
description: 'Parses RFPs.',
|
||||
fileId: 'tool-file-1',
|
||||
name: 'Tender Analyzer',
|
||||
skillMdKey: 'tender-analyzer/SKILL.md',
|
||||
archiveKey: 'tender-analyzer/.DIFY-SKILL-FULL.zip',
|
||||
}),
|
||||
],
|
||||
files: [
|
||||
expect.objectContaining({
|
||||
configName: 'sample.pdf',
|
||||
icon: 'pdf',
|
||||
id: 'sample.pdf',
|
||||
name: 'sample.pdf',
|
||||
fileId: 'drive-file-1',
|
||||
driveKey: 'files/sample.pdf',
|
||||
}),
|
||||
],
|
||||
})
|
||||
@@ -187,25 +225,32 @@ describe('agent composer store conversions', () => {
|
||||
})
|
||||
|
||||
expect(publishConfig).not.toHaveProperty('skills_files')
|
||||
expect(publishConfig.files).toEqual({
|
||||
skills: [
|
||||
{
|
||||
id: 'tender-analyzer',
|
||||
name: 'Tender Analyzer',
|
||||
description: 'Parses RFPs.',
|
||||
path: 'tender-analyzer',
|
||||
skill_md_key: 'tender-analyzer/SKILL.md',
|
||||
full_archive_key: 'tender-analyzer/.DIFY-SKILL-FULL.zip',
|
||||
},
|
||||
],
|
||||
files: [
|
||||
{
|
||||
id: 'files/sample.pdf',
|
||||
file_id: 'drive-file-1',
|
||||
name: 'sample.pdf',
|
||||
drive_key: 'files/sample.pdf',
|
||||
},
|
||||
],
|
||||
expect(publishConfig).not.toHaveProperty('files')
|
||||
expect(publishConfig.config_skills).toEqual([
|
||||
{
|
||||
name: 'Tender Analyzer',
|
||||
description: 'Parses RFPs.',
|
||||
file_id: 'tool-file-1',
|
||||
file_kind: 'tool_file',
|
||||
size: undefined,
|
||||
hash: undefined,
|
||||
mime_type: undefined,
|
||||
},
|
||||
])
|
||||
expect(publishConfig.config_files).toEqual([
|
||||
{
|
||||
name: 'sample.pdf',
|
||||
file_id: 'drive-file-1',
|
||||
file_kind: 'upload_file',
|
||||
size: undefined,
|
||||
hash: undefined,
|
||||
mime_type: 'application/pdf',
|
||||
},
|
||||
])
|
||||
expect(publishConfig.config_note).toBe('Read the proposal first.')
|
||||
expect(publishConfig.model?.model_settings).toEqual({
|
||||
temperature: 0.2,
|
||||
max_tokens: 1024,
|
||||
})
|
||||
expect(publishConfig.tools?.dify_tools).toEqual([
|
||||
expect.objectContaining({
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import type {
|
||||
AgentConfigFileRefConfig,
|
||||
AgentConfigSkillRefConfig,
|
||||
AgentKnowledgeMetadataConditions,
|
||||
AgentKnowledgeModelConfig,
|
||||
AgentKnowledgeRetrievalConfig,
|
||||
@@ -7,6 +9,7 @@ import type {
|
||||
} from '@dify/contracts/api/console/agent/types.gen'
|
||||
import type {
|
||||
AgentCliTool,
|
||||
AgentComposerModel,
|
||||
AgentFileNode,
|
||||
AgentKnowledgeRetrievalItem,
|
||||
AgentProviderTool,
|
||||
@@ -15,7 +18,6 @@ import type {
|
||||
AgentTool,
|
||||
EnvVariable,
|
||||
} from './form-state'
|
||||
import type { DefaultModel } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import type {
|
||||
MetadataFilteringConditions,
|
||||
MultipleRetrievalConfig,
|
||||
@@ -24,37 +26,12 @@ import type {
|
||||
import type { ModelConfig } from '@/app/components/workflow/types'
|
||||
import { MetadataFilteringModeEnum } from '@/app/components/workflow/nodes/knowledge-retrieval/types'
|
||||
import { DATASET_DEFAULT } from '@/config'
|
||||
import { getFileIconType } from '@/features/agent-v2/agent-detail/configure/components/orchestrate/files/file-icon'
|
||||
import { RETRIEVE_TYPE } from '@/types/app'
|
||||
import { checkKey } from '@/utils/var'
|
||||
import { defaultAgentSoulConfigFormState } from './form-state'
|
||||
import { getKnowledgeRetrievalSetName } from './knowledge-validation'
|
||||
|
||||
type AgentSoulFileRefConfig = {
|
||||
id?: string | null
|
||||
file_id?: string | null
|
||||
name?: string | null
|
||||
type?: string | null
|
||||
drive_key?: string | null
|
||||
}
|
||||
|
||||
type AgentSoulSkillRefConfig = {
|
||||
id?: string | null
|
||||
name?: string | null
|
||||
description?: string | null
|
||||
path?: string | null
|
||||
skill_md_key?: string | null
|
||||
full_archive_key?: string | null
|
||||
}
|
||||
|
||||
type AgentSoulFilesConfig = {
|
||||
skills?: AgentSoulSkillRefConfig[]
|
||||
files?: AgentSoulFileRefConfig[]
|
||||
}
|
||||
|
||||
export type AgentSoulConfigWithFiles = AgentSoulConfig & {
|
||||
files?: AgentSoulFilesConfig
|
||||
}
|
||||
|
||||
type AgentSoulDifyToolConfig = NonNullable<NonNullable<AgentSoulConfig['tools']>['dify_tools']>[number]
|
||||
type AgentSoulCliToolConfig = NonNullable<NonNullable<AgentSoulConfig['tools']>['cli_tools']>[number]
|
||||
type AgentSoulToolRuntimeParameterValue = NonNullable<AgentSoulDifyToolConfig['runtime_parameters']>[string]
|
||||
@@ -424,80 +401,77 @@ const toEnvConfig = (variables: EnvVariable[]): AgentSoulConfig['env'] => ({
|
||||
})),
|
||||
})
|
||||
|
||||
const toSkillConfigs = (skills: AgentSkill[]): AgentSoulSkillRefConfig[] => skills.map(skill => ({
|
||||
id: skill.path ?? skill.id,
|
||||
name: skill.name,
|
||||
description: skill.description,
|
||||
path: skill.path,
|
||||
skill_md_key: skill.skillMdKey,
|
||||
full_archive_key: skill.archiveKey,
|
||||
}))
|
||||
const toConfigSkillConfigs = (skills: AgentSkill[], baseConfig?: AgentSoulConfig): AgentConfigSkillRefConfig[] => {
|
||||
const existingByName = new Map((baseConfig?.config_skills ?? []).map(skill => [skill.name, skill]))
|
||||
|
||||
const toFileConfigs = (files: AgentFileNode[]): AgentSoulFileRefConfig[] => files.flatMap((file) => {
|
||||
if (file.children?.length)
|
||||
return toFileConfigs(file.children)
|
||||
return skills.flatMap((skill) => {
|
||||
const existing = existingByName.get(skill.name)
|
||||
const fileId = skill.fileId ?? existing?.file_id
|
||||
if (!fileId)
|
||||
return []
|
||||
|
||||
return [{
|
||||
id: file.id,
|
||||
file_id: file.fileId,
|
||||
name: file.name,
|
||||
drive_key: file.driveKey,
|
||||
}]
|
||||
})
|
||||
return [{
|
||||
name: skill.name,
|
||||
description: skill.description ?? existing?.description ?? '',
|
||||
file_id: fileId,
|
||||
file_kind: existing?.file_kind ?? 'tool_file',
|
||||
size: skill.size ?? existing?.size,
|
||||
hash: skill.hash ?? existing?.hash,
|
||||
mime_type: skill.mimeType ?? existing?.mime_type,
|
||||
}]
|
||||
})
|
||||
}
|
||||
|
||||
const toFilesConfig = (formState: AgentSoulConfigFormState): AgentSoulFilesConfig => ({
|
||||
skills: toSkillConfigs(formState.skills),
|
||||
files: toFileConfigs(formState.files),
|
||||
})
|
||||
const toConfigFileConfigs = (files: AgentFileNode[], baseConfig?: AgentSoulConfig): AgentConfigFileRefConfig[] => {
|
||||
const existingByName = new Map((baseConfig?.config_files ?? []).map(file => [file.name, file]))
|
||||
|
||||
const getAgentFileName = (file: AgentSoulFileRefConfig) => {
|
||||
if (file.name)
|
||||
return file.name
|
||||
return files.flatMap((file) => {
|
||||
if (file.children?.length)
|
||||
return toConfigFileConfigs(file.children, baseConfig)
|
||||
|
||||
const driveKey = file.drive_key ?? file.id ?? file.file_id ?? ''
|
||||
return driveKey.split('/').pop() || driveKey
|
||||
const configName = file.configName ?? file.name
|
||||
const existing = existingByName.get(configName)
|
||||
const fileId = file.fileId ?? existing?.file_id
|
||||
if (!fileId)
|
||||
return []
|
||||
|
||||
return [{
|
||||
name: configName,
|
||||
file_id: fileId,
|
||||
file_kind: existing?.file_kind ?? 'upload_file',
|
||||
size: file.size ?? existing?.size,
|
||||
hash: file.hash ?? existing?.hash,
|
||||
mime_type: file.mimeType ?? existing?.mime_type,
|
||||
}]
|
||||
})
|
||||
}
|
||||
|
||||
const toSkillFormState = (config?: AgentSoulConfig): AgentSkill[] => {
|
||||
const filesConfig = (config as AgentSoulConfigWithFiles | undefined)?.files
|
||||
|
||||
return (filesConfig?.skills ?? []).flatMap((skill) => {
|
||||
const id = skill.skill_md_key ?? skill.path ?? skill.id
|
||||
const name = skill.name ?? skill.path ?? id
|
||||
if (!id || !name)
|
||||
return []
|
||||
|
||||
return [{
|
||||
id,
|
||||
name,
|
||||
description: skill.description ?? undefined,
|
||||
path: skill.path ?? undefined,
|
||||
skillMdKey: skill.skill_md_key ?? undefined,
|
||||
archiveKey: skill.full_archive_key ?? undefined,
|
||||
}]
|
||||
})
|
||||
return (config?.config_skills ?? []).map(skill => ({
|
||||
id: skill.name,
|
||||
name: skill.name,
|
||||
description: skill.description ?? undefined,
|
||||
fileId: skill.file_id,
|
||||
size: skill.size ?? undefined,
|
||||
hash: skill.hash ?? undefined,
|
||||
mimeType: skill.mime_type ?? undefined,
|
||||
}))
|
||||
}
|
||||
|
||||
const toFileFormState = (config?: AgentSoulConfig): AgentFileNode[] => {
|
||||
const filesConfig = (config as AgentSoulConfigWithFiles | undefined)?.files
|
||||
|
||||
return (filesConfig?.files ?? []).flatMap((file) => {
|
||||
const id = file.drive_key ?? file.file_id ?? file.id
|
||||
const name = getAgentFileName(file)
|
||||
if (!id || !name)
|
||||
return []
|
||||
|
||||
return [{
|
||||
id,
|
||||
name,
|
||||
icon: 'file' as const,
|
||||
fileId: file.file_id ?? undefined,
|
||||
driveKey: file.drive_key ?? undefined,
|
||||
}]
|
||||
})
|
||||
return (config?.config_files ?? []).map(file => ({
|
||||
id: file.name,
|
||||
name: file.name,
|
||||
icon: getFileIconType(file.name, file.mime_type ?? undefined),
|
||||
fileId: file.file_id,
|
||||
configName: file.name,
|
||||
size: file.size ?? undefined,
|
||||
hash: file.hash ?? undefined,
|
||||
mimeType: file.mime_type ?? undefined,
|
||||
}))
|
||||
}
|
||||
|
||||
const toDraftModel = (config?: AgentSoulConfig): DefaultModel | undefined => {
|
||||
const toDraftModel = (config?: AgentSoulConfig): AgentComposerModel | undefined => {
|
||||
const modelProvider = config?.model?.model_provider
|
||||
const model = config?.model?.model
|
||||
|
||||
@@ -508,10 +482,11 @@ const toDraftModel = (config?: AgentSoulConfig): DefaultModel | undefined => {
|
||||
provider: modelProvider,
|
||||
model,
|
||||
plugin_id: config?.model?.plugin_id,
|
||||
model_settings: config?.model?.model_settings,
|
||||
}
|
||||
}
|
||||
|
||||
const getModelProviderPluginId = (model: DefaultModel, baseModel?: AgentSoulConfig['model']) => {
|
||||
const getModelProviderPluginId = (model: AgentComposerModel, baseModel?: AgentSoulConfig['model']) => {
|
||||
if (model.plugin_id)
|
||||
return model.plugin_id
|
||||
|
||||
@@ -533,8 +508,8 @@ export const formStateToAgentSoulConfig = ({
|
||||
}: {
|
||||
baseConfig?: AgentSoulConfig
|
||||
formState: AgentSoulConfigFormState
|
||||
currentModel?: DefaultModel
|
||||
}): AgentSoulConfigWithFiles => {
|
||||
currentModel?: AgentComposerModel
|
||||
}): AgentSoulConfig => {
|
||||
return {
|
||||
...baseConfig,
|
||||
prompt: {
|
||||
@@ -547,6 +522,7 @@ export const formStateToAgentSoulConfig = ({
|
||||
model_provider: currentModel.provider,
|
||||
model: currentModel.model,
|
||||
plugin_id: getModelProviderPluginId(currentModel, baseConfig?.model),
|
||||
model_settings: currentModel.model_settings,
|
||||
}
|
||||
: baseConfig?.model,
|
||||
tools: {
|
||||
@@ -557,7 +533,9 @@ export const formStateToAgentSoulConfig = ({
|
||||
app_features: formState.appFeatures ?? baseConfig?.app_features,
|
||||
knowledge: toKnowledgeConfig(formState.knowledgeRetrievals),
|
||||
env: toEnvConfig(formState.envVariables),
|
||||
files: toFilesConfig(formState),
|
||||
config_skills: toConfigSkillConfigs(formState.skills, baseConfig),
|
||||
config_files: toConfigFileConfigs(formState.files, baseConfig),
|
||||
config_note: baseConfig?.config_note ?? '',
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { AgentKnowledgeDatasetConfig, AgentSoulAppFeaturesConfig } from '@dify/contracts/api/console/agent/types.gen'
|
||||
import type { AgentKnowledgeDatasetConfig, AgentSoulAppFeaturesConfig, AgentSoulModelConfig } from '@dify/contracts/api/console/agent/types.gen'
|
||||
import type { FileTreeIconType } from '@langgenius/dify-ui/file-tree'
|
||||
import type { DefaultModel } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import type { ToolDefaultValue } from '@/app/components/workflow/block-selector/types'
|
||||
@@ -15,6 +15,10 @@ import type { I18nKeysWithPrefix } from '@/types/i18n'
|
||||
|
||||
export type EnvScope = 'secret' | 'plain'
|
||||
|
||||
export type AgentComposerModel = DefaultModel & {
|
||||
model_settings?: AgentSoulModelConfig['model_settings']
|
||||
}
|
||||
|
||||
export type EnvVariable = {
|
||||
id: string
|
||||
key: string
|
||||
@@ -25,20 +29,27 @@ export type EnvVariable = {
|
||||
|
||||
export type AgentSkill = {
|
||||
description?: string
|
||||
archiveKey?: string
|
||||
fileId?: string
|
||||
hash?: string
|
||||
id: string
|
||||
mimeType?: string
|
||||
name: string
|
||||
path?: string
|
||||
size?: number
|
||||
skillMdKey?: string
|
||||
}
|
||||
|
||||
export type AgentFileNode = {
|
||||
driveKey?: string
|
||||
hash?: string
|
||||
id: string
|
||||
name: string
|
||||
icon: FileTreeIconType
|
||||
fileId?: string
|
||||
driveKey?: string
|
||||
configName?: string
|
||||
children?: AgentFileNode[]
|
||||
virtualContent?: string
|
||||
mimeType?: string
|
||||
name: string
|
||||
size?: number
|
||||
}
|
||||
|
||||
export type AgentKnowledgeRetrievalItem = {
|
||||
@@ -98,7 +109,7 @@ export type AgentTool = AgentProviderTool | AgentCliTool
|
||||
|
||||
export type AgentSoulConfigFormState = {
|
||||
prompt: string
|
||||
model?: DefaultModel
|
||||
model?: AgentComposerModel
|
||||
appFeatures?: AgentSoulAppFeaturesConfig
|
||||
skills: AgentSkill[]
|
||||
files: AgentFileNode[]
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
import type { AgentSoulConfig } from '@dify/contracts/api/console/agent/types.gen'
|
||||
import type { ReactNode } from 'react'
|
||||
import type { AgentSoulConfigFormState } from './form-state'
|
||||
import { createStore, Provider as JotaiProvider } from 'jotai'
|
||||
import { useRef } from 'react'
|
||||
import { ScopeProvider } from 'jotai-scope'
|
||||
import { defaultAgentSoulConfigFormState } from './form-state'
|
||||
import {
|
||||
agentComposerDraftAtom,
|
||||
agentComposerOriginalConfigAtom,
|
||||
@@ -12,27 +12,6 @@ import {
|
||||
agentComposerPublishedDraftAtom,
|
||||
} from './store'
|
||||
|
||||
function createAgentComposerStore({
|
||||
initialDraft,
|
||||
initialOriginalConfig,
|
||||
}: {
|
||||
initialDraft?: AgentSoulConfigFormState
|
||||
initialOriginalConfig?: AgentSoulConfig
|
||||
}) {
|
||||
const store = createStore()
|
||||
|
||||
if (initialOriginalConfig)
|
||||
store.set(agentComposerOriginalConfigAtom, initialOriginalConfig)
|
||||
if (initialDraft)
|
||||
store.set(agentComposerDraftAtom, initialDraft)
|
||||
if (initialDraft)
|
||||
store.set(agentComposerOriginalDraftAtom, initialDraft)
|
||||
if (initialDraft)
|
||||
store.set(agentComposerPublishedDraftAtom, initialDraft)
|
||||
|
||||
return store
|
||||
}
|
||||
|
||||
export function AgentComposerProvider({
|
||||
children,
|
||||
initialDraft,
|
||||
@@ -42,18 +21,19 @@ export function AgentComposerProvider({
|
||||
initialDraft?: AgentSoulConfigFormState
|
||||
initialOriginalConfig?: AgentSoulConfig
|
||||
}) {
|
||||
const storeRef = useRef<ReturnType<typeof createAgentComposerStore> | null>(null)
|
||||
if (!storeRef.current) {
|
||||
storeRef.current = createAgentComposerStore({
|
||||
initialDraft,
|
||||
initialOriginalConfig,
|
||||
})
|
||||
}
|
||||
const store = storeRef.current
|
||||
const draft = initialDraft ?? defaultAgentSoulConfigFormState
|
||||
|
||||
return (
|
||||
<JotaiProvider store={store}>
|
||||
<ScopeProvider
|
||||
atoms={[
|
||||
[agentComposerOriginalConfigAtom, initialOriginalConfig],
|
||||
[agentComposerDraftAtom, draft],
|
||||
[agentComposerOriginalDraftAtom, draft],
|
||||
[agentComposerPublishedDraftAtom, draft],
|
||||
]}
|
||||
name="AgentComposer"
|
||||
>
|
||||
{children}
|
||||
</JotaiProvider>
|
||||
</ScopeProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import type { AgentComposerModel } from '../form-state'
|
||||
import type { DraftFieldUpdate } from './utils'
|
||||
import type { DefaultModel } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import { atom } from 'jotai'
|
||||
import { agentComposerDraftAtom } from '../store'
|
||||
import { resolveDraftFieldUpdate } from './utils'
|
||||
|
||||
export const agentComposerModelAtom = atom(
|
||||
get => get(agentComposerDraftAtom).model,
|
||||
(get, set, modelUpdate: DraftFieldUpdate<DefaultModel | undefined>) => {
|
||||
(get, set, modelUpdate: DraftFieldUpdate<AgentComposerModel | undefined>) => {
|
||||
const draft = get(agentComposerDraftAtom)
|
||||
|
||||
set(agentComposerDraftAtom, {
|
||||
|
||||
@@ -9,6 +9,19 @@ export const agentComposerOriginalDraftAtom = atom<AgentSoulConfigFormState | un
|
||||
export const agentComposerPublishedDraftAtom = atom<AgentSoulConfigFormState | undefined>(defaultAgentSoulConfigFormState)
|
||||
export const agentComposerDraftAtom = atom<AgentSoulConfigFormState>(defaultAgentSoulConfigFormState)
|
||||
|
||||
export const rebaseAgentComposerDraftAtom = atom(null, (_get, set, {
|
||||
draft,
|
||||
originalConfig,
|
||||
}: {
|
||||
draft: AgentSoulConfigFormState
|
||||
originalConfig?: AgentSoulConfig
|
||||
}) => {
|
||||
set(agentComposerOriginalConfigAtom, originalConfig)
|
||||
set(agentComposerDraftAtom, draft)
|
||||
set(agentComposerOriginalDraftAtom, draft)
|
||||
set(agentComposerPublishedDraftAtom, draft)
|
||||
})
|
||||
|
||||
export const isAgentComposerDirtyAtom = atom((get) => {
|
||||
const originalDraft = get(agentComposerOriginalDraftAtom)
|
||||
const draft = get(agentComposerDraftAtom)
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
# Agent Configure
|
||||
|
||||
Owns the Agent V2 configure runtime used by the Agent App configure page and workflow inline Agent configure surface, including editable composer draft wiring, build chat sessions, version viewing, build draft mode, and preview side panels.
|
||||
|
||||
## Internal Modules
|
||||
|
||||
- agent-composer
|
||||
- agent-detail/configure/state
|
||||
- agent-detail/configure/use-agent-build-draft-run
|
||||
- agent-detail/configure/use-agent-configure-build-draft
|
||||
- agent-detail/configure/use-agent-configure-sync
|
||||
- agent-detail/configure/components/orchestrate
|
||||
- agent-detail/configure/components/preview
|
||||
- agent-detail/configure/components/workspace
|
||||
- agent-detail/configure/model-compatibility
|
||||
|
||||
## External Modules
|
||||
|
||||
- app/components/base/chat
|
||||
- app/components/base/action-button
|
||||
- app/components/base/app-icon
|
||||
- app/components/base/features
|
||||
- app/components/base/file-uploader
|
||||
- app/components/base/infotip
|
||||
- app/components/base/loading
|
||||
- app/components/base/prompt-editor
|
||||
- app/components/base/skeleton
|
||||
- app/components/app/configuration/config/agent/agent-tools
|
||||
- app/components/datasets
|
||||
- app/components/header/account-setting/model-provider-page
|
||||
- app/components/plugins
|
||||
- app/components/tools
|
||||
- app/components/workflow/block-icon
|
||||
- app/components/workflow/block-selector
|
||||
- app/components/workflow/hooks/use-serial-async-callback
|
||||
- app/components/workflow/nodes
|
||||
- app/components/workflow/types
|
||||
- config
|
||||
- context/app-context
|
||||
- context/i18n
|
||||
- context/modal-context
|
||||
- contract/router
|
||||
- hooks/use-format-time-from-now
|
||||
- hooks/use-theme
|
||||
- hooks/use-timestamp
|
||||
- models/datasets
|
||||
- models/debug
|
||||
- models/log
|
||||
- service/base
|
||||
- service/use-common
|
||||
- types/app
|
||||
- types/common
|
||||
- types/i18n
|
||||
- types/workflow
|
||||
- utils/format
|
||||
- utils/var
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { Model, ModelItem } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import {
|
||||
ConfigurationMethodEnum,
|
||||
ModelStatusEnum,
|
||||
ModelTypeEnum,
|
||||
} from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import { isAgentCompatibleModel } from '../model-compatibility'
|
||||
|
||||
const createModel = (provider: string): Model => ({
|
||||
provider,
|
||||
icon_small: { en_US: '', zh_Hans: '' },
|
||||
label: { en_US: provider, zh_Hans: provider },
|
||||
models: [],
|
||||
status: ModelStatusEnum.active,
|
||||
})
|
||||
|
||||
const createModelItem = (model: string, overrides: Partial<ModelItem> = {}): ModelItem => ({
|
||||
model,
|
||||
label: { en_US: model, zh_Hans: model },
|
||||
model_type: ModelTypeEnum.textGeneration,
|
||||
fetch_from: ConfigurationMethodEnum.predefinedModel,
|
||||
status: ModelStatusEnum.active,
|
||||
model_properties: {},
|
||||
load_balancing_enabled: false,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('isAgentCompatibleModel', () => {
|
||||
it('should reject configured OpenAI models below the Agent-compatible baseline', () => {
|
||||
const provider = createModel('langgenius/openai/openai')
|
||||
|
||||
expect(isAgentCompatibleModel(provider, createModelItem('gpt-4o-mini'))).toBe(false)
|
||||
expect(isAgentCompatibleModel(provider, createModelItem('gpt-4.1-mini'))).toBe(false)
|
||||
expect(isAgentCompatibleModel(provider, createModelItem('gpt-4.1-nano'))).toBe(false)
|
||||
expect(isAgentCompatibleModel(provider, createModelItem('gpt-4'))).toBe(false)
|
||||
expect(isAgentCompatibleModel(provider, createModelItem('gpt-4-turbo'))).toBe(false)
|
||||
expect(isAgentCompatibleModel(provider, createModelItem('gpt-4-vision-preview'))).toBe(false)
|
||||
expect(isAgentCompatibleModel(provider, createModelItem('gpt-3.5-turbo-16k'))).toBe(false)
|
||||
expect(isAgentCompatibleModel(provider, createModelItem('o3-mini'))).toBe(false)
|
||||
expect(isAgentCompatibleModel(provider, createModelItem('o4-mini'))).toBe(false)
|
||||
})
|
||||
|
||||
it('should allow newer OpenAI models and other providers', () => {
|
||||
expect(isAgentCompatibleModel(createModel('openai'), createModelItem('gpt-4o'))).toBe(true)
|
||||
expect(isAgentCompatibleModel(createModel('openai'), createModelItem('gpt-4.1'))).toBe(true)
|
||||
expect(isAgentCompatibleModel(createModel('anthropic'), createModelItem('other-model'))).toBe(true)
|
||||
})
|
||||
|
||||
it('should reject specifically configured models that do not meet the Agent baseline', () => {
|
||||
expect(isAgentCompatibleModel(createModel('anthropic'), createModelItem('claude-3-haiku-20240307'))).toBe(false)
|
||||
expect(isAgentCompatibleModel(createModel('anthropic'), createModelItem('claude-3.5-sonnet-20241022'))).toBe(false)
|
||||
expect(isAgentCompatibleModel(createModel('langgenius/gemini/google'), createModelItem('gemini-2.5-flash-lite'))).toBe(false)
|
||||
expect(isAgentCompatibleModel(createModel('langgenius/gemini/google'), createModelItem('gemini-1.5-flash-8b'))).toBe(false)
|
||||
expect(isAgentCompatibleModel(createModel('deepseek'), createModelItem('deepseek-r1-distill-qwen-32b'))).toBe(false)
|
||||
expect(isAgentCompatibleModel(createModel('minimax'), createModelItem('minimax-text-01'))).toBe(false)
|
||||
expect(isAgentCompatibleModel(createModel('minimax'), createModelItem('minimax-m1'))).toBe(false)
|
||||
expect(isAgentCompatibleModel(createModel('tongyi'), createModelItem('qwen2.5-72b-instruct'))).toBe(false)
|
||||
expect(isAgentCompatibleModel(createModel('tongyi'), createModelItem('qwen2.5-coder-32b-instruct'))).toBe(false)
|
||||
expect(isAgentCompatibleModel(createModel('tongyi'), createModelItem('qwen3-30b'))).toBe(false)
|
||||
expect(isAgentCompatibleModel(createModel('langgenius/zhipuai/zhipuai'), createModelItem('glm-4-airx'))).toBe(false)
|
||||
expect(isAgentCompatibleModel(createModel('langgenius/zhipuai/zhipuai'), createModelItem('glm-z1-flash'))).toBe(false)
|
||||
})
|
||||
|
||||
it('should allow unconfigured models from the same providers', () => {
|
||||
expect(isAgentCompatibleModel(createModel('anthropic'), createModelItem('claude-sonnet-4'))).toBe(true)
|
||||
expect(isAgentCompatibleModel(createModel('langgenius/gemini/google'), createModelItem('gemini-2.5-pro'))).toBe(true)
|
||||
expect(isAgentCompatibleModel(createModel('deepseek'), createModelItem('deepseek-coder'))).toBe(true)
|
||||
expect(isAgentCompatibleModel(createModel('tongyi'), createModelItem('qwen3-coder-plus'))).toBe(true)
|
||||
expect(isAgentCompatibleModel(createModel('langgenius/zhipuai/zhipuai'), createModelItem('glm-4.7'))).toBe(true)
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,83 @@
|
||||
import { createStore } from 'jotai'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
agentConfigureComposerRebaseRevisionAtom,
|
||||
agentConfigureConversationIdsAtom,
|
||||
agentConfigureRightPanelChatModeAtom,
|
||||
agentConfigureRightPanelModeAtom,
|
||||
agentConfigureSelectedVersionIdAtom,
|
||||
agentConfigureSelectVersionAtom,
|
||||
agentConfigureShowPreviewVersionsAtom,
|
||||
agentConfigureSoulSourceOverrideAtom,
|
||||
rebaseAgentConfigureComposerAtom,
|
||||
resetAgentConfigureConversationAtom,
|
||||
setAgentConfigureConversationIdAtom,
|
||||
} from '../state'
|
||||
|
||||
describe('agent configure state graph', () => {
|
||||
it('selects versions through the shared source override entrypoint', () => {
|
||||
const store = createStore()
|
||||
|
||||
store.set(agentConfigureSelectVersionAtom, 'snapshot-1')
|
||||
|
||||
expect(store.get(agentConfigureSelectedVersionIdAtom)).toBe('snapshot-1')
|
||||
expect(store.get(agentConfigureSoulSourceOverrideAtom)).toBe('view-version')
|
||||
|
||||
store.set(agentConfigureSelectVersionAtom, null)
|
||||
|
||||
expect(store.get(agentConfigureSelectedVersionIdAtom)).toBeNull()
|
||||
expect(store.get(agentConfigureSoulSourceOverrideAtom)).toBeNull()
|
||||
})
|
||||
|
||||
it('derives the actual chat mode from the visible right panel mode', () => {
|
||||
const store = createStore()
|
||||
|
||||
expect(store.get(agentConfigureRightPanelChatModeAtom)).toBe('build')
|
||||
|
||||
store.set(agentConfigureRightPanelModeAtom, 'preview')
|
||||
|
||||
expect(store.get(agentConfigureRightPanelChatModeAtom)).toBe('build')
|
||||
})
|
||||
|
||||
it('updates and resets conversation state through named actions', () => {
|
||||
const store = createStore()
|
||||
|
||||
store.set(setAgentConfigureConversationIdAtom, {
|
||||
mode: 'build',
|
||||
conversationId: 'build-conversation-1',
|
||||
})
|
||||
store.set(setAgentConfigureConversationIdAtom, {
|
||||
mode: 'preview',
|
||||
conversationId: 'preview-conversation-1',
|
||||
})
|
||||
|
||||
expect(store.get(agentConfigureConversationIdsAtom)).toEqual({
|
||||
build: 'build-conversation-1',
|
||||
preview: 'preview-conversation-1',
|
||||
})
|
||||
|
||||
store.set(resetAgentConfigureConversationAtom, 'build')
|
||||
|
||||
expect(store.get(agentConfigureConversationIdsAtom)).toEqual({
|
||||
build: null,
|
||||
preview: 'preview-conversation-1',
|
||||
})
|
||||
})
|
||||
|
||||
it('tracks composer rebase as a workflow command', () => {
|
||||
const store = createStore()
|
||||
|
||||
store.set(rebaseAgentConfigureComposerAtom)
|
||||
store.set(rebaseAgentConfigureComposerAtom)
|
||||
|
||||
expect(store.get(agentConfigureComposerRebaseRevisionAtom)).toBe(2)
|
||||
})
|
||||
|
||||
it('keeps independent panel state as separate primitives', () => {
|
||||
const store = createStore()
|
||||
|
||||
store.set(agentConfigureShowPreviewVersionsAtom, true)
|
||||
|
||||
expect(store.get(agentConfigureShowPreviewVersionsAtom)).toBe(true)
|
||||
})
|
||||
})
|
||||
+284
-31
@@ -6,9 +6,11 @@ import { defaultAgentSoulConfigFormState } from '@/features/agent-v2/agent-compo
|
||||
import { agentComposerDraftAtom, agentComposerPublishedDraftAtom } from '@/features/agent-v2/agent-composer/store'
|
||||
import { agentComposerFilesAtom } from '@/features/agent-v2/agent-composer/store-modules/files'
|
||||
import { agentComposerPromptAtom } from '@/features/agent-v2/agent-composer/store-modules/prompt'
|
||||
import { agentComposerSkillsAtom } from '@/features/agent-v2/agent-composer/store-modules/skills'
|
||||
import { useAgentConfigureSync } from '../use-agent-configure-sync'
|
||||
|
||||
const toastMock = vi.hoisted(() => ({
|
||||
error: vi.fn(),
|
||||
success: vi.fn(),
|
||||
}))
|
||||
|
||||
@@ -81,6 +83,13 @@ function createDeferredPromise<T>() {
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
function setDocumentVisibilityState(visibilityState: DocumentVisibilityState) {
|
||||
Object.defineProperty(document, 'visibilityState', {
|
||||
configurable: true,
|
||||
value: visibilityState,
|
||||
})
|
||||
}
|
||||
|
||||
vi.mock('@langgenius/dify-ui/toast', () => ({
|
||||
toast: toastMock,
|
||||
}))
|
||||
@@ -88,6 +97,9 @@ vi.mock('@langgenius/dify-ui/toast', () => ({
|
||||
vi.mock('@/service/client', () => ({
|
||||
consoleQuery: {
|
||||
agent: {
|
||||
get: {
|
||||
key: () => ['agents'],
|
||||
},
|
||||
byAgentId: {
|
||||
get: {
|
||||
queryKey: ({ input }: { input: { params: { agent_id: string } } }) => [
|
||||
@@ -162,12 +174,14 @@ describe('useAgentConfigureSync', () => {
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
setDocumentVisibilityState('visible')
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('should automatically save configure page changes to draft', async () => {
|
||||
vi.setSystemTime(1710000100000)
|
||||
const { queryClient, result, store } = renderUseAgentConfigureSync()
|
||||
const invalidateQueries = vi.spyOn(queryClient, 'invalidateQueries')
|
||||
queryClient.setQueryData(['agent-detail', 'agent-1'], {
|
||||
active_config_is_published: true,
|
||||
name: 'Agent',
|
||||
@@ -183,7 +197,7 @@ describe('useAgentConfigureSync', () => {
|
||||
})
|
||||
|
||||
expect(queryClient.getQueryData(['agent-detail', 'agent-1'])).toEqual({
|
||||
active_config_is_published: false,
|
||||
active_config_is_published: true,
|
||||
name: 'Agent',
|
||||
})
|
||||
expect(composerPutMutationFn).not.toHaveBeenCalled()
|
||||
@@ -206,14 +220,95 @@ describe('useAgentConfigureSync', () => {
|
||||
}),
|
||||
}),
|
||||
}))
|
||||
expect(queryClient.getQueryData(['agent-composer', 'agent-1'])).toBeUndefined()
|
||||
expect(queryClient.getQueryData(['agent-composer', 'agent-1'])).toEqual({
|
||||
agent_soul: expect.objectContaining({
|
||||
prompt: expect.objectContaining({
|
||||
system_prompt: 'Draft only prompt',
|
||||
}),
|
||||
}),
|
||||
})
|
||||
expect(queryClient.getQueryData(['agent-detail', 'agent-1'])).toEqual({
|
||||
active_config_is_published: false,
|
||||
active_config_is_published: true,
|
||||
name: 'Agent',
|
||||
})
|
||||
expect(invalidateQueries).toHaveBeenCalledWith({
|
||||
queryKey: ['agent-detail', 'agent-1'],
|
||||
})
|
||||
expect(result.current.draftSavedAt).toBe(1710000105000)
|
||||
})
|
||||
|
||||
it('should cancel pending autosave when the draft returns to the saved baseline', async () => {
|
||||
const { queryClient, result, store } = renderUseAgentConfigureSync()
|
||||
queryClient.setQueryData(['agent-detail', 'agent-1'], {
|
||||
active_config_is_published: true,
|
||||
name: 'Agent',
|
||||
})
|
||||
|
||||
act(() => {
|
||||
store.set(agentComposerDraftAtom, {
|
||||
...defaultAgentSoulConfigFormState,
|
||||
prompt: 'Temporary prompt',
|
||||
})
|
||||
})
|
||||
act(() => {
|
||||
store.set(agentComposerDraftAtom, defaultAgentSoulConfigFormState)
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(5000)
|
||||
})
|
||||
|
||||
expect(composerPutMutationFn).not.toHaveBeenCalled()
|
||||
expect(queryClient.getQueryData(['agent-detail', 'agent-1'])).toEqual({
|
||||
active_config_is_published: true,
|
||||
name: 'Agent',
|
||||
})
|
||||
expect(result.current.draftSavedAt).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should save dirty draft once when the page is closing', async () => {
|
||||
const saveDeferred = createDeferredPromise<{ agent_soul: Record<string, unknown> }>()
|
||||
composerPutMutationFn.mockReturnValueOnce(saveDeferred.promise)
|
||||
const { store } = renderUseAgentConfigureSync()
|
||||
|
||||
act(() => {
|
||||
store.set(agentComposerDraftAtom, {
|
||||
...defaultAgentSoulConfigFormState,
|
||||
prompt: 'Closing prompt',
|
||||
})
|
||||
})
|
||||
|
||||
expect(composerPutMutationFn).not.toHaveBeenCalled()
|
||||
|
||||
await act(async () => {
|
||||
setDocumentVisibilityState('hidden')
|
||||
document.dispatchEvent(new Event('visibilitychange'))
|
||||
window.dispatchEvent(new Event('beforeunload'))
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
expect(composerPutMutationFn).toHaveBeenCalledTimes(1)
|
||||
expect(composerPutMutationFn).toHaveBeenCalledWith(expect.objectContaining({
|
||||
params: {
|
||||
agent_id: 'agent-1',
|
||||
},
|
||||
body: expect.objectContaining({
|
||||
variant: 'agent_app',
|
||||
save_strategy: 'save_to_current_version',
|
||||
agent_soul: expect.objectContaining({
|
||||
prompt: expect.objectContaining({
|
||||
system_prompt: 'Closing prompt',
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}))
|
||||
|
||||
await act(async () => {
|
||||
saveDeferred.resolve({ agent_soul: {} })
|
||||
await Promise.resolve()
|
||||
})
|
||||
})
|
||||
|
||||
it('should include Agent Soul files when autosaving file changes', async () => {
|
||||
const { store } = renderUseAgentConfigureSync()
|
||||
|
||||
@@ -222,11 +317,11 @@ describe('useAgentConfigureSync', () => {
|
||||
...defaultAgentSoulConfigFormState,
|
||||
files: [
|
||||
{
|
||||
id: 'files/uploaded.md',
|
||||
id: 'uploaded.md',
|
||||
name: 'uploaded.md',
|
||||
icon: 'markdown',
|
||||
fileId: 'drive-file-1',
|
||||
driveKey: 'files/uploaded.md',
|
||||
configName: 'uploaded.md',
|
||||
},
|
||||
],
|
||||
})
|
||||
@@ -239,17 +334,59 @@ describe('useAgentConfigureSync', () => {
|
||||
expect(composerPutMutationFn).toHaveBeenCalledWith(expect.objectContaining({
|
||||
body: expect.objectContaining({
|
||||
agent_soul: expect.objectContaining({
|
||||
files: {
|
||||
skills: [],
|
||||
files: [
|
||||
{
|
||||
id: 'files/uploaded.md',
|
||||
file_id: 'drive-file-1',
|
||||
name: 'uploaded.md',
|
||||
drive_key: 'files/uploaded.md',
|
||||
},
|
||||
],
|
||||
},
|
||||
config_files: [
|
||||
{
|
||||
file_id: 'drive-file-1',
|
||||
file_kind: 'upload_file',
|
||||
name: 'uploaded.md',
|
||||
},
|
||||
],
|
||||
config_skills: [],
|
||||
}),
|
||||
}),
|
||||
}))
|
||||
})
|
||||
|
||||
it('should preserve uploaded skills when prompt is updated immediately after upload', async () => {
|
||||
const { store } = renderUseAgentConfigureSync()
|
||||
|
||||
act(() => {
|
||||
store.set(agentComposerSkillsAtom, [
|
||||
{
|
||||
id: 'Tender Analyzer',
|
||||
name: 'Tender Analyzer',
|
||||
description: 'Extracts tender requirements.',
|
||||
fileId: 'tool-file-1',
|
||||
hash: 'sha256:skill-1',
|
||||
mimeType: 'application/zip',
|
||||
size: 42,
|
||||
},
|
||||
])
|
||||
store.set(agentComposerPromptAtom, 'Use [§skill:Tender Analyzer:Tender Analyzer§]')
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(5000)
|
||||
})
|
||||
|
||||
expect(composerPutMutationFn).toHaveBeenCalledWith(expect.objectContaining({
|
||||
body: expect.objectContaining({
|
||||
agent_soul: expect.objectContaining({
|
||||
prompt: expect.objectContaining({
|
||||
system_prompt: 'Use [§skill:Tender Analyzer:Tender Analyzer§]',
|
||||
}),
|
||||
config_skills: [
|
||||
{
|
||||
description: 'Extracts tender requirements.',
|
||||
file_id: 'tool-file-1',
|
||||
file_kind: 'tool_file',
|
||||
hash: 'sha256:skill-1',
|
||||
mime_type: 'application/zip',
|
||||
name: 'Tender Analyzer',
|
||||
size: 42,
|
||||
},
|
||||
],
|
||||
config_files: [],
|
||||
}),
|
||||
}),
|
||||
}))
|
||||
@@ -261,14 +398,17 @@ describe('useAgentConfigureSync', () => {
|
||||
act(() => {
|
||||
store.set(agentComposerFilesAtom, [
|
||||
{
|
||||
id: 'files/uploaded.md',
|
||||
id: 'uploaded.md',
|
||||
name: 'uploaded.md',
|
||||
icon: 'markdown',
|
||||
fileId: 'drive-file-1',
|
||||
driveKey: 'files/uploaded.md',
|
||||
configName: 'uploaded.md',
|
||||
hash: 'sha256:file-1',
|
||||
mimeType: 'text/markdown',
|
||||
size: 5,
|
||||
},
|
||||
])
|
||||
store.set(agentComposerPromptAtom, 'Use [§file:files%2Fuploaded.md:uploaded.md§]')
|
||||
store.set(agentComposerPromptAtom, 'Use [§file:uploaded.md:uploaded.md§]')
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
@@ -279,19 +419,19 @@ describe('useAgentConfigureSync', () => {
|
||||
body: expect.objectContaining({
|
||||
agent_soul: expect.objectContaining({
|
||||
prompt: expect.objectContaining({
|
||||
system_prompt: 'Use [§file:files%2Fuploaded.md:uploaded.md§]',
|
||||
system_prompt: 'Use [§file:uploaded.md:uploaded.md§]',
|
||||
}),
|
||||
files: {
|
||||
skills: [],
|
||||
files: [
|
||||
{
|
||||
id: 'files/uploaded.md',
|
||||
file_id: 'drive-file-1',
|
||||
name: 'uploaded.md',
|
||||
drive_key: 'files/uploaded.md',
|
||||
},
|
||||
],
|
||||
},
|
||||
config_files: [
|
||||
{
|
||||
file_id: 'drive-file-1',
|
||||
file_kind: 'upload_file',
|
||||
hash: 'sha256:file-1',
|
||||
mime_type: 'text/markdown',
|
||||
name: 'uploaded.md',
|
||||
size: 5,
|
||||
},
|
||||
],
|
||||
config_skills: [],
|
||||
}),
|
||||
}),
|
||||
}))
|
||||
@@ -321,6 +461,27 @@ describe('useAgentConfigureSync', () => {
|
||||
expect(result.current.draftSavedAt).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should keep autosave failures silent and leave the local draft dirty', async () => {
|
||||
composerPutMutationFn.mockRejectedValueOnce(new Error('save failed'))
|
||||
const { result, store } = renderUseAgentConfigureSync()
|
||||
|
||||
act(() => {
|
||||
store.set(agentComposerDraftAtom, {
|
||||
...defaultAgentSoulConfigFormState,
|
||||
prompt: 'Unsaved autosave prompt',
|
||||
})
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(5000)
|
||||
})
|
||||
|
||||
expect(composerPutMutationFn).toHaveBeenCalledTimes(1)
|
||||
expect(result.current.draftSavedAt).toBeUndefined()
|
||||
expect(store.get(agentComposerDraftAtom).prompt).toBe('Unsaved autosave prompt')
|
||||
expect(toastMock.error).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should save the latest draft immediately when requested', async () => {
|
||||
vi.setSystemTime(1710000200000)
|
||||
const { result, store } = renderUseAgentConfigureSync()
|
||||
@@ -354,6 +515,73 @@ describe('useAgentConfigureSync', () => {
|
||||
expect(result.current.draftSavedAt).toBe(1710000200000)
|
||||
})
|
||||
|
||||
it('should reject explicit save requests when the draft cannot be saved', async () => {
|
||||
composerPutMutationFn.mockRejectedValueOnce(new Error('save failed'))
|
||||
const { result, store } = renderUseAgentConfigureSync()
|
||||
|
||||
act(() => {
|
||||
store.set(agentComposerDraftAtom, {
|
||||
...defaultAgentSoulConfigFormState,
|
||||
prompt: 'Run prompt',
|
||||
})
|
||||
})
|
||||
|
||||
await expect(result.current.saveDraft()).rejects.toThrow('Failed to save agent composer draft.')
|
||||
expect(result.current.draftSavedAt).toBeUndefined()
|
||||
expect(store.get(agentComposerDraftAtom).prompt).toBe('Run prompt')
|
||||
expect(toastMock.error).toHaveBeenCalledWith('common.api.actionFailed')
|
||||
})
|
||||
|
||||
it('should not save the draft immediately when the composer draft is unchanged', async () => {
|
||||
const { queryClient, result } = renderUseAgentConfigureSync()
|
||||
queryClient.setQueryData(['agent-detail', 'agent-1'], {
|
||||
active_config_is_published: true,
|
||||
name: 'Agent',
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.saveDraft()
|
||||
})
|
||||
|
||||
expect(composerPutMutationFn).not.toHaveBeenCalled()
|
||||
expect(queryClient.getQueryData(['agent-detail', 'agent-1'])).toEqual({
|
||||
active_config_is_published: true,
|
||||
name: 'Agent',
|
||||
})
|
||||
expect(result.current.draftSavedAt).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should save the effective model before run when the form draft is unchanged', async () => {
|
||||
const { result } = renderUseAgentConfigureSync({
|
||||
baseConfig: {
|
||||
schema_version: 1,
|
||||
prompt: {
|
||||
system_prompt: '',
|
||||
},
|
||||
},
|
||||
currentModel: {
|
||||
provider: 'langgenius/openai/openai',
|
||||
model: 'gpt-4o-mini',
|
||||
},
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.saveDraft()
|
||||
})
|
||||
|
||||
expect(composerPutMutationFn).toHaveBeenCalledWith(expect.objectContaining({
|
||||
body: expect.objectContaining({
|
||||
agent_soul: expect.objectContaining({
|
||||
model: expect.objectContaining({
|
||||
model_provider: 'langgenius/openai/openai',
|
||||
model: 'gpt-4o-mini',
|
||||
plugin_id: 'langgenius/openai',
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}))
|
||||
})
|
||||
|
||||
it('should reject manual save when knowledge retrieval validation fails', async () => {
|
||||
const { result, store } = renderUseAgentConfigureSync()
|
||||
|
||||
@@ -502,6 +730,31 @@ describe('useAgentConfigureSync', () => {
|
||||
expect(publishAgentMutationFn).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should reject publish and keep the publish mutation untouched when saving the draft fails', async () => {
|
||||
composerPutMutationFn.mockRejectedValueOnce(new Error('save failed'))
|
||||
const { queryClient, result, store } = renderUseAgentConfigureSync()
|
||||
queryClient.setQueryData(['agent-detail', 'agent-1'], {
|
||||
active_config_is_published: false,
|
||||
name: 'Agent',
|
||||
})
|
||||
|
||||
act(() => {
|
||||
store.set(agentComposerDraftAtom, {
|
||||
...defaultAgentSoulConfigFormState,
|
||||
prompt: 'Unpublished prompt',
|
||||
})
|
||||
})
|
||||
|
||||
await expect(result.current.publishDraft()).rejects.toThrow('Failed to save agent composer draft.')
|
||||
|
||||
expect(publishAgentMutationFn).not.toHaveBeenCalled()
|
||||
expect(queryClient.getQueryData(['agent-detail', 'agent-1'])).toEqual({
|
||||
active_config_is_published: false,
|
||||
name: 'Agent',
|
||||
})
|
||||
expect(toastMock.error).toHaveBeenCalledWith('common.api.actionFailed')
|
||||
})
|
||||
|
||||
it('should reject publish when knowledge retrieval validation fails', async () => {
|
||||
const { result, store } = renderUseAgentConfigureSync()
|
||||
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type { ContractRouterClient } from '@orpc/contract'
|
||||
import type { JsonifiedClient } from '@orpc/openapi-client'
|
||||
import type { ConsoleRouterContract } from '@/service/console-link'
|
||||
import { createORPCClient } from '@orpc/client'
|
||||
import { OpenAPILink } from '@orpc/openapi-client/fetch'
|
||||
import { createTanstackQueryUtils } from '@orpc/tanstack-query'
|
||||
import { API_PREFIX } from '@/config'
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { request } from '@/service/base'
|
||||
import { getBaseURL } from '@/service/client'
|
||||
import { createConsoleDynamicLink } from '@/service/console-link'
|
||||
|
||||
type AgentConfigureConsoleClientContext = {
|
||||
silent?: boolean
|
||||
}
|
||||
|
||||
const agentConfigureConsoleLink = createConsoleDynamicLink<AgentConfigureConsoleClientContext>(contract => new OpenAPILink<AgentConfigureConsoleClientContext>(contract, {
|
||||
url: getBaseURL(API_PREFIX),
|
||||
fetch: (input, init, options) => {
|
||||
return request(
|
||||
input.url,
|
||||
init,
|
||||
{
|
||||
fetchCompat: true,
|
||||
request: input,
|
||||
silent: options.context.silent,
|
||||
},
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
const agentConfigureConsoleClient: JsonifiedClient<ContractRouterClient<ConsoleRouterContract, AgentConfigureConsoleClientContext>>
|
||||
= createORPCClient(agentConfigureConsoleLink)
|
||||
|
||||
export const agentConfigureConsoleQuery = createTanstackQueryUtils(agentConfigureConsoleClient, {
|
||||
path: ['console'],
|
||||
})
|
||||
+28
-15
@@ -15,6 +15,7 @@ import { AgentPromptSlashMenu } from '../orchestrate/prompt-editor/slash'
|
||||
const mockPromptEditor = vi.hoisted(() => vi.fn())
|
||||
const mockCopy = vi.hoisted(() => vi.fn())
|
||||
const mockReset = vi.hoisted(() => vi.fn())
|
||||
const mockUseClipboard = vi.hoisted(() => vi.fn())
|
||||
const mockBuiltInTools = vi.hoisted(() => [
|
||||
{
|
||||
id: 'duckduckgo',
|
||||
@@ -71,11 +72,7 @@ vi.mock('@/app/components/base/infotip', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('foxact/use-clipboard', () => ({
|
||||
useClipboard: () => ({
|
||||
copied: false,
|
||||
copy: mockCopy,
|
||||
reset: mockReset,
|
||||
}),
|
||||
useClipboard: mockUseClipboard,
|
||||
}))
|
||||
|
||||
vi.mock('@/context/i18n', () => ({
|
||||
@@ -100,17 +97,16 @@ vi.mock('@/hooks/use-theme', () => ({
|
||||
default: () => ({ theme: 'light' }),
|
||||
}))
|
||||
|
||||
vi.mock('../orchestrate/drive-context', () => ({
|
||||
useAgentDriveSkills: () => ({
|
||||
vi.mock('../orchestrate/config-context', () => ({
|
||||
useAgentConfigSkills: () => ({
|
||||
skills: [
|
||||
{
|
||||
id: 'playwright/SKILL.md',
|
||||
id: 'playwright',
|
||||
name: 'Playwright',
|
||||
skillMdKey: 'playwright/SKILL.md',
|
||||
},
|
||||
],
|
||||
}),
|
||||
useAgentDriveFiles: () => ({ files: [] }),
|
||||
useAgentConfigFiles: () => ({ files: [] }),
|
||||
}))
|
||||
|
||||
const duckDuckGoSearchAction = {
|
||||
@@ -171,6 +167,11 @@ const renderAgentPromptEditor = (
|
||||
describe('AgentPromptEditor', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUseClipboard.mockReturnValue({
|
||||
copied: false,
|
||||
copy: mockCopy,
|
||||
reset: mockReset,
|
||||
})
|
||||
})
|
||||
|
||||
// Prompt actions should expose the designed copy control and copy the current draft prompt.
|
||||
@@ -183,6 +184,18 @@ describe('AgentPromptEditor', () => {
|
||||
expect(mockCopy).toHaveBeenCalledWith('Review these tenders')
|
||||
})
|
||||
|
||||
it('should let clipboard timeout restore the copied state instead of resetting on mouse leave', () => {
|
||||
renderAgentPromptEditor('Review these tenders')
|
||||
|
||||
expect(mockUseClipboard).toHaveBeenCalledWith(expect.objectContaining({
|
||||
timeout: 2000,
|
||||
}))
|
||||
|
||||
fireEvent.mouseLeave(screen.getByRole('button', { name: /agentDetail\.configure\.prompt\.copy/i }))
|
||||
|
||||
expect(mockReset).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should update knowledge reference labels when the retrieval title changes', () => {
|
||||
const store = createStore()
|
||||
store.set(agentComposerDraftAtom, {
|
||||
@@ -293,7 +306,7 @@ describe('AgentPromptEditor', () => {
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Playwright/i }))
|
||||
|
||||
expect(store.get(agentComposerPromptAtom)).toBe('Review these tenders [§skill:playwright%2FSKILL.md:Playwright§]')
|
||||
expect(store.get(agentComposerPromptAtom)).toBe('Review these tenders [§skill:playwright:Playwright§]')
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('button', { name: /Playwright/i })).not.toBeInTheDocument()
|
||||
})
|
||||
@@ -330,7 +343,7 @@ describe('AgentPromptEditor', () => {
|
||||
files={[]}
|
||||
tools={[]}
|
||||
onToolsChange={vi.fn()}
|
||||
onAddSkill={options => options?.onAdded?.({ id: 'skill-1', name: 'Skill One', skillMdKey: 'skills/skill-1/SKILL.md' })}
|
||||
onAddSkill={options => options?.onAdded?.({ id: 'skill-1', name: 'Skill One' })}
|
||||
retrievals={[]}
|
||||
onBack={vi.fn()}
|
||||
onOpenCategory={vi.fn()}
|
||||
@@ -338,7 +351,7 @@ describe('AgentPromptEditor', () => {
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(screen.getByRole('button', { name: /agentDetail\.configure\.skills\.add/i }))
|
||||
expect(onSelect).toHaveBeenCalledWith('[§skill:skills%2Fskill-1%2FSKILL.md:Skill One§]')
|
||||
expect(onSelect).toHaveBeenCalledWith('[§skill:skill-1:Skill One§]')
|
||||
|
||||
rerender(
|
||||
<AgentPromptSlashMenu
|
||||
@@ -348,7 +361,7 @@ describe('AgentPromptEditor', () => {
|
||||
files={[]}
|
||||
tools={[]}
|
||||
onToolsChange={vi.fn()}
|
||||
onAddFile={options => options?.onAdded?.({ id: 'file-1', name: 'Guide.md', icon: 'markdown', driveKey: 'files/Guide.md' })}
|
||||
onAddFile={options => options?.onAdded?.({ id: 'file-1', name: 'Guide.md', icon: 'markdown', configName: 'Guide.md' })}
|
||||
retrievals={[]}
|
||||
onBack={vi.fn()}
|
||||
onOpenCategory={vi.fn()}
|
||||
@@ -356,7 +369,7 @@ describe('AgentPromptEditor', () => {
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(screen.getByRole('button', { name: /agentDetail\.configure\.files\.add/i }))
|
||||
expect(onSelect).toHaveBeenCalledWith('[§file:files%2FGuide.md:Guide.md§]')
|
||||
expect(onSelect).toHaveBeenCalledWith('[§file:Guide.md:Guide.md§]')
|
||||
|
||||
rerender(
|
||||
<AgentPromptSlashMenu
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { ComponentPropsWithoutRef } from 'react'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
|
||||
const buildGridColumnCount = 384
|
||||
const buildGridRowCount = 32
|
||||
|
||||
function getBuildGridCellOpacity(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 / (buildGridRowCount - 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 buildGridCells = Array.from(
|
||||
{ length: buildGridColumnCount * buildGridRowCount },
|
||||
(_, index) => {
|
||||
const row = Math.floor(index / buildGridColumnCount)
|
||||
const column = index % buildGridColumnCount
|
||||
const opacity = getBuildGridCellOpacity(row, column)
|
||||
|
||||
return {
|
||||
id: `build-grid-cell-${row}-${column}`,
|
||||
column: column + 1,
|
||||
opacity,
|
||||
row: row + 1,
|
||||
}
|
||||
},
|
||||
).filter(cell => cell.opacity > 0)
|
||||
|
||||
export function AgentBuildGridTexture({
|
||||
cellOpacityMultiplier = 1,
|
||||
className,
|
||||
dotClassName,
|
||||
...props
|
||||
}: ComponentPropsWithoutRef<'div'> & {
|
||||
cellOpacityMultiplier?: number
|
||||
dotClassName?: string
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn('grid grid-cols-[repeat(384,4px)] grid-rows-[repeat(32,4px)] gap-0.5 opacity-70', className)}
|
||||
{...props}
|
||||
>
|
||||
{buildGridCells.map(cell => (
|
||||
<span
|
||||
key={cell.id}
|
||||
className={cn('rounded-[1px] bg-[#98A2B2]', dotClassName)}
|
||||
style={{ gridColumn: `${cell.column}`, gridRow: `${cell.row}`, opacity: Math.min(1, cell.opacity * cellOpacityMultiplier) }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,16 +1,27 @@
|
||||
'use client'
|
||||
|
||||
import type { AgentAppDetailWithSite, AgentIconType } from '@dify/contracts/api/console/agent/types.gen'
|
||||
import type { Dispatch, SetStateAction } from 'react'
|
||||
import type { AgentAppDetailWithSite, AgentIconType, AgentSoulConfig } from '@dify/contracts/api/console/agent/types.gen'
|
||||
import type { useAgentConfigureData } from '../hooks'
|
||||
import type { AgentConfigureConversationIds, AgentConfigureRightPanelMode } from './preview/right-panel-chat'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useAtomValue, useSetAtom } from 'jotai'
|
||||
import { ScopeProvider } from 'jotai-scope'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { agentSoulConfigToFormState } from '@/features/agent-v2/agent-composer/conversions'
|
||||
import { AgentComposerProvider } from '@/features/agent-v2/agent-composer/provider'
|
||||
import { rebaseAgentComposerDraftAtom } from '@/features/agent-v2/agent-composer/store'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { useAgentConfigureModelOptions } from '../hooks'
|
||||
import {
|
||||
agentConfigureConversationIdsAtom,
|
||||
agentConfigureRightPanelChatModeAtom,
|
||||
agentConfigureRightPanelModeAtom,
|
||||
agentConfigureShowChatFeaturesAtom,
|
||||
agentConfigureShowPreviewVersionsAtom,
|
||||
agentConfigureSoulSourceOverrideAtom,
|
||||
resetAgentConfigureConversationAtom,
|
||||
setAgentConfigureConversationIdAtom,
|
||||
} from '../state'
|
||||
import { useAgentConfigureBuildDraftActions, useAgentConfigureBuildDraftData } from '../use-agent-configure-build-draft'
|
||||
import { useAgentConfigureSync } from '../use-agent-configure-sync'
|
||||
import { AgentOrchestratePanel } from './orchestrate'
|
||||
@@ -19,20 +30,11 @@ import { AgentConfigurePageLoading } from './page-loading'
|
||||
import { AgentBuildPanelBackground } from './preview/build-background'
|
||||
import { AgentChatFeaturesPanel } from './preview/chat-features-panel'
|
||||
import { AgentPreviewHeader } from './preview/header'
|
||||
import { invalidateAgentWorkingDirectoryFiles, useAgentWorkingDirectoryPanel } from './preview/hook/use-working-directory-panel'
|
||||
import { AgentConfigureRightPanelChat } from './preview/right-panel-chat'
|
||||
import { useAgentWorkingDirectoryPanel } from './preview/use-working-directory-panel'
|
||||
import { AgentPreviewVersionsPanel } from './preview/versions-panel'
|
||||
import { AgentConfigurePreviewSurface, AgentConfigureWorkspace } from './workspace'
|
||||
|
||||
type DebugConversationRefreshInput = {
|
||||
params: {
|
||||
agent_id: string
|
||||
}
|
||||
body: {
|
||||
debug_conversation_id: string
|
||||
}
|
||||
}
|
||||
|
||||
export function AgentConfigureComposerScope({
|
||||
agentId,
|
||||
composerRebaseRevision,
|
||||
@@ -53,6 +55,8 @@ export function AgentConfigureComposerScope({
|
||||
activeVersionId,
|
||||
agentSoulConfig,
|
||||
} = configureData
|
||||
const soulSourceOverride = useAtomValue(agentConfigureSoulSourceOverrideAtom)
|
||||
const setSoulSourceOverride = useSetAtom(agentConfigureSoulSourceOverrideAtom)
|
||||
const isViewingVersion = !!selectedVersionId
|
||||
const buildDraft = useAgentConfigureBuildDraftData({
|
||||
agentId,
|
||||
@@ -60,6 +64,8 @@ export function AgentConfigureComposerScope({
|
||||
composerAgentSoulConfig: composerQuery.data?.agent_soul,
|
||||
isViewingVersion,
|
||||
normalAgentSoulConfig: agentSoulConfig,
|
||||
setSoulSourceOverride,
|
||||
soulSourceOverride,
|
||||
})
|
||||
|
||||
if (buildDraft.isPending) {
|
||||
@@ -68,9 +74,7 @@ export function AgentConfigureComposerScope({
|
||||
)
|
||||
}
|
||||
|
||||
const composerSessionKey = buildDraft.isActive
|
||||
? `${agentId}:${buildDraft.activeVersionId ?? 'build-draft'}`
|
||||
: `${agentId}:${buildDraft.activeVersionId ?? 'draft'}:${composerRebaseRevision}`
|
||||
const composerSessionKey = `${agentId}:${activeVersionId ?? selectedVersionId ?? 'draft'}:${composerRebaseRevision}`
|
||||
|
||||
return (
|
||||
<AgentConfigurePageComposerSession
|
||||
@@ -106,19 +110,13 @@ function AgentConfigurePageComposerSession({
|
||||
agentQuery,
|
||||
} = configureData
|
||||
const queryClient = useQueryClient()
|
||||
const [showChatFeatures, setShowChatFeatures] = useState(false)
|
||||
const [showPreviewVersions, setShowPreviewVersions] = useState(false)
|
||||
const workingDirectoryPanel = useAgentWorkingDirectoryPanel()
|
||||
const [clearPreviewChat, setClearPreviewChat] = useState(false)
|
||||
const [rightPanelMode, setRightPanelMode] = useState<AgentConfigureRightPanelMode>('build')
|
||||
const [hideBuildDraftBarUntilRefresh, setHideBuildDraftBarUntilRefresh] = useState(false)
|
||||
const [conversationIds, setConversationIds] = useState<AgentConfigureConversationIds>({
|
||||
build: agentQuery.data?.debug_conversation_id ?? null,
|
||||
preview: null,
|
||||
})
|
||||
const agentIconType = agentQuery.data?.icon_type as AgentIconType | null | undefined
|
||||
const refreshDebugConversationMutation = useMutation(consoleQuery.agent.byAgentId.debugConversation.refresh.post.mutationOptions({
|
||||
onSuccess: ({ debug_conversation_id }) => {
|
||||
onSuccess: ({
|
||||
debug_conversation_id,
|
||||
debug_conversation_has_messages,
|
||||
debug_conversation_message_count,
|
||||
}) => {
|
||||
queryClient.setQueryData<AgentAppDetailWithSite | undefined>(
|
||||
consoleQuery.agent.byAgentId.get.queryKey({ input: { params: { agent_id: agentId } } }),
|
||||
(agentDetail) => {
|
||||
@@ -128,6 +126,8 @@ function AgentConfigurePageComposerSession({
|
||||
return {
|
||||
...agentDetail,
|
||||
debug_conversation_id,
|
||||
debug_conversation_has_messages: debug_conversation_has_messages ?? false,
|
||||
debug_conversation_message_count: debug_conversation_message_count ?? 0,
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -138,73 +138,47 @@ function AgentConfigurePageComposerSession({
|
||||
mutateAsync: refreshDebugConversationRequestAsync,
|
||||
isPending: isRefreshingDebugConversation,
|
||||
} = refreshDebugConversationMutation
|
||||
const refreshDebugConversationInput = useCallback((conversationId: string): DebugConversationRefreshInput => ({
|
||||
const refreshDebugConversationInput = useCallback(() => ({
|
||||
params: {
|
||||
agent_id: agentId,
|
||||
},
|
||||
body: {
|
||||
debug_conversation_id: conversationId,
|
||||
},
|
||||
}), [agentId])
|
||||
const refreshDebugConversation = useCallback((conversationId: string) => {
|
||||
const input = refreshDebugConversationInput(conversationId)
|
||||
|
||||
refreshDebugConversationRequest(
|
||||
input as unknown as Parameters<typeof refreshDebugConversationRequest>[0],
|
||||
)
|
||||
const refreshDebugConversation = useCallback(() => {
|
||||
refreshDebugConversationRequest(refreshDebugConversationInput())
|
||||
}, [refreshDebugConversationInput, refreshDebugConversationRequest])
|
||||
const refreshDebugConversationAsync = useCallback((conversationId: string) => {
|
||||
const input = refreshDebugConversationInput(conversationId)
|
||||
|
||||
return refreshDebugConversationRequestAsync(
|
||||
input as unknown as Parameters<typeof refreshDebugConversationRequestAsync>[0],
|
||||
)
|
||||
const refreshDebugConversationAsync = useCallback(() => {
|
||||
return refreshDebugConversationRequestAsync(refreshDebugConversationInput())
|
||||
}, [refreshDebugConversationInput, refreshDebugConversationRequestAsync])
|
||||
const resetBuildChatSession = useCallback(async () => {
|
||||
try {
|
||||
await refreshDebugConversationAsync(conversationIds.build ?? '')
|
||||
}
|
||||
finally {
|
||||
setConversationIds(current => ({
|
||||
...current,
|
||||
build: null,
|
||||
}))
|
||||
setClearPreviewChat(true)
|
||||
}
|
||||
}, [conversationIds.build, refreshDebugConversationAsync])
|
||||
|
||||
return (
|
||||
<AgentComposerProvider
|
||||
key={composerSessionKey}
|
||||
initialDraft={agentSoulConfigToFormState(buildDraft.agentSoulConfig)}
|
||||
initialOriginalConfig={buildDraft.agentSoulConfig}
|
||||
<ScopeProvider
|
||||
atoms={[
|
||||
[agentConfigureConversationIdsAtom, {
|
||||
build: agentQuery.data?.debug_conversation_id ?? null,
|
||||
preview: null,
|
||||
}],
|
||||
]}
|
||||
name="AgentConfigureConversation"
|
||||
>
|
||||
<AgentConfigurePageComposerContent
|
||||
agentId={agentId}
|
||||
agentIconType={agentIconType}
|
||||
buildDraft={buildDraft}
|
||||
clearPreviewChat={clearPreviewChat}
|
||||
configureData={configureData}
|
||||
conversationIds={conversationIds}
|
||||
hideBuildDraftBarUntilRefresh={hideBuildDraftBarUntilRefresh}
|
||||
isRefreshingDebugConversation={isRefreshingDebugConversation}
|
||||
isViewingVersion={isViewingVersion}
|
||||
resetBuildChatSession={resetBuildChatSession}
|
||||
rightPanelMode={rightPanelMode}
|
||||
setClearPreviewChat={setClearPreviewChat}
|
||||
setConversationIds={setConversationIds}
|
||||
setHideBuildDraftBarUntilRefresh={setHideBuildDraftBarUntilRefresh}
|
||||
setRightPanelMode={setRightPanelMode}
|
||||
setShowChatFeatures={setShowChatFeatures}
|
||||
setShowPreviewVersions={setShowPreviewVersions}
|
||||
showChatFeatures={showChatFeatures}
|
||||
showPreviewVersions={showPreviewVersions}
|
||||
workingDirectoryPanel={workingDirectoryPanel}
|
||||
onComposerRebase={onComposerRebase}
|
||||
onRefreshDebugConversation={refreshDebugConversation}
|
||||
onSelectVersion={onSelectVersion}
|
||||
/>
|
||||
</AgentComposerProvider>
|
||||
<AgentComposerProvider
|
||||
key={composerSessionKey}
|
||||
initialDraft={agentSoulConfigToFormState(buildDraft.agentSoulConfig)}
|
||||
initialOriginalConfig={buildDraft.agentSoulConfig}
|
||||
>
|
||||
<AgentConfigurePageComposerContent
|
||||
agentId={agentId}
|
||||
agentIconType={agentIconType}
|
||||
buildDraft={buildDraft}
|
||||
configureData={configureData}
|
||||
isRefreshingDebugConversation={isRefreshingDebugConversation}
|
||||
isViewingVersion={isViewingVersion}
|
||||
onComposerRebase={onComposerRebase}
|
||||
onRefreshDebugConversation={refreshDebugConversation}
|
||||
onRefreshDebugConversationAsync={refreshDebugConversationAsync}
|
||||
onSelectVersion={onSelectVersion}
|
||||
/>
|
||||
</AgentComposerProvider>
|
||||
</ScopeProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -212,49 +186,23 @@ function AgentConfigurePageComposerContent({
|
||||
agentId,
|
||||
agentIconType,
|
||||
buildDraft,
|
||||
clearPreviewChat,
|
||||
configureData,
|
||||
conversationIds,
|
||||
hideBuildDraftBarUntilRefresh,
|
||||
isRefreshingDebugConversation,
|
||||
isViewingVersion,
|
||||
resetBuildChatSession,
|
||||
rightPanelMode,
|
||||
setClearPreviewChat,
|
||||
setConversationIds,
|
||||
setHideBuildDraftBarUntilRefresh,
|
||||
setRightPanelMode,
|
||||
setShowChatFeatures,
|
||||
setShowPreviewVersions,
|
||||
showChatFeatures,
|
||||
showPreviewVersions,
|
||||
workingDirectoryPanel,
|
||||
onComposerRebase,
|
||||
onRefreshDebugConversation,
|
||||
onRefreshDebugConversationAsync,
|
||||
onSelectVersion,
|
||||
}: {
|
||||
agentId: string
|
||||
agentIconType: AgentIconType | null | undefined
|
||||
buildDraft: ReturnType<typeof useAgentConfigureBuildDraftData>
|
||||
clearPreviewChat: boolean
|
||||
configureData: ReturnType<typeof useAgentConfigureData>
|
||||
conversationIds: AgentConfigureConversationIds
|
||||
hideBuildDraftBarUntilRefresh: boolean
|
||||
isRefreshingDebugConversation: boolean
|
||||
isViewingVersion: boolean
|
||||
resetBuildChatSession: () => Promise<void>
|
||||
rightPanelMode: AgentConfigureRightPanelMode
|
||||
setClearPreviewChat: Dispatch<SetStateAction<boolean>>
|
||||
setConversationIds: Dispatch<SetStateAction<AgentConfigureConversationIds>>
|
||||
setHideBuildDraftBarUntilRefresh: Dispatch<SetStateAction<boolean>>
|
||||
setRightPanelMode: Dispatch<SetStateAction<AgentConfigureRightPanelMode>>
|
||||
setShowChatFeatures: Dispatch<SetStateAction<boolean>>
|
||||
setShowPreviewVersions: Dispatch<SetStateAction<boolean>>
|
||||
showChatFeatures: boolean
|
||||
showPreviewVersions: boolean
|
||||
workingDirectoryPanel: ReturnType<typeof useAgentWorkingDirectoryPanel>
|
||||
onComposerRebase: () => void
|
||||
onRefreshDebugConversation: (conversationId: string) => void
|
||||
onRefreshDebugConversation: () => void
|
||||
onRefreshDebugConversationAsync: () => Promise<unknown>
|
||||
onSelectVersion: (versionId: string | null) => void
|
||||
}) {
|
||||
const {
|
||||
@@ -266,8 +214,39 @@ function AgentConfigurePageComposerContent({
|
||||
activeConfigSnapshot,
|
||||
agentSoulConfig,
|
||||
} = configureData
|
||||
const rightPanelChatMode: AgentConfigureRightPanelMode = rightPanelMode === 'preview' ? 'build' : rightPanelMode
|
||||
const showBuildDraftBar = buildDraft.isActive && !hideBuildDraftBarUntilRefresh
|
||||
const [buildDraftActionsDisabled, setBuildDraftActionsDisabled] = useState(false)
|
||||
const [clearPreviewChat, setClearPreviewChat] = useState(false)
|
||||
const conversationIds = useAtomValue(agentConfigureConversationIdsAtom)
|
||||
const rightPanelChatMode = useAtomValue(agentConfigureRightPanelChatModeAtom)
|
||||
const workingDirectoryPanel = useAgentWorkingDirectoryPanel({
|
||||
agentId,
|
||||
conversationId: conversationIds[rightPanelChatMode],
|
||||
})
|
||||
const showChatFeatures = useAtomValue(agentConfigureShowChatFeaturesAtom)
|
||||
const showPreviewVersions = useAtomValue(agentConfigureShowPreviewVersionsAtom)
|
||||
const resetConversation = useSetAtom(resetAgentConfigureConversationAtom)
|
||||
const setConversationId = useSetAtom(setAgentConfigureConversationIdAtom)
|
||||
const setRightPanelMode = useSetAtom(agentConfigureRightPanelModeAtom)
|
||||
const setShowChatFeatures = useSetAtom(agentConfigureShowChatFeaturesAtom)
|
||||
const setShowPreviewVersions = useSetAtom(agentConfigureShowPreviewVersionsAtom)
|
||||
const rebaseComposerDraft = useSetAtom(rebaseAgentComposerDraftAtom)
|
||||
const queryClient = useQueryClient()
|
||||
const showBuildDraftBar = buildDraft.isActive
|
||||
const resetBuildChatSession = useCallback(async () => {
|
||||
try {
|
||||
await onRefreshDebugConversationAsync()
|
||||
}
|
||||
finally {
|
||||
setConversationId({ mode: 'build', conversationId: null })
|
||||
setClearPreviewChat(true)
|
||||
}
|
||||
}, [onRefreshDebugConversationAsync, setClearPreviewChat, setConversationId])
|
||||
const rebaseComposerDraftFromSoulConfig = useCallback((agentSoulConfig?: AgentSoulConfig) => {
|
||||
rebaseComposerDraft({
|
||||
draft: agentSoulConfigToFormState(agentSoulConfig),
|
||||
originalConfig: agentSoulConfig,
|
||||
})
|
||||
}, [rebaseComposerDraft])
|
||||
const {
|
||||
currentModel,
|
||||
setConfigureModel,
|
||||
@@ -286,7 +265,10 @@ function AgentConfigurePageComposerContent({
|
||||
})
|
||||
const buildDraftActions = useAgentConfigureBuildDraftActions({
|
||||
agentId,
|
||||
buildDraftAgentSoulConfig: buildDraft.agentSoulConfig,
|
||||
isActive: buildDraft.isActive,
|
||||
normalAgentSoulConfig: agentSoulConfig,
|
||||
rebaseComposerDraft: rebaseComposerDraftFromSoulConfig,
|
||||
refetchBuildDraft: buildDraft.refetch,
|
||||
refetchComposer: composerQuery.refetch,
|
||||
resetBuildChatSession,
|
||||
@@ -295,22 +277,30 @@ function AgentConfigurePageComposerContent({
|
||||
setSoulSourceOverride: buildDraft.setSoulSourceOverride,
|
||||
})
|
||||
const selectVersion = useCallback((versionId: string | null) => {
|
||||
buildDraft.setSoulSourceOverride(versionId ? 'view-version' : null)
|
||||
onSelectVersion(versionId)
|
||||
}, [buildDraft, onSelectVersion])
|
||||
}, [onSelectVersion])
|
||||
const hasRestartCurrentChatTarget = rightPanelChatMode === 'build'
|
||||
? (agentQuery.data?.debug_conversation_has_messages ?? false) || buildDraft.isActive
|
||||
: !!conversationIds[rightPanelChatMode]
|
||||
const isRestartCurrentChatDisabled = !hasRestartCurrentChatTarget
|
||||
|| buildDraftActionsDisabled
|
||||
|| isRefreshingDebugConversation
|
||||
|| buildDraftActions.isApplyingBuildDraft
|
||||
|| buildDraftActions.isDiscardingBuildDraft
|
||||
const isChatFeaturesReadOnly = (isViewingVersion && versionQuery.isPending) || buildDraft.isActive
|
||||
const restartCurrentChat = () => {
|
||||
if (isRestartCurrentChatDisabled)
|
||||
return
|
||||
|
||||
if (rightPanelChatMode === 'build' && buildDraft.isActive) {
|
||||
void buildDraftActions.discardBuildDraft()
|
||||
return
|
||||
}
|
||||
|
||||
if (rightPanelChatMode === 'build')
|
||||
onRefreshDebugConversation(conversationIds.build ?? '')
|
||||
onRefreshDebugConversation()
|
||||
|
||||
setConversationIds(current => ({
|
||||
...current,
|
||||
[rightPanelChatMode]: null,
|
||||
}))
|
||||
resetConversation(rightPanelChatMode)
|
||||
setClearPreviewChat(true)
|
||||
}
|
||||
|
||||
@@ -331,11 +321,13 @@ function AgentConfigurePageComposerContent({
|
||||
readOnly={isViewingVersion || buildDraft.isActive}
|
||||
selectedVersionSnapshot={isViewingVersion ? activeConfigSnapshot : undefined}
|
||||
isBuildDraftActive={buildDraft.isActive}
|
||||
buildDraftChangedKeys={buildDraft.changedKeys}
|
||||
showPublishBar={!buildDraft.isActive}
|
||||
bottomAction={showBuildDraftBar
|
||||
? (
|
||||
<AgentBuildDraftBar
|
||||
changesCount={buildDraft.changesCount}
|
||||
disabled={buildDraftActionsDisabled}
|
||||
isApplying={buildDraftActions.isApplyingBuildDraft}
|
||||
isDiscarding={buildDraftActions.isDiscardingBuildDraft}
|
||||
onApply={() => {
|
||||
@@ -371,7 +363,7 @@ function AgentConfigurePageComposerContent({
|
||||
workingDirectoryPanel.openWorkingDirectory()
|
||||
}}
|
||||
onRefresh={restartCurrentChat}
|
||||
refreshDisabled={isRefreshingDebugConversation || buildDraftActions.isDiscardingBuildDraft}
|
||||
refreshDisabled={isRestartCurrentChatDisabled}
|
||||
/>
|
||||
)}
|
||||
chat={(
|
||||
@@ -387,22 +379,35 @@ function AgentConfigurePageComposerContent({
|
||||
draftType={rightPanelChatMode === 'build' ? 'debug_build' : undefined}
|
||||
mode={rightPanelChatMode}
|
||||
onClearChatListChange={setClearPreviewChat}
|
||||
onConversationComplete={(mode) => {
|
||||
if (mode === 'build')
|
||||
buildDraftActions.refreshBuildDraftAfterBuildChat(() => setHideBuildDraftBarUntilRefresh(false))
|
||||
onConversationComplete={(mode, completedConversationId) => {
|
||||
if (mode === 'build') {
|
||||
invalidateAgentWorkingDirectoryFiles({
|
||||
agentId,
|
||||
conversationId: completedConversationId,
|
||||
queryClient,
|
||||
})
|
||||
buildDraftActions.refreshBuildDraftAfterBuildChat(() => setBuildDraftActionsDisabled(false))
|
||||
}
|
||||
}}
|
||||
onConversationIdChange={(mode, conversationId) => {
|
||||
setConversationIds(current => ({
|
||||
...current,
|
||||
[mode]: conversationId,
|
||||
}))
|
||||
setConversationId({ mode, conversationId })
|
||||
}}
|
||||
onSaveDraftBeforeRun={rightPanelChatMode === 'build'
|
||||
? async () => {
|
||||
setHideBuildDraftBarUntilRefresh(true)
|
||||
await buildDraftActions.prepareBuildDraftBeforeRun()
|
||||
setBuildDraftActionsDisabled(true)
|
||||
try {
|
||||
return await buildDraftActions.prepareBuildDraftBeforeRun()
|
||||
}
|
||||
catch (error) {
|
||||
setBuildDraftActionsDisabled(false)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
: saveDraft}
|
||||
onSendInterrupted={() => {
|
||||
if (rightPanelChatMode === 'build')
|
||||
setBuildDraftActionsDisabled(false)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
@@ -420,8 +425,8 @@ function AgentConfigurePageComposerContent({
|
||||
{workingDirectoryPanel.panel}
|
||||
<AgentChatFeaturesPanel
|
||||
show={showChatFeatures}
|
||||
appFeatures={agentSoulConfig?.app_features}
|
||||
disabled={versionQuery.isPending}
|
||||
appFeatures={buildDraft.agentSoulConfig?.app_features}
|
||||
disabled={isChatFeaturesReadOnly}
|
||||
onClose={() => setShowChatFeatures(false)}
|
||||
/>
|
||||
</>
|
||||
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { AgentBuildDraftBar } from '../build-draft-bar'
|
||||
|
||||
describe('AgentBuildDraftBar', () => {
|
||||
it('should disable both build draft actions when the bar is disabled', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onApply = vi.fn()
|
||||
const onDiscard = vi.fn()
|
||||
|
||||
render(
|
||||
<AgentBuildDraftBar
|
||||
changesCount={1}
|
||||
disabled
|
||||
onApply={onApply}
|
||||
onDiscard={onDiscard}
|
||||
/>,
|
||||
)
|
||||
|
||||
const applyButton = screen.getByRole('button', { name: 'custom.apply' })
|
||||
const discardButton = screen.getByRole('button', { name: 'agentV2.agentDetail.configure.buildDraft.discard' })
|
||||
|
||||
expect(applyButton).toBeDisabled()
|
||||
expect(discardButton).toBeDisabled()
|
||||
|
||||
await user.click(applyButton)
|
||||
await user.click(discardButton)
|
||||
|
||||
expect(onApply).not.toHaveBeenCalled()
|
||||
expect(onDiscard).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should disable both actions while apply is pending', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onApply = vi.fn()
|
||||
const onDiscard = vi.fn()
|
||||
|
||||
render(
|
||||
<AgentBuildDraftBar
|
||||
changesCount={1}
|
||||
isApplying
|
||||
onApply={onApply}
|
||||
onDiscard={onDiscard}
|
||||
/>,
|
||||
)
|
||||
|
||||
const applyButton = screen.getByRole('button', { name: 'custom.apply' })
|
||||
const discardButton = screen.getByRole('button', { name: 'agentV2.agentDetail.configure.buildDraft.discard' })
|
||||
|
||||
expect(applyButton).toHaveAttribute('aria-disabled', 'true')
|
||||
expect(applyButton.querySelector('[aria-hidden="true"]')).toBeInTheDocument()
|
||||
expect(discardButton).toBeDisabled()
|
||||
|
||||
await user.click(applyButton)
|
||||
await user.click(discardButton)
|
||||
|
||||
expect(onApply).not.toHaveBeenCalled()
|
||||
expect(onDiscard).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should disable both actions while discard is pending', () => {
|
||||
render(
|
||||
<AgentBuildDraftBar
|
||||
changesCount={1}
|
||||
isDiscarding
|
||||
onApply={vi.fn()}
|
||||
onDiscard={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'custom.apply' })).toBeDisabled()
|
||||
expect(screen.getByRole('button', { name: 'agentV2.agentDetail.configure.buildDraft.discard' })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should not show build draft change metadata', () => {
|
||||
const { rerender } = render(
|
||||
<AgentBuildDraftBar
|
||||
changesCount={0}
|
||||
onApply={vi.fn()}
|
||||
onDiscard={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('agentV2.agentDetail.configure.buildDraft.title')).toBeInTheDocument()
|
||||
expect(screen.getAllByText(/^agentV2\.agentDetail\.configure\.buildDraft\./)).toHaveLength(2)
|
||||
|
||||
rerender(
|
||||
<AgentBuildDraftBar
|
||||
changesCount={2}
|
||||
onApply={vi.fn()}
|
||||
onDiscard={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('agentV2.agentDetail.configure.buildDraft.title')).toBeInTheDocument()
|
||||
expect(screen.getAllByText(/^agentV2\.agentDetail\.configure\.buildDraft\./)).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('should keep both actions enabled when there are no build draft changes', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onApply = vi.fn()
|
||||
const onDiscard = vi.fn()
|
||||
|
||||
render(
|
||||
<AgentBuildDraftBar
|
||||
changesCount={0}
|
||||
onApply={onApply}
|
||||
onDiscard={onDiscard}
|
||||
/>,
|
||||
)
|
||||
|
||||
const discardButton = screen.getByRole('button', { name: 'agentV2.agentDetail.configure.buildDraft.discard' })
|
||||
const applyButton = screen.getByRole('button', { name: 'custom.apply' })
|
||||
const buttons = screen.getAllByRole('button')
|
||||
|
||||
expect(buttons[0]).toBe(discardButton)
|
||||
expect(buttons[1]).toBe(applyButton)
|
||||
expect(discardButton).toBeEnabled()
|
||||
expect(applyButton).toBeEnabled()
|
||||
|
||||
await user.click(discardButton)
|
||||
await user.click(applyButton)
|
||||
|
||||
expect(onDiscard).toHaveBeenCalledTimes(1)
|
||||
expect(onApply).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
+4
-6
@@ -8,12 +8,10 @@ import { AgentKnowledgeRetrieval } from '../knowledge'
|
||||
import { AgentSkills } from '../skills'
|
||||
import { AgentTools } from '../tools'
|
||||
|
||||
vi.mock('../drive-context', () => ({
|
||||
FILES_DRIVE_PREFIX: 'files/',
|
||||
getAgentDriveFileName: (key: string) => key.split('/').pop() ?? key,
|
||||
useAgentDriveApiContext: () => ({ agentId: 'agent-1' }),
|
||||
useAgentDriveFiles: () => ({ files: [], query: { refetch: vi.fn() } }),
|
||||
useAgentDriveSkills: () => ({ skills: [], query: { refetch: vi.fn() } }),
|
||||
vi.mock('../config-context', () => ({
|
||||
useAgentConfigApiContext: () => ({ agentId: 'agent-1', draftType: 'draft' }),
|
||||
useAgentConfigFiles: () => ({ files: [] }),
|
||||
useAgentConfigSkills: () => ({ skills: [] }),
|
||||
}))
|
||||
|
||||
function renderEmptySections() {
|
||||
|
||||
+39
-7
@@ -186,14 +186,16 @@ function renderPublishBar({
|
||||
setupStore?.(store)
|
||||
|
||||
const renderPublishBarTree = (nextProps?: {
|
||||
activeConfigIsPublished?: boolean
|
||||
activeConfigSnapshot?: AgentConfigSnapshotSummaryResponse | null
|
||||
isPublishing?: boolean
|
||||
}) => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<JotaiProvider store={store}>
|
||||
<AgentConfigurePublishBar
|
||||
agentId="agent-1"
|
||||
activeConfigIsPublished={activeConfigIsPublished}
|
||||
activeConfigSnapshot={activeConfigSnapshot}
|
||||
activeConfigIsPublished={nextProps && 'activeConfigIsPublished' in nextProps ? nextProps.activeConfigIsPublished : activeConfigIsPublished}
|
||||
activeConfigSnapshot={nextProps && 'activeConfigSnapshot' in nextProps ? nextProps.activeConfigSnapshot : activeConfigSnapshot}
|
||||
draftSavedAt={draftSavedAt}
|
||||
agentName="Iris"
|
||||
isPublishing={nextProps?.isPublishing ?? isPublishing}
|
||||
@@ -334,11 +336,16 @@ describe('AgentConfigurePublishBar', () => {
|
||||
})
|
||||
|
||||
it('should keep published state when the published detail updates before the active snapshot is refreshed', () => {
|
||||
renderPublishBar({
|
||||
const { rerender, rerenderPublishBar } = renderPublishBar({
|
||||
activeConfigIsPublished: true,
|
||||
activeConfigSnapshot: null,
|
||||
})
|
||||
|
||||
rerender(rerenderPublishBar({
|
||||
activeConfigIsPublished: undefined,
|
||||
activeConfigSnapshot: undefined,
|
||||
}))
|
||||
|
||||
expect(screen.getByText('agentV2.agentDetail.configure.publishBar.upToDate')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'agentV2.agentDetail.configure.publishBar.published' })).toBeDisabled()
|
||||
expect(hotkeyRegistrations.get('Mod+Shift+P')?.options).toEqual(
|
||||
@@ -346,17 +353,17 @@ describe('AgentConfigurePublishBar', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('should keep published state from active config status even when local draft differs', () => {
|
||||
it('should show unpublished state from local draft changes even when active config is published', () => {
|
||||
renderPublishBar({
|
||||
activeConfigIsPublished: true,
|
||||
activeConfigSnapshot: null,
|
||||
prompt: 'Updated system prompt',
|
||||
})
|
||||
|
||||
expect(screen.getByText('agentV2.agentDetail.configure.publishBar.upToDate')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'agentV2.agentDetail.configure.publishBar.published' })).toBeDisabled()
|
||||
expect(screen.getByText('agentV2.agentDetail.configure.publishBar.unpublishedChanges')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /agentV2\.agentDetail\.configure\.publishBar\.publishUpdate/ })).toBeInTheDocument()
|
||||
expect(hotkeyRegistrations.get('Mod+Shift+P')?.options).toEqual(
|
||||
expect.objectContaining({ enabled: false, ignoreInputs: false }),
|
||||
expect.objectContaining({ enabled: true, ignoreInputs: false }),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -437,6 +444,31 @@ describe('AgentConfigurePublishBar', () => {
|
||||
expect(screen.getByRole('button', { name: /agentV2\.agentDetail\.configure\.publishBar\.publishUpdate/ })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should trust backend published state after autosave confirms the draft matches the active snapshot', () => {
|
||||
const stalePublishedDraftBaseline = {
|
||||
...defaultAgentSoulConfigFormState,
|
||||
prompt: 'Old unpublished normal draft',
|
||||
}
|
||||
const savedDraftMatchingActiveSnapshot = {
|
||||
...defaultAgentSoulConfigFormState,
|
||||
prompt: 'Published prompt',
|
||||
}
|
||||
|
||||
renderPublishBar({
|
||||
activeConfigIsPublished: true,
|
||||
activeConfigSnapshot,
|
||||
setupStore: (store) => {
|
||||
store.set(agentComposerPublishedDraftAtom, stalePublishedDraftBaseline)
|
||||
store.set(agentComposerOriginalDraftAtom, savedDraftMatchingActiveSnapshot)
|
||||
store.set(agentComposerDraftAtom, savedDraftMatchingActiveSnapshot)
|
||||
},
|
||||
})
|
||||
|
||||
expect(screen.getByText('agentV2.agentDetail.configure.publishBar.upToDate')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'agentV2.agentDetail.configure.publishBar.published' })).toBeDisabled()
|
||||
expect(screen.queryByRole('button', { name: /agentV2\.agentDetail\.configure\.publishBar\.publishUpdate/ })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render publishing as a single disabled action state', () => {
|
||||
renderPublishBar({ isPublishing: true, prompt: 'Updated system prompt' })
|
||||
|
||||
|
||||
+1
-1
@@ -23,6 +23,6 @@ describe('AgentAdvancedSettings', () => {
|
||||
fireEvent.click(triggers[0]!)
|
||||
|
||||
expect(screen.getByText('advanced-env-editor')).toBeInTheDocument()
|
||||
expect(screen.getByText('advanced-content-moderation')).toBeInTheDocument()
|
||||
expect(screen.queryByText('advanced-content-moderation')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
+2
-1
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ENABLE_AGENT_CONTENT_MODERATION } from '../../../feature-flags'
|
||||
import { ConfigureSection } from '../common/section'
|
||||
import { AgentContentModerationSettings } from './content-moderation'
|
||||
import { AgentEnvEditor } from './env'
|
||||
@@ -22,7 +23,7 @@ export function AgentAdvancedSettings() {
|
||||
panelContentClassName="flex flex-col rounded-lg bg-background-section"
|
||||
>
|
||||
<AgentEnvEditor />
|
||||
<AgentContentModerationSettings />
|
||||
{ENABLE_AGENT_CONTENT_MODERATION && <AgentContentModerationSettings />}
|
||||
</ConfigureSection>
|
||||
)
|
||||
}
|
||||
|
||||
+26
-23
@@ -2,9 +2,11 @@
|
||||
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { AgentBuildGridTexture } from '../build-grid-texture'
|
||||
|
||||
type AgentBuildDraftBarProps = {
|
||||
changesCount: number
|
||||
disabled?: boolean
|
||||
isApplying?: boolean
|
||||
isDiscarding?: boolean
|
||||
onApply: () => void
|
||||
@@ -12,7 +14,7 @@ type AgentBuildDraftBarProps = {
|
||||
}
|
||||
|
||||
export function AgentBuildDraftBar({
|
||||
changesCount,
|
||||
disabled = false,
|
||||
isApplying = false,
|
||||
isDiscarding = false,
|
||||
onApply,
|
||||
@@ -20,41 +22,42 @@ export function AgentBuildDraftBar({
|
||||
}: AgentBuildDraftBarProps) {
|
||||
const { t } = useTranslation('agentV2')
|
||||
const { t: tCustom } = useTranslation('custom')
|
||||
const isPending = isApplying || isDiscarding
|
||||
const metaLabel = changesCount > 0
|
||||
? t('agentDetail.configure.buildDraft.changes', { count: changesCount })
|
||||
: t('agentDetail.configure.buildDraft.noChanges')
|
||||
const isActionPending = isApplying || isDiscarding
|
||||
const applyDisabled = disabled || isActionPending
|
||||
const discardDisabled = disabled || isActionPending
|
||||
|
||||
return (
|
||||
<div className="pointer-events-auto flex max-w-full min-w-0 items-center gap-2 overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur py-2 pr-2 pl-4 shadow-lg shadow-shadow-shadow-5 backdrop-blur-[5px]">
|
||||
<div className="flex min-w-0 flex-1 flex-col justify-center gap-0.5 pr-2">
|
||||
<div className="pointer-events-auto relative flex h-[50px] w-fit max-w-full min-w-0 items-center gap-2 overflow-hidden rounded-xl border-[1.5px] border-[#A0BDFF] bg-components-panel-bg-blur p-2 shadow-lg shadow-shadow-shadow-5 backdrop-blur-[10px]">
|
||||
<AgentBuildGridTexture
|
||||
aria-hidden
|
||||
cellOpacityMultiplier={3}
|
||||
className="pointer-events-none absolute top-[-104px] left-[-1171px] z-0 opacity-70"
|
||||
dotClassName="bg-[#5C90FF]"
|
||||
/>
|
||||
<div className="relative z-1 flex min-w-0 flex-1 flex-col justify-center gap-0.5 pr-8 pl-2">
|
||||
<p className="min-w-0 truncate system-sm-semibold text-text-primary">
|
||||
{t('agentDetail.configure.buildDraft.title')}
|
||||
</p>
|
||||
<p className="min-w-0 truncate system-xs-regular text-text-tertiary">
|
||||
{metaLabel}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
loading={isApplying}
|
||||
disabled={isPending}
|
||||
className="h-8 rounded-lg px-3"
|
||||
onClick={onApply}
|
||||
>
|
||||
{tCustom('apply')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
loading={isDiscarding}
|
||||
disabled={isPending}
|
||||
className="h-8 rounded-lg px-3"
|
||||
disabled={discardDisabled}
|
||||
className="relative z-1 h-8 shrink-0 rounded-lg px-3"
|
||||
onClick={onDiscard}
|
||||
>
|
||||
{t('agentDetail.configure.buildDraft.discard')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
loading={isApplying}
|
||||
disabled={applyDisabled}
|
||||
className="relative z-1 h-8 min-w-20 shrink-0 rounded-lg px-3"
|
||||
onClick={onApply}
|
||||
>
|
||||
{tCustom('apply')}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
'use client'
|
||||
|
||||
export function AgentBuildDraftChangeDot() {
|
||||
return (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute top-[9px] left-[-9px] size-[5px] rounded-full bg-text-warning-secondary"
|
||||
/>
|
||||
)
|
||||
}
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
'use client'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import type { AgentSoulConfigFormState } from '@/features/agent-v2/agent-composer/form-state'
|
||||
import { createContext, createElement, useContext, useMemo } from 'react'
|
||||
|
||||
export type AgentBuildDraftChangedKey = keyof AgentSoulConfigFormState
|
||||
|
||||
export type AgentBuildDraftChangeSection
|
||||
= | 'skills'
|
||||
| 'files'
|
||||
|
||||
const changedKeysBySection: Record<AgentBuildDraftChangeSection, readonly AgentBuildDraftChangedKey[]> = {
|
||||
skills: ['skills'],
|
||||
files: ['files'],
|
||||
}
|
||||
|
||||
const AgentBuildDraftChangedKeysContext = createContext<ReadonlySet<AgentBuildDraftChangedKey>>(new Set())
|
||||
|
||||
export function AgentBuildDraftChangedKeysProvider({
|
||||
changedKeys,
|
||||
children,
|
||||
}: {
|
||||
changedKeys: readonly AgentBuildDraftChangedKey[]
|
||||
children: ReactNode
|
||||
}) {
|
||||
const changedKeySet = useMemo(() => new Set(changedKeys), [changedKeys])
|
||||
|
||||
return createElement(
|
||||
AgentBuildDraftChangedKeysContext.Provider,
|
||||
{ value: changedKeySet },
|
||||
children,
|
||||
)
|
||||
}
|
||||
|
||||
export function useIsAgentBuildDraftSectionChanged(section?: AgentBuildDraftChangeSection) {
|
||||
const changedKeys = useContext(AgentBuildDraftChangedKeysContext)
|
||||
|
||||
if (!section)
|
||||
return false
|
||||
|
||||
return changedKeysBySection[section].some(key => changedKeys.has(key))
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
import type { AgentBuildDraftChangedKey } from '../../build-draft-changes-context'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { AgentBuildDraftChangedKeysProvider } from '../../build-draft-changes-context'
|
||||
import { ConfigureSection } from '../section'
|
||||
|
||||
function renderSection({
|
||||
section = 'skills',
|
||||
changedKeys,
|
||||
}: {
|
||||
section?: 'skills' | 'files'
|
||||
changedKeys: AgentBuildDraftChangedKey[]
|
||||
}) {
|
||||
return render(
|
||||
<AgentBuildDraftChangedKeysProvider changedKeys={changedKeys}>
|
||||
<ConfigureSection
|
||||
label={section === 'skills' ? 'Skills' : 'Files'}
|
||||
labelId={`${section}-label`}
|
||||
buildDraftChangeSection={section}
|
||||
>
|
||||
<div>{`${section} content`}</div>
|
||||
</ConfigureSection>
|
||||
</AgentBuildDraftChangedKeysProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('ConfigureSection', () => {
|
||||
it('should show a build draft change dot when Skills changed', () => {
|
||||
renderSection({ section: 'skills', changedKeys: ['skills'] })
|
||||
|
||||
expect(screen.getByRole('heading', { name: 'Skills' }).querySelector('.bg-text-warning-secondary')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show a build draft change dot when Files changed', () => {
|
||||
renderSection({ section: 'files', changedKeys: ['files'] })
|
||||
|
||||
expect(screen.getByRole('heading', { name: 'Files' }).querySelector('.bg-text-warning-secondary')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show a build draft change dot when only another key changed', () => {
|
||||
renderSection({ section: 'skills', changedKeys: ['prompt'] })
|
||||
|
||||
expect(screen.getByRole('heading', { name: 'Skills' }).querySelector('.bg-text-warning-secondary')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
+8
-1
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import type { AgentBuildDraftChangeSection } from '../build-draft-changes-context'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import {
|
||||
CollapsiblePanel,
|
||||
@@ -8,12 +9,15 @@ import {
|
||||
CollapsibleTrigger,
|
||||
} from '@langgenius/dify-ui/collapsible'
|
||||
import { Infotip } from '@/app/components/base/infotip'
|
||||
import { AgentBuildDraftChangeDot } from '../build-draft-change-dot'
|
||||
import { useIsAgentBuildDraftSectionChanged } from '../build-draft-changes-context'
|
||||
|
||||
type ConfigureSectionBaseProps = {
|
||||
label: ReactNode
|
||||
labelId: string
|
||||
children: ReactNode
|
||||
actions?: ReactNode
|
||||
buildDraftChangeSection?: AgentBuildDraftChangeSection
|
||||
description?: ReactNode
|
||||
defaultOpen?: boolean
|
||||
headingLevel?: 'h3' | 'h4'
|
||||
@@ -40,6 +44,7 @@ export function ConfigureSection({
|
||||
labelId,
|
||||
children,
|
||||
actions,
|
||||
buildDraftChangeSection,
|
||||
description,
|
||||
defaultOpen = true,
|
||||
headingLevel = 'h3',
|
||||
@@ -54,6 +59,7 @@ export function ConfigureSection({
|
||||
const Heading = headingLevel
|
||||
const hasDescription = description !== undefined && description !== null
|
||||
const hasTip = tip !== undefined && tip !== null
|
||||
const isBuildDraftChanged = useIsAgentBuildDraftSectionChanged(buildDraftChangeSection)
|
||||
|
||||
return (
|
||||
<CollapsibleRoot
|
||||
@@ -65,7 +71,8 @@ export function ConfigureSection({
|
||||
<div className={cn('mb-2 flex min-h-6 items-center gap-2', headerClassName)}>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className={cn('flex min-w-0 items-center', titleRowClassName)}>
|
||||
<Heading id={labelId} className="min-w-0 shrink-0">
|
||||
<Heading id={labelId} className="relative min-w-0 shrink-0">
|
||||
{isBuildDraftChanged && <AgentBuildDraftChangeDot />}
|
||||
<CollapsibleTrigger
|
||||
className="h-6 min-h-0 w-auto max-w-full justify-start gap-0 rounded-sm px-0 text-text-secondary hover:not-data-disabled:bg-transparent hover:not-data-disabled:text-text-secondary data-panel-open:text-text-secondary"
|
||||
>
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
'use client'
|
||||
|
||||
import { useAtomValue } from 'jotai'
|
||||
import { createContext, use } from 'react'
|
||||
import { agentComposerFilesAtom } from '@/features/agent-v2/agent-composer/store-modules/files'
|
||||
import { agentComposerSkillsAtom } from '@/features/agent-v2/agent-composer/store-modules/skills'
|
||||
|
||||
export type AgentConfigApiContext = {
|
||||
agentId: string
|
||||
draftType?: 'draft' | 'debug_build'
|
||||
versionId?: string
|
||||
workflow?: {
|
||||
appId: string
|
||||
nodeId: string
|
||||
}
|
||||
}
|
||||
|
||||
const AgentConfigApiContext = createContext<AgentConfigApiContext | null>(null)
|
||||
|
||||
export const AgentConfigApiContextProvider = AgentConfigApiContext.Provider
|
||||
|
||||
export const useAgentConfigApiContext = () => {
|
||||
const context = use(AgentConfigApiContext)
|
||||
if (!context)
|
||||
throw new Error('AgentConfigApiContextProvider is required for config-backed UI.')
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
export const useAgentConfigSkills = () => {
|
||||
const apiContext = useAgentConfigApiContext()
|
||||
const skills = useAtomValue(agentComposerSkillsAtom)
|
||||
|
||||
return {
|
||||
apiContext,
|
||||
skills,
|
||||
}
|
||||
}
|
||||
|
||||
export const useAgentConfigFiles = () => {
|
||||
const apiContext = useAgentConfigApiContext()
|
||||
const files = useAtomValue(agentComposerFilesAtom)
|
||||
|
||||
return {
|
||||
apiContext,
|
||||
files,
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useAtomValue } from 'jotai'
|
||||
import { createContext, use, useMemo } from 'react'
|
||||
import { agentComposerFilesAtom } from '@/features/agent-v2/agent-composer/store-modules/files'
|
||||
import { agentComposerSkillsAtom } from '@/features/agent-v2/agent-composer/store-modules/skills'
|
||||
|
||||
export type AgentDriveApiContext = {
|
||||
agentId: string
|
||||
workflow?: {
|
||||
appId: string
|
||||
nodeId: string
|
||||
}
|
||||
}
|
||||
|
||||
export const FILES_DRIVE_PREFIX = 'files/'
|
||||
|
||||
const AgentDriveApiContext = createContext<AgentDriveApiContext | null>(null)
|
||||
|
||||
export const AgentDriveApiContextProvider = AgentDriveApiContext.Provider
|
||||
|
||||
export const useAgentDriveApiContext = () => {
|
||||
const context = use(AgentDriveApiContext)
|
||||
if (!context)
|
||||
throw new Error('AgentDriveApiContextProvider is required for drive-backed UI.')
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
export const useAgentDriveSkills = () => {
|
||||
const apiContext = useAgentDriveApiContext()
|
||||
const skills = useAtomValue(agentComposerSkillsAtom)
|
||||
|
||||
return {
|
||||
apiContext,
|
||||
skills,
|
||||
}
|
||||
}
|
||||
|
||||
export const useAgentDriveFiles = ({
|
||||
prefix = FILES_DRIVE_PREFIX,
|
||||
}: {
|
||||
prefix?: string
|
||||
} = {}) => {
|
||||
const apiContext = useAgentDriveApiContext()
|
||||
const draftFiles = useAtomValue(agentComposerFilesAtom)
|
||||
const files = useMemo(
|
||||
() => draftFiles.filter(file => !prefix || file.driveKey?.startsWith(prefix)),
|
||||
[draftFiles, prefix],
|
||||
)
|
||||
|
||||
return {
|
||||
apiContext,
|
||||
files,
|
||||
}
|
||||
}
|
||||
+283
-735
File diff suppressed because it is too large
Load Diff
+65
-33
@@ -2,7 +2,7 @@
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import type { AgentOrchestrateAddActionOptions } from '../add-actions-context'
|
||||
import type { AgentDriveApiContext } from '../drive-context'
|
||||
import type { AgentConfigApiContext } from '../config-context'
|
||||
import type { AgentFileNode } from '@/features/agent-v2/agent-composer/form-state'
|
||||
import {
|
||||
Dialog,
|
||||
@@ -15,6 +15,7 @@ import { useMutation, useQuery } from '@tanstack/react-query'
|
||||
import { useAtomValue, useSetAtom } from 'jotai'
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { agentComposerOriginalConfigAtom } from '@/features/agent-v2/agent-composer/store'
|
||||
import { agentComposerFilesAtom } from '@/features/agent-v2/agent-composer/store-modules/files'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { useRegisterAgentOrchestrateAddAction } from '../add-actions-context'
|
||||
@@ -22,13 +23,28 @@ import { ConfigureSectionAddButton } from '../common/add-button'
|
||||
import { ConfigureSectionEmpty } from '../common/empty'
|
||||
import { ConfigureSection } from '../common/section'
|
||||
import { AgentConfigureTipContent } from '../common/tip-content'
|
||||
import { FILES_DRIVE_PREFIX, useAgentDriveApiContext } from '../drive-context'
|
||||
import { useAgentConfigApiContext } from '../config-context'
|
||||
import { useAgentOrchestrateReadOnly } from '../read-only-context'
|
||||
import { AgentSkillDetailDialog } from '../skills/detail-dialog'
|
||||
import { AgentFileTree } from './tree'
|
||||
import { AgentFileUploadDialog } from './upload-dialog'
|
||||
|
||||
const getAgentFilePreviewKey = (file: AgentFileNode) => file.driveKey ?? file.id
|
||||
const BUILD_NOTE_FILE_ID = '__agent_config_build_note__'
|
||||
const BUILD_NOTE_FILE_NAME = 'build_note.md'
|
||||
|
||||
const getAgentFilePreviewKey = (file: AgentFileNode) => file.configName ?? file.name
|
||||
|
||||
const getBuildNoteFile = (configNote: string | undefined): AgentFileNode | undefined => {
|
||||
if (!configNote?.trim())
|
||||
return undefined
|
||||
|
||||
return {
|
||||
id: BUILD_NOTE_FILE_ID,
|
||||
icon: 'markdown',
|
||||
name: BUILD_NOTE_FILE_NAME,
|
||||
virtualContent: configNote,
|
||||
}
|
||||
}
|
||||
|
||||
const findAgentFileNode = (files: AgentFileNode[], fileId: string): AgentFileNode | undefined => {
|
||||
for (const file of files) {
|
||||
@@ -64,7 +80,7 @@ function AgentFileItem({
|
||||
depth: number
|
||||
file: AgentFileNode
|
||||
files: AgentFileNode[]
|
||||
apiContext: AgentDriveApiContext
|
||||
apiContext: AgentConfigApiContext
|
||||
onRemove: (fileId: string) => void
|
||||
selected: boolean
|
||||
}) {
|
||||
@@ -73,60 +89,69 @@ function AgentFileItem({
|
||||
const [isPreviewOpen, setIsPreviewOpen] = useState(false)
|
||||
const [selectedFileId, setSelectedFileId] = useState<string>()
|
||||
const selectedFile = selectedFileId ? findAgentFileNode(files, selectedFileId) : undefined
|
||||
const previewFileId = getAgentFilePreviewKey(selectedFile ?? file)
|
||||
const selectedPreviewFile = selectedFile ?? file
|
||||
const isVirtualPreviewFile = selectedPreviewFile.virtualContent !== undefined
|
||||
const previewFileId = isVirtualPreviewFile ? undefined : getAgentFilePreviewKey(selectedPreviewFile)
|
||||
const agentPreviewQuery = useQuery({
|
||||
...consoleQuery.agent.byAgentId.drive.files.preview.get.queryOptions({
|
||||
...consoleQuery.agent.byAgentId.config.files.byName.preview.get.queryOptions({
|
||||
input: {
|
||||
params: {
|
||||
agent_id: apiContext.agentId,
|
||||
name: previewFileId ?? '',
|
||||
},
|
||||
query: {
|
||||
key: previewFileId ?? '',
|
||||
draft_type: apiContext.draftType,
|
||||
version_id: apiContext.versionId,
|
||||
},
|
||||
},
|
||||
}),
|
||||
enabled: isPreviewOpen && !!previewFileId && !apiContext.workflow,
|
||||
enabled: isPreviewOpen && !!previewFileId && !isVirtualPreviewFile && !apiContext.workflow,
|
||||
})
|
||||
const workflowPreviewQuery = useQuery({
|
||||
...consoleQuery.apps.byAppId.agent.drive.files.preview.get.queryOptions({
|
||||
...consoleQuery.apps.byAppId.agent.config.files.byName.preview.get.queryOptions({
|
||||
input: {
|
||||
params: {
|
||||
app_id: apiContext.workflow?.appId ?? '',
|
||||
name: previewFileId ?? '',
|
||||
},
|
||||
query: {
|
||||
key: previewFileId ?? '',
|
||||
node_id: apiContext.workflow?.nodeId,
|
||||
draft_type: apiContext.draftType,
|
||||
version_id: apiContext.versionId,
|
||||
},
|
||||
},
|
||||
}),
|
||||
enabled: isPreviewOpen && !!previewFileId && !!apiContext.workflow,
|
||||
enabled: isPreviewOpen && !!previewFileId && !isVirtualPreviewFile && !!apiContext.workflow,
|
||||
})
|
||||
const previewQuery = apiContext.workflow ? workflowPreviewQuery : agentPreviewQuery
|
||||
const selectedPreviewFile = selectedFile ?? file
|
||||
const isImagePreviewFile = selectedPreviewFile.icon === 'image'
|
||||
const shouldDownloadPreviewFile = isPreviewOpen && !!previewFileId && (isImagePreviewFile || !!previewQuery.data?.binary)
|
||||
const shouldDownloadPreviewFile = isPreviewOpen && !!previewFileId && !isVirtualPreviewFile && (isImagePreviewFile || !!previewQuery.data?.binary)
|
||||
const agentDownloadQuery = useQuery({
|
||||
...consoleQuery.agent.byAgentId.drive.files.download.get.queryOptions({
|
||||
...consoleQuery.agent.byAgentId.config.files.byName.download.get.queryOptions({
|
||||
input: {
|
||||
params: {
|
||||
agent_id: apiContext.agentId,
|
||||
name: previewFileId ?? '',
|
||||
},
|
||||
query: {
|
||||
key: previewFileId ?? '',
|
||||
draft_type: apiContext.draftType,
|
||||
version_id: apiContext.versionId,
|
||||
},
|
||||
},
|
||||
}),
|
||||
enabled: shouldDownloadPreviewFile && !apiContext.workflow,
|
||||
})
|
||||
const workflowDownloadQuery = useQuery({
|
||||
...consoleQuery.apps.byAppId.agent.drive.files.download.get.queryOptions({
|
||||
...consoleQuery.apps.byAppId.agent.config.files.byName.download.get.queryOptions({
|
||||
input: {
|
||||
params: {
|
||||
app_id: apiContext.workflow?.appId ?? '',
|
||||
name: previewFileId ?? '',
|
||||
},
|
||||
query: {
|
||||
key: previewFileId ?? '',
|
||||
node_id: apiContext.workflow?.nodeId,
|
||||
draft_type: apiContext.draftType,
|
||||
version_id: apiContext.versionId,
|
||||
},
|
||||
},
|
||||
}),
|
||||
@@ -169,14 +194,14 @@ function AgentFileItem({
|
||||
files,
|
||||
filePreview: {
|
||||
binary: previewQuery.data?.binary,
|
||||
content: previewQuery.data?.text ?? undefined,
|
||||
content: selectedPreviewFile.virtualContent ?? previewQuery.data?.text ?? undefined,
|
||||
downloadUrl: downloadQuery.data?.url,
|
||||
fileName: selectedPreviewFile.name,
|
||||
isDownloadError: downloadQuery.isError,
|
||||
isDownloadLoading: shouldDownloadPreviewFile && downloadQuery.isPending,
|
||||
isError: previewQuery.isError,
|
||||
isError: !isVirtualPreviewFile && previewQuery.isError,
|
||||
isImage: isImagePreviewFile,
|
||||
isLoading: previewQuery.isPending,
|
||||
isLoading: !isVirtualPreviewFile && previewQuery.isPending,
|
||||
},
|
||||
onSelectFile: selectedFile => setSelectedFileId(selectedFile.id),
|
||||
selectedFileId: selectedFileId ?? file.id,
|
||||
@@ -184,7 +209,7 @@ function AgentFileItem({
|
||||
}}
|
||||
/>
|
||||
</Dialog>
|
||||
{!readOnly && (
|
||||
{!readOnly && !file.virtualContent && (
|
||||
<button
|
||||
type="button"
|
||||
data-agent-file-remove-button
|
||||
@@ -205,17 +230,19 @@ export function AgentFiles() {
|
||||
const filesTreeId = 'agent-configure-files-tree'
|
||||
const [isUploadOpen, setIsUploadOpen] = useState(false)
|
||||
const promptAddCallbackRef = useRef<AgentOrchestrateAddActionOptions['onAdded']>(undefined)
|
||||
const apiContext = useAgentDriveApiContext()
|
||||
const draftFiles = useAtomValue(agentComposerFilesAtom)
|
||||
const apiContext = useAgentConfigApiContext()
|
||||
const originalConfig = useAtomValue(agentComposerOriginalConfigAtom)
|
||||
const files = useAtomValue(agentComposerFilesAtom)
|
||||
const setFiles = useSetAtom(agentComposerFilesAtom)
|
||||
const files = draftFiles.filter(file => file.driveKey?.startsWith(FILES_DRIVE_PREFIX))
|
||||
const { mutate: deleteAgentFile } = useMutation(consoleQuery.agent.byAgentId.files.delete.mutationOptions())
|
||||
const { mutate: deleteWorkflowAgentFile } = useMutation(consoleQuery.apps.byAppId.agent.files.delete.mutationOptions())
|
||||
const buildNoteFile = getBuildNoteFile(originalConfig?.config_note)
|
||||
const visibleFiles = buildNoteFile ? [buildNoteFile, ...files] : files
|
||||
const { mutate: deleteAgentFile } = useMutation(consoleQuery.agent.byAgentId.config.files.byName.delete.mutationOptions())
|
||||
const { mutate: deleteWorkflowAgentFile } = useMutation(consoleQuery.apps.byAppId.agent.config.files.byName.delete.mutationOptions())
|
||||
const removeFile = useCallback((fileId: string) => {
|
||||
const file = findAgentFileNode(files, fileId)
|
||||
const driveKey = file?.driveKey
|
||||
const configName = file?.configName ?? file?.name
|
||||
|
||||
if (!driveKey)
|
||||
if (!configName)
|
||||
return
|
||||
|
||||
const onSuccess = () => {
|
||||
@@ -225,10 +252,12 @@ export function AgentFiles() {
|
||||
deleteWorkflowAgentFile({
|
||||
params: {
|
||||
app_id: apiContext.workflow.appId,
|
||||
name: configName,
|
||||
},
|
||||
query: {
|
||||
key: driveKey,
|
||||
node_id: apiContext.workflow.nodeId,
|
||||
draft_type: apiContext.draftType,
|
||||
version_id: apiContext.versionId,
|
||||
},
|
||||
}, { onSuccess })
|
||||
return
|
||||
@@ -237,9 +266,11 @@ export function AgentFiles() {
|
||||
deleteAgentFile({
|
||||
params: {
|
||||
agent_id: apiContext.agentId,
|
||||
name: configName,
|
||||
},
|
||||
query: {
|
||||
key: driveKey,
|
||||
draft_type: apiContext.draftType,
|
||||
version_id: apiContext.versionId,
|
||||
},
|
||||
}, { onSuccess })
|
||||
}, [apiContext, deleteAgentFile, deleteWorkflowAgentFile, files, setFiles])
|
||||
@@ -267,6 +298,7 @@ export function AgentFiles() {
|
||||
<ConfigureSection
|
||||
label={t('agentDetail.configure.files.label')}
|
||||
labelId="agent-configure-files-label"
|
||||
buildDraftChangeSection="files"
|
||||
tip={<AgentConfigureTipContent type="files" />}
|
||||
tipAriaLabel={filesTip}
|
||||
rootClassName="border-b border-divider-subtle pt-4"
|
||||
@@ -278,7 +310,7 @@ export function AgentFiles() {
|
||||
/>
|
||||
)}
|
||||
>
|
||||
{files.length === 0
|
||||
{visibleFiles.length === 0
|
||||
? (
|
||||
<ConfigureSectionEmpty
|
||||
title={t('agentDetail.configure.files.empty.title')}
|
||||
@@ -288,7 +320,7 @@ export function AgentFiles() {
|
||||
: (
|
||||
<AgentFileTree
|
||||
id={filesTreeId}
|
||||
files={files}
|
||||
files={visibleFiles}
|
||||
treeLabel={t('agentDetail.configure.files.treeLabel')}
|
||||
className="rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-on-panel-item-bg p-1 shadow-xs shadow-shadow-shadow-3"
|
||||
scrollAreaClassName="max-h-[250px] flex-none"
|
||||
@@ -296,7 +328,7 @@ export function AgentFiles() {
|
||||
<AgentFileItem
|
||||
depth={depth}
|
||||
file={file}
|
||||
files={files}
|
||||
files={visibleFiles}
|
||||
apiContext={apiContext}
|
||||
selected={selected}
|
||||
onRemove={removeFile}
|
||||
|
||||
@@ -28,6 +28,21 @@ type AgentFileTreeRenderFile = (context: {
|
||||
children: ReactNode
|
||||
}) => ReactNode
|
||||
|
||||
type AgentFileTreeRenderFolderPanel = (context: {
|
||||
depth: number
|
||||
file: AgentFileNode
|
||||
}) => ReactNode
|
||||
|
||||
type AgentFileTreeRenderFolderSuffix = (context: {
|
||||
depth: number
|
||||
file: AgentFileNode
|
||||
}) => ReactNode
|
||||
|
||||
type AgentFileTreeFolderOpenState = (context: {
|
||||
file: AgentFileNode
|
||||
depth: number
|
||||
}) => boolean
|
||||
|
||||
const firstLevelFolderOpenStrategy: AgentFileTreeFolderOpenStrategy = ({ depth }) => depth === 1
|
||||
|
||||
function AgentFileTreeRows({
|
||||
@@ -35,13 +50,23 @@ function AgentFileTreeRows({
|
||||
selectedFileId,
|
||||
depth,
|
||||
folderOpenStrategy,
|
||||
folderOpenState,
|
||||
onFolderOpenChange,
|
||||
onFolderOpen,
|
||||
renderFile,
|
||||
renderFolderSuffix,
|
||||
renderFolderPanel,
|
||||
}: {
|
||||
files: AgentFileNode[]
|
||||
selectedFileId?: string
|
||||
depth: number
|
||||
folderOpenStrategy: AgentFileTreeFolderOpenStrategy
|
||||
folderOpenState?: AgentFileTreeFolderOpenState
|
||||
onFolderOpenChange?: (context: { file: AgentFileNode, depth: number, open: boolean }) => void
|
||||
onFolderOpen?: (file: AgentFileNode) => void
|
||||
renderFile: AgentFileTreeRenderFile
|
||||
renderFolderSuffix?: AgentFileTreeRenderFolderSuffix
|
||||
renderFolderPanel?: AgentFileTreeRenderFolderPanel
|
||||
}) {
|
||||
return files.map((file) => {
|
||||
const children = (
|
||||
@@ -51,23 +76,32 @@ function AgentFileTreeRows({
|
||||
</>
|
||||
)
|
||||
|
||||
if (file.children?.length) {
|
||||
if (file.icon === 'folder') {
|
||||
return (
|
||||
<FileTreeFolder
|
||||
key={file.id}
|
||||
defaultOpen={folderOpenStrategy({ file, depth })}
|
||||
open={folderOpenState?.({ file, depth })}
|
||||
onOpenChange={open => onFolderOpenChange?.({ file, depth, open })}
|
||||
>
|
||||
<FileTreeFolderTrigger>
|
||||
<FileTreeFolderTrigger onClick={() => onFolderOpen?.(file)}>
|
||||
<FileTreeIcon type="folder" />
|
||||
<FileTreeLabel className="max-w-full" title={file.name}>{file.name}</FileTreeLabel>
|
||||
{renderFolderSuffix?.({ depth, file })}
|
||||
</FileTreeFolderTrigger>
|
||||
<FileTreeFolderPanel>
|
||||
{renderFolderPanel?.({ depth, file })}
|
||||
<AgentFileTreeRows
|
||||
files={file.children}
|
||||
files={file.children ?? []}
|
||||
selectedFileId={selectedFileId}
|
||||
depth={depth + 1}
|
||||
folderOpenStrategy={folderOpenStrategy}
|
||||
folderOpenState={folderOpenState}
|
||||
onFolderOpenChange={onFolderOpenChange}
|
||||
onFolderOpen={onFolderOpen}
|
||||
renderFile={renderFile}
|
||||
renderFolderSuffix={renderFolderSuffix}
|
||||
renderFolderPanel={renderFolderPanel}
|
||||
/>
|
||||
</FileTreeFolderPanel>
|
||||
</FileTreeFolder>
|
||||
@@ -106,7 +140,12 @@ export function AgentFileTree({
|
||||
rootClassName,
|
||||
listClassName,
|
||||
folderOpenStrategy = firstLevelFolderOpenStrategy,
|
||||
folderOpenState,
|
||||
onFolderOpenChange,
|
||||
onFolderOpen,
|
||||
renderFile = defaultRenderFile,
|
||||
renderFolderSuffix,
|
||||
renderFolderPanel,
|
||||
}: {
|
||||
files: AgentFileNode[]
|
||||
selectedFileId?: string
|
||||
@@ -120,7 +159,12 @@ export function AgentFileTree({
|
||||
rootClassName?: string
|
||||
listClassName?: string
|
||||
folderOpenStrategy?: AgentFileTreeFolderOpenStrategy
|
||||
folderOpenState?: AgentFileTreeFolderOpenState
|
||||
onFolderOpenChange?: (context: { file: AgentFileNode, depth: number, open: boolean }) => void
|
||||
onFolderOpen?: (file: AgentFileNode) => void
|
||||
renderFile?: AgentFileTreeRenderFile
|
||||
renderFolderSuffix?: AgentFileTreeRenderFolderSuffix
|
||||
renderFolderPanel?: AgentFileTreeRenderFolderPanel
|
||||
}) {
|
||||
return (
|
||||
<div className={cn('flex min-h-0 w-full max-w-full min-w-0 flex-col overflow-clip', className)}>
|
||||
@@ -146,7 +190,12 @@ export function AgentFileTree({
|
||||
selectedFileId={selectedFileId}
|
||||
depth={1}
|
||||
folderOpenStrategy={folderOpenStrategy}
|
||||
folderOpenState={folderOpenState}
|
||||
onFolderOpenChange={onFolderOpenChange}
|
||||
onFolderOpen={onFolderOpen}
|
||||
renderFile={renderFile}
|
||||
renderFolderSuffix={renderFolderSuffix}
|
||||
renderFolderPanel={renderFolderPanel}
|
||||
/>
|
||||
</FileTreeList>
|
||||
</FileTreeRoot>
|
||||
|
||||
+19
-18
@@ -1,8 +1,9 @@
|
||||
'use client'
|
||||
|
||||
import type { AgentConfigFileItemResponse, AgentConfigFileUploadResponse } from '@dify/contracts/api/console/agent/types.gen'
|
||||
import type { FileResponse } from '@dify/contracts/api/console/files/types.gen'
|
||||
import type { ChangeEvent, DragEvent } from 'react'
|
||||
import type { AgentDriveApiContext } from '../drive-context'
|
||||
import type { AgentConfigApiContext } from '../config-context'
|
||||
import type { AgentFileNode } from '@/features/agent-v2/agent-composer/form-state'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
@@ -17,22 +18,16 @@ import { consoleQuery } from '@/service/client'
|
||||
import { formatFileSize } from '@/utils/format'
|
||||
import { getFileIconType } from './file-icon'
|
||||
|
||||
type AgentDriveFileCommit = {
|
||||
file: {
|
||||
drive_key: string
|
||||
file_id: string
|
||||
mime_type?: string | null
|
||||
name: string
|
||||
}
|
||||
}
|
||||
|
||||
function toAgentFileNode(committedFile: AgentDriveFileCommit['file']): AgentFileNode {
|
||||
function toAgentFileNode(committedFile: AgentConfigFileItemResponse): AgentFileNode {
|
||||
return {
|
||||
id: committedFile.file_id,
|
||||
id: committedFile.name,
|
||||
name: committedFile.name,
|
||||
icon: getFileIconType(committedFile.name, committedFile.mime_type),
|
||||
fileId: committedFile.file_id,
|
||||
driveKey: committedFile.drive_key,
|
||||
fileId: committedFile.file_id ?? undefined,
|
||||
configName: committedFile.name,
|
||||
size: committedFile.size ?? undefined,
|
||||
hash: committedFile.hash ?? undefined,
|
||||
mimeType: committedFile.mime_type ?? undefined,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -178,7 +173,7 @@ export function AgentFileUploadDialog({
|
||||
onOpenChange,
|
||||
onUploaded,
|
||||
}: {
|
||||
apiContext: AgentDriveApiContext
|
||||
apiContext: AgentConfigApiContext
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onUploaded: (file: AgentFileNode) => void
|
||||
@@ -187,14 +182,14 @@ export function AgentFileUploadDialog({
|
||||
const { t: tCommon } = useTranslation('common')
|
||||
const [file, setFile] = useState<File>()
|
||||
const uploadFileMutation = useMutation(consoleQuery.files.upload.post.mutationOptions())
|
||||
const commitAgentFileMutation = useMutation(consoleQuery.agent.byAgentId.files.post.mutationOptions())
|
||||
const commitWorkflowAgentFileMutation = useMutation(consoleQuery.apps.byAppId.agent.files.post.mutationOptions())
|
||||
const commitAgentFileMutation = useMutation(consoleQuery.agent.byAgentId.config.files.post.mutationOptions())
|
||||
const commitWorkflowAgentFileMutation = useMutation(consoleQuery.apps.byAppId.agent.config.files.post.mutationOptions())
|
||||
const isUploading = uploadFileMutation.isPending
|
||||
|| commitAgentFileMutation.isPending
|
||||
|| commitWorkflowAgentFileMutation.isPending
|
||||
|
||||
const commitUploadedFile = (uploadedFile: FileResponse, options: {
|
||||
onSuccess: (committedFile: AgentDriveFileCommit) => void
|
||||
onSuccess: (committedFile: AgentConfigFileUploadResponse) => void
|
||||
onError: () => void
|
||||
}) => {
|
||||
const body = {
|
||||
@@ -208,6 +203,8 @@ export function AgentFileUploadDialog({
|
||||
},
|
||||
query: {
|
||||
node_id: apiContext.workflow.nodeId,
|
||||
draft_type: apiContext.draftType,
|
||||
version_id: apiContext.versionId,
|
||||
},
|
||||
body,
|
||||
}, options)
|
||||
@@ -218,6 +215,10 @@ export function AgentFileUploadDialog({
|
||||
params: {
|
||||
agent_id: apiContext.agentId,
|
||||
},
|
||||
query: {
|
||||
draft_type: apiContext.draftType,
|
||||
version_id: apiContext.versionId,
|
||||
},
|
||||
body,
|
||||
}, options)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
import type { AgentConfigSnapshotDetailResponse, AgentConfigSnapshotSummaryResponse } from '@dify/contracts/api/console/agent/types.gen'
|
||||
import type { ReactNode } from 'react'
|
||||
import type { DefaultModel, Model } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import type { AgentBuildDraftChangedKey } from './build-draft-changes-context'
|
||||
import type { Model } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import type { AgentComposerModel } from '@/features/agent-v2/agent-composer/form-state'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { ScrollArea } from '@langgenius/dify-ui/scroll-area'
|
||||
import { useMemo } from 'react'
|
||||
@@ -10,7 +12,8 @@ import { useTranslation } from 'react-i18next'
|
||||
import { AgentOrchestrateAddActionsProvider } from './add-actions'
|
||||
import { AgentAdvancedSettings } from './advanced'
|
||||
import { AgentOrchestrateBottomActions } from './bottom-actions'
|
||||
import { AgentDriveApiContextProvider } from './drive-context'
|
||||
import { AgentBuildDraftChangedKeysProvider } from './build-draft-changes-context'
|
||||
import { AgentConfigApiContextProvider } from './config-context'
|
||||
import { AgentFiles } from './files'
|
||||
import { AgentOrchestrateHeader } from './header'
|
||||
import { AgentKnowledgeRetrieval } from './knowledge'
|
||||
@@ -21,6 +24,8 @@ import { AgentOrchestrateReadOnlyContext } from './read-only-context'
|
||||
import { AgentSkills } from './skills'
|
||||
import { AgentTools } from './tools'
|
||||
|
||||
const EMPTY_BUILD_DRAFT_CHANGED_KEYS: readonly AgentBuildDraftChangedKey[] = []
|
||||
|
||||
type AgentOrchestratePanelProps = {
|
||||
agentId: string
|
||||
appId?: string
|
||||
@@ -29,7 +34,7 @@ type AgentOrchestratePanelProps = {
|
||||
activeConfigSnapshot?: AgentConfigSnapshotSummaryResponse | null
|
||||
agentSoulConfig?: AgentConfigSnapshotDetailResponse['config_snapshot']
|
||||
agentName?: string | null
|
||||
currentModel?: DefaultModel
|
||||
currentModel?: AgentComposerModel
|
||||
textGenerationModelList: Model[]
|
||||
draftSavedAt?: number
|
||||
isPublishing?: boolean
|
||||
@@ -37,14 +42,15 @@ type AgentOrchestratePanelProps = {
|
||||
readOnly?: boolean
|
||||
selectedVersionSnapshot?: AgentConfigSnapshotSummaryResponse | null
|
||||
isBuildDraftActive?: boolean
|
||||
buildDraftChangedKeys?: readonly AgentBuildDraftChangedKey[]
|
||||
showHeader?: boolean
|
||||
showPublishBar?: boolean
|
||||
headerAction?: ReactNode
|
||||
bottomAction?: ReactNode
|
||||
onSelectModel: (model: DefaultModel) => void
|
||||
onPublish: () => void | Promise<void>
|
||||
onSelectModel: (model: AgentComposerModel) => void
|
||||
onPublish?: () => void | Promise<void>
|
||||
onExitVersions?: () => void
|
||||
onOpenVersions: () => void
|
||||
onOpenVersions?: () => void
|
||||
}
|
||||
|
||||
export function AgentOrchestratePanel({
|
||||
@@ -63,6 +69,7 @@ export function AgentOrchestratePanel({
|
||||
readOnly = false,
|
||||
selectedVersionSnapshot,
|
||||
isBuildDraftActive = false,
|
||||
buildDraftChangedKeys = [],
|
||||
showHeader = true,
|
||||
showPublishBar = true,
|
||||
headerAction,
|
||||
@@ -92,15 +99,22 @@ export function AgentOrchestratePanel({
|
||||
)
|
||||
: null)
|
||||
const hasBottomAction = !!orchestrateBottomAction
|
||||
const driveApiContext = useMemo(() => appId && nodeId
|
||||
const draftType = isBuildDraftActive ? ('debug_build' as const) : ('draft' as const)
|
||||
const configApiContext = useMemo(() => appId && nodeId
|
||||
? {
|
||||
agentId,
|
||||
draftType,
|
||||
versionId: selectedVersionSnapshot?.id ?? undefined,
|
||||
workflow: {
|
||||
appId,
|
||||
nodeId,
|
||||
},
|
||||
}
|
||||
: { agentId }, [agentId, appId, nodeId])
|
||||
: {
|
||||
agentId,
|
||||
draftType,
|
||||
versionId: selectedVersionSnapshot?.id ?? undefined,
|
||||
}, [agentId, appId, draftType, nodeId, selectedVersionSnapshot?.id])
|
||||
|
||||
return (
|
||||
<div className={cn('relative flex max-w-140 min-w-90 flex-[0_0_min(41.08280255%,560px)] flex-col overflow-hidden rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg', className)}>
|
||||
@@ -121,21 +135,23 @@ export function AgentOrchestratePanel({
|
||||
scrollbar: hasBottomAction ? 'z-20' : undefined,
|
||||
}}
|
||||
>
|
||||
<AgentDriveApiContextProvider value={driveApiContext}>
|
||||
<AgentConfigApiContextProvider value={configApiContext}>
|
||||
<AgentOrchestrateAddActionsProvider>
|
||||
<AgentModelField
|
||||
currentModel={currentModel}
|
||||
textGenerationModelList={textGenerationModelList}
|
||||
onSelect={onSelectModel}
|
||||
/>
|
||||
<AgentPromptEditor />
|
||||
<AgentSkills />
|
||||
<AgentFiles />
|
||||
<AgentTools />
|
||||
<AgentKnowledgeRetrieval />
|
||||
<AgentAdvancedSettings />
|
||||
<AgentBuildDraftChangedKeysProvider changedKeys={isBuildDraftActive ? buildDraftChangedKeys : EMPTY_BUILD_DRAFT_CHANGED_KEYS}>
|
||||
<AgentModelField
|
||||
currentModel={currentModel}
|
||||
textGenerationModelList={textGenerationModelList}
|
||||
onSelect={onSelectModel}
|
||||
/>
|
||||
<AgentPromptEditor />
|
||||
<AgentSkills />
|
||||
<AgentFiles />
|
||||
<AgentTools />
|
||||
<AgentKnowledgeRetrieval />
|
||||
<AgentAdvancedSettings />
|
||||
</AgentBuildDraftChangedKeysProvider>
|
||||
</AgentOrchestrateAddActionsProvider>
|
||||
</AgentDriveApiContextProvider>
|
||||
</AgentConfigApiContextProvider>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</AgentOrchestrateReadOnlyContext>
|
||||
|
||||
+51
-3
@@ -11,6 +11,24 @@ import { agentComposerDraftAtom } from '@/features/agent-v2/agent-composer/store
|
||||
import { AgentOrchestrateReadOnlyContext } from '../../read-only-context'
|
||||
import { AgentKnowledgeRetrieval } from '../index'
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/knowledge-retrieval/components/add-dataset', () => ({
|
||||
default: function MockAddKnowledge({
|
||||
onChange,
|
||||
}: {
|
||||
onChange: (datasets: Array<{ id: string, name: string }>) => void
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="common.operation.add workflow.nodes.knowledgeRetrieval.knowledge"
|
||||
onClick={() => onChange([{ id: 'dataset-2', name: 'Release Docs' }])}
|
||||
>
|
||||
Add mock knowledge
|
||||
</button>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
const agentKnowledgeDraft = {
|
||||
...defaultAgentSoulConfigFormState,
|
||||
knowledgeRetrievals: [
|
||||
@@ -136,6 +154,9 @@ describe('AgentKnowledgeRetrieval', () => {
|
||||
expect(within(dialog).getByRole('button', {
|
||||
name: 'workflow.nodes.knowledgeRetrieval.metadata.options.disabled.title',
|
||||
})).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', {
|
||||
name: 'agentV2.agentDetail.configure.knowledgeRetrieval.edit:{"name":"agentV2.agentDetail.configure.knowledgeRetrieval.retrievalTwo"}',
|
||||
})).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show the custom query input when query mode changes', async () => {
|
||||
@@ -162,7 +183,24 @@ describe('AgentKnowledgeRetrieval', () => {
|
||||
expect(within(dialog).queryByText('agentV2.agentDetail.configure.knowledgeRetrieval.dialog.query.agentDescription')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show inline validation for missing datasets and blank custom queries', async () => {
|
||||
it('should not create a new retrieval until knowledge is selected', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderKnowledgeRetrieval()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'agentV2.agentDetail.configure.knowledgeRetrieval.add' }))
|
||||
|
||||
expect(screen.queryByRole('button', {
|
||||
name: 'agentV2.agentDetail.configure.knowledgeRetrieval.edit:{"name":"agentV2.agentDetail.configure.knowledgeRetrieval.retrievalTwo"}',
|
||||
})).not.toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Close' }))
|
||||
|
||||
expect(screen.queryByRole('button', {
|
||||
name: 'agentV2.agentDetail.configure.knowledgeRetrieval.edit:{"name":"agentV2.agentDetail.configure.knowledgeRetrieval.retrievalTwo"}',
|
||||
})).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show inline validation for blank custom queries after knowledge is selected', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderKnowledgeRetrieval()
|
||||
|
||||
@@ -171,7 +209,9 @@ describe('AgentKnowledgeRetrieval', () => {
|
||||
name: 'agentV2.agentDetail.configure.knowledgeRetrieval.dialog.title',
|
||||
})
|
||||
|
||||
expect(within(dialog).getByText('common.errorMsg.fieldRequired:{"field":"agentV2.agentDetail.configure.knowledgeRetrieval.dialog.knowledge.label"}')).toBeInTheDocument()
|
||||
await user.click(within(dialog).getByRole('button', {
|
||||
name: 'common.operation.add workflow.nodes.knowledgeRetrieval.knowledge',
|
||||
}))
|
||||
|
||||
await user.click(within(dialog).getByRole('radio', {
|
||||
name: 'agentV2.agentDetail.configure.knowledgeRetrieval.dialog.query.custom',
|
||||
@@ -240,6 +280,9 @@ describe('AgentKnowledgeRetrieval', () => {
|
||||
await user.type(within(dialog).getByRole('textbox', {
|
||||
name: 'agentV2.agentDetail.configure.knowledgeRetrieval.dialog.query.customInputLabel',
|
||||
}), 'new release notes')
|
||||
await user.click(within(dialog).getByRole('button', {
|
||||
name: 'common.operation.add workflow.nodes.knowledgeRetrieval.knowledge',
|
||||
}))
|
||||
|
||||
const knowledgeConfig = JSON.parse(screen.getByLabelText('config snapshot').textContent ?? '{}')
|
||||
expect(knowledgeConfig.sets).toEqual(expect.arrayContaining([
|
||||
@@ -253,7 +296,12 @@ describe('AgentKnowledgeRetrieval', () => {
|
||||
}),
|
||||
expect.objectContaining({
|
||||
name: 'agentV2.agentDetail.configure.knowledgeRetrieval.retrievalTwo',
|
||||
datasets: [],
|
||||
datasets: [
|
||||
expect.objectContaining({
|
||||
id: 'dataset-2',
|
||||
name: 'Release Docs',
|
||||
}),
|
||||
],
|
||||
query: {
|
||||
mode: 'user_query',
|
||||
value: 'new release notes',
|
||||
|
||||
+29
-8
@@ -218,12 +218,14 @@ const createMetadataCondition = ({ id, name, type }: MetadataInDoc): MetadataFil
|
||||
export function AgentKnowledgeRetrievalDialog({
|
||||
item,
|
||||
initialName,
|
||||
onItemCreate,
|
||||
onItemChange,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
item?: AgentKnowledgeRetrievalItem
|
||||
initialName?: string
|
||||
onItemCreate?: (item: AgentKnowledgeRetrievalItem) => void
|
||||
onItemChange?: (item: AgentKnowledgeRetrievalItem) => void
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
@@ -294,6 +296,20 @@ export function AgentKnowledgeRetrievalDialog({
|
||||
}
|
||||
})
|
||||
}
|
||||
const createItemFromDialogState = (patch: Partial<AgentKnowledgeRetrievalItem>): AgentKnowledgeRetrievalItem => ({
|
||||
id: globalThis.crypto?.randomUUID?.() ?? `retrieval-${Date.now()}`,
|
||||
name,
|
||||
queryMode,
|
||||
customQuery,
|
||||
selectedDatasets,
|
||||
retrievalMode,
|
||||
multipleRetrievalConfig,
|
||||
singleRetrievalConfig,
|
||||
metadataFilterMode,
|
||||
metadataFilteringConditions,
|
||||
metadataModelConfig,
|
||||
...patch,
|
||||
})
|
||||
const updateItem = (patch: Partial<AgentKnowledgeRetrievalItem>) => {
|
||||
if (!item)
|
||||
return
|
||||
@@ -313,6 +329,17 @@ export function AgentKnowledgeRetrievalDialog({
|
||||
...patch,
|
||||
})
|
||||
}
|
||||
const handleSelectedDatasetsChange = (nextDatasets: DataSet[]) => {
|
||||
patchDialogState({ selectedDatasets: nextDatasets })
|
||||
|
||||
if (item) {
|
||||
updateItem({ selectedDatasets: nextDatasets })
|
||||
return
|
||||
}
|
||||
|
||||
if (nextDatasets.length > 0)
|
||||
onItemCreate?.(createItemFromDialogState({ selectedDatasets: nextDatasets }))
|
||||
}
|
||||
const metadataList = useMemo(() => {
|
||||
const datasetsWithMetadata = selectedDatasets.filter(dataset => !!dataset.doc_metadata)
|
||||
|
||||
@@ -523,10 +550,7 @@ export function AgentKnowledgeRetrievalDialog({
|
||||
<AddKnowledge
|
||||
selectedIds={selectedDatasets.map(dataset => dataset.id)}
|
||||
modal
|
||||
onChange={(nextDatasets) => {
|
||||
patchDialogState({ selectedDatasets: nextDatasets })
|
||||
updateItem({ selectedDatasets: nextDatasets })
|
||||
}}
|
||||
onChange={handleSelectedDatasetsChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -534,10 +558,7 @@ export function AgentKnowledgeRetrievalDialog({
|
||||
<>
|
||||
<DatasetList
|
||||
list={selectedDatasets}
|
||||
onChange={(nextDatasets) => {
|
||||
patchDialogState({ selectedDatasets: nextDatasets })
|
||||
updateItem({ selectedDatasets: nextDatasets })
|
||||
}}
|
||||
onChange={handleSelectedDatasetsChange}
|
||||
settingsDrawerBackdropClassName="bg-background-overlay"
|
||||
settingsDrawerBackdropForceRender
|
||||
settingsDrawerPopupClassName="data-[swipe-direction=right]:top-6 data-[swipe-direction=right]:bottom-6"
|
||||
|
||||
+17
-12
@@ -3,7 +3,7 @@
|
||||
import type { AgentOrchestrateAddActionOptions } from '../add-actions-context'
|
||||
import type { AgentKnowledgeRetrievalItem } from '@/features/agent-v2/agent-composer/form-state'
|
||||
import { useAtom } from 'jotai'
|
||||
import { useState } from 'react'
|
||||
import { useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { agentComposerKnowledgeRetrievalsAtom } from '@/features/agent-v2/agent-composer/store-modules/knowledge'
|
||||
import { useRegisterAgentOrchestrateAddAction } from '../add-actions-context'
|
||||
@@ -49,7 +49,9 @@ export function AgentKnowledgeRetrieval() {
|
||||
const { t } = useTranslation('agentV2')
|
||||
const [retrievals, setRetrievals] = useAtom(agentComposerKnowledgeRetrievalsAtom)
|
||||
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false)
|
||||
const [addDialogName, setAddDialogName] = useState<string>()
|
||||
const [editingRetrieval, setEditingRetrieval] = useState<AgentKnowledgeRetrievalItem | null>(null)
|
||||
const addOptionsRef = useRef<AgentOrchestrateAddActionOptions | undefined>(undefined)
|
||||
const knowledgeRetrievalTip = t('agentDetail.configure.knowledgeRetrieval.tip')
|
||||
const retrievalListId = 'agent-configure-knowledge-retrieval-list'
|
||||
const isDialogOpen = isAddDialogOpen || !!editingRetrieval
|
||||
@@ -66,16 +68,16 @@ export function AgentKnowledgeRetrieval() {
|
||||
return t('agentDetail.configure.knowledgeRetrieval.defaultName', { index })
|
||||
}
|
||||
const addRetrieval = (options?: AgentOrchestrateAddActionOptions) => {
|
||||
const nextRetrieval: AgentKnowledgeRetrievalItem = {
|
||||
id: globalThis.crypto?.randomUUID?.() ?? `retrieval-${Date.now()}`,
|
||||
name: getDefaultRetrievalName(retrievals.length + 1),
|
||||
queryMode: 'agent',
|
||||
}
|
||||
|
||||
setRetrievals([...retrievals, nextRetrieval])
|
||||
setEditingRetrieval(nextRetrieval)
|
||||
addOptionsRef.current = options
|
||||
setAddDialogName(getDefaultRetrievalName(retrievals.length + 1))
|
||||
setIsAddDialogOpen(true)
|
||||
options?.onAdded?.(nextRetrieval)
|
||||
}
|
||||
const createRetrieval = (nextRetrieval: AgentKnowledgeRetrievalItem) => {
|
||||
setRetrievals(current => [...current, nextRetrieval])
|
||||
setEditingRetrieval(nextRetrieval)
|
||||
setIsAddDialogOpen(false)
|
||||
addOptionsRef.current?.onAdded?.(nextRetrieval)
|
||||
addOptionsRef.current = undefined
|
||||
}
|
||||
useRegisterAgentOrchestrateAddAction('knowledge', addRetrieval)
|
||||
|
||||
@@ -114,13 +116,16 @@ export function AgentKnowledgeRetrieval() {
|
||||
</ConfigureSection>
|
||||
<AgentKnowledgeRetrievalDialog
|
||||
item={editingRetrieval ?? undefined}
|
||||
initialName={editingRetrieval ? (editingRetrieval.name ?? (editingRetrieval.nameKey ? t(editingRetrieval.nameKey) : editingRetrieval.id)) : undefined}
|
||||
initialName={editingRetrieval ? (editingRetrieval.name ?? (editingRetrieval.nameKey ? t(editingRetrieval.nameKey) : editingRetrieval.id)) : addDialogName}
|
||||
onItemCreate={createRetrieval}
|
||||
onItemChange={updateRetrieval}
|
||||
open={isDialogOpen}
|
||||
onOpenChange={(open) => {
|
||||
setIsAddDialogOpen(open)
|
||||
if (!open)
|
||||
if (!open) {
|
||||
setEditingRetrieval(null)
|
||||
addOptionsRef.current = undefined
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
|
||||
+55
-8
@@ -1,15 +1,19 @@
|
||||
'use client'
|
||||
|
||||
import type { DefaultModel, Model } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import type { FormValue, Model } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import type { AgentComposerModel } from '@/features/agent-v2/agent-composer/form-state'
|
||||
import { FieldLabel, FieldRoot } from '@langgenius/dify-ui/field'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
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 { isAgentCompatibleModel } from '../../../model-compatibility'
|
||||
import { useAgentOrchestrateReadOnly } from '../read-only-context'
|
||||
|
||||
type AgentModelFieldProps = {
|
||||
currentModel?: DefaultModel
|
||||
currentModel?: AgentComposerModel
|
||||
textGenerationModelList: Model[]
|
||||
onSelect: (model: DefaultModel) => void
|
||||
onSelect: (model: AgentComposerModel) => void
|
||||
}
|
||||
|
||||
export function AgentModelField({
|
||||
@@ -18,7 +22,9 @@ export function AgentModelField({
|
||||
onSelect,
|
||||
}: AgentModelFieldProps) {
|
||||
const { t } = useTranslation('agentV2')
|
||||
const { t: tCommon } = useTranslation('common')
|
||||
const readOnly = useAgentOrchestrateReadOnly()
|
||||
const canConfigureModelSettings = !readOnly && !!currentModel?.provider && !!currentModel.model
|
||||
|
||||
return (
|
||||
<FieldRoot name="model" className="gap-1 pb-4">
|
||||
@@ -33,20 +39,61 @@ export function AgentModelField({
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<>
|
||||
<div className="flex h-8 min-w-0 items-center gap-px overflow-hidden rounded-lg">
|
||||
<ModelSelector
|
||||
defaultModel={currentModel}
|
||||
modelList={textGenerationModelList}
|
||||
triggerClassName="h-8! w-full rounded-lg! pr-10! [&_.i-ri-arrow-down-s-line]:hidden"
|
||||
triggerClassName="h-8! w-full rounded-r-none! [&_.i-ri-arrow-down-s-line]:hidden"
|
||||
popupClassName="w-(--anchor-width) max-w-[min(var(--anchor-width),var(--available-width),calc(100vw-32px))]"
|
||||
providerSettingsSource="agent"
|
||||
showModelMeta={false}
|
||||
modelPredicate={isAgentCompatibleModel}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
<div className="pointer-events-none absolute inset-y-0 right-0 flex w-8 items-center justify-center rounded-r-lg bg-components-button-tertiary-bg">
|
||||
<span aria-hidden="true" className="i-ri-equalizer-2-line size-4 text-text-tertiary" />
|
||||
<div className="w-8 shrink-0">
|
||||
<ModelParameterModal
|
||||
isAdvancedMode
|
||||
modelId={currentModel?.model ?? ''}
|
||||
provider={currentModel?.provider ?? ''}
|
||||
completionParams={(currentModel?.model_settings ?? {}) as FormValue}
|
||||
readonly={!canConfigureModelSettings}
|
||||
hideDebugWithMultipleModel
|
||||
popupClassName="w-[400px]"
|
||||
setModel={({ modelId, provider }) => {
|
||||
onSelect({
|
||||
...currentModel,
|
||||
provider,
|
||||
model: modelId,
|
||||
})
|
||||
}}
|
||||
onCompletionParamsChange={(modelSettings) => {
|
||||
if (!currentModel)
|
||||
return
|
||||
|
||||
onSelect({
|
||||
...currentModel,
|
||||
model_settings: modelSettings,
|
||||
})
|
||||
}}
|
||||
renderTrigger={() => (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
disabled={!canConfigureModelSettings}
|
||||
render={(
|
||||
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-l-none rounded-r-lg bg-components-button-tertiary-bg text-text-tertiary hover:bg-components-button-tertiary-bg-hover hover:text-text-secondary aria-disabled:cursor-not-allowed aria-disabled:text-text-disabled">
|
||||
<span className="sr-only">{tCommon('modelProvider.modelSettings')}</span>
|
||||
<span className="i-ri-equalizer-2-line size-4" />
|
||||
</span>
|
||||
)}
|
||||
/>
|
||||
<TooltipContent placement="top">
|
||||
{tCommon('modelProvider.modelSettings')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</FieldRoot>
|
||||
|
||||
+64
-8
@@ -3,14 +3,14 @@
|
||||
import type { KeyboardEvent, MouseEvent, PointerEvent as ReactPointerEvent } from 'react'
|
||||
import type { SlashMenuCategory, SlashMenuView } from './slash'
|
||||
import type { RosterReferenceToken } from '@/app/components/base/prompt-editor/plugins/roster-reference-block/utils'
|
||||
import type { AgentProviderTool, AgentTool } from '@/features/agent-v2/agent-composer/form-state'
|
||||
import type { AgentFileNode, AgentProviderTool, AgentTool } from '@/features/agent-v2/agent-composer/form-state'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Kbd } from '@langgenius/dify-ui/kbd'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
|
||||
import { useClipboard } from 'foxact/use-clipboard'
|
||||
import { useAtom, useAtomValue } from 'jotai'
|
||||
import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Infotip } from '@/app/components/base/infotip'
|
||||
import PromptEditor from '@/app/components/base/prompt-editor'
|
||||
@@ -22,7 +22,7 @@ import { agentComposerToolsAtom } from '@/features/agent-v2/agent-composer/store
|
||||
import { ENABLE_AGENT_CLI_TOOLS } from '@/features/agent-v2/agent-detail/configure/feature-flags'
|
||||
import { useAgentOrchestrateAddActions } from '../add-actions-context'
|
||||
import { AgentConfigureTipContent } from '../common/tip-content'
|
||||
import { useAgentDriveFiles, useAgentDriveSkills } from '../drive-context'
|
||||
import { useAgentConfigFiles, useAgentConfigSkills } from '../config-context'
|
||||
import { useAgentOrchestrateReadOnly } from '../read-only-context'
|
||||
import { useAgentPromptToolIconResolver } from './hooks'
|
||||
import { replaceTrailingSlashWithToken } from './options'
|
||||
@@ -63,12 +63,17 @@ function getProviderToolFromToken(token: RosterReferenceToken, tools: AgentTool[
|
||||
return tools.find(tool =>
|
||||
tool.kind === 'provider'
|
||||
&& (
|
||||
token.id === `${tool.id}/*`
|
||||
token.id === tool.id
|
||||
|| token.id === `${tool.id}/*`
|
||||
|| tool.actions.some(action => token.id === `${tool.id}/${action.toolName}`)
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
const flattenFileNodes = (files: AgentFileNode[]): AgentFileNode[] => files.flatMap(file => (
|
||||
file.children?.length ? flattenFileNodes(file.children) : [file]
|
||||
))
|
||||
|
||||
function AgentPromptRosterReferenceIcon({
|
||||
token,
|
||||
tools,
|
||||
@@ -151,8 +156,8 @@ export function AgentPromptEditor() {
|
||||
const { t } = useTranslation('agentV2')
|
||||
const readOnly = useAgentOrchestrateReadOnly()
|
||||
const [value, setValue] = useAtom(agentComposerPromptAtom)
|
||||
const { skills } = useAgentDriveSkills()
|
||||
const { files } = useAgentDriveFiles()
|
||||
const { skills } = useAgentConfigSkills()
|
||||
const { files } = useAgentConfigFiles()
|
||||
const [tools, setTools] = useAtom(agentComposerToolsAtom)
|
||||
const { getConfiguredToolIcon } = useAgentPromptToolIconResolver()
|
||||
const retrievals = useAtomValue(agentComposerKnowledgeRetrievalsAtom)
|
||||
@@ -165,7 +170,8 @@ export function AgentPromptEditor() {
|
||||
insertLabel={t('agentDetail.configure.prompt.insert.label').toLocaleLowerCase()}
|
||||
/>
|
||||
)
|
||||
const { copied, copy, reset } = useClipboard({
|
||||
const { copied, copy } = useClipboard({
|
||||
timeout: 2000,
|
||||
onCopyError: () => {
|
||||
toast.error(t('agentDetail.configure.prompt.copyFailed'))
|
||||
},
|
||||
@@ -174,6 +180,34 @@ export function AgentPromptEditor() {
|
||||
const [isSlashMenuOpen, setIsSlashMenuOpen] = useState(false)
|
||||
const rootRef = useRef<HTMLDivElement>(null)
|
||||
const editorRef = useRef<HTMLDivElement>(null)
|
||||
const configuredReferenceIds = useMemo(() => {
|
||||
const skillIds = new Set<string>()
|
||||
skills.forEach((skill) => {
|
||||
skillIds.add(skill.id)
|
||||
skillIds.add(encodeURIComponent(skill.id))
|
||||
if (skill.skillMdKey) {
|
||||
skillIds.add(skill.skillMdKey)
|
||||
skillIds.add(encodeURIComponent(skill.skillMdKey))
|
||||
}
|
||||
})
|
||||
|
||||
const fileIds = new Set<string>()
|
||||
flattenFileNodes(files).forEach((file) => {
|
||||
fileIds.add(file.id)
|
||||
fileIds.add(encodeURIComponent(file.id))
|
||||
if (file.driveKey) {
|
||||
fileIds.add(file.driveKey)
|
||||
fileIds.add(encodeURIComponent(file.driveKey))
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
skills: skillIds,
|
||||
files: fileIds,
|
||||
knowledge: new Set(retrievals.map(retrieval => retrieval.id)),
|
||||
cliTools: new Set(tools.flatMap(tool => tool.kind === 'cli' ? [tool.id] : [])),
|
||||
}
|
||||
}, [files, retrievals, skills, tools])
|
||||
|
||||
const handleCopyPrompt = useCallback(() => {
|
||||
void copy(value)
|
||||
@@ -269,6 +303,9 @@ export function AgentPromptEditor() {
|
||||
if (token.kind !== 'tool' && token.kind !== 'tool-all' && token.kind !== 'cli_tool')
|
||||
return null
|
||||
|
||||
if ((token.kind === 'tool' || token.kind === 'tool-all') && !getProviderToolFromToken(token, tools))
|
||||
return null
|
||||
|
||||
return (
|
||||
<AgentPromptRosterReferenceIcon
|
||||
token={token}
|
||||
@@ -278,6 +315,25 @@ export function AgentPromptEditor() {
|
||||
)
|
||||
}, [getConfiguredToolIcon, tools])
|
||||
|
||||
const getRosterReferenceWarning = useCallback((token: RosterReferenceToken) => {
|
||||
const warning = t('agentDetail.configure.prompt.referenceMissing', { name: token.label })
|
||||
|
||||
if (token.kind === 'skill')
|
||||
return configuredReferenceIds.skills.has(token.id) ? undefined : warning
|
||||
|
||||
if (token.kind === 'file')
|
||||
return configuredReferenceIds.files.has(token.id) ? undefined : warning
|
||||
|
||||
if (token.kind === 'knowledge')
|
||||
return configuredReferenceIds.knowledge.has(token.id) ? undefined : warning
|
||||
|
||||
if (token.kind === 'cli_tool')
|
||||
return ENABLE_AGENT_CLI_TOOLS && configuredReferenceIds.cliTools.has(token.id) ? undefined : warning
|
||||
|
||||
if (token.kind === 'tool' || token.kind === 'tool-all')
|
||||
return getProviderToolFromToken(token, tools) ? undefined : warning
|
||||
}, [configuredReferenceIds, t, tools])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSlashMenuOpen)
|
||||
return
|
||||
@@ -342,7 +398,6 @@ export function AgentPromptEditor() {
|
||||
aria-label={copied ? t('agentDetail.configure.prompt.copied') : t('agentDetail.configure.prompt.copy')}
|
||||
className="flex size-6 shrink-0 items-center justify-center rounded-md p-0.5 text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden"
|
||||
onClick={handleCopyPrompt}
|
||||
onMouseLeave={reset}
|
||||
>
|
||||
<span aria-hidden className={copied ? 'i-ri-check-line size-4' : 'i-ri-clipboard-line size-4'} />
|
||||
</button>
|
||||
@@ -382,6 +437,7 @@ export function AgentPromptEditor() {
|
||||
rosterReferenceBlock={{
|
||||
show: true,
|
||||
renderIcon: renderRosterReferenceIcon,
|
||||
getWarning: getRosterReferenceWarning,
|
||||
}}
|
||||
disableSlashPicker
|
||||
disableBracePicker
|
||||
|
||||
+15
-8
@@ -57,14 +57,18 @@ const createReferenceToken = (kind: string, id: string, label?: string) => (
|
||||
`[§${kind}:${id}${label ? `:${label}` : ''}§]`
|
||||
)
|
||||
|
||||
const createDriveReferenceToken = (kind: 'skill' | 'file', driveKey: string, label: string) => (
|
||||
createReferenceToken(kind, encodeURIComponent(driveKey), label)
|
||||
const createConfigReferenceToken = (kind: 'skill' | 'file', name: string, label: string) => (
|
||||
createReferenceToken(kind, name, label)
|
||||
)
|
||||
|
||||
const isPromptReferenceItem = (item: AgentOrchestrateAddedItem): item is AgentFileNode | AgentSkill => (
|
||||
'id' in item && 'name' in item
|
||||
)
|
||||
|
||||
const isAgentFileNode = (item: AgentOrchestrateAddedItem): item is AgentFileNode => (
|
||||
'icon' in item
|
||||
)
|
||||
|
||||
const isCliToolItem = (item: AgentOrchestrateAddedItem): item is Extract<AgentTool, { kind: 'cli' }> => (
|
||||
'kind' in item && item.kind === 'cli'
|
||||
)
|
||||
@@ -95,8 +99,8 @@ export function AgentPromptSlashMenu({
|
||||
if (view === 'skills') {
|
||||
onAddSkill?.({
|
||||
onAdded: (item) => {
|
||||
if (isPromptReferenceItem(item) && 'skillMdKey' in item && typeof item.skillMdKey === 'string')
|
||||
onSelect(createDriveReferenceToken('skill', item.skillMdKey, item.name))
|
||||
if (isPromptReferenceItem(item))
|
||||
onSelect(createConfigReferenceToken('skill', item.id, item.name))
|
||||
},
|
||||
})
|
||||
return
|
||||
@@ -105,8 +109,8 @@ export function AgentPromptSlashMenu({
|
||||
if (view === 'files') {
|
||||
onAddFile?.({
|
||||
onAdded: (item) => {
|
||||
if (isPromptReferenceItem(item) && 'driveKey' in item && typeof item.driveKey === 'string')
|
||||
onSelect(createDriveReferenceToken('file', item.driveKey, item.name))
|
||||
if (isAgentFileNode(item))
|
||||
onSelect(createConfigReferenceToken('file', item.configName ?? item.id, item.name))
|
||||
},
|
||||
})
|
||||
return
|
||||
@@ -234,7 +238,7 @@ function AgentPromptSkillRows({
|
||||
key={skill.id}
|
||||
icon="i-ri-box-3-line"
|
||||
label={skill.name}
|
||||
onClick={() => skill.skillMdKey && onSelect(createDriveReferenceToken('skill', skill.skillMdKey, skill.name))}
|
||||
onClick={() => onSelect(createConfigReferenceToken('skill', skill.id, skill.name))}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
@@ -259,7 +263,10 @@ function AgentPromptFileRows({
|
||||
label={file.name}
|
||||
depth={depth}
|
||||
hasChildren={!!file.children?.length}
|
||||
onClick={() => file.driveKey && onSelect(createDriveReferenceToken('file', file.driveKey, file.name))}
|
||||
onClick={() => {
|
||||
if (!file.children?.length)
|
||||
onSelect(createConfigReferenceToken('file', file.configName ?? file.id, file.name))
|
||||
}}
|
||||
/>
|
||||
{!!file.children?.length && (
|
||||
<AgentPromptFileRows files={file.children} depth={depth + 1} onSelect={onSelect} />
|
||||
|
||||
+25
-11
@@ -10,10 +10,10 @@ import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { formatForDisplay, useHotkey } from '@tanstack/react-hotkeys'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import { useState } from 'react'
|
||||
import { useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useKnowledgeValidationMessage, validateKnowledgeRetrievals } from '@/features/agent-v2/agent-composer/knowledge-validation'
|
||||
import { hasAgentComposerUnpublishedChangesAtom } from '@/features/agent-v2/agent-composer/store'
|
||||
import { hasAgentComposerUnpublishedChangesAtom, isAgentComposerDirtyAtom } from '@/features/agent-v2/agent-composer/store'
|
||||
import { agentComposerKnowledgeRetrievalsAtom } from '@/features/agent-v2/agent-composer/store-modules/knowledge'
|
||||
import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now'
|
||||
import useTimestamp from '@/hooks/use-timestamp'
|
||||
@@ -37,27 +37,32 @@ type AgentConfigurePublishBarProps = {
|
||||
selectedVersionSnapshot?: AgentConfigSnapshotSummaryResponse | null
|
||||
onPublish?: () => void | Promise<void>
|
||||
onExitVersions?: () => void
|
||||
onOpenVersions: () => void
|
||||
onOpenVersions?: () => void
|
||||
}
|
||||
|
||||
function getPublishState({
|
||||
activeConfigIsPublished,
|
||||
activeConfigSnapshot,
|
||||
isDirty,
|
||||
hasLocalChanges,
|
||||
hasUnpublishedChanges,
|
||||
isPublishing,
|
||||
}: {
|
||||
activeConfigIsPublished?: boolean
|
||||
activeConfigSnapshot?: AgentConfigSnapshotSummaryResponse | null
|
||||
isDirty: boolean
|
||||
hasLocalChanges: boolean
|
||||
hasUnpublishedChanges: boolean
|
||||
isPublishing: boolean
|
||||
}): AgentConfigurePublishState {
|
||||
if (isPublishing)
|
||||
return 'publishing'
|
||||
|
||||
if (hasLocalChanges)
|
||||
return 'unpublished'
|
||||
|
||||
if (activeConfigIsPublished)
|
||||
return 'published'
|
||||
|
||||
if (isDirty)
|
||||
if (hasUnpublishedChanges)
|
||||
return 'unpublished'
|
||||
|
||||
if (!activeConfigSnapshot)
|
||||
@@ -96,20 +101,29 @@ export function AgentConfigurePublishBar({
|
||||
const { formatTimeFromNow } = useFormatTimeFromNow()
|
||||
const queryClient = useQueryClient()
|
||||
const [publishBarMode, setPublishBarMode] = useState<PublishBarMode>({ status: 'compact' })
|
||||
const lastKnownPublishedRef = useRef(false)
|
||||
if (activeConfigIsPublished === true)
|
||||
lastKnownPublishedRef.current = true
|
||||
if (activeConfigIsPublished === false)
|
||||
lastKnownPublishedRef.current = false
|
||||
const stableActiveConfigIsPublished = activeConfigIsPublished ?? (lastKnownPublishedRef.current ? true : undefined)
|
||||
const hasUnpublishedChanges = useAtomValue(hasAgentComposerUnpublishedChangesAtom)
|
||||
const hasLocalChanges = useAtomValue(isAgentComposerDirtyAtom)
|
||||
const knowledgeRetrievals = useAtomValue(agentComposerKnowledgeRetrievalsAtom)
|
||||
const knowledgeValidation = validateKnowledgeRetrievals(knowledgeRetrievals)
|
||||
const getValidationMessage = useKnowledgeValidationMessage()
|
||||
const publishableState = getPublishState({
|
||||
activeConfigIsPublished,
|
||||
activeConfigIsPublished: stableActiveConfigIsPublished,
|
||||
activeConfigSnapshot,
|
||||
isDirty: hasUnpublishedChanges,
|
||||
hasLocalChanges,
|
||||
hasUnpublishedChanges,
|
||||
isPublishing: false,
|
||||
})
|
||||
const publishState = getPublishState({
|
||||
activeConfigIsPublished,
|
||||
activeConfigIsPublished: stableActiveConfigIsPublished,
|
||||
activeConfigSnapshot,
|
||||
isDirty: hasUnpublishedChanges,
|
||||
hasLocalChanges,
|
||||
hasUnpublishedChanges,
|
||||
isPublishing,
|
||||
})
|
||||
const publishIsAvailable = !isPublishing && (publishableState === 'draft' || publishableState === 'unpublished')
|
||||
@@ -296,7 +310,7 @@ export function AgentConfigurePublishBar({
|
||||
statusLabel={currentStateMeta.statusLabel}
|
||||
canPublish={canPublish}
|
||||
onCancelImpact={() => setPublishBarMode({ status: 'compact' })}
|
||||
onOpenVersions={onOpenVersions}
|
||||
onOpenVersions={() => onOpenVersions?.()}
|
||||
onPublishRequest={handlePublishRequest}
|
||||
/>
|
||||
</CollapsibleRoot>
|
||||
|
||||
+294
-757
File diff suppressed because it is too large
Load Diff
+78
-44
@@ -1,5 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import type { AgentFileNode } from '@/features/agent-v2/agent-composer/form-state'
|
||||
import {
|
||||
DialogCloseButton,
|
||||
@@ -12,7 +13,6 @@ import { ScrollArea } from '@langgenius/dify-ui/scroll-area'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import { AgentFileTree } from '../files/tree'
|
||||
import { countAgentFileNodes } from '../utils'
|
||||
|
||||
type AgentSkillFileNode = AgentFileNode
|
||||
|
||||
@@ -26,7 +26,9 @@ type AgentSkillDetailSection = {
|
||||
export type AgentSkillDetail = {
|
||||
description: string
|
||||
fileCount?: number
|
||||
fileListTitle?: string
|
||||
files: AgentSkillFileNode[]
|
||||
folderOpenState?: (context: { file: AgentSkillFileNode, depth: number }) => boolean
|
||||
filePreview?: {
|
||||
binary?: boolean
|
||||
content?: string
|
||||
@@ -38,20 +40,30 @@ export type AgentSkillDetail = {
|
||||
isImage?: boolean
|
||||
isLoading?: boolean
|
||||
}
|
||||
onFolderOpenChange?: (context: { file: AgentSkillFileNode, depth: number, open: boolean }) => void
|
||||
onSelectFile?: (file: AgentSkillFileNode) => void
|
||||
renderFolderSuffix?: (context: { file: AgentSkillFileNode, depth: number }) => ReactNode
|
||||
selectedFileId?: string
|
||||
sections: AgentSkillDetailSection[]
|
||||
}
|
||||
|
||||
const keepSkillFoldersClosed = () => false
|
||||
|
||||
function AgentSkillFileList({
|
||||
fileListTitle,
|
||||
files,
|
||||
fileCount,
|
||||
folderOpenState,
|
||||
onFolderOpenChange,
|
||||
onSelectFile,
|
||||
renderFolderSuffix,
|
||||
selectedFileId,
|
||||
}: {
|
||||
fileListTitle?: string
|
||||
files: AgentSkillFileNode[]
|
||||
fileCount: number
|
||||
folderOpenState?: AgentSkillDetail['folderOpenState']
|
||||
onFolderOpenChange?: AgentSkillDetail['onFolderOpenChange']
|
||||
onSelectFile?: (file: AgentSkillFileNode) => void
|
||||
renderFolderSuffix?: AgentSkillDetail['renderFolderSuffix']
|
||||
selectedFileId?: string
|
||||
}) {
|
||||
const { t } = useTranslation('agentV2')
|
||||
@@ -61,7 +73,11 @@ function AgentSkillFileList({
|
||||
files={files}
|
||||
selectedFileId={selectedFileId}
|
||||
labelledBy="agent-skill-detail-files-heading"
|
||||
className="h-[258px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-on-panel-item-bg p-1 shadow-xs shadow-shadow-shadow-3"
|
||||
className="h-full bg-background-section p-1"
|
||||
scrollAreaClassName="flex-1"
|
||||
folderOpenStrategy={keepSkillFoldersClosed}
|
||||
folderOpenState={folderOpenState}
|
||||
onFolderOpenChange={onFolderOpenChange}
|
||||
renderFile={onSelectFile
|
||||
? ({ file, selected, children }) => (
|
||||
<FileTreeFile selected={selected} onClick={() => onSelectFile(file)}>
|
||||
@@ -69,17 +85,12 @@ function AgentSkillFileList({
|
||||
</FileTreeFile>
|
||||
)
|
||||
: undefined}
|
||||
renderFolderSuffix={renderFolderSuffix}
|
||||
header={(
|
||||
<>
|
||||
<h3 id="agent-skill-detail-files-heading" className="sr-only">
|
||||
{t('agentDetail.configure.skills.detail.files')}
|
||||
<h3 id="agent-skill-detail-files-heading" className="px-4 pt-3.5 pb-3 system-xl-semibold text-text-primary">
|
||||
{fileListTitle ?? t('agentDetail.configure.skills.detail.files')}
|
||||
</h3>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="px-2 py-1 system-2xs-semibold-uppercase text-text-tertiary"
|
||||
>
|
||||
{t('agentDetail.configure.skills.detail.fileCount', { count: fileCount })}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
@@ -138,7 +149,7 @@ function AgentFilePreviewContent({
|
||||
|
||||
if (isLoading || isDownloadLoading) {
|
||||
return (
|
||||
<div className="flex min-h-40 items-center justify-center">
|
||||
<div className="flex min-h-40 flex-1 items-center justify-center">
|
||||
<Loading type="area" />
|
||||
</div>
|
||||
)
|
||||
@@ -146,7 +157,7 @@ function AgentFilePreviewContent({
|
||||
|
||||
if (isError || isDownloadError) {
|
||||
return (
|
||||
<p className="system-sm-regular text-text-tertiary">
|
||||
<p className="px-4 system-sm-regular text-text-tertiary">
|
||||
{t('agentDetail.configure.files.preview.failed')}
|
||||
</p>
|
||||
)
|
||||
@@ -154,11 +165,11 @@ function AgentFilePreviewContent({
|
||||
|
||||
if (isImage && downloadUrl) {
|
||||
return (
|
||||
<div className="flex min-h-40 items-start justify-center">
|
||||
<div className="flex min-h-40 flex-1 items-start justify-center overflow-auto px-2 pb-4">
|
||||
<img
|
||||
src={downloadUrl}
|
||||
alt={fileName ?? ''}
|
||||
className="max-h-[560px] max-w-full rounded-lg object-contain"
|
||||
className="max-h-140 max-w-full rounded-lg object-contain"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
@@ -167,7 +178,7 @@ function AgentFilePreviewContent({
|
||||
if (binary) {
|
||||
if (downloadUrl) {
|
||||
return (
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2 px-4">
|
||||
<span className="system-sm-regular text-text-tertiary">
|
||||
{t('agentDetail.configure.files.preview.unsupported')}
|
||||
</span>
|
||||
@@ -185,7 +196,7 @@ function AgentFilePreviewContent({
|
||||
}
|
||||
|
||||
return (
|
||||
<p className="system-sm-regular text-text-tertiary">
|
||||
<p className="px-4 system-sm-regular text-text-tertiary">
|
||||
{t('agentDetail.configure.files.preview.empty')}
|
||||
</p>
|
||||
)
|
||||
@@ -193,16 +204,26 @@ function AgentFilePreviewContent({
|
||||
|
||||
if (!content) {
|
||||
return (
|
||||
<p className="system-sm-regular text-text-tertiary">
|
||||
<p className="px-4 system-sm-regular text-text-tertiary">
|
||||
{t('agentDetail.configure.files.preview.empty')}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
const lines = content.split('\n')
|
||||
|
||||
return (
|
||||
<pre className="m-0 pb-4 font-mono text-xs leading-5 break-words whitespace-pre-wrap text-text-secondary">
|
||||
{content}
|
||||
</pre>
|
||||
<div className="flex min-h-0 flex-1 overflow-auto px-2 pb-4">
|
||||
<pre
|
||||
aria-hidden="true"
|
||||
className="m-0 w-7 shrink-0 pr-2 text-right font-mono text-[13px] leading-[22px] text-text-quaternary select-none"
|
||||
>
|
||||
{lines.map((_, index) => String(index + 1).padStart(2, '0')).join('\n')}
|
||||
</pre>
|
||||
<pre className="m-0 min-w-max flex-1 font-mono text-[13px] leading-[22px] whitespace-pre text-text-primary">
|
||||
{content}
|
||||
</pre>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -214,27 +235,46 @@ export function AgentSkillDetailDialog({
|
||||
detail: AgentSkillDetail
|
||||
}) {
|
||||
const { t } = useTranslation('agentV2')
|
||||
const fileCount = detail.fileCount ?? countAgentFileNodes(detail.files)
|
||||
const previewTitle = detail.filePreview?.fileName
|
||||
|
||||
return (
|
||||
<DialogContent backdropProps={{ forceRender: true }} backdropClassName="fixed" className="flex h-[min(720px,calc(100dvh-2rem))] max-h-none w-[min(960px,calc(100vw-2rem))] flex-col overflow-hidden rounded-2xl p-0">
|
||||
<DialogCloseButton className="top-5 right-5" />
|
||||
<div className="shrink-0 border-b-[0.5px] border-components-panel-border-subtle pt-6 pr-14 pb-3 pl-6">
|
||||
<DialogTitle className="title-xl-semi-bold text-text-primary">
|
||||
{skillName}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="mt-1 system-xs-regular text-text-tertiary">
|
||||
<DialogContent backdropProps={{ forceRender: true }} backdropClassName="fixed" className="flex h-[min(720px,calc(100dvh-2rem))] max-h-none w-[min(960px,calc(100vw-2rem))] flex-row overflow-hidden rounded-2xl p-0">
|
||||
<div className="flex w-56 min-w-0 shrink-0 border-r-[0.5px] border-divider-subtle bg-background-section">
|
||||
<DialogDescription className="sr-only">
|
||||
{detail.description}
|
||||
</DialogDescription>
|
||||
<DialogTitle className="sr-only">
|
||||
{previewTitle || skillName}
|
||||
</DialogTitle>
|
||||
<div className="min-h-0 w-full">
|
||||
<AgentSkillFileList
|
||||
fileListTitle={detail.fileListTitle}
|
||||
files={detail.files}
|
||||
folderOpenState={detail.folderOpenState}
|
||||
onFolderOpenChange={detail.onFolderOpenChange}
|
||||
selectedFileId={detail.selectedFileId}
|
||||
onSelectFile={detail.onSelectFile}
|
||||
renderFolderSuffix={detail.renderFolderSuffix}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-0 flex-1 items-start">
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<div className="flex shrink-0 items-start gap-2 px-4 pt-3.5 pb-2">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
{!!previewTitle && (
|
||||
<h2 className="min-w-0 truncate system-xl-semibold text-text-primary" title={previewTitle}>
|
||||
{previewTitle}
|
||||
</h2>
|
||||
)}
|
||||
</div>
|
||||
<DialogCloseButton className="static size-7 shrink-0 rounded-md" />
|
||||
</div>
|
||||
<ScrollArea
|
||||
className="relative min-h-0 flex-1 self-stretch overflow-hidden has-[>_:first-child:focus-visible]:outline-2 has-[>_:first-child:focus-visible]:outline-offset-0 has-[>_:first-child:focus-visible]:outline-state-accent-solid"
|
||||
className="relative min-h-0 flex-1 overflow-hidden has-[>_:first-child:focus-visible]:outline-2 has-[>_:first-child:focus-visible]:outline-offset-0 has-[>_:first-child:focus-visible]:outline-state-accent-solid"
|
||||
label={t('agentDetail.configure.skills.detail.contentRegion')}
|
||||
slotClassNames={{
|
||||
viewport: 'overscroll-contain outline-none focus-visible:outline-none mask-linear-[to_bottom,transparent_0,black_min(40px,var(--scroll-area-overflow-y-start)),black_calc(100%_-_min(40px,var(--scroll-area-overflow-y-end,40px))),transparent_100%] mask-no-repeat',
|
||||
content: 'flex min-h-full w-full max-w-full min-w-0 flex-col gap-2 px-6 pt-4 pb-0',
|
||||
viewport: 'overscroll-contain outline-none focus-visible:outline-none',
|
||||
content: 'flex min-h-full w-full max-w-full min-w-0 flex-col gap-2',
|
||||
}}
|
||||
>
|
||||
{detail.filePreview && (
|
||||
@@ -251,17 +291,11 @@ export function AgentSkillDetailDialog({
|
||||
/>
|
||||
)}
|
||||
{detail.sections.map(section => (
|
||||
<AgentSkillDetailSectionBlock key={section.id} section={section} />
|
||||
<div key={section.id} className="px-4">
|
||||
<AgentSkillDetailSectionBlock section={section} />
|
||||
</div>
|
||||
))}
|
||||
</ScrollArea>
|
||||
<div className="flex w-56 max-w-56 min-w-0 shrink-0 items-start justify-center p-4 pl-2">
|
||||
<AgentSkillFileList
|
||||
files={detail.files}
|
||||
fileCount={fileCount}
|
||||
selectedFileId={detail.selectedFileId}
|
||||
onSelectFile={detail.onSelectFile}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
)
|
||||
|
||||
+14
-8
@@ -13,7 +13,7 @@ import { ConfigureSectionAddButton } from '../common/add-button'
|
||||
import { ConfigureSectionEmpty } from '../common/empty'
|
||||
import { ConfigureSection } from '../common/section'
|
||||
import { AgentConfigureTipContent } from '../common/tip-content'
|
||||
import { useAgentDriveApiContext } from '../drive-context'
|
||||
import { useAgentConfigApiContext } from '../config-context'
|
||||
import { AgentSkillItem } from './item'
|
||||
import { AgentSkillUploadDialog } from './upload-dialog'
|
||||
|
||||
@@ -23,11 +23,11 @@ export function AgentSkills() {
|
||||
const skillsListId = 'agent-configure-skills-list'
|
||||
const [isUploadOpen, setIsUploadOpen] = useState(false)
|
||||
const promptAddCallbackRef = useRef<AgentOrchestrateAddActionOptions['onAdded']>(undefined)
|
||||
const apiContext = useAgentDriveApiContext()
|
||||
const apiContext = useAgentConfigApiContext()
|
||||
const skills = useAtomValue(agentComposerSkillsAtom)
|
||||
const setSkills = useSetAtom(agentComposerSkillsAtom)
|
||||
const { mutate: deleteAgentSkill } = useMutation(consoleQuery.agent.byAgentId.skills.bySlug.delete.mutationOptions())
|
||||
const { mutate: deleteAppSkill } = useMutation(consoleQuery.apps.byAppId.agent.skills.bySlug.delete.mutationOptions())
|
||||
const { mutate: deleteAgentSkill } = useMutation(consoleQuery.agent.byAgentId.config.skills.byName.delete.mutationOptions())
|
||||
const { mutate: deleteAppSkill } = useMutation(consoleQuery.apps.byAppId.agent.config.skills.byName.delete.mutationOptions())
|
||||
|
||||
const handleOpenUpload = useCallback((options?: AgentOrchestrateAddActionOptions) => {
|
||||
promptAddCallbackRef.current = options?.onAdded
|
||||
@@ -52,8 +52,7 @@ export function AgentSkills() {
|
||||
|
||||
const handleRemoveSkill = useCallback((skillId: string) => {
|
||||
const skill = skills.find(item => item.id === skillId)
|
||||
const skillSlug = skill?.path ?? skill?.skillMdKey?.split('/', 1)[0]
|
||||
if (!skillSlug)
|
||||
if (!skill)
|
||||
return
|
||||
|
||||
const onSuccess = () => {
|
||||
@@ -63,10 +62,12 @@ export function AgentSkills() {
|
||||
deleteAppSkill({
|
||||
params: {
|
||||
app_id: apiContext.workflow.appId,
|
||||
slug: skillSlug,
|
||||
name: skill.name,
|
||||
},
|
||||
query: {
|
||||
node_id: apiContext.workflow.nodeId,
|
||||
draft_type: apiContext.draftType,
|
||||
version_id: apiContext.versionId,
|
||||
},
|
||||
}, { onSuccess })
|
||||
return
|
||||
@@ -75,7 +76,11 @@ export function AgentSkills() {
|
||||
deleteAgentSkill({
|
||||
params: {
|
||||
agent_id: apiContext.agentId,
|
||||
slug: skillSlug,
|
||||
name: skill.name,
|
||||
},
|
||||
query: {
|
||||
draft_type: apiContext.draftType,
|
||||
version_id: apiContext.versionId,
|
||||
},
|
||||
}, { onSuccess })
|
||||
}, [apiContext, deleteAgentSkill, deleteAppSkill, setSkills, skills])
|
||||
@@ -85,6 +90,7 @@ export function AgentSkills() {
|
||||
<ConfigureSection
|
||||
label={t('agentDetail.configure.skills.label')}
|
||||
labelId="agent-configure-skills-label"
|
||||
buildDraftChangeSection="skills"
|
||||
panelId={skillsListId}
|
||||
tip={<AgentConfigureTipContent type="skills" />}
|
||||
tipAriaLabel={skillsTip}
|
||||
|
||||
+24
-21
@@ -1,7 +1,8 @@
|
||||
'use client'
|
||||
|
||||
import type { AgentDriveApiContext } from '../drive-context'
|
||||
import type { AgentConfigApiContext } from '../config-context'
|
||||
import type { AgentSkill } from '@/features/agent-v2/agent-composer/form-state'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import {
|
||||
Dialog,
|
||||
} from '@langgenius/dify-ui/dialog'
|
||||
@@ -16,7 +17,7 @@ export function AgentSkillItem({
|
||||
skill,
|
||||
onRemove,
|
||||
}: {
|
||||
apiContext: AgentDriveApiContext
|
||||
apiContext: AgentConfigApiContext
|
||||
skill: AgentSkill
|
||||
onRemove: (skillId: string) => void
|
||||
}) {
|
||||
@@ -38,35 +39,37 @@ export function AgentSkillItem({
|
||||
|
||||
return (
|
||||
<Dialog open={isPreviewOpen} onOpenChange={setIsPreviewOpen}>
|
||||
<div className="group flex h-8 items-center gap-1 overflow-hidden rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-on-panel-item-bg py-1 pr-2.5 pl-2 shadow-xs shadow-shadow-shadow-3 focus-within:bg-components-panel-on-panel-item-bg-hover focus-within:shadow-sm hover:bg-components-panel-on-panel-item-bg-hover hover:pr-1 hover:shadow-sm has-[[data-agent-skill-remove-button]:focus-visible]:border-state-destructive-border! has-[[data-agent-skill-remove-button]:focus-visible]:bg-state-destructive-hover! has-[[data-agent-skill-remove-button]:focus-visible]:shadow-xs! has-[[data-agent-skill-remove-button]:hover]:border-state-destructive-border! has-[[data-agent-skill-remove-button]:hover]:bg-state-destructive-hover! has-[[data-agent-skill-remove-button]:hover]:shadow-xs!">
|
||||
<div className="group relative h-8 overflow-hidden rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-on-panel-item-bg shadow-xs shadow-shadow-shadow-3 focus-within:bg-components-panel-on-panel-item-bg-hover focus-within:shadow-sm hover:bg-components-panel-on-panel-item-bg-hover hover:shadow-sm has-[[data-agent-skill-remove-button]:focus-visible]:border-state-destructive-border! has-[[data-agent-skill-remove-button]:focus-visible]:bg-state-destructive-hover! has-[[data-agent-skill-remove-button]:focus-visible]:shadow-xs! has-[[data-agent-skill-remove-button]:hover]:border-state-destructive-border! has-[[data-agent-skill-remove-button]:hover]:bg-state-destructive-hover! has-[[data-agent-skill-remove-button]:hover]:shadow-xs!">
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-full min-w-0 flex-1 cursor-pointer items-center gap-1 rounded-md text-left outline-hidden focus-visible:ring-2 focus-visible:ring-state-accent-solid"
|
||||
aria-label={skill.name}
|
||||
className="flex h-full w-full min-w-0 cursor-pointer items-center gap-1 rounded-lg py-1 pr-2.5 pl-2 text-left outline-hidden select-none focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:ring-inset"
|
||||
onClick={handleOpenPreview}
|
||||
>
|
||||
<span aria-hidden className="i-custom-public-agent-building-blocks size-4 shrink-0" />
|
||||
<span className="min-w-0 flex-1 truncate system-sm-medium text-text-secondary">
|
||||
{skill.name}
|
||||
</span>
|
||||
</button>
|
||||
{!readOnly && (
|
||||
<div className="hidden shrink-0 items-center justify-center rounded-md p-0.5 group-focus-within:flex group-hover:flex">
|
||||
<button
|
||||
type="button"
|
||||
data-agent-skill-remove-button
|
||||
aria-label={t('agentDetail.configure.skills.remove', { name: skill.name })}
|
||||
onClick={handleRemove}
|
||||
className="flex size-5 items-center justify-center rounded-md text-text-tertiary hover:bg-state-destructive-hover hover:text-text-destructive focus-visible:bg-state-destructive-hover focus-visible:text-text-destructive focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden"
|
||||
>
|
||||
<span aria-hidden className="i-ri-delete-bin-line size-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex shrink-0 items-center justify-center group-focus-within:hidden group-hover:hidden">
|
||||
<span className="system-xs-regular text-text-tertiary">
|
||||
<span
|
||||
className={cn(
|
||||
'shrink-0 system-xs-regular text-text-tertiary',
|
||||
!readOnly && 'group-focus-within:opacity-0 group-hover:opacity-0',
|
||||
)}
|
||||
>
|
||||
{t('agentDetail.configure.skills.itemType')}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
{!readOnly && (
|
||||
<button
|
||||
type="button"
|
||||
data-agent-skill-remove-button
|
||||
aria-label={t('agentDetail.configure.skills.remove', { name: skill.name })}
|
||||
onClick={handleRemove}
|
||||
className="pointer-events-none absolute top-1/2 right-1 flex size-5 -translate-y-1/2 items-center justify-center rounded-md text-text-tertiary opacity-0 group-focus-within:pointer-events-auto group-focus-within:opacity-100 group-hover:pointer-events-auto group-hover:opacity-100 hover:bg-state-destructive-hover hover:text-text-destructive focus-visible:bg-state-destructive-hover focus-visible:text-text-destructive focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden"
|
||||
>
|
||||
<span aria-hidden className="i-ri-delete-bin-line size-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{isPreviewOpen && (
|
||||
<AgentSkillDetailDialog
|
||||
|
||||
+23
-19
@@ -1,9 +1,9 @@
|
||||
'use client'
|
||||
|
||||
import type { PostAgentByAgentIdSkillsUploadResponse } from '@dify/contracts/api/console/agent/types.gen'
|
||||
import type { PostAppsByAppIdAgentSkillsUploadResponse } from '@dify/contracts/api/console/apps/types.gen'
|
||||
import type { PostAgentByAgentIdConfigSkillsUploadResponse } from '@dify/contracts/api/console/agent/types.gen'
|
||||
import type { PostAppsByAppIdAgentConfigSkillsUploadResponse } from '@dify/contracts/api/console/apps/types.gen'
|
||||
import type { ChangeEvent, DragEvent } from 'react'
|
||||
import type { AgentDriveApiContext } from '../drive-context'
|
||||
import type { AgentConfigApiContext } from '../config-context'
|
||||
import type { AgentSkill } from '@/features/agent-v2/agent-composer/form-state'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
@@ -22,23 +22,19 @@ const skillPackageExtensions = ['.zip', '.skill']
|
||||
const getSkillNameFromFile = (file: File) => file.name.replace(/\.(?:skill|zip)$/iu, '') || file.name
|
||||
|
||||
const toUploadedSkill = (
|
||||
response: PostAgentByAgentIdSkillsUploadResponse | PostAppsByAppIdAgentSkillsUploadResponse,
|
||||
response: PostAgentByAgentIdConfigSkillsUploadResponse | PostAppsByAppIdAgentConfigSkillsUploadResponse,
|
||||
file: File,
|
||||
): AgentSkill => {
|
||||
const name = response.skill?.name
|
||||
?? response.manifest?.name
|
||||
?? getSkillNameFromFile(file)
|
||||
const id = response.skill?.skill_md_key
|
||||
?? response.skill?.path
|
||||
?? name
|
||||
const name = response.skill?.name ?? getSkillNameFromFile(file)
|
||||
|
||||
return {
|
||||
description: response.skill?.description ?? response.manifest?.description ?? undefined,
|
||||
archiveKey: response.skill?.archive_key ?? undefined,
|
||||
id,
|
||||
description: response.skill?.description ?? undefined,
|
||||
fileId: response.skill?.file_id ?? undefined,
|
||||
hash: response.skill?.hash ?? undefined,
|
||||
id: name,
|
||||
mimeType: response.skill?.mime_type ?? undefined,
|
||||
name,
|
||||
path: response.skill?.path ?? undefined,
|
||||
skillMdKey: response.skill?.skill_md_key ?? undefined,
|
||||
size: response.skill?.size ?? undefined,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,7 +187,7 @@ export function AgentSkillUploadDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
apiContext: AgentDriveApiContext
|
||||
apiContext: AgentConfigApiContext
|
||||
onUploaded?: (skill: AgentSkill) => void
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
@@ -199,8 +195,8 @@ export function AgentSkillUploadDialog({
|
||||
const { t } = useTranslation('agentV2')
|
||||
const { t: tCommon } = useTranslation('common')
|
||||
const [file, setFile] = useState<File>()
|
||||
const uploadAgentSkillMutation = useMutation(consoleQuery.agent.byAgentId.skills.upload.post.mutationOptions())
|
||||
const uploadWorkflowSkillMutation = useMutation(consoleQuery.apps.byAppId.agent.skills.upload.post.mutationOptions())
|
||||
const uploadAgentSkillMutation = useMutation(consoleQuery.agent.byAgentId.config.skills.upload.post.mutationOptions())
|
||||
const uploadWorkflowSkillMutation = useMutation(consoleQuery.apps.byAppId.agent.config.skills.upload.post.mutationOptions())
|
||||
const uploadSkillMutation = apiContext.workflow ? uploadWorkflowSkillMutation : uploadAgentSkillMutation
|
||||
|
||||
const handleUpload = () => {
|
||||
@@ -208,7 +204,9 @@ export function AgentSkillUploadDialog({
|
||||
return
|
||||
|
||||
const options = {
|
||||
onSuccess: (response: PostAgentByAgentIdSkillsUploadResponse | PostAppsByAppIdAgentSkillsUploadResponse) => {
|
||||
onSuccess: (
|
||||
response: PostAgentByAgentIdConfigSkillsUploadResponse | PostAppsByAppIdAgentConfigSkillsUploadResponse,
|
||||
) => {
|
||||
toast.success(t('agentDetail.configure.skills.upload.success'))
|
||||
onUploaded?.(toUploadedSkill(response, file))
|
||||
setFile(undefined)
|
||||
@@ -226,6 +224,8 @@ export function AgentSkillUploadDialog({
|
||||
},
|
||||
query: {
|
||||
node_id: apiContext.workflow.nodeId,
|
||||
draft_type: apiContext.draftType,
|
||||
version_id: apiContext.versionId,
|
||||
},
|
||||
body: {
|
||||
file,
|
||||
@@ -238,6 +238,10 @@ export function AgentSkillUploadDialog({
|
||||
params: {
|
||||
agent_id: apiContext.agentId,
|
||||
},
|
||||
query: {
|
||||
draft_type: apiContext.draftType,
|
||||
version_id: apiContext.versionId,
|
||||
},
|
||||
body: {
|
||||
file,
|
||||
},
|
||||
|
||||
+61
-83
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import type { AgentDriveSkillFileResponse } from '@dify/contracts/api/console/agent/types.gen'
|
||||
import type { AgentDriveApiContext } from '../drive-context'
|
||||
import type { AgentConfigSkillFileResponse } from '@dify/contracts/api/console/agent/types.gen'
|
||||
import type { AgentConfigApiContext } from '../config-context'
|
||||
import type { AgentSkillDetail } from './detail-dialog'
|
||||
import type { AgentFileNode, AgentSkill } from '@/features/agent-v2/agent-composer/form-state'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
@@ -9,34 +9,16 @@ import { useMemo, useState } from 'react'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { getDriveFileIconType } from '../files/file-icon'
|
||||
|
||||
const DIFY_SKILL_FULL_ARCHIVE_NAME = '.DIFY-SKILL-FULL.zip'
|
||||
const isSkillFolder = (file: AgentConfigSkillFileResponse) =>
|
||||
file.type === 'directory'
|
||||
|
||||
const getSkillDrivePath = (skill: AgentSkill) => {
|
||||
const skillMdKeySlug = skill.skillMdKey?.split('/', 1)[0]
|
||||
return skill.path ?? skillMdKeySlug ?? skill.id
|
||||
}
|
||||
|
||||
const getSkillFileName = (key: string, skillDrivePath: string) => key.startsWith(`${skillDrivePath}/`)
|
||||
? key.slice(skillDrivePath.length + 1)
|
||||
: key
|
||||
|
||||
const getSkillRelativePath = (path: string, skillDrivePath: string) =>
|
||||
getSkillFileName(path, skillDrivePath).split('/').filter(Boolean).join('/')
|
||||
|
||||
const isSkillArchiveFile = (path: string) =>
|
||||
path === DIFY_SKILL_FULL_ARCHIVE_NAME || path.endsWith(`/${DIFY_SKILL_FULL_ARCHIVE_NAME}`)
|
||||
|
||||
const isSkillFolder = (file: AgentDriveSkillFileResponse) =>
|
||||
file.type === 'directory' || file.type === 'folder'
|
||||
|
||||
const toSkillFileNode = (item: AgentDriveSkillFileResponse, skillDrivePath: string): AgentFileNode => {
|
||||
const filePath = getSkillFileName(item.path, skillDrivePath)
|
||||
const fileName = item.name || filePath.split('/').pop() || filePath
|
||||
const id = item.drive_key
|
||||
?? (item.path.startsWith(`${skillDrivePath}/`) ? item.path : `${skillDrivePath}/${item.path}`)
|
||||
const toSkillFileNode = (item: AgentConfigSkillFileResponse): AgentFileNode => {
|
||||
const fileName = item.name || item.path.split('/').pop() || item.path
|
||||
|
||||
return {
|
||||
driveKey: item.available_in_drive ? item.drive_key ?? undefined : undefined,
|
||||
id: item.path,
|
||||
name: fileName,
|
||||
configName: item.path,
|
||||
icon: isSkillFolder(item)
|
||||
? 'folder'
|
||||
: getDriveFileIconType({
|
||||
@@ -44,8 +26,6 @@ const toSkillFileNode = (item: AgentDriveSkillFileResponse, skillDrivePath: stri
|
||||
fileName,
|
||||
mimeType: undefined,
|
||||
}),
|
||||
id,
|
||||
name: fileName,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,21 +39,14 @@ const sortSkillFileNodes = (files: AgentFileNode[]): AgentFileNode[] => [...file
|
||||
return first.name.localeCompare(second.name)
|
||||
}).map(file => file.children ? { ...file, children: sortSkillFileNodes(file.children) } : file)
|
||||
|
||||
const toSkillFileTree = (files: AgentDriveSkillFileResponse[], skillDrivePath: string): AgentFileNode[] => {
|
||||
const toSkillFileTree = (files: AgentConfigSkillFileResponse[]): AgentFileNode[] => {
|
||||
const root: AgentFileNode[] = []
|
||||
const folders = new Map<string, AgentFileNode>()
|
||||
const seenFilePaths = new Set<string>()
|
||||
|
||||
for (const file of files) {
|
||||
const relativePath = getSkillRelativePath(file.path, skillDrivePath)
|
||||
if (!relativePath || isSkillArchiveFile(relativePath))
|
||||
const relativePath = file.path.split('/').filter(Boolean).join('/')
|
||||
if (!relativePath)
|
||||
continue
|
||||
if (!isSkillFolder(file)) {
|
||||
if (seenFilePaths.has(relativePath))
|
||||
continue
|
||||
|
||||
seenFilePaths.add(relativePath)
|
||||
}
|
||||
|
||||
const segments = relativePath.split('/').filter(Boolean)
|
||||
let siblings = root
|
||||
@@ -104,7 +77,7 @@ const toSkillFileTree = (files: AgentDriveSkillFileResponse[], skillDrivePath: s
|
||||
}
|
||||
|
||||
siblings.push({
|
||||
...toSkillFileNode(file, skillDrivePath),
|
||||
...toSkillFileNode(file),
|
||||
name: segment,
|
||||
})
|
||||
})
|
||||
@@ -113,15 +86,15 @@ const toSkillFileTree = (files: AgentDriveSkillFileResponse[], skillDrivePath: s
|
||||
return sortSkillFileNodes(root)
|
||||
}
|
||||
|
||||
const countSkillPackageFiles = (files: AgentDriveSkillFileResponse[] | undefined, skillDrivePath: string) => {
|
||||
const countSkillPackageFiles = (files: AgentConfigSkillFileResponse[] | undefined) => {
|
||||
const filePaths = new Set<string>()
|
||||
|
||||
for (const file of files ?? []) {
|
||||
if (isSkillFolder(file))
|
||||
continue
|
||||
|
||||
const relativePath = getSkillRelativePath(file.path, skillDrivePath)
|
||||
if (!relativePath || isSkillArchiveFile(relativePath))
|
||||
const relativePath = file.path.split('/').filter(Boolean).join('/')
|
||||
if (!relativePath)
|
||||
continue
|
||||
|
||||
filePaths.add(relativePath)
|
||||
@@ -172,33 +145,38 @@ export function useAgentSkillDetail({
|
||||
isOpen,
|
||||
skill,
|
||||
}: {
|
||||
apiContext: AgentDriveApiContext
|
||||
apiContext: AgentConfigApiContext
|
||||
description: string
|
||||
isOpen: boolean
|
||||
skill: AgentSkill
|
||||
}): AgentSkillDetail {
|
||||
const [selectedFileId, setSelectedFileId] = useState<string>()
|
||||
const skillDrivePath = getSkillDrivePath(skill)
|
||||
const agentSkillInspectQuery = useQuery({
|
||||
...consoleQuery.agent.byAgentId.drive.skills.bySkillPath.inspect.get.queryOptions({
|
||||
...consoleQuery.agent.byAgentId.config.skills.byName.inspect.get.queryOptions({
|
||||
input: {
|
||||
params: {
|
||||
agent_id: apiContext.agentId,
|
||||
skill_path: skillDrivePath,
|
||||
name: skill.name,
|
||||
},
|
||||
query: {
|
||||
draft_type: apiContext.draftType,
|
||||
version_id: apiContext.versionId,
|
||||
},
|
||||
},
|
||||
}),
|
||||
enabled: isOpen && !apiContext.workflow,
|
||||
})
|
||||
const workflowSkillInspectQuery = useQuery({
|
||||
...consoleQuery.apps.byAppId.agent.drive.skills.bySkillPath.inspect.get.queryOptions({
|
||||
...consoleQuery.apps.byAppId.agent.config.skills.byName.inspect.get.queryOptions({
|
||||
input: {
|
||||
params: {
|
||||
app_id: apiContext.workflow?.appId ?? '',
|
||||
skill_path: skillDrivePath,
|
||||
name: skill.name,
|
||||
},
|
||||
query: {
|
||||
node_id: apiContext.workflow?.nodeId,
|
||||
draft_type: apiContext.draftType,
|
||||
version_id: apiContext.versionId,
|
||||
},
|
||||
},
|
||||
}),
|
||||
@@ -206,89 +184,89 @@ export function useAgentSkillDetail({
|
||||
})
|
||||
const inspectQuery = apiContext.workflow ? workflowSkillInspectQuery : agentSkillInspectQuery
|
||||
const detailFiles = useMemo(
|
||||
() => toSkillFileTree(inspectQuery.data?.files ?? [], skillDrivePath),
|
||||
[inspectQuery.data?.files, skillDrivePath],
|
||||
() => toSkillFileTree(inspectQuery.data?.files ?? []),
|
||||
[inspectQuery.data?.files],
|
||||
)
|
||||
const previewFileId = selectedFileId
|
||||
?? skill.skillMdKey
|
||||
?? inspectQuery.data?.skill_md.key
|
||||
?? inspectQuery.data?.skill_md.path
|
||||
?? (inspectQuery.isSuccess ? getSkillMdFileId(detailFiles) ?? getFirstSkillFileId(detailFiles) : undefined)
|
||||
const selectedFile = findSkillFileById(detailFiles, previewFileId)
|
||||
const isSkillMdSelected = previewFileId === inspectQuery.data?.skill_md.key
|
||||
|| previewFileId === skill.skillMdKey
|
||||
|| selectedFile?.name === 'SKILL.md'
|
||||
const selectedPreviewKey = isSkillMdSelected
|
||||
? undefined
|
||||
: selectedFile?.driveKey
|
||||
const isSkillMdSelected = previewFileId === inspectQuery.data?.skill_md.path || selectedFile?.name === 'SKILL.md'
|
||||
const selectedPreviewPath = isSkillMdSelected ? undefined : selectedFile?.configName ?? selectedFile?.id
|
||||
const agentPreviewQuery = useQuery({
|
||||
...consoleQuery.agent.byAgentId.drive.files.preview.get.queryOptions({
|
||||
...consoleQuery.agent.byAgentId.config.skills.byName.files.preview.get.queryOptions({
|
||||
input: {
|
||||
params: {
|
||||
agent_id: apiContext.agentId,
|
||||
name: skill.name,
|
||||
},
|
||||
query: {
|
||||
key: selectedPreviewKey ?? '',
|
||||
path: selectedPreviewPath ?? '',
|
||||
draft_type: apiContext.draftType,
|
||||
version_id: apiContext.versionId,
|
||||
},
|
||||
},
|
||||
}),
|
||||
enabled: isOpen && !!selectedPreviewKey && !apiContext.workflow,
|
||||
enabled: isOpen && !!selectedPreviewPath && !apiContext.workflow,
|
||||
})
|
||||
const workflowPreviewQuery = useQuery({
|
||||
...consoleQuery.apps.byAppId.agent.drive.files.preview.get.queryOptions({
|
||||
...consoleQuery.apps.byAppId.agent.config.skills.byName.files.preview.get.queryOptions({
|
||||
input: {
|
||||
params: {
|
||||
app_id: apiContext.workflow?.appId ?? '',
|
||||
name: skill.name,
|
||||
},
|
||||
query: {
|
||||
node_id: apiContext.workflow?.nodeId,
|
||||
key: selectedPreviewKey ?? '',
|
||||
path: selectedPreviewPath ?? '',
|
||||
draft_type: apiContext.draftType,
|
||||
version_id: apiContext.versionId,
|
||||
},
|
||||
},
|
||||
}),
|
||||
enabled: isOpen && !!selectedPreviewKey && !!apiContext.workflow,
|
||||
enabled: isOpen && !!selectedPreviewPath && !!apiContext.workflow,
|
||||
})
|
||||
const previewQuery = apiContext.workflow ? workflowPreviewQuery : agentPreviewQuery
|
||||
const isImagePreviewFile = selectedFile?.icon === 'image'
|
||||
const shouldDownloadPreviewFile = isOpen && !!selectedPreviewPath && (isImagePreviewFile || !!previewQuery.data?.binary)
|
||||
const agentDownloadQuery = useQuery({
|
||||
...consoleQuery.agent.byAgentId.drive.files.download.get.queryOptions({
|
||||
...consoleQuery.agent.byAgentId.config.skills.byName.files.download.get.queryOptions({
|
||||
input: {
|
||||
params: {
|
||||
agent_id: apiContext.agentId,
|
||||
name: skill.name,
|
||||
},
|
||||
query: {
|
||||
key: selectedPreviewKey ?? '',
|
||||
path: selectedPreviewPath ?? '',
|
||||
draft_type: apiContext.draftType,
|
||||
version_id: apiContext.versionId,
|
||||
},
|
||||
},
|
||||
}),
|
||||
enabled:
|
||||
isOpen
|
||||
&& !!selectedPreviewKey
|
||||
&& (isImagePreviewFile || !!previewQuery.data?.binary)
|
||||
&& !apiContext.workflow,
|
||||
enabled: shouldDownloadPreviewFile && !apiContext.workflow,
|
||||
})
|
||||
const workflowDownloadQuery = useQuery({
|
||||
...consoleQuery.apps.byAppId.agent.drive.files.download.get.queryOptions({
|
||||
...consoleQuery.apps.byAppId.agent.config.skills.byName.files.download.get.queryOptions({
|
||||
input: {
|
||||
params: {
|
||||
app_id: apiContext.workflow?.appId ?? '',
|
||||
name: skill.name,
|
||||
},
|
||||
query: {
|
||||
node_id: apiContext.workflow?.nodeId,
|
||||
key: selectedPreviewKey ?? '',
|
||||
path: selectedPreviewPath ?? '',
|
||||
draft_type: apiContext.draftType,
|
||||
version_id: apiContext.versionId,
|
||||
},
|
||||
},
|
||||
}),
|
||||
enabled:
|
||||
isOpen
|
||||
&& !!selectedPreviewKey
|
||||
&& (isImagePreviewFile || !!previewQuery.data?.binary)
|
||||
&& !!apiContext.workflow,
|
||||
enabled: shouldDownloadPreviewFile && !!apiContext.workflow,
|
||||
})
|
||||
const downloadQuery = apiContext.workflow ? workflowDownloadQuery : agentDownloadQuery
|
||||
|
||||
return {
|
||||
description,
|
||||
fileCount: countSkillPackageFiles(inspectQuery.data?.files, skillDrivePath),
|
||||
fileCount: countSkillPackageFiles(inspectQuery.data?.files),
|
||||
files: detailFiles,
|
||||
filePreview: {
|
||||
binary: isSkillMdSelected ? inspectQuery.data?.skill_md.binary : previewQuery.data?.binary,
|
||||
@@ -296,10 +274,10 @@ export function useAgentSkillDetail({
|
||||
downloadUrl: downloadQuery.data?.url,
|
||||
fileName: selectedFile?.name,
|
||||
isDownloadError: downloadQuery.isError,
|
||||
isDownloadLoading: !!selectedPreviewKey && (isImagePreviewFile || !!previewQuery.data?.binary) && downloadQuery.isPending,
|
||||
isError: isSkillMdSelected ? inspectQuery.isError : !!selectedPreviewKey && previewQuery.isError,
|
||||
isDownloadLoading: shouldDownloadPreviewFile && downloadQuery.isPending,
|
||||
isError: isSkillMdSelected ? inspectQuery.isError : !!selectedPreviewPath && previewQuery.isError,
|
||||
isImage: isImagePreviewFile,
|
||||
isLoading: isSkillMdSelected ? inspectQuery.isPending : !!selectedPreviewKey && previewQuery.isPending,
|
||||
isLoading: isSkillMdSelected ? inspectQuery.isPending : !!selectedPreviewPath && previewQuery.isPending,
|
||||
},
|
||||
onSelectFile: file => setSelectedFileId(file.id),
|
||||
selectedFileId: previewFileId,
|
||||
|
||||
+49
@@ -3,10 +3,17 @@ import type { AgentSoulConfigFormState } from '@/features/agent-v2/agent-compose
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { cleanup, render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { createStore, Provider as JotaiProvider } from 'jotai'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { CollectionType } from '@/app/components/tools/types'
|
||||
import { defaultAgentSoulConfigFormState } from '@/features/agent-v2/agent-composer/form-state'
|
||||
import { AgentComposerProvider } from '@/features/agent-v2/agent-composer/provider'
|
||||
import {
|
||||
agentComposerDraftAtom,
|
||||
agentComposerOriginalDraftAtom,
|
||||
agentComposerPublishedDraftAtom,
|
||||
isAgentComposerDirtyAtom,
|
||||
} from '@/features/agent-v2/agent-composer/store'
|
||||
import { AgentOrchestrateReadOnlyContext } from '../../read-only-context'
|
||||
import { AgentTools } from '../index'
|
||||
|
||||
@@ -233,6 +240,33 @@ function renderAgentTools(initialDraft: AgentSoulConfigFormState = agentToolsDra
|
||||
)
|
||||
}
|
||||
|
||||
function renderAgentToolsWithStore(initialDraft: AgentSoulConfigFormState = agentToolsDraft) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
const store = createStore()
|
||||
store.set(agentComposerDraftAtom, initialDraft)
|
||||
store.set(agentComposerOriginalDraftAtom, initialDraft)
|
||||
store.set(agentComposerPublishedDraftAtom, initialDraft)
|
||||
|
||||
const view = render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<JotaiProvider store={store}>
|
||||
<AgentTools />
|
||||
</JotaiProvider>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
|
||||
return {
|
||||
...view,
|
||||
store,
|
||||
}
|
||||
}
|
||||
|
||||
function renderReadonlyAgentTools(initialDraft: AgentSoulConfigFormState = agentToolsDraft) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
@@ -384,6 +418,21 @@ describe('AgentTools', () => {
|
||||
expect(screen.queryByText('tools.notAuthorized')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should keep provider credential metadata display-only without dirtying the composer draft', () => {
|
||||
toolProviderState.builtInTools = [duckDuckGoProvider]
|
||||
const { store } = renderAgentToolsWithStore(reflectedUnauthorizedNoCredentialDraft)
|
||||
|
||||
expect(screen.getByRole('button', {
|
||||
name: 'DuckDuckGo',
|
||||
})).toBeInTheDocument()
|
||||
expect(screen.queryByText('tools.notAuthorized')).not.toBeInTheDocument()
|
||||
expect(store.get(agentComposerDraftAtom).tools[0]).toMatchObject({
|
||||
credentialType: 'unauthorized',
|
||||
credentialVariant: 'unauthorized',
|
||||
})
|
||||
expect(store.get(isAgentComposerDirtyAtom)).toBe(false)
|
||||
})
|
||||
|
||||
it('should open provider tool settings with catalog icon and parameters', async () => {
|
||||
const user = userEvent.setup()
|
||||
toolProviderState.builtInTools = [duckDuckGoProvider]
|
||||
|
||||
+48
-40
@@ -7,7 +7,7 @@ import type { ToolWithProvider } from '@/app/components/workflow/types'
|
||||
import type { AgentCliTool, AgentProviderTool, AgentTool } from '@/features/agent-v2/agent-composer/form-state'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useCallback, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ToolPickerContent } from '@/app/components/workflow/block-selector/tool-picker'
|
||||
import { useGetLanguage } from '@/context/i18n'
|
||||
@@ -340,7 +340,6 @@ export function AgentTools() {
|
||||
settingTarget,
|
||||
isCliToolDialogOpen,
|
||||
editingCliTool,
|
||||
setTools,
|
||||
setToolOpen,
|
||||
setSettingTarget,
|
||||
addTools,
|
||||
@@ -358,43 +357,52 @@ export function AgentTools() {
|
||||
[tools],
|
||||
)
|
||||
const displayTools = useDisplayTools(visibleTools, providerById)
|
||||
const displayToolById = useMemo(
|
||||
() => new Map(displayTools.map(tool => [tool.id, tool])),
|
||||
[displayTools],
|
||||
)
|
||||
useEffect(() => {
|
||||
if (readOnly)
|
||||
return
|
||||
|
||||
let shouldSyncCredentials = false
|
||||
const nextTools = tools.map((tool) => {
|
||||
const displayTool = displayToolById.get(tool.id)
|
||||
|
||||
if (tool.kind !== 'provider' || displayTool?.kind !== 'provider')
|
||||
return tool
|
||||
|
||||
if (
|
||||
tool.allowDelete === displayTool.allowDelete
|
||||
&& tool.credentialKey === displayTool.credentialKey
|
||||
&& tool.credentialType === displayTool.credentialType
|
||||
&& tool.credentialVariant === displayTool.credentialVariant
|
||||
) {
|
||||
return tool
|
||||
}
|
||||
|
||||
shouldSyncCredentials = true
|
||||
return {
|
||||
...tool,
|
||||
allowDelete: displayTool.allowDelete,
|
||||
credentialKey: displayTool.credentialKey,
|
||||
credentialType: displayTool.credentialType,
|
||||
credentialVariant: displayTool.credentialVariant,
|
||||
}
|
||||
})
|
||||
|
||||
if (shouldSyncCredentials)
|
||||
setTools(nextTools)
|
||||
}, [displayToolById, readOnly, setTools, tools])
|
||||
/*
|
||||
* knip-ignore-start
|
||||
* Keep this disabled sync logic while backend credential snapshots are being investigated.
|
||||
* Re-enabling it writes catalog-derived credential metadata into the composer draft on page entry.
|
||||
* That can mark a published agent as locally dirty before the user changes anything.
|
||||
*
|
||||
* const displayToolById = useMemo(
|
||||
* () => new Map(displayTools.map(tool => [tool.id, tool])),
|
||||
* [displayTools],
|
||||
* )
|
||||
*
|
||||
* useEffect(() => {
|
||||
* if (readOnly)
|
||||
* return
|
||||
*
|
||||
* let shouldSyncCredentials = false
|
||||
* const nextTools = tools.map((tool) => {
|
||||
* const displayTool = displayToolById.get(tool.id)
|
||||
*
|
||||
* if (tool.kind !== 'provider' || displayTool?.kind !== 'provider')
|
||||
* return tool
|
||||
*
|
||||
* if (
|
||||
* tool.allowDelete === displayTool.allowDelete
|
||||
* && tool.credentialKey === displayTool.credentialKey
|
||||
* && tool.credentialType === displayTool.credentialType
|
||||
* && tool.credentialVariant === displayTool.credentialVariant
|
||||
* ) {
|
||||
* return tool
|
||||
* }
|
||||
*
|
||||
* shouldSyncCredentials = true
|
||||
* return {
|
||||
* ...tool,
|
||||
* allowDelete: displayTool.allowDelete,
|
||||
* credentialKey: displayTool.credentialKey,
|
||||
* credentialType: displayTool.credentialType,
|
||||
* credentialVariant: displayTool.credentialVariant,
|
||||
* }
|
||||
* })
|
||||
*
|
||||
* if (shouldSyncCredentials)
|
||||
* setTools(nextTools)
|
||||
* }, [displayToolById, readOnly, setTools, tools])
|
||||
* knip-ignore-end
|
||||
*/
|
||||
const promptAddCallbackRef = useRef<AgentOrchestrateAddActionOptions['onAdded']>(undefined)
|
||||
const openCliToolDialogFromPrompt = useCallback((options?: AgentOrchestrateAddActionOptions) => {
|
||||
promptAddCallbackRef.current = options?.onAdded
|
||||
@@ -414,7 +422,7 @@ export function AgentTools() {
|
||||
}, [handleCliDialogOpenChange])
|
||||
useRegisterAgentOrchestrateAddAction(
|
||||
'cli',
|
||||
ENABLE_AGENT_CLI_TOOLS ? openCliToolDialogFromPrompt : () => {},
|
||||
ENABLE_AGENT_CLI_TOOLS ? openCliToolDialogFromPrompt : () => { },
|
||||
)
|
||||
const toolsTip = t('agentDetail.configure.tools.tip')
|
||||
const toolsListId = 'agent-configure-tools-list'
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
import type { AgentFileNode } from '@/features/agent-v2/agent-composer/form-state'
|
||||
|
||||
export function countAgentFileNodes(files: AgentFileNode[]): number {
|
||||
return files.reduce((count, file) => count + 1 + (file.children ? countAgentFileNodes(file.children) : 0), 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
// TODO: Remove this marker after the first file selector is wired.
|
||||
export function getFirstAgentFileId(files: AgentFileNode[]): string | undefined {
|
||||
for (const file of files) {
|
||||
if (!file.children?.length)
|
||||
return file.id
|
||||
|
||||
const childFileId = getFirstAgentFileId(file.children)
|
||||
if (childFileId)
|
||||
return childFileId
|
||||
}
|
||||
}
|
||||
+354
-41
@@ -1,10 +1,12 @@
|
||||
import type { ComponentProps } from 'react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { createStore, Provider as JotaiProvider } from 'jotai'
|
||||
import { useState } from 'react'
|
||||
import { SupportUploadFileTypes } from '@/app/components/workflow/types'
|
||||
import { agentComposerModelAtom } from '@/features/agent-v2/agent-composer/store-modules/model'
|
||||
import { agentComposerPromptAtom } from '@/features/agent-v2/agent-composer/store-modules/prompt'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { TransferMethod } from '@/types/app'
|
||||
import { AgentChatRuntime } from '../chat-runtime'
|
||||
|
||||
@@ -13,27 +15,43 @@ const handleSendMock = vi.hoisted(() => vi.fn())
|
||||
const stopCallbackRef = vi.hoisted(() => ({
|
||||
current: undefined as undefined | ((taskId: string) => void),
|
||||
}))
|
||||
const sendResultRef = vi.hoisted(() => ({
|
||||
current: undefined as unknown,
|
||||
}))
|
||||
const chatMessagesGetMock = vi.hoisted(() => vi.fn())
|
||||
const suggestedQuestionsGetMock = vi.hoisted(() => vi.fn())
|
||||
const stopPostMock = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@/next/dynamic', () => ({
|
||||
default: () => function MockChat(props: {
|
||||
onSend: (message: string) => void
|
||||
onStopResponding: () => void
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<button type="button" onClick={() => props.onSend('hello')}>
|
||||
send
|
||||
</button>
|
||||
<button type="button" onClick={props.onStopResponding}>
|
||||
stop
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}))
|
||||
vi.mock('@/next/dynamic', async () => {
|
||||
const { useState } = await import('react')
|
||||
|
||||
return {
|
||||
default: () => function MockChat(props: {
|
||||
onSend: (message: string) => unknown
|
||||
onStopResponding: () => void
|
||||
}) {
|
||||
const [sent, setSent] = useState(false)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<span>{`sessionSent:${sent ? 'yes' : 'no'}`}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSent(true)
|
||||
sendResultRef.current = props.onSend('hello')
|
||||
}}
|
||||
>
|
||||
send
|
||||
</button>
|
||||
<button type="button" onClick={props.onStopResponding}>
|
||||
stop
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/app/components/base/chat/chat/hooks', () => ({
|
||||
useChat: useChatMock.mockImplementation((
|
||||
@@ -83,27 +101,47 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', ()
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/client', () => ({
|
||||
consoleClient: {
|
||||
agent: {
|
||||
byAgentId: {
|
||||
chatMessages: {
|
||||
get: chatMessagesGetMock,
|
||||
byMessageId: {
|
||||
suggestedQuestions: {
|
||||
get: suggestedQuestionsGetMock,
|
||||
vi.mock('@/service/client', async () => {
|
||||
const { skipToken } = await import('@tanstack/react-query')
|
||||
const getChatMessagesQueryKey = (input: unknown) => ['agent-chat-conversation-messages', input]
|
||||
|
||||
return {
|
||||
consoleClient: {
|
||||
agent: {
|
||||
byAgentId: {
|
||||
chatMessages: {
|
||||
get: chatMessagesGetMock,
|
||||
byMessageId: {
|
||||
suggestedQuestions: {
|
||||
get: suggestedQuestionsGetMock,
|
||||
},
|
||||
},
|
||||
},
|
||||
byTaskId: {
|
||||
stop: {
|
||||
post: stopPostMock,
|
||||
byTaskId: {
|
||||
stop: {
|
||||
post: stopPostMock,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
consoleQuery: {
|
||||
agent: {
|
||||
byAgentId: {
|
||||
chatMessages: {
|
||||
get: {
|
||||
queryKey: ({ input }: { input: unknown }) => getChatMessagesQueryKey(input),
|
||||
queryOptions: ({ input }: { input: unknown }) => ({
|
||||
queryKey: getChatMessagesQueryKey(input),
|
||||
queryFn: input === skipToken ? skipToken : () => chatMessagesGetMock(input),
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
function renderPreviewChat(props?: Partial<ComponentProps<typeof AgentChatRuntime>>) {
|
||||
const store = createStore()
|
||||
@@ -120,22 +158,112 @@ function renderPreviewChat(props?: Partial<ComponentProps<typeof AgentChatRuntim
|
||||
})
|
||||
store.set(agentComposerPromptAtom, 'You are helpful.')
|
||||
|
||||
return {
|
||||
queryClient,
|
||||
...render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<JotaiProvider store={store}>
|
||||
<AgentChatRuntime
|
||||
agentId="agent-1"
|
||||
clearChatList={false}
|
||||
inputPlaceholder="Message agent"
|
||||
renderEmptyState={() => null}
|
||||
onClearChatListChange={vi.fn()}
|
||||
{...props}
|
||||
/>
|
||||
</JotaiProvider>
|
||||
</QueryClientProvider>,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
function RuntimeConversationHarness() {
|
||||
const [conversationId, setConversationId] = useState<string | null>(null)
|
||||
|
||||
return (
|
||||
<AgentChatRuntime
|
||||
agentId="agent-1"
|
||||
clearChatList={false}
|
||||
conversationId={conversationId}
|
||||
inputPlaceholder="Message agent"
|
||||
renderEmptyState={() => null}
|
||||
onClearChatListChange={vi.fn()}
|
||||
onConversationIdChange={setConversationId}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function RuntimeClearCommandHarness({
|
||||
inputPlaceholder,
|
||||
}: {
|
||||
inputPlaceholder: string
|
||||
}) {
|
||||
const [clearChatList, setClearChatList] = useState(true)
|
||||
|
||||
return (
|
||||
<AgentChatRuntime
|
||||
agentId="agent-1"
|
||||
clearChatList={clearChatList}
|
||||
inputPlaceholder={inputPlaceholder}
|
||||
renderEmptyState={() => null}
|
||||
onClearChatListChange={setClearChatList}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function renderPreviewChatWithConversationHarness() {
|
||||
const store = createStore()
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
store.set(agentComposerModelAtom, {
|
||||
provider: 'openai',
|
||||
model: 'gpt-4',
|
||||
})
|
||||
store.set(agentComposerPromptAtom, 'You are helpful.')
|
||||
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<JotaiProvider store={store}>
|
||||
<AgentChatRuntime
|
||||
agentId="agent-1"
|
||||
clearChatList={false}
|
||||
inputPlaceholder="Message agent"
|
||||
renderEmptyState={() => null}
|
||||
onClearChatListChange={vi.fn()}
|
||||
{...props}
|
||||
/>
|
||||
<RuntimeConversationHarness />
|
||||
</JotaiProvider>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
function renderPreviewChatWithClearCommandHarness() {
|
||||
const store = createStore()
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
store.set(agentComposerModelAtom, {
|
||||
provider: 'openai',
|
||||
model: 'gpt-4',
|
||||
})
|
||||
store.set(agentComposerPromptAtom, 'You are helpful.')
|
||||
|
||||
const renderHarness = (inputPlaceholder: string) => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<JotaiProvider store={store}>
|
||||
<RuntimeClearCommandHarness inputPlaceholder={inputPlaceholder} />
|
||||
</JotaiProvider>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
|
||||
return {
|
||||
...render(renderHarness('Message agent')),
|
||||
renderHarness,
|
||||
}
|
||||
}
|
||||
|
||||
describe('AgentPreviewChat', () => {
|
||||
beforeEach(() => {
|
||||
useChatMock.mockClear()
|
||||
@@ -144,6 +272,7 @@ describe('AgentPreviewChat', () => {
|
||||
suggestedQuestionsGetMock.mockResolvedValue({ data: [] })
|
||||
stopPostMock.mockResolvedValue({ result: 'success' })
|
||||
stopCallbackRef.current = undefined
|
||||
sendResultRef.current = undefined
|
||||
})
|
||||
|
||||
it('should initialize preview chat with the stable debug conversation history', async () => {
|
||||
@@ -258,6 +387,103 @@ describe('AgentPreviewChat', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('should sync the completed conversation history into the query cache', async () => {
|
||||
const conversationMessagesResponse = {
|
||||
data: [
|
||||
{
|
||||
id: 'message-after-send',
|
||||
conversation_id: 'conversation-1',
|
||||
query: 'hello',
|
||||
answer: 'hi',
|
||||
inputs: {},
|
||||
message: [],
|
||||
message_files: [],
|
||||
agent_thoughts: [],
|
||||
feedbacks: [],
|
||||
answer_tokens: 1,
|
||||
message_tokens: 1,
|
||||
provider_response_latency: 1,
|
||||
status: 'success',
|
||||
from_source: 'console',
|
||||
},
|
||||
],
|
||||
}
|
||||
chatMessagesGetMock.mockResolvedValue(conversationMessagesResponse)
|
||||
const { queryClient } = renderPreviewChat()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'send' }))
|
||||
|
||||
await waitFor(() => expect(handleSendMock).toHaveBeenCalledTimes(1))
|
||||
const callbacks = handleSendMock.mock.calls.at(0)?.[2]
|
||||
|
||||
await callbacks.onGetConversationMessages('conversation-1')
|
||||
|
||||
expect(queryClient.getQueryData(consoleQuery.agent.byAgentId.chatMessages.get.queryKey({
|
||||
input: {
|
||||
params: {
|
||||
agent_id: 'agent-1',
|
||||
},
|
||||
query: {
|
||||
conversation_id: 'conversation-1',
|
||||
},
|
||||
},
|
||||
}))).toBe(conversationMessagesResponse)
|
||||
})
|
||||
|
||||
it('should notify the owner when a send settles with an error', async () => {
|
||||
const onSendInterrupted = vi.fn()
|
||||
renderPreviewChat({
|
||||
onSendInterrupted,
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'send' }))
|
||||
|
||||
await waitFor(() => expect(handleSendMock).toHaveBeenCalledTimes(1))
|
||||
const callbacks = handleSendMock.mock.calls.at(0)?.[2]
|
||||
|
||||
act(() => {
|
||||
callbacks.onSendSettled(true)
|
||||
})
|
||||
|
||||
expect(onSendInterrupted).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should notify the owner when stopping a responding send', async () => {
|
||||
const onSendInterrupted = vi.fn()
|
||||
renderPreviewChat({
|
||||
onSendInterrupted,
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'stop' }))
|
||||
|
||||
expect(onSendInterrupted).toHaveBeenCalledTimes(1)
|
||||
expect(stopPostMock).toHaveBeenCalledWith({
|
||||
params: {
|
||||
agent_id: 'agent-1',
|
||||
task_id: 'task-1',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('should notify the owner once when a stopped send later settles with an error', async () => {
|
||||
const onSendInterrupted = vi.fn()
|
||||
renderPreviewChat({
|
||||
onSendInterrupted,
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'send' }))
|
||||
|
||||
await waitFor(() => expect(handleSendMock).toHaveBeenCalledTimes(1))
|
||||
const callbacks = handleSendMock.mock.calls.at(0)?.[2]
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'stop' }))
|
||||
act(() => {
|
||||
callbacks.onSendSettled(true)
|
||||
})
|
||||
|
||||
expect(onSendInterrupted).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should not send preview chat when draft save fails', async () => {
|
||||
const saveDraftBeforeRun = vi.fn().mockRejectedValue(new Error('save failed'))
|
||||
renderPreviewChat({
|
||||
@@ -267,6 +493,7 @@ describe('AgentPreviewChat', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'send' }))
|
||||
|
||||
await waitFor(() => expect(saveDraftBeforeRun).toHaveBeenCalledTimes(1))
|
||||
await expect(sendResultRef.current).resolves.toBe(false)
|
||||
expect(handleSendMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -287,6 +514,92 @@ describe('AgentPreviewChat', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('should send build chat inputs from the prepared build draft snapshot', async () => {
|
||||
const saveDraftBeforeRun = vi.fn().mockResolvedValue({
|
||||
app_variables: [
|
||||
{
|
||||
name: 'city',
|
||||
type: 'text-input',
|
||||
default: 'Paris',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
model: {
|
||||
model_provider: 'openai',
|
||||
model: 'gpt-4',
|
||||
},
|
||||
prompt: {
|
||||
system_prompt: 'Build draft prompt',
|
||||
},
|
||||
})
|
||||
renderPreviewChat({
|
||||
agentSoulConfig: {
|
||||
app_variables: [
|
||||
{
|
||||
name: 'city',
|
||||
type: 'text-input',
|
||||
default: 'London',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
draftType: 'debug_build',
|
||||
onSaveDraftBeforeRun: saveDraftBeforeRun,
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'send' }))
|
||||
|
||||
await waitFor(() => expect(handleSendMock).toHaveBeenCalledTimes(1))
|
||||
expect(handleSendMock).toHaveBeenCalledWith(
|
||||
'agent/agent-1/chat-messages',
|
||||
expect.objectContaining({
|
||||
draft_type: 'debug_build',
|
||||
inputs: {
|
||||
city: 'Paris',
|
||||
},
|
||||
overrideInputsForm: [
|
||||
expect.objectContaining({
|
||||
variable: 'city',
|
||||
default: 'Paris',
|
||||
}),
|
||||
],
|
||||
}),
|
||||
expect.any(Object),
|
||||
)
|
||||
})
|
||||
|
||||
it('should keep the current chat session visible when a sent message creates a conversation', async () => {
|
||||
chatMessagesGetMock.mockReturnValue(new Promise(() => undefined))
|
||||
renderPreviewChatWithConversationHarness()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'send' }))
|
||||
|
||||
await waitFor(() => expect(handleSendMock).toHaveBeenCalledTimes(1))
|
||||
const callbacks = handleSendMock.mock.calls.at(0)?.[2]
|
||||
|
||||
await act(async () => {
|
||||
callbacks.onConversationComplete('conversation-created-by-send')
|
||||
})
|
||||
|
||||
expect(screen.getByRole('button', { name: 'send' })).toBeInTheDocument()
|
||||
expect(screen.getByText('sessionSent:yes')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should keep the reset command acknowledgement stable while clear chat is pending', async () => {
|
||||
const { renderHarness, rerender } = renderPreviewChatWithClearCommandHarness()
|
||||
|
||||
await waitFor(() => expect(useChatMock).toHaveBeenCalled())
|
||||
const firstResetAcknowledgement = useChatMock.mock.calls.at(-1)?.[5]
|
||||
|
||||
rerender(renderHarness('Message agent again'))
|
||||
|
||||
await waitFor(() => expect(useChatMock.mock.calls.length).toBeGreaterThan(1))
|
||||
const secondResetAcknowledgement = useChatMock.mock.calls.at(-1)?.[5]
|
||||
|
||||
expect(secondResetAcknowledgement).toBe(firstResetAcknowledgement)
|
||||
})
|
||||
|
||||
it('should keep preview file upload disabled by default', async () => {
|
||||
renderPreviewChat()
|
||||
|
||||
|
||||
+13
@@ -9,6 +9,7 @@ function renderHeader({
|
||||
onToggleChatFeatures = vi.fn(),
|
||||
onOpenWorkingDirectory = vi.fn(),
|
||||
onRefresh = vi.fn(),
|
||||
refreshDisabled = false,
|
||||
}: {
|
||||
mode?: 'build' | 'preview'
|
||||
previewEnabled?: boolean
|
||||
@@ -16,6 +17,7 @@ function renderHeader({
|
||||
onToggleChatFeatures?: () => void
|
||||
onOpenWorkingDirectory?: () => void
|
||||
onRefresh?: () => void
|
||||
refreshDisabled?: boolean
|
||||
} = {}) {
|
||||
render(
|
||||
<AgentPreviewHeader
|
||||
@@ -26,6 +28,7 @@ function renderHeader({
|
||||
onToggleChatFeatures={onToggleChatFeatures}
|
||||
onOpenWorkingDirectory={onOpenWorkingDirectory}
|
||||
onRefresh={onRefresh}
|
||||
refreshDisabled={refreshDisabled}
|
||||
/>,
|
||||
)
|
||||
}
|
||||
@@ -45,6 +48,16 @@ describe('AgentPreviewHeader', () => {
|
||||
expect(onRefresh).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should not emit refresh when the restart button is disabled', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onRefresh = vi.fn()
|
||||
renderHeader({ mode: 'build', onRefresh, refreshDisabled: true })
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'agentV2.agentDetail.configure.preview.restart' }))
|
||||
|
||||
expect(onRefresh).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should show chat features in build mode', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onToggleChatFeatures = vi.fn()
|
||||
|
||||
+57
-2
@@ -13,10 +13,17 @@ const versions: AgentConfigSnapshotSummaryResponse[] = [
|
||||
{
|
||||
id: 'version-1',
|
||||
version: 1,
|
||||
version_note: 'Initial release',
|
||||
version_note: null,
|
||||
created_at: 1710000000,
|
||||
created_by: 'Bob',
|
||||
},
|
||||
{
|
||||
id: 'version-0',
|
||||
version: 0,
|
||||
version_note: 'Initial release',
|
||||
created_at: 1709999900,
|
||||
created_by: 'user-1',
|
||||
},
|
||||
]
|
||||
|
||||
vi.mock('@tanstack/react-query', async (importOriginal) => {
|
||||
@@ -51,6 +58,17 @@ vi.mock('@/service/client', () => ({
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useSelector: <T,>(selector: (state: { userProfile: { id: string, name: string, email: string } }) => T) =>
|
||||
selector({
|
||||
userProfile: {
|
||||
id: 'user-1',
|
||||
name: 'Alice',
|
||||
email: 'alice@example.com',
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('AgentPreviewVersionsPanel', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@@ -71,7 +89,7 @@ describe('AgentPreviewVersionsPanel', () => {
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Initial release/i }))
|
||||
|
||||
expect(handleSelectVersion).toHaveBeenCalledWith('version-1')
|
||||
expect(handleSelectVersion).toHaveBeenCalledWith('version-0')
|
||||
})
|
||||
|
||||
it('should notify null when the current draft row is clicked', () => {
|
||||
@@ -91,4 +109,41 @@ describe('AgentPreviewVersionsPanel', () => {
|
||||
expect(handleSelectVersion).toHaveBeenCalledWith(null)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Version filter', () => {
|
||||
it('should show filter options when the filter trigger is clicked', () => {
|
||||
render(
|
||||
<AgentPreviewVersionsPanel
|
||||
agentId="agent-1"
|
||||
activeVersionId="version-2"
|
||||
onSelectVersion={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /filter/i }))
|
||||
|
||||
expect(screen.getByRole('button', { name: /all/i })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /onlyYours/i })).toBeInTheDocument()
|
||||
expect(screen.queryByText(/onlyShowNamedVersions/i)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should only show current user versions when only yours is selected', () => {
|
||||
render(
|
||||
<AgentPreviewVersionsPanel
|
||||
agentId="agent-1"
|
||||
activeVersionId="version-2"
|
||||
onSelectVersion={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /filter/i }))
|
||||
fireEvent.click(screen.getByRole('button', { name: /onlyYours/i }))
|
||||
|
||||
expect(screen.getByRole('button', { name: /Published update/i })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /Initial release/i })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: /versionName.*1/i })).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
+3
-53
@@ -1,55 +1,5 @@
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
|
||||
const buildPanelGridColumnCount = 384
|
||||
const buildPanelGridRowCount = 32
|
||||
|
||||
function getBuildPanelGridCellOpacity(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 / (buildPanelGridRowCount - 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 buildPanelGridCells = Array.from(
|
||||
{ length: buildPanelGridColumnCount * buildPanelGridRowCount },
|
||||
(_, index) => {
|
||||
const row = Math.floor(index / buildPanelGridColumnCount)
|
||||
const column = index % buildPanelGridColumnCount
|
||||
const opacity = getBuildPanelGridCellOpacity(row, column)
|
||||
|
||||
return {
|
||||
id: `build-panel-grid-cell-${row}-${column}`,
|
||||
column: column + 1,
|
||||
opacity,
|
||||
row: row + 1,
|
||||
}
|
||||
},
|
||||
).filter(cell => cell.opacity > 0)
|
||||
|
||||
function AgentBuildPanelGrid({
|
||||
className,
|
||||
}: {
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<div className={cn('grid grid-cols-[repeat(384,4px)] grid-rows-[repeat(32,4px)] gap-0.5 opacity-70', className)}>
|
||||
{buildPanelGridCells.map(cell => (
|
||||
<span
|
||||
key={cell.id}
|
||||
className="rounded-[1px] bg-[#98A2B2]"
|
||||
style={{ gridColumn: `${cell.column}`, gridRow: `${cell.row}`, opacity: cell.opacity }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
import { AgentBuildGridTexture } from '../build-grid-texture'
|
||||
|
||||
export function AgentBuildPanelBackground({
|
||||
visible,
|
||||
@@ -64,8 +14,8 @@ export function AgentBuildPanelBackground({
|
||||
visible && 'opacity-100',
|
||||
)}
|
||||
>
|
||||
<AgentBuildPanelGrid className="absolute top-0 left-0" />
|
||||
<AgentBuildPanelGrid className="absolute bottom-0 left-0 origin-center scale-y-[-1]" />
|
||||
<AgentBuildGridTexture className="absolute top-0 left-0" />
|
||||
<AgentBuildGridTexture className="absolute bottom-0 left-0 origin-center scale-y-[-1]" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
+4
-1
@@ -80,12 +80,15 @@ function AgentChatFeaturesPanelContent({
|
||||
const featuresStore = useFeaturesStore()
|
||||
const setAppFeatures = useSetAppFeatures()
|
||||
const handleChange = useCallback(() => {
|
||||
if (disabled)
|
||||
return
|
||||
|
||||
const features = featuresStore?.getState().features
|
||||
if (!features)
|
||||
return
|
||||
|
||||
setAppFeatures(currentAppFeatures => toAppFeatures(features, currentAppFeatures ?? appFeatures))
|
||||
}, [appFeatures, featuresStore, setAppFeatures])
|
||||
}, [appFeatures, disabled, featuresStore, setAppFeatures])
|
||||
|
||||
return (
|
||||
<NewFeaturePanel
|
||||
|
||||
@@ -11,19 +11,19 @@ import type {
|
||||
import type {
|
||||
ReactNode,
|
||||
} from 'react'
|
||||
import type { FeedbackType, IChatItem, ThoughtItem } from '@/app/components/base/chat/chat/type'
|
||||
import type { FeedbackType, IChatItem, InputForm, ThoughtItem } from '@/app/components/base/chat/chat/type'
|
||||
import type { ChatConfig, ChatItem, ChatItemInTree, OnSend } from '@/app/components/base/chat/types'
|
||||
import type { FileUpload } from '@/app/components/base/features/types'
|
||||
import type { FileEntity } from '@/app/components/base/file-uploader/types'
|
||||
import type { DefaultModel } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import type { AgentComposerModel } from '@/features/agent-v2/agent-composer/form-state'
|
||||
import type { Inputs } from '@/models/debug'
|
||||
import type { MessageRating } from '@/models/log'
|
||||
import type { FileResponse } from '@/types/workflow'
|
||||
import { Avatar } from '@langgenius/dify-ui/avatar'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { skipToken, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import ChatInputArea from '@/app/components/base/chat/chat/chat-input-area'
|
||||
import { useChat } from '@/app/components/base/chat/chat/hooks'
|
||||
import { buildChatItemTree, getLastAnswer, isValidGeneratedAnswer } from '@/app/components/base/chat/utils'
|
||||
@@ -40,7 +40,7 @@ import { agentComposerPromptAtom } from '@/features/agent-v2/agent-composer/stor
|
||||
import { ENABLE_AGENT_CLI_TOOLS } from '@/features/agent-v2/agent-detail/configure/feature-flags'
|
||||
import { PromptMode } from '@/models/debug'
|
||||
import dynamic from '@/next/dynamic'
|
||||
import { consoleClient } from '@/service/client'
|
||||
import { consoleClient, consoleQuery } from '@/service/client'
|
||||
import { AgentStrategy, ModelModeType, RETRIEVE_TYPE, TransferMethod } from '@/types/app'
|
||||
|
||||
const Chat = dynamic(() => import('@/app/components/base/chat/chat'), { ssr: false })
|
||||
@@ -155,6 +155,15 @@ const toInputForm = (variable: NonNullable<AgentSoulConfig['app_variables']>[num
|
||||
}
|
||||
}
|
||||
|
||||
const getAgentSoulInputsForm = (agentSoulConfig?: AgentSoulConfig) => (agentSoulConfig?.app_variables ?? []).map(toInputForm)
|
||||
|
||||
const getAgentSoulInputs = (inputsForm: InputForm[]) => {
|
||||
return inputsForm.reduce<Inputs>((acc, input) => {
|
||||
acc[input.variable] = (input.default ?? '') as Inputs[string]
|
||||
return acc
|
||||
}, {})
|
||||
}
|
||||
|
||||
const toAgentTool = (tool: AgentSoulDifyToolConfig) => ({
|
||||
provider_id: tool.provider_id ?? tool.provider ?? tool.plugin_id ?? '',
|
||||
provider_type: tool.provider_type ?? 'builtin',
|
||||
@@ -215,17 +224,6 @@ const stopAgentChatMessageResponding = (agentId: string, taskId: string) => {
|
||||
})
|
||||
}
|
||||
|
||||
const fetchAgentConversationMessages = (agentId: string, conversationId: string) => {
|
||||
return consoleClient.agent.byAgentId.chatMessages.get({
|
||||
params: {
|
||||
agent_id: agentId,
|
||||
},
|
||||
query: {
|
||||
conversation_id: conversationId,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const toFileResponse = (file: NonNullable<MessageDetailResponse['message_files']>[number]): FileResponse => ({
|
||||
related_id: file.id ?? file.upload_file_id,
|
||||
extension: '',
|
||||
@@ -287,6 +285,16 @@ const getAgentDebugMessageAnswer = (message: MessageDetailResponse) => {
|
||||
return message.answer ?? ''
|
||||
}
|
||||
|
||||
function getLastWorkflowRunId(messages: MessageDetailResponse[]) {
|
||||
for (let index = messages.length - 1; index >= 0; index--) {
|
||||
const workflowRunId = messages[index]?.workflow_run_id
|
||||
if (workflowRunId)
|
||||
return workflowRunId
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function getFormattedAgentDebugChatTree(messages: MessageDetailResponse[]): ChatItemInTree[] {
|
||||
const chatList: IChatItem[] = []
|
||||
|
||||
@@ -350,10 +358,10 @@ const buildChatConfig = ({
|
||||
prompt,
|
||||
}: {
|
||||
agentSoulConfig?: AgentSoulConfig
|
||||
currentModel?: DefaultModel
|
||||
currentModel?: AgentComposerModel
|
||||
prompt: string
|
||||
}): AgentPreviewChatConfig => {
|
||||
const modelSettings = getModelSettings(agentSoulConfig)
|
||||
const modelSettings = currentModel?.model_settings ?? getModelSettings(agentSoulConfig)
|
||||
const appFeatures = agentSoulConfig?.app_features ?? {}
|
||||
const difyTools = agentSoulConfig?.tools?.dify_tools ?? []
|
||||
const cliTools = ENABLE_AGENT_CLI_TOOLS ? (agentSoulConfig?.tools?.cli_tools ?? []) : []
|
||||
@@ -443,9 +451,11 @@ export type AgentChatRuntimeProps = {
|
||||
sendButtonLabel?: string
|
||||
renderEmptyState: (props: AgentChatRuntimeEmptyStateProps) => ReactNode
|
||||
onClearChatListChange: (clearChatList: boolean) => void
|
||||
onConversationComplete?: (conversationId: string) => void
|
||||
onConversationComplete?: (conversationId: string, workflowRunId?: string) => void
|
||||
onConversationIdChange?: (conversationId: string) => void
|
||||
onSaveDraftBeforeRun?: () => Promise<void>
|
||||
onWorkflowRunIdChange?: (workflowRunId: string | null) => void
|
||||
onSaveDraftBeforeRun?: () => Promise<AgentSoulConfig | void>
|
||||
onSendInterrupted?: () => void
|
||||
}
|
||||
|
||||
export function AgentChatRuntime({
|
||||
@@ -464,29 +474,54 @@ export function AgentChatRuntime({
|
||||
onClearChatListChange,
|
||||
onConversationComplete,
|
||||
onConversationIdChange,
|
||||
onWorkflowRunIdChange,
|
||||
onSendInterrupted,
|
||||
onSaveDraftBeforeRun,
|
||||
}: AgentChatRuntimeProps) {
|
||||
const historyQuery = useQuery({
|
||||
queryKey: ['agent-chat-conversation-messages', agentId, conversationId],
|
||||
queryFn: () => fetchAgentConversationMessages(agentId, conversationId!),
|
||||
enabled: !!conversationId,
|
||||
})
|
||||
const [currentSessionConversationId, setCurrentSessionConversationId] = useState<string | null>(null)
|
||||
const handleClearChatListChange = useCallback((nextClearChatList: boolean) => {
|
||||
if (!nextClearChatList)
|
||||
setCurrentSessionConversationId(null)
|
||||
onClearChatListChange(nextClearChatList)
|
||||
}, [onClearChatListChange])
|
||||
const historyQuery = useQuery(consoleQuery.agent.byAgentId.chatMessages.get.queryOptions({
|
||||
input: conversationId
|
||||
? {
|
||||
params: {
|
||||
agent_id: agentId,
|
||||
},
|
||||
query: {
|
||||
conversation_id: conversationId,
|
||||
},
|
||||
}
|
||||
: skipToken,
|
||||
}))
|
||||
const conversationBelongsToCurrentSession = !!conversationId && conversationId === currentSessionConversationId
|
||||
const initialChatTree = useMemo(
|
||||
() => getFormattedAgentDebugChatTree(historyQuery.data?.data ?? []),
|
||||
[historyQuery.data?.data],
|
||||
)
|
||||
useEffect(() => {
|
||||
if (!conversationId || !historyQuery.data)
|
||||
return
|
||||
|
||||
if (conversationId && historyQuery.isPending) {
|
||||
onWorkflowRunIdChange?.(getLastWorkflowRunId(historyQuery.data.data ?? []))
|
||||
}, [conversationId, historyQuery.data, onWorkflowRunIdChange])
|
||||
|
||||
if (conversationId && historyQuery.isPending && !conversationBelongsToCurrentSession) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<Loading type="app" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const chatSessionKey = !conversationId || conversationBelongsToCurrentSession
|
||||
? 'current-session'
|
||||
: `${conversationId}-${historyQuery.dataUpdatedAt}`
|
||||
|
||||
return (
|
||||
<AgentPreviewChatSession
|
||||
key={`${conversationId ?? 'new'}-${historyQuery.dataUpdatedAt}`}
|
||||
key={chatSessionKey}
|
||||
agentId={agentId}
|
||||
agentIcon={agentIcon}
|
||||
agentIconBackground={agentIconBackground}
|
||||
@@ -500,9 +535,11 @@ export function AgentChatRuntime({
|
||||
inputPlaceholder={inputPlaceholder}
|
||||
sendButtonLabel={sendButtonLabel}
|
||||
renderEmptyState={renderEmptyState}
|
||||
onClearChatListChange={onClearChatListChange}
|
||||
onClearChatListChange={handleClearChatListChange}
|
||||
onConversationComplete={onConversationComplete}
|
||||
onConversationIdChange={onConversationIdChange}
|
||||
onCurrentSessionConversationIdChange={setCurrentSessionConversationId}
|
||||
onSendInterrupted={onSendInterrupted}
|
||||
onSaveDraftBeforeRun={onSaveDraftBeforeRun}
|
||||
/>
|
||||
)
|
||||
@@ -525,6 +562,8 @@ function AgentPreviewChatSession({
|
||||
onClearChatListChange,
|
||||
onConversationComplete,
|
||||
onConversationIdChange,
|
||||
onCurrentSessionConversationIdChange,
|
||||
onSendInterrupted,
|
||||
onSaveDraftBeforeRun,
|
||||
}: {
|
||||
agentId: string
|
||||
@@ -541,10 +580,13 @@ function AgentPreviewChatSession({
|
||||
sendButtonLabel?: string
|
||||
renderEmptyState: (props: AgentChatRuntimeEmptyStateProps) => ReactNode
|
||||
onClearChatListChange: (clearChatList: boolean) => void
|
||||
onConversationComplete?: (conversationId: string) => void
|
||||
onConversationComplete?: (conversationId: string, workflowRunId?: string) => void
|
||||
onConversationIdChange?: (conversationId: string) => void
|
||||
onSaveDraftBeforeRun?: () => Promise<void>
|
||||
onCurrentSessionConversationIdChange: (conversationId: string) => void
|
||||
onSaveDraftBeforeRun?: () => Promise<AgentSoulConfig | void>
|
||||
onSendInterrupted?: () => void
|
||||
}) {
|
||||
const queryClient = useQueryClient()
|
||||
const { userProfile } = useAppContext()
|
||||
const prompt = useAtomValue(agentComposerPromptAtom)
|
||||
const currentModel = useAtomValue(agentComposerModelAtom)
|
||||
@@ -553,13 +595,16 @@ function AgentPreviewChatSession({
|
||||
currentModel,
|
||||
prompt,
|
||||
}), [agentSoulConfig, currentModel, prompt])
|
||||
const inputsForm = useMemo(() => (agentSoulConfig?.app_variables ?? []).map(toInputForm), [agentSoulConfig?.app_variables])
|
||||
const inputs = useMemo(() => {
|
||||
return inputsForm.reduce<Inputs>((acc, input) => {
|
||||
acc[input.variable] = (input.default ?? '') as Inputs[string]
|
||||
return acc
|
||||
}, {})
|
||||
}, [inputsForm])
|
||||
const inputsForm = useMemo(() => getAgentSoulInputsForm(agentSoulConfig), [agentSoulConfig])
|
||||
const inputs = useMemo(() => getAgentSoulInputs(inputsForm), [inputsForm])
|
||||
const sendInterruptedRef = useRef(false)
|
||||
const notifySendInterrupted = useCallback(() => {
|
||||
if (sendInterruptedRef.current)
|
||||
return
|
||||
|
||||
sendInterruptedRef.current = true
|
||||
onSendInterrupted?.()
|
||||
}, [onSendInterrupted])
|
||||
const {
|
||||
textGenerationModelList,
|
||||
} = useTextGenerationCurrentProviderAndModelAndModelList(currentModel)
|
||||
@@ -589,40 +634,78 @@ function AgentPreviewChatSession({
|
||||
)
|
||||
|
||||
const doSend: OnSend = useCallback(async (message, files, isRegenerate = false, parentAnswer: ChatItem | null = null) => {
|
||||
sendInterruptedRef.current = false
|
||||
|
||||
try {
|
||||
await onSaveDraftBeforeRun?.()
|
||||
const preparedAgentSoulConfig = await onSaveDraftBeforeRun?.()
|
||||
const runtimeAgentSoulConfig = preparedAgentSoulConfig || agentSoulConfig
|
||||
const runtimeInputsForm = preparedAgentSoulConfig ? getAgentSoulInputsForm(runtimeAgentSoulConfig) : inputsForm
|
||||
const runtimeInputs = preparedAgentSoulConfig ? getAgentSoulInputs(runtimeInputsForm) : inputs
|
||||
const runtimeConfig = preparedAgentSoulConfig
|
||||
? buildChatConfig({
|
||||
agentSoulConfig: runtimeAgentSoulConfig,
|
||||
currentModel: undefined,
|
||||
prompt: runtimeAgentSoulConfig?.prompt?.system_prompt ?? '',
|
||||
})
|
||||
: config
|
||||
|
||||
const currentProvider = textGenerationModelList.find(item => item.provider === runtimeConfig.model.provider)
|
||||
const selectedModel = currentProvider?.models.find(model => model.model === runtimeConfig.model.name)
|
||||
const supportVision = selectedModel?.features?.includes(ModelFeatureEnum.vision)
|
||||
const data: Record<string, unknown> = {
|
||||
query: message,
|
||||
inputs: runtimeInputs,
|
||||
overrideInputsForm: runtimeInputsForm,
|
||||
parent_message_id: (isRegenerate ? parentAnswer?.id : getLastAnswer(chatList)?.id) || null,
|
||||
}
|
||||
if (draftType)
|
||||
data.draft_type = draftType
|
||||
|
||||
if (files?.length && supportVision)
|
||||
data.files = files
|
||||
|
||||
handleSend(
|
||||
`agent/${agentId}/chat-messages`,
|
||||
data as Parameters<typeof handleSend>[1],
|
||||
{
|
||||
onGetConversationMessages: async (conversationId) => {
|
||||
return queryClient.fetchQuery({
|
||||
...consoleQuery.agent.byAgentId.chatMessages.get.queryOptions({
|
||||
input: {
|
||||
params: {
|
||||
agent_id: agentId,
|
||||
},
|
||||
query: {
|
||||
conversation_id: conversationId,
|
||||
},
|
||||
},
|
||||
}),
|
||||
staleTime: 0,
|
||||
})
|
||||
},
|
||||
onGetSuggestedQuestions: responseItemId => fetchAgentSuggestedQuestions(agentId, responseItemId),
|
||||
onConversationComplete: (completedConversationId, workflowRunId) => {
|
||||
if (completedConversationId && completedConversationId !== conversationId)
|
||||
onCurrentSessionConversationIdChange(completedConversationId)
|
||||
onConversationIdChange?.(completedConversationId)
|
||||
onConversationComplete?.(completedConversationId, workflowRunId)
|
||||
},
|
||||
onSendSettled: (hasError) => {
|
||||
if (hasError)
|
||||
notifySendInterrupted()
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
catch {
|
||||
return
|
||||
return false
|
||||
}
|
||||
}, [agentId, agentSoulConfig, chatList, config, conversationId, draftType, handleSend, inputs, inputsForm, notifySendInterrupted, onConversationComplete, onConversationIdChange, onCurrentSessionConversationIdChange, onSaveDraftBeforeRun, queryClient, textGenerationModelList])
|
||||
|
||||
const currentProvider = textGenerationModelList.find(item => item.provider === config.model.provider)
|
||||
const selectedModel = currentProvider?.models.find(model => model.model === config.model.name)
|
||||
const supportVision = selectedModel?.features?.includes(ModelFeatureEnum.vision)
|
||||
const data: Record<string, unknown> = {
|
||||
query: message,
|
||||
inputs,
|
||||
parent_message_id: (isRegenerate ? parentAnswer?.id : getLastAnswer(chatList)?.id) || null,
|
||||
}
|
||||
if (draftType)
|
||||
data.draft_type = draftType
|
||||
|
||||
if (files?.length && supportVision)
|
||||
data.files = files
|
||||
|
||||
handleSend(
|
||||
`agent/${agentId}/chat-messages`,
|
||||
data as Parameters<typeof handleSend>[1],
|
||||
{
|
||||
onGetConversationMessages: conversationId => fetchAgentConversationMessages(agentId, conversationId),
|
||||
onGetSuggestedQuestions: responseItemId => fetchAgentSuggestedQuestions(agentId, responseItemId),
|
||||
onConversationComplete: (conversationId) => {
|
||||
onConversationIdChange?.(conversationId)
|
||||
onConversationComplete?.(conversationId)
|
||||
},
|
||||
},
|
||||
)
|
||||
}, [agentId, chatList, config.model.name, config.model.provider, draftType, handleSend, inputs, onConversationComplete, onConversationIdChange, onSaveDraftBeforeRun, textGenerationModelList])
|
||||
const doStopResponding = useCallback(() => {
|
||||
handleStop()
|
||||
notifySendInterrupted()
|
||||
}, [handleStop, notifySendInterrupted])
|
||||
|
||||
const doRegenerate = useCallback((chatItem: ChatItem, editedQuestion?: { message: string, files?: FileEntity[] }) => {
|
||||
const question = editedQuestion ? chatItem : chatList.find(item => item.id === chatItem.parentMessageId)
|
||||
@@ -685,7 +768,7 @@ function AgentPreviewChatSession({
|
||||
inputsForm={inputsForm}
|
||||
onRegenerate={doRegenerate}
|
||||
switchSibling={siblingMessageId => setTargetMessageId(siblingMessageId)}
|
||||
onStopResponding={handleStop}
|
||||
onStopResponding={doStopResponding}
|
||||
noChatInput={isEmptyChat}
|
||||
showPromptLog
|
||||
questionIcon={<Avatar avatar={userProfile.avatar_url} name={userProfile.name} size="xl" />}
|
||||
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
'use client'
|
||||
|
||||
import type { QueryClient } from '@tanstack/react-query'
|
||||
import type { ReactNode } from 'react'
|
||||
import type { AgentWorkingDirectorySource } from '../working-directory-panel'
|
||||
import { useState } from 'react'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { AgentWorkingDirectoryPanel } from '../working-directory-panel'
|
||||
|
||||
export function invalidateAgentWorkingDirectoryFiles({
|
||||
appId,
|
||||
conversationId,
|
||||
nodeId,
|
||||
queryClient,
|
||||
}: {
|
||||
agentId: string
|
||||
appId?: string
|
||||
conversationId?: string | null
|
||||
nodeId?: string
|
||||
queryClient: QueryClient
|
||||
workflowRunId?: string | null
|
||||
}) {
|
||||
if (appId && nodeId) {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: consoleQuery.apps.byAppId.workflowRuns.byWorkflowRunId.agentNodes.byNodeId.sandbox.files.get.key({ type: 'query' }),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (!conversationId)
|
||||
return
|
||||
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: consoleQuery.agent.byAgentId.sandbox.files.get.key({ type: 'query' }),
|
||||
})
|
||||
}
|
||||
|
||||
export function useAgentWorkingDirectoryPanel({
|
||||
agentId,
|
||||
appId,
|
||||
conversationId,
|
||||
nodeId,
|
||||
workflowRunId,
|
||||
}: {
|
||||
agentId: string
|
||||
appId?: string
|
||||
conversationId?: string | null
|
||||
nodeId?: string
|
||||
workflowRunId?: string | null
|
||||
}): {
|
||||
closeWorkingDirectory: () => void
|
||||
openWorkingDirectory: () => void
|
||||
panel: ReactNode
|
||||
} {
|
||||
const [open, setOpen] = useState(false)
|
||||
const source: AgentWorkingDirectorySource = appId && nodeId
|
||||
? {
|
||||
type: 'workflow-node',
|
||||
appId,
|
||||
conversationId,
|
||||
nodeId,
|
||||
workflowRunId,
|
||||
}
|
||||
: {
|
||||
type: 'agent',
|
||||
agentId,
|
||||
conversationId,
|
||||
}
|
||||
|
||||
return {
|
||||
closeWorkingDirectory: () => setOpen(false),
|
||||
openWorkingDirectory: () => setOpen(true),
|
||||
panel: open
|
||||
? (
|
||||
<AgentWorkingDirectoryPanel
|
||||
source={source}
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
/>
|
||||
)
|
||||
: null,
|
||||
}
|
||||
}
|
||||
+4
-6
@@ -1,13 +1,11 @@
|
||||
'use client'
|
||||
|
||||
import type { AgentSoulConfig } from '@dify/contracts/api/console/agent/types.gen'
|
||||
import type { AgentConfigureConversationIds, AgentConfigureRightPanelMode } from '../../state'
|
||||
import { useAgentPreviewSoulConfig } from '../../hooks'
|
||||
import { AgentBuildChat } from './build-chat'
|
||||
import { AgentPreviewChat } from './preview-chat'
|
||||
|
||||
export type AgentConfigureRightPanelMode = 'build' | 'preview'
|
||||
export type AgentConfigureConversationIds = Record<AgentConfigureRightPanelMode, string | null>
|
||||
|
||||
export function AgentConfigureRightPanelChat({
|
||||
agentSoulConfig,
|
||||
conversationIds,
|
||||
@@ -19,7 +17,7 @@ export function AgentConfigureRightPanelChat({
|
||||
agentSoulConfig?: AgentSoulConfig
|
||||
conversationIds: AgentConfigureConversationIds
|
||||
mode: AgentConfigureRightPanelMode
|
||||
onConversationComplete?: (mode: AgentConfigureRightPanelMode) => void
|
||||
onConversationComplete?: (mode: AgentConfigureRightPanelMode, conversationId: string, workflowRunId?: string) => void
|
||||
onConversationIdChange: (mode: AgentConfigureRightPanelMode, conversationId: string) => void
|
||||
}) {
|
||||
const previewAgentSoulConfig = useAgentPreviewSoulConfig(agentSoulConfig)
|
||||
@@ -27,8 +25,8 @@ export function AgentConfigureRightPanelChat({
|
||||
const handleConversationIdChange = (newConversationId: string) => {
|
||||
onConversationIdChange(mode, newConversationId)
|
||||
}
|
||||
const handleConversationComplete = () => {
|
||||
onConversationComplete?.(mode)
|
||||
const handleConversationComplete = (completedConversationId: string, workflowRunId?: string) => {
|
||||
onConversationComplete?.(mode, completedConversationId, workflowRunId)
|
||||
}
|
||||
|
||||
return mode === 'build'
|
||||
|
||||
-24
@@ -1,24 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import { useState } from 'react'
|
||||
import { AgentWorkingDirectoryPanel } from './working-directory-panel'
|
||||
|
||||
export function useAgentWorkingDirectoryPanel(): {
|
||||
closeWorkingDirectory: () => void
|
||||
openWorkingDirectory: () => void
|
||||
panel: ReactNode
|
||||
} {
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
return {
|
||||
closeWorkingDirectory: () => setOpen(false),
|
||||
openWorkingDirectory: () => setOpen(true),
|
||||
panel: (
|
||||
<AgentWorkingDirectoryPanel
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
/>
|
||||
),
|
||||
}
|
||||
}
|
||||
@@ -1,224 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type { AgentConfigSnapshotSummaryResponse } from '@dify/contracts/api/console/agent/types.gen'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import useTimestamp from '@/hooks/use-timestamp'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
|
||||
type AgentPreviewVersionsPanelProps = {
|
||||
agentId: string
|
||||
activeVersionId?: string | null
|
||||
onSelectVersion: (versionId: string | null) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
function VersionTimelineDot({
|
||||
isActive,
|
||||
isFirst,
|
||||
isLast,
|
||||
}: {
|
||||
isActive: boolean
|
||||
isFirst: boolean
|
||||
isLast: boolean
|
||||
}) {
|
||||
return (
|
||||
<div className="relative flex w-[18px] shrink-0 justify-center pt-1.5">
|
||||
{!isFirst && <div className="absolute top-0 h-2 w-0.5 bg-divider-subtle" />}
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
'relative z-1 size-2 rounded-full border-2 bg-components-panel-bg',
|
||||
isActive ? 'border-text-accent' : 'border-text-quaternary',
|
||||
)}
|
||||
/>
|
||||
{!isLast && <div className="absolute top-3 bottom-[-18px] w-0.5 bg-divider-subtle" />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function VersionMetadata({
|
||||
version,
|
||||
}: {
|
||||
version: AgentConfigSnapshotSummaryResponse
|
||||
}) {
|
||||
const { t } = useTranslation('agentV2')
|
||||
const { formatTime } = useTimestamp()
|
||||
|
||||
if (version.created_at == null && !version.created_by)
|
||||
return null
|
||||
|
||||
return (
|
||||
<p className="truncate system-xs-regular text-text-tertiary">
|
||||
{version.created_at != null && formatTime(version.created_at, t('roster.dateTimeFormat'))}
|
||||
{version.created_at != null && version.created_by && ' · '}
|
||||
{version.created_by}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
function VersionItem({
|
||||
version,
|
||||
activeVersionId,
|
||||
isLatest,
|
||||
isFirst,
|
||||
isLast,
|
||||
onSelect,
|
||||
}: {
|
||||
version: AgentConfigSnapshotSummaryResponse
|
||||
activeVersionId?: string | null
|
||||
isLatest: boolean
|
||||
isFirst: boolean
|
||||
isLast: boolean
|
||||
onSelect: (versionId: string) => void
|
||||
}) {
|
||||
const { t } = useTranslation('agentV2')
|
||||
const { t: tWorkflow } = useTranslation('workflow')
|
||||
const isActive = version.id === activeVersionId
|
||||
const label = version.version_note || t('agentDetail.versionHistory.versionName', { version: version.version })
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-current={isActive ? 'true' : undefined}
|
||||
onClick={() => onSelect(version.id)}
|
||||
className={cn(
|
||||
'group relative flex w-full items-start gap-1 rounded-lg py-1 pr-1.5 pl-2 text-left focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden',
|
||||
isActive ? 'bg-state-accent-active' : 'hover:bg-state-base-hover',
|
||||
)}
|
||||
>
|
||||
<VersionTimelineDot isActive={isActive} isFirst={isFirst} isLast={isLast} />
|
||||
<div className="min-w-0 flex-1 py-0.5">
|
||||
<div className="flex min-w-0 items-center gap-1">
|
||||
<p className={cn('truncate system-sm-semibold', isActive ? 'text-text-accent' : 'text-text-secondary')}>
|
||||
{label}
|
||||
</p>
|
||||
{isLatest && (
|
||||
<span className="shrink-0 rounded-[5px] border border-text-accent-secondary bg-components-badge-bg-dimm px-[5px] py-[3px] system-2xs-medium-uppercase text-text-accent-secondary">
|
||||
{tWorkflow('versionHistory.latest')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{isActive && version.summary && (
|
||||
<p className="mt-0.5 line-clamp-4 system-xs-regular text-text-secondary">
|
||||
{version.summary}
|
||||
</p>
|
||||
)}
|
||||
<VersionMetadata version={version} />
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function CurrentDraftItem({
|
||||
isActive,
|
||||
isLast,
|
||||
onSelect,
|
||||
}: {
|
||||
isActive: boolean
|
||||
isLast: boolean
|
||||
onSelect: () => void
|
||||
}) {
|
||||
const { t: tWorkflow } = useTranslation('workflow')
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-current={isActive ? 'true' : undefined}
|
||||
onClick={onSelect}
|
||||
className={cn(
|
||||
'flex w-full items-start gap-1 rounded-lg py-1 pr-1.5 pl-2 text-left focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden',
|
||||
isActive ? 'bg-state-accent-active' : 'hover:bg-state-base-hover',
|
||||
)}
|
||||
>
|
||||
<VersionTimelineDot isActive={isActive} isFirst isLast={isLast} />
|
||||
<div className="min-w-0 flex-1 py-1">
|
||||
<p className={cn('truncate system-sm-semibold', isActive ? 'text-text-accent' : 'text-text-secondary')}>
|
||||
{tWorkflow('versionHistory.currentDraft')}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export function AgentPreviewVersionsPanel({
|
||||
agentId,
|
||||
activeVersionId,
|
||||
onSelectVersion,
|
||||
onClose,
|
||||
}: AgentPreviewVersionsPanelProps) {
|
||||
const { t } = useTranslation('agentV2')
|
||||
const { t: tCommon } = useTranslation('common')
|
||||
const { t: tWorkflow } = useTranslation('workflow')
|
||||
const versionsQuery = useQuery(consoleQuery.agent.byAgentId.versions.get.queryOptions({
|
||||
input: {
|
||||
params: {
|
||||
agent_id: agentId,
|
||||
},
|
||||
},
|
||||
}))
|
||||
const versions = versionsQuery.data?.data ?? []
|
||||
const latestVersionId = versions[0]?.id
|
||||
|
||||
return (
|
||||
<aside className="flex h-full w-[268px] shrink-0 flex-col rounded-l-lg bg-components-panel-bg shadow-xl shadow-shadow-shadow-5">
|
||||
<div className="flex shrink-0 items-center gap-2 pt-3 pr-3 pl-4">
|
||||
<h2 className="min-w-0 flex-1 truncate system-xl-semibold text-text-primary">
|
||||
{tWorkflow('versionHistory.title')}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('agentDetail.versionHistory.filter')}
|
||||
className="flex size-6 shrink-0 items-center justify-center rounded-md p-0.5 text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden"
|
||||
>
|
||||
<span aria-hidden className="i-ri-filter-3-line size-4" />
|
||||
</button>
|
||||
<div className="h-3.5 w-px shrink-0 bg-divider-regular" />
|
||||
<button
|
||||
type="button"
|
||||
aria-label={tCommon('operation.close')}
|
||||
onClick={onClose}
|
||||
className="flex size-6 shrink-0 items-center justify-center rounded-md p-0.5 text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden"
|
||||
>
|
||||
<span aria-hidden className="i-ri-close-line size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-3 py-2">
|
||||
{versionsQuery.isPending && (
|
||||
<div className="space-y-1">
|
||||
<div className="h-10 animate-pulse rounded-lg bg-state-base-hover" />
|
||||
<div className="h-18 animate-pulse rounded-lg bg-state-base-hover" />
|
||||
<div className="h-10 animate-pulse rounded-lg bg-state-base-hover" />
|
||||
</div>
|
||||
)}
|
||||
{!versionsQuery.isPending && versions.length === 0 && (
|
||||
<div className="rounded-lg border border-components-panel-border bg-components-panel-on-panel-item-bg px-3 py-6 text-center system-sm-regular text-text-tertiary">
|
||||
{t('agentDetail.versionHistory.empty')}
|
||||
</div>
|
||||
)}
|
||||
{!versionsQuery.isPending && versions.length > 0 && (
|
||||
<div className="flex flex-col gap-px">
|
||||
<CurrentDraftItem
|
||||
isActive={!activeVersionId}
|
||||
isLast={versions.length === 0}
|
||||
onSelect={() => onSelectVersion(null)}
|
||||
/>
|
||||
{versions.map((version, index) => (
|
||||
<VersionItem
|
||||
key={version.id}
|
||||
version={version}
|
||||
activeVersionId={activeVersionId}
|
||||
isLatest={version.id === latestVersionId}
|
||||
isFirst={false}
|
||||
isLast={index === versions.length - 1}
|
||||
onSelect={onSelectVersion}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { VersionTimelineDot } from './version-timeline-dot'
|
||||
|
||||
export function CurrentDraftItem({
|
||||
isActive,
|
||||
isLast,
|
||||
onSelect,
|
||||
}: {
|
||||
isActive: boolean
|
||||
isLast: boolean
|
||||
onSelect: () => void
|
||||
}) {
|
||||
const { t: tWorkflow } = useTranslation('workflow')
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-current={isActive ? 'true' : undefined}
|
||||
onClick={onSelect}
|
||||
className={cn(
|
||||
'flex w-full items-start gap-1 rounded-lg py-1 pr-1.5 pl-2 text-left focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden',
|
||||
isActive ? 'bg-state-accent-active' : 'hover:bg-state-base-hover',
|
||||
)}
|
||||
>
|
||||
<VersionTimelineDot isActive={isActive} isFirst isLast={isLast} />
|
||||
<div className="min-w-0 flex-1 py-1">
|
||||
<p className={cn('truncate system-sm-semibold', isActive ? 'text-text-accent' : 'text-text-secondary')}>
|
||||
{tWorkflow('versionHistory.currentDraft')}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
'use client'
|
||||
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@langgenius/dify-ui/popover'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export type AgentVersionFilter = 'all' | 'onlyYours'
|
||||
|
||||
function FilterItem({
|
||||
label,
|
||||
selected,
|
||||
onClick,
|
||||
}: {
|
||||
label: string
|
||||
selected: boolean
|
||||
onClick: () => void
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="flex w-full cursor-pointer items-center justify-between gap-x-1 rounded-lg px-2 py-1.5 text-left hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden"
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate system-md-regular text-text-primary">{label}</span>
|
||||
{selected && <span aria-hidden className="i-ri-check-line size-4 shrink-0 text-text-accent" />}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export function VersionFilter({
|
||||
filterValue,
|
||||
onFilterChange,
|
||||
}: {
|
||||
filterValue: AgentVersionFilter
|
||||
onFilterChange: (filterValue: AgentVersionFilter) => void
|
||||
}) {
|
||||
const { t } = useTranslation('agentV2')
|
||||
const { t: tWorkflow } = useTranslation('workflow')
|
||||
const [open, setOpen] = useState(false)
|
||||
const isFiltering = filterValue !== 'all'
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger
|
||||
nativeButton={false}
|
||||
render={(
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('agentDetail.versionHistory.filter')}
|
||||
className={cn(
|
||||
'flex size-6 shrink-0 items-center justify-center rounded-md p-0.5 focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden',
|
||||
isFiltering
|
||||
? 'bg-state-accent-active-alt text-text-accent'
|
||||
: 'text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary',
|
||||
)}
|
||||
>
|
||||
<span aria-hidden className="i-ri-filter-3-line size-4" />
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
<PopoverContent
|
||||
placement="bottom-end"
|
||||
sideOffset={4}
|
||||
alignOffset={55}
|
||||
popupClassName="border-none bg-transparent shadow-none"
|
||||
>
|
||||
<div className="flex w-[248px] flex-col rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg shadow-shadow-shadow-5 backdrop-blur-[5px]">
|
||||
<div className="flex flex-col p-1">
|
||||
<FilterItem
|
||||
label={tWorkflow('versionHistory.filter.all')}
|
||||
selected={filterValue === 'all'}
|
||||
onClick={() => onFilterChange('all')}
|
||||
/>
|
||||
<FilterItem
|
||||
label={tWorkflow('versionHistory.filter.onlyYours')}
|
||||
selected={filterValue === 'onlyYours'}
|
||||
onClick={() => onFilterChange('onlyYours')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
+118
@@ -0,0 +1,118 @@
|
||||
'use client'
|
||||
|
||||
import type { AgentVersionFilter } from './filter'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useSelector as useAppContextSelector } from '@/context/app-context'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { CurrentDraftItem } from './current-draft-item'
|
||||
import { VersionFilter } from './filter'
|
||||
import { VersionFilterEmpty } from './version-filter-empty'
|
||||
import { VersionItem } from './version-item'
|
||||
|
||||
type AgentPreviewVersionsPanelProps = {
|
||||
agentId: string
|
||||
activeVersionId?: string | null
|
||||
onSelectVersion: (versionId: string | null) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function AgentPreviewVersionsPanel({
|
||||
agentId,
|
||||
activeVersionId,
|
||||
onSelectVersion,
|
||||
onClose,
|
||||
}: AgentPreviewVersionsPanelProps) {
|
||||
const { t } = useTranslation('agentV2')
|
||||
const { t: tCommon } = useTranslation('common')
|
||||
const { t: tWorkflow } = useTranslation('workflow')
|
||||
const userProfile = useAppContextSelector(state => state.userProfile)
|
||||
const [filterValue, setFilterValue] = useState<AgentVersionFilter>('all')
|
||||
const versionsQuery = useQuery(consoleQuery.agent.byAgentId.versions.get.queryOptions({
|
||||
input: {
|
||||
params: {
|
||||
agent_id: agentId,
|
||||
},
|
||||
},
|
||||
}))
|
||||
const versions = versionsQuery.data?.data ?? []
|
||||
const latestVersionId = versions[0]?.id
|
||||
const currentUserCreatedByValues = new Set([
|
||||
userProfile.id,
|
||||
userProfile.name,
|
||||
userProfile.email,
|
||||
].filter(Boolean))
|
||||
const filteredVersions = versions.filter((version) => {
|
||||
if (filterValue === 'onlyYours')
|
||||
return !!version.created_by && currentUserCreatedByValues.has(version.created_by)
|
||||
|
||||
return true
|
||||
})
|
||||
const isFiltering = filterValue !== 'all'
|
||||
|
||||
const handleResetFilter = () => {
|
||||
setFilterValue('all')
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="flex h-full w-[268px] shrink-0 flex-col rounded-l-lg bg-components-panel-bg shadow-xl shadow-shadow-shadow-5">
|
||||
<div className="flex shrink-0 items-center gap-2 pt-3 pr-3 pl-4">
|
||||
<h2 className="min-w-0 flex-1 truncate system-xl-semibold text-text-primary">
|
||||
{tWorkflow('versionHistory.title')}
|
||||
</h2>
|
||||
<VersionFilter
|
||||
filterValue={filterValue}
|
||||
onFilterChange={setFilterValue}
|
||||
/>
|
||||
<div className="h-3.5 w-px shrink-0 bg-divider-regular" />
|
||||
<button
|
||||
type="button"
|
||||
aria-label={tCommon('operation.close')}
|
||||
onClick={onClose}
|
||||
className="flex size-6 shrink-0 items-center justify-center rounded-md p-0.5 text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden"
|
||||
>
|
||||
<span aria-hidden className="i-ri-close-line size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-3 py-2">
|
||||
{versionsQuery.isPending && (
|
||||
<div className="space-y-1">
|
||||
<div className="h-10 animate-pulse rounded-lg bg-state-base-hover" />
|
||||
<div className="h-18 animate-pulse rounded-lg bg-state-base-hover" />
|
||||
<div className="h-10 animate-pulse rounded-lg bg-state-base-hover" />
|
||||
</div>
|
||||
)}
|
||||
{!versionsQuery.isPending && versions.length === 0 && (
|
||||
<div className="rounded-lg border border-components-panel-border bg-components-panel-on-panel-item-bg px-3 py-6 text-center system-sm-regular text-text-tertiary">
|
||||
{t('agentDetail.versionHistory.empty')}
|
||||
</div>
|
||||
)}
|
||||
{!versionsQuery.isPending && versions.length > 0 && (
|
||||
<div className="flex flex-col gap-px">
|
||||
<CurrentDraftItem
|
||||
isActive={!activeVersionId}
|
||||
isLast={filteredVersions.length === 0}
|
||||
onSelect={() => onSelectVersion(null)}
|
||||
/>
|
||||
{filteredVersions.length === 0 && isFiltering && (
|
||||
<VersionFilterEmpty onReset={handleResetFilter} />
|
||||
)}
|
||||
{filteredVersions.map((version, index) => (
|
||||
<VersionItem
|
||||
key={version.id}
|
||||
version={version}
|
||||
activeVersionId={activeVersionId}
|
||||
isLatest={version.id === latestVersionId}
|
||||
isFirst={false}
|
||||
isLast={index === filteredVersions.length - 1}
|
||||
onSelect={onSelectVersion}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export function VersionFilterEmpty({
|
||||
onReset,
|
||||
}: {
|
||||
onReset: () => void
|
||||
}) {
|
||||
const { t: tWorkflow } = useTranslation('workflow')
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-components-panel-border bg-components-panel-on-panel-item-bg px-3 py-6 text-center">
|
||||
<p className="system-sm-regular text-text-tertiary">
|
||||
{tWorkflow('versionHistory.filter.empty')}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onReset}
|
||||
className="mt-2 rounded-md px-2 py-1 system-xs-medium text-text-accent hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden"
|
||||
>
|
||||
{tWorkflow('versionHistory.filter.reset')}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
import type { AgentConfigSnapshotSummaryResponse } from '@dify/contracts/api/console/agent/types.gen'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import useTimestamp from '@/hooks/use-timestamp'
|
||||
import { VersionTimelineDot } from './version-timeline-dot'
|
||||
|
||||
function VersionMetadata({
|
||||
version,
|
||||
}: {
|
||||
version: AgentConfigSnapshotSummaryResponse
|
||||
}) {
|
||||
const { t } = useTranslation('agentV2')
|
||||
const { formatTime } = useTimestamp()
|
||||
|
||||
if (version.created_at == null && !version.created_by)
|
||||
return null
|
||||
|
||||
return (
|
||||
<p className="truncate system-xs-regular text-text-tertiary">
|
||||
{version.created_at != null && formatTime(version.created_at, t('roster.dateTimeFormat'))}
|
||||
{version.created_at != null && version.created_by && ' · '}
|
||||
{version.created_by}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
export function VersionItem({
|
||||
version,
|
||||
activeVersionId,
|
||||
isLatest,
|
||||
isFirst,
|
||||
isLast,
|
||||
onSelect,
|
||||
}: {
|
||||
version: AgentConfigSnapshotSummaryResponse
|
||||
activeVersionId?: string | null
|
||||
isLatest: boolean
|
||||
isFirst: boolean
|
||||
isLast: boolean
|
||||
onSelect: (versionId: string) => void
|
||||
}) {
|
||||
const { t } = useTranslation('agentV2')
|
||||
const { t: tWorkflow } = useTranslation('workflow')
|
||||
const isActive = version.id === activeVersionId
|
||||
const label = version.version_note || t('agentDetail.versionHistory.versionName', { version: version.version })
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-current={isActive ? 'true' : undefined}
|
||||
onClick={() => onSelect(version.id)}
|
||||
className={cn(
|
||||
'group relative flex w-full items-start gap-1 rounded-lg py-1 pr-1.5 pl-2 text-left focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden',
|
||||
isActive ? 'bg-state-accent-active' : 'hover:bg-state-base-hover',
|
||||
)}
|
||||
>
|
||||
<VersionTimelineDot isActive={isActive} isFirst={isFirst} isLast={isLast} />
|
||||
<div className="min-w-0 flex-1 py-0.5">
|
||||
<div className="flex min-w-0 items-center gap-1">
|
||||
<p className={cn('truncate system-sm-semibold', isActive ? 'text-text-accent' : 'text-text-secondary')}>
|
||||
{label}
|
||||
</p>
|
||||
{isLatest && (
|
||||
<span className="shrink-0 rounded-[5px] border border-text-accent-secondary bg-components-badge-bg-dimm px-[5px] py-[3px] system-2xs-medium-uppercase text-text-accent-secondary">
|
||||
{tWorkflow('versionHistory.latest')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{isActive && version.summary && (
|
||||
<p className="mt-0.5 line-clamp-4 system-xs-regular text-text-secondary">
|
||||
{version.summary}
|
||||
</p>
|
||||
)}
|
||||
<VersionMetadata version={version} />
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
|
||||
export function VersionTimelineDot({
|
||||
isActive,
|
||||
isFirst,
|
||||
isLast,
|
||||
}: {
|
||||
isActive: boolean
|
||||
isFirst: boolean
|
||||
isLast: boolean
|
||||
}) {
|
||||
return (
|
||||
<div className="relative flex w-[18px] shrink-0 justify-center pt-1.5">
|
||||
{!isFirst && <div className="absolute top-0 h-2 w-0.5 bg-divider-subtle" />}
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
'relative z-1 size-2 rounded-full border-2 bg-components-panel-bg',
|
||||
isActive ? 'border-text-accent' : 'border-text-quaternary',
|
||||
)}
|
||||
/>
|
||||
{!isLast && <div className="absolute top-3 bottom-[-18px] w-0.5 bg-divider-subtle" />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+364
-102
@@ -1,124 +1,386 @@
|
||||
'use client'
|
||||
|
||||
import type { SandboxFileEntryResponse, SandboxListResponse, SandboxReadResponse } from '@dify/contracts/api/console/agent/types.gen'
|
||||
import type { AgentFileNode } from '@/features/agent-v2/agent-composer/form-state'
|
||||
import {
|
||||
Drawer,
|
||||
DrawerBackdrop,
|
||||
DrawerCloseButton,
|
||||
DrawerContent,
|
||||
DrawerDescription,
|
||||
DrawerPopup,
|
||||
DrawerPortal,
|
||||
DrawerTitle,
|
||||
DrawerViewport,
|
||||
} from '@langgenius/dify-ui/drawer'
|
||||
import { FileTreeFile } from '@langgenius/dify-ui/file-tree'
|
||||
import { Dialog } from '@langgenius/dify-ui/dialog'
|
||||
import { skipToken, useQueries, useQuery } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { AgentFileTree } from '../orchestrate/files/tree'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { getFileIconType } from '../orchestrate/files/file-icon'
|
||||
import { AgentSkillDetailDialog } from '../orchestrate/skills/detail-dialog'
|
||||
|
||||
type AgentWorkingDirectoryPanelProps = {
|
||||
source: AgentWorkingDirectorySource
|
||||
onOpenChange: (open: boolean) => void
|
||||
open: boolean
|
||||
}
|
||||
|
||||
const workingDirectoryFiles: AgentFileNode[] = [
|
||||
{
|
||||
id: 'working-directory/_index.json',
|
||||
name: '_index.json',
|
||||
icon: 'json',
|
||||
driveKey: 'working-directory/_index.json',
|
||||
},
|
||||
{
|
||||
id: 'working-directory/web-game',
|
||||
name: 'web-game',
|
||||
icon: 'folder',
|
||||
driveKey: 'working-directory/web-game',
|
||||
children: [
|
||||
{
|
||||
id: 'working-directory/web-game/public',
|
||||
name: 'public',
|
||||
icon: 'folder',
|
||||
driveKey: 'working-directory/web-game/public',
|
||||
},
|
||||
{
|
||||
id: 'working-directory/web-game/assets',
|
||||
name: 'assets',
|
||||
icon: 'folder',
|
||||
driveKey: 'working-directory/web-game/assets',
|
||||
},
|
||||
{
|
||||
id: 'working-directory/web-game/src',
|
||||
name: 'src',
|
||||
icon: 'folder',
|
||||
driveKey: 'working-directory/web-game/src',
|
||||
},
|
||||
{
|
||||
id: 'working-directory/web-game/styles',
|
||||
name: 'styles',
|
||||
icon: 'folder',
|
||||
driveKey: 'working-directory/web-game/styles',
|
||||
},
|
||||
{
|
||||
id: 'working-directory/web-game/README.md',
|
||||
name: 'README.md',
|
||||
icon: 'markdown',
|
||||
driveKey: 'working-directory/web-game/README.md',
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
export type AgentWorkingDirectorySource = {
|
||||
type: 'agent'
|
||||
agentId: string
|
||||
conversationId?: string | null
|
||||
} | {
|
||||
type: 'workflow-node'
|
||||
appId?: string
|
||||
conversationId?: string | null
|
||||
nodeId: string
|
||||
workflowRunId?: string | null
|
||||
}
|
||||
|
||||
const selectedWorkingDirectoryFileId = 'working-directory/web-game/README.md'
|
||||
type SandboxErrorPayload = {
|
||||
code?: string
|
||||
}
|
||||
|
||||
const normalizeSandboxPath = (path: string) => {
|
||||
const normalizedPath = path.replace(/^\.\//, '').replace(/^\/+|\/+$/g, '')
|
||||
return normalizedPath === '.' ? '' : normalizedPath
|
||||
}
|
||||
|
||||
const joinSandboxPath = (basePath: string, name: string) => {
|
||||
const normalizedBasePath = normalizeSandboxPath(basePath)
|
||||
return normalizedBasePath ? `${normalizedBasePath}/${name}` : name
|
||||
}
|
||||
|
||||
function getSandboxEntryPathSegments(entryName: string, basePath: string) {
|
||||
const normalizedBasePath = normalizeSandboxPath(basePath)
|
||||
const normalizedEntryName = normalizeSandboxPath(entryName)
|
||||
|
||||
if (!normalizedEntryName)
|
||||
return []
|
||||
|
||||
if (!normalizedBasePath)
|
||||
return normalizedEntryName.split('/').filter(Boolean)
|
||||
|
||||
if (normalizedEntryName === normalizedBasePath || normalizedEntryName.startsWith(`${normalizedBasePath}/`))
|
||||
return normalizedEntryName.split('/').filter(Boolean)
|
||||
|
||||
return [...normalizedBasePath.split('/').filter(Boolean), ...normalizedEntryName.split('/').filter(Boolean)]
|
||||
}
|
||||
|
||||
function buildSandboxFileTree(entries: SandboxFileEntryResponse[] = [], basePath = '.'): AgentFileNode[] {
|
||||
const rootFiles: AgentFileNode[] = []
|
||||
|
||||
for (const entry of entries) {
|
||||
const pathSegments = getSandboxEntryPathSegments(entry.name, basePath)
|
||||
if (!pathSegments.length)
|
||||
continue
|
||||
|
||||
let currentFiles = rootFiles
|
||||
let currentPath = ''
|
||||
|
||||
pathSegments.forEach((segment, index) => {
|
||||
const isLeaf = index === pathSegments.length - 1
|
||||
const isFolder = !isLeaf || entry.type === 'dir'
|
||||
const nodePath = joinSandboxPath(currentPath, segment)
|
||||
let node = currentFiles.find(file => file.id === nodePath)
|
||||
|
||||
if (!node) {
|
||||
node = {
|
||||
id: nodePath,
|
||||
name: segment,
|
||||
icon: isFolder ? 'folder' : getFileIconType(segment),
|
||||
children: isFolder ? [] : undefined,
|
||||
}
|
||||
currentFiles.push(node)
|
||||
}
|
||||
|
||||
if (isFolder) {
|
||||
node.children ??= []
|
||||
currentFiles = node.children
|
||||
}
|
||||
|
||||
currentPath = nodePath
|
||||
})
|
||||
}
|
||||
|
||||
return rootFiles
|
||||
}
|
||||
|
||||
function mergeSandboxFileTree(targetFiles: AgentFileNode[], sourceFiles: AgentFileNode[]): AgentFileNode[] {
|
||||
const mergedFiles = [...targetFiles]
|
||||
|
||||
for (const sourceFile of sourceFiles) {
|
||||
const targetFileIndex = mergedFiles.findIndex(file => file.id === sourceFile.id)
|
||||
if (targetFileIndex === -1) {
|
||||
mergedFiles.push(sourceFile)
|
||||
continue
|
||||
}
|
||||
|
||||
const targetFile = mergedFiles[targetFileIndex]!
|
||||
mergedFiles[targetFileIndex] = {
|
||||
...targetFile,
|
||||
...sourceFile,
|
||||
children: mergeSandboxFileTree(targetFile.children ?? [], sourceFile.children ?? []),
|
||||
}
|
||||
}
|
||||
|
||||
return mergedFiles
|
||||
}
|
||||
|
||||
function findFirstReadableFile(files: AgentFileNode[]): AgentFileNode | undefined {
|
||||
for (const file of files) {
|
||||
if (file.children?.length) {
|
||||
const childFile = findFirstReadableFile(file.children)
|
||||
if (childFile)
|
||||
return childFile
|
||||
}
|
||||
else if (file.icon !== 'folder') {
|
||||
return file
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function findReadableFile(files: AgentFileNode[], fileId?: string): AgentFileNode | undefined {
|
||||
if (!fileId)
|
||||
return undefined
|
||||
|
||||
for (const file of files) {
|
||||
if (file.id === fileId && file.icon !== 'folder')
|
||||
return file
|
||||
|
||||
const childFile = findReadableFile(file.children ?? [], fileId)
|
||||
if (childFile)
|
||||
return childFile
|
||||
}
|
||||
}
|
||||
|
||||
function countReadableFiles(files: AgentFileNode[]): number {
|
||||
return files.reduce((count, file) => {
|
||||
if (file.icon === 'folder')
|
||||
return count + countReadableFiles(file.children ?? [])
|
||||
|
||||
return count + 1
|
||||
}, 0)
|
||||
}
|
||||
|
||||
async function isNoActiveSessionError(error: unknown) {
|
||||
if (!(error instanceof Response) || error.status !== 404)
|
||||
return false
|
||||
|
||||
try {
|
||||
const payload = await error.clone().json() as SandboxErrorPayload
|
||||
return payload.code === 'no_active_session'
|
||||
}
|
||||
catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const isNotFoundResponse = (error: unknown) => error instanceof Response && error.status === 404
|
||||
|
||||
export function AgentWorkingDirectoryPanel({
|
||||
source,
|
||||
onOpenChange,
|
||||
open,
|
||||
}: AgentWorkingDirectoryPanelProps) {
|
||||
const { t } = useTranslation('agentV2')
|
||||
const { t: tCommon } = useTranslation('common')
|
||||
const [selectedFileId, setSelectedFileId] = useState<string>()
|
||||
const [loadedFolderPaths, setLoadedFolderPaths] = useState<string[]>([])
|
||||
const [openFolderPaths, setOpenFolderPaths] = useState<string[]>([])
|
||||
const [pendingOpenFolderPaths, setPendingOpenFolderPaths] = useState<string[]>([])
|
||||
const workflowNodeRunId = source.type === 'workflow-node'
|
||||
? (source.workflowRunId ?? source.conversationId)
|
||||
: undefined
|
||||
const hasWorkingDirectorySource = source.type === 'agent'
|
||||
? !!source.conversationId
|
||||
: !!source.appId && !!workflowNodeRunId
|
||||
const getFileListQueryOptions = (path: string) => source.type === 'agent'
|
||||
? consoleQuery.agent.byAgentId.sandbox.files.get.queryOptions({
|
||||
input: source.conversationId
|
||||
? {
|
||||
params: {
|
||||
agent_id: source.agentId,
|
||||
},
|
||||
query: {
|
||||
conversation_id: source.conversationId,
|
||||
path,
|
||||
},
|
||||
}
|
||||
: skipToken,
|
||||
context: {
|
||||
silent: true,
|
||||
},
|
||||
})
|
||||
: consoleQuery.apps.byAppId.workflowRuns.byWorkflowRunId.agentNodes.byNodeId.sandbox.files.get.queryOptions({
|
||||
input: source.appId && workflowNodeRunId
|
||||
? {
|
||||
params: {
|
||||
app_id: source.appId,
|
||||
workflow_run_id: workflowNodeRunId,
|
||||
node_id: source.nodeId,
|
||||
},
|
||||
query: {
|
||||
path,
|
||||
},
|
||||
}
|
||||
: skipToken,
|
||||
context: {
|
||||
silent: true,
|
||||
},
|
||||
})
|
||||
const fileListQueryOptions = getFileListQueryOptions('.')
|
||||
const fileListQuery = useQuery({
|
||||
...fileListQueryOptions,
|
||||
queryFn: async (context): Promise<SandboxListResponse> => {
|
||||
try {
|
||||
return await fileListQueryOptions.queryFn(context)
|
||||
}
|
||||
catch (error) {
|
||||
if (await isNoActiveSessionError(error)) {
|
||||
return {
|
||||
entries: [],
|
||||
path: '.',
|
||||
}
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
},
|
||||
retry: false,
|
||||
})
|
||||
const expandedFolderQueries = useQueries({
|
||||
queries: hasWorkingDirectorySource
|
||||
? loadedFolderPaths.map((path) => {
|
||||
const queryOptions = getFileListQueryOptions(path)
|
||||
|
||||
return {
|
||||
...queryOptions,
|
||||
queryFn: async (context): Promise<SandboxListResponse> => {
|
||||
try {
|
||||
return await queryOptions.queryFn(context)
|
||||
}
|
||||
catch (error) {
|
||||
if (await isNoActiveSessionError(error)) {
|
||||
return {
|
||||
entries: [],
|
||||
path,
|
||||
}
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
},
|
||||
retry: false,
|
||||
}
|
||||
})
|
||||
: [],
|
||||
})
|
||||
const workingDirectoryFiles = expandedFolderQueries.reduce((files, query) => {
|
||||
return mergeSandboxFileTree(files, buildSandboxFileTree(query.data?.entries, query.data?.path))
|
||||
}, buildSandboxFileTree(fileListQuery.data?.entries, fileListQuery.data?.path))
|
||||
const selectedWorkingDirectoryFile = findReadableFile(workingDirectoryFiles, selectedFileId)
|
||||
?? findFirstReadableFile(workingDirectoryFiles)
|
||||
const isFileListLoading = hasWorkingDirectorySource && fileListQuery.isPending
|
||||
const loadingFolderPaths = new Set(loadedFolderPaths.filter((path, index) => expandedFolderQueries[index]?.isPending))
|
||||
const loadedFolderPathIndexes = new Map(loadedFolderPaths.map((path, index) => [path, index]))
|
||||
const fileReadQueryOptions = source.type === 'agent'
|
||||
? consoleQuery.agent.byAgentId.sandbox.files.read.get.queryOptions({
|
||||
input: source.conversationId && selectedWorkingDirectoryFile?.id
|
||||
? {
|
||||
params: {
|
||||
agent_id: source.agentId,
|
||||
},
|
||||
query: {
|
||||
conversation_id: source.conversationId,
|
||||
path: selectedWorkingDirectoryFile.id,
|
||||
},
|
||||
}
|
||||
: skipToken,
|
||||
context: {
|
||||
silent: true,
|
||||
},
|
||||
})
|
||||
: consoleQuery.apps.byAppId.workflowRuns.byWorkflowRunId.agentNodes.byNodeId.sandbox.files.read.get.queryOptions({
|
||||
input: source.appId && workflowNodeRunId && selectedWorkingDirectoryFile?.id
|
||||
? {
|
||||
params: {
|
||||
app_id: source.appId,
|
||||
workflow_run_id: workflowNodeRunId,
|
||||
node_id: source.nodeId,
|
||||
},
|
||||
query: {
|
||||
path: selectedWorkingDirectoryFile.id,
|
||||
},
|
||||
}
|
||||
: skipToken,
|
||||
context: {
|
||||
silent: true,
|
||||
},
|
||||
})
|
||||
const fileReadQuery = useQuery({
|
||||
...fileReadQueryOptions,
|
||||
enabled: open && !!selectedWorkingDirectoryFile,
|
||||
queryFn: async (context): Promise<SandboxReadResponse> => {
|
||||
try {
|
||||
return await fileReadQueryOptions.queryFn(context)
|
||||
}
|
||||
catch (error) {
|
||||
if (isNotFoundResponse(error)) {
|
||||
return {
|
||||
binary: false,
|
||||
path: selectedWorkingDirectoryFile?.id ?? '',
|
||||
text: null,
|
||||
truncated: false,
|
||||
}
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
},
|
||||
retry: false,
|
||||
})
|
||||
const isFileReadLoading = !!selectedWorkingDirectoryFile && fileReadQuery.isPending
|
||||
|
||||
return (
|
||||
<Drawer open={open} onOpenChange={onOpenChange} swipeDirection="right">
|
||||
<DrawerPortal>
|
||||
<DrawerBackdrop forceRender className="fixed bg-transparent" />
|
||||
<DrawerViewport>
|
||||
<DrawerPopup className="data-[swipe-direction=right]:top-2 data-[swipe-direction=right]:bottom-2 data-[swipe-direction=right]:h-auto data-[swipe-direction=right]:w-[360px]">
|
||||
<DrawerContent className="flex min-h-0 flex-1 flex-col p-0 pb-0">
|
||||
<div className="flex shrink-0 items-start gap-2 px-4 pt-3 pb-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<DrawerTitle className="truncate system-xl-semibold text-text-primary">
|
||||
{t('agentDetail.configure.workingDirectory.title')}
|
||||
</DrawerTitle>
|
||||
<DrawerDescription className="body-xs-regular text-text-tertiary">
|
||||
{t('agentDetail.configure.workingDirectory.description')}
|
||||
</DrawerDescription>
|
||||
</div>
|
||||
<DrawerCloseButton
|
||||
aria-label={tCommon('operation.close')}
|
||||
className="size-6 rounded-md p-0.5"
|
||||
/>
|
||||
</div>
|
||||
<AgentFileTree
|
||||
files={workingDirectoryFiles}
|
||||
selectedFileId={selectedWorkingDirectoryFileId}
|
||||
treeLabel={t('agentDetail.configure.workingDirectory.treeLabel')}
|
||||
className="min-h-0 flex-1 px-3 py-1"
|
||||
scrollAreaClassName="flex-1"
|
||||
rootClassName="p-0"
|
||||
listClassName="gap-px"
|
||||
renderFile={({ selected, children }) => (
|
||||
<FileTreeFile selected={selected}>
|
||||
{children}
|
||||
{selected && (
|
||||
<span aria-hidden className="ms-auto i-ri-more-fill flex size-5 shrink-0 items-center justify-center text-text-tertiary" />
|
||||
)}
|
||||
</FileTreeFile>
|
||||
)}
|
||||
/>
|
||||
</DrawerContent>
|
||||
</DrawerPopup>
|
||||
</DrawerViewport>
|
||||
</DrawerPortal>
|
||||
</Drawer>
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<AgentSkillDetailDialog
|
||||
skillName={t('agentDetail.configure.workingDirectory.title')}
|
||||
detail={{
|
||||
description: t('agentDetail.configure.workingDirectory.description'),
|
||||
fileCount: countReadableFiles(workingDirectoryFiles),
|
||||
fileListTitle: t('agentDetail.configure.workingDirectory.title'),
|
||||
files: workingDirectoryFiles,
|
||||
filePreview: {
|
||||
binary: fileReadQuery.data?.binary,
|
||||
content: fileReadQuery.data?.text ?? undefined,
|
||||
fileName: isFileListLoading ? '' : selectedWorkingDirectoryFile?.name,
|
||||
isError: fileListQuery.isError || fileReadQuery.isError,
|
||||
isLoading: isFileListLoading || isFileReadLoading,
|
||||
},
|
||||
folderOpenState: ({ file }) => {
|
||||
const queryIndex = loadedFolderPathIndexes.get(file.id)
|
||||
const folderLoaded = queryIndex !== undefined && expandedFolderQueries[queryIndex]?.isSuccess
|
||||
|
||||
return openFolderPaths.includes(file.id)
|
||||
|| (pendingOpenFolderPaths.includes(file.id) && !!folderLoaded)
|
||||
},
|
||||
onFolderOpenChange: ({ file, open }) => {
|
||||
if (loadingFolderPaths.has(file.id))
|
||||
return
|
||||
|
||||
if (open && !loadedFolderPaths.includes(file.id)) {
|
||||
setLoadedFolderPaths(paths => [...paths, file.id])
|
||||
setPendingOpenFolderPaths(paths => paths.includes(file.id) ? paths : [...paths, file.id])
|
||||
return
|
||||
}
|
||||
|
||||
setPendingOpenFolderPaths(paths => paths.filter(path => path !== file.id))
|
||||
setOpenFolderPaths(paths => open
|
||||
? (paths.includes(file.id) ? paths : [...paths, file.id])
|
||||
: paths.filter(path => path !== file.id))
|
||||
},
|
||||
onSelectFile: selectedFile => setSelectedFileId(selectedFile.id),
|
||||
renderFolderSuffix: ({ file }) => loadingFolderPaths.has(file.id)
|
||||
? (
|
||||
<span aria-label={tCommon('loading')} className="ms-auto i-ri-loader-4-line size-4 shrink-0 animate-spin text-text-tertiary" />
|
||||
)
|
||||
: null,
|
||||
selectedFileId: selectedWorkingDirectoryFile?.id,
|
||||
sections: [],
|
||||
}}
|
||||
/>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export const ENABLE_AGENT_CLI_TOOLS = false
|
||||
export const ENABLE_AGENT_CONTENT_MODERATION = false
|
||||
|
||||
@@ -42,6 +42,9 @@ export function useAgentConfigureData(agentId: string, selectedVersionId: string
|
||||
const activeVersionId = selectedVersionId ?? (shouldLoadPublishedVersion ? publishedVersionId : null)
|
||||
const activeConfigSnapshot = selectedVersionId ? versionDetail : (composerQuery.data?.active_config_snapshot ?? versionDetail)
|
||||
const agentSoulConfig = selectedVersionId ? versionDetail?.config_snapshot : (composerQuery.data?.agent_soul ?? versionDetail?.config_snapshot)
|
||||
const isPending = agentQuery.isPending
|
||||
|| composerQuery.isPending
|
||||
|| (shouldLoadVersion && versionQuery.isPending)
|
||||
|
||||
return {
|
||||
agentQuery,
|
||||
@@ -52,6 +55,7 @@ export function useAgentConfigureData(agentId: string, selectedVersionId: string
|
||||
activeVersionId,
|
||||
activeConfigSnapshot,
|
||||
agentSoulConfig,
|
||||
isPending,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { Model, ModelItem } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
|
||||
type ProviderModelCompatibility = {
|
||||
providers: string[]
|
||||
incompatibleModels: RegExp[]
|
||||
}
|
||||
|
||||
const agentIncompatibleModelConfig: ProviderModelCompatibility[] = [
|
||||
{
|
||||
providers: ['openai'],
|
||||
incompatibleModels: [
|
||||
/^gpt-4o-mini(?:-|$)/i,
|
||||
/^gpt-4\.1-(?:mini|nano)(?:-|$)/i,
|
||||
/^gpt-4(?:-|$)/i,
|
||||
/^gpt-3\.5/i,
|
||||
/^o[34]-mini(?:-|$)/i,
|
||||
],
|
||||
},
|
||||
{
|
||||
providers: ['anthropic'],
|
||||
incompatibleModels: [
|
||||
/^claude-3-(?:haiku|sonnet|opus)(?:-|$)/i,
|
||||
/^claude-3(?:\.5|-5)-(?:haiku|sonnet)(?:-|$)/i,
|
||||
],
|
||||
},
|
||||
{
|
||||
providers: ['gemini', 'google'],
|
||||
incompatibleModels: [
|
||||
/^gemini-2[.-][05]-flash(?:-lite)?(?:-|$)/i,
|
||||
/^gemini-1[.-]5-flash(?:-8b)?(?:-|$)/i,
|
||||
],
|
||||
},
|
||||
{
|
||||
providers: ['deepseek'],
|
||||
incompatibleModels: [
|
||||
/^deepseek-r1$/i,
|
||||
/^deepseek-r1-lite$/i,
|
||||
/^deepseek-r1-distill(?:-[a-z0-9]+)*-(?:1\.5b|7b|8b|14b|32b|70b)$/i,
|
||||
],
|
||||
},
|
||||
{
|
||||
providers: ['minimax'],
|
||||
incompatibleModels: [
|
||||
/^minimax-text-01$/i,
|
||||
/^minimax-m1$/i,
|
||||
],
|
||||
},
|
||||
{
|
||||
providers: ['tongyi', 'qwen'],
|
||||
incompatibleModels: [
|
||||
/^qwen2[.-]5(?:-[a-z0-9.]+)?-instruct(?:-|$)/i,
|
||||
/^qwen2[.-]5-coder(?:-|$)/i,
|
||||
/^qwen3-(?:0\.6b|1\.7b|4b|8b|14b|30b)(?:-|$)/i,
|
||||
],
|
||||
},
|
||||
{
|
||||
providers: ['chatglm', 'zhipuai'],
|
||||
incompatibleModels: [
|
||||
/^glm-4-(?:air|airx|flash)$/i,
|
||||
/^glm-z1-(?:air|flash)$/i,
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const normalizeProviderName = (provider: string) => provider.split('/').at(-1)?.toLowerCase() ?? provider.toLowerCase()
|
||||
|
||||
export function isAgentCompatibleModel(provider: Model, modelItem: ModelItem) {
|
||||
const providerName = normalizeProviderName(provider.provider)
|
||||
const providerConfig = agentIncompatibleModelConfig.find(config => config.providers.includes(providerName))
|
||||
|
||||
if (!providerConfig)
|
||||
return true
|
||||
|
||||
return !providerConfig.incompatibleModels.some(pattern => pattern.test(modelItem.model))
|
||||
}
|
||||
@@ -1,10 +1,18 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useAtomValue, useSetAtom } from 'jotai'
|
||||
import { ScopeProvider } from 'jotai-scope'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { AgentConfigureComposerScope } from './components/composer-session'
|
||||
import { AgentConfigurePageLoading } from './components/page-loading'
|
||||
import { useAgentConfigureData } from './hooks'
|
||||
import {
|
||||
agentConfigureComposerRebaseRevisionAtom,
|
||||
agentConfigureScopedAtoms,
|
||||
agentConfigureSelectedVersionIdAtom,
|
||||
agentConfigureSelectVersionAtom,
|
||||
rebaseAgentConfigureComposerAtom,
|
||||
} from './state'
|
||||
|
||||
type AgentConfigurePageProps = {
|
||||
agentId: string
|
||||
@@ -14,7 +22,13 @@ export function AgentConfigurePage({
|
||||
agentId,
|
||||
}: AgentConfigurePageProps) {
|
||||
return (
|
||||
<AgentConfigurePageContent agentId={agentId} />
|
||||
<ScopeProvider
|
||||
key={agentId}
|
||||
atoms={agentConfigureScopedAtoms}
|
||||
name="AgentConfigure"
|
||||
>
|
||||
<AgentConfigurePageContent agentId={agentId} />
|
||||
</ScopeProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -22,14 +36,13 @@ function AgentConfigurePageContent({
|
||||
agentId,
|
||||
}: AgentConfigurePageProps) {
|
||||
const { t } = useTranslation('agentV2')
|
||||
const [selectedVersionId, setSelectedVersionId] = useState<string | null>(null)
|
||||
const [composerRebaseRevision, setComposerRebaseRevision] = useState(0)
|
||||
const selectedVersionId = useAtomValue(agentConfigureSelectedVersionIdAtom)
|
||||
const composerRebaseRevision = useAtomValue(agentConfigureComposerRebaseRevisionAtom)
|
||||
const rebaseComposer = useSetAtom(rebaseAgentConfigureComposerAtom)
|
||||
const selectVersion = useSetAtom(agentConfigureSelectVersionAtom)
|
||||
const configureData = useAgentConfigureData(agentId, selectedVersionId)
|
||||
const isConfigureDataPending = configureData.agentQuery.isPending
|
||||
|| configureData.composerQuery.isPending
|
||||
|| (configureData.shouldLoadVersion && configureData.versionQuery.isPending)
|
||||
|
||||
if (isConfigureDataPending) {
|
||||
if (configureData.isPending) {
|
||||
return (
|
||||
<AgentConfigurePageLoading label={t('agentDetail.sections.configure')} />
|
||||
)
|
||||
@@ -40,8 +53,8 @@ function AgentConfigurePageContent({
|
||||
agentId={agentId}
|
||||
composerRebaseRevision={composerRebaseRevision}
|
||||
configureData={configureData}
|
||||
onComposerRebase={() => setComposerRebaseRevision(revision => revision + 1)}
|
||||
onSelectVersion={setSelectedVersionId}
|
||||
onComposerRebase={rebaseComposer}
|
||||
onSelectVersion={selectVersion}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { atom } from 'jotai'
|
||||
|
||||
export type AgentConfigureRightPanelMode = 'build' | 'preview'
|
||||
export type AgentConfigureConversationIds = Record<AgentConfigureRightPanelMode, string | null>
|
||||
export type AgentConfigureSoulSource = 'draft' | 'build-draft' | 'view-version'
|
||||
|
||||
export const agentConfigureSelectedVersionIdAtom = atom<string | null>(null)
|
||||
export const agentConfigureComposerRebaseRevisionAtom = atom(0)
|
||||
export const agentConfigureSoulSourceOverrideAtom = atom<AgentConfigureSoulSource | null>(null)
|
||||
|
||||
export const agentConfigureShowChatFeaturesAtom = atom(false)
|
||||
export const agentConfigureShowPreviewVersionsAtom = atom(false)
|
||||
export const agentConfigureRightPanelModeAtom = atom<AgentConfigureRightPanelMode>('build')
|
||||
export const agentConfigureConversationIdsAtom = atom<AgentConfigureConversationIds>({
|
||||
build: null,
|
||||
preview: null,
|
||||
})
|
||||
|
||||
export const agentConfigureRightPanelChatModeAtom = atom((get): AgentConfigureRightPanelMode => {
|
||||
const mode = get(agentConfigureRightPanelModeAtom)
|
||||
|
||||
return mode === 'preview' ? 'build' : mode
|
||||
})
|
||||
|
||||
export const agentConfigureSelectVersionAtom = atom(null, (_get, set, versionId: string | null) => {
|
||||
set(agentConfigureSoulSourceOverrideAtom, versionId ? 'view-version' : null)
|
||||
set(agentConfigureSelectedVersionIdAtom, versionId)
|
||||
})
|
||||
|
||||
export const rebaseAgentConfigureComposerAtom = atom(null, (get, set) => {
|
||||
set(agentConfigureComposerRebaseRevisionAtom, get(agentConfigureComposerRebaseRevisionAtom) + 1)
|
||||
})
|
||||
|
||||
export const setAgentConfigureConversationIdAtom = atom(null, (get, set, {
|
||||
mode,
|
||||
conversationId,
|
||||
}: {
|
||||
mode: AgentConfigureRightPanelMode
|
||||
conversationId: string | null
|
||||
}) => {
|
||||
set(agentConfigureConversationIdsAtom, {
|
||||
...get(agentConfigureConversationIdsAtom),
|
||||
[mode]: conversationId,
|
||||
})
|
||||
})
|
||||
|
||||
export const resetAgentConfigureConversationAtom = atom(null, (get, set, mode: AgentConfigureRightPanelMode) => {
|
||||
set(agentConfigureConversationIdsAtom, {
|
||||
...get(agentConfigureConversationIdsAtom),
|
||||
[mode]: null,
|
||||
})
|
||||
})
|
||||
|
||||
export const agentConfigureScopedAtoms = [
|
||||
agentConfigureSelectedVersionIdAtom,
|
||||
agentConfigureComposerRebaseRevisionAtom,
|
||||
agentConfigureSoulSourceOverrideAtom,
|
||||
agentConfigureShowChatFeaturesAtom,
|
||||
agentConfigureShowPreviewVersionsAtom,
|
||||
agentConfigureRightPanelModeAtom,
|
||||
] as const
|
||||
@@ -1,19 +1,23 @@
|
||||
'use client'
|
||||
|
||||
import type { AgentSoulConfig } from '@dify/contracts/api/console/agent/types.gen'
|
||||
import type { AgentConfigureSoulSource } from './state'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useCallback } from 'react'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
|
||||
export type AgentConfigureSoulSource = 'draft' | 'build-draft' | 'view-version'
|
||||
|
||||
export function usePrepareAgentBuildDraftBeforeRun({
|
||||
agentId,
|
||||
buildDraftAgentSoulConfig,
|
||||
isBuildDraftActive,
|
||||
rebaseComposerDraft,
|
||||
saveDraft,
|
||||
setSoulSourceOverride,
|
||||
}: {
|
||||
agentId?: string
|
||||
buildDraftAgentSoulConfig?: AgentSoulConfig
|
||||
isBuildDraftActive: boolean
|
||||
rebaseComposerDraft?: (agentSoulConfig?: AgentSoulConfig) => void
|
||||
saveDraft: () => Promise<unknown>
|
||||
setSoulSourceOverride?: (source: AgentConfigureSoulSource) => void
|
||||
}) {
|
||||
@@ -32,8 +36,10 @@ export function usePrepareAgentBuildDraftBeforeRun({
|
||||
if (!agentId)
|
||||
return
|
||||
|
||||
if (!isBuildDraftActive)
|
||||
await saveDraft()
|
||||
if (isBuildDraftActive)
|
||||
return buildDraftAgentSoulConfig
|
||||
|
||||
await saveDraft()
|
||||
|
||||
const buildDraft = await checkoutBuildDraft({
|
||||
params: {
|
||||
@@ -44,8 +50,10 @@ export function usePrepareAgentBuildDraftBeforeRun({
|
||||
},
|
||||
})
|
||||
queryClient.setQueryData(buildDraftQueryOptions.queryKey, buildDraft)
|
||||
rebaseComposerDraft?.(buildDraft.agent_soul as AgentSoulConfig | undefined)
|
||||
setSoulSourceOverride?.('build-draft')
|
||||
}, [agentId, buildDraftQueryOptions.queryKey, checkoutBuildDraft, isBuildDraftActive, queryClient, saveDraft, setSoulSourceOverride])
|
||||
return buildDraft.agent_soul as AgentSoulConfig | undefined
|
||||
}, [agentId, buildDraftAgentSoulConfig, buildDraftQueryOptions.queryKey, checkoutBuildDraft, isBuildDraftActive, queryClient, rebaseComposerDraft, saveDraft, setSoulSourceOverride])
|
||||
|
||||
return {
|
||||
isCheckingOutBuildDraft,
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
'use client'
|
||||
|
||||
import type { AgentSoulConfig } from '@dify/contracts/api/console/agent/types.gen'
|
||||
import type { AgentBuildDraftChangedKey } from './components/orchestrate/build-draft-changes-context'
|
||||
import type { AgentConfigureSoulSource } from './state'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import isEqual from 'fast-deep-equal'
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { agentSoulConfigToFormState } from '@/features/agent-v2/agent-composer/conversions'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { agentConfigureConsoleQuery } from './build-draft-query'
|
||||
import { usePrepareAgentBuildDraftBeforeRun } from './use-agent-build-draft-run'
|
||||
|
||||
export type AgentConfigureSoulSource = 'draft' | 'build-draft' | 'view-version'
|
||||
|
||||
const isNotFoundResponse = (error: unknown) => error instanceof Response && error.status === 404
|
||||
const getAgentSoulConfigFromRefetchResult = (result: unknown) => {
|
||||
return (result as { data?: { agent_soul?: AgentSoulConfig } } | undefined)?.data?.agent_soul
|
||||
}
|
||||
|
||||
export function useAgentConfigureBuildDraftData({
|
||||
agentId,
|
||||
@@ -20,27 +23,30 @@ export function useAgentConfigureBuildDraftData({
|
||||
composerAgentSoulConfig,
|
||||
isViewingVersion,
|
||||
normalAgentSoulConfig,
|
||||
setSoulSourceOverride,
|
||||
soulSourceOverride,
|
||||
}: {
|
||||
agentId: string
|
||||
activeVersionId: string | null | undefined
|
||||
composerAgentSoulConfig?: AgentSoulConfig
|
||||
isViewingVersion: boolean
|
||||
normalAgentSoulConfig?: AgentSoulConfig
|
||||
setSoulSourceOverride: (source: AgentConfigureSoulSource | null) => void
|
||||
soulSourceOverride: AgentConfigureSoulSource | null
|
||||
}) {
|
||||
const shouldSilenceBuildDraftCheckRef = useRef(true)
|
||||
const [soulSourceOverride, setSoulSourceOverride] = useState<AgentConfigureSoulSource | null>(null)
|
||||
const buildDraftQueryInput = {
|
||||
params: {
|
||||
agent_id: agentId,
|
||||
},
|
||||
}
|
||||
const buildDraftQueryOptions = agentConfigureConsoleQuery.agent.byAgentId.buildDraft.get.queryOptions({
|
||||
const buildDraftQueryOptions = consoleQuery.agent.byAgentId.buildDraft.get.queryOptions({
|
||||
input: {
|
||||
params: buildDraftQueryInput.params,
|
||||
},
|
||||
context: {},
|
||||
})
|
||||
const silentBuildDraftQueryOptions = agentConfigureConsoleQuery.agent.byAgentId.buildDraft.get.queryOptions({
|
||||
const silentBuildDraftQueryOptions = consoleQuery.agent.byAgentId.buildDraft.get.queryOptions({
|
||||
input: {
|
||||
params: buildDraftQueryInput.params,
|
||||
},
|
||||
@@ -68,6 +74,8 @@ export function useAgentConfigureBuildDraftData({
|
||||
}
|
||||
},
|
||||
retry: false,
|
||||
refetchOnReconnect: false,
|
||||
refetchOnWindowFocus: false,
|
||||
})
|
||||
const {
|
||||
data: buildDraftData,
|
||||
@@ -84,22 +92,22 @@ export function useAgentConfigureBuildDraftData({
|
||||
const isBuildDraftActive = soulSource === 'build-draft'
|
||||
const buildDraftAgentSoulConfig = buildDraftData?.agent_soul as AgentSoulConfig | undefined
|
||||
const visibleAgentSoulConfig = isBuildDraftActive ? buildDraftAgentSoulConfig : normalAgentSoulConfig
|
||||
const buildDraftChangesCount = useMemo(() => {
|
||||
const buildDraftChangedKeys = useMemo<AgentBuildDraftChangedKey[]>(() => {
|
||||
if (!buildDraftAgentSoulConfig || !composerAgentSoulConfig)
|
||||
return 0
|
||||
return []
|
||||
|
||||
const normalDraft = agentSoulConfigToFormState(composerAgentSoulConfig)
|
||||
const buildDraft = agentSoulConfigToFormState(buildDraftAgentSoulConfig)
|
||||
|
||||
return (Object.keys(buildDraft) as Array<keyof typeof buildDraft>)
|
||||
.filter(key => JSON.stringify(buildDraft[key]) !== JSON.stringify(normalDraft[key]))
|
||||
.length
|
||||
.filter(key => !isEqual(buildDraft[key], normalDraft[key]))
|
||||
}, [buildDraftAgentSoulConfig, composerAgentSoulConfig])
|
||||
|
||||
return {
|
||||
activeVersionId: isBuildDraftActive ? `build-draft:${buildDraftDataUpdatedAt}` : activeVersionId,
|
||||
agentSoulConfig: visibleAgentSoulConfig,
|
||||
changesCount: buildDraftChangesCount,
|
||||
changedKeys: buildDraftChangedKeys,
|
||||
changesCount: buildDraftChangedKeys.length,
|
||||
isActive: isBuildDraftActive,
|
||||
isPending: !isViewingVersion && soulSourceOverride !== 'draft' && soulSourceOverride !== 'view-version' && isBuildDraftPending,
|
||||
refetch: refetchBuildDraft,
|
||||
@@ -110,7 +118,10 @@ export function useAgentConfigureBuildDraftData({
|
||||
|
||||
export function useAgentConfigureBuildDraftActions({
|
||||
agentId,
|
||||
buildDraftAgentSoulConfig,
|
||||
isActive,
|
||||
normalAgentSoulConfig,
|
||||
rebaseComposerDraft,
|
||||
refetchBuildDraft,
|
||||
refetchComposer,
|
||||
resetBuildChatSession,
|
||||
@@ -119,7 +130,10 @@ export function useAgentConfigureBuildDraftActions({
|
||||
setSoulSourceOverride,
|
||||
}: {
|
||||
agentId: string
|
||||
buildDraftAgentSoulConfig?: AgentSoulConfig
|
||||
isActive: boolean
|
||||
normalAgentSoulConfig?: AgentSoulConfig
|
||||
rebaseComposerDraft: (agentSoulConfig?: AgentSoulConfig) => void
|
||||
refetchBuildDraft: () => Promise<unknown>
|
||||
refetchComposer: () => Promise<unknown>
|
||||
resetBuildChatSession: () => Promise<void>
|
||||
@@ -130,6 +144,7 @@ export function useAgentConfigureBuildDraftActions({
|
||||
const { t: tCommon } = useTranslation('common')
|
||||
const queryClient = useQueryClient()
|
||||
const buildDraftRefreshTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const buildDraftRefreshGenerationRef = useRef(0)
|
||||
const buildDraftQueryOptions = consoleQuery.agent.byAgentId.buildDraft.get.queryOptions({
|
||||
input: {
|
||||
params: {
|
||||
@@ -137,47 +152,94 @@ export function useAgentConfigureBuildDraftActions({
|
||||
},
|
||||
},
|
||||
})
|
||||
const agentDetailQueryKey = consoleQuery.agent.byAgentId.get.queryKey({ input: { params: { agent_id: agentId } } })
|
||||
const finalizeBuildChatMutation = useMutation(consoleQuery.agent.byAgentId.buildChat.finalize.post.mutationOptions())
|
||||
const applyBuildDraftMutation = useMutation(consoleQuery.agent.byAgentId.buildDraft.apply.post.mutationOptions())
|
||||
const discardBuildDraftMutation = useMutation(consoleQuery.agent.byAgentId.buildDraft.delete.mutationOptions())
|
||||
const { mutateAsync: finalizeBuildChatRequest, isPending: isFinalizingBuildChat } = finalizeBuildChatMutation
|
||||
const { mutateAsync: applyBuildDraftRequest, isPending: isApplyingBuildDraft } = applyBuildDraftMutation
|
||||
const { mutateAsync: discardBuildDraftRequest, isPending: isDiscardingBuildDraft } = discardBuildDraftMutation
|
||||
const { prepareBuildDraftBeforeRun } = usePrepareAgentBuildDraftBeforeRun({
|
||||
agentId,
|
||||
buildDraftAgentSoulConfig,
|
||||
isBuildDraftActive: isActive,
|
||||
rebaseComposerDraft,
|
||||
saveDraft,
|
||||
setSoulSourceOverride,
|
||||
})
|
||||
|
||||
const cancelBuildDraftRefresh = useCallback(() => {
|
||||
buildDraftRefreshGenerationRef.current += 1
|
||||
if (!buildDraftRefreshTimerRef.current)
|
||||
return
|
||||
|
||||
clearTimeout(buildDraftRefreshTimerRef.current)
|
||||
buildDraftRefreshTimerRef.current = null
|
||||
}, [])
|
||||
|
||||
const prepareBuildDraftRun = useCallback(async () => {
|
||||
cancelBuildDraftRefresh()
|
||||
return prepareBuildDraftBeforeRun()
|
||||
}, [cancelBuildDraftRefresh, prepareBuildDraftBeforeRun])
|
||||
|
||||
const refreshBuildDraftAfterBuildChat = useCallback((onRefreshed?: () => void) => {
|
||||
if (buildDraftRefreshTimerRef.current)
|
||||
clearTimeout(buildDraftRefreshTimerRef.current)
|
||||
cancelBuildDraftRefresh()
|
||||
const refreshGeneration = buildDraftRefreshGenerationRef.current
|
||||
|
||||
buildDraftRefreshTimerRef.current = setTimeout(async () => {
|
||||
buildDraftRefreshTimerRef.current = null
|
||||
await refetchBuildDraft()
|
||||
onRefreshed?.()
|
||||
try {
|
||||
const result = await refetchBuildDraft()
|
||||
if (refreshGeneration !== buildDraftRefreshGenerationRef.current)
|
||||
return
|
||||
|
||||
const agentSoulConfig = getAgentSoulConfigFromRefetchResult(result)
|
||||
if (agentSoulConfig)
|
||||
rebaseComposerDraft(agentSoulConfig)
|
||||
}
|
||||
catch {}
|
||||
finally {
|
||||
if (refreshGeneration === buildDraftRefreshGenerationRef.current)
|
||||
onRefreshed?.()
|
||||
}
|
||||
}, 1000)
|
||||
}, [refetchBuildDraft])
|
||||
}, [cancelBuildDraftRefresh, rebaseComposerDraft, refetchBuildDraft])
|
||||
|
||||
const exitBuildDraftMode = useCallback(async (shouldRefetchComposer: boolean) => {
|
||||
cancelBuildDraftRefresh()
|
||||
await resetBuildChatSession().catch(() => undefined)
|
||||
setSoulSourceOverride('draft')
|
||||
queryClient.removeQueries({
|
||||
queryKey: buildDraftQueryOptions.queryKey,
|
||||
})
|
||||
if (shouldRefetchComposer) {
|
||||
await refetchComposer()
|
||||
const result = await refetchComposer()
|
||||
rebaseComposerDraft(getAgentSoulConfigFromRefetchResult(result) ?? normalAgentSoulConfig)
|
||||
onComposerRebased?.()
|
||||
}
|
||||
}, [buildDraftQueryOptions.queryKey, onComposerRebased, queryClient, refetchComposer, resetBuildChatSession, setSoulSourceOverride])
|
||||
else {
|
||||
rebaseComposerDraft(normalAgentSoulConfig)
|
||||
}
|
||||
}, [buildDraftQueryOptions.queryKey, cancelBuildDraftRefresh, normalAgentSoulConfig, onComposerRebased, queryClient, rebaseComposerDraft, refetchComposer, resetBuildChatSession, setSoulSourceOverride])
|
||||
|
||||
const applyBuildDraft = async () => {
|
||||
try {
|
||||
await finalizeBuildChatRequest({
|
||||
params: {
|
||||
agent_id: agentId,
|
||||
},
|
||||
})
|
||||
await applyBuildDraftRequest({
|
||||
params: {
|
||||
agent_id: agentId,
|
||||
},
|
||||
})
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: agentDetailQueryKey,
|
||||
})
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: consoleQuery.agent.get.key(),
|
||||
})
|
||||
await exitBuildDraftMode(true)
|
||||
toast.success(tCommon('api.actionSuccess'))
|
||||
}
|
||||
@@ -203,17 +265,17 @@ export function useAgentConfigureBuildDraftActions({
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (buildDraftRefreshTimerRef.current)
|
||||
clearTimeout(buildDraftRefreshTimerRef.current)
|
||||
cancelBuildDraftRefresh()
|
||||
}
|
||||
}, [])
|
||||
}, [cancelBuildDraftRefresh])
|
||||
|
||||
return {
|
||||
applyBuildDraft,
|
||||
cancelBuildDraftRefresh,
|
||||
discardBuildDraft,
|
||||
isApplyingBuildDraft,
|
||||
isApplyingBuildDraft: isFinalizingBuildChat || isApplyingBuildDraft,
|
||||
isDiscardingBuildDraft,
|
||||
prepareBuildDraftBeforeRun,
|
||||
prepareBuildDraftBeforeRun: prepareBuildDraftRun,
|
||||
refreshBuildDraftAfterBuildChat,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
'use client'
|
||||
|
||||
import type { AgentAppDetailWithSite, AgentSoulConfig } from '@dify/contracts/api/console/agent/types.gen'
|
||||
import type { AgentSoulConfig } from '@dify/contracts/api/console/agent/types.gen'
|
||||
import type { DefaultModel } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import type { AgentSoulConfigFormState } from '@/features/agent-v2/agent-composer/form-state'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { debounce } from 'es-toolkit/compat'
|
||||
import isEqual from 'fast-deep-equal'
|
||||
import { useSetAtom, useStore } from 'jotai'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@@ -52,6 +53,7 @@ export function useAgentConfigureSync({
|
||||
const currentModelRef = useRef(currentModel)
|
||||
const enabledRef = useRef(enabled)
|
||||
const lastAutosavedDraftKeyRef = useRef<string | undefined>(undefined)
|
||||
const pageCloseSavingDraftKeyRef = useRef<string | undefined>(undefined)
|
||||
const publishInFlightRef = useRef(false)
|
||||
|
||||
baseConfigRef.current = baseConfig
|
||||
@@ -64,21 +66,6 @@ export function useAgentConfigureSync({
|
||||
currentModel: currentModelRef.current,
|
||||
}), [store])
|
||||
|
||||
const markActiveConfigUnpublished = useCallback(() => {
|
||||
queryClient.setQueryData<AgentAppDetailWithSite | undefined>(
|
||||
consoleQuery.agent.byAgentId.get.queryKey({ input: { params: { agent_id: agentId } } }),
|
||||
(agentDetail) => {
|
||||
if (!agentDetail)
|
||||
return agentDetail
|
||||
|
||||
return {
|
||||
...agentDetail,
|
||||
active_config_is_published: false,
|
||||
}
|
||||
},
|
||||
)
|
||||
}, [agentId, queryClient])
|
||||
|
||||
const {
|
||||
mutateAsync: saveComposerDraft,
|
||||
} = useMutation(
|
||||
@@ -94,13 +81,16 @@ export function useAgentConfigureSync({
|
||||
const saveComposer = useSerialAsyncCallback(async ({
|
||||
configSnapshot,
|
||||
draftBaseline,
|
||||
silent = true,
|
||||
}: {
|
||||
configSnapshot: AgentSoulConfig
|
||||
draftBaseline: AgentSoulConfigFormState
|
||||
silent?: boolean
|
||||
}) => {
|
||||
const savedDraftKey = JSON.stringify(configSnapshot)
|
||||
const agentDetailQueryKey = consoleQuery.agent.byAgentId.get.queryKey({ input: { params: { agent_id: agentId } } })
|
||||
try {
|
||||
await saveComposerDraft({
|
||||
const composerState = await saveComposerDraft({
|
||||
params: {
|
||||
agent_id: agentId,
|
||||
},
|
||||
@@ -110,13 +100,24 @@ export function useAgentConfigureSync({
|
||||
agent_soul: configSnapshot,
|
||||
},
|
||||
})
|
||||
queryClient.setQueryData(
|
||||
consoleQuery.agent.byAgentId.composer.get.queryKey({ input: { params: { agent_id: agentId } } }),
|
||||
composerState,
|
||||
)
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: agentDetailQueryKey,
|
||||
})
|
||||
}
|
||||
catch {
|
||||
// Draft sync follows workflow autosave behavior: save failures are silent and keep the local draft intact.
|
||||
// Autosave is silent and keeps the local draft intact; explicit commands must stop at this boundary.
|
||||
if (!silent) {
|
||||
toast.error(tCommon('api.actionFailed'))
|
||||
throw new Error('Failed to save agent composer draft.')
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
markActiveConfigUnpublished()
|
||||
setOriginalDraft(draftBaseline)
|
||||
setDraftSavedAt(Date.now())
|
||||
lastAutosavedDraftKeyRef.current = savedDraftKey
|
||||
@@ -146,11 +147,49 @@ export function useAgentConfigureSync({
|
||||
const draft = store.get(agentComposerDraftAtom)
|
||||
if (!validateKnowledgeRetrievals(draft.knowledgeRetrievals).isValid)
|
||||
throw new InvalidKnowledgeConfigurationError()
|
||||
const configSnapshot = getAgentSoulDraft()
|
||||
const hasEffectiveModelChange = !isEqual(configSnapshot.model, baseConfigRef.current?.model)
|
||||
debouncedSaveDraft.cancel?.()
|
||||
if (!store.get(isAgentComposerDirtyAtom) && !hasEffectiveModelChange)
|
||||
return
|
||||
|
||||
await saveComposer({
|
||||
configSnapshot,
|
||||
draftBaseline: draft,
|
||||
silent: false,
|
||||
})
|
||||
}, [debouncedSaveDraft, getAgentSoulDraft, saveComposer, store])
|
||||
|
||||
const saveDirtyDraftOnPageClose = useCallback(() => {
|
||||
if (!enabledRef.current || publishInFlightRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
const draft = store.get(agentComposerDraftAtom)
|
||||
if (
|
||||
!store.get(isAgentComposerDirtyAtom)
|
||||
|| !validateKnowledgeRetrievals(draft.knowledgeRetrievals).isValid
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
const configSnapshot = getAgentSoulDraft()
|
||||
const draftKey = JSON.stringify(configSnapshot)
|
||||
if (
|
||||
lastAutosavedDraftKeyRef.current === draftKey
|
||||
|| pageCloseSavingDraftKeyRef.current === draftKey
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
debouncedSaveDraft.cancel?.()
|
||||
await saveComposer({
|
||||
configSnapshot: getAgentSoulDraft(),
|
||||
pageCloseSavingDraftKeyRef.current = draftKey
|
||||
void saveComposer({
|
||||
configSnapshot,
|
||||
draftBaseline: draft,
|
||||
}).finally(() => {
|
||||
if (pageCloseSavingDraftKeyRef.current === draftKey)
|
||||
pageCloseSavingDraftKeyRef.current = undefined
|
||||
})
|
||||
}, [debouncedSaveDraft, getAgentSoulDraft, saveComposer, store])
|
||||
|
||||
@@ -164,11 +203,11 @@ export function useAgentConfigureSync({
|
||||
!enabledRef.current
|
||||
|| !isDirty
|
||||
) {
|
||||
if (!isDirty)
|
||||
debouncedSaveDraft.cancel?.()
|
||||
return
|
||||
}
|
||||
|
||||
markActiveConfigUnpublished()
|
||||
|
||||
if (
|
||||
!validateKnowledgeRetrievals(store.get(agentComposerDraftAtom).knowledgeRetrievals).isValid
|
||||
|| lastAutosavedDraftKeyRef.current === agentSoulDraftKey
|
||||
@@ -178,7 +217,7 @@ export function useAgentConfigureSync({
|
||||
|
||||
debouncedSaveDraft()
|
||||
})
|
||||
}, [debouncedSaveDraft, getAgentSoulDraft, markActiveConfigUnpublished, store])
|
||||
}, [debouncedSaveDraft, getAgentSoulDraft, store])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -186,6 +225,24 @@ export function useAgentConfigureSync({
|
||||
}
|
||||
}, [debouncedSaveDraft])
|
||||
|
||||
useEffect(() => {
|
||||
const saveDraftWhenPageHidden = () => {
|
||||
if (document.visibilityState === 'hidden')
|
||||
saveDirtyDraftOnPageClose()
|
||||
}
|
||||
const saveDraftBeforeUnload = () => {
|
||||
saveDirtyDraftOnPageClose()
|
||||
}
|
||||
|
||||
document.addEventListener('visibilitychange', saveDraftWhenPageHidden)
|
||||
window.addEventListener('beforeunload', saveDraftBeforeUnload)
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('visibilitychange', saveDraftWhenPageHidden)
|
||||
window.removeEventListener('beforeunload', saveDraftBeforeUnload)
|
||||
}
|
||||
}, [saveDirtyDraftOnPageClose])
|
||||
|
||||
const publishDraft = useCallback(async () => {
|
||||
if (publishInFlightRef.current)
|
||||
return
|
||||
@@ -206,6 +263,7 @@ export function useAgentConfigureSync({
|
||||
const saved = await saveComposer({
|
||||
configSnapshot,
|
||||
draftBaseline: draft,
|
||||
silent: false,
|
||||
})
|
||||
if (!saved)
|
||||
return
|
||||
|
||||
@@ -209,6 +209,8 @@ describe('AgentRosterList', () => {
|
||||
|
||||
const workflowLink = screen.getByRole('menuitem', { name: /RFP Review Flow/ })
|
||||
expect(workflowLink).toHaveAttribute('href', '/app/workflow-app-id/workflow')
|
||||
expect(workflowLink).toHaveAttribute('target', '_blank')
|
||||
expect(workflowLink).toHaveAttribute('rel', 'noopener noreferrer')
|
||||
expect(screen.getByText(/agentV2\.roster\.references\.label/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
|
||||
@@ -182,7 +182,7 @@ function AgentRosterItem({
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<div className="flex h-4 shrink-0 items-center gap-1">
|
||||
<span aria-hidden className="i-custom-vender-agent-v2-plan size-3 shrink-0 text-text-tertiary" />
|
||||
<span className="system-xs-regular text-text-tertiary">{referenceCount}</span>
|
||||
</div>
|
||||
|
||||
@@ -45,7 +45,7 @@ export function AgentWorkflowReferencesDropdown({
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger
|
||||
aria-label={t('roster.references.trigger', { name: agentName, count: referenceCount })}
|
||||
className="-ml-1 flex shrink-0 cursor-pointer items-center gap-1 rounded-md px-1 py-0.5 outline-hidden hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid data-popup-open:bg-state-base-hover"
|
||||
className="relative flex h-4 shrink-0 cursor-pointer items-center gap-1 rounded-md outline-hidden before:pointer-events-none before:absolute before:-inset-x-1 before:-inset-y-0.5 before:rounded-md before:content-[''] hover:before:bg-state-base-hover focus-visible:before:ring-2 focus-visible:before:ring-state-accent-solid data-popup-open:before:bg-state-base-hover"
|
||||
>
|
||||
<span aria-hidden className="i-custom-vender-agent-v2-plan size-3 shrink-0 text-text-tertiary" />
|
||||
<span className="system-xs-regular text-text-tertiary">{referenceCount}</span>
|
||||
@@ -57,7 +57,7 @@ export function AgentWorkflowReferencesDropdown({
|
||||
{publishedReferences.map(reference => (
|
||||
<DropdownMenuLinkItem
|
||||
key={reference.app_id}
|
||||
render={<Link href={getWorkflowReferenceHref(reference)} />}
|
||||
render={<Link href={getWorkflowReferenceHref(reference)} target="_blank" rel="noopener noreferrer" />}
|
||||
className="mx-0 h-8 gap-2 px-2 py-1 pr-2.5 system-md-regular text-text-secondary"
|
||||
>
|
||||
<span aria-hidden className="shrink-0">
|
||||
|
||||
Reference in New Issue
Block a user