Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 41 additions & 13 deletions apps/desktop/src-tauri/src/commands/chat.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ use crate::assistant_prompts::main_chat_system;
use crate::commands::chat_tools::{build_chat_tools, dispatch_tool};
use crate::commands::memory::is_long_term_memory_enabled;
use crate::llm::{
resolve_model, resolve_temperature, LlmClient, LlmImage, LlmMessage, StreamOutcome,
explain_vision_error, resolve_model, resolve_temperature, LlmClient, LlmImage, LlmMessage,
StreamOutcome,
};
use crate::services::agent_runtime_service::{
run_agent_runtime, AgentRuntimeKind, AgentRuntimeRequest,
Expand All @@ -22,6 +23,31 @@ use std::collections::HashMap;
use tauri::{Emitter, State};
use uuid::Uuid;

fn scoped_context_type(context_type: Option<&str>, has_context_id: bool) -> String {
match (context_type, has_context_id) {
(Some("interest"), true) => "interest".to_string(),
(Some("paper"), true) => "paper".to_string(),
_ => "general".to_string(),
}
}

#[cfg(test)]
mod tests {
use super::scoped_context_type;

#[test]
fn preserves_supported_scoped_contexts() {
assert_eq!(scoped_context_type(Some("interest"), true), "interest");
assert_eq!(scoped_context_type(Some("paper"), true), "paper");
}

#[test]
fn falls_back_to_general_without_a_scope_id() {
assert_eq!(scoped_context_type(Some("paper"), false), "general");
assert_eq!(scoped_context_type(Some("unknown"), true), "general");
}
}

/// 前端 chat_stream 传入的图片块:data 为 base64(不含 data: 前缀),mediaType 为 MIME 类型。
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
Expand Down Expand Up @@ -136,11 +162,8 @@ pub async fn chat_update_session_context(
Some(trimmed)
}
});
let normalized_context_type = if context_type == "interest" && normalized_context_id.is_some() {
"interest".to_string()
} else {
"general".to_string()
};
let normalized_context_type =
scoped_context_type(Some(&context_type), normalized_context_id.is_some());

sqlx::query(
"UPDATE chat_sessions SET context_type = ?, context_id = ?, updated_at = ? WHERE id = ?",
Expand Down Expand Up @@ -287,12 +310,7 @@ pub async fn chat_stream(
Some(trimmed)
}
});
let ctx_type = if context_type.as_deref() == Some("interest") && normalized_context_id.is_some()
{
"interest".to_string()
} else {
"general".to_string()
};
let ctx_type = scoped_context_type(context_type.as_deref(), normalized_context_id.is_some());

