refactor(web): 重构知识图谱和移动聊天页面

- 更新知识图谱页面,添加图谱数据处理和渲染逻辑
- 重构移动聊天页面,增加主题模式切换功能
- 优化应用创建模态框,支持预览图标和更新表单字段
main
Tuzki 4 months ago
parent 8fdcc8b2e1
commit deb5daa977
  1. 11
      web/pages/construct/app/components/create-app-modal/index.tsx
  2. 203
      web/pages/knowledge/graph/index.tsx
  3. 26
      web/pages/mobile/chat/index.tsx

@ -115,12 +115,12 @@ const CreateAppModal: React.FC<{
}
let objectUrl: string | null = null;
let isMounted = true;
if (open && appInfo?.icon) {
(async () => {
const [err, blob] = await apiInterceptors(getImage(appInfo.icon));
if (!err && blob) {
if (!err && blob && isMounted) {
objectUrl = URL.createObjectURL(blob);
setImageUrl(objectUrl);
@ -137,7 +137,7 @@ const CreateAppModal: React.FC<{
}
})();
}
}, [open, appInfo, form]);
}, [open, appInfo?.icon, appInfo?.app_type, form]);
// 获取工作模式列表
const { data, loading } = useRequest(async () => {
@ -171,6 +171,7 @@ const CreateAppModal: React.FC<{
const [error, data] = res;
if (!error) {
if (type === 'edit') {
debugger;
const [, res] = await apiInterceptors(getAppList({}));
const curApp = res?.app_list?.find(item => item.app_code === appInfo?.app_code);
localStorage.setItem('new_app_info', JSON.stringify({ ...curApp, isEdit: true }));
@ -285,6 +286,7 @@ const CreateAppModal: React.FC<{
// 确保 updatedFileList 是数组
form.setFieldsValue({ icon: [...updatedFileList] });
// debugger;
}
}
}
@ -326,12 +328,13 @@ const CreateAppModal: React.FC<{
open={open}
onOk={async () => {
form.validateFields().then(async (values: any) => {
debugger;
await createApp({
app_name: values?.app_name,
app_describe: values?.app_describe,
team_mode: values?.team_mode?.value,
app_type: values?.app_type,
icon: values.icon?.[0]?.url || '',
icon: values.icon?.[0]?.url||values.icon?.[0]?.response?.url || '',
});
});
}}

@ -1,7 +1,202 @@
// pages/knowledge/graph.tsx
import dynamic from 'next/dynamic';
import { apiInterceptors, getGraphVis } from '@/client/api';
import { RollbackOutlined } from '@ant-design/icons';
import type { Graph, GraphData, GraphOptions, ID, IPointerEvent, PluginOptions } from '@antv/g6';
import { idOf } from '@antv/g6';
import { Graphin } from '@antv/graphin';
import { Button, Spin } from 'antd';
import { groupBy } from 'lodash';
import { useRouter } from 'next/router';
import { useEffect, useMemo, useRef, useState } from 'react';
import type { GraphVisResult } from '../../../types/knowledge';
import { getDegree, getSize, isInCommunity } from '../../../utils/graph';
// 动态导入组件,禁用 SSR
const GraphVis = dynamic(() => import('@/components/knowledge/GraphVis'), { ssr: false });
type GraphVisData = GraphVisResult | null;
const PALETTE = ['#5F95FF', '#61DDAA', '#F6BD16', '#7262FD', '#78D3F8', '#9661BC', '#F6903D', '#008685', '#F08BB4'];
function GraphVis() {
const LIMIT = 500;
const router = useRouter();
const [data, setData] = useState<GraphVisData>(null);
const graphRef = useRef<Graph | null>();
const [isReady, setIsReady] = useState(false);
const fetchGraphVis = async () => {
const [_, data] = await apiInterceptors(getGraphVis(spaceName as string, { limit: LIMIT }));
setData(data);
};
const transformData = (data: GraphVisData): GraphData => {
if (!data) return { nodes: [], edges: [] };
const nodes = data.nodes.map(node => ({ id: node.id, data: node }));
const edges = data.edges.map(edge => ({
source: edge.source,
target: edge.target,
data: edge,
}));
return { nodes, edges };
};
const back = () => {
router.push(`/construct/knowledge`);
};
const {
query: { spaceName },
} = useRouter();
useEffect(() => {
if (spaceName) fetchGraphVis();
}, [spaceName]);
const graphData = useMemo(() => transformData(data), [data]);
useEffect(() => {
if (isReady && graphRef.current) {
const groupedNodes = groupBy(graphData.nodes, node => node.data!.communityId);
const plugins: PluginOptions = [];
Object.entries(groupedNodes).forEach(([key, nodes]) => {
if (!key || nodes.length < 2) return;
const color = graphRef.current?.getElementRenderStyle(idOf(nodes[0])).fill;
plugins.push({
key,
type: 'bubble-sets',
members: nodes.map(idOf),
stroke: color,
fill: color,
fillOpacity: 0.1,
});
});
graphRef.current.setPlugins(prev => [...prev, ...plugins]);
}
}, [isReady]);
const getNodeSize = (nodeId: ID) => {
return getSize(getNodeDegree(nodeId));
};
const getNodeDegree = (nodeId?: ID) => {
if (!nodeId) return 0;
return getDegree(graphData.edges!, nodeId);
};
const options: GraphOptions = {
data: graphData,
autoFit: 'center',
node: {
style: d => {
const style = {
size: getNodeSize(idOf(d)),
label: true,
labelLineWidth: 2,
labelText: d.data?.name as string,
labelFontSize: 10,
labelBackground: true,
labelBackgroundFill: '#e5e7eb',
labelPadding: [0, 6],
labelBackgroundRadius: 4,
labelMaxWidth: '400%',
labelWordWrap: true,
};
if (!isInCommunity(graphData, idOf(d))) {
Object.assign(style, { fill: '#b0b0b0' });
}
return style;
},
state: {
active: {
lineWidth: 2,
labelWordWrap: false,
labelFontSize: 12,
labelFontWeight: 'bold',
},
inactive: {
label: false,
},
},
palette: {
type: 'group',
field: 'communityId',
color: PALETTE,
},
},
edge: {
style: {
lineWidth: 1,
stroke: '#e2e2e2',
endArrow: true,
endArrowType: 'vee',
label: true,
labelFontSize: 8,
labelBackground: true,
labelText: e => e.data!.name as string,
labelBackgroundFill: '#e5e7eb',
labelPadding: [0, 6],
labelBackgroundRadius: 4,
labelMaxWidth: '60%',
labelWordWrap: true,
},
state: {
active: {
stroke: '#b0b0b0',
labelWordWrap: false,
labelFontSize: 10,
labelFontWeight: 'bold',
},
inactive: {
label: false,
},
},
},
behaviors: [
'drag-canvas',
'zoom-canvas',
'drag-element',
{
type: 'hover-activate',
degree: 1,
state: 'active',
enable: (event: IPointerEvent) => ['node'].includes(event.targetType),
},
],
animation: false,
layout: {
type: 'force',
preventOverlap: true,
nodeSize: d => getNodeSize(d?.id as ID),
linkDistance: edge => {
const { source, target } = edge as { source: ID; target: ID };
const nodeSize = Math.min(getNodeSize(source), getNodeSize(target));
const degree = Math.min(getNodeDegree(source), getNodeDegree(target));
return degree === 1 ? nodeSize * 2 : Math.min(degree * nodeSize * 1.5, 700);
},
},
transforms: ['process-parallel-edges'],
};
if (!data) return <Spin className='h-full justify-center content-center' />;
return (
<div className='p-4 h-full overflow-y-scroll relative px-2'>
<Graphin
ref={ref => {
graphRef.current = ref;
}}
style={{ height: '100%', width: '100%' }}
options={options}
onReady={() => {
setIsReady(true);
}}
>
<Button style={{ background: '#fff' }} onClick={back} icon={<RollbackOutlined />}>
Back
</Button>
</Graphin>
</div>
);
}
export default GraphVis;

@ -6,6 +6,7 @@ import useUser from '@/hooks/use-user';
import { IApp } from '@/types/app';
import { ChatHistoryResponse } from '@/types/chat';
import { HEADER_USER_ID_KEY } from '@/utils/constants/index';
import { STORAGE_THEME_KEY } from '@/utils/constants/index';
import { EventStreamContentType, fetchEventSource } from '@microsoft/fetch-event-source';
import { useRequest } from 'ahooks';
import { Spin, Modal, Input, message } from 'antd';
@ -17,14 +18,16 @@ import InputContainer from './components/InputContainer';
import { useRouter } from 'next/router';
import { STORAGE_USERINFO_KEY, STORAGE_USERINFO_VALID_TIME_KEY } from '@/utils/constants/index';
type ThemeMode = 'dark' | 'light';
const Content = dynamic(() => import('@/pages/mobile/chat/components/Content'), { ssr: false });
interface MobileChatProps {
model: string;
mode: ThemeMode;
temperature: number;
resource: any;
setResource: React.Dispatch<React.SetStateAction<any>>;
setTemperature: React.Dispatch<React.SetStateAction<number>>;
setMode: (mode: ThemeMode) => void;
setModel: React.Dispatch<React.SetStateAction<string>>;
scene: string;
history: ChatHistoryResponse; // 会话内容
@ -44,12 +47,18 @@ interface MobileChatProps {
setUserInput: React.Dispatch<React.SetStateAction<string>>;
getChatHistoryRun: () => void;
}
function getDefaultTheme(): ThemeMode {
const theme = localStorage.getItem(STORAGE_THEME_KEY) as ThemeMode;
if (theme) return theme;
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
export const MobileChatContext = createContext<MobileChatProps>({
model: '',
mode: 'dark',
temperature: 0.5,
resource: null,
setModel: () => { },
setMode: () => void 0,
setTemperature: () => { },
setResource: () => { },
scene: '',
@ -72,6 +81,7 @@ export const MobileChatContext = createContext<MobileChatProps>({
});
const MobileChat: React.FC = () => {
const [mode, setMode] = useState<ThemeMode>('dark');
const [isLoginReady, setIsLoginReady] = useState(false);
const [loginVisible, setLoginVisible] = useState<boolean>(false);
@ -79,7 +89,9 @@ const MobileChat: React.FC = () => {
const [captchaId, setCaptchaId] = useState<string>('');
const [captchaInput, setCaptchaInput] = useState<string>('');
const [authToken, setAuthToken] = useState<string>('');
// useEffect(() => {
// setMode(getDefaultTheme());
// }, []);
// 初始检测是否有token,没有就弹窗登录
useEffect(() => {
const tokenStr = localStorage.getItem(STORAGE_USERINFO_KEY) || '{}';
@ -288,7 +300,7 @@ const MobileChat: React.FC = () => {
// 处理会话
const handleChat = async (content?: string) => {
setUserInput('');
ctrl.current = new AbortController();
const params = {
@ -393,8 +405,10 @@ const MobileChat: React.FC = () => {
<MobileChatContext.Provider
value={{
model,
mode,
resource,
setModel,
setMode,
setTemperature,
setResource,
temperature,
@ -422,7 +436,7 @@ const MobileChat: React.FC = () => {
className='flex h-screen w-screen justify-center items-center max-h-screen'
spinning={historyLoading || appInfoLoading || resourceLoading || dialogueListLoading}
>
<div className='flex flex-col h-screen bg-gradient-light dark:bg-gradient-dark p-4 pt-0'>
<div className='flex flex-col h-screen bg-[#001533] bg-gradient-light dark:bg-gradient-dark p-4 pt-0'>
<div ref={scrollViewRef} className='flex flex-col flex-1 overflow-y-auto mb-3'>
<Header />
<Content />
@ -433,7 +447,7 @@ const MobileChat: React.FC = () => {
</MobileChatContext.Provider>
<Modal
open={loginVisible}
title="为了确保安全,请输入验证码"
title="为了确保信息安全,请输入验证码"
onOk={handleLogin}
onCancel={() => { }}
okText="确定"

Loading…
Cancel
Save