Compare commits

..

69 Commits

Author SHA1 Message Date
4e037d14d1 Merge branch 'main' into feat/memory-orchestration-fed 2025-12-02 10:59:54 +08:00
08ca4e3e8b Merge branch 'main' into feat/memory-orchestration-fed 2025-11-28 12:01:11 +08:00
0998fab282 Merge branch 'main' into feat/memory-orchestration-fed 2025-11-18 10:05:39 +08:00
eb536956ef Merge branch 'main' into feat/memory-orchestration-fed 2025-11-17 10:10:02 +08:00
338e10424c Merge branch 'main' into feat/memory-orchestration-fed 2025-11-14 10:00:01 +08:00
c2ce2be102 merge main 2025-11-13 11:23:51 +08:00
b7311bf1d3 Merge branch 'main' into feat/memory-orchestration-fed 2025-11-10 10:47:28 +08:00
4050ed8c8e fix 2025-11-10 10:46:58 +08:00
2f0076166a Merge branch 'main' into feat/memory-orchestration-fed 2025-11-06 15:50:38 +08:00
896a29b836 fix 2025-11-06 15:49:50 +08:00
7cef0fff89 fix: 2025-11-05 18:28:17 +08:00
9d310fed92 fix: memory variable item 2025-11-04 17:53:51 +08:00
2944f831e2 Merge branch 'main' into feat/memory-orchestration-fed 2025-11-04 15:05:56 +08:00
10f5270320 feat: memory 2025-10-28 18:27:59 +08:00
38d27100ac Merge branch 'main' into feat/memory-orchestration-fed 2025-10-28 14:04:06 +08:00
9efbfdda55 Merge branch 'main' into feat/memory-orchestration-fed 2025-10-24 10:40:30 +08:00
7e2d16a518 feat: memory 2025-10-24 10:39:27 +08:00
6b2d460023 feat: memory 2025-10-22 16:28:08 +08:00
1cf7007fb4 fix: form validator 2025-10-22 11:38:46 +08:00
828b9c6a93 Merge branch 'main' into feat/memory-orchestration-fed 2025-10-21 13:27:10 +08:00
c8188274a2 Merge branch 'main' into feat/memory-orchestration-fed 2025-10-21 13:01:37 +08:00
d1d419e2b0 merge main 2025-10-20 14:11:20 +08:00
e90086c2d2 add memory variable 2025-10-16 18:30:15 +08:00
1c3ff179f8 use setMemoryVariables 2025-10-15 16:44:15 +08:00
8a348615bf Merge branch 'main' into feat/memory-orchestration-fed 2025-10-15 16:20:00 +08:00
2a27d81553 add memory variable 2025-10-15 16:15:16 +08:00
6b6ab5e034 merge main 2025-10-14 10:26:12 +08:00
50a7d93ddc memory in embedded chatbot 2025-10-12 13:22:04 +08:00
61e4bc6b17 memory in web app 2025-10-12 13:13:33 +08:00
0e545d7be5 Merge branch 'main' into feat/memory-orchestration-fed 2025-10-11 14:08:09 +08:00
eddc39cd0a fix: error import 2025-09-24 10:45:11 +08:00
efcaa2bbbd support create memory var in memory popup 2025-09-23 17:42:53 +08:00
05c05bb6d0 empty state 2025-09-23 16:24:15 +08:00
57624979e4 select memory var in popup 2025-09-23 16:07:46 +08:00
7e9375ce7e fix 2025-09-23 10:45:05 +08:00
0b1445aed5 memory popup 2025-09-23 10:20:15 +08:00
f6623423dd memory var sort 2025-09-23 10:20:09 +08:00
77b7bf9d47 memory 2025-09-22 18:00:05 +08:00
b0dd7d303d merge main 2025-09-22 14:45:17 +08:00
f8097b20c8 fix: memory type 2025-09-22 14:43:04 +08:00
257e43e2c2 add memory icon in editor block 2025-09-15 18:40:08 +08:00
55f146f527 node selector 2025-09-15 16:30:25 +08:00
2cd9d1066f node selector of base field 2025-09-15 15:39:46 +08:00
612112c919 add memory variable in llm node 2025-09-12 16:27:20 +08:00
df502e0d79 update memory by SSE in preview 2025-09-12 14:35:07 +08:00
27d6fee1ed merge main 2025-09-05 14:22:49 +08:00
35c116906e fix node memory 2025-09-02 17:30:00 +08:00
e9685a38cb merge main 2025-09-02 16:32:25 +08:00
a7156244f7 llm node 2025-08-28 10:53:29 +08:00
01f0ee339e form 2025-08-26 18:29:58 +08:00
158da1ce6e merge main 2025-08-26 16:11:07 +08:00
761ad336e9 form 2025-08-26 16:07:56 +08:00
239e15eac6 Merge branch 'main' into feat/memory-orchestration-fed 2025-08-25 14:05:56 +08:00
2acee44071 Merge branch 'main' into feat/memory-orchestration-fed 2025-08-21 14:28:16 +08:00
944b2ff22d form 2025-08-14 15:58:50 +08:00
a5dfc09e90 Merge branch 'main' into feat/memory-orchestration-fed 2025-08-14 15:04:56 +08:00
8266dc1dcc form 2025-08-13 10:12:17 +08:00
edeea0a8b9 Merge branch 'main' into feat/memory-orchestration-fed 2025-08-12 13:44:59 +08:00
dfc17f3b08 Merge branch 'main' into feat/memory-orchestration-fed 2025-08-11 10:38:43 +08:00
bca0b7c087 form 2025-08-08 17:21:52 +08:00
985becbc41 Merge branch 'main' into feat/memory-orchestration-fed 2025-08-08 13:45:47 +08:00
c90ab89323 embedded chat memory UI 2025-07-31 16:54:39 +08:00
8547d4b1ff memory panel in mobile 2025-07-31 16:18:19 +08:00
fbcfebbba1 memory edit modal 2025-07-31 15:14:37 +08:00
f827f8dc63 memory card operation 2025-07-31 14:32:35 +08:00
fef9907af3 memory card 2025-07-31 12:07:48 +08:00
e3b7f8afdd memory list 2025-07-30 17:00:04 +08:00
dceac39078 memory panel 2025-07-30 15:58:57 +08:00
58fd2f7a2f add chat memory action button 2025-07-30 14:42:46 +08:00
127 changed files with 4662 additions and 2760 deletions

View File

@ -1004,11 +1004,6 @@ class RagPipelineRecommendedPluginApi(Resource):
@login_required @login_required
@account_initialization_required @account_initialization_required
def get(self): def get(self):
parser = reqparse.RequestParser()
parser.add_argument('type', type=str, location='args', required=False, default='all')
args = parser.parse_args()
type = args["type"]
rag_pipeline_service = RagPipelineService() rag_pipeline_service = RagPipelineService()
recommended_plugins = rag_pipeline_service.get_recommended_plugins(type) recommended_plugins = rag_pipeline_service.get_recommended_plugins()
return recommended_plugins return recommended_plugins

View File

@ -1,64 +0,0 @@
"""Alter table pipeline_recommended_plugins add column type
Revision ID: 6bb0832495f0
Revises: 7bb281b7a422
Create Date: 2025-12-15 16:14:38.482072
"""
from alembic import op
import models as models
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = '6bb0832495f0'
down_revision = '7bb281b7a422'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('app_triggers', schema=None) as batch_op:
batch_op.alter_column('provider_name',
existing_type=sa.VARCHAR(length=255),
nullable=False,
existing_server_default=sa.text("''::character varying"))
with op.batch_alter_table('operation_logs', schema=None) as batch_op:
batch_op.alter_column('content',
existing_type=postgresql.JSON(astext_type=sa.Text()),
nullable=False)
with op.batch_alter_table('pipeline_recommended_plugins', schema=None) as batch_op:
batch_op.add_column(sa.Column('type', sa.String(length=50), nullable=True))
with op.batch_alter_table('providers', schema=None) as batch_op:
batch_op.alter_column('quota_used',
existing_type=sa.BIGINT(),
nullable=False)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('providers', schema=None) as batch_op:
batch_op.alter_column('quota_used',
existing_type=sa.BIGINT(),
nullable=True)
with op.batch_alter_table('pipeline_recommended_plugins', schema=None) as batch_op:
batch_op.drop_column('type')
with op.batch_alter_table('operation_logs', schema=None) as batch_op:
batch_op.alter_column('content',
existing_type=postgresql.JSON(astext_type=sa.Text()),
nullable=True)
with op.batch_alter_table('app_triggers', schema=None) as batch_op:
batch_op.alter_column('provider_name',
existing_type=sa.VARCHAR(length=255),
nullable=True,
existing_server_default=sa.text("''::character varying"))
# ### end Alembic commands ###

View File