let sid = if let Some(id) = session_id {
let _ = sqlx::query(
Expand Down Expand Up @@ -369,6 +387,11 @@ pub async fn chat_stream(
let ctx_type_clone = ctx_type.clone();
let context_id_clone = normalized_context_id.clone();
let chat_handles = state.chat_handles.clone();
let uses_vision = !images.is_empty() || history.iter().any(|item| !item.images.is_empty());
let vision_model = settings
.get("vision_model")
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty());

let handle = tokio::spawn(async move {
let result = run_chat(
Expand All @@ -388,6 +411,11 @@ pub async fn chat_stream(

if let Err(e) = result {
let error_message = e.to_string();
let visible_error = if uses_vision {
explain_vision_error(&error_message, vision_model.as_deref())
} else {
error_message.clone()
};
if long_term_memory_enabled {
let _ = crate::commands::memory::record_chat_failure_event(
&db,
Expand All @@ -413,7 +441,7 @@ pub async fn chat_stream(
}
let _ = app.emit(
"chat:error",
json!({ "request_id": rid, "error": error_message }),
json!({ "request_id": rid, "error": visible_error }),
);
}
let _ = app.emit("chat:done", json!({ "request_id": rid }));
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src-tauri/src/llm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ use std::sync::OnceLock;
mod shared;
mod transport;

pub(crate) use self::shared::explain_vision_error;
use self::shared::{
build_anthropic_tools, build_anthropic_user_messages,
build_message_array as build_message_array_impl,
Expand Down
79 changes: 79 additions & 0 deletions apps/desktop/src-tauri/src/llm/shared.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,62 @@ pub(super) fn compact_preview(text: &str, max_chars: usize) -> String {
.join(" ")
}

/// 将视觉模型的网关错误转换为可操作的中文提示;未知错误保留原文供排查。
pub(crate) fn explain_vision_error(error: &str, model: Option<&str>) -> String {
let lower = error.to_ascii_lowercase();
let model = model.map(str::trim).filter(|value| !value.is_empty());

if lower.contains("no endpoints found that support image input")
|| lower.contains("does not support image input")
|| lower.contains("image input is not supported")
{
let target = model
.map(|value| format!("视觉模型「{}」", value))
.unwrap_or_else(|| "当前使用的模型".to_string());
return format!(
"{}不支持图片输入。请前往「设置 → 模型角色 → 视界·视觉」更换多模态模型,并通过连接测试后重试。",
target
);
}

if lower.contains("html") || lower.contains("<html") || lower.contains("<!doctype") {
return "接口地址返回了网页而不是 API。请检查视觉模型接口地址是否正确,通常需要以 /v1 结尾。"
.to_string();
}

if lower.contains("401") || lower.contains("unauthorized") || lower.contains("invalid api key")
{
return "视觉模型的 API Key 无效或没有权限。请在设置中检查密钥。".to_string();
}

if lower.contains("403") || lower.contains("forbidden") {
return "当前账号没有权限访问该视觉模型。请检查密钥权限或更换模型。".to_string();
}

if lower.contains("model") && (lower.contains("not exist") || lower.contains("not found")) {
return model
.map(|value| format!("视觉模型「{}」不存在。请检查模型名称。", value))
.unwrap_or_else(|| "视觉模型不存在。请检查模型名称。".to_string());
}

if lower.contains("404") || lower.contains("not found") {
return "视觉模型的接口地址或模型名称不存在。请检查设置。".to_string();
}

if lower.contains("timeout") || lower.contains("timed out") {
return "图片解读请求超时。请检查网络或稍后重试。".to_string();
}

if lower.contains("connection refused")
|| lower.contains("dns error")
|| lower.contains("could not connect")
{
return "无法连接视觉模型接口。请检查网络和接口地址。".to_string();
}

error.trim().to_string()
}

pub(super) fn build_message_array(messages: &[LlmMessage]) -> serde_json::Value {
json!(messages
.iter()
Expand Down Expand Up @@ -186,3 +242,26 @@ pub(super) fn extract_anthropic_response_text(
preview
))
}

#[cfg(test)]
mod tests {
use super::explain_vision_error;

#[test]
fn explains_unsupported_image_input_with_next_step() {
let error = r#"LLM streaming API error: HTTP 404 {"error":{"message":"No endpoints found that support image input"}}"#;
let message = explain_vision_error(error, Some("text-only-model"));

assert!(message.contains("不支持图片输入"));
assert!(message.contains("视界·视觉"));
assert!(message.contains("text-only-model"));
}

#[test]
fn keeps_unknown_vision_errors_for_diagnostics() {
assert_eq!(
explain_vision_error("unexpected upstream failure", None),
"unexpected upstream failure"
);
}
}
59 changes: 4 additions & 55 deletions apps/desktop/src-tauri/src/services/settings_service.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
use crate::llm::{anthropic_auth_header, is_anthropic_compatible_base_url, LlmClient};
use crate::llm::{
anthropic_auth_header, explain_vision_error, is_anthropic_compatible_base_url, LlmClient,
};
use crate::repositories::settings_repository::{
delete_settings_history, get_settings_history, insert_settings_history, list_settings_history,
load_all_settings, rename_settings_history, update_settings_history, upsert_settings,
Expand Down Expand Up @@ -35,59 +37,6 @@ const ERR_NO_VALID_CONFIG_ITEMS: &str = "文件中未找到有效配置项。";
const ERR_SETTINGS_HISTORY_NOT_FOUND: &str = "未找到对应的配置历史。";
const ERR_SETTINGS_HISTORY_EMPTY: &str = "这份配置历史中没有可应用的设置项。";

/// 把视觉模型测试的原始错误/HTTP 返回转换为用户可读的中文提示。
fn explain_vision_test_error(error: &str, model: &str) -> String {
let lower = error.to_ascii_lowercase();

if lower.contains("no endpoints found that support image input")
|| lower.contains("does not support image input")
|| lower.contains("image input is not supported")
{
return format!(
"当前接口或模型「{}」不支持图片输入,请确认选择的是多模态模型。",
model
);
}

if lower.contains("html")
|| lower.contains("<html")
|| lower.contains("<!doctype")
{
return "接口地址返回了网页而不是 API,请检查是否以 /v1 结尾。".to_string();
}

if lower.contains("401") || lower.contains("unauthorized") || lower.contains("invalid api key")
{
return "API Key 无效或没有权限,请检查密钥是否正确。".to_string();
}

if lower.contains("403") || lower.contains("forbidden") {
return "当前账号没有权限访问该模型,请检查密钥或模型可用性。".to_string();
}

if lower.contains("404") || lower.contains("not found") {
return "接口地址或模型名称不存在,请检查配置。".to_string();
}

if lower.contains("timeout") || lower.contains("timed out") {
return "请求超时,模型响应过慢或网络不稳定。".to_string();
}

if lower.contains("connection refused")
|| lower.contains("dns error")
|| lower.contains("could not connect")
{
return "无法连接到接口地址,请检查网络或 base_url 是否正确。".to_string();
}

if lower.contains("model") && (lower.contains("not exist") || lower.contains("not found")) {
return format!("模型「{}」不存在,请检查模型名称是否拼写正确。", model);
}

// 兜底:保留原始信息,但用更友好的前缀。
format!("测试失败:{}", error.trim())
}

const SETTINGS_HISTORY_SNAPSHOT_PREFIX: &str = "配置快照";
const LOCAL_ONLY_SETTINGS_KEYS: &[&str] = &[
"app_lock_enabled",
Expand Down Expand Up @@ -811,7 +760,7 @@ pub async fn test_vision_settings(
0.0,
)
.await
.map_err(|e| explain_vision_test_error(&e.to_string(), model.as_deref().unwrap_or("")))?;
.map_err(|e| explain_vision_error(&e.to_string(), model.as_deref()))?;
Ok(reply.trim().to_string())
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import type { PDFDocumentProxy } from "pdfjs-dist";
import { afterEach, describe, expect, it, vi } from "vitest";
import PdfReaderViewer from "../../../features/reader/PdfReaderViewer";
import { render } from "../../helpers/render";

vi.mock("pdfjs-dist", () => ({
setLayerDimensions: vi.fn(),
TextLayer: class {
render = vi.fn(async () => undefined);
cancel = vi.fn();
},
}));

describe("PdfReaderViewer", () => {
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});

it("把第 5 页及后续页面交给懒渲染观察器", () => {
const observedPages = new Set<number>();
class CapturingIntersectionObserver {
observe(element: Element) {
const page = Number((element as HTMLElement).dataset.pageNum);
if (page > 0) observedPages.add(page);
}
unobserve() { return undefined; }
disconnect() { return undefined; }
}
vi.stubGlobal("IntersectionObserver", CapturingIntersectionObserver);
vi.spyOn(window, "requestAnimationFrame").mockImplementation(() => 1);

render(
<PdfReaderViewer
pdfDoc={{ numPages: 7 } as PDFDocumentProxy}
notes={[]}
scale={1.4}
onTextSelected={vi.fn()}
onSelectionCleared={vi.fn()}
onNoteClick={vi.fn()}
onZoom={vi.fn()}
/>,
);

expect([...observedPages]).toEqual([1, 2, 3, 4, 5, 6, 7]);
});
});
Loading
Loading