From a52e61fb87b1795b506f87284383e3be4f7df4b4 Mon Sep 17 00:00:00 2001 From: yujiangping Date: Mon, 12 Jan 2026 17:51:45 +0800 Subject: [PATCH 01/27] style(homepage): remove fixed height constraint from guide card - Remove `rb:h-[204px]` class from GuideCard container div - Allow guide card to adapt height based on content - Maintain responsive width and padding styling - Improves layout flexibility for different screen sizes --- web/src/views/Index/components/GuideCard.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/views/Index/components/GuideCard.tsx b/web/src/views/Index/components/GuideCard.tsx index a30626f4..d60eae36 100644 --- a/web/src/views/Index/components/GuideCard.tsx +++ b/web/src/views/Index/components/GuideCard.tsx @@ -59,7 +59,7 @@ const GuideCard: React.FC = () => { return ( <> -
+
{ t('index.getStarted')}
From bca4b2245368e4f234e9685a5a3c271e5f44e28a Mon Sep 17 00:00:00 2001 From: zhaoying Date: Tue, 13 Jan 2026 10:19:04 +0800 Subject: [PATCH 02/27] fix(web): workflow's chat --- web/src/components/Chat/ChatContent.tsx | 4 +- web/src/utils/auth.ts | 4 +- web/src/utils/stream.ts | 96 ++++++++++++------- .../views/Workflow/components/Chat/Chat.tsx | 10 +- 4 files changed, 74 insertions(+), 40 deletions(-) diff --git a/web/src/components/Chat/ChatContent.tsx b/web/src/components/Chat/ChatContent.tsx index 2067f57e..11ccb5c3 100644 --- a/web/src/components/Chat/ChatContent.tsx +++ b/web/src/components/Chat/ChatContent.tsx @@ -2,7 +2,7 @@ * @Author: ZhaoYing * @Date: 2025-12-10 16:46:17 * @Last Modified by: ZhaoYing - * @Last Modified time: 2025-12-11 13:40:18 + * @Last Modified time: 2026-01-12 20:41:27 */ import { type FC, useRef, useEffect } from 'react' import clsx from 'clsx' @@ -55,7 +55,7 @@ const ChatContent: FC = ({
} {/* 消息气泡框 */} -
{ console.log("Clearing auth data and redirecting to login"); - sessionStorage.clear(); - localStorage.clear() + // sessionStorage.clear(); + // localStorage.clear() cookieUtils.clear(); } \ No newline at end of file diff --git a/web/src/utils/stream.ts b/web/src/utils/stream.ts index abaaca2d..7688cdd5 100644 --- a/web/src/utils/stream.ts +++ b/web/src/utils/stream.ts @@ -12,43 +12,57 @@ export function parseSSEToJSON(sseString: string) { const lines = sseString.trim().split('\n') let currentEvent: SSEMessage = {} + let dataContent = '' + for (const line of lines) { + if (line.startsWith('event:')) { + if (currentEvent.event && dataContent) { + currentEvent.data = parseDataContent(dataContent) + events.push(currentEvent) + } + currentEvent = { event: line.substring(6).trim() } + dataContent = '' + } else if (line.startsWith('data:')) { + if (dataContent) dataContent += '\n' + dataContent += line.substring(5).trim() + } + } + + + if (currentEvent.event && dataContent) { + currentEvent.data = parseDataContent(dataContent) + console.log('currentEvent', currentEvent) + events.push(currentEvent) + } + + return events +} + +function parseDataContent(dataContent: string): string | object { try { - for (const line of lines) { - if (line.startsWith('event:')) { - if (Object.keys(currentEvent).length > 0) { - events.push(currentEvent) - currentEvent = {} - } - currentEvent.event = line.substring(6).trim() - } else if (line.startsWith('data:')) { - const dataStr = line.substring(5).trim() - if (dataStr) { - try { - // 尝试解析为 JSON - currentEvent.data = JSON.parse(dataStr) - } catch { - // JSON 解析失败时,检查是否是被转义的 JSON 字符串 - try { - const unescaped = dataStr.replace(/"/g, '"').replace(/&/g, '&') - currentEvent.data = JSON.parse(unescaped) - } catch { - // 如果仍然失败,保存为原始字符串 - currentEvent.data = dataStr - } - } - } + // 第一层解码:HTML实体 + let unescaped = dataContent + .replace(/"/g, '"') + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/'/g, "'") + + // 解析第一层JSON + const firstParse = JSON.parse(unescaped) + + // 如果data字段是字符串且包含JSON,解析data层但保持chunk为字符串 + if (firstParse.data && typeof firstParse.data === 'string' && firstParse.data.includes("{")) { + try { + firstParse.data = JSON.parse(firstParse.data) + } catch { + // 保持原字符串 } } - if (Object.keys(currentEvent).length > 0) { - events.push(currentEvent) - } - - return events - } catch (error) { - console.error('Parse stream error:', error) - return [] + return firstParse + } catch { + return dataContent } } @@ -80,16 +94,30 @@ export const handleSSE = async (url: string, data: any, onMessage?: (data: SSEMe const reader = response.body.getReader(); const decoder = new TextDecoder(); + let buffer = ''; // 添加缓冲区来处理不完整的消息 while (true) { const { done, value } = await reader.read(); if (done) break; const chunk = decoder.decode(value, { stream: true }); - if (onMessage) { - onMessage(parseSSEToJSON(chunk) ?? {}); + buffer += chunk; + + // 处理完整的事件 + const events = buffer.split('\n\n'); + buffer = events.pop() || ''; // 保留最后一个可能不完整的事件 + + for (const event of events) { + if (event.trim() && onMessage) { + onMessage(parseSSEToJSON(event) ?? {}); + } } } + + // 处理剩余的缓冲区内容 + if (buffer.trim() && onMessage) { + onMessage(parseSSEToJSON(buffer) ?? {}); + } break; } } catch (error) { diff --git a/web/src/views/Workflow/components/Chat/Chat.tsx b/web/src/views/Workflow/components/Chat/Chat.tsx index 4824cc9c..0673389e 100644 --- a/web/src/views/Workflow/components/Chat/Chat.tsx +++ b/web/src/views/Workflow/components/Chat/Chat.tsx @@ -26,6 +26,7 @@ const Chat = forwardRef(({ appId const [chatList, setChatList] = useState([]) const [variables, setVariables] = useState([]) const [streamLoading, setStreamLoading] = useState(false) + const [conversationId, setConversationId] = useState(null) const handleOpen = () => { setOpen(true) @@ -100,7 +101,7 @@ const Chat = forwardRef(({ appId setStreamLoading(false) data.forEach(item => { - const { chunk } = item.data as { chunk: string; }; + const { chunk, conversation_id } = item.data as { chunk: string; conversation_id: string | null; }; switch(item.event) { case 'message': @@ -131,6 +132,10 @@ const Chat = forwardRef(({ appId setStreamLoading(false) break } + + if (conversation_id && conversationId !== conversation_id) { + setConversationId(conversation_id) + } }) } @@ -138,7 +143,8 @@ const Chat = forwardRef(({ appId draftRun(appId, { message: message, variables: params, - stream: true + stream: true, + conversation_id: conversationId }, handleStreamMessage) .finally(() => { setLoading(false) From c01bddf5beaf4eb494089c54edb513bb7cd71bfa Mon Sep 17 00:00:00 2001 From: zhaoying Date: Tue, 13 Jan 2026 10:25:17 +0800 Subject: [PATCH 03/27] feat(web): user memory updated --- web/src/api/memory.ts | 5 +- web/src/assets/images/logout_hover.svg | 17 +++ web/src/i18n/en.ts | 5 + web/src/i18n/zh.ts | 5 + .../components/EmotionLine.tsx | 14 ++- .../components/ForgetRefreshModal.tsx | 113 ++++++++++++++++++ .../components/InteractionBar.tsx | 17 +-- .../components/PageHeader.tsx | 21 ++-- .../UserMemoryDetail/components/Timeline.tsx | 15 ++- .../UserMemoryDetail/pages/ForgetDetail.tsx | 33 ++++- .../views/UserMemoryDetail/pages/index.tsx | 21 +++- 11 files changed, 229 insertions(+), 37 deletions(-) create mode 100644 web/src/assets/images/logout_hover.svg create mode 100644 web/src/views/UserMemoryDetail/components/ForgetRefreshModal.tsx diff --git a/web/src/api/memory.ts b/web/src/api/memory.ts index 47177136..3c0fe6fa 100644 --- a/web/src/api/memory.ts +++ b/web/src/api/memory.ts @@ -204,8 +204,9 @@ export const getConversationMessages = (end_user: string, conversation_id: strin export const getConversationDetail = (end_user: string, conversation_id: string) => { return request.get(`/memory/work/${end_user}/detail`, { conversation_id }) } - - +export const forgetTrigger = (data: { max_merge_batch_size: number; min_days_since_access: number; end_user_id: string;}) => { + return request.post(`/memory/forget/trigger`, data) +} /*************** end 用户记忆 相关接口 ******************************/ /****************** 记忆管理 相关接口 *******************************/ diff --git a/web/src/assets/images/logout_hover.svg b/web/src/assets/images/logout_hover.svg new file mode 100644 index 00000000..d77ab292 --- /dev/null +++ b/web/src/assets/images/logout_hover.svg @@ -0,0 +1,17 @@ + + + 退出 + + + + + + + + + + + + + + \ No newline at end of file diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index 1f4871d0..05d3d879 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -1244,6 +1244,10 @@ export const en = { MemorySummary: 'Long-term Accumulation', Statement: 'Emotional Memory', ExtractedEntity: 'Episodic Memory', + positive: 'Positive Emotion', + negative: 'Negative Emotion', + neutral: 'Neutral Emotion', + interactionCountData: 'Interaction Count', }, space: { createSpace: 'Create Space', @@ -2203,6 +2207,7 @@ Memory Bear: After the rebellion, regional warlordism intensified for several re node_type: 'Node Type', last_access_time: 'Last Activation Time', activation_value: 'Current Activation Value', + refreshSuccess: 'Forgetting Execution Successful', }, episodicDetail: { title: 'Record every important scene you have truly experienced', diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index 32188ede..b065a19a 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -1324,6 +1324,10 @@ export const zh = { MemorySummary: '长期沉淀', Statement: '情绪记忆', ExtractedEntity: '情景记忆', + positive: '正向情绪', + negative: '负向情绪', + neutral: '中性情绪', + interactionCountData: '互动次数', }, space: { createSpace: '创建空间', @@ -2302,6 +2306,7 @@ export const zh = { node_type: '节点类型', last_access_time: '最后激活时间', activation_value: '当前激活值', + refreshSuccess: '遗忘执行成功', }, episodicDetail: { title: '记录你真实经历过的每一个重要场景', diff --git a/web/src/views/UserMemoryDetail/components/EmotionLine.tsx b/web/src/views/UserMemoryDetail/components/EmotionLine.tsx index 3652e7c5..c62fbfb9 100644 --- a/web/src/views/UserMemoryDetail/components/EmotionLine.tsx +++ b/web/src/views/UserMemoryDetail/components/EmotionLine.tsx @@ -4,6 +4,7 @@ import ReactEcharts from 'echarts-for-react'; import Empty from '@/components/Empty' import Loading from '@/components/Empty/Loading' import type { Emotion } from './GraphDetail' +import { format } from 'echarts'; interface EmotionLineProps { chartData: Emotion[]; @@ -26,7 +27,7 @@ const EmotionLine: FC = ({ chartData, loading }) => { const seriesData = timePoints.map(time => dataMap.get(time) || 0) return { - name: emotionType, + name: t(`userMemory.${emotionType}`), type: 'line', smooth: true, lineStyle: { @@ -71,7 +72,7 @@ const EmotionLine: FC = ({ chartData, loading }) => { formatter: function(params: any) { let result = `${params[0].axisValue}
` params.forEach((param: any) => { - result += `${param.marker}${param.seriesName}: ${param.value}
` + result += `${param.marker}${param.seriesName}: ${param.value}%
` }) return result } @@ -92,7 +93,7 @@ const EmotionLine: FC = ({ chartData, loading }) => { }, grid: { top: 16, - left: 30, + left: 40, right: 36, bottom: 48, // containLabel: false @@ -103,7 +104,7 @@ const EmotionLine: FC = ({ chartData, loading }) => { boundaryGap: false, axisLabel: { color: '#A8A9AA', - fontFamily: 'PingFangSC, PingFang SC' + fontFamily: 'PingFangSC, PingFang SC', }, axisLine: { show: true, @@ -130,7 +131,8 @@ const EmotionLine: FC = ({ chartData, loading }) => { type: 'value', axisLabel: { color: '#A8A9AA', - fontFamily: 'PingFangSC, PingFang SC' + fontFamily: 'PingFangSC, PingFang SC', + formatter: '{value}%' }, axisLine: { show: true, @@ -152,7 +154,7 @@ const EmotionLine: FC = ({ chartData, loading }) => { type: 'solid' } }, - max: 1, + max: 100, min: 0 }, series: getSeries() diff --git a/web/src/views/UserMemoryDetail/components/ForgetRefreshModal.tsx b/web/src/views/UserMemoryDetail/components/ForgetRefreshModal.tsx new file mode 100644 index 00000000..1d0974e3 --- /dev/null +++ b/web/src/views/UserMemoryDetail/components/ForgetRefreshModal.tsx @@ -0,0 +1,113 @@ +import { forwardRef, useImperativeHandle, useState } from 'react'; +import { useParams } from 'react-router-dom' +import { Form, Slider } from 'antd'; +import { useTranslation } from 'react-i18next'; + +import RbModal from '@/components/RbModal' +import { forgetTrigger } from '@/api/memory' +import type { ForgetRefreshModalRef } from '../pages/ForgetDetail' + +interface ForgetRefreshModalProps { + refresh: (flag: boolean) => void; +} + +const ForgetRefreshModal = forwardRef(({ + refresh +}, ref) => { + const { t } = useTranslation(); + const { id } = useParams() + const [visible, setVisible] = useState(false); + const [form] = Form.useForm<{ max_merge_batch_size: number; min_days_since_access: number; }>(); + const [loading, setLoading] = useState(false) + const values = Form.useWatch([], form); + + // 封装取消方法,添加关闭弹窗逻辑 + const handleClose = () => { + setVisible(false); + form.resetFields(); + setLoading(false) + }; + + const handleOpen = () => { + form.resetFields(); + setVisible(true); + }; + // 封装保存方法,添加提交逻辑 + const handleSave = () => { + if(!id) return + form + .validateFields() + .then((values) => { + setLoading(true) + forgetTrigger({ + ...values, + end_user_id: id + }) + .then(() => { + refresh(true) + handleClose() + }) + .finally(() => { + setLoading(false) + }) + }) + .catch((err) => { + console.log('err', err) + }); + } + + // 暴露给父组件的方法 + useImperativeHandle(ref, () => ({ + handleOpen, + handleClose + })); + + return ( + +
+
+
+ {t(`forgettingEngine.max_merge_batch_size`)} +
+ + + + +
+ {t(`forgettingEngine.range`)}: {[1, 1000]?.join('-')} + {t('forgettingEngine.CurrentValue')}: {values?.min_days_since_access || 0} +
+
+
+
+ {t(`forgettingEngine.min_days_since_access`)} +
+ + + + +
+ {t(`forgettingEngine.range`)}: {[1, 365]?.join('-')} + {t('forgettingEngine.CurrentValue')}: {values?.min_days_since_access || 0} +
+
+
+
+ ); +}); + +export default ForgetRefreshModal; \ No newline at end of file diff --git a/web/src/views/UserMemoryDetail/components/InteractionBar.tsx b/web/src/views/UserMemoryDetail/components/InteractionBar.tsx index 51224676..0db33b6f 100644 --- a/web/src/views/UserMemoryDetail/components/InteractionBar.tsx +++ b/web/src/views/UserMemoryDetail/components/InteractionBar.tsx @@ -1,4 +1,4 @@ -import { type FC } from 'react' +import { type FC, useMemo } from 'react' import { useTranslation } from 'react-i18next' import ReactEcharts from 'echarts-for-react' import Empty from '@/components/Empty' @@ -14,11 +14,13 @@ const Colors = ['#155EEF', '#369F21', '#FF5D34'] const InteractionBar: FC = ({ chartData, loading }) => { const { t } = useTranslation() - const series = [{ - name: 'Interaction Count', - type: 'bar', - data: chartData.map(item => item.count) - }] + const series = useMemo(() => { + return [{ + name: t('userMemory.interactionCountData'), + type: 'bar', + data: chartData.map(item => item.count) + }] + }, [chartData, t]) return ( <> @@ -80,6 +82,7 @@ const InteractionBar: FC = ({ chartData, loading }) => { }, yAxis: { type: 'value', + minInterval: 1, axisLabel: { color: '#A8A9AA', fontFamily: 'PingFangSC, PingFang SC' @@ -104,8 +107,6 @@ const InteractionBar: FC = ({ chartData, loading }) => { type: 'solid' } }, - max: 1, - min: 0 }, series }} diff --git a/web/src/views/UserMemoryDetail/components/PageHeader.tsx b/web/src/views/UserMemoryDetail/components/PageHeader.tsx index 56da70e0..68cdada8 100644 --- a/web/src/views/UserMemoryDetail/components/PageHeader.tsx +++ b/web/src/views/UserMemoryDetail/components/PageHeader.tsx @@ -1,20 +1,22 @@ import { type FC, type ReactNode } from 'react'; import { useNavigate } from 'react-router-dom'; -import { Layout } from 'antd'; +import { Layout, Space, Button } from 'antd'; import { useTranslation } from 'react-i18next'; -import logoutIcon from '@/assets/images/logout.svg' +import logoutIcon from '@/assets/images/logout_hover.svg' const { Header } = Layout; interface ConfigHeaderProps { name?: string; operation?: ReactNode; - source?: 'detail' | 'node' + source?: 'detail' | 'node'; + extra?: ReactNode; } const PageHeader: FC = ({ name, operation, - source = 'detail' + source = 'detail', + extra }) => { const { t } = useTranslation(); const navigate = useNavigate(); @@ -33,10 +35,13 @@ const PageHeader: FC = ({ {operation}
-
- - {t('common.return')} -
+ + + {extra} + ); }; diff --git a/web/src/views/UserMemoryDetail/components/Timeline.tsx b/web/src/views/UserMemoryDetail/components/Timeline.tsx index d7b9b273..e2d9446f 100644 --- a/web/src/views/UserMemoryDetail/components/Timeline.tsx +++ b/web/src/views/UserMemoryDetail/components/Timeline.tsx @@ -9,6 +9,7 @@ import { } from '@/api/memory' import { formatDateTime } from '@/utils/format'; import Empty from '@/components/Empty' +import Tag from '@/components/Tag' interface TimelineItem { id: string; @@ -18,6 +19,9 @@ interface TimelineItem { summary: string; storage_type: number; created_time: string | number; + domain: string; + topic: string; + keywords: string[] } const KEYS = { @@ -68,9 +72,14 @@ const Timeline: FC = () => { {formatDateTime(vo.created_time)} {index !== data.length - 1 && }
-
-
{vo.summary}
-
{t(`perceptualDetail.${perceptual_type[vo.perceptual_type]}`)}
+
+
+
{vo.summary}
+
{t(`perceptualDetail.${perceptual_type[vo.perceptual_type]}`)}
+
+
{[vo.domain, vo.topic].join(' | ')}
+ + {vo.keywords.map(tag => {tag})}
))} diff --git a/web/src/views/UserMemoryDetail/pages/ForgetDetail.tsx b/web/src/views/UserMemoryDetail/pages/ForgetDetail.tsx index 602dbf25..9a19f055 100644 --- a/web/src/views/UserMemoryDetail/pages/ForgetDetail.tsx +++ b/web/src/views/UserMemoryDetail/pages/ForgetDetail.tsx @@ -1,7 +1,7 @@ -import { type FC, useEffect, useState, useMemo } from 'react' +import { useEffect, useState, useMemo, forwardRef, useImperativeHandle, useRef } from 'react' import { useTranslation } from 'react-i18next' import { useParams } from 'react-router-dom' -import { Row, Col, Progress } from 'antd' +import { Row, Col, Progress, App } from 'antd' import RbCard from '@/components/RbCard/Card' import { getForgetStats, @@ -12,6 +12,7 @@ import RecentTrendsLineCard from '../components/RecentTrendsLineCard' import Table from '@/components/Table' import { formatDateTime } from '@/utils/format' import StatusTag from '@/components/StatusTag' +import ForgetRefreshModal from '../components/ForgetRefreshModal' const statusTagColors: Record = { statement: 'success', @@ -20,24 +21,33 @@ const statusTagColors: Record { +export interface ForgetRefreshModalRef { + handleOpen: () => void; +} + +const ForgetDetail = forwardRef((_props, ref) => { const { t } = useTranslation() const { id } = useParams() + const { message } = App.useApp() const [loading, setLoading] = useState(false) const [data, setData] = useState({} as ForgetData) + const forgetRefreshModalRef = useRef(null) useEffect(() => { if (!id) return getData() }, [id]) - const getData = () => { + const getData = (flag: boolean = false) => { if (!id) return setLoading(true) getForgetStats(id).then((res) => { const response = res as ForgetData setData(response) setLoading(false) + if (flag) { + message.success(t('forgetDetail.refreshSuccess')) + } }) .finally(() => { setLoading(false) @@ -67,6 +77,14 @@ const ForgetDetail: FC = () => { } }, [data.recent_trends]) + const handleRefresh = () => { + forgetRefreshModalRef.current?.handleOpen() + } + + useImperativeHandle(ref, () => ({ + handleRefresh + })); + return (
{t('forgetDetail.title')}
@@ -152,7 +170,12 @@ const ForgetDetail: FC = () => { ]} pagination={false} /> + +
) -} +}) export default ForgetDetail \ No newline at end of file diff --git a/web/src/views/UserMemoryDetail/pages/index.tsx b/web/src/views/UserMemoryDetail/pages/index.tsx index d29a8fad..8f5ee146 100644 --- a/web/src/views/UserMemoryDetail/pages/index.tsx +++ b/web/src/views/UserMemoryDetail/pages/index.tsx @@ -1,7 +1,7 @@ -import { type FC, useEffect, useState, useMemo } from 'react' +import { type FC, useEffect, useState, useMemo, useRef } from 'react' import { useParams, useNavigate } from 'react-router-dom' import { useTranslation } from 'react-i18next' -import { Dropdown } from 'antd' +import { Dropdown, Space, Button } from 'antd' import PageHeader from '../components/PageHeader' import StatementDetail from './StatementDetail' @@ -15,12 +15,15 @@ import WorkingDetail from './WorkingDetail' import { getEndUserProfile, } from '@/api/memory' +import refreshIcon from '@/assets/images/refresh_hover.svg' const Detail: FC = () => { const { t } = useTranslation() const { id, type } = useParams() const navigate = useNavigate() const [name, setName] = useState('') + const forgetDetailRef = useRef<{ handleRefresh: () => void }>(null) + useEffect(() => { if (!id) return getData() @@ -40,6 +43,9 @@ const Detail: FC = () => { const onClick = ({ key }: { key: string }) => { navigate(`/user-memory/detail/${id}/${key}`, { replace: true }) } + const handleRefresh = () => { + forgetDetailRef.current?.handleRefresh() + } return (
@@ -49,17 +55,22 @@ const Detail: FC = () => { operation={
- - {type ? t(`userMemory.${type}`) : ''} + - {type ? t(`userMemory.${type}`) : ''}
-
+
} + extra={type === 'FORGETTING_MANAGEMENT' && + } />
{type === 'EMOTIONAL_MEMORY' && } - {type === 'FORGETTING_MANAGEMENT' && } + {type === 'FORGETTING_MANAGEMENT' && } {type === 'IMPLICIT_MEMORY' && } {type === 'SHORT_TERM_MEMORY' && } {type === 'PERCEPTUAL_MEMORY' && } From 0433e17b34b618454da491cf5001407c47bc8944 Mon Sep 17 00:00:00 2001 From: zhaoying Date: Tue, 13 Jan 2026 10:59:28 +0800 Subject: [PATCH 04/27] fix(web): update auth --- web/src/utils/auth.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/src/utils/auth.ts b/web/src/utils/auth.ts index 2426c5e8..b7c72527 100644 --- a/web/src/utils/auth.ts +++ b/web/src/utils/auth.ts @@ -1,7 +1,7 @@ import { cookieUtils } from './request' export const clearAuthData = () => { console.log("Clearing auth data and redirecting to login"); - // sessionStorage.clear(); - // localStorage.clear() + sessionStorage.clear(); + localStorage.clear() cookieUtils.clear(); } \ No newline at end of file From 7741cffa03fc1ff19880d74896a3bdf43a2691b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B0=A2=E4=BF=8A=E7=94=B7?= Date: Tue, 13 Jan 2026 11:36:09 +0800 Subject: [PATCH 05/27] feat(workflow node): built-in tools output modifications to adapt to workflow nodes --- .../core/tools/builtin/baidu_search_tool.py | 2 +- api/app/core/tools/builtin/datetime_tool.py | 27 ++++++++++++------- api/app/core/tools/builtin/json_tool.py | 10 ++++--- 3 files changed, 24 insertions(+), 15 deletions(-) diff --git a/api/app/core/tools/builtin/baidu_search_tool.py b/api/app/core/tools/builtin/baidu_search_tool.py index e1f80f34..02431aed 100644 --- a/api/app/core/tools/builtin/baidu_search_tool.py +++ b/api/app/core/tools/builtin/baidu_search_tool.py @@ -110,7 +110,7 @@ class BaiduSearchTool(BuiltinTool): execution_time = time.time() - start_time return ToolResult.success_result( - data=result, + data=result["results"], execution_time=execution_time ) diff --git a/api/app/core/tools/builtin/datetime_tool.py b/api/app/core/tools/builtin/datetime_tool.py index 7b6fa8ef..00004dfe 100644 --- a/api/app/core/tools/builtin/datetime_tool.py +++ b/api/app/core/tools/builtin/datetime_tool.py @@ -95,7 +95,7 @@ class DateTimeTool(BuiltinTool): execution_time = time.time() - start_time return ToolResult.success_result( - data=result, + data=result["result_data"], execution_time=execution_time ) @@ -123,12 +123,14 @@ class DateTimeTool(BuiltinTool): utc_now = datetime.now(timezone.utc) return { - "datetime": now.strftime(output_format), - "timestamp": int(now.timestamp()), "timezone": timezone_str, "iso_format": now.isoformat(), - "timestamp_ms": int(now.timestamp() * 1000), - "utc_datetime": utc_now.strftime(output_format) + "result_data": { + "datetime": now.strftime(output_format), + "timestamp": int(now.timestamp()), + "timestamp_ms": int(now.timestamp() * 1000), + "utc_datetime": utc_now.strftime(output_format), + } } @staticmethod @@ -148,7 +150,8 @@ class DateTimeTool(BuiltinTool): "original": input_value, "formatted": dt.strftime(output_format), "timestamp": int(dt.timestamp()), - "iso_format": dt.isoformat() + "iso_format": dt.isoformat(), + "result_data": dt.strftime(output_format) } @staticmethod @@ -189,7 +192,8 @@ class DateTimeTool(BuiltinTool): "original_timezone": from_timezone, "converted": converted_dt.strftime(output_format), "converted_timezone": to_timezone, - "timestamp": int(converted_dt.timestamp()) + "timestamp": int(converted_dt.timestamp()), + "result_data": converted_dt.strftime(output_format) } @staticmethod @@ -219,7 +223,8 @@ class DateTimeTool(BuiltinTool): "timestamp": timestamp, "datetime": dt.strftime(output_format), "timezone": timezone_str, - "iso_format": dt.isoformat() + "iso_format": dt.isoformat(), + "result_data": dt.strftime(output_format) } @staticmethod @@ -249,7 +254,8 @@ class DateTimeTool(BuiltinTool): "datetime": input_value, "timezone": timezone_str, "timestamp": int(dt.timestamp()), - "iso_format": dt.isoformat() + "iso_format": dt.isoformat(), + "result_data": int(dt.timestamp()) } def _calculate_datetime(self, kwargs) -> dict: @@ -287,7 +293,8 @@ class DateTimeTool(BuiltinTool): "calculation": calculation, "result": calculated_dt.strftime(output_format), "timezone": timezone_str, - "timestamp": int(calculated_dt.timestamp()) + "timestamp": int(calculated_dt.timestamp()), + "result_data": calculated_dt.strftime(output_format) } @staticmethod diff --git a/api/app/core/tools/builtin/json_tool.py b/api/app/core/tools/builtin/json_tool.py index f22e9370..57d3130d 100644 --- a/api/app/core/tools/builtin/json_tool.py +++ b/api/app/core/tools/builtin/json_tool.py @@ -69,7 +69,7 @@ class JsonTool(BuiltinTool): ToolParameter( name="json_path", type=ParameterType.STRING, - description="JSON路径表达式(用于extract、insert、replace、delete、parse操作,如:$.user.name或users[0].name)", + description="JSON路径表达式(用于insert、replace、delete、parse操作,如:$.user.name或users[0].name)", required=False ), ToolParameter( @@ -136,7 +136,7 @@ class JsonTool(BuiltinTool): execution_time = time.time() - start_time return ToolResult.success_result( - data=result, + data=result["result_data"], execution_time=execution_time ) @@ -671,7 +671,8 @@ class JsonTool(BuiltinTool): "success": True, "value": current, "value_type": type(current).__name__, - "value_json": json.dumps(current, indent=2, ensure_ascii=False) if isinstance(current, (dict, list)) else str(current) + "value_json": json.dumps(current, indent=2, ensure_ascii=False) if isinstance(current, (dict, list)) else str(current), + "result_data": json.dumps(current, indent=2, ensure_ascii=False) if isinstance(current, (dict, list)) else str(current) } except (KeyError, IndexError, TypeError) as e: @@ -680,7 +681,8 @@ class JsonTool(BuiltinTool): "json_path": json_path, "success": False, "error": str(e), - "value": None + "value": None, + "result_data": None } def _analyze_json_structure(self, data: Any, depth: int = 0) -> Dict[str, Any]: From e4f8ddca9ac4ed3cb2d3d290cd4dcdba719ca08c Mon Sep 17 00:00:00 2001 From: zhaoying Date: Tue, 13 Jan 2026 11:46:10 +0800 Subject: [PATCH 06/27] fix(web): knowledge_retrieval type node's knowledge_bases --- web/src/views/Workflow/hooks/useWorkflowGraph.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/web/src/views/Workflow/hooks/useWorkflowGraph.ts b/web/src/views/Workflow/hooks/useWorkflowGraph.ts index dfbf2e92..4a336fa5 100644 --- a/web/src/views/Workflow/hooks/useWorkflowGraph.ts +++ b/web/src/views/Workflow/hooks/useWorkflowGraph.ts @@ -954,7 +954,10 @@ export const useWorkflowGraph = ({ itemConfig = { ...itemConfig, ...data.config[key].defaultValue, - knowledge_bases: knowledge_bases?.map((vo: any) => ({ kb_id: vo.id, ...vo.config })) + knowledge_bases: knowledge_bases?.map((vo: any) => { + const kb_config = vo.config || { similarity_threshold: vo.similarity_threshold, strategy: vo.strategy, top_k: vo.top_k, weight: vo.weight } + return { kb_id: vo.kb_id || vo.id, ...kb_config, } + }) } } }) From 49fa6906acbffb91b6baa330dfd7e476e03b18ae Mon Sep 17 00:00:00 2001 From: zhaoying Date: Tue, 13 Jan 2026 12:18:30 +0800 Subject: [PATCH 07/27] fix(web): if-else and question-classifier node support link to same node --- .../views/Workflow/hooks/useWorkflowGraph.ts | 33 ++++++++++++++++--- 1 file changed, 29 insertions(+), 4 deletions(-) diff --git a/web/src/views/Workflow/hooks/useWorkflowGraph.ts b/web/src/views/Workflow/hooks/useWorkflowGraph.ts index 4a336fa5..dc7001e5 100644 --- a/web/src/views/Workflow/hooks/useWorkflowGraph.ts +++ b/web/src/views/Workflow/hooks/useWorkflowGraph.ts @@ -282,9 +282,21 @@ export const useWorkflowGraph = ({ }, 100) } if (edges.length) { - // 去重处理:相同节点之间的连线仅连一次 + // 去重处理:对于if-else和question-classifier节点,不同连接桩允许连接到相同节点 const uniqueEdges = edges.filter((edge, index, arr) => { - return arr.findIndex(e => e.source === edge.source && e.target === edge.target) === index; + return arr.findIndex(e => { + const sourceCell = graphRef.current?.getCellById(e.source); + const sourceType = sourceCell?.getData()?.type; + const isMultiPortNode = sourceType === 'question-classifier' || sourceType === 'if-else'; + + if (isMultiPortNode) { + // 多端口节点需要同时比较source、target和label + return e.source === edge.source && e.target === edge.target && e.label === edge.label; + } else { + // 其他节点只比较source和target + return e.source === edge.source && e.target === edge.target; + } + }) === index; }); const edgeList = uniqueEdges.map(edge => { @@ -1028,8 +1040,21 @@ export const useWorkflowGraph = ({ }) .filter(edge => edge !== null) .filter((edge, index, arr) => { - // 去重:相同节点之间的连线仅保留一次 - return arr.findIndex(e => e && e.source === edge?.source && e.target === edge?.target) === index; + // 去重:对于if-else和question-classifier节点,不同连接桩允许连接到相同节点 + return arr.findIndex(e => { + if (!e || !edge) return false; + const sourceCell = graphRef.current?.getCellById(e.source); + const sourceType = sourceCell?.getData()?.type; + const isMultiPortNode = sourceType === 'question-classifier' || sourceType === 'if-else'; + + if (isMultiPortNode) { + // 多端口节点需要同时比较source、target和label + return e.source === edge.source && e.target === edge.target && e.label === edge.label; + } else { + // 其他节点只比较source和target + return e.source === edge.source && e.target === edge.target; + } + }) === index; }), } saveWorkflowConfig(config.app_id, params as WorkflowConfig) From f5e71f56e9c37070ecae300e62b1e57d24bf6771 Mon Sep 17 00:00:00 2001 From: zhaoying Date: Tue, 13 Jan 2026 13:56:14 +0800 Subject: [PATCH 08/27] fix(web): tool's api response change --- web/src/i18n/en.ts | 17 ++++---- web/src/i18n/zh.ts | 19 ++++----- .../components/JsonToolModal.tsx | 42 +++++++------------ .../components/TimeToolModal.tsx | 7 ++-- web/src/views/ToolManagement/types.ts | 1 + 5 files changed, 39 insertions(+), 47 deletions(-) diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index 05d3d879..40510a8b 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -72,7 +72,7 @@ export const en = { modelManagement: 'Model Management', memoryStore: 'Memory Store', apiParameters: 'API Parameters', - userMemory: 'User Memory', + userMemory: 'Memory Store', memberManagement: 'Member Management', memorySummary: 'Memory Summary', memoryConversation: 'Memory Validation', @@ -1211,6 +1211,8 @@ export const en = { hire_date: 'Hire Date', memoryContent: 'Memory Content', created_at: 'Created At', + updated_at: 'Updated At', + fullScreen: 'Full Screen', memoryWindow: "{{name}}'s Window of Memory", memory_insight: 'Overall Overview', @@ -1237,7 +1239,7 @@ export const en = { unix: 'items', completeMemory: 'Complete Memory', relationshipEvolution: 'Relationship Evolution', - timelineMemories: 'Shared Memory Timeline', + timelineMemories: 'Long-term Memory', emotionLine: 'Emotion Changes Over Time', interaction: 'Interaction Frequency & Relationship Stages', timelines_memory: 'All', @@ -1600,11 +1602,9 @@ Memory Bear: After the rebellion, regional warlordism intensified for several re enterJson: 'Enter JSON', jsonPlaceholder: 'Enter JSON data, e.g.: {"name": "test", "value": 123}', clear: 'Clear', - parse: 'Paste', - format: 'Format', - minify: 'Minify', - validate: 'Validate', - convert: 'Escape', + paste: 'Paste', + parse: 'Parse', + json_path: 'JSON Path Parameters', outputResult: 'Output Result', validJosn: 'JSON format is correct, validation passed!', @@ -1923,7 +1923,8 @@ Memory Bear: After the rebellion, regional warlordism intensified for several re variableConfig: 'Variable Configuration', variableRequired: 'Required', addMessage: 'Add Message', - answerDesc: 'Reply' + answerDesc: 'Reply', + addNode: 'Add Node', }, emotionEngine: { emotionEngineConfig: 'Emotion Engine Configuration', diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index b065a19a..e1d87cf1 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -72,7 +72,7 @@ export const zh = { modelManagement: '模型管理', memoryStore: '记忆存储', apiParameters: 'API参数', - userMemory: '用户记忆', + userMemory: '记忆库', memberManagement: '成员管理', memorySummary: '记忆摘要', memoryConversation: '记忆验证', @@ -87,7 +87,7 @@ export const zh = { knowledgeShare: '详情', knowledgeCreateDataset: '新建数据集', knowledgeDocumentDetails: '详情', - userMemoryDetail: '用户记忆详情', + userMemoryDetail: '记忆库详情', toolManagement: '工具管理', emotionEngine: '情感引擎', statementDetail: '情绪记忆', @@ -1292,7 +1292,7 @@ export const zh = { updated_at: '最后更新时间', fullScreen: '全屏', - memoryWindow: "{{name}}的记忆之窗", + memoryWindow: "{{name}} 的记忆之窗", memory_insight: '总体概述', key_findings: '关键发现', behavior_pattern: '行为模式', @@ -1317,7 +1317,7 @@ export const zh = { unix: '个', completeMemory: '完整记忆', relationshipEvolution: '关系演化', - timelineMemories: '共同记忆时间线', + timelineMemories: '长期记忆', emotionLine: '情绪随时间变化', interaction: '互动频率 & 关系阶段', timelines_memory: '全部', @@ -1698,11 +1698,9 @@ export const zh = { enterJson: '输入JSON', jsonPlaceholder: '输入JSON数据,例如:{"name": "测试", "value": 123}', clear: '清空', - parse: '粘贴', - format: '格式化', - minify: '压缩', - validate: '验证', - convert: '转义', + paste: '粘贴', + parse: '解析', + json_path: 'JSON 路径参数', outputResult: '输出结果', validJosn: 'JSON格式正确,验证通过!', @@ -2022,7 +2020,8 @@ export const zh = { variableConfig: '变量配置', variableRequired: '必填', addMessage: '添加消息', - answerDesc: '回复' + answerDesc: '回复', + addNode: '添加节点', }, emotionEngine: { emotionEngineConfig: '情感引擎配置', diff --git a/web/src/views/ToolManagement/components/JsonToolModal.tsx b/web/src/views/ToolManagement/components/JsonToolModal.tsx index 165cfba8..894f4b54 100644 --- a/web/src/views/ToolManagement/components/JsonToolModal.tsx +++ b/web/src/views/ToolManagement/components/JsonToolModal.tsx @@ -1,5 +1,5 @@ import { forwardRef, useImperativeHandle, useState } from 'react'; -import { Form, Input, Button, Space, Tree } from 'antd'; +import { Form, Input, Button, Space } from 'antd'; import { useTranslation } from 'react-i18next'; import type { TreeDataNode } from 'antd'; @@ -12,7 +12,7 @@ import { execute } from '@/api/tools'; const JsonToolModal = forwardRef((_props, ref) => { const { t } = useTranslation(); const [visible, setVisible] = useState(false); - const [form] = Form.useForm<{ json: string; }>(); + const [form] = Form.useForm<{ json: string; json_path: string; }>(); const [data, setData] = useState({} as ToolItem) const [formatValue, setFormatValue] = useState | null>(null) @@ -60,44 +60,29 @@ const JsonToolModal = forwardRef((_props, ref) => { } const handleOperate = (type: string) => { const json = form.getFieldValue('json') + const json_path = form.getFieldValue('json_path') if (!json || !data.id) return let params: ExecuteData = { tool_id: data.id, parameters: { operation: type, - input_data: json + input_data: json, + json_path } } - if (type === 'format') { + if (type === 'parse') { params = { ...params, parameters: { ...params.parameters, - indent: 2, - ensure_ascii: false, - sort_keys: false } } } execute(params) .then(res => { - const { data } = res as {data: { - formatted_json: string; - minified_json: string; - is_valid: boolean; - converted_json: string; - error: string; - structure: Record - }} - switch (type) { - case 'format': - setFormatValue(data.formatted_json); - break - case 'minify': - setFormatValue(data.minified_json) - break - } + const { data } = res as { data: string; } + setFormatValue(data); }) } const clear = () => { @@ -126,15 +111,20 @@ const JsonToolModal = forwardRef((_props, ref) => { label={ {t('tool.enterJson')} - + } > + + + - - + ((_props, ref) => { const { t } = useTranslation(); @@ -88,8 +89,8 @@ const TimeToolModal = forwardRef((_props, ref) => { } }) .then(res => { - const response = res as { data: CurrentTimeObj } - setTimestampFormat(response.data.datetime) + const response = res as { data: string } + setTimestampFormat(response.data) }) } const handleChangeFormatType = () => { @@ -149,7 +150,7 @@ const TimeToolModal = forwardRef((_props, ref) => { - + diff --git a/web/src/views/ToolManagement/types.ts b/web/src/views/ToolManagement/types.ts index 6fd4e439..aa97db66 100644 --- a/web/src/views/ToolManagement/types.ts +++ b/web/src/views/ToolManagement/types.ts @@ -130,6 +130,7 @@ export interface ExecuteData { ensure_ascii?: boolean; sort_keys?: boolean; input_data?: string; + json_path?: string; } } export interface CustomToolModalRef { From 2f13cb4cbcf04e133bb5e32c7296b8a6a9f8800d Mon Sep 17 00:00:00 2001 From: zhaoying Date: Tue, 13 Jan 2026 14:03:44 +0800 Subject: [PATCH 09/27] =?UTF-8?q?fix(web):=20iteration=20node=E2=80=98s=20?= =?UTF-8?q?variableList=20updated?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/src/views/Workflow/components/Nodes/LoopNode.tsx | 4 ++-- .../views/Workflow/components/Properties/VariableSelect.tsx | 1 + web/src/views/Workflow/components/Properties/index.tsx | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/web/src/views/Workflow/components/Nodes/LoopNode.tsx b/web/src/views/Workflow/components/Nodes/LoopNode.tsx index dac91b68..98ca39d8 100644 --- a/web/src/views/Workflow/components/Nodes/LoopNode.tsx +++ b/web/src/views/Workflow/components/Nodes/LoopNode.tsx @@ -33,7 +33,7 @@ const LoopNode: ReactShapeConfig['component'] = ({ node, graph }) => { y: cycleStartBBox.y, data: { type: 'add-node', - label: '添加节点', + label: t('workflow.addNode'), icon: '+', parentId: node.id, cycle: data.id, @@ -97,7 +97,7 @@ const LoopNode: ReactShapeConfig['component'] = ({ node, graph }) => { y: centerY, data: { type: 'add-node', - label: '添加节点', + label: t('workflow.addNode'), icon: '+', parentId: node.id, cycle: data.id, diff --git a/web/src/views/Workflow/components/Properties/VariableSelect.tsx b/web/src/views/Workflow/components/Properties/VariableSelect.tsx index b92475d7..5f0f1f0b 100644 --- a/web/src/views/Workflow/components/Properties/VariableSelect.tsx +++ b/web/src/views/Workflow/components/Properties/VariableSelect.tsx @@ -91,6 +91,7 @@ const VariableSelect: FC = ({ showSearch allowClear={allowClear} filterOption={(input, option) => { + if (input === '/') return true; if (option?.options) { return option.label?.toLowerCase().includes(input.toLowerCase()) || option.options.some((opt: any) => diff --git a/web/src/views/Workflow/components/Properties/index.tsx b/web/src/views/Workflow/components/Properties/index.tsx index 765fd207..fe9dbf31 100644 --- a/web/src/views/Workflow/components/Properties/index.tsx +++ b/web/src/views/Workflow/components/Properties/index.tsx @@ -1068,7 +1068,7 @@ const Properties: FC = ({ (Array.isArray(config.filterNodeTypes) && config.filterNodeTypes.includes(variable.nodeData?.type)); const variableNameMatch = !config.filterVariableNames || (Array.isArray(config.filterVariableNames) && config.filterVariableNames.includes(variable.label)); - return nodeTypeMatch && variableNameMatch; + return nodeTypeMatch || variableNameMatch; }); } // Filter child nodes for iteration output From 1ebab759b1cab2dcc2c29d04b8e4e7999f019623 Mon Sep 17 00:00:00 2001 From: zhaoying Date: Tue, 13 Jan 2026 14:04:28 +0800 Subject: [PATCH 10/27] feat(web): add graph detail page --- .../components/EmotionLine.tsx | 3 +- .../components/InteractionBar.tsx | 2 +- .../components/RelationshipNetwork.tsx | 22 ++- .../{components => pages}/GraphDetail.tsx | 127 ++++++++++-------- .../views/UserMemoryDetail/pages/index.tsx | 7 +- 5 files changed, 86 insertions(+), 75 deletions(-) rename web/src/views/UserMemoryDetail/{components => pages}/GraphDetail.tsx (50%) diff --git a/web/src/views/UserMemoryDetail/components/EmotionLine.tsx b/web/src/views/UserMemoryDetail/components/EmotionLine.tsx index c62fbfb9..68664d39 100644 --- a/web/src/views/UserMemoryDetail/components/EmotionLine.tsx +++ b/web/src/views/UserMemoryDetail/components/EmotionLine.tsx @@ -3,8 +3,7 @@ import { useTranslation } from 'react-i18next' import ReactEcharts from 'echarts-for-react'; import Empty from '@/components/Empty' import Loading from '@/components/Empty/Loading' -import type { Emotion } from './GraphDetail' -import { format } from 'echarts'; +import type { Emotion } from '../pages/GraphDetail' interface EmotionLineProps { chartData: Emotion[]; diff --git a/web/src/views/UserMemoryDetail/components/InteractionBar.tsx b/web/src/views/UserMemoryDetail/components/InteractionBar.tsx index 0db33b6f..60c977fd 100644 --- a/web/src/views/UserMemoryDetail/components/InteractionBar.tsx +++ b/web/src/views/UserMemoryDetail/components/InteractionBar.tsx @@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next' import ReactEcharts from 'echarts-for-react' import Empty from '@/components/Empty' import Loading from '@/components/Empty/Loading' -import type { Interaction } from './GraphDetail' +import type { Interaction } from '../pages/GraphDetail' interface InteractionBarProps { chartData: Interaction[]; diff --git a/web/src/views/UserMemoryDetail/components/RelationshipNetwork.tsx b/web/src/views/UserMemoryDetail/components/RelationshipNetwork.tsx index 07095fe4..d12c3e57 100644 --- a/web/src/views/UserMemoryDetail/components/RelationshipNetwork.tsx +++ b/web/src/views/UserMemoryDetail/components/RelationshipNetwork.tsx @@ -1,19 +1,18 @@ import React, { type FC, useEffect, useState, useRef, useCallback } from 'react' import { useTranslation } from 'react-i18next' -import { useParams } from 'react-router-dom' +import { useParams, useNavigate } from 'react-router-dom' import { Col, Row, Space, Button } from 'antd' import dayjs from 'dayjs' import RbCard from '@/components/RbCard/Card' import ReactEcharts from 'echarts-for-react' import detailEmpty from '@/assets/images/userMemory/detail_empty.png' -import type { Node, Edge, GraphData, StatementNodeProperties, ExtractedEntityNodeProperties, GraphDetailRef } from '../types' +import type { Node, Edge, GraphData, StatementNodeProperties, ExtractedEntityNodeProperties } from '../types' import { getMemorySearchEdges, } from '@/api/memory' import Empty from '@/components/Empty' import Tag from '@/components/Tag' -import GraphDetail from '../components/GraphDetail' const colors = ['#155EEF', '#369F21', '#4DA8FF', '#FF5D34', '#9C6FFF', '#FF8A4C', '#8BAEF7', '#FFB048'] const RelationshipNetwork:FC = () => { @@ -26,7 +25,7 @@ const RelationshipNetwork:FC = () => { const [categories, setCategories] = useState<{ name: string }[]>([]) const [selectedNode, setSelectedNode] = useState(null) // const [fullScreen, setFullScreen] = useState(false) - const graphDetailRef = useRef(null) + const navigate = useNavigate() console.log('categories', categories) // 关系网络 @@ -133,15 +132,14 @@ const RelationshipNetwork:FC = () => { } }, [nodes]) - // const handleFullScreen = () => { - // setFullScreen(prev => !prev) - // } - - console.log('selectedNode', selectedNode) - const handleViewAll = () => { if (!selectedNode) return - graphDetailRef.current?.handleOpen(selectedNode) + const params = new URLSearchParams({ + nodeId: selectedNode.id, + nodeLabel: selectedNode.label, + nodeName: selectedNode.name || '' + }) + navigate(`/user-memory/detail/${id}/GRAPH?${params.toString()}`) } return ( @@ -336,8 +334,6 @@ const RelationshipNetwork:FC = () => {
- - ) } diff --git a/web/src/views/UserMemoryDetail/components/GraphDetail.tsx b/web/src/views/UserMemoryDetail/pages/GraphDetail.tsx similarity index 50% rename from web/src/views/UserMemoryDetail/components/GraphDetail.tsx rename to web/src/views/UserMemoryDetail/pages/GraphDetail.tsx index aed795f5..72a7b13d 100644 --- a/web/src/views/UserMemoryDetail/components/GraphDetail.tsx +++ b/web/src/views/UserMemoryDetail/pages/GraphDetail.tsx @@ -1,16 +1,17 @@ -import { useState, forwardRef, useImperativeHandle, useMemo } from 'react' +import { useState, forwardRef, useImperativeHandle, useMemo, useEffect } from 'react' import { useTranslation } from 'react-i18next' +import { useSearchParams } from 'react-router-dom' import { Row, Col, Tabs, Space, Skeleton } from 'antd' import { getRelationshipEvolution, getTimelineMemories } from '@/api/memory' import type { Node, GraphDetailRef } from '../types' -import RbDrawer from '@/components/RbDrawer' import RbCard from '@/components/RbCard/Card' -import EmotionLine from './EmotionLine' +import EmotionLine from '../components/EmotionLine' import { formatDateTime } from '@/utils/format' import Tag from '@/components/Tag' -import InteractionBar from './InteractionBar' +import InteractionBar from '../components/InteractionBar' import Empty from '@/components/Empty' +import PageHeader from '../components/PageHeader' export interface Emotion { emotion_intensity: number; @@ -35,7 +36,7 @@ interface Timeline { const GraphDetail = forwardRef((_props, ref) => { const { t } = useTranslation() - const [open, setOpen] = useState(false); + const [searchParams] = useSearchParams() const [vo, setVo] = useState(null) const [loading, setLoading] = useState(false) const [emotionData, setEmotionData] = useState([]) @@ -43,14 +44,23 @@ const GraphDetail = forwardRef((_props, ref) => { const [activeTab, setActiveTab] = useState('timelines_memory') const [timelineLoading, setTimelineLoading] = useState(false) const [timelineMemories, setTimelineMemories] = useState({ timelines_memory: [], MemorySummary: [], Statement: [], ExtractedEntity: []}) + useEffect(() => { + const nodeId = searchParams.get('nodeId') + const nodeLabel = searchParams.get('nodeLabel') + const nodeName = searchParams.get('nodeName') + + if (nodeId && nodeLabel) { + const nodeFromUrl = { + id: nodeId, + label: nodeLabel, + name: nodeName || nodeLabel + } + handleOpen(nodeFromUrl as Node) + } + }, [searchParams]) - const handleCancel = () => { - setVo(null) - setOpen(false) - } const handleOpen = (vo: Node) => { setActiveTab('timelines_memory') - setOpen(true) setVo(vo) getRelationshipEvolutionData(vo) getTimelineMemoriesData(vo) @@ -85,56 +95,57 @@ const GraphDetail = forwardRef((_props, ref) => { }, [activeTab, timelineMemories]) return ( - -
{t('userMemory.relationshipEvolution')}
- - - - - - - - - - + <> + +
+
{t('userMemory.relationshipEvolution')}
+ + + + + + + + + + -
{t('userMemory.timelineMemories')}
- - ({ - label: t(`userMemory.${key}`), - key - }))} - onChange={(key: string) => setActiveTab(key)} - /> - {timelineLoading - ? - : !activeContent || activeContent.length === 0 - ? - : - {activeContent.map((vo, index) => ( - -
{formatDateTime(vo.created_at)}
- {vo.type} -
- ))} -
- } +
{t('userMemory.timelineMemories')}
+ + ({ + label: t(`userMemory.${key}`), + key + }))} + onChange={(key: string) => setActiveTab(key)} + /> + {timelineLoading + ? + : !activeContent || activeContent.length === 0 + ? + : + {activeContent.map((vo, index) => ( + +
{formatDateTime(vo.created_at)}
+ {vo.type} +
+ ))} +
+ } - -
- + +
+
+ ) }) export default GraphDetail \ No newline at end of file diff --git a/web/src/views/UserMemoryDetail/pages/index.tsx b/web/src/views/UserMemoryDetail/pages/index.tsx index 8f5ee146..f5b1a937 100644 --- a/web/src/views/UserMemoryDetail/pages/index.tsx +++ b/web/src/views/UserMemoryDetail/pages/index.tsx @@ -1,7 +1,7 @@ import { type FC, useEffect, useState, useMemo, useRef } from 'react' import { useParams, useNavigate } from 'react-router-dom' import { useTranslation } from 'react-i18next' -import { Dropdown, Space, Button } from 'antd' +import { Dropdown, Button } from 'antd' import PageHeader from '../components/PageHeader' import StatementDetail from './StatementDetail' @@ -16,6 +16,7 @@ import { getEndUserProfile, } from '@/api/memory' import refreshIcon from '@/assets/images/refresh_hover.svg' +import GraphDetail from './GraphDetail' const Detail: FC = () => { const { t } = useTranslation() @@ -47,6 +48,10 @@ const Detail: FC = () => { forgetDetailRef.current?.handleRefresh() } + if (type === 'GRAPH') { + return + } + return (
Date: Tue, 13 Jan 2026 14:55:12 +0800 Subject: [PATCH 11/27] Feature/return memoryconfig (#89) * [add]Newly added: Memory configuration for returning results * [add]Newly added: Memory configuration for returning results * [changes]Based on the improvement of AI review --- .../memory_dashboard_controller.py | 41 ++++- api/app/services/memory_agent_service.py | 159 +++++++++++++++++- 2 files changed, 184 insertions(+), 16 deletions(-) diff --git a/api/app/controllers/memory_dashboard_controller.py b/api/app/controllers/memory_dashboard_controller.py index 5166d012..2afff491 100644 --- a/api/app/controllers/memory_dashboard_controller.py +++ b/api/app/controllers/memory_dashboard_controller.py @@ -1,18 +1,15 @@ from fastapi import APIRouter, Depends, HTTPException, status, Query from sqlalchemy.orm import Session -from typing import List, Optional -import uuid -from app.repositories.end_user_repository import update_end_user_other_name -import uuid +from typing import Optional from app.core.response_utils import success from app.db import get_db from app.dependencies import get_current_user from app.models.user_model import User from app.schemas.memory_agent_schema import End_User_Information from app.schemas.response_schema import ApiResponse -from app.schemas.app_schema import App as AppSchema from app.services import memory_dashboard_service, memory_storage_service, workspace_service +from app.services.memory_agent_service import get_end_users_connected_configs_batch from app.core.logging_config import get_api_logger # 获取API专用日志器 @@ -102,7 +99,8 @@ async def get_workspace_end_users( """ 获取工作空间的宿主列表 - 返回格式与原 memory_list 接口中的 end_users 字段相同 + 返回格式与原 memory_list 接口中的 end_users 字段相同, + 并包含每个用户的记忆配置信息(memory_config_id 和 memory_config_name) """ workspace_id = current_user.current_workspace_id # 获取当前空间类型 @@ -113,6 +111,17 @@ async def get_workspace_end_users( workspace_id=workspace_id, current_user=current_user ) + + # 批量获取所有用户的记忆配置信息(优化:一次查询而非 N 次) + end_user_ids = [str(user.id) for user in end_users] + memory_configs_map = {} + if end_user_ids: + try: + memory_configs_map = get_end_users_connected_configs_batch(end_user_ids, db) + except Exception as e: + api_logger.error(f"批量获取记忆配置失败: {str(e)}") + # 失败时使用空字典,不影响其他数据返回 + result = [] for end_user in end_users: memory_num = {} @@ -123,10 +132,25 @@ async def get_workspace_end_users( memory_num = { "total":memory_dashboard_service.get_current_user_total_chunk(str(end_user.id), db, current_user) } + + # 从批量查询结果中获取配置信息 + user_id = str(end_user.id) + memory_config_info = memory_configs_map.get(user_id, { + "memory_config_id": None, + "memory_config_name": None + }) + + # 只保留需要的字段,移除 error 字段(如果有) + memory_config = { + "memory_config_id": memory_config_info.get("memory_config_id"), + "memory_config_name": memory_config_info.get("memory_config_name") + } + result.append( { - 'end_user':end_user, - 'memory_num':memory_num + 'end_user': end_user, + 'memory_num': memory_num, + 'memory_config': memory_config } ) @@ -465,7 +489,6 @@ async def dashboard_data( if storage_type is None: storage_type = 'neo4j' - user_rag_memory_id = None # 根据 storage_type 决定返回哪个数据对象 # 如果是 'rag',neo4j_data 为 null;否则 rag_data 为 null diff --git a/api/app/services/memory_agent_service.py b/api/app/services/memory_agent_service.py index 71eda535..10f53ed7 100644 --- a/api/app/services/memory_agent_service.py +++ b/api/app/services/memory_agent_service.py @@ -4,7 +4,6 @@ Memory Agent Service Handles business logic for memory agent operations including read/write services, health checks, and message type classification. """ -import datetime import json import os import re @@ -27,7 +26,7 @@ from app.db import get_db_context from app.models.knowledge_model import Knowledge, KnowledgeType from app.repositories.memory_short_repository import ShortTermMemoryRepository from app.repositories.neo4j.neo4j_connector import Neo4jConnector -from app.schemas.memory_config_schema import ConfigurationError, MemoryConfig +from app.schemas.memory_config_schema import ConfigurationError from app.services.memory_config_service import MemoryConfigService from app.services.memory_konwledges_server import ( write_rag, @@ -610,7 +609,7 @@ class MemoryAgentService: reranked_results=raw_results.get('reranked_results',[]) try: statements=[statement['statement'] for statement in reranked_results.get('statements', [])] - except Exception as e: + except Exception: statements=[] statements=list(set(statements)) retrieved_content.append({query:statements}) @@ -832,7 +831,6 @@ class MemoryAgentService: # 获取当前空间下的所有宿主 from app.repositories import app_repository, end_user_repository from app.schemas.app_schema import App as AppSchema - from app.schemas.end_user_schema import EndUser as EndUserSchema # 查询应用并转换为 Pydantic 模型 apps_orm = app_repository.get_apps_by_workspace_id(db, current_workspace_id) @@ -1175,19 +1173,21 @@ def get_end_user_connected_config(end_user_id: str, db: Session) -> Dict[str, An 1. 根据 end_user_id 获取用户的 app_id 2. 获取该应用的最新发布版本 3. 从发布版本的 config 字段中提取 memory_config_id + 4. 根据 memory_config_id 查询配置名称 Args: end_user_id: 终端用户ID db: 数据库会话 Returns: - 包含 memory_config_id 和相关信息的字典 + 包含 memory_config_id、config_name 和相关信息的字典 Raises: ValueError: 当终端用户不存在或应用未发布时 """ from app.models.app_release_model import AppRelease from app.models.end_user_model import EndUser + from app.models.data_config_model import DataConfig from sqlalchemy import select logger.info(f"Getting connected config for end_user: {end_user_id}") @@ -1220,13 +1220,158 @@ def get_end_user_connected_config(end_user_id: str, db: Session) -> Dict[str, An memory_obj = config.get('memory', {}) memory_config_id = memory_obj.get('memory_content') if isinstance(memory_obj, dict) else None + # 4. 根据 memory_config_id 查询配置名称 + config_name = None + if memory_config_id: + try: + # memory_config_id 可能是整数或字符串,需要转换 + config_id = int(memory_config_id) if isinstance(memory_config_id, str) else memory_config_id + data_config = db.query(DataConfig).filter(DataConfig.config_id == config_id).first() + if data_config: + config_name = data_config.config_name + logger.debug(f"Found config_name: {config_name} for config_id: {config_id}") + else: + logger.warning(f"DataConfig not found for config_id: {config_id}") + except (ValueError, TypeError) as e: + logger.warning(f"Invalid memory_config_id format: {memory_config_id}, error: {str(e)}") + result = { "end_user_id": str(end_user_id), "app_id": str(app_id), "release_id": str(latest_release.id), "release_version": latest_release.version, - "memory_config_id": memory_config_id + "memory_config_id": memory_config_id, + "memory_config_name": config_name } - logger.info(f"Successfully retrieved connected config: memory_config_id={memory_config_id}") + logger.info(f"Successfully retrieved connected config: memory_config_id={memory_config_id}, config_name={config_name}") + return result + + +def get_end_users_connected_configs_batch(end_user_ids: List[str], db: Session) -> Dict[str, Dict[str, Any]]: + """ + 批量获取多个终端用户关联的记忆配置 + + 通过优化的查询减少数据库往返次数: + 1. 一次性查询所有 end_user 及其 app_id + 2. 批量查询所有相关的 app_release + 3. 批量查询所有相关的 data_config + + Args: + end_user_ids: 终端用户ID列表 + db: 数据库会话 + + Returns: + 字典,key 为 end_user_id,value 为配置信息字典 + 对于查询失败的用户,value 包含 error 字段 + """ + from app.models.app_release_model import AppRelease + from app.models.end_user_model import EndUser + from app.models.data_config_model import DataConfig + from sqlalchemy import select + + logger.info(f"Batch getting connected configs for {len(end_user_ids)} end users") + + result = {} + + # 1. 批量查询所有 end_user 及其 app_id + end_users = db.query(EndUser).filter(EndUser.id.in_(end_user_ids)).all() + + # 构建 end_user_id -> end_user 的映射 + end_user_map = {str(user.id): user for user in end_users} + + # 记录不存在的用户 + for user_id in end_user_ids: + if user_id not in end_user_map: + result[user_id] = { + "end_user_id": user_id, + "memory_config_id": None, + "memory_config_name": None, + "error": f"终端用户不存在: {user_id}" + } + + if not end_users: + logger.warning("No valid end users found") + return result + + # 2. 批量查询所有相关应用的最新发布版本 + app_ids = [user.app_id for user in end_users] + + # 使用子查询找到每个 app 的最新版本 + from sqlalchemy import and_ + + # 查询所有相关的活跃发布版本 + releases = db.query(AppRelease).filter( + and_( + AppRelease.app_id.in_(app_ids), + AppRelease.is_active.is_(True) + ) + ).order_by(AppRelease.app_id, AppRelease.version.desc()).all() + + # 构建 app_id -> latest_release 的映射(每个 app 只保留最新版本) + app_release_map = {} + for release in releases: + app_id_str = str(release.app_id) + if app_id_str not in app_release_map: + app_release_map[app_id_str] = release + + # 3. 收集所有 memory_config_id + memory_config_ids = [] + for release in app_release_map.values(): + config = release.config or {} + memory_obj = config.get('memory', {}) + memory_config_id = memory_obj.get('memory_content') if isinstance(memory_obj, dict) else None + if memory_config_id: + try: + config_id = int(memory_config_id) if isinstance(memory_config_id, str) else memory_config_id + memory_config_ids.append(config_id) + except (ValueError, TypeError): + pass + + # 4. 批量查询所有 data_config + config_name_map = {} + if memory_config_ids: + data_configs = db.query(DataConfig).filter( + DataConfig.config_id.in_(memory_config_ids) + ).all() + config_name_map = {config.config_id: config.config_name for config in data_configs} + + # 5. 组装结果 + for user in end_users: + user_id = str(user.id) + app_id = str(user.app_id) + + # 检查是否有发布版本 + if app_id not in app_release_map: + result[user_id] = { + "end_user_id": user_id, + "memory_config_id": None, + "memory_config_name": None, + "error": f"应用未发布: {app_id}" + } + continue + + release = app_release_map[app_id] + + # 提取 memory_config_id + config = release.config or {} + memory_obj = config.get('memory', {}) + memory_config_id = memory_obj.get('memory_content') if isinstance(memory_obj, dict) else None + + # 获取 config_name + config_name = None + if memory_config_id: + try: + config_id = int(memory_config_id) if isinstance(memory_config_id, str) else memory_config_id + config_name = config_name_map.get(config_id) + except (ValueError, TypeError): + pass + + result[user_id] = { + "end_user_id": user_id, + "memory_config_id": memory_config_id, + "memory_config_name": config_name + } + + logger.info(f"Successfully retrieved batch configs: total={len(result)}, with_config={sum(1 for v in result.values() if v.get('memory_config_id'))}") return result \ No newline at end of file From ab02f610e52b1f6f38b80bc8b6cd69d54b4d3e2c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B0=A2=E4=BF=8A=E7=94=B7?= Date: Tue, 13 Jan 2026 14:59:12 +0800 Subject: [PATCH 12/27] feat(workflow node): The execution records of the tool remove the foreign key that binds to the user, and directly store the user ID. --- api/app/core/workflow/nodes/tool/node.py | 6 ++++-- api/app/models/tool_model.py | 3 +-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/api/app/core/workflow/nodes/tool/node.py b/api/app/core/workflow/nodes/tool/node.py index e1b5f380..6084dbe3 100644 --- a/api/app/core/workflow/nodes/tool/node.py +++ b/api/app/core/workflow/nodes/tool/node.py @@ -1,5 +1,6 @@ import logging import re +import uuid from typing import Any from app.core.workflow.nodes.base_node import BaseNode, WorkflowState @@ -24,10 +25,10 @@ class ToolNode(BaseNode): # 获取租户ID和用户ID tenant_id = self.get_variable("sys.tenant_id", state) user_id = self.get_variable("sys.user_id", state) + workspace_id = self.get_variable("sys.workspace_id", state) # 如果没有租户ID,尝试从工作流ID获取 if not tenant_id: - workspace_id = self.get_variable("sys.workspace_id", state) if workspace_id: from app.repositories.tool_repository import ToolRepository with get_db_read() as db: @@ -62,7 +63,8 @@ class ToolNode(BaseNode): tool_id=self.typed_config.tool_id, parameters=rendered_parameters, tenant_id=tenant_id, - user_id=user_id + user_id=uuid.UUID(user_id), + workspace_id=uuid.UUID(workspace_id) ) if result.success: diff --git a/api/app/models/tool_model.py b/api/app/models/tool_model.py index aec74ef7..ccd28693 100644 --- a/api/app/models/tool_model.py +++ b/api/app/models/tool_model.py @@ -211,12 +211,11 @@ class ToolExecution(Base): token_usage = Column(JSON) # 用户信息 - user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), index=True) + user_id = Column(UUID(as_uuid=True), index=True, nullable=True) workspace_id = Column(UUID(as_uuid=True), ForeignKey("workspaces.id"), nullable=False, index=True) # 关联关系 tool_config = relationship("ToolConfig", back_populates="executions") - user = relationship("User") workspace = relationship("Workspace") def __repr__(self): From 2ba8bb58e085a5441f2b424072efbd8cdf7dee3e Mon Sep 17 00:00:00 2001 From: Mark Date: Tue, 13 Jan 2026 15:16:09 +0800 Subject: [PATCH 13/27] [add] migration script --- .../versions/9ab9b6393f32_20261511.py | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 api/migrations/versions/9ab9b6393f32_20261511.py diff --git a/api/migrations/versions/9ab9b6393f32_20261511.py b/api/migrations/versions/9ab9b6393f32_20261511.py new file mode 100644 index 00000000..8c4a5326 --- /dev/null +++ b/api/migrations/versions/9ab9b6393f32_20261511.py @@ -0,0 +1,30 @@ +"""20261511 + +Revision ID: 9ab9b6393f32 +Revises: 793c31683aa5 +Create Date: 2026-01-13 15:14:54.708405 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '9ab9b6393f32' +down_revision: Union[str, None] = '793c31683aa5' +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.drop_constraint(op.f('tool_executions_user_id_fkey'), 'tool_executions', type_='foreignkey') + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_foreign_key(op.f('tool_executions_user_id_fkey'), 'tool_executions', 'users', ['user_id'], ['id']) + # ### end Alembic commands ### From 7a5792ba015064b7c08decb568890c84a6b70778 Mon Sep 17 00:00:00 2001 From: Eternity <61316157+myhMARS@users.noreply.github.com> Date: Tue, 13 Jan 2026 15:42:00 +0800 Subject: [PATCH 14/27] Fix/workflow (#92) * fix(workflow): use loose rendering for end-node variables * fix(workflow): use int type for memory node config id * fix(workflow): handle missing environment variable defaults * fix(workflow): render jinja variables with actual values in non-strict mode * fix(workflow): support reordering without a rerank model in knowledge base * fix(workflow): fix typo in key value --- api/app/core/workflow/executor.py | 23 ++- api/app/core/workflow/graph_builder.py | 1 - api/app/core/workflow/nodes/assigner/node.py | 2 +- api/app/core/workflow/nodes/base_node.py | 165 +++++++++--------- api/app/core/workflow/nodes/end/node.py | 16 +- .../core/workflow/nodes/jinja_render/node.py | 6 +- .../core/workflow/nodes/knowledge/config.py | 2 +- api/app/core/workflow/nodes/knowledge/node.py | 25 ++- api/app/core/workflow/nodes/memory/config.py | 4 +- api/app/core/workflow/nodes/memory/node.py | 4 +- api/app/core/workflow/nodes/operators.py | 5 +- api/app/core/workflow/template_renderer.py | 39 +++-- .../workflows/simple_qa/template.yml | 2 +- 13 files changed, 179 insertions(+), 115 deletions(-) diff --git a/api/app/core/workflow/executor.py b/api/app/core/workflow/executor.py index d42fcf75..e3d634d8 100644 --- a/api/app/core/workflow/executor.py +++ b/api/app/core/workflow/executor.py @@ -14,6 +14,7 @@ from langgraph.graph.state import CompiledStateGraph from app.core.workflow.graph_builder import GraphBuilder from app.core.workflow.nodes import WorkflowState +from app.core.workflow.nodes.base_config import VariableType from app.core.workflow.nodes.enums import NodeType # from app.core.tools.registry import ToolRegistry @@ -78,9 +79,21 @@ class WorkflowExecutor: var_name = var_def.get("name") var_default = var_def.get("default") if var_name: - # TODO: 入参类型校验 - conversation_vars[var_name] = var_default - + if var_default: + conversation_vars[var_name] = var_default + else: + var_type = var_def.get("type") + match var_type: + case VariableType.STRING: + conversation_vars[var_name] = "" + case VariableType.NUMBER: + conversation_vars[var_name] = 0 + case VariableType.OBJECT: + conversation_vars[var_name] = {} + case VariableType.BOOLEAN: + conversation_vars[var_name] = False + case VariableType.ARRAY_NUMBER | VariableType.ARRAY_OBJECT | VariableType.ARRAY_BOOLEAN | VariableType.ARRAY_STRING: + conversation_vars[var_name] = [] input_variables = input_data.get("variables") or {} # Start 节点的自定义变量 # 构建分层的变量结构 @@ -362,7 +375,7 @@ class WorkflowExecutor: inputv = payload.get("input", {}) variables = inputv.get("variables", {}) variables_sys = variables.get("sys", {}) - conversation_id = variables_sys.get("conversation_id") + conversation_id = input_data.get("conversation_id") execution_id = variables_sys.get("execution_id") logger.info(f"[DEBUG] Node starts execution: {node_name}") @@ -381,7 +394,7 @@ class WorkflowExecutor: inputv = result.get("input", {}) variables = inputv.get("variables", {}) variables_sys = variables.get("sys", {}) - conversation_id = variables_sys.get("conversation_id") + conversation_id = input_data.get("conversation_id") execution_id = variables_sys.get("execution_id") logger.info(f"[DEBUG] Node execution completed: {node_name}") diff --git a/api/app/core/workflow/graph_builder.py b/api/app/core/workflow/graph_builder.py index b24d5202..69ed3b6a 100644 --- a/api/app/core/workflow/graph_builder.py +++ b/api/app/core/workflow/graph_builder.py @@ -12,7 +12,6 @@ from app.core.workflow.nodes.enums import NodeType logger = logging.getLogger(__name__) -# TODO: 子图拆解支持 class GraphBuilder: def __init__( self, diff --git a/api/app/core/workflow/nodes/assigner/node.py b/api/app/core/workflow/nodes/assigner/node.py index 008002ed..7b9d645b 100644 --- a/api/app/core/workflow/nodes/assigner/node.py +++ b/api/app/core/workflow/nodes/assigner/node.py @@ -45,6 +45,7 @@ class AssignerNode(BaseNode): # Get the value or expression to assign value = assignment.value + logger.debug(f"left:{variable_selector}, right: {value}") pattern = r"\{\{\s*(.*?)\s*\}\}" if isinstance(value, str): expression = re.match(pattern, value) @@ -85,4 +86,3 @@ class AssignerNode(BaseNode): case _: raise ValueError(f"Invalid Operator: {assignment.operation}") logger.info(f"Node {self.node_id}: execution completed") - diff --git a/api/app/core/workflow/nodes/base_node.py b/api/app/core/workflow/nodes/base_node.py index e7007884..727f7391 100644 --- a/api/app/core/workflow/nodes/base_node.py +++ b/api/app/core/workflow/nodes/base_node.py @@ -35,7 +35,7 @@ class WorkflowState(TypedDict): # Uses a deep merge function, supporting nested dict updates (e.g., conv.xxx) variables: Annotated[dict[str, Any], lambda x, y: { **x, - **{k: {**x.get(k, {}), **v} if isinstance(v, dict) and isinstance(x.get(k), dict) else v + **{k: {**x.get(k, {}), **v} if isinstance(v, dict) and isinstance(x.get(k), dict) else v for k, v in y.items()} }] @@ -46,12 +46,12 @@ class WorkflowState(TypedDict): # Runtime node variables (simplified version, stores business data for fast access between nodes) # Format: {node_id: business_result} runtime_vars: 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 @@ -66,7 +66,7 @@ class BaseNode(ABC): 所有节点类型都应该继承此基类,实现 execute 方法。 """ - + def __init__(self, node_config: dict[str, Any], workflow_config: dict[str, Any]): """初始化节点 @@ -83,7 +83,7 @@ class BaseNode(ABC): # 使用 or 运算符处理 None 值 self.config = node_config.get("config") or {} self.error_handling = node_config.get("error_handling") or {} - + @abstractmethod async def execute(self, state: WorkflowState) -> Any: """执行节点业务逻辑(非流式) @@ -108,7 +108,7 @@ class BaseNode(ABC): >>> return {"message": "开始", "conversation_id": "xxx"} """ pass - + async def execute_stream(self, state: WorkflowState): """执行节点业务逻辑(流式) @@ -138,7 +138,7 @@ class BaseNode(ABC): result = await self.execute(state) # 默认实现:直接 yield 完成标记 yield {"__final__": True, "result": result} - + def supports_streaming(self) -> bool: """节点是否支持流式输出 @@ -147,7 +147,7 @@ class BaseNode(ABC): """ # 检查子类是否重写了 execute_stream 方法 return self.execute_stream.__func__ != BaseNode.execute_stream.__func__ - + def get_timeout(self) -> int: """获取超时时间(秒) @@ -156,7 +156,7 @@ class BaseNode(ABC): """ return 60 # return self.error_handling.get("timeout", 60) - + async def run(self, state: WorkflowState) -> dict[str, Any]: """执行节点(带错误处理和输出包装,非流式) @@ -173,33 +173,33 @@ class BaseNode(ABC): 标准化的状态更新字典 """ import time - + start_time = time.time() timeout = self.get_timeout() - + try: # 调用节点的业务逻辑 business_result = await asyncio.wait_for( self.execute(state), timeout=timeout ) - + elapsed_time = time.time() - start_time - + # 提取处理后的输出(调用子类的 _extract_output) extracted_output = self._extract_output(business_result) - + # 包装成标准输出格式 wrapped_output = self._wrap_output(business_result, elapsed_time, state) - + # 将提取后的输出存储到运行时变量中(供后续节点快速访问) # 如果提取后的输出是字典,拆包存储;否则存储为 output 字段 if isinstance(extracted_output, dict): runtime_var = extracted_output else: runtime_var = {"output": extracted_output} - + # 返回包装后的输出和运行时变量 return { **wrapped_output, @@ -208,7 +208,7 @@ class BaseNode(ABC): }, "looping": state["looping"] } - + except TimeoutError: elapsed_time = time.time() - start_time logger.error(f"节点 {self.node_id} 执行超时({timeout}秒)") @@ -217,7 +217,7 @@ class BaseNode(ABC): elapsed_time = time.time() - start_time logger.error(f"节点 {self.node_id} 执行失败: {e}", exc_info=True) return self._wrap_error(str(e), elapsed_time, state) - + async def run_stream(self, state: WorkflowState): """Execute node with error handling and output wrapping (streaming) @@ -240,40 +240,41 @@ class BaseNode(ABC): State updates with streaming buffer and final result """ import time - + start_time = time.time() timeout = self.get_timeout() - + try: # Get LangGraph's stream writer for sending custom data writer = get_stream_writer() - + # Check if this is an End node # End nodes CAN send chunks (for suffix), but only after LLM content is_end_node = self.node_type == "end" - + # Check if this node is adjacent to End node (for message type) is_adjacent_to_end = getattr(self, '_is_adjacent_to_end', False) - + # Determine chunk type: "message" for End and adjacent nodes, "node_chunk" for others chunk_type = "message" if (is_end_node or is_adjacent_to_end) else "node_chunk" - - logger.debug(f"节点 {self.node_id} chunk 类型: {chunk_type} (is_end={is_end_node}, adjacent={is_adjacent_to_end})") - + + logger.debug( + f"节点 {self.node_id} chunk 类型: {chunk_type} (is_end={is_end_node}, adjacent={is_adjacent_to_end})") + # Accumulate complete result (for final wrapping) chunks = [] final_result = None chunk_count = 0 - + # Stream chunks in real-time loop_start = asyncio.get_event_loop().time() - + async for item in self.execute_stream(state): # Check timeout if asyncio.get_event_loop().time() - loop_start > timeout: raise TimeoutError() - + # Check if it's a completion marker if isinstance(item, dict) and item.get("__final__"): final_result = item["result"] @@ -282,10 +283,10 @@ class BaseNode(ABC): chunk_count += 1 chunks.append(item) full_content = "".join(chunks) - + # Send chunks for all nodes (including End nodes for suffix) logger.debug(f"节点 {self.node_id} 发送 chunk #{chunk_count}: {item[:50]}...") - + # 1. Send via stream writer (for real-time client updates) writer({ "type": chunk_type, # "message" or "node_chunk" @@ -294,7 +295,7 @@ class BaseNode(ABC): "full_content": full_content, "chunk_index": chunk_count }) - + # 2. Update streaming buffer in state (for downstream nodes) # Only non-End nodes need streaming buffer if not is_end_node: @@ -313,7 +314,7 @@ class BaseNode(ABC): chunk_str = str(item) chunks.append(chunk_str) full_content = "".join(chunks) - + # Send chunks for all nodes writer({ "type": chunk_type, # "message" or "node_chunk" @@ -322,7 +323,7 @@ class BaseNode(ABC): "full_content": full_content, "chunk_index": chunk_count }) - + # Only non-End nodes need streaming buffer if not is_end_node: yield { @@ -334,23 +335,23 @@ class BaseNode(ABC): } } } - + elapsed_time = time.time() - start_time - + logger.info(f"节点 {self.node_id} 流式执行完成,耗时: {elapsed_time:.2f}s, chunks: {chunk_count}") - + # Extract processed output (call subclass's _extract_output) extracted_output = self._extract_output(final_result) - + # Wrap final result final_output = self._wrap_output(final_result, elapsed_time, state) - + # Store extracted output in runtime variables (for quick access by subsequent nodes) if isinstance(extracted_output, dict): runtime_var = extracted_output else: runtime_var = {"output": extracted_output} - + # Build complete state update (including node_outputs, runtime_vars, and final streaming buffer) state_update = { **final_output, @@ -359,7 +360,7 @@ class BaseNode(ABC): }, "looping": state["looping"] } - + # Add streaming buffer for non-End nodes if not is_end_node: state_update["streaming_buffer"] = { @@ -369,11 +370,11 @@ class BaseNode(ABC): "is_complete": True # Mark as complete } } - + # Finally yield state update # LangGraph will merge this into state yield state_update - + except TimeoutError: elapsed_time = time.time() - start_time logger.error(f"节点 {self.node_id} 执行超时 ({timeout}s)") @@ -384,12 +385,12 @@ class BaseNode(ABC): logger.error(f"节点 {self.node_id} 执行失败: {e}", exc_info=True) error_output = self._wrap_error(str(e), elapsed_time, state) yield error_output - + def _wrap_output( - self, - business_result: Any, - elapsed_time: float, - state: WorkflowState + self, + business_result: Any, + elapsed_time: float, + state: WorkflowState ) -> dict[str, Any]: """将业务结果包装成标准输出格式 @@ -403,13 +404,13 @@ class BaseNode(ABC): """ # 提取输入数据(用于记录) input_data = self._extract_input(state) - + # 提取 token 使用情况(如果有) token_usage = self._extract_token_usage(business_result) - + # 提取实际输出(去除元数据) output = self._extract_output(business_result) - + # 构建标准节点输出 node_output = { "node_id": self.node_id, @@ -422,18 +423,18 @@ class BaseNode(ABC): "token_usage": token_usage, "error": None } - + return { "node_outputs": { self.node_id: node_output } } - + def _wrap_error( - self, - error_message: str, - elapsed_time: float, - state: WorkflowState + self, + error_message: str, + elapsed_time: float, + state: WorkflowState ) -> dict[str, Any]: """将错误包装成标准输出格式 @@ -447,10 +448,10 @@ class BaseNode(ABC): """ # 查找错误边 error_edge = self._find_error_edge() - + # 提取输入数据 input_data = self._extract_input(state) - + # 构建错误输出 node_output = { "node_id": self.node_id, @@ -463,7 +464,7 @@ class BaseNode(ABC): "token_usage": None, "error": error_message } - + if error_edge: # 有错误边:记录错误并继续 logger.warning( @@ -480,7 +481,7 @@ class BaseNode(ABC): # 无错误边:抛出异常停止工作流 logger.error(f"节点 {self.node_id} 执行失败,停止工作流: {error_message}") raise Exception(f"节点 {self.node_id} 执行失败: {error_message}") - + def _extract_input(self, state: WorkflowState) -> dict[str, Any]: """提取节点输入数据(用于记录) @@ -494,7 +495,7 @@ class BaseNode(ABC): """ # 默认返回配置 return {"config": self.config} - + def _extract_output(self, business_result: Any) -> Any: """从业务结果中提取实际输出 @@ -508,7 +509,7 @@ class BaseNode(ABC): """ # 默认直接返回业务结果 return business_result - + def _extract_token_usage(self, business_result: Any) -> dict[str, int] | None: """从业务结果中提取 token 使用情况 @@ -522,7 +523,7 @@ class BaseNode(ABC): """ # 默认返回 None return None - + def _find_error_edge(self) -> dict[str, Any] | None: """查找错误边 @@ -533,8 +534,8 @@ class BaseNode(ABC): if edge.get("source") == self.node_id and edge.get("type") == "error": return edge return None - - def _render_template(self, template: str, state: WorkflowState | None, struct: bool = True) -> str: + + def _render_template(self, template: str, state: WorkflowState | None, strict: bool = True) -> str: """渲染模板 支持的变量命名空间: @@ -550,28 +551,28 @@ class BaseNode(ABC): 渲染后的字符串 """ from app.core.workflow.template_renderer import render_template - + # 处理 state 为 None 的情况 if state is None: state = {} - + # 使用变量池获取变量 pool = VariablePool(state) - + # 构建完整的 variables 结构 variables = { "sys": pool.get_all_system_vars(), "conv": pool.get_all_conversation_vars() } - + return render_template( template=template, variables=variables, node_outputs=pool.get_all_node_outputs(), system_vars=pool.get_all_system_vars(), - struct=struct + strict=strict ) - + def _evaluate_condition(self, expression: str, state: WorkflowState | None) -> bool: """评估条件表达式 @@ -588,20 +589,20 @@ class BaseNode(ABC): 布尔值结果 """ from app.core.workflow.expression_evaluator import evaluate_condition - + # 处理 state 为 None 的情况 if state is None: state = {} - + # 使用变量池获取变量 pool = VariablePool(state) - + # 构建完整的 variables 结构(包含 sys 和 conv) variables = { "sys": pool.get_all_system_vars(), "conv": pool.get_all_conversation_vars() } - + return evaluate_condition( expression=expression, variables=variables, @@ -626,12 +627,12 @@ class BaseNode(ABC): >>> llm_output = pool.get("llm_qa.output") """ return VariablePool(state) - + def get_variable( - self, - selector: list[str] | str, - state: WorkflowState, - default: Any = None + self, + selector: list[str] | str, + state: WorkflowState, + default: Any = None ) -> Any: """获取变量值(便捷方法) @@ -650,7 +651,7 @@ class BaseNode(ABC): """ pool = VariablePool(state) return pool.get(selector, default=default) - + def has_variable(self, selector: list[str] | str, state: WorkflowState) -> bool: """检查变量是否存在(便捷方法) diff --git a/api/app/core/workflow/nodes/end/node.py b/api/app/core/workflow/nodes/end/node.py index 6230345c..6195afbd 100644 --- a/api/app/core/workflow/nodes/end/node.py +++ b/api/app/core/workflow/nodes/end/node.py @@ -37,7 +37,7 @@ class EndNode(BaseNode): # 如果配置了输出模板,使用模板渲染;否则使用默认输出 if output_template: - output = self._render_template(output_template, state, struct=False) + output = self._render_template(output_template, state, strict=False) else: output = "工作流已完成" @@ -156,6 +156,16 @@ class EndNode(BaseNode): if not output_template: output = "工作流已完成" + from langgraph.config import get_stream_writer + writer = get_stream_writer() + writer({ + "type": "message", # End node output uses message type + "node_id": self.node_id, + "chunk": "", + "full_content": output, + "chunk_index": 1, + "is_suffix": False + }) yield {"__final__": True, "result": output} return @@ -190,7 +200,7 @@ class EndNode(BaseNode): if upstream_llm_ref_index is None: # No reference to direct upstream LLM node, output complete template content - output = self._render_template(output_template, state) + output = self._render_template(output_template, state, strict=False) logger.info(f"节点 {self.node_id} 没有引用直接上游 LLM 节点,输出完整内容: '{output[:50]}...'") # Send complete content via writer (as a single message chunk) @@ -246,7 +256,7 @@ class EndNode(BaseNode): suffix = "".join(suffix_parts) # 构建完整输出(用于返回,包含前缀 + 动态内容 + 后缀) - full_output = self._render_template(output_template, state) + full_output = self._render_template(output_template, state, strict=False) logger.info(f"[后缀调试] 节点 {self.node_id} 后缀部分数量: {len(suffix_parts)}") logger.info(f"[后缀调试] 后缀内容: '{suffix}'") diff --git a/api/app/core/workflow/nodes/jinja_render/node.py b/api/app/core/workflow/nodes/jinja_render/node.py index e18a2001..70993573 100644 --- a/api/app/core/workflow/nodes/jinja_render/node.py +++ b/api/app/core/workflow/nodes/jinja_render/node.py @@ -38,7 +38,11 @@ class JinjaRenderNode(BaseNode): context = {} for variable in self.typed_config.mapping: - context[variable.name] = self._render_template(variable.value, state) + try: + context[variable.name] = self.get_variable(variable.value, state) + except Exception: + logger.info(f"variable not found, var: {variable.value}") + continue try: res = render.env.from_string(self.typed_config.template).render(**context) diff --git a/api/app/core/workflow/nodes/knowledge/config.py b/api/app/core/workflow/nodes/knowledge/config.py index cdb83131..9d307216 100644 --- a/api/app/core/workflow/nodes/knowledge/config.py +++ b/api/app/core/workflow/nodes/knowledge/config.py @@ -45,7 +45,7 @@ class KnowledgeRetrievalNodeConfig(BaseNodeConfig): ) reranker_id: UUID = Field( - ..., + default="", description="Reranker top k" ) diff --git a/api/app/core/workflow/nodes/knowledge/node.py b/api/app/core/workflow/nodes/knowledge/node.py index 5a6b2a7f..061328e1 100644 --- a/api/app/core/workflow/nodes/knowledge/node.py +++ b/api/app/core/workflow/nodes/knowledge/node.py @@ -203,19 +203,34 @@ class KnowledgeRetrievalNode(BaseNode): rs2 = vector_service.search_by_full_text(query=query, top_k=kb_config.top_k, indices=indices, score_threshold=kb_config.similarity_threshold) + # Deduplicate hy brid retrieval results unique_rs = self._deduplicate_docs(rs1, rs2) if not unique_rs: continue - vector_service.reranker = self.get_reranker_model() - rs.extend(vector_service.rerank(query=query, docs=unique_rs, top_k=kb_config.top_k)) + if self.typed_config.reranker_id: + vector_service.reranker = self.get_reranker_model() + rs.extend(vector_service.rerank(query=query, docs=unique_rs, top_k=kb_config.top_k)) + else: + rs.extend(sorted( + unique_rs, + key=lambda d: d.metadata.get("score", 0), + reverse=True + )[:kb_config.top_k]) case _: raise RuntimeError("Unknown retrieval type") if not rs: return [] - vector_service.reranker = self.get_reranker_model() - # TODO:其他重排序方式支持 - final_rs = vector_service.rerank(query=query, docs=rs, top_k=self.typed_config.reranker_top_k) + if self.typed_config.reranker_id: + vector_service.reranker = self.get_reranker_model() + final_rs = vector_service.rerank(query=query, docs=rs, top_k=self.typed_config.reranker_top_k) + else: + final_rs = sorted( + rs, + key=lambda d: d.metadata.get("score", 0), + reverse=True + )[:self.typed_config.reranker_top_k] + logger.info( f"Node {self.node_id}: knowledge base retrieval completed, results count: {len(final_rs)}" ) diff --git a/api/app/core/workflow/nodes/memory/config.py b/api/app/core/workflow/nodes/memory/config.py index 317dc507..987230c1 100644 --- a/api/app/core/workflow/nodes/memory/config.py +++ b/api/app/core/workflow/nodes/memory/config.py @@ -11,7 +11,7 @@ class MemoryReadNodeConfig(BaseNodeConfig): ... ) - config_id: str = Field( + config_id: int = Field( ... ) @@ -26,6 +26,6 @@ class MemoryWriteNodeConfig(BaseNodeConfig): ... ) - config_id: str = Field( + config_id: int = Field( ... ) diff --git a/api/app/core/workflow/nodes/memory/node.py b/api/app/core/workflow/nodes/memory/node.py index bb2366f6..0d1b1fb4 100644 --- a/api/app/core/workflow/nodes/memory/node.py +++ b/api/app/core/workflow/nodes/memory/node.py @@ -25,7 +25,7 @@ class MemoryReadNode(BaseNode): return await MemoryAgentService().read_memory( group_id=end_user_id, message=self._render_template(self.typed_config.message, state), - config_id=self.typed_config.config_id, + config_id=str(self.typed_config.config_id), search_switch=self.typed_config.search_switch, history=[], db=db, @@ -52,7 +52,7 @@ class MemoryWriteNode(BaseNode): return await MemoryAgentService().write_memory( group_id=end_user_id, message=self._render_template(self.typed_config.message, state), - config_id=self.typed_config.config_id, + config_id=str(self.typed_config.config_id), db=db, storage_type="neo4j", user_rag_memory_id="" diff --git a/api/app/core/workflow/nodes/operators.py b/api/app/core/workflow/nodes/operators.py index 25caec07..ad38284a 100644 --- a/api/app/core/workflow/nodes/operators.py +++ b/api/app/core/workflow/nodes/operators.py @@ -386,7 +386,10 @@ class ArrayComparisonOperator(ConditionBase): return self.right_value not in self.left_value -class NoneObjectComparisonOperator(ConditionBase): +class NoneObjectComparisonOperator: + def __init__(self, *arg, **kwargs): + pass + def __getattr__(self, name): return lambda *args, **kwargs: False diff --git a/api/app/core/workflow/template_renderer.py b/api/app/core/workflow/template_renderer.py index 198a3322..c2d7f255 100644 --- a/api/app/core/workflow/template_renderer.py +++ b/api/app/core/workflow/template_renderer.py @@ -5,6 +5,7 @@ """ import logging +from collections import defaultdict from typing import Any from jinja2 import TemplateSyntaxError, UndefinedError, Environment, StrictUndefined, Undefined @@ -12,6 +13,18 @@ from jinja2 import TemplateSyntaxError, UndefinedError, Environment, StrictUndef logger = logging.getLogger(__name__) +class SafeUndefined(Undefined): + """访问未定义属性不会报错,返回空字符串""" + __slots__ = () + + def _fail_with_undefined_error(self, *args, **kwargs): + return "" + + __add__ = __radd__ = __mul__ = __rmul__ = __div__ = __rdiv__ = __truediv__ = __rtruediv__ = _fail_with_undefined_error + __getitem__ = __getattr__ = _fail_with_undefined_error + __str__ = __repr__ = lambda self: "" + + class TemplateRenderer: """模板渲染器""" @@ -21,8 +34,9 @@ class TemplateRenderer: Args: strict: 是否使用严格模式(未定义变量会抛出异常) """ + self.strict = strict self.env = Environment( - undefined=StrictUndefined if strict else Undefined, + undefined=StrictUndefined if strict else SafeUndefined, autoescape=False # 不自动转义,因为我们处理的是文本而非 HTML ) @@ -69,12 +83,17 @@ class TemplateRenderer: # variables 的结构:{"sys": {...}, "conv": {...}} sys_vars = variables.get("sys", {}) if isinstance(variables, dict) else {} conv_vars = variables.get("conv", {}) if isinstance(variables, dict) else {} - - context = { - "conv": conv_vars, # 会话变量:{{conv.user_name}} - "node": node_outputs, # 节点输出:{{node.node_1.output}} - "sys": {**(system_vars or {}), **sys_vars}, # 系统变量:{{sys.execution_id}}(合并两个来源) - } + if self.strict: + context = defaultdict(dict) + context["conv"] = conv_vars + context["node"] = node_outputs + context["sys"] = {**(system_vars or {}), **sys_vars} + else: + context = { + "conv": conv_vars, # 会话变量:{{conv.user_name}} + "node": node_outputs, # 节点输出:{{node.node_1.output}} + "sys": {**(system_vars or {}), **sys_vars}, # 系统变量:{{sys.execution_id}}(合并两个来源) + } # 支持直接通过节点ID访问节点输出:{{llm_qa.output}} # 将所有节点输出添加到顶层上下文 @@ -141,12 +160,12 @@ def render_template( variables: dict[str, Any], node_outputs: dict[str, Any], system_vars: dict[str, Any] | None = None, - struct: bool = True + strict: bool = True ) -> str: """渲染模板(便捷函数) Args: - struct: 渲染模式 + strict: 严格模式 template: 模板字符串 variables: 用户变量 node_outputs: 节点输出 @@ -164,7 +183,7 @@ def render_template( ... ) '请分析: 这是一段文本' """ - renderer = TemplateRenderer(strict=struct) + renderer = TemplateRenderer(strict=strict) return renderer.render(template, variables, node_outputs, system_vars) diff --git a/api/app/templates/workflows/simple_qa/template.yml b/api/app/templates/workflows/simple_qa/template.yml index 2cf0f9b1..14de4a73 100644 --- a/api/app/templates/workflows/simple_qa/template.yml +++ b/api/app/templates/workflows/simple_qa/template.yml @@ -53,7 +53,7 @@ nodes: type: end name: 结束 config: - output: "{{llm_qa.output}}" + output: "{{ llm_qa.output }}" position: x: 900 y: 100 From dec9fca8c2a6c88f899a951756c3e3f0e51d2618 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B9=90=E5=8A=9B=E9=BD=90?= <162269739+lanceyq@users.noreply.github.com> Date: Tue, 13 Jan 2026 16:02:36 +0800 Subject: [PATCH 15/27] Refactor/episodic explicit (#93) * [refactor]Reconstruct episodic memory * [refactor]Reconstructing explicit memory * [refactor]Reconstruct episodic memory * [refactor]Reconstructing explicit memory * [changes]Based on the improvement of AI review * [changes]Modify the routing * [changes]Uniform routing format * [fix]Fix the failure in parsing the timestamp. * [refactor]Reconstruct episodic memory * [refactor]Reconstructing explicit memory * [changes]Based on the improvement of AI review * [changes]Modify the routing * [changes]Uniform routing format * [fix]Fix the failure in parsing the timestamp. * [deleted]Delete migration files * [refactor]Reconstruct episodic memory * [refactor]Reconstructing explicit memory * [changes]Based on the improvement of AI review * [changes]Modify the routing * [changes]Uniform routing format * [fix]Fix the failure in parsing the timestamp. * [deleted]Delete migration files * feat: add database migration 9ab9b6393f32_20261511 --- api/app/controllers/__init__.py | 4 + .../controllers/memory_episodic_controller.py | 125 +++ .../controllers/memory_explicit_controller.py | 115 +++ .../controllers/user_memory_controllers.py | 198 ---- ...ry_schema.py => memory_episodic_schema.py} | 15 +- api/app/schemas/memory_explicit_schema.py | 15 + api/app/services/memory_base_service.py | 111 +++ api/app/services/memory_episodic_service.py | 405 +++++++++ api/app/services/memory_explicit_service.py | 274 ++++++ api/app/services/user_memory_service.py | 860 ------------------ .../versions/9ab9b6393f32_20261511.py | 2 +- 11 files changed, 1051 insertions(+), 1073 deletions(-) create mode 100644 api/app/controllers/memory_episodic_controller.py create mode 100644 api/app/controllers/memory_explicit_controller.py rename api/app/schemas/{user_memory_schema.py => memory_episodic_schema.py} (67%) create mode 100644 api/app/schemas/memory_explicit_schema.py create mode 100644 api/app/services/memory_base_service.py create mode 100644 api/app/services/memory_episodic_service.py create mode 100644 api/app/services/memory_explicit_service.py diff --git a/api/app/controllers/__init__.py b/api/app/controllers/__init__.py index a45c701f..2fabbcc8 100644 --- a/api/app/controllers/__init__.py +++ b/api/app/controllers/__init__.py @@ -20,6 +20,8 @@ from . import ( knowledgeshare_controller, memory_agent_controller, memory_dashboard_controller, + memory_episodic_controller, + memory_explicit_controller, memory_forget_controller, memory_reflection_controller, memory_short_term_controller, @@ -67,6 +69,8 @@ manager_router.include_router(memory_agent_controller.router) manager_router.include_router(memory_dashboard_controller.router) manager_router.include_router(memory_storage_controller.router) manager_router.include_router(user_memory_controllers.router) +manager_router.include_router(memory_episodic_controller.router) +manager_router.include_router(memory_explicit_controller.router) manager_router.include_router(api_key_controller.router) manager_router.include_router(release_share_controller.router) manager_router.include_router(public_share_controller.router) # 公开路由(无需认证) diff --git a/api/app/controllers/memory_episodic_controller.py b/api/app/controllers/memory_episodic_controller.py new file mode 100644 index 00000000..331adfd3 --- /dev/null +++ b/api/app/controllers/memory_episodic_controller.py @@ -0,0 +1,125 @@ +""" +情景记忆相关的控制器 +包含情景记忆总览和详情查询接口 +""" + +from fastapi import APIRouter, Depends + +from app.core.error_codes import BizCode +from app.core.logging_config import get_api_logger +from app.core.response_utils import fail, success +from app.dependencies import get_current_user +from app.models.user_model import User +from app.schemas.response_schema import ApiResponse +from app.schemas.memory_episodic_schema import ( + EpisodicMemoryOverviewRequest, + EpisodicMemoryDetailsRequest, +) +from app.services.memory_episodic_service import memory_episodic_service + +# Get API logger +api_logger = get_api_logger() + +router = APIRouter( + prefix="/memory/episodic-memory", + tags=["Episodic Memory"], +) + + +@router.post("/overview", response_model=ApiResponse) +async def get_episodic_memory_overview_api( + request: EpisodicMemoryOverviewRequest, + current_user: User = Depends(get_current_user), +) -> dict: + """ + 获取情景记忆总览 + + 返回指定用户的所有情景记忆列表,包括标题和创建时间。 + 支持通过时间范围、情景类型和标题关键词进行筛选。 + + """ + workspace_id = current_user.current_workspace_id + + # 检查用户是否已选择工作空间 + if workspace_id is None: + api_logger.warning(f"用户 {current_user.username} 尝试查询情景记忆总览但未选择工作空间") + return fail(BizCode.INVALID_PARAMETER, "请先切换到一个工作空间", "current_workspace_id is None") + + # 验证参数 + valid_time_ranges = ["all", "today", "this_week", "this_month"] + valid_episodic_types = ["all", "conversation", "project_work", "learning", "decision", "important_event"] + + if request.time_range not in valid_time_ranges: + return fail(BizCode.INVALID_PARAMETER, f"无效的时间范围参数,可选值:{', '.join(valid_time_ranges)}") + + if request.episodic_type not in valid_episodic_types: + return fail(BizCode.INVALID_PARAMETER, f"无效的情景类型参数,可选值:{', '.join(valid_episodic_types)}") + + # 处理 title_keyword(去除首尾空格) + title_keyword = request.title_keyword.strip() if request.title_keyword else None + + api_logger.info( + f"情景记忆总览查询请求: end_user_id={request.end_user_id}, user={current_user.username}, " + f"workspace={workspace_id}, time_range={request.time_range}, episodic_type={request.episodic_type}, " + f"title_keyword={title_keyword}" + ) + + try: + # 调用Service层方法 + result = await memory_episodic_service.get_episodic_memory_overview( + request.end_user_id, request.time_range, request.episodic_type, title_keyword + ) + + api_logger.info( + f"成功获取情景记忆总览: end_user_id={request.end_user_id}, " + f"total={result['total']}" + ) + return success(data=result, msg="查询成功") + + except Exception as e: + api_logger.error(f"情景记忆总览查询失败: end_user_id={request.end_user_id}, error={str(e)}") + return fail(BizCode.INTERNAL_ERROR, "情景记忆总览查询失败", str(e)) + + +@router.post("/details", response_model=ApiResponse) +async def get_episodic_memory_details_api( + request: EpisodicMemoryDetailsRequest, + current_user: User = Depends(get_current_user), +) -> dict: + """ + 获取情景记忆详情 + + 返回指定情景记忆的详细信息,包括涉及对象、情景类型、内容记录和情绪。 + + """ + workspace_id = current_user.current_workspace_id + + # 检查用户是否已选择工作空间 + if workspace_id is None: + api_logger.warning(f"用户 {current_user.username} 尝试查询情景记忆详情但未选择工作空间") + return fail(BizCode.INVALID_PARAMETER, "请先切换到一个工作空间", "current_workspace_id is None") + + api_logger.info( + f"情景记忆详情查询请求: end_user_id={request.end_user_id}, summary_id={request.summary_id}, " + f"user={current_user.username}, workspace={workspace_id}" + ) + + try: + # 调用Service层方法 + result = await memory_episodic_service.get_episodic_memory_details( + end_user_id=request.end_user_id, + summary_id=request.summary_id + ) + + api_logger.info( + f"成功获取情景记忆详情: end_user_id={request.end_user_id}, summary_id={request.summary_id}" + ) + return success(data=result, msg="查询成功") + + except ValueError as e: + # 处理情景记忆不存在的情况 + api_logger.warning(f"情景记忆不存在: end_user_id={request.end_user_id}, summary_id={request.summary_id}, error={str(e)}") + return fail(BizCode.INVALID_PARAMETER, "情景记忆不存在", str(e)) + except Exception as e: + api_logger.error(f"情景记忆详情查询失败: end_user_id={request.end_user_id}, summary_id={request.summary_id}, error={str(e)}") + return fail(BizCode.INTERNAL_ERROR, "情景记忆详情查询失败", str(e)) diff --git a/api/app/controllers/memory_explicit_controller.py b/api/app/controllers/memory_explicit_controller.py new file mode 100644 index 00000000..c52f308c --- /dev/null +++ b/api/app/controllers/memory_explicit_controller.py @@ -0,0 +1,115 @@ +""" +显性记忆控制器 + +处理显性记忆相关的API接口,包括情景记忆和语义记忆的查询。 +""" + +from fastapi import APIRouter, Depends + +from app.core.logging_config import get_api_logger +from app.core.response_utils import success, fail +from app.core.error_codes import BizCode +from app.services.memory_explicit_service import MemoryExplicitService +from app.schemas.response_schema import ApiResponse +from app.schemas.memory_explicit_schema import ( + ExplicitMemoryOverviewRequest, + ExplicitMemoryDetailsRequest, +) +from app.dependencies import get_current_user +from app.models.user_model import User + +# Get API logger +api_logger = get_api_logger() + +# Initialize service +memory_explicit_service = MemoryExplicitService() + +router = APIRouter( + prefix="/memory/explicit-memory", + tags=["Explicit Memory"], +) + + +@router.post("/overview", response_model=ApiResponse) +async def get_explicit_memory_overview_api( + request: ExplicitMemoryOverviewRequest, + current_user: User = Depends(get_current_user), +) -> dict: + """ + 获取显性记忆总览 + + 返回指定用户的所有显性记忆列表,包括标题、完整内容、创建时间和情绪信息。 + """ + workspace_id = current_user.current_workspace_id + + # 检查用户是否已选择工作空间 + if workspace_id is None: + api_logger.warning(f"用户 {current_user.username} 尝试查询显性记忆总览但未选择工作空间") + return fail(BizCode.INVALID_PARAMETER, "请先切换到一个工作空间", "current_workspace_id is None") + + api_logger.info( + f"显性记忆总览查询请求: end_user_id={request.end_user_id}, user={current_user.username}, " + f"workspace={workspace_id}" + ) + + try: + # 调用Service层方法 + result = await memory_explicit_service.get_explicit_memory_overview( + request.end_user_id + ) + + api_logger.info( + f"成功获取显性记忆总览: end_user_id={request.end_user_id}, " + f"total={result['total']}" + ) + return success(data=result, msg="查询成功") + + except Exception as e: + api_logger.error(f"显性记忆总览查询失败: end_user_id={request.end_user_id}, error={str(e)}") + return fail(BizCode.INTERNAL_ERROR, "显性记忆总览查询失败", str(e)) + + +@router.post("/details", response_model=ApiResponse) +async def get_explicit_memory_details_api( + request: ExplicitMemoryDetailsRequest, + current_user: User = Depends(get_current_user), +) -> dict: + """ + 获取显性记忆详情 + + 根据 memory_id 返回情景记忆或语义记忆的详细信息。 + - 情景记忆:包括标题、内容、情绪、创建时间 + - 语义记忆:包括名称、核心定义、详细笔记、创建时间 + """ + workspace_id = current_user.current_workspace_id + + # 检查用户是否已选择工作空间 + if workspace_id is None: + api_logger.warning(f"用户 {current_user.username} 尝试查询显性记忆详情但未选择工作空间") + return fail(BizCode.INVALID_PARAMETER, "请先切换到一个工作空间", "current_workspace_id is None") + + api_logger.info( + f"显性记忆详情查询请求: end_user_id={request.end_user_id}, memory_id={request.memory_id}, " + f"user={current_user.username}, workspace={workspace_id}" + ) + + try: + # 调用Service层方法 + result = await memory_explicit_service.get_explicit_memory_details( + end_user_id=request.end_user_id, + memory_id=request.memory_id + ) + + api_logger.info( + f"成功获取显性记忆详情: end_user_id={request.end_user_id}, memory_id={request.memory_id}, " + f"memory_type={result.get('memory_type')}" + ) + return success(data=result, msg="查询成功") + + except ValueError as e: + # 处理记忆不存在的情况 + api_logger.warning(f"显性记忆不存在: end_user_id={request.end_user_id}, memory_id={request.memory_id}, error={str(e)}") + return fail(BizCode.INVALID_PARAMETER, "显性记忆不存在", str(e)) + except Exception as e: + api_logger.error(f"显性记忆详情查询失败: end_user_id={request.end_user_id}, memory_id={request.memory_id}, error={str(e)}") + return fail(BizCode.INTERNAL_ERROR, "显性记忆详情查询失败", str(e)) diff --git a/api/app/controllers/user_memory_controllers.py b/api/app/controllers/user_memory_controllers.py index 5fd9b841..a96c7a52 100644 --- a/api/app/controllers/user_memory_controllers.py +++ b/api/app/controllers/user_memory_controllers.py @@ -20,12 +20,6 @@ from app.services.user_memory_service import ( from app.services.memory_entity_relationship_service import MemoryEntityService,MemoryEmotion,MemoryInteraction from app.schemas.response_schema import ApiResponse from app.schemas.memory_storage_schema import GenerateCacheRequest -from app.schemas.user_memory_schema import ( - EpisodicMemoryOverviewRequest, - EpisodicMemoryDetailsRequest, - ExplicitMemoryOverviewRequest, - ExplicitMemoryDetailsRequest, -) from app.schemas.end_user_schema import ( EndUserProfileResponse, @@ -440,195 +434,3 @@ async def memory_space_relationship_evolution(id: str, label: str, except Exception as e: api_logger.error(f"关系演变查询失败: id={id}, table={label}, error={str(e)}", exc_info=True) return fail(BizCode.INTERNAL_ERROR, "关系演变查询失败", str(e)) - - -@router.post("/classifications/episodic-memory", response_model=ApiResponse) -async def get_episodic_memory_overview_api( - request: EpisodicMemoryOverviewRequest, - current_user: User = Depends(get_current_user), - db: Session = Depends(get_db), -) -> dict: - """ - 获取情景记忆总览 - - 返回指定用户的所有情景记忆列表,包括标题和创建时间。 - 支持通过时间范围、情景类型和标题关键词进行筛选。 - - """ - workspace_id = current_user.current_workspace_id - - # 检查用户是否已选择工作空间 - if workspace_id is None: - api_logger.warning(f"用户 {current_user.username} 尝试查询情景记忆总览但未选择工作空间") - return fail(BizCode.INVALID_PARAMETER, "请先切换到一个工作空间", "current_workspace_id is None") - - # 验证参数 - valid_time_ranges = ["all", "today", "this_week", "this_month"] - valid_episodic_types = ["all", "conversation", "project_work", "learning", "decision", "important_event"] - - if request.time_range not in valid_time_ranges: - return fail(BizCode.INVALID_PARAMETER, f"无效的时间范围参数,可选值:{', '.join(valid_time_ranges)}") - - if request.episodic_type not in valid_episodic_types: - return fail(BizCode.INVALID_PARAMETER, f"无效的情景类型参数,可选值:{', '.join(valid_episodic_types)}") - - # 处理 title_keyword(去除首尾空格) - title_keyword = request.title_keyword.strip() if request.title_keyword else None - - api_logger.info( - f"情景记忆总览查询请求: end_user_id={request.end_user_id}, user={current_user.username}, " - f"workspace={workspace_id}, time_range={request.time_range}, episodic_type={request.episodic_type}, " - f"title_keyword={title_keyword}" - ) - - try: - # 调用Service层方法 - result = await user_memory_service.get_episodic_memory_overview( - db, request.end_user_id, request.time_range, request.episodic_type, title_keyword - ) - - api_logger.info( - f"成功获取情景记忆总览: end_user_id={request.end_user_id}, " - f"total={result['total']}" - ) - return success(data=result, msg="查询成功") - - except Exception as e: - api_logger.error(f"情景记忆总览查询失败: end_user_id={request.end_user_id}, error={str(e)}") - return fail(BizCode.INTERNAL_ERROR, "情景记忆总览查询失败", str(e)) - - -@router.post("/classifications/episodic-memory-details", response_model=ApiResponse) -async def get_episodic_memory_details_api( - request: EpisodicMemoryDetailsRequest, - current_user: User = Depends(get_current_user), - db: Session = Depends(get_db), -) -> dict: - """ - 获取情景记忆详情 - - 返回指定情景记忆的详细信息,包括涉及对象、情景类型、内容记录和情绪。 - - """ - workspace_id = current_user.current_workspace_id - - # 检查用户是否已选择工作空间 - if workspace_id is None: - api_logger.warning(f"用户 {current_user.username} 尝试查询情景记忆详情但未选择工作空间") - return fail(BizCode.INVALID_PARAMETER, "请先切换到一个工作空间", "current_workspace_id is None") - - api_logger.info( - f"情景记忆详情查询请求: end_user_id={request.end_user_id}, summary_id={request.summary_id}, " - f"user={current_user.username}, workspace={workspace_id}" - ) - - try: - # 调用Service层方法 - result = await user_memory_service.get_episodic_memory_details( - db=db, - end_user_id=request.end_user_id, - summary_id=request.summary_id - ) - - api_logger.info( - f"成功获取情景记忆详情: end_user_id={request.end_user_id}, summary_id={request.summary_id}" - ) - return success(data=result, msg="查询成功") - - except ValueError as e: - # 处理情景记忆不存在的情况 - api_logger.warning(f"情景记忆不存在: end_user_id={request.end_user_id}, summary_id={request.summary_id}, error={str(e)}") - return fail(BizCode.INVALID_PARAMETER, "情景记忆不存在", str(e)) - except Exception as e: - api_logger.error(f"情景记忆详情查询失败: end_user_id={request.end_user_id}, summary_id={request.summary_id}, error={str(e)}") - return fail(BizCode.INTERNAL_ERROR, "情景记忆详情查询失败", str(e)) - - -@router.post("/classifications/explicit-memory", response_model=ApiResponse) -async def get_explicit_memory_overview_api( - request: ExplicitMemoryOverviewRequest, - current_user: User = Depends(get_current_user), - db: Session = Depends(get_db), -) -> dict: - """ - 获取显性记忆总览 - - 返回指定用户的所有显性记忆列表,包括标题、完整内容、创建时间和情绪信息。 - """ - workspace_id = current_user.current_workspace_id - - # 检查用户是否已选择工作空间 - if workspace_id is None: - api_logger.warning(f"用户 {current_user.username} 尝试查询显性记忆总览但未选择工作空间") - return fail(BizCode.INVALID_PARAMETER, "请先切换到一个工作空间", "current_workspace_id is None") - - api_logger.info( - f"显性记忆总览查询请求: end_user_id={request.end_user_id}, user={current_user.username}, " - f"workspace={workspace_id}" - ) - - try: - # 调用Service层方法 - result = await user_memory_service.get_explicit_memory_overview( - db, request.end_user_id - ) - - api_logger.info( - f"成功获取显性记忆总览: end_user_id={request.end_user_id}, " - f"total={result['total']}" - ) - return success(data=result, msg="查询成功") - - except Exception as e: - api_logger.error(f"显性记忆总览查询失败: end_user_id={request.end_user_id}, error={str(e)}") - return fail(BizCode.INTERNAL_ERROR, "显性记忆总览查询失败", str(e)) - - -@router.post("/classifications/explicit-memory-details", response_model=ApiResponse) -async def get_explicit_memory_details_api( - request: ExplicitMemoryDetailsRequest, - current_user: User = Depends(get_current_user), - db: Session = Depends(get_db), -) -> dict: - """ - 获取显性记忆详情 - - 根据 memory_id 返回情景记忆或语义记忆的详细信息。 - - 情景记忆:包括标题、内容、情绪、创建时间 - - 语义记忆:包括名称、核心定义、详细笔记、创建时间 - """ - workspace_id = current_user.current_workspace_id - - # 检查用户是否已选择工作空间 - if workspace_id is None: - api_logger.warning(f"用户 {current_user.username} 尝试查询显性记忆详情但未选择工作空间") - return fail(BizCode.INVALID_PARAMETER, "请先切换到一个工作空间", "current_workspace_id is None") - - api_logger.info( - f"显性记忆详情查询请求: end_user_id={request.end_user_id}, memory_id={request.memory_id}, " - f"user={current_user.username}, workspace={workspace_id}" - ) - - try: - # 调用Service层方法 - result = await user_memory_service.get_explicit_memory_details( - db=db, - end_user_id=request.end_user_id, - memory_id=request.memory_id - ) - - api_logger.info( - f"成功获取显性记忆详情: end_user_id={request.end_user_id}, memory_id={request.memory_id}, " - f"memory_type={result.get('memory_type')}" - ) - return success(data=result, msg="查询成功") - - except ValueError as e: - # 处理记忆不存在的情况 - api_logger.warning(f"显性记忆不存在: end_user_id={request.end_user_id}, memory_id={request.memory_id}, error={str(e)}") - return fail(BizCode.INVALID_PARAMETER, "显性记忆不存在", str(e)) - except Exception as e: - api_logger.error(f"显性记忆详情查询失败: end_user_id={request.end_user_id}, memory_id={request.memory_id}, error={str(e)}") - return fail(BizCode.INTERNAL_ERROR, "显性记忆详情查询失败", str(e)) - - diff --git a/api/app/schemas/user_memory_schema.py b/api/app/schemas/memory_episodic_schema.py similarity index 67% rename from api/app/schemas/user_memory_schema.py rename to api/app/schemas/memory_episodic_schema.py index 796ad72f..7b3f3d2d 100644 --- a/api/app/schemas/user_memory_schema.py +++ b/api/app/schemas/memory_episodic_schema.py @@ -1,5 +1,5 @@ """ -用户记忆相关的请求和响应模型 +情景记忆的请求和响应模型 """ from pydantic import BaseModel, Field from typing import Optional @@ -28,16 +28,3 @@ class EpisodicMemoryDetailsRequest(BaseModel): end_user_id: str = Field(..., description="终端用户ID") summary_id: str = Field(..., description="情景记忆摘要ID") - - -class ExplicitMemoryOverviewRequest(BaseModel): - """显性记忆总览查询请求""" - - end_user_id: str = Field(..., description="终端用户ID") - - -class ExplicitMemoryDetailsRequest(BaseModel): - """显性记忆详情查询请求""" - - end_user_id: str = Field(..., description="终端用户ID") - memory_id: str = Field(..., description="记忆ID(情景记忆或语义记忆的ID)") diff --git a/api/app/schemas/memory_explicit_schema.py b/api/app/schemas/memory_explicit_schema.py new file mode 100644 index 00000000..c2b51a81 --- /dev/null +++ b/api/app/schemas/memory_explicit_schema.py @@ -0,0 +1,15 @@ +""" +显性记忆的请求和响应模型 +""" +from pydantic import BaseModel, Field + +class ExplicitMemoryOverviewRequest(BaseModel): + """显性记忆总览查询请求""" + + end_user_id: str = Field(..., description="终端用户ID") + +class ExplicitMemoryDetailsRequest(BaseModel): + """显性记忆详情查询请求""" + + end_user_id: str = Field(..., description="终端用户ID") + memory_id: str = Field(..., description="记忆ID(情景记忆或语义记忆的ID)") diff --git a/api/app/services/memory_base_service.py b/api/app/services/memory_base_service.py new file mode 100644 index 00000000..8eae3c42 --- /dev/null +++ b/api/app/services/memory_base_service.py @@ -0,0 +1,111 @@ +""" +Memory Base Service + +提供记忆服务的基础功能和共享辅助方法。 +""" + +from datetime import datetime +from typing import Optional + +from app.core.logging_config import get_logger +from app.repositories.neo4j.neo4j_connector import Neo4jConnector + +logger = get_logger(__name__) + + +class MemoryBaseService: + """记忆服务基类,提供共享的辅助方法""" + + def __init__(self): + self.neo4j_connector = Neo4jConnector() + + @staticmethod + def parse_timestamp(timestamp_value) -> Optional[int]: + """ + 将时间戳转换为毫秒级时间戳 + + 支持多种输入格式: + - Neo4j DateTime 对象 + - ISO格式的时间戳字符串 + - Python datetime 对象 + + Args: + timestamp_value: 时间戳值(可以是多种类型) + + Returns: + 毫秒级时间戳,如果解析失败则返回None + """ + if not timestamp_value: + return None + + try: + # 处理 Neo4j DateTime 对象 + if hasattr(timestamp_value, 'to_native'): + dt_object = timestamp_value.to_native() + return int(dt_object.timestamp() * 1000) + + # 处理 Python datetime 对象 + if isinstance(timestamp_value, datetime): + return int(timestamp_value.timestamp() * 1000) + + # 处理字符串格式 + if isinstance(timestamp_value, str): + dt_object = datetime.fromisoformat(timestamp_value.replace("Z", "+00:00")) + return int(dt_object.timestamp() * 1000) + + # 其他情况尝试转换为字符串再解析 + dt_object = datetime.fromisoformat(str(timestamp_value).replace("Z", "+00:00")) + return int(dt_object.timestamp() * 1000) + + except (ValueError, TypeError, AttributeError) as e: + logger.warning(f"无法解析时间戳: {timestamp_value}, error={str(e)}") + return None + + async def extract_episodic_emotion( + self, + summary_id: str, + end_user_id: str + ) -> Optional[str]: + """ + 提取情景记忆的主要情绪 + + 查询MemorySummary节点关联的Statement节点, + 返回emotion_intensity最大的emotion_type。 + + Args: + summary_id: Summary节点的ID + end_user_id: 终端用户ID (group_id) + + Returns: + 最大emotion_intensity对应的emotion_type,如果没有则返回None + """ + try: + query = """ + MATCH (s:MemorySummary) + WHERE elementId(s) = $summary_id AND s.group_id = $group_id + MATCH (s)-[:DERIVED_FROM_STATEMENT]->(stmt:Statement) + WHERE stmt.emotion_type IS NOT NULL + AND stmt.emotion_intensity IS NOT NULL + RETURN stmt.emotion_type AS emotion_type, + stmt.emotion_intensity AS emotion_intensity + ORDER BY emotion_intensity DESC + LIMIT 1 + """ + + result = await self.neo4j_connector.execute_query( + query, + summary_id=summary_id, + group_id=end_user_id + ) + + if result and len(result) > 0: + emotion_type = result[0].get("emotion_type") + logger.info(f"成功提取 summary_id={summary_id} 的情绪: {emotion_type}") + return emotion_type + else: + logger.info(f"summary_id={summary_id} 没有情绪信息") + return None + + except Exception as e: + logger.error(f"提取情景记忆情绪时出错: {str(e)}", exc_info=True) + return None diff --git a/api/app/services/memory_episodic_service.py b/api/app/services/memory_episodic_service.py new file mode 100644 index 00000000..e8bb0bfc --- /dev/null +++ b/api/app/services/memory_episodic_service.py @@ -0,0 +1,405 @@ +""" +Episodic Memory Service + +处理情景记忆相关的业务逻辑,包括情景记忆总览、详情查询等。 +""" + +from datetime import datetime, timedelta +from typing import Any, Dict, List, Optional, Tuple + +import pytz +from app.core.logging_config import get_logger +from app.services.memory_base_service import MemoryBaseService + +logger = get_logger(__name__) + + +class MemoryEpisodicService(MemoryBaseService): + """情景记忆服务类""" + + def __init__(self): + super().__init__() + logger.info("MemoryEpisodicService initialized") + + async def _get_title_and_type( + self, + summary_id: str, + end_user_id: str + ) -> Tuple[str, str]: + """ + 读取情景记忆的标题(title)和类型(type) + + 仅负责读取已存在的title和type,不进行生成 + title从name属性读取,type从memory_type属性读取 + + Args: + summary_id: Summary节点的ID + end_user_id: 终端用户ID (group_id) + + Returns: + (标题, 类型)元组,如果不存在则返回默认值 + """ + try: + # 查询Summary节点的name(作为title)和memory_type(作为type) + query = """ + MATCH (s:MemorySummary) + WHERE elementId(s) = $summary_id AND s.group_id = $group_id + RETURN s.name AS title, s.memory_type AS type + """ + + result = await self.neo4j_connector.execute_query( + query, + summary_id=summary_id, + group_id=end_user_id + ) + + if not result or len(result) == 0: + logger.warning(f"未找到 summary_id={summary_id} 的节点") + return ("未知标题", "其他") + + record = result[0] + title = record.get("title") or "未命名" + episodic_type = record.get("type") or "其他" + + return (title, episodic_type) + + except Exception as e: + logger.error(f"读取标题和类型时出错: {str(e)}", exc_info=True) + return ("错误", "其他") + + async def _extract_involved_objects( + self, + summary_id: str, + end_user_id: str + ) -> List[str]: + """ + 提取情景记忆涉及的前3个最重要实体 + + Args: + summary_id: Summary节点的ID + end_user_id: 终端用户ID (group_id) + + Returns: + 前3个实体的name属性列表 + """ + try: + # 查询Summary节点指向的Statement节点,再查询Statement指向的ExtractedEntity节点 + # 按activation_value降序排序,返回前3个 + query = """ + MATCH (s:MemorySummary) + WHERE elementId(s) = $summary_id AND s.group_id = $group_id + MATCH (s)-[:DERIVED_FROM_STATEMENT]->(stmt:Statement) + MATCH (stmt)-[:REFERENCES_ENTITY]->(entity:ExtractedEntity) + WHERE entity.activation_value IS NOT NULL + RETURN DISTINCT entity.name AS name, entity.activation_value AS activation + ORDER BY activation DESC + LIMIT 3 + """ + + result = await self.neo4j_connector.execute_query( + query, + summary_id=summary_id, + group_id=end_user_id + ) + + # 提取实体名称 + involved_objects = [record["name"] for record in result if record.get("name")] + + logger.info(f"成功提取 summary_id={summary_id} 的涉及对象: {involved_objects}") + + return involved_objects + + except Exception as e: + logger.error(f"提取涉及对象时出错: {str(e)}", exc_info=True) + return [] + + async def _extract_content_records( + self, + summary_id: str, + end_user_id: str + ) -> List[str]: + """ + 提取情景记忆的内容记录 + + Args: + summary_id: Summary节点的ID + end_user_id: 终端用户ID (group_id) + + Returns: + 所有Statement节点的statement属性内容列表 + """ + try: + # 查询Summary节点指向的所有Statement节点 + query = """ + MATCH (s:MemorySummary) + WHERE elementId(s) = $summary_id AND s.group_id = $group_id + MATCH (s)-[:DERIVED_FROM_STATEMENT]->(stmt:Statement) + WHERE stmt.statement IS NOT NULL AND stmt.statement <> '' + RETURN stmt.statement AS statement + """ + + result = await self.neo4j_connector.execute_query( + query, + summary_id=summary_id, + group_id=end_user_id + ) + + # 提取statement内容 + content_records = [record["statement"] for record in result if record.get("statement")] + + logger.info(f"成功提取 summary_id={summary_id} 的内容记录,共 {len(content_records)} 条") + + return content_records + + except Exception as e: + logger.error(f"提取内容记录时出错: {str(e)}", exc_info=True) + return [] + + def _calculate_time_filter(self, time_range: str) -> Optional[str]: + """ + 根据时间范围计算过滤的起始时间 + + Args: + time_range: 时间范围 (all/today/this_week/this_month) + + Returns: + ISO格式的时间字符串,如果是"all"则返回None + """ + if time_range == "all": + return None + + # 获取当前时间(UTC) + now = datetime.now(pytz.UTC) + + if time_range == "today": + # 今天的开始时间(00:00:00) + start_time = now.replace(hour=0, minute=0, second=0, microsecond=0) + elif time_range == "this_week": + # 本周的开始时间(周一00:00:00) + days_since_monday = now.weekday() + start_time = (now - timedelta(days=days_since_monday)).replace( + hour=0, minute=0, second=0, microsecond=0 + ) + elif time_range == "this_month": + # 本月的开始时间(1号00:00:00) + start_time = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0) + else: + return None + + # 返回ISO格式字符串 + return start_time.isoformat() + + async def get_episodic_memory_overview( + self, + end_user_id: str, + time_range: str = "all", + episodic_type: str = "all", + title_keyword: Optional[str] = None + ) -> Dict[str, Any]: + """ + 获取情景记忆总览信息 + + Args: + end_user_id: 终端用户ID + time_range: 时间范围筛选 + episodic_type: 情景类型筛选 + title_keyword: 标题关键词(可选,用于模糊搜索) + """ + try: + logger.info( + f"开始查询 end_user_id={end_user_id} 的情景记忆总览, " + f"time_range={time_range}, episodic_type={episodic_type}, title_keyword={title_keyword}" + ) + + # 1. 先查询所有情景记忆的总数(不受筛选条件限制) + total_all_query = """ + MATCH (s:MemorySummary) + WHERE s.group_id = $group_id + RETURN count(s) AS total_all + """ + total_all_result = await self.neo4j_connector.execute_query( + total_all_query, + group_id=end_user_id + ) + total_all = total_all_result[0]["total_all"] if total_all_result else 0 + + # 2. 计算时间范围的起始时间戳 + time_filter = self._calculate_time_filter(time_range) + + # 3. 构建Cypher查询 + query = """ + MATCH (s:MemorySummary) + WHERE s.group_id = $group_id + """ + + # 添加时间范围过滤 + if time_filter: + query += " AND s.created_at >= $time_filter" + + # 添加标题关键词过滤(如果提供了title_keyword) + if title_keyword: + query += " AND toLower(s.name) CONTAINS toLower($title_keyword)" + + query += """ + RETURN elementId(s) AS id, + s.created_at AS created_at, + s.memory_type AS type, + s.name AS title + ORDER BY s.created_at DESC + """ + + params = {"group_id": end_user_id} + if time_filter: + params["time_filter"] = time_filter + if title_keyword: + params["title_keyword"] = title_keyword + + result = await self.neo4j_connector.execute_query(query, **params) + + # 4. 如果没有数据,返回空列表 + if not result: + logger.info(f"end_user_id={end_user_id} 没有情景记忆数据") + return { + "total": 0, + "total_all": total_all, + "episodic_memories": [] + } + + # 5. 对每个节点读取标题和类型,并应用类型筛选 + episodic_memories = [] + for record in result: + summary_id = record["id"] + created_at_str = record.get("created_at") + memory_type = record.get("type", "其他") + title = record.get("title") or "未命名" # 直接从查询结果获取标题 + + # 应用情景类型筛选 + if episodic_type != "all": + # 检查类型是否匹配 + # 注意:Neo4j 中存储的 memory_type 现在应该是英文格式(如 "conversation", "project_work") + # 但为了兼容旧数据,我们也支持中文格式的匹配 + type_mapping = { + "conversation": "对话", + "project_work": "项目/工作", + "learning": "学习", + "decision": "决策", + "important_event": "重要事件" + } + + # 获取对应的中文类型(用于兼容旧数据) + chinese_type = type_mapping.get(episodic_type) + + # 检查类型是否匹配(支持新的英文格式和旧的中文格式) + if memory_type != episodic_type and memory_type != chinese_type: + continue + + # 使用基类方法转换时间戳 + created_at_timestamp = self.parse_timestamp(created_at_str) + + episodic_memories.append({ + "id": summary_id, + "title": title, + "type": memory_type, + "created_at": created_at_timestamp + }) + + logger.info( + f"成功获取 end_user_id={end_user_id} 的情景记忆总览," + f"筛选后 {len(episodic_memories)} 条,总共 {total_all} 条" + ) + + return { + "total": len(episodic_memories), + "total_all": total_all, + "episodic_memories": episodic_memories + } + + except Exception as e: + logger.error(f"获取情景记忆总览时出错: {str(e)}", exc_info=True) + raise + + async def get_episodic_memory_details( + self, + end_user_id: str, + summary_id: str + ) -> Dict[str, Any]: + """ + 获取单个情景记忆的详细信息 + + """ + try: + logger.info(f"开始查询 end_user_id={end_user_id}, summary_id={summary_id} 的情景记忆详情") + + # 1. 查询指定的MemorySummary节点 + query = """ + MATCH (s:MemorySummary) + WHERE elementId(s) = $summary_id AND s.group_id = $group_id + RETURN elementId(s) AS id, s.created_at AS created_at + """ + + result = await self.neo4j_connector.execute_query( + query, + summary_id=summary_id, + group_id=end_user_id + ) + + # 2. 如果节点不存在,返回错误 + if not result or len(result) == 0: + logger.warning(f"未找到 summary_id={summary_id} 的情景记忆") + raise ValueError(f"情景记忆不存在: summary_id={summary_id}") + + # 3. 获取基本信息 + record = result[0] + created_at_str = record.get("created_at") + + # 使用基类方法转换时间戳 + created_at_timestamp = self.parse_timestamp(created_at_str) + + # 4. 调用_get_title_and_type读取标题和类型 + title, episodic_type = await self._get_title_and_type( + summary_id=summary_id, + end_user_id=end_user_id + ) + + # 5. 调用_extract_involved_objects提取涉及对象 + involved_objects = await self._extract_involved_objects( + summary_id=summary_id, + end_user_id=end_user_id + ) + + # 6. 调用_extract_content_records提取内容记录 + content_records = await self._extract_content_records( + summary_id=summary_id, + end_user_id=end_user_id + ) + + # 7. 使用基类方法提取情绪 + emotion = await self.extract_episodic_emotion( + summary_id=summary_id, + end_user_id=end_user_id + ) + + # 8. 返回完整的详情信息 + details = { + "id": summary_id, + "created_at": created_at_timestamp, + "involved_objects": involved_objects, + "episodic_type": episodic_type, + "content_records": content_records, + "emotion": emotion + } + + logger.info(f"成功获取 summary_id={summary_id} 的情景记忆详情") + + return details + + except ValueError: + # 重新抛出ValueError,让Controller层处理 + raise + except Exception as e: + logger.error(f"获取情景记忆详情时出错: {str(e)}", exc_info=True) + raise + + +# 创建全局服务实例 +memory_episodic_service = MemoryEpisodicService() diff --git a/api/app/services/memory_explicit_service.py b/api/app/services/memory_explicit_service.py new file mode 100644 index 00000000..713215c3 --- /dev/null +++ b/api/app/services/memory_explicit_service.py @@ -0,0 +1,274 @@ +""" +显性记忆服务 + +处理显性记忆相关的业务逻辑,包括情景记忆和语义记忆的查询。 +""" + +from typing import Any, Dict + +from app.core.logging_config import get_logger +from app.services.memory_base_service import MemoryBaseService + +logger = get_logger(__name__) + + +class MemoryExplicitService(MemoryBaseService): + """显性记忆服务类""" + + def __init__(self): + super().__init__() + logger.info("MemoryExplicitService initialized") + + async def get_explicit_memory_overview( + self, + end_user_id: str + ) -> Dict[str, Any]: + """ + 获取显性记忆总览信息 + + 返回两部分: + 1. 情景记忆(episodic_memories)- 来自MemorySummary节点 + 2. 语义记忆(semantic_memories)- 来自ExtractedEntity节点(is_explicit_memory=true) + + Args: + end_user_id: 终端用户ID + + Returns: + { + "total": int, + "episodic_memories": [ + { + "id": str, + "title": str, + "content": str, + "created_at": int + } + ], + "semantic_memories": [ + { + "id": str, + "name": str, + "entity_type": str, + "core_definition": str + } + ] + } + """ + try: + logger.info(f"开始查询 end_user_id={end_user_id} 的显性记忆总览(情景记忆+语义记忆)") + + # ========== 1. 查询情景记忆(MemorySummary节点) ========== + episodic_query = """ + MATCH (s:MemorySummary) + WHERE s.group_id = $group_id + RETURN elementId(s) AS id, + s.name AS title, + s.content AS content, + s.created_at AS created_at + ORDER BY s.created_at DESC + """ + + episodic_result = await self.neo4j_connector.execute_query( + episodic_query, + group_id=end_user_id + ) + + # 处理情景记忆数据 + episodic_memories = [] + if episodic_result: + for record in episodic_result: + summary_id = record["id"] + title = record.get("title") or "未命名" + content = record.get("content") or "" + created_at_str = record.get("created_at") + + # 使用基类方法转换时间戳 + created_at_timestamp = self.parse_timestamp(created_at_str) + + # 注意:总览接口不返回 emotion 字段 + episodic_memories.append({ + "id": summary_id, + "title": title, + "content": content, + "created_at": created_at_timestamp + }) + + # ========== 2. 查询语义记忆(ExtractedEntity节点) ========== + semantic_query = """ + MATCH (e:ExtractedEntity) + WHERE e.group_id = $group_id + AND e.is_explicit_memory = true + RETURN elementId(e) AS id, + e.name AS name, + e.entity_type AS entity_type, + e.description AS core_definition + ORDER BY e.name ASC + """ + + semantic_result = await self.neo4j_connector.execute_query( + semantic_query, + group_id=end_user_id + ) + + # 处理语义记忆数据 + semantic_memories = [] + if semantic_result: + for record in semantic_result: + entity_id = record["id"] + name = record.get("name") or "未命名" + entity_type = record.get("entity_type") or "未分类" + core_definition = record.get("core_definition") or "" + + # 注意:总览接口不返回 detailed_notes 和 created_at 字段 + semantic_memories.append({ + "id": entity_id, + "name": name, + "entity_type": entity_type, + "core_definition": core_definition + }) + + # ========== 3. 返回结果 ========== + total_count = len(episodic_memories) + len(semantic_memories) + + logger.info( + f"成功获取 end_user_id={end_user_id} 的显性记忆总览," + f"情景记忆={len(episodic_memories)} 条,语义记忆={len(semantic_memories)} 条," + f"总计 {total_count} 条" + ) + + return { + "total": total_count, + "episodic_memories": episodic_memories, + "semantic_memories": semantic_memories + } + + except Exception as e: + logger.error(f"获取显性记忆总览时出错: {str(e)}", exc_info=True) + raise + + async def get_explicit_memory_details( + self, + end_user_id: str, + memory_id: str + ) -> Dict[str, Any]: + """ + 获取显性记忆详情 + + 根据 memory_id 查询情景记忆或语义记忆的详细信息。 + 先尝试查询情景记忆,如果找不到再查询语义记忆。 + + Args: + end_user_id: 终端用户ID + memory_id: 记忆ID(可以是情景记忆或语义记忆的ID) + + Returns: + 情景记忆返回: + { + "memory_type": "episodic", + "title": str, + "content": str, + "emotion": Dict, + "created_at": int + } + + 语义记忆返回: + { + "memory_type": "semantic", + "name": str, + "core_definition": str, + "detailed_notes": str, + "created_at": int + } + + Raises: + ValueError: 当记忆不存在时 + """ + try: + logger.info(f"开始查询显性记忆详情: end_user_id={end_user_id}, memory_id={memory_id}") + + # ========== 1. 先尝试查询情景记忆 ========== + episodic_query = """ + MATCH (s:MemorySummary) + WHERE elementId(s) = $memory_id AND s.group_id = $group_id + RETURN s.name AS title, + s.content AS content, + s.created_at AS created_at + """ + + episodic_result = await self.neo4j_connector.execute_query( + episodic_query, + memory_id=memory_id, + group_id=end_user_id + ) + + if episodic_result and len(episodic_result) > 0: + record = episodic_result[0] + title = record.get("title") or "未命名" + content = record.get("content") or "" + created_at_str = record.get("created_at") + + # 使用基类方法转换时间戳 + created_at_timestamp = self.parse_timestamp(created_at_str) + + # 使用基类方法获取情绪信息 + emotion = await self.extract_episodic_emotion( + summary_id=memory_id, + end_user_id=end_user_id + ) + + logger.info(f"成功获取情景记忆详情: memory_id={memory_id}") + return { + "memory_type": "episodic", + "title": title, + "content": content, + "emotion": emotion, + "created_at": created_at_timestamp + } + + # ========== 2. 如果不是情景记忆,尝试查询语义记忆 ========== + semantic_query = """ + MATCH (e:ExtractedEntity) + WHERE elementId(e) = $memory_id + AND e.group_id = $group_id + AND e.is_explicit_memory = true + RETURN e.name AS name, + e.description AS core_definition, + e.example AS detailed_notes, + e.created_at AS created_at + """ + + semantic_result = await self.neo4j_connector.execute_query( + semantic_query, + memory_id=memory_id, + group_id=end_user_id + ) + + if semantic_result and len(semantic_result) > 0: + record = semantic_result[0] + name = record.get("name") or "未命名" + core_definition = record.get("core_definition") or "" + detailed_notes = record.get("detailed_notes") or "" + created_at_str = record.get("created_at") + + # 使用基类方法转换时间戳 + created_at_timestamp = self.parse_timestamp(created_at_str) + + logger.info(f"成功获取语义记忆详情: memory_id={memory_id}") + return { + "memory_type": "semantic", + "name": name, + "core_definition": core_definition, + "detailed_notes": detailed_notes, + "created_at": created_at_timestamp + } + + # ========== 3. 两种记忆都找不到 ========== + logger.warning(f"记忆不存在: memory_id={memory_id}, end_user_id={end_user_id}") + raise ValueError(f"记忆不存在: memory_id={memory_id}") + + except ValueError: + # 重新抛出 ValueError(记忆不存在) + raise + except Exception as e: + logger.error(f"获取显性记忆详情时出错: {str(e)}", exc_info=True) + raise diff --git a/api/app/services/user_memory_service.py b/api/app/services/user_memory_service.py index b77a4ada..bfb05d47 100644 --- a/api/app/services/user_memory_service.py +++ b/api/app/services/user_memory_service.py @@ -883,866 +883,6 @@ class UserMemoryService: "failed": failed, "errors": errors + [{"error": f"批量处理失败: {str(e)}"}] } - - async def _get_title_and_type( - self, - summary_id: str, - end_user_id: str - ) -> Tuple[str, str]: - """ - 读取情景记忆的标题(title)和类型(type) - - 仅负责读取已存在的title和type,不进行生成 - title从name属性读取,type从memory_type属性读取 - - Args: - summary_id: Summary节点的ID - end_user_id: 终端用户ID (group_id) - - Returns: - (标题, 类型)元组,如果不存在则返回默认值 - """ - try: - # 查询Summary节点的name(作为title)和memory_type(作为type) - query = """ - MATCH (s:MemorySummary) - WHERE elementId(s) = $summary_id AND s.group_id = $group_id - RETURN s.name AS title, s.memory_type AS type - """ - - result = await self.neo4j_connector.execute_query( - query, - summary_id=summary_id, - group_id=end_user_id - ) - - if not result or len(result) == 0: - logger.warning(f"未找到 summary_id={summary_id} 的节点") - return ("未知标题", "其他") - - record = result[0] - title = record.get("title") or "未命名" - episodic_type = record.get("type") or "其他" - - return (title, episodic_type) - - except Exception as e: - logger.error(f"读取标题和类型时出错: {str(e)}", exc_info=True) - return ("错误", "其他") - - @staticmethod - async def generate_title_and_type_for_summary( - content: str, - end_user_id: str - ) -> Tuple[str, str]: - """ - 为MemorySummary生成标题和类型(静态方法,用于创建节点时调用) - - 此方法应该在创建MemorySummary节点时调用,生成title和type - - Args: - content: Summary的内容文本 - end_user_id: 终端用户ID (group_id) - - Returns: - (标题, 类型)元组 - """ - from app.core.memory.utils.prompt.prompt_utils import render_episodic_title_and_type_prompt - import json - - # 定义有效的类型集合 - VALID_TYPES = { - "conversation", # 对话 - "project_work", # 项目/工作 - "learning", # 学习 - "decision", # 决策 - "important_event" # 重要事件 - } - DEFAULT_TYPE = "conversation" # 默认类型 - - try: - if not content: - logger.warning("content为空,无法生成标题和类型") - return ("空内容", DEFAULT_TYPE) - - # 1. 渲染Jinja2提示词模板 - prompt = await render_episodic_title_and_type_prompt(content) - - # 2. 调用LLM生成标题和类型 - llm_client = _get_llm_client_for_user(end_user_id) - messages = [ - {"role": "user", "content": prompt} - ] - - response = await llm_client.chat(messages=messages) - - # 3. 解析LLM响应 - content_response = response.content - if isinstance(content_response, list): - if len(content_response) > 0: - if isinstance(content_response[0], dict): - text = content_response[0].get('text', content_response[0].get('content', str(content_response[0]))) - full_response = str(text) - else: - full_response = str(content_response[0]) - else: - full_response = "" - elif isinstance(content_response, dict): - full_response = str(content_response.get('text', content_response.get('content', str(content_response)))) - else: - full_response = str(content_response) if content_response is not None else "" - - # 4. 解析JSON响应 - try: - # 尝试从响应中提取JSON - # 移除可能的markdown代码块标记 - json_str = full_response.strip() - if json_str.startswith("```json"): - json_str = json_str[7:] - if json_str.startswith("```"): - json_str = json_str[3:] - if json_str.endswith("```"): - json_str = json_str[:-3] - json_str = json_str.strip() - - result_data = json.loads(json_str) - title = result_data.get("title", "未知标题") - episodic_type_raw = result_data.get("type", DEFAULT_TYPE) - - # 5. 校验和归一化类型 - # 将类型转换为小写并去除空格 - episodic_type_normalized = str(episodic_type_raw).lower().strip() - - # 检查是否在有效类型集合中 - if episodic_type_normalized in VALID_TYPES: - episodic_type = episodic_type_normalized - else: - # 尝试映射常见的中文类型到英文 - type_mapping = { - "对话": "conversation", - "项目": "project_work", - "工作": "project_work", - "项目/工作": "project_work", - "学习": "learning", - "决策": "decision", - "重要事件": "important_event", - "事件": "important_event" - } - episodic_type = type_mapping.get(episodic_type_raw, DEFAULT_TYPE) - logger.warning( - f"LLM返回的类型 '{episodic_type_raw}' 不在有效集合中," - f"已归一化为 '{episodic_type}'" - ) - - logger.info(f"成功生成标题和类型: title={title}, type={episodic_type}") - return (title, episodic_type) - - except json.JSONDecodeError: - logger.error(f"无法解析LLM响应为JSON: {full_response}") - return ("解析失败", DEFAULT_TYPE) - - except Exception as e: - logger.error(f"生成标题和类型时出错: {str(e)}", exc_info=True) - return ("错误", DEFAULT_TYPE) - - async def _extract_involved_objects( - self, - summary_id: str, - end_user_id: str - ) -> List[str]: - """ - 提取情景记忆涉及的前3个最重要实体 - - Args: - summary_id: Summary节点的ID - end_user_id: 终端用户ID (group_id) - - Returns: - 前3个实体的name属性列表 - """ - try: - # 查询Summary节点指向的Statement节点,再查询Statement指向的ExtractedEntity节点 - # 按activation_value降序排序,返回前3个 - query = """ - MATCH (s:MemorySummary) - WHERE elementId(s) = $summary_id AND s.group_id = $group_id - MATCH (s)-[:DERIVED_FROM_STATEMENT]->(stmt:Statement) - MATCH (stmt)-[:REFERENCES_ENTITY]->(entity:ExtractedEntity) - WHERE entity.activation_value IS NOT NULL - RETURN DISTINCT entity.name AS name, entity.activation_value AS activation - ORDER BY activation DESC - LIMIT 3 - """ - - result = await self.neo4j_connector.execute_query( - query, - summary_id=summary_id, - group_id=end_user_id - ) - - # 提取实体名称 - involved_objects = [record["name"] for record in result if record.get("name")] - - logger.info(f"成功提取 summary_id={summary_id} 的涉及对象: {involved_objects}") - - return involved_objects - - except Exception as e: - logger.error(f"提取涉及对象时出错: {str(e)}", exc_info=True) - return [] - - async def _extract_content_records( - self, - summary_id: str, - end_user_id: str - ) -> List[str]: - """ - 提取情景记忆的内容记录 - - Args: - summary_id: Summary节点的ID - end_user_id: 终端用户ID (group_id) - - Returns: - 所有Statement节点的statement属性内容列表 - """ - try: - # 查询Summary节点指向的所有Statement节点 - query = """ - MATCH (s:MemorySummary) - WHERE elementId(s) = $summary_id AND s.group_id = $group_id - MATCH (s)-[:DERIVED_FROM_STATEMENT]->(stmt:Statement) - WHERE stmt.statement IS NOT NULL AND stmt.statement <> '' - RETURN stmt.statement AS statement - """ - - result = await self.neo4j_connector.execute_query( - query, - summary_id=summary_id, - group_id=end_user_id - ) - - # 提取statement内容 - content_records = [record["statement"] for record in result if record.get("statement")] - - logger.info(f"成功提取 summary_id={summary_id} 的内容记录,共 {len(content_records)} 条") - - return content_records - - except Exception as e: - logger.error(f"提取内容记录时出错: {str(e)}", exc_info=True) - return [] - - async def _extract_episodic_emotion( - self, - summary_id: str, - end_user_id: str - ) -> Optional[str]: - """ - 提取情景记忆的主要情绪 - - Args: - summary_id: Summary节点的ID - end_user_id: 终端用户ID (group_id) - - Returns: - 最大emotion_intensity对应的emotion_type,如果没有则返回None - """ - try: - # 查询Summary节点指向的所有Statement节点 - # 筛选具有emotion_type属性的节点 - # 按emotion_intensity降序排序,返回第一个 - query = """ - MATCH (s:MemorySummary) - WHERE elementId(s) = $summary_id AND s.group_id = $group_id - MATCH (s)-[:DERIVED_FROM_STATEMENT]->(stmt:Statement) - WHERE stmt.emotion_type IS NOT NULL - AND stmt.emotion_intensity IS NOT NULL - RETURN stmt.emotion_type AS emotion_type, - stmt.emotion_intensity AS emotion_intensity - ORDER BY emotion_intensity DESC - LIMIT 1 - """ - - result = await self.neo4j_connector.execute_query( - query, - summary_id=summary_id, - group_id=end_user_id - ) - - # 提取emotion_type - if result and len(result) > 0: - emotion_type = result[0].get("emotion_type") - logger.info(f"成功提取 summary_id={summary_id} 的情绪: {emotion_type}") - return emotion_type - else: - logger.info(f"summary_id={summary_id} 没有情绪信息") - return None - - except Exception as e: - logger.error(f"提取情景记忆情绪时出错: {str(e)}", exc_info=True) - return None - - async def get_episodic_memory_overview( - self, - db: Session, - end_user_id: str, - time_range: str = "all", - episodic_type: str = "all", - title_keyword: Optional[str] = None - ) -> Dict[str, Any]: - """ - 获取情景记忆总览信息 - - Args: - db: 数据库会话 - end_user_id: 终端用户ID - time_range: 时间范围筛选 - episodic_type: 情景类型筛选 - title_keyword: 标题关键词(可选,用于模糊搜索) - """ - try: - logger.info( - f"开始查询 end_user_id={end_user_id} 的情景记忆总览, " - f"time_range={time_range}, episodic_type={episodic_type}, title_keyword={title_keyword}" - ) - - # 1. 先查询所有情景记忆的总数(不受筛选条件限制) - total_all_query = """ - MATCH (s:MemorySummary) - WHERE s.group_id = $group_id - RETURN count(s) AS total_all - """ - total_all_result = await self.neo4j_connector.execute_query( - total_all_query, - group_id=end_user_id - ) - total_all = total_all_result[0]["total_all"] if total_all_result else 0 - - # 2. 计算时间范围的起始时间戳 - time_filter = self._calculate_time_filter(time_range) - - # 3. 构建Cypher查询 - query = """ - MATCH (s:MemorySummary) - WHERE s.group_id = $group_id - """ - - # 添加时间范围过滤 - if time_filter: - query += " AND s.created_at >= $time_filter" - - # 添加标题关键词过滤(如果提供了title_keyword) - if title_keyword: - query += " AND toLower(s.name) CONTAINS toLower($title_keyword)" - - query += """ - RETURN elementId(s) AS id, - s.created_at AS created_at, - s.memory_type AS type, - s.name AS title - ORDER BY s.created_at DESC - """ - - params = {"group_id": end_user_id} - if time_filter: - params["time_filter"] = time_filter - if title_keyword: - params["title_keyword"] = title_keyword - - result = await self.neo4j_connector.execute_query(query, **params) - - # 4. 如果没有数据,返回空列表 - if not result: - logger.info(f"end_user_id={end_user_id} 没有情景记忆数据") - return { - "total": 0, - "total_all": total_all, - "episodic_memories": [] - } - - # 5. 对每个节点读取标题和类型,并应用类型筛选 - episodic_memories = [] - for record in result: - summary_id = record["id"] - created_at_str = record.get("created_at") - memory_type = record.get("type", "其他") - title = record.get("title") or "未命名" # 直接从查询结果获取标题 - - # 应用情景类型筛选 - if episodic_type != "all": - # 检查类型是否匹配 - # 注意:Neo4j 中存储的 memory_type 现在应该是英文格式(如 "conversation", "project_work") - # 但为了兼容旧数据,我们也支持中文格式的匹配 - type_mapping = { - "conversation": "对话", - "project_work": "项目/工作", - "learning": "学习", - "decision": "决策", - "important_event": "重要事件" - } - - # 获取对应的中文类型(用于兼容旧数据) - chinese_type = type_mapping.get(episodic_type) - - # 检查类型是否匹配(支持新的英文格式和旧的中文格式) - if memory_type != episodic_type and memory_type != chinese_type: - continue - - # 转换时间戳 - created_at_timestamp = None - if created_at_str: - try: - from datetime import datetime - dt_object = datetime.fromisoformat(created_at_str.replace("Z", "+00:00")) - created_at_timestamp = int(dt_object.timestamp() * 1000) - except (ValueError, TypeError, AttributeError) as e: - logger.warning(f"无法解析时间戳: {created_at_str}, error={str(e)}") - - episodic_memories.append({ - "id": summary_id, - "title": title, - "type": memory_type, - "created_at": created_at_timestamp - }) - - logger.info( - f"成功获取 end_user_id={end_user_id} 的情景记忆总览," - f"筛选后 {len(episodic_memories)} 条,总共 {total_all} 条" - ) - - return { - "total": len(episodic_memories), - "total_all": total_all, - "episodic_memories": episodic_memories - } - - except Exception as e: - logger.error(f"获取情景记忆总览时出错: {str(e)}", exc_info=True) - raise - - def _calculate_time_filter(self, time_range: str) -> Optional[str]: - """ - 根据时间范围计算过滤的起始时间 - - Args: - time_range: 时间范围 (all/today/this_week/this_month) - - Returns: - ISO格式的时间字符串,如果是"all"则返回None - """ - from datetime import datetime, timedelta - import pytz - - if time_range == "all": - return None - - # 获取当前时间(UTC) - now = datetime.now(pytz.UTC) - - if time_range == "today": - # 今天的开始时间(00:00:00) - start_time = now.replace(hour=0, minute=0, second=0, microsecond=0) - elif time_range == "this_week": - # 本周的开始时间(周一00:00:00) - days_since_monday = now.weekday() - start_time = (now - timedelta(days=days_since_monday)).replace( - hour=0, minute=0, second=0, microsecond=0 - ) - elif time_range == "this_month": - # 本月的开始时间(1号00:00:00) - start_time = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0) - else: - return None - - # 返回ISO格式字符串 - return start_time.isoformat() - - async def get_episodic_memory_details( - self, - db: Session, - end_user_id: str, - summary_id: str - ) -> Dict[str, Any]: - """ - 获取单个情景记忆的详细信息 - - """ - try: - logger.info(f"开始查询 end_user_id={end_user_id}, summary_id={summary_id} 的情景记忆详情") - - # 1. 查询指定的MemorySummary节点 - query = """ - MATCH (s:MemorySummary) - WHERE elementId(s) = $summary_id AND s.group_id = $group_id - RETURN elementId(s) AS id, s.created_at AS created_at - """ - - result = await self.neo4j_connector.execute_query( - query, - summary_id=summary_id, - group_id=end_user_id - ) - - # 2. 如果节点不存在,返回错误 - if not result or len(result) == 0: - logger.warning(f"未找到 summary_id={summary_id} 的情景记忆") - raise ValueError(f"情景记忆不存在: summary_id={summary_id}") - - # 3. 获取基本信息 - record = result[0] - created_at_str = record.get("created_at") - - # 转换时间戳 - created_at_timestamp = None - if created_at_str: - try: - from datetime import datetime - dt_object = datetime.fromisoformat(created_at_str.replace("Z", "+00:00")) - created_at_timestamp = int(dt_object.timestamp() * 1000) - except (ValueError, TypeError, AttributeError) as e: - logger.warning(f"无法解析时间戳: {created_at_str}, error={str(e)}") - - # 4. 调用_get_title_and_type读取标题和类型 - title, episodic_type = await self._get_title_and_type( - summary_id=summary_id, - end_user_id=end_user_id - ) - - # 5. 调用_extract_involved_objects提取涉及对象 - involved_objects = await self._extract_involved_objects( - summary_id=summary_id, - end_user_id=end_user_id - ) - - # 6. 调用_extract_content_records提取内容记录 - content_records = await self._extract_content_records( - summary_id=summary_id, - end_user_id=end_user_id - ) - - # 7. 调用_extract_episodic_emotion提取情绪 - emotion = await self._extract_episodic_emotion( - summary_id=summary_id, - end_user_id=end_user_id - ) - - # 8. 返回完整的详情信息 - details = { - "id": summary_id, - "created_at": created_at_timestamp, - "involved_objects": involved_objects, - "episodic_type": episodic_type, - "content_records": content_records, - "emotion": emotion - } - - logger.info(f"成功获取 summary_id={summary_id} 的情景记忆详情") - - return details - - except ValueError: - # 重新抛出ValueError,让Controller层处理 - raise - except Exception as e: - logger.error(f"获取情景记忆详情时出错: {str(e)}", exc_info=True) - raise - - async def get_explicit_memory_overview( - self, - db: Session, - end_user_id: str - ) -> Dict[str, Any]: - """ - 获取显性记忆总览信息 - - 返回两部分: - 1. 情景记忆(episodic_memories)- 来自MemorySummary节点 - 2. 语义记忆(semantic_memories)- 来自ExtractedEntity节点(is_explicit_memory=true) - - Args: - db: 数据库会话 - end_user_id: 终端用户ID - - Returns: - { - "total": int, - "episodic_memories": [ - { - "id": str, - "title": str, - "content": str, - "created_at": int, - "emotion": Dict - } - ], - "semantic_memories": [ - { - "id": str, - "name": str, - "entity_type": str, - "core_definition": str, - "detailed_notes": str, - "created_at": int - } - ] - } - """ - try: - logger.info(f"开始查询 end_user_id={end_user_id} 的显性记忆总览(情景记忆+语义记忆)") - - # ========== 1. 查询情景记忆(MemorySummary节点) ========== - episodic_query = """ - MATCH (s:MemorySummary) - WHERE s.group_id = $group_id - RETURN elementId(s) AS id, - s.name AS title, - s.content AS content, - s.created_at AS created_at - ORDER BY s.created_at DESC - """ - - episodic_result = await self.neo4j_connector.execute_query( - episodic_query, - group_id=end_user_id - ) - - # 处理情景记忆数据 - episodic_memories = [] - if episodic_result: - for record in episodic_result: - summary_id = record["id"] - title = record.get("title") or "未命名" - content = record.get("content") or "" - created_at_str = record.get("created_at") - - # 转换时间戳 - created_at_timestamp = None - if created_at_str: - try: - from datetime import datetime - dt_object = datetime.fromisoformat(created_at_str.replace("Z", "+00:00")) - created_at_timestamp = int(dt_object.timestamp() * 1000) - except (ValueError, TypeError, AttributeError) as e: - logger.warning(f"无法解析时间戳: {created_at_str}, error={str(e)}") - - # 注意:总览接口不返回 emotion 字段 - episodic_memories.append({ - "id": summary_id, - "title": title, - "content": content, - "created_at": created_at_timestamp - }) - - # ========== 2. 查询语义记忆(ExtractedEntity节点) ========== - semantic_query = """ - MATCH (e:ExtractedEntity) - WHERE e.group_id = $group_id - AND e.is_explicit_memory = true - RETURN elementId(e) AS id, - e.name AS name, - e.entity_type AS entity_type, - e.description AS core_definition, - e.example AS detailed_notes, - e.created_at AS created_at - ORDER BY e.created_at DESC - """ - - semantic_result = await self.neo4j_connector.execute_query( - semantic_query, - group_id=end_user_id - ) - - # 处理语义记忆数据 - semantic_memories = [] - if semantic_result: - for record in semantic_result: - entity_id = record["id"] - name = record.get("name") or "未命名" - entity_type = record.get("entity_type") or "未分类" - core_definition = record.get("core_definition") or "" - created_at_str = record.get("created_at") - - # 转换时间戳 - created_at_timestamp = None - if created_at_str: - try: - from datetime import datetime - dt_object = datetime.fromisoformat(created_at_str.replace("Z", "+00:00")) - created_at_timestamp = int(dt_object.timestamp() * 1000) - except (ValueError, TypeError, AttributeError) as e: - logger.warning(f"无法解析时间戳: {created_at_str}, error={str(e)}") - - # 注意:总览接口不返回 detailed_notes 字段 - semantic_memories.append({ - "id": entity_id, - "name": name, - "entity_type": entity_type, - "core_definition": core_definition, - "created_at": created_at_timestamp - }) - - # ========== 3. 返回结果 ========== - total_count = len(episodic_memories) + len(semantic_memories) - - logger.info( - f"成功获取 end_user_id={end_user_id} 的显性记忆总览," - f"情景记忆={len(episodic_memories)} 条,语义记忆={len(semantic_memories)} 条," - f"总计 {total_count} 条" - ) - - return { - "total": total_count, - "episodic_memories": episodic_memories, - "semantic_memories": semantic_memories - } - - except Exception as e: - logger.error(f"获取显性记忆总览时出错: {str(e)}", exc_info=True) - raise - - async def get_explicit_memory_details( - self, - db: Session, - end_user_id: str, - memory_id: str - ) -> Dict[str, Any]: - """ - 获取显性记忆详情 - - 根据 memory_id 查询情景记忆或语义记忆的详细信息。 - 先尝试查询情景记忆,如果找不到再查询语义记忆。 - - Args: - db: 数据库会话 - end_user_id: 终端用户ID - memory_id: 记忆ID(可以是情景记忆或语义记忆的ID) - - Returns: - 情景记忆返回: - { - "memory_type": "episodic", - "title": str, - "content": str, - "emotion": Dict, - "created_at": int - } - - 语义记忆返回: - { - "memory_type": "semantic", - "name": str, - "core_definition": str, - "detailed_notes": str, - "created_at": int - } - - Raises: - ValueError: 当记忆不存在时 - """ - try: - logger.info(f"开始查询显性记忆详情: end_user_id={end_user_id}, memory_id={memory_id}") - - # ========== 1. 先尝试查询情景记忆 ========== - episodic_query = """ - MATCH (s:MemorySummary) - WHERE elementId(s) = $memory_id AND s.group_id = $group_id - RETURN s.name AS title, - s.content AS content, - s.created_at AS created_at - """ - - episodic_result = await self.neo4j_connector.execute_query( - episodic_query, - memory_id=memory_id, - group_id=end_user_id - ) - - if episodic_result and len(episodic_result) > 0: - record = episodic_result[0] - title = record.get("title") or "未命名" - content = record.get("content") or "" - created_at_str = record.get("created_at") - - # 转换时间戳 - created_at_timestamp = None - if created_at_str: - try: - from datetime import datetime - dt_object = datetime.fromisoformat(created_at_str.replace("Z", "+00:00")) - created_at_timestamp = int(dt_object.timestamp() * 1000) - except (ValueError, TypeError, AttributeError) as e: - logger.warning(f"无法解析时间戳: {created_at_str}, error={str(e)}") - - # 获取情绪信息 - emotion = await self._extract_episodic_emotion( - summary_id=memory_id, - end_user_id=end_user_id - ) - - logger.info(f"成功获取情景记忆详情: memory_id={memory_id}") - return { - "memory_type": "episodic", - "title": title, - "content": content, - "emotion": emotion, - "created_at": created_at_timestamp - } - - # ========== 2. 如果不是情景记忆,尝试查询语义记忆 ========== - semantic_query = """ - MATCH (e:ExtractedEntity) - WHERE elementId(e) = $memory_id - AND e.group_id = $group_id - AND e.is_explicit_memory = true - RETURN e.name AS name, - e.description AS core_definition, - e.example AS detailed_notes, - e.created_at AS created_at - """ - - semantic_result = await self.neo4j_connector.execute_query( - semantic_query, - memory_id=memory_id, - group_id=end_user_id - ) - - if semantic_result and len(semantic_result) > 0: - record = semantic_result[0] - name = record.get("name") or "未命名" - core_definition = record.get("core_definition") or "" - detailed_notes = record.get("detailed_notes") or "" - created_at_str = record.get("created_at") - - # 转换时间戳 - created_at_timestamp = None - if created_at_str: - try: - from datetime import datetime - dt_object = datetime.fromisoformat(created_at_str.replace("Z", "+00:00")) - created_at_timestamp = int(dt_object.timestamp() * 1000) - except (ValueError, TypeError, AttributeError) as e: - logger.warning(f"无法解析时间戳: {created_at_str}, error={str(e)}") - - logger.info(f"成功获取语义记忆详情: memory_id={memory_id}") - return { - "memory_type": "semantic", - "name": name, - "core_definition": core_definition, - "detailed_notes": detailed_notes, - "created_at": created_at_timestamp - } - - # ========== 3. 两种记忆都找不到 ========== - logger.warning(f"记忆不存在: memory_id={memory_id}, end_user_id={end_user_id}") - raise ValueError(f"记忆不存在: memory_id={memory_id}") - - except ValueError: - # 重新抛出 ValueError(记忆不存在) - raise - except Exception as e: - logger.error(f"获取显性记忆详情时出错: {str(e)}", exc_info=True) - raise # 独立的分析函数 diff --git a/api/migrations/versions/9ab9b6393f32_20261511.py b/api/migrations/versions/9ab9b6393f32_20261511.py index 8c4a5326..f8bc7418 100644 --- a/api/migrations/versions/9ab9b6393f32_20261511.py +++ b/api/migrations/versions/9ab9b6393f32_20261511.py @@ -27,4 +27,4 @@ def upgrade() -> None: def downgrade() -> None: # ### commands auto generated by Alembic - please adjust! ### op.create_foreign_key(op.f('tool_executions_user_id_fkey'), 'tool_executions', 'users', ['user_id'], ['id']) - # ### end Alembic commands ### + # ### end Alembic commands ### \ No newline at end of file From e187c01dc9ef6f045a44c5d4ccb0422e2a84f20d Mon Sep 17 00:00:00 2001 From: zhaoying Date: Tue, 13 Jan 2026 16:16:46 +0800 Subject: [PATCH 16/27] feat(web): add space config page; user memory page update --- web/src/api/memory.ts | 8 +- web/src/assets/images/menu/spaceConfig.svg | 17 ++ .../assets/images/menu/spaceConfig_active.svg | 17 ++ web/src/assets/images/userMemory/goto.svg | 19 ++ web/src/components/CustomSelect/index.tsx | 45 ++-- web/src/components/SiderMenu/index.tsx | 6 +- web/src/i18n/en.ts | 18 +- web/src/i18n/zh.ts | 18 +- web/src/routes/index.tsx | 1 + web/src/routes/routes.json | 1 + web/src/store/menu.json | 15 ++ web/src/views/SpaceConfig/index.tsx | 118 +++++++++++ web/src/views/SpaceConfig/types.ts | 8 + web/src/views/ToolManagement/constant.ts | 8 +- .../UserMemory/components/ConfigModal.tsx | 127 ----------- web/src/views/UserMemory/index.tsx | 198 ++++++------------ web/src/views/UserMemory/types.ts | 13 +- 17 files changed, 321 insertions(+), 316 deletions(-) create mode 100644 web/src/assets/images/menu/spaceConfig.svg create mode 100644 web/src/assets/images/menu/spaceConfig_active.svg create mode 100644 web/src/assets/images/userMemory/goto.svg create mode 100644 web/src/views/SpaceConfig/index.tsx create mode 100644 web/src/views/SpaceConfig/types.ts delete mode 100644 web/src/views/UserMemory/components/ConfigModal.tsx diff --git a/web/src/api/memory.ts b/web/src/api/memory.ts index 3c0fe6fa..2ecc077f 100644 --- a/web/src/api/memory.ts +++ b/web/src/api/memory.ts @@ -176,10 +176,10 @@ export const getPerceptualTimeline = (end_user: string) => { } // 情景记忆-总览 export const getEpisodicOverview = (data: { end_user_id: string; time_range: string; episodic_type: string; } ) => { - return request.post(`/memory-storage/classifications/episodic-memory`, data) + return request.post(`/memory/episodic-memory/overview`, data) } export const getEpisodicDetail = (data: { end_user_id: string; summary_id: string; } ) => { - return request.post(`/memory-storage/classifications/episodic-memory-details`, data) + return request.post(`/memory/episodic-memory/details`, data) } // 关系演化 export const getRelationshipEvolution = (data: { id: string; label: string; } ) => { @@ -190,10 +190,10 @@ export const getTimelineMemories = (data: { id: string; label: string; }) => { return request.get(`/memory-storage/memory_space/timeline_memories`, data) } export const getExplicitMemory = (end_user_id: string) => { - return request.post(`/memory-storage/classifications/explicit-memory`, { end_user_id }) + return request.post(`/memory/explicit-memory/overview`, { end_user_id }) } export const getExplicitMemoryDetails = (data: { end_user_id: string, memory_id: string; }) => { - return request.post(`/memory-storage/classifications/explicit-memory-details`, data) + return request.post(`/memory/explicit-memory/details`, data) } export const getConversations = (end_user: string) => { return request.get(`/memory/work/${end_user}/conversations`) diff --git a/web/src/assets/images/menu/spaceConfig.svg b/web/src/assets/images/menu/spaceConfig.svg new file mode 100644 index 00000000..bcfeae12 --- /dev/null +++ b/web/src/assets/images/menu/spaceConfig.svg @@ -0,0 +1,17 @@ + + + 模型 (1) + + + + + + + + + + + + + + \ No newline at end of file diff --git a/web/src/assets/images/menu/spaceConfig_active.svg b/web/src/assets/images/menu/spaceConfig_active.svg new file mode 100644 index 00000000..41b25689 --- /dev/null +++ b/web/src/assets/images/menu/spaceConfig_active.svg @@ -0,0 +1,17 @@ + + + 模型 (1) + + + + + + + + + + + + + + \ No newline at end of file diff --git a/web/src/assets/images/userMemory/goto.svg b/web/src/assets/images/userMemory/goto.svg new file mode 100644 index 00000000..a66e2011 --- /dev/null +++ b/web/src/assets/images/userMemory/goto.svg @@ -0,0 +1,19 @@ + + + 编组 13备份 + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/web/src/components/CustomSelect/index.tsx b/web/src/components/CustomSelect/index.tsx index 97ca4e4b..e9ccce74 100644 --- a/web/src/components/CustomSelect/index.tsx +++ b/web/src/components/CustomSelect/index.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState, useCallback, useRef, type FC, type Key } from 'react'; +import { useEffect, useState, type FC, type Key } from 'react'; import { Select } from 'antd' import type { SelectProps, DefaultOptionType } from 'antd/es/select' import { useTranslation } from 'react-i18next'; @@ -26,7 +26,7 @@ interface CustomSelectProps extends Omit { disabled?: boolean; style?: React.CSSProperties; className?: string; - filterOption?: (inputValue: string, option: DefaultOptionType) => boolean; + filterOption?: (inputValue: string, option?: DefaultOptionType) => boolean; } interface OptionType { [key: string]: Key | string | number; @@ -48,44 +48,27 @@ const CustomSelect: FC = ({ }) => { const { t } = useTranslation(); const [options, setOptions] = useState([]); - // 创建防抖定时器引用 - const debounceRef = useRef(); - - // 防抖搜索函数 - const handleSearch = useCallback((value?: string) => { - // 清除之前的定时器 - if (debounceRef.current) { - clearTimeout(debounceRef.current); - } - - // 设置新的定时器 - debounceRef.current = window.setTimeout(() => { - request.get>(url, {...params, [optionFilterProp]: value}).then((res) => { - const data = res; - setOptions(Array.isArray(data) ? data || [] : Array.isArray(data?.items) ? data.items || [] : []); - }); - }, 300); // 300毫秒防抖延迟 - }, [url, params, optionFilterProp]); + // 默认模糊搜索函数 + const defaultFilterOption = (inputValue: string, option?: DefaultOptionType) => { + if (!option || !inputValue) return true; + const label = String(option.children || option.label || ''); + return label.toLowerCase().includes(inputValue.toLowerCase()); + }; // 组件挂载时获取初始数据 useEffect(() => { - handleSearch(); - - // 组件卸载时清除定时器 - return () => { - if (debounceRef.current) { - clearTimeout(debounceRef.current); - } - }; - }, [url, handleSearch]); + request.get>(url, params).then((res) => { + const data = res; + setOptions(Array.isArray(data) ? data || [] : Array.isArray(data?.items) ? data.items || [] : []); + }); + }, []); return ( } diff --git a/web/src/views/Workflow/components/Properties/CycleVarsList/index.tsx b/web/src/views/Workflow/components/Properties/CycleVarsList/index.tsx index c05cce25..4d436af0 100644 --- a/web/src/views/Workflow/components/Properties/CycleVarsList/index.tsx +++ b/web/src/views/Workflow/components/Properties/CycleVarsList/index.tsx @@ -1,6 +1,6 @@ import { type FC } from 'react' import { useTranslation } from 'react-i18next'; -import { Form, Button, Select, Row, Col, Input } from 'antd' +import { Form, Select, Row, Col, Input } from 'antd' import { DeleteOutlined, PlusOutlined } from '@ant-design/icons'; import VariableSelect from '../VariableSelect' @@ -36,7 +36,6 @@ const CycleVarsList: FC = ({ value = [], options, parentName, - onChange, selectedNode, graphRef }) => { @@ -139,12 +138,17 @@ const CycleVarsList: FC = ({ {currentInputType === 'variable' ? ( { + const currentType = value?.[index]?.type; + if (!currentType) return true; + + return option.dataType === currentType + })} /> ) : ( diff --git a/web/src/views/Workflow/components/Properties/GroupVariableList/index.tsx b/web/src/views/Workflow/components/Properties/GroupVariableList/index.tsx index 2b2db0f7..61cdd7b0 100644 --- a/web/src/views/Workflow/components/Properties/GroupVariableList/index.tsx +++ b/web/src/views/Workflow/components/Properties/GroupVariableList/index.tsx @@ -18,8 +18,22 @@ const GroupVariableList: FC = ({ isCanAdd = false }) => { const { t } = useTranslation(); + const form = Form.useFormInstance(); + const value = form.getFieldValue(name) || []; + + console.log('GroupVariableList', value) if (!isCanAdd) { + // Filter options based on first variable's dataType if value exists + let filteredOptions = options; + if (value.length > 0) { + const firstVariableValue = value[0]; + const firstVariable = options.find(opt => `{{${opt.value}}}` === firstVariableValue); + if (firstVariable) { + filteredOptions = options.filter(opt => opt.dataType === firstVariable.dataType); + } + } + return (
@@ -38,7 +52,7 @@ const GroupVariableList: FC = ({ > @@ -77,7 +91,18 @@ const GroupVariableList: FC = ({ > { + const currentGroupValue = value[name]?.value || []; + if (currentGroupValue.length > 0) { + const firstVariableValue = currentGroupValue[0]; + const firstVariable = options.find(opt => `{{${opt.value}}}` === firstVariableValue); + if (firstVariable) { + return options.filter(opt => opt.dataType === firstVariable.dataType); + } + } + return options; + })() + } mode="multiple" /> diff --git a/web/src/views/Workflow/components/Properties/HttpRequest/index.tsx b/web/src/views/Workflow/components/Properties/HttpRequest/index.tsx index bbb3238d..5823c1d8 100644 --- a/web/src/views/Workflow/components/Properties/HttpRequest/index.tsx +++ b/web/src/views/Workflow/components/Properties/HttpRequest/index.tsx @@ -90,7 +90,7 @@ const HttpRequest: FC<{ options: Suggestion[]; selectedNode?: any; graphRef?: an - + vo.dataType === 'string' || vo.dataType === 'number')} variant="outlined" /> @@ -144,7 +144,7 @@ const HttpRequest: FC<{ options: Suggestion[]; selectedNode?: any; graphRef?: an vo.dataType === 'string' || vo.dataType === 'number')} filterBooleanType={true} /> @@ -154,7 +154,7 @@ const HttpRequest: FC<{ options: Suggestion[]; selectedNode?: any; graphRef?: an vo.dataType === 'string' || vo.dataType === 'number')} isArray={false} title="JSON" /> diff --git a/web/src/views/Workflow/components/Properties/index.tsx b/web/src/views/Workflow/components/Properties/index.tsx index fe9dbf31..9fcc8821 100644 --- a/web/src/views/Workflow/components/Properties/index.tsx +++ b/web/src/views/Workflow/components/Properties/index.tsx @@ -22,6 +22,7 @@ import ConditionList from './ConditionList' import CycleVarsList from './CycleVarsList' import AssignmentList from './AssignmentList' import ToolConfig from './ToolConfig' +import { calculateVariableList } from './utils/variableListCalculator' interface PropertiesProps { selectedNode?: Node | null; @@ -338,112 +339,35 @@ const Properties: FC = ({ const parentLoopNode = getParentLoopNode(selectedNode.id); console.log('childNodeIds', selectedNode, childNodeIds) - const allRelevantNodeIds = [...allPreviousNodeIds, ...childNodeIds]; + let allRelevantNodeIds = [...allPreviousNodeIds, ...childNodeIds]; - // Add parent loop/iteration node variables if current node is a child + // Add variables from nodes preceding the parent loop/iteration node if current node is a child if (parentLoopNode) { - const parentData = parentLoopNode.getData(); - const parentNodeId = parentLoopNode.getData().id; - - if (parentData.type === 'loop') { - const cycleVars = parentData.cycle_vars || []; - cycleVars.forEach((cycleVar: any) => { - const key = `${parentNodeId}_cycle_${cycleVar.name}`; - if (!addedKeys.has(key)) { - addedKeys.add(key); - variableList.push({ - key, - label: cycleVar.name, - type: 'variable', - dataType: cycleVar.type || 'String', - value: `${parentNodeId}.${cycleVar.name}`, - nodeData: parentData, - }); - } - }); - } else if (parentData.type === 'iteration') { - // Add item and index variables for iteration parent - const itemKey = `${parentNodeId}_item`; - const indexKey = `${parentNodeId}_index`; - - if (!addedKeys.has(itemKey)) { - addedKeys.add(itemKey); - variableList.push({ - key: itemKey, - label: 'item', - type: 'variable', - dataType: 'Object', - value: `${parentNodeId}.item`, - nodeData: parentData, - }); - } - - if (!addedKeys.has(indexKey)) { - addedKeys.add(indexKey); - variableList.push({ - key: indexKey, - label: 'index', - type: 'variable', - dataType: 'Number', - value: `${parentNodeId}.index`, - nodeData: parentData, - }); - } - } - - // Check if parent loop/iteration is connected to http-request via ERROR connection - if (parentData.type === 'loop' || parentData.type === 'iteration') { - const parentPreviousNodeIds = getAllPreviousNodes(parentLoopNode.id); - parentPreviousNodeIds.forEach(prevNodeId => { - const prevNode = nodes.find(n => n.id === prevNodeId); - if (!prevNode) return; - - const prevNodeData = prevNode.getData(); - if (prevNodeData.type === 'http-request') { - // Check if connected via ERROR connection point - const errorEdges = edges.filter(edge => { - return edge.getTargetCellId() === parentLoopNode.id && - edge.getSourceCellId() === prevNodeId && - edge.getSourcePortId() === 'ERROR' - }); - - if (errorEdges.length > 0) { - const errorMessageKey = `${prevNodeData.id}_error_message`; - const errorTypeKey = `${prevNodeData.id}_error_type`; - - if (!addedKeys.has(errorMessageKey)) { - addedKeys.add(errorMessageKey); - variableList.push({ - key: errorMessageKey, - label: 'error_message', - type: 'variable', - dataType: 'string', - value: `${prevNodeData.id}.error_message`, - nodeData: prevNodeData, - }); - } - - if (!addedKeys.has(errorTypeKey)) { - addedKeys.add(errorTypeKey); - variableList.push({ - key: errorTypeKey, - label: 'error_type', - type: 'variable', - dataType: 'string', - value: `${prevNodeData.id}.error_type`, - nodeData: prevNodeData, - }); - } - } - } - }); - } - - // Add variables from nodes preceding the parent loop/iteration node const parentPreviousNodeIds = getAllPreviousNodes(parentLoopNode.id); allRelevantNodeIds.push(...parentPreviousNodeIds); } + + + // Add conversation variables from global config + const conversationVariables = workflowConfig?.variables || []; + + conversationVariables.forEach((variable: any) => { + const key = `CONVERSATION_${variable.name}`; + if (!addedKeys.has(key)) { + addedKeys.add(key); + variableList.push({ + key, + label: variable.name, + type: 'variable', + dataType: variable.type, + value: `conv.${variable.name}`, + nodeData: { type: 'CONVERSATION', name: 'CONVERSATION', icon: '' }, + group: 'CONVERSATION' + }); + } + }); + allRelevantNodeIds.forEach(nodeId => { const node = nodes.find(n => n.id === nodeId); if (!node) return; @@ -496,7 +420,7 @@ const Properties: FC = ({ key: llmKey, label: 'output', type: 'variable', - dataType: 'String', + dataType: 'string', value: `${dataNodeId}.output`, nodeData: nodeData, }); @@ -565,6 +489,17 @@ const Properties: FC = ({ const groupVariables = nodeData.config.group_variables.defaultValue || []; groupVariables?.forEach((groupVar: any) => { if (!groupVar || !groupVar.key) return; + + // Determine dataType from first variable in the group + let groupDataType = 'string'; + if (groupVar.value && Array.isArray(groupVar.value) && groupVar.value.length > 0) { + const firstVariableValue = groupVar.value[0]; + const firstVariable = variableList.find(v => `{{${v.value}}}` === firstVariableValue); + if (firstVariable) { + groupDataType = firstVariable.dataType; + } + } + const groupVarKey = `${dataNodeId}_${groupVar.key}`; if (!addedKeys.has(groupVarKey)) { addedKeys.add(groupVarKey); @@ -572,14 +507,26 @@ const Properties: FC = ({ key: groupVarKey, label: groupVar.key, type: 'variable', - dataType: 'string', + dataType: groupDataType, value: `${dataNodeId}.${groupVar.key}`, nodeData: nodeData, }); } }); } else { - // If group=false, add output variable + // If group=false, add output variable with type from first group_variable + const groupVariables = nodeData.config.group_variables.defaultValue || []; + const firstVariable = groupVariables[0]; + let outputDataType: string = 'any'; + if (firstVariable) { + const filterVo = [...variableList].find(v => { + return `{{${v.value}}}` === firstVariable + }) + if (filterVo) { + outputDataType = filterVo?.dataType + } + } + const varAggregatorKey = `${dataNodeId}_output`; if (!addedKeys.has(varAggregatorKey)) { addedKeys.add(varAggregatorKey); @@ -587,7 +534,7 @@ const Properties: FC = ({ key: varAggregatorKey, label: 'output', type: 'variable', - dataType: 'string', + dataType: outputDataType, value: `${dataNodeId}.output`, nodeData: nodeData, }); @@ -684,21 +631,20 @@ const Properties: FC = ({ nodeData: nodeData, }); } - if (!addedKeys.has(outputKey)) { - addedKeys.add(outputKey); - variableList.push({ - key: outputKey, - label: 'output', - type: 'variable', - dataType: 'string', - value: `${dataNodeId}.output`, - nodeData: nodeData, - }); - } + // if (!addedKeys.has(outputKey)) { + // addedKeys.add(outputKey); + // variableList.push({ + // key: outputKey, + // label: 'output', + // type: 'variable', + // dataType: 'string', + // value: `${dataNodeId}.output`, + // nodeData: nodeData, + // }); + // } break case 'iteration': const iterationOutputKey = `${dataNodeId}_output`; - const iterationItemKey = `${dataNodeId}_item`; if (!addedKeys.has(iterationOutputKey)) { addedKeys.add(iterationOutputKey); // Get the data type from the output configuration, default to string @@ -715,22 +661,11 @@ const Properties: FC = ({ key: iterationOutputKey, label: 'output', type: 'variable', - dataType: outputDataType, + dataType: `array[${outputDataType}]`, value: `${dataNodeId}.output`, nodeData: nodeData, }); } - if (!addedKeys.has(iterationItemKey)) { - addedKeys.add(iterationItemKey); - variableList.push({ - key: iterationItemKey, - label: 'item', - type: 'variable', - dataType: 'string', - value: `${dataNodeId}.item`, - nodeData: nodeData, - }); - } break case 'loop': const cycleVars = nodeData.config.cycle_vars.defaultValue || []; @@ -760,47 +695,337 @@ const Properties: FC = ({ key: toolDataKey, label: 'data', type: 'variable', - dataType: 'object', + dataType: 'string', value: `${dataNodeId}.data`, nodeData: nodeData, }); } break + case 'memory-read': + const memoryReadAnswerKey = `${dataNodeId}_answer`; + const memoryReadIntermediateOutputs = `${dataNodeId}_intermediate_outputs`; + if (!addedKeys.has(memoryReadAnswerKey)) { + addedKeys.add(memoryReadAnswerKey); + variableList.push({ + key: memoryReadAnswerKey, + label: 'answer', + type: 'variable', + dataType: 'string', + value: `${dataNodeId}.answer`, + nodeData: nodeData, + }); + } + if (!addedKeys.has(memoryReadIntermediateOutputs)) { + addedKeys.add(memoryReadIntermediateOutputs); + variableList.push({ + key: memoryReadIntermediateOutputs, + label: 'intermediate_outputs', + type: 'variable', + dataType: 'array[object]', + value: `${dataNodeId}.intermediate_outputs`, + nodeData: nodeData, + }); + } + break } }); - // Add conversation variables from global config - const conversationVariables = workflowConfig?.variables || []; - - conversationVariables.forEach((variable: any) => { - const key = `CONVERSATION_${variable.name}`; - if (!addedKeys.has(key)) { - addedKeys.add(key); - variableList.push({ - key, - label: variable.name, - type: 'variable', - dataType: variable.type, - value: `conv.${variable.name}`, - nodeData: { type: 'CONVERSATION', name: 'CONVERSATION', icon: '' }, - group: 'CONVERSATION' + + // Add parent loop/iteration node variables if current node is a child + if (parentLoopNode) { + const parentData = parentLoopNode.getData(); + const parentNodeId = parentLoopNode.getData().id; + + if (parentData.type === 'loop') { + const cycleVars = parentData.cycle_vars || []; + cycleVars.forEach((cycleVar: any) => { + const key = `${parentNodeId}_cycle_${cycleVar.name}`; + if (!addedKeys.has(key)) { + addedKeys.add(key); + variableList.push({ + key, + label: cycleVar.name, + type: 'variable', + dataType: cycleVar.type || 'String', + value: `${parentNodeId}.${cycleVar.name}`, + nodeData: parentData, + }); + } + }); + } else if (parentData.type === 'iteration') { + // Add item and index variables for iteration parent only if input has value + if (parentData.config.input.defaultValue) { + const itemKey = `${parentNodeId}_item`; + const indexKey = `${parentNodeId}_index`; + + // Determine item dataType from input variable + let itemDataType = 'object'; + const inputVariable = variableList.find(v => `{{${v.value}}}` === parentData.config.input.defaultValue); + console.log('itemDataType defaultValue', parentData.config.input.defaultValue, variableList, inputVariable) + if (inputVariable && inputVariable.dataType.startsWith('array[')) { + itemDataType = inputVariable.dataType.replace(/^array\[(.+)\]$/, '$1'); + console.log('itemDataType', itemDataType) + } + + + if (!addedKeys.has(itemKey)) { + addedKeys.add(itemKey); + variableList.push({ + key: itemKey, + label: 'item', + type: 'variable', + dataType: itemDataType, + value: `${parentNodeId}.item`, + nodeData: parentData, + }); + } + + if (!addedKeys.has(indexKey)) { + addedKeys.add(indexKey); + variableList.push({ + key: indexKey, + label: 'index', + type: 'variable', + dataType: 'number', + value: `${parentNodeId}.index`, + nodeData: parentData, + }); + } + } + } + + // Check if parent loop/iteration is connected to http-request via ERROR connection + if (parentData.type === 'loop' || parentData.type === 'iteration') { + const parentPreviousNodeIds = getAllPreviousNodes(parentLoopNode.id); + parentPreviousNodeIds.forEach(prevNodeId => { + const prevNode = nodes.find(n => n.id === prevNodeId); + if (!prevNode) return; + + const prevNodeData = prevNode.getData(); + if (prevNodeData.type === 'http-request') { + // Check if connected via ERROR connection point + const errorEdges = edges.filter(edge => { + return edge.getTargetCellId() === parentLoopNode.id && + edge.getSourceCellId() === prevNodeId && + edge.getSourcePortId() === 'ERROR' + }); + + if (errorEdges.length > 0) { + const errorMessageKey = `${prevNodeData.id}_error_message`; + const errorTypeKey = `${prevNodeData.id}_error_type`; + + if (!addedKeys.has(errorMessageKey)) { + addedKeys.add(errorMessageKey); + variableList.push({ + key: errorMessageKey, + label: 'error_message', + type: 'variable', + dataType: 'string', + value: `${prevNodeData.id}.error_message`, + nodeData: prevNodeData, + }); + } + + if (!addedKeys.has(errorTypeKey)) { + addedKeys.add(errorTypeKey); + variableList.push({ + key: errorTypeKey, + label: 'error_type', + type: 'variable', + dataType: 'string', + value: `${prevNodeData.id}.error_type`, + nodeData: prevNodeData, + }); + } + } + } }); } - }); + } return variableList; }, [selectedNode, graphRef, workflowConfig?.variables]); // Filter out boolean type variables for loop and llm nodes - const getFilteredVariableList = (nodeType?: string) => { - if (nodeType === 'loop' || nodeType === 'llm') { - return variableList.filter(variable => variable.dataType !== 'boolean'); + const getFilteredVariableList = (nodeType?: string, key?: string) => { + // Check if current node is a child of iteration node + const parentIterationNode = selectedNode ? (() => { + const nodes = graphRef.current?.getNodes() || []; + const nodeData = selectedNode.getData(); + const cycle = nodeData?.cycle; + + if (cycle) { + const parentNode = nodes.find(n => n.getData().id === cycle); + if (parentNode) { + const parentData = parentNode.getData(); + if (parentData?.type === 'iteration') { + return parentNode; + } + } + } + return null; + })() : null; + + // Helper function to add parent iteration variables + const addParentIterationVars = (filteredList: any[]) => { + if (parentIterationNode) { + const parentData = parentIterationNode.getData(); + const parentNodeId = parentData.id; + + if (parentData.config?.input?.defaultValue) { + const itemKey = `${parentNodeId}_item`; + const indexKey = `${parentNodeId}_index`; + + const existingItemVar = filteredList.find(v => v.key === itemKey); + const existingIndexVar = filteredList.find(v => v.key === indexKey); + + if (!existingItemVar) { + // Determine item dataType from input variable + let itemDataType = 'object'; + const inputVariable = variableList.find(v => `{{${v.value}}}` === parentData.config.input.defaultValue); + if (inputVariable && inputVariable.dataType.startsWith('array[')) { + itemDataType = inputVariable.dataType.replace(/^array\[(.+)\]$/, '$1'); + } + + filteredList.push({ + key: itemKey, + label: 'item', + type: 'variable', + dataType: itemDataType, + value: `${parentNodeId}.item`, + nodeData: parentData, + }); + } + + if (!existingIndexVar) { + filteredList.push({ + key: indexKey, + label: 'index', + type: 'variable', + dataType: 'number', + value: `${parentNodeId}.index`, + nodeData: parentData, + }); + } + } + } + return filteredList; + }; + + if (nodeType === 'llm') { + // For LLM nodes that are children of iteration or loop nodes, include parent variables + const parentLoopNode = selectedNode ? (() => { + const nodes = graphRef.current?.getNodes() || []; + const nodeData = selectedNode.getData(); + const cycle = nodeData?.cycle; + + if (cycle) { + const parentNode = nodes.find(n => n.getData().id === cycle); + if (parentNode) { + const parentData = parentNode.getData(); + if (parentData?.type === 'loop' || parentData?.type === 'iteration') { + return parentNode; + } + } + } + return null; + })() : null; + + let filteredList = variableList.filter(variable => variable.dataType !== 'boolean'); + + // If this LLM node is a child of iteration/loop, ensure parent variables are included + if (parentLoopNode) { + const parentData = parentLoopNode.getData(); + const parentNodeId = parentData.id; + + // Ensure parent loop/iteration variables are included + if (parentData.type === 'loop') { + const cycleVars = parentData.cycle_vars || []; + cycleVars.forEach((cycleVar: any) => { + const key = `${parentNodeId}_cycle_${cycleVar.name}`; + const existingVar = filteredList.find(v => v.key === key); + if (!existingVar && cycleVar.name && cycleVar.type !== 'boolean') { + filteredList.push({ + key, + label: cycleVar.name, + type: 'variable', + dataType: cycleVar.type || 'String', + value: `${parentNodeId}.${cycleVar.name}`, + nodeData: parentData, + }); + } + }); + } else if (parentData.type === 'iteration') { + // Add item and index variables for iteration parent + if (parentData.config?.input?.defaultValue) { + const itemKey = `${parentNodeId}_item`; + const indexKey = `${parentNodeId}_index`; + + const existingItemVar = filteredList.find(v => v.key === itemKey); + const existingIndexVar = filteredList.find(v => v.key === indexKey); + + if (!existingItemVar) { + // Determine item dataType from input variable + let itemDataType = 'object'; + const inputVariable = variableList.find(v => `{{${v.value}}}` === parentData.config.input.defaultValue); + if (inputVariable && inputVariable.dataType.startsWith('array[')) { + itemDataType = inputVariable.dataType.replace(/^array\[(.+)\]$/, '$1'); + } + + filteredList.push({ + key: itemKey, + label: 'item', + type: 'variable', + dataType: itemDataType, + value: `${parentNodeId}.item`, + nodeData: parentData, + }); + } + + if (!existingIndexVar) { + filteredList.push({ + key: indexKey, + label: 'index', + type: 'variable', + dataType: 'Number', + value: `${parentNodeId}.index`, + nodeData: parentData, + }); + } + } + } + } + + return filteredList; } - return variableList; + if (nodeType === 'knowledge-retrieval' || nodeType === 'parameter-extractor' && key !== 'prompt' || nodeType === 'memory-read' || nodeType === 'memory-write' || nodeType === 'question-classifier') { + let filteredList = variableList.filter(variable => variable.dataType === 'string'); + return addParentIterationVars(filteredList); + } + if (nodeType === 'parameter-extractor' && key === 'prompt') { + let filteredList = variableList.filter(variable => variable.dataType === 'string' || variable.dataType === 'number'); + return addParentIterationVars(filteredList); + } + if (nodeType === 'iteration' && key === 'output') { + return variableList.filter(variable => variable.value.includes('sys.')); + } + if (nodeType === 'iteration') { + return variableList.filter(variable => variable.dataType.includes('array')); + } + if (nodeType === 'loop' && key === 'condition') { + let filteredList = variableList.filter(variable => variable.nodeData.type !== 'loop'); + return addParentIterationVars(filteredList); + } + + // For all other node types, add parent iteration variables if applicable + let baseList = variableList; + return addParentIterationVars(baseList); }; + const defaultVariableList = calculateVariableList(selectedNode as Node, graphRef, workflowConfig ) + console.log('values', values) - console.log('variableList', variableList, selectedNode?.data) + console.log('variableList', variableList, defaultVariableList) return (
@@ -901,11 +1126,10 @@ const Properties: FC = ({ }); } } - return ( variable.nodeData?.type !== 'knowledge-retrieval')} parentName={key} /> @@ -915,7 +1139,12 @@ const Properties: FC = ({ if (selectedNode?.data?.type === 'end' && key === 'output') { return ( - + variable.nodeData?.type !== 'knowledge-retrieval')} + /> ) } @@ -943,7 +1172,7 @@ const Properties: FC = ({ isArray={!!config.isArray} parentName={key} enableJinja2={config.enableJinja2 as boolean} - options={getFilteredVariableList(selectedNode?.data?.type)} + options={getFilteredVariableList(selectedNode?.data?.type, key)} /> ) @@ -964,7 +1193,7 @@ const Properties: FC = ({ @@ -976,7 +1205,7 @@ const Properties: FC = ({ @@ -989,7 +1218,7 @@ const Properties: FC = ({ - + ) @@ -999,7 +1228,7 @@ const Properties: FC = ({ ) @@ -1013,9 +1242,9 @@ const Properties: FC = ({ if (config.filterLoopIterationVars) { const loopIterationVars: Suggestion[] = []; - return [...getFilteredVariableList(selectedNode?.data?.type), ...loopIterationVars]; + return [...getFilteredVariableList(selectedNode?.data?.type, key), ...loopIterationVars]; } - return getFilteredVariableList(selectedNode?.data?.type); + return getFilteredVariableList(selectedNode?.data?.type, key); })() } /> @@ -1060,7 +1289,7 @@ const Properties: FC = ({ ? { - const baseVariableList = getFilteredVariableList(selectedNode?.data?.type); + const baseVariableList = getFilteredVariableList(selectedNode?.data?.type, key); // Apply filtering if specified in config if (config.filterNodeTypes || config.filterVariableNames) { return baseVariableList.filter(variable => { @@ -1085,7 +1314,7 @@ const Properties: FC = ({ }); return baseVariableList.filter(variable => - childNodes.some(node => node.id === variable.nodeData?.id) + childNodes.some(node => node.id === variable.nodeData?.id) || selectedNode?.data?.type === 'iteration' && key === 'output' && variable.value.includes('sys.') ); } return baseVariableList; @@ -1095,7 +1324,12 @@ const Properties: FC = ({ : config.type === 'switch' ? { form.setFieldValue('group_variables', []) } : undefined} /> : config.type === 'categoryList' - ? + ? : config.type === 'conditionList' ? = ({ value: `${selectedNode.getData().id}.${cycleVar.name}`, nodeData: selectedNode.getData(), })); - return [...variableList.filter(variable => { - // Keep conversation variables - if (variable.group === 'CONVERSATION') return true; - // Keep sys variables from start nodes - if (variable.nodeData?.type === 'start' && variable.value?.startsWith('sys.')) return true; - // Keep variables from non-start nodes - if (variable.nodeData?.type !== 'start' && variable.nodeData?.type !== 'http-request' && variable.dataType !== 'boolean') return true; - // Filter out custom variables from start nodes - return false; - }), ...cycleVarSuggestions]; - })() - } + + return [...getFilteredVariableList(selectedNode?.data?.type, key), ...cycleVarSuggestions]; + })()} selectedNode={selectedNode} graphRef={graphRef} addBtnText={t('workflow.config.addCase')} diff --git a/web/src/views/Workflow/constant.ts b/web/src/views/Workflow/constant.ts index 692339da..593639ce 100644 --- a/web/src/views/Workflow/constant.ts +++ b/web/src/views/Workflow/constant.ts @@ -270,7 +270,7 @@ export const nodeLibrary: NodeLibrary[] = [ config: { input: { type: 'variableList', - filterNodeTypes: ['knowledge-retrieval'], + filterNodeTypes: ['knowledge-retrieval', 'iteration', 'loop'], filterVariableNames: ['message'] }, parallel: { @@ -334,8 +334,7 @@ export const nodeLibrary: NodeLibrary[] = [ } } }, - { - type: "assigner", icon: assignerIcon, + { type: "assigner", icon: assignerIcon, config: { assignments: { type: 'assignmentList', @@ -628,4 +627,114 @@ export const graphNodeLibrary: Record = { items: [{ group: 'left' }], }, } +} + + +export interface OutputVariable { + default?: Array<{ + name: string; + type: string; + }>; + define?: string[]; + sys?: Array<{ + name: string; + type: string; + }>; + error?: Array<{ + name: string; + type: string; + }>; +} +export const outputVariable: { [key: string]: OutputVariable } = { + start: { + sys: [ + { name: "message", type: "string" }, + { name: "conversation_id", type: "string" }, + { name: "execution_id", type: "string", }, + { name: "workspace_id", type: "string" }, + { name: "user_id", type: "string" }, + ], + define: ['variables'] + }, + end: { + }, + llm: { + default: [ + { name: "output", type: "string" }, + ] + }, + 'knowledge-retrieval': { + default: [ + { name: "output", type: "array[object]" }, + ] + }, + 'parameter-extractor': { + default: [ + { name: "__is_success", type: "number" }, + { name: "__reason", type: "string" }, + ], + define: ['params'] + }, + 'memory-read': { + default: [ + { name: "answer", type: "string" }, + { name: "intermediate_outputs", type: "array[object]" }, + ], + }, + 'memory-write': { + + }, + 'if-else': { + + }, + 'question-classifier': { + default: [ + { name: "class_name", type: "string" }, + // { name: "output", type: "string" }, + ], + }, + 'iteration': { + default: [ + // { name: "item", type: "string" }, // 仅内部使用 + { name: "output", type: "array[string]" }, + ], + }, + 'loop': { + define: ['cycle_vars'] + }, + 'cycle-start': { + + }, + 'break': { + + }, + 'var-aggregator': { + // default: [ + // { name: "output", type: "string" }, + // ], + define: ['group_variables'] + }, + 'assigner': { + + }, + 'http-request': { + default: [ + { name: "body", type: "string" }, + { name: "status_code", type: "number" }, + ], + error: [ + { name: "error_message", type: "string" }, + { name: "error_type", type: "string" }, + ] + }, + 'tool': { + default: [ + { name: "data", type: "string" }, + ], + }, + 'jinja-render': { + default: [ + { name: "output", type: "string" }, + ], + }, } \ No newline at end of file From e72ecfcb0a5856a21652ef83f04d00783d725134 Mon Sep 17 00:00:00 2001 From: zhaoying Date: Tue, 13 Jan 2026 21:21:19 +0800 Subject: [PATCH 27/27] fix(web): remove calculateVariableList --- web/src/views/Workflow/components/Properties/index.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/web/src/views/Workflow/components/Properties/index.tsx b/web/src/views/Workflow/components/Properties/index.tsx index 9fcc8821..2903b2c9 100644 --- a/web/src/views/Workflow/components/Properties/index.tsx +++ b/web/src/views/Workflow/components/Properties/index.tsx @@ -22,7 +22,7 @@ import ConditionList from './ConditionList' import CycleVarsList from './CycleVarsList' import AssignmentList from './AssignmentList' import ToolConfig from './ToolConfig' -import { calculateVariableList } from './utils/variableListCalculator' +// import { calculateVariableList } from './utils/variableListCalculator' interface PropertiesProps { selectedNode?: Node | null; @@ -1022,10 +1022,10 @@ const Properties: FC = ({ return addParentIterationVars(baseList); }; - const defaultVariableList = calculateVariableList(selectedNode as Node, graphRef, workflowConfig ) + // const defaultVariableList = calculateVariableList(selectedNode as Node, graphRef, workflowConfig ) console.log('values', values) - console.log('variableList', variableList, defaultVariableList) + // console.log('variableList', variableList, defaultVariableList) return (