From ab9c2d81b06a078ae76a47dc8f7059d0b953bdc6 Mon Sep 17 00:00:00 2001 From: Mark Date: Thu, 22 Jan 2026 12:14:02 +0800 Subject: [PATCH 1/9] [add] public file url --- .../controllers/file_storage_controller.py | 93 ++++++++++++++++++- 1 file changed, 90 insertions(+), 3 deletions(-) diff --git a/api/app/controllers/file_storage_controller.py b/api/app/controllers/file_storage_controller.py index ea8aa7ee..c28ffe6c 100644 --- a/api/app/controllers/file_storage_controller.py +++ b/api/app/controllers/file_storage_controller.py @@ -267,25 +267,27 @@ async def delete_file( async def get_file_url( file_id: uuid.UUID, expires: int = None, + permanent: bool = False, db: Session = Depends(get_db), storage_service: FileStorageService = Depends(get_file_storage_service), ): """ - Get a temporary access URL for a file (no authentication required). + Get an access URL for a file (no authentication required). Args: file_id: The UUID of the file. expires: URL validity period in seconds (default from FILE_URL_EXPIRES env). + permanent: If True, return a permanent URL without expiration. db: Database session. storage_service: The file storage service. Returns: - ApiResponse with the temporary access URL. + ApiResponse with the access URL. """ if expires is None: expires = settings.FILE_URL_EXPIRES - api_logger.info(f"Get file URL request: file_id={file_id}, expires={expires}") + api_logger.info(f"Get file URL request: file_id={file_id}, expires={expires}, permanent={permanent}") # Query file metadata from database file_metadata = db.query(FileMetadata).filter(FileMetadata.id == file_id).first() @@ -306,6 +308,20 @@ async def get_file_url( storage = storage_service.storage try: + if permanent: + # Generate permanent URL (no expiration check) + server_url = f"http://{settings.SERVER_IP}:8000/api" + url = f"{server_url}/storage/permanent/{file_id}" + return success( + data={ + "url": url, + "expires_in": None, + "permanent": True, + "file_name": file_metadata.file_name, + }, + msg="Permanent file URL generated successfully" + ) + if isinstance(storage, LocalStorage): # For local storage, generate signed URL with expiration url = generate_signed_url(str(file_id), expires) @@ -318,6 +334,7 @@ async def get_file_url( data={ "url": url, "expires_in": expires, + "permanent": False, "file_name": file_metadata.file_name, }, msg="File URL generated successfully" @@ -410,3 +427,73 @@ async def public_download_file( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to retrieve file: {str(e)}" ) + + +@router.get("/permanent/{file_id}", response_model=Any) +async def permanent_download_file( + file_id: uuid.UUID, + db: Session = Depends(get_db), + storage_service: FileStorageService = Depends(get_file_storage_service), +) -> Any: + """ + Permanent file download endpoint (no expiration, no signature required). + + This endpoint allows downloading files without authentication or expiration. + Use with caution as URLs are permanently accessible. + + Args: + file_id: The UUID of the file. + db: Database session. + storage_service: The file storage service. + + Returns: + FileResponse for the requested file. + """ + api_logger.info(f"Permanent download request: file_id={file_id}") + + # Query file metadata from database + file_metadata = db.query(FileMetadata).filter(FileMetadata.id == file_id).first() + if not file_metadata: + api_logger.warning(f"File not found in database: file_id={file_id}") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="The file does not exist" + ) + + if file_metadata.status != "completed": + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"File upload not completed, status: {file_metadata.status}" + ) + + file_key = file_metadata.file_key + storage = storage_service.storage + + if isinstance(storage, LocalStorage): + full_path = storage._get_full_path(file_key) + + if not full_path.exists(): + api_logger.warning(f"File not found on disk: file_key={file_key}") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="File not found" + ) + + api_logger.info(f"Serving permanent file: file_key={file_key}") + return FileResponse( + path=str(full_path), + filename=file_metadata.file_name, + media_type=file_metadata.content_type or "application/octet-stream" + ) + else: + # For remote storage, redirect to presigned URL with long expiration + try: + # Use a very long expiration (7 days max for most cloud providers) + presigned_url = await storage_service.get_file_url(file_key, expires=604800) + return RedirectResponse(url=presigned_url, status_code=status.HTTP_302_FOUND) + except Exception as e: + api_logger.error(f"Failed to get presigned URL: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to retrieve file: {str(e)}" + ) From 8b32f80e279bd8d5468eaebff0ce4823694d76ed Mon Sep 17 00:00:00 2001 From: Mark Date: Thu, 22 Jan 2026 12:25:50 +0800 Subject: [PATCH 2/9] [modify] dependencies --- api/pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/pyproject.toml b/api/pyproject.toml index 7ead16e3..81ac57a1 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -140,7 +140,7 @@ dependencies = [ "deprecated>=1.3.1", "oss2>=2.19.1", "flower>=2.0.1", - "aiofile>=3.9.0", + "aiofiles>=23.0.0", ] [tool.pytest.ini_options] From da75abb22328c814d201d24ab4e04fd73b0eb0cd Mon Sep 17 00:00:00 2001 From: zhaoying Date: Thu, 22 Jan 2026 12:26:37 +0800 Subject: [PATCH 3/9] feat(web): user memory feature optimize --- web/src/i18n/en.ts | 1 + web/src/i18n/zh.ts | 1 + .../components/PageHeader.tsx | 6 ++--- .../components/RelationshipNetwork.tsx | 10 +++++--- .../components/Suggestions.tsx | 7 +++++- .../UserMemoryDetail/pages/ImplicitDetail.tsx | 24 ++++++++++++------- .../pages/StatementDetail.tsx | 19 +++++++++++---- .../UserMemoryDetail/pages/WorkingDetail.tsx | 20 ++++++++-------- .../views/UserMemoryDetail/pages/index.tsx | 21 ++++++++++++---- 9 files changed, 75 insertions(+), 34 deletions(-) diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index bc757797..1df2eb6d 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -2064,6 +2064,7 @@ Memory Bear: After the rebellion, regional warlordism intensified for several re stability: 'Stability', resilience: 'Resilience', suggestions: 'Personalized Suggestions', + suggestionLoading: 'Your personalized suggestions are being generated', }, reflectionEngine: { reflectionEngineConfig: 'Reflection Engine Configuration', diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index 2e88ad4a..44f17f49 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -2158,6 +2158,7 @@ export const zh = { stability: '稳定性', resilience: '恢复力', suggestions: '个性化建议', + suggestionLoading: '您的个性化建议正在生成中', }, reflectionEngine: { reflectionEngineConfig: '反思引擎配置', diff --git a/web/src/views/UserMemoryDetail/components/PageHeader.tsx b/web/src/views/UserMemoryDetail/components/PageHeader.tsx index 68cdada8..2c457067 100644 --- a/web/src/views/UserMemoryDetail/components/PageHeader.tsx +++ b/web/src/views/UserMemoryDetail/components/PageHeader.tsx @@ -35,13 +35,13 @@ const PageHeader: FC = ({ {operation} - - {extra} - + ); }; diff --git a/web/src/views/UserMemoryDetail/components/RelationshipNetwork.tsx b/web/src/views/UserMemoryDetail/components/RelationshipNetwork.tsx index 7a927479..55e94270 100644 --- a/web/src/views/UserMemoryDetail/components/RelationshipNetwork.tsx +++ b/web/src/views/UserMemoryDetail/components/RelationshipNetwork.tsx @@ -266,7 +266,7 @@ const RelationshipNetwork:FC = () => { size={[197.81, 150]} /> : <> -
{selectedNode.name}
+ {selectedNode.name &&
{selectedNode.name}
}
<>
{t('userMemory.memoryContent')}
@@ -297,7 +297,8 @@ const RelationshipNetwork:FC = () => { {selectedNode.label === 'Statement' && <> {(['emotion_keywords', 'emotion_type', 'emotion_subject', 'importance_score'] as const).map(key => { const statementProps = selectedNode.properties as StatementNodeProperties; - if ((key === 'emotion_keywords' && statementProps[key]?.length > 0) || statementProps[key]) { + if ((key === 'emotion_keywords' && statementProps[key]?.length > 0) || typeof statementProps[key] === 'string') { + console.log('statementProps[key]', statementProps[key]) return (
{t(`userMemory.Statement_${key}`)} @@ -321,7 +322,10 @@ const RelationshipNetwork:FC = () => {
{t(`userMemory.ExtractedEntity_${key}`)}
- {entityProps[key]} + {Array.isArray(entityProps[key]) && entityProps[key].length > 0 + ? entityProps[key].map((vo, index) =>
- {vo}
) + : entityProps[key] + }
) diff --git a/web/src/views/UserMemoryDetail/components/Suggestions.tsx b/web/src/views/UserMemoryDetail/components/Suggestions.tsx index c2c8ca8b..2687e457 100644 --- a/web/src/views/UserMemoryDetail/components/Suggestions.tsx +++ b/web/src/views/UserMemoryDetail/components/Suggestions.tsx @@ -21,6 +21,7 @@ interface Suggestions { const Suggestions = forwardRef<{ handleRefresh: () => void; }>((_props, ref) => { const { t } = useTranslation() const { id } = useParams() + const [loading, setLoading] = useState(false) const [suggestions, setSuggestions] = useState(null) useEffect(() => { @@ -31,10 +32,14 @@ const Suggestions = forwardRef<{ handleRefresh: () => void; }>((_props, ref) => if (!id) { return } + setLoading(true) getEmotionSuggestions(id) .then((res) => { setSuggestions(res as Suggestions) }) + .finally(() => { + setLoading(false) + }) } useImperativeHandle(ref, () => ({ @@ -63,7 +68,7 @@ const Suggestions = forwardRef<{ handleRefresh: () => void; }>((_props, ref) => ))}
- : + : } ) diff --git a/web/src/views/UserMemoryDetail/pages/ImplicitDetail.tsx b/web/src/views/UserMemoryDetail/pages/ImplicitDetail.tsx index d79407da..dfe5c1ee 100644 --- a/web/src/views/UserMemoryDetail/pages/ImplicitDetail.tsx +++ b/web/src/views/UserMemoryDetail/pages/ImplicitDetail.tsx @@ -20,14 +20,22 @@ const ImplicitDetail = forwardRef<{ handleRefresh: () => void; }>((_props, ref) const habitsRef = useRef<{ handleRefresh: () => void; }>(null) const handleRefresh = () => { - if (!id) return - generateProfile(id) - .then(() => { - preferencesRef.current?.handleRefresh() - portraitRef.current?.handleRefresh() - interestAreasRef.current?.handleRefresh() - habitsRef.current?.handleRefresh() - }) + if (!id) { + return Promise.resolve() + } + return new Promise((resolve, reject) => { + generateProfile(id) + .then(() => { + preferencesRef.current?.handleRefresh() + portraitRef.current?.handleRefresh() + interestAreasRef.current?.handleRefresh() + habitsRef.current?.handleRefresh() + resolve(true) + }) + .catch((error) => { + reject(error) + }) + }) } useImperativeHandle(ref, () => ({ handleRefresh diff --git a/web/src/views/UserMemoryDetail/pages/StatementDetail.tsx b/web/src/views/UserMemoryDetail/pages/StatementDetail.tsx index 6515263e..72d35c60 100644 --- a/web/src/views/UserMemoryDetail/pages/StatementDetail.tsx +++ b/web/src/views/UserMemoryDetail/pages/StatementDetail.tsx @@ -13,11 +13,20 @@ const StatementDetail = forwardRef((_props, ref) => { const { id } = useParams() const suggestionsRef = useRef<{ handleRefresh: () => void; }>(null) const handleRefresh = () => { - if (!id) return - generateSuggestions(id) - .then(() => { - suggestionsRef.current?.handleRefresh() - }) + if (!id) { + return Promise.resolve() + } + + return new Promise((resolve, reject) => { + generateSuggestions(id) + .then(() => { + suggestionsRef.current?.handleRefresh() + resolve(true) + }) + .catch((error) => { + reject(error) + }) + }) } useImperativeHandle(ref, () => ({ handleRefresh diff --git a/web/src/views/UserMemoryDetail/pages/WorkingDetail.tsx b/web/src/views/UserMemoryDetail/pages/WorkingDetail.tsx index 7ba7c414..843a3b23 100644 --- a/web/src/views/UserMemoryDetail/pages/WorkingDetail.tsx +++ b/web/src/views/UserMemoryDetail/pages/WorkingDetail.tsx @@ -2,7 +2,7 @@ import { type FC, useEffect, useState, useMemo } from 'react' import clsx from 'clsx' import { useTranslation } from 'react-i18next' import { useParams } from 'react-router-dom' -import { Row, Col, Select, Form, Space, Skeleton, Input, Button, Divider } from 'antd' +import { Row, Col, Skeleton, Button, Divider, Tooltip } from 'antd' import RbCard from '@/components/RbCard/Card' import { getConversations, @@ -10,7 +10,6 @@ import { getConversationDetail, } from '@/api/memory' import { formatDateTime } from '@/utils/format' -import Tag from '@/components/Tag' import RbAlert from '@/components/RbAlert' import Empty from '@/components/Empty' import ChatContent from '@/components/Chat/ChatContent' @@ -33,7 +32,6 @@ interface Detail { const WorkingDetail: FC = () => { const { t } = useTranslation() const { id } = useParams() - const [form] = Form.useForm() const [loading, setLoading] = useState(false) const [data, setData] = useState([]) const [messagesLoading, setMessagesLoading] = useState(false) @@ -110,13 +108,15 @@ const WorkingDetail: FC = () => {
{data.map(item => (
-
setSelected(item)} - > - {item.title} -
+ +
setSelected(item)} + > + {item.title} +
+
))}
diff --git a/web/src/views/UserMemoryDetail/pages/index.tsx b/web/src/views/UserMemoryDetail/pages/index.tsx index 16004edc..c5dea163 100644 --- a/web/src/views/UserMemoryDetail/pages/index.tsx +++ b/web/src/views/UserMemoryDetail/pages/index.tsx @@ -2,6 +2,7 @@ import { type FC, useEffect, useState, useMemo, useRef } from 'react' import { useParams, useNavigate } from 'react-router-dom' import { useTranslation } from 'react-i18next' import { Dropdown, Button } from 'antd' +import { LoadingOutlined } from '@ant-design/icons'; import PageHeader from '../components/PageHeader' import StatementDetail from './StatementDetail' @@ -46,18 +47,30 @@ const Detail: FC = () => { const onClick = ({ key }: { key: string }) => { navigate(`/user-memory/detail/${id}/${key}`, { replace: true }) } + + const [loading, setLoading] = useState(false) const handleRefresh = () => { + setLoading(true) + let response: any = null switch(type) { case 'FORGET_MEMORY': forgetDetailRef.current?.handleRefresh() break; case 'EMOTIONAL_MEMORY': - statementDetailRef.current?.handleRefresh() + response = statementDetailRef.current?.handleRefresh() break case 'IMPLICIT_MEMORY': - implicitDetailRef.current?.handleRefresh() + response = implicitDetailRef.current?.handleRefresh() break } + + if (response instanceof Promise) { + response.finally(() => { + setLoading(false) + }) + } else { + setLoading(false) + } } if (type === 'GRAPH') { @@ -80,8 +93,8 @@ const Detail: FC = () => { } extra={['FORGET_MEMORY', 'EMOTIONAL_MEMORY', 'IMPLICIT_MEMORY'].includes(type as string) && - } /> From 15221005d1168b323ea54d38cbc2dee7308a4e52 Mon Sep 17 00:00:00 2001 From: zhaoying Date: Thu, 22 Jan 2026 14:20:02 +0800 Subject: [PATCH 4/9] fix(web): workflow's variables bugfix --- .../components/Properties/CaseList/index.tsx | 5 +- .../Properties/CategoryList/index.tsx | 4 +- .../Properties/hooks/useVariableList.ts | 6 ++ .../Workflow/components/Properties/index.tsx | 66 ++++++++++++++++++- web/src/views/Workflow/constant.ts | 9 +-- .../views/Workflow/hooks/useWorkflowGraph.ts | 12 ++-- 6 files changed, 88 insertions(+), 14 deletions(-) diff --git a/web/src/views/Workflow/components/Properties/CaseList/index.tsx b/web/src/views/Workflow/components/Properties/CaseList/index.tsx index d9521059..f76bd7db 100644 --- a/web/src/views/Workflow/components/Properties/CaseList/index.tsx +++ b/web/src/views/Workflow/components/Properties/CaseList/index.tsx @@ -6,7 +6,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 } from '../../../constant' +import { edgeAttrs, portArgs } from '../../../constant' interface CaseListProps { value?: Array<{ logical_operator: 'and' | 'or'; expressions: { left: string; operator: string; right: string; input_type?: string; }[] }>; @@ -92,6 +92,7 @@ const CaseList: FC = ({ selectedNode.addPort({ id: 'CASE1', group: 'right', + args: portArgs, attrs: { text: { text: 'IF', fontSize: 12, fill: '#5B6167' }} }); @@ -100,6 +101,7 @@ const CaseList: FC = ({ selectedNode.addPort({ id: `CASE${i + 1}`, group: 'right', + args: portArgs, attrs: { text: { text: 'ELIF', fontSize: 12, fill: '#5B6167' }} }); } @@ -108,6 +110,7 @@ const CaseList: FC = ({ selectedNode.addPort({ id: `CASE${caseCount + 1}`, group: 'right', + args: portArgs, attrs: { text: { text: 'ELSE', fontSize: 12, fill: '#5B6167' }} }); diff --git a/web/src/views/Workflow/components/Properties/CategoryList/index.tsx b/web/src/views/Workflow/components/Properties/CategoryList/index.tsx index aabc3ad3..22163905 100644 --- a/web/src/views/Workflow/components/Properties/CategoryList/index.tsx +++ b/web/src/views/Workflow/components/Properties/CategoryList/index.tsx @@ -5,7 +5,7 @@ import { Graph, Node } from '@antv/x6'; import Editor from '../../Editor'; import type { Suggestion } from '../../Editor/plugin/AutocompletePlugin' -import { edgeAttrs } from '../../../constant' +import { edgeAttrs, portArgs } from '../../../constant' interface CategoryListProps { parentName: string; @@ -55,7 +55,7 @@ const CategoryList: FC = ({ parentName, selectedNode, graphRe selectedNode.addPort({ id: `CASE${i + 1}`, group: 'right', - args: i === 0 ? { dy: 24 } : undefined, + args: portArgs, attrs: { text: { text: `分类${i + 1}`, fontSize: 12, fill: '#5B6167' } } }); } diff --git a/web/src/views/Workflow/components/Properties/hooks/useVariableList.ts b/web/src/views/Workflow/components/Properties/hooks/useVariableList.ts index ab37fec9..16c32b7c 100644 --- a/web/src/views/Workflow/components/Properties/hooks/useVariableList.ts +++ b/web/src/views/Workflow/components/Properties/hooks/useVariableList.ts @@ -190,6 +190,12 @@ export const useVariableList = ( if (iv?.dataType.startsWith('array[')) itemType = iv.dataType.replace(/^array\[(.+)\]$/, '$1'); addVariable(list, keys, `${pid}_item`, 'item', itemType, `${pid}.item`, pd); addVariable(list, keys, `${pid}_index`, 'index', 'number', `${pid}.index`, pd); + } else if (pd.type === 'iteration' && !pd.config.input.defaultValue) { + let itemType = 'object'; + const iv = list.find(v => `{{${v.value}}}` === pd.config.input.defaultValue); + if (iv?.dataType.startsWith('array[')) itemType = iv.dataType.replace(/^array\[(.+)\]$/, '$1'); + addVariable(list, keys, `${pid}_item`, 'item', 'string', `${pid}.item`, pd); + addVariable(list, keys, `${pid}_index`, 'index', 'number', `${pid}.index`, pd); } } diff --git a/web/src/views/Workflow/components/Properties/index.tsx b/web/src/views/Workflow/components/Properties/index.tsx index d55e1d9e..26ad0470 100644 --- a/web/src/views/Workflow/components/Properties/index.tsx +++ b/web/src/views/Workflow/components/Properties/index.tsx @@ -291,7 +291,69 @@ const Properties: FC = ({ return filteredList; } if (nodeType === 'iteration' && key === 'output') { - return variableList.filter(variable => variable.value.includes('sys.')); + let filteredList = variableList.filter(variable => variable.value.includes('sys.')); + // Add child node output variables for loop nodes + if (selectedNode) { + const graph = graphRef.current; + if (graph) { + const nodes = graph.getNodes(); + const childNodes = nodes.filter(node => { + const nodeData = node.getData(); + return nodeData?.cycle === selectedNode.id; + }); + + // Add output variables from child nodes + childNodes.forEach(childNode => { + const childData = childNode.getData(); + const childNodeId = childData.id; + + // Add child node output variables based on their type + switch (childData.type) { + case 'llm': + case 'jinja-render': + case 'tool': + const outputKey = `${childNodeId}_output`; + const existingOutput = filteredList.find(v => v.key === outputKey); + if (!existingOutput) { + filteredList.push({ + key: outputKey, + label: 'output', + type: 'variable', + dataType: 'string', + value: `${childNodeId}.output`, + nodeData: childData, + }); + } + break; + case 'http-request': + const bodyKey = `${childNodeId}_body`; + const statusKey = `${childNodeId}_status_code`; + if (!filteredList.find(v => v.key === bodyKey)) { + filteredList.push({ + key: bodyKey, + label: 'body', + type: 'variable', + dataType: 'string', + value: `${childNodeId}.body`, + nodeData: childData, + }); + } + if (!filteredList.find(v => v.key === statusKey)) { + filteredList.push({ + key: statusKey, + label: 'status_code', + type: 'variable', + dataType: 'number', + value: `${childNodeId}.status_code`, + nodeData: childData, + }); + } + break; + } + }); + } + } + return filteredList; } if (nodeType === 'iteration') { return variableList.filter(variable => variable.dataType.includes('array')); @@ -411,7 +473,7 @@ const Properties: FC = ({ /> : selectedNode?.data?.type === 'tool' ? - : selectedNode?.data.type === 'jinja-render' + : selectedNode?.data?.type === 'jinja-render' ? = { iteration: { width: 240, @@ -591,8 +592,8 @@ export const graphNodeLibrary: Record = { groups: defaultPortGroups, items: [ { group: 'left' }, - { group: 'right', id: 'CASE1', args: { dy: 24 }, attrs: { text: { text: 'IF', fontSize: 12, color: '#5B6167' }} }, - { group: 'right', id: 'CASE2', attrs: { text: { text: 'ELSE', fontSize: 12, color: '#5B6167' }} } + { 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' }} } ], }, }, @@ -604,8 +605,8 @@ export const graphNodeLibrary: Record = { groups: defaultPortGroups, items: [ { group: 'left' }, - { group: 'right', id: 'CASE1', args: { dy: 24 }, attrs: { text: { text: '分类1', fontSize: 12, color: '#5B6167' } } }, - { group: 'right', id: 'CASE2', attrs: { text: { text: '分类2', fontSize: 12, color: '#5B6167' } } } + { 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' } } } ], }, }, diff --git a/web/src/views/Workflow/hooks/useWorkflowGraph.ts b/web/src/views/Workflow/hooks/useWorkflowGraph.ts index f8a5a6bc..0cc69fea 100644 --- a/web/src/views/Workflow/hooks/useWorkflowGraph.ts +++ b/web/src/views/Workflow/hooks/useWorkflowGraph.ts @@ -5,7 +5,7 @@ import { App } from 'antd' import { Graph, Node, MiniMap, Snapline, Clipboard, Keyboard, type Edge } from '@antv/x6'; import { register } from '@antv/x6-react-shape'; -import { nodeRegisterLibrary, graphNodeLibrary, nodeLibrary, portMarkup, portAttrs, edgeAttrs, edge_color, edge_selected_color } from '../constant'; +import { nodeRegisterLibrary, graphNodeLibrary, nodeLibrary, portMarkup, portAttrs, edgeAttrs, edge_color, edge_selected_color, portArgs } from '../constant'; import type { WorkflowConfig, NodeProperties, ChatVariable } from '../types'; import { getWorkflowConfig, saveWorkflowConfig } from '@/api/application' import type { PortMetadata } from '@antv/x6/lib/model/port'; @@ -132,7 +132,7 @@ export const useWorkflowGraph = ({ const portItems: PortMetadata[] = [ { group: 'left' }, - { group: 'right', id: 'CASE1', args: { dy: 24 }, attrs: { text: { text: 'IF', fontSize: 12, fill: '#5B6167' }} } + { group: 'right', id: 'CASE1', args: portArgs, attrs: { text: { text: 'IF', fontSize: 12, fill: '#5B6167' }} } ]; // 添加 ELIF 端口 @@ -140,6 +140,7 @@ export const useWorkflowGraph = ({ portItems.push({ group: 'right', id: `CASE${i + 1}`, + args: portArgs, attrs: { text: { text: 'ELIF', fontSize: 12, fill: '#5B6167' }} }); } @@ -148,6 +149,7 @@ export const useWorkflowGraph = ({ portItems.push({ group: 'right', id: `CASE${caseCount + 1}`, + args: portArgs, attrs: { text: { text: 'ELSE', fontSize: 12, fill: '#5B6167' }} }); @@ -173,12 +175,12 @@ export const useWorkflowGraph = ({ ]; // 添加分类端口 - config.categories.forEach((category: any, index: number) => { + config.categories.forEach((_category: any, index: number) => { portItems.push({ group: 'right', id: `CASE${index + 1}`, - args: index === 0 ? { dy: 24 } : undefined, - attrs: { text: { text: category.class_name || `分类${index + 1}`, fontSize: 12, fill: '#5B6167' }} + args: portArgs, + attrs: { text: { text: `分类${index + 1}`, fontSize: 12, fill: '#5B6167' }} }); }); From 5a3cddab0f3cdf25b40cecd78454db4adf487fcf Mon Sep 17 00:00:00 2001 From: zhaoying Date: Thu, 22 Jan 2026 14:25:38 +0800 Subject: [PATCH 5/9] fix(web): agent's memory_content convert to number --- web/src/views/ApplicationConfig/Agent.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/web/src/views/ApplicationConfig/Agent.tsx b/web/src/views/ApplicationConfig/Agent.tsx index 28c96ec0..77e90440 100644 --- a/web/src/views/ApplicationConfig/Agent.tsx +++ b/web/src/views/ApplicationConfig/Agent.tsx @@ -128,7 +128,11 @@ const Agent = forwardRef((_props, ref) => { let allTools = Array.isArray(response.tools) ? response.tools : [] form.setFieldsValue({ ...response, - tools: allTools + tools: allTools, + memory: { + ...response.memory, + memory_content: response.memory?.memory_content ? Number(response.memory?.memory_content) : undefined + } }) setData({ ...response, From cd3b4d8ddea475614bdcf7cd9fc26398fa95f11e Mon Sep 17 00:00:00 2001 From: zhaoying Date: Thu, 22 Jan 2026 14:35:11 +0800 Subject: [PATCH 6/9] feat(web): request add X-Language-Type header --- web/src/utils/request.ts | 4 +++- web/src/views/UserMemoryDetail/components/MemoryInsight.tsx | 3 ++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/web/src/utils/request.ts b/web/src/utils/request.ts index 447ff88c..e49fcb3c 100644 --- a/web/src/utils/request.ts +++ b/web/src/utils/request.ts @@ -52,6 +52,8 @@ service.interceptors.request.use( config.headers.Authorization = `Bearer ${token}`; } } + const language = localStorage.getItem('language') + config.headers['X-Language-Type'] = language || 'en'; config.headers.Cookie = undefined return config; }, @@ -146,7 +148,7 @@ service.interceptors.response.use( break; default: if (!msg && Array.isArray(error.response?.data?.detail)) { - msg = error.response?.data?.detail?.map(item => item.msg).join(';') + msg = error.response?.data?.detail?.map((item: { msg: string }) => item.msg).join(';') } else { msg = msg || i18n.t('common.unknownError'); } diff --git a/web/src/views/UserMemoryDetail/components/MemoryInsight.tsx b/web/src/views/UserMemoryDetail/components/MemoryInsight.tsx index 7dd31fc4..5146a5ac 100644 --- a/web/src/views/UserMemoryDetail/components/MemoryInsight.tsx +++ b/web/src/views/UserMemoryDetail/components/MemoryInsight.tsx @@ -57,7 +57,8 @@ const MemoryInsight = forwardRef((_props, ref) => { : Object.keys(data).length > 0 ? {['memory_insight', 'key_findings', 'behavior_pattern', 'growth_trajectory'].map(key => { - if (data[key as keyof Data]) { + const value = data[key as keyof Data]; + if (Array.isArray(value) && value.length > 0 || (!Array.isArray(value) && value)) { return (
Date: Thu, 22 Jan 2026 15:15:54 +0800 Subject: [PATCH 7/9] fix(web): JinjaRender's form bugfix --- .../Editor/plugin/Jinja2HighlightPlugin.tsx | 25 ++++++- .../Properties/JinjaRender/index.tsx | 71 ++++++++----------- 2 files changed, 55 insertions(+), 41 deletions(-) diff --git a/web/src/views/Workflow/components/Editor/plugin/Jinja2HighlightPlugin.tsx b/web/src/views/Workflow/components/Editor/plugin/Jinja2HighlightPlugin.tsx index 4c75fc58..fb6212bd 100644 --- a/web/src/views/Workflow/components/Editor/plugin/Jinja2HighlightPlugin.tsx +++ b/web/src/views/Workflow/components/Editor/plugin/Jinja2HighlightPlugin.tsx @@ -1,6 +1,6 @@ import { useEffect } from 'react'; import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; -import { TextNode, $createTextNode } from 'lexical'; +import { TextNode, $createTextNode, $getSelection, $isRangeSelection } from 'lexical'; const Jinja2HighlightPlugin = () => { const [editor] = useLexicalComposerContext(); @@ -18,6 +18,16 @@ const Jinja2HighlightPlugin = () => { const parent = textNode.getParent(); if (!parent) return; + // Preserve selection + const selection = $getSelection(); + let selectionOffset = null; + if ($isRangeSelection(selection)) { + const anchor = selection.anchor; + if (anchor.getNode() === textNode) { + selectionOffset = anchor.offset; + } + } + const tokens = tokenizeJinja2(text); // Skip if no meaningful tokenization (only one text token) @@ -85,6 +95,19 @@ const Jinja2HighlightPlugin = () => { for (let i = 1; i < newNodes.length; i++) { newNodes[i - 1].insertAfter(newNodes[i]); } + + // Restore selection + if (selectionOffset !== null && $isRangeSelection(selection)) { + let currentOffset = 0; + for (const node of newNodes) { + const nodeLength = node.getTextContent().length; + if (currentOffset + nodeLength >= selectionOffset) { + node.select(selectionOffset - currentOffset, selectionOffset - currentOffset); + break; + } + currentOffset += nodeLength; + } + } } }); }, [editor]); diff --git a/web/src/views/Workflow/components/Properties/JinjaRender/index.tsx b/web/src/views/Workflow/components/Properties/JinjaRender/index.tsx index a2c9da37..d1a392ae 100644 --- a/web/src/views/Workflow/components/Properties/JinjaRender/index.tsx +++ b/web/src/views/Workflow/components/Properties/JinjaRender/index.tsx @@ -31,13 +31,11 @@ const JinjaRender: FC = ({ selectedNode, options, templateOpti const form = Form.useFormInstance() const values = Form.useWatch([], form) || {} - console.log('JinjaRender values', values) - const prevMappingNamesRef = useRef([]) const prevTemplateVarsRef = useRef([]) - const syncTimeoutRef = useRef(null) const isSyncingRef = useRef(false) const lastSyncSourceRef = useRef<'mapping' | 'template' | null>(null) + const editorKeyRef = useRef(0) // Reset refs when node changes useEffect(() => { @@ -68,46 +66,39 @@ const JinjaRender: FC = ({ selectedNode, options, templateOpti if (JSON.stringify(prevNames) === JSON.stringify(currentMappingNames)) return - if (syncTimeoutRef.current) clearTimeout(syncTimeoutRef.current) - const activeElement = document.activeElement as HTMLElement + let updatedTemplate = String(form.getFieldValue('template') || '') - syncTimeoutRef.current = setTimeout(() => { - let updatedTemplate = String(form.getFieldValue('template') || '') - - prevNames.forEach((oldName, index) => { - const newName = currentMappingNames[index] - if (newName && oldName !== newName) { - updatedTemplate = updatedTemplate.replace( - new RegExp(`{{\\s*${oldName}\\s*}}`, 'g'), - `{{${newName}}}` - ) - } - }) - - if (updatedTemplate !== form.getFieldValue('template')) { - isSyncingRef.current = true - lastSyncSourceRef.current = 'mapping' - - prevTemplateVarsRef.current = extractTemplateVars(updatedTemplate) - prevMappingNamesRef.current = currentMappingNames - form.setFieldValue('template', updatedTemplate) - - requestAnimationFrame(() => { - activeElement?.focus?.() - setTimeout(() => { - isSyncingRef.current = false - lastSyncSourceRef.current = null - }, 50) - }) - } else { - prevMappingNamesRef.current = currentMappingNames + prevNames.forEach((oldName, index) => { + const newName = currentMappingNames[index] + if (newName && oldName !== newName) { + updatedTemplate = updatedTemplate.replace( + new RegExp(`{{\\s*${oldName}\\s*}}`, 'g'), + `{{${newName}}}` + ) } - }, 0) + }) + + + if (updatedTemplate !== form.getFieldValue('template')) { + isSyncingRef.current = true + lastSyncSourceRef.current = 'mapping' + + prevTemplateVarsRef.current = extractTemplateVars(updatedTemplate) + prevMappingNamesRef.current = currentMappingNames + form.setFieldValue('template', updatedTemplate) + editorKeyRef.current++ + + setTimeout(() => { + isSyncingRef.current = false + lastSyncSourceRef.current = null + }, 0) + } else { + prevMappingNamesRef.current = currentMappingNames + } }, [values?.mapping, selectedNode?.data?.type, form]) // Sync mapping when template variables change useEffect(() => { - console.log('values?.template', values?.template) if ( isSyncingRef.current || lastSyncSourceRef.current === 'template' || @@ -155,11 +146,10 @@ const JinjaRender: FC = ({ selectedNode, options, templateOpti } }) - // Remove unused mappings and duplicates + // Remove duplicates only const seenNames = new Set() const finalMapping = updatedMapping.filter(item => { - const isUsed = templateVars.some(v => item.name === v || item.value === `{{${v}}}`) - if (!isUsed || !item.name || seenNames.has(item.name)) return false + if (!item.name || seenNames.has(item.name)) return false seenNames.add(item.name) return true }) @@ -190,6 +180,7 @@ const JinjaRender: FC = ({ selectedNode, options, templateOpti Date: Thu, 22 Jan 2026 15:45:10 +0800 Subject: [PATCH 8/9] fix(web): no workspace_id user jump url update --- web/src/App.tsx | 4 ++++ web/src/store/user.ts | 20 +++++++++++++++++++- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/web/src/App.tsx b/web/src/App.tsx index 1abbc2cc..032338a3 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -28,15 +28,19 @@ import 'dayjs/locale/zh-cn' import 'dayjs/plugin/timezone' import 'dayjs/plugin/utc' import { cookieUtils } from './utils/request'; +import { useUser } from '@/store/user'; function App() { const { t } = useTranslation(); const { locale, language, timeZone } = useI18n() + const { checkJump } = useUser(); useEffect(() => { const authToken = cookieUtils.get('authToken') if (!authToken && !window.location.hash.includes('#/login') && !window.location.hash.includes('#/conversation/')) { window.location.href = `/#/login`; + } else { + checkJump() } }, []) diff --git a/web/src/store/user.ts b/web/src/store/user.ts index 28809e79..75d10812 100644 --- a/web/src/store/user.ts +++ b/web/src/store/user.ts @@ -21,8 +21,15 @@ export interface UserState { clearUserInfo: () => void; logout: () => void; getStorageType: () => void; + checkJump: () => void; } +export const whitePage = [ + '/conversation', + '/login', + '/invite-register' +] + export const useUser = create((set, get) => ({ user: localStorage.getItem('user') ? JSON.parse(localStorage.getItem('user') || '{}') as User : {} as User, loginInfo: {} as LoginInfo, @@ -36,8 +43,10 @@ export const useUser = create((set, get) => ({ if (!cookieUtils.get('authToken')) { return } + const { checkJump } = get() const localUser = JSON.parse(localStorage.getItem('user') || '{}') as User; if (localUser.id) { + checkJump() return } getUsers() @@ -87,5 +96,14 @@ export const useUser = create((set, get) => ({ .catch(() => { console.error('Failed to load storage type'); }) - } + }, + checkJump: () => { + const localUser = JSON.parse(localStorage.getItem('user') || '{}') as User; + const hash = window.location.hash; + + if (localUser.id && (!localUser.current_workspace_id || localUser.current_workspace_id === '') && !whitePage.find(vo => hash.includes(vo))) { + console.log('whitePage', whitePage.find(vo => hash.includes(vo))) + window.location.href = '/#/index' + } + }, })) \ No newline at end of file From acecdcc0415b6892e7e84e2bf37c68dfdce71d74 Mon Sep 17 00:00:00 2001 From: yujiangping Date: Thu, 22 Jan 2026 16:39:27 +0800 Subject: [PATCH 9/9] feat(knowledgeBase): enhance dataset creation with progress tracking and model defaults - Add Progress component import to display file upload progress in real-time - Implement progress bar rendering for files with 0-1 progress values (processing state) - Refactor progress column logic to handle three states: completed (1), processing (0-1), and pending (0) - Add automatic default model selection for each type when creating new knowledge base - Improve file removal handling with better error messages and conditional server deletion - Add console logging for upload cancellation and file deletion operations - Remove loading state from primary button to prevent UI conflicts - Comment out Spin wrapper on step 2 to allow better progress visibility - Update Chinese translation for total_running_apps label for clarity - Enhance error handling with i18n support for deletion failures --- web/src/i18n/zh.ts | 2 +- .../[knowledgeBaseId]/CreateDataset.tsx | 56 ++++++++++++++----- .../KnowledgeBase/components/CreateModal.tsx | 20 +++++++ 3 files changed, 62 insertions(+), 16 deletions(-) diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index 028202d1..9320ec99 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -716,7 +716,7 @@ export const zh = { total_models: '可用模型总数', total_spaces: '活跃空间数量', total_users: '用户总数', - total_running_apps: '应用运行次数', + total_running_apps: '正在运行的应用', desc_models: '包含 {{ account }} 个大语言模型和 {{ nums }} 个嵌入模型', desc_spaces: '多于上周', desc_users: '本周新增', diff --git a/web/src/views/KnowledgeBase/[knowledgeBaseId]/CreateDataset.tsx b/web/src/views/KnowledgeBase/[knowledgeBaseId]/CreateDataset.tsx index b8f7daba..117689c1 100644 --- a/web/src/views/KnowledgeBase/[knowledgeBaseId]/CreateDataset.tsx +++ b/web/src/views/KnowledgeBase/[knowledgeBaseId]/CreateDataset.tsx @@ -1,5 +1,5 @@ import { useMemo,useRef, useState, useEffect } from 'react'; -import { Button, Flex, Radio, Steps, Modal, Input, Spin, message, Checkbox, Select, Form} from 'antd'; +import { Button, Flex, Radio, Steps, Modal, Input, Spin, message, Checkbox, Select, Form, Progress} from 'antd'; import { useTranslation } from 'react-i18next'; import { useLocation, useNavigate, useParams } from 'react-router-dom'; import Table, { type TableRef } from '@/components/Table' @@ -261,12 +261,36 @@ const CreateDataset = () => { dataIndex: 'progress', key: 'progress', render: (value: number, record: any) => { - return ( - - - {value === 1 ? t('knowledgeBase.completed') : value === 0 ? t('knowledgeBase.pending') : t('knowledgeBase.processing')} - - ); + // value = 1 时完成,0~1 时显示进度条 + if (value === 1) { + return ( + + + {t('knowledgeBase.completed')} + + ); + } else if (value > 0 && value < 1) { + // 处理中,显示进度条 + return ( +
+ +
+ ); + } else { + // value = 0 或其他情况,显示待处理 + return ( + + + {t('knowledgeBase.pending')} + + ); + } } }, { @@ -553,21 +577,24 @@ const CreateDataset = () => { if (abortController) { abortController.abort(); abortControllersRef.current.delete(fileUid); - + console.log('已取消上传:', (file as any).name); } - console.log('文件移除前:', uploadRef.current?.fileList); - // 如果文件已经上传成功,删除服务器上的文件并从rechunkFileIds中移除对应的ID + + // 只有当文件已经上传成功(有response.id)时,才删除服务器上的文件 if (file.response?.id) { try { await deleteDocument(file.response.id); setRechunkFileIds(prev => prev.filter(id => id !== file.response.id)); + console.log('已删除服务器文件:', file.response.id); } catch (error) { console.error('删除文件失败:', error); - messageApi.error('删除文件失败'); + messageApi.error(t('common.deleteFailed') || '删除文件失败'); + return false; // 删除失败时不移除文件 } } - return true; // 允许移除文件 + // 允许移除文件(无论是取消上传还是删除成功) + return true; }} /> )} {source && source === 'link' && ( @@ -776,7 +803,7 @@ const CreateDataset = () => { )} */} {current === 2 && ( - + //
{rechunkFileIds.length > 0 ? ( { /> )} - + // )}
@@ -810,7 +837,6 @@ const CreateDataset = () => { type='primary' onClick={current === 2 ? handleStartUpload : handleNext} disabled={pollingLoading || (current === 0 && rechunkFileIds.length === 0)} - loading={pollingLoading} > {current === 2 ? t('knowledgeBase.startUploading') || 'Start Upload' : t('common.next') || 'Next'} diff --git a/web/src/views/KnowledgeBase/components/CreateModal.tsx b/web/src/views/KnowledgeBase/components/CreateModal.tsx index ce228fa4..f0a0b50c 100644 --- a/web/src/views/KnowledgeBase/components/CreateModal.tsx +++ b/web/src/views/KnowledgeBase/components/CreateModal.tsx @@ -164,6 +164,26 @@ const CreateModal = forwardRef(({ }); setModelOptionsByType(next); + + // 如果不是编辑模式,为每个类型的下拉框设置默认值为第一条数据 + if (!datasets?.id) { + const defaultValues: Record = {}; + types.forEach((tp) => { + const fieldKey = typeToFieldKey(tp); + const options = tp.toLowerCase() === 'llm' + ? [...(next['llm'] || []), ...(next['chat'] || [])] + : next[tp] || []; + + // 如果有选项且当前字段没有值,设置第一个选项为默认值 + if (options.length > 0 && !form.getFieldValue(fieldKey)) { + defaultValues[fieldKey] = options[0].value; + } + }); + + if (Object.keys(defaultValues).length > 0) { + form.setFieldsValue(defaultValues as Partial); + } + } }; const setBaseFields = (record: KnowledgeBaseListItem | null, type?: string) => {