修补输入框

This commit is contained in:
2026-04-01 12:50:16 +08:00
parent 80237463ef
commit 6fa1fd6e7f
8 changed files with 450 additions and 316 deletions

BIN
.gitignore vendored

Binary file not shown.

5
.idea/codeStyles/codeStyleConfig.xml generated Normal file
View File

@@ -0,0 +1,5 @@
<component name="ProjectCodeStyleConfiguration">
<state>
<option name="PREFERRED_PROJECT_CODE_STYLE" value="Default" />
</state>
</component>

View File

@@ -15,6 +15,14 @@ class Message(BaseModel):
description="消息发送时间戳" description="消息发送时间戳"
) )
floor: int = Field(0, description="对话楼层数") floor: int = Field(0, description="对话楼层数")
swipes: List[str] = Field(
default_factory=list,
description="历史版本列表。用户消息存编辑过的不同版本。AI消息存重roll生成的不同版本"
)
swipe_id: int = Field(
0,
description="当前指针。指示当前显示的是 swipes 数组中的第几个(从 0 开始)"
)
mes: str = Field(..., description="消息内容文本") mes: str = Field(..., description="消息内容文本")
extra: Dict[str, Any] = Field( extra: Dict[str, Any] = Field(
default_factory=dict, default_factory=dict,
@@ -211,17 +219,26 @@ class ChatHistory(BaseModel):
"name": str, "name": str,
"is_user": bool, "is_user": bool,
"floor": int, "floor": int,
"mes": str "mes": str,
"swipes": List[str],
"swipe_id": int
} }
""" """
# 创建消息字典列表 # 创建消息字典列表
messages_list = [] messages_list = []
for msg in self.messages: for msg in self.messages:
# 获取当前消息内容优先从swipes数组中获取如果不存在则使用mes
current_mes = msg.mes
if msg.swipes and 0 <= msg.swipe_id < len(msg.swipes):
current_mes = msg.swipes[msg.swipe_id]
msg_dict = { msg_dict = {
"name": msg.name, "name": msg.name,
"is_user": msg.is_user, "is_user": msg.is_user,
"floor": msg.floor, "floor": msg.floor,
"mes": msg.mes "mes": current_mes,
"swipes": msg.swipes,
"swipe_id": msg.swipe_id
} }
messages_list.append(msg_dict) messages_list.append(msg_dict)

View File

@@ -1,6 +1,6 @@
# 使用 Node.js 18 Alpine 镜像作为基础 # 使用 Node.js 18 Alpine 镜像作为基础
# 必须明确指定 node 版本,否则可能默认为空或 python 镜像 # 必须明确指定 node 版本,否则可能默认为空或 python 镜像
FROM node:18-alpine FROM node:20-alpine
# 设置工作目录 # 设置工作目录
WORKDIR /app WORKDIR /app

View File

@@ -3,88 +3,147 @@ import { subscribeWithSelector } from 'zustand/middleware';
const useChatBoxStore = create( const useChatBoxStore = create(
subscribeWithSelector((set, get) => ({ subscribeWithSelector((set, get) => ({
// 聊天历史消息列表 // 聊天历史消息列表
messages: [], messages: [],
// 用户名称 // 用户名称
userName: '', userName: '',
// 角色名称 // 角色名称
characterName: '', characterName: '',
// 当前选中的角色 // 当前选中的角色
currentRole: null, currentRole: null,
// 当前选中的聊天 // 当前选中的聊天
currentChat: null, currentChat: null,
// 是否正在加载 // 是否正在加载
isLoading: false, isLoading: false,
// 错误信息 // 是否正在生成
error: null, isGenerating: false,
// 设置消息列表 // 错误信息
setMessages: (messages) => set({ messages }), error: null,
// 设置用户名称 // 设置消息列表
setUserName: (userName) => set({ userName }), setMessages: (messages) => set({ messages }),
// 设置角色名称 // 设置用户名称
setCharacterName: (characterName) => set({ characterName }), setUserName: (userName) => set({ userName }),
// 设置当前角色 // 设置角色名称
setCurrentRole: (role) => set({ currentRole: role }), setCharacterName: (characterName) => set({ characterName }),
// 设置当前聊天 // 设置当前角色
setCurrentChat: (chat) => set({ currentChat: chat }), setCurrentRole: (role) => set({ currentRole: role }),
// 同时设置角色和聊天 // 设置当前聊天
setChatBoxRoleAndChat: (role, chat) => set({ setCurrentChat: (chat) => set({ currentChat: chat }),
currentRole: role,
currentChat: chat
}),
// 同时设置角色和聊天
// 加载聊天历史 setChatBoxRoleAndChat: (role, chat) => {
fetchChatHistory: async (roleName, chatName) => { console.log('[ChatBoxStore] setChatBoxRoleAndChat called with:', { role, chat });
set({ isLoading: true, error: null }); set({
try { currentRole: role,
const response = await fetch(`/api/chat_box/get_chat_history?role_name=${encodeURIComponent(roleName)}&chat_name=${encodeURIComponent(chatName)}`); currentChat: chat
if (!response.ok) { });
throw new Error('Failed to fetch chat history');
}
const data = await response.json();
// 修改数据处理逻辑适配API返回的数据结构
set({
messages: data || [], // 直接使用返回的数组
userName: 'User', // 固定用户名
characterName: roleName || 'Assistant', // 使用角色名作为角色名称
isLoading: false
});
} catch (error) {
set({
error: error.message,
isLoading: false
});
}
}, },
// 清空聊天历史 // 设置生成状态
clearChatHistory: () => set({ setIsGenerating: (status) => set({ isGenerating: status }),
messages: [],
userName: '',
characterName: '',
error: null
}),
// 更新特定消息的内容 sendMessage: async (content) => {
updateMessage: (id, content) => set((state) => ({ const { messages, userName, characterName, currentRole, currentChat } = get();
messages: state.messages.map((msg) =>
msg.id === id ? { ...msg, content } : msg set({
) isGenerating: true,
})), messages: [...messages, {
id: Date.now(),
floor: messages.length + 1,
mes: content,
is_user: true
}]
});
try {
const response = await fetch('/api/chat_box/send_message', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
role_name: currentRole,
chat_name: currentChat,
message: content
})
});
if (!response.ok) {
throw new Error('Failed to send message');
}
const data = await response.json();
set((state) => ({
messages: [...state.messages, {
id: Date.now(),
floor: state.messages.length + 1,
mes: data.response,
is_user: false
}],
isGenerating: false
}));
} catch (error) {
set({
error: error.message,
isGenerating: false
});
}
},
// 终止生成
stopGeneration: () => set({ isGenerating: false }),
// 加载聊天历史
fetchChatHistory: async (roleName, chatName) => {
set({ isLoading: true, error: null });
try {
const response = await fetch(`/api/chat_box/get_chat_history?role_name=${encodeURIComponent(roleName)}&chat_name=${encodeURIComponent(chatName)}`);
if (!response.ok) {
throw new Error('Failed to fetch chat history');
}
const data = await response.json();
// 修改数据处理逻辑适配API返回的数据结构
set({
messages: data || [], // 直接使用返回的数组
userName: 'User', // 固定用户名
characterName: roleName || 'Assistant', // 使用角色名作为角色名称
isLoading: false
});
} catch (error) {
set({
error: error.message,
isLoading: false
});
}
},
// 清空聊天历史
clearChatHistory: () => set({
messages: [],
userName: '',
characterName: '',
error: null
}),
// 更新特定消息的内容
updateMessage: (id, content) => set((state) => ({
messages: state.messages.map((msg) =>
msg.id === id ? { ...msg, content } : msg
)
})),
})) }))
); );
@@ -92,15 +151,15 @@ const useChatBoxStore = create(
useChatBoxStore.subscribe( useChatBoxStore.subscribe(
(state) => ({ role: state.currentRole, chat: state.currentChat }), (state) => ({ role: state.currentRole, chat: state.currentChat }),
({ role, chat }, prev) => { ({ role, chat }, prev) => {
// 只有当角色或聊天发生变化时才处理 // 只有当角色或聊天发生变化时才处理
if (role !== prev.role || chat !== prev.chat) { if (role !== prev.role || chat !== prev.chat) {
// 确保角色和聊天都存在且不为null // 确保角色和聊天都存在且不为null
if (role && chat) { if (role && chat) {
useChatBoxStore.getState().fetchChatHistory(role, chat); useChatBoxStore.getState().fetchChatHistory(role, chat);
} else { } else {
useChatBoxStore.getState().clearChatHistory(); useChatBoxStore.getState().clearChatHistory();
} }
} }
}, },
{ equalityFn: (a, b) => a.role === b.role && a.chat === b.chat } { equalityFn: (a, b) => a.role === b.role && a.chat === b.chat }
); );

View File

@@ -6,128 +6,181 @@ const ChatBox = () => {
const [editingId, setEditingId] = useState(null); const [editingId, setEditingId] = useState(null);
const [editContent, setEditContent] = useState(''); const [editContent, setEditContent] = useState('');
const messagesEndRef = useRef(null); const messagesEndRef = useRef(null);
const [inputValue, setInputValue] = useState('');
// 从 ChatBoxStore 获取状态和方法 // 从 ChatBoxStore 获取状态和方法
const { const {
messages, messages,
isLoading, isLoading,
error, error,
userName, userName,
characterName, characterName,
updateMessage updateMessage,
} = useChatBoxStore(); isGenerating,
sendMessage,
stopGeneration
} = useChatBoxStore();
const [inputHeight, setInputHeight] = useState(42);
// 添加输入框高度自适应处理
const handleInputHeight = (e) => {
const textarea = e.target;
const newHeight = Math.min(Math.max(textarea.scrollHeight, 42), 300);
setInputHeight(newHeight);
};
// 处理发送或终止
const handleSendOrStop = () => {
if (isGenerating) {
stopGeneration();
} else {
sendMessage(inputValue);
setInputValue('');
setInputHeight(42);
}
};
// 处理键盘事件
const handleKeyDown = (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSendOrStop();
}
};
// 自动滚动到底部 // 自动滚动到底部
const scrollToBottom = () => { const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}; };
useEffect(() => { useEffect(() => {
scrollToBottom(); scrollToBottom();
}, [messages]); }, [messages]);
// 处理编辑消息 // 处理编辑消息
const handleEdit = (message) => { const handleEdit = (message) => {
setEditingId(message.floor); setEditingId(message.floor);
setEditContent(message.mes); setEditContent(message.mes);
}; };
// 保存编辑 // 保存编辑
const handleSaveEdit = (messageId) => { const handleSaveEdit = (messageId) => {
// 调用 store 中的 updateMessage 方法 // 调用 store 中的 updateMessage 方法
updateMessage(messageId, editContent); updateMessage(messageId, editContent);
setEditingId(null); setEditingId(null);
setEditContent(''); setEditContent('');
}; };
// 取消编辑 // 取消编辑
const handleCancelEdit = () => { const handleCancelEdit = () => {
setEditingId(null); setEditingId(null);
setEditContent(''); setEditContent('');
}; };
// 渲染单条消息 // 渲染单条消息
const renderMessage = (message) => { const renderMessage = (message) => {
const isUser = message.is_user; const isUser = message.is_user;
const isEditing = editingId === message.floor; const isEditing = editingId === message.floor;
// 根据消息类型设置显示名称 // 根据消息类型设置显示名称
const displayName = isUser ? userName : characterName; const displayName = isUser ? userName : characterName;
return ( return (
<div key={message.floor} className={`message ${isUser ? 'user' : 'ai'}`}> <div key={message.floor} className={`message ${isUser ? 'user' : 'ai'}`}>
<div className="message-container"> <div className="message-container">
<div className="message-header"> <div className="message-header">
<span className="message-name">{displayName}</span> <span className="message-name">{displayName}</span>
<span className="message-id">#{message.floor}</span> <span className="message-id">#{message.floor}</span>
<div className="message-toolbar"> <div className="message-toolbar">
<div className="toolbar-buttons"> <div className="toolbar-buttons">
<button <button
className="toolbar-button" className="toolbar-button"
onClick={() => handleEdit(message)} onClick={() => handleEdit(message)}
title="编辑" title="编辑"
> >
</button> </button>
<button <button
className="toolbar-button" className="toolbar-button"
title="更多" title="更多"
> >
</button> </button>
</div> </div>
</div> </div>
</div> </div>
<div className="message-content"> <div className="message-content">
{isEditing ? ( {isEditing ? (
<div className="edit-container"> <div className="edit-container">
<textarea <textarea
className="edit-textarea" className="edit-textarea"
value={editContent} value={editContent}
onChange={(e) => setEditContent(e.target.value)} onChange={(e) => setEditContent(e.target.value)}
/> />
<div className="edit-buttons"> <div className="edit-buttons">
<button <button
className="cancel-button" className="cancel-button"
onClick={handleCancelEdit} onClick={handleCancelEdit}
> >
取消 取消
</button> </button>
<button <button
className="save-button" className="save-button"
onClick={() => handleSaveEdit(message.floor)} onClick={() => handleSaveEdit(message.floor)}
> >
保存 保存
</button> </button>
</div> </div>
</div> </div>
) : ( ) : (
<div className="bubble"> <div className="bubble">
{message.mes} {message.mes}
</div> </div>
)} )}
</div>
</div>
</div>
);
};
return (
<div className="chat-box">
<div className="chat-messages">
{isLoading ? (
<div className="loading">加载中...</div>
) : error ? (
<div className="error">{error}</div>
) : messages.length === 0 ? (
<div className="loading">暂无消息</div>
) : (
messages.map(renderMessage)
)}
<div ref={messagesEndRef} />
</div>
<div className="chat-input-wrapper">
<div className="chat-input-area">
<textarea
value={inputValue}
onChange={(e) => {
setInputValue(e.target.value);
handleInputHeight(e);
}}
onKeyDown={handleKeyDown}
style={{ height: `${inputHeight}px` }}
placeholder="输入消息..."
/>
</div> </div>
<button
className={`send-button ${isGenerating ? 'stopping' : ''}`}
onClick={handleSendOrStop}
disabled={!inputValue.trim() && !isGenerating}
>
{isGenerating ? '终止' : '发送'}
</button>
</div> </div>
</div> </div>
); );
};
return (
<div className="chat-box">
<div className="chat-messages">
{isLoading ? (
<div className="loading">加载中...</div>
) : error ? (
<div className="error">{error}</div>
) : messages.length === 0 ? (
<div className="loading">暂无消息</div>
) : (
messages.map(renderMessage)
)}
<div ref={messagesEndRef} />
</div>
</div>
);
}; };
export default ChatBox; export default ChatBox;

