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/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/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' && } 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)