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:
yyh
2026-07-01 05:07:23 +00:00
committed by GitHub
co-authored by yunlu.wen autofix-ci[bot] Yunlu Wen Copilot Autofix powered by AI Joel Yanli 盐粒 盐粒 Yanli zyssyz123 盐粒 Yanli
parent f816ae2e95
commit 0923ebaf88
277 changed files with 26866 additions and 7348 deletions
@@ -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)
})
})
@@ -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'],
})
@@ -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)}
/>
</>
@@ -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)
})
})
@@ -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() {
@@ -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' })
@@ -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()
})
})
@@ -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>
)
}
@@ -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>
)
}
@@ -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"
/>
)
}
@@ -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))
}
@@ -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()
})
})
@@ -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,
}
}
@@ -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>
@@ -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>
@@ -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',
@@ -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"
@@ -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
}
}}
/>
</>
@@ -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>
@@ -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
@@ -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} />
@@ -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>
@@ -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>
)
@@ -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}
@@ -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
@@ -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,
},
@@ -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,
@@ -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]
@@ -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
}
}
@@ -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()
@@ -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()
@@ -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()
})
})
})
@@ -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>
)
}
@@ -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" />}
@@ -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,
}
}
@@ -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'
@@ -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>
)
}
@@ -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>
)
}
@@ -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>
)
}
@@ -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>
)
}
@@ -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>
)
}
@@ -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>
)
}
@@ -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>
)
}
@@ -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">