Compare commits
No commits in common. "1288ba268fd8d04009a074d5ec1358a17fbbd9a9" and "3d9c981c2a38bf55da02ef414fa5ec6ef358793d" have entirely different histories.
1288ba268f
...
3d9c981c2a
@ -209,7 +209,6 @@ pub async fn run(gateway_url: &str) -> Result<(), Box<dyn std::error::Error>> {
|
||||
InputEvent::Message(msg) => {
|
||||
let inbound = WsInbound::Message {
|
||||
content: msg.content,
|
||||
attachments: Vec::new(),
|
||||
channel: None,
|
||||
chat_id: current_session_id.clone(),
|
||||
sender_id: None,
|
||||
|
||||
@ -38,20 +38,10 @@
|
||||
|
||||
### 记忆写入
|
||||
|
||||
#### 命名空间分类
|
||||
记忆必须使用以下命名空间之一:
|
||||
- `user` - 用户记忆:用户长期偏好、身份背景和历史协作信息
|
||||
- `semantic` - 语义记忆:结构化或非结构化知识内容
|
||||
- `episodic` - 情景记忆:历史对话、任务执行过程及关键事件
|
||||
- `skill` - 技能记忆:技能定义、工作流、工具调用策略及最佳实践
|
||||
- `environment` - 环境记忆:外部系统状态、运行环境配置和实时资源信息
|
||||
- `reflection` - 反思记忆:成功经验、失败原因和优化建议
|
||||
- `other` - 其他记忆:不属于以上分类的其他内容
|
||||
|
||||
#### 写入规则
|
||||
- 写入或修改记忆时使用 memory_manage。
|
||||
- 遇到未来仍有用的信息时写入记忆:用户长期偏好、稳定事实、用户对你的纠正、持续任务或项目上下文、明确决策等。
|
||||
- 写入时必须使用允许的命名空间:user、semantic、episodic、skill、environment、reflection、other。
|
||||
- 写入时优先使用规范 namespace:preferences、profile、tasks、decisions。
|
||||
- 优先调用 memory_manage(action='put');同一 namespace/key 可直接覆盖更新。
|
||||
|
||||
#### 【重要注意!】以下场景视为高价值加分,必须记录记忆
|
||||
|
||||
@ -208,8 +208,6 @@ impl AgentExecutionService {
|
||||
}
|
||||
let enriched_content =
|
||||
enrich_user_content_with_media_refs(request.content, &media_refs)?;
|
||||
enrich_user_content_with_media_refs(request.content, &media_refs)?;
|
||||
enrich_user_content_with_media_refs(request.content, &media_refs)?;
|
||||
|
||||
// 先计算 user_message_count(在添加新消息之前)
|
||||
let history_before = session_guard.get_or_create_history(request.chat_id).clone();
|
||||
|
||||
@ -19,7 +19,7 @@
|
||||
|
||||
- merges:对象数组。每个对象必须包含 source_ids、namespace、memory_key、content。
|
||||
- source_ids: 字符串数组,要合并的源记忆ID列表
|
||||
- namespace: 目标命名空间(必须是以下之一:user、semantic、episodic、skill、environment、reflection、other)
|
||||
- namespace: 目标命名空间
|
||||
- memory_key: 目标记忆键(可以自由决定)
|
||||
- content: 合并后的内容
|
||||
- conflicts:对象数组。每个对象必须包含 source_ids、note。
|
||||
@ -27,16 +27,6 @@
|
||||
- note: 冲突说明
|
||||
- low_value_ids:需要删除的低价值候选记忆 ID 数组
|
||||
|
||||
命名空间分类说明:
|
||||
|
||||
- `user` - 用户记忆:用户长期偏好、身份背景和历史协作信息
|
||||
- `semantic` - 语义记忆:结构化或非结构化知识内容
|
||||
- `episodic` - 情景记忆:历史对话、任务执行过程及关键事件
|
||||
- `skill` - 技能记忆:技能定义、工作流、工具调用策略及最佳实践
|
||||
- `environment` - 环境记忆:外部系统状态、运行环境配置和实时资源信息
|
||||
- `reflection` - 反思记忆:成功经验、失败原因和优化建议
|
||||
- `other` - 其他记忆:不属于以上分类的其他内容
|
||||
|
||||
组织原则:
|
||||
|
||||
- 根据记忆的语义内容自然分组
|
||||
|
||||
@ -1192,7 +1192,7 @@ mod tests {
|
||||
.put_memory(&crate::storage::MemoryUpsert {
|
||||
scope_kind: "user".to_string(),
|
||||
scope_key: "feishu:user-1".to_string(),
|
||||
namespace: "user".to_string(),
|
||||
namespace: "profile".to_string(),
|
||||
memory_key: "work".to_string(),
|
||||
content: "用户在做AI产品".to_string(),
|
||||
source_type: "message".to_string(),
|
||||
@ -1282,7 +1282,7 @@ mod tests {
|
||||
.put_memory(&crate::storage::MemoryUpsert {
|
||||
scope_kind: "user".to_string(),
|
||||
scope_key: "feishu:user-1".to_string(),
|
||||
namespace: "user".to_string(),
|
||||
namespace: "profile".to_string(),
|
||||
memory_key: "work".to_string(),
|
||||
content: "用户在做AI产品".to_string(),
|
||||
source_type: "message".to_string(),
|
||||
@ -1371,7 +1371,7 @@ mod tests {
|
||||
.put_memory(&crate::storage::MemoryUpsert {
|
||||
scope_kind: "user".to_string(),
|
||||
scope_key: "feishu:user-1".to_string(),
|
||||
namespace: "user".to_string(),
|
||||
namespace: "profile".to_string(),
|
||||
memory_key: "work".to_string(),
|
||||
content: "用户在做AI产品".to_string(),
|
||||
source_type: "message".to_string(),
|
||||
@ -1442,7 +1442,7 @@ mod tests {
|
||||
.put_memory(&crate::storage::MemoryUpsert {
|
||||
scope_kind: "user".to_string(),
|
||||
scope_key: "feishu:user-1".to_string(),
|
||||
namespace: "user".to_string(),
|
||||
namespace: "profile".to_string(),
|
||||
memory_key: "work".to_string(),
|
||||
content: "用户在做AI产品".to_string(),
|
||||
source_type: "message".to_string(),
|
||||
@ -1522,7 +1522,7 @@ mod tests {
|
||||
.put_memory(&crate::storage::MemoryUpsert {
|
||||
scope_kind: "user".to_string(),
|
||||
scope_key: "feishu:user-1".to_string(),
|
||||
namespace: "user".to_string(),
|
||||
namespace: "profile".to_string(),
|
||||
memory_key: "work".to_string(),
|
||||
content: "用户在做AI产品".to_string(),
|
||||
source_type: "message".to_string(),
|
||||
@ -1590,7 +1590,7 @@ mod tests {
|
||||
.put_memory(&crate::storage::MemoryUpsert {
|
||||
scope_kind: "user".to_string(),
|
||||
scope_key: scope_key.to_string(),
|
||||
namespace: "user".to_string(),
|
||||
namespace: "profile".to_string(),
|
||||
memory_key: "work".to_string(),
|
||||
content: format!("{} 在做AI产品", scope_key),
|
||||
source_type: "message".to_string(),
|
||||
@ -1623,7 +1623,7 @@ mod tests {
|
||||
.put_memory(&crate::storage::MemoryUpsert {
|
||||
scope_kind: "user".to_string(),
|
||||
scope_key: scope_key.to_string(),
|
||||
namespace: "user".to_string(),
|
||||
namespace: "profile".to_string(),
|
||||
memory_key: "work_short".to_string(),
|
||||
content: "用户在做AI产品".to_string(),
|
||||
source_type: "message".to_string(),
|
||||
@ -1638,7 +1638,7 @@ mod tests {
|
||||
.put_memory(&crate::storage::MemoryUpsert {
|
||||
scope_kind: "user".to_string(),
|
||||
scope_key: scope_key.to_string(),
|
||||
namespace: "user".to_string(),
|
||||
namespace: "profile".to_string(),
|
||||
memory_key: "work_detail".to_string(),
|
||||
content: "用户主要在做AI产品设计和实现".to_string(),
|
||||
source_type: "message".to_string(),
|
||||
@ -1653,7 +1653,7 @@ mod tests {
|
||||
.put_memory(&crate::storage::MemoryUpsert {
|
||||
scope_kind: "user".to_string(),
|
||||
scope_key: scope_key.to_string(),
|
||||
namespace: "other".to_string(),
|
||||
namespace: "notes".to_string(),
|
||||
memory_key: "temporary".to_string(),
|
||||
content: "今天临时提到过一个无后续的小细节".to_string(),
|
||||
source_type: "message".to_string(),
|
||||
@ -1671,7 +1671,7 @@ mod tests {
|
||||
.put_memory(&crate::storage::MemoryUpsert {
|
||||
scope_kind: "user".to_string(),
|
||||
scope_key: scope_key.to_string(),
|
||||
namespace: "user".to_string(),
|
||||
namespace: "profile".to_string(),
|
||||
memory_key: format!("extra_{}", i),
|
||||
content: format!("额外记忆 {}", i),
|
||||
source_type: "message".to_string(),
|
||||
@ -1692,7 +1692,7 @@ mod tests {
|
||||
let output = MemoryOrganizationOutput {
|
||||
merges: vec![MemoryMaintenanceMerge {
|
||||
source_ids: vec![work.id.clone(), role.id.clone()],
|
||||
namespace: "user".to_string(),
|
||||
namespace: "profile".to_string(),
|
||||
memory_key: "work".to_string(),
|
||||
content: "用户主要在做AI产品设计与实现".to_string(),
|
||||
}],
|
||||
@ -1715,10 +1715,10 @@ mod tests {
|
||||
let all_memories = store.list_memories_for_scope("user", scope_key).unwrap();
|
||||
// 过滤掉 _meta 记录
|
||||
let user_memories: Vec<_> = all_memories.iter().filter(|m| m.namespace != "_meta").collect();
|
||||
// 合并 2 条为 1 条,删除 1 条,7 - 2 + 1 = 5 条
|
||||
assert_eq!(user_memories.len(), 5);
|
||||
// 合并 2 条为 1 条,删除 1 条,7 - 2 + 1 = 6 条(加上 _meta 记录)
|
||||
assert_eq!(user_memories.len(), 6);
|
||||
// 验证合并后的记忆存在
|
||||
assert!(user_memories.iter().any(|m| m.namespace == "user" && m.memory_key == "work"));
|
||||
assert!(user_memories.iter().any(|m| m.namespace == "profile" && m.memory_key == "work"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -1948,7 +1948,7 @@ mod tests {
|
||||
id: "1".to_string(),
|
||||
scope_kind: "user".to_string(),
|
||||
scope_key: "feishu:user-1".to_string(),
|
||||
namespace: "user".to_string(),
|
||||
namespace: "profile".to_string(),
|
||||
memory_key: "work".to_string(),
|
||||
content: "用户在做AI产品".to_string(),
|
||||
source_type: "message".to_string(),
|
||||
@ -1964,7 +1964,7 @@ mod tests {
|
||||
id: "2".to_string(),
|
||||
scope_kind: "user".to_string(),
|
||||
scope_key: "feishu:user-1".to_string(),
|
||||
namespace: "user".to_string(),
|
||||
namespace: "profile".to_string(),
|
||||
memory_key: "work".to_string(),
|
||||
content: "用户在做AI产品".to_string(),
|
||||
source_type: "message".to_string(),
|
||||
@ -1980,7 +1980,7 @@ mod tests {
|
||||
id: "3".to_string(),
|
||||
scope_kind: "user".to_string(),
|
||||
scope_key: "feishu:user-1".to_string(),
|
||||
namespace: "user".to_string(),
|
||||
namespace: "preferences".to_string(),
|
||||
memory_key: "style".to_string(),
|
||||
content: "偏好简洁表达".to_string(),
|
||||
source_type: "message".to_string(),
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
use super::GatewayState;
|
||||
use crate::agent::{AgentError, CompositeSystemPromptProvider};
|
||||
use crate::bus::{InboundMessage, MediaItem};
|
||||
use crate::bus::InboundMessage;
|
||||
use crate::command::adapter::{InputAdapter, OutputAdapter};
|
||||
use crate::command::adapters::websocket::{WebSocketInputAdapter, WebSocketOutputAdapter};
|
||||
use crate::command::context::CommandContext;
|
||||
@ -17,100 +17,18 @@ use crate::command::handlers::save_session::SaveSessionCommandHandler;
|
||||
use crate::command::handlers::session::SessionCommandHandler;
|
||||
use crate::command::handlers::switch_topic::SwitchTopicCommandHandler;
|
||||
use crate::gateway::agent_prompt_provider::AgentPromptProvider;
|
||||
use crate::protocol::{WsInbound, WsOutbound, MediaSummary, parse_inbound, serialize_outbound};
|
||||
use crate::protocol::{WsInbound, WsOutbound, parse_inbound, serialize_outbound};
|
||||
use crate::skills::SkillPromptProvider;
|
||||
use axum::extract::State;
|
||||
use axum::extract::ws::{Message as WsMessage, WebSocket, WebSocketUpgrade};
|
||||
use axum::response::Response;
|
||||
use base64::{Engine as _, engine::general_purpose::STANDARD};
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
const CLI_CHANNEL_NAME: &str = "cli";
|
||||
|
||||
/// Default media directory for WebSocket uploads
|
||||
fn default_ws_media_dir() -> PathBuf {
|
||||
let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
|
||||
home.join(".picobot").join("media").join("ws")
|
||||
}
|
||||
|
||||
/// Build a unique filename for media upload
|
||||
fn build_media_filename(media_type: &str, file_name: Option<&str>) -> String {
|
||||
if let Some(file_name) = file_name {
|
||||
let sanitized: String = file_name
|
||||
.chars()
|
||||
.map(|ch| match ch {
|
||||
'/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
|
||||
_ => ch,
|
||||
})
|
||||
.collect();
|
||||
if !sanitized.trim().is_empty() {
|
||||
return format!("{}_{}", uuid::Uuid::new_v4(), sanitized);
|
||||
}
|
||||
}
|
||||
format!("{}_{}", media_type, uuid::Uuid::new_v4())
|
||||
}
|
||||
|
||||
/// Process attachments with base64 content: save to local file and return MediaItem with correct path
|
||||
/// Keeps content_base64 for frontend display/download
|
||||
fn process_attachments_with_base64(attachments: Vec<MediaSummary>) -> Result<Vec<MediaItem>, AgentError> {
|
||||
if attachments.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let media_dir = default_ws_media_dir();
|
||||
std::fs::create_dir_all(&media_dir)
|
||||
.map_err(|error| AgentError::Other(format!("Failed to create media dir: {}", error)))?;
|
||||
|
||||
attachments
|
||||
.into_iter()
|
||||
.map(|att| {
|
||||
// If content_base64 exists, save to file and update path
|
||||
if let Some(base64_content) = &att.content_base64 {
|
||||
let decoded = STANDARD
|
||||
.decode(base64_content)
|
||||
.map_err(|error| AgentError::Other(format!("Failed to decode base64: {}", error)))?;
|
||||
|
||||
let filename = build_media_filename(&att.media_type, att.file_name.as_deref());
|
||||
let file_path = media_dir.join(&filename);
|
||||
|
||||
std::fs::write(&file_path, decoded)
|
||||
.map_err(|error| AgentError::Other(format!("Failed to write media file: {}", error)))?;
|
||||
|
||||
tracing::info!(
|
||||
filename = %filename,
|
||||
media_type = %att.media_type,
|
||||
file_path = %file_path.to_string_lossy(),
|
||||
"Saved WebSocket media to local file"
|
||||
);
|
||||
|
||||
Ok(MediaItem {
|
||||
path: file_path.to_string_lossy().to_string(),
|
||||
media_type: att.media_type,
|
||||
mime_type: att.mime_type,
|
||||
original_key: None,
|
||||
// Keep content_base64 for frontend display/download
|
||||
content_base64: att.content_base64,
|
||||
file_name: att.file_name,
|
||||
})
|
||||
} else {
|
||||
// No base64 content, keep original path (should already be valid)
|
||||
Ok(MediaItem {
|
||||
path: att.path,
|
||||
media_type: att.media_type,
|
||||
mime_type: att.mime_type,
|
||||
original_key: None,
|
||||
content_base64: None,
|
||||
file_name: att.file_name,
|
||||
})
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub async fn ws_handler(ws: WebSocketUpgrade, State(state): State<Arc<GatewayState>>) -> Response {
|
||||
ws.on_upgrade(|socket| async {
|
||||
handle_socket(socket, state).await;
|
||||
@ -278,7 +196,6 @@ async fn handle_inbound(
|
||||
match inbound {
|
||||
WsInbound::Message {
|
||||
content,
|
||||
attachments,
|
||||
chat_id,
|
||||
sender_id,
|
||||
..
|
||||
@ -296,9 +213,6 @@ async fn handle_inbound(
|
||||
)
|
||||
.await;
|
||||
|
||||
// Process attachments: save base64 content to local files and build MediaItems with correct paths
|
||||
let media = process_attachments_with_base64(attachments)?;
|
||||
|
||||
state
|
||||
.bus
|
||||
.publish_inbound(InboundMessage {
|
||||
@ -307,7 +221,7 @@ async fn handle_inbound(
|
||||
chat_id,
|
||||
content,
|
||||
timestamp: current_timestamp(),
|
||||
media,
|
||||
media: Vec::new(),
|
||||
metadata: HashMap::new(),
|
||||
forwarded_metadata: HashMap::new(),
|
||||
})
|
||||
@ -576,57 +490,6 @@ async fn send_task_messages(
|
||||
fn chat_message_to_ws_outbound(msg: &crate::bus::ChatMessage) -> Option<WsOutbound> {
|
||||
use crate::bus::message::ToolMessageState;
|
||||
|
||||
// Helper function to strip media_refs_json from content
|
||||
fn strip_media_refs_json(content: &str) -> String {
|
||||
// Remove the media_refs_json suffix if present
|
||||
if let Some(pos) = content.find("\n\nmedia_refs_json:") {
|
||||
content[..pos].to_string()
|
||||
} else {
|
||||
content.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
// Build attachments from media_refs, reading file content for base64
|
||||
let attachments: Vec<MediaSummary> = msg
|
||||
.media_refs
|
||||
.iter()
|
||||
.filter_map(|path| {
|
||||
// Try to read file and encode as base64
|
||||
let file_content = std::fs::read(path).ok()?;
|
||||
let base64_content = STANDARD.encode(&file_content);
|
||||
|
||||
// Guess mime type from path
|
||||
let mime_type = mime_guess::from_path(path)
|
||||
.first_raw()
|
||||
.map(ToOwned::to_owned);
|
||||
|
||||
// Determine media type from mime type
|
||||
let media_type = mime_type
|
||||
.as_ref()
|
||||
.map(|m| {
|
||||
if m.starts_with("image/") { "image" }
|
||||
else if m.starts_with("audio/") { "audio" }
|
||||
else if m.starts_with("video/") { "video" }
|
||||
else { "file" }
|
||||
})
|
||||
.unwrap_or("file");
|
||||
|
||||
// Get file name from path
|
||||
let file_name = std::path::Path::new(path)
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.map(ToOwned::to_owned);
|
||||
|
||||
Some(MediaSummary {
|
||||
path: path.clone(),
|
||||
media_type: media_type.to_string(),
|
||||
mime_type,
|
||||
content_base64: Some(base64_content),
|
||||
file_name,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
match msg.role.as_str() {
|
||||
"assistant" => {
|
||||
if let Some(tool_calls) = &msg.tool_calls {
|
||||
@ -676,9 +539,9 @@ fn chat_message_to_ws_outbound(msg: &crate::bus::ChatMessage) -> Option<WsOutbou
|
||||
}
|
||||
"user" => Some(WsOutbound::AssistantResponse {
|
||||
id: msg.id.clone(),
|
||||
content: strip_media_refs_json(&msg.content),
|
||||
content: msg.content.clone(),
|
||||
role: msg.role.clone(),
|
||||
attachments,
|
||||
attachments: Vec::new(),
|
||||
subagent_task_id: None,
|
||||
}),
|
||||
_ => None,
|
||||
@ -687,9 +550,7 @@ fn chat_message_to_ws_outbound(msg: &crate::bus::ChatMessage) -> Option<WsOutbou
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{resolve_ws_sender_id, build_media_filename, process_attachments_with_base64};
|
||||
use crate::protocol::MediaSummary;
|
||||
use base64::{Engine as _, engine::general_purpose::STANDARD};
|
||||
use super::resolve_ws_sender_id;
|
||||
|
||||
#[test]
|
||||
fn test_resolve_ws_sender_id_prefers_inbound_sender() {
|
||||
@ -708,72 +569,4 @@ mod tests {
|
||||
assert_eq!(resolve_ws_sender_id(None, "runtime-1"), "runtime-1");
|
||||
assert_eq!(resolve_ws_sender_id(Some(" "), "runtime-1"), "runtime-1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_media_filename_preserves_original_name() {
|
||||
let filename = build_media_filename("image", Some("photo.png"));
|
||||
assert!(filename.ends_with("_photo.png"));
|
||||
// UUID is 36 chars, plus underscore and original name
|
||||
assert!(filename.len() >= 36 + 1 + "photo.png".len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_media_filename_generates_default_when_no_name() {
|
||||
let filename = build_media_filename("image", None);
|
||||
assert!(filename.starts_with("image_"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_process_attachments_with_base64_saves_to_file() {
|
||||
let test_content = "test image content";
|
||||
let base64_content = STANDARD.encode(test_content.as_bytes());
|
||||
|
||||
let attachments = vec![MediaSummary {
|
||||
path: "test_image.png".to_string(),
|
||||
media_type: "image".to_string(),
|
||||
mime_type: Some("image/png".to_string()),
|
||||
content_base64: Some(base64_content.clone()),
|
||||
file_name: Some("test_image.png".to_string()),
|
||||
}];
|
||||
|
||||
let result = process_attachments_with_base64(attachments).unwrap();
|
||||
|
||||
// Verify path is now a full path
|
||||
assert!(result[0].path.contains(".picobot"));
|
||||
assert!(result[0].path.contains("media"));
|
||||
assert!(result[0].path.contains("ws"));
|
||||
|
||||
// Verify content_base64 is kept for frontend display
|
||||
assert!(result[0].content_base64.is_some());
|
||||
assert_eq!(result[0].content_base64.as_ref().unwrap(), &base64_content);
|
||||
|
||||
// Verify file was actually written
|
||||
let file_content = std::fs::read(&result[0].path).unwrap();
|
||||
assert_eq!(file_content, test_content.as_bytes());
|
||||
|
||||
// Cleanup
|
||||
std::fs::remove_file(&result[0].path).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_process_attachments_without_base64_keeps_original_path() {
|
||||
let attachments = vec![MediaSummary {
|
||||
path: "/existing/path/image.jpg".to_string(),
|
||||
media_type: "image".to_string(),
|
||||
mime_type: Some("image/jpeg".to_string()),
|
||||
content_base64: None,
|
||||
file_name: Some("image.jpg".to_string()),
|
||||
}];
|
||||
|
||||
let result = process_attachments_with_base64(attachments).unwrap();
|
||||
|
||||
// Path should remain unchanged
|
||||
assert_eq!(result[0].path, "/existing/path/image.jpg");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_process_empty_attachments_returns_empty_vec() {
|
||||
let result = process_attachments_with_base64(Vec::new()).unwrap();
|
||||
assert!(result.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
@ -55,8 +55,6 @@ pub enum WsInbound {
|
||||
#[serde(rename = "message")]
|
||||
Message {
|
||||
content: String,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
attachments: Vec<MediaSummary>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
channel: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
|
||||
@ -16,9 +16,8 @@ pub use ports::{
|
||||
SkillEventRepository,
|
||||
};
|
||||
pub use records::{
|
||||
allowed_namespace_names, get_namespace_description, is_valid_namespace,
|
||||
ALLOWED_MEMORY_NAMESPACES, MemoryRecord, MemoryUpsert, SchedulerJobRecord, SchedulerJobState,
|
||||
SchedulerJobStatus, SchedulerJobUpsert, SessionRecord, SkillEventRecord, TopicRecord,
|
||||
MemoryRecord, MemoryUpsert, SchedulerJobRecord, SchedulerJobState, SchedulerJobStatus,
|
||||
SchedulerJobUpsert, SessionRecord, SkillEventRecord, TopicRecord,
|
||||
};
|
||||
|
||||
#[derive(Clone)]
|
||||
@ -2285,7 +2284,7 @@ mod tests {
|
||||
.put_memory(&MemoryUpsert {
|
||||
scope_kind: "user".to_string(),
|
||||
scope_key: format!("{}:user-1", TEST_CHANNEL),
|
||||
namespace: "user".to_string(),
|
||||
namespace: "profile".to_string(),
|
||||
memory_key: "language".to_string(),
|
||||
content: "Rust".to_string(),
|
||||
source_type: "message".to_string(),
|
||||
@ -2304,7 +2303,7 @@ mod tests {
|
||||
assert_eq!(saved.source_message_seq, Some(7));
|
||||
|
||||
let fetched = store
|
||||
.get_memory("user", "test-channel:user-1", "user", "language")
|
||||
.get_memory("user", "test-channel:user-1", "profile", "language")
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(fetched.id, saved.id);
|
||||
@ -2319,7 +2318,7 @@ mod tests {
|
||||
.put_memory(&MemoryUpsert {
|
||||
scope_kind: "user".to_string(),
|
||||
scope_key: format!("{}:user-1", TEST_CHANNEL),
|
||||
namespace: "user".to_string(),
|
||||
namespace: "preferences".to_string(),
|
||||
memory_key: "editor".to_string(),
|
||||
content: "Prefers rust-analyzer and cargo test output".to_string(),
|
||||
source_type: "message".to_string(),
|
||||
@ -2341,7 +2340,7 @@ mod tests {
|
||||
.put_memory(&MemoryUpsert {
|
||||
scope_kind: "user".to_string(),
|
||||
scope_key: format!("{}:user-1", TEST_CHANNEL),
|
||||
namespace: "user".to_string(),
|
||||
namespace: "preferences".to_string(),
|
||||
memory_key: "editor".to_string(),
|
||||
content: "Prefers clippy diagnostics".to_string(),
|
||||
source_type: "message".to_string(),
|
||||
@ -2364,7 +2363,7 @@ mod tests {
|
||||
assert_eq!(new_hits.len(), 1);
|
||||
|
||||
let deleted = store
|
||||
.delete_memory("user", "test-channel:user-1", "user", "editor")
|
||||
.delete_memory("user", "test-channel:user-1", "preferences", "editor")
|
||||
.unwrap();
|
||||
assert!(deleted);
|
||||
|
||||
@ -2382,7 +2381,7 @@ mod tests {
|
||||
.put_memory(&MemoryUpsert {
|
||||
scope_kind: "user".to_string(),
|
||||
scope_key: format!("{}:user-1", TEST_CHANNEL),
|
||||
namespace: "user".to_string(),
|
||||
namespace: "preferences".to_string(),
|
||||
memory_key: "email_folder_preference".to_string(),
|
||||
content: "用户提到邮件时默认查看代收邮箱。".to_string(),
|
||||
source_type: "message".to_string(),
|
||||
@ -2410,7 +2409,7 @@ mod tests {
|
||||
.put_memory(&MemoryUpsert {
|
||||
scope_kind: "user".to_string(),
|
||||
scope_key: format!("{}:user-1", TEST_CHANNEL),
|
||||
namespace: "user".to_string(),
|
||||
namespace: "preferences".to_string(),
|
||||
memory_key: "editor".to_string(),
|
||||
content: "Prefers rust-analyzer and cargo test output".to_string(),
|
||||
source_type: "message".to_string(),
|
||||
@ -2426,7 +2425,7 @@ mod tests {
|
||||
.put_memory(&MemoryUpsert {
|
||||
scope_kind: "user".to_string(),
|
||||
scope_key: format!("{}:user-1", TEST_CHANNEL),
|
||||
namespace: "episodic".to_string(),
|
||||
namespace: "tasks".to_string(),
|
||||
memory_key: "quality".to_string(),
|
||||
content: "Tracks clippy warnings before release".to_string(),
|
||||
source_type: "message".to_string(),
|
||||
@ -2461,7 +2460,7 @@ mod tests {
|
||||
.put_memory(&MemoryUpsert {
|
||||
scope_kind: "user".to_string(),
|
||||
scope_key: format!("{}:user-2", TEST_CHANNEL),
|
||||
namespace: "user".to_string(),
|
||||
namespace: "preferences".to_string(),
|
||||
memory_key: "style".to_string(),
|
||||
content: "偏好简洁表达".to_string(),
|
||||
source_type: "message".to_string(),
|
||||
@ -2476,7 +2475,7 @@ mod tests {
|
||||
.put_memory(&MemoryUpsert {
|
||||
scope_kind: "user".to_string(),
|
||||
scope_key: format!("{}:user-1", TEST_CHANNEL),
|
||||
namespace: "user".to_string(),
|
||||
namespace: "profile".to_string(),
|
||||
memory_key: "work".to_string(),
|
||||
content: "用户在做AI产品".to_string(),
|
||||
source_type: "message".to_string(),
|
||||
|
||||
@ -1,37 +1,5 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// 允许的记忆命名空间列表
|
||||
///
|
||||
/// 每个命名空间代表一类记忆内容,用于分类管理和检索。
|
||||
/// 禁止使用未在此列表中的 namespace 创建记忆。
|
||||
pub const ALLOWED_MEMORY_NAMESPACES: &[(&str, &str)] = &[
|
||||
("user", "用户记忆:存储用户长期偏好、身份背景和历史协作信息,实现跨会话的个性化服务与持续协作"),
|
||||
("semantic", "语义记忆:存储结构化或非结构化知识内容,支持知识检索、问答增强和长期知识积累"),
|
||||
("episodic", "情景记忆:记录历史对话、任务执行过程及关键事件,支持经验回溯、案例复用和行为追踪"),
|
||||
("skill", "技能记忆:存储技能定义、工作流、工具调用策略及最佳实践,支持能力复用与自动化执行"),
|
||||
("environment", "环境记忆:存储外部系统状态、运行环境配置和实时资源信息,为智能决策提供环境感知能力"),
|
||||
("reflection", "反思记忆:沉淀任务执行过程中的成功经验、失败原因和优化建议,支持智能体持续学习与自我改进"),
|
||||
("other", "其他记忆:不属于以上分类的其他记忆内容"),
|
||||
];
|
||||
|
||||
/// 验证 namespace 是否在允许列表中
|
||||
pub fn is_valid_namespace(namespace: &str) -> bool {
|
||||
ALLOWED_MEMORY_NAMESPACES.iter().any(|(name, _)| *name == namespace)
|
||||
}
|
||||
|
||||
/// 获取 namespace 的中文描述
|
||||
pub fn get_namespace_description(namespace: &str) -> Option<&'static str> {
|
||||
ALLOWED_MEMORY_NAMESPACES
|
||||
.iter()
|
||||
.find(|(name, _)| *name == namespace)
|
||||
.map(|(_, desc)| *desc)
|
||||
}
|
||||
|
||||
/// 获取所有允许的 namespace 名称列表(用于 JSON schema enum)
|
||||
pub fn allowed_namespace_names() -> Vec<&'static str> {
|
||||
ALLOWED_MEMORY_NAMESPACES.iter().map(|(name, _)| *name).collect()
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SkillEventRecord {
|
||||
pub id: String,
|
||||
|
||||
@ -3,7 +3,7 @@ use std::sync::Arc;
|
||||
use async_trait::async_trait;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::storage::{is_valid_namespace, MemoryRecord, MemoryRepository, MemoryUpsert};
|
||||
use crate::storage::{MemoryRecord, MemoryRepository, MemoryUpsert};
|
||||
use crate::tools::traits::{Tool, ToolContext, ToolResult};
|
||||
|
||||
pub struct MemoryManageTool {
|
||||
@ -27,27 +27,25 @@ impl Tool for MemoryManageTool {
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> serde_json::Value {
|
||||
let namespaces = crate::storage::allowed_namespace_names();
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"action": {
|
||||
"type": "string",
|
||||
"enum": ["put", "update", "delete"],
|
||||
"description": "管理操作。put 用于创建或覆盖,update 用于修改已有记录,delete 用于删除。检索请使用 memory_search。"
|
||||
"description": "Management action to perform. Use 'put' to create or overwrite, 'update' to modify an existing record, and 'delete' to remove one. Use memory_search for retrieval."
|
||||
},
|
||||
"namespace": {
|
||||
"type": "string",
|
||||
"enum": namespaces,
|
||||
"description": "记忆命名空间分类"
|
||||
"description": "Optional memory namespace filter, such as profile, preferences, or tasks"
|
||||
},
|
||||
"key": {
|
||||
"type": "string",
|
||||
"description": "命名空间内的记忆键名"
|
||||
"description": "Exact memory key within the namespace"
|
||||
},
|
||||
"content": {
|
||||
"type": "string",
|
||||
"description": "put/update 时的记忆内容"
|
||||
"description": "Memory content for put/update"
|
||||
}
|
||||
},
|
||||
"required": ["action"]
|
||||
@ -147,14 +145,6 @@ fn build_memory_upsert(
|
||||
Some(namespace) => namespace,
|
||||
None => return Err(error_result("Missing required parameter: namespace")),
|
||||
};
|
||||
// 验证 namespace 是否在允许列表中
|
||||
if !is_valid_namespace(namespace) {
|
||||
let allowed = crate::storage::allowed_namespace_names().join(", ");
|
||||
return Err(error_result(&format!(
|
||||
"Invalid namespace '{}'. Allowed namespaces: {}",
|
||||
namespace, allowed
|
||||
)));
|
||||
}
|
||||
let key = match args.get("key").and_then(|value| value.as_str()) {
|
||||
Some(key) => key,
|
||||
None => return Err(error_result("Missing required parameter: key")),
|
||||
@ -247,7 +237,7 @@ mod tests {
|
||||
&context,
|
||||
json!({
|
||||
"action": "put",
|
||||
"namespace": "user",
|
||||
"namespace": "profile",
|
||||
"key": "language",
|
||||
"content": "Rust"
|
||||
}),
|
||||
@ -292,7 +282,7 @@ mod tests {
|
||||
&context,
|
||||
json!({
|
||||
"action": "get",
|
||||
"namespace": "user",
|
||||
"namespace": "profile",
|
||||
"key": "language"
|
||||
}),
|
||||
)
|
||||
|
||||
@ -28,35 +28,33 @@ impl Tool for MemorySearchTool {
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> serde_json::Value {
|
||||
let namespaces = crate::storage::allowed_namespace_names();
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"action": {
|
||||
"type": "string",
|
||||
"enum": ["search", "get", "list"],
|
||||
"description": "检索操作。search 用于多关键词召回,get 用于精确 namespace/key 读取,list 用于浏览最近记忆。"
|
||||
"description": "Retrieval action. Use 'search' for multi-keyword recall, 'get' for an exact namespace/key read, and 'list' to browse recent memories."
|
||||
},
|
||||
"namespace": {
|
||||
"type": "string",
|
||||
"enum": namespaces,
|
||||
"description": "可选的命名空间过滤。get 操作时必填。"
|
||||
"description": "Optional namespace filter, such as profile, preferences, tasks, or decisions. Required for get."
|
||||
},
|
||||
"queries": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "搜索关键词数组。建议提供多个简洁的双语关键词、英文别名和可能的 snake_case memory_key。search 操作时必填。",
|
||||
"description": "Keyword queries for memory search. Provide multiple concise bilingual keywords, English aliases, and likely snake_case memory_key terms when known. Search matches any of the provided entries. Required for search.",
|
||||
"minItems": 1
|
||||
},
|
||||
"key": {
|
||||
"type": "string",
|
||||
"description": "命名空间内的记忆键名。get 操作时必填。"
|
||||
"description": "Exact memory key within the namespace. Required for get."
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"description": "返回记忆的最大数量",
|
||||
"description": "Maximum number of memories to return",
|
||||
"minimum": 1,
|
||||
"default": 10
|
||||
}
|
||||
@ -235,7 +233,7 @@ mod tests {
|
||||
.put_memory(&crate::storage::MemoryUpsert {
|
||||
scope_kind: "user".to_string(),
|
||||
scope_key: TEST_CHANNEL.to_string(),
|
||||
namespace: "user".to_string(),
|
||||
namespace: "preferences".to_string(),
|
||||
memory_key: "language".to_string(),
|
||||
content: "User prefers Chinese responses".to_string(),
|
||||
source_type: "message".to_string(),
|
||||
@ -276,7 +274,7 @@ mod tests {
|
||||
&context,
|
||||
json!({
|
||||
"action": "get",
|
||||
"namespace": "user",
|
||||
"namespace": "preferences",
|
||||
"key": "language"
|
||||
}),
|
||||
)
|
||||
|
||||
@ -70,7 +70,6 @@ fn test_command_inbound_serialization() {
|
||||
fn test_message_inbound_serialization() {
|
||||
let msg = WsInbound::Message {
|
||||
content: "Hello world".to_string(),
|
||||
attachments: Vec::new(),
|
||||
channel: None,
|
||||
chat_id: Some("session-1".to_string()),
|
||||
sender_id: Some("user-1".to_string()),
|
||||
|
||||
@ -7,7 +7,7 @@ import { ToolPanel } from './components/Panel/ToolPanel'
|
||||
import { ConnectionStatus } from './components/ConnectionStatus'
|
||||
import { useWebSocket } from './hooks/useWebSocket'
|
||||
import { useChat } from './hooks/useChat'
|
||||
import type { ChatMessage, Command, Attachment } from './types/protocol'
|
||||
import type { ChatMessage, Command } from './types/protocol'
|
||||
|
||||
const WS_URL = 'ws://127.0.0.1:19876/ws'
|
||||
|
||||
@ -89,7 +89,7 @@ function App() {
|
||||
}, [topics, status, selectTopic, switchTopic, handleCommand, sendMessage]);
|
||||
|
||||
const handleSendMessage = useCallback(
|
||||
(content: string, attachments: Attachment[] = []) => {
|
||||
(content: string) => {
|
||||
if (isReadOnly || !sessionId) {
|
||||
return
|
||||
}
|
||||
@ -126,11 +126,10 @@ function App() {
|
||||
handleCommand(cmd)
|
||||
sendMessage({ type: 'command', payload: JSON.stringify(cmd) })
|
||||
} else {
|
||||
handleMessage(content, attachments)
|
||||
handleMessage(content)
|
||||
sendMessage({
|
||||
type: 'message',
|
||||
content,
|
||||
attachments,
|
||||
chat_id: chatId,
|
||||
})
|
||||
}
|
||||
|
||||
@ -1,13 +1,13 @@
|
||||
import { MessageList } from './MessageList'
|
||||
import { MessageInput } from './MessageInput'
|
||||
import type { ChatMessage, Attachment } from '../../types/protocol'
|
||||
import type { ChatMessage } from '../../types/protocol'
|
||||
|
||||
interface ChatContainerProps {
|
||||
messages: ChatMessage[]
|
||||
isLoading: boolean
|
||||
isReadOnly?: boolean
|
||||
channelName?: string
|
||||
onSendMessage: (content: string, attachments: Attachment[]) => void
|
||||
onSendMessage: (content: string) => void
|
||||
onNavigateToSubAgent?: (taskId: string, description: string) => void
|
||||
}
|
||||
|
||||
|
||||
@ -1,11 +1,8 @@
|
||||
import { Send, Loader2, Sparkles, Eye, Paperclip, X, FileIcon, ImageIcon, MusicIcon, VideoIcon } from 'lucide-react'
|
||||
import { Send, Loader2, Sparkles, Eye } from 'lucide-react'
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import type { Attachment } from '../../types/protocol'
|
||||
|
||||
const MAX_FILE_SIZE = 50 * 1024 * 1024 // 50MB
|
||||
|
||||
interface MessageInputProps {
|
||||
onSend: (content: string, attachments: Attachment[]) => void
|
||||
onSend: (content: string) => void
|
||||
disabled?: boolean
|
||||
isLoading?: boolean
|
||||
placeholder?: string
|
||||
@ -13,20 +10,6 @@ interface MessageInputProps {
|
||||
channelName?: string
|
||||
}
|
||||
|
||||
interface FileAttachment {
|
||||
file: File
|
||||
attachment: Attachment
|
||||
preview?: string // 用于图片预览
|
||||
}
|
||||
|
||||
// 根据 MIME 类型判断 media_type
|
||||
function getMediaType(mimeType: string): string {
|
||||
if (mimeType.startsWith('image/')) return 'image'
|
||||
if (mimeType.startsWith('audio/')) return 'audio'
|
||||
if (mimeType.startsWith('video/')) return 'video'
|
||||
return 'file'
|
||||
}
|
||||
|
||||
export function MessageInput({
|
||||
onSend,
|
||||
disabled = false,
|
||||
@ -36,11 +19,7 @@ export function MessageInput({
|
||||
channelName,
|
||||
}: MessageInputProps) {
|
||||
const [content, setContent] = useState('')
|
||||
const [attachments, setAttachments] = useState<FileAttachment[]>([])
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const wasLoadingRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
@ -59,166 +38,10 @@ export function MessageInput({
|
||||
wasLoadingRef.current = isLoading
|
||||
}, [isLoading, isReadOnly])
|
||||
|
||||
// 处理文件选择
|
||||
const handleFileSelect = async (files: FileList | null) => {
|
||||
if (!files) return
|
||||
setError(null)
|
||||
|
||||
const newAttachments: FileAttachment[] = []
|
||||
for (const file of Array.from(files)) {
|
||||
// 检查文件大小
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
setError(`文件 "${file.name}" 超过 50MB 限制`)
|
||||
continue
|
||||
}
|
||||
|
||||
// 读取文件为 base64
|
||||
const base64 = await readFileAsBase64(file)
|
||||
const mimeType = file.type || 'application/octet-stream'
|
||||
const mediaType = getMediaType(mimeType)
|
||||
|
||||
const attachment: Attachment = {
|
||||
path: file.name,
|
||||
media_type: mediaType,
|
||||
mime_type: mimeType,
|
||||
content_base64: base64,
|
||||
file_name: file.name,
|
||||
}
|
||||
|
||||
const fileAttachment: FileAttachment = {
|
||||
file,
|
||||
attachment,
|
||||
preview: mediaType === 'image' ? base64 : undefined,
|
||||
}
|
||||
|
||||
newAttachments.push(fileAttachment)
|
||||
}
|
||||
|
||||
setAttachments(prev => [...prev, ...newAttachments])
|
||||
}
|
||||
|
||||
// 读取文件为 base64
|
||||
const readFileAsBase64 = (file: File): Promise<string> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => {
|
||||
const result = reader.result as string
|
||||
// 移除 data:xxx;base64, 前缀
|
||||
const base64 = result.split(',')[1]
|
||||
resolve(base64)
|
||||
}
|
||||
reader.onerror = reject
|
||||
reader.readAsDataURL(file)
|
||||
})
|
||||
}
|
||||
|
||||
// 点击附件按钮
|
||||
const handleAttachClick = () => {
|
||||
fileInputRef.current?.click()
|
||||
}
|
||||
|
||||
// 删除附件
|
||||
const handleRemoveAttachment = (index: number) => {
|
||||
setAttachments(prev => prev.filter((_, i) => i !== index))
|
||||
}
|
||||
|
||||
// 粘贴事件处理
|
||||
const handlePaste = async (e: React.ClipboardEvent) => {
|
||||
if (disabled || isReadOnly) return
|
||||
|
||||
const clipboardData = e.clipboardData
|
||||
const items = clipboardData.items
|
||||
|
||||
// 检查是否有文件(图片或其他文件)
|
||||
const files: File[] = []
|
||||
for (const item of Array.from(items)) {
|
||||
if (item.kind === 'file') {
|
||||
const file = item.getAsFile()
|
||||
if (file) {
|
||||
files.push(file)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 如果有文件,处理文件并阻止默认粘贴行为
|
||||
if (files.length > 0) {
|
||||
e.preventDefault()
|
||||
// 直接处理文件数组
|
||||
setError(null)
|
||||
for (const file of files) {
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
setError(`文件 "${file.name}" 超过 50MB 限制`)
|
||||
continue
|
||||
}
|
||||
|
||||
const base64 = await readFileAsBase64(file)
|
||||
const mimeType = file.type || 'application/octet-stream'
|
||||
const mediaType = getMediaType(mimeType)
|
||||
|
||||
const attachment: Attachment = {
|
||||
path: file.name,
|
||||
media_type: mediaType,
|
||||
mime_type: mimeType,
|
||||
content_base64: base64,
|
||||
file_name: file.name,
|
||||
}
|
||||
|
||||
const fileAttachment: FileAttachment = {
|
||||
file,
|
||||
attachment,
|
||||
preview: mediaType === 'image' ? base64 : undefined,
|
||||
}
|
||||
|
||||
setAttachments(prev => [...prev, fileAttachment])
|
||||
}
|
||||
}
|
||||
// 否则让默认的文本粘贴行为继续
|
||||
}
|
||||
|
||||
// 拖拽事件
|
||||
const handleDragEnter = (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (!disabled && !isReadOnly) {
|
||||
setIsDragging(true)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDragLeave = (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
// 检查是否真的离开了拖拽区域(而不是进入子元素)
|
||||
const relatedTarget = e.relatedTarget as Node | null
|
||||
const currentTarget = e.currentTarget
|
||||
if (!relatedTarget || !currentTarget.contains(relatedTarget)) {
|
||||
setIsDragging(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
}
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setIsDragging(false)
|
||||
if (!disabled && !isReadOnly) {
|
||||
handleFileSelect(e.dataTransfer.files)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSend = () => {
|
||||
const hasContent = content.trim() || attachments.length > 0
|
||||
if (hasContent && !disabled && !isReadOnly) {
|
||||
onSend(
|
||||
content.trim(),
|
||||
attachments.map(a => a.attachment)
|
||||
)
|
||||
if (content.trim() && !disabled && !isReadOnly) {
|
||||
onSend(content.trim())
|
||||
setContent('')
|
||||
setAttachments([])
|
||||
setError(null)
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = 'auto'
|
||||
}
|
||||
@ -232,20 +55,6 @@ export function MessageInput({
|
||||
}
|
||||
}
|
||||
|
||||
// 获取附件图标
|
||||
const getAttachmentIcon = (mediaType: string) => {
|
||||
switch (mediaType) {
|
||||
case 'image':
|
||||
return <ImageIcon className="h-4 w-4" />
|
||||
case 'audio':
|
||||
return <MusicIcon className="h-4 w-4" />
|
||||
case 'video':
|
||||
return <VideoIcon className="h-4 w-4" />
|
||||
default:
|
||||
return <FileIcon className="h-4 w-4" />
|
||||
}
|
||||
}
|
||||
|
||||
// 只读模式:显示提示占位符
|
||||
if (isReadOnly) {
|
||||
return (
|
||||
@ -276,114 +85,34 @@ export function MessageInput({
|
||||
|
||||
return (
|
||||
<div className="border-t border-white/8 bg-[#12121a]/80 backdrop-blur-md p-4">
|
||||
<div className="max-w-5xl mx-auto">
|
||||
{/* 错误提示 */}
|
||||
{error && (
|
||||
<div className="mb-2 rounded-lg bg-red-500/10 border border-red-500/20 px-3 py-2 text-sm text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 附件预览列表 */}
|
||||
{attachments.length > 0 && (
|
||||
<div className="mb-2 flex flex-wrap gap-2">
|
||||
{attachments.map((att, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center gap-2 rounded-lg border border-white/10 bg-[#1a1a25] px-2 py-1.5 text-sm"
|
||||
>
|
||||
{att.preview ? (
|
||||
<img
|
||||
src={`data:${att.attachment.mime_type};base64,${att.preview}`}
|
||||
alt={att.attachment.file_name}
|
||||
className="h-8 w-8 rounded object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="h-8 w-8 rounded bg-zinc-700/50 flex items-center justify-center text-zinc-400">
|
||||
{getAttachmentIcon(att.attachment.media_type)}
|
||||
</div>
|
||||
)}
|
||||
<span className="text-zinc-300 max-w-[120px] truncate">
|
||||
{att.attachment.file_name}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => handleRemoveAttachment(index)}
|
||||
className="text-zinc-500 hover:text-red-400 transition-colors"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 输入区域 */}
|
||||
<div
|
||||
className={`flex gap-3 items-center relative ${isDragging ? 'ring-2 ring-[#00f0ff]/50 rounded-xl' : ''}`}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
{/* 拖拽提示 */}
|
||||
{isDragging && (
|
||||
<div className="absolute inset-0 rounded-xl bg-[#00f0ff]/10 border-2 border-[#00f0ff]/50 flex items-center justify-center z-10">
|
||||
<div className="text-[#00f0ff] text-sm font-medium">
|
||||
拖放文件到这里
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 附件按钮 */}
|
||||
<button
|
||||
onClick={handleAttachClick}
|
||||
<div className="flex gap-3 items-center max-w-5xl mx-auto">
|
||||
<div className="flex-1 relative flex items-center">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border border-white/10 bg-[#1a1a25] text-zinc-400 hover:text-white hover:border-white/20 disabled:opacity-50 transition-all"
|
||||
>
|
||||
<Paperclip className="h-5 w-5" />
|
||||
</button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(e) => handleFileSelect(e.target.files)}
|
||||
rows={1}
|
||||
className="w-full resize-none rounded-xl border border-white/10 bg-[#1a1a25] px-4 py-3 pr-12 text-sm text-white placeholder:text-zinc-500 focus:border-[#00f0ff]/50 focus:outline-none focus:ring-1 focus:ring-[#00f0ff]/20 disabled:opacity-50 transition-all self-center scrollbar-hide"
|
||||
/>
|
||||
|
||||
{/* 输入框 */}
|
||||
<div className="flex-1 relative flex items-center">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPaste={handlePaste}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
rows={1}
|
||||
className="w-full resize-none rounded-xl border border-white/10 bg-[#1a1a25] px-4 py-3 pr-12 text-sm text-white placeholder:text-zinc-500 focus:border-[#00f0ff]/50 focus:outline-none focus:ring-1 focus:ring-[#00f0ff]/20 disabled:opacity-50 transition-all self-center scrollbar-hide"
|
||||
/>
|
||||
<Sparkles className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 text-zinc-600 pointer-events-none" />
|
||||
</div>
|
||||
|
||||
{/* 发送按钮 */}
|
||||
<button
|
||||
onClick={handleSend}
|
||||
disabled={disabled || (!content.trim() && attachments.length === 0)}
|
||||
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-gradient-to-r from-[#00f0ff] to-[#3b82f6] text-white shadow-lg shadow-[#00f0ff]/20 hover:shadow-xl hover:shadow-[#00f0ff]/30 hover:scale-105 disabled:opacity-50 disabled:hover:scale-100 disabled:cursor-not-allowed transition-all"
|
||||
>
|
||||
{disabled ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Send className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 提示 */}
|
||||
<div className="mt-2 text-center text-xs text-zinc-500">
|
||||
按 Enter 发送,Shift+Enter 换行 · 支持拖拽/粘贴文件 · 最大 50MB
|
||||
<Sparkles className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 text-zinc-600 pointer-events-none" />
|
||||
</div>
|
||||
<button
|
||||
onClick={handleSend}
|
||||
disabled={disabled || !content.trim()}
|
||||
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-gradient-to-r from-[#00f0ff] to-[#3b82f6] text-white shadow-lg shadow-[#00f0ff]/20 hover:shadow-xl hover:shadow-[#00f0ff]/30 hover:scale-105 disabled:opacity-50 disabled:hover:scale-100 disabled:cursor-not-allowed transition-all"
|
||||
>
|
||||
{disabled ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Send className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-2 text-center text-xs text-zinc-500">
|
||||
按 Enter 发送,Shift+Enter 换行
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -14,7 +14,6 @@ import type {
|
||||
TopicSummary,
|
||||
Session,
|
||||
TaskMessagesLoaded,
|
||||
Attachment,
|
||||
} from '../types/protocol'
|
||||
|
||||
// 简化后的层级状态
|
||||
@ -41,7 +40,7 @@ interface UseChatReturn {
|
||||
subAgentView: SubAgentView | null
|
||||
|
||||
// 方法
|
||||
handleMessage: (content: string, attachments?: Attachment[]) => void
|
||||
handleMessage: (content: string) => void
|
||||
handleCommand: (command: Command) => void
|
||||
clearMessages: () => void
|
||||
handleServerMessage: (message: WsOutbound) => void
|
||||
@ -389,7 +388,7 @@ export function useChat(): UseChatReturn {
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleMessage = useCallback((content: string, attachments?: Attachment[]) => {
|
||||
const handleMessage = useCallback((content: string) => {
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
@ -398,7 +397,6 @@ export function useChat(): UseChatReturn {
|
||||
content,
|
||||
timestamp: Date.now(),
|
||||
type: 'message',
|
||||
attachments: attachments || [],
|
||||
},
|
||||
])
|
||||
setIsLoading(true)
|
||||
|
||||
@ -7,7 +7,6 @@
|
||||
export interface WsInboundMessage {
|
||||
type: 'message'
|
||||
content: string
|
||||
attachments?: Attachment[]
|
||||
channel?: string
|
||||
chat_id?: string
|
||||
sender_id?: string
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user