Commit 3489b2a6 authored by David Yang's avatar David Yang

feat: init

parents
module.exports = {
extends: require.resolve('@umijs/max/eslint'),
};
/node_modules
/.env.local
/.umirc.local.ts
/config/config.local.ts
/src/.umi
/src/.umi-production
/src/.umi-test
/.umi
/.umi-production
/.umi-test
/dist
/.mfsu
.swc
.idea/*
.git/*
.DS_Store
npx --no-install max verify-commit $1
npx --no-install lint-staged --quiet
{
"*.{md,json}": [
"prettier --cache --write"
],
"*.{js,jsx}": [
"max lint --fix --eslint-only",
"prettier --cache --write"
],
"*.{css,less}": [
"max lint --fix --stylelint-only",
"prettier --cache --write"
],
"*.ts?(x)": [
"max lint --fix --eslint-only",
"prettier --cache --parser=typescript --write"
]
}
registry=https://registry.npmmirror.com/
node_modules
.umi
.umi-production
{
"printWidth": 80,
"singleQuote": true,
"trailingComma": "all",
"proseWrap": "never",
"overrides": [{ "files": ".prettierrc", "options": { "parser": "json" } }],
"plugins": ["prettier-plugin-organize-imports", "prettier-plugin-packagejson"]
}
module.exports = {
extends: require.resolve('@umijs/max/stylelint'),
};
import { defineConfig } from '@umijs/max';
export default defineConfig({
base: 'ai',
publicPath: '/ai/',
antd: {},
access: {},
model: {},
initialState: {},
request: {},
reactQuery: {
devtool: false,
},
layout: false,
routes: [
{
name: '首页',
path: '/',
// redirect: '/chat',
component: './Home',
},
// {
// name: '聊天',
// path: '/chat',
// component: './Home',
// },
// {
// name: '聊天',
// path: '/chat/:name',
// component: './Home',
// },
],
npmClient: 'pnpm',
proxy: {
'/api': {
target: 'https://awm.rsmchina.com.cn/api',
// target: 'http://localhost:3000',
changeOrigin: true,
pathRewrite: { '^/api': '' },
},
'/myapi/upload_invoices': {
target: 'http://1.94.111.115:5173',
changeOrigin: true,
secure: false,
pathRewrite: { '^/myapi': '' },
},
},
});
# README
`@umijs/max` 模板项目,更多功能参考 [Umi Max 简介](https://umijs.org/docs/max/introduce)
const users = [
{ id: 0, name: 'Umi', nickName: 'U', gender: 'MALE' },
{ id: 1, name: 'Fish', nickName: 'B', gender: 'FEMALE' },
];
export default {
'GET /api/v1/queryUserList': (req: any, res: any) => {
res.json({
success: true,
data: { list: users },
errorCode: 0,
});
},
'PUT /api/v1/user/': (req: any, res: any) => {
res.json({
success: true,
errorCode: 0,
});
},
};
{
"private": true,
"author": "Mars Wu <mars.wu@drigle.com>",
"scripts": {
"build": "max build",
"dev": "max dev",
"format": "prettier --cache --write .",
"postinstall": "max setup",
"prepare": "husky",
"setup": "max setup",
"start": "npm run dev"
},
"dependencies": {
"@ant-design/icons": "^5.0.1",
"@ant-design/pro-components": "^2.4.4",
"@ant-design/x": "^1.6.0",
"@ant-design/x-markdown": "2.0.0-alpha.5",
"@modelcontextprotocol/sdk": "^1.17.5",
"@umijs/max": "^4.4.12",
"antd": "^5.4.0",
"antd-style": "^3.7.1",
"dayjs": "^1.11.18",
"jszip": "^3.10.1",
"katex": "^0.16.22",
"lodash": "^4.17.21",
"marked": "^16.2.1",
"marked-katex-extension": "^5.1.5",
"xlsx": "^0.18.5"
},
"devDependencies": {
"@types/react": "^18.0.33",
"@types/react-dom": "^18.0.11",
"husky": "^9",
"lint-staged": "^13.2.0",
"prettier": "^2.8.7",
"prettier-plugin-organize-imports": "^3.2.2",
"prettier-plugin-packagejson": "^2.4.3",
"typescript": "^5.0.3"
}
}
This source diff could not be displayed because it is too large. You can view the blob instead.
{
"name": "S-Agent",
"version": "1.0.26",
"build": "1"
}
export default (initialState) => {
// 在这里按照初始化数据定义项目中的权限,统一管理
// 参考文档 https://umijs.org/docs/max/access
const canSeeAdmin = !!(
initialState && initialState.name !== 'dontHaveAccess'
);
return {
canSeeAdmin,
};
};
import { defineApp, matchRoutes } from 'umi';
import { createTracking } from './services/DataService';
import {
collectAppInfo,
collectClientInfo,
collectUserInfo,
} from './utils/trackingHelper';
export const request = {
prefix: '/api',
timeout: 30000,
// requestInterceptors: [
// (config) => {
// const user = JSON.parse(localStorage.getItem('current_user') || 'null');
// const token = user?.token || localStorage.getItem('auth_token');
// const headers = {
// ContentType: 'application/json',
// ...config.headers,
// };
//
// if (token) {
// headers['Authorization'] = `Bearer ${token}`;
// }
//
// return {
// ...config,
// headers,
// };
// },
// ],
};
export const reactQuery = {
queryClient: {
defaultOptions: {
queries: {
refetchOnWindowFocus: false,
},
},
},
};
/**
* 从路由配置中获取路由名称
*/
function getRouteName(clientRoutes, pathname) {
try {
const matches = matchRoutes(clientRoutes, pathname);
if (matches && matches.length > 0) {
const route = matches[matches.length - 1]?.route;
return route?.name || route?.path || null;
}
} catch (e) {
// 忽略错误
}
return null;
}
/**
* 统一的路由处理逻辑
*/
function handleRouteTracking({
location,
clientRoutes,
action,
basename,
isFirst,
}) {
// 延迟执行以等待页面标题更新 (例如等待 PageContainer 或 Helmet 生效)
setTimeout(() => {
try {
// 收集用户信息
const user = collectUserInfo();
if (!user) {
return;
}
// 收集客户端信息
const client = collectClientInfo();
// 获取路由名称
const routeName = getRouteName(clientRoutes, location.pathname);
// 收集应用信息
const app = collectAppInfo(location, document, routeName);
// 构建完整的埋点数据
const trackingData = {
client,
app,
user,
event: {
name: 'page_view',
type: 'navigation',
},
data: {
action: action || 'PUSH',
basename: basename || '',
is_first: isFirst || false,
route_path: location.pathname,
route_name: routeName,
route_search: location.search || '',
route_hash: location.hash || '',
route_state: location.state || null,
route_key: location.key || null,
timestamp: new Date().toISOString(),
},
};
// 发送埋点
createTracking(trackingData).catch((error) => {
// 静默处理错误
});
} catch (error) {
// 静默处理错误
}
}, 300);
}
// Umi 4 推荐使用 defineApp
export default defineApp({
onRouteChange: handleRouteTracking,
});
// 保留命名导出以兼容某些 Umi 配置
export const onRouteChange = handleRouteTracking;
import { login } from '@/services/DataService';
import { LockOutlined, UserOutlined } from '@ant-design/icons';
import { LoginForm, ProFormText } from '@ant-design/pro-components';
import { Alert, Flex, Modal, Typography } from 'antd';
import { createStyles } from 'antd-style';
import { useState } from 'react';
const useStyle = createStyles(({ css }) => {
return {
modal: css`
.ant-modal-content {
border-radius: 30px;
padding: 0;
overflow: hidden;
background: radial-gradient(
circle at top right,
rgba(125, 211, 252, 0.22),
transparent 20%
),
linear-gradient(180deg, #ffffff 0%, #f8fbff 100%);
}
.ant-modal-body {
padding: 0;
}
`,
shell: css`
display: grid;
grid-template-columns: minmax(0, 1.05fr) minmax(0, 0.95fr);
min-height: 540px;
`,
visual: css`
padding: 38px 34px;
background: radial-gradient(
circle at top left,
rgba(0, 166, 232, 0.22),
transparent 22%
),
linear-gradient(160deg, #0f172a 0%, #1e293b 100%);
color: #f8fafc;
display: flex;
flex-direction: column;
justify-content: space-between;
`,
visualBadge: css`
width: fit-content;
padding: 8px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.14);
font-size: 12px;
letter-spacing: 0.04em;
`,
visualTitle: css`
margin-top: 24px;
h2 {
margin: 0;
font-size: 34px;
line-height: 1.15;
color: #f8fafc;
}
p {
margin: 14px 0 0;
font-size: 15px;
line-height: 1.8;
color: rgba(226, 232, 240, 0.78);
}
`,
visualList: css`
display: grid;
gap: 12px;
margin-top: 28px;
div {
border-radius: 18px;
padding: 14px 16px;
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.08);
}
strong {
display: block;
font-size: 14px;
color: #f8fafc;
}
span {
display: block;
margin-top: 4px;
font-size: 12px;
line-height: 1.6;
color: rgba(226, 232, 240, 0.72);
}
`,
formPane: css`
padding: 34px 32px;
display: flex;
align-items: center;
justify-content: center;
.ant-pro-form-login-container {
width: 100%;
box-shadow: none;
padding: 0;
}
.ant-pro-form-login-logo {
width: 58px;
height: 58px;
}
.ant-pro-form-login-title {
color: #0f172a;
font-size: 28px;
line-height: 1.2;
}
.ant-pro-form-login-subtitle {
color: #64748b;
font-size: 14px;
}
.ant-input-affix-wrapper,
.ant-input {
border-radius: 16px !important;
min-height: 48px;
}
.ant-btn-primary {
height: 46px;
border-radius: 16px;
font-weight: 600;
}
`,
formInner: css`
width: 100%;
max-width: 360px;
`,
error: css`
margin-bottom: 20px;
border-radius: 14px;
`,
};
});
export default (props) => {
const { onError, onSuccess } = props;
const [errorMessage, setErrorMessage] = useState(null);
const { styles } = useStyle();
return (
<Modal
{...props}
closable={false}
footer={null}
width={920}
className={styles.modal}
>
<div className={styles.shell}>
<div className={styles.visual}>
<div>
<div className={styles.visualBadge}>Enterprise AI Workspace</div>
<div className={styles.visualTitle}>
<Typography.Title level={2}>
让业务知识真正进入对话流程
</Typography.Title>
<Typography.Paragraph>
统一连接企业知识库、智能体工具链、文件理解与流程型操作,让每次提问都更接近可执行结果。
</Typography.Paragraph>
</div>
<div className={styles.visualList}>
<div>
<strong>知识与上下文统一</strong>
<span>支持话题问答、附件内容提取与历史会话连续理解。</span>
</div>
<div>
<strong>智能体可直接协作</strong>
<span>在同一工作台中触发专属助手和 MCP 工具调用。</span>
</div>
<div>
<strong>更适合内部业务落地</strong>
<span>保留稳定功能栈,同时提供更清晰一致的产品体验。</span>
</div>
</div>
</div>
</div>
<div className={styles.formPane}>
<div className={styles.formInner}>
<LoginForm
logo="https://oss.streams7.com/rsm/BfNsqGPGTKE2.svg"
title="智能助手"
subTitle="登录后继续使用统一 AI 对话工作台"
onFinish={async (form) => {
try {
const userInfo = await login(form.username, form.password);
localStorage.setItem(
'current_user',
JSON.stringify({
...userInfo,
token: undefined,
}),
);
setErrorMessage(null);
onSuccess?.(userInfo);
} catch (error) {
const nextErrorMessage =
error.response?.data?.message ||
error.message ||
'网络错误';
setErrorMessage(nextErrorMessage);
onError?.(nextErrorMessage, error);
}
}}
>
<Flex vertical>
<ProFormText
name="username"
fieldProps={{
size: 'large',
prefix: <UserOutlined className={'prefixIcon'} />,
}}
placeholder="用户名"
rules={[
{
required: true,
message: '请输入用户名',
},
]}
/>
<ProFormText.Password
name="password"
fieldProps={{
size: 'large',
prefix: <LockOutlined className={'prefixIcon'} />,
}}
placeholder="密码"
rules={[
{
required: true,
message: '请输入密码',
},
]}
/>
{errorMessage && (
<Alert
className={styles.error}
message={errorMessage}
type="error"
showIcon
/>
)}
</Flex>
</LoginForm>
</div>
</div>
</div>
</Modal>
);
};
export default (props) => {};
export const DEFAULT_NAME = 'Umi Max';
import { Outlet } from '@umijs/max';
import { App, ConfigProvider, theme } from 'antd';
import { createGlobalStyle } from 'antd-style';
const { defaultSeed, defaultAlgorithm } = theme;
const APP_FONT_FAMILY =
"'PingFang SC', 'Microsoft YaHei', 'Helvetica Neue', Arial, sans-serif";
const GlobalStyle = createGlobalStyle`
:root {
--app-shell-bg:
radial-gradient(
circle at top left,
rgba(0, 156, 217, 0.16),
transparent 26%
),
radial-gradient(
circle at bottom right,
rgba(126, 34, 206, 0.08),
transparent 26%
),
linear-gradient(180deg, #f8fbff 0%, #eef4fb 100%);
--app-surface: rgba(255, 255, 255, 0.86);
--app-surface-strong: #ffffff;
--app-sidebar: #0f172a;
--app-sidebar-soft: rgba(30, 41, 59, 0.9);
--app-border: rgba(148, 163, 184, 0.2);
--app-shadow: 0 24px 60px rgba(15, 23, 42, 0.12);
--app-shadow-soft: 0 14px 32px rgba(15, 23, 42, 0.08);
--app-radius-xl: 32px;
--app-radius-lg: 24px;
--app-radius-md: 18px;
--app-radius-sm: 14px;
--app-content-width: 840px;
}
html,
body,
#root {
min-height: 100%;
}
body {
margin: 0;
background: var(--app-shell-bg);
color: #0f172a;
font-family: ${APP_FONT_FAMILY};
}
* {
box-sizing: border-box;
}
*::-webkit-scrollbar {
width: 10px;
height: 10px;
}
*::-webkit-scrollbar-thumb {
background: rgba(148, 163, 184, 0.45);
border: 2px solid transparent;
border-radius: 999px;
background-clip: content-box;
}
*::-webkit-scrollbar-track {
background: transparent;
}
a {
color: inherit;
text-decoration: none;
}
.app-glass-card {
background: var(--app-surface);
border: 1px solid var(--app-border);
box-shadow: var(--app-shadow-soft);
backdrop-filter: blur(18px);
}
`;
export default () => {
const themeToken = defaultAlgorithm({
...defaultSeed,
colorPrimary: '#009cd9',
colorInfo: '#009cd9',
colorSuccess: '#1d9b62',
colorWarning: '#d97706',
colorError: '#dc2626',
colorBgBase: '#f6f9fc',
colorBgContainer: '#ffffff',
colorTextBase: '#0f172a',
borderRadius: 18,
borderRadiusLG: 24,
borderRadiusSM: 14,
boxShadow:
'0 20px 50px rgba(15, 23, 42, 0.12), 0 6px 18px rgba(15, 23, 42, 0.06)',
boxShadowSecondary:
'0 12px 30px rgba(15, 23, 42, 0.08), 0 4px 12px rgba(15, 23, 42, 0.05)',
controlHeight: 44,
fontSize: 15,
});
return (
<>
<GlobalStyle />
<ConfigProvider
theme={{
token: themeToken,
components: {
Layout: {
bodyBg: 'transparent',
siderBg: 'transparent',
},
Button: {
borderRadius: 14,
controlHeight: 42,
primaryShadow: 'none',
},
Input: {
borderRadius: 14,
activeShadow: '0 0 0 3px rgba(0, 156, 217, 0.12)',
},
Modal: {
borderRadiusLG: 24,
},
Collapse: {
headerBg: 'transparent',
contentBg: 'transparent',
},
Drawer: {
colorBgElevated: '#0f172a',
},
Steps: {
colorPrimary: '#009cd9',
},
},
}}
>
<App>
<Outlet />
</App>
</ConfigProvider>
</>
);
};
// 全局共享数据示例
import { DEFAULT_NAME } from '@/constants';
import { useState } from 'react';
const useUser = () => {
const [name, setName] = useState(DEFAULT_NAME);
return {
name,
setName,
};
};
export default useUser;
import { PageContainer } from '@ant-design/pro-components';
import { Access, useAccess } from '@umijs/max';
import { Button } from 'antd';
const AccessPage = () => {
const access = useAccess();
return (
<PageContainer
ghost
header={{
title: '权限示例',
}}
>
<Access accessible={access.canSeeAdmin}>
<Button>只有 Admin 可以看到这个按钮</Button>
</Access>
</PageContainer>
);
};
export default AccessPage;
import {
BulbOutlined,
CloudUploadOutlined,
CopyOutlined,
LoadingOutlined,
PaperClipOutlined,
PayCircleOutlined,
ReloadOutlined,
ToolOutlined,
} from '@ant-design/icons';
import {
Attachments,
Bubble,
Prompts,
Sender,
useXAgent,
useXChat,
Welcome,
XStream,
} from '@ant-design/x';
import { useQuery } from '@umijs/max';
import {
Button,
Checkbox,
Collapse,
Flex,
message,
Space,
Tag,
theme,
Typography,
} from 'antd';
import { createStyles } from 'antd-style';
import 'katex/dist/katex.min.css';
import _ from 'lodash';
import { marked } from 'marked';
import marketKatex from 'marked-katex-extension';
import {
forwardRef,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
} from 'react';
import {
callMcpTool,
createConversation,
documentAnalyze,
documentRerank,
imageAnalyze,
llmChat,
queryMcps,
queryTopics,
semanticSearch,
updateConversation,
} from '../../services/DataService';
// 导入发票助手组件
import InvoiceAssistant from './invoice';
const { success } = message;
marked.use(
marketKatex({
strict: false,
throwOnError: false,
nonStandard: true,
}),
);
const preprocessLaTeX = (content) => {
return content.replace(/\s\$\$\s/gi, '\n\n$$$$\n\n');
};
const SYSTEM_PROMPT = `你是一个有用的的智能助手。
如果用户提供了上下文,请根据上下文回答问题,不要试图创造一个答案。如果你不知道答案,请回答你只能根据上下文的内容回答问题。`;
const AGENTS = {
key: '2',
label: '智能体',
children: [
{
key: '2-4',
icon: <PayCircleOutlined />,
label: '发票助手',
description: '支持全票种识别,精准高效助力发票管理',
disabled: false,
},
// {
// key: '2-2',
// icon: <PaperClipOutlined />,
// label: '存单数据提取',
// description: '高效采集存单数据,助力业务精准处理',
// disabled: true,
// },
// {
// key: '2-3',
// icon: <FileDoneOutlined />,
// label: '报告核对',
// description: '高效核对报告,精准揪错,提升文档准确性',
// disabled: true,
// },
],
};
const useStyle = createStyles(({ token, css, responsive }) => {
return {
chat: css`
height: 100%;
min-height: 0;
width: 100%;
display: flex;
flex-direction: column;
gap: 18px;
padding: 18px;
background: transparent;
${responsive.xs} {
padding: 12px;
gap: 14px;
}
`,
chatPrompt: css`
.ant-prompts-item {
border-radius: 22px !important;
border: 1px solid rgba(148, 163, 184, 0.16) !important;
background: linear-gradient(
180deg,
rgba(255, 255, 255, 0.98) 0%,
rgba(248, 250, 252, 0.9) 100%
) !important;
box-shadow: 0 12px 28px rgba(15, 23, 42, 0.06) !important;
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.ant-prompts-item:hover {
transform: translateY(-2px);
box-shadow: 0 18px 32px rgba(15, 23, 42, 0.1) !important;
}
.ant-prompts-label {
color: #0f172a !important;
font-weight: 600 !important;
}
.ant-prompts-desc {
color: #64748b !important;
width: 100%;
line-height: 1.6;
}
.ant-prompts-icon {
color: ${token.colorPrimary} !important;
}
.ant-prompts-sub-item {
border-radius: 16px !important;
background: rgba(255, 255, 255, 0.72) !important;
border: 1px solid rgba(148, 163, 184, 0.12) !important;
}
`,
chatList: css`
flex: 1;
min-height: 0;
overflow: auto;
`,
chatListInner: css`
width: min(100%, var(--app-content-width));
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 16px;
min-height: 100%;
`,
bubbleList: css`
.ant-bubble-list {
gap: 16px;
}
.ant-bubble {
max-width: 100%;
}
.ant-bubble-content {
border-radius: 22px !important;
padding: 0 !important;
background: transparent !important;
box-shadow: none !important;
}
.ant-bubble-avatar {
margin-top: 2px;
}
`,
userBubble: css`
background: linear-gradient(135deg, #0ea5e9 0%, #0284c7 100%);
color: #ffffff;
border-radius: 22px 22px 10px 22px;
box-shadow: 0 18px 36px rgba(14, 165, 233, 0.24);
padding: 14px 18px;
`,
assistantBubble: css`
background: linear-gradient(
180deg,
rgba(255, 255, 255, 0.98) 0%,
rgba(248, 250, 252, 0.92) 100%
);
border: 1px solid rgba(148, 163, 184, 0.16);
border-radius: 22px 22px 22px 10px;
box-shadow: 0 16px 34px rgba(15, 23, 42, 0.08);
color: #0f172a;
padding: 16px 18px;
`,
toolBubble: css`
background: rgba(255, 255, 255, 0.78);
border: 1px dashed rgba(148, 163, 184, 0.4);
border-radius: 18px;
padding: 14px 16px;
`,
loadingMessage: css`
background-image: linear-gradient(90deg, #3f9c35 0%, #009cde 100%);
background-size: 100% 3px;
background-repeat: no-repeat;
background-position: bottom;
`,
placeholder: css`
justify-content: center;
padding-top: 10px;
`,
heroCard: css`
padding: 32px;
border-radius: 30px;
background: radial-gradient(
circle at top right,
rgba(125, 211, 252, 0.36),
transparent 24%
),
linear-gradient(
180deg,
rgba(255, 255, 255, 0.98) 0%,
rgba(248, 250, 252, 0.92) 100%
);
border: 1px solid rgba(148, 163, 184, 0.16);
box-shadow: 0 22px 54px rgba(15, 23, 42, 0.08);
${responsive.xs} {
padding: 24px 18px;
}
`,
promptRow: css`
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
${responsive.md} {
grid-template-columns: 1fr;
}
`,
sender: css`
width: 100%;
max-width: var(--app-content-width);
margin: 0 auto;
.ant-sender {
border-radius: 24px !important;
border: 1px solid rgba(148, 163, 184, 0.18) !important;
background: rgba(255, 255, 255, 0.9) !important;
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.08) !important;
padding: 10px !important;
}
.ant-sender-textarea {
background: transparent !important;
}
.ant-sender-input {
font-size: 15px;
}
.ant-sender-header {
margin-bottom: 8px;
border-radius: 18px;
overflow: hidden;
}
`,
speechButton: css`
font-size: 18px;
color: ${token.colorTextSecondary} !important;
`,
senderShell: css`
width: min(100%, calc(var(--app-content-width) + 24px));
margin: 0 auto;
padding: 8px;
border-radius: 30px;
background: rgba(255, 255, 255, 0.5);
border: 1px solid rgba(255, 255, 255, 0.68);
box-shadow: 0 20px 46px rgba(15, 23, 42, 0.08);
`,
senderPrompt: css`
width: 100%;
max-width: var(--app-content-width);
margin: 0 auto;
color: ${token.colorText};
.ant-prompts-item {
border-radius: 16px !important;
background: rgba(255, 255, 255, 0.75) !important;
border: 1px solid rgba(148, 163, 184, 0.16) !important;
}
`,
thinkingCard: css`
border-radius: 18px;
background: rgba(248, 250, 252, 0.92);
border: 1px solid rgba(148, 163, 184, 0.18);
overflow: hidden;
.ant-collapse-header {
padding: 10px 14px !important;
color: #475569 !important;
font-weight: 600;
}
.ant-collapse-content-box {
padding: 0 14px 14px !important;
}
`,
markdown: css`
font-size: 15px;
line-height: 1.8;
color: #0f172a;
p:first-child {
margin-top: 0;
}
p:last-child {
margin-bottom: 0;
}
pre {
overflow-x: auto;
border-radius: 16px;
background: #0f172a;
color: #e2e8f0;
padding: 14px 16px;
}
code {
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo,
monospace;
}
`,
assistantActions: css`
padding-top: 8px;
`,
};
});
export default forwardRef((props, ref) => {
const { userInfo, conversation, onAddConversation } = props;
const { styles } = useStyle();
const { token } = theme.useToken();
// 检测是否是发票助手(根据conversation的label判断)
const isInvoiceAssistant = conversation?.metadata?.label === '发票助手';
const [attachmentsOpen, setAttachmentsOpen] = useState(false);
const [attachedFiles, setAttachedFiles] = useState([]);
const [knowledgeBases, setKnowledgeBases] = useState([]);
const [inputValue, setInputValue] = useState('');
const senderRef = useRef(null);
const abortController = useRef(null);
useImperativeHandle(ref, () => {
return {
submit: async (message) => {
const files = message.files || [];
const settings = message.settings || {};
setAttachedFiles(files);
await onSubmit({
content: message.content,
files,
settings,
});
},
};
});
const transformMessage = (originMessage, chunk) => {
const originContent = originMessage?.content || '';
const originThink = originMessage?.think || '';
let currentContent = '';
let currentThink = '';
const toolCalls = originMessage?.tool_calls || [];
if (!chunk?.data) {
return originMessage;
}
if (chunk.data.includes('DONE')) {
return {
...originMessage,
tool_calls: undefined,
};
}
if (chunk.data.includes('LOADING')) {
return {
loading: true,
content: '',
role: 'assistant',
};
}
try {
let messageData;
try {
messageData = JSON.parse(chunk?.data);
} catch (jsonError) {
console.error('消息数据JSON解析失败:', jsonError);
console.error('原始数据:', chunk?.data);
return originMessage;
}
currentThink = messageData?.choices?.[0]?.delta?.reasoning_content || '';
currentContent = messageData?.choices?.[0]?.delta?.content || '';
messageData?.choices?.[0]?.delta?.tool_calls?.forEach((call) => {
const index = call.index;
const toolCall = (toolCalls[index] = toolCalls[index] || {
id: null,
index: null,
type: null,
function: {
name: '',
arguments: '',
},
});
toolCall.id = toolCall.id || call.id;
toolCall.index = toolCall.index || index;
toolCall.type = toolCall.type || call.type;
toolCall.function.name += call.function.name || '';
toolCall.function.arguments += call.function.arguments || '';
});
} catch (error) {
console.error(error);
}
console.log('toolCalls:', toolCalls);
return {
think: `${originThink}${currentThink}`,
content: `${originContent}${currentContent}`,
role: 'assistant',
tool_calls: toolCalls,
tools: toolCalls,
};
};
const getTools = async () => {
const res = await queryMcps();
return res.mcps.flatMap((mcp) =>
mcp.tools.map((tool) => ({
type: 'function',
function: {
name: `${mcp.name}:${tool.definition.name}`,
description: tool.definition.description.trim(),
parameters: tool.definition.params || {},
required: tool.definition.params
? Object.keys(tool.definition.params).filter(
(key) => tool.definition.params[key].required,
)
: [],
},
})),
);
};
const { data: topics = [], refetch: refetchConversations } = useQuery({
queryKey: ['topics', userInfo],
queryFn: async () => {
if (!userInfo) {
return [];
}
try {
const res = await queryTopics();
return res.records;
} catch (err) {}
},
});
const getSystemPrompt = async () => {
const topic = conversation.metadata.topic;
if (!topic) {
return SYSTEM_PROMPT;
}
return `${topic.metadata.prompt || ''}\n${topic.metadata.context || ''}`;
};
const getMessagePrompt = async (message) => {
const topic = conversation.metadata.topic;
const files = message.files || [];
const searchSubjects = topic?.metadata?.search_subjects || [];
const kbs = topic?.metadata?.kbs || [];
const searchResults = [];
const docs = [];
let fileAnalyzeResults = [];
let searchPrompt = '';
let filePrompt = '';
let kbPrompt = '';
for (const searchSubject of searchSubjects) {
const res = await semanticSearch(searchSubject.subject, {
text: message.content,
size: 5,
source: ['title', 'embeddings.content', 'embeddings.text'],
...searchSubject.query,
});
docs.push(
res.hits?.hits?.flatMap((doc) =>
doc._source.embeddings.map(
(chunk) =>
`# ${doc._source.title}\n${chunk.content || chunk.text || ''}`,
),
) || [],
);
}
if (kbs.length > 0) {
const res = await semanticSearch('ai_knowledge_base_document', {
text: message.content,
size: 5,
source: ['title', 'embeddings.content', 'embeddings.text'],
terms: {
knowledge_base: kbs[0]._id,
},
});
docs.push(
...(res.hits?.hits?.flatMap((doc) =>
doc._source.embeddings.map(
(chunk) =>
`# ${doc._source.title}\n${chunk.content || chunk.text || ''}`,
),
) || []),
);
}
if (docs.length > 0) {
const batchSize = 50;
const chunks = _.chunk(docs, batchSize);
const scores = [];
let offset = 0;
for (const chunk of chunks) {
const res = await documentRerank({
query: message.content,
documents: chunk,
});
scores.push(
...res.map((s) => ({
index: s.index + offset,
score: s.score,
})),
);
offset += batchSize;
}
console.log(
'scores',
scores.sort((s) => s.score),
);
searchResults.push(
...scores
.filter((item) => item.score > 0.8)
.map((item) => docs[item.index])
.slice(0, 10),
);
console.log('search results', searchResults);
}
for (const file of files) {
console.log('file:', file);
let res;
// 如果是PDF文件,使用documentAnalyze,否则使用imageAnalyze
if (
file.type === 'application/pdf' ||
file.name?.toLowerCase().endsWith('.pdf')
) {
res = await documentAnalyze({
document: {
url: file.url,
},
});
} else {
res = await imageAnalyze({
image: {
url: file.url,
},
});
}
fileAnalyzeResults.push({
type: file.type,
name: file.name,
content: res.result?.data?.content,
content_type: res.result?.data?.content_type,
});
}
if (searchResults.length > 0) {
searchPrompt = `知识库搜索结果:\n${searchResults.join('\n')}\n\n`;
}
if (fileAnalyzeResults.length > 0) {
const fileContent = fileAnalyzeResults
.map(
(item) =>
`文件名:\n${item.name}\n文件类型:${item.type}\n文件内容:\n${item.content}\n`,
)
.join('\n');
filePrompt = `用户上传的文件内容:\n${fileContent}\n\n`;
}
return `${searchPrompt}${filePrompt}${kbPrompt}用户提问:${message.content}`;
};
const [agent] = useXAgent({
request: async (
{ message, messages },
{ onUpdate, onSuccess, onError, onStream },
) => {
const chunks = [];
try {
const controller = new AbortController();
onStream(controller);
onUpdate({
data: 'LOADING',
});
const prompt = await getMessagePrompt(message);
const tools = await getTools();
const requestMessages = [
{
role: 'system',
content: await getSystemPrompt(),
},
...messages.slice(0, -1),
{
...message,
content: prompt,
},
];
const content = JSON.stringify(requestMessages);
const long = content.length > 900000;
const model = long ? 'qwen-long' : 'qwen-plus';
const enable_search =
conversation.metadata.topic?.metadata?.enable_search;
const enable_thinking = true;
const data = {
model,
stream: true,
messages: requestMessages,
tools:
!conversation.metadata.topic && tools.length > 0
? tools
: undefined,
enable_search,
enable_thinking,
};
onUpdate({
data: 'LOADING',
});
await updateConversation(conversation.name, {
messages: messages,
});
const response = await llmChat(data, controller.signal);
const stream = XStream({
readableStream: response.body,
});
for await (const chunk of stream) {
onUpdate(chunk);
chunks.push(chunk);
}
const currentMessage = chunks.reduce(transformMessage);
console.log('currentMessage:', currentMessage);
if (currentMessage.tool_calls?.length > 0) {
const toolMessages = [];
for (const call of currentMessage.tool_calls) {
const [mcpName, toolName] = call.function.name.split(':');
// 安全地解析JSON参数
let mcpArguments;
try {
mcpArguments = JSON.parse(call.function.arguments);
} catch (jsonError) {
console.error('JSON解析失败:', jsonError);
console.error('原始参数:', call.function.arguments);
console.error('参数长度:', call.function.arguments?.length);
console.error('参数类型:', typeof call.function.arguments);
// 尝试清理参数字符串
let cleanedArguments = call.function.arguments;
if (typeof cleanedArguments === 'string') {
// 移除可能的控制字符和无效字符
cleanedArguments = cleanedArguments
.replace(/[\x00-\x1F\x7F]/g, '') // 移除控制字符
.replace(/\s+/g, ' ') // 规范化空白字符
.trim();
try {
mcpArguments = JSON.parse(cleanedArguments);
} catch (secondError) {
console.error('清理后仍然解析失败:', secondError);
// 使用默认参数
mcpArguments = {};
}
} else {
mcpArguments = {};
}
}
// 根据发票类型调用不同的MCP工具
let result;
try {
// 尝试调用MCP工具
result = await callMcpTool(mcpName, toolName, mcpArguments);
} catch (error) {
console.error('MCP工具调用失败:', error);
result = {
content: `MCP工具调用失败: ${error.message}`,
error: true,
};
}
// 打印MCP工具返回值
console.log('=== MCP工具返回值 ===');
console.log('调用的工具:', mcpName, ':', toolName);
console.log('传入参数:', mcpArguments);
console.log('返回结果:', result);
// 安全地访问result.content
if (result) {
console.log('result.content:', result.content);
console.log('result.content类型:', typeof result.content);
console.log(
'result.content是否为数组:',
Array.isArray(result.content),
);
} else {
console.log('result为undefined或null');
}
console.log('==================');
// 处理MCP工具返回的内容
let content = '';
if (result && result.content) {
if (Array.isArray(result.content)) {
// 如果是数组,按原逻辑处理
const items = result.content;
content = items
.filter(
(item) => item.type === 'text' || typeof item === 'string',
)
.map((item) => item.text || item)
.join('\n');
} else if (typeof result.content === 'string') {
// 如果是字符串,直接使用
content = result.content;
} else if (typeof result.content === 'object') {
// 如果是对象,尝试转换为字符串
content = JSON.stringify(result.content, null, 2);
} else {
// 其他类型,转换为字符串
content = String(result.content);
}
} else {
// 如果没有content,使用整个result
content = JSON.stringify(result, null, 2);
}
console.log('处理后的content:', content);
toolMessages.push({
role: 'tool',
content,
});
}
const toolData = {
...data,
tools: undefined,
enable_thinking: false,
messages: [
{
role: 'system',
content: SYSTEM_PROMPT,
},
...data.messages,
currentMessage,
...toolMessages,
],
};
const toolResponse = await llmChat(toolData, controller.signal);
const toolStream = XStream({
readableStream: toolResponse.body,
});
for await (const chunk of toolStream) {
onUpdate(chunk);
chunks.push(chunk);
}
}
onUpdate({
data: 'DONE',
});
onSuccess(chunks);
} catch (error) {
if (error.name === 'AbortError') {
onSuccess(chunks);
} else {
onError(error);
}
}
},
});
const loading = agent.isRequesting();
const { onRequest, messages, setMessages } = useXChat({
agent,
requestFallback: (message, { error }) => {
console.log('error', error);
if (error.name === 'AbortError') {
return {
content: 'Request is aborted',
role: 'assistant',
};
}
return {
content: 'Request failed, please try again!',
role: 'assistant',
};
},
transformMessage: (info) =>
transformMessage(info.originMessage, info.chunk),
resolveAbortController: (controller) => {
abortController.current = controller;
},
});
const messageItems = useMemo(() => {
return messages?.map((item, index) => {
const messageItem = {
...item.message,
classNames: {
content: item.status === 'loading' ? styles.loadingMessage : '',
},
loading: item.message?.loading,
};
if (item.message.role === 'user') {
messageItem.classNames = {
content: styles.userBubble,
};
messageItem.messageRender = (content) => {
return (
<Flex vertical gap={8}>
<div>{content}</div>
{item.message.files?.map((file, index) => (
<Attachments.FileCard key={index} item={file} />
))}
</Flex>
);
};
}
if (item.message.role === 'assistant') {
messageItem.classNames = {
content: `${item.status === 'loading' ? styles.loadingMessage : ''} ${
styles.assistantBubble
}`,
};
messageItem.variant = 'borderless';
messageItem.footer = (
<Space className={styles.assistantActions}>
<Button
type="text"
size="small"
icon={<CopyOutlined />}
onClick={() => {
navigator.clipboard.writeText(item.message.content);
success('已复制到剪贴板');
}}
/>
{index === messages.length - 1 && (
<Button
type="text"
size="small"
icon={<ReloadOutlined />}
onClick={() => {
const userMessageIndex = messages.findLastIndex(
(m) => m.message.role === 'user',
);
if (userMessageIndex >= 0) {
setMessages((messages) =>
messages.slice(0, userMessageIndex),
);
onSubmit(messages[userMessageIndex]?.message);
}
}}
/>
)}
</Space>
);
messageItem.messageRender = () => {
return (
<Flex vertical gap={8} style={{ width: '100%' }}>
{item.message.think && (
<Collapse
className={styles.thinkingCard}
size="small"
bordered={false}
style={{
alignSelf: 'flex-start',
}}
defaultActiveKey="think"
items={[
{
key: 'think',
showArrow: false,
label:
item.status === 'success' || item.message.content ? (
<Space>
<BulbOutlined />
思考完成
</Space>
) : (
<Space>
<LoadingOutlined />
正在思考...
</Space>
),
children: (
<Typography.Paragraph style={{ marginBottom: 0 }}>
<div
className={styles.markdown}
dangerouslySetInnerHTML={{
__html: marked.parse(
preprocessLaTeX(item.message.think),
),
}}
></div>
</Typography.Paragraph>
),
},
]}
/>
)}
{item.message.tools?.length > 0 && (
<Collapse
className={styles.thinkingCard}
size="small"
bordered={false}
style={{
alignSelf: 'flex-start',
}}
defaultActiveKey="think"
items={item.message.tools.map((tool) => ({
key: 'tool_' + tool.function.name,
showArrow: false,
label: (
<Space>
<ToolOutlined />
工具调用:{tool.function.name}
</Space>
),
children: (
<Typography.Paragraph style={{ marginBottom: 0 }}>
<div
className={styles.markdown}
dangerouslySetInnerHTML={{
__html: marked.parse(
preprocessLaTeX(
'```json\n' + tool.function.arguments + '\n```',
),
),
}}
></div>
</Typography.Paragraph>
),
}))}
/>
)}
<div
className={styles.markdown}
dangerouslySetInnerHTML={{
__html: marked.parse(preprocessLaTeX(item.message.content)),
}}
></div>
</Flex>
);
};
}
if (item.message.role === 'tool') {
messageItem.classNames = {
content: styles.toolBubble,
};
messageItem.messageRender = () => {
return (
<>
{item.message.tool && (
<Collapse
size="small"
bordered={false}
items={[
{
key: 'think',
label: (
<Space>
<BulbOutlined />
工具调用:{item.message.tool}
</Space>
),
children: item.message.think,
showArrow: false,
},
]}
/>
)}
<div>{item.message.content}</div>
</>
);
};
}
return messageItem;
});
}, [messages]);
useEffect(() => {
abortController?.current?.abort('cancelled');
const messages = conversation?.metadata?.messages || [];
setMessages(
messages.map((message) => ({
status: 'success',
message: message,
})),
);
senderRef.current?.focus();
}, [conversation]);
useEffect(() => {
if (conversation) {
const oldMessage = _.last(conversation.metadata.messages);
const newMessage = _.last(messages);
if (
newMessage?.status === 'success' &&
newMessage?.message?.content !== oldMessage?.content
) {
updateConversation(conversation.name, {
messages: messages.map((m) => m.message),
}).then();
}
}
}, [messages]);
useEffect(() => {
setKnowledgeBases([
{
key: 'external_law',
description: '法律法规库',
icon: <Checkbox />,
selected: false,
},
{
key: 'regulatory_penalty',
description: '监管处罚库',
icon: <Checkbox />,
selected: false,
},
{
key: 'am_material',
description: 'AM材料库',
icon: <Checkbox />,
selected: false,
},
]);
}, []);
const toggleKnowledgeBase = (key) => {
const knowledgeBase = knowledgeBases.find((kb) => kb.key === key);
if (knowledgeBase) {
knowledgeBase.selected = !knowledgeBase.selected;
knowledgeBase.icon = <Checkbox checked={knowledgeBase.selected} />;
}
setKnowledgeBases([...knowledgeBases]);
};
const addConversation = async (data, message) => {
const conversation = await createConversation(data);
conversation.metadata = {
...conversation.metadata,
...data,
};
onAddConversation?.(conversation, message);
};
const onSubmit = async ({
content,
topic,
files = null,
settings = null,
agentLabel = null,
}) => {
const userMessage = content && {
role: 'user',
content,
files:
files ||
attachedFiles
.filter((file) => file.status === 'done')
.map((file) => file.response),
settings: settings,
};
if (!conversation) {
await addConversation(
{
label: agentLabel || content || topic?.title,
topic: topic,
},
userMessage,
);
return;
}
// 如果没有内容且不是创建新对话,直接返回
if (!content) {
return;
}
if (loading) {
message.warning('请等待请求完成');
return;
}
setAttachedFiles([]);
onRequest({
stream: true,
message: userMessage,
});
};
if (isInvoiceAssistant) {
return (
<InvoiceAssistant
ref={ref}
conversation={conversation}
onAddConversation={onAddConversation}
userInfo={userInfo}
/>
);
}
const chatList = (
<div className={styles.chatList}>
{conversation ? (
<div className={styles.chatListInner}>
{conversation.metadata.topic && (
<Welcome
title={conversation.metadata.topic.title}
style={{
background:
'linear-gradient(135deg, rgba(0, 156, 217, 0.12) 0%, rgba(186, 230, 253, 0.36) 100%)',
border: '1px solid rgba(125, 211, 252, 0.28)',
borderRadius: 24,
}}
/>
)}
<div className={styles.bubbleList}>
<Bubble.List
items={messageItems}
style={{
height: '100%',
}}
roles={{
assistant: {
placement: 'start',
avatar: {
children: 'AI',
style: {
background: '#e0f2fe',
color: token.colorPrimary,
},
},
},
user: {
placement: 'end',
avatar: userInfo?.avatar
? { src: userInfo.avatar }
: {
children: userInfo?.display_name?.[0] || '我',
style: {
background: '#dbeafe',
color: '#1d4ed8',
},
},
},
}}
/>
</div>
</div>
) : (
<div className={`${styles.chatListInner} ${styles.placeholder}`}>
<div className={styles.heroCard}>
<Space direction="vertical" size={18} style={{ width: '100%' }}>
<Tag
color="cyan"
bordered={false}
style={{ width: 'fit-content' }}
>
企业 AI 对话应用
</Tag>
<Welcome
variant="borderless"
icon="https://oss.streams7.com/rsm/BfNsqGPGTKE2.svg"
title="智能助手"
description="统一承载知识问答、业务理解、附件分析与专属智能体协作。"
/>
</Space>
</div>
<div className={styles.promptRow}>
<Prompts
items={[
{
key: 'topics',
label: '话题',
children: topics.map((topic, i) => ({
key: topic.name,
description: topic.title,
icon: (
<span
style={{
fontWeight: 'bold',
fontFamily: 'monospace',
color: token.colorPrimary,
}}
>
{i + 1}
</span>
),
topic: topic,
})),
},
]}
style={{
flex: 1,
}}
styles={{
list: { height: '100%' },
subItem: { padding: 0, background: 'transparent' },
}}
onItemClick={async (info) => {
onSubmit({
topic: info.data.topic,
});
}}
className={styles.chatPrompt}
/>
<Prompts
items={[AGENTS]}
style={{
flex: 1,
}}
styles={{
subItem: { background: 'rgba(255,255,255,0.72)' },
}}
onItemClick={(info) => {
// 所有智能体都正常发送描述信息,包括发票助手
onSubmit({
content: info.data.description,
agentLabel: info.data.label,
});
}}
className={styles.chatPrompt}
/>
</div>
</div>
)}
</div>
);
const senderHeader = (
<Sender.Header
title="上传文件"
open={attachmentsOpen}
onOpenChange={setAttachmentsOpen}
styles={{ content: { padding: 0 } }}
>
<Attachments
items={attachedFiles}
action="/api/upload"
onChange={(info) => {
setAttachedFiles(info.fileList);
}}
placeholder={(type) =>
type === 'drop'
? {
title: '拖动文件到这里',
}
: {
icon: <CloudUploadOutlined />,
title: '点击或拖动文件到这里',
description:
'支持文件类型: DOC, DOCX, XSL, XSLX, PDF, JPG, PNG',
}
}
/>
</Sender.Header>
);
const chatSender = (
<div className={styles.senderShell}>
{messages.length === 0 && conversation?.metadata?.topic && (
<Prompts
vertical
className={styles.senderPrompt}
styles={{
item: {
padding: '6px 12px',
},
}}
items={conversation.metadata.topic.metadata.questions?.map((q) => ({
key: q,
description: q,
}))}
onItemClick={(info) => {
onSubmit({
content: info.data.description,
});
}}
/>
)}
<Sender
ref={senderRef}
value={inputValue}
header={senderHeader}
onSubmit={() => {
onSubmit({
content: inputValue,
});
setInputValue('');
}}
onChange={setInputValue}
onCancel={() => abortController.current?.abort()}
prefix={
<Button
type="text"
icon={<PaperClipOutlined style={{ fontSize: 18 }} />}
onClick={() => setAttachmentsOpen(!attachmentsOpen)}
/>
}
loading={loading}
className={styles.sender}
allowSpeech
actions={(_, info) => {
const { SendButton, LoadingButton, SpeechButton } = info.components;
return (
<Flex gap={4}>
<SpeechButton className={styles.speechButton} />
{loading ? (
<LoadingButton type="default" />
) : (
<SendButton type="primary" />
)}
</Flex>
);
}}
placeholder="请输入消息内容"
/>
</div>
);
return (
<div className={styles.chat}>
{chatList}
{chatSender}
</div>
);
});
import {
BulbOutlined,
CheckCircleOutlined,
CloudUploadOutlined,
CopyOutlined,
DownloadOutlined,
PaperClipOutlined,
PayCircleOutlined,
ReloadOutlined,
} from '@ant-design/icons';
import {
Attachments,
Bubble,
Prompts,
Sender,
useXAgent,
useXChat,
XStream,
} from '@ant-design/x';
import { App, Button, Flex, Space, Spin, Steps, Tag, Typography } from 'antd';
import { createStyles } from 'antd-style';
import 'katex/dist/katex.min.css';
import _ from 'lodash';
import { marked } from 'marked';
import marketKatex from 'marked-katex-extension';
import {
forwardRef,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
} from 'react';
import {
callMcpTool,
createConversation,
llmChat,
queryMcps,
updateConversation,
} from '../../services/DataService';
// 导入附件生成函数
import { generateExcelZip } from '../../utils/excelGenerator.js';
// 导入JSZip用于解压ZIP文件
import JSZip from 'jszip';
marked.use(
marketKatex({
strict: false,
throwOnError: false,
nonStandard: true,
}),
);
const preprocessLaTeX = (content) => {
return content.replace(/\s\$\$\s/gi, '\n\n$$$$\n\n');
};
const sanitizeHtml = (html) => {
if (typeof html !== 'string') {
return '';
}
return html
.replace(/<script[\s\S]*?>[\s\S]*?<\/script>/gi, '')
.replace(/on[a-z]+=(".*?"|'.*?'|[^\s>]+)/gi, '')
.replace(/javascript:/gi, '');
};
const SYSTEM_PROMPT = `你叫Mia,是一个有用的的发票智能助手。如果用户提供了上下文,请根据上下文回答问题,不要试图创造一个答案。
如果你不知道答案,请回答你只能根据上下文的内容回答问题。在识别完文件之后,需要调用mcp工具生成用户可能需要的问题选项。用户确认数量没问题之后,需要生成一个表单供用户填写。接收到的是html的时候,assistent角色回复的消息需要是html格式,只要用户发送了表单信息,那么无论表单信息内容是否为空,都需要调用mcp工具对表单信息进行标准化处理
`;
const useStyle = createStyles(({ token, css, responsive }) => {
return {
chat: css`
flex: 1;
min-height: 0;
display: flex;
flex-direction: row;
gap: 18px;
overflow: hidden;
${responsive.md} {
flex-direction: column;
}
`,
chatWithSidebar: css`
height: 100%;
width: 100%;
display: flex;
flex-direction: column;
gap: 18px;
flex: 1;
order: 1;
`,
stepsSidebar: css`
width: 300px;
min-width: 300px;
height: 100%;
background: linear-gradient(
180deg,
rgba(255, 255, 255, 0.96) 0%,
rgba(248, 250, 252, 0.92) 100%
);
border: 1px solid rgba(148, 163, 184, 0.16);
border-radius: 28px;
padding: 22px 20px;
display: flex;
flex-direction: column;
gap: 16px;
overflow-y: auto;
order: 2;
box-shadow: 0 20px 40px rgba(15, 23, 42, 0.08);
${responsive.md} {
width: 100%;
min-width: 0;
max-height: 280px;
}
`,
sidebarTitle: css`
font-size: 18px;
font-weight: 600;
color: #0f172a;
display: flex;
align-items: center;
gap: 8px;
`,
chatList: css`
flex: 1;
min-height: 0;
overflow: auto;
`,
chatListInner: css`
display: flex;
flex-direction: column;
min-height: 100%;
width: min(100%, var(--app-content-width));
margin: 0 auto;
gap: 16px;
`,
loadingMessage: css`
background-image: linear-gradient(90deg, #3f9c35 0%, #009cde 100%);
background-size: 100% 3px;
background-repeat: no-repeat;
background-position: bottom;
`,
placeholder: css`
justify-content: center;
padding-top: 10px;
`,
sender: css`
width: 100%;
max-width: var(--app-content-width);
margin: 0 auto;
.ant-sender {
border-radius: 24px !important;
border: 1px solid rgba(148, 163, 184, 0.18) !important;
background: rgba(255, 255, 255, 0.9) !important;
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.08) !important;
padding: 10px !important;
}
.ant-sender-header {
margin-bottom: 8px;
border-radius: 18px;
overflow: hidden;
}
`,
speechButton: css`
font-size: 18px;
color: ${token.colorTextSecondary} !important;
`,
senderPrompt: css`
width: 100%;
max-width: var(--app-content-width);
margin: 0 auto;
color: ${token.colorText};
`,
assistantBubble: css`
background: linear-gradient(
180deg,
rgba(255, 255, 255, 0.98) 0%,
rgba(248, 250, 252, 0.92) 100%
);
border: 1px solid rgba(148, 163, 184, 0.16);
border-radius: 22px 22px 22px 10px;
padding: 16px 18px;
box-shadow: 0 16px 34px rgba(15, 23, 42, 0.08);
color: #0f172a;
`,
userBubble: css`
background: linear-gradient(135deg, #0ea5e9 0%, #0284c7 100%);
color: #ffffff;
border-radius: 22px 22px 10px 22px;
box-shadow: 0 18px 36px rgba(14, 165, 233, 0.24);
padding: 14px 18px;
`,
senderShell: css`
width: min(100%, calc(var(--app-content-width) + 24px));
margin: 0 auto;
padding: 8px;
border-radius: 30px;
background: rgba(255, 255, 255, 0.5);
border: 1px solid rgba(255, 255, 255, 0.68);
box-shadow: 0 20px 46px rgba(15, 23, 42, 0.08);
`,
bubbleList: css`
.ant-bubble-list {
gap: 16px;
}
.ant-bubble-content {
border-radius: 22px !important;
padding: 0 !important;
background: transparent !important;
box-shadow: none !important;
}
`,
markdown: css`
font-size: 15px;
line-height: 1.8;
color: #0f172a;
p:first-child {
margin-top: 0;
}
p:last-child {
margin-bottom: 0;
}
pre {
overflow-x: auto;
border-radius: 16px;
background: #0f172a;
color: #e2e8f0;
padding: 14px 16px;
}
`,
heroCard: css`
padding: 28px;
border-radius: 30px;
background: radial-gradient(
circle at top right,
rgba(125, 211, 252, 0.32),
transparent 24%
),
linear-gradient(
180deg,
rgba(255, 255, 255, 0.98) 0%,
rgba(248, 250, 252, 0.92) 100%
);
border: 1px solid rgba(148, 163, 184, 0.16);
box-shadow: 0 22px 54px rgba(15, 23, 42, 0.08);
`,
promptCard: css`
.ant-prompts-item,
.ant-prompts-sub-item {
border-radius: 18px !important;
border: 1px solid rgba(148, 163, 184, 0.14) !important;
background: rgba(255, 255, 255, 0.8) !important;
}
`,
stepHint: css`
color: #64748b;
font-size: 13px;
line-height: 1.6;
`,
downloadButton: css`
margin-top: auto;
width: 100%;
height: 42px;
`,
sectionTag: css`
width: fit-content;
`,
};
});
const InvoiceAssistant = forwardRef((props, ref) => {
const { conversation, onAddConversation, userInfo } = props;
const { styles } = useStyle();
const { message: messageApi } = App.useApp();
// 发票助手相关状态
const [attachmentsOpen, setAttachmentsOpen] = useState(false);
const [attachedFiles, setAttachedFiles] = useState([]);
const [inputValue, setInputValue] = useState('');
const [currentStep, setCurrentStep] = useState(0);
const projectInfo = useRef({});
const senderRef = useRef(null);
const abortController = useRef(null);
const outputListRef = useRef([]);
const renamedFilesRef = useRef([]);
const processedFilesRef = useRef([]); // 存储处理后的文件列表(包含解压后的文件,不包含ZIP文件)
const chatEndRef = useRef(null);
const chatContainerRef = useRef(null);
// 由智能体思考并填充的步骤列表(进入页面后由助手输出决定)
const [stepsList, setStepsList] = useState([]);
const [thinkPrompts, setThinkPrompts] = useState([]);
const thinkPromptItems = useMemo(() => {
if (!thinkPrompts?.length) {
return [];
}
return [
{
key: 'think-prompts',
label: '推荐回答',
children: thinkPrompts.map((prompt) => ({
key: prompt.key,
title: prompt.title,
description: prompt.description,
icon: <BulbOutlined />,
data: prompt,
})),
},
];
}, [thinkPrompts]);
const didSetStepsFromAIRef = useRef(false);
// 进入页面或收到首条助手消息时,从助手文本动态推导步骤并填充(固定4步)
const extractStepsFromMessages = (message) => {
// 从当前的messagr中尝试提取步骤
let text = '';
text = message.content;
// 提取形如 1. xxx / 1)xxx / 步骤x:xxx / - xxx 的行作为步骤
// 支持更多格式:1.、1)、一.、第一步、Step 1等
const lines = text
.split(/\r?\n/)
.map((l) => l.trim())
.filter(Boolean);
const stepLines = lines.filter((l) => {
return /^(\d+[.)])|^[一二三四五六][.、)]|^步骤[一二三四五六\d]|^[S]tep\s*\d+|^[-•・●◦▪▫]/i.test(
l,
);
});
const parsed = stepLines.map((l) => {
// 移除各种前缀格式
let title = l
.replace(/^(\d+[.)]\s*)/, '') // 1. 或 1)
.replace(/^([一二三四五六][.、)]\s*)/, '') // 一. 或 一)
.replace(/^步骤[一二三四五六\d]+[::.)\s]*/i, '') // 步骤1:或 步骤一:
.replace(/^[S]tep\s*\d+[::.)\s]*/i, '') // Step 1: 或 step 1:
.replace(/^[-•・●◦▪▫]\s*/, '') // 各种列表符号
.trim();
return { title: title || '步骤' };
});
// 只取前6个步骤
const extractedSteps = parsed.slice(0, 6);
const uniqTitles = Array.from(new Set(extractedSteps.map((p) => p.title)));
// 分割为标题和详情的对象数组
const firstStep = uniqTitles[0] || '';
const shouldSplit = firstStep.includes(':');
const formattedSteps = uniqTitles.map((step) => {
if (!shouldSplit) {
return {
title: step.trim(),
detail: '',
};
}
const [title, ...detailParts] = step.split(':');
const detail = detailParts.join(':').trim();
return {
title: title.trim(),
detail,
};
});
// 确保有至少2个步骤,且不超过6个
if (formattedSteps.length >= 2 && formattedSteps.length <= 6) {
setStepsList(
formattedSteps.map((t) => ({
title: t.title,
description: t.detail || '',
})),
);
didSetStepsFromAIRef.current = true;
}
};
useImperativeHandle(ref, () => {
return {
submit: async (message) => {
const files = message.files || [];
setAttachedFiles(files);
await onSubmit({
content: message.content,
files,
});
},
};
});
const transformMessage = (originMessage, chunk) => {
const originContent = originMessage?.content || '';
const originThink = originMessage?.think || '';
let currentContent = '';
let currentThink = '';
const toolCalls = originMessage?.tool_calls || [];
if (!chunk?.data) {
return originMessage;
}
if (chunk.data.includes('DONE')) {
return {
...originMessage,
tool_calls: undefined,
};
}
if (chunk.data.includes('LOADING')) {
return {
loading: true,
content: '',
role: 'assistant',
};
}
try {
let messageData;
try {
messageData = JSON.parse(chunk?.data);
} catch (jsonError) {
console.error('消息数据JSON解析失败:', jsonError);
console.error('原始数据:', chunk?.data);
return originMessage;
}
currentThink = messageData?.choices?.[0]?.delta?.reasoning_content || '';
currentContent = messageData?.choices?.[0]?.delta?.content || '';
messageData?.choices?.[0]?.delta?.tool_calls?.forEach((call) => {
const index = call.index;
const toolCall = (toolCalls[index] = toolCalls[index] || {
id: null,
index: null,
type: null,
function: {
name: '',
arguments: '',
},
});
toolCall.id = toolCall.id || call.id;
toolCall.index = toolCall.index || index;
toolCall.type = toolCall.type || call.type;
toolCall.function.name += call.function.name || '';
toolCall.function.arguments += call.function.arguments || '';
});
} catch (error) {
console.error(error);
}
// 注释掉这里的打印,避免在 reduce 过程中多次打印
return {
think: `${originThink}${currentThink}`,
content: `${originContent}${currentContent}`,
role: 'assistant',
tool_calls: toolCalls,
tools: toolCalls,
};
};
const getTools = async () => {
const res = await queryMcps({
subject: 'invoice_assitence',
filter: {
name: 'invoice_toolset',
},
});
return res.mcps.flatMap((mcp) =>
mcp.tools.map((tool) => ({
type: 'function',
function: {
name: `${mcp.name}:${tool.definition.name}`,
description: tool.definition.description.trim(),
parameters: tool.definition.params || {},
required: tool.definition.params
? Object.keys(tool.definition.params).filter(
(key) => tool.definition.params[key].required,
)
: [],
},
})),
);
};
const getMessagePrompt = async (message) => {
const fileAnalyzeResults = [];
let files = message.files || [];
let filePrompt = '';
// 只有在有文件时才重置,避免用户确认消息时清空数据
if (files.length > 0) {
outputListRef.current = []; // 重置为空数组
renamedFilesRef.current = []; // 重置重命名文件列表
processedFilesRef.current = []; // 重置处理后的文件列表
projectInfo.current = [];
setCurrentStep(1);
}
// 处理ZIP文件:解压并将解压后的文件添加到文件列表
const processedFiles = [];
let hasZipFile = false;
let hideLoading2 = null; // 将变量提升到外部作用域
for (const file of files) {
// 检查是否是ZIP文件
if (
file.type === 'application/zip' ||
file.type === 'application/x-zip-compressed' ||
file.name?.toLowerCase().endsWith('.zip')
) {
hasZipFile = true;
let hideLoading1 = null;
try {
// 使用更安全的方式调用 messageApi.loading
if (typeof messageApi.loading === 'function') {
hideLoading1 = messageApi.loading('正在解压ZIP文件...', 0);
} else {
messageApi.info('正在解压ZIP文件...');
}
// 下载ZIP文件
const zipResponse = await fetch(file.url);
const zipBlob = await zipResponse.blob();
// 解压ZIP文件
const zip = await JSZip.loadAsync(zipBlob);
const zipFiles = Object.keys(zip.files);
// 关闭第一个加载提示
if (hideLoading1 && typeof hideLoading1 === 'function') {
hideLoading1();
}
if (typeof messageApi.loading === 'function') {
hideLoading2 = messageApi.loading(
`正在处理ZIP文件中的 ${zipFiles.length} 个文件...`,
0,
);
} else {
messageApi.info(`正在处理ZIP文件中的 ${zipFiles.length} 个文件...`);
}
// 遍历ZIP中的每个文件
for (const zipFileName of zipFiles) {
const zipFile = zip.files[zipFileName];
// 跳过目录
if (zipFile.dir) continue;
// 只处理图片和PDF文件
const lowerName = zipFileName.toLowerCase();
if (!lowerName.match(/\.(jpg|jpeg|png|pdf)$/i)) continue;
// 获取文件内容
const fileBlob = await zipFile.async('blob');
// 上传文件到服务器获取URL
const formData = new FormData();
formData.append('file', fileBlob, zipFileName);
const uploadResponse = await fetch('/api/upload', {
method: 'POST',
body: formData,
});
if (uploadResponse.ok) {
const uploadResult = await uploadResponse.json();
// 处理不同的响应格式
let fileUrl =
uploadResult.url ||
uploadResult.data?.url ||
uploadResult.response?.url;
// 如果响应格式是 { response: { url: '...' } }
if (!fileUrl && uploadResult.response) {
fileUrl = uploadResult.response.url;
}
if (fileUrl) {
// 确定文件类型
let fileType = fileBlob.type;
if (!fileType) {
if (lowerName.endsWith('.pdf')) {
fileType = 'application/pdf';
} else if (
lowerName.endsWith('.jpg') ||
lowerName.endsWith('.jpeg')
) {
fileType = 'image/jpeg';
} else if (lowerName.endsWith('.png')) {
fileType = 'image/png';
}
}
processedFiles.push({
name: zipFileName,
url: fileUrl,
type: fileType,
});
} else {
console.error(
`上传文件响应格式错误: ${zipFileName}`,
uploadResult,
);
messageApi.warning(
`处理文件 ${zipFileName} 失败:无法获取文件URL`,
);
}
} else {
const errorText = await uploadResponse.text();
console.error(
`上传文件失败: ${zipFileName}`,
uploadResponse.status,
errorText,
);
messageApi.warning(`上传文件 ${zipFileName} 失败`);
}
}
} catch (error) {
// 关闭加载提示
if (hideLoading1 && typeof hideLoading1 === 'function') {
hideLoading1();
}
if (hideLoading2 && typeof hideLoading2 === 'function') {
hideLoading2();
}
console.error('解压ZIP文件失败:', error);
messageApi.error(`解压ZIP文件失败: ${error.message}`);
}
} else {
// 非ZIP文件,直接添加
processedFiles.push(file);
}
}
// 如果处理了ZIP文件,关闭加载提示并显示成功消息
if (hasZipFile && hideLoading2 && typeof hideLoading2 === 'function') {
hideLoading2(); // 关闭加载提示
const originalFileNames = new Set(
(message.files || []).map((f) => f.name),
);
const extractedCount = processedFiles.filter(
(f) => !originalFileNames.has(f.name),
).length;
if (extractedCount > 0) {
messageApi.success(
`成功从ZIP文件中提取并处理了 ${extractedCount} 个文件`,
);
}
}
// 使用处理后的文件列表(包含解压后的文件)
files = processedFiles.length > 0 ? processedFiles : files;
// 保存处理后的文件列表到 ref,供后续处理使用
processedFilesRef.current = files;
for (const file of files) {
fileAnalyzeResults.push({
name: file.name,
url: file.url,
});
}
if (fileAnalyzeResults.length > 0) {
// 合并所有文件的content内容
const mergedContent = fileAnalyzeResults
.map((item) => item.url)
.join('\n');
filePrompt = `全部发票的url集合:\n${mergedContent}\n\n`;
return `${filePrompt},请根据已经上传的文件的类型判断调用哪一个mcp工具。
在调用mcp工具识别完所有的文件内容后,根据上下文内容,系统回复的消息只需要包括以下信息:
1、告知用户一共上传多少份文件;
2、各类型(增值发票、出租车、普通发票、铁路客票)分别有多少张,识别有误的有多少张,格式错误而导致的无法识别的文件有多少份;
3、提示用户确认数量,以及让用户自行在下载的压缩包内填写无法识别或识别错误的文件的相关信息。`;
}
return `用户回答:${message.content}`;
};
const [agent] = useXAgent({
request: async (
{ message, messages },
{ onUpdate, onSuccess, onError, onStream },
) => {
const chunks = [];
try {
const controller = new AbortController();
onStream(controller);
onUpdate({
data: 'LOADING',
});
setThinkPrompts([]);
const prompt = await getMessagePrompt(message);
const tools = await getTools();
let requestMessages = [];
if (message.files?.length > 0) {
setAttachmentsOpen(false);
requestMessages = [
{
...message,
content: prompt,
},
];
} else {
requestMessages = [
{
role: 'system',
content: SYSTEM_PROMPT,
},
...messages.slice(0, -1),
{
...message,
content: prompt,
},
];
}
const content = JSON.stringify(requestMessages);
const long = content.length > 900000;
const model = long ? 'qwen-long' : 'qwen-plus';
const enable_search = false;
const enable_thinking = false;
const data = {
model,
stream: true,
messages: requestMessages,
tools: tools.length > 0 ? tools : undefined,
enable_search,
enable_thinking,
};
onUpdate({
data: 'LOADING',
});
if (conversation) {
await updateConversation(conversation.name, {
messages: messages,
});
}
const response = await llmChat(data, controller.signal);
const stream = XStream({
readableStream: response.body,
});
for await (const chunk of stream) {
onUpdate(chunk);
chunks.push(chunk);
}
let currentMessage = chunks.reduce(transformMessage, {});
if (
currentMessage.role === 'assistant' &&
currentMessage.content.includes('Mia')
) {
await extractStepsFromMessages(currentMessage);
}
if (currentMessage.tool_calls?.length > 0) {
const toolMessages = [];
const toolHtmlSnippets = [];
let usedFormNormalization = false;
// 使用处理后的文件列表(包含解压后的文件,不包含ZIP文件)
const files =
processedFilesRef.current.length > 0
? processedFilesRef.current
: message.files || [];
for (const call of currentMessage.tool_calls) {
const [mcpName, toolName] = call.function.name.split(':');
// 安全地解析工具参数
let mcpArguments = {};
try {
const cleanArgs = (call.function.arguments || '').trim();
if (cleanArgs) {
mcpArguments = JSON.parse(cleanArgs);
}
} catch (error) {
console.error('解析工具参数失败:', error);
console.error('原始参数:', call.function.arguments);
mcpArguments = {};
}
// 添加固定的 API 配置
if (
toolName === '非出租车发票处理工具' ||
toolName === '出租车发票处理工具'
) {
mcpArguments = {
...mcpArguments,
apikey: 'sk-bf3698390f3b40dd8435cb73169a7437',
baseurl: 'https://api.deepseek.com',
};
}
let result;
try {
// 尝试调用MCP工具
result = await callMcpTool(mcpName, toolName, mcpArguments);
} catch (error) {
console.error('MCP工具调用失败:', error);
result = {
content: `MCP工具调用失败: ${error.message}`,
error: true,
};
}
// 处理MCP工具返回的内容
let content = '';
if (result && result.content) {
if (Array.isArray(result.content)) {
// 如果是数组,按原逻辑处理
const items = result.content;
content = items
.filter(
(item) => item.type === 'text' || typeof item === 'string',
)
.map((item) => item.text || item)
.join('\n');
} else if (typeof result.content === 'string') {
// 如果是字符串,直接使用
content = result.content;
} else if (typeof result.content === 'object') {
// 如果是对象,尝试转换为字符串
content = JSON.stringify(result.content, null, 2);
} else {
// 其他类型,转换为字符串
content = String(result.content);
}
} else {
// 如果没有content,使用整个result
content = JSON.stringify(result, null, 2);
}
// 安全地解析 JSON 内容
if (toolName === '表单信息标准化') {
projectInfo.current = JSON.parse(content);
usedFormNormalization = true;
}
if (
toolName === '非出租车发票处理工具' ||
toolName === '出租车发票处理工具' ||
toolName === '非发票文件处理工具'
) {
// 如果发生错误,不解析JSON,跳过处理
if (result && result.error) {
console.error(
`${toolName}工具调用失败,跳过JSON解析和文件处理`,
);
// 仍然将错误信息传递给LLM
} else {
try {
const parsedContent = JSON.parse(content);
// 确保 parsedContent 是数组且第一个元素存在
if (
Array.isArray(parsedContent) &&
parsedContent.length > 0 &&
parsedContent[0]
) {
outputListRef.current.push(parsedContent[0]);
// 基于解析结果重命名当前文件:发票号码+发票代码+原扩展名(鲁棒提取)
try {
const firstItem = parsedContent[0];
// 安全地获取 code 和 number,可能不存在(如铁路客票)
const codeVal = firstItem?.code;
const numberVal = firstItem?.number;
// 根据URL获取对应的文件
const fileUrl = mcpArguments.url || '';
// 通过URL匹配文件
const currentFile = files.find((f) => {
const fUrl = f.url || '';
// 完全匹配
return fUrl === fileUrl;
});
if (currentFile || fileUrl) {
// 从URL或文件名中提取扩展名
let ext = '';
const fileName = currentFile?.name || '';
const finalUrl = currentFile?.url || fileUrl;
// 优先从文件名中提取扩展名
if (fileName) {
const dotIdx = fileName.lastIndexOf('.');
ext = dotIdx >= 0 ? fileName.slice(dotIdx) : '';
}
// 如果文件名中没有扩展名,尝试从URL中提取
if (!ext && finalUrl) {
try {
const urlPath = new URL(finalUrl).pathname;
const urlDotIdx = urlPath.lastIndexOf('.');
if (urlDotIdx >= 0) {
const urlExt = urlPath.slice(urlDotIdx);
// 检查是否是有效的文件扩展名(最多6个字符,如 .xlsx)
if (
urlExt.length <= 6 &&
/^\.\w+$/.test(urlExt)
) {
ext = urlExt;
}
}
} catch (e) {
// URL解析失败,尝试简单的字符串匹配
const urlDotIdx = finalUrl.lastIndexOf('.');
if (urlDotIdx >= 0) {
const urlExt = finalUrl
.slice(urlDotIdx)
.split(/[?#]/)[0]; // 移除查询参数和hash
if (
urlExt.length <= 6 &&
/^\.\w+$/.test(urlExt)
) {
ext = urlExt;
}
}
}
}
// 如果 code 或 number 存在,则用于重命名;否则使用原始文件名
if (codeVal || numberVal) {
let newName = '';
// 如果其中一个为20位,直接使用该值作为newName
if (codeVal && String(codeVal).length === 20) {
newName = `${codeVal}${ext}`;
} else if (
numberVal &&
String(numberVal).length === 20
) {
newName = `${numberVal}${ext}`;
} else {
// 否则拼接两个值
const base = `${numberVal || ''}${codeVal || ''}`;
const safeBase = base.replace(
/[^\w\u4e00-\u9fa5-]+/g,
'',
);
newName = safeBase
? `${safeBase}${ext}`
: fileName || `文件_${Date.now()}${ext}`;
}
renamedFilesRef.current.push({
oldName: fileName || '未知文件',
newName,
url: finalUrl,
});
} else {
// 如果没有 code 和 number,使用原始文件名
renamedFilesRef.current.push({
oldName: fileName || '未知文件',
newName: fileName || `文件_${Date.now()}${ext}`,
url: finalUrl,
});
}
} else {
console.warn('未找到匹配的文件,URL:', fileUrl);
}
} catch (e) {
console.warn('文件重命名失败,已跳过:', e);
// 即使重命名失败,也保留原始文件名,通过URL匹配文件
const fileUrl = mcpArguments.url || '';
const currentFile = files.find((f) => {
const fUrl = f.url || '';
return (
fUrl === fileUrl ||
fUrl.includes(fileUrl) ||
fileUrl.includes(fUrl)
);
});
if (currentFile || fileUrl) {
renamedFilesRef.current.push({
oldName: currentFile?.name || '未知文件',
newName: currentFile?.name || '未知文件',
url: currentFile?.url || fileUrl,
});
}
}
} else {
console.warn('解析内容格式不正确,跳过文件重命名');
}
} catch (e) {
console.error(`解析${toolName}返回内容JSON失败:`, e);
console.error('原始内容:', content);
const urlReg = /https?:\/\/[^\s]+/;
const temp_url = content.match(urlReg)?.[0] || '未找到URL';
if (content.includes('日期或金额') && temp_url) {
renamedFilesRef.current.push({
oldName: '' || '未知文件',
newName: `识别有误文件${temp_url.split('/').pop()}`,
url: temp_url,
});
}
}
}
}
if (toolName === '表单生成工具') {
setCurrentStep(2);
}
if (typeof content === 'string') {
const trimmedContent = content.trim();
if (trimmedContent.includes('<form')) {
const sanitized = sanitizeHtml(trimmedContent);
const enhanced = sanitized.replace(
/<button/gi,
'<button data-tool-button="true"',
);
toolHtmlSnippets.push(enhanced);
content = sanitized;
}
}
toolMessages.push({
role: 'tool',
content,
});
}
const toolData = {
...data,
stream: true,
tools: undefined,
enable_thinking: false,
messages: [
data.messages[data.messages.length - 1],
currentMessage,
...toolMessages,
...(usedFormNormalization
? [
{
role: 'assistant',
content:
'请将用户刚刚提供的表单信息进行总结,不必确认表单项是否有实际值,并提示用户点击“下载处理结果”按钮进行下载,以获取报销文件压缩包。',
},
]
: []),
],
};
const toolResponse = await llmChat(toolData, controller.signal);
const toolStream = XStream({
readableStream: toolResponse.body,
});
for await (const chunk of toolStream) {
onUpdate(chunk);
chunks.push(chunk);
}
currentMessage = chunks.reduce(transformMessage, {});
if (toolHtmlSnippets.length > 0) {
const htmlBlock = toolHtmlSnippets.join('<hr />');
currentMessage.content = currentMessage.content
? `${currentMessage.content}\n\n${htmlBlock}`
: htmlBlock;
}
// console.log('currentMessage:', currentMessage);
}
if (!currentMessage.content && !currentMessage.tool_calls?.length) {
currentMessage.content =
'识别完成,但没有可展示的内容。如有文件识别异常,请稍后重试或联系管理员。';
}
// 在助手输出结束后,根据输出内容决定进行到哪一步
if (currentMessage.role === 'assistant') {
const contentStr = String(currentMessage.content || '').toLowerCase();
// 发票识别总结是否完成(包含数量/类型关键词)
const hasInvoiceSummary = contentStr.includes('发票');
// 仅在识别阶段(步骤1或2)并且确实有文件时,生成推荐问句
const hasFiles =
(message.files && message.files.length > 0) ||
attachedFiles.length > 0;
if (hasFiles && hasInvoiceSummary) {
try {
const thinkPromptsResult = await callMcpTool(
'invoice_toolset',
'联想词生成',
{
content: currentMessage.content,
},
);
const firstText = Array.isArray(thinkPromptsResult?.content)
? thinkPromptsResult.content.find((item) => item?.text)?.text
: typeof thinkPromptsResult?.content === 'string'
? thinkPromptsResult.content
: '';
if (firstText) {
let parsed = [];
try {
const json = JSON.parse(firstText);
if (Array.isArray(json)) {
parsed = json;
} else if (json && typeof json === 'object') {
parsed = Object.entries(json).map(([key, value]) => ({
title: key,
content: value,
}));
}
} catch (parseError) {
console.warn('联想词解析失败,使用原始文本', parseError);
parsed = [firstText];
}
const normalized = parsed
.map((item, index) => {
if (!item) {
return null;
}
const key = `think-${Date.now()}-${index}`;
if (typeof item === 'string') {
return {
key,
title: `推荐问题 ${index + 1}`,
description: item,
content: item,
};
}
if (Array.isArray(item)) {
const [title, content] = item;
return {
key,
title: title || `推荐问题 ${index + 1}`,
description: content || '',
content: content || title || '',
};
}
const title =
item.title ||
item.label ||
item.name ||
`推荐问题 ${index + 1}`;
const description =
item.description ||
item.content ||
item.prompt ||
item.value ||
'';
const content =
item.content ||
item.prompt ||
item.value ||
description ||
title;
return {
key,
title,
description,
content,
};
})
.filter(Boolean);
setThinkPrompts(normalized);
} else {
setThinkPrompts([]);
}
} catch (err) {
console.error('联想词生成失败:', err);
setThinkPrompts([]);
}
} else {
setThinkPrompts([]);
}
}
if (
currentMessage.role === 'assistant' &&
currentMessage.content.includes('“下载处理结果”') &&
requestMessages.length > 2
) {
setCurrentStep(3);
}
onUpdate({
data: 'DONE',
});
onSuccess(chunks);
} catch (error) {
if (error.name === 'AbortError') {
onSuccess(chunks);
} else {
onError(error);
}
}
},
});
const agentLoading = agent.isRequesting();
const { onRequest, messages, setMessages } = useXChat({
agent,
requestFallback: (message, { error }) => {
if (error.name === 'AbortError') {
return {
content: 'Request is aborted',
role: 'assistant',
};
}
return {
content: 'Request failed, please try again!',
role: 'assistant',
};
},
transformMessage: (info) =>
transformMessage(info.originMessage, info.chunk),
resolveAbortController: (controller) => {
abortController.current = controller;
},
});
const messageItems = useMemo(() => {
// 过滤掉包含"打招呼"的用户消息(自动发送的欢迎消息)
const visibleMessages = messages?.filter((item) => {
if (!item?.message) {
return false;
}
if (
item.message.role === 'user' &&
item.message.content?.includes('打招呼')
) {
return false;
}
if (item.message.metadata?.hidden) {
return false;
}
return true;
});
return visibleMessages?.map((item, index) => {
const messageItem = {
...item.message,
classNames: {
content: '',
},
loading: item.message?.loading,
};
const contentClasses = [];
if (item.status === 'loading') {
contentClasses.push(styles.loadingMessage);
}
if (item.message.role === 'user') {
messageItem.classNames = {
content: styles.userBubble,
};
messageItem.messageRender = (content) => {
return (
<Flex vertical gap={8}>
<div>{content}</div>
{item.message.files?.map((file, index) => (
<Attachments.FileCard key={index} item={file} />
))}
</Flex>
);
};
}
if (item.message.role === 'assistant') {
const isLastAssistant =
item.message.role === 'assistant' &&
index === visibleMessages.length - 1;
contentClasses.push(styles.assistantBubble);
messageItem.variant = 'borderless';
messageItem.footer = (
<Space>
<Button
type="text"
size="small"
icon={<CopyOutlined />}
onClick={() => {
navigator.clipboard.writeText(item.message.content);
messageApi.success('已复制到剪贴板');
}}
/>
{index === messages.length - 1 && (
<Button
type="text"
size="small"
icon={<ReloadOutlined />}
onClick={() => {
const userMessageIndex = messages.findLastIndex(
(m) => m.message.role === 'user',
);
if (userMessageIndex >= 0) {
setMessages((messages) =>
messages.slice(0, userMessageIndex),
);
onSubmit(messages[userMessageIndex]?.message);
}
}}
/>
)}
</Space>
);
messageItem.messageRender = () => {
const content = item.message.content?.trim();
if (!content) {
return (
<Space>
<Spin size="small" />
正在生成回复,请稍候...
</Space>
);
}
return (
<Flex vertical gap={8} style={{ width: '100%' }}>
<div
className={styles.markdown}
dangerouslySetInnerHTML={{
__html: marked.parse(preprocessLaTeX(content)),
}}
></div>
</Flex>
);
};
}
if (contentClasses.length > 0) {
messageItem.classNames = {
...messageItem.classNames,
content: contentClasses.join(' '),
};
}
return messageItem;
});
}, [messages]);
useEffect(() => {
const container = chatContainerRef.current;
if (!container) {
return undefined;
}
const handleClick = (event) => {
const target = event.target.closest(
'button[data-tool-button], button[data-message]',
);
if (!target || !container.contains(target)) {
return;
}
event.preventDefault();
const form = target.closest('form');
if (form && form.id === 'projectForm') {
const formData = new FormData(form);
const formProjectName = (formData.get('projectName') || '')
.toString()
.trim();
const formMembers = (formData.get('projectMembers') || '')
.toString()
.trim();
const formTravel = (formData.get('travelTime') || '').toString().trim();
const formNumber = (formData.get('reimbursementNumber') || '')
.toString()
.trim();
const summaryLines = [
`项目名称:${formProjectName || ''}`,
`项目组人员:${formMembers || ''}`,
`出差期间:${formTravel || ''}`,
`报销单号:${formNumber || ''}`,
];
const summaryContent = summaryLines.join('\n');
if (conversation) {
onRequest({
stream: true,
message: {
role: 'user',
content: summaryContent,
metadata: {
hidden: true,
},
},
});
} else {
onSubmit({
content: summaryContent,
});
}
return;
}
const messageContent =
target.dataset.message ||
target.dataset.submitMessage ||
target.textContent?.trim() ||
'';
if (messageContent) {
let formattedMessage = messageContent.trim();
try {
const parsed = JSON.parse(formattedMessage);
if (parsed && typeof parsed === 'object') {
const entries = Array.isArray(parsed)
? parsed
: Object.entries(parsed).map(([key, value]) => [key, value]);
formattedMessage = entries
.map((entry) => {
if (Array.isArray(entry)) {
const [key, value] = entry;
return `${key}:${value ?? ''}`;
}
if (entry && typeof entry === 'object') {
return Object.entries(entry)
.map(([key, value]) => `${key}:${value ?? ''}`)
.join('\n');
}
return String(entry ?? '').trim();
})
.filter(Boolean)
.join('\n');
}
} catch (error) {
formattedMessage = formattedMessage
.split(/[\n,;]+/)
.map((line) => line.trim())
.filter(Boolean)
.join('\n');
}
onSubmit({
content: formattedMessage,
});
}
};
container.addEventListener('click', handleClick, true);
return () => {
container.removeEventListener('click', handleClick, true);
};
}, [onSubmit]);
useEffect(() => {
if (!conversation) {
return;
}
const frame = requestAnimationFrame(() => {
chatEndRef.current?.scrollIntoView({
behavior: 'smooth',
block: 'end',
});
});
return () => cancelAnimationFrame(frame);
}, [conversation, messages]);
useEffect(() => {
abortController?.current?.abort('cancelled');
const messages = conversation?.metadata?.messages || [];
setMessages(
messages.map((message) => ({
status: 'success',
message: message,
})),
);
// 重置步骤和发票类型
setCurrentStep(0);
projectInfo.current = {};
// 如果是发票助手且没有消息,自动触发AI思考操作流程并生成步骤列表
if (conversation && conversation.metadata?.label === '发票助手') {
if (messages.length === 0) {
onRequest({
content: `请向用户打招呼,并思考并介绍发票识别的操作流程,要求生成4个步骤:
1. 上传发票(可以上传zip、pdf和图片)
2. 识别发票
3. 输入表单信息(项目名称、项目组人员、出差时间、报销单号)
4. 下载zip文件。
请以清晰的步骤列表形式展示,每个步骤单独一行,且描述信息主要是说该步骤需要填写或上传的内容,最后一步的描述信息可以简略一点`,
role: 'user',
});
} else if (messages.length >= 2 && !didSetStepsFromAIRef.current) {
extractStepsFromMessages(messages[1]);
}
}
senderRef.current?.focus();
}, [conversation]);
useEffect(() => {
if (conversation) {
const oldMessage = _.last(conversation.metadata.messages);
const newMessage = _.last(messages);
if (
newMessage?.status === 'success' &&
newMessage?.message?.content !== oldMessage?.content
) {
updateConversation(conversation.name, {
messages: messages.map((m) => m.message),
}).then();
}
}
}, [messages]);
useEffect(() => {
if (currentStep !== 0) {
return;
}
// 检查所有文件是否都上传完成
const hasUploadedFiles =
attachedFiles.length > 0 &&
attachedFiles.every(
(file) => file.status === 'done' && file.response?.url,
);
if (hasUploadedFiles) {
setCurrentStep(1);
}
}, [attachedFiles, currentStep]);
const addConversation = async (data, message) => {
const conversation = await createConversation(data);
conversation.metadata = {
...conversation.metadata,
...data,
};
onAddConversation?.(conversation, message); // 委托给父组件
};
const onSubmit = async ({ content, files = null, agentLabel = null }) => {
const userMessage = {
role: 'user',
content: content || '',
files:
files ||
attachedFiles
.filter((file) => file.status === 'done')
.map((file) => file.response),
};
if (!conversation) {
await addConversation(
{
label: agentLabel || content || '发票助手',
},
userMessage,
);
return;
}
if (agentLoading) {
messageApi.warning('请等待请求完成');
return;
}
setAttachedFiles([]);
onRequest({
stream: true,
message: userMessage,
});
};
const chatList = (
<div className={styles.chatList} ref={chatContainerRef}>
{conversation ? (
<div className={styles.chatListInner}>
<div className={styles.heroCard}>
<Space direction="vertical" size={16}>
<Tag color="cyan" bordered={false} className={styles.sectionTag}>
发票识别工作流
</Tag>
<Typography.Title level={3} style={{ margin: 0 }}>
Mia 发票助手
</Typography.Title>
<Typography.Paragraph
style={{ margin: 0, color: '#64748b', lineHeight: 1.7 }}
>
支持 ZIP、PDF
和图片批量识别,自动联想下一步问题,并在流程完成后生成可下载结果。
</Typography.Paragraph>
</Space>
</div>
<div className={styles.bubbleList}>
<Bubble.List
items={messageItems}
style={{
height: '100%',
}}
roles={{
assistant: {
placement: 'start',
avatar: {
children: 'Mia',
style: { background: '#fde3cf', color: '#9a3412' },
},
},
user: {
placement: 'end',
avatar: userInfo?.avatar
? { src: userInfo.avatar }
: { children: userInfo?.display_name?.[0] || '我' },
},
}}
/>
</div>
{thinkPromptItems.length > 0 && (
<Prompts
items={thinkPromptItems}
style={{ width: '100%' }}
className={styles.promptCard}
onItemClick={(info) => {
const promptContent =
info?.data?.content ||
info?.data?.description ||
info?.data?.title;
if (promptContent) {
onSubmit({
content: promptContent,
});
}
}}
/>
)}
<div ref={chatEndRef} />
</div>
) : (
<div className={`${styles.chatListInner} ${styles.placeholder}`}>
<div className={styles.heroCard}>
<Typography.Title level={3} style={{ margin: 0 }}>
发票助手已就绪
</Typography.Title>
<Typography.Paragraph
style={{ margin: '10px 0 0', color: '#64748b', lineHeight: 1.7 }}
>
上传文件后,Mia 会按步骤帮你完成识别、确认、补充表单和结果下载。
</Typography.Paragraph>
</div>
</div>
)}
</div>
);
const senderHeader = (
<Sender.Header
title="上传文件"
open={attachmentsOpen}
onOpenChange={setAttachmentsOpen}
styles={{ content: { padding: 0 } }}
>
<Attachments
items={attachedFiles}
action="/api/upload"
onChange={(info) => {
setAttachedFiles(info.fileList);
}}
multiple={true}
placeholder={(type) =>
type === 'drop'
? {
title: '拖动文件到这里',
}
: {
icon: <CloudUploadOutlined />,
title: '点击或拖动文件到这里',
description: '支持文件类型: ZIP, PDF, JPG, PNG',
}
}
/>
</Sender.Header>
);
const chatSender = (
<div className={styles.senderShell}>
<Sender
ref={senderRef}
value={inputValue}
header={senderHeader}
onSubmit={() => {
onSubmit({
content: inputValue,
});
setInputValue('');
}}
onChange={setInputValue}
onCancel={() => abortController.current?.abort()}
prefix={
<Button
type="text"
icon={<PaperClipOutlined style={{ fontSize: 18 }} />}
onClick={() => setAttachmentsOpen(!attachmentsOpen)}
/>
}
loading={agentLoading}
className={styles.sender}
allowSpeech
submitType="enter"
actions={(_, info) => {
const { SendButton, LoadingButton, SpeechButton } = info.components;
return (
<Flex gap={4}>
<SpeechButton className={styles.speechButton} />
{agentLoading ? (
<LoadingButton type="default" />
) : (
<SendButton type="primary" />
)}
</Flex>
);
}}
placeholder="请输入消息内容"
/>
</div>
);
// 右侧步骤栏组件
const stepsSidebar = (
<div className={styles.stepsSidebar}>
<div className={styles.sidebarTitle}>
<PayCircleOutlined />
发票助手流程
</div>
<div className={styles.stepHint}>
右侧面板会同步展示当前进度,方便在批量处理发票时快速确认下一步。
</div>
{stepsList.length > 0 && (
<Steps
direction="vertical"
current={currentStep}
items={stepsList.map((step, index) => ({
title: step.title,
description: step.description,
status:
index < currentStep
? 'finish'
: index === currentStep
? 'process'
: 'wait',
icon:
index < currentStep ? (
<CheckCircleOutlined style={{ color: '#52c41a' }} />
) : undefined,
}))}
style={{
'--ant-steps-item-finish-icon-color': '#52c41a',
'--ant-steps-item-finish-line-color': '#52c41a',
}}
/>
)}
{stepsList.length > 0 && currentStep === stepsList.length - 1 && (
<Button
type="primary"
icon={<DownloadOutlined />}
size="small"
onClick={async () => {
const excelData = {
outputList: outputListRef.current,
info: projectInfo.current,
files: renamedFilesRef.current,
};
try {
await generateExcelZip(excelData);
} catch (error) {
console.error('Excel文件生成失败:', error);
messageApi.error('Excel文件生成失败,请重试');
}
}}
className={styles.downloadButton}
>
下载处理结果
</Button>
)}
</div>
);
return (
<div className={styles.chat}>
<div className={styles.chatWithSidebar}>
{chatList}
{chatSender}
</div>
{stepsSidebar}
</div>
);
});
export default InvoiceAssistant;
import Chat from '@/pages/Chat';
import {
getRecord,
queryConversations,
removeConversation,
updateConversation,
} from '@/services/DataService';
import {
BorderlessTableOutlined,
CommentOutlined,
DeleteOutlined,
EditOutlined,
MenuFoldOutlined,
MenuUnfoldOutlined,
PlusOutlined,
PoweroffOutlined,
PushpinOutlined,
PushpinTwoTone,
} from '@ant-design/icons';
import { Conversations } from '@ant-design/x';
import { useQuery } from '@umijs/max';
import {
Avatar,
Button,
Drawer,
Dropdown,
Empty,
Input,
Modal,
Spin,
Typography,
message,
theme,
} from 'antd';
import { createStyles } from 'antd-style';
import { useEffect, useMemo, useRef, useState } from 'react';
import LoginModal from '../../components/LoginModal';
const useStyle = createStyles(({ token, css, responsive }) => {
return {
shell: css`
min-height: 100vh;
padding: 16px;
display: flex;
${responsive.xs} {
padding: 12px;
}
`,
frame: css`
width: 100%;
min-height: calc(100vh - 32px);
display: flex;
gap: 16px;
border-radius: 36px;
position: relative;
overflow: hidden;
${responsive.xs} {
min-height: calc(100vh - 24px);
border-radius: 26px;
}
`,
sidebar: css`
width: 304px;
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: 16px;
background: linear-gradient(
180deg,
rgba(15, 23, 42, 0.98) 0%,
rgba(15, 23, 42, 0.92) 100%
);
border: 1px solid rgba(255, 255, 255, 0.08);
box-shadow: 0 26px 60px rgba(15, 23, 42, 0.24);
color: rgba(255, 255, 255, 0.95);
padding: 22px 18px 18px;
border-radius: 32px;
`,
sidebarCollapsed: css`
width: 92px;
padding-inline: 14px;
`,
sidebarHeader: css`
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
`,
brand: css`
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
`,
brandMark: css`
width: 40px;
height: 40px;
border-radius: 14px;
background: linear-gradient(135deg, #00a6e8 0%, #7dd3fc 100%);
display: inline-flex;
align-items: center;
justify-content: center;
color: #ffffff;
font-size: 18px;
font-weight: 700;
box-shadow: 0 12px 24px rgba(0, 166, 232, 0.28);
`,
brandText: css`
min-width: 0;
h1 {
margin: 0;
font-size: 17px;
line-height: 1.2;
color: #f8fafc;
}
p {
margin: 4px 0 0;
color: rgba(226, 232, 240, 0.72);
font-size: 12px;
}
`,
collapseButton: css`
color: rgba(255, 255, 255, 0.78);
&:hover {
color: #ffffff !important;
background: rgba(255, 255, 255, 0.08) !important;
}
`,
newButton: css`
width: 100%;
height: 46px;
border: none;
background: linear-gradient(135deg, #00a6e8 0%, #0ea5e9 100%);
box-shadow: 0 14px 24px rgba(0, 166, 232, 0.24);
color: #ffffff;
font-weight: 600;
&:hover {
color: #ffffff !important;
background: linear-gradient(
135deg,
#13b5f5 0%,
#38bdf8 100%
) !important;
}
`,
sectionLabel: css`
font-size: 12px;
line-height: 1;
text-transform: uppercase;
letter-spacing: 0.08em;
color: rgba(226, 232, 240, 0.78);
font-weight: 700;
margin: 4px 6px 0;
`,
conversations: css`
flex: 1;
overflow-y: auto;
padding-right: 2px;
position: relative;
&::before {
content: '';
position: sticky;
top: 0;
display: block;
height: 6px;
background: linear-gradient(
180deg,
rgba(15, 23, 42, 0.95) 0%,
rgba(15, 23, 42, 0) 100%
);
z-index: 1;
pointer-events: none;
}
.ant-conversations-list {
padding-inline-start: 0;
}
.ant-conversations-group {
margin-top: 10px;
}
.ant-conversations-group-title {
color: rgba(226, 232, 240, 0.72);
font-size: 12px;
padding: 0 6px 10px;
font-weight: 700;
letter-spacing: 0.06em;
}
.ant-conversations-item {
margin-bottom: 8px;
border-radius: 18px;
color: #e5eefb;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(148, 163, 184, 0.08);
transition: all 0.2s ease, transform 0.2s ease;
backdrop-filter: blur(10px);
min-height: 72px;
}
.ant-conversations-item:hover {
background: rgba(255, 255, 255, 0.11);
border-color: rgba(125, 211, 252, 0.22);
transform: translateY(-1px);
}
.ant-conversations-item-active {
background: linear-gradient(
135deg,
rgba(14, 165, 233, 0.3) 0%,
rgba(59, 130, 246, 0.22) 100%
);
border-color: rgba(125, 211, 252, 0.38);
box-shadow: inset 0 0 0 1px rgba(125, 211, 252, 0.2),
0 12px 26px rgba(2, 132, 199, 0.18);
}
.ant-conversations-item-label,
.ant-conversations-item-icon {
color: inherit !important;
}
.ant-conversations-item-label {
font-size: 15px;
font-weight: 600;
line-height: 1.45;
color: #edf4ff !important;
text-shadow: 0 1px 0 rgba(15, 23, 42, 0.35);
}
.ant-conversations-item-desc {
color: rgba(203, 213, 225, 0.68) !important;
}
.ant-conversations-item-icon {
font-size: 16px;
opacity: 0.94;
}
.ant-conversations-item-active .ant-conversations-item-label {
color: #ffffff !important;
}
.ant-conversations-item-active .ant-conversations-item-icon {
color: #e0f2fe !important;
}
.ant-conversations-item-icon {
width: 18px;
height: 18px;
}
.ant-conversations-item-content {
padding-block: 2px;
}
.ant-conversations-item-menu {
color: rgba(226, 232, 240, 0.72) !important;
}
.ant-conversations-item-menu:hover {
color: #ffffff !important;
}
`,
emptyHistory: css`
padding-top: 28px;
.ant-empty-description {
color: rgba(203, 213, 225, 0.62);
}
`,
sidebarFooter: css`
padding-top: 12px;
border-top: 1px solid rgba(255, 255, 255, 0.08);
`,
accountCard: css`
display: flex;
align-items: center;
gap: 12px;
padding: 10px 12px;
border-radius: 18px;
background: rgba(255, 255, 255, 0.06);
cursor: pointer;
transition: background 0.2s ease;
&:hover {
background: rgba(255, 255, 255, 0.1);
}
`,
accountMeta: css`
min-width: 0;
strong {
display: block;
color: #f8fafc;
font-size: 14px;
line-height: 1.3;
}
span {
display: block;
color: rgba(203, 213, 225, 0.7);
font-size: 12px;
line-height: 1.3;
margin-top: 2px;
}
`,
contentShell: css`
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
background: rgba(255, 255, 255, 0.58);
border: 1px solid rgba(255, 255, 255, 0.72);
box-shadow: 0 24px 60px rgba(15, 23, 42, 0.1);
backdrop-filter: blur(18px);
border-radius: 32px;
overflow: hidden;
`,
toolbar: css`
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 20px 24px 12px;
flex-wrap: wrap;
`,
toolbarTitle: css`
min-width: 0;
h2 {
margin: 0;
font-size: 24px;
line-height: 1.25;
color: #0f172a;
}
p {
margin: 6px 0 0;
font-size: 13px;
line-height: 1.5;
color: #64748b;
}
`,
toolbarActions: css`
display: flex;
align-items: center;
gap: 10px;
`,
mobileMenuButton: css`
display: none;
${responsive.lg} {
display: inline-flex;
}
`,
mobileOnly: css`
display: none;
${responsive.lg} {
display: inline-flex;
}
`,
desktopSidebar: css`
display: flex;
${responsive.lg} {
display: none;
}
`,
body: css`
flex: 1;
min-height: 0;
padding: 0 20px 20px;
${responsive.xs} {
padding: 0 12px 12px;
}
`,
chatStage: css`
height: 100%;
border-radius: 28px;
background: linear-gradient(
180deg,
rgba(255, 255, 255, 0.82) 0%,
rgba(248, 250, 252, 0.94) 100%
);
border: 1px solid rgba(148, 163, 184, 0.18);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.7);
overflow: hidden;
`,
drawerMask: css`
.ant-drawer-header,
.ant-drawer-body {
padding: 0;
}
.ant-drawer-content {
background: transparent;
box-shadow: none;
}
`,
renameInput: css`
margin-top: 12px;
`,
};
});
function buildConversationItems(conversations = []) {
return conversations.map((c) => ({
key: c.name,
label: c.metadata.label || '新会话',
icon: c.metadata.topic ? <BorderlessTableOutlined /> : <CommentOutlined />,
pinned: c.metadata.pinned,
group: '历史会话',
}));
}
export default () => {
const { token } = theme.useToken();
const [collapsed, setCollapsed] = useState(false);
const [mobileOpen, setMobileOpen] = useState(false);
const [isMobile, setIsMobile] = useState(false);
const [userInfo, setUserInfo] = useState(
localStorage.getItem('current_user')
? JSON.parse(localStorage.getItem('current_user'))
: null,
);
const { styles, cx } = useStyle();
const [currentConversation, setCurrentConversation] = useState(null);
const [renamingConversation, setRenamingConversation] = useState(null);
const [renamingConversationLabel, setRenamingConversationLabel] =
useState(null);
const [renamingModalOpen, setRenamingModalOpen] = useState(false);
const [loading, setLoading] = useState(false);
const chatRef = useRef(null);
const { data: conversations = [], refetch: refetchConversations } = useQuery({
queryKey: ['conversations', userInfo],
queryFn: async () => {
if (!userInfo) {
return [];
}
setLoading(true);
try {
const res = await queryConversations();
return res.records;
} catch (err) {
console.error(err);
if (err.response?.status === 401) {
setUserInfo(null);
message.warning('请重新登录');
} else {
message.error('加载数据失败');
}
} finally {
setLoading(false);
}
},
});
useEffect(() => {
document.title = 'AI 应用';
}, []);
useEffect(() => {
if (typeof window === 'undefined') {
return;
}
const media = window.matchMedia('(max-width: 1023px)');
const sync = () => {
const mobile = media.matches;
setIsMobile(mobile);
if (mobile) {
setCollapsed(false);
}
};
sync();
media.addEventListener?.('change', sync);
return () => {
media.removeEventListener?.('change', sync);
};
}, []);
const currentTitle = useMemo(() => {
if (!currentConversation) {
return '智能工作台';
}
return currentConversation.metadata?.label || '当前会话';
}, [currentConversation]);
const currentSubtitle = useMemo(() => {
if (!currentConversation) {
return '统一承载知识问答、智能体协作、附件分析与业务流程型对话。';
}
if (currentConversation.metadata?.topic?.title) {
return `当前话题:${currentConversation.metadata.topic.title}`;
}
return '保持连续上下文,支持流式回复、知识检索与工具调用。';
}, [currentConversation]);
const conversationItems = useMemo(
() => buildConversationItems(conversations),
[conversations],
);
const openConversation = async (name) => {
const conversation = await getRecord(name);
setCurrentConversation(conversation);
setMobileOpen(false);
};
const conversationMenu = (conversation) => ({
items: [
conversation.pinned
? {
key: 'unpin',
label: '已置顶',
type: 'primary',
icon: <PushpinTwoTone />,
onClick: async () => {
await updateConversation(conversation.key, {
pinned: false,
});
refetchConversations();
},
}
: {
key: 'pin',
label: '置顶',
icon: <PushpinOutlined />,
onClick: async () => {
await updateConversation(conversation.key, {
pinned: true,
});
refetchConversations();
},
},
{
label: '重命名',
key: 'rename',
icon: <EditOutlined />,
onClick: () => {
setRenamingConversation(conversation);
setRenamingConversationLabel(conversation.label);
setRenamingModalOpen(true);
},
},
{
label: '删除',
key: 'delete',
icon: <DeleteOutlined />,
danger: true,
onClick: async () => {
if (conversation.key === currentConversation?.name) {
setCurrentConversation(null);
}
setLoading(true);
await removeConversation(conversation.key);
await refetchConversations();
setLoading(false);
},
},
],
});
const sidebarContent = (
<div className={cx(styles.sidebar, collapsed && styles.sidebarCollapsed)}>
<div className={styles.sidebarHeader}>
<div className={styles.brand}>
<div className={styles.brandMark}>AI</div>
{!collapsed && (
<div className={styles.brandText}>
<h1>智能助手</h1>
<p>面向业务场景的统一 AI 对话工作台</p>
</div>
)}
</div>
{!isMobile && (
<Button
type="text"
icon={collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
onClick={() => setCollapsed((value) => !value)}
className={styles.collapseButton}
/>
)}
</div>
<Button
onClick={async () => {
setCurrentConversation(null);
setMobileOpen(false);
}}
icon={<PlusOutlined />}
className={styles.newButton}
>
{!collapsed ? '新建会话' : null}
</Button>
{!collapsed && <div className={styles.sectionLabel}>Conversations</div>}
{conversations?.length > 0 ? (
<Conversations
items={conversationItems}
className={styles.conversations}
activeKey={currentConversation?.name}
onActiveChange={openConversation}
groupable
styles={{
item: {
padding: collapsed ? '0 4px' : '0 8px',
},
}}
menu={conversationMenu}
/>
) : (
<div className={styles.conversations}>
<Empty
description={collapsed ? '' : '还没有历史会话'}
image={Empty.PRESENTED_IMAGE_SIMPLE}
className={styles.emptyHistory}
/>
</div>
)}
<div className={styles.sidebarFooter}>
{userInfo && (
<Dropdown
trigger={['click']}
menu={{
items: [
{
key: 'logout',
label: '退出登录',
icon: <PoweroffOutlined />,
onClick: () => {
localStorage.removeItem('current_user');
setUserInfo(null);
},
},
],
}}
>
<div className={styles.accountCard}>
<Avatar
size={collapsed ? 40 : 42}
src={userInfo?.avatar}
style={{
background: '#e0f2fe',
color: token.colorPrimary,
flexShrink: 0,
}}
>
{userInfo.display_name?.[0]}
</Avatar>
{!collapsed && (
<div className={styles.accountMeta}>
<strong>{userInfo.display_name || '当前用户'}</strong>
<span>账户设置与登录状态</span>
</div>
)}
</div>
</Dropdown>
)}
</div>
</div>
);
return (
<Spin spinning={loading} tip="正在同步工作台">
<div className={styles.shell}>
<div className={styles.frame}>
<div className={styles.desktopSidebar}>{sidebarContent}</div>
<Drawer
placement="left"
width={332}
open={mobileOpen}
closeIcon={null}
onClose={() => setMobileOpen(false)}
className={styles.drawerMask}
>
{sidebarContent}
</Drawer>
<div className={styles.contentShell}>
<div className={styles.toolbar}>
<div className={styles.toolbarTitle}>
<Typography.Title level={2}>{currentTitle}</Typography.Title>
<Typography.Paragraph>{currentSubtitle}</Typography.Paragraph>
</div>
<div className={styles.toolbarActions}>
<Button
type="default"
icon={<MenuUnfoldOutlined />}
onClick={() => setMobileOpen(true)}
className={styles.mobileMenuButton}
>
菜单
</Button>
</div>
</div>
<div className={styles.body}>
<div className={styles.chatStage}>
<Chat
ref={chatRef}
userInfo={userInfo}
key={currentConversation?.name}
conversation={currentConversation}
onAddConversation={async (conversation, message) => {
await refetchConversations();
setCurrentConversation(conversation);
setMobileOpen(false);
if (message) {
setTimeout(() => {
chatRef.current?.submit(message);
}, 50);
}
}}
/>
</div>
</div>
</div>
</div>
</div>
<Modal
title="重命名会话"
icon={null}
okText="确定"
cancelText="取消"
width={380}
open={renamingModalOpen}
onOk={async () => {
if (renamingConversation) {
await updateConversation(renamingConversation.key, {
label: renamingConversationLabel,
});
await refetchConversations();
setRenamingModalOpen(false);
}
}}
onCancel={() => {
setRenamingConversationLabel(null);
setRenamingModalOpen(false);
}}
>
<Input
size="large"
placeholder="输入会话名称"
value={renamingConversationLabel}
onChange={(e) => {
setRenamingConversationLabel(e.target.value);
}}
className={styles.renameInput}
/>
</Modal>
<LoginModal
open={!userInfo}
onSuccess={(currentUser) => {
setUserInfo(currentUser);
message.success('登录成功');
}}
onError={(errorMessage) => message.error(errorMessage)}
/>
</Spin>
);
};
.container {
padding-top: 80px;
}
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
import { request } from '@umijs/max';
const SUBJECT_CONVERSATION = 'ai_conversation';
const SUBJECT_TOPIC = 'ai_topic';
const SUBJECT_KNOWLEDGEBASE = 'ai_knowledgebase';
export function getApplication(name) {
return request(`/api/application/${name}`, {
method: 'get',
});
}
export function getRecord(name) {
return request(`/api/record/${name}`, {
method: 'get',
});
}
export function createRecord(params, preview = false) {
return request('/api/record?preview=' + preview, {
method: 'post',
data: params,
});
}
export function updateRecord(name, model = {}) {
return request(`/api/record/${name}`, {
method: 'put',
data: model,
});
}
export function removeRecord(name, force = false) {
return request(`/api/record/${name}${force ? '?force=1' : ''}`, {
method: 'delete',
});
}
export function queryRecords(query) {
return request('/api/record/query', {
method: 'post',
data: query,
});
}
export function getUserInfo() {
const currentUser = localStorage.getItem('current_user');
return currentUser && JSON.parse(currentUser);
}
export async function login(username, password) {
await request('/api/auth/login', {
method: 'post',
data: {
type: 'password',
identity: username,
password: password,
},
});
return request(`/api/user/info`, {
method: 'get',
});
}
export function documentAnalyze(data) {
return request(`/api/search/service/document-analyze`, {
method: 'post',
data,
});
}
export function imageAnalyze(data) {
return request(`/api/search/service/image-analyze`, {
method: 'post',
data,
});
}
export function documentRerank(data) {
return request(`/api/search/service/document-rerank`, {
method: 'post',
data,
});
}
export function semanticSearch(subject, query) {
return request(`/api/search/semantic?index=${subject}`, {
method: 'post',
data: query,
});
}
export function queryMcps(query) {
return request('/api/mcp/query', {
method: 'post',
data: query,
});
}
export function createExcel(data) {
return request('/myapi/upload_invoices', {
method: 'post',
data: data,
responseType: 'arraybuffer',
headers: {
'Content-Type': 'application/json',
},
});
}
export function addFiles(url) {
return request(url, {
method: 'get',
responseType: 'arraybuffer',
timeout: 30000, // 30秒超时
});
}
export async function callMcpTool(mcpName, toolName, data = {}) {
try {
const client = new Client({
name: 'streamable-http-client',
version: '1.0.0',
});
const transport = new StreamableHTTPClientTransport(`/api/mcp/${mcpName}`);
await client.connect(transport);
return await client.callTool({
name: toolName,
arguments: data,
});
} catch (error) {
console.error('Error connecting to MCP tools:', error);
throw error; // 重新抛出错误,以便调用者可以处理
}
}
export async function llmChat(data, signal = undefined) {
return fetch(`/api/llm/chat`, {
method: 'post',
headers: {
'Content-Type': 'application/json',
},
signal,
body: JSON.stringify(data),
});
}
export async function queryConversations(query) {
const userInfo = getUserInfo();
if (!userInfo) {
return [];
}
return queryRecords({
subject: SUBJECT_CONVERSATION,
page_size: 20,
mode: 'basic',
select:
'metadata.label metadata.pinned metadata.agent metadata.avatar metadata.topic',
sort: '-metadata.pinned -created_at',
filter: {
'metadata.user': userInfo._id,
...query?.filter,
},
...query,
});
}
export async function createConversation(data) {
const userInfo = getUserInfo();
if (!userInfo) {
return null;
}
return createRecord({
subject: SUBJECT_CONVERSATION,
title: `会话_${userInfo.display_name}_${new Date()}`,
metadata: {
user: userInfo._id,
pinned: false,
...data,
},
});
}
export async function updateConversation(name, data) {
return updateRecord(name, {
metadata: data,
});
}
export async function removeConversation(name) {
return removeRecord(name);
}
export async function queryTopics(query) {
return queryRecords({
subject: SUBJECT_TOPIC,
page_size: 10,
...query,
});
}
// 创建埋点
export async function createTracking(data) {
return request('/api/tracking', {
method: 'post',
data,
});
}
// 导入 dayjs 处理日期(需提前安装:npm install dayjs)
import dayjs from 'dayjs';
// 导入JSZip库用于ZIP文件打包
import JSZip from 'jszip';
import { addFiles, createExcel } from '../services/DataService';
/**
* 附件一:通用发票数据转换(含费用摘要、进项税额分类)
* @param {Object} jsonData - 输入的 JSON 数据(需包含 outputList 字段)
* @returns {Object} - 包含 data(处理后的数据数组)和 columns(列名)的对象
*/
export function jsonToDataframe1(jsonData) {
const data = [];
const key = [
'tax',
'total',
'date',
'code',
'number',
'seller',
'judge',
'item',
];
const outputList = jsonData.outputList || [];
outputList.forEach((item, index) => {
let processedItem = item;
// 处理 item 为数组的情况(取第一个元素)
if (Array.isArray(processedItem)) {
processedItem = processedItem[0];
}
// 检查是否包含所有必要字段,缺少则跳过
const hasAllKeys = key.every((k) => processedItem.hasOwnProperty(k));
if (!hasAllKeys) return;
// 基础字段初始化
const serialNumber = index + 1;
const date = processedItem.date;
let buyer = processedItem.buyer || '';
let invoiceTitle;
// 处理购货方(发票抬头)
if (buyer === '铁路电子客票') {
invoiceTitle = '不适用';
} else if (buyer.includes('容诚会计师事务所')) {
buyer = buyer.replace(/\(/g, '(').replace(/\)/g, ')');
invoiceTitle = buyer;
} else if (buyer.includes('容诚企业咨询')) {
buyer = buyer.replace(/\(/g, '(').replace(/\)/g, ')');
invoiceTitle = buyer;
} else {
invoiceTitle = '公司名称和税号错误';
}
// 票据金额
const ticketAmount = processedItem.total;
// 处理 item 字符串(格式清洗)
let itemStr = processedItem.item
.replace(/ /g, '')
.replace(/,/g, '\n')
.replace('住宿服务', '住宿服务 ')
.replace('运输服务', '运输服务 ')
.replace('餐饮服务', '餐饮服务 ');
const sellStr = processedItem.seller;
// 费用摘要、票据种类、备注初始化
let expenseSummary,
ticketType,
remark = '';
// 按 itemStr 匹配费用类型
if (/住宿服务|代订住宿|住宿费|房费/.test(itemStr)) {
expenseSummary = '住宿费';
ticketType = '住宿票';
remark = /代订住宿/.test(itemStr) ? '携程/飞猪等酒店发票' : '';
} else if (/国内航空旅客运输服务|代订机票/.test(itemStr)) {
expenseSummary = '交通费';
if (/代订机票/.test(itemStr)) {
ticketType = '其他运输服务电子发票';
remark = '携程/飞猪等机票发票';
} else {
ticketType = '机票';
}
} else if (/客运服务费/.test(itemStr)) {
expenseSummary = '交通费';
ticketType = '打车软件发票';
} else if (/餐饮服务/.test(itemStr)) {
expenseSummary = '餐费';
ticketType = /上海/.test(sellStr) ? '本地餐票' : '外地餐票';
} else {
if (processedItem.judge === '铁路电子客票') {
expenseSummary = '交通费';
ticketType = '火车票';
} else if (processedItem.judge === '出租车发票') {
expenseSummary = '交通费';
ticketType = '出租车票';
} else {
expenseSummary = processedItem.item;
ticketType = processedItem.judge;
}
}
// 处理进项税额
let inputTax;
if (processedItem.judge === '铁路电子客票') {
// 铁路票按 9% 税率计算,保留两位小数
inputTax = Math.round((processedItem.total / 1.09) * 0.09 * 100) / 100;
} else {
inputTax =
expenseSummary === '餐费' || processedItem.judge === '出租车发票'
? '—'
: processedItem.tax;
}
// 加入数据数组
data.push([
serialNumber,
date,
expenseSummary,
invoiceTitle,
ticketType,
ticketAmount,
inputTax,
remark,
]);
});
// 列名定义
const columns = [
'序号',
'发生日期',
'费用摘要',
'购货方(发票抬头)',
'票据种类',
'票据金额',
'进项税额',
'备注',
];
// 转换为对象数组并处理日期、排序
let df = formatAndSortData(data, columns, ['发生日期', '票据种类']);
return { rows: df, columns };
}
// 以上都没问题
/**
* 附件二:筛选可抵扣交通类发票数据(过滤进项税额为0的记录)
* @param {Object} jsonData - 输入的 JSON 数据(需包含 outputList 字段)
* @returns {Object} - 包含 data(处理后的数据数组)和 columns(列名)的对象
*/
export function jsonToDataframe2(jsonData) {
const data = [];
const key = [
'tax',
'total',
'date',
'code',
'number',
'seller',
'judge',
'item',
];
const outputList = jsonData.outputList || [];
outputList.forEach((item, index) => {
let processedItem = item;
if (Array.isArray(processedItem)) {
processedItem = processedItem[0];
}
const hasAllKeys = key.every((k) => processedItem.hasOwnProperty(k));
if (!hasAllKeys) return;
// 基础字段初始化
const serialNumber = index + 1;
const date = processedItem.date;
let buyer = processedItem.buyer || '';
let invoiceTitle;
let numberBx = '';
// 处理购货方(发票抬头)
if (buyer === '铁路电子客票') {
invoiceTitle = '不适用';
} else if (buyer.includes('容诚会计师事务所')) {
buyer = buyer.replace(/\(/g, '(').replace(/\)/g, ')');
invoiceTitle = buyer;
} else if (buyer.includes('容诚企业咨询')) {
buyer = buyer.replace(/\(/g, '(').replace(/\)/g, ')');
invoiceTitle = buyer;
} else {
invoiceTitle = '公司名称和税号错误';
}
// 票据金额、进项税额
const ticketAmount = processedItem.total;
let inputTax;
if (processedItem.judge === '铁路电子客票') {
inputTax = Math.round((processedItem.total / 1.09) * 0.09 * 100) / 100;
} else {
inputTax = processedItem.tax;
}
// 处理 item 字符串并匹配票据种类
let itemStr = processedItem.item
.replace(/ /g, '')
.replace(/,/g, '\n')
.replace('住宿服务', '住宿服务 ')
.replace('运输服务', '运输服务 ')
.replace('餐饮服务', '餐饮服务 ');
let ticketType,
remark = '';
if (/住宿服务|代订住宿|住宿费|房费/.test(itemStr)) {
ticketType = '住宿票';
remark = /代订住宿/.test(itemStr) ? '携程/飞猪等酒店发票' : '';
} else if (/国内航空旅客运输服务|代订机票|燃油附加费/.test(itemStr)) {
if (/代订机票/.test(itemStr)) {
ticketType = '其他运输服务电子发票';
remark = '携程/飞猪等机票发票';
} else {
ticketType = '机票';
}
} else if (/客运服务费/.test(itemStr)) {
ticketType = '打车软件发票';
} else if (/餐饮服务/.test(itemStr)) {
ticketType = '餐票';
} else {
ticketType =
processedItem.judge === '铁路电子客票' ? '火车票' : processedItem.judge;
}
// 加入数据数组
data.push([
serialNumber,
date,
invoiceTitle,
ticketType,
ticketAmount,
inputTax,
numberBx,
remark,
]);
});
// 列名定义
const columns = [
'序号',
'发生日期',
'购货方(发票抬头)',
'票据种类',
'票据金额',
'进项税额',
'报销单号',
'备注',
];
// 转换为对象数组、处理日期、排序,并过滤可抵扣的交通类发票
let df = formatAndSortData(data, columns, ['发生日期', '票据种类']);
// 过滤条件:进项税额≠0 且 票据种类属于交通类
const deductibleTypes = [
'火车票',
'公路发票',
'水路发票',
'打车软件发票',
'机票',
'其他运输服务电子发票',
];
df = df.filter(
(item) =>
item['进项税额'] !== 0 && deductibleTypes.includes(item['票据种类']),
);
// 过滤后重新排序序号
df = df.map((item, idx) => ({ ...item, 序号: idx + 1 }));
return { rows: df, columns };
}
/**
* 附件三:发票代码/号码结构化处理(侧重发票标识信息)
* @param {Object} jsonData - 输入的 JSON 数据(需包含 outputList 字段)
* @returns {Object} - 包含 data(处理后的数据数组)和 columns(列名)的对象
*/
export function jsonToDataframe3(jsonData) {
const data = [];
const key = [
'tax',
'total',
'date',
'code',
'number',
'seller',
'judge',
'item',
];
const outputList = jsonData.outputList || [];
outputList.forEach((item, index) => {
let processedItem = item;
if (Array.isArray(processedItem)) {
processedItem = processedItem[0];
}
const hasAllKeys = key.every((k) => processedItem.hasOwnProperty(k));
if (!hasAllKeys) return;
// 如果是出租车发票,跳过不填入表三
if (processedItem.judge === '出租车发票') {
return;
}
// 基础字段初始化
const serialNumber = index + 1;
const date = processedItem.date;
const ticketAmount = processedItem.total;
let people = '';
let numberBx = '';
// 处理发票代码和发票号码(按长度规则拆分)
let invoiceId, invoiceNumber;
const code = processedItem.code || '';
const number = processedItem.number || '';
if (number.length > 0 && code.length > 0) {
if (code.length === 8) {
invoiceId = number;
invoiceNumber = code;
} else {
invoiceId = code;
invoiceNumber = number;
}
} else {
if (code.length > 0) {
invoiceId = code.slice(0, 12);
invoiceNumber = code.slice(12);
} else {
invoiceId = number.slice(0, 12);
invoiceNumber = number.slice(12);
}
}
if (number.length + code.length !== 20) {
invoiceId = '发票号码错误';
}
// 处理开票内容(标准化格式)
let content = processedItem.item;
if (content === '住宿费' || content === '住宿服务') {
content = '住宿服务 住宿费';
} else if (content === '运输服务|客运服务费') {
content = '运输服务 客运服务费';
} else if (content === '餐饮服务') {
content = '餐饮服务 餐饮服务';
} else {
content = content
.replace(/ /g, '')
.replace(/,/g, '\n')
.replace('住宿服务', '住宿服务 ')
.replace('运输服务', '运输服务 ')
.replace('餐饮服务', '餐饮服务 ');
}
// 加入数据数组
data.push([
serialNumber,
date,
invoiceId,
invoiceNumber,
content,
ticketAmount,
people,
numberBx,
]);
});
// 列名定义
const columns = [
'序号',
'日期',
'发票代码',
'发票号码',
'开票内容',
'金额',
'报销人',
'报销单号',
];
// 转换为对象数组、处理日期、按日期排序
let df = formatAndSortData(data, columns, ['日期']);
return { rows: df, columns };
}
/**
* 工具函数:统一处理数据格式(转换为对象数组、日期格式化、排序)
* @param {Array} data - 原始二维数组数据
* @param {Array} columns - 列名数组
* @param {Array} sortKeys - 排序字段数组(如 ['发生日期', '票据种类'])
* @returns {Array} - 处理后的对象数组
*/
function formatAndSortData(data, columns, sortKeys) {
// 1. 转换为对象数组(二维数组 → 对象数组,键为列名)
let formattedData = data.map((row) => {
const obj = {};
columns.forEach((col, idx) => {
obj[col] = row[idx];
});
// 2. 日期格式化:YYYY年MM月DD日 → YYYY-MM-DD
const dateKey = columns.find((col) => col.includes('日期'));
if (dateKey && obj[dateKey]) {
obj[dateKey] = dayjs(obj[dateKey], 'YYYY年MM月DD日').format('YYYY-MM-DD');
}
return obj;
});
// 3. 按指定字段排序
formattedData.sort((a, b) => {
for (const key of sortKeys) {
// 日期字段按时间排序
if (key.includes('日期')) {
const dateA = dayjs(a[key]).valueOf();
const dateB = dayjs(b[key]).valueOf();
if (dateA !== dateB) {
return dateA - dateB;
}
}
// 非日期字段按字符串字典序排序
else {
if (a[key] !== b[key]) {
return a[key].localeCompare(b[key]);
}
}
}
return 0;
});
// 4. 重新生成序号(排序后重置)
formattedData = formattedData.map((item, idx) => ({
...item,
序号: idx + 1,
}));
return formattedData;
}
/**
* 读取模板文件并填入数据,然后打包成ZIP文件
* @param {Object} jsonData - 输入的 JSON 数据(需包含 outputList 字段)
* @param {string} filename - 文件名前缀,默认为 '发票处理结果'
* @returns {Object} - 包含 success 状态和 filename 的结果对象
*/
export async function generateExcelZip(jsonData, filename = '发票处理结果') {
try {
// const zip = new JSZip();
// 预处理数据帧并按要求过滤 result3
const result1 = jsonToDataframe1(jsonData);
const result2 = jsonToDataframe2(jsonData);
let result3 = jsonToDataframe3(jsonData);
const data = {
attachment1: result1,
attachment2: result2,
attachment3: result3,
info: jsonData.info,
};
// console.log('发送的数据大小:', JSON.stringify(data).length); // 测试环境下查看控制台
// console.log('data:', data);
let baseZipBuffer;
try {
const resp = await createExcel(data);
// console.log('resp:', resp);
// 确保我们获得正确的数据格式
// umi的request方法在responseType为'arraybuffer'时,数据在resp中
if (resp instanceof ArrayBuffer) {
baseZipBuffer = resp;
} else if (resp && resp.data instanceof ArrayBuffer) {
baseZipBuffer = resp.data;
} else {
// 如果数据不是ArrayBuffer格式,尝试转换
throw new Error('返回的数据格式不正确,期望ArrayBuffer格式');
}
} catch (error) {
if (error.response) {
// 服务器返回了错误响应
const status = error.response.status;
const statusText = error.response.statusText;
let errorMessage = `服务器错误: ${status} ${statusText}`;
// 尝试解析错误响应
try {
const errorText = new TextDecoder().decode(error.response.data);
const errorJson = JSON.parse(errorText);
errorMessage =
errorJson.detail ||
errorJson.message ||
errorJson.error ||
errorMessage;
} catch (e) {
// 如果无法解析为JSON,使用原始错误
}
throw new Error(errorMessage);
} else if (error.request) {
// 请求已发送但没有收到响应
throw new Error(
'网络错误:无法连接到服务器,请检查网络连接或服务器地址',
);
} else {
// 请求配置错误
throw new Error(`请求配置错误: ${error.message}`);
}
}
// 用 JSZip 解析后端 ZIP
// 确保baseZipBuffer是有效的ArrayBuffer
if (!(baseZipBuffer instanceof ArrayBuffer)) {
throw new Error('ZIP数据格式不正确,期望ArrayBuffer格式');
}
const baseZip = await JSZip.loadAsync(baseZipBuffer);
// 获取项目信息用于重命名
const info = jsonData.info || {};
// 支持多种可能的字段名格式
const reimbursementNumber =
info.reimbursementNumber || info.报销单号 || info.numberBx || '';
const projectName = info.projectName || info.项目名称 || info.project || '';
// 重命名三个 Excel 文件
// 找到所有 .xlsx 文件并重命名
const fileNames = Object.keys(baseZip.files);
const excelFiles = fileNames.filter(
(name) => !baseZip.files[name].dir && /\.xlsx?$/i.test(name),
);
// 按文件名排序,确保按 attachment1, attachment2, attachment3 的顺序
excelFiles.sort();
// 构建文件名后缀
// 1. 如果报销单号不为空,加上 _报销单号,否则加上 _xxx
const reimbursementSuffix =
reimbursementNumber && reimbursementNumber.trim()
? `_${reimbursementNumber.trim()}`
: '_XXX';
// 2. 如果项目名称不为空,加上 _项目名称,否则加上 _xxx有限公司
const projectSuffix =
projectName && projectName.trim()
? `_${projectName.trim()}`
: '_XXX有限公司2024年产品审计及其他鉴证业务';
// 重命名每个 Excel 文件(最多三个)
const renamePromises = excelFiles.slice(0, 3).map(async (oldName) => {
try {
const file = baseZip.files[oldName];
if (!file || file.dir) return;
// 获取文件扩展名和基础名称
const lastDotIndex = oldName.lastIndexOf('.');
const extension =
lastDotIndex >= 0 ? oldName.slice(lastDotIndex) : '.xlsx';
const baseName =
lastDotIndex >= 0 ? oldName.slice(0, lastDotIndex) : oldName;
// 构建新文件名:原文件名 + 报销单号后缀 + 项目名称后缀 + 扩展名
const newName = `${baseName}${reimbursementSuffix}${projectSuffix}${extension}`;
// 读取文件内容
const fileContent = await file.async('arraybuffer');
// 写入新文件名
baseZip.file(newName, fileContent);
// 删除旧文件
delete baseZip.files[oldName];
} catch (error) {
console.warn(`重命名文件失败: ${oldName}`, error);
}
});
// 等待所有重命名完成
await Promise.all(renamePromises);
// 追加 jsonData.files 到 ZIP(只包含处理后的文件,不包含ZIP文件)
const files = Array.isArray(jsonData?.files)
? jsonData.files
: jsonData?.files
? [jsonData.files]
: [];
// 过滤掉ZIP文件,只保留处理后的文件
const processedFiles = files.filter((f) => {
const fileName = (f.newName || f.name || f.oldName || '').toLowerCase();
const fileType = f.type || '';
return (
!fileName.endsWith('.zip') &&
fileType !== 'application/zip' &&
fileType !== 'application/x-zip-compressed'
);
});
for (const f of processedFiles) {
try {
let url = f.url || f.link || f.href;
if (!url) continue;
const fresp = await addFiles(url);
const buf = fresp.data;
const name = f.newName || f.name || f.oldName || `文件_${Date.now()}`;
baseZip.file(name, buf);
} catch (e) {
console.warn('追加文件失败,已跳过:', f, e);
}
}
// 重新打包 ZIP 并下载
const zipBlob = await baseZip.generateAsync({
type: 'blob',
compression: 'DEFLATE',
compressionOptions: { level: 6 },
});
// 设置下载文件名为"所有附件.zip"
const suggested = '所有附件.zip';
const url = window.URL.createObjectURL(zipBlob);
const a = document.createElement('a');
a.href = url;
a.download = suggested;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.URL.revokeObjectURL(url);
return { success: true, filename: suggested };
} catch (error) {
console.error('ZIP文件生成失败:', error);
return { success: false, error: error.message };
}
}
// 示例方法,没有实际意义
export function trim(str) {
return str.trim();
}
/**
* 客户端信息收集工具
* 用于埋点功能,收集浏览器、操作系统、设备等信息
*/
function detectOS() {
const userAgent = navigator.userAgent || navigator.vendor || window.opera;
if (/windows/i.test(userAgent)) return 'Windows';
if (/macintosh|mac os x/i.test(userAgent)) return 'macOS';
if (/linux/i.test(userAgent)) return 'Linux';
if (/android/i.test(userAgent)) return 'Android';
if (/iPad|iPhone|iPod/.test(userAgent) && !window.MSStream) return 'iOS';
return 'Unknown';
}
function detectBrowser() {
const userAgent = navigator.userAgent || navigator.vendor || window.opera;
if (userAgent.indexOf('Firefox') > -1) return 'Firefox';
if (userAgent.indexOf('Opera') > -1 || userAgent.indexOf('OPR') > -1)
return 'Opera';
if (userAgent.indexOf('Chrome') > -1 && userAgent.indexOf('Edg') === -1)
return 'Chrome';
if (userAgent.indexOf('Safari') > -1 && userAgent.indexOf('Chrome') === -1)
return 'Safari';
if (userAgent.indexOf('Edg') > -1) return 'Edge';
return 'Unknown';
}
function detectDeviceType() {
const userAgent = navigator.userAgent || navigator.vendor || window.opera;
const width = window.screen.width || window.innerWidth;
if (
/android|webos|iphone|ipad|ipod|blackberry|iemobile|opera mini/i.test(
userAgent,
)
) {
return width < 768 ? 'mobile' : 'tablet';
}
return width >= 1024 ? 'desktop' : width >= 768 ? 'tablet' : 'mobile';
}
function detectNetwork() {
try {
const connection =
navigator.connection ||
navigator.mozConnection ||
navigator.webkitConnection;
if (connection) {
return connection.type || connection.effectiveType || 'unknown';
}
return navigator.onLine ? 'online' : 'offline';
} catch (e) {
return 'unknown';
}
}
export function collectClientInfo() {
try {
return {
user_agent: navigator.userAgent || '',
os: detectOS(),
browser: detectBrowser(),
device_type: detectDeviceType(),
screen_width: window.screen.width || 0,
screen_height: window.screen.height || 0,
network: detectNetwork(),
app_version: '1.0.0',
language: navigator.language || 'unknown',
};
} catch (e) {
return {};
}
}
export function collectAppInfo(location, document, routeName = null) {
try {
let applicationTitle = '';
try {
// 尝试从 localStorage 获取
const current_user = JSON.parse(
localStorage.getItem('current_user') || 'null',
);
// 如果没有专门的 site_info,尝试从用户关联的应用信息中找
applicationTitle = document?.title || 'AI 应用';
} catch (e) {}
return {
url: window.location.href,
path: location.pathname,
title: document?.title || '',
application_title: applicationTitle,
referrer: document?.referrer || '',
route_name: routeName,
};
} catch (e) {
return { path: location.pathname };
}
}
export function collectUserInfo() {
try {
const userStr = localStorage.getItem('current_user');
if (!userStr || userStr === 'null') {
return null;
}
const currentUser = JSON.parse(userStr);
if (currentUser && currentUser._id) {
return {
_id: currentUser._id,
username: currentUser.username || '',
display_name: currentUser.display_name || '',
type: currentUser.type || 'normal',
};
}
// 如果只有 ID 字符串
if (typeof currentUser === 'string') {
return { _id: currentUser };
}
if (currentUser?._id) {
return { _id: currentUser._id };
}
} catch (e) {
// 忽略错误
}
return null;
}
{
"extends": "./src/.umi/tsconfig.json"
}
import '@umijs/max/typings';
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment