Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
95d7ff1299 | ||
|
|
00624d81cf | ||
|
|
57df39e4ce | ||
|
|
496be1f032 |
Generated
+22
@@ -3743,6 +3743,12 @@ dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "symlink"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a7973cce6668464ea31f176d85b13c7ab3bba2cb3b77a2ed26abd7801688010a"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "1.0.109"
|
||||
@@ -4471,6 +4477,19 @@ dependencies = [
|
||||
"tracing-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-appender"
|
||||
version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "050686193eb999b4bb3bc2acfa891a13da00f79734704c4b8b4ef1a10b368a3c"
|
||||
dependencies = [
|
||||
"crossbeam-channel",
|
||||
"symlink",
|
||||
"thiserror 2.0.20",
|
||||
"time",
|
||||
"tracing-subscriber",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-attributes"
|
||||
version = "0.1.31"
|
||||
@@ -5522,8 +5541,10 @@ dependencies = [
|
||||
name = "xianren-desktop"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"dirs 5.0.1",
|
||||
"futures",
|
||||
"reqwest 0.12.28",
|
||||
"rusqlite",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
@@ -5531,6 +5552,7 @@ dependencies = [
|
||||
"tauri-plugin-opener",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tracing-appender",
|
||||
"tracing-subscriber",
|
||||
"uuid",
|
||||
"xianren-api",
|
||||
|
||||
@@ -19,6 +19,7 @@ serde_json = "1"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
tracing-appender = "0.2"
|
||||
thiserror = "2"
|
||||
anyhow = "1"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "rustls-tls"] }
|
||||
|
||||
@@ -21,12 +21,15 @@ tauri = { version = "2", features = [] }
|
||||
tauri-plugin-opener = "2"
|
||||
tracing-subscriber.workspace = true
|
||||
tracing.workspace = true
|
||||
tracing-appender.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
tokio.workspace = true
|
||||
reqwest.workspace = true
|
||||
futures.workspace = true
|
||||
uuid.workspace = true
|
||||
dirs.workspace = true
|
||||
rusqlite.workspace = true
|
||||
xianren-core = { path = "../../crates/core" }
|
||||
xianren-engine = { path = "../../crates/engine" }
|
||||
xianren-download = { path = "../../crates/download" }
|
||||
|
||||
@@ -19,6 +19,7 @@ pub struct AppInfo {
|
||||
pub version: String,
|
||||
pub platform: String,
|
||||
pub models_dir: String,
|
||||
pub logs_dir: String,
|
||||
pub engine_bin: Option<String>,
|
||||
pub engine_exists: bool,
|
||||
pub db_path: String,
|
||||
@@ -133,6 +134,7 @@ pub fn app_info(state: State<'_, App>) -> AppInfo {
|
||||
version: env!("CARGO_PKG_VERSION").to_string(),
|
||||
platform: std::env::consts::OS.to_string(),
|
||||
models_dir: state.core.models_dir.to_string_lossy().to_string(),
|
||||
logs_dir: state.core.logs_dir.to_string_lossy().to_string(),
|
||||
engine_exists: engine_bin
|
||||
.as_ref()
|
||||
.is_some_and(|p| PathBuf::from(p).exists()),
|
||||
@@ -215,10 +217,12 @@ pub fn list_conversations(state: State<'_, App>) -> Result<Vec<xianren_core::Con
|
||||
pub fn create_conversation(
|
||||
state: State<'_, App>,
|
||||
title: String,
|
||||
model_id: Option<String>,
|
||||
) -> Result<xianren_core::Conversation, String> {
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
let db = state.core.db.lock().unwrap();
|
||||
sessions_db::create_conversation(&db, &id, &title, None, None).map_err(|e| e.to_string())?;
|
||||
sessions_db::create_conversation(&db, &id, &title, model_id.as_deref(), None)
|
||||
.map_err(|e| e.to_string())?;
|
||||
sessions_db::get_conversation(&db, &id)
|
||||
.map_err(|e| e.to_string())?
|
||||
.ok_or_else(|| "conversation not found".to_string())
|
||||
@@ -458,12 +462,12 @@ pub async fn chat_send(
|
||||
app: AppHandle,
|
||||
state: State<'_, App>,
|
||||
payload: ChatSendPayload,
|
||||
) -> Result<(), String> {
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let core = state.core.clone();
|
||||
let engine = state.engine.clone();
|
||||
let base = state.engine_base.clone();
|
||||
|
||||
let (history, model, engine_bin) = {
|
||||
let (history, model, engine_bin, user_message_id) = {
|
||||
let db = core.db.lock().unwrap();
|
||||
if sessions_db::get_conversation(&db, &payload.conversation_id)
|
||||
.map_err(|e| e.to_string())?
|
||||
@@ -479,7 +483,10 @@ pub async fn chat_send(
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
sessions_db::add_message(
|
||||
// 始终把当前使用的模型写回会话,编辑/重新生成依赖它
|
||||
sessions_db::update_conversation_model(&db, &payload.conversation_id, &payload.model_id)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let user_message_id = sessions_db::add_message(
|
||||
&db,
|
||||
&payload.conversation_id,
|
||||
"user",
|
||||
@@ -508,7 +515,7 @@ pub async fn chat_send(
|
||||
let bin = settings_db::get(&db, "engine_bin")
|
||||
.map_err(|e| e.to_string())?
|
||||
.unwrap_or_default();
|
||||
(history, model, bin)
|
||||
(history, model, bin, user_message_id)
|
||||
};
|
||||
|
||||
let conversation_id = payload.conversation_id.clone();
|
||||
@@ -523,13 +530,16 @@ pub async fn chat_send(
|
||||
core,
|
||||
engine,
|
||||
base,
|
||||
conversation_id,
|
||||
conversation_id.clone(),
|
||||
history,
|
||||
model,
|
||||
engine_bin,
|
||||
payload.params,
|
||||
));
|
||||
Ok(())
|
||||
Ok(serde_json::json!({
|
||||
"conversation_id": conversation_id,
|
||||
"message_id": user_message_id,
|
||||
}))
|
||||
}
|
||||
|
||||
/// 统一的生成与流式转发:支持本地引擎 / 远程 API,统计首字延迟、token 数与耗时。
|
||||
@@ -719,6 +729,21 @@ fn estimate_tokens(text: &str) -> usize {
|
||||
score / 4 + 1
|
||||
}
|
||||
|
||||
/// 解析会话使用的模型:优先会话记录;未记录时若本地只有一个模型则自动使用,否则提示。
|
||||
fn resolve_conversation_model(
|
||||
db: &rusqlite::Connection,
|
||||
conversation: &xianren_core::Conversation,
|
||||
) -> Result<String, String> {
|
||||
if let Some(id) = &conversation.model_id {
|
||||
return Ok(id.clone());
|
||||
}
|
||||
let all = models_db::list(db).map_err(|e| e.to_string())?;
|
||||
if all.len() == 1 {
|
||||
return Ok(all[0].id.clone());
|
||||
}
|
||||
Err("会话未记录模型:请先在该会话发送一条消息,或重新选择模型后重试".into())
|
||||
}
|
||||
|
||||
/// 重新生成某条助手回复:旧内容存入版本历史,截断后重新生成。
|
||||
#[tauri::command]
|
||||
pub async fn regenerate_message(
|
||||
@@ -763,10 +788,7 @@ pub async fn regenerate_message(
|
||||
images: m.images.clone(),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let model_id = conversation
|
||||
.model_id
|
||||
.clone()
|
||||
.ok_or_else(|| "conversation has no model".to_string())?;
|
||||
let model_id = resolve_conversation_model(&db, &conversation)?;
|
||||
let model = models_db::get(&db, &model_id)
|
||||
.map_err(|e| e.to_string())?
|
||||
.ok_or_else(|| "model not found".to_string())?;
|
||||
@@ -830,10 +852,7 @@ pub async fn edit_message(
|
||||
images: m.images,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let model_id = conversation
|
||||
.model_id
|
||||
.clone()
|
||||
.ok_or_else(|| "conversation has no model".to_string())?;
|
||||
let model_id = resolve_conversation_model(&db, &conversation)?;
|
||||
let model = models_db::get(&db, &model_id)
|
||||
.map_err(|e| e.to_string())?
|
||||
.ok_or_else(|| "model not found".to_string())?;
|
||||
@@ -1413,3 +1432,10 @@ pub async fn server_status(state: State<'_, App>) -> Result<serde_json::Value, S
|
||||
pub fn open_path(path: String) -> Result<(), String> {
|
||||
tauri_plugin_opener::open_path(path, None::<&str>).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 前端上报运行时错误:写入本地日志便于排查。
|
||||
#[tauri::command]
|
||||
pub fn report_error(source: String, message: String) -> Result<(), String> {
|
||||
tracing::error!(source = %source, error = %message, "frontend reported error");
|
||||
Ok(())
|
||||
}
|
||||
@@ -17,11 +17,20 @@ pub struct App {
|
||||
}
|
||||
|
||||
pub fn run() {
|
||||
let data_dir = dirs::data_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
.join("XianrenStudio");
|
||||
let logs_dir = data_dir.join("logs");
|
||||
let _ = std::fs::create_dir_all(&logs_dir);
|
||||
let file_appender = tracing_appender::rolling::daily(&logs_dir, "app.log");
|
||||
let (non_blocking, _guard) = tracing_appender::non_blocking(file_appender);
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| "info".into()),
|
||||
)
|
||||
.with_writer(non_blocking)
|
||||
.with_ansi(false)
|
||||
.init();
|
||||
|
||||
tauri::Builder::default()
|
||||
@@ -115,6 +124,7 @@ pub fn run() {
|
||||
commands::edit_message,
|
||||
commands::list_message_versions,
|
||||
commands::apply_message_version,
|
||||
commands::report_error,
|
||||
commands::download_enqueue,
|
||||
commands::server_start,
|
||||
commands::server_stop,
|
||||
|
||||
@@ -85,6 +85,14 @@ pub fn touch_conversation(db: &Connection, id: &str) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn update_conversation_model(db: &Connection, id: &str, model_id: &str) -> Result<()> {
|
||||
db.execute(
|
||||
"UPDATE conversations SET model_id = ?1 WHERE id = ?2",
|
||||
params![model_id, id],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn delete_conversation(db: &Connection, id: &str) -> Result<()> {
|
||||
db.execute("DELETE FROM conversations WHERE id = ?1", params![id])?;
|
||||
Ok(())
|
||||
@@ -148,11 +156,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 rowid, 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, rowid
|
||||
FROM messages WHERE id = ?1",
|
||||
)?;
|
||||
let mut rows = stmt.query_map(params![id], |row| {
|
||||
let rowid: i64 = row.get(0)?;
|
||||
let rowid: i64 = row.get(10)?;
|
||||
let msg = row_to_message(row)?;
|
||||
Ok((rowid, msg))
|
||||
})?;
|
||||
|
||||
+35
-8
@@ -1,10 +1,28 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { invoke as rawInvoke } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
|
||||
/** 统一命令包装:失败时自动上报错误到本地日志,再抛出给界面。 */
|
||||
async function invoke<T>(command: string, args?: Record<string, unknown>): Promise<T> {
|
||||
try {
|
||||
return await rawInvoke<T>(command, args);
|
||||
} catch (error) {
|
||||
try {
|
||||
await rawInvoke("report_error", {
|
||||
source: command,
|
||||
message: String(error),
|
||||
});
|
||||
} catch {
|
||||
// 上报失败不影响主流程
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export interface AppInfo {
|
||||
version: string;
|
||||
platform: string;
|
||||
models_dir: string;
|
||||
logs_dir: string;
|
||||
engine_bin: string | null;
|
||||
engine_exists: boolean;
|
||||
db_path: string;
|
||||
@@ -176,8 +194,8 @@ export const api = {
|
||||
settingsSet: (key: string, value: string) =>
|
||||
invoke<void>("settings_set", { key, value }),
|
||||
listConversations: () => invoke<Conversation[]>("list_conversations"),
|
||||
createConversation: (title: string) =>
|
||||
invoke<Conversation>("create_conversation", { title }),
|
||||
createConversation: (title: string, modelId?: string) =>
|
||||
invoke<Conversation>("create_conversation", { title, modelId: modelId ?? null }),
|
||||
deleteConversation: (id: string) =>
|
||||
invoke<void>("delete_conversation", { id }),
|
||||
getMessages: (conversationId: string) =>
|
||||
@@ -188,12 +206,16 @@ export const api = {
|
||||
invoke<void>("deploy_model", { modelId, params }),
|
||||
engineStop: () => invoke<void>("engine_stop"),
|
||||
engineStatus: () => invoke<EngineStatus>("engine_status"),
|
||||
chatSend: (payload: ChatSendPayload) => invoke<string>("chat_send", { payload }),
|
||||
chatSend: (payload: ChatSendPayload) =>
|
||||
invoke<{ conversation_id: string; message_id: string }>("chat_send", { payload }),
|
||||
regenerateMessage: (
|
||||
conversationId: string,
|
||||
messageId: string,
|
||||
params: ChatParams,
|
||||
) => invoke<void>("regenerate_message", { payload: { conversationId, messageId, params } }),
|
||||
) =>
|
||||
invoke<void>("regenerate_message", {
|
||||
payload: { conversation_id: conversationId, message_id: messageId, params },
|
||||
}),
|
||||
editMessage: (
|
||||
conversationId: string,
|
||||
messageId: string,
|
||||
@@ -201,7 +223,12 @@ export const api = {
|
||||
params: ChatParams,
|
||||
) =>
|
||||
invoke<void>("edit_message", {
|
||||
payload: { conversationId, messageId, content, params },
|
||||
payload: {
|
||||
conversation_id: conversationId,
|
||||
message_id: messageId,
|
||||
content,
|
||||
params,
|
||||
},
|
||||
}),
|
||||
listMessageVersions: (messageId: string) =>
|
||||
invoke<MessageVersion[]>("list_message_versions", { messageId }),
|
||||
@@ -220,9 +247,9 @@ export const api = {
|
||||
invoke<string>("download_enqueue", {
|
||||
payload: {
|
||||
url,
|
||||
fileName,
|
||||
file_name: fileName,
|
||||
sha256: sha256 ?? null,
|
||||
repoId: repoId ?? null,
|
||||
repo_id: repoId ?? null,
|
||||
source: source ?? null,
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -219,21 +219,27 @@ export default function ChatPage() {
|
||||
await refreshConversations();
|
||||
}
|
||||
|
||||
const tempUserId = `user-${Date.now()}`;
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{ id: `user-${Date.now()}`, role: "user", content, images },
|
||||
{ id: tempUserId, role: "user", content, images },
|
||||
{ id: `assistant-${Date.now()}`, role: "assistant", content: "", streaming: true },
|
||||
]);
|
||||
setStreaming(true);
|
||||
|
||||
try {
|
||||
await api.chatSend({
|
||||
const result = await api.chatSend({
|
||||
conversation_id: convId,
|
||||
model_id: selectedModelId,
|
||||
content,
|
||||
params,
|
||||
images,
|
||||
});
|
||||
if (result?.message_id) {
|
||||
setMessages((prev) =>
|
||||
prev.map((m) => (m.id === tempUserId ? { ...m, id: result.message_id } : m)),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
setStreaming(false);
|
||||
setError(String(e));
|
||||
@@ -325,7 +331,7 @@ export default function ChatPage() {
|
||||
}
|
||||
|
||||
async function handleNewConversation() {
|
||||
const conv = await api.createConversation("新会话");
|
||||
const conv = await api.createConversation("新会话", selectedModelId || undefined);
|
||||
setActiveConvId(conv.id);
|
||||
setMessages([]);
|
||||
setError(null);
|
||||
|
||||
@@ -30,6 +30,19 @@ export default function SettingsPage() {
|
||||
<div>
|
||||
数据目录:<span className="text-slate-300">{info?.db_path}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span>
|
||||
日志目录:<span className="text-slate-300">{info?.logs_dir}</span>
|
||||
</span>
|
||||
{info?.logs_dir ? (
|
||||
<button
|
||||
className="text-xs text-slate-400 hover:text-slate-200"
|
||||
onClick={() => api.openPath(info.logs_dir!)}
|
||||
>
|
||||
打开
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<div>
|
||||
引擎可用:
|
||||
{info?.engine_exists ? (
|
||||
@@ -103,4 +116,3 @@ function SettingInput({
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user