Compare commits

..

3 Commits

Author SHA1 Message Date
1288ba268f feat: 限定记忆命名空间为7种分类
- 新增 ALLOWED_MEMORY_NAMESPACES 常量定义允许的命名空间
- 添加 namespace 验证函数 is_valid_namespace()
- memory_manage 工具 schema 使用 enum 限制 namespace
- memory_search 工具 schema 使用 enum 提示可用 namespace
- 更新系统提示词添加命名空间分类说明
- 更新记忆维护提示词添加命名空间分类说明
- 修复测试中使用旧 namespace 的问题

命名空间分类:
- user: 用户记忆
- semantic: 语义记忆
- episodic: 情景记忆
- skill: 技能记忆
- environment: 环境记忆
- reflection: 反思记忆
- other: 其他记忆
2026-05-30 13:06:55 +08:00
7d9355fd78 feat: WebSocket 媒体文件处理优化
- 后端 ws.rs: 处理前端上传的 base64 内容,保存到本地文件并更新路径
- 后端 ws.rs: 历史消息加载时从文件读取内容填充 base64,过滤 media_refs_json
- 前端 App.tsx: 传递 attachments 给 handleMessage 实现实时显示
- 前端 useChat.ts: handleMessage 支持 attachments 参数
- 前端 MessageInput.tsx: 支持剪贴板粘贴文件/图片
- 前端 MessageInput.tsx: 修复拖拽文件时闪烁问题
- 测试 test_request_format.rs: 补充缺失的 attachments 字段
2026-05-30 10:22:30 +08:00
c2293238fc feat: 前端支持文件附件输入
- 后端 WsInbound::Message 添加 attachments 字段
- ws.rs 将 attachments 转换为 MediaItem
- 前端 MessageInput 支持点击选择和拖拽文件
- 附件预览列表,支持删除
- 文件大小限制 50MB
- 支持所有文件类型
2026-05-30 08:07:02 +08:00
17 changed files with 642 additions and 89 deletions

View File

@ -209,6 +209,7 @@ 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,

View File

@ -38,10 +38,20 @@
### 记忆写入
#### 命名空间分类
记忆必须使用以下命名空间之一:
- `user` - 用户记忆:用户长期偏好、身份背景和历史协作信息
- `semantic` - 语义记忆:结构化或非结构化知识内容
- `episodic` - 情景记忆:历史对话、任务执行过程及关键事件
- `skill` - 技能记忆:技能定义、工作流、工具调用策略及最佳实践
- `environment` - 环境记忆:外部系统状态、运行环境配置和实时资源信息
- `reflection` - 反思记忆:成功经验、失败原因和优化建议
- `other` - 其他记忆:不属于以上分类的其他内容
#### 写入规则
- 写入或修改记忆时使用 memory_manage。
- 遇到未来仍有用的信息时写入记忆:用户长期偏好、稳定事实、用户对你的纠正、持续任务或项目上下文、明确决策等。
- 写入时优先使用规范 namespacepreferences、profile、tasks、decisions。
- 写入时必须使用允许的命名空间user、semantic、episodic、skill、environment、reflection、other
- 优先调用 memory_manage(action='put');同一 namespace/key 可直接覆盖更新。
#### 【重要注意!】以下场景视为高价值加分,必须记录记忆

View File

@ -208,6 +208,8 @@ 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();

View File

@ -19,7 +19,7 @@
- merges对象数组。每个对象必须包含 source_ids、namespace、memory_key、content。
- source_ids: 字符串数组要合并的源记忆ID列表
- namespace: 目标命名空间
- namespace: 目标命名空间必须是以下之一user、semantic、episodic、skill、environment、reflection、other
- memory_key: 目标记忆键(可以自由决定)
- content: 合并后的内容
- conflicts对象数组。每个对象必须包含 source_ids、note。
@ -27,6 +27,16 @@
- note: 冲突说明
- low_value_ids需要删除的低价值候选记忆 ID 数组
命名空间分类说明:
- `user` - 用户记忆:用户长期偏好、身份背景和历史协作信息
- `semantic` - 语义记忆:结构化或非结构化知识内容
- `episodic` - 情景记忆:历史对话、任务执行过程及关键事件
- `skill` - 技能记忆:技能定义、工作流、工具调用策略及最佳实践
- `environment` - 环境记忆:外部系统状态、运行环境配置和实时资源信息
- `reflection` - 反思记忆:成功经验、失败原因和优化建议
- `other` - 其他记忆:不属于以上分类的其他内容
组织原则:
- 根据记忆的语义内容自然分组