View File

@@ -1,146 +1,146 @@
// npm install react react-dom react-markdown react-syntax-highlighter remark-math rehype-katex react-copy-to-clipboard mermaid katex // // npm install react react-dom react-markdown react-syntax-highlighter remark-math rehype-katex react-copy-to-clipboard mermaid katex
import React, { useState, useCallback, useEffect } from "react"; // import React, { useState, useCallback, useEffect } from "react";
import ReactMarkdown from "react-markdown"; // import ReactMarkdown from "react-markdown";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; // import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { materialLight } from "react-syntax-highlighter/dist/cjs/styles/prism"; // import { materialLight } from "react-syntax-highlighter/dist/cjs/styles/prism";
import remarkMath from "remark-math"; // import remarkMath from "remark-math";
import rehypeKatex from "rehype-katex"; // import rehypeKatex from "rehype-katex";
import { CopyToClipboard } from "react-copy-to-clipboard"; // import { CopyToClipboard } from "react-copy-to-clipboard";
import mermaid from "mermaid"; // import mermaid from "mermaid";
import "katex/dist/katex.min.css"; // import "katex/dist/katex.min.css";
//
// Mermaid 初始化配置 // // Mermaid 初始化配置
// Mermaid是一个画图的语言 // // Mermaid是一个画图的语言
mermaid.initialize({ // mermaid.initialize({
startOnLoad: false, // startOnLoad: false,
theme: "default", // theme: "default",
securityLevel: "loose", // securityLevel: "loose",
}); // });
//
const DownSvg = () => ( // const DownSvg = () => (
<svg width="12" height="12" viewBox="0 0 24 24"> // <svg width="12" height="12" viewBox="0 0 24 24">
<path d="M7 10l5 5 5-5z" fill="currentColor" /> // <path d="M7 10l5 5 5-5z" fill="currentColor" />
</svg> // </svg>
); // );
//
const CopySvg = () => ( // const CopySvg = () => (
<svg width="14" height="14" viewBox="0 0 24 24"> // <svg width="14" height="14" viewBox="0 0 24 24">
<path d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z" fill="currentColor" /> // <path d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z" fill="currentColor" />
</svg> // </svg>
); // );
//
//
const MermaidChart= ({ code }) => { // const MermaidChart= ({ code }) => {
const [svg, setSvg] = useState(""); // const [svg, setSvg] = useState("");
const [id] = useState(() => `mermaid-${Math.random().toString(36).substr(2, 9)}`); // const [id] = useState(() => `mermaid-${Math.random().toString(36).substr(2, 9)}`);
//
useEffect(() => { // useEffect(() => {
try { // try {
mermaid.parse(code); // mermaid.parse(code);
mermaid.render(id, code).then(({ svg }) => setSvg(svg)); // mermaid.render(id, code).then(({ svg }) => setSvg(svg));
} catch (err) { // } catch (err) {
setSvg(`<pre>Error rendering mermaid: ${err}</pre>`); // setSvg(`<pre>Error rendering mermaid: ${err}</pre>`);
} // }
}, [code, id]); // }, [code, id]);
//
return <div dangerouslySetInnerHTML={{ __html: svg }} />; // return <div dangerouslySetInnerHTML={{ __html: svg }} />;
}; // };
//
const MarkdownToHTML= ({ markdownRAW, className }) => { // const MarkdownToHTML= ({ markdownRAW, className }) => {
const [isMermaidLoaded] = useState(true); // const [isMermaidLoaded] = useState(true);
// 渲染代码块的方法 // // 渲染代码块的方法
const CodeBlock = useCallback( // const CodeBlock = useCallback(
({ node, inline, className, children, ...props } ) => { // ({ node, inline, className, children, ...props } ) => {
const [isShowCode, setIsShowCode] = useState(true); // 添加展开与收起代码块状态 // const [isShowCode, setIsShowCode] = useState(true); // 添加展开与收起代码块状态
const [isShowCopy, setIsShowCopy] = useState(false);// 添加点击复制的状态 // const [isShowCopy, setIsShowCopy] = useState(false);// 添加点击复制的状态
const match = /language-(\w+)/.exec(className || ""); // 这是用来匹配代码块对应语言的方法 // const match = /language-(\w+)/.exec(className || ""); // 这是用来匹配代码块对应语言的方法
const codeContent = String(children).replace(/\n$/, ""); // const codeContent = String(children).replace(/\n$/, "");
// 处理复制成功提示 // // 处理复制成功提示
const handleCopy = () => { // const handleCopy = () => {
setIsShowCopy(true); // setIsShowCopy(true);
setTimeout(() => setIsShowCopy(false), 1500); // setTimeout(() => setIsShowCopy(false), 1500);
}; // };
//处理单行代码 // //处理单行代码
if (inline) { // if (inline) {
return <code className={className} {...props}>{children}</code>; // return <code className={className} {...props}>{children}</code>;
} // }
//
// 处理 Mermaid 图表代码 // // 处理 Mermaid 图表代码
if (match?.[1] === "mermaid" && isMermaidLoaded) { // if (match?.[1] === "mermaid" && isMermaidLoaded) {
return ( // return (
<div style={{ position: "relative", margin: "20px 0" }}> // <div style={{ position: "relative", margin: "20px 0" }}>
<div className="code-header"> // <div className="code-header">
<div // <div
style={{ cursor: "pointer", marginRight: "10px", transformOrigin: "8px" }} // style={{ cursor: "pointer", marginRight: "10px", transformOrigin: "8px" }}
className={isShowCode ? "code-rotate-down" : "code-rotate-right"} // className={isShowCode ? "code-rotate-down" : "code-rotate-right"}
onClick={() => setIsShowCode(!isShowCode)} // onClick={() => setIsShowCode(!isShowCode)}
> // >
<DownSvg /> // <DownSvg />
</div> // </div>
<div>{match[1]}</div> // <div>{match[1]}</div>
<CopyToClipboard text={codeContent} onCopy={handleCopy}> // <CopyToClipboard text={codeContent} onCopy={handleCopy}>
<div className="preview-code-copy" style={{ cursor: "pointer" }}> // <div className="preview-code-copy" style={{ cursor: "pointer" }}>
{isShowCopy && <span className="copy-success"> 复制成功</span>} // {isShowCopy && <span className="copy-success">✓ 复制成功</span>}
<CopySvg /> // <CopySvg />
</div> // </div>
</CopyToClipboard> // </CopyToClipboard>
</div> // </div>
{isShowCode && <MermaidChart code={codeContent} />} // {isShowCode && <MermaidChart code={codeContent} />}
</div> // </div>
); // );
} // }
//
return ( // return (
<div style={{ position: "relative", padding:"0"}}> // <div style={{ position: "relative", padding:"0"}}>
<div className="code-header"> // <div className="code-header">
<div // <div
style={{ cursor: "pointer", marginRight: "10px", transformOrigin: "8px" }} // style={{ cursor: "pointer", marginRight: "10px", transformOrigin: "8px" }}
className={isShowCode ? "code-rotate-down" : "code-rotate-right"} // className={isShowCode ? "code-rotate-down" : "code-rotate-right"}
onClick={() => setIsShowCode(!isShowCode)} // onClick={() => setIsShowCode(!isShowCode)}
> // >
<DownSvg style={{ // <DownSvg style={{
transform: isShowCode ? "rotate(0deg)" : "rotate(-90deg)", // transform: isShowCode ? "rotate(0deg)" : "rotate(-90deg)",
transition: "transform 0.2s" // transition: "transform 0.2s"
}} /> // }} />
</div> // </div>
<div>{match?.[1] || "code"}</div> // <div>{match?.[1] || "code"}</div>
<CopyToClipboard text={codeContent} onCopy={handleCopy}> // <CopyToClipboard text={codeContent} onCopy={handleCopy}>
<div className="preview-code-copy" style={{ cursor: "pointer" }}> // <div className="preview-code-copy" style={{ cursor: "pointer" }}>
{isShowCopy && <span className="copy-success"> 复制成功</span>} // {isShowCopy && <span className="copy-success">✓ 复制成功</span>}
<CopySvg /> // <CopySvg />
</div> // </div>
</CopyToClipboard> // </CopyToClipboard>
</div> // </div>
{isShowCode && ( // {isShowCode && (
<SyntaxHighlighter // <SyntaxHighlighter
style={materialLight} // style={materialLight}
language={match?.[1] || "text"} // language={match?.[1] || "text"}
PreTag="div" // PreTag="div"
showLineNumbers // showLineNumbers
{...props} // {...props}
> // >
{codeContent} // {codeContent}
</SyntaxHighlighter> // </SyntaxHighlighter>
)} // )}
</div> // </div>
); // );
}, // },
[isMermaidLoaded] // [isMermaidLoaded]
); // );
//
return ( // return (
<div // <div
className={className} > // className={className} >
<ReactMarkdown // <ReactMarkdown
remarkPlugins={[remarkMath]} // remarkPlugins={[remarkMath]}
rehypePlugins={[rehypeKatex]} // rehypePlugins={[rehypeKatex]}
components={{ code: CodeBlock }} // components={{ code: CodeBlock }}
> // >
{markdownRAW} // {markdownRAW}
</ReactMarkdown> // </ReactMarkdown>
//
</div> // </div>
); // );
}; // };
//
export default MarkdownToHTML; // export default MarkdownToHTML;

Binary file not shown.

Before

Width:  |  Height:  |  Size: 497 KiB

After

Width:  |  Height:  |  Size: 0 B