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:
+24
-1
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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)?,
|
||||
})
|
||||
}
|
||||
@@ -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)?,
|
||||
|
||||
@@ -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
@@ -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)?,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user