From ebad5e00a32ff92439bdaed24150b774c480c98a Mon Sep 17 00:00:00 2001 From: Timebomb2018 <18868801967@163.com> Date: Mon, 9 Feb 2026 10:02:34 +0800 Subject: [PATCH 01/36] fix(model): 1. when adding a model API key to the model list, a tenant_id uniqueness check needs to be added; 2.the Model Square has cancelled custom models; 3. optimization of the interface logic for customizing model configurations in the model list --- api/app/controllers/model_controller.py | 5 ++- api/app/repositories/model_repository.py | 12 ++++--- api/app/schemas/model_schema.py | 6 ++-- api/app/services/model_service.py | 42 +++++++++++++++--------- 4 files changed, 43 insertions(+), 22 deletions(-) diff --git a/api/app/controllers/model_controller.py b/api/app/controllers/model_controller.py index 83753744..bb1ba526 100644 --- a/api/app/controllers/model_controller.py +++ b/api/app/controllers/model_controller.py @@ -328,7 +328,7 @@ async def update_composite_model( try: if model_data.type is not None: - raise BusinessException("不允许更改模型类型和供应商", BizCode.INVALID_PARAMETER) + raise BusinessException("不允许更改模型类型", BizCode.INVALID_PARAMETER) result_orm = await ModelConfigService.update_composite_model(db=db, model_id=model_id, model_data=model_data, tenant_id=current_user.tenant_id) api_logger.info(f"组合模型更新成功: {result_orm.name} (ID: {model_id})") @@ -368,6 +368,9 @@ def update_model( 更新模型配置 """ api_logger.info(f"更新模型配置请求: model_id={model_id}, 用户: {current_user.username}, tenant_id={current_user.tenant_id}") + + if model_data.type is not None or model_data.provider is not None: + raise BusinessException("不允许更改模型类型和供应商", BizCode.INVALID_PARAMETER) try: api_logger.debug(f"开始更新模型配置: model_id={model_id}") diff --git a/api/app/repositories/model_repository.py b/api/app/repositories/model_repository.py index f323b30c..2c513e82 100644 --- a/api/app/repositories/model_repository.py +++ b/api/app/repositories/model_repository.py @@ -48,13 +48,17 @@ class ModelConfigRepository: raise @staticmethod - def get_by_name(db: Session, name: str, tenant_id: uuid.UUID | None = None) -> Optional[ModelConfig]: - """根据名称获取模型配置""" - db_logger.debug(f"根据名称查询模型配置: name={name}, tenant_id={tenant_id}") + def get_by_name(db: Session, name: str, provider: str | None = None, tenant_id: uuid.UUID | None = None) -> Optional[ModelConfig]: + """根据名称和供应商获取模型配置""" + db_logger.debug(f"根据名称查询模型配置: name={name}, provider={provider}, tenant_id={tenant_id}") try: query = db.query(ModelConfig).filter(ModelConfig.name == name) + # 添加供应商过滤 + if provider: + query = query.filter(ModelConfig.provider == provider) + # 添加租户过滤 if tenant_id: query = query.filter( @@ -69,7 +73,7 @@ class ModelConfigRepository: db_logger.debug(f"模型配置查询成功: {model.name}") return model except Exception as e: - db_logger.error(f"根据名称查询模型配置失败: name={name} - {str(e)}") + db_logger.error(f"根据名称查询模型配置失败: name={name}, provider={provider} - {str(e)}") raise @staticmethod diff --git a/api/app/schemas/model_schema.py b/api/app/schemas/model_schema.py index a2d3650a..0c0bbeed 100644 --- a/api/app/schemas/model_schema.py +++ b/api/app/schemas/model_schema.py @@ -25,9 +25,9 @@ class ModelConfigBase(BaseModel): class ApiKeyCreateNested(BaseModel): """用于在创建模型时内嵌创建API Key的Schema""" - model_name: str = Field(..., description="模型实际名称", max_length=255) + model_name: Optional[str] = Field(None, description="模型实际名称", max_length=255) description: Optional[str] = Field(None, description="备注") - provider: ModelProvider = Field(..., description="API Key提供商") + provider: Optional[str] = Field(None, description="API Key提供商") api_key: str = Field(..., description="API密钥", max_length=500) api_base: Optional[str] = Field(None, description="API基础URL", max_length=500) config: Optional[Dict[str, Any]] = Field({}, description="API Key特定配置") @@ -57,6 +57,8 @@ class ModelConfigUpdate(BaseModel): """更新模型配置Schema""" name: Optional[str] = Field(None, description="模型显示名称", max_length=255) type: Optional[ModelType] = Field(None, description="模型类型") + provider: Optional[str] = Field(None, description="供应商") + logo: Optional[str] = Field(None, description="模型logo图片URL", max_length=255) description: Optional[str] = Field(None, description="模型描述") config: Optional[Dict[str, Any]] = Field(None, description="模型配置参数") is_active: Optional[bool] = Field(None, description="是否激活") diff --git a/api/app/services/model_service.py b/api/app/services/model_service.py index d382b1b1..aa8cfbac 100644 --- a/api/app/services/model_service.py +++ b/api/app/services/model_service.py @@ -6,7 +6,7 @@ import math import time import asyncio -from app.models.models_model import ModelConfig, ModelApiKey, ModelType, LoadBalanceStrategy +from app.models.models_model import ModelConfig, ModelApiKey, ModelType, LoadBalanceStrategy, ModelProvider from app.repositories.model_repository import ModelConfigRepository, ModelApiKeyRepository, ModelBaseRepository from app.schemas import model_schema from app.schemas.model_schema import ( @@ -69,9 +69,9 @@ class ModelConfigService: return items @staticmethod - def get_model_by_name(db: Session, name: str, tenant_id: uuid.UUID | None = None) -> ModelConfig: + def get_model_by_name(db: Session, name: str, provider: str | None = None, tenant_id: uuid.UUID | None = None) -> ModelConfig: """根据名称获取模型配置""" - model = ModelConfigRepository.get_by_name(db, name, tenant_id=tenant_id) + model = ModelConfigRepository.get_by_name(db, name, provider=provider, tenant_id=tenant_id) if not model: raise BusinessException("模型配置不存在", BizCode.MODEL_NOT_FOUND) return model @@ -244,7 +244,7 @@ class ModelConfigService: async def create_model(db: Session, model_data: ModelConfigCreate, tenant_id: uuid.UUID) -> ModelConfig: """创建模型配置""" # 检查名称是否已存在(同租户内) - if ModelConfigRepository.get_by_name(db, model_data.name, tenant_id=tenant_id): + if ModelConfigRepository.get_by_name(db, model_data.name, provider=model_data.provider, tenant_id=tenant_id): raise BusinessException("模型名称已存在", BizCode.DUPLICATE_NAME) # 验证配置 @@ -253,8 +253,8 @@ class ModelConfigService: for api_key_data in api_key_data_list: validation_result = await ModelConfigService.validate_model_config( db=db, - model_name=api_key_data.model_name, - provider=api_key_data.provider, + model_name=model_data.name, + provider=model_data.provider, api_key=api_key_data.api_key, api_base=api_key_data.api_base, model_type=model_data.type, # 传递模型类型 @@ -277,6 +277,8 @@ class ModelConfigService: if api_key_datas: for api_key_data in api_key_datas: + api_key_data.model_name = model_data.name + api_key_data.provider = model_data.provider api_key_create_schema = ModelApiKeyCreate( model_config_ids=[model.id], **api_key_data.model_dump() @@ -295,7 +297,7 @@ class ModelConfigService: raise BusinessException("模型配置不存在", BizCode.MODEL_NOT_FOUND) if model_data.name and model_data.name != existing_model.name: - if ModelConfigRepository.get_by_name(db, model_data.name, tenant_id=tenant_id): + if ModelConfigRepository.get_by_name(db, model_data.name, provider=existing_model.provider, tenant_id=tenant_id): raise BusinessException("模型名称已存在", BizCode.DUPLICATE_NAME) model = ModelConfigRepository.update(db, model_id, model_data, tenant_id=tenant_id) @@ -306,7 +308,7 @@ class ModelConfigService: @staticmethod async def create_composite_model(db: Session, model_data: model_schema.CompositeModelCreate, tenant_id: uuid.UUID) -> ModelConfig: """创建组合模型""" - if ModelConfigRepository.get_by_name(db, model_data.name, tenant_id=tenant_id): + if ModelConfigRepository.get_by_name(db, model_data.name, provider=ModelProvider.COMPOSITE, tenant_id=tenant_id): raise BusinessException("模型名称已存在", BizCode.DUPLICATE_NAME) # 验证所有 API Key 存在且类型匹配 @@ -341,7 +343,7 @@ class ModelConfigService: "type": model_data.type, "logo": model_data.logo, "description": model_data.description, - "provider": "composite", + "provider": ModelProvider.COMPOSITE, "config": model_data.config, "is_active": model_data.is_active, "is_public": model_data.is_public, @@ -369,6 +371,10 @@ class ModelConfigService: existing_model = ModelConfigRepository.get_by_id(db, model_id, tenant_id=tenant_id) if not existing_model: raise BusinessException("模型配置不存在", BizCode.MODEL_NOT_FOUND) + + if model_data.name and model_data.name != existing_model.name: + if ModelConfigRepository.get_by_name(db, model_data.name, provider=existing_model.provider, tenant_id=tenant_id): + raise BusinessException("模型名称已存在", BizCode.DUPLICATE_NAME) if not existing_model.is_composite: raise BusinessException("该模型不是组合模型", BizCode.INVALID_PARAMETER) @@ -471,11 +477,14 @@ class ModelApiKeyService: # 从ModelBase获取model_name model_name = model_config.model_base.name if model_config.model_base else model_config.name - # 检查是否存在API Key(包括软删除) - existing_key = db.query(ModelApiKey).filter( + # 检查是否存在API Key(包括软删除),需要考虑tenant_id + existing_key = db.query(ModelApiKey).join( + ModelApiKey.model_configs + ).filter( ModelApiKey.api_key == data.api_key, ModelApiKey.provider == data.provider, - ModelApiKey.model_name == model_name + ModelApiKey.model_name == model_name, + ModelConfig.tenant_id == model_config.tenant_id ).first() if existing_key: @@ -542,11 +551,14 @@ class ModelApiKeyService: if not model_config: raise BusinessException("模型配置不存在", BizCode.MODEL_NOT_FOUND) - # 检查API Key是否已存在(包括软删除) - existing_key = db.query(ModelApiKey).filter( + # 检查API Key是否已存在(包括软删除),需要考虑tenant_id + existing_key = db.query(ModelApiKey).join( + ModelApiKey.model_configs + ).filter( ModelApiKey.api_key == api_key_data.api_key, ModelApiKey.provider == api_key_data.provider, - ModelApiKey.model_name == api_key_data.model_name + ModelApiKey.model_name == api_key_data.model_name, + ModelConfig.tenant_id == model_config.tenant_id ).first() if existing_key: From 81e92b4fa604f4f53fcbd959faa648b3a8a7f1ce Mon Sep 17 00:00:00 2001 From: zhaoying Date: Mon, 9 Feb 2026 10:00:36 +0800 Subject: [PATCH 02/36] feat(web): move create custom model to model list --- web/src/api/models.ts | 4 +-- web/src/components/Upload/UploadImages.tsx | 2 +- web/src/views/ModelManagement/List.tsx | 15 +++++--- web/src/views/ModelManagement/Square.tsx | 4 +-- .../components/CustomModelModal.tsx | 34 +++++++++++-------- .../components/ModelListDetail.tsx | 11 ++++-- .../components/ModelSquareDetail.tsx | 5 +-- web/src/views/ModelManagement/index.tsx | 27 +++++++++------ web/src/views/ModelManagement/types.ts | 16 +++++---- 9 files changed, 70 insertions(+), 48 deletions(-) diff --git a/web/src/api/models.ts b/web/src/api/models.ts index eb18ce91..2d590287 100644 --- a/web/src/api/models.ts +++ b/web/src/api/models.ts @@ -66,9 +66,9 @@ export const addModelPlaza = (model_base_id: string) => { } // Create custom model export const addCustomModel = (data: CustomModelForm) => { - return request.post('/models/model_plaza', data) + return request.post('/models', data) } // Update custom model export const updateCustomModel = (model_base_id: string, data: CustomModelForm) => { - return request.put(`/models/model_plaza/${model_base_id}`, data) + return request.put(`/models/${model_base_id}`, data) } \ No newline at end of file diff --git a/web/src/components/Upload/UploadImages.tsx b/web/src/components/Upload/UploadImages.tsx index 47343482..e60a5b81 100644 --- a/web/src/components/Upload/UploadImages.tsx +++ b/web/src/components/Upload/UploadImages.tsx @@ -93,7 +93,7 @@ const UploadImages = forwardRef(({ onChange, disabled = false, fileSize, - fileType = ['png', 'jpg', 'gif'], + fileType = ['png', 'jpg', 'gif', 'svg'], isAutoUpload = true, maxCount = 1, className = 'rb:size-24! rb:leading-1!', diff --git a/web/src/views/ModelManagement/List.tsx b/web/src/views/ModelManagement/List.tsx index bf8b42d7..ffa89fb4 100644 --- a/web/src/views/ModelManagement/List.tsx +++ b/web/src/views/ModelManagement/List.tsx @@ -10,11 +10,11 @@ * Shows model tags and allows viewing model details */ -import { useRef, useState, useEffect, type FC } from 'react'; +import { useRef, useState, useEffect, forwardRef, useImperativeHandle } from 'react'; import { Button, Flex, Row, Col } from 'antd' import { useTranslation } from 'react-i18next'; -import type { ProviderModelItem, KeyConfigModalRef, ModelListDetailRef } from './types' +import type { ProviderModelItem, KeyConfigModalRef, ModelListDetailRef, ModelListItem, BaseRef } from './types' import RbCard from '@/components/RbCard/Card' import { getModelNewList } from '@/api/models' import PageEmpty from '@/components/Empty/PageEmpty'; @@ -26,11 +26,12 @@ import { getLogoUrl } from './utils' /** * Model list component */ -const ModelList: FC<{ query: any }> = ({ query }) => { +const ModelList = forwardRef void; }> (({ query, handleEdit }, ref) => { const { t } = useTranslation(); const keyConfigModalRef = useRef(null) const modelListDetailRef = useRef(null) const [list, setList] = useState([]) + useEffect(() => { getList() }, [query]) @@ -54,6 +55,11 @@ const ModelList: FC<{ query: any }> = ({ query }) => { keyConfigModalRef.current?.handleOpen(vo) } + /** Expose methods to parent component */ + useImperativeHandle(ref, () => ({ + getList, + modelListDetailRefresh: () => modelListDetailRef.current?.handleRefresh() + })); return ( <> {list.length === 0 @@ -96,9 +102,10 @@ const ModelList: FC<{ query: any }> = ({ query }) => { ) -} +}) export default ModelList \ No newline at end of file diff --git a/web/src/views/ModelManagement/Square.tsx b/web/src/views/ModelManagement/Square.tsx index 67ef641d..a9b345a1 100644 --- a/web/src/views/ModelManagement/Square.tsx +++ b/web/src/views/ModelManagement/Square.tsx @@ -26,7 +26,7 @@ import { getLogoUrl } from './utils' /** * Model square component */ -const ModelSquare = forwardRef void; }>(({ query, handleEdit }, ref) => { +const ModelSquare = forwardRef (({ query }, ref) => { const { t } = useTranslation(); const { message } = App.useApp() const modelSquareDetailRef = useRef(null) @@ -96,7 +96,6 @@ const ModelSquare = forwardRef {item.add_count} - {!item.is_official && } {item.is_added ? : @@ -114,7 +113,6 @@ const ModelSquare = forwardRef ) diff --git a/web/src/views/ModelManagement/components/CustomModelModal.tsx b/web/src/views/ModelManagement/components/CustomModelModal.tsx index 5366baa9..fb0db96e 100644 --- a/web/src/views/ModelManagement/components/CustomModelModal.tsx +++ b/web/src/views/ModelManagement/components/CustomModelModal.tsx @@ -11,10 +11,10 @@ */ import { forwardRef, useImperativeHandle, useState } from 'react'; -import { Form, Input, App, Select } from 'antd'; +import { Form, Input, App } from 'antd'; import { useTranslation } from 'react-i18next'; -import type { CustomModelForm, ModelPlazaItem, CustomModelModalRef, CustomModelModalProps } from '../types'; +import type { CustomModelForm, ModelListItem, CustomModelModalRef, CustomModelModalProps } from '../types'; import RbModal from '@/components/RbModal' import CustomSelect from '@/components/CustomSelect' import UploadImages from '@/components/Upload/UploadImages' @@ -30,22 +30,21 @@ const CustomModelModal = forwardRef( const { t } = useTranslation(); const { message } = App.useApp(); const [visible, setVisible] = useState(false); - const [model, setModel] = useState({} as ModelPlazaItem); + const [model, setModel] = useState({} as ModelListItem); const [isEdit, setIsEdit] = useState(false); const [form] = Form.useForm(); const [loading, setLoading] = useState(false) - const formValues = Form.useWatch([], form) /** Close modal and reset state */ const handleClose = () => { - setModel({} as ModelPlazaItem); + setModel({} as ModelListItem); form.resetFields(); setLoading(false) setVisible(false); }; /** Open modal with optional model data for editing */ - const handleOpen = (model?: ModelPlazaItem) => { + const handleOpen = (model?: ModelListItem) => { if (model) { setIsEdit(true); setModel(model); @@ -66,7 +65,7 @@ const CustomModelModal = forwardRef( const res = isEdit ? updateCustomModel(model.id, rest) : addCustomModel(data) res.then(() => { - refresh && refresh() + refresh && refresh(isEdit) handleClose() message.success(isEdit ? t('common.updateSuccess') : t('common.createSuccess')) }) @@ -79,12 +78,10 @@ const CustomModelModal = forwardRef( form .validateFields() .then((values) => { - setLoading(true) const { logo, ...rest } = values; let formData: CustomModelForm = { ...rest } - formData.is_official = false; if (typeof logo === 'object' && logo?.response?.data.file_id) { getFileLink(logo?.response?.data.file_id) @@ -111,8 +108,6 @@ const CustomModelModal = forwardRef( handleOpen, })); - console.log('formValues', formValues) - return ( ( > + + - diff --git a/web/src/views/ModelManagement/components/ModelListDetail.tsx b/web/src/views/ModelManagement/components/ModelListDetail.tsx index 2955660d..aad7b887 100644 --- a/web/src/views/ModelManagement/components/ModelListDetail.tsx +++ b/web/src/views/ModelManagement/components/ModelListDetail.tsx @@ -30,12 +30,13 @@ import CustomSelect from '@/components/CustomSelect' interface ModelListDetailProps { /** Callback to refresh parent list */ refresh?: () => void; + handleEdit: (vo?: ModelListItem) => void; } /** * Model list detail drawer component */ -const ModelListDetail = forwardRef(({ refresh }, ref) => { +const ModelListDetail = forwardRef(({ refresh, handleEdit }, ref) => { const { t } = useTranslation(); const [open, setOpen] = useState(false); const [data, setData] = useState({} as ProviderModelItem) @@ -95,7 +96,8 @@ const ModelListDetail = forwardRef(({ /** Expose methods to parent component */ useImperativeHandle(ref, () => ({ - handleOpen, + handleOpen, + handleRefresh, })); /** Filter models by selected type */ @@ -149,7 +151,10 @@ const ModelListDetail = forwardRef(({
- + + + + diff --git a/web/src/views/ModelManagement/components/ModelSquareDetail.tsx b/web/src/views/ModelManagement/components/ModelSquareDetail.tsx index fd52260a..4fee5a7b 100644 --- a/web/src/views/ModelManagement/components/ModelSquareDetail.tsx +++ b/web/src/views/ModelManagement/components/ModelSquareDetail.tsx @@ -29,14 +29,12 @@ import { getLogoUrl } from '../utils' interface ModelSquareDetailProps { /** Callback to refresh parent list */ refresh: () => void; - /** Callback to edit model */ - handleEdit: (vo: ModelPlazaItem) => void; } /** * Model square detail drawer component */ -const ModelSquareDetail = forwardRef(({ refresh, handleEdit }, ref) => { +const ModelSquareDetail = forwardRef(({ refresh }, ref) => { const { t } = useTranslation(); const { message } = App.useApp() const [model, setModel] = useState({} as ModelPlaza) @@ -112,7 +110,6 @@ const ModelSquareDetail = forwardRef {item.add_count} - {!item.is_official && } {item.is_added ? : diff --git a/web/src/views/ModelManagement/index.tsx b/web/src/views/ModelManagement/index.tsx index c3b5ca83..35d7d864 100644 --- a/web/src/views/ModelManagement/index.tsx +++ b/web/src/views/ModelManagement/index.tsx @@ -15,7 +15,7 @@ import { Button, Flex, Space, type SegmentedProps, Form } from 'antd' import { useTranslation } from 'react-i18next'; import GroupModelModal from './components/GroupModelModal' -import type { ModelListItem, GroupModelModalRef, CustomModelModalRef, ModelPlazaItem, BaseRef, Query } from './types' +import type { ModelListItem, GroupModelModalRef, CustomModelModalRef, BaseRef, Query } from './types' import SearchInput from '@/components/SearchInput' import PageTabs from '@/components/PageTabs' import GroupModel from './Group' @@ -38,7 +38,7 @@ const tabKeys = ['group', 'list', 'square'] const configModalRef = useRef(null) const customModelModalRef = useRef(null) const groupRef = useRef(null) - const squareRef = useRef(null) + const modelListRef = useRef(null) const [form] = Form.useForm() const query = Form.useWatch([], form) @@ -56,24 +56,29 @@ const tabKeys = ['group', 'list', 'square'] } /** Open edit modal based on active tab */ - const handleEdit = (vo?: ModelListItem | ModelPlazaItem) => { + const handleEdit = (vo?: ModelListItem | ModelListItem) => { switch(activeTab) { case 'group': configModalRef?.current?.handleOpen(vo as ModelListItem) break - case 'square': - customModelModalRef?.current?.handleOpen(vo as ModelPlazaItem) + case 'list': + customModelModalRef?.current?.handleOpen(vo as ModelListItem) break } } /** Refresh list based on active tab */ - const handleRefresh = () => { + const handleRefresh = (isEdit?: boolean) => { switch (activeTab) { case 'group': groupRef.current?.getList() break - case 'square': - squareRef.current?.getList() + case 'list': + console.log('isEdit', isEdit) + if (isEdit) { + modelListRef.current?.modelListDetailRefresh?.() + } else { + modelListRef.current?.getList() + } break } } @@ -122,15 +127,15 @@ const tabKeys = ['group', 'list', 'square'] } {activeTab === 'group' && } - {activeTab === 'square' && } + {activeTab === 'list' && }
{activeTab === 'group' && } - {activeTab === 'list' && } - {activeTab === 'square' && } + {activeTab === 'list' && } + {activeTab === 'square' && }
void; + handleRefresh: () => void; } /** @@ -284,10 +285,12 @@ export interface CustomModelForm { logo?: any; /** Model description */ description: string; - /** Whether model is official */ - is_official: boolean; - /** Model tags */ - tags: string[]; + api_keys: Array<{ + /** API key value */ + api_key: string; + /** API base URL */ + api_base: string; + }> } /** @@ -295,7 +298,7 @@ export interface CustomModelForm { */ export interface CustomModelModalRef { /** Open modal with optional model plaza item */ - handleOpen: (vo?: ModelPlazaItem) => void; + handleOpen: (vo?: ModelListItem) => void; } /** @@ -303,7 +306,7 @@ export interface CustomModelModalRef { */ export interface CustomModelModalProps { /** Callback to refresh model list */ - refresh?: () => void; + refresh?: (flag?: boolean) => void; } /** @@ -312,4 +315,5 @@ export interface CustomModelModalProps { export interface BaseRef { /** Refresh list data */ getList: () => void; + modelListDetailRefresh?: () => void; } \ No newline at end of file From e19d27f6404f532215b9d121d7d07fd08333a381 Mon Sep 17 00:00:00 2001 From: zhaoying Date: Mon, 9 Feb 2026 12:06:29 +0800 Subject: [PATCH 03/36] feat(web): editor variable support key command --- .../Editor/plugin/AutocompletePlugin.tsx | 95 ++++++++++++++++++- 1 file changed, 91 insertions(+), 4 deletions(-) diff --git a/web/src/views/Workflow/components/Editor/plugin/AutocompletePlugin.tsx b/web/src/views/Workflow/components/Editor/plugin/AutocompletePlugin.tsx index 7811cc7b..8e2687f1 100644 --- a/web/src/views/Workflow/components/Editor/plugin/AutocompletePlugin.tsx +++ b/web/src/views/Workflow/components/Editor/plugin/AutocompletePlugin.tsx @@ -1,6 +1,6 @@ import { useEffect, useState, type FC } from 'react'; import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; -import { $getSelection, $isRangeSelection, $isTextNode } from 'lexical'; +import { $getSelection, $isRangeSelection, $isTextNode, COMMAND_PRIORITY_HIGH, KEY_ENTER_COMMAND, KEY_ARROW_DOWN_COMMAND, KEY_ARROW_UP_COMMAND, KEY_ESCAPE_COMMAND } from 'lexical'; import { INSERT_VARIABLE_COMMAND } from '../commands'; import type { NodeProperties } from '../../../types' @@ -45,6 +45,9 @@ const AutocompletePlugin: FC<{ options: Suggestion[], enableJinja2?: boolean }> (textBeforeCursor === '/' && anchorOffset === 1); setShowSuggestions(shouldShow); + if (!shouldShow) { + setSelectedIndex(0); + } if (shouldShow) { const domSelection = window.getSelection(); @@ -113,9 +116,6 @@ const AutocompletePlugin: FC<{ options: Suggestion[], enableJinja2?: boolean }> setShowSuggestions(false); }; - if (!showSuggestions) return null; - - // Group options by node id const groupedSuggestions = options.reduce((groups: Record, suggestion) => { const { nodeData } = suggestion const nodeId = nodeData.id as string; @@ -126,6 +126,93 @@ const AutocompletePlugin: FC<{ options: Suggestion[], enableJinja2?: boolean }> return groups; }, {}); + useEffect(() => { + if (!showSuggestions) return; + + const allOptions = Object.values(groupedSuggestions).flat(); + + return editor.registerCommand( + KEY_ENTER_COMMAND, + (event) => { + if (showSuggestions && allOptions.length > 0) { + const selectedOption = allOptions[selectedIndex]; + if (selectedOption && !selectedOption.disabled) { + event?.preventDefault(); + insertMention(selectedOption); + return true; + } + } + return false; + }, + COMMAND_PRIORITY_HIGH + ); + }, [showSuggestions, selectedIndex, groupedSuggestions, insertMention, editor]); + + useEffect(() => { + if (!showSuggestions) return; + + const allOptions = Object.values(groupedSuggestions).flat(); + + const unregisterArrowDown = editor.registerCommand( + KEY_ARROW_DOWN_COMMAND, + (event) => { + if (showSuggestions && allOptions.length > 0) { + event?.preventDefault(); + setSelectedIndex(prev => { + let nextIndex = prev + 1; + while (nextIndex < allOptions.length && allOptions[nextIndex].disabled) { + nextIndex++; + } + return nextIndex >= allOptions.length ? prev : nextIndex; + }); + return true; + } + return false; + }, + COMMAND_PRIORITY_HIGH + ); + + const unregisterArrowUp = editor.registerCommand( + KEY_ARROW_UP_COMMAND, + (event) => { + if (showSuggestions && allOptions.length > 0) { + event?.preventDefault(); + setSelectedIndex(prev => { + let prevIndex = prev - 1; + while (prevIndex >= 0 && allOptions[prevIndex].disabled) { + prevIndex--; + } + return prevIndex < 0 ? prev : prevIndex; + }); + return true; + } + return false; + }, + COMMAND_PRIORITY_HIGH + ); + + const unregisterEscape = editor.registerCommand( + KEY_ESCAPE_COMMAND, + (event) => { + if (showSuggestions) { + event?.preventDefault(); + setShowSuggestions(false); + return true; + } + return false; + }, + COMMAND_PRIORITY_HIGH + ); + + return () => { + unregisterArrowDown(); + unregisterArrowUp(); + unregisterEscape(); + }; + }, [showSuggestions, selectedIndex, groupedSuggestions, editor]); + + if (!showSuggestions) return null; + if (Object.entries(groupedSuggestions).length === 0) { return null } From f076199e3f3d1009695ff90e4128562f990e0426 Mon Sep 17 00:00:00 2001 From: zhaoying Date: Mon, 9 Feb 2026 18:40:24 +0800 Subject: [PATCH 04/36] feat(web): if-else/question-classifier node port layout update --- .../Workflow/components/Nodes/AddNode.tsx | 21 ++++-- .../Workflow/components/PortClickHandler.tsx | 53 ++++++++------ .../components/Properties/CaseList/index.tsx | 72 +++++++++++-------- .../Properties/CategoryList/index.tsx | 41 ++++++----- .../Properties/HttpRequest/index.tsx | 17 +++-- web/src/views/Workflow/constant.ts | 60 +++++++++++----- .../views/Workflow/hooks/useWorkflowGraph.ts | 52 ++++++-------- 7 files changed, 188 insertions(+), 128 deletions(-) diff --git a/web/src/views/Workflow/components/Nodes/AddNode.tsx b/web/src/views/Workflow/components/Nodes/AddNode.tsx index f9561a44..13e80150 100644 --- a/web/src/views/Workflow/components/Nodes/AddNode.tsx +++ b/web/src/views/Workflow/components/Nodes/AddNode.tsx @@ -1,8 +1,14 @@ +/* + * @Author: ZhaoYing + * @Date: 2026-02-09 18:31:30 + * @Last Modified by: ZhaoYing + * @Last Modified time: 2026-02-09 18:31:30 + */ import { useState } from 'react'; import { Popover } from 'antd'; import clsx from 'clsx'; import type { ReactShapeConfig } from '@antv/x6-react-shape'; -import { nodeLibrary, graphNodeLibrary, edgeAttrs } from '../../constant'; +import { nodeLibrary, graphNodeLibrary, edgeAttrs, nodeWidth } from '../../constant'; import { useTranslation } from 'react-i18next'; const AddNode: ReactShapeConfig['component'] = ({ node, graph }) => { @@ -10,6 +16,7 @@ const AddNode: ReactShapeConfig['component'] = ({ node, graph }) => { const { t } = useTranslation(); const [open, setOpen] = useState(false); + // Handle node selection from popover and create new node replacing the add-node placeholder const handleNodeSelect = (selectedNodeType: any) => { const parentBBox = node.getBBox(); const cycleId = data.cycle; @@ -32,7 +39,7 @@ const AddNode: ReactShapeConfig['component'] = ({ node, graph }) => { }, }); - // 将新节点添加为父节点的子节点 + // Add new node as child of parent node if (cycleId) { const parentNode = graph.getNodes().find((n: any) => n.getData()?.id === cycleId); if (parentNode) { @@ -61,14 +68,14 @@ const AddNode: ReactShapeConfig['component'] = ({ node, graph }) => { }); }); - // 删除所有add-node类型的节点 + // Remove all add-node type nodes graph.getNodes().forEach((n: any) => { if (n.getData()?.type === 'add-node' && n.getData()?.cycle === cycleId) { n.remove(); } }); - // 自动调整循环节点大小 + // Automatically adjust loop node size const loopNode = graph.getNodes().find((n: any) => n.getData()?.id === cycleId); if (loopNode) { const adjustLoopSize = () => { @@ -85,7 +92,7 @@ const AddNode: ReactShapeConfig['component'] = ({ node, graph }) => { }, { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity }); const padding = 20; - const newWidth = Math.max(240, bounds.maxX - bounds.minX + padding * 2); + const newWidth = Math.max(nodeWidth, bounds.maxX - bounds.minX + padding * 2); const newHeight = Math.max(120, bounds.maxY - bounds.minY + padding * 2); loopNode.prop('size', { width: newWidth, height: newHeight }); @@ -94,7 +101,7 @@ const AddNode: ReactShapeConfig['component'] = ({ node, graph }) => { adjustLoopSize(); - // 监听子节点移动事件 + // Listen to child node movement events const childNodes = graph.getNodes().filter((n: any) => n.getData()?.cycle === cycleId); childNodes.forEach((childNode: any) => { childNode.on('change:position', adjustLoopSize); @@ -104,7 +111,7 @@ const AddNode: ReactShapeConfig['component'] = ({ node, graph }) => { }; const content = ( -
+
{nodeLibrary.map((category, categoryIndex) => { const filteredNodes = category.nodes.filter(nodeType => nodeType.type !== 'start' && nodeType.type !== 'end' && nodeType.type !== 'iteration' && nodeType.type !== 'loop' && nodeType.type !== 'cycle-start' diff --git a/web/src/views/Workflow/components/PortClickHandler.tsx b/web/src/views/Workflow/components/PortClickHandler.tsx index 0a1e3906..ec898bc8 100644 --- a/web/src/views/Workflow/components/PortClickHandler.tsx +++ b/web/src/views/Workflow/components/PortClickHandler.tsx @@ -1,7 +1,13 @@ +/* + * @Author: ZhaoYing + * @Date: 2026-02-09 18:30:28 + * @Last Modified by: ZhaoYing + * @Last Modified time: 2026-02-09 18:30:28 + */ import { useEffect, useState } from 'react'; import { Popover } from 'antd'; import { useTranslation } from 'react-i18next'; -import { nodeLibrary, graphNodeLibrary, edgeAttrs } from '../constant'; +import { nodeLibrary, graphNodeLibrary, edgeAttrs, nodeWidth } from '../constant'; interface PortClickHandlerProps { graph: any; @@ -32,13 +38,14 @@ const PortClickHandler: React.FC = ({ graph }) => { }; }, []); + // Handle node selection from popover menu and create new node with edge connection const handleNodeSelect = (selectedNodeType: any) => { if (!sourceNode || !graph) return; const sourceNodeData = sourceNode.getData(); const sourceNodeType = sourceNodeData?.type; - // 如果是cycle-start节点,需要处理add-node节点 + // If it's a cycle-start node, handle the add-node placeholder let addNodePosition = null; if (sourceNodeType === 'cycle-start' && sourceNodeData.cycle) { const cycleId = sourceNodeData.cycle; @@ -53,38 +60,38 @@ const PortClickHandler: React.FC = ({ graph }) => { } } - // 计算新节点位置,避免重叠 + // Calculate new node position to avoid overlapping const sourceBBox = sourceNode.getBBox(); const nodeWidth = graphNodeLibrary[selectedNodeType.type]?.width || 120; const nodeHeight = graphNodeLibrary[selectedNodeType.type]?.height || 88; const horizontalSpacing = sourceNodeType === 'cycle-start' ? 40 : 80; const verticalSpacing = 10; - // 获取源连接桩的group信息 + // Get source port group information const sourcePortInfo = sourceNode.getPorts().find((p: any) => p.id === sourcePort); const sourcePortGroup = sourcePortInfo?.group || sourcePort; console.log('sourcePortGroup', sourcePortGroup, sourcePortInfo) - // 如果有add-node位置,使用该位置,否则计算新位置 + // If add-node position exists, use it; otherwise calculate new position let newX, newY; if (addNodePosition) { newX = addNodePosition.x; newY = addNodePosition.y; } else { - // 根据连接桩位置决定节点放置方向 + // Determine node placement direction based on port position if (sourcePortGroup === 'left') { - // 左侧连接桩,在左侧添加节点 + // Left port: add node to the left newX = sourceBBox.x - nodeWidth*2 - horizontalSpacing; newY = sourceBBox.y; } else { - // 右侧连接桩,在右侧添加节点 + // Right port: add node to the right newX = sourceBBox.x + sourceBBox.width + horizontalSpacing; newY = sourceBBox.y; } - // 检查位置是否与现有节点重叠(只考虑与当前节点相连的节点) + // Check if position overlaps with existing nodes (only consider connected nodes) const checkOverlap = (x: number, y: number) => { - // 获取与源节点相连的节点 + // Get nodes connected to the source node const connectedNodes = new Set(); graph.getConnectedEdges(sourceNode).forEach((edge: any) => { const sourceId = edge.getSourceCellId(); @@ -95,20 +102,20 @@ const PortClickHandler: React.FC = ({ graph }) => { return graph.getNodes().some((node: any) => { if (node.id === sourceNode.id) return false; - if (!connectedNodes.has(node.id)) return false; // 只考虑相连的节点 + if (!connectedNodes.has(node.id)) return false; // Only consider connected nodes const bbox = node.getBBox(); return !(x + nodeWidth < bbox.x || x > bbox.x + bbox.width || y + nodeHeight < bbox.y || y > bbox.y + bbox.height); }); }; - // 如果位置被占用,向下寻找空位 + // If position is occupied, search downward for empty space while (checkOverlap(newX, newY)) { newY += nodeHeight + verticalSpacing; } } - // 创建新节点 + // Create new node const id = `${selectedNodeType.type.replace(/-/g, '_')}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}` const newNode = graph.addNode({ ...(graphNodeLibrary[selectedNodeType.type] || graphNodeLibrary.default), @@ -120,12 +127,12 @@ const PortClickHandler: React.FC = ({ graph }) => { type: selectedNodeType.type, icon: selectedNodeType.icon, name: t(`workflow.${selectedNodeType.type}`), - cycle: sourceNodeData.cycle, // 继承源节点的cycle + cycle: sourceNodeData.cycle, // Inherit cycle from source node config: selectedNodeType.config || {} }, }); - // 将新节点添加为父节点的子节点 + // Add new node as child of parent node if (sourceNodeData.cycle) { const parentNode = graph.getNodes().find((n: any) => n.getData()?.id === sourceNodeData.cycle); if (parentNode) { @@ -133,16 +140,16 @@ const PortClickHandler: React.FC = ({ graph }) => { } } - // 创建连线 + // Create edge connection setTimeout(() => { const targetPorts = newNode.getPorts(); let targetPort; if (sourcePortGroup === 'left') { - // 从左侧连接桩连出,连接到新节点的右侧 + // Connect from left port to new node's right side targetPort = targetPorts.find((port: any) => port.group === 'right')?.id || 'right'; } else { - // 从右侧连接桩连出,连接到新节点的左侧 + // Connect from right port to new node's left side targetPort = targetPorts.find((port: any) => port.group === 'left')?.id || 'left'; } @@ -153,7 +160,7 @@ const PortClickHandler: React.FC = ({ graph }) => { // zIndex: sourceNodeData.cycle && sourceNodeType == 'cycle-start' ? 1 : sourceNodeData.cycle ? 2 : 0 }); - // 循环节点内子节点通过连接桩添加时,调整循环节点大小 + // Adjust loop node size when child node is added via port within loop node const cycleId = sourceNodeData.cycle; if (cycleId) { const parentNode = graph.getNodes().find((n: any) => n.getData()?.id === cycleId); @@ -174,7 +181,7 @@ const PortClickHandler: React.FC = ({ graph }) => { const padding = 20; const bottomPadding = 50; - const newWidth = Math.max(240, bounds.maxX - bounds.minX + padding * 2); + const newWidth = Math.max(nodeWidth, bounds.maxX - bounds.minX + padding * 2); const newHeight = Math.max(120, bounds.maxY - bounds.minY + padding + bottomPadding); parentNode.prop('size', { width: newWidth, height: newHeight }); @@ -183,7 +190,7 @@ const PortClickHandler: React.FC = ({ graph }) => { adjustLoopSize(); - // 监听子节点移动事件 + // Listen to child node movement events const childNodes = graph.getNodes().filter((n: any) => n.getData()?.cycle === cycleId); childNodes.forEach((childNode: any) => { childNode.on('change:position', adjustLoopSize); @@ -192,7 +199,7 @@ const PortClickHandler: React.FC = ({ graph }) => { } }, 50); - // 清理临时元素 + // Clean up temporary element if (tempElement) { document.body.removeChild(tempElement); setTempElement(null); @@ -210,7 +217,7 @@ const PortClickHandler: React.FC = ({ graph }) => { }; const content = ( -
+
{nodeLibrary.map((category, categoryIndex) => { const sourceNodeData = sourceNode?.getData(); const isChildOfLoop = sourceNodeData?.cycle && graph?.getNodes().find((n: any) => n.getData()?.id === sourceNodeData.cycle && n.getData()?.type === 'loop'); diff --git a/web/src/views/Workflow/components/Properties/CaseList/index.tsx b/web/src/views/Workflow/components/Properties/CaseList/index.tsx index f76bd7db..34708513 100644 --- a/web/src/views/Workflow/components/Properties/CaseList/index.tsx +++ b/web/src/views/Workflow/components/Properties/CaseList/index.tsx @@ -1,3 +1,9 @@ +/* + * @Author: ZhaoYing + * @Date: 2026-02-09 18:24:53 + * @Last Modified by: ZhaoYing + * @Last Modified time: 2026-02-09 18:24:53 + */ import { type FC } from 'react' import clsx from 'clsx' import { useTranslation } from 'react-i18next'; @@ -6,7 +12,7 @@ import { Form, Button, Select, Space, Divider, InputNumber, Radio, type SelectPr import type { Suggestion } from '../../Editor/plugin/AutocompletePlugin' import VariableSelect from '../VariableSelect' import Editor from '../../Editor' -import { edgeAttrs, portArgs } from '../../../constant' +import { edgeAttrs, portTextAttrs, nodeWidth } from '../../../constant' interface CaseListProps { value?: Array<{ logical_operator: 'and' | 'or'; expressions: { left: string; operator: string; right: string; input_type?: string; }[] }>; @@ -52,15 +58,16 @@ const CaseList: FC = ({ const { t } = useTranslation(); const form = Form.useFormInstance(); + // Update node ports based on case count changes (add/remove cases) const updateNodePorts = (caseCount: number, removedCaseIndex?: number) => { if (!selectedNode || !graphRef?.current) return; - // 获取当前端口数量来判断是添加还是删除操作 + // Get current port count to determine if it's an add or remove operation const currentPorts = selectedNode.getPorts().filter((port: any) => port.group === 'right'); - const currentCaseCount = currentPorts.length - 1; // 减去ELSE端口 + const currentCaseCount = currentPorts.length - 1; // Exclude ELSE port const isAddingCase = removedCaseIndex === undefined && caseCount > currentCaseCount; - // 保存现有连线信息(包括左侧端口连线) + // Save existing edge connections (including left-side port connections) const existingEdges = graphRef.current.getEdges().filter((edge: any) => edge.getSourceCellId() === selectedNode.id || edge.getTargetCellId() === selectedNode.id ); @@ -73,7 +80,7 @@ const CaseList: FC = ({ isIncoming: edge.getTargetCellId() === selectedNode.id })); - // 移除所有现有的右侧端口 + // Remove all existing right-side ports const existingPorts = selectedNode.getPorts(); existingPorts.forEach((port: any) => { if (port.group === 'right') { @@ -81,43 +88,52 @@ const CaseList: FC = ({ } }); - // 计算新的节点高度:基础高度88px + 每个额外port增加30px + // Calculate new node height: base height 88px + 30px for each additional port const baseHeight = 88; const totalPorts = caseCount + 1; // IF/ELIF + ELSE const newHeight = baseHeight + (totalPorts - 2) * 30; - selectedNode.prop('size', { width: 240, height: newHeight }) - - // 添加 IF 端口 + selectedNode.prop('size', { width: nodeWidth, height: newHeight }) + + // Add IF port selectedNode.addPort({ id: 'CASE1', group: 'right', - args: portArgs, - attrs: { text: { text: 'IF', fontSize: 12, fill: '#5B6167' }} - }); + args: { + x: nodeWidth, + y: 42, + }, + attrs: { text: { text: 'IF', ...portTextAttrs } } + }) - // 添加 ELIF 端口 + // Add ELIF ports for (let i = 1; i < caseCount; i++) { selectedNode.addPort({ id: `CASE${i + 1}`, group: 'right', - args: portArgs, - attrs: { text: { text: 'ELIF', fontSize: 12, fill: '#5B6167' }} + args: { + x: nodeWidth, + y: 30 * i + 42, + }, + attrs: { text: { text: 'ELIF', ...portTextAttrs }} }); } - // 添加 ELSE 端口 + // Add ELSE port selectedNode.addPort({ id: `CASE${caseCount + 1}`, group: 'right', - args: portArgs, - attrs: { text: { text: 'ELSE', fontSize: 12, fill: '#5B6167' }} + args: { + x: nodeWidth, + y: 30 * caseCount + 42, + }, + attrs: { text: { text: 'ELSE', ...portTextAttrs }} }); - // 恢复连线 + // Restore edge connections setTimeout(() => { edgeConnections.forEach(({ edge, sourcePortId, targetCellId, targetPortId, sourceCellId, isIncoming }: any) => { - // 如果是进入连线(左侧端口),直接恢复 + // If it's an incoming connection (left-side port), restore directly if (isIncoming) { const sourceCell = graphRef.current?.getCellById(sourceCellId); if (sourceCell) { @@ -131,10 +147,10 @@ const CaseList: FC = ({ return; } - // 处理右侧端口连线 + // Handle right-side port connections const originalCaseNumber = parseInt(sourcePortId.match(/CASE(\d+)/)?.[1] || '0'); - // 如果是删除操作且是被删除的端口,删除连线 + // If it's a remove operation and the port is being removed, delete the connection if (removedCaseIndex !== undefined && originalCaseNumber === removedCaseIndex + 1) { graphRef.current?.removeCell(edge); return; @@ -142,22 +158,22 @@ const CaseList: FC = ({ let newPortId = sourcePortId; - // 如果是删除操作,需要重新映射端口ID + // If it's a remove operation, remap port IDs if (removedCaseIndex !== undefined) { if (originalCaseNumber > removedCaseIndex + 1) { - // 被删除端口之后的端口,编号向前移动 + // Ports after the removed port, shift numbering forward newPortId = `CASE${originalCaseNumber - 1}`; } - // ELSE端口始终映射到新的ELSE端口位置 + // ELSE port always maps to the new ELSE port position else if (originalCaseNumber === currentCaseCount + 1) { newPortId = `CASE${caseCount + 1}`; } } else if (isAddingCase) { - // 如果是添加操作,ELSE端口需要重新映射 + // If it's an add operation, ELSE port needs to be remapped if (originalCaseNumber === currentCaseCount + 1) { - newPortId = `CASE${caseCount + 1}`; // 新的ELSE端口 + newPortId = `CASE${caseCount + 1}`; // New ELSE port } - // 新添加的端口不恢复任何连线 + // Newly added ports don't restore any connections } const newPorts = selectedNode.getPorts(); diff --git a/web/src/views/Workflow/components/Properties/CategoryList/index.tsx b/web/src/views/Workflow/components/Properties/CategoryList/index.tsx index 22163905..63c64583 100644 --- a/web/src/views/Workflow/components/Properties/CategoryList/index.tsx +++ b/web/src/views/Workflow/components/Properties/CategoryList/index.tsx @@ -1,3 +1,9 @@ +/* + * @Author: ZhaoYing + * @Date: 2026-02-09 18:34:33 + * @Last Modified by: ZhaoYing + * @Last Modified time: 2026-02-09 18:34:33 + */ import { type FC } from 'react'; import { useTranslation } from 'react-i18next'; import { Button, Form, Space } from 'antd'; @@ -5,7 +11,7 @@ import { Graph, Node } from '@antv/x6'; import Editor from '../../Editor'; import type { Suggestion } from '../../Editor/plugin/AutocompletePlugin' -import { edgeAttrs, portArgs } from '../../../constant' +import { edgeAttrs, portTextAttrs, nodeWidth } from '../../../constant' interface CategoryListProps { parentName: string; @@ -19,10 +25,11 @@ const CategoryList: FC = ({ parentName, selectedNode, graphRe const form = Form.useFormInstance(); const formValues = Form.useWatch([parentName], form); + // Update node ports based on category count changes (add/remove categories) const updateNodePorts = (caseCount: number, removedCaseIndex?: number) => { if (!selectedNode || !graphRef?.current) return; - // 保存现有连线信息(包括左侧端口连线) + // Save existing edge connections (including left-side port connections) const existingEdges = graphRef.current.getEdges().filter((edge: any) => edge.getSourceCellId() === selectedNode.id || edge.getTargetCellId() === selectedNode.id ); @@ -35,7 +42,7 @@ const CategoryList: FC = ({ parentName, selectedNode, graphRe isIncoming: edge.getTargetCellId() === selectedNode.id })); - // 移除所有现有的右侧端口 + // Remove all existing right-side ports const existingPorts = selectedNode.getPorts(); existingPorts.forEach((port: any) => { if (port.group === 'right') { @@ -43,28 +50,30 @@ const CategoryList: FC = ({ parentName, selectedNode, graphRe } }); - // 计算新的节点高度:基础高度88px + 每个额外port增加30px + // Calculate new node height: base height 88px + 30px for each additional port const baseHeight = 88; - const totalPorts = caseCount + 1; // IF/ELIF + ELSE - const newHeight = baseHeight + (totalPorts - 2) * 30; + const newHeight = baseHeight + (caseCount - 2) * 30; - selectedNode.prop('size', { width: 240, height: newHeight < baseHeight ? baseHeight : newHeight }) + selectedNode.prop('size', { width: nodeWidth, height: newHeight < baseHeight ? baseHeight : newHeight }) - // 添加 分类 端口 + // Add category ports for (let i = 0; i < caseCount; i++) { selectedNode.addPort({ id: `CASE${i + 1}`, group: 'right', - args: portArgs, - attrs: { text: { text: `分类${i + 1}`, fontSize: 12, fill: '#5B6167' } } + args: { + x: nodeWidth, + y: 30 * i + 42, + }, + attrs: { text: { text: `分类${i + 1}`, ...portTextAttrs } } }); } - // 恢复连线 + // Restore edge connections setTimeout(() => { edgeConnections.forEach(({ edge, sourcePortId, targetCellId, targetPortId, sourceCellId, isIncoming }: any) => { graphRef.current?.removeCell(edge); - // 如果是进入连线(左侧端口),直接恢复 + // If it's an incoming connection (left-side port), restore directly if (isIncoming) { const sourceCell = graphRef.current?.getCellById(sourceCellId); if (sourceCell) { @@ -77,22 +86,22 @@ const CategoryList: FC = ({ parentName, selectedNode, graphRe return; } - // 处理右侧端口连线 + // Handle right-side port connections const originalCaseNumber = parseInt(sourcePortId.match(/CASE(\d+)/)?.[1] || '0'); - // 如果是被删除的端口,不重新创建连线 + // If it's a removed port, don't recreate the connection if (removedCaseIndex !== undefined && originalCaseNumber === removedCaseIndex + 1) { return; } let newPortId = sourcePortId; - // 如果删除了某个端口,需要重新映射后续端口的ID + // If a port was removed, remap subsequent port IDs if (removedCaseIndex !== undefined && originalCaseNumber > removedCaseIndex + 1) { newPortId = `CASE${originalCaseNumber - 1}`; } - // 检查新端口是否存在 + // Check if the new port exists const newPorts = selectedNode.getPorts(); const matchingPort = newPorts.find((port: any) => port.id === newPortId); diff --git a/web/src/views/Workflow/components/Properties/HttpRequest/index.tsx b/web/src/views/Workflow/components/Properties/HttpRequest/index.tsx index a6b50e33..ae5aab01 100644 --- a/web/src/views/Workflow/components/Properties/HttpRequest/index.tsx +++ b/web/src/views/Workflow/components/Properties/HttpRequest/index.tsx @@ -1,7 +1,14 @@ +/* + * @Author: ZhaoYing + * @Date: 2026-02-09 18:35:43 + * @Last Modified by: ZhaoYing + * @Last Modified time: 2026-02-09 18:35:43 + */ import { type FC, useRef, useState } from "react"; import { useTranslation } from 'react-i18next' import { Form, Row, Col, Select, Button, Divider, InputNumber, Switch, Input } from 'antd' import { CaretDownOutlined, CaretRightOutlined, SettingOutlined } from '@ant-design/icons'; + import Editor from '../../Editor' import type { Suggestion } from '../../Editor/plugin/AutocompletePlugin' import AuthConfigModal from './AuthConfigModal' @@ -9,6 +16,7 @@ import type { AuthConfigModalRef, HttpRequestConfigForm } from './types' import VariableSelect from "../VariableSelect"; import MessageEditor from '../MessageEditor' import EditableTable from './EditableTable' +import { portTextAttrs } from '../../../constant' const HttpRequest: FC<{ options: Suggestion[]; selectedNode?: any; graphRef?: any; }> = ({ options, @@ -32,6 +40,7 @@ const HttpRequest: FC<{ options: Suggestion[]; selectedNode?: any; graphRef?: an form.setFieldValue(['body', 'data'], undefined) } + // Handle error handling method change and update node ports accordingly const handleChangeErrorHandleMethod = (method: string) => { form.setFieldsValue({ error_handle: { @@ -42,21 +51,21 @@ const HttpRequest: FC<{ options: Suggestion[]; selectedNode?: any; graphRef?: an } }) - // 更新节点连接桩 + // Update node ports console.log('handleChangeErrorHandleMethod', selectedNode, graphRef?.current) if (selectedNode && graphRef?.current) { const existingPorts = selectedNode.getPorts(); const errorPort = existingPorts.find((port: any) => port.id === 'ERROR'); if (method === 'branch' && !errorPort) { - // 添加异常节点连接桩 + // Add error branch port selectedNode.addPort({ id: 'ERROR', group: 'right', - attrs: { text: { text: t('workflow.config.http-request.errorBranch'), fontSize: 12, fill: '#5B6167' }} + attrs: { text: { text: t('workflow.config.http-request.errorBranch'), ...portTextAttrs }} }); } else if (method !== 'branch' && errorPort) { - // 移除异常节点连接桩和相关连线 + // Remove error branch port and related edges const edges = graphRef.current.getEdges().filter((edge: any) => edge.getSourceCellId() === selectedNode.id && edge.getSourcePortId() === 'ERROR' ); diff --git a/web/src/views/Workflow/constant.ts b/web/src/views/Workflow/constant.ts index a01dab9d..23b5ca23 100644 --- a/web/src/views/Workflow/constant.ts +++ b/web/src/views/Workflow/constant.ts @@ -2,7 +2,7 @@ * @Author: ZhaoYing * @Date: 2026-02-03 15:06:18 * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-02-05 14:15:13 + * @Last Modified time: 2026-02-09 17:48:46 */ import LoopNode from './components/Nodes/LoopNode'; import NormalNode from './components/Nodes/NormalNode'; @@ -518,6 +518,7 @@ export const nodeLibrary: NodeLibrary[] = [ // }, ]; +export const nodeWidth = 240; /** * Node registration library for X6 graph * Maps node shapes to their React components @@ -525,13 +526,13 @@ export const nodeLibrary: NodeLibrary[] = [ export const nodeRegisterLibrary: ReactShapeConfig[] = [ { shape: 'loop-node', - width: 240, + width: nodeWidth, height: 120, component: LoopNode, }, { shape: 'iteration-node', - width: 240, + width: nodeWidth, height: 120, component: LoopNode, }, @@ -543,7 +544,7 @@ export const nodeRegisterLibrary: ReactShapeConfig[] = [ }, { shape: 'condition-node', - width: 240, + width: nodeWidth, height: 88, component: ConditionNode, }, @@ -625,8 +626,9 @@ export const portAttrs = { textAnchor: 'middle', textVerticalAnchor: 'middle', pointerEvents: 'none', - } + }, } +export const portTextAttrs = { fontSize: 12, fill: '#5B6167' } /** * Unified port group configuration @@ -638,6 +640,12 @@ const defaultPortGroups = { // bottom: { position: 'bottom', markup: portMarkup, attrs: portAttrs }, left: { position: 'left', markup: portMarkup, attrs: portAttrs }, } +export const defaultAbsolutePortGroups = { + // top: { position: 'top', markup: portMarkup, attrs: portAttrs }, + right: { position: { name: 'absolute' }, markup: portMarkup, attrs: portAttrs }, + // bottom: { position: 'bottom', markup: portMarkup, attrs: portAttrs }, + left: { position: 'left', markup: portMarkup, attrs: portAttrs }, +} /** * Default port items for standard nodes */ @@ -650,7 +658,7 @@ const defaultPortItems = [ /** * Port position arguments */ -export const portArgs = { dy: 18 } +export const portArgs = { x: nodeWidth, y: 42 } /** * Graph node library configuration @@ -658,7 +666,7 @@ export const portArgs = { dy: 18 } */ export const graphNodeLibrary: Record = { iteration: { - width: 240, + width: nodeWidth, height: 120, shape: 'iteration-node', ports: { @@ -667,7 +675,7 @@ export const graphNodeLibrary: Record = { }, }, loop: { - width: 240, + width: nodeWidth, height: 120, shape: 'loop-node', ports: { @@ -676,33 +684,47 @@ export const graphNodeLibrary: Record = { }, }, 'if-else': { - width: 240, + width: nodeWidth, height: 88, shape: 'condition-node', ports: { - groups: defaultPortGroups, + groups: defaultAbsolutePortGroups, items: [ { group: 'left' }, - { group: 'right', id: 'CASE1', args: portArgs, attrs: { text: { text: 'IF', fontSize: 12, color: '#5B6167' }} }, - { group: 'right', id: 'CASE2', args: portArgs, attrs: { text: { text: 'ELSE', fontSize: 12, color: '#5B6167' }} } + ...(['IF', 'ELSE'].map((text, index) => ({ + group: 'right', + id: `CASE${index}`, + args: { + ...portArgs, + y: 30 * index + 42, + }, + attrs: { text: { text: text, ...portTextAttrs } } + }))), ], }, }, 'question-classifier': { - width: 240, + width: nodeWidth, height: 88, shape: 'condition-node', ports: { - groups: defaultPortGroups, + groups: defaultAbsolutePortGroups, items: [ { group: 'left' }, - { group: 'right', id: 'CASE1', args: portArgs, attrs: { text: { text: '分类1', fontSize: 12, color: '#5B6167' } } }, - { group: 'right', id: 'CASE2', args: portArgs, attrs: { text: { text: '分类2', fontSize: 12, color: '#5B6167' } } } + ...(['分类1', '分类2'].map((text, index) => ({ + group: 'right', + id: `CASE${index}`, + args: { + ...portArgs, + y: 30 * index + 42, + }, + attrs: { text: { text: text, ...portTextAttrs } } + }))), ], }, }, start: { - width: 240, + width: nodeWidth, height: 64, shape: 'normal-node', ports: { @@ -711,7 +733,7 @@ export const graphNodeLibrary: Record = { }, }, end: { - width: 240, + width: nodeWidth, height: 64, shape: 'normal-node', ports: { @@ -738,7 +760,7 @@ export const graphNodeLibrary: Record = { }, }, default: { - width: 240, + width: nodeWidth, height: 64, shape: 'normal-node', ports: { diff --git a/web/src/views/Workflow/hooks/useWorkflowGraph.ts b/web/src/views/Workflow/hooks/useWorkflowGraph.ts index d267faf8..6042d73c 100644 --- a/web/src/views/Workflow/hooks/useWorkflowGraph.ts +++ b/web/src/views/Workflow/hooks/useWorkflowGraph.ts @@ -1,8 +1,8 @@ /* * @Author: ZhaoYing * @Date: 2026-02-03 15:17:48 - * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-02-03 15:17:48 + * @Last Modified by: ZhaoYing + * @Last Modified time: 2026-02-09 18:37:01 */ import { useRef, useEffect, useState } from 'react'; import { useParams } from 'react-router-dom'; @@ -12,7 +12,7 @@ import { Graph, Node, MiniMap, Snapline, Clipboard, Keyboard, type Edge } from ' import { register } from '@antv/x6-react-shape'; import type { PortMetadata } from '@antv/x6/lib/model/port'; -import { nodeRegisterLibrary, graphNodeLibrary, nodeLibrary, portMarkup, portAttrs, edgeAttrs, edge_color, edge_selected_color, portArgs } from '../constant'; +import { nodeRegisterLibrary, graphNodeLibrary, nodeLibrary, portMarkup, portAttrs, edgeAttrs, edge_color, edge_selected_color, portTextAttrs, defaultAbsolutePortGroups, nodeWidth } from '../constant'; import type { WorkflowConfig, NodeProperties, ChatVariable } from '../types'; import { getWorkflowConfig, saveWorkflowConfig } from '@/api/application' @@ -168,6 +168,7 @@ export const useWorkflowGraph = ({ } }) } + const nodeConfig = { ...(graphNodeLibrary[type] ?? graphNodeLibrary.default), id, @@ -179,39 +180,28 @@ export const useWorkflowGraph = ({ // Generate ports dynamically for if-else node based on cases if (type === 'if-else' && config.cases && Array.isArray(config.cases)) { - const caseCount = config.cases.length; - const totalPorts = caseCount + 1; // IF/ELIF + ELSE + const totalPorts = config.cases.length + 1; // IF/ELIF + ELSE const baseHeight = 88; const newHeight = baseHeight + (totalPorts - 2) * 30; const portItems: PortMetadata[] = [ { group: 'left' }, - { group: 'right', id: 'CASE1', args: portArgs, attrs: { text: { text: 'IF', fontSize: 12, fill: '#5B6167' }} } ]; - - // Add ELIF ports - for (let i = 1; i < caseCount; i++) { + // Add IF/ELIF/ELSE ports + for (let i = 0; i < totalPorts; i++) { portItems.push({ group: 'right', id: `CASE${i + 1}`, - args: portArgs, - attrs: { text: { text: 'ELIF', fontSize: 12, fill: '#5B6167' }} + args: { + x: nodeWidth, + y: 30 * i + 42, + }, + attrs: { text: { text: i === 0 ? 'IF' : i === totalPorts - 1 ? 'ELSE' : 'ELIF', ...portTextAttrs } } }); } - // Add ELSE port - portItems.push({ - group: 'right', - id: `CASE${caseCount + 1}`, - args: portArgs, - attrs: { text: { text: 'ELSE', fontSize: 12, fill: '#5B6167' }} - }); - nodeConfig.ports = { - groups: { - right: { position: 'right', markup: portMarkup, attrs: portAttrs }, - left: { position: 'left', markup: portMarkup, attrs: portAttrs }, - }, + groups: defaultAbsolutePortGroups, items: portItems }; @@ -222,7 +212,7 @@ export const useWorkflowGraph = ({ if (type === 'question-classifier' && config.categories && Array.isArray(config.categories)) { const categoryCount = config.categories.length; const baseHeight = 88; - const newHeight = baseHeight + (categoryCount - 1) * 30; + const newHeight = baseHeight + (categoryCount - 2) * 30; const portItems: PortMetadata[] = [ { group: 'left' } @@ -233,16 +223,16 @@ export const useWorkflowGraph = ({ portItems.push({ group: 'right', id: `CASE${index + 1}`, - args: portArgs, - attrs: { text: { text: `分类${index + 1}`, fontSize: 12, fill: '#5B6167' }} + args: { + x: nodeWidth, + y: 30 * index + 42, + }, + attrs: { text: { text: `分类${index + 1}`, ...portTextAttrs }} }); }); nodeConfig.ports = { - groups: { - right: { position: 'right', markup: portMarkup, attrs: portAttrs }, - left: { position: 'left', markup: portMarkup, attrs: portAttrs }, - }, + groups: defaultAbsolutePortGroups, items: portItems }; @@ -259,7 +249,7 @@ export const useWorkflowGraph = ({ items: [ { group: 'left' }, { group: 'right', id: 'right' }, - { group: 'right', id: 'ERROR', attrs: { text: { text: t('workflow.config.http-request.errorBranch'), fontSize: 12, fill: '#5B6167' }}} + { group: 'right', id: 'ERROR', attrs: { text: { text: t('workflow.config.http-request.errorBranch'), ...portTextAttrs }}} ] }; } From d477e24e345fa98e9aaba12bf3d07814dd51e36a Mon Sep 17 00:00:00 2001 From: Eternity <1533512157@qq.com> Date: Tue, 10 Feb 2026 13:33:57 +0800 Subject: [PATCH 05/36] refactor(workflow): add new engine and utils modules - Add engine/ directory with core components: - graph_builder: workflow graph construction - variable_pool: variable management - state_manager: execution state tracking - event_stream_handler: event processing - stream_output_coordinator: streaming output control - result_builder: result aggregation - runtime_schema: runtime type definitions - Add utils/ directory with utilities: - expression_evaluator: safe expression evaluation - template_renderer: Jinja2 template rendering --- .gitignore | 3 +- api/app/core/__init__.py | 4 + api/app/core/workflow/engine/__init__.py | 4 + .../workflow/engine/event_stream_handler.py | 273 +++++++ .../workflow/{ => engine}/graph_builder.py | 169 +--- .../core/workflow/engine/result_builder.py | 104 +++ .../core/workflow/engine/runtime_schema.py | 29 + api/app/core/workflow/engine/state_manager.py | 99 +++ .../engine/stream_output_coordinator.py | 328 ++++++++ .../workflow/{ => engine}/variable_pool.py | 89 ++- api/app/core/workflow/executor.py | 729 +++--------------- api/app/core/workflow/nodes/__init__.py | 7 +- api/app/core/workflow/nodes/agent/node.py | 10 +- api/app/core/workflow/nodes/assigner/node.py | 5 +- api/app/core/workflow/nodes/base_node.py | 48 +- api/app/core/workflow/nodes/breaker/node.py | 5 +- api/app/core/workflow/nodes/code/node.py | 7 +- .../workflow/nodes/cycle_graph/iteration.py | 4 +- .../core/workflow/nodes/cycle_graph/loop.py | 6 +- .../core/workflow/nodes/cycle_graph/node.py | 6 +- api/app/core/workflow/nodes/end/node.py | 5 +- .../core/workflow/nodes/http_request/node.py | 5 +- api/app/core/workflow/nodes/if_else/config.py | 2 +- api/app/core/workflow/nodes/if_else/node.py | 5 +- .../core/workflow/nodes/jinja_render/node.py | 6 +- api/app/core/workflow/nodes/knowledge/node.py | 5 +- api/app/core/workflow/nodes/llm/config.py | 5 +- api/app/core/workflow/nodes/llm/node.py | 7 +- api/app/core/workflow/nodes/memory/node.py | 4 +- api/app/core/workflow/nodes/operators.py | 4 +- .../nodes/parameter_extractor/node.py | 9 +- .../nodes/question_classifier/node.py | 11 +- api/app/core/workflow/nodes/start/node.py | 7 +- api/app/core/workflow/nodes/tool/node.py | 9 +- .../nodes/variable_aggregator/node.py | 4 +- api/app/core/workflow/utils/__init__.py | 4 + .../{ => utils}/expression_evaluator.py | 0 .../workflow/{ => utils}/template_renderer.py | 1 - api/app/core/workflow/validator.py | 2 +- .../workflow/executor/test_vairable_pool.py | 2 +- api/tests/workflow/nodes/base.py | 4 +- api/tests/workflow/nodes/test_start_node.py | 4 +- 42 files changed, 1109 insertions(+), 925 deletions(-) create mode 100644 api/app/core/__init__.py create mode 100644 api/app/core/workflow/engine/__init__.py create mode 100644 api/app/core/workflow/engine/event_stream_handler.py rename api/app/core/workflow/{ => engine}/graph_builder.py (78%) create mode 100644 api/app/core/workflow/engine/result_builder.py create mode 100644 api/app/core/workflow/engine/runtime_schema.py create mode 100644 api/app/core/workflow/engine/state_manager.py create mode 100644 api/app/core/workflow/engine/stream_output_coordinator.py rename api/app/core/workflow/{ => engine}/variable_pool.py (79%) create mode 100644 api/app/core/workflow/utils/__init__.py rename api/app/core/workflow/{ => utils}/expression_evaluator.py (100%) rename api/app/core/workflow/{ => utils}/template_renderer.py (99%) diff --git a/.gitignore b/.gitignore index 2fcdbcd6..2fb41537 100644 --- a/.gitignore +++ b/.gitignore @@ -37,5 +37,4 @@ tika-server*.jar* cl100k_base.tiktoken libssl*.deb -sandbox/lib/seccomp_python/target -sandbox/lib/seccomp_nodejs/target +sandbox/lib/seccomp_redbear/target diff --git a/api/app/core/__init__.py b/api/app/core/__init__.py new file mode 100644 index 00000000..559af4a5 --- /dev/null +++ b/api/app/core/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: UTF-8 -*- +# Author: Eternity +# @Email: 1533512157@qq.com +# @Time : 2026/2/9 16:24 diff --git a/api/app/core/workflow/engine/__init__.py b/api/app/core/workflow/engine/__init__.py new file mode 100644 index 00000000..bdd44b47 --- /dev/null +++ b/api/app/core/workflow/engine/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: UTF-8 -*- +# Author: Eternity +# @Email: 1533512157@qq.com +# @Time : 2026/2/9 16:28 diff --git a/api/app/core/workflow/engine/event_stream_handler.py b/api/app/core/workflow/engine/event_stream_handler.py new file mode 100644 index 00000000..e49a2e8b --- /dev/null +++ b/api/app/core/workflow/engine/event_stream_handler.py @@ -0,0 +1,273 @@ +# -*- coding: UTF-8 -*- +# Author: Eternity +# @Email: 1533512157@qq.com +# @Time : 2026/2/10 13:33 +import datetime + +from langchain_core.runnables import RunnableConfig +from langgraph.graph.state import CompiledStateGraph + +from app.core.logging_config import get_logger +from app.core.workflow.engine.stream_output_coordinator import StreamOutputCoordinator +from app.core.workflow.engine.variable_pool import VariablePool + +logger = get_logger(__name__) + + +class EventStreamHandler: + def __init__( + self, + output_coordinator: StreamOutputCoordinator, + variable_pool: VariablePool, + execution_id: str, + ): + self.coordinator = output_coordinator + self.variable_pool = variable_pool + self.execution_id = execution_id + + def update_stream_output_status(self, activate: dict, data: dict): + """ + Update the stream output state of End nodes based on workflow state updates. + + This method checks which nodes/scopes are activated and propagates + activation to End nodes accordingly. + + Args: + activate (dict): Mapping of node_id -> bool indicating which nodes/scopes are activated. + data (dict): Mapping of node_id -> node runtime data, including outputs. + + Behavior: + For each node in `data`: + 1. If the node is activated (`activate[node_id]` is True), + retrieve its output status from `runtime_vars`. + 2. Call `_update_scope_activate` to propagate the activation + to all relevant End nodes and update `self.activate_end`. + """ + for node_id in data.keys(): + if activate.get(node_id): + node_output_status = self.variable_pool.get_value(f"{node_id}.output", default=None, strict=False) + self.coordinator.update_scope_activation(node_id, status=node_output_status) + + async def handle_updates_event( + self, + data: dict, + graph: CompiledStateGraph, + checkpoint_config: RunnableConfig + ): + """ + Handle workflow state update events ("updates") and stream active End node outputs. + + Steps: + 1. Retrieve the current graph state. + 2. Extract node activation information from the state. + 3. Update the activation status of all End nodes. + 4. While there is an active End node: + - Call _emit_active_chunks() to yield all currently active output segments. + - After all segments are processed, update activate_end if there are remaining End nodes. + 5. Log a debug message indicating state update received. + + Args: + data (dict): The latest node state updates. + graph (CompiledStateGraph): The compiled LangGraph state machine. + checkpoint_config (RunnableConfig): Configuration for the current execution context.) + + Yields: + dict: Streamed output event, each chunk in the format: + {"event": "message", "data": {"chunk": ...}} + """ + state = graph.get_state(config=checkpoint_config).values + activate = state.get("activate", {}) + + self.update_stream_output_status(activate, data) + wait = False + while self.coordinator.activate_end and not wait: + async for msg_event in self.coordinator.emit_activate_chunk(self.variable_pool): + yield msg_event + + if self.coordinator.activate_end: + wait = True + else: + self.update_stream_output_status(activate, data) + + logger.debug(f"[UPDATES] Received state update from nodes: {list(data.keys())} " + f"- execution_id: {self.execution_id}") + + async def handle_node_chunk_event(self, data: dict): + """ + Handle streaming chunk events from individual nodes ("node_chunk"). + + This method processes output segments for the currently active End node. + If the segment depends on the provided node_id: + - If the node has finished execution (`done=True`), advance the cursor. + - If all segments are processed, deactivate the End node. + - Otherwise, yield the current chunk as a streaming message. + + Args: + data (dict): Node chunk event data, expected keys: + - "node_id": ID of the node producing this chunk + - "chunk": Chunk of output text + - "done": Boolean indicating whether the node finished producing output + + Yields: + dict: Streaming message event in the format: + {"event": "message", "data": {"chunk": ...}} + """ + node_id = data.get("node_id") + if self.coordinator.activate_end: + end_info = self.coordinator.current_activate_end_info + if not end_info or end_info.cursor >= len(end_info.outputs): + return + current_output = end_info.outputs[end_info.cursor] + if current_output.is_variable and current_output.depends_on_scope(node_id): + if data.get("done"): + end_info.cursor += 1 + if end_info.cursor >= len(end_info.outputs): + self.coordinator.pop_current_activate_end() + else: + yield { + "event": "message", + "data": { + "chunk": data.get("chunk") + } + } + + @staticmethod + async def handle_node_error_event(data: dict): + """ + Handle node error events ("node_error") during workflow execution. + + This method streams an error event for a node that has failed. The event + contains the node ID, status, input data, elapsed time, and error message. + + Args: + data (dict): Node error event data, expected keys: + - "node_id": ID of the node that failed + - "input_data": The input data that caused the error + - "elapsed_time": Execution time before the error occurred + - "error": Error message or exception string + + Yields: + dict: Node error event in the format: + { + "event": "node_error", + "data": { + "node_id": str, + "status": "failed", + "input": ..., + "elapsed_time": float, + "output": None, + "error": str + } + } + """ + node_id = data.get("node_id") + yield { + "event": "node_error", + "data": { + "node_id": node_id, + "status": "failed", + "input": data.get("input_data"), + "elapsed_time": data.get("elapsed_time"), + "output": None, + "error": data.get("error") + } + } + + async def handle_debug_event(self, data: dict, input_data: dict): + """ + Handle debug events ("debug") related to node execution status. + + This method streams debug events for nodes, including when a node starts + execution ("node_start") and when it completes execution ("node_end"). + It filters out nodes with names starting with "nop" as no-operation nodes. + + Args: + data (dict): Debug event data, expected keys: + - "type": Event type ("task" for start, "task_result" for completion) + - "payload": Node-related information, including: + - "name": Node name / ID + - "input": Node input data (for "task" type) + - "result": Node execution result (for "task_result" type) + - "timestamp": ISO timestamp string of the event + input_data (dict): Original workflow input data (used to get conversation_id) + + Yields: + dict: Node debug event in one of the following formats: + 1. Node start: + { + "event": "node_start", + "data": { + "node_id": str, + "conversation_id": str, + "execution_id": str, + "timestamp": int (ms) + } + } + 2. Node end: + { + "event": "node_end", + "data": { + "node_id": str, + "conversation_id": str, + "execution_id": str, + "timestamp": int (ms), + "input": dict, + "output": Any, + "elapsed_time": float + } + } + """ + event_type = data.get("type") + payload = data.get("payload", {}) + node_name = payload.get("name") + conversation_id = input_data.get("conversation_id") + + # Skip no-operation nodes + if node_name and node_name.startswith("nop"): + return + + if event_type == "task": + # Node starts execution + inputv = payload.get("input", {}) + if not inputv.get("activate", {}).get(node_name): + return + + logger.info( + f"[NODE-START] Node '{node_name}' execution started - execution_id: {self.execution_id}") + + yield { + "event": "node_start", + "data": { + "node_id": node_name, + "conversation_id": conversation_id, + "execution_id": self.execution_id, + "timestamp": int(datetime.datetime.fromisoformat( + data.get("timestamp") + ).timestamp() * 1000), + } + } + elif event_type == "task_result": + # Node execution completed + result = payload.get("result", {}) + if not result.get("activate", {}).get(node_name): + return + + logger.info( + f"[NODE-END] Node '{node_name}' execution completed - execution_id: {self.execution_id}") + + yield { + "event": "node_end", + "data": { + "node_id": node_name, + "conversation_id": conversation_id, + "execution_id": self.execution_id, + "timestamp": int(datetime.datetime.fromisoformat( + data.get("timestamp") + ).timestamp() * 1000), + "input": result.get("node_outputs", {}).get(node_name, {}).get("input"), + "output": result.get("node_outputs", {}).get(node_name, {}).get("output"), + "elapsed_time": result.get("node_outputs", {}).get(node_name, {}).get("elapsed_time"), + "token_usage": result.get("node_outputs", {}).get(node_name, {}).get("token_usage") + } + } + diff --git a/api/app/core/workflow/graph_builder.py b/api/app/core/workflow/engine/graph_builder.py similarity index 78% rename from api/app/core/workflow/graph_builder.py rename to api/app/core/workflow/engine/graph_builder.py index 8620bb9a..0d0035ab 100644 --- a/api/app/core/workflow/graph_builder.py +++ b/api/app/core/workflow/engine/graph_builder.py @@ -1,3 +1,7 @@ +# -*- coding: UTF-8 -*- +# Author: Eternity +# @Email: 1533512157@qq.com +# @Time : 2026/2/10 13:33 import logging import re import uuid @@ -9,169 +13,16 @@ from langgraph.checkpoint.memory import InMemorySaver from langgraph.graph import START, END from langgraph.graph.state import CompiledStateGraph, StateGraph from langgraph.types import Send -from pydantic import BaseModel, Field -from app.core.workflow.expression_evaluator import evaluate_condition -from app.core.workflow.nodes import WorkflowState, NodeFactory +from app.core.workflow.engine.state_manager import WorkflowState +from app.core.workflow.engine.stream_output_coordinator import OutputContent, StreamOutputConfig +from app.core.workflow.engine.variable_pool import VariablePool +from app.core.workflow.nodes import NodeFactory from app.core.workflow.nodes.enums import NodeType, BRANCH_NODES -from app.core.workflow.variable_pool import VariablePool +from app.core.workflow.utils.expression_evaluator import evaluate_condition logger = logging.getLogger(__name__) -SCOPE_PATTERN = re.compile( - r"\{\{\s*([a-zA-Z_][a-zA-Z0-9_]*)\.[a-zA-Z0-9_]+\s*}}" -) - - -class OutputContent(BaseModel): - """ - Represents a single output segment of an End node. - - An output segment can be either: - - literal text (static string) - - a variable placeholder (e.g. {{ node.field }}) - - Each segment has its own activation state, which is especially - important in stream mode. - """ - - literal: str = Field( - ..., - description="Raw output content. Can be literal text or a variable placeholder." - ) - - activate: bool = Field( - ..., - description=( - "Whether this output segment is currently active.\n" - "- True: allowed to be emitted/output\n" - "- False: blocked until activated by branch control" - ) - ) - - is_variable: bool = Field( - ..., - description=( - "Whether this segment represents a variable placeholder.\n" - "True -> variable (e.g. {{ node.field }})\n" - "False -> literal text" - ) - ) - - _SCOPE: str | None = None - - def get_scope(self) -> str: - self._SCOPE = SCOPE_PATTERN.findall(self.literal)[0] - return self._SCOPE - - def depends_on_scope(self, scope: str) -> bool: - """ - Check if this segment depends on a given scope. - - Args: - scope (str): Node ID or special variable prefix (e.g., "sys"). - - Returns: - bool: True if this segment references the given scope. - """ - if self._SCOPE: - return self._SCOPE == scope - return self.get_scope() == scope - - -class StreamOutputConfig(BaseModel): - """ - Streaming output configuration for an End node. - - This configuration describes how the End node output behaves in streaming mode, - including: - - whether output emission is globally activated - - which upstream branch/control nodes gate the activation - - how each parsed output segment is streamed and activated - """ - - activate: bool = Field( - ..., - description=( - "Global activation flag for the End node output.\n" - "When False, output segments should not be emitted even if available.\n" - "This flag typically becomes True once required control branch conditions " - "are satisfied." - ) - ) - - control_nodes: dict[str, list[str]] = Field( - ..., - description=( - "Control branch conditions for this End node output.\n" - "Mapping of `branch_node_id -> expected_branch_label`.\n" - "The End node output becomes globally active when a controlling branch node " - "reports a matching completion status." - ) - ) - - outputs: list[OutputContent] = Field( - ..., - description=( - "Ordered list of output segments parsed from the output template.\n" - "Each segment represents either a literal text block or a variable placeholder " - "that may be activated independently." - ) - ) - - cursor: int = Field( - ..., - description=( - "Streaming cursor index.\n" - "Indicates the next output segment index to be emitted.\n" - "Segments with index < cursor are considered already streamed." - ) - ) - - def update_activate(self, scope: str, status=None): - """ - Update streaming activation state based on an upstream node or special variable. - - Args: - scope (str): - Identifier of the completed upstream entity. - - If a control branch node, it should match a key in `control_nodes`. - - If a variable placeholder (e.g., "sys.xxx"), it may appear in output segments. - status (optional): - Completion status of the control branch node. - Required when `scope` refers to a control node. - - Behavior: - 1. Control branch nodes: - - If `scope` matches a key in `control_nodes` and `status` matches the expected - branch label, the End node output becomes globally active (`activate = True`). - - 2. Variable output segments: - - For each segment that is a variable (`is_variable=True`): - - If the segment literal references `scope`, mark the segment as active. - - This applies both to regular node variables (e.g., "node_id.field") - and special system variables (e.g., "sys.xxx"). - - Notes: - - This method does not emit output or advance the streaming cursor. - - It only updates activation flags based on upstream events or special variables. - """ - - # Case 1: resolve control branch dependency - if scope in self.control_nodes.keys(): - if status is None: - raise RuntimeError("[Stream Output] Control node activation status not provided") - if status in self.control_nodes[scope]: - self.activate = True - - # Case 2: activate variable segments related to this node - for i in range(len(self.outputs)): - if ( - self.outputs[i].is_variable - and self.outputs[i].depends_on_scope(scope) - ): - self.outputs[i].activate = True - class GraphBuilder: def __init__( @@ -230,7 +81,7 @@ class GraphBuilder: raise RuntimeError(f"Node not found: Id={node_id}") @staticmethod - def _merge_control_nodes(control_nodes: list[tuple[str, str]]) -> dict[str, list]: + def _merge_control_nodes(control_nodes: tuple[tuple[str, str]]) -> dict[str, list]: result = defaultdict(list) for node in control_nodes: result[node[0]].append(node[1]) diff --git a/api/app/core/workflow/engine/result_builder.py b/api/app/core/workflow/engine/result_builder.py new file mode 100644 index 00000000..dbaf8fa6 --- /dev/null +++ b/api/app/core/workflow/engine/result_builder.py @@ -0,0 +1,104 @@ +# -*- coding: UTF-8 -*- +# Author: Eternity +# @Email: 1533512157@qq.com +# @Time : 2026/2/10 13:33 +from app.core.workflow.engine.variable_pool import VariablePool + + +class WorkflowResultBuiler: + def build_final_output( + self, + result: dict, + variable_pool: VariablePool, + elapsed_time: float, + final_output: str, + ): + """Construct the final standardized output of the workflow execution. + + This method aggregates node outputs, token usage, conversation and system + variables, messages, and other metadata into a consistent dictionary + structure suitable for returning from workflow execution. + + Args: + result (dict): The runtime state returned by the workflow graph execution. + Expected keys include: + - "node_outputs" (dict): Outputs of executed nodes. + - "messages" (list): Conversation messages exchanged during execution. + - "error" (str, optional): Error message if any node failed. + variable_pool (VariablePool): Variable Pool + elapsed_time (float): Total execution time in seconds. + final_output (Any): The aggregated or final output content of the workflow + (e.g., combined messages from all End nodes). + + Returns: + dict: A dictionary containing the final workflow execution result with keys: + - "status": Execution status ("completed") + - "output": Aggregated final output content + - "variables": Namespace dictionary with: + - "conv": Conversation variables + - "sys": System variables + - "node_outputs": Outputs from all executed nodes + - "messages": Conversation messages exchanged + - "conversation_id": ID of the current conversation + - "elapsed_time": Total execution time in seconds + - "token_usage": Aggregated token usage across nodes (if available) + - "error": Error message if any occurred during execution + """ + node_outputs = result.get("node_outputs", {}) + token_usage = self.aggregate_token_usage(node_outputs) + conversation_id = variable_pool.get_value("sys.conversation_id") + + return { + "status": "completed", + "output": final_output, + "variables": { + "conv": variable_pool.get_all_conversation_vars(), + "sys": variable_pool.get_all_system_vars() + }, + "node_outputs": node_outputs, + "messages": result.get("messages", []), + "conversation_id": conversation_id, + "elapsed_time": elapsed_time, + "token_usage": token_usage, + "error": result.get("error"), + } + + @staticmethod + def aggregate_token_usage(node_outputs: dict) -> dict[str, int] | None: + """ + Aggregate token usage statistics across all nodes. + + Args: + node_outputs (dict): A dictionary of all node outputs. + + Returns: + dict | None: Aggregated token usage in the format: + { + "prompt_tokens": int, + "completion_tokens": int, + "total_tokens": int + } + Returns None if no token usage information is available. + """ + total_prompt_tokens = 0 + total_completion_tokens = 0 + total_tokens = 0 + has_token_info = False + + for node_output in node_outputs.values(): + if isinstance(node_output, dict): + token_usage = node_output.get("token_usage") + if token_usage and isinstance(token_usage, dict): + has_token_info = True + total_prompt_tokens += token_usage.get("prompt_tokens", 0) + total_completion_tokens += token_usage.get("completion_tokens", 0) + total_tokens += token_usage.get("total_tokens", 0) + + if not has_token_info: + return None + + return { + "prompt_tokens": total_prompt_tokens, + "completion_tokens": total_completion_tokens, + "total_tokens": total_tokens + } diff --git a/api/app/core/workflow/engine/runtime_schema.py b/api/app/core/workflow/engine/runtime_schema.py new file mode 100644 index 00000000..e4bf65af --- /dev/null +++ b/api/app/core/workflow/engine/runtime_schema.py @@ -0,0 +1,29 @@ +# -*- coding: UTF-8 -*- +# Author: Eternity +# @Email: 1533512157@qq.com +# @Time : 2026/2/10 13:33 +import uuid + +from langchain_core.runnables import RunnableConfig +from pydantic import BaseModel + + +class ExecutionContext(BaseModel): + execution_id: str + workspace_id: str + user_id: str + checkpoint_config: RunnableConfig + + @classmethod + def create(cls, execution_id: str, workspace_id: str, user_id: str): + return cls( + execution_id=execution_id, + workspace_id=workspace_id, + user_id=user_id, + checkpoint_config=RunnableConfig( + configurable={ + "thread_id": uuid.uuid4(), + } + ) + ) + diff --git a/api/app/core/workflow/engine/state_manager.py b/api/app/core/workflow/engine/state_manager.py new file mode 100644 index 00000000..0a4a1463 --- /dev/null +++ b/api/app/core/workflow/engine/state_manager.py @@ -0,0 +1,99 @@ +# -*- coding: UTF-8 -*- +# Author: Eternity +# @Email: 1533512157@qq.com +# @Time : 2026/2/10 13:33 +from typing import Annotated, Any + +from app.core.workflow.engine.runtime_schema import ExecutionContext +from app.core.workflow.nodes.enums import NodeType + + +def merge_activate_state(x, y): + return { + k: x.get(k, False) or y.get(k, False) + for k in set(x) | set(y) + } + + +def merge_looping_state(x, y): + return y if y > x else x + + +class WorkflowState(dict): + """Workflow state + + The state object passed between nodes in a workflow, containing messages, variables, node outputs, etc. + """ + __required_keys__ = frozenset({ + "messages", + "cycle_nodes", + "looping", + "node_outputs", + "execution_id", + "workspace_id", + "user_id", + "activate", + }) + __optional_keys__ = frozenset({ + "error", + "error_node", + }) + + # List of messages (append mode) + messages: Annotated[list[dict[str, str]], lambda x, y: y] + + # Set of loop node IDs, used for assigning values in loop nodes + cycle_nodes: list + looping: Annotated[int, merge_looping_state] + + # Node outputs (stores execution results of each node for variable references) + # Uses a custom merge function to combine new node outputs into the existing dictionary + node_outputs: Annotated[dict[str, Any], lambda x, y: {**x, **y}] + + # Execution context + execution_id: str + workspace_id: str + user_id: str + + # Error information (for error edges) + error: str | None + error_node: str | None + + # node activate status + activate: Annotated[dict[str, bool], merge_activate_state] + + +class WorkflowStateManager: + def create_initial_state( + self, + workflow_config: dict, + input_data: dict, + execution_context: ExecutionContext, + start_node_id: str + ) -> WorkflowState: + conversation_messages = input_data.get("conv_messages", []) + + return WorkflowState( + messages=conversation_messages, + node_outputs={}, + execution_id=execution_context.execution_id, + workspace_id=execution_context.workspace_id, + user_id=execution_context.user_id, + error=None, + error_node=None, + cycle_nodes=self._identify_cycle_nodes(workflow_config), + looping=0, + activate={ + start_node_id: True + } + ) + + @staticmethod + def _identify_cycle_nodes( + workflow_config: dict + ): + return [ + node.get("id") + for node in workflow_config.get("nodes") + if node.get("type") in [NodeType.LOOP, NodeType.ITERATION] + ] diff --git a/api/app/core/workflow/engine/stream_output_coordinator.py b/api/app/core/workflow/engine/stream_output_coordinator.py new file mode 100644 index 00000000..778c6acf --- /dev/null +++ b/api/app/core/workflow/engine/stream_output_coordinator.py @@ -0,0 +1,328 @@ +# -*- coding: UTF-8 -*- +# Author: Eternity +# @Email: 1533512157@qq.com +# @Time : 2026/2/9 15:11 +import re +from typing import AsyncGenerator + +from pydantic import BaseModel, Field + +from app.core.logging_config import get_logger +from app.core.workflow.engine.variable_pool import VariablePool + +logger = get_logger(__name__) + +SCOPE_PATTERN = re.compile( + r"\{\{\s*([a-zA-Z_][a-zA-Z0-9_]*)\.[a-zA-Z0-9_]+\s*}}" +) + + +class OutputContent(BaseModel): + """ + Represents a single output segment of an End node. + + An output segment can be either: + - literal text (static string) + - a variable placeholder (e.g. {{ node.field }}) + + Each segment has its own activation state, which is especially + important in stream mode. + """ + + literal: str = Field( + ..., + description="Raw output content. Can be literal text or a variable placeholder." + ) + + activate: bool = Field( + ..., + description=( + "Whether this output segment is currently active.\n" + "- True: allowed to be emitted/output\n" + "- False: blocked until activated by branch control" + ) + ) + + is_variable: bool = Field( + ..., + description=( + "Whether this segment represents a variable placeholder.\n" + "True -> variable (e.g. {{ node.field }})\n" + "False -> literal text" + ) + ) + + _SCOPE: str | None = None + + def get_scope(self) -> str: + self._SCOPE = SCOPE_PATTERN.findall(self.literal)[0] + return self._SCOPE + + def depends_on_scope(self, scope: str) -> bool: + """ + Check if this segment depends on a given scope. + + Args: + scope (str): Node ID or special variable prefix (e.g., "sys"). + + Returns: + bool: True if this segment references the given scope. + """ + if self._SCOPE: + return self._SCOPE == scope + return self.get_scope() == scope + + +class StreamOutputConfig(BaseModel): + """ + Streaming output configuration for an End node. + + This configuration describes how the End node output behaves in streaming mode, + including: + - whether output emission is globally activated + - which upstream branch/control nodes gate the activation + - how each parsed output segment is streamed and activated + """ + + activate: bool = Field( + ..., + description=( + "Global activation flag for the End node output.\n" + "When False, output segments should not be emitted even if available.\n" + "This flag typically becomes True once required control branch conditions " + "are satisfied." + ) + ) + + control_nodes: dict[str, list[str]] = Field( + ..., + description=( + "Control branch conditions for this End node output.\n" + "Mapping of `branch_node_id -> expected_branch_label`.\n" + "The End node output becomes globally active when a controlling branch node " + "reports a matching completion status." + ) + ) + + outputs: list[OutputContent] = Field( + ..., + description=( + "Ordered list of output segments parsed from the output template.\n" + "Each segment represents either a literal text block or a variable placeholder " + "that may be activated independently." + ) + ) + + cursor: int = Field( + ..., + description=( + "Streaming cursor index.\n" + "Indicates the next output segment index to be emitted.\n" + "Segments with index < cursor are considered already streamed." + ) + ) + + def update_activate(self, scope: str, status=None): + """ + Update streaming activation state based on an upstream node or special variable. + + Args: + scope (str): + Identifier of the completed upstream entity. + - If a control branch node, it should match a key in `control_nodes`. + - If a variable placeholder (e.g., "sys.xxx"), it may appear in output segments. + status (optional): + Completion status of the control branch node. + Required when `scope` refers to a control node. + + Behavior: + 1. Control branch nodes: + - If `scope` matches a key in `control_nodes` and `status` matches the expected + branch label, the End node output becomes globally active (`activate = True`). + + 2. Variable output segments: + - For each segment that is a variable (`is_variable=True`): + - If the segment literal references `scope`, mark the segment as active. + - This applies both to regular node variables (e.g., "node_id.field") + and special system variables (e.g., "sys.xxx"). + + Notes: + - This method does not emit output or advance the streaming cursor. + - It only updates activation flags based on upstream events or special variables. + """ + + # Case 1: resolve control branch dependency + if scope in self.control_nodes.keys(): + if status is None: + raise RuntimeError("[Stream Output] Control node activation status not provided") + if status in self.control_nodes[scope]: + self.activate = True + + # Case 2: activate variable segments related to this node + for i in range(len(self.outputs)): + if ( + self.outputs[i].is_variable + and self.outputs[i].depends_on_scope(scope) + ): + self.outputs[i].activate = True + + +class StreamOutputCoordinator: + def __init__(self): + self.end_outputs: dict[str, StreamOutputConfig] = {} + self.activate_end: str | None = None + + def initialize_end_outputs( + self, + end_node_map: dict[str, StreamOutputConfig] + ): + self.end_outputs = end_node_map + + @property + def current_activate_end_info(self): + return self.end_outputs.get(self.activate_end) + + def pop_current_activate_end(self): + self.end_outputs.pop(self.activate_end) + self.activate_end = None + + def update_scope_activation( + self, + scope: str, + status: str | None = None + ): + """ + Update the activation state of all End nodes based on a completed scope (node or variable). + + Iterates over all End nodes in `self.end_outputs` and calls + `update_activate` on each, which may: + - Activate variable segments that depend on the completed node/scope. + - Activate the entire End node output if any control conditions are met. + + If any End node becomes active and `self.activate_end` is not yet set, + this node will be marked as the currently active End node. + + Args: + scope (str): The node ID or scope that has completed execution. + status (str | None): Optional status of the node (used for branch/control nodes). + """ + for node in self.end_outputs.keys(): + self.end_outputs[node].update_activate(scope, status) + if self.end_outputs[node].activate and self.activate_end is None: + self.activate_end = node + + async def emit_activate_chunk( + self, + variable_pool: VariablePool, + force: bool = False + ) -> AsyncGenerator[dict[str, str | dict], None]: + """ + Process and yield all currently active output segments for the currently active End node. + + This method handles stream-mode output for an End node by iterating through its output segments + (`OutputContent`). Only segments marked as active (`activate=True`) are processed, unless + `force=True`, which allows all segments to be processed regardless of their activation state. + + Behavior: + 1. Iterates from the current `cursor` position to the end of the outputs list. + 2. For each segment: + - If the segment is literal text (`is_variable=False`), append it directly. + - If the segment is a variable (`is_variable=True`), evaluate it using + `evaluate_expression` with the given `node_outputs` and `variables`, + then transform the result with `_trans_output_string`. + 3. Yield a stream event of type "message" containing the processed chunk. + 4. Move the `cursor` forward after processing each segment. + 5. When all segments have been processed, remove this End node from `end_outputs` + and reset `activate_end` to None. + + Args: + variable_pool (VariablePool): Pool of variables for evaluating segment values. + force (bool, default=False): If True, process segments even if `activate=False`. + + Yields: + dict: A stream event of type "message" containing the processed chunk. + + Notes: + - Segments that fail evaluation (ValueError) are skipped with a warning logged. + - This method only processes the currently active End node (`self.activate_end`). + - Use `force=True` for final emission regardless of activation state. + """ + end_info = self.end_outputs[self.activate_end] + + while end_info.cursor < len(end_info.outputs): + final_chunk = '' + current_segment = end_info.outputs[end_info.cursor] + + if not current_segment.activate and not force: + # Stop processing until this segment becomes active + break + + # Literal segment + if not current_segment.is_variable: + final_chunk += current_segment.literal + else: + # Variable segment: evaluate and transform + try: + # Simulate evaluation (replace with actual logic) + chunk = variable_pool.get_literal(current_segment.literal) + final_chunk += chunk + except Exception as e: + # Log failed evaluation but continue streaming + logger.warning(f"[STREAM] Failed to evaluate segment: {current_segment.literal}, error: {e}") + + if final_chunk: + logger.warning(f"[STREAM] StreamOutput Node:{self.activate_end}, chunk:{final_chunk}") + yield { + "event": "message", + "data": { + "chunk": final_chunk + } + } + + # Advance cursor after processing + end_info.cursor += 1 + + if end_info.cursor >= len(end_info.outputs): + self.end_outputs.pop(self.activate_end) + self.activate_end = None + + async def flush_remaining_chunk( + self, + variable_pool: VariablePool + ) -> AsyncGenerator[dict[str, str | dict], None]: + """ + Flush and yield all remaining output segments from active End nodes. + + This method ensures that any remaining chunks of output, which may not have + been emitted during normal streaming due to activation conditions, are fully + processed. It is typically called at the end of a workflow to guarantee + that all output is delivered. + + Behavior: + 1. Filter `end_outputs` to only keep End nodes that are still active. + 2. While there is an active End node (`self.activate_end`): + - Call `_emit_active_chunks(force=True)` to emit all segments regardless + of their activation state. + - If the current End node finishes, move to the next active End node + if any remain. + + Yields: + dict: Streamed output events in the format: + {"event": "message", "data": {"chunk": ...}} + """ + # Keep only active End nodes + self.end_outputs = { + node_id: node_info + for node_id, node_info in self.end_outputs.items() + if node_info.activate + } + + if self.activate_end or self.activate_end: + while self.activate_end: + # Force emit all remaining chunks of the active End node + async for msg_event in self.emit_activate_chunk(variable_pool, force=True): + yield msg_event + + # Move to next active End node if current one is done + if not self.activate_end and self.end_outputs: + self.activate_end = list(self.end_outputs.keys())[0] diff --git a/api/app/core/workflow/variable_pool.py b/api/app/core/workflow/engine/variable_pool.py similarity index 79% rename from api/app/core/workflow/variable_pool.py rename to api/app/core/workflow/engine/variable_pool.py index ae56bcb4..55966ed6 100644 --- a/api/app/core/workflow/variable_pool.py +++ b/api/app/core/workflow/engine/variable_pool.py @@ -1,14 +1,7 @@ -""" -变量池 (Variable Pool) - -工作流执行的数据中心,管理所有变量的存储和访问。 - -变量类型: -1. 系统变量 (sys.*) - 系统内置变量(execution_id, workspace_id, user_id, message 等) -2. 节点输出 (node_id.*) - 节点执行结果 -3. 会话变量 (conv.*) - 会话级变量(跨多轮对话保持) -""" - +# -*- coding: UTF-8 -*- +# Author: Eternity +# @Email: 1533512157@qq.com +# @Time : 2025/12/15 19:50 import logging import re from asyncio import Lock @@ -18,7 +11,8 @@ from typing import Any, Generic from pydantic import BaseModel -from app.core.workflow.variable.base_variable import VariableType +from app.core.workflow.engine.runtime_schema import ExecutionContext +from app.core.workflow.variable.base_variable import VariableType, DEFAULT_VALUE from app.core.workflow.variable.variable_objects import T, create_variable_instance logger = logging.getLogger(__name__) @@ -359,3 +353,74 @@ class VariablePool: f" runtime_vars={len(runtime_vars)}\n" f")" ) + + +class VariablePoolInitializer: + def __init__(self, workflow_config: dict): + self.workflow_config = workflow_config + + async def initialize( + self, + variable_pool: VariablePool, + input_data: dict, + execution_context: ExecutionContext + ) -> None: + await self._init_conversation_vars(variable_pool, input_data) + await self._init_system_vars(variable_pool, input_data, execution_context) + + async def _init_conversation_vars( + self, + variable_pool: VariablePool, + input_data: dict + ): + init_conv_vars: list[dict] = self.workflow_config.get("variables") or [] + runtime_conv_vars: dict[str, Any] = input_data.get("conv", {}) + + for var_def in init_conv_vars: + var_name = var_def.get("name") + var_default = runtime_conv_vars.get(var_name, var_def.get("default")) + var_type = var_def.get("type") + if var_name: + if var_default: + var_value = var_default + else: + var_value = DEFAULT_VALUE(var_type) + await variable_pool.new( + namespace="conv", + key=var_name, + value=var_value, + var_type=var_type, + mut=True + ) + + @staticmethod + async def _init_system_vars( + variable_pool: VariablePool, + input_data: dict, + context: ExecutionContext + ): + user_message = input_data.get("message") or "" + user_files = input_data.get("files") or [] + + input_variables = input_data.get("variables") or {} + sys_vars = { + "message": (user_message, VariableType.STRING), + "conversation_id": (input_data.get("conversation_id"), VariableType.STRING), + "execution_id": (context.execution_id, VariableType.STRING), + "workspace_id": (context.workspace_id, VariableType.STRING), + "user_id": (context.user_id, VariableType.STRING), + "input_variables": (input_variables, VariableType.OBJECT), + "files": (user_files, VariableType.ARRAY_FILE) + } + for key, var_def in sys_vars.items(): + value = var_def[0] + var_type = var_def[1] + await variable_pool.new( + namespace='sys', + key=key, + value=value, + var_type=var_type, + mut=False + ) + + diff --git a/api/app/core/workflow/executor.py b/api/app/core/workflow/executor.py index bebb67fc..2ec7992b 100644 --- a/api/app/core/workflow/executor.py +++ b/api/app/core/workflow/executor.py @@ -1,21 +1,20 @@ -""" -工作流执行器 - -基于 LangGraph 的工作流执行引擎。 -""" +# -*- coding: UTF-8 -*- +# Author: Eternity +# @Email: 1533512157@qq.com +# @Time : 2026/2/9 13:51 import datetime import logging -import uuid from typing import Any -from langchain_core.runnables import RunnableConfig from langgraph.graph.state import CompiledStateGraph -from app.core.workflow.graph_builder import GraphBuilder, StreamOutputConfig -from app.core.workflow.nodes import WorkflowState -from app.core.workflow.nodes.enums import NodeType -from app.core.workflow.variable.base_variable import VariableType, DEFAULT_VALUE -from app.core.workflow.variable_pool import VariablePool +from app.core.workflow.engine.event_stream_handler import EventStreamHandler +from app.core.workflow.engine.graph_builder import GraphBuilder +from app.core.workflow.engine.result_builder import WorkflowResultBuiler +from app.core.workflow.engine.runtime_schema import ExecutionContext +from app.core.workflow.engine.state_manager import WorkflowStateManager +from app.core.workflow.engine.stream_output_coordinator import StreamOutputCoordinator +from app.core.workflow.engine.variable_pool import VariablePool, VariablePoolInitializer logger = logging.getLogger(__name__) @@ -30,9 +29,7 @@ class WorkflowExecutor: def __init__( self, workflow_config: dict[str, Any], - execution_id: str, - workspace_id: str, - user_id: str, + execution_context: ExecutionContext, ): """Initialize Workflow Executor. @@ -41,13 +38,10 @@ class WorkflowExecutor: Args: workflow_config (dict): The workflow configuration dictionary. - execution_id (str): Unique identifier for this workflow execution. - workspace_id (str): Workspace or project ID. - user_id (str): User ID executing the workflow. + execution_context (ExecutionContext): The workflow execution context + include execution_id, workspace_id, user_id, checkpoint_config Attributes: - self.nodes (list): List of node definitions from workflow_config. - self.edges (list): List of edge definitions from workflow_config. self.execution_config (dict): Optional execution parameters from workflow_config. self.start_node_id (str | None): ID of the Start node, set after graph build. self.end_outputs (dict[str, StreamOutputConfig]): End node output configs. @@ -57,555 +51,18 @@ class WorkflowExecutor: self.checkpoint_config (RunnableConfig): Config for LangGraph checkpointing. """ self.workflow_config = workflow_config - self.execution_id = execution_id - self.workspace_id = workspace_id - self.user_id = user_id - self.nodes = workflow_config.get("nodes", []) - self.edges = workflow_config.get("edges", []) + self.execution_context = execution_context self.execution_config = workflow_config.get("execution_config", {}) - self.start_node_id = None - self.end_outputs: dict[str, StreamOutputConfig] = {} - self.activate_end: str | None = None + self.start_node_id: str | None = None self.variable_pool: VariablePool | None = None - self.graph: CompiledStateGraph | None = None - self.checkpoint_config = RunnableConfig( - configurable={ - "thread_id": uuid.uuid4(), - } - ) - async def __init_variable_pool(self, input_data: dict[str, Any]): - """Initialize the variable pool with system, conversation, and input variables. - - This method populates the VariablePool instance with: - - Conversation-level variables (`conv` namespace) from workflow config or provided values. - - System variables (`sys` namespace) such as message, files, conversation_id, execution_id, workspace_id, user_id, and input_variables. - - Args: - input_data (dict): Input data for workflow execution, may contain: - - "message": user message (str) - - "file": list of user-uploaded files - - "conv": existing conversation variables (dict) - - "variables": custom variables for the Start node (dict) - - "conversation_id": conversation identifier - """ - user_message = input_data.get("message") or "" - user_files = input_data.get("files") or [] - - config_variables_list = self.workflow_config.get("variables") or [] - conv_vars = input_data.get("conv", {}) - - # Initialize conversation variables (conv namespace) - for var_def in config_variables_list: - var_name = var_def.get("name") - var_default = conv_vars.get(var_name, var_def.get("default")) - var_type = var_def.get("type") - if var_name: - if var_default: - var_value = var_default - else: - var_value = DEFAULT_VALUE(var_type) - await self.variable_pool.new( - namespace="conv", - key=var_name, - value=var_value, - var_type=var_type, - mut=True - ) - - # Initialize system variables (sys namespace) - input_variables = input_data.get("variables") or {} - sys_vars = { - "message": (user_message, VariableType.STRING), - "conversation_id": (input_data.get("conversation_id"), VariableType.STRING), - "execution_id": (self.execution_id, VariableType.STRING), - "workspace_id": (self.workspace_id, VariableType.STRING), - "user_id": (self.user_id, VariableType.STRING), - "input_variables": (input_variables, VariableType.OBJECT), - "files": (user_files, VariableType.ARRAY_FILE) - } - for key, var_def in sys_vars.items(): - value = var_def[0] - var_type = var_def[1] - await self.variable_pool.new( - namespace='sys', - key=key, - value=value, - var_type=var_type, - mut=False - ) - - def _prepare_initial_state(self, input_data: dict[str, Any]) -> WorkflowState: - """Generate the initial workflow state for execution. - - This method prepares the runtime state dictionary with system variables, - conversation variables, node outputs, loop tracking, and activation flags. - - Args: - input_data (dict): The input payload for workflow execution. - Expected keys: - - "conv_messages" (list, optional): Historical conversation messages - to include in the workflow state. - - Returns: - WorkflowState: A dictionary representing the initialized workflow state - with the following keys: - - "messages": List of conversation messages - - "node_outputs": Empty dict to store outputs of executed nodes - - "execution_id": Current workflow execution ID - - "workspace_id": Current workspace ID - - "user_id": ID of the user triggering execution - - "error": None initially, will store error message if a node fails - - "error_node": None initially, will store ID of node that caused error - - "cycle_nodes": List of node IDs that are of type LOOP or ITERATION - - "looping": Integer flag indicating loop execution state (0 = not looping) - - "activate": Dict mapping node IDs to activation status; initially - only the start node is active - """ - conversation_messages = input_data.get("conv_messages") or [] - - return { - "messages": conversation_messages, - "node_outputs": {}, - "execution_id": self.execution_id, - "workspace_id": self.workspace_id, - "user_id": self.user_id, - "error": None, - "error_node": None, - "cycle_nodes": [ - node.get("id") - for node in self.workflow_config.get("nodes") - if node.get("type") in [NodeType.LOOP, NodeType.ITERATION] - ], # loop, iteration node id - "looping": 0, # loop runing flag, only use in loop node,not use in main loop - "activate": { - self.start_node_id: True - } - } - - def _build_final_output(self, result, elapsed_time, final_output): - """Construct the final standardized output of the workflow execution. - - This method aggregates node outputs, token usage, conversation and system - variables, messages, and other metadata into a consistent dictionary - structure suitable for returning from workflow execution. - - Args: - result (dict): The runtime state returned by the workflow graph execution. - Expected keys include: - - "node_outputs" (dict): Outputs of executed nodes. - - "messages" (list): Conversation messages exchanged during execution. - - "error" (str, optional): Error message if any node failed. - elapsed_time (float): Total execution time in seconds. - final_output (Any): The aggregated or final output content of the workflow - (e.g., combined messages from all End nodes). - - Returns: - dict: A dictionary containing the final workflow execution result with keys: - - "status": Execution status ("completed") - - "output": Aggregated final output content - - "variables": Namespace dictionary with: - - "conv": Conversation variables - - "sys": System variables - - "node_outputs": Outputs from all executed nodes - - "messages": Conversation messages exchanged - - "conversation_id": ID of the current conversation - - "elapsed_time": Total execution time in seconds - - "token_usage": Aggregated token usage across nodes (if available) - - "error": Error message if any occurred during execution - """ - node_outputs = result.get("node_outputs", {}) - token_usage = self._aggregate_token_usage(node_outputs) - conversation_id = self.variable_pool.get_value("sys.conversation_id") - - return { - "status": "completed", - "output": final_output, - "variables": { - "conv": self.variable_pool.get_all_conversation_vars(), - "sys": self.variable_pool.get_all_system_vars() - }, - "node_outputs": node_outputs, - "messages": result.get("messages", []), - "conversation_id": conversation_id, - "elapsed_time": elapsed_time, - "token_usage": token_usage, - "error": result.get("error"), - } - - def _update_scope_activate(self, scope, status=None): - """ - Update the activation state of all End nodes based on a completed scope (node or variable). - - Iterates over all End nodes in `self.end_outputs` and calls - `update_activate` on each, which may: - - Activate variable segments that depend on the completed node/scope. - - Activate the entire End node output if any control conditions are met. - - If any End node becomes active and `self.activate_end` is not yet set, - this node will be marked as the currently active End node. - - Args: - scope (str): The node ID or scope that has completed execution. - status (str | None): Optional status of the node (used for branch/control nodes). - """ - for node in self.end_outputs.keys(): - self.end_outputs[node].update_activate(scope, status) - if self.end_outputs[node].activate and self.activate_end is None: - self.activate_end = node - - def _update_stream_output_status(self, activate, data): - """ - Update the stream output state of End nodes based on workflow state updates. - - This method checks which nodes/scopes are activated and propagates - activation to End nodes accordingly. - - Args: - activate (dict): Mapping of node_id -> bool indicating which nodes/scopes are activated. - data (dict): Mapping of node_id -> node runtime data, including outputs. - - Behavior: - For each node in `data`: - 1. If the node is activated (`activate[node_id]` is True), - retrieve its output status from `runtime_vars`. - 2. Call `_update_scope_activate` to propagate the activation - to all relevant End nodes and update `self.activate_end`. - """ - for node_id in data.keys(): - if activate.get(node_id): - node_output_status = self.variable_pool.get_value(f"{node_id}.output", default=None, strict=False) - self._update_scope_activate(node_id, status=node_output_status) - - async def _emit_active_chunks( - self, - force=False - ): - """ - Process and yield all currently active output segments for the currently active End node. - - This method handles stream-mode output for an End node by iterating through its output segments - (`OutputContent`). Only segments marked as active (`activate=True`) are processed, unless - `force=True`, which allows all segments to be processed regardless of their activation state. - - Behavior: - 1. Iterates from the current `cursor` position to the end of the outputs list. - 2. For each segment: - - If the segment is literal text (`is_variable=False`), append it directly. - - If the segment is a variable (`is_variable=True`), evaluate it using - `evaluate_expression` with the given `node_outputs` and `variables`, - then transform the result with `_trans_output_string`. - 3. Yield a stream event of type "message" containing the processed chunk. - 4. Move the `cursor` forward after processing each segment. - 5. When all segments have been processed, remove this End node from `end_outputs` - and reset `activate_end` to None. - - Args: - force (bool, default=False): If True, process segments even if `activate=False`. - - Yields: - dict: A stream event of type "message" containing the processed chunk. - - Notes: - - Segments that fail evaluation (ValueError) are skipped with a warning logged. - - This method only processes the currently active End node (`self.activate_end`). - - Use `force=True` for final emission regardless of activation state. - """ - - end_info = self.end_outputs[self.activate_end] - - while end_info.cursor < len(end_info.outputs): - final_chunk = '' - current_segment = end_info.outputs[end_info.cursor] - - if not current_segment.activate and not force: - # Stop processing until this segment becomes active - break - - # Literal segment - if not current_segment.is_variable: - final_chunk += current_segment.literal - else: - # Variable segment: evaluate and transform - try: - chunk = self.variable_pool.get_literal(current_segment.literal) - final_chunk += chunk - except KeyError: - # Log failed evaluation but continue streaming - logger.warning(f"[STREAM] Failed to evaluate segment: {current_segment.literal}") - - if final_chunk: - logger.info(f"[STREAM] StreamOutput Node:{self.activate_end}, chunk:{final_chunk}") - yield { - "event": "message", - "data": { - "chunk": final_chunk - } - } - - # Advance cursor after processing - end_info.cursor += 1 - - # Remove End node from active tracking if all segments have been processed - if end_info.cursor >= len(end_info.outputs): - self.end_outputs.pop(self.activate_end) - self.activate_end = None - - async def _handle_updates_event(self, data): - """ - Handle workflow state update events ("updates") and stream active End node outputs. - - Steps: - 1. Retrieve the current graph state. - 2. Extract node activation information from the state. - 3. Update the activation status of all End nodes. - 4. While there is an active End node: - - Call _emit_active_chunks() to yield all currently active output segments. - - After all segments are processed, update activate_end if there are remaining End nodes. - 5. Log a debug message indicating state update received. - - Args: - data (dict): The latest node state updates. - - Yields: - dict: Streamed output event, each chunk in the format: - {"event": "message", "data": {"chunk": ...}} - """ - # Get the latest workflow state - state = self.graph.get_state(config=self.checkpoint_config).values - activate = state.get("activate", {}) - - # Update End node activation based on the new state - self._update_stream_output_status(activate, data) - wait = False - while self.activate_end and not wait: - async for msg_event in self._emit_active_chunks(): - yield msg_event - - if self.activate_end: - wait = True - else: - self._update_stream_output_status(activate, data) - - logger.debug(f"[UPDATES] Received state update from nodes: {list(data.keys())} " - f"- execution_id: {self.execution_id}") - - async def _handle_node_chunk_event(self, data): - """ - Handle streaming chunk events from individual nodes ("node_chunk"). - - This method processes output segments for the currently active End node. - If the segment depends on the provided node_id: - - If the node has finished execution (`done=True`), advance the cursor. - - If all segments are processed, deactivate the End node. - - Otherwise, yield the current chunk as a streaming message. - - Args: - data (dict): Node chunk event data, expected keys: - - "node_id": ID of the node producing this chunk - - "chunk": Chunk of output text - - "done": Boolean indicating whether the node finished producing output - - Yields: - dict: Streaming message event in the format: - {"event": "message", "data": {"chunk": ...}} - """ - node_id = data.get("node_id") - if self.activate_end: - end_info = self.end_outputs.get(self.activate_end) - if not end_info or end_info.cursor >= len(end_info.outputs): - return - current_output = end_info.outputs[end_info.cursor] - if current_output.is_variable and current_output.depends_on_scope(node_id): - if data.get("done"): - end_info.cursor += 1 - if end_info.cursor >= len(end_info.outputs): - self.end_outputs.pop(self.activate_end) - self.activate_end = None - else: - yield { - "event": "message", - "data": { - "chunk": data.get("chunk") - } - } - - async def _handle_node_error_event(self, data): - """ - Handle node error events ("node_error") during workflow execution. - - This method streams an error event for a node that has failed. The event - contains the node ID, status, input data, elapsed time, and error message. - - Args: - data (dict): Node error event data, expected keys: - - "node_id": ID of the node that failed - - "input_data": The input data that caused the error - - "elapsed_time": Execution time before the error occurred - - "error": Error message or exception string - - Yields: - dict: Node error event in the format: - { - "event": "node_error", - "data": { - "node_id": str, - "status": "failed", - "input": ..., - "elapsed_time": float, - "output": None, - "error": str - } - } - """ - node_id = data.get("node_id") - yield { - "event": "node_error", - "data": { - "node_id": node_id, - "status": "failed", - "input": data.get("input_data"), - "elapsed_time": data.get("elapsed_time"), - "output": None, - "error": data.get("error") - } - } - - async def _handle_debug_event(self, data, input_data): - """ - Handle debug events ("debug") related to node execution status. - - This method streams debug events for nodes, including when a node starts - execution ("node_start") and when it completes execution ("node_end"). - It filters out nodes with names starting with "nop" as no-operation nodes. - - Args: - data (dict): Debug event data, expected keys: - - "type": Event type ("task" for start, "task_result" for completion) - - "payload": Node-related information, including: - - "name": Node name / ID - - "input": Node input data (for "task" type) - - "result": Node execution result (for "task_result" type) - - "timestamp": ISO timestamp string of the event - input_data (dict): Original workflow input data (used to get conversation_id) - - Yields: - dict: Node debug event in one of the following formats: - 1. Node start: - { - "event": "node_start", - "data": { - "node_id": str, - "conversation_id": str, - "execution_id": str, - "timestamp": int (ms) - } - } - 2. Node end: - { - "event": "node_end", - "data": { - "node_id": str, - "conversation_id": str, - "execution_id": str, - "timestamp": int (ms), - "input": dict, - "output": Any, - "elapsed_time": float - } - } - """ - event_type = data.get("type") - payload = data.get("payload", {}) - node_name = payload.get("name") - - # Skip no-operation nodes - if node_name and node_name.startswith("nop"): - return - - if event_type == "task": - # Node starts execution - inputv = payload.get("input", {}) - if not inputv.get("activate", {}).get(node_name): - return - conversation_id = input_data.get("conversation_id") - logger.info(f"[NODE-START] Node '{node_name}' execution started - execution_id: {self.execution_id}") - - yield { - "event": "node_start", - "data": { - "node_id": node_name, - "conversation_id": conversation_id, - "execution_id": self.execution_id, - "timestamp": int(datetime.datetime.fromisoformat( - data.get("timestamp") - ).timestamp() * 1000), - } - } - elif event_type == "task_result": - # Node execution completed - result = payload.get("result", {}) - if not result.get("activate", {}).get(node_name): - return - - conversation_id = input_data.get("conversation_id") - logger.info(f"[NODE-END] Node '{node_name}' execution completed - execution_id: {self.execution_id}") - - yield { - "event": "node_end", - "data": { - "node_id": node_name, - "conversation_id": conversation_id, - "execution_id": self.execution_id, - "timestamp": int(datetime.datetime.fromisoformat( - data.get("timestamp") - ).timestamp() * 1000), - "input": result.get("node_outputs", {}).get(node_name, {}).get("input"), - "output": result.get("node_outputs", {}).get(node_name, {}).get("output"), - "elapsed_time": result.get("node_outputs", {}).get(node_name, {}).get("elapsed_time"), - "token_usage": result.get("node_outputs", {}).get(node_name, {}).get("token_usage") - } - } - - async def _flush_remaining_chunk(self): - """ - Flush and yield all remaining output segments from active End nodes. - - This method ensures that any remaining chunks of output, which may not have - been emitted during normal streaming due to activation conditions, are fully - processed. It is typically called at the end of a workflow to guarantee - that all output is delivered. - - Behavior: - 1. Filter `end_outputs` to only keep End nodes that are still active. - 2. While there is an active End node (`self.activate_end`): - - Call `_emit_active_chunks(force=True)` to emit all segments regardless - of their activation state. - - If the current End node finishes, move to the next active End node - if any remain. - - Yields: - dict: Streamed output events in the format: - {"event": "message", "data": {"chunk": ...}} - """ - # Keep only active End nodes - self.end_outputs = { - node_id: node_info - for node_id, node_info in self.end_outputs.items() - if node_info.activate - } - - if self.end_outputs or self.activate_end: - while self.activate_end: - # Force emit all remaining chunks of the active End node - async for msg_event in self._emit_active_chunks(force=True): - yield msg_event - - # Move to next active End node if current one is done - if not self.activate_end and self.end_outputs: - self.activate_end = list(self.end_outputs.keys())[0] + self.variable_initializer = VariablePoolInitializer(workflow_config) + self.state_manager = WorkflowStateManager() + self.result_builder = WorkflowResultBuiler() + self.stream_coordinator = StreamOutputCoordinator() + self.event_handler: EventStreamHandler | None = None def build_graph(self, stream=False) -> CompiledStateGraph: """ @@ -624,16 +81,22 @@ class WorkflowExecutor: Returns: CompiledStateGraph: The compiled and ready-to-run state graph. """ - logger.info(f"Starting workflow graph build: execution_id={self.execution_id}") + logger.info(f"Starting workflow graph build: execution_id={self.execution_context.execution_id}") builder = GraphBuilder( self.workflow_config, stream=stream, ) self.start_node_id = builder.start_node_id - self.end_outputs = builder.end_node_map self.variable_pool = builder.variable_pool self.graph = builder.build() - logger.info(f"Workflow graph build completed: execution_id={self.execution_id}") + + self.stream_coordinator.initialize_end_outputs(builder.end_node_map) + self.event_handler = EventStreamHandler( + output_coordinator=self.stream_coordinator, + variable_pool=self.variable_pool, + execution_id=self.execution_context.execution_id + ) + logger.info(f"Workflow graph build completed: execution_id={self.execution_context.execution_id}") return self.graph @@ -665,7 +128,7 @@ class WorkflowExecutor: - token_usage: aggregated token usage if available - error: error message if any """ - logger.info(f"Starting workflow execution: execution_id={self.execution_id}") + logger.info(f"Starting workflow execution: execution_id={self.execution_context.execution_id}") start_time = datetime.datetime.now() @@ -673,16 +136,25 @@ class WorkflowExecutor: graph = self.build_graph() # Initialize the variable pool with input data - await self.__init_variable_pool(input_data) - initial_state = self._prepare_initial_state(input_data) + await self.variable_initializer.initialize( + variable_pool=self.variable_pool, + input_data=input_data, + execution_context=self.execution_context + ) + initial_state = self.state_manager.create_initial_state( + workflow_config=self.workflow_config, + input_data=input_data, + execution_context=self.execution_context, + start_node_id=self.start_node_id + ) # Execute the workflow try: - result = await graph.ainvoke(initial_state, config=self.checkpoint_config) + result = await graph.ainvoke(initial_state, config=self.execution_context.checkpoint_config) # Aggregate output from all End nodes full_content = '' - for end_id in self.end_outputs.keys(): + for end_id in self.stream_coordinator.end_outputs.keys(): full_content += self.variable_pool.get_value(f"{end_id}.output", default="", strict=False) # Append messages for user and assistant @@ -703,15 +175,16 @@ class WorkflowExecutor: elapsed_time = (end_time - start_time).total_seconds() logger.info( - f"Workflow execution completed: execution_id={self.execution_id}, elapsed_time={elapsed_time:.2f}s") + f"Workflow execution completed: execution_id={self.execution_context.execution_id}, elapsed_time={elapsed_time:.2f}s") - return self._build_final_output(result, elapsed_time, full_content) + return self.result_builder.build_final_output(result, self.variable_pool, elapsed_time, full_content) except Exception as e: end_time = datetime.datetime.now() elapsed_time = (end_time - start_time).total_seconds() - logger.error(f"Workflow execution failed: execution_id={self.execution_id}, error={e}", exc_info=True) + logger.error(f"Workflow execution failed: execution_id={self.execution_context.execution_id}, error={e}", + exc_info=True) return { "status": "failed", "error": str(e), @@ -744,15 +217,15 @@ class WorkflowExecutor: "data": {...} } """ - logger.info(f"Starting workflow execution (streaming): execution_id={self.execution_id}") + logger.info(f"Starting workflow execution (streaming): execution_id={self.execution_context.execution_id}") start_time = datetime.datetime.now() yield { "event": "workflow_start", "data": { - "execution_id": self.execution_id, - "workspace_id": self.workspace_id, + "execution_id": self.execution_context.execution_id, + "workspace_id": self.execution_context.workspace_id, "conversation_id": input_data.get("conversation_id"), "timestamp": int(start_time.timestamp() * 1000) } @@ -762,18 +235,27 @@ class WorkflowExecutor: graph = self.build_graph(stream=True) # Initialize the variable pool and system variables - await self.__init_variable_pool(input_data) - initial_state = self._prepare_initial_state(input_data) + await self.variable_initializer.initialize( + variable_pool=self.variable_pool, + input_data=input_data, + execution_context=self.execution_context + ) + initial_state = self.state_manager.create_initial_state( + workflow_config=self.workflow_config, + input_data=input_data, + execution_context=self.execution_context, + start_node_id=self.start_node_id + ) try: full_content = '' - self._update_scope_activate("sys") + self.stream_coordinator.update_scope_activation("sys") # Execute the workflow with streaming async for event in graph.astream( initial_state, stream_mode=["updates", "debug", "custom"], # Use updates + debug + custom mode - config=self.checkpoint_config + config=self.execution_context.checkpoint_config ): # event should be a tuple: (mode, data) # But let's handle both cases @@ -782,38 +264,42 @@ class WorkflowExecutor: else: # Unexpected format, log and skip logger.warning(f"[STREAM] Unexpected event format: {type(event)}, value: {event}" - f"- execution_id: {self.execution_id}") + f"- execution_id: {self.execution_context.execution_id}") continue if mode == "custom": # Handle custom streaming events (chunks from nodes via stream writer) event_type = data.get("type", "node_chunk") # "message" or "node_chunk" if event_type == "node_chunk": - async for msg_event in self._handle_node_chunk_event(data): + async for msg_event in self.event_handler.handle_node_chunk_event(data): full_content += msg_event["data"]["chunk"] yield msg_event elif event_type == "node_error": - async for error_event in self._handle_node_error_event(data): + async for error_event in self.event_handler.handle_node_error_event(data): yield error_event elif mode == "debug": - async for debug_event in self._handle_debug_event(data, input_data): + async for debug_event in self.event_handler.handle_debug_event(data, input_data): yield debug_event elif mode == "updates": logger.debug(f"[UPDATES] 收到 state 更新 from {list(data.keys())} " - f"- execution_id: {self.execution_id}") - async for msg_event in self._handle_updates_event(data): + f"- execution_id: {self.execution_context.execution_id}") + async for msg_event in self.event_handler.handle_updates_event( + data, + self.graph, + self.execution_context.checkpoint_config + ): full_content += msg_event["data"]['chunk'] yield msg_event # Flush any remaining chunks - async for msg_event in self._flush_remaining_chunk(): + async for msg_event in self.stream_coordinator.flush_remaining_chunk(self.variable_pool): full_content += msg_event["data"]['chunk'] yield msg_event - result = graph.get_state(self.checkpoint_config).values + result = graph.get_state(self.execution_context.checkpoint_config).values end_time = datetime.datetime.now() elapsed_time = (end_time - start_time).total_seconds() @@ -832,24 +318,25 @@ class WorkflowExecutor: ) logger.info( f"Workflow execution completed (streaming), " - f"elapsed: {elapsed_time:.2f}s, execution_id: {self.execution_id}" + f"elapsed: {elapsed_time:.2f}s, execution_id: {self.execution_context.execution_id}" ) yield { "event": "workflow_end", - "data": self._build_final_output(result, elapsed_time, full_content) + "data": self.result_builder.build_final_output(result, self.variable_pool, elapsed_time, full_content) } except Exception as e: end_time = datetime.datetime.now() elapsed_time = (end_time - start_time).total_seconds() - logger.error(f"Workflow execution failed: execution_id={self.execution_id}, error={e}", exc_info=True) + logger.error(f"Workflow execution failed: execution_id={self.execution_context.execution_id}, error={e}", + exc_info=True) yield { "event": "workflow_end", "data": { - "execution_id": self.execution_id, + "execution_id": self.execution_context.execution_id, "status": "failed", "error": str(e), "elapsed_time": elapsed_time, @@ -857,46 +344,6 @@ class WorkflowExecutor: } } - @staticmethod - def _aggregate_token_usage(node_outputs: dict[str, Any]) -> dict[str, int] | None: - """ - Aggregate token usage statistics across all nodes. - - Args: - node_outputs (dict): A dictionary of all node outputs. - - Returns: - dict | None: Aggregated token usage in the format: - { - "prompt_tokens": int, - "completion_tokens": int, - "total_tokens": int - } - Returns None if no token usage information is available. - """ - total_prompt_tokens = 0 - total_completion_tokens = 0 - total_tokens = 0 - has_token_info = False - - for node_output in node_outputs.values(): - if isinstance(node_output, dict): - token_usage = node_output.get("token_usage") - if token_usage and isinstance(token_usage, dict): - has_token_info = True - total_prompt_tokens += token_usage.get("prompt_tokens", 0) - total_completion_tokens += token_usage.get("completion_tokens", 0) - total_tokens += token_usage.get("total_tokens", 0) - - if not has_token_info: - return None - - return { - "prompt_tokens": total_prompt_tokens, - "completion_tokens": total_completion_tokens, - "total_tokens": total_tokens - } - async def execute_workflow( workflow_config: dict[str, Any], @@ -918,12 +365,15 @@ async def execute_workflow( Returns: dict: Workflow execution result. """ - executor = WorkflowExecutor( - workflow_config=workflow_config, + execution_context = ExecutionContext.create( execution_id=execution_id, workspace_id=workspace_id, user_id=user_id ) + executor = WorkflowExecutor( + workflow_config=workflow_config, + execution_context=execution_context + ) return await executor.execute(input_data) @@ -947,11 +397,14 @@ async def execute_workflow_stream( Yields: dict: Streaming workflow events, e.g. node start, node end, chunk messages, workflow end. """ - executor = WorkflowExecutor( - workflow_config=workflow_config, + execution_context = ExecutionContext.create( execution_id=execution_id, workspace_id=workspace_id, user_id=user_id ) + executor = WorkflowExecutor( + workflow_config=workflow_config, + execution_context=execution_context + ) async for event in executor.execute_stream(input_data): yield event diff --git a/api/app/core/workflow/nodes/__init__.py b/api/app/core/workflow/nodes/__init__.py index 885dfbc9..7c24d079 100644 --- a/api/app/core/workflow/nodes/__init__.py +++ b/api/app/core/workflow/nodes/__init__.py @@ -6,7 +6,8 @@ from app.core.workflow.nodes.agent import AgentNode from app.core.workflow.nodes.assigner import AssignerNode -from app.core.workflow.nodes.base_node import BaseNode, WorkflowState +from app.core.workflow.nodes.base_node import BaseNode +from app.core.workflow.nodes.code import CodeNode from app.core.workflow.nodes.end import EndNode from app.core.workflow.nodes.http_request import HttpRequestNode from app.core.workflow.nodes.if_else import IfElseNode @@ -14,16 +15,14 @@ from app.core.workflow.nodes.jinja_render import JinjaRenderNode from app.core.workflow.nodes.knowledge import KnowledgeRetrievalNode from app.core.workflow.nodes.llm import LLMNode from app.core.workflow.nodes.node_factory import NodeFactory, WorkflowNode -from app.core.workflow.nodes.start import StartNode from app.core.workflow.nodes.parameter_extractor import ParameterExtractorNode from app.core.workflow.nodes.question_classifier import QuestionClassifierNode +from app.core.workflow.nodes.start import StartNode from app.core.workflow.nodes.tool import ToolNode from app.core.workflow.nodes.variable_aggregator import VariableAggregatorNode -from app.core.workflow.nodes.code import CodeNode __all__ = [ "BaseNode", - "WorkflowState", "LLMNode", "AgentNode", "IfElseNode", diff --git a/api/app/core/workflow/nodes/agent/node.py b/api/app/core/workflow/nodes/agent/node.py index 0818749c..98d8bb75 100644 --- a/api/app/core/workflow/nodes/agent/node.py +++ b/api/app/core/workflow/nodes/agent/node.py @@ -7,14 +7,16 @@ Agent 节点实现 import logging from typing import Any + from langchain_core.messages import AIMessage -from app.core.workflow.nodes.base_node import BaseNode, WorkflowState +from app.core.workflow.engine.state_manager import WorkflowState +from app.core.workflow.engine.variable_pool import VariablePool +from app.core.workflow.nodes.base_node import BaseNode from app.core.workflow.variable.base_variable import VariableType -from app.core.workflow.variable_pool import VariablePool -from app.services.draft_run_service import DraftRunService -from app.models import AppRelease from app.db import get_db +from app.models import AppRelease +from app.services.draft_run_service import DraftRunService logger = logging.getLogger(__name__) diff --git a/api/app/core/workflow/nodes/assigner/node.py b/api/app/core/workflow/nodes/assigner/node.py index e1bb6e9d..be51f81d 100644 --- a/api/app/core/workflow/nodes/assigner/node.py +++ b/api/app/core/workflow/nodes/assigner/node.py @@ -2,12 +2,13 @@ import logging import re from typing import Any +from app.core.workflow.engine.state_manager import WorkflowState +from app.core.workflow.engine.variable_pool import VariablePool from app.core.workflow.nodes.assigner.config import AssignerNodeConfig -from app.core.workflow.nodes.base_node import BaseNode, WorkflowState +from app.core.workflow.nodes.base_node import BaseNode from app.core.workflow.nodes.enums import AssignmentOperator from app.core.workflow.nodes.operators import AssignmentOperatorInstance, AssignmentOperatorResolver from app.core.workflow.variable.base_variable import VariableType -from app.core.workflow.variable_pool import VariablePool logger = logging.getLogger(__name__) diff --git a/api/app/core/workflow/nodes/base_node.py b/api/app/core/workflow/nodes/base_node.py index 107567e1..a01ffbe3 100644 --- a/api/app/core/workflow/nodes/base_node.py +++ b/api/app/core/workflow/nodes/base_node.py @@ -5,57 +5,17 @@ from functools import cached_property from typing import Any, AsyncGenerator from langgraph.config import get_stream_writer -from typing_extensions import TypedDict, Annotated from app.core.config import settings +from app.core.workflow.engine.state_manager import WorkflowState +from app.core.workflow.engine.variable_pool import VariablePool from app.core.workflow.nodes.enums import BRANCH_NODES from app.core.workflow.variable.base_variable import VariableType -from app.core.workflow.variable_pool import VariablePool from app.services.multimodal_service import PROVIDER_STRATEGIES logger = logging.getLogger(__name__) -def merge_activate_state(x, y): - return { - k: x.get(k, False) or y.get(k, False) - for k in set(x) | set(y) - } - - -def merge_looping_state(x, y): - return y if y > x else x - - -class WorkflowState(TypedDict): - """Workflow state - - The state object passed between nodes in a workflow, containing messages, variables, node outputs, etc. - """ - # List of messages (append mode) - messages: Annotated[list[dict[str, str]], lambda x, y: y] - - # Set of loop node IDs, used for assigning values in loop nodes - cycle_nodes: list - looping: Annotated[int, merge_looping_state] - - # Node outputs (stores execution results of each node for variable references) - # Uses a custom merge function to combine new node outputs into the existing dictionary - node_outputs: Annotated[dict[str, Any], lambda x, y: {**x, **y}] - - # Execution context - execution_id: str - workspace_id: str - user_id: str - - # Error information (for error edges) - error: str | None - error_node: str | None - - # node activate status - activate: Annotated[dict[str, bool], merge_activate_state] - - class BaseNode(ABC): """Base class for workflow nodes. @@ -584,7 +544,7 @@ class BaseNode(ABC): Returns: The rendered string with all variables substituted. """ - from app.core.workflow.template_renderer import render_template + from app.core.workflow.utils.template_renderer import render_template return render_template( template=template, @@ -611,7 +571,7 @@ class BaseNode(ABC): Returns: The boolean result of evaluating the expression. """ - from app.core.workflow.expression_evaluator import evaluate_condition + from app.core.workflow.utils.expression_evaluator import evaluate_condition return evaluate_condition( expression=expression, diff --git a/api/app/core/workflow/nodes/breaker/node.py b/api/app/core/workflow/nodes/breaker/node.py index 8b772d6a..34162c1d 100644 --- a/api/app/core/workflow/nodes/breaker/node.py +++ b/api/app/core/workflow/nodes/breaker/node.py @@ -1,9 +1,10 @@ import logging from typing import Any -from app.core.workflow.nodes import BaseNode, WorkflowState +from app.core.workflow.engine.state_manager import WorkflowState +from app.core.workflow.engine.variable_pool import VariablePool +from app.core.workflow.nodes import BaseNode from app.core.workflow.variable.base_variable import VariableType -from app.core.workflow.variable_pool import VariablePool logger = logging.getLogger(__name__) diff --git a/api/app/core/workflow/nodes/code/node.py b/api/app/core/workflow/nodes/code/node.py index f6176edf..9303302d 100644 --- a/api/app/core/workflow/nodes/code/node.py +++ b/api/app/core/workflow/nodes/code/node.py @@ -6,13 +6,14 @@ import urllib.parse from string import Template from textwrap import dedent from typing import Any -import urllib.parse + import httpx -from app.core.workflow.nodes import BaseNode, WorkflowState +from app.core.workflow.engine.state_manager import WorkflowState +from app.core.workflow.engine.variable_pool import VariablePool +from app.core.workflow.nodes import BaseNode from app.core.workflow.nodes.code.config import CodeNodeConfig from app.core.workflow.variable.base_variable import VariableType -from app.core.workflow.variable_pool import VariablePool logger = logging.getLogger(__name__) diff --git a/api/app/core/workflow/nodes/cycle_graph/iteration.py b/api/app/core/workflow/nodes/cycle_graph/iteration.py index 762da847..f1138840 100644 --- a/api/app/core/workflow/nodes/cycle_graph/iteration.py +++ b/api/app/core/workflow/nodes/cycle_graph/iteration.py @@ -5,10 +5,10 @@ from typing import Any from langgraph.graph.state import CompiledStateGraph -from app.core.workflow.nodes import WorkflowState +from app.core.workflow.engine.state_manager import WorkflowState +from app.core.workflow.engine.variable_pool import VariablePool from app.core.workflow.nodes.cycle_graph import IterationNodeConfig from app.core.workflow.variable.base_variable import VariableType -from app.core.workflow.variable_pool import VariablePool logger = logging.getLogger(__name__) diff --git a/api/app/core/workflow/nodes/cycle_graph/loop.py b/api/app/core/workflow/nodes/cycle_graph/loop.py index 7204a642..b4406f74 100644 --- a/api/app/core/workflow/nodes/cycle_graph/loop.py +++ b/api/app/core/workflow/nodes/cycle_graph/loop.py @@ -3,12 +3,12 @@ from typing import Any from langgraph.graph.state import CompiledStateGraph -from app.core.workflow.expression_evaluator import evaluate_expression -from app.core.workflow.nodes import WorkflowState +from app.core.workflow.engine.state_manager import WorkflowState +from app.core.workflow.engine.variable_pool import VariablePool from app.core.workflow.nodes.cycle_graph import LoopNodeConfig from app.core.workflow.nodes.enums import ValueInputType, ComparisonOperator, LogicOperator from app.core.workflow.nodes.operators import TypeTransformer, ConditionExpressionResolver, CompareOperatorInstance -from app.core.workflow.variable_pool import VariablePool +from app.core.workflow.utils.expression_evaluator import evaluate_expression logger = logging.getLogger(__name__) diff --git a/api/app/core/workflow/nodes/cycle_graph/node.py b/api/app/core/workflow/nodes/cycle_graph/node.py index 6908cb73..72768b77 100644 --- a/api/app/core/workflow/nodes/cycle_graph/node.py +++ b/api/app/core/workflow/nodes/cycle_graph/node.py @@ -4,14 +4,14 @@ from typing import Any from langgraph.graph import StateGraph from langgraph.graph.state import CompiledStateGraph -from app.core.workflow.nodes import WorkflowState +from app.core.workflow.engine.state_manager import WorkflowState +from app.core.workflow.engine.variable_pool import VariablePool from app.core.workflow.nodes.base_node import BaseNode from app.core.workflow.nodes.cycle_graph import LoopNodeConfig, IterationNodeConfig from app.core.workflow.nodes.cycle_graph.iteration import IterationRuntime from app.core.workflow.nodes.cycle_graph.loop import LoopRuntime from app.core.workflow.nodes.enums import NodeType from app.core.workflow.variable.base_variable import VariableType -from app.core.workflow.variable_pool import VariablePool logger = logging.getLogger(__name__) @@ -136,7 +136,7 @@ class CycleGraphNode(BaseNode): 2. Construct a StateGraph using GraphBuilder in subgraph mode 3. Compile the graph for runtime execution """ - from app.core.workflow.graph_builder import GraphBuilder + from app.core.workflow.engine.graph_builder import GraphBuilder self.cycle_nodes, self.cycle_edges = self.pure_cycle_graph() self.child_variable_pool = VariablePool() builder = GraphBuilder( diff --git a/api/app/core/workflow/nodes/end/node.py b/api/app/core/workflow/nodes/end/node.py index a13a8153..2799316a 100644 --- a/api/app/core/workflow/nodes/end/node.py +++ b/api/app/core/workflow/nodes/end/node.py @@ -6,9 +6,10 @@ End 节点实现 import logging -from app.core.workflow.nodes.base_node import BaseNode, WorkflowState +from app.core.workflow.engine.state_manager import WorkflowState +from app.core.workflow.engine.variable_pool import VariablePool +from app.core.workflow.nodes.base_node import BaseNode from app.core.workflow.variable.base_variable import VariableType -from app.core.workflow.variable_pool import VariablePool logger = logging.getLogger(__name__) diff --git a/api/app/core/workflow/nodes/http_request/node.py b/api/app/core/workflow/nodes/http_request/node.py index 64fdfcb9..cdb34b57 100644 --- a/api/app/core/workflow/nodes/http_request/node.py +++ b/api/app/core/workflow/nodes/http_request/node.py @@ -7,11 +7,12 @@ import httpx # import filetypes # TODO: File support (Feature) from httpx import AsyncClient, Response, Timeout -from app.core.workflow.nodes.base_node import BaseNode, WorkflowState +from app.core.workflow.engine.state_manager import WorkflowState +from app.core.workflow.engine.variable_pool import VariablePool +from app.core.workflow.nodes.base_node import BaseNode from app.core.workflow.nodes.enums import HttpRequestMethod, HttpErrorHandle, HttpAuthType, HttpContentType from app.core.workflow.nodes.http_request.config import HttpRequestNodeConfig, HttpRequestNodeOutput from app.core.workflow.variable.base_variable import VariableType -from app.core.workflow.variable_pool import VariablePool logger = logging.getLogger(__file__) diff --git a/api/app/core/workflow/nodes/if_else/config.py b/api/app/core/workflow/nodes/if_else/config.py index 3e5ea22a..894898f0 100644 --- a/api/app/core/workflow/nodes/if_else/config.py +++ b/api/app/core/workflow/nodes/if_else/config.py @@ -60,7 +60,7 @@ class IfElseNodeConfig(BaseNodeConfig): @field_validator("cases") @classmethod - def validate_case_number(cls, v, info): + def validate_case_number(cls, v): if len(v) < 1: raise ValueError("At least one cases are required") return v diff --git a/api/app/core/workflow/nodes/if_else/node.py b/api/app/core/workflow/nodes/if_else/node.py index 3c6d0e36..29f7085b 100644 --- a/api/app/core/workflow/nodes/if_else/node.py +++ b/api/app/core/workflow/nodes/if_else/node.py @@ -2,12 +2,13 @@ import logging import re from typing import Any -from app.core.workflow.nodes.base_node import BaseNode, WorkflowState +from app.core.workflow.engine.state_manager import WorkflowState +from app.core.workflow.engine.variable_pool import VariablePool +from app.core.workflow.nodes.base_node import BaseNode from app.core.workflow.nodes.enums import ComparisonOperator, LogicOperator from app.core.workflow.nodes.if_else import IfElseNodeConfig from app.core.workflow.nodes.operators import ConditionExpressionResolver, CompareOperatorInstance from app.core.workflow.variable.base_variable import VariableType -from app.core.workflow.variable_pool import VariablePool logger = logging.getLogger(__name__) diff --git a/api/app/core/workflow/nodes/jinja_render/node.py b/api/app/core/workflow/nodes/jinja_render/node.py index 240b003b..e13709d4 100644 --- a/api/app/core/workflow/nodes/jinja_render/node.py +++ b/api/app/core/workflow/nodes/jinja_render/node.py @@ -1,12 +1,12 @@ import logging from typing import Any -from app.core.workflow.nodes import WorkflowState +from app.core.workflow.engine.state_manager import WorkflowState +from app.core.workflow.engine.variable_pool import VariablePool from app.core.workflow.nodes.base_node import BaseNode from app.core.workflow.nodes.jinja_render.config import JinjaRenderNodeConfig -from app.core.workflow.template_renderer import TemplateRenderer +from app.core.workflow.utils.template_renderer import TemplateRenderer from app.core.workflow.variable.base_variable import VariableType -from app.core.workflow.variable_pool import VariablePool logger = logging.getLogger(__name__) diff --git a/api/app/core/workflow/nodes/knowledge/node.py b/api/app/core/workflow/nodes/knowledge/node.py index 1e146721..17f55319 100644 --- a/api/app/core/workflow/nodes/knowledge/node.py +++ b/api/app/core/workflow/nodes/knowledge/node.py @@ -6,10 +6,11 @@ from app.core.error_codes import BizCode from app.core.exceptions import BusinessException from app.core.models import RedBearRerank, RedBearModelConfig from app.core.rag.vdb.elasticsearch.elasticsearch_vector import ElasticSearchVectorFactory -from app.core.workflow.nodes.base_node import BaseNode, WorkflowState +from app.core.workflow.engine.state_manager import WorkflowState +from app.core.workflow.engine.variable_pool import VariablePool +from app.core.workflow.nodes.base_node import BaseNode from app.core.workflow.nodes.knowledge import KnowledgeRetrievalNodeConfig from app.core.workflow.variable.base_variable import VariableType -from app.core.workflow.variable_pool import VariablePool from app.db import get_db_read from app.models import knowledge_model, knowledgeshare_model, ModelType from app.repositories import knowledge_repository, knowledgeshare_repository diff --git a/api/app/core/workflow/nodes/llm/config.py b/api/app/core/workflow/nodes/llm/config.py index 1229450f..771262c1 100644 --- a/api/app/core/workflow/nodes/llm/config.py +++ b/api/app/core/workflow/nodes/llm/config.py @@ -1,6 +1,7 @@ """LLM 节点配置""" from typing import Any +import uuid from pydantic import BaseModel, Field, field_validator @@ -56,7 +57,7 @@ class LLMNodeConfig(BaseNodeConfig): 2. 消息模式:使用 messages 字段(推荐) """ - model_id: str = Field( + model_id: uuid.UUID = Field( ..., description="模型配置 ID" ) @@ -148,7 +149,7 @@ class LLMNodeConfig(BaseNodeConfig): @field_validator("messages", "prompt") @classmethod - def validate_input_mode(cls, v, info): + def validate_input_mode(cls, v): """验证输入模式:prompt 和 messages 至少有一个""" # 这个验证在 model_validator 中更合适 return v diff --git a/api/app/core/workflow/nodes/llm/node.py b/api/app/core/workflow/nodes/llm/node.py index 761a2e22..fdd5df58 100644 --- a/api/app/core/workflow/nodes/llm/node.py +++ b/api/app/core/workflow/nodes/llm/node.py @@ -13,10 +13,11 @@ from langchain_core.messages import AIMessage from app.core.error_codes import BizCode from app.core.exceptions import BusinessException from app.core.models import RedBearLLM, RedBearModelConfig -from app.core.workflow.nodes.base_node import BaseNode, WorkflowState +from app.core.workflow.engine.state_manager import WorkflowState +from app.core.workflow.engine.variable_pool import VariablePool +from app.core.workflow.nodes.base_node import BaseNode from app.core.workflow.nodes.llm.config import LLMNodeConfig from app.core.workflow.variable.base_variable import VariableType -from app.core.workflow.variable_pool import VariablePool from app.db import get_db_context from app.models import ModelType from app.services.model_service import ModelConfigService @@ -268,7 +269,7 @@ class LLMNode(BaseNode): llm = await self._prepare_llm(state, variable_pool, True) logger.info(f"节点 {self.node_id} 开始执行 LLM 调用(流式)") - logger.debug(f"LLM 配置: streaming={getattr(llm._model, 'streaming', 'unknown')}") + # logger.debug(f"LLM 配置: streaming={getattr(llm._model, 'streaming', 'unknown')}") # 累积完整响应 full_response = "" diff --git a/api/app/core/workflow/nodes/memory/node.py b/api/app/core/workflow/nodes/memory/node.py index 654ea0c6..1d42e82e 100644 --- a/api/app/core/workflow/nodes/memory/node.py +++ b/api/app/core/workflow/nodes/memory/node.py @@ -1,10 +1,10 @@ from typing import Any -from app.core.workflow.nodes import WorkflowState +from app.core.workflow.engine.state_manager import WorkflowState +from app.core.workflow.engine.variable_pool import VariablePool from app.core.workflow.nodes.base_node import BaseNode from app.core.workflow.nodes.memory.config import MemoryReadNodeConfig, MemoryWriteNodeConfig from app.core.workflow.variable.base_variable import VariableType -from app.core.workflow.variable_pool import VariablePool from app.db import get_db_read from app.services.memory_agent_service import MemoryAgentService from app.tasks import write_message_task diff --git a/api/app/core/workflow/nodes/operators.py b/api/app/core/workflow/nodes/operators.py index 251d6a79..be33d35a 100644 --- a/api/app/core/workflow/nodes/operators.py +++ b/api/app/core/workflow/nodes/operators.py @@ -3,9 +3,9 @@ import re from abc import ABC from typing import Union, Type, NoReturn, Any -from app.core.workflow.variable.base_variable import VariableType +from app.core.workflow.engine.variable_pool import VariablePool from app.core.workflow.nodes.enums import ValueInputType -from app.core.workflow.variable_pool import VariablePool +from app.core.workflow.variable.base_variable import VariableType class TypeTransformer: diff --git a/api/app/core/workflow/nodes/parameter_extractor/node.py b/api/app/core/workflow/nodes/parameter_extractor/node.py index 9dd91cad..4811c118 100644 --- a/api/app/core/workflow/nodes/parameter_extractor/node.py +++ b/api/app/core/workflow/nodes/parameter_extractor/node.py @@ -1,19 +1,18 @@ -import os import logging - -import json_repair +import os from typing import Any +import json_repair from jinja2 import Template from app.core.error_codes import BizCode from app.core.exceptions import BusinessException from app.core.models import RedBearLLM, RedBearModelConfig -from app.core.workflow.nodes import WorkflowState +from app.core.workflow.engine.state_manager import WorkflowState +from app.core.workflow.engine.variable_pool import VariablePool from app.core.workflow.nodes.base_node import BaseNode from app.core.workflow.nodes.parameter_extractor.config import ParameterExtractorNodeConfig from app.core.workflow.variable.base_variable import VariableType -from app.core.workflow.variable_pool import VariablePool from app.db import get_db_read from app.models import ModelType from app.services.model_service import ModelConfigService diff --git a/api/app/core/workflow/nodes/question_classifier/node.py b/api/app/core/workflow/nodes/question_classifier/node.py index 5b041a6a..e2fd97ae 100644 --- a/api/app/core/workflow/nodes/question_classifier/node.py +++ b/api/app/core/workflow/nodes/question_classifier/node.py @@ -1,13 +1,14 @@ import logging from typing import Any -from app.core.workflow.nodes.base_node import BaseNode, WorkflowState -from app.core.workflow.nodes.question_classifier.config import QuestionClassifierNodeConfig -from app.core.models import RedBearLLM, RedBearModelConfig -from app.core.exceptions import BusinessException from app.core.error_codes import BizCode +from app.core.exceptions import BusinessException +from app.core.models import RedBearLLM, RedBearModelConfig +from app.core.workflow.engine.state_manager import WorkflowState +from app.core.workflow.engine.variable_pool import VariablePool +from app.core.workflow.nodes.base_node import BaseNode +from app.core.workflow.nodes.question_classifier.config import QuestionClassifierNodeConfig from app.core.workflow.variable.base_variable import VariableType -from app.core.workflow.variable_pool import VariablePool from app.db import get_db_read from app.models import ModelType from app.services.model_service import ModelConfigService diff --git a/api/app/core/workflow/nodes/start/node.py b/api/app/core/workflow/nodes/start/node.py index db66bc65..a9618f7b 100644 --- a/api/app/core/workflow/nodes/start/node.py +++ b/api/app/core/workflow/nodes/start/node.py @@ -7,10 +7,11 @@ Start 节点实现 import logging from typing import Any -from app.core.workflow.variable.base_variable import VariableType, DEFAULT_VALUE -from app.core.workflow.nodes.base_node import BaseNode, WorkflowState +from app.core.workflow.engine.state_manager import WorkflowState +from app.core.workflow.engine.variable_pool import VariablePool +from app.core.workflow.nodes.base_node import BaseNode from app.core.workflow.nodes.start.config import StartNodeConfig -from app.core.workflow.variable_pool import VariablePool +from app.core.workflow.variable.base_variable import VariableType, DEFAULT_VALUE logger = logging.getLogger(__name__) diff --git a/api/app/core/workflow/nodes/tool/node.py b/api/app/core/workflow/nodes/tool/node.py index adc55d87..096f498f 100644 --- a/api/app/core/workflow/nodes/tool/node.py +++ b/api/app/core/workflow/nodes/tool/node.py @@ -4,16 +4,17 @@ import re import uuid from typing import Any -from app.core.workflow.nodes.base_node import BaseNode, WorkflowState +from app.core.workflow.engine.state_manager import WorkflowState +from app.core.workflow.engine.variable_pool import VariablePool +from app.core.workflow.nodes.base_node import BaseNode from app.core.workflow.nodes.tool.config import ToolNodeConfig from app.core.workflow.variable.base_variable import VariableType -from app.core.workflow.variable_pool import VariablePool -from app.services.tool_service import ToolService from app.db import get_db_read +from app.services.tool_service import ToolService logger = logging.getLogger(__name__) -TEMPLATE_PATTERN = re.compile(r"\{\{.*?\}\}") +TEMPLATE_PATTERN = re.compile(r"\{\{.*?}}") class ToolNode(BaseNode): diff --git a/api/app/core/workflow/nodes/variable_aggregator/node.py b/api/app/core/workflow/nodes/variable_aggregator/node.py index 56ab4cfb..de82f8ff 100644 --- a/api/app/core/workflow/nodes/variable_aggregator/node.py +++ b/api/app/core/workflow/nodes/variable_aggregator/node.py @@ -2,11 +2,11 @@ import logging import re from typing import Any -from app.core.workflow.nodes import WorkflowState +from app.core.workflow.engine.state_manager import WorkflowState +from app.core.workflow.engine.variable_pool import VariablePool from app.core.workflow.nodes.base_node import BaseNode from app.core.workflow.nodes.variable_aggregator.config import VariableAggregatorNodeConfig from app.core.workflow.variable.base_variable import VariableType, DEFAULT_VALUE -from app.core.workflow.variable_pool import VariablePool logger = logging.getLogger(__name__) diff --git a/api/app/core/workflow/utils/__init__.py b/api/app/core/workflow/utils/__init__.py new file mode 100644 index 00000000..559af4a5 --- /dev/null +++ b/api/app/core/workflow/utils/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: UTF-8 -*- +# Author: Eternity +# @Email: 1533512157@qq.com +# @Time : 2026/2/9 16:24 diff --git a/api/app/core/workflow/expression_evaluator.py b/api/app/core/workflow/utils/expression_evaluator.py similarity index 100% rename from api/app/core/workflow/expression_evaluator.py rename to api/app/core/workflow/utils/expression_evaluator.py diff --git a/api/app/core/workflow/template_renderer.py b/api/app/core/workflow/utils/template_renderer.py similarity index 99% rename from api/app/core/workflow/template_renderer.py rename to api/app/core/workflow/utils/template_renderer.py index 9e2a28e8..236e0840 100644 --- a/api/app/core/workflow/template_renderer.py +++ b/api/app/core/workflow/utils/template_renderer.py @@ -5,7 +5,6 @@ """ import logging -from collections import defaultdict from typing import Any from jinja2 import TemplateSyntaxError, UndefinedError, Environment, StrictUndefined, Undefined diff --git a/api/app/core/workflow/validator.py b/api/app/core/workflow/validator.py index c846a1c4..47256b75 100644 --- a/api/app/core/workflow/validator.py +++ b/api/app/core/workflow/validator.py @@ -187,7 +187,7 @@ class WorkflowValidator: ) # 8. 验证变量名 - from app.core.workflow.expression_evaluator import ExpressionEvaluator + from app.core.workflow.utils.expression_evaluator import ExpressionEvaluator var_errors = ExpressionEvaluator.validate_variable_names(variables) errors.extend(var_errors) diff --git a/api/tests/workflow/executor/test_vairable_pool.py b/api/tests/workflow/executor/test_vairable_pool.py index 6fb91bec..3404eb79 100644 --- a/api/tests/workflow/executor/test_vairable_pool.py +++ b/api/tests/workflow/executor/test_vairable_pool.py @@ -4,8 +4,8 @@ # @Time : 2026/2/6 import pytest +from app.core.workflow.engine.variable_pool import VariablePool, VariableSelector from app.core.workflow.variable.base_variable import VariableType -from app.core.workflow.variable_pool import VariablePool, VariableSelector # ==================== VariableSelector 测试 ==================== diff --git a/api/tests/workflow/nodes/base.py b/api/tests/workflow/nodes/base.py index 4dfc05ae..164ac6f7 100644 --- a/api/tests/workflow/nodes/base.py +++ b/api/tests/workflow/nodes/base.py @@ -6,8 +6,8 @@ import os import pytest -from app.core.workflow.variable.base_variable import VariableType, DEFAULT_VALUE -from app.core.workflow.variable_pool import VariablePool +from app.core.workflow.engine.variable_pool import VariablePool +from app.core.workflow.variable.base_variable import VariableType TEST_WORKSPACE_ID = "test_workspace_id" TEST_USER_ID = "test_user_id" diff --git a/api/tests/workflow/nodes/test_start_node.py b/api/tests/workflow/nodes/test_start_node.py index fb6a3140..77649bba 100644 --- a/api/tests/workflow/nodes/test_start_node.py +++ b/api/tests/workflow/nodes/test_start_node.py @@ -4,11 +4,11 @@ # @Time : 2026/2/6 import pytest +from app.core.workflow.engine.variable_pool import VariablePool from app.core.workflow.nodes import StartNode from app.core.workflow.variable.base_variable import VariableType -from app.core.workflow.variable_pool import VariablePool from tests.workflow.nodes.base import ( - simple_state, + simple_state, simple_vairable_pool, TEST_EXECUTION_ID, TEST_WORKSPACE_ID, From b3a136ac0377ed449db1beda1a90b08bec347eea Mon Sep 17 00:00:00 2001 From: zhaoying Date: Tue, 10 Feb 2026 15:53:37 +0800 Subject: [PATCH 06/36] fix(web): jump page add clearAuthData --- web/src/views/JumpPage.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/web/src/views/JumpPage.tsx b/web/src/views/JumpPage.tsx index aa978121..c176e9e0 100644 --- a/web/src/views/JumpPage.tsx +++ b/web/src/views/JumpPage.tsx @@ -2,13 +2,14 @@ * @Author: ZhaoYing * @Date: 2026-02-04 18:34:36 * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-02-09 15:46:07 + * @Last Modified time: 2026-02-10 15:49:29 */ import { useEffect, type FC } from 'react' import { useNavigate, useSearchParams } from 'react-router-dom' import { cookieUtils } from '@/utils/request' import { useI18n } from '@/store/locale' +import { clearAuthData } from '@/utils/auth' /** * JumpPage Component @@ -30,6 +31,7 @@ const JumpPage: FC = () => { const { changeLanguage } = useI18n() useEffect(() => { + clearAuthData() // Convert URLSearchParams to a plain object for easier access const data = Object.fromEntries(searchParams) const { access_token, refresh_token, target, language } = data From dc2ea5c0072f16aee3b47efb4ccc5894d51d2721 Mon Sep 17 00:00:00 2001 From: Eternity <1533512157@qq.com> Date: Tue, 10 Feb 2026 15:47:34 +0800 Subject: [PATCH 07/36] feat(workflow): add system-level workflow variable for dialogue turns and fix bug --- api/app/core/workflow/engine/graph_builder.py | 4 ++-- api/app/core/workflow/engine/result_builder.py | 2 +- api/app/core/workflow/engine/stream_output_coordinator.py | 5 ++--- api/app/core/workflow/engine/variable_pool.py | 3 +++ api/app/core/workflow/executor.py | 4 ++-- 5 files changed, 10 insertions(+), 8 deletions(-) diff --git a/api/app/core/workflow/engine/graph_builder.py b/api/app/core/workflow/engine/graph_builder.py index 0d0035ab..7b5c059c 100644 --- a/api/app/core/workflow/engine/graph_builder.py +++ b/api/app/core/workflow/engine/graph_builder.py @@ -7,7 +7,7 @@ import re import uuid from collections import defaultdict from functools import lru_cache -from typing import Any +from typing import Any, Iterable from langgraph.checkpoint.memory import InMemorySaver from langgraph.graph import START, END @@ -81,7 +81,7 @@ class GraphBuilder: raise RuntimeError(f"Node not found: Id={node_id}") @staticmethod - def _merge_control_nodes(control_nodes: tuple[tuple[str, str]]) -> dict[str, list]: + def _merge_control_nodes(control_nodes: Iterable[tuple[str, str]]) -> dict[str, list]: result = defaultdict(list) for node in control_nodes: result[node[0]].append(node[1]) diff --git a/api/app/core/workflow/engine/result_builder.py b/api/app/core/workflow/engine/result_builder.py index dbaf8fa6..31bccf57 100644 --- a/api/app/core/workflow/engine/result_builder.py +++ b/api/app/core/workflow/engine/result_builder.py @@ -5,7 +5,7 @@ from app.core.workflow.engine.variable_pool import VariablePool -class WorkflowResultBuiler: +class WorkflowResultBuilder: def build_final_output( self, result: dict, diff --git a/api/app/core/workflow/engine/stream_output_coordinator.py b/api/app/core/workflow/engine/stream_output_coordinator.py index 778c6acf..5155a76f 100644 --- a/api/app/core/workflow/engine/stream_output_coordinator.py +++ b/api/app/core/workflow/engine/stream_output_coordinator.py @@ -263,7 +263,6 @@ class StreamOutputCoordinator: else: # Variable segment: evaluate and transform try: - # Simulate evaluation (replace with actual logic) chunk = variable_pool.get_literal(current_segment.literal) final_chunk += chunk except Exception as e: @@ -271,7 +270,7 @@ class StreamOutputCoordinator: logger.warning(f"[STREAM] Failed to evaluate segment: {current_segment.literal}, error: {e}") if final_chunk: - logger.warning(f"[STREAM] StreamOutput Node:{self.activate_end}, chunk:{final_chunk}") + logger.info(f"[STREAM] StreamOutput Node:{self.activate_end}, chunk:{final_chunk}") yield { "event": "message", "data": { @@ -317,7 +316,7 @@ class StreamOutputCoordinator: if node_info.activate } - if self.activate_end or self.activate_end: + if self.end_outputs or self.activate_end: while self.activate_end: # Force emit all remaining chunks of the active End node async for msg_event in self.emit_activate_chunk(variable_pool, force=True): diff --git a/api/app/core/workflow/engine/variable_pool.py b/api/app/core/workflow/engine/variable_pool.py index 55966ed6..22be08c8 100644 --- a/api/app/core/workflow/engine/variable_pool.py +++ b/api/app/core/workflow/engine/variable_pool.py @@ -401,10 +401,13 @@ class VariablePoolInitializer: ): user_message = input_data.get("message") or "" user_files = input_data.get("files") or [] + conversations = input_data.get("conv_messages", []) + conversation_index = len(conversations) // 2 input_variables = input_data.get("variables") or {} sys_vars = { "message": (user_message, VariableType.STRING), + "conversation_index": (conversation_index, VariableType.NUMBER), "conversation_id": (input_data.get("conversation_id"), VariableType.STRING), "execution_id": (context.execution_id, VariableType.STRING), "workspace_id": (context.workspace_id, VariableType.STRING), diff --git a/api/app/core/workflow/executor.py b/api/app/core/workflow/executor.py index 2ec7992b..ff48fb07 100644 --- a/api/app/core/workflow/executor.py +++ b/api/app/core/workflow/executor.py @@ -10,7 +10,7 @@ from langgraph.graph.state import CompiledStateGraph from app.core.workflow.engine.event_stream_handler import EventStreamHandler from app.core.workflow.engine.graph_builder import GraphBuilder -from app.core.workflow.engine.result_builder import WorkflowResultBuiler +from app.core.workflow.engine.result_builder import WorkflowResultBuilder from app.core.workflow.engine.runtime_schema import ExecutionContext from app.core.workflow.engine.state_manager import WorkflowStateManager from app.core.workflow.engine.stream_output_coordinator import StreamOutputCoordinator @@ -60,7 +60,7 @@ class WorkflowExecutor: self.variable_initializer = VariablePoolInitializer(workflow_config) self.state_manager = WorkflowStateManager() - self.result_builder = WorkflowResultBuiler() + self.result_builder = WorkflowResultBuilder() self.stream_coordinator = StreamOutputCoordinator() self.event_handler: EventStreamHandler | None = None From 3a09b26b6da12ed7e9bb7d19434ecb2e0bf13e17 Mon Sep 17 00:00:00 2001 From: Eternity <1533512157@qq.com> Date: Tue, 10 Feb 2026 17:46:38 +0800 Subject: [PATCH 08/36] fix(sandbox): fix potential preload injection issue --- sandbox/app/core/runners/python/prescript.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sandbox/app/core/runners/python/prescript.py b/sandbox/app/core/runners/python/prescript.py index b694fe9b..dad78f9e 100644 --- a/sandbox/app/core/runners/python/prescript.py +++ b/sandbox/app/core/runners/python/prescript.py @@ -33,8 +33,6 @@ key = b64decode(key) os.chdir(running_path) -# Preload code -{{preload}} # Apply security if library is available init_status = lib.init_seccomp({{uid}}, {{gid}}, {{enable_network}}) @@ -42,6 +40,8 @@ if init_status != 0: raise Exception(f"code executor err - {str(init_status)}") del lib +# Preload code +{{preload}} # Decrypt and execute code code = b64decode("{{code}}") From b272a52b578e75fbff158f2a8d1adc1bf0a11e93 Mon Sep 17 00:00:00 2001 From: Ke Sun <33739460+keeees@users.noreply.github.com> Date: Wed, 11 Feb 2026 18:19:32 +0800 Subject: [PATCH 09/36] Release/v0.2.4 (#397) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fix/bug en zh (#389) * [fix]The log retains genuine alerts and errors, while filtering out unnecessary noise. * [fix]Scenario English and Chinese, emotion specifications * [fix]Change the "no data" scenario from 0.0 to None * [fix]The emotional health indicators, emotional advice, and emotional distribution analysis are all linked together. * [fix]The emotional health indicators, emotional advice, and emotional distribution analysis are all linked together. * [fix]Separate expected errors from unexpected errors * [changes]Translation of emotion labels, and the list of hosts arranged in the order of creation * [changes]Translation of emotion labels, and the list of hosts arranged in the order of creation * feat(web): improve knowledge base form validation and parser config handling - Refactor form validation logic to support tab-specific field validation in edit mode - Add conditional validation for knowledge graph fields when editing existing knowledge base - Preserve all existing parser_config fields when merging graphrag configuration - Skip third-party authentication check when editing on knowledge graph tab - Update form value retrieval to include disabled fields using getFieldsValue(true) - Improve comments to clarify parser_config field preservation and validation behavior - This change enables users to edit knowledge graph settings without re-validating all basic configuration fields * fix(web): improve infinite scroll handling in knowledge base list - Add auto-load detection when initial data doesn't fill viewport to prevent empty scrollbar - Implement scroll height check to automatically load more data if content is insufficient - Fix hasMore condition to prevent premature loader hiding - Update loader visibility to only show when data exists and is actively loading - Refine end message display to show only when all data is loaded and no more items available - Resolves issue where knowledge base list shows no scrollbar on initial load with limited items * fix(web): FileUpload bugfix * fix(web): change skill search key * Fix/bug en zh (#391) * [fix]The log retains genuine alerts and errors, while filtering out unnecessary noise. * [fix]Scenario English and Chinese, emotion specifications * [fix]Change the "no data" scenario from 0.0 to None * [fix]The emotional health indicators, emotional advice, and emotional distribution analysis are all linked together. * [fix]The emotional health indicators, emotional advice, and emotional distribution analysis are all linked together. * [fix]Separate expected errors from unexpected errors * [changes]Translation of emotion labels, and the list of hosts arranged in the order of creation * [changes]Translation of emotion labels, and the list of hosts arranged in the order of creation * [fix]The mainframe engineering supports Chinese verification. * [fix]The mainframe engineering supports Chinese verification. * fix(web): update en * fix(web): file upload bugfix * fix(web): memory-write node hide message config --------- Co-authored-by: 乐力齐 <162269739+lanceyq@users.noreply.github.com> Co-authored-by: yujiangping Co-authored-by: zhaoying Co-authored-by: yingzhao --- .../memory/models/ontology_scenario_models.py | 13 +++-- .../utils/validation/ontology_validator.py | 12 +++-- api/app/services/emotion_analytics_service.py | 6 +-- api/app/services/memory_dashboard_service.py | 6 +-- web/src/i18n/en.ts | 1 + web/src/views/ApplicationConfig/Agent.tsx | 10 ++-- .../ApplicationConfig/components/Chat.tsx | 48 +++++++++---------- .../components/Skill/SkillListModal.tsx | 12 ++--- .../Conversation/components/FileUpload.tsx | 17 ++----- web/src/views/Conversation/index.tsx | 6 +-- .../KnowledgeBase/components/CreateModal.tsx | 34 ++++++++++--- web/src/views/KnowledgeBase/index.tsx | 18 +++++-- .../views/Workflow/components/Chat/Chat.tsx | 6 +-- .../Workflow/components/Properties/index.tsx | 3 +- web/src/views/Workflow/constant.ts | 5 +- 15 files changed, 109 insertions(+), 88 deletions(-) diff --git a/api/app/core/memory/models/ontology_scenario_models.py b/api/app/core/memory/models/ontology_scenario_models.py index 24a61f5f..b51d8bb2 100644 --- a/api/app/core/memory/models/ontology_scenario_models.py +++ b/api/app/core/memory/models/ontology_scenario_models.py @@ -74,7 +74,7 @@ class OntologyClass(BaseModel): """Validate that the class name follows PascalCase convention. PascalCase rules: - - Must start with an uppercase letter + - Must start with an uppercase letter (for English) or any character (for Chinese/Unicode) - Cannot contain spaces - Should not contain special characters except underscores @@ -90,7 +90,10 @@ class OntologyClass(BaseModel): if not v: raise ValueError("Class name cannot be empty") - if not v[0].isupper(): + # For Chinese/Unicode characters, skip the uppercase check + # Only check uppercase for ASCII letters + first_char = v[0] + if first_char.isascii() and first_char.isalpha() and not first_char.isupper(): raise ValueError( f"Class name '{v}' must start with an uppercase letter (PascalCase)" ) @@ -100,11 +103,11 @@ class OntologyClass(BaseModel): f"Class name '{v}' cannot contain spaces (PascalCase)" ) - # Check for invalid characters (allow alphanumeric and underscore only) - if not all(c.isalnum() or c == '_' for c in v): + # Check for invalid characters (allow alphanumeric, underscore, and Unicode characters) + if not all(c.isalnum() or c == '_' or ord(c) > 127 for c in v): raise ValueError( f"Class name '{v}' contains invalid characters. " - "Only alphanumeric characters and underscores are allowed" + "Only alphanumeric characters, underscores, and Unicode characters are allowed" ) return v diff --git a/api/app/core/memory/utils/validation/ontology_validator.py b/api/app/core/memory/utils/validation/ontology_validator.py index 1e1ee506..cb3bcec8 100644 --- a/api/app/core/memory/utils/validation/ontology_validator.py +++ b/api/app/core/memory/utils/validation/ontology_validator.py @@ -88,8 +88,10 @@ class OntologyValidator: logger.warning(f"Validation failed: {error_msg}") return False, error_msg - # Check if starts with uppercase letter - if not name[0].isupper(): + # Check if starts with uppercase letter (only for ASCII letters) + # For Chinese/Unicode characters, skip this check + first_char = name[0] + if first_char.isascii() and first_char.isalpha() and not first_char.isupper(): error_msg = f"Class name '{name}' must start with an uppercase letter (PascalCase)" logger.warning(f"Validation failed: {error_msg}") return False, error_msg @@ -100,9 +102,9 @@ class OntologyValidator: logger.warning(f"Validation failed: {error_msg}") return False, error_msg - # Check for invalid characters (only alphanumeric and underscore allowed) - if not re.match(r'^[A-Za-z0-9_]+$', name): - error_msg = f"Class name '{name}' contains invalid characters. Only alphanumeric characters and underscores are allowed" + # Check for invalid characters (allow alphanumeric, underscore, and Unicode characters) + if not re.match(r'^[A-Za-z0-9_\u4e00-\u9fff]+$', name): + error_msg = f"Class name '{name}' contains invalid characters. Only alphanumeric characters, underscores, and Chinese characters are allowed" logger.warning(f"Validation failed: {error_msg}") return False, error_msg diff --git a/api/app/services/emotion_analytics_service.py b/api/app/services/emotion_analytics_service.py index d78dc20c..89e3cab9 100644 --- a/api/app/services/emotion_analytics_service.py +++ b/api/app/services/emotion_analytics_service.py @@ -124,17 +124,17 @@ class EmotionAnalyticsService: # 将查询结果转换为字典,方便查找 tags_dict = {tag["emotion_type"]: tag for tag in tags} - # 补全缺失的情绪维度,并翻译 emotion_type + # 补全缺失的情绪维度,直接使用英文枚举key(前端自行翻译) complete_tags = [] for emotion in all_emotion_types: if emotion in tags_dict: tag = tags_dict[emotion].copy() - tag["emotion_type"] = self._translate_emotion_type(emotion, language) + tag["emotion_type"] = emotion complete_tags.append(tag) else: # 如果该情绪类型不存在,添加默认值 complete_tags.append({ - "emotion_type": self._translate_emotion_type(emotion, language), + "emotion_type": emotion, "count": 0, "percentage": 0.0, "avg_intensity": 0.0 diff --git a/api/app/services/memory_dashboard_service.py b/api/app/services/memory_dashboard_service.py index 6fa8b228..8d6071cc 100644 --- a/api/app/services/memory_dashboard_service.py +++ b/api/app/services/memory_dashboard_service.py @@ -55,7 +55,7 @@ def get_workspace_end_users( ) -> List[EndUser]: """获取工作空间的所有宿主(优化版本:减少数据库查询次数) - 返回结果按 updated_at 从新到旧排序(NULL 值排在最后) + 返回结果按 created_at 从新到旧排序(NULL 值排在最后) """ business_logger.info(f"获取工作空间宿主列表: workspace_id={workspace_id}, 操作者: {current_user.username}") @@ -71,13 +71,13 @@ def get_workspace_end_users( app_ids = [app.id for app in apps_orm] # 批量查询所有 end_users(一次查询而非循环查询) - # 按 updated_at 降序排序,NULL 值排在最后;id 作为次级排序键保证确定性 + # 按 created_at 降序排序,NULL 值排在最后;id 作为次级排序键保证确定性 from app.models.end_user_model import EndUser as EndUserModel from sqlalchemy import desc, nullslast end_users_orm = db.query(EndUserModel).filter( EndUserModel.app_id.in_(app_ids) ).order_by( - nullslast(desc(EndUserModel.updated_at)), + nullslast(desc(EndUserModel.created_at)), desc(EndUserModel.id) ).all() diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index 9892b728..7f013752 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -1289,6 +1289,7 @@ export const en = { priority: 'Structured Integration', addTool: 'Add Tool', tool: 'Tool', + variableConfig: 'Variable Configuration', statistics: 'Data Statistics', daily_conversations: 'Daily Conversations', diff --git a/web/src/views/ApplicationConfig/Agent.tsx b/web/src/views/ApplicationConfig/Agent.tsx index a0587798..4c3b73ba 100644 --- a/web/src/views/ApplicationConfig/Agent.tsx +++ b/web/src/views/ApplicationConfig/Agent.tsx @@ -2,7 +2,7 @@ * @Author: ZhaoYing * @Date: 2026-02-03 16:29:21 * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-02-09 16:56:27 + * @Last Modified time: 2026-02-10 18:46:40 */ import { type FC, type ReactNode, useEffect, useRef, useState, forwardRef, useImperativeHandle } from 'react'; import clsx from 'clsx' @@ -480,9 +480,11 @@ const Agent = forwardRef((_props, ref) => { {t('application.debuggingAndPreview')} - + {chatVariables.length > 0 && + + } diff --git a/web/src/views/ApplicationConfig/components/Chat.tsx b/web/src/views/ApplicationConfig/components/Chat.tsx index effb34c3..794489c6 100644 --- a/web/src/views/ApplicationConfig/components/Chat.tsx +++ b/web/src/views/ApplicationConfig/components/Chat.tsx @@ -2,7 +2,7 @@ * @Author: ZhaoYing * @Date: 2026-02-03 16:27:39 * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-02-10 12:18:23 + * @Last Modified time: 2026-02-10 17:40:15 */ /** * Chat debugging component for application testing @@ -366,10 +366,8 @@ const Chat: FC = ({ chatList, data, updateChatList, handleSave, sourc const handleMessageChange = (message: string) => { setMessage(message) } - const [update, setUpdate] = useState(false) const fileChange = (file?: any) => { setFileList([...fileList, file]) - setUpdate(prev => !prev) } // const handleRecordingComplete = async (file: any) => { // console.log('file', file) @@ -456,29 +454,27 @@ const Chat: FC = ({ chatList, data, updateChatList, handleSave, sourc onChange={handleMessageChange} > - - - ) - }, - ], - onClick: handleShowUpload - }} - > -
-
+ + + ) + }, + ], + onClick: handleShowUpload + }} + > +
+
{/* diff --git a/web/src/views/ApplicationConfig/components/Skill/SkillListModal.tsx b/web/src/views/ApplicationConfig/components/Skill/SkillListModal.tsx index 8220878e..0a56b82f 100644 --- a/web/src/views/ApplicationConfig/components/Skill/SkillListModal.tsx +++ b/web/src/views/ApplicationConfig/components/Skill/SkillListModal.tsx @@ -1,8 +1,8 @@ /* * @Author: ZhaoYing * @Date: 2026-02-05 10:45:08 - * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-02-05 10:45:08 + * @Last Modified by: ZhaoYing + * @Last Modified time: 2026-02-10 17:59:37 */ import { forwardRef, useEffect, useImperativeHandle, useState } from 'react'; import { Space, List, Flex, Tooltip } from 'antd'; @@ -31,7 +31,7 @@ interface SkillModalProps { * * A modal dialog for selecting skills from a searchable list. * Features: - * - Search functionality to filter skills by keywords + * - Search functionality to filter skills by search * - Grid layout displaying skill cards with icons and descriptions * - Multi-select capability with visual feedback * - Excludes already selected skills from the list @@ -49,7 +49,7 @@ const SkillListModal = forwardRef(({ const [visible, setVisible] = useState(false); const [list, setList] = useState([]) const [filterList, setFilterList] = useState([]) - const [query, setQuery] = useState<{keywords?: string}>({}) + const [query, setQuery] = useState<{search?: string}>({}) const [selectedIds, setSelectedIds] = useState([]) const [selectedRows, setSelectedRows] = useState([]) @@ -82,7 +82,7 @@ const SkillListModal = forwardRef(({ if (visible) { getList() } - }, [query.keywords, visible]) + }, [query.search, visible]) /** * Fetches the skill list from API with current search parameters @@ -123,7 +123,7 @@ const SkillListModal = forwardRef(({ * @param value - Search keyword */ const handleSearch = (value?: string) => { - setQuery({keywords: value}) + setQuery({search: value}) setSelectedIds([]) setSelectedRows([]) } diff --git a/web/src/views/Conversation/components/FileUpload.tsx b/web/src/views/Conversation/components/FileUpload.tsx index b7d65f80..70ee9cf2 100644 --- a/web/src/views/Conversation/components/FileUpload.tsx +++ b/web/src/views/Conversation/components/FileUpload.tsx @@ -2,7 +2,7 @@ * @Author: ZhaoYing * @Date: 2026-02-06 21:09:42 * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-02-09 16:41:31 + * @Last Modified time: 2026-02-11 11:32:48 */ /** * File Upload Component @@ -55,8 +55,6 @@ interface UploadFilesProps extends Omit { maxCount?: number; /** Custom file removal callback */ onRemove?: (file: UploadFile) => boolean | void | Promise; - /** Trigger to reset file list */ - update?: boolean; } // Mapping of file extensions to MIME types const ALL_FILE_TYPE: { @@ -109,7 +107,6 @@ const UploadFiles = forwardRef(({ isAutoUpload = true, maxCount = 1, onRemove: customOnRemove, - update, requestConfig, ...props }, ref) => { @@ -118,11 +115,6 @@ const UploadFiles = forwardRef(({ const [fileList, setFileList] = useState(propFileList); const [accept, setAccept] = useState(); - // Reset file list when update prop changes - useEffect(() => { - setFileList([]) - }, [update]) - /** * Validates file type and size before upload * @returns Upload.LIST_IGNORE to prevent upload, or true to proceed @@ -175,7 +167,7 @@ const UploadFiles = forwardRef(({ formData.append('file', file); const response = await request.uploadFile(action, formData, requestConfig); - + onSuccess?.({data: response}); } catch (error) { onError?.(error as Error); @@ -185,11 +177,10 @@ const UploadFiles = forwardRef(({ /** * Handles upload state changes */ - const handleChange: UploadProps['onChange'] = ({ fileList: newFileList, event }) => { - console.log('event', event) + const handleChange: UploadProps['onChange'] = ({ fileList: newFileList }) => { setFileList(newFileList); if (onChange) { - onChange(maxCount === 1 ? newFileList[0] : newFileList); + onChange(maxCount === 1 ? newFileList[newFileList.length - 1] : newFileList); } }; diff --git a/web/src/views/Conversation/index.tsx b/web/src/views/Conversation/index.tsx index fcc32bf8..825ea834 100644 --- a/web/src/views/Conversation/index.tsx +++ b/web/src/views/Conversation/index.tsx @@ -2,7 +2,7 @@ * @Author: ZhaoYing * @Date: 2026-02-03 16:58:03 * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-02-09 20:20:01 + * @Last Modified time: 2026-02-10 17:41:05 */ /** * Conversation Page @@ -254,10 +254,8 @@ const Conversation: FC = () => { }) } - const [update, setUpdate] = useState(false) const fileChange = (file?: any) => { form.setFieldValue('files', [...(queryValues.files || []), file]) - setUpdate(prev => !prev) } // const handleRecordingComplete = async (file: any) => { // console.log('file', file) @@ -353,8 +351,6 @@ const Conversation: FC = () => { action={shareFileUploadUrlWithoutApiPrefix} fileType={['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg']} onChange={fileChange} - fileList={[]} - update={update} requestConfig={{ headers: { 'Content-Type': 'multipart/form-data', diff --git a/web/src/views/KnowledgeBase/components/CreateModal.tsx b/web/src/views/KnowledgeBase/components/CreateModal.tsx index 71fe1b94..76640058 100644 --- a/web/src/views/KnowledgeBase/components/CreateModal.tsx +++ b/web/src/views/KnowledgeBase/components/CreateModal.tsx @@ -221,9 +221,9 @@ const CreateModal = forwardRef(({ status: record.status, }; - // Process parser_config data, set default values if not present + // Process parser_config data, preserve all existing fields and merge graphrag config baseValues.parser_config = { - ...record.parser_config, + ...record.parser_config, // Preserve all existing parser_config fields (yuque_user_id, yuque_token, feishu_app_id, etc.) graphrag: { use_graphrag: false, scene_name: '', @@ -362,12 +362,32 @@ const CreateModal = forwardRef(({ // Actual save logic const performSave = async () => { try { - await form.validateFields(); - setLoading(true); - const formValues = form.getFieldsValue(); + // Get fields to validate based on current tab and edit mode + let fieldsToValidate: any[] | undefined = undefined; - // Check Third-party authentication before saving - if (formValues.type === 'Third-party' || currentType === 'Third-party') { + // If in edit mode and on knowledge graph tab, only validate knowledge graph fields + if (datasets?.id && activeTab === 'knowledgeGraph') { + fieldsToValidate = [ + ['parser_config', 'graphrag', 'use_graphrag'], + ['parser_config', 'graphrag', 'scene_name'], + ['parser_config', 'graphrag', 'entity_types'], + ['parser_config', 'graphrag', 'method'], + ['parser_config', 'graphrag', 'resolution'], + ['parser_config', 'graphrag', 'community'], + ]; + } + + // Validate only specified fields or all fields + await form.validateFields(fieldsToValidate); + setLoading(true); + // Get all field values including disabled fields + const formValues = form.getFieldsValue(true); + + // Only check Third-party authentication when creating new or explicitly on basic config tab + // Skip authentication check when editing and on knowledge graph tab + const shouldCheckAuth = !datasets?.id || activeTab === 'basic'; + + if (shouldCheckAuth && (formValues.type === 'Third-party' || currentType === 'Third-party')) { const platform = formValues.parser_config?._third_party_platform || thirdPartyPlatform; try { diff --git a/web/src/views/KnowledgeBase/index.tsx b/web/src/views/KnowledgeBase/index.tsx index cef520ab..1ad6997b 100644 --- a/web/src/views/KnowledgeBase/index.tsx +++ b/web/src/views/KnowledgeBase/index.tsx @@ -334,6 +334,18 @@ const KnowledgeBaseManagement: FC = () => { setHasMore(hasNext); buildModelMenus(list, isLoadMore); + + // 首次加载后,检查是否需要自动加载更多(解决无滚动条问题) + if (!isLoadMore && hasNext) { + setTimeout(() => { + const scrollDiv = document.getElementById('scrollableDiv'); + if (scrollDiv && scrollDiv.scrollHeight <= scrollDiv.clientHeight) { + console.log('No scrollbar detected, auto-loading more data'); + setPage(2); + fetchData(2, true); + } + }, 100); + } } catch (error) { console.error('Failed to fetch knowledge base list:', error); if (!isLoadMore) { @@ -532,10 +544,10 @@ const KnowledgeBaseManagement: FC = () => { {t('common.loading')}
} + hasMore={hasMore} + loader={loading && data.length > 0 ?
{t('common.loading')}
: null} endMessage={ - data.length > 0 ? ( + data.length > 0 && !hasMore ? (
{t('common.noMoreData')}
diff --git a/web/src/views/Workflow/components/Chat/Chat.tsx b/web/src/views/Workflow/components/Chat/Chat.tsx index 95f43a9c..00a0c7fb 100644 --- a/web/src/views/Workflow/components/Chat/Chat.tsx +++ b/web/src/views/Workflow/components/Chat/Chat.tsx @@ -2,7 +2,7 @@ * @Author: ZhaoYing * @Date: 2026-02-06 21:10:56 * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-02-10 12:17:41 + * @Last Modified time: 2026-02-10 17:41:24 */ /** * Workflow Chat Component @@ -343,13 +343,11 @@ const Chat = forwardRef(({ appId const handleMessageChange = (message: string) => { setMessage(message) } - const [update, setUpdate] = useState(false) /** * Handles file upload from local device */ const fileChange = (file?: any) => { setFileList([...fileList, file]) - setUpdate(prev => !prev) } // const handleRecordingComplete = async (file: any) => { // console.log('file', file) @@ -517,8 +515,6 @@ const Chat = forwardRef(({ appId ) }, diff --git a/web/src/views/Workflow/components/Properties/index.tsx b/web/src/views/Workflow/components/Properties/index.tsx index 9e6e418b..e96b1757 100644 --- a/web/src/views/Workflow/components/Properties/index.tsx +++ b/web/src/views/Workflow/components/Properties/index.tsx @@ -2,7 +2,7 @@ * @Author: ZhaoYing * @Date: 2026-02-03 15:39:59 * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-02-09 19:56:42 + * @Last Modified time: 2026-02-11 12:07:06 */ import { type FC, useEffect, useState, useMemo } from "react"; import clsx from 'clsx' @@ -627,6 +627,7 @@ const Properties: FC = ({ } layout={config.type === 'switch' ? 'horizontal' : 'vertical'} className={key === 'parallel_count' ? 'rb:-mt-3! rb:leading-3.5!' : ''} + hidden={Boolean(config.hidden)} > {config.type === 'input' ? diff --git a/web/src/views/Workflow/constant.ts b/web/src/views/Workflow/constant.ts index 6b36dec5..5ae3e5b0 100644 --- a/web/src/views/Workflow/constant.ts +++ b/web/src/views/Workflow/constant.ts @@ -2,7 +2,7 @@ * @Author: ZhaoYing * @Date: 2026-02-03 15:06:18 * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-02-09 20:08:03 + * @Last Modified time: 2026-02-11 12:07:20 */ import LoopNode from './components/Nodes/LoopNode'; import NormalNode from './components/Nodes/NormalNode'; @@ -240,7 +240,8 @@ export const nodeLibrary: NodeLibrary[] = [ config: { message: { type: 'editor', - isArray: false + isArray: false, + hidden: true, }, messages: { type: 'messageEditor', From b462e17a5bfb3ed2f67e544db46c3a17040fa271 Mon Sep 17 00:00:00 2001 From: lixiangcheng1 Date: Tue, 24 Feb 2026 16:53:07 +0800 Subject: [PATCH 10/36] [fix]A threading communication issue occurred when using the Trio asynchronous framework. The core error was OSError: [errno 9] Bad file descriptor, which occurred when Trio attempted to wake up the event loop in a multi-threaded environment --- api/app/tasks.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/api/app/tasks.py b/api/app/tasks.py index e6bdeb18..e29d0406 100644 --- a/api/app/tasks.py +++ b/api/app/tasks.py @@ -378,6 +378,9 @@ def build_graphrag_for_kb(kb_id: uuid.UUID): ) except Exception as e: print(f"{datetime.now().strftime('%H:%M:%S')} GraphRAG task failed for task {task}:\n{str(e)}\n") + finally: + if db: + db.close() print(f"{datetime.now().strftime('%H:%M:%S')} Knowledge Graph done ({time.time() - start_time}s)") result = f"build knowledge graph '{db_knowledge.name}' processed successfully." @@ -388,7 +391,8 @@ def build_graphrag_for_kb(kb_id: uuid.UUID): result = f"build knowledge grap '{db_knowledge.name}' failed." return result finally: - db.close() + if db: + db.close() @celery_app.task(name="app.core.rag.tasks.sync_knowledge_for_kb") From 44083aec7999d1565fcfedef746877d53d47bc7c Mon Sep 17 00:00:00 2001 From: Eternity <1533512157@qq.com> Date: Tue, 24 Feb 2026 15:26:32 +0800 Subject: [PATCH 11/36] feat(workflow): include loop information in loop node outputs --- .../workflow/engine/event_stream_handler.py | 8 +++ api/app/core/workflow/executor.py | 4 ++ .../workflow/nodes/cycle_graph/iteration.py | 64 ++++++++++++++--- .../core/workflow/nodes/cycle_graph/loop.py | 68 +++++++++++++++++-- .../core/workflow/nodes/cycle_graph/node.py | 35 ++++++++++ api/app/schemas/app_schema.py | 2 +- api/app/services/workflow_service.py | 2 +- 7 files changed, 167 insertions(+), 16 deletions(-) diff --git a/api/app/core/workflow/engine/event_stream_handler.py b/api/app/core/workflow/engine/event_stream_handler.py index e49a2e8b..5b7d8de2 100644 --- a/api/app/core/workflow/engine/event_stream_handler.py +++ b/api/app/core/workflow/engine/event_stream_handler.py @@ -271,3 +271,11 @@ class EventStreamHandler: } } + @staticmethod + async def handle_cycle_item_event(data: dict): + yield { + "event": "cycle_item", + "data": data.get("data") + } + + diff --git a/api/app/core/workflow/executor.py b/api/app/core/workflow/executor.py index ff48fb07..2b554a60 100644 --- a/api/app/core/workflow/executor.py +++ b/api/app/core/workflow/executor.py @@ -279,6 +279,10 @@ class WorkflowExecutor: async for error_event in self.event_handler.handle_node_error_event(data): yield error_event + elif event_type == "cycle_item": + async for cycle_event in self.event_handler.handle_cycle_item_event(data): + yield cycle_event + elif mode == "debug": async for debug_event in self.event_handler.handle_debug_event(data, input_data): yield debug_event diff --git a/api/app/core/workflow/nodes/cycle_graph/iteration.py b/api/app/core/workflow/nodes/cycle_graph/iteration.py index f1138840..e4026f2d 100644 --- a/api/app/core/workflow/nodes/cycle_graph/iteration.py +++ b/api/app/core/workflow/nodes/cycle_graph/iteration.py @@ -1,13 +1,17 @@ import asyncio import logging import re +import uuid from typing import Any +from langchain_core.runnables import RunnableConfig from langgraph.graph.state import CompiledStateGraph +from langgraph.config import get_stream_writer from app.core.workflow.engine.state_manager import WorkflowState from app.core.workflow.engine.variable_pool import VariablePool from app.core.workflow.nodes.cycle_graph import IterationNodeConfig +from app.core.workflow.nodes.enums import NodeType from app.core.workflow.variable.base_variable import VariableType logger = logging.getLogger(__name__) @@ -25,6 +29,7 @@ class IterationRuntime: def __init__( self, start_id: str, + stream: bool, graph: CompiledStateGraph, node_id: str, config: dict[str, Any], @@ -42,6 +47,7 @@ class IterationRuntime: state: Current workflow state at the point of iteration. """ self.start_id = start_id + self.stream = stream self.graph = graph self.state = state self.node_id = node_id @@ -49,6 +55,12 @@ class IterationRuntime: self.looping = True self.variable_pool = variable_pool self.child_variable_pool = child_variable_pool + self.event_write = get_stream_writer() + self.checkpoint = RunnableConfig( + configurable={ + "thread_id": uuid.uuid4() + } + ) self.output_value = None self.result: list = [] @@ -91,7 +103,46 @@ class IterationRuntime: item: The input element for this iteration. idx: The index of this iteration. """ - result = await self.graph.ainvoke(await self._init_iteration_state(item, idx)) + if self.stream: + async for event in self.graph.astream( + await self._init_iteration_state(item, idx), + stream_mode=["debug"], + config=self.checkpoint + ): + if isinstance(event, tuple) and len(event) == 2: + mode, data = event + else: + continue + if mode == "debug": + event_type = data.get("type") + payload = data.get("payload", {}) + node_name = payload.get("name") + + if node_name and node_name.startswith("nop"): + continue + if event_type == "task_result": + result = payload.get("result", {}) + if not result.get("activate", {}).get(node_name): + continue + node_type = result.get("node_outputs", {}).get(node_name, {}).get("node_type") + cycle_variable = {"item": item} if node_type == NodeType.CYCLE_START else None + self.event_write({ + "type": "cycle_item", + "data": { + "cycle_id": self.node_id, + "cycle_idx": idx, + "node_id": node_name, + "input": result.get("node_outputs", {}).get(node_name, {}).get("input") + if not cycle_variable else cycle_variable, + "output": result.get("node_outputs", {}).get(node_name, {}).get("output") + if not cycle_variable else cycle_variable, + "elapsed_time": result.get("node_outputs", {}).get(node_name, {}).get("elapsed_time"), + "token_usage": result.get("node_outputs", {}).get(node_name, {}).get("token_usage") + } + }) + result = self.graph.get_state(config=self.checkpoint).values + else: + result = await self.graph.ainvoke(await self._init_iteration_state(item, idx)) output = self.child_variable_pool.get_value(self.output_value) if isinstance(output, list) and self.typed_config.flatten: self.result.extend(output) @@ -152,16 +203,9 @@ class IterationRuntime: while idx < len(array_obj) and self.looping: logger.info(f"Iteration node {self.node_id}: running") item = array_obj[idx] - result = await self.graph.ainvoke(await self._init_iteration_state(item, idx)) - child_state.append(result) - output = self.child_variable_pool.get_value(self.output_value) + result = await self.run_task(item, idx) self.merge_conv_vars() - if isinstance(output, list) and self.typed_config.flatten: - self.result.extend(output) - else: - self.result.append(output) - if result["looping"] == 2: - self.looping = False + child_state.append(result) idx += 1 logger.info(f"Iteration node {self.node_id}: execution completed") return { diff --git a/api/app/core/workflow/nodes/cycle_graph/loop.py b/api/app/core/workflow/nodes/cycle_graph/loop.py index b4406f74..cebadfdc 100644 --- a/api/app/core/workflow/nodes/cycle_graph/loop.py +++ b/api/app/core/workflow/nodes/cycle_graph/loop.py @@ -1,12 +1,15 @@ import logging +import uuid from typing import Any +from langchain_core.runnables import RunnableConfig +from langgraph.config import get_stream_writer from langgraph.graph.state import CompiledStateGraph from app.core.workflow.engine.state_manager import WorkflowState from app.core.workflow.engine.variable_pool import VariablePool from app.core.workflow.nodes.cycle_graph import LoopNodeConfig -from app.core.workflow.nodes.enums import ValueInputType, ComparisonOperator, LogicOperator +from app.core.workflow.nodes.enums import ValueInputType, ComparisonOperator, LogicOperator, NodeType from app.core.workflow.nodes.operators import TypeTransformer, ConditionExpressionResolver, CompareOperatorInstance from app.core.workflow.utils.expression_evaluator import evaluate_expression @@ -27,6 +30,7 @@ class LoopRuntime: def __init__( self, start_id: str, + stream: bool, graph: CompiledStateGraph, node_id: str, config: dict[str, Any], @@ -46,6 +50,7 @@ class LoopRuntime: child_variable_pool: A VariablePool instance for managing child node outputs. """ self.start_id = start_id + self.stream = stream self.graph = graph self.state = state self.node_id = node_id @@ -53,6 +58,13 @@ class LoopRuntime: self.looping = True self.variable_pool = variable_pool self.child_variable_pool = child_variable_pool + self.event_write = get_stream_writer() + + self.checkpoint = RunnableConfig( + configurable={ + "thread_id": uuid.uuid4() + } + ) async def _init_loop_state(self): """ @@ -142,10 +154,12 @@ class LoopRuntime: case _: raise ValueError(f"Invalid condition: {operator}") - def merge_conv_vars(self): + def merge_conv_vars(self, loopstate): self.variable_pool.variables["conv"].update( self.child_variable_pool.variables.get("conv", {}) ) + loop_vars = self.child_variable_pool.get_node_output(self.node_id, defalut={}, strict=False) + loopstate["node_outputs"][self.node_id] = loop_vars def evaluate_conditional(self) -> bool: """ @@ -175,6 +189,50 @@ class LoopRuntime: else: return any(conditions) + async def _run(self, loopstate, idx): + if self.stream: + async for event in self.graph.astream( + loopstate, + stream_mode=["debug"], + config=self.checkpoint + ): + if isinstance(event, tuple) and len(event) == 2: + mode, data = event + else: + continue + if mode == "debug": + event_type = data.get("type") + payload = data.get("payload", {}) + node_name = payload.get("name") + + if node_name and node_name.startswith("nop"): + continue + if event_type == "task_result": + result = payload.get("result", {}) + node_type = result.get("node_outputs", {}).get(node_name, {}).get("node_type") + if not result.get("activate", {}).get(node_name): + continue + cycle_variable = None + if node_type == NodeType.CYCLE_START: + cycle_variable = loopstate.get("node_outputs", {}).get(self.node_id, {}) + self.event_write({ + "type": "cycle_item", + "data": { + "cycle_id": self.node_id, + "cycle_idx": idx, + "node_id": node_name, + "input": result.get("node_outputs", {}).get(node_name, {}).get("input") + if not cycle_variable else cycle_variable, + "output": result.get("node_outputs", {}).get(node_name, {}).get("output") + if not cycle_variable else cycle_variable, + "elapsed_time": result.get("node_outputs", {}).get(node_name, {}).get("elapsed_time"), + "token_usage": result.get("node_outputs", {}).get(node_name, {}).get("token_usage") + } + }) + return self.graph.get_state(config=self.checkpoint).values + else: + return await self.graph.ainvoke(loopstate) + async def run(self): """ Execute the loop node until termination conditions are met. @@ -190,15 +248,17 @@ class LoopRuntime: loopstate = await self._init_loop_state() loop_time = self.typed_config.max_loop child_state = [] + idx = 0 while not self.evaluate_conditional() and self.looping and loop_time > 0: logger.info(f"loop node {self.node_id}: running") - result = await self.graph.ainvoke(loopstate) + result = await self._run(loopstate, idx) child_state.append(result) - self.merge_conv_vars() + self.merge_conv_vars(loopstate) if result["looping"] == 2: self.looping = False loop_time -= 1 + idx += 1 logger.info(f"loop node {self.node_id}: execution completed") return self.child_variable_pool.get_node_output(self.node_id) | {"__child_state": child_state} diff --git a/api/app/core/workflow/nodes/cycle_graph/node.py b/api/app/core/workflow/nodes/cycle_graph/node.py index 72768b77..f2912e2c 100644 --- a/api/app/core/workflow/nodes/cycle_graph/node.py +++ b/api/app/core/workflow/nodes/cycle_graph/node.py @@ -172,6 +172,7 @@ class CycleGraphNode(BaseNode): if self.node_type == NodeType.LOOP: return await LoopRuntime( start_id=self.start_node_id, + stream=False, graph=self.graph, node_id=self.node_id, config=self.config, @@ -182,6 +183,7 @@ class CycleGraphNode(BaseNode): if self.node_type == NodeType.ITERATION: return await IterationRuntime( start_id=self.start_node_id, + stream=False, graph=self.graph, node_id=self.node_id, config=self.config, @@ -190,3 +192,36 @@ class CycleGraphNode(BaseNode): child_variable_pool=self.child_variable_pool ).run() raise RuntimeError("Unknown cycle node type") + + async def execute_stream(self, state: WorkflowState, variable_pool: VariablePool): + if self.node_type == NodeType.LOOP: + yield { + "__final__": True, + "result": await LoopRuntime( + start_id=self.start_node_id, + stream=True, + graph=self.graph, + node_id=self.node_id, + config=self.config, + state=state, + variable_pool=variable_pool, + child_variable_pool=self.child_variable_pool, + ).run() + } + return + if self.node_type == NodeType.ITERATION: + yield { + "__final__": True, + "result": await IterationRuntime( + start_id=self.start_node_id, + stream=True, + graph=self.graph, + node_id=self.node_id, + config=self.config, + state=state, + variable_pool=variable_pool, + child_variable_pool=self.child_variable_pool + ).run() + } + return + raise RuntimeError("Unknown cycle node type") diff --git a/api/app/schemas/app_schema.py b/api/app/schemas/app_schema.py index 792a32ac..8cf81b92 100644 --- a/api/app/schemas/app_schema.py +++ b/api/app/schemas/app_schema.py @@ -439,7 +439,7 @@ class DraftRunRequest(BaseModel): user_id: Optional[str] = Field(default=None, description="用户ID(用于会话管理)") variables: Optional[Dict[str, Any]] = Field(default=None, description="自定义变量参数值") stream: bool = Field(default=False, description="是否流式返回") - files: Optional[List[FileInput]] = Field(default=None, description="附件列表(支持多文件)") + files: Optional[List[FileInput]] = Field(default_factory=list, description="附件列表(支持多文件)") class DraftRunResponse(BaseModel): diff --git a/api/app/services/workflow_service.py b/api/app/services/workflow_service.py index fb88f804..d06a05d7 100644 --- a/api/app/services/workflow_service.py +++ b/api/app/services/workflow_service.py @@ -588,7 +588,7 @@ class WorkflowService: "message_length": len(payload.get("output", "")) } } - case "node_start" | "node_end" | "node_error": + case "node_start" | "node_end" | "node_error" | "cycle_item": return None case _: return event From cb7dbb0ed446932bd642d72b59711b8d094ebb84 Mon Sep 17 00:00:00 2001 From: zhaoying Date: Tue, 24 Feb 2026 17:58:59 +0800 Subject: [PATCH 12/36] feat(web): loop & iteration run add sub node detail --- web/src/i18n/en.ts | 8 + web/src/i18n/zh.ts | 8 + .../views/Workflow/components/Chat/Chat.tsx | 950 ++++++++++++++++-- .../Workflow/components/Chat/Runtime.tsx | 234 +++++ 4 files changed, 1104 insertions(+), 96 deletions(-) create mode 100644 web/src/views/Workflow/components/Chat/Runtime.tsx diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index 7f013752..5fcdf0ed 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -2148,6 +2148,14 @@ Memory Bear: After the rebellion, regional warlordism intensified for several re input: 'Input', output: 'Output', error: 'Error Message', + loopNum: ' loops', + iterationNum: ' iterations', + runtime: { + loop: 'Loop', + iteration: 'Iteration', + input_cycle_vars: 'Initial Loop Variables', + output_cycle_vars: 'Final Loop Variables', + } }, emotionEngine: { emotionEngineConfig: 'Emotion Engine Configuration', diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index c72da969..6b880426 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -2242,6 +2242,14 @@ export const zh = { input: '输入', output: '输出', error: '错误信息', + loopNum: '个循环', + iterationNum: '个迭代', + runtime: { + loop: '循环', + iteration: '迭代', + input_cycle_vars: '初始循环变量', + output_cycle_vars: '最终循环变量', + } }, emotionEngine: { emotionEngineConfig: '情感引擎配置', diff --git a/web/src/views/Workflow/components/Chat/Chat.tsx b/web/src/views/Workflow/components/Chat/Chat.tsx index 00a0c7fb..51b1be38 100644 --- a/web/src/views/Workflow/components/Chat/Chat.tsx +++ b/web/src/views/Workflow/components/Chat/Chat.tsx @@ -2,7 +2,7 @@ * @Author: ZhaoYing * @Date: 2026-02-06 21:10:56 * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-02-10 17:41:24 + * @Last Modified time: 2026-02-24 17:55:08 */ /** * Workflow Chat Component @@ -23,10 +23,7 @@ */ import { forwardRef, useImperativeHandle, useState, useRef } from 'react' import { useTranslation } from 'react-i18next' -import clsx from 'clsx' -import { App, Space, Button, Collapse, Flex, Dropdown, type MenuProps } from 'antd' -import { CheckCircleFilled, CloseCircleFilled, LoadingOutlined } from '@ant-design/icons' -import CodeBlock from '@/components/Markdown/CodeBlock' +import { App, Space, Button, Flex, Dropdown, type MenuProps } from 'antd' import ChatIcon from '@/assets/images/application/chat.png' import RbDrawer from '@/components/RbDrawer'; @@ -39,13 +36,12 @@ import dayjs from 'dayjs' import type { ChatRef, VariableConfigModalRef, GraphRef } from '../../types' import { type SSEMessage } from '@/utils/stream' import type { Variable } from '../Properties/VariableList/types' -import styles from './chat.module.css' -import Markdown from '@/components/Markdown' import ChatInput from '@/components/Chat/ChatInput' import UploadFiles from '@/views/Conversation/components/FileUpload' // import AudioRecorder from '@/components/AudioRecorder' import UploadFileListModal from '@/views/Conversation/components/UploadFileListModal' import type { UploadFileListModalRef } from '@/views/Conversation/types' +import Runtime from './Runtime'; const Chat = forwardRef(({ appId, graphRef }, ref) => { const { t } = useTranslation() @@ -54,7 +50,815 @@ const Chat = forwardRef(({ appId // State management const [open, setOpen] = useState(false) // Drawer visibility const [loading, setLoading] = useState(false) // Send button loading state - const [chatList, setChatList] = useState([]) // Chat message history + const [chatList, setChatList] = useState([ + { + "role": "assistant", + "content": "经过多次打磨,最终作品如下:\n《咏一·三题》 \n孤光未凿太初溟, \n一粟吞天万籁宁。 \n影堕千峰青未染, \n心空四象白犹灵。 \n非从烛焰求明性, \n但向尘劳见本形。 \n忽有松风穿石罅, \n泠然吹落满山星。 \n\n注:本诗严守平水韵九青部(溟、宁、灵、形、星),其中“星”属下平声九青部异读字(《广韵》息盈切,与“灵”“宁”同部),古诗常用以协律,如王维“清溪流过碧山头,空水澄鲜一色秋。隔断红尘三十里,白云红叶两悠悠”中“悠”亦借韵通协。全诗紧扣“以一为魂”之旨:首句“孤光未凿”化《庄子·应帝王》“浑沌凿七窍而死”典,反写太初本明未分之境;次句“一粟吞天”,以微纳巨,承“一芥”而力愈雄浑;颔联“青未染”“白犹灵”,双色映照,暗喻性体离垢绝染而朗然常照;颈联直破二边——不假烛焰(破外求)、不避尘劳(破厌离),显《坛经》“佛法在世间,不离世间觉”之旨;结句松风裂石、星落满山,是“一”之活泼妙用:寂而常照,照而恒寂,恰如《道德经》“天得一以清,地得一以宁”之诗性证成。 \nLLM1结果:\n《咏一》 \n孤峰独峙破苍冥, \n一芥微身立太清。 \n万古乾坤凝此数, \n千山雪落只无声。 \n\n注:本诗以“一”为魂,通过“孤峰”“一芥”“此数”层层递进,赋予数字哲思——既写天地间唯一性之壮美(孤峰破冥),又寓渺小个体与永恒宇宙的辩证(芥子纳太清)。末句“千山雪落只无声”,以大静写大一,雪覆千山而声息俱寂,暗合《道德经》“天得一以清”之境。平仄依平水韵,押九青部(冥、清、声)。 ", + "created_at": 1771925594511, + "subContent": [ + { + "id": "start_1767617465337_0djnmpk2y", + "node_id": "start_1767617465337_0djnmpk2y", + "node_name": "开始(Start)", + "icon": "/src/assets/images/workflow/start.png", + "content": { + "input": { + "execution_id": "exec_11a80fb1cde148cb", + "conversation_id": "37ee003e-cc53-47e7-930f-a436a1252dd1", + "message": "1", + "conversation_vars": {} + }, + "output": { + "message": "1", + "execution_id": "exec_11a80fb1cde148cb", + "conversation_id": "37ee003e-cc53-47e7-930f-a436a1252dd1", + "workspace_id": "d17cd62d-a725-4fc0-813b-1093f2dfdee4", + "user_id": "ab27a27f-072b-47e9-8bbb-1f19322debcd", + "topic": "", + "number": 0, + "Boolean": false + } + }, + "status": "completed", + "elapsed_time": 0 + }, + { + "id": "llm_1767617499720_zvqwjpw3b", + "node_id": "llm_1767617499720_zvqwjpw3b", + "node_name": "大语言模型 (LLM)-初始创作", + "icon": "/src/assets/images/workflow/llm.png", + "content": { + "input": { + "prompt": null, + "messages": [ + { + "role": "system", + "content": "请根据1 为主题写一首七字诗。" + } + ], + "config": { + "model_id": "2699984d-23be-4817-b81c-c38682a08306", + "temperature": 0.7, + "max_tokens": 2000 + } + }, + "output": "《咏一》 \n孤峰独峙破苍冥, \n一芥微身立太清。 \n万古乾坤凝此数, \n千山雪落只无声。 \n\n注:本诗以“一”为魂,通过“孤峰”“一芥”“此数”层层递进,赋予数字哲思——既写天地间唯一性之壮美(孤峰破冥),又寓渺小个体与永恒宇宙的辩证(芥子纳太清)。末句“千山雪落只无声”,以大静写大一,雪覆千山而声息俱寂,暗合《道德经》“天得一以清”之境。平仄依平水韵,押九青部(冥、清、声)。" + }, + "status": "completed", + "elapsed_time": 4.518743515014648 + }, + { + "id": "loop_1767617552451_hq3j342ha", + "node_id": "loop_1767617552451_hq3j342ha", + "node_name": "循环 (Loop)", + "icon": "/src/assets/images/workflow/loop.png", + "content": { + "input": { + "config": { + "max_loop": 10, + "condition": { + "expressions": [ + { + "left": "{{loop_1767617552451_hq3j342ha.round}}", + "right": 3, + "operator": "eq", + "input_type": "Constant" + } + ], + "logical_operator": "and" + }, + "cycle_vars": [ + { + "name": "poem_content", + "type": "string", + "value": "{{llm_1767617499720_zvqwjpw3b.output}}", + "input_type": "variable" + }, + { + "name": "round", + "type": "number", + "value": "0", + "input_type": "constant" + } + ] + } + }, + "output": { + "poem_content": "《咏一·三题》 \n孤光未凿太初溟, \n一粟吞天万籁宁。 \n影堕千峰青未染, \n心空四象白犹灵。 \n非从烛焰求明性, \n但向尘劳见本形。 \n忽有松风穿石罅, \n泠然吹落满山星。 \n\n注:本诗严守平水韵九青部(溟、宁、灵、形、星),其中“星”属下平声九青部异读字(《广韵》息盈切,与“灵”“宁”同部),古诗常用以协律,如王维“清溪流过碧山头,空水澄鲜一色秋。隔断红尘三十里,白云红叶两悠悠”中“悠”亦借韵通协。全诗紧扣“以一为魂”之旨:首句“孤光未凿”化《庄子·应帝王》“浑沌凿七窍而死”典,反写太初本明未分之境;次句“一粟吞天”,以微纳巨,承“一芥”而力愈雄浑;颔联“青未染”“白犹灵”,双色映照,暗喻性体离垢绝染而朗然常照;颈联直破二边——不假烛焰(破外求)、不避尘劳(破厌离),显《坛经》“佛法在世间,不离世间觉”之旨;结句松风裂石、星落满山,是“一”之活泼妙用:寂而常照,照而恒寂,恰如《道德经》“天得一以清,地得一以宁”之诗性证成。", + "round": 3, + "__child_state": [ + { + "messages": [], + "cycle_nodes": [ + "loop_1767617552451_hq3j342ha" + ], + "looping": 1, + "node_outputs": { + "start_1767617465337_0djnmpk2y": { + "node_id": "start_1767617465337_0djnmpk2y", + "node_type": "start", + "node_name": "开始(Start)", + "status": "completed", + "input": { + "execution_id": "exec_11a80fb1cde148cb", + "conversation_id": "37ee003e-cc53-47e7-930f-a436a1252dd1", + "message": "1", + "conversation_vars": {} + }, + "output": { + "message": "1", + "execution_id": "exec_11a80fb1cde148cb", + "conversation_id": "37ee003e-cc53-47e7-930f-a436a1252dd1", + "workspace_id": "d17cd62d-a725-4fc0-813b-1093f2dfdee4", + "user_id": "ab27a27f-072b-47e9-8bbb-1f19322debcd", + "topic": "", + "number": 0, + "Boolean": false + }, + "elapsed_time": 0, + "token_usage": null, + "error": null + }, + "llm_1767617499720_zvqwjpw3b": { + "node_id": "llm_1767617499720_zvqwjpw3b", + "node_type": "llm", + "node_name": "大语言模型 (LLM)-初始创作", + "status": "completed", + "input": { + "prompt": null, + "messages": [ + { + "role": "system", + "content": "请根据1 为主题写一首七字诗。" + } + ], + "config": { + "model_id": "2699984d-23be-4817-b81c-c38682a08306", + "temperature": 0.7, + "max_tokens": 2000 + } + }, + "output": "《咏一》 \n孤峰独峙破苍冥, \n一芥微身立太清。 \n万古乾坤凝此数, \n千山雪落只无声。 \n\n注:本诗以“一”为魂,通过“孤峰”“一芥”“此数”层层递进,赋予数字哲思——既写天地间唯一性之壮美(孤峰破冥),又寓渺小个体与永恒宇宙的辩证(芥子纳太清)。末句“千山雪落只无声”,以大静写大一,雪覆千山而声息俱寂,暗合《道德经》“天得一以清”之境。平仄依平水韵,押九青部(冥、清、声)。", + "elapsed_time": 4.518743515014648, + "token_usage": { + "prompt_tokens": 25, + "completion_tokens": 165, + "total_tokens": 190 + }, + "error": null + }, + "loop_1767617552451_hq3j342ha": { + "poem_content": "《咏一》 \n孤峰独峙破苍冥, \n一芥微身立太清。 \n万古乾坤凝此数, \n千山雪落只无声。 \n\n注:本诗以“一”为魂,通过“孤峰”“一芥”“此数”层层递进,赋予数字哲思——既写天地间唯一性之壮美(孤峰破冥),又寓渺小个体与永恒宇宙的辩证(芥子纳太清)。末句“千山雪落只无声”,以大静写大一,雪覆千山而声息俱寂,暗合《道德经》“天得一以清”之境。平仄依平水韵,押九青部(冥、清、声)。", + "round": 0 + }, + "21046fb8-1f33-45f7-aeda-2c196471f119": { + "node_id": "21046fb8-1f33-45f7-aeda-2c196471f119", + "node_type": "cycle-start", + "node_name": null, + "status": "completed", + "input": { + "execution_id": "exec_11a80fb1cde148cb", + "conversation_id": "37ee003e-cc53-47e7-930f-a436a1252dd1", + "message": "1", + "conversation_vars": {} + }, + "output": { + "message": "1", + "execution_id": "exec_11a80fb1cde148cb", + "conversation_id": "37ee003e-cc53-47e7-930f-a436a1252dd1", + "workspace_id": "d17cd62d-a725-4fc0-813b-1093f2dfdee4", + "user_id": "ab27a27f-072b-47e9-8bbb-1f19322debcd" + }, + "elapsed_time": 0.0005278587341308594, + "token_usage": null, + "error": null + }, + "llm_1767617560401_bsx1vhi25": { + "node_id": "llm_1767617560401_bsx1vhi25", + "node_type": "llm", + "node_name": "大语言模型 (LLM)-润色器", + "status": "completed", + "input": { + "prompt": null, + "messages": [ + { + "role": "system", + "content": "请根据《咏一》 \n孤峰独峙破苍冥, \n一芥微身立太清。 \n万古乾坤凝此数, \n千山雪落只无声。 \n\n注:本诗以“一”为魂,通过“孤峰”“一芥”“此数”层层递进,赋予数字哲思——既写天地间唯一性之壮美(孤峰破冥),又寓渺小个体与永恒宇宙的辩证(芥子纳太清)。末句“千山雪落只无声”,以大静写大一,雪覆千山而声息俱寂,暗合《道德经》“天得一以清”之境。平仄依平水韵,押九青部(冥、清、声)。 为主题写一首七字诗。" + } + ], + "config": { + "model_id": "2699984d-23be-4817-b81c-c38682a08306", + "temperature": 0.7, + "max_tokens": 2000 + } + }, + "output": "《咏一·次韵》 \n千峰削玉立空青, \n一羽浮天亦自宁。 \n万籁收声归太始, \n孤光未堕即长明。 \n\n注:本诗承原作“以一为魂”之旨,严守平水韵九青部(青、宁、明),平仄谐律。首句“千峰削玉”反衬“一羽浮天”,以极繁托极简;次句“一羽”既承“一芥”之微,更取《庄子》“鹏徙南冥”之逸气,言至微者亦可持守本然之宁。三句“万籁收声”暗应原作“千山雪落只无声”,而升华为宇宙初开的“太始”静界;结句“孤光未堕即长明”,化用《淮南子》“日月不为明而明”与禅宗“一念不生即佛”,昭示“一”非寂灭之空,乃不假外求、本自圆成的永恒觉性——此即《道德经》“天得一以清”的诗性证悟。", + "elapsed_time": 6.8497374057769775, + "token_usage": { + "prompt_tokens": 188, + "completion_tokens": 262, + "total_tokens": 450 + }, + "error": null + }, + "assigner_1768285417545_qsoqleflh": { + "node_id": "assigner_1768285417545_qsoqleflh", + "node_type": "assigner", + "node_name": "变量赋值", + "status": "completed", + "input": { + "config": { + "assignments": [ + { + "value": "{{llm_1767617560401_bsx1vhi25.output}}", + "operation": "cover", + "variable_selector": "{{loop_1767617552451_hq3j342ha.poem_content}}" + }, + { + "value": 1, + "operation": "add", + "variable_selector": "{{loop_1767617552451_hq3j342ha.round}}" + } + ] + } + }, + "output": null, + "elapsed_time": 0.0003705024719238281, + "token_usage": null, + "error": null + } + }, + "execution_id": "exec_11a80fb1cde148cb", + "workspace_id": "d17cd62d-a725-4fc0-813b-1093f2dfdee4", + "user_id": "ab27a27f-072b-47e9-8bbb-1f19322debcd", + "error": null, + "error_node": null, + "activate": { + "llm_1767617560401_bsx1vhi25": true, + "loop_1767617552451_hq3j342ha": true, + "start_1767617465337_0djnmpk2y": true, + "21046fb8-1f33-45f7-aeda-2c196471f119": true, + "llm_1767617499720_zvqwjpw3b": true, + "assigner_1768285417545_qsoqleflh": true + } + }, + { + "messages": [], + "cycle_nodes": [ + "loop_1767617552451_hq3j342ha" + ], + "looping": 1, + "node_outputs": { + "start_1767617465337_0djnmpk2y": { + "node_id": "start_1767617465337_0djnmpk2y", + "node_type": "start", + "node_name": "开始(Start)", + "status": "completed", + "input": { + "execution_id": "exec_11a80fb1cde148cb", + "conversation_id": "37ee003e-cc53-47e7-930f-a436a1252dd1", + "message": "1", + "conversation_vars": {} + }, + "output": { + "message": "1", + "execution_id": "exec_11a80fb1cde148cb", + "conversation_id": "37ee003e-cc53-47e7-930f-a436a1252dd1", + "workspace_id": "d17cd62d-a725-4fc0-813b-1093f2dfdee4", + "user_id": "ab27a27f-072b-47e9-8bbb-1f19322debcd", + "topic": "", + "number": 0, + "Boolean": false + }, + "elapsed_time": 0, + "token_usage": null, + "error": null + }, + "llm_1767617499720_zvqwjpw3b": { + "node_id": "llm_1767617499720_zvqwjpw3b", + "node_type": "llm", + "node_name": "大语言模型 (LLM)-初始创作", + "status": "completed", + "input": { + "prompt": null, + "messages": [ + { + "role": "system", + "content": "请根据1 为主题写一首七字诗。" + } + ], + "config": { + "model_id": "2699984d-23be-4817-b81c-c38682a08306", + "temperature": 0.7, + "max_tokens": 2000 + } + }, + "output": "《咏一》 \n孤峰独峙破苍冥, \n一芥微身立太清。 \n万古乾坤凝此数, \n千山雪落只无声。 \n\n注:本诗以“一”为魂,通过“孤峰”“一芥”“此数”层层递进,赋予数字哲思——既写天地间唯一性之壮美(孤峰破冥),又寓渺小个体与永恒宇宙的辩证(芥子纳太清)。末句“千山雪落只无声”,以大静写大一,雪覆千山而声息俱寂,暗合《道德经》“天得一以清”之境。平仄依平水韵,押九青部(冥、清、声)。", + "elapsed_time": 4.518743515014648, + "token_usage": { + "prompt_tokens": 25, + "completion_tokens": 165, + "total_tokens": 190 + }, + "error": null + }, + "loop_1767617552451_hq3j342ha": { + "poem_content": "《咏一·次韵》 \n千峰削玉立空青, \n一羽浮天亦自宁。 \n万籁收声归太始, \n孤光未堕即长明。 \n\n注:本诗承原作“以一为魂”之旨,严守平水韵九青部(青、宁、明),平仄谐律。首句“千峰削玉”反衬“一羽浮天”,以极繁托极简;次句“一羽”既承“一芥”之微,更取《庄子》“鹏徙南冥”之逸气,言至微者亦可持守本然之宁。三句“万籁收声”暗应原作“千山雪落只无声”,而升华为宇宙初开的“太始”静界;结句“孤光未堕即长明”,化用《淮南子》“日月不为明而明”与禅宗“一念不生即佛”,昭示“一”非寂灭之空,乃不假外求、本自圆成的永恒觉性——此即《道德经》“天得一以清”的诗性证悟。", + "round": 1 + }, + "21046fb8-1f33-45f7-aeda-2c196471f119": { + "node_id": "21046fb8-1f33-45f7-aeda-2c196471f119", + "node_type": "cycle-start", + "node_name": null, + "status": "completed", + "input": { + "execution_id": "exec_11a80fb1cde148cb", + "conversation_id": "37ee003e-cc53-47e7-930f-a436a1252dd1", + "message": "1", + "conversation_vars": {} + }, + "output": { + "message": "1", + "execution_id": "exec_11a80fb1cde148cb", + "conversation_id": "37ee003e-cc53-47e7-930f-a436a1252dd1", + "workspace_id": "d17cd62d-a725-4fc0-813b-1093f2dfdee4", + "user_id": "ab27a27f-072b-47e9-8bbb-1f19322debcd" + }, + "elapsed_time": 0, + "token_usage": null, + "error": null + }, + "llm_1767617560401_bsx1vhi25": { + "node_id": "llm_1767617560401_bsx1vhi25", + "node_type": "llm", + "node_name": "大语言模型 (LLM)-润色器", + "status": "completed", + "input": { + "prompt": null, + "messages": [ + { + "role": "system", + "content": "请根据《咏一·次韵》 \n千峰削玉立空青, \n一羽浮天亦自宁。 \n万籁收声归太始, \n孤光未堕即长明。 \n\n注:本诗承原作“以一为魂”之旨,严守平水韵九青部(青、宁、明),平仄谐律。首句“千峰削玉”反衬“一羽浮天”,以极繁托极简;次句“一羽”既承“一芥”之微,更取《庄子》“鹏徙南冥”之逸气,言至微者亦可持守本然之宁。三句“万籁收声”暗应原作“千山雪落只无声”,而升华为宇宙初开的“太始”静界;结句“孤光未堕即长明”,化用《淮南子》“日月不为明而明”与禅宗“一念不生即佛”,昭示“一”非寂灭之空,乃不假外求、本自圆成的永恒觉性——此即《道德经》“天得一以清”的诗性证悟。 为主题写一首七字诗。" + } + ], + "config": { + "model_id": "2699984d-23be-4817-b81c-c38682a08306", + "temperature": 0.7, + "max_tokens": 2000 + } + }, + "output": "《咏一·再题》 \n一芥浮空万籁停, \n千峰影落太初青。 \n光非燃烛恒明在, \n心不沾尘即性灵。 \n\n注:本诗续写“以一为魂”之旨,严守平水韵九青部(停、青、灵),平仄精严。首句“一芥”承原作微渺意象,而“万籁停”较“收声”更显寂然自定之境;次句倒装“千峰影落”,使苍茫山势如墨痕沉入宇宙初青,暗契《淮南子》“虚霩生宇宙,宇宙生气”之太始气象。三句翻出新境:“光非燃烛”,破除对光明之形器执取,直指《楞严经》“性觉妙明,本觉明妙”之不假缘起的本明;结句“心不沾尘即性灵”,化用六祖“本来无一物”与程颢“天地之大德曰生”,言“一”非枯寂之数,乃活泼泼的性灵朗现——此即《道德经》“昔之得一者,天清地宁”的诗性澄明。", + "elapsed_time": 7.1851232051849365, + "token_usage": { + "prompt_tokens": 285, + "completion_tokens": 281, + "total_tokens": 566 + }, + "error": null + }, + "assigner_1768285417545_qsoqleflh": { + "node_id": "assigner_1768285417545_qsoqleflh", + "node_type": "assigner", + "node_name": "变量赋值", + "status": "completed", + "input": { + "config": { + "assignments": [ + { + "value": "{{llm_1767617560401_bsx1vhi25.output}}", + "operation": "cover", + "variable_selector": "{{loop_1767617552451_hq3j342ha.poem_content}}" + }, + { + "value": 1, + "operation": "add", + "variable_selector": "{{loop_1767617552451_hq3j342ha.round}}" + } + ] + } + }, + "output": null, + "elapsed_time": 0, + "token_usage": null, + "error": null + } + }, + "execution_id": "exec_11a80fb1cde148cb", + "workspace_id": "d17cd62d-a725-4fc0-813b-1093f2dfdee4", + "user_id": "ab27a27f-072b-47e9-8bbb-1f19322debcd", + "error": null, + "error_node": null, + "activate": { + "llm_1767617560401_bsx1vhi25": true, + "start_1767617465337_0djnmpk2y": true, + "loop_1767617552451_hq3j342ha": true, + "21046fb8-1f33-45f7-aeda-2c196471f119": true, + "llm_1767617499720_zvqwjpw3b": true, + "assigner_1768285417545_qsoqleflh": true + } + }, + { + "messages": [], + "cycle_nodes": [ + "loop_1767617552451_hq3j342ha" + ], + "looping": 1, + "node_outputs": { + "start_1767617465337_0djnmpk2y": { + "node_id": "start_1767617465337_0djnmpk2y", + "node_type": "start", + "node_name": "开始(Start)", + "status": "completed", + "input": { + "execution_id": "exec_11a80fb1cde148cb", + "conversation_id": "37ee003e-cc53-47e7-930f-a436a1252dd1", + "message": "1", + "conversation_vars": {} + }, + "output": { + "message": "1", + "execution_id": "exec_11a80fb1cde148cb", + "conversation_id": "37ee003e-cc53-47e7-930f-a436a1252dd1", + "workspace_id": "d17cd62d-a725-4fc0-813b-1093f2dfdee4", + "user_id": "ab27a27f-072b-47e9-8bbb-1f19322debcd", + "topic": "", + "number": 0, + "Boolean": false + }, + "elapsed_time": 0, + "token_usage": null, + "error": null + }, + "llm_1767617499720_zvqwjpw3b": { + "node_id": "llm_1767617499720_zvqwjpw3b", + "node_type": "llm", + "node_name": "大语言模型 (LLM)-初始创作", + "status": "completed", + "input": { + "prompt": null, + "messages": [ + { + "role": "system", + "content": "请根据1 为主题写一首七字诗。" + } + ], + "config": { + "model_id": "2699984d-23be-4817-b81c-c38682a08306", + "temperature": 0.7, + "max_tokens": 2000 + } + }, + "output": "《咏一》 \n孤峰独峙破苍冥, \n一芥微身立太清。 \n万古乾坤凝此数, \n千山雪落只无声。 \n\n注:本诗以“一”为魂,通过“孤峰”“一芥”“此数”层层递进,赋予数字哲思——既写天地间唯一性之壮美(孤峰破冥),又寓渺小个体与永恒宇宙的辩证(芥子纳太清)。末句“千山雪落只无声”,以大静写大一,雪覆千山而声息俱寂,暗合《道德经》“天得一以清”之境。平仄依平水韵,押九青部(冥、清、声)。", + "elapsed_time": 4.518743515014648, + "token_usage": { + "prompt_tokens": 25, + "completion_tokens": 165, + "total_tokens": 190 + }, + "error": null + }, + "loop_1767617552451_hq3j342ha": { + "poem_content": "《咏一·再题》 \n一芥浮空万籁停, \n千峰影落太初青。 \n光非燃烛恒明在, \n心不沾尘即性灵。 \n\n注:本诗续写“以一为魂”之旨,严守平水韵九青部(停、青、灵),平仄精严。首句“一芥”承原作微渺意象,而“万籁停”较“收声”更显寂然自定之境;次句倒装“千峰影落”,使苍茫山势如墨痕沉入宇宙初青,暗契《淮南子》“虚霩生宇宙,宇宙生气”之太始气象。三句翻出新境:“光非燃烛”,破除对光明之形器执取,直指《楞严经》“性觉妙明,本觉明妙”之不假缘起的本明;结句“心不沾尘即性灵”,化用六祖“本来无一物”与程颢“天地之大德曰生”,言“一”非枯寂之数,乃活泼泼的性灵朗现——此即《道德经》“昔之得一者,天清地宁”的诗性澄明。", + "round": 2 + }, + "21046fb8-1f33-45f7-aeda-2c196471f119": { + "node_id": "21046fb8-1f33-45f7-aeda-2c196471f119", + "node_type": "cycle-start", + "node_name": null, + "status": "completed", + "input": { + "execution_id": "exec_11a80fb1cde148cb", + "conversation_id": "37ee003e-cc53-47e7-930f-a436a1252dd1", + "message": "1", + "conversation_vars": {} + }, + "output": { + "message": "1", + "execution_id": "exec_11a80fb1cde148cb", + "conversation_id": "37ee003e-cc53-47e7-930f-a436a1252dd1", + "workspace_id": "d17cd62d-a725-4fc0-813b-1093f2dfdee4", + "user_id": "ab27a27f-072b-47e9-8bbb-1f19322debcd" + }, + "elapsed_time": 0, + "token_usage": null, + "error": null + }, + "llm_1767617560401_bsx1vhi25": { + "node_id": "llm_1767617560401_bsx1vhi25", + "node_type": "llm", + "node_name": "大语言模型 (LLM)-润色器", + "status": "completed", + "input": { + "prompt": null, + "messages": [ + { + "role": "system", + "content": "请根据《咏一·再题》 \n一芥浮空万籁停, \n千峰影落太初青。 \n光非燃烛恒明在, \n心不沾尘即性灵。 \n\n注:本诗续写“以一为魂”之旨,严守平水韵九青部(停、青、灵),平仄精严。首句“一芥”承原作微渺意象,而“万籁停”较“收声”更显寂然自定之境;次句倒装“千峰影落”,使苍茫山势如墨痕沉入宇宙初青,暗契《淮南子》“虚霩生宇宙,宇宙生气”之太始气象。三句翻出新境:“光非燃烛”,破除对光明之形器执取,直指《楞严经》“性觉妙明,本觉明妙”之不假缘起的本明;结句“心不沾尘即性灵”,化用六祖“本来无一物”与程颢“天地之大德曰生”,言“一”非枯寂之数,乃活泼泼的性灵朗现——此即《道德经》“昔之得一者,天清地宁”的诗性澄明。 为主题写一首七字诗。" + } + ], + "config": { + "model_id": "2699984d-23be-4817-b81c-c38682a08306", + "temperature": 0.7, + "max_tokens": 2000 + } + }, + "output": "《咏一·三题》 \n孤光未凿太初溟, \n一粟吞天万籁宁。 \n影堕千峰青未染, \n心空四象白犹灵。 \n非从烛焰求明性, \n但向尘劳见本形。 \n忽有松风穿石罅, \n泠然吹落满山星。 \n\n注:本诗严守平水韵九青部(溟、宁、灵、形、星),其中“星”属下平声九青部异读字(《广韵》息盈切,与“灵”“宁”同部),古诗常用以协律,如王维“清溪流过碧山头,空水澄鲜一色秋。隔断红尘三十里,白云红叶两悠悠”中“悠”亦借韵通协。全诗紧扣“以一为魂”之旨:首句“孤光未凿”化《庄子·应帝王》“浑沌凿七窍而死”典,反写太初本明未分之境;次句“一粟吞天”,以微纳巨,承“一芥”而力愈雄浑;颔联“青未染”“白犹灵”,双色映照,暗喻性体离垢绝染而朗然常照;颈联直破二边——不假烛焰(破外求)、不避尘劳(破厌离),显《坛经》“佛法在世间,不离世间觉”之旨;结句松风裂石、星落满山,是“一”之活泼妙用:寂而常照,照而恒寂,恰如《道德经》“天得一以清,地得一以宁”之诗性证成。", + "elapsed_time": 9.531717538833618, + "token_usage": { + "prompt_tokens": 304, + "completion_tokens": 390, + "total_tokens": 694 + }, + "error": null + }, + "assigner_1768285417545_qsoqleflh": { + "node_id": "assigner_1768285417545_qsoqleflh", + "node_type": "assigner", + "node_name": "变量赋值", + "status": "completed", + "input": { + "config": { + "assignments": [ + { + "value": "{{llm_1767617560401_bsx1vhi25.output}}", + "operation": "cover", + "variable_selector": "{{loop_1767617552451_hq3j342ha.poem_content}}" + }, + { + "value": 1, + "operation": "add", + "variable_selector": "{{loop_1767617552451_hq3j342ha.round}}" + } + ] + } + }, + "output": null, + "elapsed_time": 0, + "token_usage": null, + "error": null + } + }, + "execution_id": "exec_11a80fb1cde148cb", + "workspace_id": "d17cd62d-a725-4fc0-813b-1093f2dfdee4", + "user_id": "ab27a27f-072b-47e9-8bbb-1f19322debcd", + "error": null, + "error_node": null, + "activate": { + "llm_1767617560401_bsx1vhi25": true, + "start_1767617465337_0djnmpk2y": true, + "loop_1767617552451_hq3j342ha": true, + "21046fb8-1f33-45f7-aeda-2c196471f119": true, + "llm_1767617499720_zvqwjpw3b": true, + "assigner_1768285417545_qsoqleflh": true + } + } + ] + } + }, + "subContent": [ + { + "cycle_idx": 0, + "node_id": "21046fb8-1f33-45f7-aeda-2c196471f119", + "node_name": null, + "icon": "/src/assets/images/workflow/loop.png", + "content": { + "cycle_idx": 0, + "input": { + "poem_content": "《咏一》 \n孤峰独峙破苍冥, \n一芥微身立太清。 \n万古乾坤凝此数, \n千山雪落只无声。 \n\n注:本诗以“一”为魂,通过“孤峰”“一芥”“此数”层层递进,赋予数字哲思——既写天地间唯一性之壮美(孤峰破冥),又寓渺小个体与永恒宇宙的辩证(芥子纳太清)。末句“千山雪落只无声”,以大静写大一,雪覆千山而声息俱寂,暗合《道德经》“天得一以清”之境。平仄依平水韵,押九青部(冥、清、声)。", + "round": 0 + }, + "output": { + "poem_content": "《咏一》 \n孤峰独峙破苍冥, \n一芥微身立太清。 \n万古乾坤凝此数, \n千山雪落只无声。 \n\n注:本诗以“一”为魂,通过“孤峰”“一芥”“此数”层层递进,赋予数字哲思——既写天地间唯一性之壮美(孤峰破冥),又寓渺小个体与永恒宇宙的辩证(芥子纳太清)。末句“千山雪落只无声”,以大静写大一,雪覆千山而声息俱寂,暗合《道德经》“天得一以清”之境。平仄依平水韵,押九青部(冥、清、声)。", + "round": 0 + } + }, + "status": "completed", + "elapsed_time": 0.0005278587341308594 + }, + { + "cycle_idx": 0, + "node_id": "llm_1767617560401_bsx1vhi25", + "node_name": "大语言模型 (LLM)-润色器", + "icon": "/src/assets/images/workflow/llm.png", + "content": { + "cycle_idx": 0, + "input": { + "prompt": null, + "messages": [ + { + "role": "system", + "content": "请根据《咏一》 \n孤峰独峙破苍冥, \n一芥微身立太清。 \n万古乾坤凝此数, \n千山雪落只无声。 \n\n注:本诗以“一”为魂,通过“孤峰”“一芥”“此数”层层递进,赋予数字哲思——既写天地间唯一性之壮美(孤峰破冥),又寓渺小个体与永恒宇宙的辩证(芥子纳太清)。末句“千山雪落只无声”,以大静写大一,雪覆千山而声息俱寂,暗合《道德经》“天得一以清”之境。平仄依平水韵,押九青部(冥、清、声)。 为主题写一首七字诗。" + } + ], + "config": { + "model_id": "2699984d-23be-4817-b81c-c38682a08306", + "temperature": 0.7, + "max_tokens": 2000 + } + }, + "output": "《咏一·次韵》 \n千峰削玉立空青, \n一羽浮天亦自宁。 \n万籁收声归太始, \n孤光未堕即长明。 \n\n注:本诗承原作“以一为魂”之旨,严守平水韵九青部(青、宁、明),平仄谐律。首句“千峰削玉”反衬“一羽浮天”,以极繁托极简;次句“一羽”既承“一芥”之微,更取《庄子》“鹏徙南冥”之逸气,言至微者亦可持守本然之宁。三句“万籁收声”暗应原作“千山雪落只无声”,而升华为宇宙初开的“太始”静界;结句“孤光未堕即长明”,化用《淮南子》“日月不为明而明”与禅宗“一念不生即佛”,昭示“一”非寂灭之空,乃不假外求、本自圆成的永恒觉性——此即《道德经》“天得一以清”的诗性证悟。" + }, + "status": "completed", + "elapsed_time": 6.8497374057769775 + }, + { + "cycle_idx": 0, + "node_id": "assigner_1768285417545_qsoqleflh", + "node_name": "变量赋值", + "icon": "/src/assets/images/workflow/assigner.png", + "content": { + "cycle_idx": 0, + "input": { + "config": { + "assignments": [ + { + "value": "{{llm_1767617560401_bsx1vhi25.output}}", + "operation": "cover", + "variable_selector": "{{loop_1767617552451_hq3j342ha.poem_content}}" + }, + { + "value": 1, + "operation": "add", + "variable_selector": "{{loop_1767617552451_hq3j342ha.round}}" + } + ] + } + }, + "output": null + }, + "status": "completed", + "elapsed_time": 0.0003705024719238281 + }, + { + "cycle_idx": 1, + "node_id": "21046fb8-1f33-45f7-aeda-2c196471f119", + "node_name": null, + "icon": "/src/assets/images/workflow/loop.png", + "content": { + "cycle_idx": 1, + "input": { + "poem_content": "《咏一·次韵》 \n千峰削玉立空青, \n一羽浮天亦自宁。 \n万籁收声归太始, \n孤光未堕即长明。 \n\n注:本诗承原作“以一为魂”之旨,严守平水韵九青部(青、宁、明),平仄谐律。首句“千峰削玉”反衬“一羽浮天”,以极繁托极简;次句“一羽”既承“一芥”之微,更取《庄子》“鹏徙南冥”之逸气,言至微者亦可持守本然之宁。三句“万籁收声”暗应原作“千山雪落只无声”,而升华为宇宙初开的“太始”静界;结句“孤光未堕即长明”,化用《淮南子》“日月不为明而明”与禅宗“一念不生即佛”,昭示“一”非寂灭之空,乃不假外求、本自圆成的永恒觉性——此即《道德经》“天得一以清”的诗性证悟。", + "round": 1 + }, + "output": { + "poem_content": "《咏一·次韵》 \n千峰削玉立空青, \n一羽浮天亦自宁。 \n万籁收声归太始, \n孤光未堕即长明。 \n\n注:本诗承原作“以一为魂”之旨,严守平水韵九青部(青、宁、明),平仄谐律。首句“千峰削玉”反衬“一羽浮天”,以极繁托极简;次句“一羽”既承“一芥”之微,更取《庄子》“鹏徙南冥”之逸气,言至微者亦可持守本然之宁。三句“万籁收声”暗应原作“千山雪落只无声”,而升华为宇宙初开的“太始”静界;结句“孤光未堕即长明”,化用《淮南子》“日月不为明而明”与禅宗“一念不生即佛”,昭示“一”非寂灭之空,乃不假外求、本自圆成的永恒觉性——此即《道德经》“天得一以清”的诗性证悟。", + "round": 1 + } + }, + "status": "completed", + "elapsed_time": 0 + }, + { + "cycle_idx": 1, + "node_id": "llm_1767617560401_bsx1vhi25", + "node_name": "大语言模型 (LLM)-润色器", + "icon": "/src/assets/images/workflow/llm.png", + "content": { + "cycle_idx": 1, + "input": { + "prompt": null, + "messages": [ + { + "role": "system", + "content": "请根据《咏一·次韵》 \n千峰削玉立空青, \n一羽浮天亦自宁。 \n万籁收声归太始, \n孤光未堕即长明。 \n\n注:本诗承原作“以一为魂”之旨,严守平水韵九青部(青、宁、明),平仄谐律。首句“千峰削玉”反衬“一羽浮天”,以极繁托极简;次句“一羽”既承“一芥”之微,更取《庄子》“鹏徙南冥”之逸气,言至微者亦可持守本然之宁。三句“万籁收声”暗应原作“千山雪落只无声”,而升华为宇宙初开的“太始”静界;结句“孤光未堕即长明”,化用《淮南子》“日月不为明而明”与禅宗“一念不生即佛”,昭示“一”非寂灭之空,乃不假外求、本自圆成的永恒觉性——此即《道德经》“天得一以清”的诗性证悟。 为主题写一首七字诗。" + } + ], + "config": { + "model_id": "2699984d-23be-4817-b81c-c38682a08306", + "temperature": 0.7, + "max_tokens": 2000 + } + }, + "output": "《咏一·再题》 \n一芥浮空万籁停, \n千峰影落太初青。 \n光非燃烛恒明在, \n心不沾尘即性灵。 \n\n注:本诗续写“以一为魂”之旨,严守平水韵九青部(停、青、灵),平仄精严。首句“一芥”承原作微渺意象,而“万籁停”较“收声”更显寂然自定之境;次句倒装“千峰影落”,使苍茫山势如墨痕沉入宇宙初青,暗契《淮南子》“虚霩生宇宙,宇宙生气”之太始气象。三句翻出新境:“光非燃烛”,破除对光明之形器执取,直指《楞严经》“性觉妙明,本觉明妙”之不假缘起的本明;结句“心不沾尘即性灵”,化用六祖“本来无一物”与程颢“天地之大德曰生”,言“一”非枯寂之数,乃活泼泼的性灵朗现——此即《道德经》“昔之得一者,天清地宁”的诗性澄明。" + }, + "status": "completed", + "elapsed_time": 7.1851232051849365 + }, + { + "cycle_idx": 1, + "node_id": "assigner_1768285417545_qsoqleflh", + "node_name": "变量赋值", + "icon": "/src/assets/images/workflow/assigner.png", + "content": { + "cycle_idx": 1, + "input": { + "config": { + "assignments": [ + { + "value": "{{llm_1767617560401_bsx1vhi25.output}}", + "operation": "cover", + "variable_selector": "{{loop_1767617552451_hq3j342ha.poem_content}}" + }, + { + "value": 1, + "operation": "add", + "variable_selector": "{{loop_1767617552451_hq3j342ha.round}}" + } + ] + } + }, + "output": null + }, + "status": "completed", + "elapsed_time": 0 + }, + { + "cycle_idx": 2, + "node_id": "21046fb8-1f33-45f7-aeda-2c196471f119", + "node_name": null, + "icon": "/src/assets/images/workflow/loop.png", + "content": { + "cycle_idx": 2, + "input": { + "poem_content": "《咏一·再题》 \n一芥浮空万籁停, \n千峰影落太初青。 \n光非燃烛恒明在, \n心不沾尘即性灵。 \n\n注:本诗续写“以一为魂”之旨,严守平水韵九青部(停、青、灵),平仄精严。首句“一芥”承原作微渺意象,而“万籁停”较“收声”更显寂然自定之境;次句倒装“千峰影落”,使苍茫山势如墨痕沉入宇宙初青,暗契《淮南子》“虚霩生宇宙,宇宙生气”之太始气象。三句翻出新境:“光非燃烛”,破除对光明之形器执取,直指《楞严经》“性觉妙明,本觉明妙”之不假缘起的本明;结句“心不沾尘即性灵”,化用六祖“本来无一物”与程颢“天地之大德曰生”,言“一”非枯寂之数,乃活泼泼的性灵朗现——此即《道德经》“昔之得一者,天清地宁”的诗性澄明。", + "round": 2 + }, + "output": { + "poem_content": "《咏一·再题》 \n一芥浮空万籁停, \n千峰影落太初青。 \n光非燃烛恒明在, \n心不沾尘即性灵。 \n\n注:本诗续写“以一为魂”之旨,严守平水韵九青部(停、青、灵),平仄精严。首句“一芥”承原作微渺意象,而“万籁停”较“收声”更显寂然自定之境;次句倒装“千峰影落”,使苍茫山势如墨痕沉入宇宙初青,暗契《淮南子》“虚霩生宇宙,宇宙生气”之太始气象。三句翻出新境:“光非燃烛”,破除对光明之形器执取,直指《楞严经》“性觉妙明,本觉明妙”之不假缘起的本明;结句“心不沾尘即性灵”,化用六祖“本来无一物”与程颢“天地之大德曰生”,言“一”非枯寂之数,乃活泼泼的性灵朗现——此即《道德经》“昔之得一者,天清地宁”的诗性澄明。", + "round": 2 + } + }, + "status": "completed", + "elapsed_time": 0 + }, + { + "cycle_idx": 2, + "node_id": "llm_1767617560401_bsx1vhi25", + "node_name": "大语言模型 (LLM)-润色器", + "icon": "/src/assets/images/workflow/llm.png", + "content": { + "cycle_idx": 2, + "input": { + "prompt": null, + "messages": [ + { + "role": "system", + "content": "请根据《咏一·再题》 \n一芥浮空万籁停, \n千峰影落太初青。 \n光非燃烛恒明在, \n心不沾尘即性灵。 \n\n注:本诗续写“以一为魂”之旨,严守平水韵九青部(停、青、灵),平仄精严。首句“一芥”承原作微渺意象,而“万籁停”较“收声”更显寂然自定之境;次句倒装“千峰影落”,使苍茫山势如墨痕沉入宇宙初青,暗契《淮南子》“虚霩生宇宙,宇宙生气”之太始气象。三句翻出新境:“光非燃烛”,破除对光明之形器执取,直指《楞严经》“性觉妙明,本觉明妙”之不假缘起的本明;结句“心不沾尘即性灵”,化用六祖“本来无一物”与程颢“天地之大德曰生”,言“一”非枯寂之数,乃活泼泼的性灵朗现——此即《道德经》“昔之得一者,天清地宁”的诗性澄明。 为主题写一首七字诗。" + } + ], + "config": { + "model_id": "2699984d-23be-4817-b81c-c38682a08306", + "temperature": 0.7, + "max_tokens": 2000 + } + }, + "output": "《咏一·三题》 \n孤光未凿太初溟, \n一粟吞天万籁宁。 \n影堕千峰青未染, \n心空四象白犹灵。 \n非从烛焰求明性, \n但向尘劳见本形。 \n忽有松风穿石罅, \n泠然吹落满山星。 \n\n注:本诗严守平水韵九青部(溟、宁、灵、形、星),其中“星”属下平声九青部异读字(《广韵》息盈切,与“灵”“宁”同部),古诗常用以协律,如王维“清溪流过碧山头,空水澄鲜一色秋。隔断红尘三十里,白云红叶两悠悠”中“悠”亦借韵通协。全诗紧扣“以一为魂”之旨:首句“孤光未凿”化《庄子·应帝王》“浑沌凿七窍而死”典,反写太初本明未分之境;次句“一粟吞天”,以微纳巨,承“一芥”而力愈雄浑;颔联“青未染”“白犹灵”,双色映照,暗喻性体离垢绝染而朗然常照;颈联直破二边——不假烛焰(破外求)、不避尘劳(破厌离),显《坛经》“佛法在世间,不离世间觉”之旨;结句松风裂石、星落满山,是“一”之活泼妙用:寂而常照,照而恒寂,恰如《道德经》“天得一以清,地得一以宁”之诗性证成。" + }, + "status": "completed", + "elapsed_time": 9.531717538833618 + }, + { + "cycle_idx": 2, + "node_id": "assigner_1768285417545_qsoqleflh", + "node_name": "变量赋值", + "icon": "/src/assets/images/workflow/assigner.png", + "content": { + "cycle_idx": 2, + "input": { + "config": { + "assignments": [ + { + "value": "{{llm_1767617560401_bsx1vhi25.output}}", + "operation": "cover", + "variable_selector": "{{loop_1767617552451_hq3j342ha.poem_content}}" + }, + { + "value": 1, + "operation": "add", + "variable_selector": "{{loop_1767617552451_hq3j342ha.round}}" + } + ] + } + }, + "output": null + }, + "status": "completed", + "elapsed_time": 0 + } + ], + "status": "completed", + "elapsed_time": 23.57662582397461 + }, + { + "id": "end_1767619139811_ko97mb12l", + "node_id": "end_1767619139811_ko97mb12l", + "node_name": "结束(End)", + "icon": "/src/assets/images/workflow/end.png", + "content": { + "input": { + "config": { + "output": "经过多次打磨,最终作品如下:\n{{loop_1767617552451_hq3j342ha.poem_content}} \nLLM1结果:\n{{llm_1767617499720_zvqwjpw3b.output}} " + } + }, + "output": "经过多次打磨,最终作品如下:\n《咏一·三题》 \n孤光未凿太初溟, \n一粟吞天万籁宁。 \n影堕千峰青未染, \n心空四象白犹灵。 \n非从烛焰求明性, \n但向尘劳见本形。 \n忽有松风穿石罅, \n泠然吹落满山星。 \n\n注:本诗严守平水韵九青部(溟、宁、灵、形、星),其中“星”属下平声九青部异读字(《广韵》息盈切,与“灵”“宁”同部),古诗常用以协律,如王维“清溪流过碧山头,空水澄鲜一色秋。隔断红尘三十里,白云红叶两悠悠”中“悠”亦借韵通协。全诗紧扣“以一为魂”之旨:首句“孤光未凿”化《庄子·应帝王》“浑沌凿七窍而死”典,反写太初本明未分之境;次句“一粟吞天”,以微纳巨,承“一芥”而力愈雄浑;颔联“青未染”“白犹灵”,双色映照,暗喻性体离垢绝染而朗然常照;颈联直破二边——不假烛焰(破外求)、不避尘劳(破厌离),显《坛经》“佛法在世间,不离世间觉”之旨;结句松风裂石、星落满山,是“一”之活泼妙用:寂而常照,照而恒寂,恰如《道德经》“天得一以清,地得一以宁”之诗性证成。 \nLLM1结果:\n《咏一》 \n孤峰独峙破苍冥, \n一芥微身立太清。 \n万古乾坤凝此数, \n千山雪落只无声。 \n\n注:本诗以“一”为魂,通过“孤峰”“一芥”“此数”层层递进,赋予数字哲思——既写天地间唯一性之壮美(孤峰破冥),又寓渺小个体与永恒宇宙的辩证(芥子纳太清)。末句“千山雪落只无声”,以大静写大一,雪覆千山而声息俱寂,暗合《道德经》“天得一以清”之境。平仄依平水韵,押九青部(冥、清、声)。 " + }, + "status": "completed", + "elapsed_time": 0.0005218982696533203 + } + ], + "status": "completed" + } + ]) // Chat message history const [variables, setVariables] = useState([]) // Workflow input variables const [streamLoading, setStreamLoading] = useState(false) // SSE streaming state const [conversationId, setConversationId] = useState(null) // Current conversation ID @@ -176,9 +980,11 @@ const Chat = forwardRef(({ appId */ const handleStreamMessage = (data: SSEMessage[]) => { data.forEach(item => { - const { chunk, conversation_id, node_id, input, output, error, elapsed_time, status } = item.data as { + const { chunk, conversation_id, node_id, cycle_id, cycle_idx, input, output, error, elapsed_time, status } = item.data as { chunk: string; conversation_id: string | null; + cycle_id: string; + cycle_idx: number; node_id: string; node_name?: string; input?: any; @@ -192,8 +998,6 @@ const Chat = forwardRef(({ appId const node = graphRef.current?.getNodes().find(n => n.id === node_id); const { name, icon } = node?.getData() || {} - console.log('node', node?.getData()) - switch(item.event) { // Append streaming text chunks to assistant message case 'message': @@ -271,6 +1075,44 @@ const Chat = forwardRef(({ appId return newList }) break + // Update node with subContent + case 'cycle_item': + setChatList(prev => { + const newList = [...prev] + const lastIndex = newList.length - 1 + if (lastIndex >= 0) { + const newSubContent = newList[lastIndex].subContent || [] + const filterIndex = newSubContent.findIndex(vo => vo.id === cycle_id) + if (filterIndex > -1) { + const items = newSubContent[filterIndex].subContent || [] + items.push({ + cycle_id, + cycle_idx, + node_id, + node_name: name, + icon, + content: { + cycle_idx, + input, + output, + error, + }, + status: status || 'completed', + elapsed_time + }) + newSubContent[filterIndex] = { + ...newSubContent[filterIndex], + subContent: [...items] + } + newList[lastIndex] = { + ...newList[lastIndex], + subContent: newSubContent + } + } + } + return newList + }) + break // Mark workflow as complete case 'workflow_end': setChatList(prev => { @@ -383,12 +1225,6 @@ const Chat = forwardRef(({ appId handleClose })); - /** - * Returns CSS class for status-based text color - */ - const getStatus = (status?: string) => { - return status === 'completed' ? 'rb:text-[#369F21]' : status === 'failed' ? 'rb:text-[#FF5D34]' : 'rb:text-[#5B6167]' - } return ( @@ -413,85 +1249,7 @@ const Chat = forwardRef(({ appId labelFormat={(item) => dayjs(item.created_at).locale('en').format('MMMM D, YYYY [at] h:mm A')} errorDesc={t('application.ReplyException')} renderRuntime={(item, index) => { - return ( -
- - {item.status === 'completed' ? : item.status === 'failed' ? : } - {t('application.workflow')} -
, - className: styles.collapseItem, - children: ( - Array.isArray(item.subContent) - ? - {item.subContent?.map(vo => ( - -
- {vo.icon && } -
{vo.node_name || vo.node_id}
-
- - {typeof vo.elapsed_time == 'number' && <>{vo.elapsed_time?.toFixed(3)}ms} - {vo.status === 'completed' ? : vo.status === 'failed' ? : } - -
, - className: styles.collapseItem, - children: ( - - {vo.status === 'failed' && -
-
- {t(`workflow.error`)} - -
-
- -
-
- } - {['input', 'output'].map(key => ( -
-
- {t(`workflow.${key}`)} - -
-
- -
-
- ))} -
- ) - }]} - /> - ))} - - :
- -
- ) - }]} - /> -
- ) + return }} />
diff --git a/web/src/views/Workflow/components/Chat/Runtime.tsx b/web/src/views/Workflow/components/Chat/Runtime.tsx new file mode 100644 index 00000000..0f18f4da --- /dev/null +++ b/web/src/views/Workflow/components/Chat/Runtime.tsx @@ -0,0 +1,234 @@ +/* + * @Author: ZhaoYing + * @Date: 2026-02-24 17:57:08 + * @Last Modified by: ZhaoYing + * @Last Modified time: 2026-02-24 17:57:08 + */ +/* + * Runtime Component + * + * This component displays the execution runtime details of workflow nodes in a chat interface. + * It provides a hierarchical view of workflow execution with support for: + * - Node execution status (completed, failed, running) + * - Nested loop and iteration cycles + * - Input/output data visualization + * - Error messages for failed nodes + * - Elapsed time tracking + */ +import { type FC, useState } from 'react' +import { useTranslation } from 'react-i18next' +import clsx from 'clsx' +import { Space, Button, Collapse, Flex } from 'antd' +import { CheckCircleFilled, CloseCircleFilled, LoadingOutlined, RightOutlined, ArrowLeftOutlined } from '@ant-design/icons' + +import styles from './chat.module.css' +import type { ChatItem } from '@/components/Chat/types' +import Markdown from '@/components/Markdown' +import CodeBlock from '@/components/Markdown/CodeBlock' + +/** + * Runtime component props + * @param item - Chat item containing workflow execution data + * @param index - Index of the chat item in the list + */ +const Runtime: FC<{ item: ChatItem; index: number;}> = ({ + item, + index +}) => { + const { t } = useTranslation() + // Stores the currently selected detail view (for nested loop/iteration exploration) + const [detail, setDetail] = useState(null) + // Tracks whether the current detail view is for a loop (true) or iteration (false) + const [loop, setLoop] = useState(null) + + /** + * Handles navigation into nested loop/iteration details + * @param vo - The node object containing subContent to display + * @param isLoop - Whether this is a loop node (true) or iteration node (false) + */ + const handleViewDetail = (vo: any, isLoop: boolean) => { + setDetail(vo) + setLoop(isLoop) + } + + /** + * Returns CSS class for status-based text color + * @param status - Node execution status: 'completed', 'failed', or other + * @returns Tailwind CSS class for appropriate color + */ + const getStatus = (status?: string) => { + return status === 'completed' ? 'rb:text-[#369F21]' : status === 'failed' ? 'rb:text-[#FF5D34]' : 'rb:text-[#5B6167]' + } + + /** + * Renders child nodes grouped by cycle index (for loop/iteration nodes) + * Groups nodes by their cycle_idx and displays them in separate collapsible sections + * @param list - Array of child node execution data + */ + const renderDetailChild = (list: any) => { + // Group nodes by cycle_idx to organize loop/iteration cycles + const groupedByCycle = list.reduce((acc: any, item: any) => { + const idx = item.cycle_idx ?? 0 + if (!acc[idx]) acc[idx] = [] + acc[idx].push(item) + return acc + }, {}) + + + return ( + + {Object.entries(groupedByCycle).map(([cycleIdx, items]: [string, any]) => { + return ( + + {t(`workflow.runtime.${loop ? 'loop' : 'iteration'}`)} {Number(cycleIdx) + 1} +
, + className: styles.collapseItem, + children: renderChild(items) + }]} + /> + ) + })} + + ) + } + + /** + * Renders detailed view of child nodes with their execution information + * Displays node status, input/output data, errors, and nested cycles + * @param list - Array of node execution data or error message string + */ + const renderChild = (list: any) => { + if (Array.isArray(list)) { + return + {list?.map(vo => { + const isLoop = vo.node_id.startsWith('loop'); + // Render cycle variables for loop nodes without node_name + if (typeof vo.cycle_idx === 'number' && isLoop && !vo.node_name) { + return
+
+ {t(`workflow.config.loop.cycle_vars`)} + +
+
+ +
+
+ } + // Skip rendering if no node_name is present + if (!vo.node_name) return null + + // Render collapsible node with status, timing, and execution details + return ( + +
+ {vo.icon && } +
{vo.node_name}
+
+ + {typeof vo.elapsed_time == 'number' && <>{vo.elapsed_time?.toFixed(3)}ms} + {vo.status === 'completed' ? : vo.status === 'failed' ? : } + +
, + className: styles.collapseItem, + children: ( + + {/* Display error message for failed nodes */} + {vo.status === 'failed' && +
+
+ {t(`workflow.error`)} + +
+
+ +
+
+ } + {/* Display navigation to nested cycles if subContent exists */} + {vo.subContent?.length > 0 && ( + handleViewDetail(vo, vo.node_id.startsWith('loop'))}> + {Math.max(...vo.subContent.map((itemVo: any) => itemVo.cycle_idx + 1))} {t(`workflow.${isLoop ? 'loopNum' : 'iterationNum'}`)} + + + )} + {/* Display input and output data as JSON code blocks */} + {['input', 'output'].map(key => ( +
+
+ {isLoop ? t(`workflow.runtime.${key}_cycle_vars`) : t(`workflow.${key}`)} + +
+
+ +
+
+ ))} +
+ ) + }]} + /> + ) + })} + + } + return
+ +
+ } + + return ( +
+ + {item.status === 'completed' ? : item.status === 'failed' ? : } + {t('application.workflow')} +
, + className: styles.collapseItem, + children: ( + detail + ? ( +
+ + {renderDetailChild(detail.subContent)} +
+ ) + : renderChild(item.subContent) + ) + }]} + /> +
+ ) +} +export default Runtime \ No newline at end of file From b4f69f2cffad194aa07becbd53688885f97475ec Mon Sep 17 00:00:00 2001 From: lixiangcheng1 Date: Tue, 24 Feb 2026 18:29:31 +0800 Subject: [PATCH 13/36] [fix]Force re-importing Trio in child processes (to avoid inheriting the state of the parent process) --- api/app/tasks.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/api/app/tasks.py b/api/app/tasks.py index e29d0406..99755b83 100644 --- a/api/app/tasks.py +++ b/api/app/tasks.py @@ -13,7 +13,6 @@ from typing import Any, Dict, List, Optional import redis import requests -import trio # Import a unified Celery instance from app.celery_app import celery_app @@ -65,6 +64,10 @@ def parse_document(file_path: str, document_id: uuid.UUID): """ Document parsing, vectorization, and storage """ + # Force re-importing Trio in child processes (to avoid inheriting the state of the parent process) + import trio + import importlib + importlib.reload(trio) db = next(get_db()) # Manually call the generator db_document = None db_knowledge = None @@ -291,6 +294,10 @@ def build_graphrag_for_kb(kb_id: uuid.UUID): """ build knowledge graph """ + # Force re-importing Trio in child processes (to avoid inheriting the state of the parent process) + import trio + import importlib + importlib.reload(trio) db = next(get_db()) # Manually call the generator db_documents = None db_knowledge = None From 5ca397befa2d4826d0c22f6fb30f76d173c13953 Mon Sep 17 00:00:00 2001 From: lixiangcheng1 Date: Wed, 25 Feb 2026 10:27:16 +0800 Subject: [PATCH 14/36] [ADD]mcp market: Obtain the list of MCP services from MCP Market Source - ModelScope --- api/app/controllers/__init__.py | 4 + .../mcp_market_config_controller.py | 336 ++++++++++++++++++ api/app/controllers/mcp_market_controller.py | 262 ++++++++++++++ api/app/models/__init__.py | 4 + api/app/models/mcp_market_config_model.py | 16 + api/app/models/mcp_market_model.py | 18 + .../mcp_market_config_repository.py | 72 ++++ api/app/repositories/mcp_market_repository.py | 124 +++++++ api/app/schemas/__init__.py | 8 + api/app/schemas/mcp_market_config_schema.py | 31 ++ api/app/schemas/mcp_market_schema.py | 37 ++ api/app/services/mcp_market_config_service.py | 83 +++++ api/app/services/mcp_market_service.py | 109 ++++++ api/pyproject.toml | 1 + api/requirements.txt | 1 + 15 files changed, 1106 insertions(+) create mode 100644 api/app/controllers/mcp_market_config_controller.py create mode 100644 api/app/controllers/mcp_market_controller.py create mode 100644 api/app/models/mcp_market_config_model.py create mode 100644 api/app/models/mcp_market_model.py create mode 100644 api/app/repositories/mcp_market_config_repository.py create mode 100644 api/app/repositories/mcp_market_repository.py create mode 100644 api/app/schemas/mcp_market_config_schema.py create mode 100644 api/app/schemas/mcp_market_schema.py create mode 100644 api/app/services/mcp_market_config_service.py create mode 100644 api/app/services/mcp_market_service.py diff --git a/api/app/controllers/__init__.py b/api/app/controllers/__init__.py index 67040f40..5c33d6b0 100644 --- a/api/app/controllers/__init__.py +++ b/api/app/controllers/__init__.py @@ -19,6 +19,8 @@ from . import ( implicit_memory_controller, knowledge_controller, knowledgeshare_controller, + mcp_market_controller, + mcp_market_config_controller, memory_agent_controller, memory_dashboard_controller, memory_episodic_controller, @@ -62,6 +64,8 @@ manager_router.include_router(model_controller.router) manager_router.include_router(file_controller.router) manager_router.include_router(document_controller.router) manager_router.include_router(knowledge_controller.router) +manager_router.include_router(mcp_market_controller.router) +manager_router.include_router(mcp_market_config_controller.router) manager_router.include_router(chunk_controller.router) manager_router.include_router(test_controller.router) manager_router.include_router(knowledgeshare_controller.router) diff --git a/api/app/controllers/mcp_market_config_controller.py b/api/app/controllers/mcp_market_config_controller.py new file mode 100644 index 00000000..98012568 --- /dev/null +++ b/api/app/controllers/mcp_market_config_controller.py @@ -0,0 +1,336 @@ +import datetime +import json +from typing import Optional +import uuid + +from fastapi import APIRouter, Depends, HTTPException, status, Query +from fastapi.encoders import jsonable_encoder +import requests +from sqlalchemy import or_ +from sqlalchemy.orm import Session +from modelscope.hub.errors import raise_for_http_status +from modelscope.hub.mcp_api import MCPApi + +from app.core.logging_config import get_api_logger +from app.core.response_utils import success, fail +from app.db import get_db +from app.dependencies import get_current_user +from app.models import mcp_market_config_model +from app.models.user_model import User +from app.schemas import mcp_market_config_schema +from app.schemas.response_schema import ApiResponse +from app.services import mcp_market_config_service + +# Obtain a dedicated API logger +api_logger = get_api_logger() + +router = APIRouter( + prefix="/mcp_market_configs", + tags=["mcp_market_configs"], + dependencies=[Depends(get_current_user)] # Apply auth to all routes in this controller +) + + +@router.get("/mcp_servers", response_model=ApiResponse) +async def get_mcp_servers( + mcp_market_config_id: uuid.UUID, + page: int = Query(1, gt=0), # Default: 1, which must be greater than 0 + pagesize: int = Query(20, gt=0, le=100), # Default: 20 items per page, maximum: 100 items + keywords: Optional[str] = Query(None, description="Search keywords (Optional search query string,e.g. Chinese service name, English service name, author/owner username)"), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + Query the mcp servers list in pages + - Support keyword search for name,author,owner + - Return paging metadata + mcp server list + """ + api_logger.info( + f"Query mcp server list: tenant_id={current_user.tenant_id}, page={page}, pagesize={pagesize}, keywords={keywords}, username: {current_user.username}") + + # 1. parameter validation + if page < 1 or pagesize < 1: + api_logger.warning(f"Error in paging parameters: page={page}, pagesize={pagesize}") + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="The paging parameter must be greater than 0" + ) + + # 2. Query mcp market config information from the database + api_logger.debug(f"Query mcp market config: {mcp_market_config_id}") + db_mcp_market_config = mcp_market_config_service.get_mcp_market_config_by_id(db, + mcp_market_config_id=mcp_market_config_id, + current_user=current_user) + if not db_mcp_market_config: + api_logger.warning( + f"The mcp market config does not exist or access is denied: mcp_market_config_id={mcp_market_config_id}") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="The mcp market config does not exist or access is denied" + ) + + # 3. Execute paged query + api = MCPApi() + token = db_mcp_market_config.token + api.login(token) + + body = { + 'filter': {}, + 'page_number': page, + 'page_size': pagesize, + 'search': keywords + } + + try: + cookies = api.get_cookies(token) + r = api.session.put( + url=api.mcp_base_url, + headers=api.builder_headers(api.headers), + json=body, + cookies=cookies) + raise_for_http_status(r) + except requests.exceptions.RequestException as e: + api_logger.error(f"mFailed to get MCP servers: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to get MCP servers: {str(e)}" + ) + + data = api._handle_response(r) + total = data.get('total_count', 0) + mcp_server_list = data.get('mcp_server_list', []) + # items = [{ + # 'name': item.get('name', ''), + # 'id': item.get('id', ''), + # 'description': item.get('description', '') + # } for item in mcp_server_list] + + # 4. Return structured response + result = { + "items": mcp_server_list, + "page": { + "page": page, + "pagesize": pagesize, + "total": total, + "has_next": True if page * pagesize < total else False + } + } + return success(data=result, msg="Query of mcp servers list successful") + + +@router.get("/mcp_server", response_model=ApiResponse) +async def get_mcp_server( + mcp_market_config_id: uuid.UUID, + server_id: str, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + Get detailed information for a specific MCP Server + """ + api_logger.info( + f"Query mcp server: tenant_id={current_user.tenant_id}, mcp_market_config_id={mcp_market_config_id}, server_id={server_id}, username: {current_user.username}") + + # 1. Query mcp market config information from the database + api_logger.debug(f"Query mcp market config: {mcp_market_config_id}") + db_mcp_market_config = mcp_market_config_service.get_mcp_market_config_by_id(db, + mcp_market_config_id=mcp_market_config_id, + current_user=current_user) + if not db_mcp_market_config: + api_logger.warning( + f"The mcp market config does not exist or access is denied: mcp_market_config_id={mcp_market_config_id}") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="The mcp market config does not exist or access is denied" + ) + + # 2. Get detailed information for a specific MCP Server + api = MCPApi() + token = db_mcp_market_config.token + api.login(token) + + result = api.get_mcp_server(server_id=server_id) + return success(data=result, msg="Query of mcp servers list successful") + + +@router.post("/mcp_market_config", response_model=ApiResponse) +async def create_mcp_market_config( + create_data: mcp_market_config_schema.McpMarketConfigCreate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + create mcp market config + """ + api_logger.info( + f"Request to create a mcp market config: mcp_market_id={create_data.mcp_market_id}, tenant_id={current_user.tenant_id}, username: {current_user.username}") + + try: + api_logger.debug(f"Start creating the mcp market config: {create_data.mcp_market_id}") + # 1. Check if the mcp market name already exists + db_mcp_market_config_exist = mcp_market_config_service.get_mcp_market_config_by_mcp_market_id(db, mcp_market_id=create_data.mcp_market_id, current_user=current_user) + if db_mcp_market_config_exist: + api_logger.warning(f"The mcp market id already exists: {create_data.mcp_market_id}") + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"The mcp market id already exists: {create_data.mcp_market_id}" + ) + db_mcp_market_config = mcp_market_config_service.create_mcp_market_config(db=db, mcp_market_config=create_data, current_user=current_user) + api_logger.info( + f"The mcp market config has been successfully created: (ID: {db_mcp_market_config.id})") + return success(data=jsonable_encoder(mcp_market_config_schema.McpMarketConfig.model_validate(db_mcp_market_config)), + msg="The mcp market config has been successfully created") + except Exception as e: + api_logger.error(f"The creation of the mcp market config failed: {create_data.mcp_market_id} - {str(e)}") + raise + + +@router.get("/{mcp_market_config_id}", response_model=ApiResponse) +async def get_mcp_market_config( + mcp_market_config_id: uuid.UUID, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + Retrieve mcp market config information based on mcp_market_config_id + """ + api_logger.info( + f"Obtain details of the mcp market config: mcp_market_config_id={mcp_market_config_id}, username: {current_user.username}") + + try: + # 1. Query mcp market config information from the database + api_logger.debug(f"Query mcp market config: {mcp_market_config_id}") + db_mcp_market_config = mcp_market_config_service.get_mcp_market_config_by_id(db, mcp_market_config_id=mcp_market_config_id, current_user=current_user) + if not db_mcp_market_config: + api_logger.warning(f"The mcp market config does not exist or access is denied: mcp_market_config_id={mcp_market_config_id}") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="The mcp market config does not exist or access is denied" + ) + + api_logger.info(f"mcp market config query successful: (ID: {db_mcp_market_config.id})") + return success(data=jsonable_encoder(mcp_market_config_schema.McpMarketConfig.model_validate(db_mcp_market_config)), + msg="Successfully obtained mcp market config information") + except HTTPException: + raise + except Exception as e: + api_logger.error(f"mcp market config query failed: mcp_market_config_id={mcp_market_config_id} - {str(e)}") + raise + + +@router.get("/mcp_market_id/{mcp_market_id}", response_model=ApiResponse) +async def get_mcp_market_config_by_mcp_market_id( + mcp_market_id: uuid.UUID, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + Retrieve mcp market config information based on mcp_market_id + """ + api_logger.info( + f"Request to create a mcp market config: mcp_market_id={mcp_market_id}, tenant_id={current_user.tenant_id}, username: {current_user.username}") + + try: + # 1. Query mcp market config information from the database + api_logger.debug(f"Query mcp market config: mcp_market_id={mcp_market_id}") + db_mcp_market_config = mcp_market_config_service.get_mcp_market_config_by_mcp_market_id(db, mcp_market_id=mcp_market_id, current_user=current_user) + if not db_mcp_market_config: + api_logger.warning(f"The mcp market config does not exist or access is denied: mcp_market_id={mcp_market_id}") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="The mcp market config does not exist or access is denied" + ) + + api_logger.info(f"mcp market config query successful: (ID: {db_mcp_market_config.id})") + return success(data=jsonable_encoder(mcp_market_config_schema.McpMarketConfig.model_validate(db_mcp_market_config)), + msg="Successfully obtained mcp market config information") + except HTTPException: + raise + except Exception as e: + api_logger.error(f"mcp market config query failed: mcp_market_id={mcp_market_id} - {str(e)}") + raise + + +@router.put("/{mcp_market_config_id}", response_model=ApiResponse) +async def update_mcp_market_config( + mcp_market_config_id: uuid.UUID, + update_data: mcp_market_config_schema.McpMarketConfigUpdate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + # 1. Check if the mcp market config exists + api_logger.debug(f"Query the mcp market config to be updated: {mcp_market_config_id}") + db_mcp_market_config = mcp_market_config_service.get_mcp_market_config_by_id(db, mcp_market_config_id=mcp_market_config_id, current_user=current_user) + + if not db_mcp_market_config: + api_logger.warning( + f"The mcp market config does not exist or you do not have permission to access it: mcp_market_config_id={mcp_market_config_id}") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="The mcp market config does not exist or you do not have permission to access it" + ) + + # 2. Update fields (only update non-null fields) + api_logger.debug(f"Start updating the mcp market config fields: {mcp_market_config_id}") + update_dict = update_data.dict(exclude_unset=True) + updated_fields = [] + for field, value in update_dict.items(): + if hasattr(db_mcp_market_config, field): + old_value = getattr(db_mcp_market_config, field) + if old_value != value: + # update value + setattr(db_mcp_market_config, field, value) + updated_fields.append(f"{field}: {old_value} -> {value}") + + if updated_fields: + api_logger.debug(f"updated fields: {', '.join(updated_fields)}") + + # 3. Save to database + try: + db.commit() + db.refresh(db_mcp_market_config) + api_logger.info(f"The mcp market config has been successfully updated: (ID: {db_mcp_market_config.id})") + except Exception as e: + db.rollback() + api_logger.error(f"The mcp market config update failed: mcp_market_config_id={mcp_market_config_id} - {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"The mcp market config update failed: {str(e)}" + ) + + # 4. Return the updated mcp market config + return success(data=jsonable_encoder(mcp_market_config_schema.McpMarketConfig.model_validate(db_mcp_market_config)), + msg="The mcp market config information updated successfully") + + +@router.delete("/{mcp_market_config_id}", response_model=ApiResponse) +async def delete_mcp_market_config( + mcp_market_config_id: uuid.UUID, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + delete mcp market config + """ + api_logger.info(f"Request to delete mcp market config: mcp_market_config_id={mcp_market_config_id}, username: {current_user.username}") + + try: + # 1. Check whether the mcp market config exists + api_logger.debug(f"Check whether the mcp market config exists: {mcp_market_config_id}") + db_mcp_market_config = mcp_market_config_service.get_mcp_market_config_by_id(db, mcp_market_config_id=mcp_market_config_id, current_user=current_user) + + if not db_mcp_market_config: + api_logger.warning( + f"The mcp market config does not exist or you do not have permission to access it: mcp_market_config_id={mcp_market_config_id}") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="The mcp market config does not exist or you do not have permission to access it" + ) + + # 2. Deleting mcp market config + mcp_market_config_service.delete_mcp_market_config_by_id(db, mcp_market_config_id=mcp_market_config_id, current_user=current_user) + api_logger.info(f"The mcp market config has been successfully deleted: (ID: {mcp_market_config_id})") + return success(msg="The mcp market config has been successfully deleted") + except Exception as e: + api_logger.error(f"Failed to delete from the mcp market config: mcp_market_config_id={mcp_market_config_id} - {str(e)}") + raise diff --git a/api/app/controllers/mcp_market_controller.py b/api/app/controllers/mcp_market_controller.py new file mode 100644 index 00000000..61531a0f --- /dev/null +++ b/api/app/controllers/mcp_market_controller.py @@ -0,0 +1,262 @@ +import datetime +import json +from typing import Optional +import uuid + +from fastapi import APIRouter, Depends, HTTPException, status, Query +from fastapi.encoders import jsonable_encoder +from sqlalchemy import or_ +from sqlalchemy.orm import Session + +from app.core.logging_config import get_api_logger +from app.core.response_utils import success, fail +from app.db import get_db +from app.dependencies import get_current_user +from app.models import mcp_market_model +from app.models.user_model import User +from app.schemas import mcp_market_schema +from app.schemas.response_schema import ApiResponse +from app.services import mcp_market_service + +# Obtain a dedicated API logger +api_logger = get_api_logger() + +router = APIRouter( + prefix="/mcp_markets", + tags=["mcp_markets"], + dependencies=[Depends(get_current_user)] # Apply auth to all routes in this controller +) + + +@router.get("/mcp_markets", response_model=ApiResponse) +async def get_mcp_markets( + page: int = Query(1, gt=0), # Default: 1, which must be greater than 0 + pagesize: int = Query(20, gt=0, le=100), # Default: 20 items per page, maximum: 100 items + orderby: Optional[str] = Query(None, description="Sort fields, such as: category, created_at"), + desc: Optional[bool] = Query(False, description="Is it descending order"), + keywords: Optional[str] = Query(None, description="Search keywords (mcp_market base name)"), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + Query the mcp markets list in pages + - Support keyword search for name,description + - Support dynamic sorting + - Return paging metadata + mcp_market list + """ + api_logger.info( + f"Query mcp market list: tenant_id={current_user.tenant_id}, page={page}, pagesize={pagesize}, keywords={keywords}, username: {current_user.username}") + + # 1. parameter validation + if page < 1 or pagesize < 1: + api_logger.warning(f"Error in paging parameters: page={page}, pagesize={pagesize}") + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="The paging parameter must be greater than 0" + ) + + # 2. Construct query conditions + filters = [] + + # Keyword search (fuzzy matching of mcp market name,description) + if keywords: + api_logger.debug(f"Add keyword search criteria: {keywords}") + filters.append( + or_( + mcp_market_model.McpMarket.name.ilike(f"%{keywords}%"), + mcp_market_model.McpMarket.description.ilike(f"%{keywords}%") + ) + ) + # 3. Execute paged query + try: + api_logger.debug("Start executing mcp market paging query") + total, items = mcp_market_service.get_mcp_markets_paginated( + db=db, + filters=filters, + page=page, + pagesize=pagesize, + orderby=orderby, + desc=desc, + current_user=current_user + ) + api_logger.info(f"mcp market query successful: total={total}, returned={len(items)} records") + except Exception as e: + api_logger.error(f"mcp market query failed: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Query failed: {str(e)}" + ) + + # 4. Return structured response + result = { + "items": items, + "page": { + "page": page, + "pagesize": pagesize, + "total": total, + "has_next": True if page * pagesize < total else False + } + } + return success(data=jsonable_encoder(result), msg="Query of mcp market list successful") + + +@router.post("/mcp_market", response_model=ApiResponse) +async def create_mcp_market( + create_data: mcp_market_schema.McpMarketCreate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + create mcp market + """ + api_logger.info( + f"Request to create a mcp market: name={create_data.name}, tenant_id={current_user.tenant_id}, username: {current_user.username}") + + try: + api_logger.debug(f"Start creating the mcp market: {create_data.name}") + # 1. Check if the mcp market name already exists + db_mcp_market_exist = mcp_market_service.get_mcp_market_by_name(db, name=create_data.name, current_user=current_user) + if db_mcp_market_exist: + api_logger.warning(f"The mcp market name already exists: {create_data.name}") + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"The mcp market name already exists: {create_data.name}" + ) + db_mcp_market = mcp_market_service.create_mcp_market(db=db, mcp_market=create_data, current_user=current_user) + api_logger.info( + f"The mcp market has been successfully created: {db_mcp_market.name} (ID: {db_mcp_market.id})") + return success(data=jsonable_encoder(mcp_market_schema.McpMarket.model_validate(db_mcp_market)), + msg="The mcp market has been successfully created") + except Exception as e: + api_logger.error(f"The creation of the mcp market failed: {create_data.name} - {str(e)}") + raise + + +@router.get("/{mcp_market_id}", response_model=ApiResponse) +async def get_mcp_market( + mcp_market_id: uuid.UUID, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + Retrieve mcp market information based on mcp_market_id + """ + api_logger.info( + f"Obtain details of the mcp market: mcp_market_id={mcp_market_id}, username: {current_user.username}") + + try: + # 1. Query mcp market information from the database + api_logger.debug(f"Query mcp market: {mcp_market_id}") + db_mcp_market = mcp_market_service.get_mcp_market_by_id(db, mcp_market_id=mcp_market_id, current_user=current_user) + if not db_mcp_market: + api_logger.warning(f"The mcp market does not exist or access is denied: mcp_market_id={mcp_market_id}") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="The mcp market does not exist or access is denied" + ) + + api_logger.info(f"mcp market query successful: {db_mcp_market.name} (ID: {db_mcp_market.id})") + return success(data=jsonable_encoder(mcp_market_schema.McpMarket.model_validate(db_mcp_market)), + msg="Successfully obtained mcp market information") + except HTTPException: + raise + except Exception as e: + api_logger.error(f"mcp market query failed: mcp_market_id={mcp_market_id} - {str(e)}") + raise + + +@router.put("/{mcp_market_id}", response_model=ApiResponse) +async def update_mcp_market( + mcp_market_id: uuid.UUID, + update_data: mcp_market_schema.McpMarketUpdate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + # 1. Check if the mcp market exists + api_logger.debug(f"Query the mcp market to be updated: {mcp_market_id}") + db_mcp_market = mcp_market_service.get_mcp_market_by_id(db, mcp_market_id=mcp_market_id, current_user=current_user) + + if not db_mcp_market: + api_logger.warning( + f"The mcp market does not exist or you do not have permission to access it: mcp_market_id={mcp_market_id}") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="The mcp market does not exist or you do not have permission to access it" + ) + + # 2. not updating the name (name already exists) + update_dict = update_data.dict(exclude_unset=True) + if "name" in update_dict: + name = update_dict["name"] + if name != db_mcp_market.name: + # Check if the mcp market name already exists + db_mcp_market_exist = mcp_market_service.get_mcp_market_by_name(db, name=name, current_user=current_user) + if db_mcp_market_exist: + api_logger.warning(f"The mcp market name already exists: {name}") + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"The mcp market name already exists: {name}" + ) + # 3. Update fields (only update non-null fields) + api_logger.debug(f"Start updating the mcp market fields: {mcp_market_id}") + updated_fields = [] + for field, value in update_dict.items(): + if hasattr(db_mcp_market, field): + old_value = getattr(db_mcp_market, field) + if old_value != value: + # update value + setattr(db_mcp_market, field, value) + updated_fields.append(f"{field}: {old_value} -> {value}") + + if updated_fields: + api_logger.debug(f"updated fields: {', '.join(updated_fields)}") + + # 4. Save to database + try: + db.commit() + db.refresh(db_mcp_market) + api_logger.info(f"The mcp market has been successfully updated: {db_mcp_market.name} (ID: {db_mcp_market.id})") + except Exception as e: + db.rollback() + api_logger.error(f"The mcp market update failed: mcp_market_id={mcp_market_id} - {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"The mcp market update failed: {str(e)}" + ) + + # 5. Return the updated mcp market + return success(data=jsonable_encoder(mcp_market_schema.McpMarket.model_validate(db_mcp_market)), + msg="The mcp market information updated successfully") + + +@router.delete("/{mcp_market_id}", response_model=ApiResponse) +async def delete_mcp_market( + mcp_market_id: uuid.UUID, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + delete mcp market + """ + api_logger.info(f"Request to delete mcp market: mcp_market_id={mcp_market_id}, username: {current_user.username}") + + try: + # 1. Check whether the mcp market exists + api_logger.debug(f"Check whether the mcp market exists: {mcp_market_id}") + db_mcp_market = mcp_market_service.get_mcp_market_by_id(db, mcp_market_id=mcp_market_id, current_user=current_user) + + if not db_mcp_market: + api_logger.warning( + f"The mcp market does not exist or you do not have permission to access it: mcp_market_id={mcp_market_id}") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="The mcp market does not exist or you do not have permission to access it" + ) + + # 2. Deleting mcp market + mcp_market_service.delete_mcp_market_by_id(db, mcp_market_id=mcp_market_id, current_user=current_user) + api_logger.info(f"The mcp market has been successfully deleted: (ID: {mcp_market_id})") + return success(msg="The mcp market has been successfully deleted") + except Exception as e: + api_logger.error(f"Failed to delete from the mcp market: mcp_market_id={mcp_market_id} - {str(e)}") + raise diff --git a/api/app/models/__init__.py b/api/app/models/__init__.py index daf03841..b1b723e9 100644 --- a/api/app/models/__init__.py +++ b/api/app/models/__init__.py @@ -9,6 +9,8 @@ from .generic_file_model import GenericFile from .models_model import ModelConfig, ModelProvider, ModelType, ModelApiKey, ModelBase, LoadBalanceStrategy from .memory_short_model import ShortTermMemory, LongTermMemory from .knowledgeshare_model import KnowledgeShare +from .mcp_market_model import McpMarket +from .mcp_market_config_model import McpMarketConfig from .app_model import App from .agent_app_config_model import AgentConfig from .app_release_model import AppRelease @@ -50,6 +52,8 @@ __all__ = [ "ModelType", "ModelApiKey", "KnowledgeShare", + "McpMarket", + "McpMarketConfig", "App", "AgentConfig", "AppRelease", diff --git a/api/app/models/mcp_market_config_model.py b/api/app/models/mcp_market_config_model.py new file mode 100644 index 00000000..a7051a91 --- /dev/null +++ b/api/app/models/mcp_market_config_model.py @@ -0,0 +1,16 @@ +import datetime +import uuid +from sqlalchemy import Column, Integer, String, DateTime, ForeignKey +from sqlalchemy.dialects.postgresql import UUID +from app.db import Base + +class McpMarketConfig(Base): + __tablename__ = "mcp_market_configs" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4, index=True) + mcp_market_id = Column(UUID(as_uuid=True), nullable=False, comment="mcp_markets.id") + token = Column(String, nullable=True, comment="mcp market token") + status = Column(Integer, default=0, comment="connect status(0: Not connected, 1: connected)") + tenant_id = Column(UUID(as_uuid=True), nullable=False, comment="tenant.id") + created_by = Column(UUID(as_uuid=True), nullable=False, comment="users.id") + created_at = Column(DateTime, default=datetime.datetime.now) \ No newline at end of file diff --git a/api/app/models/mcp_market_model.py b/api/app/models/mcp_market_model.py new file mode 100644 index 00000000..95c9cec4 --- /dev/null +++ b/api/app/models/mcp_market_model.py @@ -0,0 +1,18 @@ +import datetime +import uuid +from sqlalchemy import Column, Integer, String, DateTime, ForeignKey +from sqlalchemy.dialects.postgresql import UUID +from app.db import Base + +class McpMarket(Base): + __tablename__ = "mcp_markets" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4, index=True) + name = Column(String, index=True, nullable=False, comment="mcp market name") + description = Column(String, index=True, nullable=True, comment="mcp market description") + logo_url = Column(String, index=True, nullable=True, comment="logo url") + mcp_count = Column(Integer, default=1, comment="mcp count") + url = Column(String, index=True, nullable=False, comment="mcp market url") + category = Column(String, index=True, nullable=False, comment="category") + created_by = Column(UUID(as_uuid=True), nullable=False, comment="users.id") + created_at = Column(DateTime, default=datetime.datetime.now) \ No newline at end of file diff --git a/api/app/repositories/mcp_market_config_repository.py b/api/app/repositories/mcp_market_config_repository.py new file mode 100644 index 00000000..ec31becf --- /dev/null +++ b/api/app/repositories/mcp_market_config_repository.py @@ -0,0 +1,72 @@ +import uuid +import datetime +from sqlalchemy.orm import Session +from app.models.mcp_market_config_model import McpMarketConfig +from app.schemas import mcp_market_config_schema +from app.core.logging_config import get_db_logger + +# Obtain a dedicated logger for the database +db_logger = get_db_logger() + + +def create_mcp_market_config(db: Session, mcp_market_config: mcp_market_config_schema.McpMarketConfigCreate) -> McpMarketConfig: + db_logger.debug(f"Create a mcp market config record: mcp_market_id={mcp_market_config.mcp_market_id}") + + try: + db_mcp_market_config = McpMarketConfig(**mcp_market_config.model_dump()) + db.add(db_mcp_market_config) + db.commit() + db_logger.info(f"McpMarketConfig record created successfully: {mcp_market_config.mcp_market_id} (ID: {db_mcp_market_config.id})") + return db_mcp_market_config + except Exception as e: + db_logger.error(f"Failed to create a mcp market config record: mcp_market_id={mcp_market_config.mcp_market_id} - {str(e)}") + db.rollback() + raise + + +def get_mcp_market_config_by_id(db: Session, mcp_market_config_id: uuid.UUID) -> McpMarketConfig | None: + db_logger.debug(f"Query mcp market config based on ID: mcp_market_config_id={mcp_market_config_id}") + + try: + db_mcp_market_config = db.query(McpMarketConfig).filter(McpMarketConfig.id == mcp_market_config_id).first() + if db_mcp_market_config: + db_logger.debug(f"McpMarketConfig query successful: (ID: {mcp_market_config_id})") + else: + db_logger.debug(f"McpMarketConfig does not exist: mcp_market_config_id={mcp_market_config_id}") + return db_mcp_market_config + except Exception as e: + db_logger.error(f"Failed to query the mcp market config based on the ID: {mcp_market_config_id} - {str(e)}") + raise + + +def get_mcp_market_config_by_mcp_market_id(db: Session, mcp_market_id: uuid.UUID, tenant_id: uuid.UUID) -> McpMarketConfig | None: + db_logger.debug(f"Query mcp market config based on mcp_market_id: {mcp_market_id}") + + try: + db_mcp_market_config = db.query(McpMarketConfig).filter(McpMarketConfig.mcp_market_id == mcp_market_id, McpMarketConfig.tenant_id == tenant_id).first() + if db_mcp_market_config: + db_logger.debug(f"McpMarketConfig query successful: (mcp_market_id: {mcp_market_id})") + else: + db_logger.debug(f"McpMarketConfig does not exist: mcp_market_id={mcp_market_id}") + return db_mcp_market_config + except Exception as e: + db_logger.error(f"Failed to query the mcp market config based on the mcp_market_id: {mcp_market_id} - {str(e)}") + raise + + +def delete_mcp_market_config_by_id(db: Session, mcp_market_config_id: uuid.UUID): + db_logger.debug(f"Delete McpMarketConfig record: mcp_market_config_id={mcp_market_config_id}") + + try: + # First, query the mcp market config information for logging purposes + result = db.query(McpMarketConfig).filter(McpMarketConfig.id == mcp_market_config_id).delete() + db.commit() + + if result > 0: + db_logger.info(f"McpMarketConfig record deleted successfully: (ID: {mcp_market_config_id})") + else: + db_logger.warning(f"The mcp market config record does not exist, and cannot be deleted: id={mcp_market_config_id}") + except Exception as e: + db_logger.error(f"Failed to delete mcp market config record: id={mcp_market_config_id} - {str(e)}") + db.rollback() + raise diff --git a/api/app/repositories/mcp_market_repository.py b/api/app/repositories/mcp_market_repository.py new file mode 100644 index 00000000..d5089815 --- /dev/null +++ b/api/app/repositories/mcp_market_repository.py @@ -0,0 +1,124 @@ +import uuid +import datetime +from sqlalchemy.orm import Session +from app.models.mcp_market_model import McpMarket +from app.schemas import mcp_market_schema +from app.core.logging_config import get_db_logger + +# Obtain a dedicated logger for the database +db_logger = get_db_logger() + + +def get_mcp_markets_paginated( + db: Session, + filters: list, + page: int, + pagesize: int, + orderby: str = None, + desc: bool = False +) -> tuple[int, list]: + """ + Paged query mcp market (with filtering and sorting) + """ + db_logger.debug( + f"Query mcp market in pages: page={page}, pagesize={pagesize}, orderby={orderby}, desc={desc}, filters_count={len(filters)}") + + try: + query = db.query(McpMarket) + + # Apply filter conditions + for filter_cond in filters: + query = query.filter(filter_cond) + + # Calculate the total count (for pagination) + total = query.count() + db_logger.debug(f"Total number of mcp_market queries: {total}") + + # sort + if orderby: + order_attr = getattr(McpMarket, orderby, None) + if order_attr is not None: + if desc: + query = query.order_by(order_attr.desc()) + else: + query = query.order_by(order_attr.asc()) + db_logger.debug(f"sort: {orderby}, desc={desc}") + + # pagination + items = query.offset((page - 1) * pagesize).limit(pagesize).all() + db_logger.info( + f"The mcp market paging query has been successful: total={total}, Number of current page={len(items)}") + + return total, [mcp_market_schema.McpMarket.model_validate(item) for item in items] + except Exception as e: + db_logger.error(f"Querying mcp_market pagination failed: page={page}, pagesize={pagesize} - {str(e)}") + raise + + +def create_mcp_market(db: Session, mcp_market: mcp_market_schema.McpMarketCreate) -> McpMarket: + db_logger.debug(f"Create a mcp market record: name={mcp_market.name}") + + try: + db_mcp_market = McpMarket(**mcp_market.model_dump()) + db.add(db_mcp_market) + db.commit() + db_logger.info(f"McpMarket record created successfully: {mcp_market.name} (ID: {db_mcp_market.id})") + return db_mcp_market + except Exception as e: + db_logger.error(f"Failed to create a mcp market record: title={mcp_market.name} - {str(e)}") + db.rollback() + raise + + +def get_mcp_market_by_id(db: Session, mcp_market_id: uuid.UUID) -> McpMarket | None: + db_logger.debug(f"Query mcp market based on ID: mcp_market_id={mcp_market_id}") + + try: + db_mcp_market = db.query(McpMarket).filter(McpMarket.id == mcp_market_id).first() + if db_mcp_market: + db_logger.debug(f"McpMarket query successful: {db_mcp_market.name} (ID: {mcp_market_id})") + else: + db_logger.debug(f"McpMarket does not exist: mcp_market_id={mcp_market_id}") + return db_mcp_market + except Exception as e: + db_logger.error(f"Failed to query the mcp market based on the ID: mcp_market_id={mcp_market_id} - {str(e)}") + raise + + +def get_mcp_market_by_name(db: Session, name: str) -> McpMarket | None: + db_logger.debug(f"Query mcp market based on name: name={name}") + + try: + db_mcp_market = db.query(McpMarket).filter(McpMarket.name == name).first() + if db_mcp_market: + db_logger.debug(f"mcp market query successful: {name} (ID: {db_mcp_market.id})") + else: + db_logger.debug(f"mcp market does not exist: name={name}") + return db_mcp_market + except Exception as e: + db_logger.error(f"Failed to query the mcp market based on the name: {name} - {str(e)}") + raise + + +def delete_mcp_market_by_id(db: Session, mcp_market_id: uuid.UUID): + db_logger.debug(f"Delete McpMarket record: mcp_market_id={mcp_market_id}") + + try: + # First, query the mcp market information for logging purposes + db_mcp_market = db.query(McpMarket).filter(McpMarket.id == mcp_market_id).first() + if db_mcp_market: + name = db_mcp_market.name + else: + name = "unknown" + + result = db.query(McpMarket).filter(McpMarket.id == mcp_market_id).delete() + db.commit() + + if result > 0: + db_logger.info(f"McpMarket record deleted successfully: {name} (ID: {mcp_market_id})") + else: + db_logger.warning(f"The mcp market record does not exist, and cannot be deleted: mcp_market_id={mcp_market_id}") + except Exception as e: + db_logger.error(f"Failed to delete mcp market record: mcp_market_id={mcp_market_id} - {str(e)}") + db.rollback() + raise diff --git a/api/app/schemas/__init__.py b/api/app/schemas/__init__.py index 299251f4..96c42ce7 100644 --- a/api/app/schemas/__init__.py +++ b/api/app/schemas/__init__.py @@ -8,6 +8,8 @@ from .file_schema import File, FileCreate, FileUpdate from .tenant_schema import Tenant, TenantCreate, TenantUpdate from .chunk_schema import ChunkCreate, ChunkUpdate, ChunkRetrieve from .knowledgeshare_schema import KnowledgeShare, KnowledgeShareCreate +from .mcp_market_schema import McpMarketCreate, McpMarketUpdate, McpMarket +from .mcp_market_config_schema import McpMarketConfigCreate, McpMarketConfigUpdate, McpMarketConfig from .order_schema import CreateOrderRequest, OrderResponse, ExternalOrderResponse from .app_schema import ( AppChatRequest, @@ -78,6 +80,12 @@ __all__ = [ "ChunkRetrieve", "KnowledgeShare", "KnowledgeShareCreate", + "McpMarketCreate", + "McpMarketUpdate", + "McpMarket", + "McpMarketConfigCreate", + "McpMarketConfigUpdate", + "McpMarketConfig", "CreateOrderRequest", "OrderResponse", "ExternalOrderResponse", diff --git a/api/app/schemas/mcp_market_config_schema.py b/api/app/schemas/mcp_market_config_schema.py new file mode 100644 index 00000000..c33239cf --- /dev/null +++ b/api/app/schemas/mcp_market_config_schema.py @@ -0,0 +1,31 @@ +from pydantic import BaseModel, Field, field_serializer, ConfigDict +import datetime +import uuid + + +class McpMarketConfigBase(BaseModel): + mcp_market_id: uuid.UUID + token: str | None = None + status: int | None = None + tenant_id: uuid.UUID | None = None + created_by: uuid.UUID | None = None + + +class McpMarketConfigCreate(McpMarketConfigBase): + pass + + +class McpMarketConfigUpdate(BaseModel): + token: str | None = None + status: int | None = None + + +class McpMarketConfig(McpMarketConfigBase): + id: uuid.UUID + created_at: datetime.datetime + + model_config = ConfigDict(from_attributes=True) + + @field_serializer("created_at", when_used="json") + def _serialize_created_at(self, dt: datetime.datetime): + return int(dt.timestamp() * 1000) if dt else None diff --git a/api/app/schemas/mcp_market_schema.py b/api/app/schemas/mcp_market_schema.py new file mode 100644 index 00000000..54d3b35e --- /dev/null +++ b/api/app/schemas/mcp_market_schema.py @@ -0,0 +1,37 @@ +from pydantic import BaseModel, Field, field_serializer, ConfigDict +import datetime +import uuid + + +class McpMarketBase(BaseModel): + name: str + description: str | None = None + logo_url: str | None = None + mcp_count: int + url: str + category: str + created_by: uuid.UUID | None = None + + +class McpMarketCreate(McpMarketBase): + pass + + +class McpMarketUpdate(BaseModel): + name: str | None = Field(None) + description: str | None = Field(None) + logo_url: str | None = Field(None) + mcp_count: int | None = Field(None) + url: str | None = Field(None) + category: str | None = Field(None) + + +class McpMarket(McpMarketBase): + id: uuid.UUID + created_at: datetime.datetime + + model_config = ConfigDict(from_attributes=True) + + @field_serializer("created_at", when_used="json") + def _serialize_created_at(self, dt: datetime.datetime): + return int(dt.timestamp() * 1000) if dt else None diff --git a/api/app/services/mcp_market_config_service.py b/api/app/services/mcp_market_config_service.py new file mode 100644 index 00000000..86485902 --- /dev/null +++ b/api/app/services/mcp_market_config_service.py @@ -0,0 +1,83 @@ +import uuid +from sqlalchemy.orm import Session +from app.models.user_model import User +from app.models.mcp_market_config_model import McpMarketConfig +from app.schemas.mcp_market_config_schema import McpMarketConfigCreate, McpMarketConfigUpdate +from app.repositories import mcp_market_config_repository +from app.core.logging_config import get_business_logger + +# Obtain a dedicated logger for business logic +business_logger = get_business_logger() + + +def create_mcp_market_config( + db: Session, mcp_market_config: McpMarketConfigCreate, current_user: User +) -> McpMarketConfig: + business_logger.info(f"Create a mcp market config base: {mcp_market_config.mcp_market_id}, creator: {current_user.username}") + + try: + mcp_market_config.tenant_id = current_user.tenant_id + mcp_market_config.created_by = current_user.id + business_logger.debug(f"Start creating the mcp market config on mcp_market_id: {mcp_market_config.mcp_market_id}") + db_mcp_market_config = mcp_market_config_repository.create_mcp_market_config( + db=db, mcp_market_config=mcp_market_config + ) + business_logger.info( + f"The mcp market config has been successfully created: {mcp_market_config.mcp_market_id} (ID: {db_mcp_market_config.id}), creator: {current_user.username}") + return db_mcp_market_config + except Exception as e: + business_logger.error(f"Failed to create a mcp marke config: {mcp_market_config.mcp_market_id} - {str(e)}") + raise + + +def get_mcp_market_config_by_id(db: Session, mcp_market_config_id: uuid.UUID, current_user: User) -> McpMarketConfig | None: + business_logger.debug( + f"Query mcp market config based on ID: mcp_market_config_id={mcp_market_config_id}, username: {current_user.username}") + + try: + mcpMarketConfig = mcp_market_config_repository.get_mcp_market_config_by_id(db=db, mcp_market_config_id=mcp_market_config_id) + if mcpMarketConfig: + business_logger.info(f"mcp market config query successful: (ID: {mcp_market_config_id})") + else: + business_logger.warning(f"mcp market config does not exist: mcp_market_config_id={mcp_market_config_id}") + return mcpMarketConfig + except Exception as e: + business_logger.error( + f"Failed to query the mcp market config based on the ID: {mcp_market_config_id} - {str(e)}") + raise + + +def get_mcp_market_config_by_mcp_market_id(db: Session, mcp_market_id: uuid.UUID, current_user: User) -> McpMarketConfig | None: + business_logger.debug( + f"Query mcp market config based on mcp_market_id: {mcp_market_id}, username: {current_user.username}") + + try: + mcpMarketConfig = mcp_market_config_repository.get_mcp_market_config_by_mcp_market_id(db=db, mcp_market_id=mcp_market_id, tenant_id=current_user.tenant_id) + if mcpMarketConfig: + business_logger.info(f"mcp market config query successful: (mcp_market_id: {mcp_market_id})") + else: + business_logger.warning(f"mcp market config does not exist: mcp_market_id={mcp_market_id}") + return mcpMarketConfig + except Exception as e: + business_logger.error( + f"Failed to query the mcp market config based on the mcp_market_id: {mcp_market_id} - {str(e)}") + raise + + +def delete_mcp_market_config_by_id(db: Session, mcp_market_config_id: uuid.UUID, current_user: User) -> None: + business_logger.info(f"Delete mcp market config: mcp_market_config_id={mcp_market_config_id}, operator: {current_user.username}") + + try: + # First, query the mcp market config information for logging purposes + mcpMarketConfig = mcp_market_config_repository.get_mcp_market_config_by_id(db=db, mcp_market_config_id=mcp_market_config_id) + if mcpMarketConfig: + business_logger.debug(f"Execute mcp market config deletion: (ID: {mcp_market_config_id})") + else: + business_logger.warning(f"The mcp market config to be deleted does not exist: mcp_market_config_id={mcp_market_config_id}") + + mcp_market_config_repository.delete_mcp_market_config_by_id(db=db, mcp_market_config_id=mcp_market_config_id) + business_logger.info( + f"mcp market config record deleted successfully: mcp_market_config_id={mcp_market_config_id}, operator: {current_user.username}") + except Exception as e: + business_logger.error(f"Failed to delete mcp market config: mcp_market_config_id={mcp_market_config_id} - {str(e)}") + raise diff --git a/api/app/services/mcp_market_service.py b/api/app/services/mcp_market_service.py new file mode 100644 index 00000000..6d9d26fc --- /dev/null +++ b/api/app/services/mcp_market_service.py @@ -0,0 +1,109 @@ +import uuid +from sqlalchemy.orm import Session +from app.models.user_model import User +from app.models.mcp_market_model import McpMarket +from app.schemas.mcp_market_schema import McpMarketCreate, McpMarketUpdate +from app.repositories import mcp_market_repository +from app.core.logging_config import get_business_logger + +# Obtain a dedicated logger for business logic +business_logger = get_business_logger() + + +def get_mcp_markets_paginated( + db: Session, + current_user: User, + filters: list, + page: int, + pagesize: int, + orderby: str = None, + desc: bool = False +) -> tuple[int, list]: + business_logger.debug( + f"Query mcp market in pages: username={current_user.username}, page={page}, pagesize={pagesize}, orderby={orderby}, desc={desc}") + + try: + total, items = mcp_market_repository.get_mcp_markets_paginated( + db=db, + filters=filters, + page=page, + pagesize=pagesize, + orderby=orderby, + desc=desc + ) + business_logger.info( + f"The mcp market paging query has been successful: username={current_user.username}, total={total}, Number of current page={len(items)}") + return total, items + except Exception as e: + business_logger.error(f"Querying mcp market pagination failed: username={current_user.username} - {str(e)}") + raise + + +def create_mcp_market( + db: Session, mcp_market: McpMarketCreate, current_user: User +) -> McpMarket: + business_logger.info(f"Create a mcp market base: {mcp_market.name}, creator: {current_user.username}") + + try: + mcp_market.created_by = current_user.id + business_logger.debug(f"Start creating the mcp market: {mcp_market.name}") + db_mcp_market = mcp_market_repository.create_mcp_market( + db=db, mcp_market=mcp_market + ) + business_logger.info( + f"The mcp market has been successfully created: {mcp_market.name} (ID: {db_mcp_market.id}), creator: {current_user.username}") + return db_mcp_market + except Exception as e: + business_logger.error(f"Failed to create a mcp market: {mcp_market.name} - {str(e)}") + raise + + +def get_mcp_market_by_id(db: Session, mcp_market_id: uuid.UUID, current_user: User) -> McpMarket | None: + business_logger.debug( + f"Query mcp market based on ID: mcp_market_id={mcp_market_id}, username: {current_user.username}") + + try: + mcpMarket = mcp_market_repository.get_mcp_market_by_id(db=db, mcp_market_id=mcp_market_id) + if mcpMarket: + business_logger.info(f"mcp market query successful: {mcpMarket.name} (ID: {mcp_market_id})") + else: + business_logger.warning(f"mcp market does not exist: mcp_market_id={mcp_market_id}") + return mcpMarket + except Exception as e: + business_logger.error( + f"Failed to query the mcp market based on the ID: {mcp_market_id} - {str(e)}") + raise + + +def get_mcp_market_by_name(db: Session, name: str, current_user: User) -> McpMarket | None: + business_logger.debug(f"Query mcp market based on name: name={name}, username: {current_user.username}") + + try: + db_mcp_market = mcp_market_repository.get_mcp_market_by_name(db=db, name=name) + if db_mcp_market: + business_logger.info(f"mcp market query successful: {name} (ID: {db_mcp_market.id})") + else: + business_logger.warning(f"mcp market does not exist: name={name}") + return db_mcp_market + except Exception as e: + business_logger.error(f"Failed to query the mcp market based on the name: name={name} - {str(e)}") + raise + + +def delete_mcp_market_by_id(db: Session, mcp_market_id: uuid.UUID, current_user: User) -> None: + business_logger.info(f"Delete mcp market: mcp_market_id={mcp_market_id}, operator: {current_user.username}") + + try: + # First, query the mcp market information for logging purposes + mcpMarket = mcp_market_repository.get_mcp_market_by_id(db=db, mcp_market_id=mcp_market_id) + if mcpMarket: + business_logger.debug(f"Execute mcp market deletion: {mcpMarket.name} (ID: {mcp_market_id})") + else: + business_logger.warning(f"The mcp market to be deleted does not exist: mcp_market_id={mcp_market_id}") + + mcp_market_repository.delete_mcp_market_by_id(db=db, mcp_market_id=mcp_market_id) + business_logger.info( + f"mcp market record deleted successfully: mcp_market_id={mcp_market_id}, operator: {current_user.username}") + except Exception as e: + business_logger.error(f"Failed to delete mcp market: mcp_market_id={mcp_market_id} - {str(e)}") + raise diff --git a/api/pyproject.toml b/api/pyproject.toml index 66b1a295..51c0b3e3 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -143,6 +143,7 @@ dependencies = [ "owlready2>=0.46", "lxml>=4.9.0", "httpx>=0.28.0", + "modelscope>=1.34.0", ] [tool.pytest.ini_options] diff --git a/api/requirements.txt b/api/requirements.txt index 144c0db2..2cdd2bd0 100644 --- a/api/requirements.txt +++ b/api/requirements.txt @@ -136,3 +136,4 @@ boto3>=1.28.0 aiofiles>=23.0.0 lxml>=4.9.0 httpx>=0.28.0 +modelscope>=1.34.0 From 12ba3d473e4e23a4816f48c0019b2d1b7d7567e7 Mon Sep 17 00:00:00 2001 From: Timebomb2018 <18868801967@163.com> Date: Wed, 25 Feb 2026 11:29:42 +0800 Subject: [PATCH 15/36] feat(user system): modifies the email address. --- api/app/controllers/user_controller.py | 63 ++++++++- api/app/core/config.py | 6 + api/app/schemas/user_schema.py | 22 +++ api/app/services/email_service.py | 88 ++++++++++++ api/app/services/user_service.py | 177 +++++++++++++++++++++++++ api/env.example | 9 ++ 6 files changed, 363 insertions(+), 2 deletions(-) create mode 100644 api/app/services/email_service.py diff --git a/api/app/controllers/user_controller.py b/api/app/controllers/user_controller.py index 57495a7c..3c574c81 100644 --- a/api/app/controllers/user_controller.py +++ b/api/app/controllers/user_controller.py @@ -2,15 +2,23 @@ from fastapi import APIRouter, Depends from sqlalchemy.orm import Session import uuid +from app.core.error_codes import BizCode +from app.core.exceptions import BusinessException from app.db import get_db from app.dependencies import get_current_user, get_current_superuser from app.models.user_model import User from app.schemas import user_schema -from app.schemas.user_schema import ChangePasswordRequest, AdminChangePasswordRequest +from app.schemas.user_schema import ( + ChangePasswordRequest, + AdminChangePasswordRequest, + SendEmailCodeRequest, + VerifyEmailCodeRequest, + VerifyPasswordRequest) from app.schemas.response_schema import ApiResponse from app.services import user_service from app.core.logging_config import get_api_logger from app.core.response_utils import success +from app.core.security import verify_password # 获取API专用日志器 api_logger = get_api_logger() @@ -120,6 +128,7 @@ def get_tenant_superusers( return success(data=superusers_schema, msg="租户超管列表获取成功") + @router.get("/{user_id}", response_model=ApiResponse) def get_user_info_by_id( user_id: uuid.UUID, @@ -180,4 +189,54 @@ async def admin_change_password( return success(msg="密码修改成功") else: api_logger.info(f"管理员密码重置成功: 用户 {request.user_id}, 随机密码已生成") - return success(data=generated_password, msg="密码重置成功") \ No newline at end of file + return success(data=generated_password, msg="密码重置成功") + + +@router.post("/verify_pwd", response_model=ApiResponse) +def verify_pwd( + request: VerifyPasswordRequest, + current_user: User = Depends(get_current_user), +): + """验证当前用户密码""" + api_logger.info(f"用户验证密码请求: {current_user.username}") + + is_valid = verify_password(request.password, current_user.hashed_password) + api_logger.info(f"用户密码验证结果: {current_user.username}, valid={is_valid}") + if not is_valid: + raise BusinessException("密码验证失败", code=BizCode.VALIDATION_FAILED) + return success(data={"valid": is_valid}, msg="验证完成") + + +@router.post("/send-email-code", response_model=ApiResponse) +async def send_email_code( + request: SendEmailCodeRequest, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """发送邮箱验证码""" + api_logger.info(f"用户请求发送邮箱验证码: {current_user.username}, email={request.email}") + + await user_service.send_email_code_method(db=db, email=request.email, user_id=current_user.id) + + api_logger.info(f"邮箱验证码已发送: {current_user.username}") + return success(msg="验证码已发送到您的邮箱,请查收") + + +@router.put("/change-email", response_model=ApiResponse) +async def change_email( + request: VerifyEmailCodeRequest, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """验证验证码并修改邮箱""" + api_logger.info(f"用户修改邮箱: {current_user.username}, new_email={request.new_email}") + + await user_service.verify_and_change_email( + db=db, + user_id=current_user.id, + new_email=request.new_email, + code=request.code + ) + + api_logger.info(f"用户邮箱修改成功: {current_user.username}") + return success(msg="邮箱修改成功") diff --git a/api/app/core/config.py b/api/app/core/config.py index b1354b9f..3a0c97b4 100644 --- a/api/app/core/config.py +++ b/api/app/core/config.py @@ -193,6 +193,12 @@ class Settings: CELERY_BROKER: int = int(os.getenv("CELERY_BROKER", "1")) CELERY_BACKEND: int = int(os.getenv("CELERY_BACKEND", "2")) + # SMTP Email Configuration + SMTP_SERVER: str = os.getenv("SMTP_SERVER", "smtp.gmail.com") + SMTP_PORT: int = int(os.getenv("SMTP_PORT", "587")) + SMTP_USER: str = os.getenv("SMTP_USER", "") + SMTP_PASSWORD: str = os.getenv("SMTP_PASSWORD", "") + REFLECTION_INTERVAL_SECONDS: float = float(os.getenv("REFLECTION_INTERVAL_SECONDS", "300")) HEALTH_CHECK_SECONDS: float = float(os.getenv("HEALTH_CHECK_SECONDS", "600")) MEMORY_INCREMENT_INTERVAL_HOURS: float = float(os.getenv("MEMORY_INCREMENT_INTERVAL_HOURS", "24")) diff --git a/api/app/schemas/user_schema.py b/api/app/schemas/user_schema.py index 60f52aaf..7b9e201d 100644 --- a/api/app/schemas/user_schema.py +++ b/api/app/schemas/user_schema.py @@ -36,6 +36,28 @@ class AdminChangePasswordRequest(BaseModel): new_password: Optional[str] = Field(None, min_length=6, description="新密码,至少6位。如果不提供则自动生成随机密码") +class ChangeEmailRequest(BaseModel): + """修改邮箱请求""" + password: str = Field(..., description="当前密码") + new_email: EmailStr = Field(..., description="新邮箱地址") + + +class SendEmailCodeRequest(BaseModel): + """发送邮箱验证码请求""" + email: EmailStr = Field(..., description="邮箱地址") + + +class VerifyEmailCodeRequest(BaseModel): + """验证邮箱验证码并修改邮箱请求""" + new_email: EmailStr = Field(..., description="新邮箱地址") + code: str = Field(..., min_length=6, max_length=6, description="验证码") + + +class VerifyPasswordRequest(BaseModel): + """验证密码请求""" + password: str = Field(..., description="密码") + + class ChangePasswordResponse(BaseModel): """修改密码响应""" message: str diff --git a/api/app/services/email_service.py b/api/app/services/email_service.py new file mode 100644 index 00000000..d7b255dc --- /dev/null +++ b/api/app/services/email_service.py @@ -0,0 +1,88 @@ +import smtplib +import re +import asyncio +from email.mime.text import MIMEText +from email.mime.multipart import MIMEMultipart +from email.header import Header +from email.utils import formataddr +from concurrent.futures import ThreadPoolExecutor + +from app.core.config import settings +from app.core.error_codes import BizCode +from app.core.exceptions import BusinessException +from app.core.logging_config import get_business_logger + +business_logger = get_business_logger() + + +def _send_email_sync(to_email: str, subject: str, html_content: str, text_content: str = None): + """同步发送邮件""" + smtp_server = settings.SMTP_SERVER + smtp_port = settings.SMTP_PORT + smtp_user = settings.SMTP_USER + smtp_password = settings.SMTP_PASSWORD + + if not smtp_server or not smtp_user or not smtp_password: + raise BusinessException("邮件服务未配置", code=BizCode.SERVICE_UNAVAILABLE) + + msg = MIMEMultipart('alternative') + msg['Subject'] = Header(subject, "utf-8") + from_name = "MemoryBear系统" + msg['From'] = formataddr((Header(from_name, 'utf-8').encode(), smtp_user)) + msg['To'] = Header(to_email, "utf-8") + + if not text_content: + text_content = html_content.replace('
', '\n').replace('

', '\n').replace('

', '\n') + text_content = re.sub(r'<.*?>', '', text_content) + text_part = MIMEText(text_content, 'plain', 'utf-8') + msg.attach(text_part) + + html_part = MIMEText(html_content, 'html', 'utf-8') + msg.attach(html_part) + + if smtp_port == 465: + with smtplib.SMTP_SSL(smtp_server, smtp_port, timeout=10) as server: + server.login(smtp_user, smtp_password) + server.send_message(msg) + else: + with smtplib.SMTP(smtp_server, smtp_port, timeout=10) as server: + server.starttls() + server.login(smtp_user, smtp_password) + server.send_message(msg) + + +async def send_email(to_email: str, subject: str, html_content: str, text_content: str = None): + """异步发送邮件""" + to_email = to_email.strip() + if not to_email or not re.match(r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$', to_email): + err_msg = f"收件人邮箱格式无效: {to_email}" + business_logger.error(err_msg) + raise BusinessException(err_msg, code=BizCode.INVALID_PARAMETER) + + try: + loop = asyncio.get_event_loop() + with ThreadPoolExecutor() as executor: + await loop.run_in_executor( + executor, + _send_email_sync, + to_email, + subject, + html_content, + text_content + ) + business_logger.info(f"邮件发送成功: {to_email}") + except smtplib.SMTPAuthenticationError: + err_msg = "SMTP认证失败,请检查SMTP账号/密码是否正确" + business_logger.error(f"邮件发送失败: {to_email} - {err_msg}") + raise BusinessException(err_msg, code=BizCode.UNAUTHORIZED) + except smtplib.SMTPConnectError: + err_msg = "SMTP服务器连接失败,请检查服务器地址/端口是否正确" + business_logger.error(f"邮件发送失败: {to_email} - {err_msg}") + raise BusinessException(err_msg, code=BizCode.SERVICE_UNAVAILABLE) + except TimeoutError: + err_msg = "邮件发送超时,请检查SMTP服务器配置" + business_logger.error(f"邮件发送失败: {to_email} - {err_msg}") + raise BusinessException(err_msg, code=BizCode.BAD_REQUEST) + except Exception as e: + business_logger.error(f"邮件发送失败: {to_email} - {str(e)}") + raise BusinessException(f"邮件发送失败: {str(e)}", code=BizCode.SERVICE_UNAVAILABLE) diff --git a/api/app/services/user_service.py b/api/app/services/user_service.py index d97e2fb2..22dabed7 100644 --- a/api/app/services/user_service.py +++ b/api/app/services/user_service.py @@ -1,13 +1,18 @@ import datetime +import json import secrets import string + +from pydantic import EmailStr from sqlalchemy.orm import Session import uuid +from app.aioRedis import aio_redis_set, aio_redis_get, aio_redis_delete from app.models.user_model import User from app.repositories import user_repository from app.schemas.user_schema import UserCreate from app.schemas.tenant_schema import TenantCreate +from app.services.email_service import send_email from app.services.tenant_service import TenantService from app.services.session_service import SessionService from app.core.security import get_password_hash, verify_password @@ -563,3 +568,175 @@ def generate_random_password(length: int = 12) -> str: secrets.SystemRandom().shuffle(password) return ''.join(password) + + +def generate_email_code() -> str: + """生成6位数字验证码""" + return ''.join([str(secrets.randbelow(10)) for _ in range(6)]) + + +async def send_email_code_method(db: Session, email: EmailStr, user_id: uuid.UUID): + """发送邮箱验证码""" + business_logger.info(f"发送邮箱验证码: email={email}") + + # 检查发送间隔 + rate_limit_key = f"email_code_rate:{user_id}" + last_send = await aio_redis_get(rate_limit_key) + + if last_send: + raise BusinessException("请稍后再试,验证码发送间隔为1分钟", code=BizCode.RATE_LIMITED) + + # 检查新邮箱是否已被使用 + existing_user = user_repository.get_user_by_email(db=db, email=email) + if existing_user and existing_user.id != user_id: + raise BusinessException("邮箱已被使用", code=BizCode.DUPLICATE_NAME) + + if existing_user and existing_user.id == user_id: + raise BusinessException("新邮箱与当前邮箱相同", code=BizCode.DUPLICATE_NAME) + + # 生成验证码 + code = generate_email_code() + + # 存储到 Redis,5分钟过期 + cache_key = f"email_code:{user_id}:{email}" + await aio_redis_set(cache_key, json.dumps(code), expire=300) + + # 发送邮件 + await send_email( + email, + "邮箱验证码", + f'

您的验证码是:{code}

验证码在5分钟内有效。

' + ) + + # 设置发送间隔限制,60秒 + await aio_redis_set(rate_limit_key, "1", expire=60) + + business_logger.info(f"邮箱验证码已发送: {email}") + + +async def verify_and_change_email(db: Session, user_id: uuid.UUID, new_email: EmailStr, code: str) -> User: + """验证验证码并修改邮箱""" + business_logger.info(f"验证并修改邮箱: user_id={user_id}, new_email={new_email}") + + db_user = user_repository.get_user_by_id(db=db, user_id=user_id) + if not db_user: + raise BusinessException("用户不存在", code=BizCode.USER_NOT_FOUND) + + # 验证验证码 + cache_key = f"email_code:{user_id}:{new_email}" + cached_code = await aio_redis_get(cache_key) + + if not cached_code: + raise BusinessException("验证码已过期", code=BizCode.VALIDATION_FAILED) + + if json.loads(cached_code) != code: + raise BusinessException("验证码错误", code=BizCode.VALIDATION_FAILED) + + # 修改邮箱 + db_user.email = new_email + db.commit() + db.refresh(db_user) + + # 删除验证码 + await aio_redis_delete(cache_key) + + # 使所有旧 tokens 失效 + # await SessionService.invalidate_all_user_tokens(str(user_id)) + + business_logger.info(f"用户邮箱修改成功: {db_user.username}, new_email={new_email}") + return db_user + + +# def generate_email_token(user_id: str, old_email: str, new_email: str) -> str: +# """生成邮箱修改token""" +# payload = { +# "user_id": user_id, +# "old_email": old_email, +# "new_email": new_email, +# "exp": datetime.datetime.now(datetime.timezone.utc) + timedelta(hours=24) +# } +# return jwt.encode(payload, settings.SECRET_KEY, algorithm=settings.ALGORITHM) +# +# +# def verify_email_token(token: str) -> dict: +# """验证邮箱修改token""" +# try: +# payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM]) +# return payload +# except jwt.ExpiredSignatureError: +# raise BusinessException("链接已过期", code=BizCode.VALIDATION_FAILED) +# except jwt.InvalidTokenError: +# raise BusinessException("无效的链接", code=BizCode.VALIDATION_FAILED) +# +# +# async def request_change_email(db: Session, user_id: uuid.UUID, new_email: EmailStr, current_user: User): +# """请求修改邮箱,发送验证邮件""" +# business_logger.info(f"用户请求修改邮箱: user_id={user_id}, new_email={new_email}") +# +# if current_user.id != user_id: +# raise PermissionDeniedException("只能修改自己的邮箱") +# +# db_user = user_repository.get_user_by_id(db=db, user_id=user_id) +# if not db_user: +# raise BusinessException("用户不存在", code=BizCode.USER_NOT_FOUND) +# +# if db_user.email == new_email: +# raise BusinessException("新邮箱与当前邮箱相同", code=BizCode.VALIDATION_FAILED) +# +# existing_user = user_repository.get_user_by_email(db=db, email=new_email) +# if existing_user and existing_user.id != user_id: +# raise BusinessException("邮箱已被使用", code=BizCode.DUPLICATE_NAME) +# +# token = generate_email_token(str(user_id), db_user.email, new_email) +# +# # 发送确认邮件到旧邮箱 +# old_email_link = f"{settings.BASE_URL}/api/users/email/confirm-email-change?token={token}" +# await send_email( +# db_user.email, +# "确认修改邮箱", +# f'

请点击以下链接确认修改邮箱:

确认修改' +# ) +# +# business_logger.info(f"邮箱修改确认邮件已发送到旧邮箱: {db_user.email}") +# +# +# async def confirm_email_change(db: Session, token: str): +# """确认修改邮箱(旧邮箱确认)""" +# payload = verify_email_token(token) +# user_id = uuid.UUID(payload["user_id"]) +# new_email = payload["new_email"] +# +# db_user = user_repository.get_user_by_id(db=db, user_id=user_id) +# if not db_user: +# raise BusinessException("用户不存在", code=BizCode.USER_NOT_FOUND) +# +# # 发送激活邮件到新邮箱 +# activate_link = f"{settings.BASE_URL}/api/users/email/activate-new-email?token={token}" +# await send_email( +# new_email, +# "激活新邮箱", +# f'

请点击以下链接激活新邮箱:

激活邮箱' +# ) +# +# business_logger.info(f"新邮箱激活邮件已发送: {new_email}") +# +# +# async def activate_new_email(db: Session, token: str) -> User: +# """激活新邮箱""" +# payload = verify_email_token(token) +# user_id = uuid.UUID(payload["user_id"]) +# new_email = payload["new_email"] +# +# db_user = user_repository.get_user_by_id(db=db, user_id=user_id) +# if not db_user: +# raise BusinessException("用户不存在", code=BizCode.USER_NOT_FOUND) +# +# db_user.email = new_email +# db.commit() +# db.refresh(db_user) +# +# # 使所有旧 tokens 失效 +# await SessionService.invalidate_all_user_tokens(str(user_id)) +# +# business_logger.info(f"用户邮箱修改成功: {db_user.username}, new_email={new_email}") +# return db_user diff --git a/api/env.example b/api/env.example index dfb1ae61..e8074f82 100644 --- a/api/env.example +++ b/api/env.example @@ -64,6 +64,9 @@ LANGCHAIN_ENDPOINT= # Generate a new one with: openssl rand -hex 32 SECRET_KEY=your-secret-key-here-generate-with-openssl-rand-hex-32 +# official environment system version +SYSTEM_VERSION= + # JWT Token expiration settings ACCESS_TOKEN_EXPIRE_MINUTES=30 REFRESH_TOKEN_EXPIRE_DAYS=7 @@ -129,6 +132,12 @@ KB_image2text_id= config_id= reranker_id= +# Email Configuration +SMTP_SERVER= +SMTP_PORT= +SMTP_USER= +SMTP_PASSWORD= + # 本体类型融合配置 (记得写入env_example) GENERAL_ONTOLOGY_FILES=General_purpose_entity.ttl # 指定要加载的本体文件路径,多个文件用逗号分隔 ENABLE_GENERAL_ONTOLOGY_TYPES=true # 总开关,控制是否启用通用本体类型融合功能(false = 不使用任何本体类型指导) From bd63e0fce826ef69be6a505a8b7174dd9688cd6d Mon Sep 17 00:00:00 2001 From: zhaoying Date: Wed, 25 Feb 2026 11:47:36 +0800 Subject: [PATCH 16/36] feat(web): user email support change --- web/src/api/user.ts | 18 +- web/src/components/Empty/index.tsx | 6 +- web/src/components/Header/UserInfoModal.tsx | 39 +++- web/src/i18n/en.ts | 22 ++ web/src/i18n/zh.ts | 35 ++- .../components/ChangeEmailModal.tsx | 219 ++++++++++++++++++ .../components/VerifyPasswordModal.tsx | 111 +++++++++ web/src/views/UserManagement/types.ts | 31 ++- 8 files changed, 457 insertions(+), 24 deletions(-) create mode 100644 web/src/views/UserManagement/components/ChangeEmailModal.tsx create mode 100644 web/src/views/UserManagement/components/VerifyPasswordModal.tsx diff --git a/web/src/api/user.ts b/web/src/api/user.ts index f37e685b..72a3ad73 100644 --- a/web/src/api/user.ts +++ b/web/src/api/user.ts @@ -1,11 +1,11 @@ /* * @Author: ZhaoYing * @Date: 2026-02-03 14:00:23 - * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-02-03 14:00:23 + * @Last Modified by: ZhaoYing + * @Last Modified time: 2026-02-25 11:17:44 */ import { request } from '@/utils/request' -import type { CreateModalData } from '@/views/UserManagement/types' +import type { CreateModalData, ChangeEmailModalForm } from '@/views/UserManagement/types' import { cookieUtils } from '@/utils/request' // User info @@ -28,6 +28,10 @@ export const refreshToken = () => { export const changePassword = (data: { user_id: string; new_password: string }) => { return request.put('/users/admin/change-password', data) } +// Verify password +export const verifyPassword = (data: { password: string }) => { + return request.post('/users/verify_pwd', data) +} // Disable user export const deleteUser = (user_id: string) => { return request.delete(`/users/${user_id}`) @@ -44,4 +48,12 @@ export const addUser = (data: CreateModalData) => { export const logoutUrl = '/logout' export const logout = () => { return request.post(logoutUrl) +} +// Send email verification code +export const sendEmailCode = (data: { email: string }) => { + return request.post('/users/send-email-code', data) +} +// Verify code and change email +export const changeEmail = (data: ChangeEmailModalForm) => { + return request.put('/users/change-email', data) } \ No newline at end of file diff --git a/web/src/components/Empty/index.tsx b/web/src/components/Empty/index.tsx index fbf57767..48bfa33c 100644 --- a/web/src/components/Empty/index.tsx +++ b/web/src/components/Empty/index.tsx @@ -2,7 +2,7 @@ * @Author: ZhaoYing * @Date: 2026-02-02 15:03:25 * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-02-02 15:47:31 + * @Last Modified time: 2026-02-25 11:14:25 */ /** * Empty Component @@ -13,7 +13,7 @@ * @component */ -import { type FC } from 'react'; +import { type FC, type ReactElement } from 'react'; import { useTranslation } from 'react-i18next'; import emptyIcon from '@/assets/images/empty/empty.svg'; @@ -24,7 +24,7 @@ interface EmptyProps { /** Icon size - single number or [width, height] array */ size?: number | number[]; /** Main title text */ - title?: string; + title?: string | ReactElement; /** Whether to show subtitle */ isNeedSubTitle?: boolean; /** Custom subtitle text */ diff --git a/web/src/components/Header/UserInfoModal.tsx b/web/src/components/Header/UserInfoModal.tsx index ac187fb8..94a4db7c 100644 --- a/web/src/components/Header/UserInfoModal.tsx +++ b/web/src/components/Header/UserInfoModal.tsx @@ -2,7 +2,7 @@ * @Author: ZhaoYing * @Date: 2026-02-02 15:09:47 * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-02-02 15:51:54 + * @Last Modified time: 2026-02-25 11:40:47 */ /** * UserInfoModal Component @@ -15,7 +15,7 @@ */ import { forwardRef, useImperativeHandle, useState, useRef } from 'react'; -import { Button } from 'antd'; +import { Button, Space } from 'antd'; import { UnlockOutlined } from '@ant-design/icons'; import { useTranslation } from 'react-i18next'; @@ -23,7 +23,9 @@ import { useUser } from '@/store/user'; import RbModal from '@/components/RbModal' import { formatDateTime } from '@/utils/format'; import ResetPasswordModal from '@/views/UserManagement/components/ResetPasswordModal' -import type { ResetPasswordModalRef } from '@/views/UserManagement/types' +import type { ResetPasswordModalRef, VerifyPasswordModalRef, ChangeEmailModalRef } from '@/views/UserManagement/types' +import VerifyPasswordModal from '@/views/UserManagement/components/VerifyPasswordModal' +import ChangeEmailModal from '@/views/UserManagement/components/ChangeEmailModal' /** Interface for UserInfoModal ref methods exposed to parent components */ export interface UserInfoModalRef { @@ -37,8 +39,10 @@ export interface UserInfoModalRef { const UserInfoModal = forwardRef((_props, ref) => { const { t } = useTranslation(); const resetPasswordModalRef = useRef(null) - const { user } = useUser(); + const { user, getUserInfo } = useUser(); const [visible, setVisible] = useState(false); + const verifyPasswordModalRef = useRef(null) + const changeEmailModalRef = useRef(null) /** Close the modal */ const handleClose = () => { @@ -50,6 +54,17 @@ const UserInfoModal = forwardRef((_props, ref) => { setVisible(true); }; + /** Open password verification modal before editing email */ + const handleEditEmail = () => { + verifyPasswordModalRef.current?.handleOpen() + } + + /** Update user information after email change */ + const updateUserInfo = () => { + localStorage.removeItem('user') + getUserInfo() + } + /** Expose handleOpen and handleClose methods to parent component via ref */ useImperativeHandle(ref, () => ({ handleOpen, @@ -74,7 +89,13 @@ const UserInfoModal = forwardRef((_props, ref) => { {/* Email */}
{t('user.email')} - {user.email} + + {user.email} +
+
{/* Role */}
@@ -106,6 +127,14 @@ const UserInfoModal = forwardRef((_props, ref) => { ref={resetPasswordModalRef} source="changePassword" /> + changeEmailModalRef.current?.handleOpen()} + /> + ); }); diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index 5fcdf0ed..1cac2648 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -274,6 +274,28 @@ export const en = { createdAt: 'Creation Time', member: 'Member', passwordRule: 'password should have at least 6 characters', + authVerify: 'Identity Verification', + authVerifyDesc: 'For security reasons, please verify your login password first', + verify: 'Verify', + loginPassword: 'Login Password', + loginPasswordPlaceholder: 'Please enter the login password for the current account', + loginPasswordVerifyFailed: 'Incorrect password, please try again', + bindNewEmail: 'Bind New Email', + sureChange: 'Confirm Change', + sendEmailCode: 'Send Verification Code', + currentEmail: 'Current Email', + newEmail: 'New Email Address', + emailCode: 'Verification Code', + emailCodePlaceholder: 'Please enter the verification code received by the new email', + sureChangeEmail: 'Confirm to change the bound email to', + sureChangeEmailDesc: '?', + changeSuccess: 'Changed successfully', + sendSuccess: 'Verification code has been sent, please check', + newEmailSameAsOld: 'New email cannot be the same as current email', + emailCodeLengthRule: 'Please enter a 6-digit verification code', + emailFormatError: 'Incorrect email format', + sendCodeTooFrequent: 'Please resend after {{seconds}}s', + retrySend: 'Can resend after {{seconds}}s', }, timezones: { 'Asia/Shanghai': 'China Standard Time (UTC+8)', diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index 6b880426..4b5fe798 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -946,18 +946,29 @@ export const zh = { email: '邮箱', createdAt: '创建时间', member: '成员', - batchImport: '批量导入', - batchImportUser: '批量导入用户', - downloadTemplate: '下载导入模板', - templateDownloadSuccess: '模板下载成功', - startImport: '开始导入', - batchImportSuccess: '批量导入成功', - importFailed: '导入失败,请检查文件格式', - noFileSelected: '请选择要导入的文件', - onlyXlsxOrCsv: '只能上传 .xlsx 或 .csv 格式的文件', - reselect: '重新选择', - noFileSelectedTip: '未选择任何文件', - downloadTemplateTip: '请下载模板,填写用户信息后上传。' + passwordRule: '密码至少需要6个字符', + authVerify: '身份验证', + authVerifyDesc: '出于安全考虑,请先验证您的登录密码', + verify: '验证', + loginPassword: '登录密码', + loginPasswordPlaceholder: '请输入当前账号的登录密码', + loginPasswordVerifyFailed: '密码错误,请重新输入', + bindNewEmail: '绑定新邮箱', + sureChange: '确认修改', + sendEmailCode: '发送验证码', + currentEmail: '当前邮箱', + newEmail: '新邮箱地址', + emailCode: '验证码', + emailCodePlaceholder: '请输入新邮箱收到的验证码', + sureChangeEmail: '确认将绑定邮箱修改为', + sureChangeEmailDesc: '吗?', + changeSuccess: '修改成功', + sendSuccess: '验证码已发送,请查收', + newEmailSameAsOld: '新邮箱不能与当前邮箱相同', + emailCodeLengthRule: '请输入6位的验证码', + emailFormatError: '邮箱格式不正确', + sendCodeTooFrequent: '请在{{seconds}}s后重新发送', + retrySend: '{{seconds}}s后可重发', }, common: { search: '搜索', diff --git a/web/src/views/UserManagement/components/ChangeEmailModal.tsx b/web/src/views/UserManagement/components/ChangeEmailModal.tsx new file mode 100644 index 00000000..fbf93480 --- /dev/null +++ b/web/src/views/UserManagement/components/ChangeEmailModal.tsx @@ -0,0 +1,219 @@ +/* + * @Author: ZhaoYing + * @Date: 2026-02-25 11:45:07 + * @Last Modified by: ZhaoYing + * @Last Modified time: 2026-02-25 11:45:07 + */ +/** + * ChangeEmailModal Component + * + * A two-step modal for changing user email address with verification code. + * Step 1: Enter new email and send verification code + * Step 2: Confirm the email change + */ + +import { forwardRef, useImperativeHandle, useState } from 'react'; +import { Form, Input, App, Row, Col, Button, Steps } from 'antd'; +import { useTranslation } from 'react-i18next'; + +import type { ChangeEmailModalRef, ChangeEmailModalForm } from '../types' +import RbModal from '@/components/RbModal' +import { changeEmail, sendEmailCode } from '@/api/user' +import { useUser } from '@/store/user'; +import RbAlert from '@/components/RbAlert'; +import Empty from '@/components/Empty'; +import EmailIcon from '@/assets/images/login/email.svg' + +const FormItem = Form.Item; + +/** + * Component props interface + */ +interface ChangeEmailModalProps { + /** Callback function to refresh user data after email change */ + refresh: () => void; +} + +const steps = [ + 'bindNewEmail', + 'sureChange', +] + +const ChangeEmailModal = forwardRef(({ + refresh +}, ref) => { + const { t } = useTranslation(); + const { message } = App.useApp(); + const [visible, setVisible] = useState(false); + const [form] = Form.useForm(); + const [loading, setLoading] = useState(false) + const [current, setCurrent] = useState(0); + const { user } = useUser(); + const [codeLoading, setCodeLoading] = useState(false) + const [countdown, setCountdown] = useState(0) + const newEmail = Form.useWatch(['new_email'], form) + + /** Close modal and reset form */ + const handleClose = () => { + setVisible(false); + form.resetFields(); + setLoading(false) + setCurrent(0) + setCountdown(0) + }; + /** Handle cancel button click - go back to previous step or close modal */ + const handleCancel = () => { + if (current === 0) { + handleClose() + } else { + setCurrent(0) + } + } + + /** Open modal */ + const handleOpen = () => { + form.resetFields(); + setVisible(true); + }; + /** Handle save/next button click - proceed to next step or submit email change */ + const handleSave = () => { + form + .validateFields() + .then((values) => { + if (current === 0) { + setCurrent(1) + } else { + setLoading(true) + changeEmail(values) + .then(() => { + setLoading(false) + refresh() + handleClose() + message.success(t('user.changeSuccess')) + }) + .catch(() => { + setLoading(false) + }); + } + }) + .catch((err) => { + console.log('err', err) + }); + } + + /** Send verification code to new email with countdown timer */ + const handleSendCode = () => { + if (countdown > 0) { + message.warning(t('user.sendCodeTooFrequent', { seconds: countdown })); + return; + } + form + .validateFields(['new_email']) + .then((values) => { + setCodeLoading(true) + sendEmailCode({ email: values.new_email }) + .then(() => { + message.success(t('user.sendSuccess')) + setCountdown(300) + const timer = setInterval(() => { + setCountdown((prev) => { + if (prev <= 1) { + clearInterval(timer) + return 0 + } + return prev - 1 + }) + }, 1000) + }) + .finally(() => { + setCodeLoading(false) + }) + }) + .catch((err) => { + console.log('err', err) + }); + } + + /** Expose methods to parent component */ + useImperativeHandle(ref, () => ({ + handleOpen, + handleClose + })); + + return ( + {current === 1 ? t('common.prevStep') : t('common.cancel')}, + , + ]} + > +
+ ({ title: t(`user.${key}`) }))} + /> +
+ {current === 0 && {t('user.currentEmail')}: {user.email}} + {current === 1 && + {t('user.sureChangeEmail')}
+
{newEmail}
+ {t('user.sureChangeEmailDesc')} +
} />} + + + ); +}); + +export default ChangeEmailModal; \ No newline at end of file diff --git a/web/src/views/UserManagement/components/VerifyPasswordModal.tsx b/web/src/views/UserManagement/components/VerifyPasswordModal.tsx new file mode 100644 index 00000000..4b2552ad --- /dev/null +++ b/web/src/views/UserManagement/components/VerifyPasswordModal.tsx @@ -0,0 +1,111 @@ +/* + * @Author: ZhaoYing + * @Date: 2026-02-25 10:51:17 + * @Last Modified by: ZhaoYing + * @Last Modified time: 2026-02-25 11:46:11 + */ +/** + * VerifyPasswordModal Component + * + * A modal dialog for verifying user's current login password before performing + * sensitive operations (e.g., changing email address). + */ + +import { forwardRef, useImperativeHandle, useState } from 'react'; +import { Form, Input } from 'antd'; +import { useTranslation } from 'react-i18next'; +import { ExclamationCircleFilled } from '@ant-design/icons'; + +import type { VerifyPasswordModalRef } from '../types' +import RbModal from '@/components/RbModal' +import { verifyPassword } from '@/api/user' +import RbAlert from '@/components/RbAlert'; + +/** + * VerifyPasswordModal component props + */ +interface VerifyPasswordModalProps { + /** Callback function executed after successful password verification */ + refresh: () => void; +} + +const VerifyPasswordModal = forwardRef(({ refresh }, ref) => { + const { t } = useTranslation(); + const [visible, setVisible] = useState(false); + const [form] = Form.useForm<{ password: string }>(); + const [loading, setLoading] = useState(false) + + /** Close modal and reset form */ + const handleClose = () => { + setVisible(false); + form.resetFields(); + setLoading(false) + }; + + /** Open modal */ + const handleOpen = () => { + form.resetFields(); + setVisible(true); + }; + /** Verify password and execute callback on success */ + const handleSave = () => { + form + .validateFields() + .then((values) => { + setLoading(true) + verifyPassword(values) + .then(() => { + refresh() + handleClose() + }) + .catch(() => { + form.setFields([{ + name: 'password', + errors: [t('user.loginPasswordVerifyFailed')] + }]) + }) + .finally(() => { + setLoading(false) + }) + }) + .catch((err) => { + console.log('err', err) + }); + } + + /** Expose methods to parent component */ + useImperativeHandle(ref, () => ({ + handleOpen, + handleClose + })); + + return ( + + } className="rb:mb-4!">{ t('user.authVerifyDesc') } +
+ + + +
+
+ ); +}); + +export default VerifyPasswordModal; \ No newline at end of file diff --git a/web/src/views/UserManagement/types.ts b/web/src/views/UserManagement/types.ts index e86f2e49..0250e925 100644 --- a/web/src/views/UserManagement/types.ts +++ b/web/src/views/UserManagement/types.ts @@ -2,7 +2,7 @@ * @Author: ZhaoYing * @Date: 2026-02-03 17:50:56 * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-02-03 17:51:17 + * @Last Modified time: 2026-02-25 11:44:02 */ /** * User data type @@ -49,4 +49,33 @@ export interface CreateModalRef { */ export interface ResetPasswordModalRef { handleOpen: (user: User) => void; +} +/** + * Verify password modal ref interface + */ +export interface VerifyPasswordModalRef { + handleOpen: () => void; +} + +/** + * Check password modal ref interface + */ +export interface CheckPasswordModalRef { + handleOpen: () => void; + handleClose: () => void; +} + +/** + * Change email modal ref interface + */ +export interface ChangeEmailModalRef { + handleOpen: () => void; +} + +/** + * Change email form data type + */ +export interface ChangeEmailModalForm { + new_email: string; + code: string; } \ No newline at end of file From 92d78d9a5267f9b8c75fdf723168ad4c7d5c7587 Mon Sep 17 00:00:00 2001 From: Mark Date: Wed, 25 Feb 2026 12:29:26 +0800 Subject: [PATCH 17/36] [add] migration script --- .../versions/75e28690ae87_202602251230.py | 66 +++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 api/migrations/versions/75e28690ae87_202602251230.py diff --git a/api/migrations/versions/75e28690ae87_202602251230.py b/api/migrations/versions/75e28690ae87_202602251230.py new file mode 100644 index 00000000..629e3b19 --- /dev/null +++ b/api/migrations/versions/75e28690ae87_202602251230.py @@ -0,0 +1,66 @@ +"""202602251230 + +Revision ID: 75e28690ae87 +Revises: bab823f7cc82 +Create Date: 2026-02-25 12:27:36.919237 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '75e28690ae87' +down_revision: Union[str, None] = 'bab823f7cc82' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('mcp_market_configs', + sa.Column('id', sa.UUID(), nullable=False), + sa.Column('mcp_market_id', sa.UUID(), nullable=False, comment='mcp_markets.id'), + sa.Column('token', sa.String(), nullable=True, comment='mcp market token'), + sa.Column('status', sa.Integer(), nullable=True, comment='connect status(0: Not connected, 1: connected)'), + sa.Column('tenant_id', sa.UUID(), nullable=False, comment='tenant.id'), + sa.Column('created_by', sa.UUID(), nullable=False, comment='users.id'), + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_mcp_market_configs_id'), 'mcp_market_configs', ['id'], unique=False) + op.create_table('mcp_markets', + sa.Column('id', sa.UUID(), nullable=False), + sa.Column('name', sa.String(), nullable=False, comment='mcp market name'), + sa.Column('description', sa.String(), nullable=True, comment='mcp market description'), + sa.Column('logo_url', sa.String(), nullable=True, comment='logo url'), + sa.Column('mcp_count', sa.Integer(), nullable=True, comment='mcp count'), + sa.Column('url', sa.String(), nullable=False, comment='mcp market url'), + sa.Column('category', sa.String(), nullable=False, comment='category'), + sa.Column('created_by', sa.UUID(), nullable=False, comment='users.id'), + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_mcp_markets_category'), 'mcp_markets', ['category'], unique=False) + op.create_index(op.f('ix_mcp_markets_description'), 'mcp_markets', ['description'], unique=False) + op.create_index(op.f('ix_mcp_markets_id'), 'mcp_markets', ['id'], unique=False) + op.create_index(op.f('ix_mcp_markets_logo_url'), 'mcp_markets', ['logo_url'], unique=False) + op.create_index(op.f('ix_mcp_markets_name'), 'mcp_markets', ['name'], unique=False) + op.create_index(op.f('ix_mcp_markets_url'), 'mcp_markets', ['url'], unique=False) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_mcp_markets_url'), table_name='mcp_markets') + op.drop_index(op.f('ix_mcp_markets_name'), table_name='mcp_markets') + op.drop_index(op.f('ix_mcp_markets_logo_url'), table_name='mcp_markets') + op.drop_index(op.f('ix_mcp_markets_id'), table_name='mcp_markets') + op.drop_index(op.f('ix_mcp_markets_description'), table_name='mcp_markets') + op.drop_index(op.f('ix_mcp_markets_category'), table_name='mcp_markets') + op.drop_table('mcp_markets') + op.drop_index(op.f('ix_mcp_market_configs_id'), table_name='mcp_market_configs') + op.drop_table('mcp_market_configs') + # ### end Alembic commands ### From 4ea9c7e660978239c3045ef0fd1630ffb1eecaea Mon Sep 17 00:00:00 2001 From: zhaoying Date: Wed, 25 Feb 2026 15:43:05 +0800 Subject: [PATCH 18/36] fix(web): invite-register not need authToken --- web/src/App.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/App.tsx b/web/src/App.tsx index b3d0708c..1d298358 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -37,7 +37,7 @@ function App() { const { checkJump } = useUser(); useEffect(() => { const authToken = cookieUtils.get('authToken') - if (!authToken && !window.location.hash.includes('#/login') && !window.location.hash.includes('#/conversation/') && !window.location.hash.includes('#/jump')) { + if (!authToken && !window.location.hash.includes('#/login') && !window.location.hash.includes('#/conversation/') && !window.location.hash.includes('#/jump') && !window.location.hash.includes('#/invite-register')) { window.location.href = `/#/login`; } else { checkJump() From 275be47224347b9ff995b01957ce0edf588a9c54 Mon Sep 17 00:00:00 2001 From: zhaoying Date: Wed, 25 Feb 2026 15:47:13 +0800 Subject: [PATCH 19/36] fix(web): user i18next update --- web/src/i18n/en.ts | 2 ++ web/src/i18n/zh.ts | 2 ++ web/src/views/UserManagement/index.tsx | 6 +++--- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index 1cac2648..8bfe8d41 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -256,10 +256,12 @@ export const en = { resetPasswordSuccess: 'Password reset successful', resetPasswordFailed: 'Password reset failed', enabled: 'Enabled', + enabledOpera: 'Activate', enabledConfirm: 'Are you sure to enable this user?', enabledConfirmSuccess: 'Enabled successfully', enabledConfirmFailed: 'Enabled failed', disabled: 'Disabled', + disabledOpera: 'Deactivate', disabledConfirm: 'Are you sure to disable this user?', disabledConfirmSuccess: 'Disabled successfully', disabledConfirmFailed: 'Disabled failed', diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index 4b5fe798..421d2d58 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -924,10 +924,12 @@ export const zh = { expiryDateDue: '有效期至', status: '状态', enabled: '已启用', + enabledOpera: '启用', enabledConfirm: '确定要启用此用户吗?', enabledConfirmSuccess: '启用成功', enabledConfirmFailed: '启用失败', disabled: '已停用', + disabledOpera: '停用', disabledConfirm: '确定要停用此用户吗?', disabledConfirmSuccess: '停用成功', disabledConfirmFailed: '停用失败', diff --git a/web/src/views/UserManagement/index.tsx b/web/src/views/UserManagement/index.tsx index 6948a244..9629639a 100644 --- a/web/src/views/UserManagement/index.tsx +++ b/web/src/views/UserManagement/index.tsx @@ -1,8 +1,8 @@ /* * @Author: ZhaoYing * @Date: 2026-02-03 17:51:08 - * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-02-03 17:51:08 + * @Last Modified by: ZhaoYing + * @Last Modified time: 2026-02-25 15:44:54 */ /** * User Management Page @@ -127,7 +127,7 @@ const UserManagement: React.FC = () => { type="link" onClick={() => handleChangeStatus(record as User)} > - {t(`common.${record.is_active ? 'disabled' : 'enabled'}`)} + {t(`user.${record.is_active ? 'disabledOpera' : 'enabledOpera'}`)}
), From bd037ac3a33735df0fd44903bc3ccb962760fa14 Mon Sep 17 00:00:00 2001 From: Timebomb2018 <18868801967@163.com> Date: Wed, 25 Feb 2026 16:57:00 +0800 Subject: [PATCH 20/36] fix(token): If the "username" is provided, then use "username" as the username. --- api/app/controllers/auth_controller.py | 1 + api/app/schemas/token_schema.py | 3 ++- api/app/services/auth_service.py | 6 ++++-- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/api/app/controllers/auth_controller.py b/api/app/controllers/auth_controller.py index a6960096..708cbaa2 100644 --- a/api/app/controllers/auth_controller.py +++ b/api/app/controllers/auth_controller.py @@ -61,6 +61,7 @@ async def login_for_access_token( user = auth_service.register_user_with_invite( db=db, email=form_data.email, + username=form_data.username, password=form_data.password, invite_token=form_data.invite, workspace_id=invite_info.workspace_id diff --git a/api/app/schemas/token_schema.py b/api/app/schemas/token_schema.py index 310e98a0..b10a3a14 100644 --- a/api/app/schemas/token_schema.py +++ b/api/app/schemas/token_schema.py @@ -26,5 +26,6 @@ class RefreshTokenRequest(BaseModel): class TokenRequest(BaseModel): email: EmailStr password: str - invite: Optional[str] = None + invite: Optional[str] = None, + username: Optional[str] = None diff --git a/api/app/services/auth_service.py b/api/app/services/auth_service.py index 877d8d5c..03e1ebc0 100644 --- a/api/app/services/auth_service.py +++ b/api/app/services/auth_service.py @@ -129,7 +129,8 @@ def register_user_with_invite( email: str, password: str, invite_token: str, - workspace_id: str + workspace_id: str, + username: Optional[str] = None, ) -> User: """ 使用邀请码注册新用户并加入工作空间 @@ -139,6 +140,7 @@ def register_user_with_invite( :param password: 用户密码 :param invite_token: 邀请令牌 :param workspace_id: 工作空间ID + :param username: 用户名 :return: 创建的用户对象 """ from app.schemas.user_schema import UserCreate @@ -154,7 +156,7 @@ def register_user_with_invite( user_create = UserCreate( email=email, password=password, - username=email.split('@')[0] + username=email.split('@')[0] if not username else username ) user = user_service.create_user(db=db, user=user_create) logger.info(f"用户创建成功: {user.email} (ID: {user.id})") From fc8f06ee1442b385eb3f5a445e0114300440ae73 Mon Sep 17 00:00:00 2001 From: zhaoying Date: Wed, 25 Feb 2026 18:12:33 +0800 Subject: [PATCH 21/36] fix(web): Agent init chat variables --- web/src/views/ApplicationConfig/Agent.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/web/src/views/ApplicationConfig/Agent.tsx b/web/src/views/ApplicationConfig/Agent.tsx index 4c3b73ba..2ece4b6e 100644 --- a/web/src/views/ApplicationConfig/Agent.tsx +++ b/web/src/views/ApplicationConfig/Agent.tsx @@ -2,7 +2,7 @@ * @Author: ZhaoYing * @Date: 2026-02-03 16:29:21 * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-02-10 18:46:40 + * @Last Modified time: 2026-02-25 18:11:49 */ import { type FC, type ReactNode, useEffect, useRef, useState, forwardRef, useImperativeHandle } from 'react'; import clsx from 'clsx' @@ -168,7 +168,7 @@ const Agent = forwardRef((_props, ref) => { setLoading(true) getApplicationConfig(id as string).then(res => { const response = res as Config - const { skills } = response + const { skills, variables } = response let allSkills = Array.isArray(skills?.skill_ids) ? skills?.skill_ids.map(vo => ({ id: vo })) : [] let allTools = Array.isArray(response.tools) ? response.tools : [] const memoryContent = response.memory?.memory_config_id @@ -187,6 +187,7 @@ const Agent = forwardRef((_props, ref) => { skill_ids: allSkills } }) + updateVariableList([...variables]) setData({ ...response, tools: allTools From 6c49456c131e5d693690173bcdce70e6fc4c2620 Mon Sep 17 00:00:00 2001 From: zhaoying Date: Wed, 25 Feb 2026 18:50:30 +0800 Subject: [PATCH 22/36] fix(web): update i18n --- web/src/i18n/en.ts | 15 ++++++- web/src/i18n/zh.ts | 104 ++++++--------------------------------------- 2 files changed, 27 insertions(+), 92 deletions(-) diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index 8bfe8d41..8dfb68db 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -1,6 +1,5 @@ export const en = { translation: { - welcome: 'Welcome to React Font CLI', title: 'Memory Bear.AI ', memoryBear: 'Memory Bear.AI', index:{ @@ -248,6 +247,7 @@ export const en = { usernameOrAccount: 'Username / Login Account', displayName: 'Display Name', role: 'Role', + password: 'Password', status: 'Status', createTime: 'Creation Time', lastLoginTime: 'Last Login Time', @@ -602,6 +602,9 @@ export const en = { bedrock: "Bedrock" }, knowledgeBase: { + home: 'Home', + selectSpace: 'Please select space', + preview: 'Preview', pleaseUploadFileFirst: 'Please upload file first', shareSuccess: 'Share successfully', shareFailed: 'Share failed', @@ -1192,7 +1195,7 @@ export const en = { stateSharingStrategy: 'State Sharing Strategy', intermediateResultProcessing: 'Intermediate Result Processing', metadataTransfer: 'Metadata Transfer', - + knowledgeConfig: 'Knowledge Base Configuration', temperature: 'Temperature', temperature_desc: 'Temperature parameters, control the randomness of output', max_tokens: 'Max Tokens', @@ -1336,6 +1339,13 @@ export const en = { analyTask: 'Analyze Task Intent', dynamicMatchSkill: 'Dynamic Match Skill', executeTask: 'Execute Task', + + upload: 'Upload & Parse', + complex: 'Compatibility Analysis', + node: 'Node Mapping', + configCheck: 'Configuration Validation', + sureInfo: 'Information Confirmation', + completed: 'Import Completed', }, userMemory: { userMemory: 'User Memory', @@ -2012,6 +2022,7 @@ Memory Bear: After the rebellion, regional warlordism intensified for several re query: 'Query Variable', knowledge_retrieval: 'Knowledge Base', recallConfig: 'Recall Test', + addKnowledge: 'Add Knowledge Base' }, 'parameter-extractor': { model_id: 'Model', diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index 421d2d58..feefc843 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -2,7 +2,6 @@ export const zh = { translation: { title: '记忆熊', memoryBear: '记忆熊', - welcome: '欢迎使用 React Font CLI', index:{ viewGuide: '查看引导', watchVideo: '观看视频', @@ -92,6 +91,7 @@ export const zh = { memberManagement: '成员管理', memorySummary: '记忆摘要', memoryConversation: '记忆验证', + helpCenter: '帮助中心', memorySummaryHandlers: '记忆摘要处理器', createMemorySummary: '创建记忆摘要', memoryManagement: '记忆管理', @@ -189,6 +189,7 @@ export const zh = { customText: '自定义文本', customContent: '自定义内容', createContentError: '创建自定义文件失败', + createLinkError: '创建链接内容失败', manuallyInputText: '手动输入一段文本作为数据集', openKnowledgeBase: '打开知识库', searchPlaceholder: '搜索', @@ -243,6 +244,7 @@ export const zh = { processing: '处理中', name: '名称', processingMode: '处理模式', + processMsg: '处理消息', dataSize: '数据量', createUpdateTime: '创建/更新时间', datasets: '通用知识库', @@ -440,8 +442,6 @@ export const zh = { agentDesc: '创建单个智能代理', multi_agent: '集群', multi_agentDesc: '创建Agent集群', - cluster: '集群', - clusterDesc: '创建Agent集群', workflow: '工作流', workflowDesc: '创建策略工作流', editApplication: '编辑应用信息', @@ -550,7 +550,6 @@ export const zh = { versionList: '版本列表', versionListDesc: '所有发布记录和状态', - fullAmount: '全量', current: '当前', rolledBack: '已回滚', history: '历史', @@ -574,6 +573,10 @@ export const zh = { clusterName: '集群名称', clusterDescription: '集群描述', clusterDescriptionPlaceholder: '这是一个专门处理核心业务的Agent集群,能够协作完成复杂的业务处理任务。', + toolCalling: '工具调用', + toolCallingDesc: '主控代理将子代理作为工具调用', + toolCallingFeature: '集中控制,适合结构化工作流', + handoffsFeature: '分散控制,适合复杂对话场景', recommend: '推荐', advanced: '高级', multiAgentArchitecture: '多代理架构模式', @@ -586,7 +589,7 @@ export const zh = { addSubAgent: '添加子代理', versionName: '版本名称', versionNameTip: '版本号格式:v[主版本号].[次版本号].[修订号](例如 v1.3.0)', - agentName: '代理名称', + agentName: 'Agent名称', roleType: '角色类型', coordinator: '协调者', analyzer: '分析者', @@ -595,6 +598,9 @@ export const zh = { updateSubAgent: '更新子代理', subAgentMaxLength: '子代理最多{{maxLength}}个', capabilities: '能力', + subAgent: '子代理', + maxChatCount: '最多添加4个模型', + ReplyException: '回复异常', contextEngineering: '上下文工程', dialogueHistoryManagement: '对话历史管理', stateSharingStrategy: '状态共享策略', @@ -737,55 +743,6 @@ export const zh = { sureInfo: '信息确认', completed: '完成导入', }, - role: { - roleManagement: '角色管理', - roleId: '角色ID', - roleName: '角色名称', - roleCode: '角色编码', - description: '角色描述', - status: '状态', - enabled: '已启用', - disabled: '已停用', - createTime: '创建时间', - createRole: '新建角色', - editRole: '编辑角色', - roleTemplate: '角色模板', - emptyTemplate: '空模板', - adminTemplate: '管理员模板', - userTemplate: '用户模板', - confirmDelete: '确定要删除这个角色吗?', - createSuccess: '角色创建成功', - updateSuccess: '角色更新成功', - deleteSuccess: '角色删除成功', - createFailed: '角色创建失败', - updateFailed: '角色更新失败', - deleteFailed: '角色删除失败' - }, - tenant: { - tenantId: '租户ID', - tenantName: '租户名称', - contactPerson: '联系人', - contactInfo: '联系方式', - status: '状态', - enabled: '启用', - disabled: '禁用', - expiryDate: '到期时间', - createTenant: '新增租户', - editTenant: '编辑租户', - searchPlaceholder: '搜索租户ID、名称、联系人或联系方式', - confirmDelete: '确定要删除该租户吗?', - confirmBatchDelete: '确定要批量删除选中的租户吗?', - fetchFailed: '获取租户数据失败', - batchEnableSuccess: '批量启用成功', - batchEnableFailed: '批量启用失败', - batchDisableSuccess: '批量停用成功', - batchDisableFailed: '批量停用失败', - exportSuccess: '导出成功', - batchDeleteSuccess: '批量删除成功', - batchDeleteFailed: '批量删除失败', - saveFailed: '保存失败', - batchImport: '批量导入' - }, table: { totalRecords: '共 {{total}} 条记录' }, @@ -916,12 +873,9 @@ export const zh = { subUsername: '或登录账号', usernameOrAccount: '用户名 / 登录账号', displayName: '显示名称', - tenantName: '所属租户', role: '角色', password: '密码', initialPassword: '初始密码', - expiryDate: '有效期', - expiryDateDue: '有效期至', status: '状态', enabled: '已启用', enabledOpera: '启用', @@ -993,6 +947,7 @@ export const zh = { exportList: '导出列表', selectPlaceholder: '请选择{{title}}', inputPlaceholder: '请输入{{title}}', + enterPlaceholder: '输入 {{title}}', saveSuccess: '保存成功', saveFailure: '保存失败', pleaseSelect: '请选择', @@ -1032,6 +987,7 @@ export const zh = { confirmChangeStatusDesc: '确定要更改【{{name}}】的状态吗?', operationSuccess: '操作成功', operateSuccess: '操作成功', + deleted: '已删除', pleaseUpload: '请上传', returnToSpace: '返回空间', createSuccess: '创建成功', @@ -1064,24 +1020,6 @@ export const zh = { prevStep: '上一步', exportSuccess: '导出成功', }, - product: { - applicationManagement: '应用管理', - createApplication: '创建应用', - applicationName: '应用名称', - applicationIcon: '应用图标', - applicationNameRequired: '请输入应用名称', - associationStatus: '关联状态', - associated: '已关联', - notAssociated: '未关联', - unassociate: '解除关联', - unassociateSuccess: '解除关联成功', - unassociateFailed: '解除关联失败', - viewKey: '查看KEY', - viewStats: '查看统计', - disableSuccess: '停用成功', - enableSuccess: '启用成功', - operationFailed: '操作失败', - }, model: { searchPlaceholder: '搜索模型…', type: '类型', @@ -1758,6 +1696,7 @@ export const zh = { name: '姓名', nameSubTitle: '(可选,用于团队成员识别)', namePlaceholder: '请输入您的姓名', + inviteLinkInvalid: '邀请链接无效', passwordStrength: '密码强度', noSet: '未设置', @@ -1781,21 +1720,6 @@ export const zh = { pageEmpty: '哎呀!暂无搜索结果', pageEmptyDesc: '红熊歪着头等待您更换新的关键词,让我们一起探索吧。', }, - - home: { - title: '首页', - welcome: '欢迎使用我们的带单页路由的 React 应用!', - counterCard: '计数器演示', - aboutCard: '关于我们', - workflowCard: '工作流编辑器', - websocketDemoCard: 'WebSocket 演示', - sseDemoCard: 'SSE演示' - }, - notFound: { - title: '页面未找到', - description: '请求的页面不存在。', - backToHome: '返回首页' - }, apiKey: { name: '项目名称', createApiKey: '创建API Key', From b33ccf00f9fc39881bc835b3e7847247789c9822 Mon Sep 17 00:00:00 2001 From: lixiangcheng1 Date: Wed, 25 Feb 2026 19:09:52 +0800 Subject: [PATCH 23/36] [fix]Force re-importing Trio in child processes (to avoid inheriting the state of the parent process) --- api/app/tasks.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/api/app/tasks.py b/api/app/tasks.py index 99755b83..d60af6e5 100644 --- a/api/app/tasks.py +++ b/api/app/tasks.py @@ -1,4 +1,5 @@ import asyncio +from concurrent.futures import ThreadPoolExecutor import json import os import re @@ -368,7 +369,7 @@ def build_graphrag_for_kb(kb_id: uuid.UUID): print(f"{datetime.now().strftime('%H:%M:%S')} GraphRAG task result for task {task}:\n{result}\n") return result - try: + def sync_task(): trio.run( lambda: _run( row=task, @@ -383,6 +384,10 @@ def build_graphrag_for_kb(kb_id: uuid.UUID): with_community=with_community, ) ) + try: + with ThreadPoolExecutor(max_workers=1) as executor: + future = executor.submit(sync_task) + future.result() # Blocks until the task completes except Exception as e: print(f"{datetime.now().strftime('%H:%M:%S')} GraphRAG task failed for task {task}:\n{str(e)}\n") finally: From c72ce381c06c96e1b46234d472c4474248c7da23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B1=B1=E7=A8=8B=E6=BC=AB=E6=82=9F?= <41945635+TimeBomb2018@users.noreply.github.com> Date: Thu, 26 Feb 2026 14:47:57 +0800 Subject: [PATCH 24/36] fix(workspace member) (#407) * fix(workspace member): After the space inviter is removed, it can still be invited again. * fix(login): fix login bug --- api/app/repositories/workspace_repository.py | 1 + api/app/schemas/token_schema.py | 2 +- api/app/services/workspace_service.py | 4 ++-- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/api/app/repositories/workspace_repository.py b/api/app/repositories/workspace_repository.py index 70ed7521..87b0e20f 100644 --- a/api/app/repositories/workspace_repository.py +++ b/api/app/repositories/workspace_repository.py @@ -115,6 +115,7 @@ class WorkspaceRepository: self.db.query(Workspace) .join(WorkspaceMember, Workspace.id == WorkspaceMember.workspace_id) .filter(WorkspaceMember.user_id == user_id) + .filter(WorkspaceMember.is_active.is_(True)) .filter(Workspace.is_active.is_(True)) .order_by(Workspace.updated_at.desc()) .all() diff --git a/api/app/schemas/token_schema.py b/api/app/schemas/token_schema.py index b10a3a14..3bbea35e 100644 --- a/api/app/schemas/token_schema.py +++ b/api/app/schemas/token_schema.py @@ -26,6 +26,6 @@ class RefreshTokenRequest(BaseModel): class TokenRequest(BaseModel): email: EmailStr password: str - invite: Optional[str] = None, + invite: Optional[str] = None username: Optional[str] = None diff --git a/api/app/services/workspace_service.py b/api/app/services/workspace_service.py index 9ee98fa0..6f102695 100644 --- a/api/app/services/workspace_service.py +++ b/api/app/services/workspace_service.py @@ -70,10 +70,10 @@ def delete_workspace_member( _check_workspace_admin_permission(db, workspace_id, user) workspace_member = workspace_repository.get_member_by_id(db=db, member_id=member_id) if not workspace_member: - raise BusinessException(f"工作空间成员 {member_id} 不存在", BizCode.WORKSPACE_MEMBER_NOT_FOUND) + raise BusinessException(f"工作空间成员 {member_id} 不存在", BizCode.WORKSPACE_NOT_FOUND) if workspace_member.workspace_id != workspace_id: - raise BusinessException(f"工作空间成员 {member_id} 不存在于工作空间 {workspace_id}", BizCode.WORKSPACE_MEMBER_NOT_FOUND) + raise BusinessException(f"工作空间成员 {member_id} 不存在于工作空间 {workspace_id}", BizCode.WORKSPACE_NOT_FOUND) try: workspace_member.is_active = False From cbc960249529caf82a68bc98ba46eb7a9f2ba218 Mon Sep 17 00:00:00 2001 From: Timebomb2018 <18868801967@163.com> Date: Thu, 26 Feb 2026 16:22:45 +0800 Subject: [PATCH 25/36] fix(version): fix version information --- api/app/version_info.json | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/api/app/version_info.json b/api/app/version_info.json index 991369d7..aea03dcd 100644 --- a/api/app/version_info.json +++ b/api/app/version_info.json @@ -1,4 +1,38 @@ { + "v0.2.4": { + "introduction": { + "codeName": "智远", + "releaseDate": "2026-2-11", + "upgradePosition": "🐻 生产级稳健性升级版本,智慧致远,从容应对复杂场景", + "coreUpgrades": [ + "1. Skills 技能框架 🛠️
* Skills 支持:引入全新的Skills技能系统,支持可扩展的能力模块,可在Agent和工作流中动态加载与编排", + "2. 多模态与交互 💬
* 文件多模态支持:全面支持消息输入、LLM处理和输出渲染中的多模态文件处理,实现更丰富的媒体感知对话
* 语音交互:语音交互功能正在积极开发中,为免提对话体验奠定基础(开发中)", + "3. 知识库集成 📚
* 飞书知识库:无缝对接飞书文档库,支持企业知识检索
* 语雀知识库:原生连接语雀文档平台,扩展对国内企业工具生态的覆盖
* Web站点知识库:通用Web站点抓取与索引,支持从公开网页内容构建知识库
* 视觉模型选择优化:知识库视觉模型配置现已支持LLM和Chat两种模型类型,移除了此前仅限Chat类型的限制", + "4. 记忆智能 🧠
* 本体工程(二期):基于本体工程的高级记忆场景分类与萃取,实现结构化、领域感知的记忆组织,提升分类准确性
* 默认模型配置:情绪分析、反思和记忆萃取模块现默认使用空间级模型,确保开箱即用的一致性行为
* 智能模型回退:当已配置的情绪或反思模型为空或不可用时,系统自动回退至空间默认模型,避免静默失败
* 记忆模型回退兜底:当记忆中配置的模型为空或不可用时,系统优雅降级至空间默认模型", + "5. 性能与扩展 ⚡
* 模型并发(model_api_keys):支持并发模型API Key管理,实现并行模型调用,提升高负载场景下的吞吐能力", + "6. 稳健性与缺陷修复 🔧
* 记忆配置版本固定:修复用户记忆配置未跟随应用版本发布固定的问题,消除跨部署的行为不一致
* 空间默认记忆保护:空间级默认记忆配置现不可删除;用户级配置仍可删除
* Agent与工作流配置兜底:解决Agent和工作流节点中记忆配置可能为空、或已选择但未配置的边界情况——全面的回退处理现可防止运行时错误
* 隐形记忆字段重命名:将隐形记忆接口JSON响应中的user_id修正为end_user_id,与规范数据模型对齐
* 记忆配置ID迁移:将Agent和工作流记忆配置中的memory_content重命名为memory_config_id,保持API一致性
* Worker-Memory告警解决:解决worker-memory服务中的告警级别问题,提升运维监控清晰度
* 双语接口修复:修复记忆相关API接口的中英文不一致问题
* 新用户记忆配置自动回填:新创建的EndUser若memory_config_id为None,系统自动从最新Release获取memory_config_id并回填
* 存量用户记忆配置自动回填:已有EndUser若memory_config_id为None,系统同样从最新Release获取并回填,确保向后兼容,无需手动迁移", + "
", + "Memory Bear v0.2.4 向生产级稳健性迈进,Skills框架与多模态支持开启认知平台新篇章。", + "记忆熊,智慧致远,从容应对真实世界的多样性。🐻✨" + ] + }, + "introduction_en": { + "codeName": "ZhiYuan", + "releaseDate": "2026-2-11", + "upgradePosition": "🐻 Production-grade resilience release — Wisdom Reaching Far, gracefully handling complex scenarios", + "coreUpgrades": [ + "1. Skills Framework 🛠️
* Skills Support: Introduced a new Skills system, enabling extensible capability modules that can be dynamically loaded and orchestrated within agents and workflows", + "2. Multimodal & Interaction 💬
* File Multimodal Support: Full multimodal file handling across message input, LLM processing, and output rendering — supporting richer, media-aware conversations
* Voice Interaction: Voice-based interaction capabilities are under active development, laying the groundwork for hands-free conversational experiences (In Progress)", + "3. Knowledge Base Integration 📚
* Feishu Knowledge Base: Seamless integration with Feishu (Lark) document repositories for enterprise knowledge retrieval
* Yuque Knowledge Base: Native connector for Yuque documentation platforms, expanding coverage of Chinese enterprise tooling
* Web Site Knowledge Base: General-purpose web site crawling and indexing for knowledge base construction from public web content
* Visual Model Selection: Knowledge base visual model configuration now supports both LLM and Chat model types, removing the previous restriction to Chat-only selection", + "4. Memory Intelligence 🧠
* Ontology Engineering (Phase 2): Advanced memory scene classification and extraction powered by ontology engineering — enabling structured, domain-aware memory organization with improved categorization accuracy
* Default Model Configuration: Emotion analysis, reflection, and memory extraction modules now default to the space-level model, ensuring consistent behavior out of the box
* Intelligent Model Fallback: If configured emotion or reflection models are empty or unavailable, the system automatically falls back to the space default model — preventing silent failures
* Memory Config Fallback for Models: When any memory-configured model is empty or unavailable, the system gracefully degrades to the space default model", + "5. Performance & Scalability ⚡
* Model Concurrency (model_api_keys): Support for concurrent model API key management, enabling parallel model invocations and improved throughput for high-load scenarios", + "6. Robustness & Bug Fixes 🔧
* Memory Config Version Pinning: Fixed an issue where user memory configurations were not pinned to application release versions, causing inconsistent behavior across deployments
* Space Default Memory Protection: Space-level default memory configurations are now protected from deletion; user-level configurations remain deletable
* Agent & Workflow Config Fallback: Resolved edge cases in Agent and Workflow nodes where memory config could be empty or selected but unconfigured — comprehensive fallback handling now prevents runtime errors
* Implicit Memory Field Rename: Corrected user_id to end_user_id in JSON responses from implicit memory interfaces, aligning with the canonical data model
* Memory Config ID Migration: Renamed memory_content to memory_config_id in Agent and Workflow memory configurations for API consistency
* Worker-Memory Alerts: Resolved warning-level alerts in the worker-memory service, improving operational monitoring clarity
* Bilingual Interface Fixes: Fixed Chinese/English language inconsistencies across memory-related API interfaces
* EndUser Memory Config Auto-Backfill (New Users): When a newly created EndUser has memory_config_id as None, the system automatically fetches the latest release's memory_config_id and backfills it
* EndUser Memory Config Auto-Backfill (Existing Users): For existing EndUsers with memory_config_id as None, the system similarly retrieves and backfills from the latest release — ensuring backward compatibility without manual migration", + "
", + "Memory Bear v0.2.4 advances toward production-grade resilience, with the Skills framework and multimodal support opening a new chapter for the cognitive platform.", + "MemoryBear — Wisdom Reaching Far, gracefully handling real-world variability. 🐻✨" + ] + } + }, "v0.2.3": { "introduction": { "codeName": "归墟", From be2f56ae6a7fbc835dd05e6ed9c682c68ca3f323 Mon Sep 17 00:00:00 2001 From: Timebomb2018 <18868801967@163.com> Date: Thu, 26 Feb 2026 17:09:50 +0800 Subject: [PATCH 26/36] fix(file): File uploads can be made without workspace. --- api/app/models/file_metadata_model.py | 2 +- api/app/services/file_storage_service.py | 9 +++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/api/app/models/file_metadata_model.py b/api/app/models/file_metadata_model.py index baf9bd97..28e87367 100644 --- a/api/app/models/file_metadata_model.py +++ b/api/app/models/file_metadata_model.py @@ -35,7 +35,7 @@ class FileMetadata(Base): id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4, index=True) tenant_id = Column(UUID(as_uuid=True), nullable=False, index=True, comment="Tenant ID") - workspace_id = Column(UUID(as_uuid=True), nullable=False, index=True, comment="Workspace ID") + workspace_id = Column(UUID(as_uuid=True), nullable=True, index=True, comment="Workspace ID") file_key = Column(String(512), nullable=False, unique=True, index=True, comment="Storage file key") file_name = Column(String(255), nullable=False, comment="Original file name") file_ext = Column(String(32), nullable=False, comment="File extension") diff --git a/api/app/services/file_storage_service.py b/api/app/services/file_storage_service.py index 672e1cff..bb9f1894 100644 --- a/api/app/services/file_storage_service.py +++ b/api/app/services/file_storage_service.py @@ -26,7 +26,7 @@ logger = get_business_logger() def generate_file_key( tenant_id: uuid.UUID, - workspace_id: uuid.UUID, + workspace_id: uuid.UUID | None, file_id: uuid.UUID, file_ext: str, ) -> str: @@ -56,8 +56,9 @@ def generate_file_key( # Ensure file_ext starts with a dot if file_ext and not file_ext.startswith('.'): file_ext = f'.{file_ext}' - - return f"{tenant_id}/{workspace_id}/{file_id}{file_ext}" + if workspace_id: + return f"{tenant_id}/{workspace_id}/{file_id}{file_ext}" + return f"{tenant_id}/{file_id}{file_ext}" class FileStorageService: @@ -96,7 +97,7 @@ class FileStorageService: async def upload_file( self, tenant_id: uuid.UUID, - workspace_id: uuid.UUID, + workspace_id: uuid.UUID | None, file_id: uuid.UUID, file_ext: str, content: bytes, From bcf2376f5a9e873154afb8502188b73f022f489c Mon Sep 17 00:00:00 2001 From: zhaoying Date: Fri, 27 Feb 2026 10:13:49 +0800 Subject: [PATCH 27/36] fix(web): release bugfix --- .../components/ChangeEmailModal.tsx | 6 +- .../views/Workflow/components/Chat/Chat.tsx | 812 +----------------- 2 files changed, 5 insertions(+), 813 deletions(-) diff --git a/web/src/views/UserManagement/components/ChangeEmailModal.tsx b/web/src/views/UserManagement/components/ChangeEmailModal.tsx index fbf93480..64791519 100644 --- a/web/src/views/UserManagement/components/ChangeEmailModal.tsx +++ b/web/src/views/UserManagement/components/ChangeEmailModal.tsx @@ -1,8 +1,8 @@ /* * @Author: ZhaoYing * @Date: 2026-02-25 11:45:07 - * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-02-25 11:45:07 + * @Last Modified by: ZhaoYing + * @Last Modified time: 2026-02-27 09:59:41 */ /** * ChangeEmailModal Component @@ -114,7 +114,7 @@ const ChangeEmailModal = forwardRef( sendEmailCode({ email: values.new_email }) .then(() => { message.success(t('user.sendSuccess')) - setCountdown(300) + setCountdown(60) const timer = setInterval(() => { setCountdown((prev) => { if (prev <= 1) { diff --git a/web/src/views/Workflow/components/Chat/Chat.tsx b/web/src/views/Workflow/components/Chat/Chat.tsx index 51b1be38..895ade24 100644 --- a/web/src/views/Workflow/components/Chat/Chat.tsx +++ b/web/src/views/Workflow/components/Chat/Chat.tsx @@ -2,7 +2,7 @@ * @Author: ZhaoYing * @Date: 2026-02-06 21:10:56 * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-02-24 17:55:08 + * @Last Modified time: 2026-02-27 09:58:30 */ /** * Workflow Chat Component @@ -50,815 +50,7 @@ const Chat = forwardRef(({ appId // State management const [open, setOpen] = useState(false) // Drawer visibility const [loading, setLoading] = useState(false) // Send button loading state - const [chatList, setChatList] = useState([ - { - "role": "assistant", - "content": "经过多次打磨,最终作品如下:\n《咏一·三题》 \n孤光未凿太初溟, \n一粟吞天万籁宁。 \n影堕千峰青未染, \n心空四象白犹灵。 \n非从烛焰求明性, \n但向尘劳见本形。 \n忽有松风穿石罅, \n泠然吹落满山星。 \n\n注:本诗严守平水韵九青部(溟、宁、灵、形、星),其中“星”属下平声九青部异读字(《广韵》息盈切,与“灵”“宁”同部),古诗常用以协律,如王维“清溪流过碧山头,空水澄鲜一色秋。隔断红尘三十里,白云红叶两悠悠”中“悠”亦借韵通协。全诗紧扣“以一为魂”之旨:首句“孤光未凿”化《庄子·应帝王》“浑沌凿七窍而死”典,反写太初本明未分之境;次句“一粟吞天”,以微纳巨,承“一芥”而力愈雄浑;颔联“青未染”“白犹灵”,双色映照,暗喻性体离垢绝染而朗然常照;颈联直破二边——不假烛焰(破外求)、不避尘劳(破厌离),显《坛经》“佛法在世间,不离世间觉”之旨;结句松风裂石、星落满山,是“一”之活泼妙用:寂而常照,照而恒寂,恰如《道德经》“天得一以清,地得一以宁”之诗性证成。 \nLLM1结果:\n《咏一》 \n孤峰独峙破苍冥, \n一芥微身立太清。 \n万古乾坤凝此数, \n千山雪落只无声。 \n\n注:本诗以“一”为魂,通过“孤峰”“一芥”“此数”层层递进,赋予数字哲思——既写天地间唯一性之壮美(孤峰破冥),又寓渺小个体与永恒宇宙的辩证(芥子纳太清)。末句“千山雪落只无声”,以大静写大一,雪覆千山而声息俱寂,暗合《道德经》“天得一以清”之境。平仄依平水韵,押九青部(冥、清、声)。 ", - "created_at": 1771925594511, - "subContent": [ - { - "id": "start_1767617465337_0djnmpk2y", - "node_id": "start_1767617465337_0djnmpk2y", - "node_name": "开始(Start)", - "icon": "/src/assets/images/workflow/start.png", - "content": { - "input": { - "execution_id": "exec_11a80fb1cde148cb", - "conversation_id": "37ee003e-cc53-47e7-930f-a436a1252dd1", - "message": "1", - "conversation_vars": {} - }, - "output": { - "message": "1", - "execution_id": "exec_11a80fb1cde148cb", - "conversation_id": "37ee003e-cc53-47e7-930f-a436a1252dd1", - "workspace_id": "d17cd62d-a725-4fc0-813b-1093f2dfdee4", - "user_id": "ab27a27f-072b-47e9-8bbb-1f19322debcd", - "topic": "", - "number": 0, - "Boolean": false - } - }, - "status": "completed", - "elapsed_time": 0 - }, - { - "id": "llm_1767617499720_zvqwjpw3b", - "node_id": "llm_1767617499720_zvqwjpw3b", - "node_name": "大语言模型 (LLM)-初始创作", - "icon": "/src/assets/images/workflow/llm.png", - "content": { - "input": { - "prompt": null, - "messages": [ - { - "role": "system", - "content": "请根据1 为主题写一首七字诗。" - } - ], - "config": { - "model_id": "2699984d-23be-4817-b81c-c38682a08306", - "temperature": 0.7, - "max_tokens": 2000 - } - }, - "output": "《咏一》 \n孤峰独峙破苍冥, \n一芥微身立太清。 \n万古乾坤凝此数, \n千山雪落只无声。 \n\n注:本诗以“一”为魂,通过“孤峰”“一芥”“此数”层层递进,赋予数字哲思——既写天地间唯一性之壮美(孤峰破冥),又寓渺小个体与永恒宇宙的辩证(芥子纳太清)。末句“千山雪落只无声”,以大静写大一,雪覆千山而声息俱寂,暗合《道德经》“天得一以清”之境。平仄依平水韵,押九青部(冥、清、声)。" - }, - "status": "completed", - "elapsed_time": 4.518743515014648 - }, - { - "id": "loop_1767617552451_hq3j342ha", - "node_id": "loop_1767617552451_hq3j342ha", - "node_name": "循环 (Loop)", - "icon": "/src/assets/images/workflow/loop.png", - "content": { - "input": { - "config": { - "max_loop": 10, - "condition": { - "expressions": [ - { - "left": "{{loop_1767617552451_hq3j342ha.round}}", - "right": 3, - "operator": "eq", - "input_type": "Constant" - } - ], - "logical_operator": "and" - }, - "cycle_vars": [ - { - "name": "poem_content", - "type": "string", - "value": "{{llm_1767617499720_zvqwjpw3b.output}}", - "input_type": "variable" - }, - { - "name": "round", - "type": "number", - "value": "0", - "input_type": "constant" - } - ] - } - }, - "output": { - "poem_content": "《咏一·三题》 \n孤光未凿太初溟, \n一粟吞天万籁宁。 \n影堕千峰青未染, \n心空四象白犹灵。 \n非从烛焰求明性, \n但向尘劳见本形。 \n忽有松风穿石罅, \n泠然吹落满山星。 \n\n注:本诗严守平水韵九青部(溟、宁、灵、形、星),其中“星”属下平声九青部异读字(《广韵》息盈切,与“灵”“宁”同部),古诗常用以协律,如王维“清溪流过碧山头,空水澄鲜一色秋。隔断红尘三十里,白云红叶两悠悠”中“悠”亦借韵通协。全诗紧扣“以一为魂”之旨:首句“孤光未凿”化《庄子·应帝王》“浑沌凿七窍而死”典,反写太初本明未分之境;次句“一粟吞天”,以微纳巨,承“一芥”而力愈雄浑;颔联“青未染”“白犹灵”,双色映照,暗喻性体离垢绝染而朗然常照;颈联直破二边——不假烛焰(破外求)、不避尘劳(破厌离),显《坛经》“佛法在世间,不离世间觉”之旨;结句松风裂石、星落满山,是“一”之活泼妙用:寂而常照,照而恒寂,恰如《道德经》“天得一以清,地得一以宁”之诗性证成。", - "round": 3, - "__child_state": [ - { - "messages": [], - "cycle_nodes": [ - "loop_1767617552451_hq3j342ha" - ], - "looping": 1, - "node_outputs": { - "start_1767617465337_0djnmpk2y": { - "node_id": "start_1767617465337_0djnmpk2y", - "node_type": "start", - "node_name": "开始(Start)", - "status": "completed", - "input": { - "execution_id": "exec_11a80fb1cde148cb", - "conversation_id": "37ee003e-cc53-47e7-930f-a436a1252dd1", - "message": "1", - "conversation_vars": {} - }, - "output": { - "message": "1", - "execution_id": "exec_11a80fb1cde148cb", - "conversation_id": "37ee003e-cc53-47e7-930f-a436a1252dd1", - "workspace_id": "d17cd62d-a725-4fc0-813b-1093f2dfdee4", - "user_id": "ab27a27f-072b-47e9-8bbb-1f19322debcd", - "topic": "", - "number": 0, - "Boolean": false - }, - "elapsed_time": 0, - "token_usage": null, - "error": null - }, - "llm_1767617499720_zvqwjpw3b": { - "node_id": "llm_1767617499720_zvqwjpw3b", - "node_type": "llm", - "node_name": "大语言模型 (LLM)-初始创作", - "status": "completed", - "input": { - "prompt": null, - "messages": [ - { - "role": "system", - "content": "请根据1 为主题写一首七字诗。" - } - ], - "config": { - "model_id": "2699984d-23be-4817-b81c-c38682a08306", - "temperature": 0.7, - "max_tokens": 2000 - } - }, - "output": "《咏一》 \n孤峰独峙破苍冥, \n一芥微身立太清。 \n万古乾坤凝此数, \n千山雪落只无声。 \n\n注:本诗以“一”为魂,通过“孤峰”“一芥”“此数”层层递进,赋予数字哲思——既写天地间唯一性之壮美(孤峰破冥),又寓渺小个体与永恒宇宙的辩证(芥子纳太清)。末句“千山雪落只无声”,以大静写大一,雪覆千山而声息俱寂,暗合《道德经》“天得一以清”之境。平仄依平水韵,押九青部(冥、清、声)。", - "elapsed_time": 4.518743515014648, - "token_usage": { - "prompt_tokens": 25, - "completion_tokens": 165, - "total_tokens": 190 - }, - "error": null - }, - "loop_1767617552451_hq3j342ha": { - "poem_content": "《咏一》 \n孤峰独峙破苍冥, \n一芥微身立太清。 \n万古乾坤凝此数, \n千山雪落只无声。 \n\n注:本诗以“一”为魂,通过“孤峰”“一芥”“此数”层层递进,赋予数字哲思——既写天地间唯一性之壮美(孤峰破冥),又寓渺小个体与永恒宇宙的辩证(芥子纳太清)。末句“千山雪落只无声”,以大静写大一,雪覆千山而声息俱寂,暗合《道德经》“天得一以清”之境。平仄依平水韵,押九青部(冥、清、声)。", - "round": 0 - }, - "21046fb8-1f33-45f7-aeda-2c196471f119": { - "node_id": "21046fb8-1f33-45f7-aeda-2c196471f119", - "node_type": "cycle-start", - "node_name": null, - "status": "completed", - "input": { - "execution_id": "exec_11a80fb1cde148cb", - "conversation_id": "37ee003e-cc53-47e7-930f-a436a1252dd1", - "message": "1", - "conversation_vars": {} - }, - "output": { - "message": "1", - "execution_id": "exec_11a80fb1cde148cb", - "conversation_id": "37ee003e-cc53-47e7-930f-a436a1252dd1", - "workspace_id": "d17cd62d-a725-4fc0-813b-1093f2dfdee4", - "user_id": "ab27a27f-072b-47e9-8bbb-1f19322debcd" - }, - "elapsed_time": 0.0005278587341308594, - "token_usage": null, - "error": null - }, - "llm_1767617560401_bsx1vhi25": { - "node_id": "llm_1767617560401_bsx1vhi25", - "node_type": "llm", - "node_name": "大语言模型 (LLM)-润色器", - "status": "completed", - "input": { - "prompt": null, - "messages": [ - { - "role": "system", - "content": "请根据《咏一》 \n孤峰独峙破苍冥, \n一芥微身立太清。 \n万古乾坤凝此数, \n千山雪落只无声。 \n\n注:本诗以“一”为魂,通过“孤峰”“一芥”“此数”层层递进,赋予数字哲思——既写天地间唯一性之壮美(孤峰破冥),又寓渺小个体与永恒宇宙的辩证(芥子纳太清)。末句“千山雪落只无声”,以大静写大一,雪覆千山而声息俱寂,暗合《道德经》“天得一以清”之境。平仄依平水韵,押九青部(冥、清、声)。 为主题写一首七字诗。" - } - ], - "config": { - "model_id": "2699984d-23be-4817-b81c-c38682a08306", - "temperature": 0.7, - "max_tokens": 2000 - } - }, - "output": "《咏一·次韵》 \n千峰削玉立空青, \n一羽浮天亦自宁。 \n万籁收声归太始, \n孤光未堕即长明。 \n\n注:本诗承原作“以一为魂”之旨,严守平水韵九青部(青、宁、明),平仄谐律。首句“千峰削玉”反衬“一羽浮天”,以极繁托极简;次句“一羽”既承“一芥”之微,更取《庄子》“鹏徙南冥”之逸气,言至微者亦可持守本然之宁。三句“万籁收声”暗应原作“千山雪落只无声”,而升华为宇宙初开的“太始”静界;结句“孤光未堕即长明”,化用《淮南子》“日月不为明而明”与禅宗“一念不生即佛”,昭示“一”非寂灭之空,乃不假外求、本自圆成的永恒觉性——此即《道德经》“天得一以清”的诗性证悟。", - "elapsed_time": 6.8497374057769775, - "token_usage": { - "prompt_tokens": 188, - "completion_tokens": 262, - "total_tokens": 450 - }, - "error": null - }, - "assigner_1768285417545_qsoqleflh": { - "node_id": "assigner_1768285417545_qsoqleflh", - "node_type": "assigner", - "node_name": "变量赋值", - "status": "completed", - "input": { - "config": { - "assignments": [ - { - "value": "{{llm_1767617560401_bsx1vhi25.output}}", - "operation": "cover", - "variable_selector": "{{loop_1767617552451_hq3j342ha.poem_content}}" - }, - { - "value": 1, - "operation": "add", - "variable_selector": "{{loop_1767617552451_hq3j342ha.round}}" - } - ] - } - }, - "output": null, - "elapsed_time": 0.0003705024719238281, - "token_usage": null, - "error": null - } - }, - "execution_id": "exec_11a80fb1cde148cb", - "workspace_id": "d17cd62d-a725-4fc0-813b-1093f2dfdee4", - "user_id": "ab27a27f-072b-47e9-8bbb-1f19322debcd", - "error": null, - "error_node": null, - "activate": { - "llm_1767617560401_bsx1vhi25": true, - "loop_1767617552451_hq3j342ha": true, - "start_1767617465337_0djnmpk2y": true, - "21046fb8-1f33-45f7-aeda-2c196471f119": true, - "llm_1767617499720_zvqwjpw3b": true, - "assigner_1768285417545_qsoqleflh": true - } - }, - { - "messages": [], - "cycle_nodes": [ - "loop_1767617552451_hq3j342ha" - ], - "looping": 1, - "node_outputs": { - "start_1767617465337_0djnmpk2y": { - "node_id": "start_1767617465337_0djnmpk2y", - "node_type": "start", - "node_name": "开始(Start)", - "status": "completed", - "input": { - "execution_id": "exec_11a80fb1cde148cb", - "conversation_id": "37ee003e-cc53-47e7-930f-a436a1252dd1", - "message": "1", - "conversation_vars": {} - }, - "output": { - "message": "1", - "execution_id": "exec_11a80fb1cde148cb", - "conversation_id": "37ee003e-cc53-47e7-930f-a436a1252dd1", - "workspace_id": "d17cd62d-a725-4fc0-813b-1093f2dfdee4", - "user_id": "ab27a27f-072b-47e9-8bbb-1f19322debcd", - "topic": "", - "number": 0, - "Boolean": false - }, - "elapsed_time": 0, - "token_usage": null, - "error": null - }, - "llm_1767617499720_zvqwjpw3b": { - "node_id": "llm_1767617499720_zvqwjpw3b", - "node_type": "llm", - "node_name": "大语言模型 (LLM)-初始创作", - "status": "completed", - "input": { - "prompt": null, - "messages": [ - { - "role": "system", - "content": "请根据1 为主题写一首七字诗。" - } - ], - "config": { - "model_id": "2699984d-23be-4817-b81c-c38682a08306", - "temperature": 0.7, - "max_tokens": 2000 - } - }, - "output": "《咏一》 \n孤峰独峙破苍冥, \n一芥微身立太清。 \n万古乾坤凝此数, \n千山雪落只无声。 \n\n注:本诗以“一”为魂,通过“孤峰”“一芥”“此数”层层递进,赋予数字哲思——既写天地间唯一性之壮美(孤峰破冥),又寓渺小个体与永恒宇宙的辩证(芥子纳太清)。末句“千山雪落只无声”,以大静写大一,雪覆千山而声息俱寂,暗合《道德经》“天得一以清”之境。平仄依平水韵,押九青部(冥、清、声)。", - "elapsed_time": 4.518743515014648, - "token_usage": { - "prompt_tokens": 25, - "completion_tokens": 165, - "total_tokens": 190 - }, - "error": null - }, - "loop_1767617552451_hq3j342ha": { - "poem_content": "《咏一·次韵》 \n千峰削玉立空青, \n一羽浮天亦自宁。 \n万籁收声归太始, \n孤光未堕即长明。 \n\n注:本诗承原作“以一为魂”之旨,严守平水韵九青部(青、宁、明),平仄谐律。首句“千峰削玉”反衬“一羽浮天”,以极繁托极简;次句“一羽”既承“一芥”之微,更取《庄子》“鹏徙南冥”之逸气,言至微者亦可持守本然之宁。三句“万籁收声”暗应原作“千山雪落只无声”,而升华为宇宙初开的“太始”静界;结句“孤光未堕即长明”,化用《淮南子》“日月不为明而明”与禅宗“一念不生即佛”,昭示“一”非寂灭之空,乃不假外求、本自圆成的永恒觉性——此即《道德经》“天得一以清”的诗性证悟。", - "round": 1 - }, - "21046fb8-1f33-45f7-aeda-2c196471f119": { - "node_id": "21046fb8-1f33-45f7-aeda-2c196471f119", - "node_type": "cycle-start", - "node_name": null, - "status": "completed", - "input": { - "execution_id": "exec_11a80fb1cde148cb", - "conversation_id": "37ee003e-cc53-47e7-930f-a436a1252dd1", - "message": "1", - "conversation_vars": {} - }, - "output": { - "message": "1", - "execution_id": "exec_11a80fb1cde148cb", - "conversation_id": "37ee003e-cc53-47e7-930f-a436a1252dd1", - "workspace_id": "d17cd62d-a725-4fc0-813b-1093f2dfdee4", - "user_id": "ab27a27f-072b-47e9-8bbb-1f19322debcd" - }, - "elapsed_time": 0, - "token_usage": null, - "error": null - }, - "llm_1767617560401_bsx1vhi25": { - "node_id": "llm_1767617560401_bsx1vhi25", - "node_type": "llm", - "node_name": "大语言模型 (LLM)-润色器", - "status": "completed", - "input": { - "prompt": null, - "messages": [ - { - "role": "system", - "content": "请根据《咏一·次韵》 \n千峰削玉立空青, \n一羽浮天亦自宁。 \n万籁收声归太始, \n孤光未堕即长明。 \n\n注:本诗承原作“以一为魂”之旨,严守平水韵九青部(青、宁、明),平仄谐律。首句“千峰削玉”反衬“一羽浮天”,以极繁托极简;次句“一羽”既承“一芥”之微,更取《庄子》“鹏徙南冥”之逸气,言至微者亦可持守本然之宁。三句“万籁收声”暗应原作“千山雪落只无声”,而升华为宇宙初开的“太始”静界;结句“孤光未堕即长明”,化用《淮南子》“日月不为明而明”与禅宗“一念不生即佛”,昭示“一”非寂灭之空,乃不假外求、本自圆成的永恒觉性——此即《道德经》“天得一以清”的诗性证悟。 为主题写一首七字诗。" - } - ], - "config": { - "model_id": "2699984d-23be-4817-b81c-c38682a08306", - "temperature": 0.7, - "max_tokens": 2000 - } - }, - "output": "《咏一·再题》 \n一芥浮空万籁停, \n千峰影落太初青。 \n光非燃烛恒明在, \n心不沾尘即性灵。 \n\n注:本诗续写“以一为魂”之旨,严守平水韵九青部(停、青、灵),平仄精严。首句“一芥”承原作微渺意象,而“万籁停”较“收声”更显寂然自定之境;次句倒装“千峰影落”,使苍茫山势如墨痕沉入宇宙初青,暗契《淮南子》“虚霩生宇宙,宇宙生气”之太始气象。三句翻出新境:“光非燃烛”,破除对光明之形器执取,直指《楞严经》“性觉妙明,本觉明妙”之不假缘起的本明;结句“心不沾尘即性灵”,化用六祖“本来无一物”与程颢“天地之大德曰生”,言“一”非枯寂之数,乃活泼泼的性灵朗现——此即《道德经》“昔之得一者,天清地宁”的诗性澄明。", - "elapsed_time": 7.1851232051849365, - "token_usage": { - "prompt_tokens": 285, - "completion_tokens": 281, - "total_tokens": 566 - }, - "error": null - }, - "assigner_1768285417545_qsoqleflh": { - "node_id": "assigner_1768285417545_qsoqleflh", - "node_type": "assigner", - "node_name": "变量赋值", - "status": "completed", - "input": { - "config": { - "assignments": [ - { - "value": "{{llm_1767617560401_bsx1vhi25.output}}", - "operation": "cover", - "variable_selector": "{{loop_1767617552451_hq3j342ha.poem_content}}" - }, - { - "value": 1, - "operation": "add", - "variable_selector": "{{loop_1767617552451_hq3j342ha.round}}" - } - ] - } - }, - "output": null, - "elapsed_time": 0, - "token_usage": null, - "error": null - } - }, - "execution_id": "exec_11a80fb1cde148cb", - "workspace_id": "d17cd62d-a725-4fc0-813b-1093f2dfdee4", - "user_id": "ab27a27f-072b-47e9-8bbb-1f19322debcd", - "error": null, - "error_node": null, - "activate": { - "llm_1767617560401_bsx1vhi25": true, - "start_1767617465337_0djnmpk2y": true, - "loop_1767617552451_hq3j342ha": true, - "21046fb8-1f33-45f7-aeda-2c196471f119": true, - "llm_1767617499720_zvqwjpw3b": true, - "assigner_1768285417545_qsoqleflh": true - } - }, - { - "messages": [], - "cycle_nodes": [ - "loop_1767617552451_hq3j342ha" - ], - "looping": 1, - "node_outputs": { - "start_1767617465337_0djnmpk2y": { - "node_id": "start_1767617465337_0djnmpk2y", - "node_type": "start", - "node_name": "开始(Start)", - "status": "completed", - "input": { - "execution_id": "exec_11a80fb1cde148cb", - "conversation_id": "37ee003e-cc53-47e7-930f-a436a1252dd1", - "message": "1", - "conversation_vars": {} - }, - "output": { - "message": "1", - "execution_id": "exec_11a80fb1cde148cb", - "conversation_id": "37ee003e-cc53-47e7-930f-a436a1252dd1", - "workspace_id": "d17cd62d-a725-4fc0-813b-1093f2dfdee4", - "user_id": "ab27a27f-072b-47e9-8bbb-1f19322debcd", - "topic": "", - "number": 0, - "Boolean": false - }, - "elapsed_time": 0, - "token_usage": null, - "error": null - }, - "llm_1767617499720_zvqwjpw3b": { - "node_id": "llm_1767617499720_zvqwjpw3b", - "node_type": "llm", - "node_name": "大语言模型 (LLM)-初始创作", - "status": "completed", - "input": { - "prompt": null, - "messages": [ - { - "role": "system", - "content": "请根据1 为主题写一首七字诗。" - } - ], - "config": { - "model_id": "2699984d-23be-4817-b81c-c38682a08306", - "temperature": 0.7, - "max_tokens": 2000 - } - }, - "output": "《咏一》 \n孤峰独峙破苍冥, \n一芥微身立太清。 \n万古乾坤凝此数, \n千山雪落只无声。 \n\n注:本诗以“一”为魂,通过“孤峰”“一芥”“此数”层层递进,赋予数字哲思——既写天地间唯一性之壮美(孤峰破冥),又寓渺小个体与永恒宇宙的辩证(芥子纳太清)。末句“千山雪落只无声”,以大静写大一,雪覆千山而声息俱寂,暗合《道德经》“天得一以清”之境。平仄依平水韵,押九青部(冥、清、声)。", - "elapsed_time": 4.518743515014648, - "token_usage": { - "prompt_tokens": 25, - "completion_tokens": 165, - "total_tokens": 190 - }, - "error": null - }, - "loop_1767617552451_hq3j342ha": { - "poem_content": "《咏一·再题》 \n一芥浮空万籁停, \n千峰影落太初青。 \n光非燃烛恒明在, \n心不沾尘即性灵。 \n\n注:本诗续写“以一为魂”之旨,严守平水韵九青部(停、青、灵),平仄精严。首句“一芥”承原作微渺意象,而“万籁停”较“收声”更显寂然自定之境;次句倒装“千峰影落”,使苍茫山势如墨痕沉入宇宙初青,暗契《淮南子》“虚霩生宇宙,宇宙生气”之太始气象。三句翻出新境:“光非燃烛”,破除对光明之形器执取,直指《楞严经》“性觉妙明,本觉明妙”之不假缘起的本明;结句“心不沾尘即性灵”,化用六祖“本来无一物”与程颢“天地之大德曰生”,言“一”非枯寂之数,乃活泼泼的性灵朗现——此即《道德经》“昔之得一者,天清地宁”的诗性澄明。", - "round": 2 - }, - "21046fb8-1f33-45f7-aeda-2c196471f119": { - "node_id": "21046fb8-1f33-45f7-aeda-2c196471f119", - "node_type": "cycle-start", - "node_name": null, - "status": "completed", - "input": { - "execution_id": "exec_11a80fb1cde148cb", - "conversation_id": "37ee003e-cc53-47e7-930f-a436a1252dd1", - "message": "1", - "conversation_vars": {} - }, - "output": { - "message": "1", - "execution_id": "exec_11a80fb1cde148cb", - "conversation_id": "37ee003e-cc53-47e7-930f-a436a1252dd1", - "workspace_id": "d17cd62d-a725-4fc0-813b-1093f2dfdee4", - "user_id": "ab27a27f-072b-47e9-8bbb-1f19322debcd" - }, - "elapsed_time": 0, - "token_usage": null, - "error": null - }, - "llm_1767617560401_bsx1vhi25": { - "node_id": "llm_1767617560401_bsx1vhi25", - "node_type": "llm", - "node_name": "大语言模型 (LLM)-润色器", - "status": "completed", - "input": { - "prompt": null, - "messages": [ - { - "role": "system", - "content": "请根据《咏一·再题》 \n一芥浮空万籁停, \n千峰影落太初青。 \n光非燃烛恒明在, \n心不沾尘即性灵。 \n\n注:本诗续写“以一为魂”之旨,严守平水韵九青部(停、青、灵),平仄精严。首句“一芥”承原作微渺意象,而“万籁停”较“收声”更显寂然自定之境;次句倒装“千峰影落”,使苍茫山势如墨痕沉入宇宙初青,暗契《淮南子》“虚霩生宇宙,宇宙生气”之太始气象。三句翻出新境:“光非燃烛”,破除对光明之形器执取,直指《楞严经》“性觉妙明,本觉明妙”之不假缘起的本明;结句“心不沾尘即性灵”,化用六祖“本来无一物”与程颢“天地之大德曰生”,言“一”非枯寂之数,乃活泼泼的性灵朗现——此即《道德经》“昔之得一者,天清地宁”的诗性澄明。 为主题写一首七字诗。" - } - ], - "config": { - "model_id": "2699984d-23be-4817-b81c-c38682a08306", - "temperature": 0.7, - "max_tokens": 2000 - } - }, - "output": "《咏一·三题》 \n孤光未凿太初溟, \n一粟吞天万籁宁。 \n影堕千峰青未染, \n心空四象白犹灵。 \n非从烛焰求明性, \n但向尘劳见本形。 \n忽有松风穿石罅, \n泠然吹落满山星。 \n\n注:本诗严守平水韵九青部(溟、宁、灵、形、星),其中“星”属下平声九青部异读字(《广韵》息盈切,与“灵”“宁”同部),古诗常用以协律,如王维“清溪流过碧山头,空水澄鲜一色秋。隔断红尘三十里,白云红叶两悠悠”中“悠”亦借韵通协。全诗紧扣“以一为魂”之旨:首句“孤光未凿”化《庄子·应帝王》“浑沌凿七窍而死”典,反写太初本明未分之境;次句“一粟吞天”,以微纳巨,承“一芥”而力愈雄浑;颔联“青未染”“白犹灵”,双色映照,暗喻性体离垢绝染而朗然常照;颈联直破二边——不假烛焰(破外求)、不避尘劳(破厌离),显《坛经》“佛法在世间,不离世间觉”之旨;结句松风裂石、星落满山,是“一”之活泼妙用:寂而常照,照而恒寂,恰如《道德经》“天得一以清,地得一以宁”之诗性证成。", - "elapsed_time": 9.531717538833618, - "token_usage": { - "prompt_tokens": 304, - "completion_tokens": 390, - "total_tokens": 694 - }, - "error": null - }, - "assigner_1768285417545_qsoqleflh": { - "node_id": "assigner_1768285417545_qsoqleflh", - "node_type": "assigner", - "node_name": "变量赋值", - "status": "completed", - "input": { - "config": { - "assignments": [ - { - "value": "{{llm_1767617560401_bsx1vhi25.output}}", - "operation": "cover", - "variable_selector": "{{loop_1767617552451_hq3j342ha.poem_content}}" - }, - { - "value": 1, - "operation": "add", - "variable_selector": "{{loop_1767617552451_hq3j342ha.round}}" - } - ] - } - }, - "output": null, - "elapsed_time": 0, - "token_usage": null, - "error": null - } - }, - "execution_id": "exec_11a80fb1cde148cb", - "workspace_id": "d17cd62d-a725-4fc0-813b-1093f2dfdee4", - "user_id": "ab27a27f-072b-47e9-8bbb-1f19322debcd", - "error": null, - "error_node": null, - "activate": { - "llm_1767617560401_bsx1vhi25": true, - "start_1767617465337_0djnmpk2y": true, - "loop_1767617552451_hq3j342ha": true, - "21046fb8-1f33-45f7-aeda-2c196471f119": true, - "llm_1767617499720_zvqwjpw3b": true, - "assigner_1768285417545_qsoqleflh": true - } - } - ] - } - }, - "subContent": [ - { - "cycle_idx": 0, - "node_id": "21046fb8-1f33-45f7-aeda-2c196471f119", - "node_name": null, - "icon": "/src/assets/images/workflow/loop.png", - "content": { - "cycle_idx": 0, - "input": { - "poem_content": "《咏一》 \n孤峰独峙破苍冥, \n一芥微身立太清。 \n万古乾坤凝此数, \n千山雪落只无声。 \n\n注:本诗以“一”为魂,通过“孤峰”“一芥”“此数”层层递进,赋予数字哲思——既写天地间唯一性之壮美(孤峰破冥),又寓渺小个体与永恒宇宙的辩证(芥子纳太清)。末句“千山雪落只无声”,以大静写大一,雪覆千山而声息俱寂,暗合《道德经》“天得一以清”之境。平仄依平水韵,押九青部(冥、清、声)。", - "round": 0 - }, - "output": { - "poem_content": "《咏一》 \n孤峰独峙破苍冥, \n一芥微身立太清。 \n万古乾坤凝此数, \n千山雪落只无声。 \n\n注:本诗以“一”为魂,通过“孤峰”“一芥”“此数”层层递进,赋予数字哲思——既写天地间唯一性之壮美(孤峰破冥),又寓渺小个体与永恒宇宙的辩证(芥子纳太清)。末句“千山雪落只无声”,以大静写大一,雪覆千山而声息俱寂,暗合《道德经》“天得一以清”之境。平仄依平水韵,押九青部(冥、清、声)。", - "round": 0 - } - }, - "status": "completed", - "elapsed_time": 0.0005278587341308594 - }, - { - "cycle_idx": 0, - "node_id": "llm_1767617560401_bsx1vhi25", - "node_name": "大语言模型 (LLM)-润色器", - "icon": "/src/assets/images/workflow/llm.png", - "content": { - "cycle_idx": 0, - "input": { - "prompt": null, - "messages": [ - { - "role": "system", - "content": "请根据《咏一》 \n孤峰独峙破苍冥, \n一芥微身立太清。 \n万古乾坤凝此数, \n千山雪落只无声。 \n\n注:本诗以“一”为魂,通过“孤峰”“一芥”“此数”层层递进,赋予数字哲思——既写天地间唯一性之壮美(孤峰破冥),又寓渺小个体与永恒宇宙的辩证(芥子纳太清)。末句“千山雪落只无声”,以大静写大一,雪覆千山而声息俱寂,暗合《道德经》“天得一以清”之境。平仄依平水韵,押九青部(冥、清、声)。 为主题写一首七字诗。" - } - ], - "config": { - "model_id": "2699984d-23be-4817-b81c-c38682a08306", - "temperature": 0.7, - "max_tokens": 2000 - } - }, - "output": "《咏一·次韵》 \n千峰削玉立空青, \n一羽浮天亦自宁。 \n万籁收声归太始, \n孤光未堕即长明。 \n\n注:本诗承原作“以一为魂”之旨,严守平水韵九青部(青、宁、明),平仄谐律。首句“千峰削玉”反衬“一羽浮天”,以极繁托极简;次句“一羽”既承“一芥”之微,更取《庄子》“鹏徙南冥”之逸气,言至微者亦可持守本然之宁。三句“万籁收声”暗应原作“千山雪落只无声”,而升华为宇宙初开的“太始”静界;结句“孤光未堕即长明”,化用《淮南子》“日月不为明而明”与禅宗“一念不生即佛”,昭示“一”非寂灭之空,乃不假外求、本自圆成的永恒觉性——此即《道德经》“天得一以清”的诗性证悟。" - }, - "status": "completed", - "elapsed_time": 6.8497374057769775 - }, - { - "cycle_idx": 0, - "node_id": "assigner_1768285417545_qsoqleflh", - "node_name": "变量赋值", - "icon": "/src/assets/images/workflow/assigner.png", - "content": { - "cycle_idx": 0, - "input": { - "config": { - "assignments": [ - { - "value": "{{llm_1767617560401_bsx1vhi25.output}}", - "operation": "cover", - "variable_selector": "{{loop_1767617552451_hq3j342ha.poem_content}}" - }, - { - "value": 1, - "operation": "add", - "variable_selector": "{{loop_1767617552451_hq3j342ha.round}}" - } - ] - } - }, - "output": null - }, - "status": "completed", - "elapsed_time": 0.0003705024719238281 - }, - { - "cycle_idx": 1, - "node_id": "21046fb8-1f33-45f7-aeda-2c196471f119", - "node_name": null, - "icon": "/src/assets/images/workflow/loop.png", - "content": { - "cycle_idx": 1, - "input": { - "poem_content": "《咏一·次韵》 \n千峰削玉立空青, \n一羽浮天亦自宁。 \n万籁收声归太始, \n孤光未堕即长明。 \n\n注:本诗承原作“以一为魂”之旨,严守平水韵九青部(青、宁、明),平仄谐律。首句“千峰削玉”反衬“一羽浮天”,以极繁托极简;次句“一羽”既承“一芥”之微,更取《庄子》“鹏徙南冥”之逸气,言至微者亦可持守本然之宁。三句“万籁收声”暗应原作“千山雪落只无声”,而升华为宇宙初开的“太始”静界;结句“孤光未堕即长明”,化用《淮南子》“日月不为明而明”与禅宗“一念不生即佛”,昭示“一”非寂灭之空,乃不假外求、本自圆成的永恒觉性——此即《道德经》“天得一以清”的诗性证悟。", - "round": 1 - }, - "output": { - "poem_content": "《咏一·次韵》 \n千峰削玉立空青, \n一羽浮天亦自宁。 \n万籁收声归太始, \n孤光未堕即长明。 \n\n注:本诗承原作“以一为魂”之旨,严守平水韵九青部(青、宁、明),平仄谐律。首句“千峰削玉”反衬“一羽浮天”,以极繁托极简;次句“一羽”既承“一芥”之微,更取《庄子》“鹏徙南冥”之逸气,言至微者亦可持守本然之宁。三句“万籁收声”暗应原作“千山雪落只无声”,而升华为宇宙初开的“太始”静界;结句“孤光未堕即长明”,化用《淮南子》“日月不为明而明”与禅宗“一念不生即佛”,昭示“一”非寂灭之空,乃不假外求、本自圆成的永恒觉性——此即《道德经》“天得一以清”的诗性证悟。", - "round": 1 - } - }, - "status": "completed", - "elapsed_time": 0 - }, - { - "cycle_idx": 1, - "node_id": "llm_1767617560401_bsx1vhi25", - "node_name": "大语言模型 (LLM)-润色器", - "icon": "/src/assets/images/workflow/llm.png", - "content": { - "cycle_idx": 1, - "input": { - "prompt": null, - "messages": [ - { - "role": "system", - "content": "请根据《咏一·次韵》 \n千峰削玉立空青, \n一羽浮天亦自宁。 \n万籁收声归太始, \n孤光未堕即长明。 \n\n注:本诗承原作“以一为魂”之旨,严守平水韵九青部(青、宁、明),平仄谐律。首句“千峰削玉”反衬“一羽浮天”,以极繁托极简;次句“一羽”既承“一芥”之微,更取《庄子》“鹏徙南冥”之逸气,言至微者亦可持守本然之宁。三句“万籁收声”暗应原作“千山雪落只无声”,而升华为宇宙初开的“太始”静界;结句“孤光未堕即长明”,化用《淮南子》“日月不为明而明”与禅宗“一念不生即佛”,昭示“一”非寂灭之空,乃不假外求、本自圆成的永恒觉性——此即《道德经》“天得一以清”的诗性证悟。 为主题写一首七字诗。" - } - ], - "config": { - "model_id": "2699984d-23be-4817-b81c-c38682a08306", - "temperature": 0.7, - "max_tokens": 2000 - } - }, - "output": "《咏一·再题》 \n一芥浮空万籁停, \n千峰影落太初青。 \n光非燃烛恒明在, \n心不沾尘即性灵。 \n\n注:本诗续写“以一为魂”之旨,严守平水韵九青部(停、青、灵),平仄精严。首句“一芥”承原作微渺意象,而“万籁停”较“收声”更显寂然自定之境;次句倒装“千峰影落”,使苍茫山势如墨痕沉入宇宙初青,暗契《淮南子》“虚霩生宇宙,宇宙生气”之太始气象。三句翻出新境:“光非燃烛”,破除对光明之形器执取,直指《楞严经》“性觉妙明,本觉明妙”之不假缘起的本明;结句“心不沾尘即性灵”,化用六祖“本来无一物”与程颢“天地之大德曰生”,言“一”非枯寂之数,乃活泼泼的性灵朗现——此即《道德经》“昔之得一者,天清地宁”的诗性澄明。" - }, - "status": "completed", - "elapsed_time": 7.1851232051849365 - }, - { - "cycle_idx": 1, - "node_id": "assigner_1768285417545_qsoqleflh", - "node_name": "变量赋值", - "icon": "/src/assets/images/workflow/assigner.png", - "content": { - "cycle_idx": 1, - "input": { - "config": { - "assignments": [ - { - "value": "{{llm_1767617560401_bsx1vhi25.output}}", - "operation": "cover", - "variable_selector": "{{loop_1767617552451_hq3j342ha.poem_content}}" - }, - { - "value": 1, - "operation": "add", - "variable_selector": "{{loop_1767617552451_hq3j342ha.round}}" - } - ] - } - }, - "output": null - }, - "status": "completed", - "elapsed_time": 0 - }, - { - "cycle_idx": 2, - "node_id": "21046fb8-1f33-45f7-aeda-2c196471f119", - "node_name": null, - "icon": "/src/assets/images/workflow/loop.png", - "content": { - "cycle_idx": 2, - "input": { - "poem_content": "《咏一·再题》 \n一芥浮空万籁停, \n千峰影落太初青。 \n光非燃烛恒明在, \n心不沾尘即性灵。 \n\n注:本诗续写“以一为魂”之旨,严守平水韵九青部(停、青、灵),平仄精严。首句“一芥”承原作微渺意象,而“万籁停”较“收声”更显寂然自定之境;次句倒装“千峰影落”,使苍茫山势如墨痕沉入宇宙初青,暗契《淮南子》“虚霩生宇宙,宇宙生气”之太始气象。三句翻出新境:“光非燃烛”,破除对光明之形器执取,直指《楞严经》“性觉妙明,本觉明妙”之不假缘起的本明;结句“心不沾尘即性灵”,化用六祖“本来无一物”与程颢“天地之大德曰生”,言“一”非枯寂之数,乃活泼泼的性灵朗现——此即《道德经》“昔之得一者,天清地宁”的诗性澄明。", - "round": 2 - }, - "output": { - "poem_content": "《咏一·再题》 \n一芥浮空万籁停, \n千峰影落太初青。 \n光非燃烛恒明在, \n心不沾尘即性灵。 \n\n注:本诗续写“以一为魂”之旨,严守平水韵九青部(停、青、灵),平仄精严。首句“一芥”承原作微渺意象,而“万籁停”较“收声”更显寂然自定之境;次句倒装“千峰影落”,使苍茫山势如墨痕沉入宇宙初青,暗契《淮南子》“虚霩生宇宙,宇宙生气”之太始气象。三句翻出新境:“光非燃烛”,破除对光明之形器执取,直指《楞严经》“性觉妙明,本觉明妙”之不假缘起的本明;结句“心不沾尘即性灵”,化用六祖“本来无一物”与程颢“天地之大德曰生”,言“一”非枯寂之数,乃活泼泼的性灵朗现——此即《道德经》“昔之得一者,天清地宁”的诗性澄明。", - "round": 2 - } - }, - "status": "completed", - "elapsed_time": 0 - }, - { - "cycle_idx": 2, - "node_id": "llm_1767617560401_bsx1vhi25", - "node_name": "大语言模型 (LLM)-润色器", - "icon": "/src/assets/images/workflow/llm.png", - "content": { - "cycle_idx": 2, - "input": { - "prompt": null, - "messages": [ - { - "role": "system", - "content": "请根据《咏一·再题》 \n一芥浮空万籁停, \n千峰影落太初青。 \n光非燃烛恒明在, \n心不沾尘即性灵。 \n\n注:本诗续写“以一为魂”之旨,严守平水韵九青部(停、青、灵),平仄精严。首句“一芥”承原作微渺意象,而“万籁停”较“收声”更显寂然自定之境;次句倒装“千峰影落”,使苍茫山势如墨痕沉入宇宙初青,暗契《淮南子》“虚霩生宇宙,宇宙生气”之太始气象。三句翻出新境:“光非燃烛”,破除对光明之形器执取,直指《楞严经》“性觉妙明,本觉明妙”之不假缘起的本明;结句“心不沾尘即性灵”,化用六祖“本来无一物”与程颢“天地之大德曰生”,言“一”非枯寂之数,乃活泼泼的性灵朗现——此即《道德经》“昔之得一者,天清地宁”的诗性澄明。 为主题写一首七字诗。" - } - ], - "config": { - "model_id": "2699984d-23be-4817-b81c-c38682a08306", - "temperature": 0.7, - "max_tokens": 2000 - } - }, - "output": "《咏一·三题》 \n孤光未凿太初溟, \n一粟吞天万籁宁。 \n影堕千峰青未染, \n心空四象白犹灵。 \n非从烛焰求明性, \n但向尘劳见本形。 \n忽有松风穿石罅, \n泠然吹落满山星。 \n\n注:本诗严守平水韵九青部(溟、宁、灵、形、星),其中“星”属下平声九青部异读字(《广韵》息盈切,与“灵”“宁”同部),古诗常用以协律,如王维“清溪流过碧山头,空水澄鲜一色秋。隔断红尘三十里,白云红叶两悠悠”中“悠”亦借韵通协。全诗紧扣“以一为魂”之旨:首句“孤光未凿”化《庄子·应帝王》“浑沌凿七窍而死”典,反写太初本明未分之境;次句“一粟吞天”,以微纳巨,承“一芥”而力愈雄浑;颔联“青未染”“白犹灵”,双色映照,暗喻性体离垢绝染而朗然常照;颈联直破二边——不假烛焰(破外求)、不避尘劳(破厌离),显《坛经》“佛法在世间,不离世间觉”之旨;结句松风裂石、星落满山,是“一”之活泼妙用:寂而常照,照而恒寂,恰如《道德经》“天得一以清,地得一以宁”之诗性证成。" - }, - "status": "completed", - "elapsed_time": 9.531717538833618 - }, - { - "cycle_idx": 2, - "node_id": "assigner_1768285417545_qsoqleflh", - "node_name": "变量赋值", - "icon": "/src/assets/images/workflow/assigner.png", - "content": { - "cycle_idx": 2, - "input": { - "config": { - "assignments": [ - { - "value": "{{llm_1767617560401_bsx1vhi25.output}}", - "operation": "cover", - "variable_selector": "{{loop_1767617552451_hq3j342ha.poem_content}}" - }, - { - "value": 1, - "operation": "add", - "variable_selector": "{{loop_1767617552451_hq3j342ha.round}}" - } - ] - } - }, - "output": null - }, - "status": "completed", - "elapsed_time": 0 - } - ], - "status": "completed", - "elapsed_time": 23.57662582397461 - }, - { - "id": "end_1767619139811_ko97mb12l", - "node_id": "end_1767619139811_ko97mb12l", - "node_name": "结束(End)", - "icon": "/src/assets/images/workflow/end.png", - "content": { - "input": { - "config": { - "output": "经过多次打磨,最终作品如下:\n{{loop_1767617552451_hq3j342ha.poem_content}} \nLLM1结果:\n{{llm_1767617499720_zvqwjpw3b.output}} " - } - }, - "output": "经过多次打磨,最终作品如下:\n《咏一·三题》 \n孤光未凿太初溟, \n一粟吞天万籁宁。 \n影堕千峰青未染, \n心空四象白犹灵。 \n非从烛焰求明性, \n但向尘劳见本形。 \n忽有松风穿石罅, \n泠然吹落满山星。 \n\n注:本诗严守平水韵九青部(溟、宁、灵、形、星),其中“星”属下平声九青部异读字(《广韵》息盈切,与“灵”“宁”同部),古诗常用以协律,如王维“清溪流过碧山头,空水澄鲜一色秋。隔断红尘三十里,白云红叶两悠悠”中“悠”亦借韵通协。全诗紧扣“以一为魂”之旨:首句“孤光未凿”化《庄子·应帝王》“浑沌凿七窍而死”典,反写太初本明未分之境;次句“一粟吞天”,以微纳巨,承“一芥”而力愈雄浑;颔联“青未染”“白犹灵”,双色映照,暗喻性体离垢绝染而朗然常照;颈联直破二边——不假烛焰(破外求)、不避尘劳(破厌离),显《坛经》“佛法在世间,不离世间觉”之旨;结句松风裂石、星落满山,是“一”之活泼妙用:寂而常照,照而恒寂,恰如《道德经》“天得一以清,地得一以宁”之诗性证成。 \nLLM1结果:\n《咏一》 \n孤峰独峙破苍冥, \n一芥微身立太清。 \n万古乾坤凝此数, \n千山雪落只无声。 \n\n注:本诗以“一”为魂,通过“孤峰”“一芥”“此数”层层递进,赋予数字哲思——既写天地间唯一性之壮美(孤峰破冥),又寓渺小个体与永恒宇宙的辩证(芥子纳太清)。末句“千山雪落只无声”,以大静写大一,雪覆千山而声息俱寂,暗合《道德经》“天得一以清”之境。平仄依平水韵,押九青部(冥、清、声)。 " - }, - "status": "completed", - "elapsed_time": 0.0005218982696533203 - } - ], - "status": "completed" - } - ]) // Chat message history + const [chatList, setChatList] = useState([]) // Chat message history const [variables, setVariables] = useState([]) // Workflow input variables const [streamLoading, setStreamLoading] = useState(false) // SSE streaming state const [conversationId, setConversationId] = useState(null) // Current conversation ID From e9ff74216208f6bb4ca3c4622e4991ec7bd8d459 Mon Sep 17 00:00:00 2001 From: Mark Date: Fri, 27 Feb 2026 10:22:36 +0800 Subject: [PATCH 28/36] [add] migration script --- .../versions/7672d8f0f939_202602271020.py | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 api/migrations/versions/7672d8f0f939_202602271020.py diff --git a/api/migrations/versions/7672d8f0f939_202602271020.py b/api/migrations/versions/7672d8f0f939_202602271020.py new file mode 100644 index 00000000..b99953a2 --- /dev/null +++ b/api/migrations/versions/7672d8f0f939_202602271020.py @@ -0,0 +1,36 @@ +"""202602271020 + +Revision ID: 7672d8f0f939 +Revises: 75e28690ae87 +Create Date: 2026-02-27 10:21:46.951584 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '7672d8f0f939' +down_revision: Union[str, None] = '75e28690ae87' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('file_metadata', 'workspace_id', + existing_type=sa.UUID(), + nullable=True, + existing_comment='Workspace ID') + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('file_metadata', 'workspace_id', + existing_type=sa.UUID(), + nullable=False, + existing_comment='Workspace ID') + # ### end Alembic commands ### From fa4da8f4679c76a75c751278b36d610c00578cbe Mon Sep 17 00:00:00 2001 From: zhaoying Date: Fri, 27 Feb 2026 10:23:19 +0800 Subject: [PATCH 29/36] fix(web): change model list provider logo --- web/src/views/ModelManagement/List.tsx | 8 ++++---- web/src/views/ModelManagement/utils.ts | 25 +++++++++++++++++++++++-- 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/web/src/views/ModelManagement/List.tsx b/web/src/views/ModelManagement/List.tsx index ffa89fb4..ce4d61aa 100644 --- a/web/src/views/ModelManagement/List.tsx +++ b/web/src/views/ModelManagement/List.tsx @@ -1,8 +1,8 @@ /* * @Author: ZhaoYing * @Date: 2026-02-03 16:50:10 - * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-02-03 16:50:10 + * @Last Modified by: ZhaoYing + * @Last Modified time: 2026-02-27 10:20:51 */ /** * Model List View @@ -21,7 +21,7 @@ import PageEmpty from '@/components/Empty/PageEmpty'; import Tag from '@/components/Tag'; import KeyConfigModal from './components/KeyConfigModal' import ModelListDetail from './components/ModelListDetail' -import { getLogoUrl } from './utils' +import { getListLogoUrl } from './utils' /** * Model list component @@ -70,7 +70,7 @@ const ModelList = forwardRef {item.provider[0].toUpperCase()} diff --git a/web/src/views/ModelManagement/utils.ts b/web/src/views/ModelManagement/utils.ts index fe36e137..bf44367f 100644 --- a/web/src/views/ModelManagement/utils.ts +++ b/web/src/views/ModelManagement/utils.ts @@ -1,8 +1,8 @@ /* * @Author: ZhaoYing * @Date: 2026-02-03 16:50:22 - * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-02-03 16:50:22 + * @Last Modified by: ZhaoYing + * @Last Modified time: 2026-02-27 10:22:46 */ /** * Utility functions for Model Management @@ -40,5 +40,26 @@ export const getLogoUrl = (logo?: string) => { return logo } + return ICONS[logo as keyof typeof ICONS] || undefined +} + +/** + * Get logo URL from provider name or URL + * @param provider - Provider name + * @param logo - Provider name or logo URL + * @returns Logo URL or undefined + */ +export const getListLogoUrl = (provider?: string, logo?: string) => { + let url = ICONS[provider as keyof typeof ICONS] + + if (url) return url + + if (!logo) { + return undefined + } + if (logo.startsWith('http')) { + return logo + } + return ICONS[logo as keyof typeof ICONS] || undefined } \ No newline at end of file From d4971893520db2b1a3711f4e20616729194265db Mon Sep 17 00:00:00 2001 From: Timebomb2018 <18868801967@163.com> Date: Fri, 27 Feb 2026 10:24:03 +0800 Subject: [PATCH 30/36] docs(version): Version 0.2.5 Release Notes --- api/app/version_info.json | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/api/app/version_info.json b/api/app/version_info.json index aea03dcd..a4ff5d55 100644 --- a/api/app/version_info.json +++ b/api/app/version_info.json @@ -1,4 +1,34 @@ { + "v0.2.5": { + "introduction": { + "codeName": "行云", + "releaseDate": "2026-2-26", + "upgradePosition": "🐻 精炼根基,优化核心用户体验与系统稳定性", + "coreUpgrades": [ + "1. 用户体验与国际化 🎨
* SSO 语言参数修复:解决 SSO 认证后添加 `?language=zh` 参数仍显示英文的问题,语言偏好现正确保留
* 邮箱修改支持:用户可直接在用户管理系统中修改邮箱地址", + "2. 工作流可视化增强 💬
* 循环与迭代节点输出展示:实时显示执行进度和中间输出,便于调试复杂迭代过程
* 变量支持回车选择:支持回车键确认变量选择,简化工作流配置流程", + "3. 多租户模型管理 ⚙️
* 租户隔离的模型密钥:模型广场排除自定义模型,模型列表按租户隔离密钥,防止跨租户密钥泄露", + "4. 稳健性与缺陷修复 🔧
* 知识图谱构建修复:解决知识图谱构建流程稳定性问题,确保更可靠的实体提取和关系映射", + "
", + "版本 0.2.5 通过解决国际化边界情况、增强多租户隔离和改进工作流透明度,构建更具生产就绪性的平台。工作流可视化改进为更复杂的调试和监控能力奠定基础。未来将继续深化企业就绪性,扩展用户管理功能、优化知识图谱智能和增强工作流编排能力,在可观测性、性能优化和无缝集成模式方面持续改进。", + "智慧致远 🐻✨" + ] + }, + "introduction_en": { + "codeName": "Flowing Clouds", + "releaseDate": "2026-2-26", + "upgradePosition": "🐻 Refined foundations with enhanced user experience and system stability", + "coreUpgrades": [ + "1. User Experience & Internationalization 🎨
* SSO Language Parameter Fix: Resolved issue where `?language=zh` parameter still showed English UI after SSO authentication
* Email Update Support: Users can now modify email addresses directly in user management system", + "2. Workflow Visualization Enhancements 💬
* Loop & Iteration Node Output Display: Real-time display of execution progress and intermediate outputs for easier debugging
* Variable Selection with Enter Key: Enabled Enter key confirmation for streamlined variable assignment", + "3. Multi-Tenant Model Management ⚙️
* Tenant-Scoped Model Keys: Model marketplace excludes custom models, model list properly isolates keys per tenant to prevent cross-tenant exposure", + "4. Robustness & Bug Fixes 🔧
* Knowledge Graph Construction Fix: Addressed stability issues in knowledge graph pipeline for more reliable entity extraction and relationship mapping", + "
", + "Version 0.2.5 matures MemoryBear's operational foundations by addressing internationalization edge cases, enhancing multi-tenant isolation, and improving workflow transparency. The workflow visualization improvements lay groundwork for sophisticated debugging and monitoring capabilities. Looking forward, we will deepen enterprise readiness by expanding user management features, refining knowledge graph intelligence, and enhancing workflow orchestration with continued improvements in observability, performance optimization, and seamless integration patterns.", + "Intelligent Resilience 🐻✨" + ] + } + }, "v0.2.4": { "introduction": { "codeName": "智远", From 6a6e64f487e1c9fde7e1668ab4016e8fb7997845 Mon Sep 17 00:00:00 2001 From: Timebomb2018 <18868801967@163.com> Date: Fri, 27 Feb 2026 11:06:17 +0800 Subject: [PATCH 31/36] docs(version): Version 0.2.5 Release Notes --- api/app/version_info.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/api/app/version_info.json b/api/app/version_info.json index a4ff5d55..772ff56e 100644 --- a/api/app/version_info.json +++ b/api/app/version_info.json @@ -5,9 +5,9 @@ "releaseDate": "2026-2-26", "upgradePosition": "🐻 精炼根基,优化核心用户体验与系统稳定性", "coreUpgrades": [ - "1. 用户体验与国际化 🎨
* SSO 语言参数修复:解决 SSO 认证后添加 `?language=zh` 参数仍显示英文的问题,语言偏好现正确保留
* 邮箱修改支持:用户可直接在用户管理系统中修改邮箱地址", + "1. 用户体验与国际化 🎨
* 语言参数修复:语言偏好现正确保留
* 邮箱修改支持:用户可直接在用户管理系统中修改邮箱地址", "2. 工作流可视化增强 💬
* 循环与迭代节点输出展示:实时显示执行进度和中间输出,便于调试复杂迭代过程
* 变量支持回车选择:支持回车键确认变量选择,简化工作流配置流程", - "3. 多租户模型管理 ⚙️
* 租户隔离的模型密钥:模型广场排除自定义模型,模型列表按租户隔离密钥,防止跨租户密钥泄露", + "3. 优化模型管理 ⚙️
* 模型广场移除自定义模型,优化模型使用体验", "4. 稳健性与缺陷修复 🔧
* 知识图谱构建修复:解决知识图谱构建流程稳定性问题,确保更可靠的实体提取和关系映射", "
", "版本 0.2.5 通过解决国际化边界情况、增强多租户隔离和改进工作流透明度,构建更具生产就绪性的平台。工作流可视化改进为更复杂的调试和监控能力奠定基础。未来将继续深化企业就绪性,扩展用户管理功能、优化知识图谱智能和增强工作流编排能力,在可观测性、性能优化和无缝集成模式方面持续改进。", @@ -19,9 +19,9 @@ "releaseDate": "2026-2-26", "upgradePosition": "🐻 Refined foundations with enhanced user experience and system stability", "coreUpgrades": [ - "1. User Experience & Internationalization 🎨
* SSO Language Parameter Fix: Resolved issue where `?language=zh` parameter still showed English UI after SSO authentication
* Email Update Support: Users can now modify email addresses directly in user management system", + "1. User Experience & Internationalization 🎨
* Language parameter fix: language preferences are now correctly retained
* Email Update Support: Users can now modify email addresses directly in user management system", "2. Workflow Visualization Enhancements 💬
* Loop & Iteration Node Output Display: Real-time display of execution progress and intermediate outputs for easier debugging
* Variable Selection with Enter Key: Enabled Enter key confirmation for streamlined variable assignment", - "3. Multi-Tenant Model Management ⚙️
* Tenant-Scoped Model Keys: Model marketplace excludes custom models, model list properly isolates keys per tenant to prevent cross-tenant exposure", + "3. Optimized Model Management ⚙️
* Custom models have been removed from the Model marketplace to optimize the model usage experience", "4. Robustness & Bug Fixes 🔧
* Knowledge Graph Construction Fix: Addressed stability issues in knowledge graph pipeline for more reliable entity extraction and relationship mapping", "
", "Version 0.2.5 matures MemoryBear's operational foundations by addressing internationalization edge cases, enhancing multi-tenant isolation, and improving workflow transparency. The workflow visualization improvements lay groundwork for sophisticated debugging and monitoring capabilities. Looking forward, we will deepen enterprise readiness by expanding user management features, refining knowledge graph intelligence, and enhancing workflow orchestration with continued improvements in observability, performance optimization, and seamless integration patterns.", From 2d731c64123254608ad8440095cd5bbbf52be1ad Mon Sep 17 00:00:00 2001 From: Timebomb2018 <18868801967@163.com> Date: Fri, 27 Feb 2026 11:16:15 +0800 Subject: [PATCH 32/36] docs(version): Version 0.2.5 Release Notes --- api/app/version_info.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/api/app/version_info.json b/api/app/version_info.json index 772ff56e..7d82eabc 100644 --- a/api/app/version_info.json +++ b/api/app/version_info.json @@ -10,7 +10,7 @@ "3. 优化模型管理 ⚙️
* 模型广场移除自定义模型,优化模型使用体验", "4. 稳健性与缺陷修复 🔧
* 知识图谱构建修复:解决知识图谱构建流程稳定性问题,确保更可靠的实体提取和关系映射", "
", - "版本 0.2.5 通过解决国际化边界情况、增强多租户隔离和改进工作流透明度,构建更具生产就绪性的平台。工作流可视化改进为更复杂的调试和监控能力奠定基础。未来将继续深化企业就绪性,扩展用户管理功能、优化知识图谱智能和增强工作流编排能力,在可观测性、性能优化和无缝集成模式方面持续改进。", + "版本 0.2.5 通过解决国际化边界情况和改进工作流透明度,构建更具生产就绪性的平台。工作流可视化改进为更复杂的调试和监控能力奠定基础。未来将继续深化企业就绪性,扩展用户管理功能、优化知识图谱智能和增强工作流编排能力,在可观测性、性能优化和无缝集成模式方面持续改进。", "智慧致远 🐻✨" ] }, @@ -24,7 +24,7 @@ "3. Optimized Model Management ⚙️
* Custom models have been removed from the Model marketplace to optimize the model usage experience", "4. Robustness & Bug Fixes 🔧
* Knowledge Graph Construction Fix: Addressed stability issues in knowledge graph pipeline for more reliable entity extraction and relationship mapping", "
", - "Version 0.2.5 matures MemoryBear's operational foundations by addressing internationalization edge cases, enhancing multi-tenant isolation, and improving workflow transparency. The workflow visualization improvements lay groundwork for sophisticated debugging and monitoring capabilities. Looking forward, we will deepen enterprise readiness by expanding user management features, refining knowledge graph intelligence, and enhancing workflow orchestration with continued improvements in observability, performance optimization, and seamless integration patterns.", + "Version 0.2.5 matures MemoryBear's operational foundations by addressing internationalization edge cases and improving workflow transparency. The workflow visualization improvements lay groundwork for sophisticated debugging and monitoring capabilities. Looking forward, we will deepen enterprise readiness by expanding user management features, refining knowledge graph intelligence, and enhancing workflow orchestration with continued improvements in observability, performance optimization, and seamless integration patterns.", "Intelligent Resilience 🐻✨" ] } From bbaa39c569eb3aa261ed7de6cbfbdc499ce22c3e Mon Sep 17 00:00:00 2001 From: Timebomb2018 <18868801967@163.com> Date: Fri, 27 Feb 2026 12:08:18 +0800 Subject: [PATCH 33/36] fix(user): The user changes the space and modifies the role, the role information is synchronized. --- api/app/controllers/user_controller.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/app/controllers/user_controller.py b/api/app/controllers/user_controller.py index 3c574c81..2806da1a 100644 --- a/api/app/controllers/user_controller.py +++ b/api/app/controllers/user_controller.py @@ -100,7 +100,7 @@ def get_current_user_info( result_schema.current_workspace_name = current_workspace.name for ws in result.workspaces: - if ws.workspace_id == current_user.current_workspace_id: + if ws.workspace_id == current_user.current_workspace_id and ws.is_active: result_schema.role = ws.role break From dd9be2ed90c2e00111ff7f9d4058cf9d5f5557c4 Mon Sep 17 00:00:00 2001 From: zhaoying Date: Fri, 27 Feb 2026 18:48:02 +0800 Subject: [PATCH 34/36] fix(web): AutocompletePlugin key up/down support scroll --- .../Editor/plugin/AutocompletePlugin.tsx | 34 ++++++++++++++++--- 1 file changed, 30 insertions(+), 4 deletions(-) diff --git a/web/src/views/Workflow/components/Editor/plugin/AutocompletePlugin.tsx b/web/src/views/Workflow/components/Editor/plugin/AutocompletePlugin.tsx index 8e2687f1..f9fe097e 100644 --- a/web/src/views/Workflow/components/Editor/plugin/AutocompletePlugin.tsx +++ b/web/src/views/Workflow/components/Editor/plugin/AutocompletePlugin.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState, type FC } from 'react'; +import { useEffect, useState, useRef, type FC } from 'react'; import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; import { $getSelection, $isRangeSelection, $isTextNode, COMMAND_PRIORITY_HIGH, KEY_ENTER_COMMAND, KEY_ARROW_DOWN_COMMAND, KEY_ARROW_UP_COMMAND, KEY_ESCAPE_COMMAND } from 'lexical'; @@ -22,6 +22,26 @@ const AutocompletePlugin: FC<{ options: Suggestion[], enableJinja2?: boolean }> const [showSuggestions, setShowSuggestions] = useState(false); const [selectedIndex, setSelectedIndex] = useState(0); const [popupPosition, setPopupPosition] = useState({ top: 0, left: 0 }); + const popupRef = useRef(null); + + const scrollSelectedIntoView = () => { + if (!popupRef.current) return; + + const selectedElement = popupRef.current.querySelector('[data-selected="true"]'); + if (!selectedElement) return; + + const container = popupRef.current; + const element = selectedElement as HTMLElement; + + const containerRect = container.getBoundingClientRect(); + const elementRect = element.getBoundingClientRect(); + + if (elementRect.bottom > containerRect.bottom) { + container.scrollTop += elementRect.bottom - containerRect.bottom; + } else if (elementRect.top < containerRect.top) { + container.scrollTop -= containerRect.top - elementRect.top; + } + }; useEffect(() => { return editor.registerUpdateListener(({ editorState }) => { @@ -116,7 +136,7 @@ const AutocompletePlugin: FC<{ options: Suggestion[], enableJinja2?: boolean }> setShowSuggestions(false); }; - const groupedSuggestions = options.reduce((groups: Record, suggestion) => { + const groupedSuggestions = options.reduce((groups: Record, suggestion) => { const { nodeData } = suggestion const nodeId = nodeData.id as string; if (!groups[nodeId]) { @@ -163,7 +183,9 @@ const AutocompletePlugin: FC<{ options: Suggestion[], enableJinja2?: boolean }> while (nextIndex < allOptions.length && allOptions[nextIndex].disabled) { nextIndex++; } - return nextIndex >= allOptions.length ? prev : nextIndex; + const newIndex = nextIndex >= allOptions.length ? prev : nextIndex; + setTimeout(() => scrollSelectedIntoView(), 0); + return newIndex; }); return true; } @@ -182,7 +204,9 @@ const AutocompletePlugin: FC<{ options: Suggestion[], enableJinja2?: boolean }> while (prevIndex >= 0 && allOptions[prevIndex].disabled) { prevIndex--; } - return prevIndex < 0 ? prev : prevIndex; + const newIndex = prevIndex < 0 ? prev : prevIndex; + setTimeout(() => scrollSelectedIntoView(), 0); + return newIndex; }); return true; } @@ -218,6 +242,7 @@ const AutocompletePlugin: FC<{ options: Suggestion[], enableJinja2?: boolean }> } return (
e.preventDefault()} style={{ @@ -248,6 +273,7 @@ const AutocompletePlugin: FC<{ options: Suggestion[], enableJinja2?: boolean }> return (
Date: Fri, 27 Feb 2026 18:59:58 +0800 Subject: [PATCH 35/36] feat(web): create space storage type add recommend --- web/src/components/RadioGroupCard/index.tsx | 7 ++++++- web/src/i18n/en.ts | 1 + web/src/i18n/zh.ts | 1 + web/src/views/SpaceManagement/components/SpaceModal.tsx | 8 ++++++-- 4 files changed, 14 insertions(+), 3 deletions(-) diff --git a/web/src/components/RadioGroupCard/index.tsx b/web/src/components/RadioGroupCard/index.tsx index 41924c61..e09466cd 100644 --- a/web/src/components/RadioGroupCard/index.tsx +++ b/web/src/components/RadioGroupCard/index.tsx @@ -20,6 +20,7 @@ import { type FC, type Key, type ReactNode, useEffect } from 'react'; import { type RadioGroupProps } from 'antd'; import clsx from 'clsx' +import { useTranslation } from 'react-i18next'; /** Radio card option interface */ interface RadioCardOption { @@ -33,6 +34,8 @@ interface RadioCardOption { icon?: string; /** Whether the option is disabled */ disabled?: boolean; + /** Whether the option is recommended */ + recommend?: boolean; /** Additional properties */ [key: string]: string | number | boolean | undefined | null | Key; } @@ -63,6 +66,7 @@ const RadioGroupCard: FC = ({ allowClear = true, block = false, }) => { + const { t } = useTranslation(); /** Listen to value changes and trigger side effects via onValueChange callback */ useEffect(() => { if (onValueChange) { @@ -91,12 +95,13 @@ const RadioGroupCard: FC = ({ })}> {/* Render each option as a selectable card */} {options.map(option => ( -
handleChange(option)}> + {option.recommend &&
{t('common.recommend')}
} {/* Use custom render or default card layout */} {itemRender ? itemRender(option) : ( <> diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index 8dfb68db..f2b4eaa4 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -452,6 +452,7 @@ export const en = { nextStep: 'Next Step', prevStep: 'Previous Step', exportSuccess: 'Export successful', + recommend: 'Recommend', }, model: { searchPlaceholder: 'search model…', diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index feefc843..e2e7082a 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -1019,6 +1019,7 @@ export const zh = { nextStep: '下一步', prevStep: '上一步', exportSuccess: '导出成功', + recommend: '推荐', }, model: { searchPlaceholder: '搜索模型…', diff --git a/web/src/views/SpaceManagement/components/SpaceModal.tsx b/web/src/views/SpaceManagement/components/SpaceModal.tsx index a0703d81..4f37b246 100644 --- a/web/src/views/SpaceManagement/components/SpaceModal.tsx +++ b/web/src/views/SpaceManagement/components/SpaceModal.tsx @@ -34,8 +34,8 @@ interface SpaceModalProps { } /** Storage types */ const types: StorageType[] = [ - 'rag', 'neo4j', + 'rag', ] /** Type icons mapping */ const typeIcons: Record = { @@ -154,6 +154,9 @@ const SpaceModal = forwardRef(({
(({ value: type, label: t(`space.${type}`), labelDesc: t(`space.${type}Desc`), - icon: typeIcons[type] + icon: typeIcons[type], + recommend: type === 'neo4j', }))} block={true} /> From f81fdca62a16e5b59cabf6283395473dfbaa8f80 Mon Sep 17 00:00:00 2001 From: zhaoying Date: Sat, 28 Feb 2026 17:28:55 +0800 Subject: [PATCH 36/36] fix(web): model logo; BasicAuthLayout fix --- web/src/components/Layout/BasicAuthLayout.tsx | 10 +++++----- web/src/store/user.ts | 8 ++++---- .../ModelManagement/components/CustomModelModal.tsx | 6 +++--- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/web/src/components/Layout/BasicAuthLayout.tsx b/web/src/components/Layout/BasicAuthLayout.tsx index a73f6c69..f279a48b 100644 --- a/web/src/components/Layout/BasicAuthLayout.tsx +++ b/web/src/components/Layout/BasicAuthLayout.tsx @@ -2,10 +2,10 @@ * @Author: ZhaoYing * @Date: 2026-02-02 15:12:42 * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-02-04 14:06:28 + * @Last Modified time: 2026-02-28 17:28:41 */ /** - * BasicLayout Component + * BasicAuthLayout Component * * A minimal layout wrapper that provides: * - User information initialization @@ -26,12 +26,12 @@ import { useUser } from '@/store/user'; * Basic layout component for pages without navigation UI. * Fetches user info and storage type on mount, then renders child routes. */ -const BasicLayout: FC = () => { +const BasicAuthLayout: FC = () => { const { getUserInfo } = useUser(); // Fetch user information and storage type on component mount useEffect(() => { - getUserInfo(); + getUserInfo(undefined, true); // Pass true to skip navigation jump }, [getUserInfo]); return ( @@ -42,4 +42,4 @@ const BasicLayout: FC = () => { ) }; -export default BasicLayout; \ No newline at end of file +export default BasicAuthLayout; \ No newline at end of file diff --git a/web/src/store/user.ts b/web/src/store/user.ts index c9231d9c..f5e0cb28 100644 --- a/web/src/store/user.ts +++ b/web/src/store/user.ts @@ -2,7 +2,7 @@ * @Author: ZhaoYing * @Date: 2026-02-02 16:33:54 * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-02-04 18:30:10 + * @Last Modified time: 2026-02-28 17:21:20 */ /** * User Store @@ -44,7 +44,7 @@ export interface UserState { /** Update login information */ updateLoginInfo: (values: LoginInfo) => void; /** Get user information */ - getUserInfo: (flag?: boolean) => void; + getUserInfo: (flag?: boolean, notNeedJump?: boolean) => void; /** Clear user information */ clearUserInfo: () => void; /** Logout user */ @@ -73,13 +73,13 @@ export const useUser = create((set, get) => ({ cookieUtils.set('refreshToken', values.refresh_token); set({ loginInfo: values }); }, - getUserInfo: async (flag?: boolean) => { + getUserInfo: async (flag?: boolean, notNeedJump?: boolean) => { if (!cookieUtils.get('authToken')) { return } const { checkJump } = get() const localUser = JSON.parse(localStorage.getItem('user') || '{}') as User; - if (localUser.id) { + if (localUser.id && !notNeedJump) { checkJump() return } diff --git a/web/src/views/ModelManagement/components/CustomModelModal.tsx b/web/src/views/ModelManagement/components/CustomModelModal.tsx index fb0db96e..17373a02 100644 --- a/web/src/views/ModelManagement/components/CustomModelModal.tsx +++ b/web/src/views/ModelManagement/components/CustomModelModal.tsx @@ -1,8 +1,8 @@ /* * @Author: ZhaoYing * @Date: 2026-02-03 16:49:28 - * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-02-03 16:49:28 + * @Last Modified by: ZhaoYing + * @Last Modified time: 2026-02-28 17:24:05 */ /** * Custom Model Modal @@ -50,7 +50,7 @@ const CustomModelModal = forwardRef( setModel(model); form.setFieldsValue({ ...model, - logo: model.logo ? { url: model.logo, uid: model.logo, status: 'done', name: 'logo' } : undefined + logo: model.logo && model.logo.startsWith('http') ? { url: model.logo, uid: model.logo, status: 'done', name: 'logo' } : undefined }); } else { setIsEdit(false);