feat: add MCP/skill tool protocol, tasks/tools pages, chat UX overhaul

Tools page: skill & MCP server management with [[skill:]]/[[mcp:]] protocol (up to 3 rounds). New Tasks and Tools pages. Chat: session snapshots, empty-session reuse, per-message model_id, unified JSON prediction suggestions. Settings: upload size limit, prediction options, model enable/disable. Schema additions with ensure_column migrations. Add FEATURES/ARCHITECTURE docs.
This commit is contained in:
xianrenge
2026-08-16 19:50:41 +08:00
parent bd3f648ccd
commit 10ecf526ec
33 changed files with 6241 additions and 390 deletions
+24 -1
View File
@@ -27,11 +27,13 @@ impl CoreApp {
let models_dir = data_dir.join("models");
let engines_dir = data_dir.join("engines");
let logs_dir = data_dir.join("logs");
let recommend_dir = data_dir.join("recommendations");
std::fs::create_dir_all(&data_dir)?;
std::fs::create_dir_all(&models_dir)?;
std::fs::create_dir_all(&engines_dir)?;
std::fs::create_dir_all(&logs_dir)?;
std::fs::create_dir_all(&recommend_dir)?;
let db_path = data_dir.join("xianren.db");
let conn = open_db(&db_path)?;
@@ -66,9 +68,25 @@ impl CoreApp {
("api_key", String::new()),
("api_enabled", "false".to_string()),
("upload_max_mb", "10".to_string()),
("auto_title", "true".to_string()),
("suggest_enabled", "true".to_string()),
("suggest_count", "3".to_string()),
(
"tavily_api_key",
"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".to_string(),
),
("tool_web_search_enabled", "true".to_string()),
(
"recommend_dir",
self.data_dir
.join("recommendations")
.to_string_lossy()
.to_string(),
),
];
for (key, value) in defaults {
let _ = settings::set(&db, key, &value);
// 仅补默认值,不覆盖用户已保存的设置
let _ = settings::insert_default(&db, key, &value);
}
}
}
@@ -83,9 +101,14 @@ fn open_db(path: &Path) -> Result<Connection> {
ensure_column(&conn, "models", "base_url", "TEXT")?;
ensure_column(&conn, "models", "api_key", "TEXT")?;
ensure_column(&conn, "models", "api_model", "TEXT")?;
ensure_column(&conn, "models", "enabled", "INTEGER NOT NULL DEFAULT 0")?;
ensure_column(&conn, "messages", "elapsed_ms", "INTEGER")?;
ensure_column(&conn, "messages", "first_token_ms", "INTEGER")?;
ensure_column(&conn, "messages", "images_json", "TEXT NOT NULL DEFAULT '[]'")?;
ensure_column(&conn, "messages", "model_id", "TEXT")?;
ensure_column(&conn, "conversations", "pinned", "INTEGER NOT NULL DEFAULT 0")?;
ensure_column(&conn, "conversations", "favorite", "INTEGER NOT NULL DEFAULT 0")?;
ensure_column(&conn, "conversations", "tools_json", "TEXT NOT NULL DEFAULT '[]'")?;
Ok(conn)
}
+2
View File
@@ -1,8 +1,10 @@
pub mod app;
pub mod error;
pub mod mcp_servers;
pub mod models;
pub mod sessions;
pub mod settings;
pub mod skills;
pub use app::CoreApp;
pub use error::{CoreError, Result};
+95
View File
@@ -0,0 +1,95 @@
use crate::error::Result;
use rusqlite::{params, Connection};
use serde::Serialize;
#[derive(Debug, Clone, Serialize)]
pub struct McpServer {
pub id: String,
pub name: String,
pub description: String,
pub url: String,
pub auth_token: String,
pub enabled: bool,
pub created_at: String,
}
pub fn list(db: &Connection) -> Result<Vec<McpServer>> {
let mut stmt = db.prepare(
"SELECT id, name, description, url, auth_token, enabled, created_at FROM mcp_servers ORDER BY created_at DESC",
)?;
let rows = stmt.query_map([], row_to_server)?;
let mut out = Vec::new();
for row in rows {
out.push(row?);
}
Ok(out)
}
pub fn get(db: &Connection, id: &str) -> Result<Option<McpServer>> {
let mut stmt = db.prepare(
"SELECT id, name, description, url, auth_token, enabled, created_at FROM mcp_servers WHERE id = ?1",
)?;
let mut rows = stmt.query_map(params![id], row_to_server)?;
match rows.next() {
Some(row) => Ok(Some(row?)),
None => Ok(None),
}
}
pub fn insert(
db: &Connection,
name: &str,
description: &str,
url: &str,
auth_token: &str,
enabled: bool,
) -> Result<String> {
let id = uuid::Uuid::new_v4().to_string();
db.execute(
"INSERT INTO mcp_servers (id, name, description, url, auth_token, enabled) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
params![id, name, description, url, auth_token, enabled as i32],
)?;
Ok(id)
}
pub fn update(
db: &Connection,
id: &str,
name: &str,
description: &str,
url: &str,
auth_token: &str,
enabled: bool,
) -> Result<()> {
db.execute(
"UPDATE mcp_servers SET name = ?1, description = ?2, url = ?3, auth_token = ?4, enabled = ?5 WHERE id = ?6",
params![name, description, url, auth_token, enabled as i32, id],
)?;
Ok(())
}
pub fn remove(db: &Connection, id: &str) -> Result<()> {
db.execute("DELETE FROM mcp_servers WHERE id = ?1", params![id])?;
Ok(())
}
pub fn set_enabled(db: &Connection, id: &str, enabled: bool) -> Result<()> {
db.execute(
"UPDATE mcp_servers SET enabled = ?1 WHERE id = ?2",
params![enabled as i32, id],
)?;
Ok(())
}
fn row_to_server(row: &rusqlite::Row<'_>) -> rusqlite::Result<McpServer> {
let enabled: i32 = row.get(5)?;
Ok(McpServer {
id: row.get(0)?,
name: row.get(1)?,
description: row.get(2)?,
url: row.get(3)?,
auth_token: row.get(4)?,
enabled: enabled != 0,
created_at: row.get(6)?,
})
}
+13 -2
View File
@@ -10,6 +10,7 @@ pub struct ModelInfo {
pub repo_id: String,
pub source: String,
pub kind: String,
pub enabled: bool,
pub file_name: String,
pub file_path: String,
pub file_size: i64,
@@ -73,7 +74,7 @@ pub fn insert(
pub fn list(db: &Connection) -> Result<Vec<ModelInfo>> {
let mut stmt = db.prepare(
"SELECT id, repo_id, source, kind, file_name, file_path, file_size, quant, family, status, sha256, base_url, api_key, api_model, meta_json, created_at
"SELECT id, repo_id, source, kind, file_name, file_path, file_size, quant, family, status, sha256, base_url, api_key, api_model, meta_json, created_at, enabled
FROM models ORDER BY created_at DESC",
)?;
let rows = stmt.query_map([], row_to_model)?;
@@ -86,7 +87,7 @@ pub fn list(db: &Connection) -> Result<Vec<ModelInfo>> {
pub fn get(db: &Connection, id: &str) -> Result<Option<ModelInfo>> {
let mut stmt = db.prepare(
"SELECT id, repo_id, source, kind, file_name, file_path, file_size, quant, family, status, sha256, base_url, api_key, api_model, meta_json, created_at
"SELECT id, repo_id, source, kind, file_name, file_path, file_size, quant, family, status, sha256, base_url, api_key, api_model, meta_json, created_at, enabled
FROM models WHERE id = ?1",
)?;
let mut rows = stmt.query_map(params![id], row_to_model)?;
@@ -101,6 +102,14 @@ pub fn remove(db: &Connection, id: &str) -> Result<()> {
Ok(())
}
pub fn set_enabled(db: &Connection, id: &str, enabled: bool) -> Result<()> {
db.execute(
"UPDATE models SET enabled = ?1 WHERE id = ?2",
params![enabled as i32, id],
)?;
Ok(())
}
pub fn update_file_info(db: &Connection, id: &str, size: i64, status: &str) -> Result<()> {
db.execute(
"UPDATE models SET file_size = ?1, status = ?2 WHERE id = ?3",
@@ -192,11 +201,13 @@ fn collect_gguf_files(dir: &Path, out: &mut Vec<PathBuf>) -> Result<()> {
fn row_to_model(row: &rusqlite::Row<'_>) -> rusqlite::Result<ModelInfo> {
let meta_json: String = row.get(14)?;
let enabled: i32 = row.get(16)?;
Ok(ModelInfo {
id: row.get(0)?,
repo_id: row.get(1)?,
source: row.get(2)?,
kind: row.get(3)?,
enabled: enabled != 0,
file_name: row.get(4)?,
file_path: row.get(5)?,
file_size: row.get(6)?,
+24
View File
@@ -3,6 +3,7 @@ CREATE TABLE IF NOT EXISTS models (
repo_id TEXT NOT NULL,
source TEXT NOT NULL DEFAULT 'local',
kind TEXT NOT NULL DEFAULT 'local',
enabled INTEGER NOT NULL DEFAULT 0,
file_name TEXT NOT NULL,
file_path TEXT NOT NULL,
file_size INTEGER NOT NULL DEFAULT 0,
@@ -27,6 +28,9 @@ CREATE TABLE IF NOT EXISTS conversations (
title TEXT NOT NULL DEFAULT '新会话',
model_id TEXT,
system_prompt TEXT,
pinned INTEGER NOT NULL DEFAULT 0,
favorite INTEGER NOT NULL DEFAULT 0,
tools_json TEXT NOT NULL DEFAULT '[]',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
@@ -35,6 +39,7 @@ CREATE TABLE IF NOT EXISTS messages (
id TEXT PRIMARY KEY,
conversation_id TEXT NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
role TEXT NOT NULL,
model_id TEXT,
content TEXT NOT NULL,
tokens_in INTEGER,
tokens_out INTEGER,
@@ -54,3 +59,22 @@ CREATE TABLE IF NOT EXISTS message_versions (
seq INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS skills (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
content TEXT NOT NULL DEFAULT '',
enabled INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS mcp_servers (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
url TEXT NOT NULL,
auth_token TEXT NOT NULL DEFAULT '',
enabled INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
+92 -11
View File
@@ -8,6 +8,9 @@ pub struct Conversation {
pub title: String,
pub model_id: Option<String>,
pub system_prompt: Option<String>,
pub pinned: bool,
pub favorite: bool,
pub tools: Vec<String>,
pub created_at: String,
pub updated_at: String,
}
@@ -17,6 +20,7 @@ pub struct Message {
pub id: String,
pub conversation_id: String,
pub role: String,
pub model_id: Option<String>,
pub content: String,
pub tokens_in: Option<i64>,
pub tokens_out: Option<i64>,
@@ -54,8 +58,8 @@ pub fn create_conversation(
pub fn list_conversations(db: &Connection) -> Result<Vec<Conversation>> {
let mut stmt = db.prepare(
"SELECT id, title, model_id, system_prompt, created_at, updated_at
FROM conversations ORDER BY updated_at DESC",
"SELECT id, title, model_id, system_prompt, pinned, favorite, tools_json, created_at, updated_at
FROM conversations ORDER BY pinned DESC, updated_at DESC",
)?;
let rows = stmt.query_map([], row_to_conversation)?;
let mut out = Vec::new();
@@ -67,7 +71,7 @@ pub fn list_conversations(db: &Connection) -> Result<Vec<Conversation>> {
pub fn get_conversation(db: &Connection, id: &str) -> Result<Option<Conversation>> {
let mut stmt = db.prepare(
"SELECT id, title, model_id, system_prompt, created_at, updated_at
"SELECT id, title, model_id, system_prompt, pinned, favorite, tools_json, created_at, updated_at
FROM conversations WHERE id = ?1",
)?;
let mut rows = stmt.query_map(params![id], row_to_conversation)?;
@@ -77,6 +81,22 @@ pub fn get_conversation(db: &Connection, id: &str) -> Result<Option<Conversation
}
}
/// 查找最近一个还没有任何消息的空会话(用于“新建对话”时复用,避免产生多个空会话)。
pub fn find_empty_conversation(db: &Connection) -> Result<Option<Conversation>> {
let mut stmt = db.prepare(
"SELECT c.id, c.title, c.model_id, c.system_prompt, c.pinned, c.favorite, c.tools_json, c.created_at, c.updated_at
FROM conversations c
WHERE NOT EXISTS (SELECT 1 FROM messages m WHERE m.conversation_id = c.id)
ORDER BY c.updated_at DESC
LIMIT 1",
)?;
let mut rows = stmt.query_map([], row_to_conversation)?;
match rows.next() {
Some(row) => Ok(Some(row?)),
None => Ok(None),
}
}
pub fn touch_conversation(db: &Connection, id: &str) -> Result<()> {
db.execute(
"UPDATE conversations SET updated_at = datetime('now') WHERE id = ?1",
@@ -93,6 +113,39 @@ pub fn update_conversation_model(db: &Connection, id: &str, model_id: &str) -> R
Ok(())
}
pub fn update_conversation_title(db: &Connection, id: &str, title: &str) -> Result<()> {
db.execute(
"UPDATE conversations SET title = ?1 WHERE id = ?2",
params![title, id],
)?;
Ok(())
}
pub fn set_conversation_pinned(db: &Connection, id: &str, pinned: bool) -> Result<()> {
db.execute(
"UPDATE conversations SET pinned = ?1 WHERE id = ?2",
params![pinned as i32, id],
)?;
Ok(())
}
pub fn set_conversation_favorite(db: &Connection, id: &str, favorite: bool) -> Result<()> {
db.execute(
"UPDATE conversations SET favorite = ?1 WHERE id = ?2",
params![favorite as i32, id],
)?;
Ok(())
}
pub fn update_conversation_tools(db: &Connection, id: &str, tools: &[String]) -> Result<()> {
let tools_json = serde_json::to_string(tools).unwrap_or_else(|_| "[]".to_string());
db.execute(
"UPDATE conversations SET tools_json = ?1 WHERE id = ?2",
params![tools_json, id],
)?;
Ok(())
}
pub fn delete_conversation(db: &Connection, id: &str) -> Result<()> {
db.execute("DELETE FROM conversations WHERE id = ?1", params![id])?;
Ok(())
@@ -102,6 +155,7 @@ pub fn add_message(
db: &Connection,
conversation_id: &str,
role: &str,
model_id: Option<&str>,
content: &str,
tokens_in: Option<i64>,
tokens_out: Option<i64>,
@@ -112,12 +166,13 @@ pub fn add_message(
let id = uuid::Uuid::new_v4().to_string();
let images_json = serde_json::to_string(images).unwrap_or_else(|_| "[]".to_string());
db.execute(
"INSERT INTO messages (id, conversation_id, role, content, tokens_in, tokens_out, elapsed_ms, first_token_ms, images_json)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
"INSERT INTO messages (id, conversation_id, role, model_id, content, tokens_in, tokens_out, elapsed_ms, first_token_ms, images_json)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
params![
id,
conversation_id,
role,
model_id,
content,
tokens_in,
tokens_out,
@@ -129,9 +184,28 @@ pub fn add_message(
Ok(id)
}
/// 导入对话时插入消息(可指定创建时间)。
pub fn import_message(
db: &Connection,
conversation_id: &str,
role: &str,
content: &str,
images: &[String],
created_at: Option<&str>,
) -> Result<String> {
let id = uuid::Uuid::new_v4().to_string();
let images_json = serde_json::to_string(images).unwrap_or_else(|_| "[]".to_string());
db.execute(
"INSERT INTO messages (id, conversation_id, role, content, images_json, created_at)
VALUES (?1, ?2, ?3, ?4, ?5, COALESCE(?6, datetime('now')))",
params![id, conversation_id, role, content, images_json, created_at],
)?;
Ok(id)
}
pub fn list_messages(db: &Connection, conversation_id: &str) -> Result<Vec<Message>> {
let mut stmt = db.prepare(
"SELECT id, conversation_id, role, content, tokens_in, tokens_out, elapsed_ms, first_token_ms, images_json, created_at
"SELECT id, conversation_id, role, content, tokens_in, tokens_out, elapsed_ms, first_token_ms, images_json, created_at, model_id
FROM messages WHERE conversation_id = ?1 ORDER BY rowid ASC",
)?;
let rows = stmt.query_map(params![conversation_id], row_to_message)?;
@@ -144,7 +218,7 @@ pub fn list_messages(db: &Connection, conversation_id: &str) -> Result<Vec<Messa
pub fn get_message(db: &Connection, id: &str) -> Result<Option<Message>> {
let mut stmt = db.prepare(
"SELECT id, conversation_id, role, content, tokens_in, tokens_out, elapsed_ms, first_token_ms, images_json, created_at
"SELECT id, conversation_id, role, content, tokens_in, tokens_out, elapsed_ms, first_token_ms, images_json, created_at, model_id
FROM messages WHERE id = ?1",
)?;
let mut rows = stmt.query_map(params![id], row_to_message)?;
@@ -156,11 +230,11 @@ pub fn get_message(db: &Connection, id: &str) -> Result<Option<Message>> {
pub fn get_message_with_rowid(db: &Connection, id: &str) -> Result<Option<(i64, Message)>> {
let mut stmt = db.prepare(
"SELECT id, conversation_id, role, content, tokens_in, tokens_out, elapsed_ms, first_token_ms, images_json, created_at, rowid
"SELECT id, conversation_id, role, content, tokens_in, tokens_out, elapsed_ms, first_token_ms, images_json, created_at, model_id, rowid
FROM messages WHERE id = ?1",
)?;
let mut rows = stmt.query_map(params![id], |row| {
let rowid: i64 = row.get(10)?;
let rowid: i64 = row.get(11)?;
let msg = row_to_message(row)?;
Ok((rowid, msg))
})?;
@@ -277,13 +351,19 @@ pub fn apply_message_version(
}
fn row_to_conversation(row: &rusqlite::Row<'_>) -> rusqlite::Result<Conversation> {
let pinned: i32 = row.get(4)?;
let favorite: i32 = row.get(5)?;
let tools_json: String = row.get(6)?;
Ok(Conversation {
id: row.get(0)?,
title: row.get(1)?,
model_id: row.get(2)?,
system_prompt: row.get(3)?,
created_at: row.get(4)?,
updated_at: row.get(5)?,
pinned: pinned != 0,
favorite: favorite != 0,
tools: serde_json::from_str(&tools_json).unwrap_or_default(),
created_at: row.get(7)?,
updated_at: row.get(8)?,
})
}
@@ -293,6 +373,7 @@ fn row_to_message(row: &rusqlite::Row<'_>) -> rusqlite::Result<Message> {
id: row.get(0)?,
conversation_id: row.get(1)?,
role: row.get(2)?,
model_id: row.get(10)?,
content: row.get(3)?,
tokens_in: row.get(4)?,
tokens_out: row.get(5)?,
+9 -1
View File
@@ -19,6 +19,15 @@ pub fn set(db: &Connection, key: &str, value: &str) -> Result<()> {
Ok(())
}
/// 仅当键不存在时写入默认值,避免覆盖用户已保存的设置。
pub fn insert_default(db: &Connection, key: &str, value: &str) -> Result<()> {
db.execute(
"INSERT OR IGNORE INTO settings (key, value) VALUES (?1, ?2)",
params![key, value],
)?;
Ok(())
}
pub fn all(db: &Connection) -> Result<Vec<(String, String)>> {
let mut stmt = db.prepare("SELECT key, value FROM settings ORDER BY key")?;
let rows = stmt.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)))?;
@@ -28,4 +37,3 @@ pub fn all(db: &Connection) -> Result<Vec<(String, String)>> {
}
Ok(out)
}
+119
View File
@@ -0,0 +1,119 @@
use crate::error::Result;
use rusqlite::{params, Connection};
use serde::Serialize;
#[derive(Debug, Clone, Serialize)]
pub struct Skill {
pub id: String,
pub name: String,
pub description: String,
pub content: String,
pub enabled: bool,
pub created_at: String,
}
pub fn list(db: &Connection) -> Result<Vec<Skill>> {
let mut stmt = db.prepare(
"SELECT id, name, description, content, enabled, created_at FROM skills ORDER BY created_at DESC",
)?;
let rows = stmt.query_map([], row_to_skill)?;
let mut out = Vec::new();
for row in rows {
out.push(row?);
}
Ok(out)
}
pub fn get(db: &Connection, id: &str) -> Result<Option<Skill>> {
let mut stmt = db.prepare(
"SELECT id, name, description, content, enabled, created_at FROM skills WHERE id = ?1",
)?;
let mut rows = stmt.query_map(params![id], row_to_skill)?;
match rows.next() {
Some(row) => Ok(Some(row?)),
None => Ok(None),
}
}
pub fn insert(
db: &Connection,
name: &str,
description: &str,
content: &str,
enabled: bool,
) -> Result<String> {
let id = uuid::Uuid::new_v4().to_string();
db.execute(
"INSERT INTO skills (id, name, description, content, enabled) VALUES (?1, ?2, ?3, ?4, ?5)",
params![id, name, description, content, enabled as i32],
)?;
Ok(id)
}
pub fn update(
db: &Connection,
id: &str,
name: &str,
description: &str,
content: &str,
enabled: bool,
) -> Result<()> {
db.execute(
"UPDATE skills SET name = ?1, description = ?2, content = ?3, enabled = ?4 WHERE id = ?5",
params![name, description, content, enabled as i32, id],
)?;
Ok(())
}
pub fn remove(db: &Connection, id: &str) -> Result<()> {
db.execute("DELETE FROM skills WHERE id = ?1", params![id])?;
Ok(())
}
pub fn set_enabled(db: &Connection, id: &str, enabled: bool) -> Result<()> {
db.execute(
"UPDATE skills SET enabled = ?1 WHERE id = ?2",
params![enabled as i32, id],
)?;
Ok(())
}
fn row_to_skill(row: &rusqlite::Row<'_>) -> rusqlite::Result<Skill> {
let enabled: i32 = row.get(4)?;
Ok(Skill {
id: row.get(0)?,
name: row.get(1)?,
description: row.get(2)?,
content: row.get(3)?,
enabled: enabled != 0,
created_at: row.get(5)?,
})
}
#[cfg(test)]
mod tests {
use super::*;
fn test_db() -> Connection {
let conn = Connection::open_in_memory().unwrap();
conn.execute_batch(include_str!("schema.sql")).unwrap();
conn
}
#[test]
fn skill_crud() {
let db = test_db();
let id = insert(&db, "翻译助手", "中英互译", "你是一位专业翻译。", true).unwrap();
let items = list(&db).unwrap();
assert_eq!(items.len(), 1);
assert_eq!(items[0].name, "翻译助手");
update(&db, &id, "翻译助手v2", "中英互译", "新内容", false).unwrap();
let s = get(&db, &id).unwrap().unwrap();
assert_eq!(s.content, "新内容");
assert!(!s.enabled);
set_enabled(&db, &id, true).unwrap();
assert!(get(&db, &id).unwrap().unwrap().enabled);
remove(&db, &id).unwrap();
assert!(list(&db).unwrap().is_empty());
}
}
+9 -3
View File
@@ -4,12 +4,14 @@ use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use std::time::Instant;
use std::time::{Duration, Instant};
use tokio::io::AsyncWriteExt;
use tokio::sync::Semaphore;
const DEFAULT_CHUNK_SIZE: u64 = 8 * 1024 * 1024;
const MAX_CONCURRENCY: usize = 8;
// 部分 CDN(如 ModelScope)会拦截非浏览器 User-Agent 的 GET 请求(403),需伪装浏览器标识
const BROWSER_UA: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36";
#[derive(Debug, Clone)]
pub struct DownloadOptions {
@@ -60,7 +62,12 @@ pub async fn download(
opts: DownloadOptions,
on_progress: impl Fn(DownloadProgress) + Send + Sync + 'static,
) -> Result<PathBuf> {
let client = reqwest::Client::new();
let client = reqwest::Client::builder()
.user_agent(BROWSER_UA)
.connect_timeout(Duration::from_secs(20))
.timeout(Duration::from_secs(300))
.build()
.unwrap_or_default();
if opts.dest.exists() {
if let Some(expected) = &opts.expected_sha256 {
@@ -313,4 +320,3 @@ fn report(
speed_bps: (downloaded as f64 / started.elapsed().as_secs_f64().max(0.001)) as u64,
});
}
+1 -1
View File
@@ -5,5 +5,5 @@ pub mod types;
pub use error::{EngineError, Result};
pub use manager::EngineManager;
pub use remote::{stream_chat_remote, RemoteConfig};
pub use remote::{chat_remote, stream_chat_remote, RemoteConfig};
pub use types::{ChatMessage, ChatRequest, ChatStreamEvent, EngineConfig, EngineStatus};
+5
View File
@@ -271,6 +271,11 @@ pub(crate) fn sse_text_stream(
.or_else(|| value.get("content").and_then(|v| v.as_str()));
if let Some(text) = content {
yield Ok(ChatStreamEvent::Text(text.to_string()));
} else if let Some(reasoning) = value
.pointer("/choices/0/delta/reasoning_content")
.and_then(|v| v.as_str())
{
yield Ok(ChatStreamEvent::Reasoning(reasoning.to_string()));
}
}
Err(e) => {
+26
View File
@@ -33,6 +33,32 @@ pub async fn stream_chat_remote(
Ok(sse_text_stream(response.bytes_stream()))
}
/// 调用远程 OpenAI 兼容 API 并返回完整的非流式文本。
pub async fn chat_remote(cfg: &RemoteConfig, req: ChatRequest) -> Result<String> {
let base = normalize_base(&cfg.base_url);
let url = format!("{base}/chat/completions");
let client = reqwest::Client::new();
let mut builder = client.post(&url).json(&req);
if let Some(key) = &cfg.api_key {
if !key.trim().is_empty() {
builder = builder.bearer_auth(key);
}
}
let response = builder.send().await?;
let status = response.status();
if !status.is_success() {
let body = response.text().await.unwrap_or_default();
return Err(EngineError::EngineHttp(status, body));
}
let value: serde_json::Value = response.json().await?;
let text = value
.pointer("/choices/0/message/content")
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string();
Ok(text)
}
/// 把用户填写的 base url 归一化为 `<scheme>://<host>/v1` 形式。
fn normalize_base(base: &str) -> String {
let mut s = base.trim().trim_end_matches('/').to_string();
+2
View File
@@ -55,6 +55,8 @@ impl Serialize for ChatMessage {
#[derive(Debug, Clone)]
pub enum ChatStreamEvent {
Text(String),
/// 推理模型(如 Qwen3.5/DeepSeek-R1)的思考过程增量文本
Reasoning(String),
Usage {
prompt_tokens: u32,
completion_tokens: u32,