View File

@ -1192,7 +1192,7 @@ mod tests {
.put_memory(&crate::storage::MemoryUpsert {
scope_kind: "user".to_string(),
scope_key: "feishu:user-1".to_string(),
namespace: "profile".to_string(),
namespace: "user".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: "profile".to_string(),
namespace: "user".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: "profile".to_string(),
namespace: "user".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: "profile".to_string(),
namespace: "user".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: "profile".to_string(),
namespace: "user".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: "profile".to_string(),
namespace: "user".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: "profile".to_string(),
namespace: "user".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: "profile".to_string(),
namespace: "user".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: "notes".to_string(),
namespace: "other".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: "profile".to_string(),
namespace: "user".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: "profile".to_string(),
namespace: "user".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 = 6 条(加上 _meta 记录)
assert_eq!(user_memories.len(), 6);
// 合并 2 条为 1 条,删除 1 条7 - 2 + 1 = 5 条
assert_eq!(user_memories.len(), 5);
// 验证合并后的记忆存在
assert!(user_memories.iter().any(|m| m.namespace == "profile" && m.memory_key == "work"));
assert!(user_memories.iter().any(|m| m.namespace == "user" && 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: "profile".to_string(),
namespace: "user".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: "profile".to_string(),
namespace: "user".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: "preferences".to_string(),
namespace: "user".to_string(),
memory_key: "style".to_string(),
content: "偏好简洁表达".to_string(),
source_type: "message".to_string(),

View File

@ -1,6 +1,6 @@
use super::GatewayState;
use crate::agent::{AgentError, CompositeSystemPromptProvider};
use crate::bus::InboundMessage;
use crate::bus::{InboundMessage, MediaItem};
use crate::command::adapter::{InputAdapter, OutputAdapter};
use crate::command::adapters::websocket::{WebSocketInputAdapter, WebSocketOutputAdapter};
use crate::command::context::CommandContext;
@ -17,18 +17,100 @@ 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, parse_inbound, serialize_outbound};
use crate::protocol::{WsInbound, WsOutbound, MediaSummary, 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;
@ -196,6 +278,7 @@ async fn handle_inbound(
match inbound {
WsInbound::Message {
content,
attachments,
chat_id,
sender_id,
..
@ -213,6 +296,9 @@ 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 {
@ -221,7 +307,7 @@ async fn handle_inbound(
chat_id,
content,
timestamp: current_timestamp(),
media: Vec::new(),
media,
metadata: HashMap::new(),
forwarded_metadata: HashMap::new(),
})
@ -490,6 +576,57 @@ 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 {
@ -539,9 +676,9 @@ fn chat_message_to_ws_outbound(msg: &crate::bus::ChatMessage) -> Option<WsOutbou
}
"user" => Some(WsOutbound::AssistantResponse {
id: msg.id.clone(),
content: msg.content.clone(),
content: strip_media_refs_json(&msg.content),
role: msg.role.clone(),
attachments: Vec::new(),
attachments,
subagent_task_id: None,
}),
_ => None,
@ -550,7 +687,9 @@ fn chat_message_to_ws_outbound(msg: &crate::bus::ChatMessage) -> Option<WsOutbou
#[cfg(test)]
mod tests {
use super::resolve_ws_sender_id;
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};
#[test]
fn test_resolve_ws_sender_id_prefers_inbound_sender() {
@ -569,4 +708,72 @@ 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());
}
}

View File

@ -55,6 +55,8 @@ 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")]

View File

@ -16,8 +16,9 @@ pub use ports::{
SkillEventRepository,
};
pub use records::{
MemoryRecord, MemoryUpsert, SchedulerJobRecord, SchedulerJobState, SchedulerJobStatus,
SchedulerJobUpsert, SessionRecord, SkillEventRecord, TopicRecord,
allowed_namespace_names, get_namespace_description, is_valid_namespace,
ALLOWED_MEMORY_NAMESPACES, MemoryRecord, MemoryUpsert, SchedulerJobRecord, SchedulerJobState,
SchedulerJobStatus, SchedulerJobUpsert, SessionRecord, SkillEventRecord, TopicRecord,
};
#[derive(Clone)]
@ -2284,7 +2285,7 @@ mod tests {
.put_memory(&MemoryUpsert {
scope_kind: "user".to_string(),
scope_key: format!("{}:user-1", TEST_CHANNEL),
namespace: "profile".to_string(),
namespace: "user".to_string(),
memory_key: "language".to_string(),
content: "Rust".to_string(),
source_type: "message".to_string(),
@ -2303,7 +2304,7 @@ mod tests {
assert_eq!(saved.source_message_seq, Some(7));
let fetched = store
.get_memory("user", "test-channel:user-1", "profile", "language")
.get_memory("user", "test-channel:user-1", "user", "language")
.unwrap()
.unwrap();
assert_eq!(fetched.id, saved.id);
@ -2318,7 +2319,7 @@ mod tests {
.put_memory(&MemoryUpsert {
scope_kind: "user".to_string(),
scope_key: format!("{}:user-1", TEST_CHANNEL),
namespace: "preferences".to_string(),
namespace: "user".to_string(),
memory_key: "editor".to_string(),
content: "Prefers rust-analyzer and cargo test output".to_string(),
source_type: "message".to_string(),
@ -2340,7 +2341,7 @@ mod tests {
.put_memory(&MemoryUpsert {
scope_kind: "user".to_string(),
scope_key: format!("{}:user-1", TEST_CHANNEL),
namespace: "preferences".to_string(),
namespace: "user".to_string(),
memory_key: "editor".to_string(),
content: "Prefers clippy diagnostics".to_string(),
source_type: "message".to_string(),
@ -2363,7 +2364,7 @@ mod tests {
assert_eq!(new_hits.len(), 1);
let deleted = store
.delete_memory("user", "test-channel:user-1", "preferences", "editor")
.delete_memory("user", "test-channel:user-1", "user", "editor")
.unwrap();
assert!(deleted);
@ -2381,7 +2382,7 @@ mod tests {
.put_memory(&MemoryUpsert {
scope_kind: "user".to_string(),
scope_key: format!("{}:user-1", TEST_CHANNEL),
namespace: "preferences".to_string(),
namespace: "user".to_string(),
memory_key: "email_folder_preference".to_string(),
content: "用户提到邮件时默认查看代收邮箱。".to_string(),
source_type: "message".to_string(),
@ -2409,7 +2410,7 @@ mod tests {
.put_memory(&MemoryUpsert {
scope_kind: "user".to_string(),
scope_key: format!("{}:user-1", TEST_CHANNEL),
namespace: "preferences".to_string(),
namespace: "user".to_string(),
memory_key: "editor".to_string(),
content: "Prefers rust-analyzer and cargo test output".to_string(),
source_type: "message".to_string(),
@ -2425,7 +2426,7 @@ mod tests {
.put_memory(&MemoryUpsert {
scope_kind: "user".to_string(),
scope_key: format!("{}:user-1", TEST_CHANNEL),
namespace: "tasks".to_string(),
namespace: "episodic".to_string(),
memory_key: "quality".to_string(),
content: "Tracks clippy warnings before release".to_string(),
source_type: "message".to_string(),
@ -2460,7 +2461,7 @@ mod tests {
.put_memory(&MemoryUpsert {
scope_kind: "user".to_string(),
scope_key: format!("{}:user-2", TEST_CHANNEL),
namespace: "preferences".to_string(),
namespace: "user".to_string(),
memory_key: "style".to_string(),
content: "偏好简洁表达".to_string(),
source_type: "message".to_string(),
@ -2475,7 +2476,7 @@ mod tests {
.put_memory(&MemoryUpsert {
scope_kind: "user".to_string(),
scope_key: format!("{}:user-1", TEST_CHANNEL),
namespace: "profile".to_string(),
namespace: "user".to_string(),
memory_key: "work".to_string(),
content: "用户在做AI产品".to_string(),
source_type: "message".to_string(),

View File

@ -1,5 +1,37 @@
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,

View File

@ -3,7 +3,7 @@ use std::sync::Arc;
use async_trait::async_trait;
use serde_json::json;
use crate::storage::{MemoryRecord, MemoryRepository, MemoryUpsert};
use crate::storage::{is_valid_namespace, MemoryRecord, MemoryRepository, MemoryUpsert};
use crate::tools::traits::{Tool, ToolContext, ToolResult};
pub struct MemoryManageTool {
@ -27,25 +27,27 @@ 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": "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."
"description": "管理操作。put 用于创建或覆盖update 用于修改已有记录delete 用于删除。检索请使用 memory_search。"
},
"namespace": {
"type": "string",
"description": "Optional memory namespace filter, such as profile, preferences, or tasks"
"enum": namespaces,
"description": "记忆命名空间分类"
},
"key": {
"type": "string",
"description": "Exact memory key within the namespace"
"description": "命名空间内的记忆键名"
},
"content": {
"type": "string",
"description": "Memory content for put/update"
"description": "put/update 时的记忆内容"
}
},
"required": ["action"]
@ -145,6 +147,14 @@ 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")),
@ -237,7 +247,7 @@ mod tests {
&context,
json!({
"action": "put",
"namespace": "profile",
"namespace": "user",
"key": "language",
"content": "Rust"
}),
@ -282,7 +292,7 @@ mod tests {
&context,
json!({
"action": "get",
"namespace": "profile",
"namespace": "user",
"key": "language"
}),
)

View File

@ -28,33 +28,35 @@ 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": "Retrieval action. Use 'search' for multi-keyword recall, 'get' for an exact namespace/key read, and 'list' to browse recent memories."
"description": "检索操作。search 用于多关键词召回get 用于精确 namespace/key 读取list 用于浏览最近记忆。"
},
"namespace": {
"type": "string",
"description": "Optional namespace filter, such as profile, preferences, tasks, or decisions. Required for get."
"enum": namespaces,
"description": "可选的命名空间过滤。get 操作时必填。"
},
"queries": {
"type": "array",
"items": {
"type": "string"
},
"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.",
"description": "搜索关键词数组。建议提供多个简洁的双语关键词、英文别名和可能的 snake_case memory_key。search 操作时必填。",
"minItems": 1
},
"key": {
"type": "string",
"description": "Exact memory key within the namespace. Required for get."
"description": "命名空间内的记忆键名。get 操作时必填。"
},
"limit": {
"type": "integer",
"description": "Maximum number of memories to return",
"description": "返回记忆的最大数量",
"minimum": 1,
"default": 10
}
@ -233,7 +235,7 @@ mod tests {
.put_memory(&crate::storage::MemoryUpsert {
scope_kind: "user".to_string(),
scope_key: TEST_CHANNEL.to_string(),
namespace: "preferences".to_string(),
namespace: "user".to_string(),
memory_key: "language".to_string(),
content: "User prefers Chinese responses".to_string(),
source_type: "message".to_string(),
@ -274,7 +276,7 @@ mod tests {
&context,
json!({
"action": "get",
"namespace": "preferences",
"namespace": "user",
"key": "language"
}),
)

View File

@ -70,6 +70,7 @@ 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()),

View File

@ -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 } from './types/protocol'
import type { ChatMessage, Command, Attachment } 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) => {
(content: string, attachments: Attachment[] = []) => {
if (isReadOnly || !sessionId) {
return
}
@ -126,10 +126,11 @@ function App() {
handleCommand(cmd)
sendMessage({ type: 'command', payload: JSON.stringify(cmd) })
} else {
handleMessage(content)
handleMessage(content, attachments)
sendMessage({
type: 'message',
content,
attachments,
chat_id: chatId,
})
}

View File

@ -1,13 +1,13 @@
import { MessageList } from './MessageList'
import { MessageInput } from './MessageInput'
import type { ChatMessage } from '../../types/protocol'
import type { ChatMessage, Attachment } from '../../types/protocol'
interface ChatContainerProps {
messages: ChatMessage[]
isLoading: boolean
isReadOnly?: boolean
channelName?: string
onSendMessage: (content: string) => void
onSendMessage: (content: string, attachments: Attachment[]) => void
onNavigateToSubAgent?: (taskId: string, description: string) => void
}

View File

@ -1,8 +1,11 @@
import { Send, Loader2, Sparkles, Eye } from 'lucide-react'
import { Send, Loader2, Sparkles, Eye, Paperclip, X, FileIcon, ImageIcon, MusicIcon, VideoIcon } 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) => void
onSend: (content: string, attachments: Attachment[]) => void
disabled?: boolean
isLoading?: boolean
placeholder?: string
@ -10,6 +13,20 @@ 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,
@ -19,7 +36,11 @@ 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(() => {
@ -38,10 +59,166 @@ 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 = () => {
if (content.trim() && !disabled && !isReadOnly) {
onSend(content.trim())
const hasContent = content.trim() || attachments.length > 0
if (hasContent && !disabled && !isReadOnly) {
onSend(
content.trim(),
attachments.map(a => a.attachment)
)
setContent('')
setAttachments([])
setError(null)
if (textareaRef.current) {
textareaRef.current.style.height = 'auto'
}
@ -55,6 +232,20 @@ 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 (
@ -85,13 +276,88 @@ export function MessageInput({
return (
<div className="border-t border-white/8 bg-[#12121a]/80 backdrop-blur-md p-4">
<div className="flex gap-3 items-center max-w-5xl mx-auto">
<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}
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)}
/>
{/* 输入框 */}
<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}
@ -99,9 +365,11 @@ export function MessageInput({
/>
<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()}
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 ? (
@ -111,8 +379,11 @@ export function MessageInput({
)}
</button>
</div>
{/* 提示 */}
<div className="mt-2 text-center text-xs text-zinc-500">
Enter Shift+Enter
Enter Shift+Enter · / · 50MB
</div>
</div>
</div>
)

View File

@ -14,6 +14,7 @@ import type {
TopicSummary,
Session,
TaskMessagesLoaded,
Attachment,
} from '../types/protocol'
// 简化后的层级状态
@ -40,7 +41,7 @@ interface UseChatReturn {
subAgentView: SubAgentView | null
// 方法
handleMessage: (content: string) => void
handleMessage: (content: string, attachments?: Attachment[]) => void
handleCommand: (command: Command) => void
clearMessages: () => void
handleServerMessage: (message: WsOutbound) => void
@ -388,7 +389,7 @@ export function useChat(): UseChatReturn {
}
}, [])
const handleMessage = useCallback((content: string) => {
const handleMessage = useCallback((content: string, attachments?: Attachment[]) => {
setMessages((prev) => [
...prev,
{
@ -397,6 +398,7 @@ export function useChat(): UseChatReturn {
content,
timestamp: Date.now(),
type: 'message',
attachments: attachments || [],
},
])
setIsLoading(true)

View File

@ -7,6 +7,7 @@
export interface WsInboundMessage {
type: 'message'
content: string
attachments?: Attachment[]
channel?: string
chat_id?: string
sender_id?: string