Compare commits
7 Commits
8a4799656c
...
b0c24d64f0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b0c24d64f0 | ||
|
|
c666c48f09 | ||
|
|
d5c0b50e63 | ||
|
|
5a292d60ec | ||
|
|
2ab9c2d404 | ||
|
|
27160ef560 | ||
|
|
af344b7087 |
@ -238,9 +238,17 @@ async fn handle_socket(ws: WebSocket, state: Arc<GatewayState>) {
|
|||||||
websocket_sessions.sort_by_key(|s| -(s.last_active_at));
|
websocket_sessions.sort_by_key(|s| -(s.last_active_at));
|
||||||
}
|
}
|
||||||
|
|
||||||
tracing::info!("Sending {} sessions to client", websocket_sessions.len());
|
tracing::info!(
|
||||||
|
session_count = websocket_sessions.len(),
|
||||||
|
"Sending session list to client"
|
||||||
|
);
|
||||||
for s in &websocket_sessions {
|
for s in &websocket_sessions {
|
||||||
tracing::info!(" - {}: {} (channel: {})", s.id, s.title, s.channel_name);
|
tracing::debug!(
|
||||||
|
session_id = %s.id,
|
||||||
|
title = %s.title,
|
||||||
|
channel = %s.channel_name,
|
||||||
|
"Session list entry"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let session_summaries: Vec<crate::protocol::SessionSummary> = websocket_sessions
|
let session_summaries: Vec<crate::protocol::SessionSummary> = websocket_sessions
|
||||||
|
|||||||
@ -100,6 +100,7 @@ impl PicoBotTool for McpToolWrapper {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
|
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
|
||||||
|
let mcp_start = std::time::Instant::now();
|
||||||
tracing::debug!(
|
tracing::debug!(
|
||||||
server_key = %self.server_key,
|
server_key = %self.server_key,
|
||||||
tool = %self.tool_name,
|
tool = %self.tool_name,
|
||||||
@ -131,6 +132,15 @@ impl PicoBotTool for McpToolWrapper {
|
|||||||
call.await?
|
call.await?
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// MCP 全链路往返耗时(含 stdio 传输 + server 派发 + 工具执行 + 回传)。
|
||||||
|
// 与 server 侧 wrapper 进入时间戳、结果内 duration_ms 对比可定位慢在哪一段。
|
||||||
|
tracing::debug!(
|
||||||
|
server_key = %self.server_key,
|
||||||
|
tool = %self.tool_name,
|
||||||
|
roundtrip_ms = mcp_start.elapsed().as_millis() as u64,
|
||||||
|
"MCP tool call finished"
|
||||||
|
);
|
||||||
|
|
||||||
// Convert MCP CallToolResult to PicoBot ToolResult
|
// Convert MCP CallToolResult to PicoBot ToolResult
|
||||||
let output = extract_text_content(&result);
|
let output = extract_text_content(&result);
|
||||||
let is_error = result.is_error.unwrap_or(false);
|
let is_error = result.is_error.unwrap_or(false);
|
||||||
|
|||||||
@ -196,6 +196,7 @@ struct AnthropicRequest {
|
|||||||
model: String,
|
model: String,
|
||||||
messages: Vec<AnthropicMessage>,
|
messages: Vec<AnthropicMessage>,
|
||||||
max_tokens: u32,
|
max_tokens: u32,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
temperature: Option<f32>,
|
temperature: Option<f32>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
tools: Option<Vec<AnthropicTool>>,
|
tools: Option<Vec<AnthropicTool>>,
|
||||||
@ -260,7 +261,7 @@ impl LLMProvider for AnthropicProvider {
|
|||||||
request: ChatCompletionRequest,
|
request: ChatCompletionRequest,
|
||||||
) -> Result<ChatCompletionResponse, Box<dyn std::error::Error + Send + Sync>> {
|
) -> Result<ChatCompletionResponse, Box<dyn std::error::Error + Send + Sync>> {
|
||||||
let url = format!("{}/v1/messages", self.base_url);
|
let url = format!("{}/v1/messages", self.base_url);
|
||||||
let max_tokens = request.max_tokens.or(self.max_tokens).unwrap_or(1024);
|
let max_tokens = request.max_tokens.or(self.max_tokens).unwrap_or(8192);
|
||||||
|
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
provider = %self.name,
|
provider = %self.name,
|
||||||
@ -630,6 +631,36 @@ mod tests {
|
|||||||
assert!(resp.content.is_empty());
|
assert!(resp.content.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- AnthropicRequest 序列化:未配置的可选字段不出现在请求体中 ----
|
||||||
|
|
||||||
|
fn make_request(temperature: Option<f32>) -> AnthropicRequest {
|
||||||
|
AnthropicRequest {
|
||||||
|
model: "glm-5".to_string(),
|
||||||
|
messages: vec![AnthropicMessage {
|
||||||
|
role: "user".to_string(),
|
||||||
|
content: vec![serde_json::json!({"type": "text", "text": "hi"})],
|
||||||
|
}],
|
||||||
|
max_tokens: 1024,
|
||||||
|
temperature,
|
||||||
|
tools: None,
|
||||||
|
extra: HashMap::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_request_omits_temperature_when_none() {
|
||||||
|
let json = serde_json::to_value(make_request(None)).unwrap();
|
||||||
|
let obj = json.as_object().unwrap();
|
||||||
|
assert!(!obj.contains_key("temperature"));
|
||||||
|
assert_eq!(obj.get("max_tokens").and_then(|v| v.as_u64()), Some(1024));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_request_includes_temperature_when_set() {
|
||||||
|
let json = serde_json::to_value(make_request(Some(0.5))).unwrap();
|
||||||
|
assert_eq!(json["temperature"], serde_json::json!(0.5));
|
||||||
|
}
|
||||||
|
|
||||||
// ---- format_error_chain ----
|
// ---- format_error_chain ----
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@ -5,6 +5,7 @@
|
|||||||
//! every [`super::SessionStore`] construction and are idempotent.
|
//! every [`super::SessionStore`] construction and are idempotent.
|
||||||
|
|
||||||
use rusqlite::Connection;
|
use rusqlite::Connection;
|
||||||
|
use rusqlite::params;
|
||||||
|
|
||||||
use super::StorageError;
|
use super::StorageError;
|
||||||
|
|
||||||
@ -213,6 +214,215 @@ pub(super) fn ensure_memory_scope_key_migration(conn: &Connection) -> Result<(),
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 修复历史 bug 污染的会话 ID(user_version 2,一次性迁移)。
|
||||||
|
///
|
||||||
|
/// 早期版本在 chat_id 已带通道前缀时会再次拼接 `{channel}:{chat_id}`,
|
||||||
|
/// 产生形如 `websocket:websocket:<uuid>` 的污染会话记录,且每次重连都会
|
||||||
|
/// 多累积一层前缀、多出一条孤儿会话。`persistent_session_id` 现已幂等,
|
||||||
|
/// 不会再产生新污染,但存量污染记录会在会话列表中显示为多个重复会话。
|
||||||
|
///
|
||||||
|
/// 本迁移将污染 ID 归一化:子表数据(消息/话题/技能事件/待办/子代理/记忆溯源)
|
||||||
|
/// 改指归一化 ID,污染会话记录本身被删除或重命名。通过 PRAGMA user_version
|
||||||
|
/// 追踪,仅执行一次;整体包在事务中,失败自动回滚,重启后可安全重跑。
|
||||||
|
pub(super) fn repair_session_id_prefix_pollution(
|
||||||
|
conn: &mut Connection,
|
||||||
|
) -> Result<(), StorageError> {
|
||||||
|
const SESSION_ID_REPAIR_VERSION: i64 = 2;
|
||||||
|
|
||||||
|
let current_version: i64 = conn.query_row("PRAGMA user_version", [], |row| row.get(0))?;
|
||||||
|
if current_version >= SESSION_ID_REPAIR_VERSION {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let tx = conn.transaction()?;
|
||||||
|
repair_session_id_prefix_pollution_inner(&tx)?;
|
||||||
|
tx.execute(
|
||||||
|
&format!("PRAGMA user_version = {SESSION_ID_REPAIR_VERSION}"),
|
||||||
|
[],
|
||||||
|
)?;
|
||||||
|
tx.commit()?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn repair_session_id_prefix_pollution_inner(conn: &Connection) -> Result<(), StorageError> {
|
||||||
|
let candidates: Vec<(String, String, String)> = {
|
||||||
|
let mut stmt = conn.prepare("SELECT id, channel_name, chat_id FROM sessions")?;
|
||||||
|
let rows = stmt.query_map([], |row| {
|
||||||
|
Ok((
|
||||||
|
row.get::<_, String>(0)?,
|
||||||
|
row.get::<_, String>(1)?,
|
||||||
|
row.get::<_, String>(2)?,
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
rows.collect::<Result<Vec<_>, _>>()?
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut repaired = 0usize;
|
||||||
|
for (polluted_id, channel_name, chat_id) in candidates {
|
||||||
|
let normalized = super::persistent_session_id(&channel_name, &polluted_id);
|
||||||
|
if normalized == polluted_id {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let target_exists: bool = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT COUNT(*) FROM sessions WHERE id = ?1",
|
||||||
|
params![normalized],
|
||||||
|
|row| row.get::<_, i64>(0),
|
||||||
|
)
|
||||||
|
.map(|count| count > 0)?;
|
||||||
|
|
||||||
|
if target_exists {
|
||||||
|
// 并入既有归一化会话:先把污染消息的 seq 偏移大数,
|
||||||
|
// 避免改指 session_id 时违反 UNIQUE(session_id, seq),合并后统一重编号。
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE messages SET seq = seq + 1000000000 WHERE session_id = ?1",
|
||||||
|
params![polluted_id],
|
||||||
|
)?;
|
||||||
|
} else {
|
||||||
|
// 无归一化会话:先建归一化 ID 的记录(子表改指时父记录必须已存在,
|
||||||
|
// 否则会违反外键约束),最后删除污染记录。
|
||||||
|
// websocket/cli 的 chat_id 与 session id 同源,顺带剥离累积前缀。
|
||||||
|
let clean_chat_id = if channel_name == "cli" || channel_name == "websocket" {
|
||||||
|
super::persistent_session_id(&channel_name, &chat_id)
|
||||||
|
} else {
|
||||||
|
chat_id
|
||||||
|
};
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO sessions (
|
||||||
|
id, title, channel_name, chat_id, summary,
|
||||||
|
created_at, updated_at, last_active_at, archived_at, deleted_at,
|
||||||
|
message_count, user_turn_count, agent_prompt_reinjection_count
|
||||||
|
)
|
||||||
|
SELECT ?2, title, channel_name, ?3, summary,
|
||||||
|
created_at, updated_at, last_active_at, archived_at, deleted_at,
|
||||||
|
message_count, user_turn_count, agent_prompt_reinjection_count
|
||||||
|
FROM sessions WHERE id = ?1",
|
||||||
|
params![polluted_id, normalized, clean_chat_id],
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE messages SET session_id = ?2 WHERE session_id = ?1",
|
||||||
|
params![polluted_id, normalized],
|
||||||
|
)?;
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE topics SET session_id = ?2 WHERE session_id = ?1",
|
||||||
|
params![polluted_id, normalized],
|
||||||
|
)?;
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE skill_events SET session_id = ?2 WHERE session_id = ?1",
|
||||||
|
params![polluted_id, normalized],
|
||||||
|
)?;
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE todos SET session_id = ?2 WHERE session_id = ?1",
|
||||||
|
params![polluted_id, normalized],
|
||||||
|
)?;
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE pending_subagents SET parent_session_id = ?2 WHERE parent_session_id = ?1",
|
||||||
|
params![polluted_id, normalized],
|
||||||
|
)?;
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE memories SET source_session_id = ?2 WHERE source_session_id = ?1",
|
||||||
|
params![polluted_id, normalized],
|
||||||
|
)?;
|
||||||
|
|
||||||
|
if target_exists {
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE sessions SET
|
||||||
|
created_at = MIN(created_at, (SELECT created_at FROM sessions WHERE id = ?2)),
|
||||||
|
updated_at = MAX(updated_at, (SELECT updated_at FROM sessions WHERE id = ?2)),
|
||||||
|
last_active_at = MAX(last_active_at, (SELECT last_active_at FROM sessions WHERE id = ?2)),
|
||||||
|
message_count = message_count + (SELECT message_count FROM sessions WHERE id = ?2),
|
||||||
|
user_turn_count = user_turn_count + (SELECT user_turn_count FROM sessions WHERE id = ?2)
|
||||||
|
WHERE id = ?1",
|
||||||
|
params![normalized, polluted_id],
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
conn.execute("DELETE FROM sessions WHERE id = ?1", params![polluted_id])?;
|
||||||
|
|
||||||
|
if target_exists {
|
||||||
|
// 重编号:按 created_at 恢复连续 seq。原地 UPDATE 会在行级更新过程中
|
||||||
|
// 与尚未更新的行冲突 UNIQUE(session_id, seq),因此分两步:
|
||||||
|
// 先写入"大偏移 + 行号"(互不冲突),再统一减去偏移还原为 1..N。
|
||||||
|
const SEQ_RENUMBER_OFFSET: i64 = 2_000_000_000;
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE messages SET seq = ?2 + (
|
||||||
|
SELECT t.rn FROM (
|
||||||
|
SELECT id AS mid, ROW_NUMBER() OVER (ORDER BY created_at ASC, seq ASC) AS rn
|
||||||
|
FROM messages WHERE session_id = ?1
|
||||||
|
) t WHERE t.mid = messages.id
|
||||||
|
)
|
||||||
|
WHERE session_id = ?1",
|
||||||
|
params![normalized, SEQ_RENUMBER_OFFSET],
|
||||||
|
)?;
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE messages SET seq = seq - ?2 WHERE session_id = ?1",
|
||||||
|
params![normalized, SEQ_RENUMBER_OFFSET],
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
repaired += 1;
|
||||||
|
tracing::info!(
|
||||||
|
polluted_id = %polluted_id,
|
||||||
|
normalized_id = %normalized,
|
||||||
|
merged_into_existing = target_exists,
|
||||||
|
"Repaired polluted session id"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if repaired > 0 {
|
||||||
|
tracing::info!(
|
||||||
|
repaired_count = repaired,
|
||||||
|
"Session id prefix pollution repair complete"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 清理历史遗留的空 cli 会话(user_version 3,一次性迁移)。
|
||||||
|
///
|
||||||
|
/// 早期版本每次 WebSocket 连接建立都会新建一个 cli 通道空会话
|
||||||
|
/// (自动标题 "CLI Session xxxxxxxx"),长期运行后 sessions 表积累大量
|
||||||
|
/// 空壳记录:它们会被并入发送给前端的会话列表,也在网关日志中逐条打印。
|
||||||
|
/// 本迁移删除没有任何消息、话题、待办、技能事件的 cli 会话(纯空壳),
|
||||||
|
/// 有真实内容的会话不受影响。事务包裹,通过 PRAGMA user_version 仅执行一次。
|
||||||
|
pub(super) fn cleanup_legacy_empty_cli_sessions(conn: &mut Connection) -> Result<(), StorageError> {
|
||||||
|
const EMPTY_CLI_CLEANUP_VERSION: i64 = 3;
|
||||||
|
|
||||||
|
let current_version: i64 = conn.query_row("PRAGMA user_version", [], |row| row.get(0))?;
|
||||||
|
if current_version >= EMPTY_CLI_CLEANUP_VERSION {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let tx = conn.transaction()?;
|
||||||
|
let deleted = tx.execute(
|
||||||
|
"DELETE FROM sessions
|
||||||
|
WHERE channel_name = 'cli'
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM messages m WHERE m.session_id = sessions.id)
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM topics t WHERE t.session_id = sessions.id)
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM todos d WHERE d.session_id = sessions.id)
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM skill_events s WHERE s.session_id = sessions.id)",
|
||||||
|
[],
|
||||||
|
)?;
|
||||||
|
if deleted > 0 {
|
||||||
|
tracing::info!(
|
||||||
|
deleted_count = deleted,
|
||||||
|
"Cleaned up legacy empty cli sessions"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
tx.execute(
|
||||||
|
&format!("PRAGMA user_version = {EMPTY_CLI_CLEANUP_VERSION}"),
|
||||||
|
[],
|
||||||
|
)?;
|
||||||
|
tx.commit()?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub(super) fn ensure_todos_schema(conn: &Connection) -> Result<(), StorageError> {
|
pub(super) fn ensure_todos_schema(conn: &Connection) -> Result<(), StorageError> {
|
||||||
let table_exists: bool = conn
|
let table_exists: bool = conn
|
||||||
.query_row(
|
.query_row(
|
||||||
|
|||||||
@ -65,7 +65,7 @@ impl SessionStore {
|
|||||||
/// Initialize a SessionStore from a connection and its file path.
|
/// Initialize a SessionStore from a connection and its file path.
|
||||||
/// The connection is used for schema initialization only; the pool
|
/// The connection is used for schema initialization only; the pool
|
||||||
/// manages subsequent connections using the same file path.
|
/// manages subsequent connections using the same file path.
|
||||||
fn from_connection(conn: Connection, db_uri: &str) -> Result<Self, StorageError> {
|
fn from_connection(mut conn: Connection, db_uri: &str) -> Result<Self, StorageError> {
|
||||||
conn.busy_timeout(std::time::Duration::from_secs(30))?;
|
conn.busy_timeout(std::time::Duration::from_secs(30))?;
|
||||||
conn.execute_batch(
|
conn.execute_batch(
|
||||||
"
|
"
|
||||||
@ -237,6 +237,8 @@ impl SessionStore {
|
|||||||
ensure_memory_scope_key_migration(&conn)?;
|
ensure_memory_scope_key_migration(&conn)?;
|
||||||
ensure_todos_schema(&conn)?;
|
ensure_todos_schema(&conn)?;
|
||||||
ensure_pending_subagents_schema(&conn)?;
|
ensure_pending_subagents_schema(&conn)?;
|
||||||
|
repair_session_id_prefix_pollution(&mut conn)?;
|
||||||
|
cleanup_legacy_empty_cli_sessions(&mut conn)?;
|
||||||
|
|
||||||
drop(conn);
|
drop(conn);
|
||||||
|
|
||||||
|
|||||||
@ -717,3 +717,110 @@ fn test_get_topic_message_count_uses_count_query() {
|
|||||||
0
|
0
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_repair_session_id_prefix_pollution() {
|
||||||
|
let store = SessionStore::in_memory().unwrap();
|
||||||
|
|
||||||
|
let clean = store.ensure_channel_session("websocket", "abc").unwrap();
|
||||||
|
assert_eq!(clean.id, "abc");
|
||||||
|
|
||||||
|
store
|
||||||
|
.append_message(&clean.id, &ChatMessage::user("clean msg"))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let mut conn = store.pool.get().unwrap();
|
||||||
|
conn.execute("PRAGMA user_version = 1", []).unwrap();
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO sessions (id, title, channel_name, chat_id, summary, created_at, updated_at, last_active_at, archived_at, deleted_at, message_count, user_turn_count, agent_prompt_reinjection_count) VALUES ('websocket:websocket:abc', 'Polluted', 'websocket', 'websocket:websocket:abc', NULL, 100, 100, 100, NULL, NULL, 1, 1, 0)",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO messages (id, session_id, seq, role, content, media_refs_json, created_at) VALUES ('m-polluted', 'websocket:websocket:abc', 1, 'user', 'old polluted msg', '[]', 100)",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO sessions (id, title, channel_name, chat_id, summary, created_at, updated_at, last_active_at, archived_at, deleted_at, message_count, user_turn_count, agent_prompt_reinjection_count) VALUES ('websocket:xyz', 'PollutedXyz', 'websocket', 'websocket:xyz', NULL, 200, 200, 200, NULL, NULL, 1, 1, 0)",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO messages (id, session_id, seq, role, content, media_refs_json, created_at) VALUES ('m-xyz', 'websocket:xyz', 1, 'user', 'xyz msg', '[]', 200)",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
super::migrations::repair_session_id_prefix_pollution(&mut conn).unwrap();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
store
|
||||||
|
.get_session("websocket:websocket:abc")
|
||||||
|
.unwrap()
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
assert!(store.get_session("websocket:xyz").unwrap().is_none());
|
||||||
|
|
||||||
|
let merged = store.get_session("abc").unwrap().unwrap();
|
||||||
|
assert_eq!(merged.message_count, 2);
|
||||||
|
assert_eq!(merged.user_turn_count, 2);
|
||||||
|
|
||||||
|
let msgs = store.load_messages("abc").unwrap();
|
||||||
|
assert_eq!(msgs.len(), 2);
|
||||||
|
assert_eq!(msgs[0].content, "old polluted msg");
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut stmt = conn
|
||||||
|
.prepare("SELECT seq FROM messages WHERE session_id = 'abc' ORDER BY seq")
|
||||||
|
.unwrap();
|
||||||
|
let seqs: Vec<i64> = stmt
|
||||||
|
.query_map([], |row| row.get(0))
|
||||||
|
.unwrap()
|
||||||
|
.collect::<Result<Vec<_>, _>>()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(seqs, vec![1, 2]);
|
||||||
|
}
|
||||||
|
|
||||||
|
let xyz = store.get_session("xyz").unwrap().unwrap();
|
||||||
|
assert_eq!(xyz.chat_id, "xyz");
|
||||||
|
assert_eq!(store.load_messages("xyz").unwrap().len(), 1);
|
||||||
|
|
||||||
|
assert_eq!(store.list_sessions("websocket", false).unwrap().len(), 2);
|
||||||
|
|
||||||
|
super::migrations::repair_session_id_prefix_pollution(&mut conn).unwrap();
|
||||||
|
assert_eq!(store.get_session("abc").unwrap().unwrap().message_count, 2);
|
||||||
|
assert_eq!(store.list_sessions("websocket", false).unwrap().len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_cleanup_legacy_empty_cli_sessions() {
|
||||||
|
let store = SessionStore::in_memory().unwrap();
|
||||||
|
|
||||||
|
// 空 cli 会话(旧版本遗留的空壳):应被删除
|
||||||
|
let empty = store.create_cli_session(None).unwrap();
|
||||||
|
// 有消息的 cli 会话:必须保留
|
||||||
|
let with_data = store.create_cli_session(None).unwrap();
|
||||||
|
store
|
||||||
|
.append_message(&with_data.id, &ChatMessage::user("hello"))
|
||||||
|
.unwrap();
|
||||||
|
// 空 websocket 会话:不在 cli 清理范围内
|
||||||
|
let ws = store.ensure_channel_session("websocket", "chat-1").unwrap();
|
||||||
|
|
||||||
|
let mut conn = store.pool.get().unwrap();
|
||||||
|
// from_connection 已把 user_version 推到 3,回退到 2 模拟"尚未清理"
|
||||||
|
conn.execute("PRAGMA user_version = 2", []).unwrap();
|
||||||
|
|
||||||
|
super::migrations::cleanup_legacy_empty_cli_sessions(&mut conn).unwrap();
|
||||||
|
|
||||||
|
assert!(store.get_session(&empty.id).unwrap().is_none());
|
||||||
|
assert!(store.get_session(&with_data.id).unwrap().is_some());
|
||||||
|
assert!(store.get_session(&ws.id).unwrap().is_some());
|
||||||
|
|
||||||
|
// 幂等:版本守卫使第二次调用直接跳过,已有会话不受影响
|
||||||
|
super::migrations::cleanup_legacy_empty_cli_sessions(&mut conn).unwrap();
|
||||||
|
assert!(store.get_session(&with_data.id).unwrap().is_some());
|
||||||
|
assert!(store.get_session(&ws.id).unwrap().is_some());
|
||||||
|
}
|
||||||
|
|||||||
@ -27,6 +27,7 @@ export function MessageList({
|
|||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const isAtBottomRef = useRef(true);
|
const isAtBottomRef = useRef(true);
|
||||||
const prevShowBottomRef = useRef(false);
|
const prevShowBottomRef = useRef(false);
|
||||||
|
const scrollTopRafRef = useRef(0);
|
||||||
const prevViewKeyRef = useRef(viewKey);
|
const prevViewKeyRef = useRef(viewKey);
|
||||||
const viewKeyRef = useRef(viewKey);
|
const viewKeyRef = useRef(viewKey);
|
||||||
viewKeyRef.current = viewKey;
|
viewKeyRef.current = viewKey;
|
||||||
@ -65,8 +66,16 @@ export function MessageList({
|
|||||||
|
|
||||||
// ---- scroll helpers ----
|
// ---- scroll helpers ----
|
||||||
|
|
||||||
|
const stopScrollTopAnimation = useCallback(() => {
|
||||||
|
if (scrollTopRafRef.current !== 0) {
|
||||||
|
cancelAnimationFrame(scrollTopRafRef.current);
|
||||||
|
scrollTopRafRef.current = 0;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
const scrollToBottom = useCallback(
|
const scrollToBottom = useCallback(
|
||||||
(behavior: ScrollBehavior = 'smooth') => {
|
(behavior: ScrollBehavior = 'smooth') => {
|
||||||
|
stopScrollTopAnimation();
|
||||||
isAtBottomRef.current = true;
|
isAtBottomRef.current = true;
|
||||||
setShowScrollToBottom(false);
|
setShowScrollToBottom(false);
|
||||||
setNewMessageCount(0);
|
setNewMessageCount(0);
|
||||||
@ -74,12 +83,30 @@ export function MessageList({
|
|||||||
virtualizer.scrollToIndex(messages.length - 1, { align: 'end', behavior });
|
virtualizer.scrollToIndex(messages.length - 1, { align: 'end', behavior });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[virtualizer, messages.length],
|
[virtualizer, messages.length, stopScrollTopAnimation],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 长距离滚回顶部不能用原生 behavior:'smooth':滚动途中上方行首次被测量时,
|
||||||
|
// virtualizer 会写 scrollTop 做偏移纠正(resizeItem → applyScrollAdjustment),
|
||||||
|
// 程序化 scrollTop 写入会直接打断浏览器平滑滚动动画,消息越多越容易停在半路。
|
||||||
|
// 改为 rAF 自驱动动画,每帧覆写 scrollTop,纠正写入下一帧即被覆盖,必达顶部。
|
||||||
const scrollToTop = useCallback(() => {
|
const scrollToTop = useCallback(() => {
|
||||||
containerRef.current?.scrollTo({ top: 0, behavior: 'smooth' });
|
const el = containerRef.current;
|
||||||
}, []);
|
if (!el) return;
|
||||||
|
stopScrollTopAnimation();
|
||||||
|
const from = el.scrollTop;
|
||||||
|
if (from <= 0) return;
|
||||||
|
const duration = Math.min(800, 250 + from / 8);
|
||||||
|
const start = performance.now();
|
||||||
|
const easeInOutCubic = (t: number) =>
|
||||||
|
t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
|
||||||
|
const step = (now: number) => {
|
||||||
|
const p = Math.min(1, (now - start) / duration);
|
||||||
|
el.scrollTop = from * (1 - easeInOutCubic(p));
|
||||||
|
scrollTopRafRef.current = p < 1 ? requestAnimationFrame(step) : 0;
|
||||||
|
};
|
||||||
|
scrollTopRafRef.current = requestAnimationFrame(step);
|
||||||
|
}, [stopScrollTopAnimation]);
|
||||||
|
|
||||||
// ---- scroll event: track whether user is at bottom ----
|
// ---- scroll event: track whether user is at bottom ----
|
||||||
|
|
||||||
@ -126,6 +153,7 @@ export function MessageList({
|
|||||||
|
|
||||||
if (viewChanged) {
|
if (viewChanged) {
|
||||||
// View switched (e.g. breadcrumb navigation): restore saved scroll position
|
// View switched (e.g. breadcrumb navigation): restore saved scroll position
|
||||||
|
stopScrollTopAnimation();
|
||||||
prevMessageCountRef.current = messages.length;
|
prevMessageCountRef.current = messages.length;
|
||||||
const key = viewKey ?? '';
|
const key = viewKey ?? '';
|
||||||
const savedPos = scrollPositionsRef.current.get(key);
|
const savedPos = scrollPositionsRef.current.get(key);
|
||||||
@ -148,6 +176,7 @@ export function MessageList({
|
|||||||
prevMessageCountRef.current = messages.length;
|
prevMessageCountRef.current = messages.length;
|
||||||
|
|
||||||
if (lastMessage.role === 'user' || isAtBottomRef.current) {
|
if (lastMessage.role === 'user' || isAtBottomRef.current) {
|
||||||
|
stopScrollTopAnimation();
|
||||||
virtualizer.scrollToIndex(messages.length - 1, { align: 'end', behavior: 'instant' });
|
virtualizer.scrollToIndex(messages.length - 1, { align: 'end', behavior: 'instant' });
|
||||||
// 用户自己发消息或已在底部时,不需要计数
|
// 用户自己发消息或已在底部时,不需要计数
|
||||||
if (newCount > 0) {
|
if (newCount > 0) {
|
||||||
@ -157,7 +186,7 @@ export function MessageList({
|
|||||||
// 只有真正新增了消息条数时才累加(流式 delta 不增加条数,不计数)
|
// 只有真正新增了消息条数时才累加(流式 delta 不增加条数,不计数)
|
||||||
setNewMessageCount((prev) => prev + newCount);
|
setNewMessageCount((prev) => prev + newCount);
|
||||||
}
|
}
|
||||||
}, [messages, viewKey, virtualizer]);
|
}, [messages, viewKey, virtualizer, stopScrollTopAnimation]);
|
||||||
|
|
||||||
// ---- mount: always scroll to bottom if messages already loaded ----
|
// ---- mount: always scroll to bottom if messages already loaded ----
|
||||||
|
|
||||||
@ -175,6 +204,7 @@ export function MessageList({
|
|||||||
const idx = messageIdToIndex.get(highlightedMessageId);
|
const idx = messageIdToIndex.get(highlightedMessageId);
|
||||||
if (idx === undefined) return;
|
if (idx === undefined) return;
|
||||||
|
|
||||||
|
stopScrollTopAnimation();
|
||||||
virtualizer.scrollToIndex(idx, { align: 'center', behavior: 'smooth' });
|
virtualizer.scrollToIndex(idx, { align: 'center', behavior: 'smooth' });
|
||||||
|
|
||||||
// 高亮 class 需等 DOM 渲染后操作
|
// 高亮 class 需等 DOM 渲染后操作
|
||||||
@ -188,7 +218,25 @@ export function MessageList({
|
|||||||
targetElement.classList.remove('todo-highlight');
|
targetElement.classList.remove('todo-highlight');
|
||||||
}, 2000);
|
}, 2000);
|
||||||
});
|
});
|
||||||
}, [highlightedMessageId, messageIdToIndex, virtualizer]);
|
}, [highlightedMessageId, messageIdToIndex, virtualizer, stopScrollTopAnimation]);
|
||||||
|
|
||||||
|
// ---- 滚顶动画生命周期 ----
|
||||||
|
|
||||||
|
// 用户手动滚动(滚轮/触摸)时打断滚顶动画,交还滚动控制权
|
||||||
|
const hasMessages = messages.length > 0;
|
||||||
|
useEffect(() => {
|
||||||
|
const el = containerRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
el.addEventListener('wheel', stopScrollTopAnimation, { passive: true });
|
||||||
|
el.addEventListener('touchstart', stopScrollTopAnimation, { passive: true });
|
||||||
|
return () => {
|
||||||
|
el.removeEventListener('wheel', stopScrollTopAnimation);
|
||||||
|
el.removeEventListener('touchstart', stopScrollTopAnimation);
|
||||||
|
};
|
||||||
|
}, [hasMessages, stopScrollTopAnimation]);
|
||||||
|
|
||||||
|
// 卸载时取消未完成的动画
|
||||||
|
useEffect(() => stopScrollTopAnimation, [stopScrollTopAnimation]);
|
||||||
|
|
||||||
// ---- 行高强制重测(修复 tanstack virtual-core 3.17.x 陈旧高度导致行重叠)----
|
// ---- 行高强制重测(修复 tanstack virtual-core 3.17.x 陈旧高度导致行重叠)----
|
||||||
// 3.17.x 在滚动状态会跳过同步测量、对缓冲区外的行跳过 RO 更新并复用缓存高度;
|
// 3.17.x 在滚动状态会跳过同步测量、对缓冲区外的行跳过 RO 更新并复用缓存高度;
|
||||||
|
|||||||
@ -37,7 +37,7 @@ export function ModelsTab({ config, update }: TabProps) {
|
|||||||
</Field>
|
</Field>
|
||||||
<Field
|
<Field
|
||||||
label="Temperature"
|
label="Temperature"
|
||||||
hint="控制回复随机性,0 表示确定性输出,值越大越随机。留空使用模型默认值"
|
hint="控制回复随机性,0 表示确定性输出,值越大越随机。留空则不传该字段,由模型使用其默认值"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
@ -47,12 +47,11 @@ export function ModelsTab({ config, update }: TabProps) {
|
|||||||
editors.patch(name, { temperature: e.target.value ? +e.target.value : undefined })
|
editors.patch(name, { temperature: e.target.value ? +e.target.value : undefined })
|
||||||
}
|
}
|
||||||
className={inputCls}
|
className={inputCls}
|
||||||
placeholder="0.7"
|
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<Field
|
<Field
|
||||||
label="Max Tokens"
|
label="Max Tokens"
|
||||||
hint="模型单次回复最大生成 token 数,超出会被截断。留空使用模型默认值(如 4096/8192)"
|
hint="模型单次回复最大生成 token 数,超出会被截断。留空则不传该字段(Anthropic 型 provider 会回退为 8192)"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
// Shared UI primitives extracted from ConfigPage.tsx
|
// Shared UI primitives extracted from ConfigPage.tsx
|
||||||
import { useState, type ReactNode } from 'react';
|
import { useState, type ReactNode } from 'react';
|
||||||
import { X, Plus, Trash2 } from 'lucide-react';
|
import { X, Plus, Trash2, HelpCircle } from 'lucide-react';
|
||||||
import { inputCls } from './constants';
|
import { inputCls } from './constants';
|
||||||
import type { KnownSource } from './types';
|
import type { KnownSource } from './types';
|
||||||
|
|
||||||
@ -15,9 +15,24 @@ export function Field({
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label className="block text-[13px] font-medium text-[var(--text-secondary)]">{label}</label>
|
<label className="flex items-center gap-1 text-[13px] font-medium text-[var(--text-secondary)]">
|
||||||
|
<span>{label}</span>
|
||||||
|
{hint && (
|
||||||
|
<span className="group/hint relative inline-flex">
|
||||||
|
<HelpCircle
|
||||||
|
className="h-3.5 w-3.5 shrink-0 cursor-help text-[var(--text-muted)] transition-colors group-hover/hint:text-[var(--accent-cyan)]"
|
||||||
|
aria-label={hint}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
role="tooltip"
|
||||||
|
className="pointer-events-none invisible absolute top-full left-0 z-50 mt-1.5 w-max max-w-[280px] rounded-lg border border-[var(--border-color)] bg-[var(--bg-tertiary)] px-2.5 py-1.5 text-xs leading-relaxed font-normal text-[var(--text-secondary)] opacity-0 shadow-lg transition-opacity duration-150 group-hover/hint:visible group-hover/hint:opacity-100"
|
||||||
|
>
|
||||||
|
{hint}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
{children}
|
{children}
|
||||||
{hint && <p className="text-xs text-[var(--text-muted)]">{hint}</p>}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user