RAGflow/web/src/pages/write/index.tsx

1071 lines
35 KiB
TypeScript

import HightLightMarkdown from '@/components/highlight-markdown';
import { useTranslate } from '@/hooks/common-hooks';
// 假设 aiAssistantConfig 在实际项目中是正确导入的
// import { aiAssistantConfig } from '@/pages/write/ai-assistant-config';
const aiAssistantConfig = { api: { timeout: 30000 } }; // 模拟定义
import { DeleteOutlined } from '@ant-design/icons';
import {
Button,
Card,
Divider,
Flex,
Form,
Input,
Layout,
List,
message,
Modal,
Popconfirm,
Select,
Slider,
Space,
Typography,
} from 'antd';
import axios from 'axios';
import {
AlignmentType,
Document,
HeadingLevel,
Packer,
Paragraph,
TextRun,
} from 'docx';
import { saveAs } from 'file-saver';
import { marked, Token, Tokens } from 'marked'; // 从 marked 导入 Token 和 Tokens 类型
import { useCallback, useEffect, useRef, useState } from 'react';
const { Sider, Content } = Layout;
const { Option } = Select;
const LOCAL_STORAGE_TEMPLATES_KEY = 'userWriteTemplates_v4_no_restore_final';
const LOCAL_STORAGE_INIT_FLAG_KEY =
'userWriteTemplates_initialized_v4_no_restore_final';
interface TemplateItem {
id: string;
name: string;
content: string;
isCustom?: boolean;
}
interface KnowledgeBaseItem {
id: string;
name: string;
}
// 使用 marked 导出的类型或更精确的自定义类型
type MarkedHeadingToken = Tokens.Heading;
type MarkedParagraphToken = Tokens.Paragraph;
type MarkedListItem = Tokens.ListItem;
type MarkedListToken = Tokens.List;
type MarkedSpaceToken = Tokens.Space;
const Write = () => {
const { t } = useTranslate('write');
const [content, setContent] = useState('');
const [aiQuestion, setAiQuestion] = useState('');
const [isAiLoading, setIsAiLoading] = useState(false);
const [dialogId, setDialogId] = useState('');
const [cursorPosition, setCursorPosition] = useState<number | null>(null);
const [showCursorIndicator, setShowCursorIndicator] = useState(false);
const textAreaRef = useRef<HTMLTextAreaElement>(null);
const [templates, setTemplates] = useState<TemplateItem[]>([]);
const [isTemplateModalVisible, setIsTemplateModalVisible] = useState(false);
const [templateName, setTemplateName] = useState('');
const [selectedTemplate, setSelectedTemplate] = useState<string>('');
const [viewMode, setViewMode] = useState<'edit' | 'preview' | 'split'>(
'split',
);
const [selectedKnowledgeBases, setSelectedKnowledgeBases] = useState<
string[]
>([]);
const [similarityThreshold, setSimilarityThreshold] = useState<number>(0.7);
const [keywordSimilarityWeight, setKeywordSimilarityWeight] =
useState<number>(0.5);
const [modelTemperature, setModelTemperature] = useState<number>(0.7);
const [knowledgeBases, setKnowledgeBases] = useState<KnowledgeBaseItem[]>([]);
const [isLoadingKbs, setIsLoadingKbs] = useState(false);
const getInitialDefaultTemplateDefinitions = useCallback(
(): TemplateItem[] => [
{
id: 'default_1_v4f',
name: t('defaultTemplate'),
content: `# ${t('defaultTemplateTitle')}\n ## ${t('introduction')}\n ## ${t('mainContent')}\n \n ## ${t('conclusion')}\n `,
isCustom: false,
},
{
id: 'default_2_v4f',
name: t('technicalDoc'),
content: `# ${t('technicalDocTitle')}\n \n ## ${t('overview')}\n \n ## ${t('requirements')}\n \n ## ${t('architecture')}\n \n ## ${t('implementation')}\n \n ## ${t('testing')}\n \n ## ${t('deployment')}\n \n ## ${t('maintenance')}\n `,
isCustom: false,
},
{
id: 'default_3_v4f',
name: t('meetingMinutes'),
content: `# ${t('meetingMinutesTitle')}\n \n ## ${t('date')}: ${new Date().toLocaleDateString()}\n \n ## ${t('participants')}\n \n ## ${t('agenda')}\n \n ## ${t('discussions')}\n \n ## ${t('decisions')}\n \n ## ${t('actionItems')}\n \n ## ${t('nextMeeting')}\n `,
isCustom: false,
},
],
[t],
);
const loadOrInitializeTemplates = useCallback(() => {
try {
const initialized = localStorage.getItem(LOCAL_STORAGE_INIT_FLAG_KEY);
let currentTemplates: TemplateItem[] = [];
if (initialized === 'true') {
const savedTemplatesString = localStorage.getItem(
LOCAL_STORAGE_TEMPLATES_KEY,
);
currentTemplates = savedTemplatesString
? JSON.parse(savedTemplatesString)
: getInitialDefaultTemplateDefinitions();
if (!savedTemplatesString) {
localStorage.setItem(
LOCAL_STORAGE_TEMPLATES_KEY,
JSON.stringify(currentTemplates),
);
}
} else {
currentTemplates = getInitialDefaultTemplateDefinitions();
localStorage.setItem(
LOCAL_STORAGE_TEMPLATES_KEY,
JSON.stringify(currentTemplates),
);
localStorage.setItem(LOCAL_STORAGE_INIT_FLAG_KEY, 'true');
}
setTemplates(currentTemplates);
if (currentTemplates.length > 0 && !selectedTemplate) {
setSelectedTemplate(currentTemplates[0].id);
setContent(currentTemplates[0].content);
} else if (selectedTemplate) {
const current = currentTemplates.find(
(ts) => ts.id === selectedTemplate,
);
if (current) setContent(current.content);
else if (currentTemplates.length > 0) {
setSelectedTemplate(currentTemplates[0].id);
setContent(currentTemplates[0].content);
} else {
setSelectedTemplate('');
setContent('');
}
}
} catch (error) {
console.error('加载或初始化模板失败:', error);
message.error(t('loadTemplatesFailedError'));
const fallbackDefaults = getInitialDefaultTemplateDefinitions();
setTemplates(fallbackDefaults);
if (fallbackDefaults.length > 0) {
setSelectedTemplate(fallbackDefaults[0].id);
setContent(fallbackDefaults[0].content);
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [t, getInitialDefaultTemplateDefinitions]);
useEffect(() => {
loadOrInitializeTemplates();
}, [loadOrInitializeTemplates]);
useEffect(() => {
const fetchKbs = async () => {
const authorization = localStorage.getItem('Authorization');
if (!authorization) {
setKnowledgeBases([]);
return;
}
setIsLoadingKbs(true);
try {
await new Promise((resolve) => {
setTimeout(resolve, 500);
});
const mockKbs: KnowledgeBaseItem[] = [
{
id: 'kb_default',
name: t('defaultKnowledgeBase', { defaultValue: '默认知识库' }),
},
{
id: 'kb_tech',
name: t('technicalDocsKnowledgeBase', {
defaultValue: '技术文档知识库',
}),
},
{
id: 'kb_product',
name: t('productInfoKnowledgeBase', {
defaultValue: '产品信息知识库',
}),
},
{
id: 'kb_marketing',
name: t('marketingMaterialsKB', { defaultValue: '市场营销材料库' }),
},
{
id: 'kb_legal',
name: t('legalDocumentsKB', { defaultValue: '法律文件库' }),
},
];
setKnowledgeBases(mockKbs);
} catch (error) {
console.error('获取知识库失败:', error);
message.error(t('fetchKnowledgeBaseFailed'));
setKnowledgeBases([]);
} finally {
setIsLoadingKbs(false);
}
};
fetchKbs();
}, [t]);
useEffect(() => {
const fetchDialogs = async () => {
try {
const authorization = localStorage.getItem('Authorization');
if (!authorization) return;
const response = await axios.get('/v1/dialog', {
headers: { authorization },
});
if (response.data?.data?.length > 0)
setDialogId(response.data.data[0].id);
} catch (error) {
console.error('获取对话列表失败:', error);
}
};
const loadDraftContent = () => {
try {
const draftContent = localStorage.getItem('writeDraftContent');
if (
draftContent &&
!content &&
(!selectedTemplate ||
templates.find((t) => t.id === selectedTemplate)?.content === '')
) {
setContent(draftContent);
}
} catch (error) {
console.error('加载暂存内容失败:', error);
}
};
fetchDialogs();
if (localStorage.getItem(LOCAL_STORAGE_INIT_FLAG_KEY) === 'true') {
loadDraftContent();
}
}, [content, selectedTemplate, templates]);
useEffect(() => {
const timer = setTimeout(
() => localStorage.setItem('writeDraftContent', content),
1000,
);
return () => clearTimeout(timer);
}, [content]);
const handleTemplateSelect = (templateId: string) => {
setSelectedTemplate(templateId);
const item = templates.find((t) => t.id === templateId);
if (item) setContent(item.content);
};
const handleSaveTemplate = () => {
if (!templateName.trim()) {
message.warning(t('enterTemplateName'));
return;
}
if (!content.trim()) {
message.warning(t('enterTemplateContent'));
return;
}
const newTemplate: TemplateItem = {
id: `custom_${Date.now()}`,
name: templateName,
content,
isCustom: true,
};
try {
const updatedTemplates = [...templates, newTemplate];
setTemplates(updatedTemplates);
localStorage.setItem(
LOCAL_STORAGE_TEMPLATES_KEY,
JSON.stringify(updatedTemplates),
);
message.success(t('templateSavedSuccess'));
setIsTemplateModalVisible(false);
setTemplateName('');
setSelectedTemplate(newTemplate.id);
} catch (error) {
console.error('保存模板失败:', error);
message.error(t('templateSavedFailed'));
}
};
const handleDeleteTemplate = (templateId: string) => {
try {
const updatedTemplates = templates.filter((t) => t.id !== templateId);
setTemplates(updatedTemplates);
localStorage.setItem(
LOCAL_STORAGE_TEMPLATES_KEY,
JSON.stringify(updatedTemplates),
);
if (selectedTemplate === templateId) {
if (updatedTemplates.length > 0) {
setSelectedTemplate(updatedTemplates[0].id);
setContent(updatedTemplates[0].content);
} else {
setSelectedTemplate('');
setContent('');
}
}
message.success(t('templateDeletedSuccess'));
} catch (error) {
console.error('删除模板失败:', error);
message.error(t('templateDeletedFailed'));
}
};
const handleAiQuestionSubmit = async (
e: React.KeyboardEvent<HTMLTextAreaElement>,
) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
if (!aiQuestion.trim()) {
message.warning(t('enterYourQuestion'));
return;
}
if (!dialogId) {
message.error(t('noDialogFound'));
return;
}
setIsAiLoading(true);
const initialCursorPos = cursorPosition;
const originalContent = content;
let beforeCursor = '',
afterCursor = '';
if (initialCursorPos !== null && showCursorIndicator) {
beforeCursor = originalContent.substring(0, initialCursorPos);
afterCursor = originalContent.substring(initialCursorPos);
}
const controller = new AbortController();
const timeoutId = setTimeout(
() => controller.abort(),
aiAssistantConfig.api.timeout || 30000,
);
try {
const authorization = localStorage.getItem('Authorization');
if (!authorization) {
message.error(t('loginRequiredError'));
setIsAiLoading(false);
return;
}
const conversationId =
Math.random().toString(36).substring(2) + Date.now().toString(36);
await axios.post(
'v1/conversation/set',
{
dialog_id: dialogId,
name: '文档撰写对话',
is_new: true,
conversation_id: conversationId,
message: [{ role: 'assistant', content: '新对话' }],
},
{ headers: { authorization }, signal: controller.signal },
);
const combinedQuestion = `${aiQuestion}\n\n${t('currentDocumentContextLabel')}:\n${originalContent}`;
let lastReceivedContent = '';
const response = await axios.post(
'/v1/conversation/completion',
{
conversation_id: conversationId,
messages: [{ role: 'user', content: combinedQuestion }],
knowledge_base_ids:
selectedKnowledgeBases.length > 0
? selectedKnowledgeBases
: undefined,
similarity_threshold: similarityThreshold,
keyword_similarity_weight: keywordSimilarityWeight,
temperature: modelTemperature,
},
{
timeout: aiAssistantConfig.api.timeout,
headers: { authorization },
signal: controller.signal,
},
);
if (response.data) {
const lines = response.data
.split('\n')
.filter((line: string) => line.trim());
for (let i = 0; i < lines.length; i++) {
try {
const jsonStr = lines[i].replace('data:', '').trim();
const jsonData = JSON.parse(jsonStr);
if (jsonData.code === 0 && jsonData.data?.answer) {
const answerChunk = jsonData.data.answer;
const cleanedAnswerChunk = answerChunk
.replace(/<think>[\s\S]*?<\/think>/g, '')
.trim();
const hasUnclosedThink =
cleanedAnswerChunk.includes('<think>') &&
(!cleanedAnswerChunk.includes('</think>') ||
cleanedAnswerChunk.indexOf('<think>') >
cleanedAnswerChunk.lastIndexOf('</think>'));
if (cleanedAnswerChunk && !hasUnclosedThink) {
const incrementalContent = cleanedAnswerChunk.substring(
lastReceivedContent.length,
);
if (incrementalContent) {
lastReceivedContent = cleanedAnswerChunk;
let newFullContent,
newCursorPosAfterInsertion = cursorPosition;
if (initialCursorPos !== null && showCursorIndicator) {
newFullContent =
beforeCursor + cleanedAnswerChunk + afterCursor;
newCursorPosAfterInsertion =
initialCursorPos + cleanedAnswerChunk.length;
} else {
newFullContent = originalContent + cleanedAnswerChunk;
newCursorPosAfterInsertion = newFullContent.length;
}
setContent(newFullContent);
setCursorPosition(newCursorPosAfterInsertion);
setTimeout(() => {
if (textAreaRef.current) {
textAreaRef.current.focus();
textAreaRef.current.setSelectionRange(
newCursorPosAfterInsertion!,
newCursorPosAfterInsertion!,
);
}
}, 0);
}
}
}
} catch (parseErr) {
console.error('解析单行数据失败:', parseErr);
}
if (i < lines.length - 1)
await new Promise((resolve) => {
setTimeout(resolve, 10);
});
}
}
await axios.post(
'/v1/conversation/rm',
{ conversation_ids: [conversationId], dialog_id: dialogId },
{ headers: { authorization } },
);
} catch (error: any) {
console.error('AI助手处理失败:', error);
if (error.code === 'ECONNABORTED' || error.name === 'AbortError') {
message.error(t('aiRequestTimeoutError'));
} else if (error.response?.data?.message) {
message.error(
`${t('aiRequestFailedError')}: ${error.response.data.message}`,
);
} else {
message.error(t('aiRequestFailedError'));
}
} finally {
clearTimeout(timeoutId);
setIsAiLoading(false);
setAiQuestion('');
if (textAreaRef.current) textAreaRef.current.focus();
}
}
};
const handleSave = () => {
const selectedTemplateItem = templates.find(
(item) => item.id === selectedTemplate,
);
const baseName = selectedTemplateItem
? selectedTemplateItem.name
: t('document', { defaultValue: '文档' });
const today = new Date();
const dateStr = `${today.getFullYear()}${String(today.getMonth() + 1).padStart(2, '0')}${String(today.getDate()).padStart(2, '0')}`;
const fileName = `${baseName}_${dateStr}.docx`;
if (!content.trim()) {
message.warning(
t('emptyContentCannotExport', { defaultValue: '内容为空,无法导出' }),
);
return;
}
try {
const tokens = marked.lexer(content) as Token[];
const paragraphs: Paragraph[] = [];
function parseTokensToRuns(
inlineTokens: Tokens.Generic[] | undefined,
): TextRun[] {
const runs: TextRun[] = [];
if (!inlineTokens) return runs;
inlineTokens.forEach((token) => {
if (token.type === 'text') {
runs.push(new TextRun(token.raw)); // Use raw for exact text
} else if (
token.type === 'strong' &&
'text' in token &&
typeof token.text === 'string'
) {
runs.push(new TextRun({ text: token.text, bold: true }));
} else if (
token.type === 'em' &&
'text' in token &&
typeof token.text === 'string'
) {
runs.push(new TextRun({ text: token.text, italics: true }));
} else if (
token.type === 'codespan' &&
'text' in token &&
typeof token.text === 'string'
) {
runs.push(new TextRun({ text: token.text, style: 'Consolas' }));
} else if (
token.type === 'del' &&
'text' in token &&
typeof token.text === 'string'
) {
runs.push(new TextRun({ text: token.text, strike: true }));
} else if (
token.type === 'link' &&
'text' in token &&
typeof token.text === 'string' &&
'href' in token &&
typeof token.href === 'string'
) {
runs.push(new TextRun({ text: token.text, style: 'Hyperlink' }));
} else if ('raw' in token) {
runs.push(new TextRun(token.raw));
}
});
return runs;
}
tokens.forEach((token) => {
if (token.type === 'heading') {
const headingToken = token as MarkedHeadingToken;
let docxHeadingLevel: (typeof HeadingLevel)[keyof typeof HeadingLevel];
switch (headingToken.depth) {
case 1:
docxHeadingLevel = HeadingLevel.HEADING_1;
break;
case 2:
docxHeadingLevel = HeadingLevel.HEADING_2;
break;
case 3:
docxHeadingLevel = HeadingLevel.HEADING_3;
break;
case 4:
docxHeadingLevel = HeadingLevel.HEADING_4;
break;
case 5:
docxHeadingLevel = HeadingLevel.HEADING_5;
break;
case 6:
docxHeadingLevel = HeadingLevel.HEADING_6;
break;
default:
docxHeadingLevel = HeadingLevel.HEADING_1;
}
paragraphs.push(
new Paragraph({
children: parseTokensToRuns(
headingToken.tokens ||
([
{
type: 'text',
raw: headingToken.text,
text: headingToken.text,
},
] as any),
),
heading: docxHeadingLevel,
spacing: {
after: 200 - headingToken.depth * 20,
before: headingToken.depth === 1 ? 0 : 100,
},
}),
);
} else if (token.type === 'paragraph') {
const paraToken = token as MarkedParagraphToken;
const runs = parseTokensToRuns(paraToken.tokens);
paragraphs.push(
new Paragraph({
children: runs.length > 0 ? runs : [new TextRun('')],
spacing: { after: 120 },
}),
);
} else if (token.type === 'list') {
const listToken = token as MarkedListToken;
listToken.items.forEach((listItem: MarkedListItem) => {
const itemRuns = parseTokensToRuns(listItem.tokens);
paragraphs.push(
new Paragraph({
children: itemRuns.length > 0 ? itemRuns : [new TextRun('')],
bullet: listToken.ordered ? undefined : { level: 0 },
numbering: listToken.ordered
? { reference: 'default-numbering', level: 0 }
: undefined,
}),
);
});
paragraphs.push(new Paragraph({ spacing: { after: 80 } }));
} else if (token.type === 'space') {
const spaceToken = token as MarkedSpaceToken;
const newlines = (spaceToken.raw.match(/\n/g) || []).length;
if (newlines > 1) {
for (let i = 0; i < newlines; i++)
paragraphs.push(new Paragraph({}));
} else {
paragraphs.push(new Paragraph({ spacing: { after: 80 } }));
}
}
});
if (paragraphs.length === 0 && content.trim()) {
paragraphs.push(new Paragraph({ children: [new TextRun(content)] }));
}
const doc = new Document({
numbering: {
config: [
{
reference: 'default-numbering',
levels: [
{
level: 0,
format: 'decimal',
text: '%1.',
alignment: AlignmentType.LEFT,
},
],
},
],
},
sections: [{ properties: {}, children: paragraphs }],
});
Packer.toBlob(doc)
.then((blob) => {
saveAs(blob, fileName);
message.success(
t('exportSuccess', { defaultValue: '文档导出成功!' }),
);
})
.catch((packError) => {
console.error('Error packing document: ', packError);
message.error(
t('exportFailedError', {
defaultValue: '文档导出失败,请检查控制台日志。',
}),
);
});
} catch (error) {
console.error('Error generating Word document: ', error);
message.error(
t('exportProcessError', { defaultValue: '处理文档导出时发生错误。' }),
);
}
};
const renderEditor = () => (
<Input.TextArea
ref={textAreaRef}
style={{
height: '100%',
width: '100%',
border: 'none',
padding: 24,
fontSize: 16,
resize: 'none',
}}
value={content}
onChange={(e) => setContent(e.target.value)}
onClick={(e) => {
const target = e.target as HTMLTextAreaElement;
setCursorPosition(target.selectionStart);
setShowCursorIndicator(true);
}}
onKeyUp={(e) => {
const target = e.target as HTMLTextAreaElement;
setCursorPosition(target.selectionStart);
setShowCursorIndicator(true);
}}
placeholder={t('writePlaceholder')}
autoSize={false}
/>
);
const renderPreview = () => (
<div
style={{
height: '100%',
width: '100%',
padding: 24,
overflow: 'auto',
fontSize: 16,
}}
>
<HightLightMarkdown>
{content || t('previewPlaceholder')}
</HightLightMarkdown>
</div>
);
const renderContent = () => {
switch (viewMode) {
case 'edit':
return (
<div style={{ height: '100%', width: '100%' }}>{renderEditor()}</div>
);
case 'preview':
return (
<div style={{ height: '100%', width: '100%' }}>{renderPreview()}</div>
);
case 'split':
default:
return (
<Flex style={{ height: '100%', width: '100%' }}>
<div
style={{
flex: '1 1 50%',
borderRight: '1px solid #f0f0f0',
height: '100%',
overflow: 'hidden',
}}
>
{renderEditor()}
</div>
<div
style={{ flex: '1 1 50%', height: '100%', overflow: 'hidden' }}
>
{renderPreview()}
</div>
</Flex>
);
}
};
return (
<Layout
style={{
height: 'calc(100vh - 80px)',
display: 'flex',
flexDirection: 'row',
overflow: 'hidden',
}}
>
<Sider
width={320}
theme="light"
style={{
borderRight: '1px solid #f0f0f0',
height: '100%',
display: 'flex',
flexDirection: 'column',
}}
>
<div
style={{
padding: '16px 16px 0 16px',
height: '70%',
minHeight: '250px',
display: 'flex',
flexDirection: 'column',
}}
>
<Typography.Title
level={5}
style={{ textAlign: 'center', marginBottom: 12, flexShrink: 0 }}
>
{t('templateList')}
</Typography.Title>
<Space
direction="vertical"
style={{ width: '100%', marginBottom: 16, flexShrink: 0 }}
>
<Button
type="primary"
block
onClick={() => setIsTemplateModalVisible(true)}
>
{t('saveCurrentAsTemplate')}
</Button>
</Space>
<div
style={{
flexGrow: 1,
overflowY: 'auto',
border: '1px solid #f0f0f0',
borderRadius: '4px',
}}
>
<List
dataSource={templates}
locale={{ emptyText: t('noTemplatesAvailable') }}
renderItem={(item) => (
<List.Item
actions={[
<Popconfirm
key="delete"
title={t('confirmDeleteTemplate')}
onConfirm={() => handleDeleteTemplate(item.id)}
okText={t('confirm')}
cancelText={t('cancel')}
>
<Button
type="text"
danger
icon={<DeleteOutlined />}
size="small"
/>
</Popconfirm>,
]}
style={{
cursor: 'pointer',
background:
selectedTemplate === item.id ? '#e6f7ff' : 'transparent',
padding: '8px 12px',
borderBottom: '1px solid #f0f0f0',
}}
onClick={() => handleTemplateSelect(item.id)}
>
<Typography.Text
ellipsis={{ tooltip: item.name }}
style={{ flexGrow: 1, marginRight: 8 }}
>
{item.name}
</Typography.Text>
{item.isCustom && (
<Typography.Text
type="secondary"
style={{ fontSize: '0.85em', flexShrink: 0 }}
>
({t('customTemplateMarker')})
</Typography.Text>
)}
</List.Item>
)}
/>
</div>
</div>
<Divider style={{ margin: '16px 0', borderTopWidth: '1px' }} />
<div
style={{
padding: '0 16px 16px 16px',
flexGrow: 1,
overflowY: 'auto',
}}
>
<Typography.Title
level={5}
style={{ textAlign: 'center', marginBottom: 12, flexShrink: 0 }}
>
{t('modelConfigurationTitle', { defaultValue: '模型配置' })}
</Typography.Title>
<Form layout="vertical" component={false} size="small">
<Form.Item
label={t('knowledgeBaseLabel', {
defaultValue: '知识库 (可多选)',
})}
style={{ marginBottom: 12 }}
>
<Select
mode="multiple"
placeholder={t('knowledgeBasePlaceholderMulti', {
defaultValue: '选择一个或多个知识库',
})}
value={selectedKnowledgeBases}
onChange={setSelectedKnowledgeBases}
allowClear
style={{ width: '100%' }}
loading={isLoadingKbs}
filterOption={(input, option) =>
(option?.children?.toString() ?? '')
.toLowerCase()
.includes(input.toLowerCase())
}
maxTagCount="responsive"
notFoundContent={
isLoadingKbs ? null : t('noKnowledgeBaseFound')
}
>
{knowledgeBases.map((kb) => (
<Option key={kb.id} value={kb.id}>
{kb.name}
</Option>
))}
</Select>
</Form.Item>
<Form.Item
label={`${t('similarityThresholdLabel')}: ${similarityThreshold.toFixed(2)}`}
style={{ marginBottom: 12 }}
>
<Slider
min={0}
max={1}
step={0.01}
value={similarityThreshold}
onChange={setSimilarityThreshold}
tooltip={{ formatter: (value) => `${value?.toFixed(2)}` }}
/>
</Form.Item>
<Form.Item
label={`${t('keywordSimilarityWeightLabel')}: ${keywordSimilarityWeight.toFixed(2)}`}
style={{ marginBottom: 12 }}
>
<Slider
min={0}
max={1}
step={0.01}
value={keywordSimilarityWeight}
onChange={setKeywordSimilarityWeight}
tooltip={{ formatter: (value) => `${value?.toFixed(2)}` }}
/>
</Form.Item>
<Form.Item
label={`${t('modelTemperatureLabel')}: ${modelTemperature.toFixed(2)}`}
style={{ marginBottom: 0 }}
>
<Slider
min={0}
max={2}
step={0.01}
value={modelTemperature}
onChange={setModelTemperature}
tooltip={{ formatter: (value) => `${value?.toFixed(2)}` }}
/>
</Form.Item>
</Form>
</div>
</Sider>
<Content
style={{
flexGrow: 1,
height: '100%',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
}}
>
<Flex
vertical
style={{
flexGrow: 1,
gap: 16,
height: '100%',
padding: '24px',
overflow: 'hidden',
}}
>
<Flex
justify="space-between"
align="center"
style={{ flexShrink: 0 }}
>
<Typography.Title level={3} style={{ margin: 0 }}>
{t('writeDocument')}
</Typography.Title>
<Space>
<Button.Group>
<Button
type={viewMode === 'edit' ? 'primary' : 'default'}
onClick={() => setViewMode('edit')}
>
{t('edit')}
</Button>
<Button
type={viewMode === 'split' ? 'primary' : 'default'}
onClick={() => setViewMode('split')}
>
{t('split')}
</Button>
<Button
type={viewMode === 'preview' ? 'primary' : 'default'}
onClick={() => setViewMode('preview')}
>
{t('preview')}
</Button>
</Button.Group>
<Button type="primary" onClick={handleSave}>
{t('saveToWord', { defaultValue: '导出为Word' })}
</Button>
</Space>
</Flex>
<Card
bodyStyle={{
padding: 0,
height: '100%',
overflow: 'hidden',
display: 'flex',
}}
style={{
flexGrow: 1,
display: 'flex',
flexDirection: 'column',
minHeight: 0,
}}
>
{renderContent()}
</Card>
<Card
title={t('aiAssistant')}
bodyStyle={{
padding: '12px',
display: 'flex',
flexDirection: 'column',
gap: 10,
}}
style={{ flexShrink: 0 }}
>
{isAiLoading && (
<div style={{ textAlign: 'center', marginBottom: 8 }}>
{t('aiLoadingMessage')}...
</div>
)}
<Input.TextArea
placeholder={t('askAI')}
autoSize={{ minRows: 2, maxRows: 5 }}
value={aiQuestion}
onChange={(e) => setAiQuestion(e.target.value)}
onKeyDown={handleAiQuestionSubmit}
disabled={isAiLoading}
/>
</Card>
</Flex>
</Content>
<Modal
title={t('saveAsCustomTemplateTitle')}
open={isTemplateModalVisible}
onOk={handleSaveTemplate}
onCancel={() => {
setIsTemplateModalVisible(false);
setTemplateName('');
}}
okText={t('saveTemplate')}
cancelText={t('cancel')}
>
<Form layout="vertical" style={{ marginTop: 16 }}>
<Form.Item label={t('templateNameLabel')} required>
<Input
placeholder={t('templateNamePlaceholder')}
value={templateName}
onChange={(e) => setTemplateName(e.target.value)}
/>
</Form.Item>
<Form.Item label={t('templateContentPreviewLabel')}>
<Input.TextArea value={content} disabled rows={6} />
</Form.Item>
</Form>
</Modal>
</Layout>
);
};
export default Write;