Compare commits

...
5 Commits
10 changed files with 167 additions and 34 deletions
Generated
+22
View File
@@ -3743,6 +3743,12 @@ dependencies = [
"serde_json", "serde_json",
] ]
[[package]]
name = "symlink"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7973cce6668464ea31f176d85b13c7ab3bba2cb3b77a2ed26abd7801688010a"
[[package]] [[package]]
name = "syn" name = "syn"
version = "1.0.109" version = "1.0.109"
@@ -4471,6 +4477,19 @@ dependencies = [
"tracing-core", "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]] [[package]]
name = "tracing-attributes" name = "tracing-attributes"
version = "0.1.31" version = "0.1.31"
@@ -5522,8 +5541,10 @@ dependencies = [
name = "xianren-desktop" name = "xianren-desktop"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"dirs 5.0.1",
"futures", "futures",
"reqwest 0.12.28", "reqwest 0.12.28",
"rusqlite",
"serde", "serde",
"serde_json", "serde_json",
"tauri", "tauri",
@@ -5531,6 +5552,7 @@ dependencies = [
"tauri-plugin-opener", "tauri-plugin-opener",
"tokio", "tokio",
"tracing", "tracing",
"tracing-appender",
"tracing-subscriber", "tracing-subscriber",
"uuid", "uuid",
"xianren-api", "xianren-api",
+1
View File
@@ -19,6 +19,7 @@ serde_json = "1"
tokio = { version = "1", features = ["full"] } tokio = { version = "1", features = ["full"] }
tracing = "0.1" tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] } tracing-subscriber = { version = "0.3", features = ["env-filter"] }
tracing-appender = "0.2"
thiserror = "2" thiserror = "2"
anyhow = "1" anyhow = "1"
reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "rustls-tls"] } reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "rustls-tls"] }
+3
View File
@@ -21,12 +21,15 @@ tauri = { version = "2", features = [] }
tauri-plugin-opener = "2" tauri-plugin-opener = "2"
tracing-subscriber.workspace = true tracing-subscriber.workspace = true
tracing.workspace = true tracing.workspace = true
tracing-appender.workspace = true
serde.workspace = true serde.workspace = true
serde_json.workspace = true serde_json.workspace = true
tokio.workspace = true tokio.workspace = true
reqwest.workspace = true reqwest.workspace = true
futures.workspace = true futures.workspace = true
uuid.workspace = true uuid.workspace = true
dirs.workspace = true
rusqlite.workspace = true
xianren-core = { path = "../../crates/core" } xianren-core = { path = "../../crates/core" }
xianren-engine = { path = "../../crates/engine" } xianren-engine = { path = "../../crates/engine" }
xianren-download = { path = "../../crates/download" } xianren-download = { path = "../../crates/download" }
+43 -16
View File
@@ -19,6 +19,7 @@ pub struct AppInfo {
pub version: String, pub version: String,
pub platform: String, pub platform: String,
pub models_dir: String, pub models_dir: String,
pub logs_dir: String,
pub engine_bin: Option<String>, pub engine_bin: Option<String>,
pub engine_exists: bool, pub engine_exists: bool,
pub db_path: String, pub db_path: String,
@@ -133,6 +134,7 @@ pub fn app_info(state: State<'_, App>) -> AppInfo {
version: env!("CARGO_PKG_VERSION").to_string(), version: env!("CARGO_PKG_VERSION").to_string(),
platform: std::env::consts::OS.to_string(), platform: std::env::consts::OS.to_string(),
models_dir: state.core.models_dir.to_string_lossy().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 engine_exists: engine_bin
.as_ref() .as_ref()
.is_some_and(|p| PathBuf::from(p).exists()), .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( pub fn create_conversation(
state: State<'_, App>, state: State<'_, App>,
title: String, title: String,
model_id: Option<String>,
) -> Result<xianren_core::Conversation, String> { ) -> Result<xianren_core::Conversation, String> {
let id = uuid::Uuid::new_v4().to_string(); let id = uuid::Uuid::new_v4().to_string();
let db = state.core.db.lock().unwrap(); 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) sessions_db::get_conversation(&db, &id)
.map_err(|e| e.to_string())? .map_err(|e| e.to_string())?
.ok_or_else(|| "conversation not found".to_string()) .ok_or_else(|| "conversation not found".to_string())
@@ -458,12 +462,12 @@ pub async fn chat_send(
app: AppHandle, app: AppHandle,
state: State<'_, App>, state: State<'_, App>,
payload: ChatSendPayload, payload: ChatSendPayload,
) -> Result<(), String> { ) -> Result<serde_json::Value, String> {
let core = state.core.clone(); let core = state.core.clone();
let engine = state.engine.clone(); let engine = state.engine.clone();
let base = state.engine_base.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(); let db = core.db.lock().unwrap();
if sessions_db::get_conversation(&db, &payload.conversation_id) if sessions_db::get_conversation(&db, &payload.conversation_id)
.map_err(|e| e.to_string())? .map_err(|e| e.to_string())?
@@ -479,7 +483,10 @@ pub async fn chat_send(
) )
.map_err(|e| e.to_string())?; .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, &db,
&payload.conversation_id, &payload.conversation_id,
"user", "user",
@@ -508,7 +515,7 @@ pub async fn chat_send(
let bin = settings_db::get(&db, "engine_bin") let bin = settings_db::get(&db, "engine_bin")
.map_err(|e| e.to_string())? .map_err(|e| e.to_string())?
.unwrap_or_default(); .unwrap_or_default();
(history, model, bin) (history, model, bin, user_message_id)
}; };
let conversation_id = payload.conversation_id.clone(); let conversation_id = payload.conversation_id.clone();
@@ -523,13 +530,16 @@ pub async fn chat_send(
core, core,
engine, engine,
base, base,
conversation_id, conversation_id.clone(),
history, history,
model, model,
engine_bin, engine_bin,
payload.params, payload.params,
)); ));
Ok(()) Ok(serde_json::json!({
"conversation_id": conversation_id,
"message_id": user_message_id,
}))
} }
/// 统一的生成与流式转发:支持本地引擎 / 远程 API,统计首字延迟、token 数与耗时。 /// 统一的生成与流式转发:支持本地引擎 / 远程 API,统计首字延迟、token 数与耗时。
@@ -719,6 +729,21 @@ fn estimate_tokens(text: &str) -> usize {
score / 4 + 1 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] #[tauri::command]
pub async fn regenerate_message( pub async fn regenerate_message(
@@ -763,10 +788,7 @@ pub async fn regenerate_message(
images: m.images.clone(), images: m.images.clone(),
}) })
.collect::<Vec<_>>(); .collect::<Vec<_>>();
let model_id = conversation let model_id = resolve_conversation_model(&db, &conversation)?;
.model_id
.clone()
.ok_or_else(|| "conversation has no model".to_string())?;
let model = models_db::get(&db, &model_id) let model = models_db::get(&db, &model_id)
.map_err(|e| e.to_string())? .map_err(|e| e.to_string())?
.ok_or_else(|| "model not found".to_string())?; .ok_or_else(|| "model not found".to_string())?;
@@ -819,7 +841,8 @@ pub async fn edit_message(
} }
sessions_db::update_message_content(&db, &payload.message_id, &payload.content) sessions_db::update_message_content(&db, &payload.message_id, &payload.content)
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
sessions_db::delete_messages_after(&db, &payload.conversation_id, rowid) // 清空该提问之后的所有生成内容,保留编辑后的提问本身
sessions_db::delete_messages_strictly_after(&db, &payload.conversation_id, rowid)
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
let history = sessions_db::list_messages(&db, &payload.conversation_id) let history = sessions_db::list_messages(&db, &payload.conversation_id)
.map_err(|e| e.to_string())? .map_err(|e| e.to_string())?
@@ -830,10 +853,7 @@ pub async fn edit_message(
images: m.images, images: m.images,
}) })
.collect::<Vec<_>>(); .collect::<Vec<_>>();
let model_id = conversation let model_id = resolve_conversation_model(&db, &conversation)?;
.model_id
.clone()
.ok_or_else(|| "conversation has no model".to_string())?;
let model = models_db::get(&db, &model_id) let model = models_db::get(&db, &model_id)
.map_err(|e| e.to_string())? .map_err(|e| e.to_string())?
.ok_or_else(|| "model not found".to_string())?; .ok_or_else(|| "model not found".to_string())?;
@@ -1413,3 +1433,10 @@ pub async fn server_status(state: State<'_, App>) -> Result<serde_json::Value, S
pub fn open_path(path: String) -> Result<(), String> { pub fn open_path(path: String) -> Result<(), String> {
tauri_plugin_opener::open_path(path, None::<&str>).map_err(|e| e.to_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(())
}
+10
View File
@@ -17,11 +17,20 @@ pub struct App {
} }
pub fn run() { 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() tracing_subscriber::fmt()
.with_env_filter( .with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env() tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "info".into()), .unwrap_or_else(|_| "info".into()),
) )
.with_writer(non_blocking)
.with_ansi(false)
.init(); .init();
tauri::Builder::default() tauri::Builder::default()
@@ -115,6 +124,7 @@ pub fn run() {
commands::edit_message, commands::edit_message,
commands::list_message_versions, commands::list_message_versions,
commands::apply_message_version, commands::apply_message_version,
commands::report_error,
commands::download_enqueue, commands::download_enqueue,
commands::server_start, commands::server_start,
commands::server_stop, commands::server_stop,
+1
View File
@@ -65,6 +65,7 @@ impl CoreApp {
("api_port", "1234".to_string()), ("api_port", "1234".to_string()),
("api_key", String::new()), ("api_key", String::new()),
("api_enabled", "false".to_string()), ("api_enabled", "false".to_string()),
("upload_max_mb", "10".to_string()),
]; ];
for (key, value) in defaults { for (key, value) in defaults {
let _ = settings::set(&db, key, &value); let _ = settings::set(&db, key, &value);
+23 -2
View File
@@ -85,6 +85,14 @@ pub fn touch_conversation(db: &Connection, id: &str) -> Result<()> {
Ok(()) 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<()> { pub fn delete_conversation(db: &Connection, id: &str) -> Result<()> {
db.execute("DELETE FROM conversations WHERE id = ?1", params![id])?; db.execute("DELETE FROM conversations WHERE id = ?1", params![id])?;
Ok(()) 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)>> { pub fn get_message_with_rowid(db: &Connection, id: &str) -> Result<Option<(i64, Message)>> {
let mut stmt = db.prepare( 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", FROM messages WHERE id = ?1",
)?; )?;
let mut rows = stmt.query_map(params![id], |row| { 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)?; let msg = row_to_message(row)?;
Ok((rowid, msg)) Ok((rowid, msg))
})?; })?;
@@ -193,6 +201,19 @@ pub fn delete_messages_after(db: &Connection, conversation_id: &str, rowid: i64)
Ok(()) Ok(())
} }
/// 删除某条消息之后(不含该消息)的所有消息。
pub fn delete_messages_strictly_after(
db: &Connection,
conversation_id: &str,
rowid: i64,
) -> Result<()> {
db.execute(
"DELETE FROM messages WHERE conversation_id = ?1 AND rowid > ?2",
params![conversation_id, rowid],
)?;
Ok(())
}
pub fn save_message_version( pub fn save_message_version(
db: &Connection, db: &Connection,
message_id: &str, message_id: &str,
+23 -4
View File
@@ -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"; 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 { export interface AppInfo {
version: string; version: string;
platform: string; platform: string;
models_dir: string; models_dir: string;
logs_dir: string;
engine_bin: string | null; engine_bin: string | null;
engine_exists: boolean; engine_exists: boolean;
db_path: string; db_path: string;
@@ -176,8 +194,8 @@ export const api = {
settingsSet: (key: string, value: string) => settingsSet: (key: string, value: string) =>
invoke<void>("settings_set", { key, value }), invoke<void>("settings_set", { key, value }),
listConversations: () => invoke<Conversation[]>("list_conversations"), listConversations: () => invoke<Conversation[]>("list_conversations"),
createConversation: (title: string) => createConversation: (title: string, modelId?: string) =>
invoke<Conversation>("create_conversation", { title }), invoke<Conversation>("create_conversation", { title, modelId: modelId ?? null }),
deleteConversation: (id: string) => deleteConversation: (id: string) =>
invoke<void>("delete_conversation", { id }), invoke<void>("delete_conversation", { id }),
getMessages: (conversationId: string) => getMessages: (conversationId: string) =>
@@ -188,7 +206,8 @@ export const api = {
invoke<void>("deploy_model", { modelId, params }), invoke<void>("deploy_model", { modelId, params }),
engineStop: () => invoke<void>("engine_stop"), engineStop: () => invoke<void>("engine_stop"),
engineStatus: () => invoke<EngineStatus>("engine_status"), 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: ( regenerateMessage: (
conversationId: string, conversationId: string,
messageId: string, messageId: string,
+23 -11
View File
@@ -50,9 +50,6 @@ const TEXT_EXTENSIONS = new Set([
"yml", "toml", "ini", "sh", "ps1", "bat", "sql", "env", "gitignore", "yml", "toml", "ini", "sh", "ps1", "bat", "sql", "env", "gitignore",
]); ]);
const MAX_IMAGE_SIZE = 3 * 1024 * 1024;
const MAX_TEXT_SIZE = 100 * 1024;
export default function ChatPage() { export default function ChatPage() {
const models = useStore((s) => s.models); const models = useStore((s) => s.models);
const conversations = useStore((s) => s.conversations); const conversations = useStore((s) => s.conversations);
@@ -75,6 +72,7 @@ export default function ChatPage() {
const [editingDraft, setEditingDraft] = useState(""); const [editingDraft, setEditingDraft] = useState("");
const [versionsFor, setVersionsFor] = useState<Record<string, MessageVersion[]>>({}); const [versionsFor, setVersionsFor] = useState<Record<string, MessageVersion[]>>({});
const [attachments, setAttachments] = useState<Attachment[]>([]); const [attachments, setAttachments] = useState<Attachment[]>([]);
const [uploadLimitMb, setUploadLimitMb] = useState(10);
const bottomRef = useRef<HTMLDivElement>(null); const bottomRef = useRef<HTMLDivElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
@@ -104,6 +102,13 @@ export default function ChatPage() {
} }
}, [models, selectedModelId]); }, [models, selectedModelId]);
useEffect(() => {
api.settingsGet().then((s) => {
const v = Number(s.upload_max_mb);
if (v > 0) setUploadLimitMb(v);
});
}, []);
useEffect(() => { useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: "smooth" }); bottomRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages]); }, [messages]);
@@ -219,21 +224,27 @@ export default function ChatPage() {
await refreshConversations(); await refreshConversations();
} }
const tempUserId = `user-${Date.now()}`;
setMessages((prev) => [ setMessages((prev) => [
...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 }, { id: `assistant-${Date.now()}`, role: "assistant", content: "", streaming: true },
]); ]);
setStreaming(true); setStreaming(true);
try { try {
await api.chatSend({ const result = await api.chatSend({
conversation_id: convId, conversation_id: convId,
model_id: selectedModelId, model_id: selectedModelId,
content, content,
params, params,
images, images,
}); });
if (result?.message_id) {
setMessages((prev) =>
prev.map((m) => (m.id === tempUserId ? { ...m, id: result.message_id } : m)),
);
}
} catch (e) { } catch (e) {
setStreaming(false); setStreaming(false);
setError(String(e)); setError(String(e));
@@ -296,11 +307,12 @@ export default function ChatPage() {
} }
async function handleFiles(files: FileList | File[]) { async function handleFiles(files: FileList | File[]) {
const maxBytes = uploadLimitMb * 1024 * 1024;
for (const file of Array.from(files)) { for (const file of Array.from(files)) {
const ext = file.name.split(".").pop()?.toLowerCase() ?? ""; const ext = file.name.split(".").pop()?.toLowerCase() ?? "";
if (file.type.startsWith("image/") || ["png", "jpg", "jpeg", "webp", "gif", "bmp"].includes(ext)) { if (file.type.startsWith("image/") || ["png", "jpg", "jpeg", "webp", "gif", "bmp"].includes(ext)) {
if (file.size > MAX_IMAGE_SIZE) { if (file.size > maxBytes) {
setError(`图片 ${file.name} 超过 3MB,已跳过`); setError(`图片 ${file.name} 超过上限 ${uploadLimitMb}MB,已跳过`);
continue; continue;
} }
const dataUrl = await readAsDataUrl(file); const dataUrl = await readAsDataUrl(file);
@@ -309,8 +321,8 @@ export default function ChatPage() {
{ id: crypto.randomUUID(), kind: "image", name: file.name, size: file.size, dataUrl }, { id: crypto.randomUUID(), kind: "image", name: file.name, size: file.size, dataUrl },
]); ]);
} else if (file.type.startsWith("text/") || TEXT_EXTENSIONS.has(ext)) { } else if (file.type.startsWith("text/") || TEXT_EXTENSIONS.has(ext)) {
if (file.size > MAX_TEXT_SIZE) { if (file.size > maxBytes) {
setError(`文件 ${file.name} 超过 100KB,已跳过`); setError(`文件 ${file.name} 超过上限 ${uploadLimitMb}MB,已跳过`);
continue; continue;
} }
const text = await file.text(); const text = await file.text();
@@ -325,7 +337,7 @@ export default function ChatPage() {
} }
async function handleNewConversation() { async function handleNewConversation() {
const conv = await api.createConversation("新会话"); const conv = await api.createConversation("新会话", selectedModelId || undefined);
setActiveConvId(conv.id); setActiveConvId(conv.id);
setMessages([]); setMessages([]);
setError(null); setError(null);
@@ -462,7 +474,7 @@ export default function ChatPage() {
<button <button
className="mb-1 flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-border bg-panel-2 text-slate-400 hover:bg-panel-2/60 hover:text-slate-200" className="mb-1 flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-border bg-panel-2 text-slate-400 hover:bg-panel-2/60 hover:text-slate-200"
onClick={() => fileInputRef.current?.click()} onClick={() => fileInputRef.current?.click()}
title="上传图片或文本文件" title={`上传图片或文本文件(上限 ${uploadLimitMb}MB`}
> >
<Icon name="paperclip" className="h-4 w-4" /> <Icon name="paperclip" className="h-4 w-4" />
</button> </button>
+18 -1
View File
@@ -30,6 +30,19 @@ export default function SettingsPage() {
<div> <div>
<span className="text-slate-300">{info?.db_path}</span> <span className="text-slate-300">{info?.db_path}</span>
</div> </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> <div>
{info?.engine_exists ? ( {info?.engine_exists ? (
@@ -64,6 +77,11 @@ export default function SettingsPage() {
value={settings.backend ?? ""} value={settings.backend ?? ""}
onSave={(v) => handleSave("backend", v)} onSave={(v) => handleSave("backend", v)}
/> />
<SettingInput
label="上传图片/文件大小上限(MB"
value={settings.upload_max_mb ?? "10"}
onSave={(v) => handleSave("upload_max_mb", v)}
/>
</div> </div>
</div> </div>
@@ -103,4 +121,3 @@ function SettingInput({
</label> </label>
); );
} }