feat: 添加附件支持、自动选择话题及消息展示优化
- 消息协议新增 attachments 字段,支持图片/音频/视频/文件附件 - 文本和附件合并在一条消息中发送,不再拆分为多条 - Topics 加载后自动选中第一个话题并加载历史消息 - 用户消息现在通过 WebSocket 发送,可在前端展示 - 前端过滤 tool_result 消息,添加附件卡片展示组件 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
542e11d0b3
commit
7898ca69e4
1
.gitignore
vendored
1
.gitignore
vendored
@ -30,3 +30,4 @@ output
|
|||||||
.python-version
|
.python-version
|
||||||
pyproject.toml
|
pyproject.toml
|
||||||
uv.lock
|
uv.lock
|
||||||
|
node_modules
|
||||||
|
|||||||
@ -77,6 +77,7 @@ impl OutputAdapter for WebSocketOutputAdapter {
|
|||||||
id: response.request_id.to_string(),
|
id: response.request_id.to_string(),
|
||||||
content: msg.content.clone(),
|
content: msg.content.clone(),
|
||||||
role: "assistant".to_string(),
|
role: "assistant".to_string(),
|
||||||
|
attachments: Vec::new(),
|
||||||
},
|
},
|
||||||
MessageKind::Notification => {
|
MessageKind::Notification => {
|
||||||
// 根据元数据判断具体类型
|
// 根据元数据判断具体类型
|
||||||
@ -96,6 +97,7 @@ impl OutputAdapter for WebSocketOutputAdapter {
|
|||||||
id: response.request_id.to_string(),
|
id: response.request_id.to_string(),
|
||||||
content: msg.content.clone(),
|
content: msg.content.clone(),
|
||||||
role: "assistant".to_string(),
|
role: "assistant".to_string(),
|
||||||
|
attachments: Vec::new(),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
} else if let Some(session_id) = response.metadata.get("session_id") {
|
} else if let Some(session_id) = response.metadata.get("session_id") {
|
||||||
@ -134,6 +136,7 @@ impl OutputAdapter for WebSocketOutputAdapter {
|
|||||||
id: response.request_id.to_string(),
|
id: response.request_id.to_string(),
|
||||||
content: msg.content.clone(),
|
content: msg.content.clone(),
|
||||||
role: "assistant".to_string(),
|
role: "assistant".to_string(),
|
||||||
|
attachments: Vec::new(),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
} else if let Some(sessions_json) = response.metadata.get("sessions") {
|
} else if let Some(sessions_json) = response.metadata.get("sessions") {
|
||||||
@ -151,6 +154,7 @@ impl OutputAdapter for WebSocketOutputAdapter {
|
|||||||
id: response.request_id.to_string(),
|
id: response.request_id.to_string(),
|
||||||
content: msg.content.clone(),
|
content: msg.content.clone(),
|
||||||
role: "assistant".to_string(),
|
role: "assistant".to_string(),
|
||||||
|
attachments: Vec::new(),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
} else if let Some(topics_json) = response.metadata.get("topics") {
|
} else if let Some(topics_json) = response.metadata.get("topics") {
|
||||||
@ -169,6 +173,7 @@ impl OutputAdapter for WebSocketOutputAdapter {
|
|||||||
id: response.request_id.to_string(),
|
id: response.request_id.to_string(),
|
||||||
content: msg.content.clone(),
|
content: msg.content.clone(),
|
||||||
role: "assistant".to_string(),
|
role: "assistant".to_string(),
|
||||||
|
attachments: Vec::new(),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@ -177,6 +182,7 @@ impl OutputAdapter for WebSocketOutputAdapter {
|
|||||||
id: response.request_id.to_string(),
|
id: response.request_id.to_string(),
|
||||||
content: msg.content.clone(),
|
content: msg.content.clone(),
|
||||||
role: "assistant".to_string(),
|
role: "assistant".to_string(),
|
||||||
|
attachments: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -188,6 +194,7 @@ impl OutputAdapter for WebSocketOutputAdapter {
|
|||||||
id: response.request_id.to_string(),
|
id: response.request_id.to_string(),
|
||||||
content: msg.content.clone(),
|
content: msg.content.clone(),
|
||||||
role: "assistant".to_string(),
|
role: "assistant".to_string(),
|
||||||
|
attachments: Vec::new(),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
outbounds.push(outbound);
|
outbounds.push(outbound);
|
||||||
|
|||||||
@ -33,6 +33,7 @@ impl SessionMessageSender for BusSessionMessageSender {
|
|||||||
.ok_or_else(|| anyhow::anyhow!("missing chat_id in tool context"))?;
|
.ok_or_else(|| anyhow::anyhow!("missing chat_id in tool context"))?;
|
||||||
|
|
||||||
let metadata = HashMap::new();
|
let metadata = HashMap::new();
|
||||||
|
let attachment_count = request.attachments.len();
|
||||||
let mut published_messages = 0;
|
let mut published_messages = 0;
|
||||||
let text_sent = request
|
let text_sent = request
|
||||||
.text
|
.text
|
||||||
@ -43,26 +44,27 @@ impl SessionMessageSender for BusSessionMessageSender {
|
|||||||
|
|
||||||
if let Some(text) = request.text.filter(|value| !value.trim().is_empty()) {
|
if let Some(text) = request.text.filter(|value| !value.trim().is_empty()) {
|
||||||
let content_len = text.len();
|
let content_len = text.len();
|
||||||
self.bus
|
let mut outbound = OutboundMessage::assistant(
|
||||||
.publish_outbound(OutboundMessage::assistant(
|
|
||||||
channel_name.to_string(),
|
channel_name.to_string(),
|
||||||
chat_id.to_string(),
|
chat_id.to_string(),
|
||||||
None, // session_id
|
None, // session_id
|
||||||
text,
|
text,
|
||||||
None,
|
None,
|
||||||
metadata.clone(),
|
metadata.clone(),
|
||||||
))
|
);
|
||||||
.await?;
|
if attachment_count > 0 {
|
||||||
|
outbound.media = request.attachments.clone();
|
||||||
|
}
|
||||||
|
self.bus.publish_outbound(outbound).await?;
|
||||||
published_messages += 1;
|
published_messages += 1;
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
channel = %channel_name,
|
channel = %channel_name,
|
||||||
chat_id = %chat_id,
|
chat_id = %chat_id,
|
||||||
content_len = content_len,
|
content_len = content_len,
|
||||||
|
attachment_count = attachment_count,
|
||||||
"Published session text message to outbound bus"
|
"Published session text message to outbound bus"
|
||||||
);
|
);
|
||||||
}
|
} else {
|
||||||
|
|
||||||
let attachment_count = request.attachments.len();
|
|
||||||
for attachment in request.attachments {
|
for attachment in request.attachments {
|
||||||
let media_path = attachment.path.clone();
|
let media_path = attachment.path.clone();
|
||||||
let media_type = attachment.media_type.clone();
|
let media_type = attachment.media_type.clone();
|
||||||
@ -85,6 +87,7 @@ impl SessionMessageSender for BusSessionMessageSender {
|
|||||||
"Published session attachment to outbound bus"
|
"Published session attachment to outbound bus"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Ok(SessionSendOutcome {
|
Ok(SessionSendOutcome {
|
||||||
published_messages,
|
published_messages,
|
||||||
@ -129,19 +132,15 @@ mod tests {
|
|||||||
assert_eq!(
|
assert_eq!(
|
||||||
outcome,
|
outcome,
|
||||||
SessionSendOutcome {
|
SessionSendOutcome {
|
||||||
published_messages: 2,
|
published_messages: 1,
|
||||||
text_sent: true,
|
text_sent: true,
|
||||||
attachment_count: 1,
|
attachment_count: 1,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
let first = bus.consume_outbound().await;
|
let msg = bus.consume_outbound().await;
|
||||||
assert_eq!(first.content, "hello");
|
assert_eq!(msg.content, "hello");
|
||||||
assert!(first.media.is_empty());
|
assert_eq!(msg.media.len(), 1);
|
||||||
|
assert_eq!(msg.media[0].media_type, "image");
|
||||||
let second = bus.consume_outbound().await;
|
|
||||||
assert_eq!(second.content, "");
|
|
||||||
assert_eq!(second.media.len(), 1);
|
|
||||||
assert_eq!(second.media[0].media_type, "image");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -355,10 +355,28 @@ async fn handle_inbound(
|
|||||||
*current_topic_id = Some(topic_id.clone());
|
*current_topic_id = Some(topic_id.clone());
|
||||||
|
|
||||||
// 加载并发送该话题的历史消息
|
// 加载并发送该话题的历史消息
|
||||||
if let Err(e) = send_topic_history(&store, topic_id, sender).await {
|
if let Err(e) = send_topic_history(&store, current_session_id, topic_id, sender).await {
|
||||||
tracing::warn!(error = %e, topic_id = %topic_id, "Failed to send topic history");
|
tracing::warn!(error = %e, topic_id = %topic_id, "Failed to send topic history");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if current_topic_id.is_none() {
|
||||||
|
if let Some(topics_json) = response.metadata.get("topics") {
|
||||||
|
match serde_json::from_str::<Vec<crate::protocol::TopicSummary>>(topics_json) {
|
||||||
|
Ok(topics) => {
|
||||||
|
if let Some(first_topic) = topics.first() {
|
||||||
|
let topic_id = first_topic.topic_id.clone();
|
||||||
|
*current_topic_id = Some(topic_id.clone());
|
||||||
|
if let Err(e) = send_topic_history(&store, current_session_id, &topic_id, sender).await {
|
||||||
|
tracing::warn!(error = %e, topic_id = %topic_id, "Failed to send initial topic history");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(error = %e, "Failed to parse topics metadata for initial history");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
} else if let Some(ref error) = response.error {
|
} else if let Some(ref error) = response.error {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
error_code = %error.code,
|
error_code = %error.code,
|
||||||
@ -400,11 +418,15 @@ fn resolve_ws_sender_id(sender_id: Option<&str>, runtime_session_id: &str) -> St
|
|||||||
/// 加载并发送话题历史消息
|
/// 加载并发送话题历史消息
|
||||||
async fn send_topic_history(
|
async fn send_topic_history(
|
||||||
store: &Arc<crate::storage::SessionStore>,
|
store: &Arc<crate::storage::SessionStore>,
|
||||||
|
session_id: &str,
|
||||||
topic_id: &str,
|
topic_id: &str,
|
||||||
sender: &mpsc::Sender<WsOutbound>,
|
sender: &mpsc::Sender<WsOutbound>,
|
||||||
) -> Result<(), Box<dyn std::error::Error>> {
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
// 加载话题消息
|
// 加载话题消息
|
||||||
let messages = store.load_messages_for_topic(topic_id)?;
|
let mut messages = store.load_messages_for_topic(topic_id)?;
|
||||||
|
if messages.is_empty() {
|
||||||
|
messages = store.load_messages(session_id)?;
|
||||||
|
}
|
||||||
|
|
||||||
tracing::info!(topic_id = %topic_id, message_count = messages.len(), "Sending topic history");
|
tracing::info!(topic_id = %topic_id, message_count = messages.len(), "Sending topic history");
|
||||||
|
|
||||||
@ -443,6 +465,7 @@ fn chat_message_to_ws_outbound(msg: &crate::bus::ChatMessage) -> Option<WsOutbou
|
|||||||
id: msg.id.clone(),
|
id: msg.id.clone(),
|
||||||
content: msg.content.clone(),
|
content: msg.content.clone(),
|
||||||
role: msg.role.clone(),
|
role: msg.role.clone(),
|
||||||
|
attachments: Vec::new(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
"tool" => {
|
"tool" => {
|
||||||
@ -465,10 +488,12 @@ fn chat_message_to_ws_outbound(msg: &crate::bus::ChatMessage) -> Option<WsOutbou
|
|||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"user" => {
|
"user" => Some(WsOutbound::AssistantResponse {
|
||||||
// 用户消息不通过 WsOutbound 发送,前端自己维护
|
id: msg.id.clone(),
|
||||||
None
|
content: msg.content.clone(),
|
||||||
}
|
role: msg.role.clone(),
|
||||||
|
attachments: Vec::new(),
|
||||||
|
}),
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -34,6 +34,14 @@ pub struct TopicSummary {
|
|||||||
pub last_active_at: i64,
|
pub last_active_at: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct MediaSummary {
|
||||||
|
pub path: String,
|
||||||
|
pub media_type: String,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub mime_type: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(tag = "type")]
|
#[serde(tag = "type")]
|
||||||
pub enum WsInbound {
|
pub enum WsInbound {
|
||||||
@ -63,6 +71,8 @@ pub enum WsOutbound {
|
|||||||
id: String,
|
id: String,
|
||||||
content: String,
|
content: String,
|
||||||
role: String,
|
role: String,
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
attachments: Vec<MediaSummary>,
|
||||||
},
|
},
|
||||||
#[serde(rename = "tool_call")]
|
#[serde(rename = "tool_call")]
|
||||||
ToolCall {
|
ToolCall {
|
||||||
|
|||||||
@ -5,7 +5,7 @@ use crate::bus::message::OutboundEventKind;
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
use crate::bus::message::{ToolMessageState, format_tool_call_content};
|
use crate::bus::message::{ToolMessageState, format_tool_call_content};
|
||||||
|
|
||||||
use super::WsOutbound;
|
use super::{MediaSummary, WsOutbound};
|
||||||
|
|
||||||
const TOOL_PENDING_RESUME_HINT: &str = "完成外部操作后,直接发一条继续消息即可。";
|
const TOOL_PENDING_RESUME_HINT: &str = "完成外部操作后,直接发一条继续消息即可。";
|
||||||
|
|
||||||
@ -20,6 +20,7 @@ pub(crate) fn ws_outbound_from_chat_message(message: &ChatMessage) -> Vec<WsOutb
|
|||||||
id: message.id.clone(),
|
id: message.id.clone(),
|
||||||
content: message.content.clone(),
|
content: message.content.clone(),
|
||||||
role: message.role.clone(),
|
role: message.role.clone(),
|
||||||
|
attachments: Vec::new(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -37,6 +38,7 @@ pub(crate) fn ws_outbound_from_chat_message(message: &ChatMessage) -> Vec<WsOutb
|
|||||||
id: message.id.clone(),
|
id: message.id.clone(),
|
||||||
content: message.content.clone(),
|
content: message.content.clone(),
|
||||||
role: message.role.clone(),
|
role: message.role.clone(),
|
||||||
|
attachments: Vec::new(),
|
||||||
}]
|
}]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -68,10 +70,20 @@ pub(crate) fn ws_outbound_from_chat_message(message: &ChatMessage) -> Vec<WsOutb
|
|||||||
pub(crate) fn ws_outbound_from_outbound_message(message: &OutboundMessage) -> Vec<WsOutbound> {
|
pub(crate) fn ws_outbound_from_outbound_message(message: &OutboundMessage) -> Vec<WsOutbound> {
|
||||||
match message.event_kind {
|
match message.event_kind {
|
||||||
OutboundEventKind::AssistantResponse | OutboundEventKind::SchedulerNotification => {
|
OutboundEventKind::AssistantResponse | OutboundEventKind::SchedulerNotification => {
|
||||||
|
let attachments: Vec<MediaSummary> = message
|
||||||
|
.media
|
||||||
|
.iter()
|
||||||
|
.map(|m| MediaSummary {
|
||||||
|
path: m.path.clone(),
|
||||||
|
media_type: m.media_type.clone(),
|
||||||
|
mime_type: m.mime_type.clone(),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
vec![WsOutbound::AssistantResponse {
|
vec![WsOutbound::AssistantResponse {
|
||||||
id: uuid::Uuid::new_v4().to_string(),
|
id: uuid::Uuid::new_v4().to_string(),
|
||||||
content: message.content.clone(),
|
content: message.content.clone(),
|
||||||
role: message.role.clone(),
|
role: message.role.clone(),
|
||||||
|
attachments,
|
||||||
}]
|
}]
|
||||||
}
|
}
|
||||||
OutboundEventKind::ToolCall => vec![WsOutbound::ToolCall {
|
OutboundEventKind::ToolCall => vec![WsOutbound::ToolCall {
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect } from 'react'
|
import { useCallback, useEffect, useRef } from 'react'
|
||||||
import { Zap, Cpu, MessageSquare } from 'lucide-react'
|
import { Zap, Cpu, MessageSquare } from 'lucide-react'
|
||||||
import { ChatContainer } from './components/Chat/ChatContainer'
|
import { ChatContainer } from './components/Chat/ChatContainer'
|
||||||
import { TopicList } from './components/Sidebar/TopicList'
|
import { TopicList } from './components/Sidebar/TopicList'
|
||||||
@ -12,6 +12,8 @@ import type { Command } from './types/protocol'
|
|||||||
const WS_URL = 'ws://127.0.0.1:19876/ws'
|
const WS_URL = 'ws://127.0.0.1:19876/ws'
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
|
const lastAutoSwitchedTopicRef = useRef<string | null>(null)
|
||||||
|
|
||||||
const {
|
const {
|
||||||
// 连接状态
|
// 连接状态
|
||||||
connectionId,
|
connectionId,
|
||||||
@ -64,6 +66,24 @@ function App() {
|
|||||||
}
|
}
|
||||||
}, [sessionId, status, handleCommand, sendMessage, requestTopicList])
|
}, [sessionId, status, handleCommand, sendMessage, requestTopicList])
|
||||||
|
|
||||||
|
// Topics 加载后,自动选择第一个并通知后端切换,以便加载历史消息
|
||||||
|
useEffect(() => {
|
||||||
|
if (topics.length === 0 || status !== 'connected') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstTopic = topics[0]
|
||||||
|
if (lastAutoSwitchedTopicRef.current === firstTopic.id) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
lastAutoSwitchedTopicRef.current = firstTopic.id
|
||||||
|
selectTopic(firstTopic.id)
|
||||||
|
const cmd = switchTopic(firstTopic.id)
|
||||||
|
handleCommand(cmd)
|
||||||
|
sendMessage({ type: 'command', payload: JSON.stringify(cmd) })
|
||||||
|
}, [topics, status, selectTopic, switchTopic, handleCommand, sendMessage]);
|
||||||
|
|
||||||
const handleSendMessage = useCallback(
|
const handleSendMessage = useCallback(
|
||||||
(content: string) => {
|
(content: string) => {
|
||||||
if (isReadOnly || !sessionId) {
|
if (isReadOnly || !sessionId) {
|
||||||
@ -136,6 +156,7 @@ function App() {
|
|||||||
[sendMessage, handleCommand, switchTopic, selectTopic]
|
[sendMessage, handleCommand, switchTopic, selectTopic]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const chatMessages = messages.filter((message) => message.type !== 'tool_result')
|
||||||
const toolMessages = messages
|
const toolMessages = messages
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -199,7 +220,7 @@ function App() {
|
|||||||
{/* Center - Chat */}
|
{/* Center - Chat */}
|
||||||
<div className="flex-1 min-w-0 bg-[#0a0a0f]">
|
<div className="flex-1 min-w-0 bg-[#0a0a0f]">
|
||||||
<ChatContainer
|
<ChatContainer
|
||||||
messages={messages}
|
messages={chatMessages}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
isReadOnly={isReadOnly}
|
isReadOnly={isReadOnly}
|
||||||
channelName={session?.title ?? 'PicoBot'}
|
channelName={session?.title ?? 'PicoBot'}
|
||||||
|
|||||||
@ -1,12 +1,50 @@
|
|||||||
import { User, Bot, Wrench, CheckCircle, AlertCircle, Terminal } from 'lucide-react'
|
import { User, Bot, Wrench, CheckCircle, AlertCircle, Terminal, File, Image, FileText, Music, Video, Download } from 'lucide-react'
|
||||||
import ReactMarkdown from 'react-markdown'
|
import ReactMarkdown from 'react-markdown'
|
||||||
import remarkGfm from 'remark-gfm'
|
import remarkGfm from 'remark-gfm'
|
||||||
import type { ChatMessage } from '../../types/protocol'
|
import type { ChatMessage, Attachment } from '../../types/protocol'
|
||||||
|
|
||||||
interface MessageBubbleProps {
|
interface MessageBubbleProps {
|
||||||
message: ChatMessage
|
message: ChatMessage
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getAttachmentIcon(mediaType: string) {
|
||||||
|
switch (mediaType) {
|
||||||
|
case 'image': return <Image className="h-4 w-4" />
|
||||||
|
case 'audio': return <Music className="h-4 w-4" />
|
||||||
|
case 'video': return <Video className="h-4 w-4" />
|
||||||
|
case 'file': return <FileText className="h-4 w-4" />
|
||||||
|
default: return <File className="h-4 w-4" />
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFileName(path: string): string {
|
||||||
|
const parts = path.replace(/\\/g, '/').split('/')
|
||||||
|
return parts[parts.length - 1] || path
|
||||||
|
}
|
||||||
|
|
||||||
|
function AttachmentCard({ attachment }: { attachment: Attachment }) {
|
||||||
|
const fileName = getFileName(attachment.path)
|
||||||
|
const downloadUrl = `/download?path=${encodeURIComponent(attachment.path)}`
|
||||||
|
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={downloadUrl}
|
||||||
|
download={fileName}
|
||||||
|
className="flex items-center gap-2 rounded-lg bg-white/5 border border-white/10 px-3 py-2 text-xs hover:bg-white/10 hover:border-[#00f0ff]/30 transition-colors cursor-pointer group"
|
||||||
|
title={`下载 ${fileName}`}
|
||||||
|
>
|
||||||
|
<span className="text-zinc-400 group-hover:text-[#00f0ff] transition-colors">
|
||||||
|
{getAttachmentIcon(attachment.media_type)}
|
||||||
|
</span>
|
||||||
|
<span className="text-zinc-300 truncate max-w-[200px] group-hover:text-white transition-colors" title={attachment.path}>
|
||||||
|
{fileName}
|
||||||
|
</span>
|
||||||
|
<span className="text-zinc-600 ml-auto shrink-0">{attachment.media_type}</span>
|
||||||
|
<Download className="h-3 w-3 text-zinc-600 group-hover:text-[#00f0ff] transition-colors" />
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function MessageBubble({ message }: MessageBubbleProps) {
|
export function MessageBubble({ message }: MessageBubbleProps) {
|
||||||
const isUser = message.role === 'user'
|
const isUser = message.role === 'user'
|
||||||
const isTool = message.role === 'tool'
|
const isTool = message.role === 'tool'
|
||||||
@ -160,6 +198,13 @@ export function MessageBubble({ message }: MessageBubbleProps) {
|
|||||||
</ReactMarkdown>
|
</ReactMarkdown>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{message.attachments && message.attachments.length > 0 && (
|
||||||
|
<div className="mt-2 space-y-1">
|
||||||
|
{message.attachments.map((att: Attachment, idx: number) => (
|
||||||
|
<AttachmentCard key={idx} attachment={att} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -113,7 +113,6 @@ export function useChat(): UseChatReturn {
|
|||||||
|
|
||||||
case 'session_loaded': {
|
case 'session_loaded': {
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
setMessages([])
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -133,23 +132,22 @@ export function useChat(): UseChatReturn {
|
|||||||
setTopics(newTopics)
|
setTopics(newTopics)
|
||||||
|
|
||||||
// 默认选中第一个 Topic(如果没有选中)
|
// 默认选中第一个 Topic(如果没有选中)
|
||||||
if (newTopics.length > 0 && !selectedTopic) {
|
|
||||||
setSelectedTopic(newTopics[0].id)
|
|
||||||
}
|
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'assistant_response': {
|
case 'assistant_response': {
|
||||||
const msg = message as AssistantResponse
|
const msg = message as AssistantResponse
|
||||||
|
const role = msg.role === 'user' || msg.role === 'tool' ? msg.role : 'assistant'
|
||||||
setMessages((prev) => [
|
setMessages((prev) => [
|
||||||
...prev,
|
...prev,
|
||||||
{
|
{
|
||||||
id: msg.id,
|
id: msg.id,
|
||||||
role: 'assistant',
|
role,
|
||||||
content: msg.content,
|
content: msg.content,
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
type: 'message',
|
type: 'message',
|
||||||
|
attachments: msg.attachments,
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
@ -225,7 +223,7 @@ export function useChat(): UseChatReturn {
|
|||||||
// 忽略这些消息
|
// 忽略这些消息
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}, [selectedTopic])
|
}, [])
|
||||||
|
|
||||||
const handleMessage = useCallback((content: string) => {
|
const handleMessage = useCallback((content: string) => {
|
||||||
setMessages((prev) => [
|
setMessages((prev) => [
|
||||||
|
|||||||
@ -27,11 +27,18 @@ export type WsInbound = WsInboundMessage | WsInboundCommand | WsInboundPing
|
|||||||
// Outbound Messages (Server -> Client)
|
// Outbound Messages (Server -> Client)
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface Attachment {
|
||||||
|
path: string
|
||||||
|
media_type: string
|
||||||
|
mime_type?: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface AssistantResponse {
|
export interface AssistantResponse {
|
||||||
type: 'assistant_response'
|
type: 'assistant_response'
|
||||||
id: string
|
id: string
|
||||||
content: string
|
content: string
|
||||||
role: string
|
role: string
|
||||||
|
attachments?: Attachment[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ToolCall {
|
export interface ToolCall {
|
||||||
@ -241,6 +248,7 @@ export interface ChatMessage {
|
|||||||
type?: 'message' | 'tool_call' | 'tool_result' | 'tool_pending'
|
type?: 'message' | 'tool_call' | 'tool_result' | 'tool_pending'
|
||||||
toolName?: string
|
toolName?: string
|
||||||
arguments?: unknown
|
arguments?: unknown
|
||||||
|
attachments?: Attachment[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Topic {
|
export interface Topic {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user