Compare commits

..

6 Commits

Author SHA1 Message Date
yyh
38d68ac4e3 fix(workflow): update stop state for preview chat 2026-01-26 22:50:38 +08:00
yyh
4e48a1c4c3 fix(workflow): keep preview run state on panel close 2026-01-26 22:42:14 +08:00
yyh
fb9a6bbc9f fix(workflow): restore fetchInspectVars and invalidAllLastRun calls lost during refactor
The previous commit (a86765e2b6) accidentally dropped these calls when splitting
useChat hook into modules. Also add race condition protection using runId to
prevent stale callbacks from updating state after a new run has started.
2026-01-26 21:55:16 +08:00
yyh
a86765e2b6 refactor(workflow): split useChat hook into modular files
Extract the 535-line useChat hook into 7 focused modules for better
maintainability and testability. Add chat-preview-slice to Zustand store
to centralize chat state management instead of local useState/useRef.
2026-01-26 21:49:10 +08:00
5eaf0c733a fix: service api doc can not gen (#31549) 2026-01-26 21:59:02 +09:00
yyh
f561656a89 chore: follow-up fixes for storybook vite migration (#31545) 2026-01-26 20:20:14 +08:00
21 changed files with 1026 additions and 585 deletions

0
agent-notes/.gitkeep Normal file
View File

View File

@ -0,0 +1,27 @@
# Notes: `large_language_model.py`
## Purpose
Provides the base `LargeLanguageModel` implementation used by the model runtime to invoke plugin-backed LLMs and to
bridge plugin daemon streaming semantics back into API-layer entities (`LLMResult`, `LLMResultChunk`).
## Key behaviors / invariants
- `invoke(..., stream=False)` still calls the plugin in streaming mode and then synthesizes a single `LLMResult` from
the first yielded `LLMResultChunk`.
- Plugin invocation is wrapped by `_invoke_llm_via_plugin(...)`, and `stream=False` normalization is handled by
`_normalize_non_stream_plugin_result(...)` / `_build_llm_result_from_first_chunk(...)`.
- Tool call deltas are merged incrementally via `_increase_tool_call(...)` to support multiple provider chunking
patterns (IDs anchored to first chunk, every chunk, or missing entirely).
- A tool-call delta with an empty `id` requires at least one existing tool call; otherwise we raise `ValueError` to
surface invalid delta sequences explicitly.
- Callback invocation is centralized in `_run_callbacks(...)` to ensure consistent error handling/logging.
- For compatibility with dify issue `#17799`, `prompt_messages` may be removed by the plugin daemon in chunks and must
be re-attached in this layer before callbacks/consumers use them.
- Callback hooks (`on_before_invoke`, `on_new_chunk`, `on_after_invoke`, `on_invoke_error`) must not break invocation
unless `callback.raise_error` is true.
## Test focus
- `api/tests/unit_tests/core/model_runtime/__base/test_increase_tool_call.py` validates tool-call delta merging and
patches `_gen_tool_call_id` for deterministic IDs.

View File

@ -1,47 +1,97 @@
# API Agent Guide
## Notes for Agent (must-check)
## Agent Notes (must-check)
Before changing any backend code under `api/`, you MUST read the surrounding docstrings and comments. These notes contain required context (invariants, edge cases, trade-offs) and are treated as part of the spec.
Before you start work on any backend file under `api/`, you MUST check whether a related note exists under:
Look for:
- `agent-notes/<same-relative-path-as-target-file>.md`
- The module (file) docstring at the top of a source code file
- Docstrings on classes and functions/methods
- Paragraph/block comments for non-obvious logic
Rules:
### What to write where
- **Path mapping**: for a target file `<path>/<name>.py`, the note must be `agent-notes/<path>/<name>.py.md` (same folder structure, same filename, plus `.md`).
- **Before working**:
- If the note exists, read it first and follow any constraints/decisions recorded there.
- If the note conflicts with the current code, or references an "origin" file/path that has been deleted, renamed, or migrated, treat the **code as the single source of truth** and update the note to match reality.
- If the note does not exist, create it with a short architecture/intent summary and any relevant invariants/edge cases.
- **During working**:
- Keep the note in sync as you discover constraints, make decisions, or change approach.
- If you move/rename a file, migrate its note to the new mapped path (and fix any outdated references inside the note).
- Record non-obvious edge cases, trade-offs, and the test/verification plan as you go (not just at the end).
- Keep notes **coherent**: integrate new findings into the relevant sections and rewrite for clarity; avoid append-only “recent fix” / changelog-style additions unless the note is explicitly intended to be a changelog.
- **When finishing work**:
- Update the related note(s) to reflect what changed, why, and any new edge cases/tests.
- If a file is deleted, remove or clearly deprecate the corresponding note so it cannot be mistaken as current guidance.
- Keep notes concise and accurate; they are meant to prevent repeated rediscovery.
- Keep notes scoped: module notes cover module-wide context, class notes cover class-wide context, function/method notes cover behavioural contracts, and paragraph/block comments cover local “why”. Avoid duplicating the same content across scopes unless repetition prevents misuse.
- **Module (file) docstring**: purpose, boundaries, key invariants, and “gotchas” that a new reader must know before editing.
- Include cross-links to the key collaborators (modules/services) when discovery is otherwise hard.
- Prefer stable facts (invariants, contracts) over ephemeral “today we…” notes.
- **Class docstring**: responsibility, lifecycle, invariants, and how it should be used (or not used).
- If the class is intentionally stateful, note what state exists and what methods mutate it.
- If concurrency/async assumptions matter, state them explicitly.
- **Function/method docstring**: behavioural contract.
- Document arguments, return shape, side effects (DB writes, external I/O, task dispatch), and raised domain exceptions.
- Add examples only when they prevent misuse.
- **Paragraph/block comments**: explain *why* (trade-offs, historical constraints, surprising edge cases), not what the code already states.
- Keep comments adjacent to the logic they justify; delete or rewrite comments that no longer match reality.
## Skill Index
### Rules (must follow)
Start with the section that best matches your need. Each entry lists the problems it solves plus key files/concepts so you know what to expect before opening it.
In this section, “notes” means module/class/function docstrings plus any relevant paragraph/block comments.
### Platform Foundations
- **Before working**
- Read the notes in the area youll touch; treat them as part of the spec.
- If a docstring or comment conflicts with the current code, treat the **code as the single source of truth** and update the docstring or comment to match reality.
- If important intent/invariants/edge cases are missing, add them in the closest docstring or comment (module for overall scope, function for behaviour).
- **During working**
- Keep the notes in sync as you discover constraints, make decisions, or change approach.
- If you move/rename responsibilities across modules/classes, update the affected docstrings and comments so readers can still find the “why” and the invariants.
- Record non-obvious edge cases, trade-offs, and the test/verification plan in the nearest docstring or comment that will stay correct.
- Keep the notes **coherent**: integrate new findings into the relevant docstrings and comments; avoid append-only “recent fix” / changelog-style additions.
- **When finishing**
- Update the notes to reflect what changed, why, and any new edge cases/tests.
- Remove or rewrite any comments that could be mistaken as current guidance but no longer apply.
- Keep docstrings and comments concise and accurate; they are meant to prevent repeated rediscovery.
#### [Infrastructure Overview](agent_skills/infra.md)
- **When to read this**
- You need to understand where a feature belongs in the architecture.
- Youre wiring storage, Redis, vector stores, or OTEL.
- Youre about to add CLI commands or async jobs.
- **What it covers**
- Configuration stack (`configs/app_config.py`, remote settings)
- Storage entry points (`extensions/ext_storage.py`, `core/file/file_manager.py`)
- Redis conventions (`extensions/ext_redis.py`)
- Plugin runtime topology
- Vector-store factory (`core/rag/datasource/vdb/*`)
- Observability hooks
- SSRF proxy usage
- Core CLI commands
### Plugin & Extension Development
#### [Plugin Systems](agent_skills/plugin.md)
- **When to read this**
- Youre building or debugging a marketplace plugin.
- You need to know how manifests, providers, daemons, and migrations fit together.
- **What it covers**
- Plugin manifests (`core/plugin/entities/plugin.py`)
- Installation/upgrade flows (`services/plugin/plugin_service.py`, CLI commands)
- Runtime adapters (`core/plugin/impl/*` for tool/model/datasource/trigger/endpoint/agent)
- Daemon coordination (`core/plugin/entities/plugin_daemon.py`)
- How provider registries surface capabilities to the rest of the platform
#### [Plugin OAuth](agent_skills/plugin_oauth.md)
- **When to read this**
- You must integrate OAuth for a plugin or datasource.
- Youre handling credential encryption or refresh flows.
- **Topics**
- Credential storage
- Encryption helpers (`core/helper/provider_encryption.py`)
- OAuth client bootstrap (`services/plugin/oauth_service.py`, `services/plugin/plugin_parameter_service.py`)
- How console/API layers expose the flows
### Workflow Entry & Execution
#### [Trigger Concepts](agent_skills/trigger.md)
- **When to read this**
- Youre debugging why a workflow didnt start.
- Youre adding a new trigger type or hook.
- You need to trace async execution, draft debugging, or webhook/schedule pipelines.
- **Details**
- Start-node taxonomy
- Webhook & schedule internals (`core/workflow/nodes/trigger_*`, `services/trigger/*`)
- Async orchestration (`services/async_workflow_service.py`, Celery queues)
- Debug event bus
- Storage/logging interactions
## General Reminders
- All skill docs assume you follow the coding style rules below—run the lint/type/test commands before submitting changes.
- When you cannot find an answer in these briefs, search the codebase using the paths referenced (e.g., `core/plugin/impl/tool.py`, `services/dataset_service.py`).
- If you run into cross-cutting concerns (tenancy, configuration, storage), check the infrastructure guide first; it links to most supporting modules.
- Keep multi-tenancy and configuration central: everything flows through `configs.dify_config` and `tenant_id`.
- When touching plugins or triggers, consult both the system overview and the specialised doc to ensure you adjust lifecycle, storage, and observability consistently.
## Coding Style
@ -176,7 +226,7 @@ Before opening a PR / submitting:
- Controllers: parse input via Pydantic, invoke services, return serialised responses; no business logic.
- Services: coordinate repositories, providers, background tasks; keep side effects explicit.
- Document non-obvious behaviour with concise docstrings and comments.
- Document non-obvious behaviour with concise comments.
### Miscellaneous

View File

@ -11,7 +11,9 @@ from controllers.service_api.wraps import DatasetApiResource, cloud_edition_bill
from fields.dataset_fields import dataset_metadata_fields
from services.dataset_service import DatasetService
from services.entities.knowledge_entities.knowledge_entities import (
DocumentMetadataOperation,
MetadataArgs,
MetadataDetail,
MetadataOperationData,
)
from services.metadata_service import MetadataService
@ -22,7 +24,13 @@ class MetadataUpdatePayload(BaseModel):
register_schema_model(service_api_ns, MetadataUpdatePayload)
register_schema_models(service_api_ns, MetadataArgs, MetadataOperationData)
register_schema_models(
service_api_ns,
MetadataArgs,
MetadataDetail,
DocumentMetadataOperation,
MetadataOperationData,
)
@service_api_ns.route("/datasets/<uuid:dataset_id>/metadata")

View File

@ -1,9 +1,9 @@
/// <reference types="vite/client" />
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
import * as React from 'react'
declare const require: any
type IconComponent = React.ComponentType<Record<string, unknown>>
type IconModule = { default: IconComponent }
type IconEntry = {
name: string
@ -12,18 +12,16 @@ type IconEntry = {
Component: IconComponent
}
const iconContext = require.context('./src', true, /\.tsx$/)
const iconModules: Record<string, IconModule> = import.meta.glob('./src/**/*.tsx', { eager: true })
const iconEntries: IconEntry[] = iconContext
.keys()
.filter((key: string) => !key.endsWith('.stories.tsx') && !key.endsWith('.spec.tsx'))
.map((key: string) => {
const mod = iconContext(key)
const Component = mod.default as IconComponent | undefined
const iconEntries: IconEntry[] = Object.entries(iconModules)
.filter(([key]) => !key.endsWith('.stories.tsx') && !key.endsWith('.spec.tsx'))
.map(([key, mod]) => {
const Component = mod.default
if (!Component)
return null
const relativePath = key.replace(/^\.\//, '')
const relativePath = key.replace(/^\.\/src\//, '')
const path = `app/components/base/icons/src/${relativePath}`
const parts = relativePath.split('/')
const fileName = parts.pop() || ''

View File

@ -18,7 +18,7 @@ import {
useWorkflowReadOnly,
} from '../hooks'
import { useStore, useWorkflowStore } from '../store'
import { BlockEnum, ControlMode } from '../types'
import { BlockEnum, ControlMode, WorkflowRunningStatus } from '../types'
import {
getLayoutByDagre,
getLayoutForChildNodes,
@ -36,12 +36,17 @@ export const useWorkflowInteractions = () => {
const { handleEdgeCancelRunningStatus } = useEdgesInteractionsWithoutSync()
const handleCancelDebugAndPreviewPanel = useCallback(() => {
const { workflowRunningData } = workflowStore.getState()
const runningStatus = workflowRunningData?.result?.status
const isActiveRun = runningStatus === WorkflowRunningStatus.Running || runningStatus === WorkflowRunningStatus.Waiting
workflowStore.setState({
showDebugAndPreviewPanel: false,
workflowRunningData: undefined,
...(isActiveRun ? {} : { workflowRunningData: undefined }),
})
handleNodeCancelRunningStatus()
handleEdgeCancelRunningStatus()
if (!isActiveRun) {
handleNodeCancelRunningStatus()
handleEdgeCancelRunningStatus()
}
}, [workflowStore, handleNodeCancelRunningStatus, handleEdgeCancelRunningStatus])
return {

View File

@ -21,7 +21,7 @@ import {
import { BlockEnum } from '../../types'
import ConversationVariableModal from './conversation-variable-modal'
import Empty from './empty'
import { useChat } from './hooks'
import { useChat } from './hooks/use-chat'
import UserInput from './user-input'
type ChatWrapperProps = {

View File

@ -1,516 +0,0 @@
import type { InputForm } from '@/app/components/base/chat/chat/type'
import type {
ChatItem,
ChatItemInTree,
Inputs,
} from '@/app/components/base/chat/types'
import type { FileEntity } from '@/app/components/base/file-uploader/types'
import { uniqBy } from 'es-toolkit/compat'
import { produce, setAutoFreeze } from 'immer'
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import {
getProcessedInputs,
processOpeningStatement,
} from '@/app/components/base/chat/chat/utils'
import { getThreadMessages } from '@/app/components/base/chat/utils'
import {
getProcessedFiles,
getProcessedFilesFromResponse,
} from '@/app/components/base/file-uploader/utils'
import { useToastContext } from '@/app/components/base/toast'
import { useInvalidAllLastRun } from '@/service/use-workflow'
import { TransferMethod } from '@/types/app'
import { DEFAULT_ITER_TIMES, DEFAULT_LOOP_TIMES } from '../../constants'
import {
useSetWorkflowVarsWithValue,
useWorkflowRun,
} from '../../hooks'
import { useHooksStore } from '../../hooks-store'
import { useWorkflowStore } from '../../store'
import { NodeRunningStatus, WorkflowRunningStatus } from '../../types'
type GetAbortController = (abortController: AbortController) => void
type SendCallback = {
onGetSuggestedQuestions?: (responseItemId: string, getAbortController: GetAbortController) => Promise<any>
}
export const useChat = (
config: any,
formSettings?: {
inputs: Inputs
inputsForm: InputForm[]
},
prevChatTree?: ChatItemInTree[],
stopChat?: (taskId: string) => void,
) => {
const { t } = useTranslation()
const { notify } = useToastContext()
const { handleRun } = useWorkflowRun()
const hasStopResponded = useRef(false)
const workflowStore = useWorkflowStore()
const conversationId = useRef('')
const taskIdRef = useRef('')
const [isResponding, setIsResponding] = useState(false)
const isRespondingRef = useRef(false)
const configsMap = useHooksStore(s => s.configsMap)
const invalidAllLastRun = useInvalidAllLastRun(configsMap?.flowType, configsMap?.flowId)
const { fetchInspectVars } = useSetWorkflowVarsWithValue()
const [suggestedQuestions, setSuggestQuestions] = useState<string[]>([])
const suggestedQuestionsAbortControllerRef = useRef<AbortController | null>(null)
const {
setIterTimes,
setLoopTimes,
} = workflowStore.getState()
const handleResponding = useCallback((isResponding: boolean) => {
setIsResponding(isResponding)
isRespondingRef.current = isResponding
}, [])
const [chatTree, setChatTree] = useState<ChatItemInTree[]>(prevChatTree || [])
const chatTreeRef = useRef<ChatItemInTree[]>(chatTree)
const [targetMessageId, setTargetMessageId] = useState<string>()
const threadMessages = useMemo(() => getThreadMessages(chatTree, targetMessageId), [chatTree, targetMessageId])
const getIntroduction = useCallback((str: string) => {
return processOpeningStatement(str, formSettings?.inputs || {}, formSettings?.inputsForm || [])
}, [formSettings?.inputs, formSettings?.inputsForm])
/** Final chat list that will be rendered */
const chatList = useMemo(() => {
const ret = [...threadMessages]
if (config?.opening_statement) {
const index = threadMessages.findIndex(item => item.isOpeningStatement)
if (index > -1) {
ret[index] = {
...ret[index],
content: getIntroduction(config.opening_statement),
suggestedQuestions: config.suggested_questions?.map((item: string) => getIntroduction(item)),
}
}
else {
ret.unshift({
id: `${Date.now()}`,
content: getIntroduction(config.opening_statement),
isAnswer: true,
isOpeningStatement: true,
suggestedQuestions: config.suggested_questions?.map((item: string) => getIntroduction(item)),
})
}
}
return ret
}, [threadMessages, config?.opening_statement, getIntroduction, config?.suggested_questions])
useEffect(() => {
setAutoFreeze(false)
return () => {
setAutoFreeze(true)
}
}, [])
/** Find the target node by bfs and then operate on it */
const produceChatTreeNode = useCallback((targetId: string, operation: (node: ChatItemInTree) => void) => {
return produce(chatTreeRef.current, (draft) => {
const queue: ChatItemInTree[] = [...draft]
while (queue.length > 0) {
const current = queue.shift()!
if (current.id === targetId) {
operation(current)
break
}
if (current.children)
queue.push(...current.children)
}
})
}, [])
const handleStop = useCallback(() => {
hasStopResponded.current = true
handleResponding(false)
if (stopChat && taskIdRef.current)
stopChat(taskIdRef.current)
setIterTimes(DEFAULT_ITER_TIMES)
setLoopTimes(DEFAULT_LOOP_TIMES)
if (suggestedQuestionsAbortControllerRef.current)
suggestedQuestionsAbortControllerRef.current.abort()
}, [handleResponding, setIterTimes, setLoopTimes, stopChat])
const handleRestart = useCallback(() => {
conversationId.current = ''
taskIdRef.current = ''
handleStop()
setIterTimes(DEFAULT_ITER_TIMES)
setLoopTimes(DEFAULT_LOOP_TIMES)
setChatTree([])
setSuggestQuestions([])
}, [
handleStop,
setIterTimes,
setLoopTimes,
])
const updateCurrentQAOnTree = useCallback(({
parentId,
responseItem,
placeholderQuestionId,
questionItem,
}: {
parentId?: string
responseItem: ChatItem
placeholderQuestionId: string
questionItem: ChatItem
}) => {
let nextState: ChatItemInTree[]
const currentQA = { ...questionItem, children: [{ ...responseItem, children: [] }] }
if (!parentId && !chatTree.some(item => [placeholderQuestionId, questionItem.id].includes(item.id))) {
// QA whose parent is not provided is considered as a first message of the conversation,
// and it should be a root node of the chat tree
nextState = produce(chatTree, (draft) => {
draft.push(currentQA)
})
}
else {
// find the target QA in the tree and update it; if not found, insert it to its parent node
nextState = produceChatTreeNode(parentId!, (parentNode) => {
const questionNodeIndex = parentNode.children!.findIndex(item => [placeholderQuestionId, questionItem.id].includes(item.id))
if (questionNodeIndex === -1)
parentNode.children!.push(currentQA)
else
parentNode.children![questionNodeIndex] = currentQA
})
}
setChatTree(nextState)
chatTreeRef.current = nextState
}, [chatTree, produceChatTreeNode])
const handleSend = useCallback((
params: {
query: string
files?: FileEntity[]
parent_message_id?: string
[key: string]: any
},
{
onGetSuggestedQuestions,
}: SendCallback,
) => {
if (isRespondingRef.current) {
notify({ type: 'info', message: t('errorMessage.waitForResponse', { ns: 'appDebug' }) })
return false
}
const parentMessage = threadMessages.find(item => item.id === params.parent_message_id)
const placeholderQuestionId = `question-${Date.now()}`
const questionItem = {
id: placeholderQuestionId,
content: params.query,
isAnswer: false,
message_files: params.files,
parentMessageId: params.parent_message_id,
}
const placeholderAnswerId = `answer-placeholder-${Date.now()}`
const placeholderAnswerItem = {
id: placeholderAnswerId,
content: '',
isAnswer: true,
parentMessageId: questionItem.id,
siblingIndex: parentMessage?.children?.length ?? chatTree.length,
}
setTargetMessageId(parentMessage?.id)
updateCurrentQAOnTree({
parentId: params.parent_message_id,
responseItem: placeholderAnswerItem,
placeholderQuestionId,
questionItem,
})
// answer
const responseItem: ChatItem = {
id: placeholderAnswerId,
content: '',
agent_thoughts: [],
message_files: [],
isAnswer: true,
parentMessageId: questionItem.id,
siblingIndex: parentMessage?.children?.length ?? chatTree.length,
}
handleResponding(true)
const { files, inputs, ...restParams } = params
const bodyParams = {
files: getProcessedFiles(files || []),
inputs: getProcessedInputs(inputs || {}, formSettings?.inputsForm || []),
...restParams,
}
if (bodyParams?.files?.length) {
bodyParams.files = bodyParams.files.map((item) => {
if (item.transfer_method === TransferMethod.local_file) {
return {
...item,
url: '',
}
}
return item
})
}
let hasSetResponseId = false
handleRun(
bodyParams,
{
onData: (message: string, isFirstMessage: boolean, { conversationId: newConversationId, messageId, taskId }: any) => {
responseItem.content = responseItem.content + message
if (messageId && !hasSetResponseId) {
questionItem.id = `question-${messageId}`
responseItem.id = messageId
responseItem.parentMessageId = questionItem.id
hasSetResponseId = true
}
if (isFirstMessage && newConversationId)
conversationId.current = newConversationId
taskIdRef.current = taskId
if (messageId)
responseItem.id = messageId
updateCurrentQAOnTree({
placeholderQuestionId,
questionItem,
responseItem,
parentId: params.parent_message_id,
})
},
async onCompleted(hasError?: boolean, errorMessage?: string) {
handleResponding(false)
fetchInspectVars({})
invalidAllLastRun()
if (hasError) {
if (errorMessage) {
responseItem.content = errorMessage
responseItem.isError = true
updateCurrentQAOnTree({
placeholderQuestionId,
questionItem,
responseItem,
parentId: params.parent_message_id,
})
}
return
}
if (config?.suggested_questions_after_answer?.enabled && !hasStopResponded.current && onGetSuggestedQuestions) {
try {
const { data }: any = await onGetSuggestedQuestions(
responseItem.id,
newAbortController => suggestedQuestionsAbortControllerRef.current = newAbortController,
)
setSuggestQuestions(data)
}
// eslint-disable-next-line unused-imports/no-unused-vars
catch (error) {
setSuggestQuestions([])
}
}
},
onMessageEnd: (messageEnd) => {
responseItem.citation = messageEnd.metadata?.retriever_resources || []
const processedFilesFromResponse = getProcessedFilesFromResponse(messageEnd.files || [])
responseItem.allFiles = uniqBy([...(responseItem.allFiles || []), ...(processedFilesFromResponse || [])], 'id')
updateCurrentQAOnTree({
placeholderQuestionId,
questionItem,
responseItem,
parentId: params.parent_message_id,
})
},
onMessageReplace: (messageReplace) => {
responseItem.content = messageReplace.answer
},
onError() {
handleResponding(false)
},
onWorkflowStarted: ({ workflow_run_id, task_id }) => {
taskIdRef.current = task_id
responseItem.workflow_run_id = workflow_run_id
responseItem.workflowProcess = {
status: WorkflowRunningStatus.Running,
tracing: [],
}
updateCurrentQAOnTree({
placeholderQuestionId,
questionItem,
responseItem,
parentId: params.parent_message_id,
})
},
onWorkflowFinished: ({ data }) => {
responseItem.workflowProcess!.status = data.status as WorkflowRunningStatus
updateCurrentQAOnTree({
placeholderQuestionId,
questionItem,
responseItem,
parentId: params.parent_message_id,
})
},
onIterationStart: ({ data }) => {
responseItem.workflowProcess!.tracing!.push({
...data,
status: NodeRunningStatus.Running,
})
updateCurrentQAOnTree({
placeholderQuestionId,
questionItem,
responseItem,
parentId: params.parent_message_id,
})
},
onIterationFinish: ({ data }) => {
const currentTracingIndex = responseItem.workflowProcess!.tracing!.findIndex(item => item.id === data.id)
if (currentTracingIndex > -1) {
responseItem.workflowProcess!.tracing[currentTracingIndex] = {
...responseItem.workflowProcess!.tracing[currentTracingIndex],
...data,
}
updateCurrentQAOnTree({
placeholderQuestionId,
questionItem,
responseItem,
parentId: params.parent_message_id,
})
}
},
onLoopStart: ({ data }) => {
responseItem.workflowProcess!.tracing!.push({
...data,
status: NodeRunningStatus.Running,
})
updateCurrentQAOnTree({
placeholderQuestionId,
questionItem,
responseItem,
parentId: params.parent_message_id,
})
},
onLoopFinish: ({ data }) => {
const currentTracingIndex = responseItem.workflowProcess!.tracing!.findIndex(item => item.id === data.id)
if (currentTracingIndex > -1) {
responseItem.workflowProcess!.tracing[currentTracingIndex] = {
...responseItem.workflowProcess!.tracing[currentTracingIndex],
...data,
}
updateCurrentQAOnTree({
placeholderQuestionId,
questionItem,
responseItem,
parentId: params.parent_message_id,
})
}
},
onNodeStarted: ({ data }) => {
responseItem.workflowProcess!.tracing!.push({
...data,
status: NodeRunningStatus.Running,
} as any)
updateCurrentQAOnTree({
placeholderQuestionId,
questionItem,
responseItem,
parentId: params.parent_message_id,
})
},
onNodeRetry: ({ data }) => {
responseItem.workflowProcess!.tracing!.push(data)
updateCurrentQAOnTree({
placeholderQuestionId,
questionItem,
responseItem,
parentId: params.parent_message_id,
})
},
onNodeFinished: ({ data }) => {
const currentTracingIndex = responseItem.workflowProcess!.tracing!.findIndex(item => item.id === data.id)
if (currentTracingIndex > -1) {
responseItem.workflowProcess!.tracing[currentTracingIndex] = {
...responseItem.workflowProcess!.tracing[currentTracingIndex],
...data,
}
updateCurrentQAOnTree({
placeholderQuestionId,
questionItem,
responseItem,
parentId: params.parent_message_id,
})
}
},
onAgentLog: ({ data }) => {
const currentNodeIndex = responseItem.workflowProcess!.tracing!.findIndex(item => item.node_id === data.node_id)
if (currentNodeIndex > -1) {
const current = responseItem.workflowProcess!.tracing![currentNodeIndex]
if (current.execution_metadata) {
if (current.execution_metadata.agent_log) {
const currentLogIndex = current.execution_metadata.agent_log.findIndex(log => log.message_id === data.message_id)
if (currentLogIndex > -1) {
current.execution_metadata.agent_log[currentLogIndex] = {
...current.execution_metadata.agent_log[currentLogIndex],
...data,
}
}
else {
current.execution_metadata.agent_log.push(data)
}
}
else {
current.execution_metadata.agent_log = [data]
}
}
else {
current.execution_metadata = {
agent_log: [data],
} as any
}
responseItem.workflowProcess!.tracing[currentNodeIndex] = {
...current,
}
updateCurrentQAOnTree({
placeholderQuestionId,
questionItem,
responseItem,
parentId: params.parent_message_id,
})
}
},
},
)
}, [threadMessages, chatTree.length, updateCurrentQAOnTree, handleResponding, formSettings?.inputsForm, handleRun, notify, t, config?.suggested_questions_after_answer?.enabled, fetchInspectVars, invalidAllLastRun])
return {
conversationId: conversationId.current,
chatList,
setTargetMessageId,
handleSend,
handleStop,
handleRestart,
isResponding,
suggestedQuestions,
}
}

View File

@ -0,0 +1,38 @@
import type { ChatItem, ChatItemInTree } from '@/app/components/base/chat/types'
import type { FileEntity } from '@/app/components/base/file-uploader/types'
export type ChatConfig = {
opening_statement?: string
suggested_questions?: string[]
suggested_questions_after_answer?: {
enabled?: boolean
}
text_to_speech?: unknown
speech_to_text?: unknown
retriever_resource?: unknown
sensitive_word_avoidance?: unknown
file_upload?: unknown
}
export type GetAbortController = (abortController: AbortController) => void
export type SendCallback = {
onGetSuggestedQuestions?: (responseItemId: string, getAbortController: GetAbortController) => Promise<unknown>
}
export type SendParams = {
query: string
files?: FileEntity[]
parent_message_id?: string
inputs?: Record<string, unknown>
conversation_id?: string
}
export type UpdateCurrentQAParams = {
parentId?: string
responseItem: ChatItem
placeholderQuestionId: string
questionItem: ChatItem
}
export type ChatTreeUpdater = (updater: (chatTree: ChatItemInTree[]) => ChatItemInTree[]) => void

View File

@ -0,0 +1,90 @@
import { useCallback } from 'react'
import { DEFAULT_ITER_TIMES, DEFAULT_LOOP_TIMES } from '../../../constants'
import { useEdgesInteractionsWithoutSync } from '../../../hooks/use-edges-interactions-without-sync'
import { useNodesInteractionsWithoutSync } from '../../../hooks/use-nodes-interactions-without-sync'
import { useStore, useWorkflowStore } from '../../../store'
import { WorkflowRunningStatus } from '../../../types'
type UseChatFlowControlParams = {
stopChat?: (taskId: string) => void
}
export function useChatFlowControl({
stopChat,
}: UseChatFlowControlParams) {
const workflowStore = useWorkflowStore()
const setIsResponding = useStore(s => s.setIsResponding)
const resetChatPreview = useStore(s => s.resetChatPreview)
const setActiveTaskId = useStore(s => s.setActiveTaskId)
const setHasStopResponded = useStore(s => s.setHasStopResponded)
const setSuggestedQuestionsAbortController = useStore(s => s.setSuggestedQuestionsAbortController)
const invalidateRun = useStore(s => s.invalidateRun)
const { handleNodeCancelRunningStatus } = useNodesInteractionsWithoutSync()
const { handleEdgeCancelRunningStatus } = useEdgesInteractionsWithoutSync()
const { setIterTimes, setLoopTimes } = workflowStore.getState()
const handleResponding = useCallback((responding: boolean) => {
setIsResponding(responding)
}, [setIsResponding])
const handleStop = useCallback(() => {
const {
activeTaskId,
suggestedQuestionsAbortController,
workflowRunningData,
setWorkflowRunningData,
} = workflowStore.getState()
const runningStatus = workflowRunningData?.result?.status
const isActiveRun = runningStatus === WorkflowRunningStatus.Running || runningStatus === WorkflowRunningStatus.Waiting
setHasStopResponded(true)
handleResponding(false)
if (stopChat && activeTaskId)
stopChat(activeTaskId)
setIterTimes(DEFAULT_ITER_TIMES)
setLoopTimes(DEFAULT_LOOP_TIMES)
if (suggestedQuestionsAbortController)
suggestedQuestionsAbortController.abort()
setSuggestedQuestionsAbortController(null)
setActiveTaskId('')
invalidateRun()
if (isActiveRun && workflowRunningData) {
setWorkflowRunningData({
...workflowRunningData,
result: {
...workflowRunningData.result,
status: WorkflowRunningStatus.Stopped,
},
})
}
if (isActiveRun) {
handleNodeCancelRunningStatus()
handleEdgeCancelRunningStatus()
}
}, [
handleResponding,
setIterTimes,
setLoopTimes,
stopChat,
workflowStore,
setHasStopResponded,
setSuggestedQuestionsAbortController,
setActiveTaskId,
invalidateRun,
handleNodeCancelRunningStatus,
handleEdgeCancelRunningStatus,
])
const handleRestart = useCallback(() => {
handleStop()
resetChatPreview()
setIterTimes(DEFAULT_ITER_TIMES)
setLoopTimes(DEFAULT_LOOP_TIMES)
}, [handleStop, setIterTimes, setLoopTimes, resetChatPreview])
return {
handleResponding,
handleStop,
handleRestart,
}
}

View File

@ -0,0 +1,65 @@
import type { InputForm } from '@/app/components/base/chat/chat/type'
import type { ChatItemInTree, Inputs } from '@/app/components/base/chat/types'
import { useCallback, useMemo } from 'react'
import { processOpeningStatement } from '@/app/components/base/chat/chat/utils'
import { getThreadMessages } from '@/app/components/base/chat/utils'
type UseChatListParams = {
chatTree: ChatItemInTree[]
targetMessageId: string | undefined
config: {
opening_statement?: string
suggested_questions?: string[]
} | undefined
formSettings?: {
inputs: Inputs
inputsForm: InputForm[]
}
}
export function useChatList({
chatTree,
targetMessageId,
config,
formSettings,
}: UseChatListParams) {
const threadMessages = useMemo(
() => getThreadMessages(chatTree, targetMessageId),
[chatTree, targetMessageId],
)
const getIntroduction = useCallback((str: string) => {
return processOpeningStatement(str, formSettings?.inputs || {}, formSettings?.inputsForm || [])
}, [formSettings?.inputs, formSettings?.inputsForm])
const chatList = useMemo(() => {
const ret = [...threadMessages]
if (config?.opening_statement) {
const index = threadMessages.findIndex(item => item.isOpeningStatement)
if (index > -1) {
ret[index] = {
...ret[index],
content: getIntroduction(config.opening_statement),
suggestedQuestions: config.suggested_questions?.map((item: string) => getIntroduction(item)),
}
}
else {
ret.unshift({
id: `${Date.now()}`,
content: getIntroduction(config.opening_statement),
isAnswer: true,
isOpeningStatement: true,
suggestedQuestions: config.suggested_questions?.map((item: string) => getIntroduction(item)),
})
}
}
return ret
}, [threadMessages, config?.opening_statement, getIntroduction, config?.suggested_questions])
return {
threadMessages,
chatList,
getIntroduction,
}
}

View File

@ -0,0 +1,306 @@
import type { SendCallback, SendParams, UpdateCurrentQAParams } from './types'
import type { InputForm } from '@/app/components/base/chat/chat/type'
import type { ChatItem, ChatItemInTree, Inputs } from '@/app/components/base/chat/types'
import { uniqBy } from 'es-toolkit/compat'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { getProcessedInputs } from '@/app/components/base/chat/chat/utils'
import { getProcessedFiles, getProcessedFilesFromResponse } from '@/app/components/base/file-uploader/utils'
import { useToastContext } from '@/app/components/base/toast'
import { useInvalidAllLastRun } from '@/service/use-workflow'
import { TransferMethod } from '@/types/app'
import { useSetWorkflowVarsWithValue, useWorkflowRun } from '../../../hooks'
import { useHooksStore } from '../../../hooks-store'
import { useStore, useWorkflowStore } from '../../../store'
import { createWorkflowEventHandlers } from './use-workflow-event-handlers'
type UseChatMessageSenderParams = {
threadMessages: ChatItemInTree[]
config?: {
suggested_questions_after_answer?: {
enabled?: boolean
}
}
formSettings?: {
inputs: Inputs
inputsForm: InputForm[]
}
handleResponding: (responding: boolean) => void
updateCurrentQAOnTree: (params: UpdateCurrentQAParams) => void
}
export function useChatMessageSender({
threadMessages,
config,
formSettings,
handleResponding,
updateCurrentQAOnTree,
}: UseChatMessageSenderParams) {
const { t } = useTranslation()
const { notify } = useToastContext()
const { handleRun } = useWorkflowRun()
const workflowStore = useWorkflowStore()
const configsMap = useHooksStore(s => s.configsMap)
const invalidAllLastRun = useInvalidAllLastRun(configsMap?.flowType, configsMap?.flowId)
const { fetchInspectVars } = useSetWorkflowVarsWithValue()
const setConversationId = useStore(s => s.setConversationId)
const setTargetMessageId = useStore(s => s.setTargetMessageId)
const setSuggestedQuestions = useStore(s => s.setSuggestedQuestions)
const setActiveTaskId = useStore(s => s.setActiveTaskId)
const setSuggestedQuestionsAbortController = useStore(s => s.setSuggestedQuestionsAbortController)
const startRun = useStore(s => s.startRun)
const handleSend = useCallback((
params: SendParams,
{ onGetSuggestedQuestions }: SendCallback,
) => {
if (workflowStore.getState().isResponding) {
notify({ type: 'info', message: t('errorMessage.waitForResponse', { ns: 'appDebug' }) })
return false
}
const { suggestedQuestionsAbortController } = workflowStore.getState()
if (suggestedQuestionsAbortController)
suggestedQuestionsAbortController.abort()
setSuggestedQuestionsAbortController(null)
const runId = startRun()
const isCurrentRun = () => runId === workflowStore.getState().activeRunId
const parentMessage = threadMessages.find(item => item.id === params.parent_message_id)
const placeholderQuestionId = `question-${Date.now()}`
const questionItem: ChatItem = {
id: placeholderQuestionId,
content: params.query,
isAnswer: false,
message_files: params.files,
parentMessageId: params.parent_message_id,
}
const siblingIndex = parentMessage?.children?.length ?? workflowStore.getState().chatTree.length
const placeholderAnswerId = `answer-placeholder-${Date.now()}`
const placeholderAnswerItem: ChatItem = {
id: placeholderAnswerId,
content: '',
isAnswer: true,
parentMessageId: questionItem.id,
siblingIndex,
}
setTargetMessageId(parentMessage?.id)
updateCurrentQAOnTree({
parentId: params.parent_message_id,
responseItem: placeholderAnswerItem,
placeholderQuestionId,
questionItem,
})
const responseItem: ChatItem = {
id: placeholderAnswerId,
content: '',
agent_thoughts: [],
message_files: [],
isAnswer: true,
parentMessageId: questionItem.id,
siblingIndex,
}
handleResponding(true)
const { files, inputs, ...restParams } = params
const bodyParams = {
files: getProcessedFiles(files || []),
inputs: getProcessedInputs(inputs || {}, formSettings?.inputsForm || []),
...restParams,
}
if (bodyParams?.files?.length) {
bodyParams.files = bodyParams.files.map((item) => {
if (item.transfer_method === TransferMethod.local_file) {
return {
...item,
url: '',
}
}
return item
})
}
let hasSetResponseId = false
const workflowHandlers = createWorkflowEventHandlers({
responseItem,
questionItem,
placeholderQuestionId,
parentMessageId: params.parent_message_id,
updateCurrentQAOnTree,
})
handleRun(
bodyParams,
{
onData: (message: string, isFirstMessage: boolean, { conversationId: newConversationId, messageId, taskId }) => {
if (!isCurrentRun())
return
responseItem.content = responseItem.content + message
if (messageId && !hasSetResponseId) {
questionItem.id = `question-${messageId}`
responseItem.id = messageId
responseItem.parentMessageId = questionItem.id
hasSetResponseId = true
}
if (isFirstMessage && newConversationId)
setConversationId(newConversationId)
if (taskId)
setActiveTaskId(taskId)
if (messageId)
responseItem.id = messageId
updateCurrentQAOnTree({
placeholderQuestionId,
questionItem,
responseItem,
parentId: params.parent_message_id,
})
},
async onCompleted(hasError?: boolean, errorMessage?: string) {
if (!isCurrentRun())
return
handleResponding(false)
fetchInspectVars({})
invalidAllLastRun()
if (hasError) {
if (errorMessage) {
responseItem.content = errorMessage
responseItem.isError = true
updateCurrentQAOnTree({
placeholderQuestionId,
questionItem,
responseItem,
parentId: params.parent_message_id,
})
}
return
}
if (config?.suggested_questions_after_answer?.enabled && !workflowStore.getState().hasStopResponded && onGetSuggestedQuestions) {
try {
const result = await onGetSuggestedQuestions(
responseItem.id,
newAbortController => setSuggestedQuestionsAbortController(newAbortController),
) as { data: string[] }
setSuggestedQuestions(result.data)
}
catch {
setSuggestedQuestions([])
}
finally {
setSuggestedQuestionsAbortController(null)
}
}
},
onMessageEnd: (messageEnd) => {
if (!isCurrentRun())
return
responseItem.citation = messageEnd.metadata?.retriever_resources || []
const processedFilesFromResponse = getProcessedFilesFromResponse(messageEnd.files || [])
responseItem.allFiles = uniqBy([...(responseItem.allFiles || []), ...(processedFilesFromResponse || [])], 'id')
updateCurrentQAOnTree({
placeholderQuestionId,
questionItem,
responseItem,
parentId: params.parent_message_id,
})
},
onMessageReplace: (messageReplace) => {
if (!isCurrentRun())
return
responseItem.content = messageReplace.answer
},
onError() {
if (!isCurrentRun())
return
handleResponding(false)
},
onWorkflowStarted: (event) => {
if (!isCurrentRun())
return
const taskId = workflowHandlers.onWorkflowStarted(event)
if (taskId)
setActiveTaskId(taskId)
},
onWorkflowFinished: (event) => {
if (!isCurrentRun())
return
workflowHandlers.onWorkflowFinished(event)
},
onIterationStart: (event) => {
if (!isCurrentRun())
return
workflowHandlers.onIterationStart(event)
},
onIterationFinish: (event) => {
if (!isCurrentRun())
return
workflowHandlers.onIterationFinish(event)
},
onLoopStart: (event) => {
if (!isCurrentRun())
return
workflowHandlers.onLoopStart(event)
},
onLoopFinish: (event) => {
if (!isCurrentRun())
return
workflowHandlers.onLoopFinish(event)
},
onNodeStarted: (event) => {
if (!isCurrentRun())
return
workflowHandlers.onNodeStarted(event)
},
onNodeRetry: (event) => {
if (!isCurrentRun())
return
workflowHandlers.onNodeRetry(event)
},
onNodeFinished: (event) => {
if (!isCurrentRun())
return
workflowHandlers.onNodeFinished(event)
},
onAgentLog: (event) => {
if (!isCurrentRun())
return
workflowHandlers.onAgentLog(event)
},
},
)
}, [
threadMessages,
updateCurrentQAOnTree,
handleResponding,
formSettings?.inputsForm,
handleRun,
notify,
t,
config?.suggested_questions_after_answer?.enabled,
setTargetMessageId,
setConversationId,
setSuggestedQuestions,
setActiveTaskId,
setSuggestedQuestionsAbortController,
startRun,
fetchInspectVars,
invalidAllLastRun,
workflowStore,
])
return { handleSend }
}

View File

@ -0,0 +1,59 @@
import type { ChatTreeUpdater, UpdateCurrentQAParams } from './types'
import type { ChatItemInTree } from '@/app/components/base/chat/types'
import { produce } from 'immer'
import { useCallback } from 'react'
export function useChatTreeOperations(updateChatTree: ChatTreeUpdater) {
const produceChatTreeNode = useCallback(
(tree: ChatItemInTree[], targetId: string, operation: (node: ChatItemInTree) => void) => {
return produce(tree, (draft) => {
const queue: ChatItemInTree[] = [...draft]
while (queue.length > 0) {
const current = queue.shift()!
if (current.id === targetId) {
operation(current)
break
}
if (current.children)
queue.push(...current.children)
}
})
},
[],
)
const updateCurrentQAOnTree = useCallback(({
parentId,
responseItem,
placeholderQuestionId,
questionItem,
}: UpdateCurrentQAParams) => {
const currentQA = { ...questionItem, children: [{ ...responseItem, children: [] }] } as ChatItemInTree
updateChatTree((currentChatTree) => {
if (!parentId) {
const questionIndex = currentChatTree.findIndex(item => [placeholderQuestionId, questionItem.id].includes(item.id))
return produce(currentChatTree, (draft) => {
if (questionIndex === -1)
draft.push(currentQA)
else
draft[questionIndex] = currentQA
})
}
return produceChatTreeNode(currentChatTree, parentId, (parentNode) => {
if (!parentNode.children)
parentNode.children = []
const questionNodeIndex = parentNode.children.findIndex(item => [placeholderQuestionId, questionItem.id].includes(item.id))
if (questionNodeIndex === -1)
parentNode.children.push(currentQA)
else
parentNode.children[questionNodeIndex] = currentQA
})
})
}, [produceChatTreeNode, updateChatTree])
return {
produceChatTreeNode,
updateCurrentQAOnTree,
}
}

View File

@ -0,0 +1,74 @@
import type { ChatConfig } from './types'
import type { InputForm } from '@/app/components/base/chat/chat/type'
import type { ChatItemInTree, Inputs } from '@/app/components/base/chat/types'
import { useEffect, useRef } from 'react'
import { useStore } from '../../../store'
import { useChatFlowControl } from './use-chat-flow-control'
import { useChatList } from './use-chat-list'
import { useChatMessageSender } from './use-chat-message-sender'
import { useChatTreeOperations } from './use-chat-tree-operations'
export function useChat(
config: ChatConfig | undefined,
formSettings?: {
inputs: Inputs
inputsForm: InputForm[]
},
prevChatTree?: ChatItemInTree[],
stopChat?: (taskId: string) => void,
) {
const chatTree = useStore(s => s.chatTree)
const conversationId = useStore(s => s.conversationId)
const isResponding = useStore(s => s.isResponding)
const suggestedQuestions = useStore(s => s.suggestedQuestions)
const targetMessageId = useStore(s => s.targetMessageId)
const updateChatTree = useStore(s => s.updateChatTree)
const setTargetMessageId = useStore(s => s.setTargetMessageId)
const initialChatTreeRef = useRef(prevChatTree)
useEffect(() => {
const initialChatTree = initialChatTreeRef.current
if (!initialChatTree || initialChatTree.length === 0)
return
updateChatTree(currentChatTree => (currentChatTree.length === 0 ? initialChatTree : currentChatTree))
}, [updateChatTree])
const { updateCurrentQAOnTree } = useChatTreeOperations(updateChatTree)
const {
handleResponding,
handleStop,
handleRestart,
} = useChatFlowControl({
stopChat,
})
const {
threadMessages,
chatList,
} = useChatList({
chatTree,
targetMessageId,
config,
formSettings,
})
const { handleSend } = useChatMessageSender({
threadMessages,
config,
formSettings,
handleResponding,
updateCurrentQAOnTree,
})
return {
conversationId,
chatList,
setTargetMessageId,
handleSend,
handleStop,
handleRestart,
isResponding,
suggestedQuestions,
}
}

View File

@ -0,0 +1,133 @@
import type { UpdateCurrentQAParams } from './types'
import type { ChatItem } from '@/app/components/base/chat/types'
import type { AgentLogItem, NodeTracing } from '@/types/workflow'
import { NodeRunningStatus, WorkflowRunningStatus } from '../../../types'
type WorkflowEventHandlersContext = {
responseItem: ChatItem
questionItem: ChatItem
placeholderQuestionId: string
parentMessageId?: string
updateCurrentQAOnTree: (params: UpdateCurrentQAParams) => void
}
type TracingData = Partial<NodeTracing> & { id: string }
type AgentLogData = Partial<AgentLogItem> & { node_id: string, message_id: string }
export function createWorkflowEventHandlers(ctx: WorkflowEventHandlersContext) {
const { responseItem, questionItem, placeholderQuestionId, parentMessageId, updateCurrentQAOnTree } = ctx
const updateTree = () => {
updateCurrentQAOnTree({
placeholderQuestionId,
questionItem,
responseItem,
parentId: parentMessageId,
})
}
const updateTracingItem = (data: TracingData) => {
const currentTracingIndex = responseItem.workflowProcess!.tracing!.findIndex(item => item.id === data.id)
if (currentTracingIndex > -1) {
responseItem.workflowProcess!.tracing[currentTracingIndex] = {
...responseItem.workflowProcess!.tracing[currentTracingIndex],
...data,
}
updateTree()
}
}
return {
onWorkflowStarted: ({ workflow_run_id, task_id }: { workflow_run_id: string, task_id: string }) => {
responseItem.workflow_run_id = workflow_run_id
responseItem.workflowProcess = {
status: WorkflowRunningStatus.Running,
tracing: [],
}
updateTree()
return task_id
},
onWorkflowFinished: ({ data }: { data: { status: string } }) => {
responseItem.workflowProcess!.status = data.status as WorkflowRunningStatus
updateTree()
},
onIterationStart: ({ data }: { data: Partial<NodeTracing> }) => {
responseItem.workflowProcess!.tracing!.push({
...data,
status: NodeRunningStatus.Running,
} as NodeTracing)
updateTree()
},
onIterationFinish: ({ data }: { data: TracingData }) => {
updateTracingItem(data)
},
onLoopStart: ({ data }: { data: Partial<NodeTracing> }) => {
responseItem.workflowProcess!.tracing!.push({
...data,
status: NodeRunningStatus.Running,
} as NodeTracing)
updateTree()
},
onLoopFinish: ({ data }: { data: TracingData }) => {
updateTracingItem(data)
},
onNodeStarted: ({ data }: { data: Partial<NodeTracing> }) => {
responseItem.workflowProcess!.tracing!.push({
...data,
status: NodeRunningStatus.Running,
} as NodeTracing)
updateTree()
},
onNodeRetry: ({ data }: { data: NodeTracing }) => {
responseItem.workflowProcess!.tracing!.push(data)
updateTree()
},
onNodeFinished: ({ data }: { data: TracingData }) => {
updateTracingItem(data)
},
onAgentLog: ({ data }: { data: AgentLogData }) => {
const currentNodeIndex = responseItem.workflowProcess!.tracing!.findIndex(item => item.node_id === data.node_id)
if (currentNodeIndex > -1) {
const current = responseItem.workflowProcess!.tracing![currentNodeIndex]
if (current.execution_metadata) {
if (current.execution_metadata.agent_log) {
const currentLogIndex = current.execution_metadata.agent_log.findIndex(log => log.message_id === data.message_id)
if (currentLogIndex > -1) {
current.execution_metadata.agent_log[currentLogIndex] = {
...current.execution_metadata.agent_log[currentLogIndex],
...data,
} as AgentLogItem
}
else {
current.execution_metadata.agent_log.push(data as AgentLogItem)
}
}
else {
current.execution_metadata.agent_log = [data as AgentLogItem]
}
}
else {
current.execution_metadata = {
agent_log: [data as AgentLogItem],
} as NodeTracing['execution_metadata']
}
responseItem.workflowProcess!.tracing[currentNodeIndex] = {
...current,
}
updateTree()
}
},
}
}

View File

@ -0,0 +1,96 @@
import type { StateCreator } from 'zustand'
import type { ChatItemInTree } from '@/app/components/base/chat/types'
type ChatPreviewState = {
chatTree: ChatItemInTree[]
targetMessageId: string | undefined
suggestedQuestions: string[]
conversationId: string
isResponding: boolean
activeRunId: number
activeTaskId: string
hasStopResponded: boolean
suggestedQuestionsAbortController: AbortController | null
}
type ChatPreviewActions = {
setChatTree: (chatTree: ChatItemInTree[]) => void
updateChatTree: (updater: (chatTree: ChatItemInTree[]) => ChatItemInTree[]) => void
setTargetMessageId: (messageId: string | undefined) => void
setSuggestedQuestions: (questions: string[]) => void
setConversationId: (conversationId: string) => void
setIsResponding: (isResponding: boolean) => void
setActiveTaskId: (taskId: string) => void
setHasStopResponded: (hasStopResponded: boolean) => void
setSuggestedQuestionsAbortController: (controller: AbortController | null) => void
startRun: () => number
invalidateRun: () => number
resetChatPreview: () => void
}
export type ChatPreviewSliceShape = ChatPreviewState & ChatPreviewActions
const initialState: ChatPreviewState = {
chatTree: [],
targetMessageId: undefined,
suggestedQuestions: [],
conversationId: '',
isResponding: false,
activeRunId: 0,
activeTaskId: '',
hasStopResponded: false,
suggestedQuestionsAbortController: null,
}
export const createChatPreviewSlice: StateCreator<ChatPreviewSliceShape> = (set, get) => ({
...initialState,
setChatTree: chatTree => set({ chatTree }),
updateChatTree: updater => set((state) => {
const nextChatTree = updater(state.chatTree)
if (nextChatTree === state.chatTree)
return state
return { chatTree: nextChatTree }
}),
setTargetMessageId: targetMessageId => set({ targetMessageId }),
setSuggestedQuestions: suggestedQuestions => set({ suggestedQuestions }),
setConversationId: conversationId => set({ conversationId }),
setIsResponding: isResponding => set({ isResponding }),
setActiveTaskId: activeTaskId => set({ activeTaskId }),
setHasStopResponded: hasStopResponded => set({ hasStopResponded }),
setSuggestedQuestionsAbortController: suggestedQuestionsAbortController => set({ suggestedQuestionsAbortController }),
startRun: () => {
const activeRunId = get().activeRunId + 1
set({
activeRunId,
activeTaskId: '',
hasStopResponded: false,
suggestedQuestionsAbortController: null,
})
return activeRunId
},
invalidateRun: () => {
const activeRunId = get().activeRunId + 1
set({
activeRunId,
activeTaskId: '',
suggestedQuestionsAbortController: null,
})
return activeRunId
},
resetChatPreview: () => set(state => ({
...initialState,
activeRunId: state.activeRunId + 1,
})),
})

View File

@ -1,6 +1,7 @@
import type {
StateCreator,
} from 'zustand'
import type { ChatPreviewSliceShape } from './chat-preview-slice'
import type { ChatVariableSliceShape } from './chat-variable-slice'
import type { InspectVarsSliceShape } from './debug/inspect-vars-slice'
import type { EnvVariableSliceShape } from './env-variable-slice'
@ -22,6 +23,7 @@ import {
} from 'zustand'
import { createStore } from 'zustand/vanilla'
import { WorkflowContext } from '@/app/components/workflow/context'
import { createChatPreviewSlice } from './chat-preview-slice'
import { createChatVariableSlice } from './chat-variable-slice'
import { createInspectVarsSlice } from './debug/inspect-vars-slice'
import { createEnvVariableSlice } from './env-variable-slice'
@ -42,7 +44,8 @@ export type SliceFromInjection
& Partial<RagPipelineSliceShape>
export type Shape
= ChatVariableSliceShape
= ChatPreviewSliceShape
& ChatVariableSliceShape
& EnvVariableSliceShape
& FormSliceShape
& HelpLineSliceShape
@ -67,6 +70,7 @@ export const createWorkflowStore = (params: CreateWorkflowStoreParams) => {
const { injectWorkflowStoreSliceFn } = params || {}
return createStore<Shape>((...args) => ({
...createChatPreviewSlice(...args),
...createChatVariableSlice(...args),
...createEnvVariableSlice(...args),
...createFormSlice(...args),

View File

@ -1085,11 +1085,6 @@
"count": 2
}
},
"app/components/base/icons/icon-gallery.stories.tsx": {
"ts/no-explicit-any": {
"count": 1
}
},
"app/components/base/icons/utils.ts": {
"ts/no-explicit-any": {
"count": 3
@ -3774,11 +3769,6 @@
"count": 2
}
},
"app/components/workflow/panel/debug-and-preview/hooks.ts": {
"ts/no-explicit-any": {
"count": 7
}
},
"app/components/workflow/panel/env-panel/variable-modal.tsx": {
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 4

View File

@ -48,7 +48,7 @@
"analyze-component": "node ./scripts/analyze-component.js",
"refactor-component": "node ./scripts/refactor-component.js",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build",
"storybook:build": "storybook build",
"preinstall": "npx only-allow pnpm",
"analyze": "ANALYZE=true pnpm build",
"knip": "knip"

16
web/vite.config.ts Normal file
View File

@ -0,0 +1,16 @@
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import react from '@vitejs/plugin-react'
import { defineConfig } from 'vite'
import tsconfigPaths from 'vite-tsconfig-paths'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
export default defineConfig({
plugins: [tsconfigPaths(), react()],
resolve: {
alias: {
'~@': __dirname,
},
},
})

View File

@ -1,9 +1,7 @@
import react from '@vitejs/plugin-react'
import tsconfigPaths from 'vite-tsconfig-paths'
import { defineConfig } from 'vitest/config'
import { defineConfig, mergeConfig } from 'vitest/config'
import viteConfig from './vite.config'
export default defineConfig({
plugins: [tsconfigPaths(), react()],
export default mergeConfig(viteConfig, defineConfig({
test: {
environment: 'jsdom',
globals: true,
@ -13,4 +11,4 @@ export default defineConfig({
reporter: ['text', 'json', 'json-summary'],
},
},
})
}))