@ -1458,7 +1458,6 @@ class PipelineRecommendedPlugin(TypeBase):
) )
plugin_id: Mapped[str] = mapped_column(LongText, nullable=False) plugin_id: Mapped[str] = mapped_column(LongText, nullable=False)
provider_name: Mapped[str] = mapped_column(LongText, nullable=False) provider_name: Mapped[str] = mapped_column(LongText, nullable=False)
type: Mapped[str] = mapped_column(sa.String(50), nullable=True)
position: Mapped[int] = mapped_column(sa.Integer, nullable=False, default=0) position: Mapped[int] = mapped_column(sa.Integer, nullable=False, default=0)
active: Mapped[bool] = mapped_column(sa.Boolean, nullable=False, default=True) active: Mapped[bool] = mapped_column(sa.Boolean, nullable=False, default=True)
created_at: Mapped[datetime] = mapped_column( created_at: Mapped[datetime] = mapped_column(

View File

@ -19,7 +19,7 @@ class StringUUID(TypeDecorator[uuid.UUID | str | None]):
def process_bind_param(self, value: uuid.UUID | str | None, dialect: Dialect) -> str | None: def process_bind_param(self, value: uuid.UUID | str | None, dialect: Dialect) -> str | None:
if value is None: if value is None:
return value return value
elif dialect.name in ["postgresql", "mysql"]: elif dialect.name == "postgresql":
return str(value) return str(value)
else: else:
if isinstance(value, uuid.UUID): if isinstance(value, uuid.UUID):

View File

@ -907,29 +907,19 @@ class WorkflowNodeExecutionModel(Base): # This model is expected to have `offlo
@property @property
def extras(self) -> dict[str, Any]: def extras(self) -> dict[str, Any]:
from core.tools.tool_manager import ToolManager from core.tools.tool_manager import ToolManager
from core.trigger.trigger_manager import TriggerManager
extras: dict[str, Any] = {} extras: dict[str, Any] = {}
execution_metadata = self.execution_metadata_dict if self.execution_metadata_dict:
if execution_metadata: if self.node_type == NodeType.TOOL and "tool_info" in self.execution_metadata_dict:
if self.node_type == NodeType.TOOL and "tool_info" in execution_metadata: tool_info: dict[str, Any] = self.execution_metadata_dict["tool_info"]
tool_info: dict[str, Any] = execution_metadata["tool_info"]
extras["icon"] = ToolManager.get_tool_icon( extras["icon"] = ToolManager.get_tool_icon(
tenant_id=self.tenant_id, tenant_id=self.tenant_id,
provider_type=tool_info["provider_type"], provider_type=tool_info["provider_type"],
provider_id=tool_info["provider_id"], provider_id=tool_info["provider_id"],
) )
elif self.node_type == NodeType.DATASOURCE and "datasource_info" in execution_metadata: elif self.node_type == NodeType.DATASOURCE and "datasource_info" in self.execution_metadata_dict:
datasource_info = execution_metadata["datasource_info"] datasource_info = self.execution_metadata_dict["datasource_info"]
extras["icon"] = datasource_info.get("icon") extras["icon"] = datasource_info.get("icon")
elif self.node_type == NodeType.TRIGGER_PLUGIN and "trigger_info" in execution_metadata:
trigger_info = execution_metadata["trigger_info"] or {}
provider_id = trigger_info.get("provider_id")
if provider_id:
extras["icon"] = TriggerManager.get_trigger_plugin_icon(
tenant_id=self.tenant_id,
provider_id=provider_id,
)
return extras return extras
def _get_offload_by_type(self, type_: ExecutionOffLoadType) -> Optional["WorkflowNodeExecutionOffload"]: def _get_offload_by_type(self, type_: ExecutionOffLoadType) -> Optional["WorkflowNodeExecutionOffload"]:

View File

@ -1248,14 +1248,12 @@ class RagPipelineService:
session.commit() session.commit()
return workflow_node_execution_db_model return workflow_node_execution_db_model
def get_recommended_plugins(self, type: str) -> dict: def get_recommended_plugins(self) -> dict:
# Query active recommended plugins # Query active recommended plugins
query = db.session.query(PipelineRecommendedPlugin).where(PipelineRecommendedPlugin.active == True)
if type and type != "all":
query = query.where(PipelineRecommendedPlugin.type == type)
pipeline_recommended_plugins = ( pipeline_recommended_plugins = (
query.order_by(PipelineRecommendedPlugin.position.asc()) db.session.query(PipelineRecommendedPlugin)
.where(PipelineRecommendedPlugin.active == True)
.order_by(PipelineRecommendedPlugin.position.asc())
.all() .all()
) )

File diff suppressed because it is too large Load Diff

View File

@ -1,5 +1,6 @@
'use client' 'use client'
import { useState } from 'react' import { useState } from 'react'
import useSWR from 'swr'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { import {
RiGraduationCapFill, RiGraduationCapFill,
@ -22,9 +23,8 @@ import PremiumBadge from '@/app/components/base/premium-badge'
import { useGlobalPublicStore } from '@/context/global-public-context' import { useGlobalPublicStore } from '@/context/global-public-context'
import EmailChangeModal from './email-change-modal' import EmailChangeModal from './email-change-modal'
import { validPassword } from '@/config' import { validPassword } from '@/config'
import { fetchAppList } from '@/service/apps'
import type { App } from '@/types/app' import type { App } from '@/types/app'
import { useAppList } from '@/service/use-apps'
const titleClassName = ` const titleClassName = `
system-sm-semibold text-text-secondary system-sm-semibold text-text-secondary
@ -36,7 +36,7 @@ const descriptionClassName = `
export default function AccountPage() { export default function AccountPage() {
const { t } = useTranslation() const { t } = useTranslation()
const { systemFeatures } = useGlobalPublicStore() const { systemFeatures } = useGlobalPublicStore()
const { data: appList } = useAppList({ page: 1, limit: 100, name: '' }) const { data: appList } = useSWR({ url: '/apps', params: { page: 1, limit: 100, name: '' } }, fetchAppList)
const apps = appList?.data || [] const apps = appList?.data || []
const { mutateUserProfile, userProfile } = useAppContext() const { mutateUserProfile, userProfile } = useAppContext()
const { isEducationAccount } = useProviderContext() const { isEducationAccount } = useProviderContext()

View File

@ -52,6 +52,7 @@ export type IGetAutomaticResProps = {
editorId?: string editorId?: string
currentPrompt?: string currentPrompt?: string
isBasicMode?: boolean isBasicMode?: boolean
hideTryIt?: boolean
} }
const TryLabel: FC<{ const TryLabel: FC<{
@ -80,6 +81,7 @@ const GetAutomaticRes: FC<IGetAutomaticResProps> = ({
currentPrompt, currentPrompt,
isBasicMode, isBasicMode,
onFinished, onFinished,
hideTryIt,
}) => { }) => {
const { t } = useTranslation() const { t } = useTranslation()
const localModel = localStorage.getItem('auto-gen-model') const localModel = localStorage.getItem('auto-gen-model')
@ -305,7 +307,7 @@ const GetAutomaticRes: FC<IGetAutomaticResProps> = ({
hideDebugWithMultipleModel hideDebugWithMultipleModel
/> />
</div> </div>
{isBasicMode && ( {isBasicMode && !hideTryIt && (
<div className='mt-4'> <div className='mt-4'>
<div className='flex items-center'> <div className='flex items-center'>
<div className='mr-3 shrink-0 text-xs font-semibold uppercase leading-[18px] text-text-tertiary'>{t('appDebug.generate.tryIt')}</div> <div className='mr-3 shrink-0 text-xs font-semibold uppercase leading-[18px] text-text-tertiary'>{t('appDebug.generate.tryIt')}</div>

View File

@ -3,6 +3,7 @@ import type { FC } from 'react'
import React from 'react' import React from 'react'
import ReactECharts from 'echarts-for-react' import ReactECharts from 'echarts-for-react'
import type { EChartsOption } from 'echarts' import type { EChartsOption } from 'echarts'
import useSWR from 'swr'
import type { Dayjs } from 'dayjs' import type { Dayjs } from 'dayjs'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { get } from 'lodash-es' import { get } from 'lodash-es'
@ -12,20 +13,7 @@ import { formatNumber } from '@/utils/format'
import Basic from '@/app/components/app-sidebar/basic' import Basic from '@/app/components/app-sidebar/basic'
import Loading from '@/app/components/base/loading' import Loading from '@/app/components/base/loading'
import type { AppDailyConversationsResponse, AppDailyEndUsersResponse, AppDailyMessagesResponse, AppTokenCostsResponse } from '@/models/app' import type { AppDailyConversationsResponse, AppDailyEndUsersResponse, AppDailyMessagesResponse, AppTokenCostsResponse } from '@/models/app'
import { import { getAppDailyConversations, getAppDailyEndUsers, getAppDailyMessages, getAppStatistics, getAppTokenCosts, getWorkflowDailyConversations } from '@/service/apps'
useAppAverageResponseTime,
useAppAverageSessionInteractions,
useAppDailyConversations,
useAppDailyEndUsers,
useAppDailyMessages,
useAppSatisfactionRate,
useAppTokenCosts,
useAppTokensPerSecond,
useWorkflowAverageInteractions,
useWorkflowDailyConversations,
useWorkflowDailyTerminals,
useWorkflowTokenCosts,
} from '@/service/use-apps'
const valueFormatter = (v: string | number) => v const valueFormatter = (v: string | number) => v
const COLOR_TYPE_MAP = { const COLOR_TYPE_MAP = {
@ -284,8 +272,8 @@ const getDefaultChartData = ({ start, end, key = 'count' }: { start: string; end
export const MessagesChart: FC<IBizChartProps> = ({ id, period }) => { export const MessagesChart: FC<IBizChartProps> = ({ id, period }) => {
const { t } = useTranslation() const { t } = useTranslation()
const { data: response, isLoading } = useAppDailyMessages(id, period.query) const { data: response } = useSWR({ url: `/apps/${id}/statistics/daily-messages`, params: period.query }, getAppDailyMessages)
if (isLoading || !response) if (!response)
return <Loading /> return <Loading />
const noDataFlag = !response.data || response.data.length === 0 const noDataFlag = !response.data || response.data.length === 0
return <Chart return <Chart
@ -298,8 +286,8 @@ export const MessagesChart: FC<IBizChartProps> = ({ id, period }) => {
export const ConversationsChart: FC<IBizChartProps> = ({ id, period }) => { export const ConversationsChart: FC<IBizChartProps> = ({ id, period }) => {
const { t } = useTranslation() const { t } = useTranslation()
const { data: response, isLoading } = useAppDailyConversations(id, period.query) const { data: response } = useSWR({ url: `/apps/${id}/statistics/daily-conversations`, params: period.query }, getAppDailyConversations)
if (isLoading || !response) if (!response)
return <Loading /> return <Loading />
const noDataFlag = !response.data || response.data.length === 0 const noDataFlag = !response.data || response.data.length === 0
return <Chart return <Chart
@ -313,8 +301,8 @@ export const ConversationsChart: FC<IBizChartProps> = ({ id, period }) => {
export const EndUsersChart: FC<IBizChartProps> = ({ id, period }) => { export const EndUsersChart: FC<IBizChartProps> = ({ id, period }) => {
const { t } = useTranslation() const { t } = useTranslation()
const { data: response, isLoading } = useAppDailyEndUsers(id, period.query) const { data: response } = useSWR({ url: `/apps/${id}/statistics/daily-end-users`, id, params: period.query }, getAppDailyEndUsers)
if (isLoading || !response) if (!response)
return <Loading /> return <Loading />
const noDataFlag = !response.data || response.data.length === 0 const noDataFlag = !response.data || response.data.length === 0
return <Chart return <Chart
@ -327,8 +315,8 @@ export const EndUsersChart: FC<IBizChartProps> = ({ id, period }) => {
export const AvgSessionInteractions: FC<IBizChartProps> = ({ id, period }) => { export const AvgSessionInteractions: FC<IBizChartProps> = ({ id, period }) => {
const { t } = useTranslation() const { t } = useTranslation()
const { data: response, isLoading } = useAppAverageSessionInteractions(id, period.query) const { data: response } = useSWR({ url: `/apps/${id}/statistics/average-session-interactions`, params: period.query }, getAppStatistics)
if (isLoading || !response) if (!response)
return <Loading /> return <Loading />
const noDataFlag = !response.data || response.data.length === 0 const noDataFlag = !response.data || response.data.length === 0
return <Chart return <Chart
@ -343,8 +331,8 @@ export const AvgSessionInteractions: FC<IBizChartProps> = ({ id, period }) => {
export const AvgResponseTime: FC<IBizChartProps> = ({ id, period }) => { export const AvgResponseTime: FC<IBizChartProps> = ({ id, period }) => {
const { t } = useTranslation() const { t } = useTranslation()
const { data: response, isLoading } = useAppAverageResponseTime(id, period.query) const { data: response } = useSWR({ url: `/apps/${id}/statistics/average-response-time`, params: period.query }, getAppStatistics)
if (isLoading || !response) if (!response)
return <Loading /> return <Loading />
const noDataFlag = !response.data || response.data.length === 0 const noDataFlag = !response.data || response.data.length === 0
return <Chart return <Chart
@ -360,8 +348,8 @@ export const AvgResponseTime: FC<IBizChartProps> = ({ id, period }) => {
export const TokenPerSecond: FC<IBizChartProps> = ({ id, period }) => { export const TokenPerSecond: FC<IBizChartProps> = ({ id, period }) => {
const { t } = useTranslation() const { t } = useTranslation()
const { data: response, isLoading } = useAppTokensPerSecond(id, period.query) const { data: response } = useSWR({ url: `/apps/${id}/statistics/tokens-per-second`, params: period.query }, getAppStatistics)
if (isLoading || !response) if (!response)
return <Loading /> return <Loading />
const noDataFlag = !response.data || response.data.length === 0 const noDataFlag = !response.data || response.data.length === 0
return <Chart return <Chart
@ -378,8 +366,8 @@ export const TokenPerSecond: FC<IBizChartProps> = ({ id, period }) => {
export const UserSatisfactionRate: FC<IBizChartProps> = ({ id, period }) => { export const UserSatisfactionRate: FC<IBizChartProps> = ({ id, period }) => {
const { t } = useTranslation() const { t } = useTranslation()
const { data: response, isLoading } = useAppSatisfactionRate(id, period.query) const { data: response } = useSWR({ url: `/apps/${id}/statistics/user-satisfaction-rate`, params: period.query }, getAppStatistics)
if (isLoading || !response) if (!response)
return <Loading /> return <Loading />
const noDataFlag = !response.data || response.data.length === 0 const noDataFlag = !response.data || response.data.length === 0
return <Chart return <Chart
@ -396,8 +384,8 @@ export const UserSatisfactionRate: FC<IBizChartProps> = ({ id, period }) => {
export const CostChart: FC<IBizChartProps> = ({ id, period }) => { export const CostChart: FC<IBizChartProps> = ({ id, period }) => {
const { t } = useTranslation() const { t } = useTranslation()
const { data: response, isLoading } = useAppTokenCosts(id, period.query) const { data: response } = useSWR({ url: `/apps/${id}/statistics/token-costs`, params: period.query }, getAppTokenCosts)
if (isLoading || !response) if (!response)
return <Loading /> return <Loading />
const noDataFlag = !response.data || response.data.length === 0 const noDataFlag = !response.data || response.data.length === 0
return <Chart return <Chart
@ -410,8 +398,8 @@ export const CostChart: FC<IBizChartProps> = ({ id, period }) => {
export const WorkflowMessagesChart: FC<IBizChartProps> = ({ id, period }) => { export const WorkflowMessagesChart: FC<IBizChartProps> = ({ id, period }) => {
const { t } = useTranslation() const { t } = useTranslation()
const { data: response, isLoading } = useWorkflowDailyConversations(id, period.query) const { data: response } = useSWR({ url: `/apps/${id}/workflow/statistics/daily-conversations`, params: period.query }, getWorkflowDailyConversations)
if (isLoading || !response) if (!response)
return <Loading /> return <Loading />
const noDataFlag = !response.data || response.data.length === 0 const noDataFlag = !response.data || response.data.length === 0
return <Chart return <Chart
@ -426,8 +414,8 @@ export const WorkflowMessagesChart: FC<IBizChartProps> = ({ id, period }) => {
export const WorkflowDailyTerminalsChart: FC<IBizChartProps> = ({ id, period }) => { export const WorkflowDailyTerminalsChart: FC<IBizChartProps> = ({ id, period }) => {
const { t } = useTranslation() const { t } = useTranslation()
const { data: response, isLoading } = useWorkflowDailyTerminals(id, period.query) const { data: response } = useSWR({ url: `/apps/${id}/workflow/statistics/daily-terminals`, id, params: period.query }, getAppDailyEndUsers)
if (isLoading || !response) if (!response)
return <Loading /> return <Loading />
const noDataFlag = !response.data || response.data.length === 0 const noDataFlag = !response.data || response.data.length === 0
return <Chart return <Chart
@ -441,8 +429,8 @@ export const WorkflowDailyTerminalsChart: FC<IBizChartProps> = ({ id, period })
export const WorkflowCostChart: FC<IBizChartProps> = ({ id, period }) => { export const WorkflowCostChart: FC<IBizChartProps> = ({ id, period }) => {
const { t } = useTranslation() const { t } = useTranslation()
const { data: response, isLoading } = useWorkflowTokenCosts(id, period.query) const { data: response } = useSWR({ url: `/apps/${id}/workflow/statistics/token-costs`, params: period.query }, getAppTokenCosts)
if (isLoading || !response) if (!response)
return <Loading /> return <Loading />
const noDataFlag = !response.data || response.data.length === 0 const noDataFlag = !response.data || response.data.length === 0
return <Chart return <Chart
@ -455,8 +443,8 @@ export const WorkflowCostChart: FC<IBizChartProps> = ({ id, period }) => {
export const AvgUserInteractions: FC<IBizChartProps> = ({ id, period }) => { export const AvgUserInteractions: FC<IBizChartProps> = ({ id, period }) => {
const { t } = useTranslation() const { t } = useTranslation()
const { data: response, isLoading } = useWorkflowAverageInteractions(id, period.query) const { data: response } = useSWR({ url: `/apps/${id}/workflow/statistics/average-app-interactions`, params: period.query }, getAppStatistics)
if (isLoading || !response) if (!response)
return <Loading /> return <Loading />
const noDataFlag = !response.data || response.data.length === 0 const noDataFlag = !response.data || response.data.length === 0
return <Chart return <Chart

View File

@ -23,7 +23,7 @@ const Empty = () => {
return ( return (
<> <>
<DefaultCards /> <DefaultCards />
<div className='pointer-events-none absolute inset-0 z-20 flex items-center justify-center bg-gradient-to-t from-background-body to-transparent'> <div className='absolute inset-0 z-20 flex items-center justify-center bg-gradient-to-t from-background-body to-transparent pointer-events-none'>
<span className='system-md-medium text-text-tertiary'> <span className='system-md-medium text-text-tertiary'>
{t('app.newApp.noAppsFound')} {t('app.newApp.noAppsFound')}
</span> </span>

View File

@ -4,6 +4,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'
import { import {
useRouter, useRouter,
} from 'next/navigation' } from 'next/navigation'
import useSWRInfinite from 'swr/infinite'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useDebounceFn } from 'ahooks' import { useDebounceFn } from 'ahooks'
import { import {
@ -18,6 +19,8 @@ import AppCard from './app-card'
import NewAppCard from './new-app-card' import NewAppCard from './new-app-card'
import useAppsQueryState from './hooks/use-apps-query-state' import useAppsQueryState from './hooks/use-apps-query-state'
import { useDSLDragDrop } from './hooks/use-dsl-drag-drop' import { useDSLDragDrop } from './hooks/use-dsl-drag-drop'
import type { AppListResponse } from '@/models/app'
import { fetchAppList } from '@/service/apps'
import { useAppContext } from '@/context/app-context' import { useAppContext } from '@/context/app-context'
import { NEED_REFRESH_APP_LIST_KEY } from '@/config' import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
import { CheckModal } from '@/hooks/use-pay' import { CheckModal } from '@/hooks/use-pay'
@ -32,7 +35,6 @@ import Empty from './empty'
import Footer from './footer' import Footer from './footer'
import { useGlobalPublicStore } from '@/context/global-public-context' import { useGlobalPublicStore } from '@/context/global-public-context'
import { AppModeEnum } from '@/types/app' import { AppModeEnum } from '@/types/app'
import { useInfiniteAppList } from '@/service/use-apps'
const TagManagementModal = dynamic(() => import('@/app/components/base/tag-management'), { const TagManagementModal = dynamic(() => import('@/app/components/base/tag-management'), {
ssr: false, ssr: false,
@ -41,6 +43,30 @@ const CreateFromDSLModal = dynamic(() => import('@/app/components/app/create-fro
ssr: false, ssr: false,
}) })
const getKey = (
pageIndex: number,
previousPageData: AppListResponse,
activeTab: string,
isCreatedByMe: boolean,
tags: string[],
keywords: string,
) => {
if (!pageIndex || previousPageData.has_more) {
const params: any = { url: 'apps', params: { page: pageIndex + 1, limit: 30, name: keywords, is_created_by_me: isCreatedByMe } }
if (activeTab !== 'all')
params.params.mode = activeTab
else
delete params.params.mode
if (tags.length)
params.params.tag_ids = tags
return params
}
return null
}
const List = () => { const List = () => {
const { t } = useTranslation() const { t } = useTranslation()
const { systemFeatures } = useGlobalPublicStore() const { systemFeatures } = useGlobalPublicStore()
@ -76,24 +102,16 @@ const List = () => {
enabled: isCurrentWorkspaceEditor, enabled: isCurrentWorkspaceEditor,
}) })
const appListQueryParams = { const { data, isLoading, error, setSize, mutate } = useSWRInfinite(
page: 1, (pageIndex: number, previousPageData: AppListResponse) => getKey(pageIndex, previousPageData, activeTab, isCreatedByMe, tagIDs, searchKeywords),
limit: 30, fetchAppList,
name: searchKeywords, {
tag_ids: tagIDs, revalidateFirstPage: true,
is_created_by_me: isCreatedByMe, shouldRetryOnError: false,
...(activeTab !== 'all' ? { mode: activeTab as AppModeEnum } : {}), dedupingInterval: 500,
} errorRetryCount: 3,
},
const { )
data,
isLoading,
isFetchingNextPage,
fetchNextPage,
hasNextPage,
error,
refetch,
} = useInfiniteAppList(appListQueryParams, { enabled: !isCurrentWorkspaceDatasetOperator })
const anchorRef = useRef<HTMLDivElement>(null) const anchorRef = useRef<HTMLDivElement>(null)
const options = [ const options = [
@ -108,9 +126,9 @@ const List = () => {
useEffect(() => { useEffect(() => {
if (localStorage.getItem(NEED_REFRESH_APP_LIST_KEY) === '1') { if (localStorage.getItem(NEED_REFRESH_APP_LIST_KEY) === '1') {
localStorage.removeItem(NEED_REFRESH_APP_LIST_KEY) localStorage.removeItem(NEED_REFRESH_APP_LIST_KEY)
refetch() mutate()
} }
}, [refetch]) }, [mutate, t])
useEffect(() => { useEffect(() => {
if (isCurrentWorkspaceDatasetOperator) if (isCurrentWorkspaceDatasetOperator)
@ -118,9 +136,7 @@ const List = () => {
}, [router, isCurrentWorkspaceDatasetOperator]) }, [router, isCurrentWorkspaceDatasetOperator])
useEffect(() => { useEffect(() => {
if (isCurrentWorkspaceDatasetOperator) const hasMore = data?.at(-1)?.has_more ?? true
return
const hasMore = hasNextPage ?? true
let observer: IntersectionObserver | undefined let observer: IntersectionObserver | undefined
if (error) { if (error) {
@ -135,8 +151,8 @@ const List = () => {
const dynamicMargin = Math.max(100, Math.min(containerHeight * 0.2, 200)) // Clamps to 100-200px range, using 20% of container height as the base value const dynamicMargin = Math.max(100, Math.min(containerHeight * 0.2, 200)) // Clamps to 100-200px range, using 20% of container height as the base value
observer = new IntersectionObserver((entries) => { observer = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting && !isLoading && !isFetchingNextPage && !error && hasMore) if (entries[0].isIntersecting && !isLoading && !error && hasMore)
fetchNextPage() setSize((size: number) => size + 1)
}, { }, {
root: containerRef.current, root: containerRef.current,
rootMargin: `${dynamicMargin}px`, rootMargin: `${dynamicMargin}px`,
@ -145,7 +161,7 @@ const List = () => {
observer.observe(anchorRef.current) observer.observe(anchorRef.current)
} }
return () => observer?.disconnect() return () => observer?.disconnect()
}, [isLoading, isFetchingNextPage, fetchNextPage, error, hasNextPage, isCurrentWorkspaceDatasetOperator]) }, [isLoading, setSize, data, error])
const { run: handleSearch } = useDebounceFn(() => { const { run: handleSearch } = useDebounceFn(() => {
setSearchKeywords(keywords) setSearchKeywords(keywords)
@ -169,9 +185,6 @@ const List = () => {
setQuery(prev => ({ ...prev, isCreatedByMe: newValue })) setQuery(prev => ({ ...prev, isCreatedByMe: newValue }))
}, [isCreatedByMe, setQuery]) }, [isCreatedByMe, setQuery])
const pages = data?.pages ?? []
const hasAnyApp = (pages[0]?.total ?? 0) > 0
return ( return (
<> <>
<div ref={containerRef} className='relative flex h-0 shrink-0 grow flex-col overflow-y-auto bg-background-body'> <div ref={containerRef} className='relative flex h-0 shrink-0 grow flex-col overflow-y-auto bg-background-body'>
@ -204,17 +217,17 @@ const List = () => {
/> />
</div> </div>
</div> </div>
{hasAnyApp {(data && data[0].total > 0)
? <div className='relative grid grow grid-cols-1 content-start gap-4 px-12 pt-2 sm:grid-cols-1 md:grid-cols-2 xl:grid-cols-4 2xl:grid-cols-5 2k:grid-cols-6'> ? <div className='relative grid grow grid-cols-1 content-start gap-4 px-12 pt-2 sm:grid-cols-1 md:grid-cols-2 xl:grid-cols-4 2xl:grid-cols-5 2k:grid-cols-6'>
{isCurrentWorkspaceEditor {isCurrentWorkspaceEditor
&& <NewAppCard ref={newAppCardRef} onSuccess={refetch} selectedAppType={activeTab} />} && <NewAppCard ref={newAppCardRef} onSuccess={mutate} selectedAppType={activeTab} />}
{pages.map(({ data: apps }) => apps.map(app => ( {data.map(({ data: apps }) => apps.map(app => (
<AppCard key={app.id} app={app} onRefresh={refetch} /> <AppCard key={app.id} app={app} onRefresh={mutate} />
)))} )))}
</div> </div>
: <div className='relative grid grow grid-cols-1 content-start gap-4 overflow-hidden px-12 pt-2 sm:grid-cols-1 md:grid-cols-2 xl:grid-cols-4 2xl:grid-cols-5 2k:grid-cols-6'> : <div className='relative grid grow grid-cols-1 content-start gap-4 overflow-hidden px-12 pt-2 sm:grid-cols-1 md:grid-cols-2 xl:grid-cols-4 2xl:grid-cols-5 2k:grid-cols-6'>
{isCurrentWorkspaceEditor {isCurrentWorkspaceEditor
&& <NewAppCard ref={newAppCardRef} className='z-10' onSuccess={refetch} selectedAppType={activeTab} />} && <NewAppCard ref={newAppCardRef} className='z-10' onSuccess={mutate} selectedAppType={activeTab} />}
<Empty /> <Empty />
</div>} </div>}
@ -248,7 +261,7 @@ const List = () => {
onSuccess={() => { onSuccess={() => {
setShowCreateFromDSLModal(false) setShowCreateFromDSLModal(false)
setDroppedDSLFile(undefined) setDroppedDSLFile(undefined)
refetch() mutate()
}} }}
droppedFile={droppedDSLFile} droppedFile={droppedDSLFile}
/> />

View File

@ -7,6 +7,7 @@ import type {
ChatConfig, ChatConfig,
ChatItemInTree, ChatItemInTree,
Feedback, Feedback,
Memory,
} from '../types' } from '../types'
import type { ThemeBuilder } from '../embedded-chatbot/theme/theme-context' import type { ThemeBuilder } from '../embedded-chatbot/theme/theme-context'
import type { import type {
@ -60,6 +61,14 @@ export type ChatWithHistoryContextValue = {
name?: string name?: string
avatar_url?: string avatar_url?: string
} }
showChatMemory?: boolean
setShowChatMemory: (state: boolean) => void
memoryList: Memory[]
clearAllMemory: () => void
updateMemory: (memory: Memory, content: string) => void
resetDefault: (memory: Memory) => void
clearAllUpdateVersion: (memory: Memory) => void
switchMemoryVersion: (memory: Memory, version: string) => void
} }
export const ChatWithHistoryContext = createContext<ChatWithHistoryContextValue>({ export const ChatWithHistoryContext = createContext<ChatWithHistoryContextValue>({
@ -95,5 +104,13 @@ export const ChatWithHistoryContext = createContext<ChatWithHistoryContextValue>
setCurrentConversationInputs: noop, setCurrentConversationInputs: noop,
allInputsHidden: false, allInputsHidden: false,
initUserVariables: {}, initUserVariables: {},
showChatMemory: false,
setShowChatMemory: noop,
memoryList: [],
clearAllMemory: noop,
updateMemory: noop,
resetDefault: noop,
clearAllUpdateVersion: noop,
switchMemoryVersion: noop,
}) })
export const useChatWithHistoryContext = () => useContext(ChatWithHistoryContext) export const useChatWithHistoryContext = () => useContext(ChatWithHistoryContext)

View File

@ -28,6 +28,8 @@ const HeaderInMobile = () => {
handleRenameConversation, handleRenameConversation,
conversationRenaming, conversationRenaming,
inputsForms, inputsForms,
showChatMemory,
setShowChatMemory,
} = useChatWithHistoryContext() } = useChatWithHistoryContext()
const { t } = useTranslation() const { t } = useTranslation()
const isPin = pinnedConversationList.some(item => item.id === currentConversationId) const isPin = pinnedConversationList.some(item => item.id === currentConversationId)
@ -60,6 +62,9 @@ const HeaderInMobile = () => {
if (showRename) if (showRename)
handleRenameConversation(showRename.id, newName, { onSuccess: handleCancelRename }) handleRenameConversation(showRename.id, newName, { onSuccess: handleCancelRename })
}, [showRename, handleRenameConversation, handleCancelRename]) }, [showRename, handleRenameConversation, handleCancelRename])
const handleChatMemoryToggle = useCallback(() => {
setShowChatMemory(!showChatMemory)
}, [setShowChatMemory, showChatMemory])
const [showSidebar, setShowSidebar] = useState(false) const [showSidebar, setShowSidebar] = useState(false)
const [showChatSettings, setShowChatSettings] = useState(false) const [showChatSettings, setShowChatSettings] = useState(false)
@ -98,6 +103,7 @@ const HeaderInMobile = () => {
)} )}
</div> </div>
<MobileOperationDropdown <MobileOperationDropdown
handleChatMemoryToggle={handleChatMemoryToggle}
handleResetChat={handleNewConversation} handleResetChat={handleNewConversation}
handleViewChatSettings={() => setShowChatSettings(true)} handleViewChatSettings={() => setShowChatSettings(true)}
hideViewChatSettings={inputsForms.length < 1} hideViewChatSettings={inputsForms.length < 1}

View File

@ -1,10 +1,11 @@
import { useCallback, useState } from 'react' import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { import {
RiEditBoxLine, RiEditBoxLine,
RiLayoutRight2Line, RiLayoutRight2Line,
RiResetLeftLine, RiResetLeftLine,
} from '@remixicon/react' } from '@remixicon/react'
import { useTranslation } from 'react-i18next' import { Memory } from '@/app/components/base/icons/src/vender/line/others'
import { import {
useChatWithHistoryContext, useChatWithHistoryContext,
} from '../context' } from '../context'
@ -34,6 +35,8 @@ const Header = () => {
sidebarCollapseState, sidebarCollapseState,
handleSidebarCollapse, handleSidebarCollapse,
isResponding, isResponding,
showChatMemory,
setShowChatMemory,
} = useChatWithHistoryContext() } = useChatWithHistoryContext()
const { t } = useTranslation() const { t } = useTranslation()
const isSidebarCollapsed = sidebarCollapseState const isSidebarCollapsed = sidebarCollapseState
@ -70,6 +73,10 @@ const Header = () => {
handleRenameConversation(showRename.id, newName, { onSuccess: handleCancelRename }) handleRenameConversation(showRename.id, newName, { onSuccess: handleCancelRename })
}, [showRename, handleRenameConversation, handleCancelRename]) }, [showRename, handleRenameConversation, handleCancelRename])
const handleChatMemoryToggle = useCallback(() => {
setShowChatMemory(!showChatMemory)
}, [setShowChatMemory, showChatMemory])
return ( return (
<> <>
<div className='flex h-14 shrink-0 items-center justify-between p-3'> <div className='flex h-14 shrink-0 items-center justify-between p-3'>
@ -137,6 +144,15 @@ const Header = () => {
{currentConversationId && inputsForms.length > 0 && ( {currentConversationId && inputsForms.length > 0 && (
<ViewFormDropdown /> <ViewFormDropdown />
)} )}
{currentConversationId && (
<Tooltip
popupContent={t('share.chat.memory.actionButton')}
>
<ActionButton size='l' state={showChatMemory ? ActionButtonState.Active : ActionButtonState.Default} onClick={handleChatMemoryToggle}>
<Memory className='h-[18px] w-[18px]' />
</ActionButton>
</Tooltip>
)}
</div> </div>
</div> </div>
{!!showConfirm && ( {!!showConfirm && (

View File

@ -9,12 +9,14 @@ import ActionButton, { ActionButtonState } from '@/app/components/base/action-bu
type Props = { type Props = {
handleResetChat: () => void handleResetChat: () => void
handleViewChatSettings: () => void handleViewChatSettings: () => void
handleChatMemoryToggle?: () => void
hideViewChatSettings?: boolean hideViewChatSettings?: boolean
} }
const MobileOperationDropdown = ({ const MobileOperationDropdown = ({
handleResetChat, handleResetChat,
handleViewChatSettings, handleViewChatSettings,
handleChatMemoryToggle,
hideViewChatSettings = false, hideViewChatSettings = false,
}: Props) => { }: Props) => {
const { t } = useTranslation() const { t } = useTranslation()
@ -44,6 +46,9 @@ const MobileOperationDropdown = ({
<div className='system-md-regular flex cursor-pointer items-center space-x-1 rounded-lg px-3 py-1.5 text-text-secondary hover:bg-state-base-hover' onClick={handleResetChat}> <div className='system-md-regular flex cursor-pointer items-center space-x-1 rounded-lg px-3 py-1.5 text-text-secondary hover:bg-state-base-hover' onClick={handleResetChat}>
<span className='grow'>{t('share.chat.resetChat')}</span> <span className='grow'>{t('share.chat.resetChat')}</span>
</div> </div>
<div className='system-md-regular flex cursor-pointer items-center space-x-1 rounded-lg px-3 py-1.5 text-text-secondary hover:bg-state-base-hover' onClick={handleChatMemoryToggle}>
<span className='grow'>{t('share.chat.memory.actionButton')}</span>
</div>
{!hideViewChatSettings && ( {!hideViewChatSettings && (
<div className='system-md-regular flex cursor-pointer items-center space-x-1 rounded-lg px-3 py-1.5 text-text-secondary hover:bg-state-base-hover' onClick={handleViewChatSettings}> <div className='system-md-regular flex cursor-pointer items-center space-x-1 rounded-lg px-3 py-1.5 text-text-secondary hover:bg-state-base-hover' onClick={handleViewChatSettings}>
<span className='grow'>{t('share.chat.viewChatSettings')}</span> <span className='grow'>{t('share.chat.viewChatSettings')}</span>

View File

@ -21,8 +21,11 @@ import { addFileInfos, sortAgentSorts } from '../../../tools/utils'
import { getProcessedFilesFromResponse } from '@/app/components/base/file-uploader/utils' import { getProcessedFilesFromResponse } from '@/app/components/base/file-uploader/utils'
import { import {
delConversation, delConversation,
deleteMemory,
editMemory,
fetchChatList, fetchChatList,
fetchConversations, fetchConversations,
fetchMemories,
generationConversationName, generationConversationName,
pinConversation, pinConversation,
renameConversation, renameConversation,
@ -41,6 +44,9 @@ import { InputVarType } from '@/app/components/workflow/types'
import { TransferMethod } from '@/types/app' import { TransferMethod } from '@/types/app'
import { noop } from 'lodash-es' import { noop } from 'lodash-es'
import { useWebAppStore } from '@/context/web-app-context' import { useWebAppStore } from '@/context/web-app-context'
import type { Memory } from '@/app/components/base/chat/types'
import { mockMemoryList } from '@/app/components/base/chat/chat-with-history/memory/mock'
function getFormattedChatList(messages: any[]) { function getFormattedChatList(messages: any[]) {
const newChatList: ChatItem[] = [] const newChatList: ChatItem[] = []
@ -526,6 +532,61 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => {
notify({ type: 'success', message: t('common.api.success') }) notify({ type: 'success', message: t('common.api.success') })
}, [isInstalledApp, appId, t, notify]) }, [isInstalledApp, appId, t, notify])
const [showChatMemory, setShowChatMemory] = useState(false)
const [memoryList, setMemoryList] = useState<Memory[]>(mockMemoryList)
const getMemoryList = useCallback(async (currentConversationId: string) => {
const memories = await fetchMemories(currentConversationId, '', '', isInstalledApp, appId)
setMemoryList(memories)
}, [isInstalledApp, appId])
const clearAllMemory = useCallback(async () => {
await deleteMemory('', isInstalledApp, appId)
notify({ type: 'success', message: t('common.api.success') })
getMemoryList(currentConversationId)
}, [currentConversationId, getMemoryList])
const resetDefault = useCallback(async (memory: Memory) => {
try {
await editMemory(memory.spec.id, memory.spec.template, isInstalledApp, appId)
getMemoryList(currentConversationId)
}
catch (error) {
console.error('Failed to reset memory:', error)
}
}, [currentConversationId, getMemoryList, isInstalledApp, appId])
const clearAllUpdateVersion = useCallback(async (memory: Memory) => {
await deleteMemory(memory.spec.id, isInstalledApp, appId)
notify({ type: 'success', message: t('common.api.success') })
getMemoryList(currentConversationId)
}, [currentConversationId, getMemoryList])
const switchMemoryVersion = useCallback(async (memory: Memory, version: string) => {
const memories = await fetchMemories(currentConversationId, memory.spec.id, version, isInstalledApp, appId)
const newMemory = memories[0]
const newList = produce(memoryList, (draft) => {
const index = draft.findIndex(item => item.spec.id === memory.spec.id)
if (index !== -1)
draft[index] = newMemory
})
setMemoryList(newList)
}, [memoryList, currentConversationId, isInstalledApp, appId])
const updateMemory = useCallback(async (memory: Memory, content: string) => {
try {
await editMemory(memory.spec.id, content, isInstalledApp, appId)
getMemoryList(currentConversationId)
}
catch (error) {
console.error('Failed to reset memory:', error)
}
}, [getMemoryList, currentConversationId, isInstalledApp, appId])
useEffect(() => {
getMemoryList(currentConversationId)
}, [currentConversationId, getMemoryList])
return { return {
isInstalledApp, isInstalledApp,
appId, appId,
@ -572,5 +633,13 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => {
setCurrentConversationInputs, setCurrentConversationInputs,
allInputsHidden, allInputsHidden,
initUserVariables, initUserVariables,
showChatMemory,
setShowChatMemory,
memoryList,
clearAllMemory,
updateMemory,
resetDefault,
clearAllUpdateVersion,
switchMemoryVersion,
} }
} }

View File

@ -14,6 +14,7 @@ import Sidebar from './sidebar'
import Header from './header' import Header from './header'
import HeaderInMobile from './header-in-mobile' import HeaderInMobile from './header-in-mobile'
import ChatWrapper from './chat-wrapper' import ChatWrapper from './chat-wrapper'
import MemoryPanel from './memory'
import type { InstalledApp } from '@/models/explore' import type { InstalledApp } from '@/models/explore'
import Loading from '@/app/components/base/loading' import Loading from '@/app/components/base/loading'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints' import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
@ -33,6 +34,14 @@ const ChatWithHistory: FC<ChatWithHistoryProps> = ({
isMobile, isMobile,
themeBuilder, themeBuilder,
sidebarCollapseState, sidebarCollapseState,
showChatMemory,
setShowChatMemory,
memoryList,
clearAllMemory,
updateMemory,
resetDefault,
clearAllUpdateVersion,
switchMemoryVersion,
} = useChatWithHistoryContext() } = useChatWithHistoryContext()
const isSidebarCollapsed = sidebarCollapseState const isSidebarCollapsed = sidebarCollapseState
const customConfig = appData?.custom_config const customConfig = appData?.custom_config
@ -68,7 +77,7 @@ const ChatWithHistory: FC<ChatWithHistoryProps> = ({
{isMobile && ( {isMobile && (
<HeaderInMobile /> <HeaderInMobile />
)} )}
<div className={cn('relative grow p-2', isMobile && 'h-[calc(100%_-_56px)] p-0')}> <div className={cn('relative flex grow p-2', isMobile && 'h-[calc(100%_-_56px)] p-0')}>
{isSidebarCollapsed && ( {isSidebarCollapsed && (
<div <div
className={cn( className={cn(
@ -81,7 +90,11 @@ const ChatWithHistory: FC<ChatWithHistoryProps> = ({
<Sidebar isPanel panelVisible={showSidePanel} /> <Sidebar isPanel panelVisible={showSidePanel} />
</div> </div>
)} )}
<div className={cn('flex h-full flex-col overflow-hidden border-[0,5px] border-components-panel-border-subtle bg-chatbot-bg', isMobile ? 'rounded-t-2xl' : 'rounded-2xl')}> <div className={cn(
'flex h-full grow flex-col overflow-hidden border-[0,5px] border-components-panel-border-subtle bg-chatbot-bg',
isMobile ? 'rounded-t-2xl' : 'rounded-2xl',
showChatMemory && !isMobile && 'mr-1',
)}>
{!isMobile && <Header />} {!isMobile && <Header />}
{appChatListDataLoading && ( {appChatListDataLoading && (
<Loading type='app' /> <Loading type='app' />
@ -90,6 +103,38 @@ const ChatWithHistory: FC<ChatWithHistoryProps> = ({
<ChatWrapper key={chatShouldReloadKey} /> <ChatWrapper key={chatShouldReloadKey} />
)} )}
</div> </div>
{!isMobile && (
<MemoryPanel
isMobile={isMobile}
showChatMemory={showChatMemory}
setShowChatMemory={setShowChatMemory}
memoryList={memoryList}
clearAllMemory={clearAllMemory}
updateMemory={updateMemory}
resetDefault={resetDefault}
clearAllUpdateVersion={clearAllUpdateVersion}
switchMemoryVersion={switchMemoryVersion}
/>
)}
{isMobile && showChatMemory && (
<div className='fixed inset-0 z-50 flex flex-row-reverse bg-background-overlay p-1 backdrop-blur-sm'
onClick={() => setShowChatMemory(false)}
>
<div className='flex h-full w-[360px] rounded-xl shadow-lg' onClick={e => e.stopPropagation()}>
<MemoryPanel
isMobile={isMobile}
showChatMemory={showChatMemory}
setShowChatMemory={setShowChatMemory}
memoryList={memoryList}
clearAllMemory={clearAllMemory}
updateMemory={updateMemory}
resetDefault={resetDefault}
clearAllUpdateVersion={clearAllUpdateVersion}
switchMemoryVersion={switchMemoryVersion}
/>
</div>
</div>
)}
</div> </div>
</div> </div>
) )
@ -145,6 +190,14 @@ const ChatWithHistoryWrap: FC<ChatWithHistoryWrapProps> = ({
setCurrentConversationInputs, setCurrentConversationInputs,
allInputsHidden, allInputsHidden,
initUserVariables, initUserVariables,
showChatMemory,
setShowChatMemory,
memoryList,
clearAllMemory,
updateMemory,
resetDefault,
clearAllUpdateVersion,
switchMemoryVersion,
} = useChatWithHistory(installedAppInfo) } = useChatWithHistory(installedAppInfo)
return ( return (
@ -188,6 +241,14 @@ const ChatWithHistoryWrap: FC<ChatWithHistoryWrapProps> = ({
setCurrentConversationInputs, setCurrentConversationInputs,
allInputsHidden, allInputsHidden,
initUserVariables, initUserVariables,
showChatMemory,
setShowChatMemory,
memoryList,
clearAllMemory,
updateMemory,
resetDefault,
clearAllUpdateVersion,
switchMemoryVersion,
}}> }}>
<ChatWithHistory className={className} /> <ChatWithHistory className={className} />
</ChatWithHistoryContext.Provider> </ChatWithHistoryContext.Provider>

View File

@ -0,0 +1,128 @@
'use client'
import React, { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { RiCloseLine } from '@remixicon/react'
import { Memory } from '@/app/components/base/icons/src/vender/line/others'
import Modal from '@/app/components/base/modal'
import ActionButton from '@/app/components/base/action-button'
import Badge from '@/app/components/base/badge'
import Button from '@/app/components/base/button'
import Textarea from '@/app/components/base/textarea'
import Divider from '@/app/components/base/divider'
import Toast from '@/app/components/base/toast'
import type { Memory as MemoryItem } from '@/app/components/base/chat/types'
import { noop } from 'lodash-es'
import cn from '@/utils/classnames'
type Props = {
memory: MemoryItem
show: boolean
onConfirm: (info: MemoryItem, content: string) => Promise<void>
onHide: () => void
isMobile?: boolean
}
const MemoryEditModal = ({
memory,
show = false,
onConfirm,
onHide,
isMobile,
}: Props) => {
const { t } = useTranslation()
const [content, setContent] = React.useState(memory.value)
const versionTag = useMemo(() => {
const res = `${t('share.chat.memory.updateVersion.update')} ${memory.version}`
if (memory.edited_by_user)
return `${res} · ${t('share.chat.memory.updateVersion.edited')}`
return res
}, [memory.version, t])
const reset = () => {
setContent(memory.value)
}
const submit = () => {
if (!content.trim()) {
Toast.notify({ type: 'error', message: 'content is required' })
return
}
onConfirm(memory, content)
onHide()
}
if (isMobile) {
return (
<div className='fixed inset-0 z-50 flex flex-col bg-background-overlay pt-3 backdrop-blur-sm'
onClick={onHide}
>
<div className='relative flex w-full grow flex-col rounded-t-xl bg-components-panel-bg shadow-xl' onClick={e => e.stopPropagation()}>
<div className='absolute right-4 top-4 cursor-pointer p-2'>
<ActionButton onClick={onHide}>
<RiCloseLine className='h-5 w-5' />
</ActionButton>
</div>
<div className='p-4 pb-3'>
<div className='title-2xl-semi-bold mb-2 text-text-primary'>{t('share.chat.memory.editTitle')}</div>
<div className='flex items-center gap-1 pb-1 pt-2'>
<Memory className='h-4 w-4 shrink-0 text-util-colors-teal-teal-700' />
<div className='system-sm-semibold truncate text-text-primary'>{memory.spec.name}</div>
{memory.version > 1 && <Badge text={versionTag} className='!h-4' />}
</div>
</div>
<div className='grow px-4'>
<Textarea
className='h-full'
value={content}
onChange={e => setContent(e.target.value)}
/>
</div>
<div className='flex flex-row-reverse items-center p-4'>
<Button className='ml-2' variant='primary' onClick={submit}>{t('share.chat.memory.operations.save')}</Button>
<Button className='ml-3' onClick={onHide}>{t('share.chat.memory.operations.cancel')}</Button>
<Divider type='vertical' className='!mx-0 !h-4' />
<Button className='mr-3' onClick={reset}>{t('share.chat.memory.operations.reset')}</Button>
</div>
</div>
</div>
)
}
return (
<Modal
isShow={show}
onClose={noop}
className={cn('relative !max-w-[800px]', 'p-0')}
>
<div className='absolute right-5 top-5 cursor-pointer p-2'>
<ActionButton onClick={onHide}>
<RiCloseLine className='h-5 w-5' />
</ActionButton>
</div>
<div className='p-6 pb-3'>
<div className='title-2xl-semi-bold mb-2 text-text-primary'>{t('share.chat.memory.editTitle')}</div>
<div className='flex items-center gap-1 pb-1 pt-2'>
<Memory className='h-4 w-4 shrink-0 text-util-colors-teal-teal-700' />
<div className='system-sm-semibold truncate text-text-primary'>{memory.spec.name}</div>
{memory.version > 1 && <Badge text={versionTag} />}
</div>
</div>
<div className='px-6'>
<Textarea
className='h-[562px]'
value={content}
onChange={e => setContent(e.target.value)}
/>
</div>
<div className='flex flex-row-reverse items-center p-6 pt-5'>
<Button className='ml-2' variant='primary' onClick={submit}>{t('share.chat.memory.operations.save')}</Button>
<Button className='ml-3' onClick={onHide}>{t('share.chat.memory.operations.cancel')}</Button>
<Divider type='vertical' className='!mx-0 !h-4' />
<Button className='mr-3' onClick={reset}>{t('share.chat.memory.operations.reset')}</Button>
</div>
</Modal>
)
}
export default MemoryEditModal

View File

@ -0,0 +1,127 @@
import React, { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import {
RiArrowDownSLine,
RiArrowUpSLine,
} from '@remixicon/react'
import { Memory } from '@/app/components/base/icons/src/vender/line/others'
import ActionButton from '@/app/components/base/action-button'
import Badge from '@/app/components/base/badge'
import Indicator from '@/app/components/header/indicator'
import Operation from './operation'
import MemoryEditModal from './edit-modal'
import type { Memory as MemoryItem } from '@/app/components/base/chat/types'
import cn from '@/utils/classnames'
type Props = {
isMobile?: boolean
memory: MemoryItem
updateMemory: (memory: MemoryItem, content: string) => void
resetDefault: (memory: MemoryItem) => void
clearAllUpdateVersion: (memory: MemoryItem) => void
switchMemoryVersion: (memory: MemoryItem, version: string) => void
}
const MemoryCard: React.FC<Props> = ({
isMobile,
memory,
updateMemory,
resetDefault,
clearAllUpdateVersion,
switchMemoryVersion,
}) => {
const { t } = useTranslation()
const [isHovering, setIsHovering] = React.useState(false)
const [showEditModal, setShowEditModal] = React.useState(false)
const versionTag = useMemo(() => {
const res = `${t('share.chat.memory.updateVersion.update')} ${memory.version}`
if (memory.edited_by_user)
return `${res} · ${t('share.chat.memory.updateVersion.edited')}`
return res
}, [memory.version, t])
const isLatest = useMemo(() => {
if (memory.conversation_metadata)
return memory.conversation_metadata.visible_count === memory.spec.preserved_turns
return true
}, [memory])
const waitMergeCount = useMemo(() => {
if (memory.conversation_metadata)
return memory.conversation_metadata.visible_count - memory.spec.preserved_turns
return 0
}, [memory])
const prevVersion = () => {
if (memory.version > 1)
switchMemoryVersion(memory, (memory.version - 1).toString())
}
const nextVersion = () => {
switchMemoryVersion(memory, (memory.version + 1).toString())
}
return (
<>
<div
className={cn('group mb-1 rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-on-panel-item-bg shadow-xs hover:bg-components-panel-on-panel-item-bg-hover hover:shadow-md')}
onMouseEnter={() => setIsHovering(true)}
onMouseLeave={() => setIsHovering(false)}
>
<div className='relative flex items-end justify-between pb-1 pl-4 pr-2 pt-2'>
<div className='flex items-center gap-1 pb-1 pt-2'>
<Memory className='h-4 w-4 shrink-0 text-util-colors-teal-teal-700' />
<div className='system-sm-semibold truncate text-text-primary'>{memory.spec.name}</div>
{memory.version > 1 && <Badge text={versionTag} className='!h-4' />}
</div>
{isHovering && (
<div className='hover:bg-components-actionbar-bg-hover absolute bottom-0 right-2 flex items-center gap-0.5 rounded-lg border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0.5 shadow-md'>
<ActionButton onClick={prevVersion}><RiArrowUpSLine className='h-4 w-4' /></ActionButton>
<ActionButton onClick={nextVersion}><RiArrowDownSLine className='h-4 w-4' /></ActionButton>
<Operation
memory={memory}
onEdit={() => {
setShowEditModal(true)
setIsHovering(false)
}}
resetDefault={resetDefault}
clearAllUpdateVersion={clearAllUpdateVersion}
switchMemoryVersion={switchMemoryVersion}
/>
</div>
)}
</div>
<div className='system-xs-regular line-clamp-[12] px-4 pb-2 pt-1 text-text-tertiary'>{memory.value}</div>
{isLatest && (
<div className='flex items-center gap-1 rounded-b-xl border-t-[0.5px] border-divider-subtle bg-background-default-subtle px-4 py-3 group-hover:bg-components-panel-on-panel-item-bg-hover'>
<div className='system-xs-regular text-text-tertiary'>{t('share.chat.memory.latestVersion')}</div>
<Indicator color='green' />
</div>
)}
{!isLatest && (
<div className='flex items-center gap-1 rounded-b-xl border-t-[0.5px] border-divider-subtle bg-background-default-subtle px-4 py-3 group-hover:bg-components-panel-on-panel-item-bg-hover'>
<div className='system-xs-regular text-text-tertiary'>{t('share.chat.memory.notLatestVersion', { num: waitMergeCount })}</div>
<Indicator color='orange' />
</div>
)}
</div>
{showEditModal && (
<MemoryEditModal
isMobile={isMobile}
show={showEditModal}
memory={memory}
onConfirm={async (info, content) => {
await updateMemory(info, content)
setShowEditModal(false)
}}
onHide={() => setShowEditModal(false)}
/>
)}
</>
)
}
export default MemoryCard

View File

@ -0,0 +1,88 @@
'use client'
import type { FC } from 'react'
import React, { useCallback, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { RiCheckLine, RiMoreFill } from '@remixicon/react'
import ActionButton from '@/app/components/base/action-button'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import Divider from '@/app/components/base/divider'
import type { Memory } from '@/app/components/base/chat/types'
import cn from '@/utils/classnames'
type Props = {
memory: Memory
onEdit: () => void
resetDefault: (memory: Memory) => void
clearAllUpdateVersion: (memory: Memory) => void
switchMemoryVersion: (memory: Memory, version: string) => void
}
const OperationDropdown: FC<Props> = ({
memory,
onEdit,
resetDefault,
clearAllUpdateVersion,
switchMemoryVersion,
}) => {
const { t } = useTranslation()
const [open, doSetOpen] = useState(false)
const openRef = useRef(open)
const setOpen = useCallback((v: boolean) => {
doSetOpen(v)
openRef.current = v
}, [doSetOpen])
const handleTrigger = useCallback(() => {
setOpen(!openRef.current)
}, [setOpen])
return (
<PortalToFollowElem
open={open}
onOpenChange={setOpen}
placement='bottom-end'
offset={{
mainAxis: 4,
crossAxis: 4,
}}
>
<PortalToFollowElemTrigger onClick={handleTrigger}>
<div>
<ActionButton className={cn(open && 'bg-state-base-hover')}>
<RiMoreFill className='h-4 w-4' />
</ActionButton>
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className='z-50'>
<div className='w-[220px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-sm'>
<div className='p-1'>
<div className='system-md-regular cursor-pointer rounded-lg px-3 py-1.5 text-text-secondary hover:bg-state-base-hover' onClick={onEdit}>{t('share.chat.memory.operations.edit')}</div>
<div className='system-md-regular cursor-pointer rounded-lg px-3 py-1.5 text-text-secondary hover:bg-state-base-hover' onClick={() => resetDefault(memory)}>{t('share.chat.memory.operations.reset')}</div>
<div className='system-md-regular cursor-pointer rounded-lg px-3 py-1.5 text-text-secondary hover:bg-state-destructive-hover hover:text-text-destructive' onClick={() => clearAllUpdateVersion(memory)}>{t('share.chat.memory.operations.clear')}</div>
</div>
<Divider className='!my-0 !h-px bg-divider-subtle' />
<div className='px-1 py-2'>
<div className='system-xs-medium-uppercase px-3 pb-0.5 pt-1 text-text-tertiary'>{t('share.chat.memory.updateVersion.title')}</div>
<div className='system-md-regular flex cursor-pointer items-center gap-1 rounded-lg px-3 py-1.5 text-text-secondary hover:bg-state-base-hover'>
{t('share.chat.memory.operations.edit')}
<RiCheckLine className='h-4 w-4 text-text-accent' />
</div>
<div className='system-md-regular flex cursor-pointer items-center gap-1 rounded-lg px-3 py-1.5 text-text-secondary hover:bg-state-base-hover'>
{t('share.chat.memory.operations.edit')}
<RiCheckLine className='h-4 w-4 text-text-accent' />
</div>
<div className='system-md-regular flex cursor-pointer items-center gap-1 rounded-lg px-3 py-1.5 text-text-secondary hover:bg-state-base-hover'>
{t('share.chat.memory.operations.edit')}
<RiCheckLine className='h-4 w-4 text-text-accent' />
</div>
</div>
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
)
}
export default React.memo(OperationDropdown)

View File

@ -0,0 +1,80 @@
import React from 'react'
import { useTranslation } from 'react-i18next'
import {
RiCloseLine,
RiDeleteBinLine,
} from '@remixicon/react'
import ActionButton from '@/app/components/base/action-button'
import Button from '@/app/components/base/button'
import MemoryCard from './card'
import cn from '@/utils/classnames'
import type { Memory } from '@/app/components/base/chat/types'
type Props = {
isMobile?: boolean
showChatMemory?: boolean
setShowChatMemory: (show: boolean) => void
memoryList: Memory[]
clearAllMemory: () => void
updateMemory: (memory: Memory, content: string) => void
resetDefault: (memory: Memory) => void
clearAllUpdateVersion: (memory: Memory) => void
switchMemoryVersion: (memory: Memory, version: string) => void
}
const MemoryPanel: React.FC<Props> = ({
isMobile,
showChatMemory,
setShowChatMemory,
memoryList,
clearAllMemory,
updateMemory,
resetDefault,
clearAllUpdateVersion,
switchMemoryVersion,
}) => {
const { t } = useTranslation()
return (
<div className={cn(
'flex h-full w-[360px] shrink-0 flex-col rounded-2xl border-[0.5px] border-components-panel-border-subtle bg-chatbot-bg transition-all ease-in-out',
showChatMemory ? 'w-[360px]' : 'w-0 opacity-0',
)}>
<div className='flex shrink-0 items-center border-b-[0.5px] border-components-panel-border-subtle pl-4 pr-3.5 pt-2'>
<div className='system-md-semibold-uppercase grow py-3 text-text-primary'>{t('share.chat.memory.title')}</div>
<ActionButton size='l' onClick={() => setShowChatMemory(false)}>
<RiCloseLine className='h-[18px] w-[18px]' />
</ActionButton>
</div>
<div className='h-0 grow overflow-y-auto px-3 pt-2'>
{memoryList.map(memory => (
<MemoryCard
key={memory.spec.id}
isMobile={isMobile}
memory={memory}
updateMemory={updateMemory}
resetDefault={resetDefault}
clearAllUpdateVersion={clearAllUpdateVersion}
switchMemoryVersion={switchMemoryVersion}
/>
))}
{memoryList.length > 0 && (
<div className='flex items-center justify-center'>
<Button variant='ghost' onClick={clearAllMemory}>
<RiDeleteBinLine className='mr-1 h-3.5 w-3.5' />
{t('share.chat.memory.clearAll')}
</Button>
</div>
)}
{memoryList.length === 0 && (
<div className='system-xs-regular flex items-center justify-center py-2 text-text-tertiary'>
{t('share.chat.memory.empty')}
</div>
)}
</div>
</div>
)
}
export default MemoryPanel

View File

@ -0,0 +1,96 @@
import type { Memory as MemoryItem } from '@/app/components/base/chat/types'
export const mockMemoryList: MemoryItem[] = [
{
tenant_id: 'user-tenant-id',
value: `Learning Goal: [What you\'re studying]
Current Level: [Beginner/Intermediate/Advanced]
Learning Style: [Visual, hands-on, theoretical, etc.]
Progress: [Topics mastered, current focus]
Preferred Pace: [Fast/moderate/slow explanations]
Background: [Relevant experience or education]
Time Constraints: [Available study time]`,
app_id: 'user-app-id',
conversation_id: '',
version: 1,
edited_by_user: false,
conversation_metadata: {
type: 'mutable_visible_window',
visible_count: 5,
},
spec: {
id: 'learning_companion',
name: 'Learning Companion',
description: 'A companion to help with learning goals',
template: 'no zuo no die why you try', // default value
instruction: 'enjoy yourself',
scope: 'app', // app or node
term: 'session', // session or persistent
strategy: 'on_turns',
update_turns: 3,
preserved_turns: 5,
schedule_mode: 'sync', // sync or async
end_user_visible: true,
end_user_editable: true,
},
},
{
tenant_id: 'user-tenant-id',
value: `Research Topic: [Your research topic]
Current Progress: [Literature review, experiments, etc.]
Challenges: [What you\'re struggling with]
Goals: [Short-term and long-term research goals]`,
app_id: 'user-app-id',
conversation_id: '',
version: 1,
edited_by_user: false,
conversation_metadata: {
type: 'mutable_visible_window',
visible_count: 5,
},
spec: {
id: 'research_partner',
name: 'research_partner',
description: 'A companion to help with research goals',
template: 'no zuo no die why you try', // default value
instruction: 'enjoy yourself',
scope: 'app', // app or node
term: 'session', // session or persistent
strategy: 'on_turns',
update_turns: 3,
preserved_turns: 3,
schedule_mode: 'sync', // sync or async
end_user_visible: true,
end_user_editable: false,
},
},
{
tenant_id: 'user-tenant-id',
value: `Code Context: [Brief description of the codebase]
Current Issues: [Bugs, technical debt, etc.]
Goals: [Features to implement, improvements to make]`,
app_id: 'user-app-id',
conversation_id: '',
version: 3,
edited_by_user: true,
conversation_metadata: {
type: 'mutable_visible_window',
visible_count: 5,
},
spec: {
id: 'code_partner',
name: 'code_partner',
description: 'A companion to help with code-related tasks',
template: 'no zuo no die why you try', // default value
instruction: 'enjoy yourself',
scope: 'app', // app or node
term: 'session', // session or persistent
strategy: 'on_turns',
update_turns: 3,
preserved_turns: 5,
schedule_mode: 'sync', // sync or async
end_user_visible: true,
end_user_editable: true,
},
},
]

View File

@ -6,6 +6,7 @@ import type {
ChatConfig, ChatConfig,
ChatItem, ChatItem,
Feedback, Feedback,
Memory,
} from '../types' } from '../types'
import type { ThemeBuilder } from './theme/theme-context' import type { ThemeBuilder } from './theme/theme-context'
import type { import type {
@ -53,6 +54,14 @@ export type EmbeddedChatbotContextValue = {
name?: string name?: string
avatar_url?: string avatar_url?: string
} }
showChatMemory?: boolean
setShowChatMemory: (state: boolean) => void
memoryList: Memory[]
clearAllMemory: () => void
updateMemory: (memory: Memory, content: string) => void
resetDefault: (memory: Memory) => void
clearAllUpdateVersion: (memory: Memory) => void
switchMemoryVersion: (memory: Memory, version: string) => void
} }
export const EmbeddedChatbotContext = createContext<EmbeddedChatbotContextValue>({ export const EmbeddedChatbotContext = createContext<EmbeddedChatbotContextValue>({
@ -86,5 +95,13 @@ export const EmbeddedChatbotContext = createContext<EmbeddedChatbotContextValue>
setCurrentConversationInputs: noop, setCurrentConversationInputs: noop,
allInputsHidden: false, allInputsHidden: false,
initUserVariables: {}, initUserVariables: {},
showChatMemory: false,
setShowChatMemory: noop,
memoryList: [],
clearAllMemory: noop,
updateMemory: noop,
resetDefault: noop,
clearAllUpdateVersion: noop,
switchMemoryVersion: noop,
}) })
export const useEmbeddedChatbotContext = () => useContext(EmbeddedChatbotContext) export const useEmbeddedChatbotContext = () => useContext(EmbeddedChatbotContext)

View File

@ -7,8 +7,9 @@ import { CssTransform } from '../theme/utils'
import { import {
useEmbeddedChatbotContext, useEmbeddedChatbotContext,
} from '../context' } from '../context'
import { Memory } from '@/app/components/base/icons/src/vender/line/others'
import Tooltip from '@/app/components/base/tooltip' import Tooltip from '@/app/components/base/tooltip'
import ActionButton from '@/app/components/base/action-button' import ActionButton, { ActionButtonState } from '@/app/components/base/action-button'
import Divider from '@/app/components/base/divider' import Divider from '@/app/components/base/divider'
import ViewFormDropdown from '@/app/components/base/chat/embedded-chatbot/inputs-form/view-form-dropdown' import ViewFormDropdown from '@/app/components/base/chat/embedded-chatbot/inputs-form/view-form-dropdown'
import DifyLogo from '@/app/components/base/logo/dify-logo' import DifyLogo from '@/app/components/base/logo/dify-logo'
@ -36,6 +37,8 @@ const Header: FC<IHeaderProps> = ({
appData, appData,
currentConversationId, currentConversationId,
inputsForms, inputsForms,
showChatMemory,
setShowChatMemory,
allInputsHidden, allInputsHidden,
} = useEmbeddedChatbotContext() } = useEmbeddedChatbotContext()
@ -77,6 +80,10 @@ const Header: FC<IHeaderProps> = ({
}, parentOrigin) }, parentOrigin)
}, [isIframe, parentOrigin, showToggleExpandButton, expanded]) }, [isIframe, parentOrigin, showToggleExpandButton, expanded])
const handleChatMemoryToggle = useCallback(() => {
setShowChatMemory(!showChatMemory)
}, [setShowChatMemory, showChatMemory])
if (!isMobile) { if (!isMobile) {
return ( return (
<div className='flex h-14 shrink-0 items-center justify-end p-3'> <div className='flex h-14 shrink-0 items-center justify-end p-3'>
@ -128,6 +135,15 @@ const Header: FC<IHeaderProps> = ({
{currentConversationId && inputsForms.length > 0 && !allInputsHidden && ( {currentConversationId && inputsForms.length > 0 && !allInputsHidden && (
<ViewFormDropdown /> <ViewFormDropdown />
)} )}
{currentConversationId && (
<Tooltip
popupContent={t('share.chat.memory.actionButton')}
>
<ActionButton size='l' state={showChatMemory ? ActionButtonState.Active : ActionButtonState.Default} onClick={handleChatMemoryToggle}>
<Memory className='h-[18px] w-[18px]' />
</ActionButton>
</Tooltip>
)}
</div> </div>
</div> </div>
) )
@ -175,6 +191,15 @@ const Header: FC<IHeaderProps> = ({
{currentConversationId && inputsForms.length > 0 && !allInputsHidden && ( {currentConversationId && inputsForms.length > 0 && !allInputsHidden && (
<ViewFormDropdown iconColor={theme?.colorPathOnHeader} /> <ViewFormDropdown iconColor={theme?.colorPathOnHeader} />
)} )}
{currentConversationId && (
<Tooltip
popupContent={t('share.chat.memory.actionButton')}
>
<ActionButton size='l' onClick={handleChatMemoryToggle}>
<Memory className={cn('h-[18px] w-[18px]', theme?.colorPathOnHeader)} />
</ActionButton>
</Tooltip>
)}
</div> </div>
</div> </div>
) )

View File

@ -18,8 +18,11 @@ import { CONVERSATION_ID_INFO } from '../constants'
import { buildChatItemTree, getProcessedInputsFromUrlParams, getProcessedSystemVariablesFromUrlParams, getProcessedUserVariablesFromUrlParams } from '../utils' import { buildChatItemTree, getProcessedInputsFromUrlParams, getProcessedSystemVariablesFromUrlParams, getProcessedUserVariablesFromUrlParams } from '../utils'
import { getProcessedFilesFromResponse } from '../../file-uploader/utils' import { getProcessedFilesFromResponse } from '../../file-uploader/utils'
import { import {
deleteMemory,
editMemory,
fetchChatList, fetchChatList,
fetchConversations, fetchConversations,
fetchMemories,
generationConversationName, generationConversationName,
updateFeedback, updateFeedback,
} from '@/service/share' } from '@/service/share'
@ -33,6 +36,7 @@ import { InputVarType } from '@/app/components/workflow/types'
import { TransferMethod } from '@/types/app' import { TransferMethod } from '@/types/app'
import { addFileInfos, sortAgentSorts } from '@/app/components/tools/utils' import { addFileInfos, sortAgentSorts } from '@/app/components/tools/utils'
import { noop } from 'lodash-es' import { noop } from 'lodash-es'
import type { Memory } from '@/app/components/base/chat/types'
import { useWebAppStore } from '@/context/web-app-context' import { useWebAppStore } from '@/context/web-app-context'
function getFormattedChatList(messages: any[]) { function getFormattedChatList(messages: any[]) {
@ -387,6 +391,61 @@ export const useEmbeddedChatbot = () => {
notify({ type: 'success', message: t('common.api.success') }) notify({ type: 'success', message: t('common.api.success') })
}, [isInstalledApp, appId, t, notify]) }, [isInstalledApp, appId, t, notify])
const [showChatMemory, setShowChatMemory] = useState(false)
const [memoryList, setMemoryList] = useState<Memory[]>([])
const getMemoryList = useCallback(async (currentConversationId: string) => {
const memories = await fetchMemories(currentConversationId, '', '', isInstalledApp, appId)
setMemoryList(memories)
}, [isInstalledApp, appId])
const clearAllMemory = useCallback(async () => {
await deleteMemory('', isInstalledApp, appId)
notify({ type: 'success', message: t('common.api.success') })
getMemoryList(currentConversationId)
}, [currentConversationId, getMemoryList])
const resetDefault = useCallback(async (memory: Memory) => {
try {
await editMemory(memory.spec.id, memory.spec.template, isInstalledApp, appId)
getMemoryList(currentConversationId)
}
catch (error) {
console.error('Failed to reset memory:', error)
}
}, [currentConversationId, getMemoryList, isInstalledApp, appId])
const clearAllUpdateVersion = useCallback(async (memory: Memory) => {
await deleteMemory(memory.spec.id, isInstalledApp, appId)
notify({ type: 'success', message: t('common.api.success') })
getMemoryList(currentConversationId)
}, [currentConversationId, getMemoryList])
const switchMemoryVersion = useCallback(async (memory: Memory, version: string) => {
const memories = await fetchMemories(currentConversationId, memory.spec.id, version, isInstalledApp, appId)
const newMemory = memories[0]
const newList = produce(memoryList, (draft) => {
const index = draft.findIndex(item => item.spec.id === memory.spec.id)
if (index !== -1)
draft[index] = newMemory
})
setMemoryList(newList)
}, [memoryList, currentConversationId, isInstalledApp, appId])
const updateMemory = useCallback(async (memory: Memory, content: string) => {
try {
await editMemory(memory.spec.id, content, isInstalledApp, appId)
getMemoryList(currentConversationId)
}
catch (error) {
console.error('Failed to reset memory:', error)
}
}, [getMemoryList, currentConversationId, isInstalledApp, appId])
useEffect(() => {
getMemoryList(currentConversationId)
}, [currentConversationId, getMemoryList])
return { return {
isInstalledApp, isInstalledApp,
allowResetChat, allowResetChat,
@ -426,5 +485,13 @@ export const useEmbeddedChatbot = () => {
setCurrentConversationInputs, setCurrentConversationInputs,
allInputsHidden, allInputsHidden,
initUserVariables, initUserVariables,
showChatMemory,
setShowChatMemory,
memoryList,
clearAllMemory,
updateMemory,
resetDefault,
clearAllUpdateVersion,
switchMemoryVersion,
} }
} }

View File

@ -16,6 +16,7 @@ import Loading from '@/app/components/base/loading'
import LogoHeader from '@/app/components/base/logo/logo-embedded-chat-header' import LogoHeader from '@/app/components/base/logo/logo-embedded-chat-header'
import Header from '@/app/components/base/chat/embedded-chatbot/header' import Header from '@/app/components/base/chat/embedded-chatbot/header'
import ChatWrapper from '@/app/components/base/chat/embedded-chatbot/chat-wrapper' import ChatWrapper from '@/app/components/base/chat/embedded-chatbot/chat-wrapper'
import MemoryPanel from '@/app/components/base/chat/chat-with-history/memory'
import DifyLogo from '@/app/components/base/logo/dify-logo' import DifyLogo from '@/app/components/base/logo/dify-logo'
import cn from '@/utils/classnames' import cn from '@/utils/classnames'
import useDocumentTitle from '@/hooks/use-document-title' import useDocumentTitle from '@/hooks/use-document-title'
@ -30,6 +31,14 @@ const Chatbot = () => {
chatShouldReloadKey, chatShouldReloadKey,
handleNewConversation, handleNewConversation,
themeBuilder, themeBuilder,
showChatMemory,
setShowChatMemory,
memoryList,
clearAllMemory,
updateMemory,
resetDefault,
clearAllUpdateVersion,
switchMemoryVersion,
} = useEmbeddedChatbotContext() } = useEmbeddedChatbotContext()
const { t } = useTranslation() const { t } = useTranslation()
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures) const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)
@ -90,6 +99,25 @@ const Chatbot = () => {
)} )}
</div> </div>
)} )}
{showChatMemory && (
<div className='fixed inset-0 z-50 flex flex-row-reverse bg-background-overlay p-1 backdrop-blur-sm'
onClick={() => setShowChatMemory(false)}
>
<div className='flex h-full w-[360px] rounded-xl shadow-lg' onClick={e => e.stopPropagation()}>
<MemoryPanel
isMobile={isMobile}
showChatMemory={showChatMemory}
setShowChatMemory={setShowChatMemory}
memoryList={memoryList}
clearAllMemory={clearAllMemory}
updateMemory={updateMemory}
resetDefault={resetDefault}
clearAllUpdateVersion={clearAllUpdateVersion}
switchMemoryVersion={switchMemoryVersion}
/>
</div>
</div>
)}
</div> </div>
) )
} }
@ -131,6 +159,14 @@ const EmbeddedChatbotWrapper = () => {
setCurrentConversationInputs, setCurrentConversationInputs,
allInputsHidden, allInputsHidden,
initUserVariables, initUserVariables,
showChatMemory,
setShowChatMemory,
memoryList,
clearAllMemory,
updateMemory,
resetDefault,
clearAllUpdateVersion,
switchMemoryVersion,
} = useEmbeddedChatbot() } = useEmbeddedChatbot()
return <EmbeddedChatbotContext.Provider value={{ return <EmbeddedChatbotContext.Provider value={{
@ -167,6 +203,14 @@ const EmbeddedChatbotWrapper = () => {
setCurrentConversationInputs, setCurrentConversationInputs,
allInputsHidden, allInputsHidden,
initUserVariables, initUserVariables,
showChatMemory,
setShowChatMemory,
memoryList,
clearAllMemory,
updateMemory,
resetDefault,
clearAllUpdateVersion,
switchMemoryVersion,
}}> }}>
<Chatbot /> <Chatbot />
</EmbeddedChatbotContext.Provider> </EmbeddedChatbotContext.Provider>

View File

@ -95,3 +95,36 @@ export type Feedback = {
rating: 'like' | 'dislike' | null rating: 'like' | 'dislike' | null
content?: string | null content?: string | null
} }
export type MemorySpec = {
id: string
name: string
description: string
template: string // default value
instruction: string
scope: string // app or node
term: string // session or persistent
strategy: string
update_turns: number
preserved_turns: number
schedule_mode: string // sync or async
end_user_visible: boolean
end_user_editable: boolean
}
export type ConversationMetaData = {
type: string // mutable_visible_window
visible_count: number // visible_count - preserved_turns = N messages waiting merged
}
export type Memory = {
tenant_id: string
value: string
app_id: string
conversation_id?: string
node_id?: string
version: number
edited_by_user: boolean
conversation_metadata?: ConversationMetaData
spec: MemorySpec
}

View File

@ -1,4 +1,5 @@
'use client' 'use client'
import useSWR from 'swr'
import { produce } from 'immer' import { produce } from 'immer'
import React, { Fragment } from 'react' import React, { Fragment } from 'react'
import { usePathname } from 'next/navigation' import { usePathname } from 'next/navigation'
@ -8,6 +9,7 @@ import { Listbox, ListboxButton, ListboxOption, ListboxOptions, Transition } fro
import { CheckIcon, ChevronDownIcon } from '@heroicons/react/20/solid' import { CheckIcon, ChevronDownIcon } from '@heroicons/react/20/solid'
import { useFeatures, useFeaturesStore } from '@/app/components/base/features/hooks' import { useFeatures, useFeaturesStore } from '@/app/components/base/features/hooks'
import type { Item } from '@/app/components/base/select' import type { Item } from '@/app/components/base/select'
import { fetchAppVoices } from '@/service/apps'
import Tooltip from '@/app/components/base/tooltip' import Tooltip from '@/app/components/base/tooltip'
import Switch from '@/app/components/base/switch' import Switch from '@/app/components/base/switch'
import AudioBtn from '@/app/components/base/audio-btn' import AudioBtn from '@/app/components/base/audio-btn'
@ -15,7 +17,6 @@ import { languages } from '@/i18n-config/language'
import { TtsAutoPlay } from '@/types/app' import { TtsAutoPlay } from '@/types/app'
import type { OnFeaturesChange } from '@/app/components/base/features/types' import type { OnFeaturesChange } from '@/app/components/base/features/types'
import classNames from '@/utils/classnames' import classNames from '@/utils/classnames'
import { useAppVoices } from '@/service/use-apps'
type VoiceParamConfigProps = { type VoiceParamConfigProps = {
onClose: () => void onClose: () => void
@ -38,7 +39,7 @@ const VoiceParamConfig = ({
const localLanguagePlaceholder = languageItem?.name || t('common.placeholder.select') const localLanguagePlaceholder = languageItem?.name || t('common.placeholder.select')
const language = languageItem?.value const language = languageItem?.value
const { data: voiceItems } = useAppVoices(appId, language) const voiceItems = useSWR({ appId, language }, fetchAppVoices).data
let voiceItem = voiceItems?.find(item => item.value === text2speech?.voice) let voiceItem = voiceItems?.find(item => item.value === text2speech?.voice)
if (voiceItems && !voiceItem) if (voiceItems && !voiceItem)
voiceItem = voiceItems[0] voiceItem = voiceItems[0]

View File

@ -9,7 +9,12 @@ import Tooltip from '@/app/components/base/tooltip'
import { useRenderI18nObject } from '@/hooks/use-i18n' import { useRenderI18nObject } from '@/hooks/use-i18n'
import { useTriggerPluginDynamicOptions } from '@/service/use-triggers' import { useTriggerPluginDynamicOptions } from '@/service/use-triggers'
import cn from '@/utils/classnames' import cn from '@/utils/classnames'
import { RiExternalLinkLine } from '@remixicon/react' import {
RiArrowDownSFill,
RiDraftLine,
RiExternalLinkLine,
RiInputField,
} from '@remixicon/react'
import type { AnyFieldApi } from '@tanstack/react-form' import type { AnyFieldApi } from '@tanstack/react-form'
import { useStore } from '@tanstack/react-form' import { useStore } from '@tanstack/react-form'
import { import {
@ -19,6 +24,19 @@ import {
useMemo, useMemo,
} from 'react' } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import Textarea from '@/app/components/base/textarea'
import PromptEditor from '@/app/components/base/prompt-editor'
import ModelParameterModal from '@/app/components/header/account-setting/model-provider-page/model-parameter-modal'
import ObjectValueList from '@/app/components/workflow/panel/chat-variable-panel/components/object-value-list'
import ArrayValueList from '@/app/components/workflow/panel/chat-variable-panel/components/array-value-list'
import ArrayBooleanValueList from '@/app/components/workflow/panel/chat-variable-panel/components/array-bool-list'
import CodeEditor from '@/app/components/workflow/nodes/_base/components/editor/code-editor'
import { CodeLanguage } from '@/app/components/workflow/nodes/code/types'
import Button from '@/app/components/base/button'
import PromptGeneratorBtn from '@/app/components/workflow/nodes/llm/components/prompt-generator-btn'
import Slider from '@/app/components/base/slider'
import Switch from '../../../switch'
import NodeSelector from '@/app/components/workflow/panel/chat-variable-panel/components/node-selector'
const getExtraProps = (type: FormTypeEnum) => { const getExtraProps = (type: FormTypeEnum) => {
switch (type) { switch (type) {
@ -100,7 +118,6 @@ const BaseField = ({
options, options,
labelClassName: formLabelClassName, labelClassName: formLabelClassName,
disabled: formSchemaDisabled, disabled: formSchemaDisabled,
type: formItemType,
dynamicSelectParams, dynamicSelectParams,
multiple = false, multiple = false,
tooltip, tooltip,
@ -108,7 +125,14 @@ const BaseField = ({
description, description,
url, url,
help, help,
type: typeOrFn,
fieldClassName: formFieldClassName,
inputContainerClassName: formInputContainerClassName,
inputClassName: formInputClassName,
selfFormProps,
onChange: formOnChange,
} = formSchema } = formSchema
const formItemType = typeof typeOrFn === 'function' ? typeOrFn(field.form) : typeOrFn
const disabled = propsDisabled || formSchemaDisabled const disabled = propsDisabled || formSchemaDisabled
const [translatedLabel, translatedPlaceholder, translatedTooltip, translatedDescription, translatedHelp] = useMemo(() => { const [translatedLabel, translatedPlaceholder, translatedTooltip, translatedDescription, translatedHelp] = useMemo(() => {
@ -148,7 +172,8 @@ const BaseField = ({
return true return true
return option.show_on.every((condition) => { return option.show_on.every((condition) => {
return watchedValues[condition.variable] === condition.value const conditionValue = watchedValues[condition.variable]
return Array.isArray(condition.value) ? condition.value.includes(conditionValue) : conditionValue === condition.value
}) })
}).map((option) => { }).map((option) => {
return { return {
@ -180,21 +205,67 @@ const BaseField = ({
})) }))
}, [dynamicOptionsData, renderI18nObject]) }, [dynamicOptionsData, renderI18nObject])
const booleanRadioValue = useMemo(() => {
if (value === null || value === undefined)
return undefined
return value ? 1 : 0
}, [value])
const handleChange = useCallback((value: any) => { const handleChange = useCallback((value: any) => {
if (disabled)
return
field.handleChange(value) field.handleChange(value)
formOnChange?.(field.form, value)
onChange?.(field.name, value) onChange?.(field.name, value)
}, [field, onChange]) }, [field, formOnChange, onChange, disabled])
const selfProps = typeof selfFormProps === 'function' ? selfFormProps(field.form) : selfFormProps
return ( return (
<> <>
<div className={cn(fieldClassName)}> {
<div className={cn(labelClassName, formLabelClassName)}> selfProps?.withTopDivider && (
<div className='h-px w-full bg-divider-subtle' />
)
}
<div className={cn(fieldClassName, formFieldClassName)}>
<div
className={cn(formItemType === FormTypeEnum.collapse && 'cursor-pointer', labelClassName, formLabelClassName)}
onClick={() => {
if (formItemType === FormTypeEnum.collapse)
handleChange(!value)
}}
>
{translatedLabel} {translatedLabel}
{ {
required && !isValidElement(label) && ( required && !isValidElement(label) && (
<span className='ml-1 text-text-destructive-secondary'>*</span> <span className='ml-1 text-text-destructive-secondary'>*</span>
) )
} }
{
formItemType === FormTypeEnum.collapse && (
<RiArrowDownSFill
className={cn(
'h-4 w-4 text-text-quaternary',
!value && '-rotate-90',
)}
/>
)
}
{
formItemType === FormTypeEnum.editMode && (
<Button
variant='ghost'
size='small'
className='text-text-tertiary'
onClick={() => handleChange(!value)}
>
{value ? <RiInputField className='mr-1 h-3.5 w-3.5' /> : <RiDraftLine className='mr-1 h-3.5 w-3.5' />}
{selfProps?.editModeLabel}
</Button>
)
}
{tooltip && ( {tooltip && (
<Tooltip <Tooltip
popupContent={<div className='w-[200px]'>{translatedTooltip}</div>} popupContent={<div className='w-[200px]'>{translatedTooltip}</div>}
@ -202,9 +273,9 @@ const BaseField = ({
/> />
)} )}
</div> </div>
<div className={cn(inputContainerClassName)}> <div className={cn(inputContainerClassName, formInputContainerClassName)}>
{ {
[FormTypeEnum.textInput, FormTypeEnum.secretInput, FormTypeEnum.textNumber].includes(formItemType) && ( !selfProps?.withSlider && [FormTypeEnum.textInput, FormTypeEnum.secretInput, FormTypeEnum.textNumber].includes(formItemType) && (
<Input <Input
id={field.name} id={field.name}
name={field.name} name={field.name}
@ -221,6 +292,34 @@ const BaseField = ({
/> />
) )
} }
{
formItemType === FormTypeEnum.textNumber && selfProps?.withSlider && (
<div className='flex items-center space-x-2'>
<Slider
min={selfProps?.sliderMin}
max={selfProps?.sliderMax}
step={selfProps?.sliderStep}
value={value}
onChange={handleChange}
className={cn(selfProps.sliderClassName)}
trackClassName={cn(selfProps.sliderTrackClassName)}
thumbClassName={cn(selfProps.sliderThumbClassName)}
/>
<Input
id={field.name}
name={field.name}
type='number'
className={cn('', inputClassName, formInputClassName)}
wrapperClassName={cn(selfProps.inputWrapperClassName)}
value={value || ''}
onChange={e => handleChange(e.target.value)}
onBlur={field.handleBlur}
disabled={disabled}
placeholder={translatedPlaceholder}
/>
</div>
)
}
{ {
formItemType === FormTypeEnum.select && !multiple && ( formItemType === FormTypeEnum.select && !multiple && (
<PureSelect <PureSelect
@ -276,15 +375,16 @@ const BaseField = ({
<div <div
key={option.value} key={option.value}
className={cn( className={cn(
'system-sm-regular hover:bg-components-option-card-option-hover-bg hover:border-components-option-card-option-hover-border flex h-8 flex-[1] grow cursor-pointer items-center justify-center gap-2 rounded-lg border border-components-option-card-option-border bg-components-option-card-option-bg p-2 text-text-secondary', 'system-sm-regular hover:bg-components-option-card-option-hover-bg hover:border-components-option-card-option-hover-border flex h-8 flex-[1] grow cursor-pointer items-center justify-center rounded-lg border border-components-option-card-option-border bg-components-option-card-option-bg p-2 text-text-secondary',
value === option.value && 'border-components-option-card-option-selected-border bg-components-option-card-option-selected-bg text-text-primary shadow-xs', value === option.value && 'border-components-option-card-option-selected-border bg-components-option-card-option-selected-bg text-text-primary shadow-xs',
disabled && 'cursor-not-allowed opacity-50', disabled && 'cursor-not-allowed opacity-50',
inputClassName, inputClassName,
formInputClassName,
)} )}
onClick={() => !disabled && handleChange(option.value)} onClick={() => handleChange(option.value)}
> >
{ {
formSchema.showRadioUI && ( selfProps?.showRadioUI && (
<RadioE <RadioE
className='mr-2' className='mr-2'
isChecked={value === option.value} isChecked={value === option.value}
@ -298,18 +398,151 @@ const BaseField = ({
</div> </div>
) )
} }
{
formItemType === FormTypeEnum.textareaInput && (
<Textarea
className={cn(
'min-h-[80px]',
inputClassName,
formInputClassName,
)}
value={value}
placeholder={translatedPlaceholder}
onChange={e => handleChange(e.target.value)}
onBlur={field.handleBlur}
disabled={disabled}
/>
)
}
{
formItemType === FormTypeEnum.promptInput && (
<div className={cn(
'relative rounded-lg bg-components-input-bg-normal p-2',
formInputContainerClassName,
)}>
{
selfProps?.enablePromptGenerator && (
<PromptGeneratorBtn
nodeId={selfProps?.nodeId}
editorId={selfProps?.editorId}
className='absolute right-0 top-[-26px]'
onGenerated={handleChange}
modelConfig={selfProps?.modelConfig}
currentPrompt={value}
isBasicMode={selfProps?.isBasicMode}
/>
)
}
<PromptEditor
value={value}
onChange={handleChange}
onBlur={field.handleBlur}
editable={!disabled}
placeholder={translatedPlaceholder || selfProps?.placeholder}
className={cn(
'min-h-[80px]',
inputClassName,
formInputClassName,
)}
/>
</div>
)
}
{
formItemType === FormTypeEnum.objectList && (
<ObjectValueList
list={value}
onChange={handleChange}
/>
)
}
{
formItemType === FormTypeEnum.arrayList && (
<ArrayValueList
isString={selfProps?.isString}
list={value}
onChange={handleChange}
/>
)
}
{
formItemType === FormTypeEnum.booleanList && (
<ArrayBooleanValueList
list={value}
onChange={handleChange}
/>
)
}
{
formItemType === FormTypeEnum.jsonInput && (
<div className='w-full rounded-[10px] bg-components-input-bg-normal py-2 pl-3 pr-1' style={{ height: selfProps?.editorMinHeight }}>
<CodeEditor
isExpand
noWrapper
language={CodeLanguage.json}
value={value}
placeholder={<div className='whitespace-pre'>{selfProps?.placeholder as string}</div>}
onChange={handleChange}
/>
</div>
)
}
{
formItemType === FormTypeEnum.modelSelector && (
<ModelParameterModal
popupClassName='!w-[387px]'
modelId={value?.name}
provider={value?.provider}
setModel={({ modelId, mode, provider }) => {
handleChange({
mode,
provider,
name: modelId,
completion_params: value?.completion_params,
})
}}
completionParams={value?.completion_params}
onCompletionParamsChange={(params) => {
handleChange({
...value,
completion_params: params,
})
}}
readonly={disabled}
isAdvancedMode
isInWorkflow
hideDebugWithMultipleModel
/>
)
}
{
formItemType === FormTypeEnum.nodeSelector && (
<NodeSelector
value={value}
onChange={handleChange}
/>
)
}
{ {
formItemType === FormTypeEnum.boolean && ( formItemType === FormTypeEnum.boolean && (
<Radio.Group <Radio.Group
className='flex w-fit items-center' className={cn('flex w-full items-center space-x-1', inputClassName, formInputClassName)}
value={value} value={booleanRadioValue}
onChange={v => field.handleChange(v)} onChange={handleChange}
> >
<Radio value={true} className='!mr-1'>True</Radio> <Radio value={1} className='m-0 h-7 flex-1 justify-center p-0'>True</Radio>
<Radio value={false}>False</Radio> <Radio value={0} className='m-0 h-7 flex-1 justify-center p-0'>False</Radio>
</Radio.Group> </Radio.Group>
) )
} }
{
formItemType === FormTypeEnum.switch && (
<Switch
defaultValue={value}
onChange={handleChange}
/>
)
}
{fieldState?.validateStatus && [FormItemValidateStatusEnum.Error, FormItemValidateStatusEnum.Warning].includes(fieldState?.validateStatus) && ( {fieldState?.validateStatus && [FormItemValidateStatusEnum.Error, FormItemValidateStatusEnum.Warning].includes(fieldState?.validateStatus) && (
<div className={cn( <div className={cn(
'system-xs-regular mt-1 px-0 py-[2px]', 'system-xs-regular mt-1 px-0 py-[2px]',
@ -339,6 +572,11 @@ const BaseField = ({
</a> </a>
) )
} }
{
selfProps?.withBottomDivider && (
<div className='h-px w-full bg-divider-subtle' />
)
}
</> </>
) )

View File

@ -5,6 +5,7 @@ import {
useMemo, useMemo,
useState, useState,
} from 'react' } from 'react'
import { useTranslation } from 'react-i18next'
import type { import type {
AnyFieldApi, AnyFieldApi,
AnyFormApi, AnyFormApi,
@ -31,6 +32,7 @@ import {
useGetFormValues, useGetFormValues,
useGetValidators, useGetValidators,
} from '@/app/components/base/form/hooks' } from '@/app/components/base/form/hooks'
import { Button } from '@/app/components/base/button'
export type BaseFormProps = { export type BaseFormProps = {
formSchemas?: FormSchema[] formSchemas?: FormSchema[]
@ -39,6 +41,7 @@ export type BaseFormProps = {
ref?: FormRef ref?: FormRef
disabled?: boolean disabled?: boolean
formFromProps?: AnyFormApi formFromProps?: AnyFormApi
onCancel?: () => void
onChange?: (field: string, value: any) => void onChange?: (field: string, value: any) => void
onSubmit?: (e: React.FormEvent<HTMLFormElement>) => void onSubmit?: (e: React.FormEvent<HTMLFormElement>) => void
preventDefaultSubmit?: boolean preventDefaultSubmit?: boolean
@ -55,10 +58,12 @@ const BaseForm = ({
ref, ref,
disabled, disabled,
formFromProps, formFromProps,
onCancel,
onChange, onChange,
onSubmit, onSubmit,
preventDefaultSubmit = false, preventDefaultSubmit = false,
}: BaseFormProps) => { }: BaseFormProps) => {
const { t } = useTranslation()
const initialDefaultValues = useMemo(() => { const initialDefaultValues = useMemo(() => {
if (defaultValues) if (defaultValues)
return defaultValues return defaultValues
@ -82,8 +87,22 @@ const BaseForm = ({
const result: Record<string, any> = {} const result: Record<string, any> = {}
formSchemas.forEach((schema) => { formSchemas.forEach((schema) => {
const { show_on } = schema const { show_on } = schema
if (show_on?.length) { const showOn = typeof show_on === 'function' ? show_on(form) : show_on
show_on.forEach((condition) => { if (showOn?.length) {
showOn?.forEach((condition) => {
result[condition.variable] = s.values[condition.variable]
})
}
})
return result
})
const moreOnValues = useStore(form.store, (s: any) => {
const result: Record<string, any> = {}
formSchemas.forEach((schema) => {
const { more_on } = schema
const moreOn = typeof more_on === 'function' ? more_on(form) : more_on
if (moreOn?.length) {
moreOn?.forEach((condition) => {
result[condition.variable] = s.values[condition.variable] result[condition.variable] = s.values[condition.variable]
}) })
} }
@ -135,11 +154,18 @@ const BaseForm = ({
const formSchema = formSchemas?.find(schema => schema.name === field.name) const formSchema = formSchemas?.find(schema => schema.name === field.name)
if (formSchema) { if (formSchema) {
const { more_on = [] } = formSchema
const moreOn = typeof more_on === 'function' ? more_on(form) : more_on
const more = (moreOn || []).every((condition) => {
const conditionValue = moreOnValues[condition.variable]
return Array.isArray(condition.value) ? condition.value.includes(conditionValue) : conditionValue === condition.value
})
return ( return (
<BaseField <BaseField
field={field} field={field}
formSchema={formSchema} formSchema={formSchema}
fieldClassName={fieldClassName ?? formSchema.fieldClassName} fieldClassName={cn(fieldClassName ?? formSchema.fieldClassName, !more ? 'absolute top-[-9999px]' : '')}
labelClassName={labelClassName ?? formSchema.labelClassName} labelClassName={labelClassName ?? formSchema.labelClassName}
inputContainerClassName={inputContainerClassName} inputContainerClassName={inputContainerClassName}
inputClassName={inputClassName} inputClassName={inputClassName}
@ -151,7 +177,7 @@ const BaseForm = ({
} }
return null return null
}, [formSchemas, fieldClassName, labelClassName, inputContainerClassName, inputClassName, disabled, onChange, fieldStates]) }, [formSchemas, fieldClassName, labelClassName, inputContainerClassName, inputClassName, disabled, onChange, moreOnValues, fieldStates])
const renderFieldWrapper = useCallback((formSchema: FormSchema) => { const renderFieldWrapper = useCallback((formSchema: FormSchema) => {
const validators = getValidators(formSchema) const validators = getValidators(formSchema)
@ -159,10 +185,10 @@ const BaseForm = ({
name, name,
show_on = [], show_on = [],
} = formSchema } = formSchema
const showOn = typeof show_on === 'function' ? show_on(form) : show_on
const show = show_on?.every((condition) => { const show = (showOn || []).every((condition) => {
const conditionValue = showOnValues[condition.variable] const conditionValue = showOnValues[condition.variable]
return conditionValue === condition.value return Array.isArray(condition.value) ? condition.value.includes(conditionValue) : conditionValue === condition.value
}) })
if (!show) if (!show)
@ -196,6 +222,28 @@ const BaseForm = ({
onSubmit={handleSubmit} onSubmit={handleSubmit}
> >
{formSchemas.map(renderFieldWrapper)} {formSchemas.map(renderFieldWrapper)}
{
onSubmit && (
<div className='flex justify-end space-x-2'>
{
onCancel && (
<Button
variant='secondary'
onClick={onCancel}
>
{t('common.operation.cancel')}
</Button>
)
}
<Button
variant='primary'
onClick={() => onSubmit(form.getValues())}
>
{t('common.operation.save')}
</Button>
</div>
)
}
</form> </form>
) )
} }

View File

@ -0,0 +1,25 @@
import { memo } from 'react'
import { BaseForm } from '../../components/base'
import type { BaseFormProps } from '../../components/base'
const VariableForm = ({
formSchemas = [],
defaultValues,
ref,
formFromProps,
...rest
}: BaseFormProps) => {
return (
<BaseForm
ref={ref}
formSchemas={formSchemas}
defaultValues={defaultValues}
formClassName='space-y-3'
labelClassName='h-6 flex items-center mb-1 system-sm-medium text-text-secondary'
formFromProps={formFromProps}
{...rest}
/>
)
}
export default memo(VariableForm)

View File

@ -15,13 +15,14 @@ export const useCheckValidated = (form: AnyFormApi, FormSchemas: FormSchema[]) =
const errorArray = Object.keys(fields).reduce((acc: string[], key: string) => { const errorArray = Object.keys(fields).reduce((acc: string[], key: string) => {
const currentSchema = FormSchemas.find(schema => schema.name === key) const currentSchema = FormSchemas.find(schema => schema.name === key)
const { show_on = [] } = currentSchema || {} const { show_on = [] } = currentSchema || {}
const showOnValues = show_on.reduce((acc, condition) => { const showOn = typeof show_on === 'function' ? show_on(form) : show_on
const showOnValues = (showOn || []).reduce((acc, condition) => {
acc[condition.variable] = values[condition.variable] acc[condition.variable] = values[condition.variable]
return acc return acc
}, {} as Record<string, any>) }, {} as Record<string, any>)
const show = show_on?.every((condition) => { const show = (showOn || []).every((condition) => {
const conditionValue = showOnValues[condition.variable] const conditionValue = showOnValues[condition.variable]
return conditionValue === condition.value return Array.isArray(condition.value) ? condition.value.includes(conditionValue) : conditionValue === condition.value
}) })
const errors: any[] = show ? fields[key].errors : [] const errors: any[] = show ? fields[key].errors : []

View File

@ -16,7 +16,7 @@ export type TypeWithI18N<T = string> = {
export type FormShowOnObject = { export type FormShowOnObject = {
variable: string variable: string
value: string value: string | string[]
} }
export enum FormTypeEnum { export enum FormTypeEnum {
@ -33,7 +33,17 @@ export enum FormTypeEnum {
multiToolSelector = 'array[tools]', multiToolSelector = 'array[tools]',
appSelector = 'app-selector', appSelector = 'app-selector',
dynamicSelect = 'dynamic-select', dynamicSelect = 'dynamic-select',
textareaInput = 'textarea-input',
promptInput = 'prompt-input',
objectList = 'object-list',
arrayList = 'array-list',
jsonInput = 'json-input',
collapse = 'collapse',
editMode = 'edit-mode',
boolean = 'boolean', boolean = 'boolean',
booleanList = 'boolean-list',
switch = 'switch',
nodeSelector = 'node-selector', // used in memory variable form
} }
export type FormOption = { export type FormOption = {
@ -53,7 +63,7 @@ export enum FormItemValidateStatusEnum {
} }
export type FormSchema = { export type FormSchema = {
type: FormTypeEnum type: FormTypeEnum | ((form: AnyFormApi) => FormTypeEnum)
name: string name: string
label: string | ReactNode | TypeWithI18N | Record<Locale, string> label: string | ReactNode | TypeWithI18N | Record<Locale, string>
required: boolean required: boolean
@ -61,15 +71,20 @@ export type FormSchema = {
default?: any default?: any
description?: string | TypeWithI18N | Record<Locale, string> description?: string | TypeWithI18N | Record<Locale, string>
tooltip?: string | TypeWithI18N | Record<Locale, string> tooltip?: string | TypeWithI18N | Record<Locale, string>
show_on?: FormShowOnObject[] show_on?: FormShowOnObject[] | ((form: AnyFormApi) => FormShowOnObject[])
more_on?: FormShowOnObject[] | ((form: AnyFormApi) => FormShowOnObject[])
url?: string url?: string
scope?: string scope?: string
help?: string | TypeWithI18N | Record<Locale, string> help?: string | TypeWithI18N | Record<Locale, string>
placeholder?: string | TypeWithI18N | Record<Locale, string> placeholder?: string | TypeWithI18N | Record<Locale, string>
options?: FormOption[] options?: FormOption[]
labelClassName?: string
fieldClassName?: string fieldClassName?: string
labelClassName?: string
inputContainerClassName?: string
inputClassName?: string
validators?: AnyValidators validators?: AnyValidators
selfFormProps?: ((form: AnyFormApi) => Record<string, any>) | Record<string, any>
onChange?: (form: AnyFormApi, v: any) => void
showRadioUI?: boolean showRadioUI?: boolean
disabled?: boolean disabled?: boolean
showCopy?: boolean showCopy?: boolean

View File

@ -0,0 +1,3 @@
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M10.678 1.6502C11.1023 1.50885 11.5679 1.56398 11.9473 1.80108L14.2947 3.26885C14.7333 3.54295 15 4.02396 15 4.54107V5.62505L15.9001 6.30035C16.2777 6.58359 16.5 7.02807 16.5 7.50005V9.75005C16.5 10.222 16.2777 10.6665 15.9001 10.9498L15 11.6251V13.4598C14.9999 13.9767 14.7336 14.4572 14.2954 14.7313L11.9473 16.199C11.6152 16.4066 11.217 16.4748 10.8384 16.3939L10.678 16.3499L9 15.7903L7.32202 16.3499C6.89768 16.4913 6.43213 16.4362 6.05273 16.199L3.70532 14.7313C3.2672 14.4572 3.00013 13.9768 3 13.4598V11.6251L2.09985 10.9498C1.72225 10.6665 1.5 10.222 1.5 9.75005V7.50005C1.50004 7.02809 1.72231 6.5836 2.09985 6.30035L3 5.62505V4.54107C3.00005 4.02394 3.26679 3.54294 3.70532 3.26885L6.05273 1.80108C6.43204 1.56403 6.89766 1.50884 7.32202 1.6502L9 2.20977L10.678 1.6502ZM9.75 3.54058V5.68951L10.8625 6.80206C10.9863 6.76904 11.1159 6.75005 11.25 6.75005C12.0784 6.75005 12.75 7.42165 12.75 8.25005C12.75 9.07848 12.0784 9.75005 11.25 9.75005C10.4216 9.75005 9.75 9.07848 9.75 8.25005C9.75001 8.11594 9.76898 7.98631 9.802 7.8626L8.68945 6.75005C8.40829 6.46885 8.25003 6.08736 8.25 5.68951V3.54058L6.84814 3.0733L4.5 4.54107V5.62505C4.5 6.09705 4.27767 6.54146 3.90015 6.82476L3 7.50005V9.75005L3.90015 10.4253C4.27764 10.7086 4.49996 11.1531 4.5 11.6251V13.459L6.84814 14.9268L8.25 14.4588V12.3106L7.13672 11.1973C7.01316 11.2303 6.88394 11.2501 6.75 11.2501C5.92157 11.2501 5.25 10.5785 5.25 9.75005C5.25003 8.92165 5.92159 8.25005 6.75 8.25005C7.57841 8.25005 8.24997 8.92165 8.25 9.75005C8.25 9.88396 8.23019 10.0132 8.19727 10.1368L9.31055 11.2501C9.59176 11.5313 9.74996 11.9128 9.75 12.3106V14.4588L11.1519 14.9268L13.5 13.459V11.6251C13.5 11.153 13.7224 10.7086 14.0999 10.4253L15 9.75005V7.50005L14.0999 6.82476C13.7224 6.54147 13.5 6.09707 13.5 5.62505V4.54107L11.1519 3.0733L9.75 3.54058Z" fill="#354052"/>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@ -0,0 +1,28 @@
{
"icon": {
"type": "element",
"isRootNode": true,
"name": "svg",
"attributes": {
"width": "18",
"height": "18",
"viewBox": "0 0 18 18",
"fill": "none",
"xmlns": "http://www.w3.org/2000/svg"
},
"children": [
{
"type": "element",
"name": "path",
"attributes": {
"fill-rule": "evenodd",
"clip-rule": "evenodd",
"d": "M10.678 1.6502C11.1023 1.50885 11.5679 1.56398 11.9473 1.80108L14.2947 3.26885C14.7333 3.54295 15 4.02396 15 4.54107V5.62505L15.9001 6.30035C16.2777 6.58359 16.5 7.02807 16.5 7.50005V9.75005C16.5 10.222 16.2777 10.6665 15.9001 10.9498L15 11.6251V13.4598C14.9999 13.9767 14.7336 14.4572 14.2954 14.7313L11.9473 16.199C11.6152 16.4066 11.217 16.4748 10.8384 16.3939L10.678 16.3499L9 15.7903L7.32202 16.3499C6.89768 16.4913 6.43213 16.4362 6.05273 16.199L3.70532 14.7313C3.2672 14.4572 3.00013 13.9768 3 13.4598V11.6251L2.09985 10.9498C1.72225 10.6665 1.5 10.222 1.5 9.75005V7.50005C1.50004 7.02809 1.72231 6.5836 2.09985 6.30035L3 5.62505V4.54107C3.00005 4.02394 3.26679 3.54294 3.70532 3.26885L6.05273 1.80108C6.43204 1.56403 6.89766 1.50884 7.32202 1.6502L9 2.20977L10.678 1.6502ZM9.75 3.54058V5.68951L10.8625 6.80206C10.9863 6.76904 11.1159 6.75005 11.25 6.75005C12.0784 6.75005 12.75 7.42165 12.75 8.25005C12.75 9.07848 12.0784 9.75005 11.25 9.75005C10.4216 9.75005 9.75 9.07848 9.75 8.25005C9.75001 8.11594 9.76898 7.98631 9.802 7.8626L8.68945 6.75005C8.40829 6.46885 8.25003 6.08736 8.25 5.68951V3.54058L6.84814 3.0733L4.5 4.54107V5.62505C4.5 6.09705 4.27767 6.54146 3.90015 6.82476L3 7.50005V9.75005L3.90015 10.4253C4.27764 10.7086 4.49996 11.1531 4.5 11.6251V13.459L6.84814 14.9268L8.25 14.4588V12.3106L7.13672 11.1973C7.01316 11.2303 6.88394 11.2501 6.75 11.2501C5.92157 11.2501 5.25 10.5785 5.25 9.75005C5.25003 8.92165 5.92159 8.25005 6.75 8.25005C7.57841 8.25005 8.24997 8.92165 8.25 9.75005C8.25 9.88396 8.23019 10.0132 8.19727 10.1368L9.31055 11.2501C9.59176 11.5313 9.74996 11.9128 9.75 12.3106V14.4588L11.1519 14.9268L13.5 13.459V11.6251C13.5 11.153 13.7224 10.7086 14.0999 10.4253L15 9.75005V7.50005L14.0999 6.82476C13.7224 6.54147 13.5 6.09707 13.5 5.62505V4.54107L11.1519 3.0733L9.75 3.54058Z",
"fill": "currentColor"
},
"children": []
}
]
},
"name": "Memory"
}

View File

@ -0,0 +1,20 @@
// GENERATE BY script
// DON NOT EDIT IT MANUALLY
import * as React from 'react'
import data from './Memory.json'
import IconBase from '@/app/components/base/icons/IconBase'
import type { IconData } from '@/app/components/base/icons/IconBase'
const Icon = (
{
ref,
...props
}: React.SVGProps<SVGSVGElement> & {
ref?: React.RefObject<React.MutableRefObject<HTMLOrSVGElement>>;
},
) => <IconBase {...props} ref={ref} data={data as IconData} />
Icon.displayName = 'Memory'
export default Icon

View File

@ -6,5 +6,6 @@ export { default as GlobalVariable } from './GlobalVariable'
export { default as Icon3Dots } from './Icon3Dots' export { default as Icon3Dots } from './Icon3Dots'
export { default as LongArrowLeft } from './LongArrowLeft' export { default as LongArrowLeft } from './LongArrowLeft'
export { default as LongArrowRight } from './LongArrowRight' export { default as LongArrowRight } from './LongArrowRight'
export { default as Memory } from './Memory'
export { default as SearchMenu } from './SearchMenu' export { default as SearchMenu } from './SearchMenu'
export { default as Tools } from './Tools' export { default as Tools } from './Tools'

View File

@ -10,6 +10,7 @@ export const LAST_RUN_PLACEHOLDER_TEXT = '{{#last_run#}}'
export const PRE_PROMPT_PLACEHOLDER_TEXT = '{{#pre_prompt#}}' export const PRE_PROMPT_PLACEHOLDER_TEXT = '{{#pre_prompt#}}'
export const UPDATE_DATASETS_EVENT_EMITTER = 'prompt-editor-context-block-update-datasets' export const UPDATE_DATASETS_EVENT_EMITTER = 'prompt-editor-context-block-update-datasets'
export const UPDATE_HISTORY_EVENT_EMITTER = 'prompt-editor-history-block-update-role' export const UPDATE_HISTORY_EVENT_EMITTER = 'prompt-editor-history-block-update-role'
export const UPDATE_WORKFLOW_VARIABLES_EVENT_EMITTER = 'prompt-editor-workflow-variables-block-update-variables'
export const checkHasContextBlock = (text: string) => { export const checkHasContextBlock = (text: string) => {
if (!text) if (!text)

View File

@ -61,6 +61,8 @@ import { VariableValueBlockNode } from './plugins/variable-value-block/node'
import { CustomTextNode } from './plugins/custom-text/node' import { CustomTextNode } from './plugins/custom-text/node'
import OnBlurBlock from './plugins/on-blur-or-focus-block' import OnBlurBlock from './plugins/on-blur-or-focus-block'
import UpdateBlock from './plugins/update-block' import UpdateBlock from './plugins/update-block'
import MemoryPopupPlugin from './plugins/memory-popup-plugin'
import { textToEditorState } from './utils' import { textToEditorState } from './utils'
import type { import type {
ContextBlockType, ContextBlockType,
@ -76,9 +78,11 @@ import type {
import { import {
UPDATE_DATASETS_EVENT_EMITTER, UPDATE_DATASETS_EVENT_EMITTER,
UPDATE_HISTORY_EVENT_EMITTER, UPDATE_HISTORY_EVENT_EMITTER,
UPDATE_WORKFLOW_VARIABLES_EVENT_EMITTER,
} from './constants' } from './constants'
import { useEventEmitterContextContext } from '@/context/event-emitter' import { useEventEmitterContextContext } from '@/context/event-emitter'
import cn from '@/utils/classnames' import cn from '@/utils/classnames'
import PromptEditorProvider from './store/provider'
export type PromptEditorProps = { export type PromptEditorProps = {
instanceId?: string instanceId?: string
@ -174,6 +178,13 @@ const PromptEditor: FC<PromptEditorProps> = ({
payload: historyBlock?.history, payload: historyBlock?.history,
} as any) } as any)
}, [eventEmitter, historyBlock?.history]) }, [eventEmitter, historyBlock?.history])
useEffect(() => {
eventEmitter?.emit({
type: UPDATE_WORKFLOW_VARIABLES_EVENT_EMITTER,
payload: workflowVariableBlock?.variables,
instanceId,
} as any)
}, [eventEmitter, workflowVariableBlock?.variables])
return ( return (
<LexicalComposer initialConfig={{ ...initialConfig, editable }}> <LexicalComposer initialConfig={{ ...initialConfig, editable }}>
@ -198,6 +209,12 @@ const PromptEditor: FC<PromptEditorProps> = ({
} }
ErrorBoundary={LexicalErrorBoundary} ErrorBoundary={LexicalErrorBoundary}
/> />
{workflowVariableBlock?.show && workflowVariableBlock?.isMemorySupported && (
<MemoryPopupPlugin
instanceId={instanceId}
memoryVariables={workflowVariableBlock?.variables?.find(v => v.nodeId === 'memory_block')?.vars || []}
/>
)}
<ComponentPickerBlock <ComponentPickerBlock
triggerString='/' triggerString='/'
contextBlock={contextBlock} contextBlock={contextBlock}
@ -303,4 +320,12 @@ const PromptEditor: FC<PromptEditorProps> = ({
) )
} }
export default PromptEditor const PromptEditorWithProvider = ({ instanceId, ...props }: PromptEditorProps) => {
return (
<PromptEditorProvider instanceId={instanceId}>
<PromptEditor {...props} instanceId={instanceId} />
</PromptEditorProvider>
)
}
export default PromptEditorWithProvider

View File

@ -283,7 +283,19 @@ export const useOptions = (
const workflowVariableOptions = useMemo(() => { const workflowVariableOptions = useMemo(() => {
if (!workflowVariableBlockType?.show) if (!workflowVariableBlockType?.show)
return [] return []
const res = workflowVariableBlockType.variables || [] let res = workflowVariableBlockType.variables || []
if (!workflowVariableBlockType.isMemorySupported) {
res = res.map((v) => {
if (v.nodeId === 'conversation') {
return {
...v,
vars: v.vars.filter(vv => !vv.variable.startsWith('memory_block.')),
}
}
return v
})
}
if(errorMessageBlockType?.show && res.findIndex(v => v.nodeId === 'error_message') === -1) { if(errorMessageBlockType?.show && res.findIndex(v => v.nodeId === 'error_message') === -1) {
res.unshift({ res.unshift({
nodeId: 'error_message', nodeId: 'error_message',

View File

@ -0,0 +1,273 @@
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react'
import { createPortal } from 'react-dom'
import { useTranslation } from 'react-i18next'
import {
autoUpdate,
flip,
offset,
shift,
size,
useFloating,
} from '@floating-ui/react'
import {
RiAddLine,
} from '@remixicon/react'
import { Memory } from '@/app/components/base/icons/src/vender/line/others'
import {
$getSelection,
$isRangeSelection,
} from 'lexical'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { useEventEmitterContextContext } from '@/context/event-emitter'
import { MEMORY_POPUP_SHOW_BY_EVENT_EMITTER, MEMORY_VAR_CREATED_BY_MODAL_BY_EVENT_EMITTER, MEMORY_VAR_MODAL_SHOW_BY_EVENT_EMITTER } from '@/app/components/workflow/nodes/_base/components/prompt/type'
import Divider from '@/app/components/base/divider'
import VariableIcon from '@/app/components/workflow/nodes/_base/components/variable/variable-label/base/variable-icon'
import type {
Var,
} from '@/app/components/workflow/types'
import { INSERT_WORKFLOW_VARIABLE_BLOCK_COMMAND } from '../workflow-variable-block'
import cn from '@/utils/classnames'
export type MemoryPopupProps = {
className?: string
container?: Element | null
instanceId?: string
memoryVariables: Var[]
}
export default function MemoryPopupPlugin({
className,
container,
instanceId,
memoryVariables,
}: MemoryPopupProps) {
const { t } = useTranslation()
const [editor] = useLexicalComposerContext()
const { eventEmitter } = useEventEmitterContextContext()
const [open, setOpen] = useState(false)
const portalRef = useRef<HTMLDivElement | null>(null)
const lastSelectionRef = useRef<Range | null>(null)
const containerEl = useMemo(() => container ?? (typeof document !== 'undefined' ? document.body : null), [container])
const useContainer = !!containerEl && containerEl !== document.body
const memoryVarInNode = memoryVariables.filter(memoryVariable => memoryVariable.memoryVariableNodeId)
const memoryVarInApp = memoryVariables.filter(memoryVariable => !memoryVariable.memoryVariableNodeId)
const { refs, floatingStyles, isPositioned } = useFloating({
placement: 'bottom-start',
middleware: [
offset(0), // fix hide cursor
shift({
padding: 8,
altBoundary: true,
}),
flip(),
size({
apply({ availableWidth, availableHeight, elements }) {
Object.assign(elements.floating.style, {
maxWidth: `${Math.min(400, availableWidth)}px`,
maxHeight: `${Math.min(300, availableHeight)}px`,
overflow: 'auto',
})
},
padding: 8,
}),
],
whileElementsMounted: autoUpdate,
})
const openPortal = useCallback(() => {
const domSelection = window.getSelection()
let range: Range | null = null
if (domSelection && domSelection.rangeCount > 0)
range = domSelection.getRangeAt(0).cloneRange()
else
range = lastSelectionRef.current
if (range) {
const rects = range.getClientRects()
let rect: DOMRect | null = null
if (rects && rects.length)
rect = rects[rects.length - 1]
else
rect = range.getBoundingClientRect()
if (rect.width === 0 && rect.height === 0) {
const root = editor.getRootElement()
if (root) {
const sc = range.startContainer
const node = sc.nodeType === Node.ELEMENT_NODE
? sc as Element
: (sc.parentElement || root)
rect = node.getBoundingClientRect()
if (rect.width === 0 && rect.height === 0)
rect = root.getBoundingClientRect()
}
}
if (rect && !(rect.top === 0 && rect.left === 0 && rect.width === 0 && rect.height === 0)) {
const virtualEl = {
getBoundingClientRect() {
return rect!
},
}
refs.setReference(virtualEl as Element)
}
}
setOpen(true)
}, [setOpen])
const closePortal = useCallback(() => {
setOpen(false)
}, [setOpen])
const handleSelectVariable = useCallback((variable: string[]) => {
editor.dispatchCommand(INSERT_WORKFLOW_VARIABLE_BLOCK_COMMAND, variable)
closePortal()
}, [editor, closePortal])
const handleCreate = useCallback(() => {
eventEmitter?.emit({ type: MEMORY_VAR_MODAL_SHOW_BY_EVENT_EMITTER, instanceId } as any)
closePortal()
}, [eventEmitter, instanceId, closePortal])
eventEmitter?.useSubscription((v: any) => {
if (v.type === MEMORY_POPUP_SHOW_BY_EVENT_EMITTER && v.instanceId === instanceId)
openPortal()
})
eventEmitter?.useSubscription((v: any) => {
if (v.type === MEMORY_VAR_CREATED_BY_MODAL_BY_EVENT_EMITTER && v.instanceId === instanceId)
handleSelectVariable(v.variable)
})
useEffect(() => {
return editor.registerUpdateListener(({ editorState }) => {
editorState.read(() => {
const selection = $getSelection()
if ($isRangeSelection(selection)) {
const domSelection = window.getSelection()
if (domSelection && domSelection.rangeCount > 0)
lastSelectionRef.current = domSelection.getRangeAt(0).cloneRange()
}
})
})
}, [editor])
useEffect(() => {
if (!open)
return
const onMouseDown = (e: MouseEvent) => {
if (!portalRef.current)
return
if (!portalRef.current.contains(e.target as Node))
closePortal()
}
document.addEventListener('mousedown', onMouseDown, false)
return () => document.removeEventListener('mousedown', onMouseDown, false)
}, [open, closePortal])
if (!open || !containerEl)
return null
return createPortal(
<div className='h-0 w-0'>
<div
ref={(node) => {
portalRef.current = node
refs.setFloating(node)
}}
className={cn(
useContainer ? '' : 'z-[999999]',
'absolute rounded-xl shadow-lg backdrop-blur-sm',
className,
)}
style={{
...floatingStyles,
visibility: isPositioned ? 'visible' : 'hidden',
}}
>
<div className='w-[261px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur'>
{memoryVarInNode.length > 0 && (
<>
<div className='flex items-center gap-1 pb-1 pt-2.5'>
<Divider className='!h-px !w-3 bg-divider-subtle' />
<div className='system-2xs-medium-uppercase shrink-0 text-text-tertiary'>{t('workflow.nodes.llm.memory.currentNodeLabel')}</div>
<Divider className='!h-px grow bg-divider-subtle' />
</div>
<div className='p-1'>
{memoryVarInNode.map(variable => (
<div
key={variable.variable}
className='flex cursor-pointer items-center gap-1 rounded-md px-3 py-1 hover:bg-state-base-hover'
onClick={() => handleSelectVariable(['memory_block', variable.variable])}
>
<VariableIcon
variables={['memory_block', '']}
className='text-util-colors-teal-teal-700'
/>
<div title={variable.memoryVariableName} className='system-sm-medium shrink-0 truncate text-text-secondary'>{variable.memoryVariableName}</div>
</div>
))}
</div>
</>
)}
{memoryVarInApp.length > 0 && (
<>
<div className='flex items-center gap-1 pb-1 pt-2.5'>
<Divider className='!h-px !w-3 bg-divider-subtle' />
<div className='system-2xs-medium-uppercase shrink-0 text-text-tertiary'>{t('workflow.nodes.llm.memory.conversationScopeLabel')}</div>
<Divider className='!h-px grow bg-divider-subtle' />
</div>
<div className='p-1'>
{memoryVarInApp.map(variable => (
<div
key={variable.variable}
className='flex cursor-pointer items-center gap-1 rounded-md px-3 py-1 hover:bg-state-base-hover'
onClick={() => handleSelectVariable(['memory_block', variable.variable])}
>
<VariableIcon
variables={['memory_block', '']}
className='text-util-colors-teal-teal-700'
/>
<div title={variable.variable} className='system-sm-medium shrink-0 truncate text-text-secondary'>{variable.memoryVariableName}</div>
</div>
))}
</div>
</>
)}
{!memoryVarInNode.length && !memoryVarInApp.length && (
<div className='p-2'>
<div className='flex flex-col gap-2 rounded-[10px] bg-workflow-process-bg p-4'>
<div className='flex h-10 w-10 items-center justify-center rounded-lg border-[0.5px] border-components-card-border bg-components-card-bg shadow-lg backdrop-blur-sm'>
<Memory className='h-5 w-5 text-util-colors-teal-teal-700' />
</div>
<div className='system-sm-medium text-text-secondary'>{t('workflow.nodes.llm.memory.emptyState')}</div>
</div>
</div>
)}
<div className='system-xs-medium flex cursor-pointer items-center gap-1 border-t border-divider-subtle px-4 py-2 text-text-accent-light-mode-only' onClick={handleCreate}>
<RiAddLine className='h-4 w-4' />
<div>{t('workflow.nodes.llm.memory.createButton')}</div>
</div>
</div>
</div>
</div>,
containerEl,
)
}

View File

@ -1,8 +1,11 @@
import { $insertNodes } from 'lexical' import {
$insertNodes,
} from 'lexical'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext' import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { textToEditorState } from '../utils' import { textToEditorState } from '../utils'
import { CustomTextNode } from './custom-text/node' import { CustomTextNode } from './custom-text/node'
import { CLEAR_HIDE_MENU_TIMEOUT } from './workflow-variable-block' import { CLEAR_HIDE_MENU_TIMEOUT } from './workflow-variable-block'
import { MEMORY_POPUP_SHOW_BY_EVENT_EMITTER } from '@/app/components/workflow/nodes/_base/components/prompt/type'
import { useEventEmitterContextContext } from '@/context/event-emitter' import { useEventEmitterContextContext } from '@/context/event-emitter'
export const PROMPT_EDITOR_UPDATE_VALUE_BY_EVENT_EMITTER = 'PROMPT_EDITOR_UPDATE_VALUE_BY_EVENT_EMITTER' export const PROMPT_EDITOR_UPDATE_VALUE_BY_EVENT_EMITTER = 'PROMPT_EDITOR_UPDATE_VALUE_BY_EVENT_EMITTER'
@ -36,6 +39,18 @@ const UpdateBlock = ({
} }
}) })
eventEmitter?.useSubscription((v: any) => {
if (v.type === MEMORY_POPUP_SHOW_BY_EVENT_EMITTER && v.instanceId === instanceId) {
editor.focus()
editor.update(() => {
const textNode = new CustomTextNode('')
$insertNodes([textNode])
editor.dispatchCommand(CLEAR_HIDE_MENU_TIMEOUT, undefined)
})
}
})
return null return null
} }

View File

@ -19,23 +19,27 @@ import {
DELETE_WORKFLOW_VARIABLE_BLOCK_COMMAND, DELETE_WORKFLOW_VARIABLE_BLOCK_COMMAND,
UPDATE_WORKFLOW_NODES_MAP, UPDATE_WORKFLOW_NODES_MAP,
} from './index' } from './index'
import { isConversationVar, isENV, isGlobalVar, isRagVariableVar, isSystemVar } from '@/app/components/workflow/nodes/_base/components/variable/utils' import { isConversationVar, isENV, isGlobalVar, isMemoryVariable, isRagVariableVar, isSystemVar } from '@/app/components/workflow/nodes/_base/components/variable/utils'
import Tooltip from '@/app/components/base/tooltip' import Tooltip from '@/app/components/base/tooltip'
import { isExceptionVariable } from '@/app/components/workflow/utils' import { isExceptionVariable } from '@/app/components/workflow/utils'
import VarFullPathPanel from '@/app/components/workflow/nodes/_base/components/variable/var-full-path-panel' import VarFullPathPanel from '@/app/components/workflow/nodes/_base/components/variable/var-full-path-panel'
import { Type } from '@/app/components/workflow/nodes/llm/types' import { Type } from '@/app/components/workflow/nodes/llm/types'
import type { ValueSelector, Var } from '@/app/components/workflow/types' import type {
NodeOutPutVar,
ValueSelector,
} from '@/app/components/workflow/types'
import { import {
VariableLabelInEditor, VariableLabelInEditor,
} from '@/app/components/workflow/nodes/_base/components/variable/variable-label' } from '@/app/components/workflow/nodes/_base/components/variable/variable-label'
import { useEventEmitterContextContext } from '@/context/event-emitter'
import { UPDATE_WORKFLOW_VARIABLES_EVENT_EMITTER } from '../../constants'
import { usePromptEditorStore } from '../../store/store'
type WorkflowVariableBlockComponentProps = { type WorkflowVariableBlockComponentProps = {
nodeKey: string nodeKey: string
variables: string[] variables: string[]
workflowNodesMap: WorkflowNodesMap workflowNodesMap: WorkflowNodesMap
environmentVariables?: Var[] availableVariables: NodeOutPutVar[]
conversationVariables?: Var[]
ragVariables?: Var[]
getVarType?: (payload: { getVarType?: (payload: {
nodeId: string, nodeId: string,
valueSelector: ValueSelector, valueSelector: ValueSelector,
@ -47,12 +51,27 @@ const WorkflowVariableBlockComponent = ({
variables, variables,
workflowNodesMap = {}, workflowNodesMap = {},
getVarType, getVarType,
environmentVariables, availableVariables: initialAvailableVariables,
conversationVariables,
ragVariables,
}: WorkflowVariableBlockComponentProps) => { }: WorkflowVariableBlockComponentProps) => {
const { t } = useTranslation() const { t } = useTranslation()
const [editor] = useLexicalComposerContext() const [editor] = useLexicalComposerContext()
const instanceId = usePromptEditorStore(s => s.instanceId)
const { eventEmitter } = useEventEmitterContextContext()
const [availableVariables, setAvailableVariables] = useState<NodeOutPutVar[]>(initialAvailableVariables)
eventEmitter?.useSubscription((v: any) => {
if (v?.type === UPDATE_WORKFLOW_VARIABLES_EVENT_EMITTER && instanceId && v.instanceId === instanceId)
setAvailableVariables(v.payload)
})
const environmentVariables = availableVariables?.find(v => v.nodeId === 'env')?.vars || []
const conversationVariables = availableVariables?.find(v => v.nodeId === 'conversation')?.vars || []
const memoryVariables = conversationVariables?.filter(v => v.variable.startsWith('memory_block.'))
const ragVariables = availableVariables?.reduce<any[]>((acc, curr) => {
if (curr.nodeId === 'rag')
acc.push(...curr.vars)
else
acc.push(...curr.vars.filter(v => v.isRagVariable))
return acc
}, [])
const [ref, isSelected] = useSelectOrDelete(nodeKey, DELETE_WORKFLOW_VARIABLE_BLOCK_COMMAND) const [ref, isSelected] = useSelectOrDelete(nodeKey, DELETE_WORKFLOW_VARIABLE_BLOCK_COMMAND)
const variablesLength = variables.length const variablesLength = variables.length
const isRagVar = isRagVariableVar(variables) const isRagVar = isRagVariableVar(variables)
@ -72,21 +91,23 @@ const WorkflowVariableBlockComponent = ({
let variableValid = true let variableValid = true
const isEnv = isENV(variables) const isEnv = isENV(variables)
const isChatVar = isConversationVar(variables) const isChatVar = isConversationVar(variables)
const isMemoryVar = isMemoryVariable(variables)
const isGlobal = isGlobalVar(variables) const isGlobal = isGlobalVar(variables)
if (isGlobal) if (isGlobal)
return true return true
if (isEnv) { if (isEnv) {
if (environmentVariables) variableValid
variableValid = environmentVariables.some(v => v.variable === `${variables?.[0] ?? ''}.${variables?.[1] ?? ''}`) = environmentVariables.some(v => v.variable === `${variables?.[0] ?? ''}.${variables?.[1] ?? ''}`)
} }
else if (isChatVar) { else if (isChatVar) {
if (conversationVariables) variableValid = conversationVariables.some(v => v.variable === `${variables?.[0] ?? ''}.${variables?.[1] ?? ''}`)
variableValid = conversationVariables.some(v => v.variable === `${variables?.[0] ?? ''}.${variables?.[1] ?? ''}`) }
else if (isMemoryVar) {
variableValid = memoryVariables.some(v => v.variable === `${variables?.[0] ?? ''}.${variables?.[1] ?? ''}`)
} }
else if (isRagVar) { else if (isRagVar) {
if (ragVariables) variableValid = ragVariables.some(v => v.variable === `${variables?.[0] ?? ''}.${variables?.[1] ?? ''}.${variables?.[2] ?? ''}`)
variableValid = ragVariables.some(v => v.variable === `${variables?.[0] ?? ''}.${variables?.[1] ?? ''}.${variables?.[2] ?? ''}`)
} }
else { else {
variableValid = !!node variableValid = !!node
@ -134,11 +155,28 @@ const WorkflowVariableBlockComponent = ({
}) })
}, [node, reactflow, store]) }, [node, reactflow, store])
const memoriedVariables = useMemo(() => {
if (variables[0] === 'memory_block') {
const currentMemoryVariable = memoryVariables?.find(v => v.variable === variables.join('.'))
if (currentMemoryVariable && currentMemoryVariable.memoryVariableName) {
return [
'memory_block',
currentMemoryVariable.memoryVariableName,
]
}
return variables
}
return variables
}, [memoryVariables, variables])
const Item = ( const Item = (
<VariableLabelInEditor <VariableLabelInEditor
nodeType={node?.type} nodeType={node?.type}
nodeTitle={node?.title} nodeTitle={node?.title}
variables={variables} variables={memoriedVariables}
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
handleVariableJump() handleVariableJump()
@ -152,7 +190,7 @@ const WorkflowVariableBlockComponent = ({
) )
if (!node) if (!node)
return Item return <div>{Item}</div>
return ( return (
<Tooltip <Tooltip
@ -160,10 +198,10 @@ const WorkflowVariableBlockComponent = ({
popupContent={ popupContent={
<VarFullPathPanel <VarFullPathPanel
nodeName={node.title} nodeName={node.title}
path={variables.slice(1)} path={memoriedVariables.slice(1)}
varType={getVarType ? getVarType({ varType={getVarType ? getVarType({
nodeId: variables[0], nodeId: memoriedVariables[0],
valueSelector: variables, valueSelector: memoriedVariables,
}) : Type.string} }) : Type.string}
nodeType={node?.type} nodeType={node?.type}
/>} />}

View File

@ -32,6 +32,7 @@ const WorkflowVariableBlock = memo(({
onInsert, onInsert,
onDelete, onDelete,
getVarType, getVarType,
variables: originalVariables,
}: WorkflowVariableBlockType) => { }: WorkflowVariableBlockType) => {
const [editor] = useLexicalComposerContext() const [editor] = useLexicalComposerContext()
@ -50,7 +51,7 @@ const WorkflowVariableBlock = memo(({
INSERT_WORKFLOW_VARIABLE_BLOCK_COMMAND, INSERT_WORKFLOW_VARIABLE_BLOCK_COMMAND,
(variables: string[]) => { (variables: string[]) => {
editor.dispatchCommand(CLEAR_HIDE_MENU_TIMEOUT, undefined) editor.dispatchCommand(CLEAR_HIDE_MENU_TIMEOUT, undefined)
const workflowVariableBlockNode = $createWorkflowVariableBlockNode(variables, workflowNodesMap, getVarType) const workflowVariableBlockNode = $createWorkflowVariableBlockNode(variables, workflowNodesMap, getVarType, originalVariables || [])
$insertNodes([workflowVariableBlockNode]) $insertNodes([workflowVariableBlockNode])
if (onInsert) if (onInsert)

View File

@ -3,7 +3,7 @@ import { DecoratorNode } from 'lexical'
import type { WorkflowVariableBlockType } from '../../types' import type { WorkflowVariableBlockType } from '../../types'
import WorkflowVariableBlockComponent from './component' import WorkflowVariableBlockComponent from './component'
import type { GetVarType } from '../../types' import type { GetVarType } from '../../types'
import type { Var } from '@/app/components/workflow/types' import type { NodeOutPutVar } from '@/app/components/workflow/types'
export type WorkflowNodesMap = WorkflowVariableBlockType['workflowNodesMap'] export type WorkflowNodesMap = WorkflowVariableBlockType['workflowNodesMap']
@ -11,40 +11,40 @@ export type SerializedNode = SerializedLexicalNode & {
variables: string[] variables: string[]
workflowNodesMap: WorkflowNodesMap workflowNodesMap: WorkflowNodesMap
getVarType?: GetVarType getVarType?: GetVarType
environmentVariables?: Var[] availableVariables?: NodeOutPutVar[]
conversationVariables?: Var[]
ragVariables?: Var[]
} }
export class WorkflowVariableBlockNode extends DecoratorNode<React.JSX.Element> { export class WorkflowVariableBlockNode extends DecoratorNode<React.JSX.Element> {
__variables: string[] __variables: string[]
__workflowNodesMap: WorkflowNodesMap __workflowNodesMap: WorkflowNodesMap
__getVarType?: GetVarType __getVarType?: GetVarType
__environmentVariables?: Var[] __availableVariables?: NodeOutPutVar[]
__conversationVariables?: Var[]
__ragVariables?: Var[]
static getType(): string { static getType(): string {
return 'workflow-variable-block' return 'workflow-variable-block'
} }
static clone(node: WorkflowVariableBlockNode): WorkflowVariableBlockNode { static clone(node: WorkflowVariableBlockNode): WorkflowVariableBlockNode {
return new WorkflowVariableBlockNode(node.__variables, node.__workflowNodesMap, node.__getVarType, node.__key, node.__environmentVariables, node.__conversationVariables, node.__ragVariables) return new WorkflowVariableBlockNode(node.__variables, node.__workflowNodesMap, node.__getVarType, node.__key, node.__availableVariables)
} }
isInline(): boolean { isInline(): boolean {
return true return true
} }
constructor(variables: string[], workflowNodesMap: WorkflowNodesMap, getVarType: any, key?: NodeKey, environmentVariables?: Var[], conversationVariables?: Var[], ragVariables?: Var[]) { constructor(
variables: string[],
workflowNodesMap: WorkflowNodesMap,
getVarType: any,
key?: NodeKey,
availableVariables?: NodeOutPutVar[],
) {
super(key) super(key)
this.__variables = variables this.__variables = variables
this.__workflowNodesMap = workflowNodesMap this.__workflowNodesMap = workflowNodesMap
this.__getVarType = getVarType this.__getVarType = getVarType
this.__environmentVariables = environmentVariables this.__availableVariables = availableVariables
this.__conversationVariables = conversationVariables
this.__ragVariables = ragVariables
} }
createDOM(): HTMLElement { createDOM(): HTMLElement {
@ -64,15 +64,13 @@ export class WorkflowVariableBlockNode extends DecoratorNode<React.JSX.Element>
variables={this.__variables} variables={this.__variables}
workflowNodesMap={this.__workflowNodesMap} workflowNodesMap={this.__workflowNodesMap}
getVarType={this.__getVarType!} getVarType={this.__getVarType!}
environmentVariables={this.__environmentVariables} availableVariables={this.__availableVariables || []}
conversationVariables={this.__conversationVariables}
ragVariables={this.__ragVariables}
/> />
) )
} }
static importJSON(serializedNode: SerializedNode): WorkflowVariableBlockNode { static importJSON(serializedNode: SerializedNode): WorkflowVariableBlockNode {
const node = $createWorkflowVariableBlockNode(serializedNode.variables, serializedNode.workflowNodesMap, serializedNode.getVarType, serializedNode.environmentVariables, serializedNode.conversationVariables, serializedNode.ragVariables) const node = $createWorkflowVariableBlockNode(serializedNode.variables, serializedNode.workflowNodesMap, serializedNode.getVarType, serializedNode.availableVariables)
return node return node
} }
@ -84,9 +82,7 @@ export class WorkflowVariableBlockNode extends DecoratorNode<React.JSX.Element>
variables: this.getVariables(), variables: this.getVariables(),
workflowNodesMap: this.getWorkflowNodesMap(), workflowNodesMap: this.getWorkflowNodesMap(),
getVarType: this.getVarType(), getVarType: this.getVarType(),
environmentVariables: this.getEnvironmentVariables(), availableVariables: this.getAvailableVariables(),
conversationVariables: this.getConversationVariables(),
ragVariables: this.getRagVariables(),
} }
} }
@ -105,27 +101,17 @@ export class WorkflowVariableBlockNode extends DecoratorNode<React.JSX.Element>
return self.__getVarType return self.__getVarType
} }
getEnvironmentVariables(): any { getAvailableVariables(): NodeOutPutVar[] {
const self = this.getLatest() const self = this.getLatest()
return self.__environmentVariables return self.__availableVariables || []
}
getConversationVariables(): any {
const self = this.getLatest()
return self.__conversationVariables
}
getRagVariables(): any {
const self = this.getLatest()
return self.__ragVariables
} }
getTextContent(): string { getTextContent(): string {
return `{{#${this.getVariables().join('.')}#}}` return `{{#${this.getVariables().join('.')}#}}`
} }
} }
export function $createWorkflowVariableBlockNode(variables: string[], workflowNodesMap: WorkflowNodesMap, getVarType?: GetVarType, environmentVariables?: Var[], conversationVariables?: Var[], ragVariables?: Var[]): WorkflowVariableBlockNode { export function $createWorkflowVariableBlockNode(variables: string[], workflowNodesMap: WorkflowNodesMap, getVarType?: GetVarType, availableVariables?: NodeOutPutVar[]): WorkflowVariableBlockNode {
return new WorkflowVariableBlockNode(variables, workflowNodesMap, getVarType, undefined, environmentVariables, conversationVariables, ragVariables) return new WorkflowVariableBlockNode(variables, workflowNodesMap, getVarType, undefined, availableVariables)
} }
export function $isWorkflowVariableBlockNode( export function $isWorkflowVariableBlockNode(

View File

@ -21,13 +21,6 @@ const WorkflowVariableBlockReplacementBlock = ({
variables, variables,
}: WorkflowVariableBlockType) => { }: WorkflowVariableBlockType) => {
const [editor] = useLexicalComposerContext() const [editor] = useLexicalComposerContext()
const ragVariables = variables?.reduce<any[]>((acc, curr) => {
if (curr.nodeId === 'rag')
acc.push(...curr.vars)
else
acc.push(...curr.vars.filter(v => v.isRagVariable))
return acc
}, [])
useEffect(() => { useEffect(() => {
if (!editor.hasNodes([WorkflowVariableBlockNode])) if (!editor.hasNodes([WorkflowVariableBlockNode]))
@ -39,7 +32,7 @@ const WorkflowVariableBlockReplacementBlock = ({
onInsert() onInsert()
const nodePathString = textNode.getTextContent().slice(3, -3) const nodePathString = textNode.getTextContent().slice(3, -3)
return $applyNodeReplacement($createWorkflowVariableBlockNode(nodePathString.split('.'), workflowNodesMap, getVarType, variables?.find(o => o.nodeId === 'env')?.vars || [], variables?.find(o => o.nodeId === 'conversation')?.vars || [], ragVariables)) return $applyNodeReplacement($createWorkflowVariableBlockNode(nodePathString.split('.'), workflowNodesMap, getVarType, variables))
}, [onInsert, workflowNodesMap, getVarType, variables]) }, [onInsert, workflowNodesMap, getVarType, variables])
const getMatch = useCallback((text: string) => { const getMatch = useCallback((text: string) => {

View File

@ -0,0 +1,31 @@
import { createContext, useRef } from 'react'
import { createPromptEditorStore } from './store'
type PromptEditorStoreApi = ReturnType<typeof createPromptEditorStore>
type PromptEditorContextType = PromptEditorStoreApi | undefined
export const PromptEditorContext = createContext<PromptEditorContextType>(undefined)
type PromptEditorProviderProps = {
instanceId?: string
children: React.ReactNode
}
const PromptEditorProvider = ({
instanceId,
children,
}: PromptEditorProviderProps) => {
const storeRef = useRef<PromptEditorStoreApi>(undefined)
if (!storeRef.current)
storeRef.current = createPromptEditorStore({ instanceId })
return (
<PromptEditorContext.Provider value={storeRef.current!}>
{children}
</PromptEditorContext.Provider>
)
}
export default PromptEditorProvider

View File

@ -0,0 +1,24 @@
import { useContext } from 'react'
import { createStore, useStore } from 'zustand'
import { PromptEditorContext } from './provider'
type PromptEditorStoreProps = {
instanceId?: string
}
type PromptEditorStore = {
instanceId?: string
}
export const createPromptEditorStore = ({ instanceId }: PromptEditorStoreProps) => {
return createStore<PromptEditorStore>(() => ({
instanceId,
}))
}
export const usePromptEditorStore = <T>(selector: (state: PromptEditorStore) => T): T => {
const store = useContext(PromptEditorContext)
if (!store)
throw new Error('Missing PromptEditorContext.Provider in the tree')
return useStore(store, selector)
}

View File

@ -71,6 +71,7 @@ export type WorkflowVariableBlockType = {
getVarType?: GetVarType getVarType?: GetVarType
showManageInputField?: boolean showManageInputField?: boolean
onManageInputField?: () => void onManageInputField?: () => void
isMemorySupported?: boolean
} }
export type MenuTextMatch = { export type MenuTextMatch = {

View File

@ -5,7 +5,7 @@ import {
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { RiDeleteBinLine } from '@remixicon/react' import { RiDeleteBinLine } from '@remixicon/react'
import { PlusIcon, XMarkIcon } from '@heroicons/react/20/solid' import { PlusIcon, XMarkIcon } from '@heroicons/react/20/solid'
import useSWR from 'swr' import useSWR, { useSWRConfig } from 'swr'
import SecretKeyGenerateModal from './secret-key-generate' import SecretKeyGenerateModal from './secret-key-generate'
import s from './style.module.css' import s from './style.module.css'
import ActionButton from '@/app/components/base/action-button' import ActionButton from '@/app/components/base/action-button'
@ -15,6 +15,7 @@ import CopyFeedback from '@/app/components/base/copy-feedback'
import { import {
createApikey as createAppApikey, createApikey as createAppApikey,
delApikey as delAppApikey, delApikey as delAppApikey,
fetchApiKeysList as fetchAppApiKeysList,
} from '@/service/apps' } from '@/service/apps'
import { import {
createApikey as createDatasetApikey, createApikey as createDatasetApikey,
@ -26,7 +27,6 @@ import Loading from '@/app/components/base/loading'
import Confirm from '@/app/components/base/confirm' import Confirm from '@/app/components/base/confirm'
import useTimestamp from '@/hooks/use-timestamp' import useTimestamp from '@/hooks/use-timestamp'
import { useAppContext } from '@/context/app-context' import { useAppContext } from '@/context/app-context'
import { useAppApiKeys, useInvalidateAppApiKeys } from '@/service/use-apps'
type ISecretKeyModalProps = { type ISecretKeyModalProps = {
isShow: boolean isShow: boolean
@ -45,14 +45,12 @@ const SecretKeyModal = ({
const [showConfirmDelete, setShowConfirmDelete] = useState(false) const [showConfirmDelete, setShowConfirmDelete] = useState(false)
const [isVisible, setVisible] = useState(false) const [isVisible, setVisible] = useState(false)
const [newKey, setNewKey] = useState<CreateApiKeyResponse | undefined>(undefined) const [newKey, setNewKey] = useState<CreateApiKeyResponse | undefined>(undefined)
const invalidateAppApiKeys = useInvalidateAppApiKeys() const { mutate } = useSWRConfig()
const { data: appApiKeys, isLoading: isAppApiKeysLoading } = useAppApiKeys(appId, { enabled: !!appId && isShow }) const commonParams = appId
const { data: datasetApiKeys, isLoading: isDatasetApiKeysLoading, mutate: mutateDatasetApiKeys } = useSWR( ? { url: `/apps/${appId}/api-keys`, params: {} }
!appId && isShow ? { url: '/datasets/api-keys', params: {} } : null, : { url: '/datasets/api-keys', params: {} }
fetchDatasetApiKeysList, const fetchApiKeysList = appId ? fetchAppApiKeysList : fetchDatasetApiKeysList
) const { data: apiKeysList } = useSWR(commonParams, fetchApiKeysList)
const apiKeysList = appId ? appApiKeys : datasetApiKeys
const isApiKeysLoading = appId ? isAppApiKeysLoading : isDatasetApiKeysLoading
const [delKeyID, setDelKeyId] = useState('') const [delKeyID, setDelKeyId] = useState('')
@ -66,10 +64,7 @@ const SecretKeyModal = ({
? { url: `/apps/${appId}/api-keys/${delKeyID}`, params: {} } ? { url: `/apps/${appId}/api-keys/${delKeyID}`, params: {} }
: { url: `/datasets/api-keys/${delKeyID}`, params: {} } : { url: `/datasets/api-keys/${delKeyID}`, params: {} }
await delApikey(params) await delApikey(params)
if (appId) mutate(commonParams)
invalidateAppApiKeys(appId)
else
mutateDatasetApiKeys()
} }
const onCreate = async () => { const onCreate = async () => {
@ -80,10 +75,7 @@ const SecretKeyModal = ({
const res = await createApikey(params) const res = await createApikey(params)
setVisible(true) setVisible(true)
setNewKey(res) setNewKey(res)
if (appId) mutate(commonParams)
invalidateAppApiKeys(appId)
else
mutateDatasetApiKeys()
} }
const generateToken = (token: string) => { const generateToken = (token: string) => {
@ -96,7 +88,7 @@ const SecretKeyModal = ({
<XMarkIcon className="h-6 w-6 cursor-pointer text-text-tertiary" onClick={onClose} /> <XMarkIcon className="h-6 w-6 cursor-pointer text-text-tertiary" onClick={onClose} />
</div> </div>
<p className='mt-1 shrink-0 text-[13px] font-normal leading-5 text-text-tertiary'>{t('appApi.apiKeyModal.apiSecretKeyTips')}</p> <p className='mt-1 shrink-0 text-[13px] font-normal leading-5 text-text-tertiary'>{t('appApi.apiKeyModal.apiSecretKeyTips')}</p>
{isApiKeysLoading && <div className='mt-4'><Loading /></div>} {!apiKeysList && <div className='mt-4'><Loading /></div>}
{ {
!!apiKeysList?.data?.length && ( !!apiKeysList?.data?.length && (
<div className='mt-4 flex grow flex-col overflow-hidden'> <div className='mt-4 flex grow flex-col overflow-hidden'>

View File

@ -214,12 +214,8 @@ export const searchAnything = async (
actionItem?: ActionItem, actionItem?: ActionItem,
dynamicActions?: Record<string, ActionItem>, dynamicActions?: Record<string, ActionItem>,
): Promise<SearchResult[]> => { ): Promise<SearchResult[]> => {
const trimmedQuery = query.trim()
if (actionItem) { if (actionItem) {
const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') const searchTerm = query.replace(actionItem.key, '').replace(actionItem.shortcut, '').trim()
const prefixPattern = new RegExp(`^(${escapeRegExp(actionItem.key)}|${escapeRegExp(actionItem.shortcut)})\\s*`)
const searchTerm = trimmedQuery.replace(prefixPattern, '').trim()
try { try {
return await actionItem.search(query, searchTerm, locale) return await actionItem.search(query, searchTerm, locale)
} }
@ -229,12 +225,10 @@ export const searchAnything = async (
} }
} }
if (trimmedQuery.startsWith('@') || trimmedQuery.startsWith('/')) if (query.startsWith('@') || query.startsWith('/'))
return [] return []
const globalSearchActions = Object.values(dynamicActions || Actions) const globalSearchActions = Object.values(dynamicActions || Actions)
// Exclude slash commands from general search results
.filter(action => action.key !== '/')
// Use Promise.allSettled to handle partial failures gracefully // Use Promise.allSettled to handle partial failures gracefully
const searchPromises = globalSearchActions.map(async (action) => { const searchPromises = globalSearchActions.map(async (action) => {

View File

@ -177,42 +177,31 @@ const GotoAnything: FC<Props> = ({
} }
}, [router]) }, [router])
const dedupedResults = useMemo(() => {
const seen = new Set<string>()
return searchResults.filter((result) => {
const key = `${result.type}-${result.id}`
if (seen.has(key))
return false
seen.add(key)
return true
})
}, [searchResults])
// Group results by type // Group results by type
const groupedResults = useMemo(() => dedupedResults.reduce((acc, result) => { const groupedResults = useMemo(() => searchResults.reduce((acc, result) => {
if (!acc[result.type]) if (!acc[result.type])
acc[result.type] = [] acc[result.type] = []
acc[result.type].push(result) acc[result.type].push(result)
return acc return acc
}, {} as { [key: string]: SearchResult[] }), }, {} as { [key: string]: SearchResult[] }),
[dedupedResults]) [searchResults])
useEffect(() => { useEffect(() => {
if (isCommandsMode) if (isCommandsMode)
return return
if (!dedupedResults.length) if (!searchResults.length)
return return
const currentValueExists = dedupedResults.some(result => `${result.type}-${result.id}` === cmdVal) const currentValueExists = searchResults.some(result => `${result.type}-${result.id}` === cmdVal)
if (!currentValueExists) if (!currentValueExists)
setCmdVal(`${dedupedResults[0].type}-${dedupedResults[0].id}`) setCmdVal(`${searchResults[0].type}-${searchResults[0].id}`)
}, [isCommandsMode, dedupedResults, cmdVal]) }, [isCommandsMode, searchResults, cmdVal])
const emptyResult = useMemo(() => { const emptyResult = useMemo(() => {
if (dedupedResults.length || !searchQuery.trim() || isLoading || isCommandsMode) if (searchResults.length || !searchQuery.trim() || isLoading || isCommandsMode)
return null return null
const isCommandSearch = searchMode !== 'general' const isCommandSearch = searchMode !== 'general'
@ -257,7 +246,7 @@ const GotoAnything: FC<Props> = ({
</div> </div>
</div> </div>
) )
}, [dedupedResults, searchQuery, Actions, searchMode, isLoading, isError, isCommandsMode]) }, [searchResults, searchQuery, Actions, searchMode, isLoading, isError, isCommandsMode])
const defaultUI = useMemo(() => { const defaultUI = useMemo(() => {
if (searchQuery.trim()) if (searchQuery.trim())
@ -441,14 +430,14 @@ const GotoAnything: FC<Props> = ({
{/* Always show footer to prevent height jumping */} {/* Always show footer to prevent height jumping */}
<div className='border-t border-divider-subtle bg-components-panel-bg-blur px-4 py-2 text-xs text-text-tertiary'> <div className='border-t border-divider-subtle bg-components-panel-bg-blur px-4 py-2 text-xs text-text-tertiary'>
<div className='flex min-h-[16px] items-center justify-between'> <div className='flex min-h-[16px] items-center justify-between'>
{(!!dedupedResults.length || isError) ? ( {(!!searchResults.length || isError) ? (
<> <>
<span> <span>
{isError ? ( {isError ? (
<span className='text-red-500'>{t('app.gotoAnything.someServicesUnavailable')}</span> <span className='text-red-500'>{t('app.gotoAnything.someServicesUnavailable')}</span>
) : ( ) : (
<> <>
{t('app.gotoAnything.resultCount', { count: dedupedResults.length })} {t('app.gotoAnything.resultCount', { count: searchResults.length })}
{searchMode !== 'general' && ( {searchMode !== 'general' && (
<span className='ml-2 opacity-60'> <span className='ml-2 opacity-60'>
{t('app.gotoAnything.inScope', { scope: searchMode.replace('@', '') })} {t('app.gotoAnything.inScope', { scope: searchMode.replace('@', '') })}

View File

@ -129,8 +129,8 @@ export const useProviderCredentialsAndLoadBalancing = (
// as ([Record<string, string | boolean | undefined> | undefined, ModelLoadBalancingConfig | undefined]) // as ([Record<string, string | boolean | undefined> | undefined, ModelLoadBalancingConfig | undefined])
} }
export const useModelList = (type: ModelTypeEnum) => { export const useModelList = (type: ModelTypeEnum, enabled?: boolean) => {
const { data, mutate, isLoading } = useSWR(`/workspaces/current/models/model-types/${type}`, fetchModelList) const { data, mutate, isLoading } = useSWR(enabled ? `/workspaces/current/models/model-types/${type}` : null, fetchModelList)
return { return {
data: data?.data || [], data: data?.data || [],
@ -139,8 +139,8 @@ export const useModelList = (type: ModelTypeEnum) => {
} }
} }
export const useDefaultModel = (type: ModelTypeEnum) => { export const useDefaultModel = (type: ModelTypeEnum, enabled?: boolean) => {
const { data, mutate, isLoading } = useSWR(`/workspaces/current/default-model?model_type=${type}`, fetchDefaultModal) const { data, mutate, isLoading } = useSWR(enabled ? `/workspaces/current/default-model?model_type=${type}` : null, fetchDefaultModal)
return { return {
data: data?.data, data: data?.data,

View File

@ -3,6 +3,7 @@
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useParams } from 'next/navigation' import { useParams } from 'next/navigation'
import useSWRInfinite from 'swr/infinite'
import { flatten } from 'lodash-es' import { flatten } from 'lodash-es'
import { produce } from 'immer' import { produce } from 'immer'
import { import {
@ -11,13 +12,33 @@ import {
} from '@remixicon/react' } from '@remixicon/react'
import Nav from '../nav' import Nav from '../nav'
import type { NavItem } from '../nav/nav-selector' import type { NavItem } from '../nav/nav-selector'
import { fetchAppList } from '@/service/apps'
import CreateAppTemplateDialog from '@/app/components/app/create-app-dialog' import CreateAppTemplateDialog from '@/app/components/app/create-app-dialog'
import CreateAppModal from '@/app/components/app/create-app-modal' import CreateAppModal from '@/app/components/app/create-app-modal'
import CreateFromDSLModal from '@/app/components/app/create-from-dsl-modal' import CreateFromDSLModal from '@/app/components/app/create-from-dsl-modal'
import type { AppListResponse } from '@/models/app'
import { useAppContext } from '@/context/app-context' import { useAppContext } from '@/context/app-context'
import { useStore as useAppStore } from '@/app/components/app/store' import { useStore as useAppStore } from '@/app/components/app/store'
import { AppModeEnum } from '@/types/app' import { AppModeEnum } from '@/types/app'
import { useInfiniteAppList } from '@/service/use-apps'
const getKey = (
pageIndex: number,
previousPageData: AppListResponse,
activeTab: string,
keywords: string,
) => {
if (!pageIndex || previousPageData.has_more) {
const params: any = { url: 'apps', params: { page: pageIndex + 1, limit: 30, name: keywords } }
if (activeTab !== 'all')
params.params.mode = activeTab
else
delete params.params.mode
return params
}
return null
}
const AppNav = () => { const AppNav = () => {
const { t } = useTranslation() const { t } = useTranslation()
@ -29,21 +50,17 @@ const AppNav = () => {
const [showCreateFromDSLModal, setShowCreateFromDSLModal] = useState(false) const [showCreateFromDSLModal, setShowCreateFromDSLModal] = useState(false)
const [navItems, setNavItems] = useState<NavItem[]>([]) const [navItems, setNavItems] = useState<NavItem[]>([])
const { const { data: appsData, setSize, mutate } = useSWRInfinite(
data: appsData, appId
fetchNextPage, ? (pageIndex: number, previousPageData: AppListResponse) => getKey(pageIndex, previousPageData, 'all', '')
hasNextPage, : () => null,
refetch, fetchAppList,
} = useInfiniteAppList({ { revalidateFirstPage: false },
page: 1, )
limit: 30,
name: '',
}, { enabled: !!appId })
const handleLoadMore = useCallback(() => { const handleLoadMore = useCallback(() => {
if (hasNextPage) setSize(size => size + 1)
fetchNextPage() }, [setSize])
}, [fetchNextPage, hasNextPage])
const openModal = (state: string) => { const openModal = (state: string) => {
if (state === 'blank') if (state === 'blank')
@ -56,7 +73,7 @@ const AppNav = () => {
useEffect(() => { useEffect(() => {
if (appsData) { if (appsData) {
const appItems = flatten((appsData.pages ?? []).map(appData => appData.data)) const appItems = flatten(appsData?.map(appData => appData.data))
const navItems = appItems.map((app) => { const navItems = appItems.map((app) => {
const link = ((isCurrentWorkspaceEditor, app) => { const link = ((isCurrentWorkspaceEditor, app) => {
if (!isCurrentWorkspaceEditor) { if (!isCurrentWorkspaceEditor) {
@ -115,17 +132,17 @@ const AppNav = () => {
<CreateAppModal <CreateAppModal
show={showNewAppDialog} show={showNewAppDialog}
onClose={() => setShowNewAppDialog(false)} onClose={() => setShowNewAppDialog(false)}
onSuccess={() => refetch()} onSuccess={() => mutate()}
/> />
<CreateAppTemplateDialog <CreateAppTemplateDialog
show={showNewAppTemplateDialog} show={showNewAppTemplateDialog}
onClose={() => setShowNewAppTemplateDialog(false)} onClose={() => setShowNewAppTemplateDialog(false)}
onSuccess={() => refetch()} onSuccess={() => mutate()}
/> />
<CreateFromDSLModal <CreateFromDSLModal
show={showCreateFromDSLModal} show={showCreateFromDSLModal}
onClose={() => setShowCreateFromDSLModal(false)} onClose={() => setShowCreateFromDSLModal(false)}
onSuccess={() => refetch()} onSuccess={() => mutate()}
/> />
</> </>
) )

View File

@ -15,10 +15,32 @@ import type {
OffsetOptions, OffsetOptions,
Placement, Placement,
} from '@floating-ui/react' } from '@floating-ui/react'
import { useInfiniteAppList } from '@/service/use-apps' import useSWRInfinite from 'swr/infinite'
import { fetchAppList } from '@/service/apps'
import type { AppListResponse } from '@/models/app'
const PAGE_SIZE = 20 const PAGE_SIZE = 20
const getKey = (
pageIndex: number,
previousPageData: AppListResponse,
searchText: string,
) => {
if (pageIndex === 0 || (previousPageData && previousPageData.has_more)) {
const params: any = {
url: 'apps',
params: {
page: pageIndex + 1,
limit: PAGE_SIZE,
name: searchText,
},
}
return params
}
return null
}
type Props = { type Props = {
value?: { value?: {
app_id: string app_id: string
@ -50,32 +72,30 @@ const AppSelector: FC<Props> = ({
const [searchText, setSearchText] = useState('') const [searchText, setSearchText] = useState('')
const [isLoadingMore, setIsLoadingMore] = useState(false) const [isLoadingMore, setIsLoadingMore] = useState(false)
const { const { data, isLoading, setSize } = useSWRInfinite(
data, (pageIndex: number, previousPageData: AppListResponse) => getKey(pageIndex, previousPageData, searchText),
isLoading, fetchAppList,
isFetchingNextPage, {
fetchNextPage, revalidateFirstPage: true,
hasNextPage, shouldRetryOnError: false,
} = useInfiniteAppList({ dedupingInterval: 500,
page: 1, errorRetryCount: 3,
limit: PAGE_SIZE, },
name: searchText, )
})
const pages = data?.pages ?? []
const displayedApps = useMemo(() => { const displayedApps = useMemo(() => {
if (!pages.length) return [] if (!data) return []
return pages.flatMap(({ data: apps }) => apps) return data.flatMap(({ data: apps }) => apps)
}, [pages]) }, [data])
const hasMore = hasNextPage ?? true const hasMore = data?.at(-1)?.has_more ?? true
const handleLoadMore = useCallback(async () => { const handleLoadMore = useCallback(async () => {
if (isLoadingMore || isFetchingNextPage || !hasMore) return if (isLoadingMore || !hasMore) return
setIsLoadingMore(true) setIsLoadingMore(true)
try { try {
await fetchNextPage() await setSize((size: number) => size + 1)
} }
finally { finally {
// Add a small delay to ensure state updates are complete // Add a small delay to ensure state updates are complete
@ -83,7 +103,7 @@ const AppSelector: FC<Props> = ({
setIsLoadingMore(false) setIsLoadingMore(false)
}, 300) }, 300)
} }
}, [isLoadingMore, isFetchingNextPage, hasMore, fetchNextPage]) }, [isLoadingMore, hasMore, setSize])
const handleTriggerClick = () => { const handleTriggerClick = () => {
if (disabled) return if (disabled) return
@ -165,7 +185,7 @@ const AppSelector: FC<Props> = ({
onSelect={handleSelectApp} onSelect={handleSelectApp}
scope={scope || 'all'} scope={scope || 'all'}
apps={displayedApps} apps={displayedApps}
isLoading={isLoading || isLoadingMore || isFetchingNextPage} isLoading={isLoading || isLoadingMore}
hasMore={hasMore} hasMore={hasMore}
onLoadMore={handleLoadMore} onLoadMore={handleLoadMore}
searchText={searchText} searchText={searchText}

View File

@ -19,6 +19,7 @@ import {
useWorkflowStartRun, useWorkflowStartRun,
} from '../hooks' } from '../hooks'
import { useWorkflowStore } from '@/app/components/workflow/store' import { useWorkflowStore } from '@/app/components/workflow/store'
import { useFormatMemoryVariables } from '@/app/components/workflow/hooks'
type WorkflowMainProps = Pick<WorkflowProps, 'nodes' | 'edges' | 'viewport'> type WorkflowMainProps = Pick<WorkflowProps, 'nodes' | 'edges' | 'viewport'>
const WorkflowMain = ({ const WorkflowMain = ({
@ -28,12 +29,14 @@ const WorkflowMain = ({
}: WorkflowMainProps) => { }: WorkflowMainProps) => {
const featuresStore = useFeaturesStore() const featuresStore = useFeaturesStore()
const workflowStore = useWorkflowStore() const workflowStore = useWorkflowStore()
const { formatMemoryVariables } = useFormatMemoryVariables()
const handleWorkflowDataUpdate = useCallback((payload: any) => { const handleWorkflowDataUpdate = useCallback((payload: any) => {
const { const {
features, features,
conversation_variables, conversation_variables,
environment_variables, environment_variables,
memory_blocks,
} = payload } = payload
if (features && featuresStore) { if (features && featuresStore) {
const { setFeatures } = featuresStore.getState() const { setFeatures } = featuresStore.getState()
@ -48,7 +51,11 @@ const WorkflowMain = ({
const { setEnvironmentVariables } = workflowStore.getState() const { setEnvironmentVariables } = workflowStore.getState()
setEnvironmentVariables(environment_variables) setEnvironmentVariables(environment_variables)
} }
}, [featuresStore, workflowStore]) if (memory_blocks) {
const { setMemoryVariables } = workflowStore.getState()
setMemoryVariables(formatMemoryVariables(memory_blocks))
}
}, [featuresStore, workflowStore, formatMemoryVariables])
const { const {
doSyncWorkflowDraft, doSyncWorkflowDraft,

View File

@ -27,6 +27,7 @@ export const useNodesSyncDraft = () => {
const { const {
appId, appId,
conversationVariables, conversationVariables,
memoryVariables,
environmentVariables, environmentVariables,
syncWorkflowDraftHash, syncWorkflowDraftHash,
isWorkflowDataLoaded, isWorkflowDataLoaded,
@ -74,6 +75,13 @@ export const useNodesSyncDraft = () => {
}, },
environment_variables: environmentVariables, environment_variables: environmentVariables,
conversation_variables: conversationVariables, conversation_variables: conversationVariables,
memory_blocks: memoryVariables.map(({ value_type, scope, more, node_id, ...rest }) => {
return {
...rest,
node_id: scope === 'node' ? node_id : undefined,
scope,
}
}),
hash: syncWorkflowDraftHash, hash: syncWorkflowDraftHash,
}, },
} }

View File

@ -18,6 +18,7 @@ import {
import type { FetchWorkflowDraftResponse } from '@/types/workflow' import type { FetchWorkflowDraftResponse } from '@/types/workflow'
import { useWorkflowConfig } from '@/service/use-workflow' import { useWorkflowConfig } from '@/service/use-workflow'
import type { FileUploadConfigResponse } from '@/models/common' import type { FileUploadConfigResponse } from '@/models/common'
import { useFormatMemoryVariables } from '@/app/components/workflow/hooks'
import type { Edge, Node } from '@/app/components/workflow/types' import type { Edge, Node } from '@/app/components/workflow/types'
import { BlockEnum } from '@/app/components/workflow/types' import { BlockEnum } from '@/app/components/workflow/types'
import { AppModeEnum } from '@/types/app' import { AppModeEnum } from '@/types/app'
@ -54,6 +55,7 @@ export const useWorkflowInit = () => {
data: fileUploadConfigResponse, data: fileUploadConfigResponse,
isLoading: isFileUploadConfigLoading, isLoading: isFileUploadConfigLoading,
} = useWorkflowConfig('/files/upload', handleUpdateWorkflowFileUploadConfig) } = useWorkflowConfig('/files/upload', handleUpdateWorkflowFileUploadConfig)
const { formatMemoryVariables } = useFormatMemoryVariables()
const handleGetInitialWorkflowData = useCallback(async () => { const handleGetInitialWorkflowData = useCallback(async () => {
try { try {
@ -66,6 +68,7 @@ export const useWorkflowInit = () => {
}, {} as Record<string, string>), }, {} as Record<string, string>),
environmentVariables: res.environment_variables?.map(env => env.value_type === 'secret' ? { ...env, value: '[__HIDDEN__]' } : env) || [], environmentVariables: res.environment_variables?.map(env => env.value_type === 'secret' ? { ...env, value: '[__HIDDEN__]' } : env) || [],
conversationVariables: res.conversation_variables || [], conversationVariables: res.conversation_variables || [],
memoryVariables: formatMemoryVariables((res.memory_blocks || [])),
isWorkflowDataLoaded: true, isWorkflowDataLoaded: true,
}) })
setSyncWorkflowDraftHash(res.hash) setSyncWorkflowDraftHash(res.hash)

View File

@ -3,10 +3,12 @@ import { useWorkflowStore } from '@/app/components/workflow/store'
import { fetchWorkflowDraft } from '@/service/workflow' import { fetchWorkflowDraft } from '@/service/workflow'
import type { WorkflowDataUpdater } from '@/app/components/workflow/types' import type { WorkflowDataUpdater } from '@/app/components/workflow/types'
import { useWorkflowUpdate } from '@/app/components/workflow/hooks' import { useWorkflowUpdate } from '@/app/components/workflow/hooks'
import { useFormatMemoryVariables } from '@/app/components/workflow/hooks'
export const useWorkflowRefreshDraft = () => { export const useWorkflowRefreshDraft = () => {
const workflowStore = useWorkflowStore() const workflowStore = useWorkflowStore()
const { handleUpdateWorkflowCanvas } = useWorkflowUpdate() const { handleUpdateWorkflowCanvas } = useWorkflowUpdate()
const { formatMemoryVariables } = useFormatMemoryVariables()
const handleRefreshWorkflowDraft = useCallback(() => { const handleRefreshWorkflowDraft = useCallback(() => {
const { const {
@ -16,6 +18,7 @@ export const useWorkflowRefreshDraft = () => {
setEnvironmentVariables, setEnvironmentVariables,
setEnvSecrets, setEnvSecrets,
setConversationVariables, setConversationVariables,
setMemoryVariables,
setIsWorkflowDataLoaded, setIsWorkflowDataLoaded,
isWorkflowDataLoaded, isWorkflowDataLoaded,
debouncedSyncWorkflowDraft, debouncedSyncWorkflowDraft,
@ -44,6 +47,7 @@ export const useWorkflowRefreshDraft = () => {
}, {} as Record<string, string>)) }, {} as Record<string, string>))
setEnvironmentVariables(response.environment_variables?.map(env => env.value_type === 'secret' ? { ...env, value: '[__HIDDEN__]' } : env) || []) setEnvironmentVariables(response.environment_variables?.map(env => env.value_type === 'secret' ? { ...env, value: '[__HIDDEN__]' } : env) || [])
setConversationVariables(response.conversation_variables || []) setConversationVariables(response.conversation_variables || [])
setMemoryVariables(formatMemoryVariables((response.memory_blocks || [])))
setIsWorkflowDataLoaded(true) setIsWorkflowDataLoaded(true)
}) })
.catch(() => { .catch(() => {
@ -53,7 +57,7 @@ export const useWorkflowRefreshDraft = () => {
.finally(() => { .finally(() => {
setIsSyncingWorkflowDraft(false) setIsSyncingWorkflowDraft(false)
}) })
}, [handleUpdateWorkflowCanvas, workflowStore]) }, [handleUpdateWorkflowCanvas, workflowStore, formatMemoryVariables])
return { return {
handleRefreshWorkflowDraft, handleRefreshWorkflowDraft,

View File

@ -603,6 +603,7 @@ export const useWorkflowRun = () => {
baseSseOptions.onTTSEnd, baseSseOptions.onTTSEnd,
baseSseOptions.onTextReplace, baseSseOptions.onTextReplace,
baseSseOptions.onAgentLog, baseSseOptions.onAgentLog,
baseSseOptions.onMemoryUpdate,
baseSseOptions.onDataSourceNodeProcessing, baseSseOptions.onDataSourceNodeProcessing,
baseSseOptions.onDataSourceNodeCompleted, baseSseOptions.onDataSourceNodeCompleted,
baseSseOptions.onDataSourceNodeError, baseSseOptions.onDataSourceNodeError,

View File

@ -22,5 +22,6 @@ export * from './use-DSL'
export * from './use-inspect-vars-crud' export * from './use-inspect-vars-crud'
export * from './use-set-workflow-vars-with-value' export * from './use-set-workflow-vars-with-value'
export * from './use-workflow-search' export * from './use-workflow-search'
export * from './use-memory-variable'
export * from './use-auto-generate-webhook-url' export * from './use-auto-generate-webhook-url'
export * from './use-serial-async-callback' export * from './use-serial-async-callback'

View File

@ -51,7 +51,7 @@ export const useSetWorkflowVarsWithValue = ({
const { getNodes } = store.getState() const { getNodes } = store.getState()
const nodeArr = getNodes() const nodeArr = getNodes()
const allNodesOutputVars = toNodeOutputVars(nodeArr, false, () => true, [], [], [], passedInAllPluginInfoList || allPluginInfoList, passedInSchemaTypeDefinitions || schemaTypeDefinitions) const allNodesOutputVars = toNodeOutputVars(nodeArr, false, () => true, [], [], [], [], passedInAllPluginInfoList || allPluginInfoList, passedInSchemaTypeDefinitions || schemaTypeDefinitions)
const nodesKeyValue: Record<string, Node> = {} const nodesKeyValue: Record<string, Node> = {}
nodeArr.forEach((node) => { nodeArr.forEach((node) => {

View File

@ -132,7 +132,7 @@ export const useInspectVarsCrudCommon = ({
mcpTools: mcpTools || [], mcpTools: mcpTools || [],
dataSourceList: dataSourceList || [], dataSourceList: dataSourceList || [],
} }
const currentNodeOutputVars = toNodeOutputVars([currentNode], false, () => true, [], [], [], allPluginInfoList, schemaTypeDefinitions) const currentNodeOutputVars = toNodeOutputVars([currentNode], false, () => true, [], [], [], [], allPluginInfoList, schemaTypeDefinitions)
const vars = await fetchNodeInspectVars(flowType, flowId, nodeId) const vars = await fetchNodeInspectVars(flowType, flowId, nodeId)
const varsWithSchemaType = vars.map((varItem) => { const varsWithSchemaType = vars.map((varItem) => {
const schemaType = currentNodeOutputVars[0]?.vars.find(v => v.variable === varItem.name)?.schemaType || '' const schemaType = currentNodeOutputVars[0]?.vars.find(v => v.variable === varItem.name)?.schemaType || ''

View File

@ -0,0 +1,50 @@
import {
useCallback,
} from 'react'
import {
useStore,
useWorkflowStore,
} from '@/app/components/workflow/store'
import { ChatVarType } from '@/app/components/workflow/panel/chat-variable-panel/type'
import type { MemoryVariable } from '@/app/components/workflow/types'
export const useMemoryVariable = () => {
const workflowStore = useWorkflowStore()
const setMemoryVariables = useStore(s => s.setMemoryVariables)
const handleAddMemoryVariable = useCallback((memoryVariable: MemoryVariable) => {
const { memoryVariables } = workflowStore.getState()
setMemoryVariables([memoryVariable, ...memoryVariables])
}, [setMemoryVariables, workflowStore])
const handleUpdateMemoryVariable = useCallback((memoryVariable: MemoryVariable) => {
const { memoryVariables } = workflowStore.getState()
setMemoryVariables(memoryVariables.map(v => v.id === memoryVariable.id ? memoryVariable : v))
}, [setMemoryVariables, workflowStore])
const handleDeleteMemoryVariable = useCallback((memoryVariable: MemoryVariable) => {
const { memoryVariables } = workflowStore.getState()
setMemoryVariables(memoryVariables.filter(v => v.id !== memoryVariable.id))
}, [setMemoryVariables, workflowStore])
return {
handleAddMemoryVariable,
handleUpdateMemoryVariable,
handleDeleteMemoryVariable,
}
}
export const useFormatMemoryVariables = () => {
const formatMemoryVariables = useCallback((memoryVariables: MemoryVariable[]) => {
return memoryVariables.map((v) => {
return {
...v,
value_type: ChatVarType.Memory,
}
})
}, [])
return {
formatMemoryVariables,
}
}

View File

@ -36,6 +36,7 @@ export const useWorkflowVariables = () => {
filterVar, filterVar,
hideEnv, hideEnv,
hideChatVar, hideChatVar,
conversationVariablesFirst,
}: { }: {
parentNode?: Node | null parentNode?: Node | null
beforeNodes: Node[] beforeNodes: Node[]
@ -43,10 +44,12 @@ export const useWorkflowVariables = () => {
filterVar: (payload: Var, selector: ValueSelector) => boolean filterVar: (payload: Var, selector: ValueSelector) => boolean
hideEnv?: boolean hideEnv?: boolean
hideChatVar?: boolean hideChatVar?: boolean
conversationVariablesFirst?: boolean
}): NodeOutPutVar[] => { }): NodeOutPutVar[] => {
const { const {
conversationVariables, conversationVariables,
environmentVariables, environmentVariables,
memoryVariables,
ragPipelineVariables, ragPipelineVariables,
dataSourceList, dataSourceList,
} = workflowStore.getState() } = workflowStore.getState()
@ -57,6 +60,7 @@ export const useWorkflowVariables = () => {
isChatMode, isChatMode,
environmentVariables: hideEnv ? [] : environmentVariables, environmentVariables: hideEnv ? [] : environmentVariables,
conversationVariables: (isChatMode && !hideChatVar) ? conversationVariables : [], conversationVariables: (isChatMode && !hideChatVar) ? conversationVariables : [],
memoryVariables: isChatMode ? memoryVariables : [],
ragVariables: ragPipelineVariables, ragVariables: ragPipelineVariables,
filterVar, filterVar,
allPluginInfoList: { allPluginInfoList: {
@ -67,6 +71,7 @@ export const useWorkflowVariables = () => {
dataSourceList: dataSourceList || [], dataSourceList: dataSourceList || [],
}, },
schemaTypeDefinitions, schemaTypeDefinitions,
conversationVariablesFirst,
}) })
}, [t, workflowStore, schemaTypeDefinitions, buildInTools, customTools, workflowTools, mcpTools]) }, [t, workflowStore, schemaTypeDefinitions, buildInTools, customTools, workflowTools, mcpTools])

View File

@ -13,6 +13,7 @@ type CollapseProps = {
onCollapse?: (collapsed: boolean) => void onCollapse?: (collapsed: boolean) => void
operations?: ReactNode operations?: ReactNode
hideCollapseIcon?: boolean hideCollapseIcon?: boolean
triggerClassName?: string
} }
const Collapse = ({ const Collapse = ({
disabled, disabled,
@ -22,6 +23,7 @@ const Collapse = ({
onCollapse, onCollapse,
operations, operations,
hideCollapseIcon, hideCollapseIcon,
triggerClassName,
}: CollapseProps) => { }: CollapseProps) => {
const [collapsedLocal, setCollapsedLocal] = useState(true) const [collapsedLocal, setCollapsedLocal] = useState(true)
const collapsedMerged = collapsed !== undefined ? collapsed : collapsedLocal const collapsedMerged = collapsed !== undefined ? collapsed : collapsedLocal
@ -42,7 +44,7 @@ const Collapse = ({
<> <>
<div className='group/collapse flex items-center'> <div className='group/collapse flex items-center'>
<div <div
className='ml-4 flex grow items-center' className={cn('ml-4 flex grow items-center', triggerClassName)}
onClick={() => { onClick={() => {
if (!disabled) { if (!disabled) {
setCollapsedLocal(!collapsedMerged) setCollapsedLocal(!collapsedMerged)

View File

@ -0,0 +1,27 @@
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import { Memory } from '@/app/components/base/icons/src/vender/line/others'
type Props = {
onAddMemory: () => void
}
const AddMemoryButton = ({ onAddMemory }: Props) => {
const { t } = useTranslation()
return (
<div className='ml-1.5 mt-2.5'>
<Button
variant='ghost'
size='small'
className='text-text-tertiary'
onClick={onAddMemory}
>
<Memory className='h-3.5 w-3.5' />
<span className='ml-1'>{t('workflow.nodes.llm.memory.addButton')}</span>
</Button>
</div>
)
}
export default AddMemoryButton

View File

@ -36,6 +36,9 @@ import Switch from '@/app/components/base/switch'
import { Jinja } from '@/app/components/base/icons/src/vender/workflow' import { Jinja } from '@/app/components/base/icons/src/vender/workflow'
import { useStore } from '@/app/components/workflow/store' import { useStore } from '@/app/components/workflow/store'
import { useWorkflowVariableType } from '@/app/components/workflow/hooks' import { useWorkflowVariableType } from '@/app/components/workflow/hooks'
import AddMemoryButton from './add-memory-button'
import { MEMORY_POPUP_SHOW_BY_EVENT_EMITTER } from './type'
import MemoryCreateButton from '@/app/components/workflow/nodes/llm/components/memory-system/memory-create-button'
type Props = { type Props = {
className?: string className?: string
@ -75,10 +78,11 @@ type Props = {
titleTooltip?: ReactNode titleTooltip?: ReactNode
inputClassName?: string inputClassName?: string
editorContainerClassName?: string editorContainerClassName?: string
placeholder?: string placeholder?: string | React.JSX.Element
placeholderClassName?: string placeholderClassName?: string
titleClassName?: string titleClassName?: string
required?: boolean required?: boolean
isMemorySupported?: boolean
} }
const Editor: FC<Props> = ({ const Editor: FC<Props> = ({
@ -118,6 +122,7 @@ const Editor: FC<Props> = ({
titleClassName, titleClassName,
editorContainerClassName, editorContainerClassName,
required, required,
isMemorySupported,
}) => { }) => {
const { t } = useTranslation() const { t } = useTranslation()
const { eventEmitter } = useEventEmitterContextContext() const { eventEmitter } = useEventEmitterContextContext()
@ -153,170 +158,182 @@ const Editor: FC<Props> = ({
const pipelineId = useStore(s => s.pipelineId) const pipelineId = useStore(s => s.pipelineId)
const setShowInputFieldPanel = useStore(s => s.setShowInputFieldPanel) const setShowInputFieldPanel = useStore(s => s.setShowInputFieldPanel)
const handleAddMemory = () => {
setFocus()
eventEmitter?.emit({ type: MEMORY_POPUP_SHOW_BY_EVENT_EMITTER, instanceId } as any)
}
return ( return (
<Wrap className={cn(className, wrapClassName)} style={wrapStyle} isInNode isExpand={isExpand}> <>
<div ref={ref} className={cn(isFocus ? (gradientBorder && 'bg-gradient-to-r from-components-input-border-active-prompt-1 to-components-input-border-active-prompt-2') : 'bg-transparent', isExpand && 'h-full', '!rounded-[9px] p-0.5', containerClassName)}> <Wrap className={cn(className, wrapClassName)} style={wrapStyle} isInNode isExpand={isExpand}>
<div className={cn(isFocus ? 'bg-background-default' : 'bg-components-input-bg-normal', isExpand && 'flex h-full flex-col', 'rounded-lg', containerClassName)}> <div ref={ref} className={cn(isFocus ? (gradientBorder && 'bg-gradient-to-r from-components-input-border-active-prompt-1 to-components-input-border-active-prompt-2') : 'bg-transparent', isExpand && 'h-full', '!rounded-[9px] p-0.5', containerClassName)}>
<div className={cn('flex items-center justify-between pl-3 pr-2 pt-1', headerClassName)}> <div className={cn(isFocus ? 'bg-background-default' : 'bg-components-input-bg-normal', isExpand && 'flex h-full flex-col', 'rounded-lg', containerClassName)}>
<div className='flex gap-2'> <div className={cn('flex items-center justify-between pl-3 pr-2 pt-1', headerClassName)}>
<div className={cn('text-xs font-semibold uppercase leading-4 text-text-secondary', titleClassName)}>{title} {required && <span className='text-text-destructive'>*</span>}</div> <div className='flex gap-2'>
{titleTooltip && <Tooltip popupContent={titleTooltip} />} <div className={cn('text-xs font-semibold uppercase leading-4 text-text-secondary', titleClassName)}>{title} {required && <span className='text-text-destructive'>*</span>}</div>
</div> {titleTooltip && <Tooltip popupContent={titleTooltip} />}
<div className='flex items-center'>
<div className='text-xs font-medium leading-[18px] text-text-tertiary'>{value?.length || 0}</div>
{isSupportPromptGenerator && (
<PromptGeneratorBtn
nodeId={nodeId!}
editorId={editorId}
className='ml-[5px]'
onGenerated={onGenerated}
modelConfig={modelConfig}
currentPrompt={value}
/>
)}
<div className='ml-2 mr-2 h-3 w-px bg-divider-regular'></div>
{/* Operations */}
<div className='flex items-center space-x-[2px]'>
{isSupportJinja && (
<Tooltip
popupContent={
<div>
<div>{t('workflow.common.enableJinja')}</div>
<a className='text-text-accent' target='_blank' href='https://jinja.palletsprojects.com/en/2.10.x/'>{t('workflow.common.learnMore')}</a>
</div>
}
>
<div className={cn(editionType === EditionType.jinja2 && 'border-components-button-ghost-bg-hover bg-components-button-ghost-bg-hover', 'flex h-[22px] items-center space-x-0.5 rounded-[5px] border border-transparent px-1.5 hover:border-components-button-ghost-bg-hover')}>
<Jinja className='h-3 w-6 text-text-quaternary' />
<Switch
size='sm'
defaultValue={editionType === EditionType.jinja2}
onChange={(checked) => {
onEditionTypeChange?.(checked ? EditionType.jinja2 : EditionType.basic)
}}
/>
</div>
</Tooltip>
)}
{!readOnly && (
<Tooltip
popupContent={`${t('workflow.common.insertVarTip')}`}
>
<ActionButton onClick={handleInsertVariable}>
<Variable02 className='h-4 w-4' />
</ActionButton>
</Tooltip>
)}
{showRemove && (
<ActionButton onClick={onRemove}>
<RiDeleteBinLine className='h-4 w-4' />
</ActionButton>
)}
{!isCopied
? (
<ActionButton onClick={handleCopy}>
<Copy className='h-4 w-4' />
</ActionButton>
)
: (
<ActionButton>
<CopyCheck className='h-4 w-4' />
</ActionButton>
)
}
<ToggleExpandBtn isExpand={isExpand} onExpandChange={setIsExpand} />
</div> </div>
<div className='flex items-center'>
<div className='text-xs font-medium leading-[18px] text-text-tertiary'>{value?.length || 0}</div>
{isSupportPromptGenerator && (
<PromptGeneratorBtn
nodeId={nodeId!}
editorId={editorId}
className='ml-[5px]'
onGenerated={onGenerated}
modelConfig={modelConfig}
currentPrompt={value}
/>
)}
<div className='ml-2 mr-2 h-3 w-px bg-divider-regular'></div>
{/* Operations */}
<div className='flex items-center space-x-[2px]'>
{isSupportJinja && (
<Tooltip
popupContent={
<div>
<div>{t('workflow.common.enableJinja')}</div>
<a className='text-text-accent' target='_blank' href='https://jinja.palletsprojects.com/en/2.10.x/'>{t('workflow.common.learnMore')}</a>
</div>
}
>
<div className={cn(editionType === EditionType.jinja2 && 'border-components-button-ghost-bg-hover bg-components-button-ghost-bg-hover', 'flex h-[22px] items-center space-x-0.5 rounded-[5px] border border-transparent px-1.5 hover:border-components-button-ghost-bg-hover')}>
<Jinja className='h-3 w-6 text-text-quaternary' />
<Switch
size='sm'
defaultValue={editionType === EditionType.jinja2}
onChange={(checked) => {
onEditionTypeChange?.(checked ? EditionType.jinja2 : EditionType.basic)
}}
/>
</div>
</Tooltip>
)}
{!readOnly && (
<Tooltip
popupContent={`${t('workflow.common.insertVarTip')}`}
>
<ActionButton onClick={handleInsertVariable}>
<Variable02 className='h-4 w-4' />
</ActionButton>
</Tooltip>
)}
{showRemove && (
<ActionButton onClick={onRemove}>
<RiDeleteBinLine className='h-4 w-4' />
</ActionButton>
)}
{!isCopied
? (
<ActionButton onClick={handleCopy}>
<Copy className='h-4 w-4' />
</ActionButton>
)
: (
<ActionButton>
<CopyCheck className='h-4 w-4' />
</ActionButton>
)
}
<ToggleExpandBtn isExpand={isExpand} onExpandChange={setIsExpand} />
</div>
</div>
</div> </div>
</div>
{/* Min: 80 Max: 560. Header: 24 */} {/* Min: 80 Max: 560. Header: 24 */}
<div className={cn('pb-2', isExpand && 'flex grow flex-col')}> <div className={cn('pb-2', isExpand && 'flex grow flex-col', isMemorySupported && isFocus && 'pb-1.5')}>
{!(isSupportJinja && editionType === EditionType.jinja2) {!(isSupportJinja && editionType === EditionType.jinja2)
? ( ? (
<div className={cn(isExpand ? 'grow' : 'max-h-[536px]', 'relative min-h-[56px] overflow-y-auto px-3', editorContainerClassName)}> <>
<PromptEditor <div className={cn(isExpand ? 'grow' : 'max-h-[536px]', 'relative min-h-[56px] overflow-y-auto px-3', editorContainerClassName)}>
key={controlPromptEditorRerenderKey} <PromptEditor
placeholder={placeholder} key={controlPromptEditorRerenderKey}
placeholderClassName={placeholderClassName} placeholder={placeholder}
instanceId={instanceId} placeholderClassName={placeholderClassName}
compact instanceId={instanceId}
className={cn('min-h-[56px]', inputClassName)} compact
style={isExpand ? { height: editorExpandHeight - 5 } : {}} className={cn('min-h-[56px]', inputClassName)}
value={value} style={isExpand ? { height: editorExpandHeight - 5 } : {}}
contextBlock={{ value={value}
show: justVar ? false : isShowContext, contextBlock={{
selectable: !hasSetBlockStatus?.context, show: justVar ? false : isShowContext,
canNotAddContext: true, selectable: !hasSetBlockStatus?.context,
}} canNotAddContext: true,
historyBlock={{ }}
show: justVar ? false : isShowHistory, historyBlock={{
selectable: !hasSetBlockStatus?.history, show: justVar ? false : isShowHistory,
history: { selectable: !hasSetBlockStatus?.history,
user: 'Human', history: {
assistant: 'Assistant', user: 'Human',
}, assistant: 'Assistant',
}} },
queryBlock={{ }}
show: false, // use [sys.query] instead of query block queryBlock={{
selectable: false, show: false, // use [sys.query] instead of query block
}} selectable: false,
workflowVariableBlock={{ }}
show: true, workflowVariableBlock={{
variables: nodesOutputVars || [], show: true,
getVarType: getVarType as any, variables: nodesOutputVars || [],
workflowNodesMap: availableNodes.reduce((acc, node) => { getVarType,
acc[node.id] = { workflowNodesMap: availableNodes.reduce((acc, node) => {
title: node.data.title, acc[node.id] = {
type: node.data.type, title: node.data.title,
width: node.width, type: node.data.type,
height: node.height, width: node.width,
position: node.position, height: node.height,
} position: node.position,
if (node.data.type === BlockEnum.Start) { }
acc.sys = { if (node.data.type === BlockEnum.Start) {
title: t('workflow.blocks.start'), acc.sys = {
type: BlockEnum.Start, title: t('workflow.blocks.start'),
} type: BlockEnum.Start,
} }
return acc }
}, {} as any), return acc
showManageInputField: !!pipelineId, }, {} as any),
onManageInputField: () => setShowInputFieldPanel?.(true), showManageInputField: !!pipelineId,
}} onManageInputField: () => setShowInputFieldPanel?.(true),
onChange={onChange} isMemorySupported,
onBlur={setBlur} }}
onFocus={setFocus} onChange={onChange}
editable={!readOnly} onBlur={setBlur}
isSupportFileVar={isSupportFileVar} onFocus={setFocus}
/> editable={!readOnly}
{/* to patch Editor not support dynamic change editable status */} isSupportFileVar={isSupportFileVar}
{readOnly && <div className='absolute inset-0 z-10'></div>} />
</div> {/* to patch Editor not support dynamic change editable status */}
) {readOnly && <div className='absolute inset-0 z-10'></div>}
: ( </div>
<div className={cn(isExpand ? 'grow' : 'max-h-[536px]', 'relative min-h-[56px] overflow-y-auto px-3', editorContainerClassName)}> {isMemorySupported && <AddMemoryButton onAddMemory={handleAddMemory} />}
<CodeEditor </>
availableVars={nodesOutputVars || []} )
varList={varList} : (
onAddVar={handleAddVariable} <div className={cn(isExpand ? 'grow' : 'max-h-[536px]', 'relative min-h-[56px] overflow-y-auto px-3', editorContainerClassName)}>
isInNode <CodeEditor
readOnly={readOnly} availableVars={nodesOutputVars || []}
language={CodeLanguage.python3} varList={varList}
value={value} onAddVar={handleAddVariable}
onChange={onChange} isInNode
noWrapper readOnly={readOnly}
isExpand={isExpand} language={CodeLanguage.python3}
className={inputClassName} value={value}
/> onChange={onChange}
</div> noWrapper
)} isExpand={isExpand}
className={inputClassName}
/>
</div>
)}
</div>
</div> </div>
</div> </div>
</div> </Wrap>
</Wrap> {isMemorySupported && <MemoryCreateButton nodeId={nodeId || ''} instanceId={instanceId} hideTrigger />}
</>
) )
} }

View File

@ -0,0 +1,3 @@
export const MEMORY_POPUP_SHOW_BY_EVENT_EMITTER = 'MEMORY_POPUP_SHOW_BY_EVENT_EMITTER'
export const MEMORY_VAR_MODAL_SHOW_BY_EVENT_EMITTER = 'MEMORY_VAR_MODAL_SHOW_BY_EVENT_EMITTER'
export const MEMORY_VAR_CREATED_BY_MODAL_BY_EVENT_EMITTER = 'MEMORY_VAR_CREATED_BY_MODAL_BY_EVENT_EMITTER'

View File

@ -39,6 +39,7 @@ import type {
import type { VariableAssignerNodeType } from '@/app/components/workflow/nodes/variable-assigner/types' import type { VariableAssignerNodeType } from '@/app/components/workflow/nodes/variable-assigner/types'
import type { Field as StructField } from '@/app/components/workflow/nodes/llm/types' import type { Field as StructField } from '@/app/components/workflow/nodes/llm/types'
import type { RAGPipelineVariable } from '@/models/pipeline' import type { RAGPipelineVariable } from '@/models/pipeline'
import type { MemoryVariable } from '@/app/components/workflow/types'
import type { WebhookTriggerNodeType } from '@/app/components/workflow/nodes/trigger-webhook/types' import type { WebhookTriggerNodeType } from '@/app/components/workflow/nodes/trigger-webhook/types'
import type { PluginTriggerNodeType } from '@/app/components/workflow/nodes/trigger-plugin/types' import type { PluginTriggerNodeType } from '@/app/components/workflow/nodes/trigger-plugin/types'
import PluginTriggerNodeDefault from '@/app/components/workflow/nodes/trigger-plugin/default' import PluginTriggerNodeDefault from '@/app/components/workflow/nodes/trigger-plugin/default'
@ -86,13 +87,17 @@ export const isConversationVar = (valueSelector: ValueSelector) => {
return valueSelector[0] === 'conversation' return valueSelector[0] === 'conversation'
} }
export const isMemoryVariable = (valueSelector: ValueSelector) => {
return valueSelector[0] === 'memory_block'
}
export const isRagVariableVar = (valueSelector: ValueSelector) => { export const isRagVariableVar = (valueSelector: ValueSelector) => {
if (!valueSelector) return false if (!valueSelector) return false
return valueSelector[0] === 'rag' return valueSelector[0] === 'rag'
} }
export const isSpecialVar = (prefix: string): boolean => { export const isSpecialVar = (prefix: string): boolean => {
return ['sys', 'env', 'conversation', 'rag'].includes(prefix) return ['sys', 'env', 'conversation', 'memory_block', 'rag'].includes(prefix)
} }
export const hasValidChildren = (children: any): boolean => { export const hasValidChildren = (children: any): boolean => {
@ -644,13 +649,25 @@ const formatItem = (
} }
case 'conversation': { case 'conversation': {
res.vars = data.chatVarList.map((chatVar: ConversationVariable) => { res.vars = [
return { ...data.memoryVarList.map((memoryVar: MemoryVariable) => {
variable: `conversation.${chatVar.name}`, return {
type: chatVar.value_type, variable: `memory_block.${memoryVar.node_id ? `${memoryVar.node_id}_` : ''}${memoryVar.id}`,
description: chatVar.description, type: 'memory_block',
} description: '',
}) as Var[] isMemoryVariable: true,
memoryVariableName: memoryVar.name,
memoryVariableNodeId: memoryVar.node_id,
}
}) as Var[],
...data.chatVarList.map((chatVar: ConversationVariable) => {
return {
variable: `conversation.${chatVar.name}`,
type: chatVar.value_type,
description: chatVar.description,
}
}) as Var[],
]
break break
} }
@ -698,11 +715,13 @@ const formatItem = (
(() => { (() => {
const variableArr = v.variable.split('.') const variableArr = v.variable.split('.')
const [first] = variableArr const [first] = variableArr
if (isSpecialVar(first)) return variableArr if (isSpecialVar(first)) return variableArr
return [...selector, ...variableArr] return [...selector, ...variableArr]
})(), })(),
) )
if (isCurrentMatched) return true if (isCurrentMatched) return true
const isFile = v.type === VarType.file const isFile = v.type === VarType.file
@ -777,9 +796,11 @@ export const toNodeOutputVars = (
filterVar = (_payload: Var, _selector: ValueSelector) => true, filterVar = (_payload: Var, _selector: ValueSelector) => true,
environmentVariables: EnvironmentVariable[] = [], environmentVariables: EnvironmentVariable[] = [],
conversationVariables: ConversationVariable[] = [], conversationVariables: ConversationVariable[] = [],
memoryVariables: MemoryVariable[] = [],
ragVariables: RAGPipelineVariable[] = [], ragVariables: RAGPipelineVariable[] = [],
allPluginInfoList: Record<string, ToolWithProvider[]>, allPluginInfoList: Record<string, ToolWithProvider[]>,
schemaTypeDefinitions?: SchemaTypeDefinition[], schemaTypeDefinitions?: SchemaTypeDefinition[],
conversationVariablesFirst: boolean = false,
): NodeOutPutVar[] => { ): NodeOutPutVar[] => {
// ENV_NODE data format // ENV_NODE data format
const ENV_NODE = { const ENV_NODE = {
@ -796,6 +817,7 @@ export const toNodeOutputVars = (
data: { data: {
title: 'CONVERSATION', title: 'CONVERSATION',
type: 'conversation', type: 'conversation',
memoryVarList: memoryVariables,
chatVarList: conversationVariables, chatVarList: conversationVariables,
}, },
} }
@ -995,6 +1017,7 @@ export const getVarType = ({
isConstant, isConstant,
environmentVariables = [], environmentVariables = [],
conversationVariables = [], conversationVariables = [],
memoryVariables = [],
ragVariables = [], ragVariables = [],
allPluginInfoList, allPluginInfoList,
schemaTypeDefinitions, schemaTypeDefinitions,
@ -1009,6 +1032,7 @@ export const getVarType = ({
isConstant?: boolean; isConstant?: boolean;
environmentVariables?: EnvironmentVariable[]; environmentVariables?: EnvironmentVariable[];
conversationVariables?: ConversationVariable[]; conversationVariables?: ConversationVariable[];
memoryVariables?: MemoryVariable[];
ragVariables?: RAGPipelineVariable[]; ragVariables?: RAGPipelineVariable[];
allPluginInfoList: Record<string, ToolWithProvider[]>; allPluginInfoList: Record<string, ToolWithProvider[]>;
schemaTypeDefinitions?: SchemaTypeDefinition[]; schemaTypeDefinitions?: SchemaTypeDefinition[];
@ -1022,6 +1046,7 @@ export const getVarType = ({
undefined, undefined,
environmentVariables, environmentVariables,
conversationVariables, conversationVariables,
memoryVariables,
ragVariables, ragVariables,
allPluginInfoList, allPluginInfoList,
schemaTypeDefinitions, schemaTypeDefinitions,
@ -1150,10 +1175,12 @@ export const toNodeAvailableVars = ({
isChatMode, isChatMode,
environmentVariables, environmentVariables,
conversationVariables, conversationVariables,
memoryVariables,
ragVariables, ragVariables,
filterVar, filterVar,
allPluginInfoList, allPluginInfoList,
schemaTypeDefinitions, schemaTypeDefinitions,
conversationVariablesFirst,
}: { }: {
parentNode?: Node | null; parentNode?: Node | null;
t?: any; t?: any;
@ -1164,11 +1191,14 @@ export const toNodeAvailableVars = ({
environmentVariables?: EnvironmentVariable[]; environmentVariables?: EnvironmentVariable[];
// chat var // chat var
conversationVariables?: ConversationVariable[]; conversationVariables?: ConversationVariable[];
// memory variables
memoryVariables?: MemoryVariable[];
// rag variables // rag variables
ragVariables?: RAGPipelineVariable[]; ragVariables?: RAGPipelineVariable[];
filterVar: (payload: Var, selector: ValueSelector) => boolean; filterVar: (payload: Var, selector: ValueSelector) => boolean;
allPluginInfoList: Record<string, ToolWithProvider[]>; allPluginInfoList: Record<string, ToolWithProvider[]>;
schemaTypeDefinitions?: SchemaTypeDefinition[]; schemaTypeDefinitions?: SchemaTypeDefinition[];
conversationVariablesFirst?: boolean
}): NodeOutPutVar[] => { }): NodeOutPutVar[] => {
const beforeNodesOutputVars = toNodeOutputVars( const beforeNodesOutputVars = toNodeOutputVars(
beforeNodes, beforeNodes,
@ -1176,9 +1206,11 @@ export const toNodeAvailableVars = ({
filterVar, filterVar,
environmentVariables, environmentVariables,
conversationVariables, conversationVariables,
memoryVariables,
ragVariables, ragVariables,
allPluginInfoList, allPluginInfoList,
schemaTypeDefinitions, schemaTypeDefinitions,
conversationVariablesFirst,
) )
const isInIteration = parentNode?.data.type === BlockEnum.Iteration const isInIteration = parentNode?.data.type === BlockEnum.Iteration
if (isInIteration) { if (isInIteration) {
@ -1191,6 +1223,7 @@ export const toNodeAvailableVars = ({
isChatMode, isChatMode,
environmentVariables, environmentVariables,
conversationVariables, conversationVariables,
memoryVariables,
allPluginInfoList, allPluginInfoList,
schemaTypeDefinitions, schemaTypeDefinitions,
}) })

View File

@ -65,6 +65,7 @@ const Item: FC<ItemProps> = ({
const isSys = itemData.variable.startsWith('sys.') const isSys = itemData.variable.startsWith('sys.')
const isEnv = itemData.variable.startsWith('env.') const isEnv = itemData.variable.startsWith('env.')
const isChatVar = itemData.variable.startsWith('conversation.') const isChatVar = itemData.variable.startsWith('conversation.')
const isMemoryVar = itemData.variable.startsWith('memory_block')
const isRagVariable = itemData.isRagVariable const isRagVariable = itemData.isRagVariable
const flatVarIcon = useMemo(() => { const flatVarIcon = useMemo(() => {
if (!isFlat) if (!isFlat)
@ -152,7 +153,7 @@ const Item: FC<ItemProps> = ({
if (isFlat) { if (isFlat) {
onChange([itemData.variable], itemData) onChange([itemData.variable], itemData)
} }
else if (isSys || isEnv || isChatVar || isRagVariable) { // system variable | environment variable | conversation variable else if (isSys || isEnv || isChatVar || isMemoryVar || isRagVariable) { // system variable | environment variable | conversation variable
onChange([...objPath, ...itemData.variable.split('.')], itemData) onChange([...objPath, ...itemData.variable.split('.')], itemData)
} }
else { else {
@ -162,10 +163,16 @@ const Item: FC<ItemProps> = ({
const variableCategory = useMemo(() => { const variableCategory = useMemo(() => {
if (isEnv) return 'environment' if (isEnv) return 'environment'
if (isChatVar) return 'conversation' if (isChatVar) return 'conversation'
if (isMemoryVar) return 'memory_block'
if (isLoopVar) return 'loop' if (isLoopVar) return 'loop'
if (isRagVariable) return 'rag' if (isRagVariable) return 'rag'
return 'system' return 'system'
}, [isEnv, isChatVar, isSys, isLoopVar, isRagVariable]) }, [isEnv, isChatVar, isMemoryVar, isSys, isLoopVar, isRagVariable])
const variableType = useMemo(() => {
if (itemData.type === 'memory_block')
return 'memory'
return itemData.type
}, [itemData.type])
return ( return (
<PortalToFollowElem <PortalToFollowElem
open={open} open={open}
@ -193,7 +200,7 @@ const Item: FC<ItemProps> = ({
/>} />}
{isFlat && flatVarIcon} {isFlat && flatVarIcon}
{!isEnv && !isChatVar && !isRagVariable && ( {!isEnv && !isChatVar && !isMemoryVar && !isRagVariable && (
<div title={itemData.variable} className='system-sm-medium ml-1 w-0 grow truncate text-text-secondary'>{varName}</div> <div title={itemData.variable} className='system-sm-medium ml-1 w-0 grow truncate text-text-secondary'>{varName}</div>
)} )}
{isEnv && ( {isEnv && (
@ -202,11 +209,15 @@ const Item: FC<ItemProps> = ({
{isChatVar && ( {isChatVar && (
<div title={itemData.des} className='system-sm-medium ml-1 w-0 grow truncate text-text-secondary'>{itemData.variable.replace('conversation.', '')}</div> <div title={itemData.des} className='system-sm-medium ml-1 w-0 grow truncate text-text-secondary'>{itemData.variable.replace('conversation.', '')}</div>
)} )}
{isMemoryVar && (
<div title={itemData.memoryVariableName} className='system-sm-medium ml-1 w-0 grow truncate text-text-secondary'>{itemData.memoryVariableName}</div>
)
}
{isRagVariable && ( {isRagVariable && (
<div title={itemData.des} className='system-sm-medium ml-1 w-0 grow truncate text-text-secondary'>{itemData.variable.split('.').slice(-1)[0]}</div> <div title={itemData.des} className='system-sm-medium ml-1 w-0 grow truncate text-text-secondary'>{itemData.variable.split('.').slice(-1)[0]}</div>
)} )}
</div> </div>
<div className='ml-1 shrink-0 text-xs font-normal capitalize text-text-tertiary'>{(preferSchemaType && itemData.schemaType) ? itemData.schemaType : itemData.type}</div> <div className='ml-1 shrink-0 text-xs font-normal capitalize text-text-tertiary'>{(preferSchemaType && itemData.schemaType) ? itemData.schemaType : variableType}</div>
{ {
(isObj || isStructureOutput) && ( (isObj || isStructureOutput) && (
<ChevronRight className={cn('ml-0.5 h-3 w-3 text-text-quaternary', isHovering && 'text-text-tertiary')} /> <ChevronRight className={cn('ml-0.5 h-3 w-3 text-text-quaternary', isHovering && 'text-text-tertiary')} />

View File

@ -22,6 +22,7 @@ const VariableLabel = ({
errorMsg, errorMsg,
onClick, onClick,
isExceptionVariable, isExceptionVariable,
isMemoryVariable,
ref, ref,
notShowFullPath, notShowFullPath,
rightSlot, rightSlot,

View File

@ -1,12 +1,13 @@
import { useMemo } from 'react' import { useMemo } from 'react'
import { Variable02 } from '@/app/components/base/icons/src/vender/solid/development' import { Variable02 } from '@/app/components/base/icons/src/vender/solid/development'
import { BubbleX, Env, GlobalVariable } from '@/app/components/base/icons/src/vender/line/others' import { BubbleX, Env, GlobalVariable, Memory } from '@/app/components/base/icons/src/vender/line/others'
import { Loop } from '@/app/components/base/icons/src/vender/workflow' import { Loop } from '@/app/components/base/icons/src/vender/workflow'
import { InputField } from '@/app/components/base/icons/src/vender/pipeline' import { InputField } from '@/app/components/base/icons/src/vender/pipeline'
import { import {
isConversationVar, isConversationVar,
isENV, isENV,
isGlobalVar, isGlobalVar,
isMemoryVariable,
isRagVariableVar, isRagVariableVar,
isSystemVar, isSystemVar,
} from '../utils' } from '../utils'
@ -23,6 +24,9 @@ export const useVarIcon = (variables: string[], variableCategory?: VarInInspectT
if (isENV(variables) || variableCategory === VarInInspectType.environment || variableCategory === 'environment') if (isENV(variables) || variableCategory === VarInInspectType.environment || variableCategory === 'environment')
return Env return Env
if (isMemoryVariable(variables) || variableCategory === VarInInspectType.memory || variableCategory === 'memory_block')
return Memory
if (isConversationVar(variables) || variableCategory === VarInInspectType.conversation || variableCategory === 'conversation') if (isConversationVar(variables) || variableCategory === VarInInspectType.conversation || variableCategory === 'conversation')
return BubbleX return BubbleX
@ -46,6 +50,9 @@ export const useVarColor = (variables: string[], isExceptionVariable?: boolean,
if (isConversationVar(variables) || variableCategory === VarInInspectType.conversation || variableCategory === 'conversation') if (isConversationVar(variables) || variableCategory === VarInInspectType.conversation || variableCategory === 'conversation')
return 'text-util-colors-teal-teal-700' return 'text-util-colors-teal-teal-700'
if (isMemoryVariable(variables) || variableCategory === VarInInspectType.memory || variableCategory === 'memory_block')
return 'text-util-colors-teal-teal-700'
if (isGlobalVar(variables) || variableCategory === VarInInspectType.system) if (isGlobalVar(variables) || variableCategory === VarInInspectType.system)
return 'text-util-colors-orange-orange-600' return 'text-util-colors-orange-orange-600'
@ -105,6 +112,15 @@ export const useVarBgColorInEditor = (variables: string[], hasError?: boolean) =
} }
} }
if (isMemoryVariable(variables)) {
return {
hoverBorderColor: 'hover:border-util-colors-teal-teal-100',
hoverBgColor: 'hover:bg-util-colors-teal-teal-50',
selectedBorderColor: 'border-util-colors-teal-teal-600',
selectedBgColor: 'bg-util-colors-teal-teal-50',
}
}
return { return {
hoverBorderColor: 'hover:border-state-accent-alt', hoverBorderColor: 'hover:border-state-accent-alt',
hoverBgColor: 'hover:bg-state-accent-hover', hoverBgColor: 'hover:bg-state-accent-hover',

View File

@ -13,6 +13,7 @@ export type VariablePayload = {
onClick?: (e: React.MouseEvent<HTMLDivElement>) => void onClick?: (e: React.MouseEvent<HTMLDivElement>) => void
errorMsg?: string errorMsg?: string
isExceptionVariable?: boolean isExceptionVariable?: boolean
isMemoryVariable?: boolean
ref?: React.Ref<HTMLDivElement> ref?: React.Ref<HTMLDivElement>
notShowFullPath?: boolean notShowFullPath?: boolean
rightSlot?: ReactNode rightSlot?: ReactNode

View File

@ -15,6 +15,8 @@ type Params = {
hideChatVar?: boolean hideChatVar?: boolean
filterVar: (payload: Var, selector: ValueSelector) => boolean filterVar: (payload: Var, selector: ValueSelector) => boolean
passedInAvailableNodes?: Node[] passedInAvailableNodes?: Node[]
conversationVariablesFirst?: boolean
isMemorySupported?: boolean
} }
// TODO: loop type? // TODO: loop type?
@ -24,6 +26,8 @@ const useAvailableVarList = (nodeId: string, {
hideEnv, hideEnv,
hideChatVar, hideChatVar,
passedInAvailableNodes, passedInAvailableNodes,
conversationVariablesFirst,
isMemorySupported,
}: Params = { }: Params = {
onlyLeafNodeVar: false, onlyLeafNodeVar: false,
filterVar: () => true, filterVar: () => true,
@ -63,13 +67,35 @@ const useAvailableVarList = (nodeId: string, {
}) })
} }
} }
const availableVars = [...getNodeAvailableVars({ const nodeAvailableVars = getNodeAvailableVars({
parentNode: iterationNode, parentNode: iterationNode,
beforeNodes: availableNodes, beforeNodes: availableNodes,
isChatMode, isChatMode,
filterVar, filterVar,
hideEnv, hideEnv,
hideChatVar, hideChatVar,
conversationVariablesFirst,
})
const availableVars = [...nodeAvailableVars.map((availableVar) => {
if (availableVar.nodeId === 'conversation') {
return {
...availableVar,
vars: availableVar.vars.filter((v) => {
if (!v.isMemoryVariable)
return true
if (!isMemorySupported)
return false
if (!v.memoryVariableNodeId)
return true
return v.memoryVariableNodeId === nodeId
}),
}
}
return availableVar
}), ...dataSourceRagVars] }), ...dataSourceRagVars]
return { return {

View File

@ -1,3 +1,5 @@
import { useCallback } from 'react'
import { useStoreApi } from 'reactflow'
import { useNodeDataUpdate } from '@/app/components/workflow/hooks' import { useNodeDataUpdate } from '@/app/components/workflow/hooks'
import type { CommonNodeType } from '@/app/components/workflow/types' import type { CommonNodeType } from '@/app/components/workflow/types'
const useNodeCrud = <T>(id: string, data: CommonNodeType<T>) => { const useNodeCrud = <T>(id: string, data: CommonNodeType<T>) => {
@ -16,4 +18,28 @@ const useNodeCrud = <T>(id: string, data: CommonNodeType<T>) => {
} }
} }
export const useNodeUpdate = (id: string) => {
const store = useStoreApi()
const { handleNodeDataUpdateWithSyncDraft } = useNodeDataUpdate()
const getNodeData = useCallback(() => {
const { getNodes } = store.getState()
const nodes = getNodes()
return nodes.find(node => node.id === id)
}, [store, id])
const handleNodeDataUpdate = useCallback((data: any) => {
handleNodeDataUpdateWithSyncDraft({
id,
data,
})
}, [id, handleNodeDataUpdateWithSyncDraft])
return {
getNodeData,
handleNodeDataUpdate,
}
}
export default useNodeCrud export default useNodeCrud

View File

@ -145,6 +145,7 @@ const useOneStepRun = <T>({
const { t } = useTranslation() const { t } = useTranslation()
const { getBeforeNodesInSameBranch, getBeforeNodesInSameBranchIncludeParent } = useWorkflow() as any const { getBeforeNodesInSameBranch, getBeforeNodesInSameBranchIncludeParent } = useWorkflow() as any
const conversationVariables = useStore(s => s.conversationVariables) const conversationVariables = useStore(s => s.conversationVariables)
const memoryVariables = useStore(s => s.memoryVariables)
const isChatMode = useIsChatMode() const isChatMode = useIsChatMode()
const isIteration = data.type === BlockEnum.Iteration const isIteration = data.type === BlockEnum.Iteration
const isLoop = data.type === BlockEnum.Loop const isLoop = data.type === BlockEnum.Loop
@ -173,7 +174,7 @@ const useOneStepRun = <T>({
dataSourceList: dataSourceList || [], dataSourceList: dataSourceList || [],
} }
const allOutputVars = toNodeOutputVars(availableNodes, isChatMode, undefined, undefined, conversationVariables, [], allPluginInfoList, schemaTypeDefinitions) const allOutputVars = toNodeOutputVars(availableNodes, isChatMode, undefined, undefined, conversationVariables, memoryVariables, [], allPluginInfoList, schemaTypeDefinitions)
const targetVar = allOutputVars.find(item => isSystem ? !!item.isStartNode : item.nodeId === valueSelector[0]) const targetVar = allOutputVars.find(item => isSystem ? !!item.isStartNode : item.nodeId === valueSelector[0])
if (!targetVar) if (!targetVar)
return undefined return undefined

View File

@ -58,7 +58,7 @@ const useConfig = (id: string, payload: IterationNodeType) => {
mcpTools: mcpTools || [], mcpTools: mcpTools || [],
dataSourceList: dataSourceList || [], dataSourceList: dataSourceList || [],
} }
const childrenNodeVars = toNodeOutputVars(iterationChildrenNodes, isChatMode, undefined, [], [], [], allPluginInfoList) const childrenNodeVars = toNodeOutputVars(iterationChildrenNodes, isChatMode, undefined, [], [], [], [], allPluginInfoList)
const handleOutputVarChange = useCallback((output: ValueSelector | string, _varKindType: VarKindType, varInfo?: Var) => { const handleOutputVarChange = useCallback((output: ValueSelector | string, _varKindType: VarKindType, varInfo?: Var) => {
if (isEqual(inputs.output_selector, output as ValueSelector)) if (isEqual(inputs.output_selector, output as ValueSelector))

View File

@ -39,6 +39,7 @@ type Props = {
varList: Variable[] varList: Variable[]
handleAddVariable: (payload: any) => void handleAddVariable: (payload: any) => void
modelConfig?: ModelConfig modelConfig?: ModelConfig
isMemorySupported?: boolean
} }
const roleOptions = [ const roleOptions = [
@ -81,6 +82,7 @@ const ConfigPromptItem: FC<Props> = ({
varList, varList,
handleAddVariable, handleAddVariable,
modelConfig, modelConfig,
isMemorySupported,
}) => { }) => {
const { t } = useTranslation() const { t } = useTranslation()
const workflowStore = useWorkflowStore() const workflowStore = useWorkflowStore()
@ -146,6 +148,17 @@ const ConfigPromptItem: FC<Props> = ({
varList={varList} varList={varList}
handleAddVariable={handleAddVariable} handleAddVariable={handleAddVariable}
isSupportFileVar isSupportFileVar
placeholder={
<>
<div>
{t(`${i18nPrefix}.promptEditorPlaceholder1`)}
</div>
<div>
{t(`${i18nPrefix}.promptEditorPlaceholder2`)}
</div>
</>
}
isMemorySupported={isMemorySupported}
/> />
) )
} }

View File

@ -34,6 +34,7 @@ type Props = {
varList?: Variable[] varList?: Variable[]
handleAddVariable: (payload: any) => void handleAddVariable: (payload: any) => void
modelConfig: ModelConfig modelConfig: ModelConfig
isMemorySupported?: boolean
} }
const ConfigPrompt: FC<Props> = ({ const ConfigPrompt: FC<Props> = ({
@ -49,6 +50,7 @@ const ConfigPrompt: FC<Props> = ({
varList = [], varList = [],
handleAddVariable, handleAddVariable,
modelConfig, modelConfig,
isMemorySupported,
}) => { }) => {
const { t } = useTranslation() const { t } = useTranslation()
const workflowStore = useWorkflowStore() const workflowStore = useWorkflowStore()
@ -73,6 +75,8 @@ const ConfigPrompt: FC<Props> = ({
} = useAvailableVarList(nodeId, { } = useAvailableVarList(nodeId, {
onlyLeafNodeVar: false, onlyLeafNodeVar: false,
filterVar, filterVar,
conversationVariablesFirst: true,
isMemorySupported,
}) })
const handleChatModePromptChange = useCallback((index: number) => { const handleChatModePromptChange = useCallback((index: number) => {
@ -204,6 +208,7 @@ const ConfigPrompt: FC<Props> = ({
varList={varList} varList={varList}
handleAddVariable={handleAddVariable} handleAddVariable={handleAddVariable}
modelConfig={modelConfig} modelConfig={modelConfig}
isMemorySupported={isMemorySupported}
/> />
</div> </div>
) )
@ -240,6 +245,7 @@ const ConfigPrompt: FC<Props> = ({
handleAddVariable={handleAddVariable} handleAddVariable={handleAddVariable}
onGenerated={handleGenerated} onGenerated={handleGenerated}
modelConfig={modelConfig} modelConfig={modelConfig}
isMemorySupported={isMemorySupported}
/> />
</div> </div>
)} )}

View File

@ -0,0 +1,116 @@
import {
memo,
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import {
RiDeleteBinLine,
RiEditLine,
} from '@remixicon/react'
import type { Memory } from '@/app/components/workflow/types'
import Badge from '@/app/components/base/badge'
import ActionButton from '@/app/components/base/action-button'
import { useMemoryVariables } from './hooks/use-memory-variables'
import Confirm from '@/app/components/base/confirm'
import { Memory as MemoryIcon } from '@/app/components/base/icons/src/vender/line/others'
import VariableModal from '@/app/components/workflow/panel/chat-variable-panel/components/variable-modal'
import cn from '@/utils/classnames'
type BlockMemoryProps = {
id: string
payload: Memory
}
const BlockMemory = ({ id }: BlockMemoryProps) => {
const { t } = useTranslation()
const [destructiveItemId, setDestructiveItemId] = useState<string | undefined>(undefined)
const {
memoryVariablesInUsed,
editMemoryVariable,
handleSetEditMemoryVariable,
handleEdit,
handleDelete,
handleDeleteConfirm,
cacheForDeleteMemoryVariable,
setCacheForDeleteMemoryVariable,
} = useMemoryVariables(id)
if (!memoryVariablesInUsed?.length) {
return (
<div className='system-xs-regular mt-2 flex items-center justify-center rounded-[10px] bg-background-section p-3 text-text-tertiary'>
{t('workflow.nodes.common.memory.block.empty')}
</div>
)
}
return (
<>
<div className='mt-2 space-y-1'>
{
memoryVariablesInUsed.map(memoryVariable => (
<div
key={memoryVariable.id}
className={cn(
'group flex h-8 items-center space-x-1 rounded-lg border-[0.5px] border-components-panel-border-subtle bg-components-panel-on-panel-item-bg pl-2 pr-1 shadow-xs',
destructiveItemId === memoryVariable.id && 'border border-state-destructive-solid bg-state-destructive-hover',
)}>
<MemoryIcon className='h-4 w-4 text-util-colors-teal-teal-700' />
<div
title={memoryVariable.name}
className='system-sm-medium grow truncate text-text-secondary'
>
{memoryVariable.name}
</div>
<Badge className={cn('shrink-0 group-hover:hidden', editMemoryVariable?.id === memoryVariable.id && 'hidden')}>
{memoryVariable.term}
</Badge>
<ActionButton
className={cn(
'hidden shrink-0 group-hover:inline-flex',
editMemoryVariable?.id === memoryVariable.id && 'inline-flex bg-state-base-hover text-text-secondary',
)}
size='m'
onClick={() => handleSetEditMemoryVariable(memoryVariable.id)}
>
<RiEditLine className='h-4 w-4 text-text-tertiary' />
</ActionButton>
<ActionButton
className={cn(
'hidden shrink-0 bg-transparent hover:bg-transparent hover:text-text-destructive group-hover:inline-flex',
editMemoryVariable?.id === memoryVariable.id && 'inline-flex',
)}
size='m'
onClick={() => handleDelete(memoryVariable)}
onMouseOver={() => setDestructiveItemId(memoryVariable.id)}
onMouseOut={() => setDestructiveItemId(undefined)}
>
<RiDeleteBinLine className={cn('h-4 w-4', destructiveItemId === memoryVariable.id && 'text-text-destructive')} />
</ActionButton>
</div>
))
}
</div>
{
!!cacheForDeleteMemoryVariable && (
<Confirm
isShow
onCancel={() => setCacheForDeleteMemoryVariable(undefined)}
onConfirm={() => handleDeleteConfirm(cacheForDeleteMemoryVariable.id)}
title={t('workflow.nodes.common.memory.deleteNodeMemoryVariableConfirmTitle', { name: cacheForDeleteMemoryVariable.name })}
content={t('workflow.nodes.common.memory.deleteNodeMemoryVariableConfirmDesc')}
/>
)
}
{
!!editMemoryVariable && (
<VariableModal
chatVar={editMemoryVariable}
onClose={() => handleSetEditMemoryVariable(undefined)}
onSave={handleEdit}
nodeScopeMemoryVariable={{ nodeId: id }}
/>
)
}
</>
)
}
export default memo(BlockMemory)

View File

@ -0,0 +1,20 @@
import { useCallback } from 'react'
import type { MemoryVariable } from '@/app/components/workflow/types'
import { useNodeUpdate } from '@/app/components/workflow/nodes/_base/hooks/use-node-crud'
import { findUsedVarNodes } from '@/app/components/workflow/nodes/_base/components/variable/utils'
export const useMemoryUsedDetector = (nodeId: string) => {
const { getNodeData } = useNodeUpdate(nodeId)
const getMemoryUsedDetector = useCallback((chatVar: MemoryVariable) => {
const nodeData = getNodeData()!
const valueSelector = ['memory_block', chatVar.node_id ? `${chatVar.node_id}_${chatVar.id}` : chatVar.id]
return findUsedVarNodes(
valueSelector,
[nodeData],
)
}, [getNodeData])
return {
getMemoryUsedDetector,
}
}

View File

@ -0,0 +1,77 @@
import {
useCallback,
useMemo,
useState,
} from 'react'
import {
useStore,
useWorkflowStore,
} from '@/app/components/workflow/store'
import type { MemoryVariable } from '@/app/components/workflow/types'
import { useNodesSyncDraft } from '@/app/components/workflow/hooks/use-nodes-sync-draft'
import { useMemoryUsedDetector } from './use-memory-used-detector'
export const useMemoryVariables = (nodeId: string) => {
const workflowStore = useWorkflowStore()
const memoryVariables = useStore(s => s.memoryVariables)
const [editMemoryVariable, setEditMemoryVariable] = useState<MemoryVariable | undefined>(undefined)
const { handleSyncWorkflowDraft } = useNodesSyncDraft()
const { getMemoryUsedDetector } = useMemoryUsedDetector(nodeId)
const [cacheForDeleteMemoryVariable, setCacheForDeleteMemoryVariable] = useState<MemoryVariable | undefined>(undefined)
const memoryVariablesInUsed = useMemo(() => {
return memoryVariables.filter(variable => variable.node_id === nodeId)
}, [memoryVariables, nodeId])
const handleSave = useCallback((newMemoryVar: MemoryVariable) => {
const { memoryVariables, setMemoryVariables } = workflowStore.getState()
const newList = [newMemoryVar, ...memoryVariables]
setMemoryVariables(newList)
handleSyncWorkflowDraft()
}, [handleSyncWorkflowDraft, workflowStore])
const handleSetEditMemoryVariable = (memoryVariableId?: string) => {
if (!memoryVariableId) {
setEditMemoryVariable(undefined)
return
}
const memoryVariable = memoryVariables.find(variable => variable.id === memoryVariableId)
setEditMemoryVariable(memoryVariable)
}
const handleEdit = (memoryVariable: MemoryVariable) => {
const { memoryVariables, setMemoryVariables } = workflowStore.getState()
const newList = memoryVariables.map(variable => variable.id === memoryVariable.id ? memoryVariable : variable)
setMemoryVariables(newList)
handleSyncWorkflowDraft()
}
const handleDeleteConfirm = (memoryVariableId?: string) => {
const { memoryVariables, setMemoryVariables } = workflowStore.getState()
const newList = memoryVariables.filter(variable => variable.id !== memoryVariableId)
setMemoryVariables(newList)
handleSyncWorkflowDraft()
setCacheForDeleteMemoryVariable(undefined)
}
const handleDelete = (memoryVariable: MemoryVariable) => {
const effectedNodes = getMemoryUsedDetector(memoryVariable)
if (effectedNodes.length > 0) {
setCacheForDeleteMemoryVariable(memoryVariable)
return
}
handleDeleteConfirm(memoryVariable.id)
}
return {
memoryVariablesInUsed,
handleDelete,
handleSave,
handleSetEditMemoryVariable,
handleEdit,
editMemoryVariable,
handleDeleteConfirm,
cacheForDeleteMemoryVariable,
setCacheForDeleteMemoryVariable,
}
}

View File

@ -0,0 +1,136 @@
import {
useCallback,
useMemo,
useState,
} from 'react'
import type { LLMNodeType } from '../../../types'
import { useNodeUpdate } from '@/app/components/workflow/nodes/_base/hooks/use-node-crud'
import {
MEMORY_DEFAULT,
} from '../linear-memory'
import type { Memory } from '@/app/components/workflow/types'
import { MemoryMode } from '@/app/components/workflow/types'
import { useWorkflowStore } from '@/app/components/workflow/store'
import { useMemoryUsedDetector } from './use-memory-used-detector'
export const useMemory = (
id: string,
data: LLMNodeType,
) => {
const workflowStore = useWorkflowStore()
const { memory } = data
const initCollapsed = useMemo(() => {
if (!memory?.enabled)
return true
return false
}, [memory])
const [collapsed, setCollapsed] = useState(initCollapsed)
const {
getNodeData,
handleNodeDataUpdate,
} = useNodeUpdate(id)
const [showTipsWhenMemoryModeBlockToLinear, setShowTipsWhenMemoryModeBlockToLinear] = useState(false)
const { getMemoryUsedDetector } = useMemoryUsedDetector(id)
const handleMemoryTypeChange = useCallback((value: string) => {
const nodeData = getNodeData()
const { memory: memoryData = {} as Memory } = nodeData?.data as LLMNodeType
if (value === MemoryMode.disabled) {
setCollapsed(true)
handleNodeDataUpdate({
memory: {
...memoryData,
enabled: false,
mode: '',
},
})
}
if (value === MemoryMode.linear) {
setCollapsed(false)
handleNodeDataUpdate({
memory: {
...memoryData,
enabled: true,
mode: MemoryMode.linear,
window: memoryData?.window || MEMORY_DEFAULT.window,
query_prompt_template: memoryData?.query_prompt_template || MEMORY_DEFAULT.query_prompt_template,
},
})
}
if (value === MemoryMode.block) {
setCollapsed(false)
handleNodeDataUpdate({
memory: {
...memoryData,
enabled: true,
mode: MemoryMode.block,
block_id: memoryData?.block_id || [],
query_prompt_template: memoryData?.query_prompt_template || MEMORY_DEFAULT.query_prompt_template,
},
})
}
setShowTipsWhenMemoryModeBlockToLinear(false)
}, [getNodeData, handleNodeDataUpdate])
const handleMemoryTypeChangeBefore = useCallback((value: string) => {
const nodeData = getNodeData()
const { memory: memoryData = {} as Memory } = nodeData?.data as LLMNodeType
const { memoryVariables } = workflowStore.getState()
if (memoryData.mode === MemoryMode.block && value === MemoryMode.linear && nodeData) {
const globalMemoryVariables = memoryVariables.filter(variable => variable.scope === 'app')
const currentNodeMemoryVariables = memoryVariables.filter(variable => variable.node_id === id)
const allMemoryVariables = [...globalMemoryVariables, ...currentNodeMemoryVariables]
for (const variable of allMemoryVariables) {
const effectedNodes = getMemoryUsedDetector(variable)
if (effectedNodes.length > 0) {
setShowTipsWhenMemoryModeBlockToLinear(true)
return
}
}
handleMemoryTypeChange(value)
}
else {
handleMemoryTypeChange(value)
}
}, [getNodeData, workflowStore, handleMemoryTypeChange])
const handleUpdateMemory = useCallback((memory: Memory) => {
handleNodeDataUpdate({
memory,
})
}, [handleNodeDataUpdate])
const memoryType = useMemo(() => {
if (!memory)
return MemoryMode.disabled
if (!('enabled' in memory))
return MemoryMode.linear
if (memory.enabled) {
if (memory.mode === MemoryMode.linear)
return MemoryMode.linear
if (memory.mode === MemoryMode.block)
return MemoryMode.block
}
else {
return MemoryMode.disabled
}
}, [memory])
return {
collapsed,
setCollapsed,
handleMemoryTypeChange,
handleMemoryTypeChangeBefore,
memoryType,
handleUpdateMemory,
showTipsWhenMemoryModeBlockToLinear,
setShowTipsWhenMemoryModeBlockToLinear,
}
}

View File

@ -0,0 +1,139 @@
import {
memo,
useCallback,
} from 'react'
import { useTranslation } from 'react-i18next'
import { RiAddLine } from '@remixicon/react'
import Collapse from '@/app/components/workflow/nodes/_base/components/collapse'
import type {
Node,
} from '@/app/components/workflow/types'
import Divider from '@/app/components/base/divider'
import Tooltip from '@/app/components/base/tooltip'
import MemoryCreateButton from './memory-create-button'
import MemorySelector from './memory-selector'
import LinearMemory from './linear-memory'
import type { Memory } from '@/app/components/workflow/types'
import type { LLMNodeType } from '../../types'
import { useMemory } from './hooks/use-memory'
import Split from '@/app/components/workflow/nodes/_base/components/split'
import { MemoryMode } from '@/app/components/workflow/types'
import BlockMemory from './block-memory'
import { ActionButton } from '@/app/components/base/action-button'
import cn from '@/utils/classnames'
import Confirm from '@/app/components/base/confirm'
type MemoryProps = Pick<Node, 'id' | 'data'> & {
readonly?: boolean
canSetRoleName?: boolean
}
const MemorySystem = ({
id,
data,
readonly,
canSetRoleName,
}: MemoryProps) => {
const { t } = useTranslation()
const { memory } = data as LLMNodeType
const {
collapsed,
setCollapsed,
handleMemoryTypeChange,
handleMemoryTypeChangeBefore,
memoryType,
handleUpdateMemory,
showTipsWhenMemoryModeBlockToLinear,
setShowTipsWhenMemoryModeBlockToLinear,
} = useMemory(id, data as LLMNodeType)
const renderTrigger = useCallback((open?: boolean) => {
return (
<ActionButton className={cn('shrink-0', open && 'bg-state-base-hover')}>
<RiAddLine className='h-4 w-4' />
</ActionButton>
)
}, [])
return (
<>
<div className=''>
<Collapse
disabled={!memory?.enabled}
collapsed={collapsed}
onCollapse={setCollapsed}
hideCollapseIcon
triggerClassName='ml-0 system-sm-semibold-uppercase'
trigger={
collapseIcon => (
<div className='flex grow items-center justify-between'>
<div className='flex items-center'>
<div className='system-sm-semibold-uppercase mr-0.5 text-text-secondary'>
{t('workflow.nodes.common.memory.memory')}
</div>
<Tooltip
popupContent={t('workflow.nodes.common.memory.memoryTip')}
triggerClassName='w-4 h-4'
/>
{collapseIcon}
{
memoryType === MemoryMode.block && (
<>
<Divider type='vertical' className='!ml-1.5 !mr-1 h-3 !w-px bg-divider-regular' />
<div onClick={e => e.stopPropagation()}>
<MemoryCreateButton
nodeId={id}
renderTrigger={renderTrigger}
/>
</div>
</>
)
}
</div>
<MemorySelector
value={memoryType}
onSelected={handleMemoryTypeChangeBefore}
readonly={readonly}
/>
</div>
)}
>
<>
{
memoryType === MemoryMode.linear && !collapsed && (
<LinearMemory
className='mt-2'
payload={memory as Memory}
onChange={handleUpdateMemory}
readonly={readonly}
canSetRoleName={canSetRoleName}
/>
)
}
{
memoryType === MemoryMode.block && !collapsed && (
<BlockMemory id={id} payload={memory as Memory} />
)
}
</>
</Collapse>
<Split className='mt-4' />
</div>
{
showTipsWhenMemoryModeBlockToLinear && (
<Confirm
isShow
type='info'
showCancel={false}
onCancel={() => setShowTipsWhenMemoryModeBlockToLinear(false)}
onConfirm={() => handleMemoryTypeChange(MemoryMode.linear)}
confirmText={t('workflow.nodes.common.memory.toLinearConfirmButton')}
title={t('workflow.nodes.common.memory.toLinearConfirmTitle')}
content={t('workflow.nodes.common.memory.toLinearConfirmDesc')}
/>
)
}
</>
)
}
export default memo(MemorySystem)

View File

@ -0,0 +1,182 @@
import {
memo,
useCallback,
} from 'react'
import { produce } from 'immer'
import { useTranslation } from 'react-i18next'
import Switch from '@/app/components/base/switch'
import Slider from '@/app/components/base/slider'
import Input from '@/app/components/base/input'
import type { Memory } from '@/app/components/workflow/types'
import { MemoryRole } from '@/app/components/workflow/types'
import cn from '@/utils/classnames'
const WINDOW_SIZE_MIN = 1
const WINDOW_SIZE_MAX = 100
export const WINDOW_SIZE_DEFAULT = 50
export const MEMORY_DEFAULT: Memory = {
window: { enabled: false, size: WINDOW_SIZE_DEFAULT },
query_prompt_template: '{{#sys.query#}}\n\n{{#sys.files#}}',
}
type RoleItemProps = {
readonly?: boolean
title: string
value: string
onChange: (value: string) => void
}
const RoleItem = ({
readonly,
title,
value,
onChange,
}: RoleItemProps) => {
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
onChange(e.target.value)
}, [onChange])
return (
<div className='flex items-center justify-between'>
<div className='text-[13px] font-normal text-text-secondary'>{title}</div>
<Input
readOnly={readonly}
value={value}
onChange={handleChange}
className='h-8 w-[200px]'
type='text' />
</div>
)
}
type LinearMemoryProps = {
payload: Memory
readonly?: boolean
onChange: (payload: Memory) => void
canSetRoleName?: boolean
className?: string
}
const LinearMemory = ({
payload,
readonly,
onChange,
canSetRoleName,
className,
}: LinearMemoryProps) => {
const i18nPrefix = 'workflow.nodes.common.memory'
const { t } = useTranslation()
const handleWindowEnabledChange = useCallback((enabled: boolean) => {
const newPayload = produce(payload || MEMORY_DEFAULT, (draft) => {
if (!draft.window)
draft.window = { enabled: false, size: WINDOW_SIZE_DEFAULT }
draft.window.enabled = enabled
})
onChange(newPayload)
}, [payload, onChange])
const handleWindowSizeChange = useCallback((size: number | string) => {
const newPayload = produce(payload || MEMORY_DEFAULT, (draft) => {
if (!draft.window)
draft.window = { enabled: true, size: WINDOW_SIZE_DEFAULT }
let limitedSize: null | string | number = size
if (limitedSize === '') {
limitedSize = null
}
else {
limitedSize = Number.parseInt(limitedSize as string, 10)
if (Number.isNaN(limitedSize))
limitedSize = WINDOW_SIZE_DEFAULT
if (limitedSize < WINDOW_SIZE_MIN)
limitedSize = WINDOW_SIZE_MIN
if (limitedSize > WINDOW_SIZE_MAX)
limitedSize = WINDOW_SIZE_MAX
}
draft.window.size = limitedSize as number
})
onChange(newPayload)
}, [payload, onChange])
const handleBlur = useCallback(() => {
if (!payload)
return
if (payload.window.size === '' || payload.window.size === null)
handleWindowSizeChange(WINDOW_SIZE_DEFAULT)
}, [handleWindowSizeChange, payload])
const handleRolePrefixChange = useCallback((role: MemoryRole) => {
return (value: string) => {
const newPayload = produce(payload || MEMORY_DEFAULT, (draft) => {
if (!draft.role_prefix) {
draft.role_prefix = {
user: '',
assistant: '',
}
}
draft.role_prefix[role] = value
})
onChange(newPayload)
}
}, [payload, onChange])
return (
<>
<div className={cn('flex justify-between', className)}>
<div className='flex h-8 items-center space-x-2'>
<Switch
defaultValue={payload?.window?.enabled}
onChange={handleWindowEnabledChange}
size='md'
disabled={readonly}
/>
<div className='system-xs-medium-uppercase text-text-tertiary'>{t(`${i18nPrefix}.windowSize`)}</div>
</div>
<div className='flex h-8 items-center space-x-2'>
<Slider
className='w-[144px]'
value={(payload.window?.size || WINDOW_SIZE_DEFAULT) as number}
min={WINDOW_SIZE_MIN}
max={WINDOW_SIZE_MAX}
step={1}
onChange={handleWindowSizeChange}
disabled={readonly || !payload.window?.enabled}
/>
<Input
value={(payload.window?.size || WINDOW_SIZE_DEFAULT) as number}
wrapperClassName='w-12'
className='appearance-none pr-0'
type='number'
min={WINDOW_SIZE_MIN}
max={WINDOW_SIZE_MAX}
step={1}
onChange={e => handleWindowSizeChange(e.target.value)}
onBlur={handleBlur}
disabled={readonly || !payload.window?.enabled}
/>
</div>
</div>
{canSetRoleName && (
<div className='mt-4'>
<div className='text-xs font-medium uppercase leading-6 text-text-tertiary'>{t(`${i18nPrefix}.conversationRoleName`)}</div>
<div className='mt-1 space-y-2'>
<RoleItem
readonly={!!readonly}
title={t(`${i18nPrefix}.user`)}
value={payload.role_prefix?.user || ''}
onChange={handleRolePrefixChange(MemoryRole.user)}
/>
<RoleItem
readonly={!!readonly}
title={t(`${i18nPrefix}.assistant`)}
value={payload.role_prefix?.assistant || ''}
onChange={handleRolePrefixChange(MemoryRole.assistant)}
/>
</div>
</div>
)}
</>
)
}
export default memo(LinearMemory)

View File

@ -0,0 +1,70 @@
import { useCallback, useState } from 'react'
import VariableModal from '@/app/components/workflow/panel/chat-variable-panel/components/variable-modal'
import type { OffsetOptions, Placement } from '@floating-ui/react'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import type { MemoryVariable } from '@/app/components/workflow/types'
import { useEventEmitterContextContext } from '@/context/event-emitter'
import { MEMORY_VAR_CREATED_BY_MODAL_BY_EVENT_EMITTER, MEMORY_VAR_MODAL_SHOW_BY_EVENT_EMITTER } from '@/app/components/workflow/nodes/_base/components/prompt/type'
import { useMemoryVariables } from './hooks/use-memory-variables'
type Props = {
placement?: Placement
offset?: number | OffsetOptions
hideTrigger?: boolean
instanceId?: string
nodeId: string
renderTrigger?: (open?: boolean) => React.ReactNode
}
const MemoryCreateButton = ({
placement,
offset,
instanceId,
nodeId,
renderTrigger = () => <div></div>,
}: Props) => {
const { eventEmitter } = useEventEmitterContextContext()
const [open, setOpen] = useState(false)
const { handleSave: handleSaveMemoryVariables } = useMemoryVariables(nodeId)
const handleSave = useCallback((newMemoryVar: MemoryVariable) => {
handleSaveMemoryVariables(newMemoryVar)
if (instanceId)
eventEmitter?.emit({ type: MEMORY_VAR_CREATED_BY_MODAL_BY_EVENT_EMITTER, instanceId, variable: ['memory', newMemoryVar.name] } as any)
}, [handleSaveMemoryVariables, eventEmitter, instanceId])
eventEmitter?.useSubscription((v: any) => {
if (v.type === MEMORY_VAR_MODAL_SHOW_BY_EVENT_EMITTER && v.instanceId === instanceId)
setOpen(true)
})
return (
<>
<PortalToFollowElem
open={open}
onOpenChange={setOpen}
placement={placement || 'left'}
offset={offset}
>
<PortalToFollowElemTrigger onClick={() => setOpen(v => !v)}>
{renderTrigger?.(open)}
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className='z-[11]'>
<VariableModal
onSave={handleSave}
onClose={() => {
setOpen(false)
}}
nodeScopeMemoryVariable={nodeId ? { nodeId } : undefined}
/>
</PortalToFollowElemContent>
</PortalToFollowElem>
</>
)
}
export default MemoryCreateButton

View File

@ -0,0 +1,107 @@
import {
memo,
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import {
RiArrowDownSLine,
RiCheckLine,
} from '@remixicon/react'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import Button from '@/app/components/base/button'
import { MemoryMode } from '@/app/components/workflow/types'
type MemorySelectorProps = {
value?: string
onSelected: (value: string) => void
readonly?: boolean
}
const MemorySelector = ({
value,
onSelected,
readonly,
}: MemorySelectorProps) => {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const options = [
{
value: MemoryMode.disabled,
label: t('workflow.nodes.common.memory.disabled.title'),
description: t('workflow.nodes.common.memory.disabled.desc'),
},
{
value: MemoryMode.linear,
label: t('workflow.nodes.common.memory.linear.title'),
description: t('workflow.nodes.common.memory.linear.desc'),
},
{
value: MemoryMode.block,
label: t('workflow.nodes.common.memory.block.title'),
description: t('workflow.nodes.common.memory.block.desc'),
},
]
const selectedOption = options.find(option => option.value === value)
return (
<PortalToFollowElem
open={open}
onOpenChange={setOpen}
placement='bottom-end'
offset={4}
>
<PortalToFollowElemTrigger onClick={(e) => {
if (readonly)
return
e.stopPropagation()
e.nativeEvent.stopImmediatePropagation()
setOpen(v => !v)
}}>
<Button
size='small'
disabled={readonly}
>
{selectedOption?.label || t('workflow.nodes.common.memory.disabled.title')}
<RiArrowDownSLine className='h-3.5 w-3.5' />
</Button>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className='z-[11]'>
<div className='w-[280px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-1 shadow-lg'>
{
options.map(option => (
<div
key={option.value}
className='flex cursor-pointer rounded-lg p-2 pr-3 hover:bg-state-base-hover'
onClick={(e) => {
if (readonly)
return
e.stopPropagation()
e.nativeEvent.stopImmediatePropagation()
onSelected(option.value)
setOpen(false)
}}
>
<div className='mr-1 w-4 shrink-0'>
{
value === option.value && (
<RiCheckLine className='h-4 w-4 text-text-accent' />
)
}
</div>
<div className='grow'>
<div className='system-sm-semibold mb-0.5 text-text-secondary'>{option.label}</div>
<div className='system-xs-regular text-text-tertiary'>{option.description}</div>
</div>
</div>
))
}
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
)
}
export default memo(MemorySelector)

View File

@ -18,6 +18,7 @@ type Props = {
nodeId: string nodeId: string
editorId?: string editorId?: string
currentPrompt?: string currentPrompt?: string
isBasicMode?: boolean
} }
const PromptGeneratorBtn: FC<Props> = ({ const PromptGeneratorBtn: FC<Props> = ({
@ -26,6 +27,7 @@ const PromptGeneratorBtn: FC<Props> = ({
nodeId, nodeId,
editorId, editorId,
currentPrompt, currentPrompt,
isBasicMode,
}) => { }) => {
const [showAutomatic, { setTrue: showAutomaticTrue, setFalse: showAutomaticFalse }] = useBoolean(false) const [showAutomatic, { setTrue: showAutomaticTrue, setFalse: showAutomaticFalse }] = useBoolean(false)
const handleAutomaticRes = useCallback((res: GenRes) => { const handleAutomaticRes = useCallback((res: GenRes) => {
@ -50,6 +52,8 @@ const PromptGeneratorBtn: FC<Props> = ({
nodeId={nodeId} nodeId={nodeId}
editorId={editorId} editorId={editorId}
currentPrompt={currentPrompt} currentPrompt={currentPrompt}
isBasicMode={isBasicMode}
hideTryIt
/> />
)} )}
</div> </div>

View File

@ -1,7 +1,6 @@
import type { FC } from 'react' import type { FC } from 'react'
import React, { useCallback } from 'react' import React, { useCallback } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import MemoryConfig from '../_base/components/memory-config'
import VarReferencePicker from '../_base/components/variable/var-reference-picker' import VarReferencePicker from '../_base/components/variable/var-reference-picker'
import ConfigVision from '../_base/components/config-vision' import ConfigVision from '../_base/components/config-vision'
import useConfig from './use-config' import useConfig from './use-config'
@ -22,6 +21,9 @@ import Switch from '@/app/components/base/switch'
import { RiAlertFill, RiQuestionLine } from '@remixicon/react' import { RiAlertFill, RiQuestionLine } from '@remixicon/react'
import { fetchAndMergeValidCompletionParams } from '@/utils/completion-params' import { fetchAndMergeValidCompletionParams } from '@/utils/completion-params'
import Toast from '@/app/components/base/toast' import Toast from '@/app/components/base/toast'
import MemorySystem from './components/memory-system'
import { useMemory } from './components/memory-system/hooks/use-memory'
import { MemoryMode } from '@/app/components/workflow/types'
const i18nPrefix = 'workflow.nodes.llm' const i18nPrefix = 'workflow.nodes.llm'
@ -53,7 +55,6 @@ const Panel: FC<NodePanelProps<LLMNodeType>> = ({
handleVarListChange, handleVarListChange,
handleVarNameChange, handleVarNameChange,
handleSyeQueryChange, handleSyeQueryChange,
handleMemoryChange,
handleVisionResolutionEnabledChange, handleVisionResolutionEnabledChange,
handleVisionResolutionChange, handleVisionResolutionChange,
isModelSupportStructuredOutput, isModelSupportStructuredOutput,
@ -64,6 +65,7 @@ const Panel: FC<NodePanelProps<LLMNodeType>> = ({
filterJinja2InputVar, filterJinja2InputVar,
handleReasoningFormatChange, handleReasoningFormatChange,
} = useConfig(id, data) } = useConfig(id, data)
const { memoryType } = useMemory(id, data)
const model = inputs.model const model = inputs.model
@ -151,6 +153,7 @@ const Panel: FC<NodePanelProps<LLMNodeType>> = ({
varList={inputs.prompt_config?.jinja2_variables || []} varList={inputs.prompt_config?.jinja2_variables || []}
handleAddVariable={handleAddVariable} handleAddVariable={handleAddVariable}
modelConfig={model} modelConfig={model}
isMemorySupported={memoryType === MemoryMode.block}
/> />
)} )}
@ -174,7 +177,7 @@ const Panel: FC<NodePanelProps<LLMNodeType>> = ({
)} )}
{/* Memory put place examples. */} {/* Memory put place examples. */}
{isChatMode && isChatModel && !!inputs.memory && ( {isChatMode && isChatModel && (memoryType === MemoryMode.linear || memoryType === MemoryMode.block) && (
<div className='mt-4'> <div className='mt-4'>
<div className='flex h-8 items-center justify-between rounded-lg bg-components-input-bg-normal pl-3 pr-2'> <div className='flex h-8 items-center justify-between rounded-lg bg-components-input-bg-normal pl-3 pr-2'>
<div className='flex items-center space-x-1'> <div className='flex items-center space-x-1'>
@ -198,7 +201,7 @@ const Panel: FC<NodePanelProps<LLMNodeType>> = ({
triggerClassName='w-4 h-4' triggerClassName='w-4 h-4'
/> />
</div>} </div>}
value={inputs.memory.query_prompt_template || '{{#sys.query#}}'} value={inputs.memory?.query_prompt_template || '{{#sys.query#}}'}
onChange={handleSyeQueryChange} onChange={handleSyeQueryChange}
readOnly={readOnly} readOnly={readOnly}
isShowContext={false} isShowContext={false}
@ -208,9 +211,10 @@ const Panel: FC<NodePanelProps<LLMNodeType>> = ({
nodesOutputVars={availableVars} nodesOutputVars={availableVars}
availableNodes={availableNodesWithParent} availableNodes={availableNodesWithParent}
isSupportFileVar isSupportFileVar
instanceId={`${id}-chat-workflow-llm-prompt-editor-user`}
/> />
{inputs.memory.query_prompt_template && !inputs.memory.query_prompt_template.includes('{{#sys.query#}}') && ( {inputs.memory?.query_prompt_template && !inputs.memory.query_prompt_template.includes('{{#sys.query#}}') && (
<div className='text-xs font-normal leading-[18px] text-[#DC6803]'>{t(`${i18nPrefix}.sysQueryInUser`)}</div> <div className='text-xs font-normal leading-[18px] text-[#DC6803]'>{t(`${i18nPrefix}.sysQueryInUser`)}</div>
)} )}
</div> </div>
@ -220,11 +224,10 @@ const Panel: FC<NodePanelProps<LLMNodeType>> = ({
{/* Memory */} {/* Memory */}
{isChatMode && ( {isChatMode && (
<> <>
<Split /> <MemorySystem
<MemoryConfig id={id}
data={data}
readonly={readOnly} readonly={readOnly}
config={{ data: inputs.memory }}
onChange={handleMemoryChange}
canSetRoleName={isCompletionModel} canSetRoleName={isCompletionModel}
/> />
</> </>

View File

@ -308,11 +308,11 @@ const useConfig = (id: string, payload: LLMNodeType) => {
}, [setInputs, deleteNodeInspectorVars, id]) }, [setInputs, deleteNodeInspectorVars, id])
const filterInputVar = useCallback((varPayload: Var) => { const filterInputVar = useCallback((varPayload: Var) => {
return [VarType.number, VarType.string, VarType.secret, VarType.arrayString, VarType.arrayNumber, VarType.file, VarType.arrayFile].includes(varPayload.type) return [VarType.number, VarType.string, VarType.secret, VarType.arrayString, VarType.arrayNumber, VarType.file, VarType.arrayFile, VarType.memory].includes(varPayload.type)
}, []) }, [])
const filterJinja2InputVar = useCallback((varPayload: Var) => { const filterJinja2InputVar = useCallback((varPayload: Var) => {
return [VarType.number, VarType.string, VarType.secret, VarType.arrayString, VarType.arrayNumber, VarType.arrayBoolean, VarType.arrayObject, VarType.object, VarType.array, VarType.boolean].includes(varPayload.type) return [VarType.number, VarType.string, VarType.secret, VarType.arrayString, VarType.arrayNumber, VarType.arrayBoolean, VarType.arrayObject, VarType.object, VarType.array, VarType.boolean, VarType.memory].includes(varPayload.type)
}, []) }, [])
const filterMemoryPromptVar = useCallback((varPayload: Var) => { const filterMemoryPromptVar = useCallback((varPayload: Var) => {

View File

@ -26,7 +26,7 @@ import {
arrayObjectPlaceholder, arrayObjectPlaceholder,
arrayStringPlaceholder, arrayStringPlaceholder,
objectPlaceholder, objectPlaceholder,
} from '@/app/components/workflow/panel/chat-variable-panel/utils' } from '@/app/components/workflow/panel/chat-variable-panel/constants'
import ArrayBoolList from '@/app/components/workflow/panel/chat-variable-panel/components/array-bool-list' import ArrayBoolList from '@/app/components/workflow/panel/chat-variable-panel/components/array-bool-list'
type FormItemProps = { type FormItemProps = {

View File

@ -38,7 +38,7 @@ const useConfig = (id: string, payload: LoopNodeType) => {
const { nodesReadOnly: readOnly } = useNodesReadOnly() const { nodesReadOnly: readOnly } = useNodesReadOnly()
const isChatMode = useIsChatMode() const isChatMode = useIsChatMode()
const conversationVariables = useStore(s => s.conversationVariables) const conversationVariables = useStore(s => s.conversationVariables)
const memoryVariables = useStore(s => s.memoryVariables)
const { inputs, setInputs } = useNodeCrud<LoopNodeType>(id, payload) const { inputs, setInputs } = useNodeCrud<LoopNodeType>(id, payload)
const inputsRef = useRef(inputs) const inputsRef = useRef(inputs)
const handleInputsChange = useCallback((newInputs: LoopNodeType) => { const handleInputsChange = useCallback((newInputs: LoopNodeType) => {
@ -65,7 +65,7 @@ const useConfig = (id: string, payload: LoopNodeType) => {
mcpTools: mcpTools || [], mcpTools: mcpTools || [],
dataSourceList: dataSourceList || [], dataSourceList: dataSourceList || [],
} }
const childrenNodeVars = toNodeOutputVars(loopChildrenNodes, isChatMode, undefined, [], conversationVariables, [], allPluginInfoList) const childrenNodeVars = toNodeOutputVars(loopChildrenNodes, isChatMode, undefined, [], conversationVariables, memoryVariables, [], allPluginInfoList)
const { const {
getIsVarFileAttribute, getIsVarFileAttribute,

View File

@ -11,7 +11,7 @@ const VALID_PARAMETER_TYPES: readonly VarType[] = [
] as const ] as const
// Type display name mappings // Type display name mappings
const TYPE_DISPLAY_NAMES: Record<VarType, string> = { const TYPE_DISPLAY_NAMES: Record<Exclude<VarType, VarType.memory>, string> = {
[VarType.string]: 'String', [VarType.string]: 'String',
[VarType.number]: 'Number', [VarType.number]: 'Number',
[VarType.boolean]: 'Boolean', [VarType.boolean]: 'Boolean',
@ -91,7 +91,7 @@ export const normalizeParameterType = (input: string | undefined | null): VarTyp
/** /**
* Gets display name for parameter types in UI components * Gets display name for parameter types in UI components
*/ */
export const getParameterTypeDisplayName = (type: VarType): string => { export const getParameterTypeDisplayName = (type: Exclude<VarType, VarType.memory>): string => {
return TYPE_DISPLAY_NAMES[type] return TYPE_DISPLAY_NAMES[type]
} }
@ -119,7 +119,7 @@ export const createParameterTypeOptions = (contentType?: string) => {
const availableTypes = getAvailableParameterTypes(contentType) const availableTypes = getAvailableParameterTypes(contentType)
return availableTypes.map(type => ({ return availableTypes.map(type => ({
name: getParameterTypeDisplayName(type), name: getParameterTypeDisplayName(type as Exclude<VarType, VarType.memory>),
value: type, value: type,
})) }))
} }

View File

@ -52,7 +52,7 @@ const ArrayValueList: FC<Props> = ({
<div className='flex items-center space-x-1' key={index}> <div className='flex items-center space-x-1' key={index}>
<Input <Input
placeholder={t('workflow.chatVariable.modal.arrayValue') || ''} placeholder={t('workflow.chatVariable.modal.arrayValue') || ''}
value={list[index]} value={list[index] || ''}
onChange={handleNameChange(index)} onChange={handleNameChange(index)}
type={isString ? 'text' : 'number'} type={isString ? 'text' : 'number'}
/> />

View File

@ -0,0 +1,79 @@
'use client'
import type { FC } from 'react'
import React, { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
RiArrowDownSLine,
RiCheckLine,
} from '@remixicon/react'
import { useShallow } from 'zustand/react/shallow'
import {
useStore,
} from 'reactflow'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import BlockIcon from '@/app/components/workflow/block-icon'
import { BlockEnum } from '@/app/components/workflow/types'
import cn from '@/utils/classnames'
type Props = {
value: string
onChange: (value: string) => void
nodeType?: BlockEnum
}
const NodeSelector: FC<Props> = ({
value,
onChange,
nodeType = BlockEnum.LLM,
}) => {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const filteredNodes = useStore(useShallow((s) => {
const nodes = [...s.nodeInternals.values()]
return nodes.filter(node => node.data?.type === nodeType)
}))
const currentNode = useMemo(() => filteredNodes.find(node => node.id === value), [filteredNodes, value])
return (
<PortalToFollowElem
open={open}
onOpenChange={setOpen}
placement='bottom-end'
offset={4}
>
<PortalToFollowElemTrigger onClick={() => setOpen(v => !v)}>
{currentNode && (
<div className={cn('flex h-8 w-[208px] cursor-pointer items-center rounded-lg bg-components-input-bg-normal px-2 py-1 pl-3 hover:bg-state-base-hover-alt', open && 'bg-state-base-hover-alt')}>
<BlockIcon className={cn('mr-1.5 h-4 w-4 shrink-0')} type={currentNode.data?.type} />
<div className='system-sm-regular grow truncate text-components-input-text-filled'>{currentNode.data?.title}</div>
</div>
)}
{!currentNode && (
<div className={cn('flex h-8 w-[208px] cursor-pointer items-center gap-1 rounded-lg bg-components-input-bg-normal px-2 py-1 pl-3 hover:bg-state-base-hover-alt', open && 'bg-state-base-hover-alt')}>
<div className='system-sm-regular grow truncate text-components-input-text-placeholder'>{t('workflow.chatVariable.modal.selectNode')}</div>
<RiArrowDownSLine className='h-4 w-4 text-text-quaternary' />
</div>
)}
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className='z-[25]'>
<div className='w-[209px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg p-1 shadow-lg'>
{filteredNodes.map(node => (
<div key={node.id} className='flex cursor-pointer items-center rounded-lg px-2 py-1 hover:bg-state-base-hover' onClick={() => onChange(node.id)}>
<BlockIcon className={cn('mr-2 shrink-0')} type={node.data?.type} />
<div className='system-sm-medium grow text-text-secondary'>{node.data?.title}</div>
{currentNode?.id === node.id && (
<RiCheckLine className='h-4 w-4 shrink-0 text-text-accent' />
)}
</div>
))}
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
)
}
export default React.memo(NodeSelector)

View File

@ -0,0 +1,54 @@
import { memo } from 'react'
import { useStore } from 'reactflow'
import VariableItem from './variable-item'
import BlockIcon from '@/app/components/workflow/block-icon'
import { BlockEnum } from '@/app/components/workflow/types'
import type { MemoryVariable } from '@/app/components/workflow/types'
type VariableItemWithNodeProps = {
nodeId: string
memoryVariables: MemoryVariable[]
onEdit: (memoryVariable: MemoryVariable) => void
onDelete: (memoryVariable: MemoryVariable) => void
currentVarId?: string
}
const VariableItemWithNode = ({
nodeId,
memoryVariables,
onEdit,
onDelete,
currentVarId,
}: VariableItemWithNodeProps) => {
const currentNode = useStore(s => s.nodeInternals.get(nodeId))
if (!currentNode) return null
return (
<div className='space-y-1 py-1'>
<div className='mb-1 flex items-center'>
<BlockIcon className='mr-1.5 shrink-0' type={BlockEnum.LLM} />
<div
className='system-sm-medium grow truncate text-text-secondary'
title={currentNode?.data.title}
>
{currentNode?.data.title}
</div>
</div>
{
memoryVariables.map(memoryVariable => (
<VariableItem
key={memoryVariable.id}
item={memoryVariable}
onEdit={onEdit}
onDelete={onDelete}
scope='node'
term={memoryVariable.term}
currentVarId={currentVarId}
/>
))
}
</div>
)
}
export default memo(VariableItemWithNode)

View File

@ -1,47 +1,87 @@
import { memo, useState } from 'react' import { memo, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { capitalize } from 'lodash-es' import { capitalize } from 'lodash-es'
import { RiDeleteBinLine, RiEditLine } from '@remixicon/react' import { RiDeleteBinLine, RiEditLine } from '@remixicon/react'
import { BubbleX } from '@/app/components/base/icons/src/vender/line/others' import {
import type { ConversationVariable } from '@/app/components/workflow/types' BubbleX,
Memory,
} from '@/app/components/base/icons/src/vender/line/others'
import type { ConversationVariable, MemoryVariable } from '@/app/components/workflow/types'
import cn from '@/utils/classnames' import cn from '@/utils/classnames'
import { ChatVarType } from '../type'
import Badge from '@/app/components/base/badge'
type VariableItemProps = { type VariableItemProps = {
item: ConversationVariable item: ConversationVariable | MemoryVariable
onEdit: (item: ConversationVariable) => void onEdit: (item: ConversationVariable | MemoryVariable) => void
onDelete: (item: ConversationVariable) => void onDelete: (item: ConversationVariable | MemoryVariable) => void
scope?: string
term?: string
currentVarId?: string
} }
const VariableItem = ({ const VariableItem = ({
item, item,
onEdit, onEdit,
onDelete, onDelete,
scope,
term,
currentVarId,
}: VariableItemProps) => { }: VariableItemProps) => {
const { t } = useTranslation()
const [destructive, setDestructive] = useState(false) const [destructive, setDestructive] = useState(false)
const valueType = useMemo(() => {
if (item.value_type === ChatVarType.Memory)
return 'memory'
return item.value_type
}, [item.value_type])
return ( return (
<div className={cn( <div className={cn(
'radius-md mb-1 border border-components-panel-border-subtle bg-components-panel-on-panel-item-bg px-2.5 py-2 shadow-xs hover:bg-components-panel-on-panel-item-bg-hover', 'radius-md mb-1 border border-components-panel-border-subtle bg-components-panel-on-panel-item-bg px-2.5 py-2 shadow-xs hover:bg-components-panel-on-panel-item-bg-hover',
destructive && 'border-state-destructive-border hover:bg-state-destructive-hover', destructive && 'border-state-destructive-border hover:bg-state-destructive-hover',
)}> )}>
<div className='flex items-center justify-between'> <div className='group flex items-center justify-between'>
<div className='flex grow items-center gap-1'> <div className='flex grow items-center gap-1'>
<BubbleX className='h-4 w-4 text-util-colors-teal-teal-700' /> {
item.value_type === ChatVarType.Memory && (
<Memory className='h-4 w-4 text-util-colors-teal-teal-700' />
)
}
{
item.value_type !== ChatVarType.Memory && (
<BubbleX className='h-4 w-4 text-util-colors-teal-teal-700' />
)
}
<div className='system-sm-medium text-text-primary'>{item.name}</div> <div className='system-sm-medium text-text-primary'>{item.name}</div>
<div className='system-xs-medium text-text-tertiary'>{capitalize(item.value_type)}</div> <div className='system-xs-medium text-text-tertiary'>{capitalize(valueType)}</div>
</div> </div>
<div className='flex shrink-0 items-center gap-1 text-text-tertiary'> <div className='flex shrink-0 items-center gap-1 text-text-tertiary'>
<div className='radius-md cursor-pointer p-1 hover:bg-state-base-hover hover:text-text-secondary'> <div className={cn('radius-md hidden cursor-pointer p-1 hover:bg-state-base-hover hover:text-text-secondary group-hover:block', currentVarId === item.id && 'block bg-state-base-hover text-text-secondary')}>
<RiEditLine className='h-4 w-4' onClick={() => onEdit(item)}/> <RiEditLine className='h-4 w-4' onClick={() => onEdit(item)}/>
</div> </div>
<div <div
className='radius-md cursor-pointer p-1 hover:bg-state-destructive-hover hover:text-text-destructive' className={cn('radius-md hidden cursor-pointer p-1 hover:bg-state-destructive-hover hover:text-text-destructive group-hover:block', currentVarId === item.id && 'block')}
onMouseOver={() => setDestructive(true)} onMouseOver={() => setDestructive(true)}
onMouseOut={() => setDestructive(false)} onMouseOut={() => setDestructive(false)}
> >
<RiDeleteBinLine className='h-4 w-4' onClick={() => onDelete(item)}/> <RiDeleteBinLine className='h-4 w-4' onClick={() => onDelete(item)}/>
</div> </div>
<div className={cn('flex h-6 items-center gap-0.5 group-hover:hidden', currentVarId === item.id && 'hidden')}>
{scope === 'app' && <Badge text={'conv'} />}
{term && <Badge text={term} />}
</div>
</div> </div>
</div> </div>
<div className='system-xs-regular truncate text-text-tertiary'>{item.description}</div> {
'description' in item && item.description && (
<div className='system-xs-regular truncate text-text-tertiary'>{item.description}</div>
)
}
{
scope === 'app' && (
<div className='system-xs-regular truncate text-text-tertiary'>{t('workflow.chatVariable.appScopeText')}</div>
)
}
</div> </div>
) )
} }

View File

@ -9,15 +9,15 @@ import {
PortalToFollowElemContent, PortalToFollowElemContent,
PortalToFollowElemTrigger, PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem' } from '@/app/components/base/portal-to-follow-elem'
import type { ConversationVariable } from '@/app/components/workflow/types' import type { ConversationVariable, MemoryVariable } from '@/app/components/workflow/types'
type Props = { type Props = {
open: boolean open: boolean
setOpen: (value: React.SetStateAction<boolean>) => void setOpen: (value: React.SetStateAction<boolean>) => void
showTip: boolean showTip: boolean
chatVar?: ConversationVariable chatVar?: ConversationVariable | MemoryVariable
onClose: () => void onClose: () => void
onSave: (env: ConversationVariable) => void onSave: (env: ConversationVariable | MemoryVariable) => void
} }
const VariableModalTrigger = ({ const VariableModalTrigger = ({

Some files were not shown because too many files have changed in this diff Show More