Venice: add 💭 Reasoning spoiler block (html <details>)
This commit is contained in:
@@ -184,9 +184,7 @@ impl ControllerTrait for ControllerType {
|
||||
ControllerType::Anthropic(controller) => {
|
||||
controller.generate_image(prompt, params).await
|
||||
}
|
||||
ControllerType::Venice(controller) => {
|
||||
controller.generate_image(prompt, params).await
|
||||
}
|
||||
ControllerType::Venice(controller) => controller.generate_image(prompt, params).await,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -115,10 +115,7 @@ pub async fn generate_text(
|
||||
venice_parameters,
|
||||
};
|
||||
|
||||
let url = format!(
|
||||
"{}/chat/completions",
|
||||
config.base_url.trim_end_matches('/')
|
||||
);
|
||||
let url = format!("{}/chat/completions", config.base_url.trim_end_matches('/'));
|
||||
|
||||
tracing::trace!(
|
||||
model = text_generation_config.model_id,
|
||||
@@ -201,6 +198,13 @@ pub(super) fn derive_prompt_cache_key(prompt_text: &str, conversation_start_time
|
||||
/// `strip_thinking_response`, which only strips inline `<think>` blocks from `content`), so reading
|
||||
/// it here is independent of that knob. Default-off matches today's behavior: thinking never reaches
|
||||
/// a room that did not ask for it.
|
||||
///
|
||||
/// The thinking renders as a Matrix-native collapsible `<details>` block: folded by default, one
|
||||
/// click to expand, so it stays out of the way of the answer instead of dumping a wall of reasoning
|
||||
/// inline. This survives the send path: the reply goes through markdown (`send_text_markdown`),
|
||||
/// whose pulldown-cmark pass writes raw HTML verbatim rather than escaping it, and ruma's HTML
|
||||
/// sanitizer allow-lists `<details>`/`<summary>`. Clients that do not render `<details>` degrade to
|
||||
/// showing the summary and reasoning inline, so nothing is lost there either.
|
||||
pub(super) fn append_reasoning(
|
||||
text: String,
|
||||
reasoning_content: Option<String>,
|
||||
@@ -212,7 +216,13 @@ pub(super) fn append_reasoning(
|
||||
|
||||
match reasoning_content {
|
||||
Some(reasoning) if !reasoning.trim().is_empty() => {
|
||||
format!("{text}\n\n---\n\n*Reasoning:*\n\n{reasoning}")
|
||||
// The blank lines around the trimmed reasoning keep it a separate markdown block from
|
||||
// the surrounding `<details>`/`</details>` HTML blocks, so the reasoning itself still
|
||||
// renders as markdown (lists, code, emphasis) inside the collapsible.
|
||||
let reasoning = reasoning.trim();
|
||||
format!(
|
||||
"{text}\n\n<details><summary>💭 Reasoning</summary>\n\n{reasoning}\n\n</details>"
|
||||
)
|
||||
}
|
||||
_ => text,
|
||||
}
|
||||
|
||||
@@ -185,8 +185,6 @@ fn image_format_to_mime_type(format: Option<&str>) -> mxlink::mime::Mime {
|
||||
"jpeg" | "jpg" => mxlink::mime::IMAGE_JPEG,
|
||||
"png" => mxlink::mime::IMAGE_PNG,
|
||||
// No mxlink::mime constant for webp; parse it, falling back to PNG on any surprise value.
|
||||
_ => "image/webp"
|
||||
.parse()
|
||||
.unwrap_or(mxlink::mime::IMAGE_PNG),
|
||||
_ => "image/webp".parse().unwrap_or(mxlink::mime::IMAGE_PNG),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,7 +57,10 @@ speech_to_text:
|
||||
!json.contains("character_slug"),
|
||||
"an unset knob must be omitted entirely: {json}"
|
||||
);
|
||||
assert!(!json.contains("null"), "no nulls belong in the body: {json}");
|
||||
assert!(
|
||||
!json.contains("null"),
|
||||
"no nulls belong in the body: {json}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -97,8 +100,7 @@ fn converts_text_image_and_file_to_content_parts() {
|
||||
},
|
||||
];
|
||||
|
||||
let converted =
|
||||
convert_llm_messages_to_venice(messages).expect("conversion should succeed");
|
||||
let converted = convert_llm_messages_to_venice(messages).expect("conversion should succeed");
|
||||
|
||||
// Text, image, AND file all survive now: the file is no longer warn-skipped.
|
||||
assert_eq!(converted.len(), 3);
|
||||
@@ -202,7 +204,10 @@ fn speech_request_serializes_voice_and_omits_unset() {
|
||||
!json.contains("temperature"),
|
||||
"an unset knob must be omitted (not null): {json}"
|
||||
);
|
||||
assert!(!json.contains("null"), "no nulls belong in the body: {json}");
|
||||
assert!(
|
||||
!json.contains("null"),
|
||||
"no nulls belong in the body: {json}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -245,7 +250,10 @@ fn generate_image_request_pins_flags_and_omits_unset() {
|
||||
!json.contains("cfg_scale"),
|
||||
"an unset knob must be omitted: {json}"
|
||||
);
|
||||
assert!(!json.contains("null"), "no nulls belong in the body: {json}");
|
||||
assert!(
|
||||
!json.contains("null"),
|
||||
"no nulls belong in the body: {json}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -262,10 +270,7 @@ fn edit_image_request_carries_model_and_base64_image() {
|
||||
|
||||
let json = serde_json::to_string(&request).expect("serialize EditImageRequest");
|
||||
|
||||
assert!(
|
||||
json.contains("\"model\":\"firered-image-edit\""),
|
||||
"{json}"
|
||||
);
|
||||
assert!(json.contains("\"model\":\"firered-image-edit\""), "{json}");
|
||||
assert!(
|
||||
json.contains("\"image\":\"aGVsbG8=\""),
|
||||
"the base64 image string must be present: {json}"
|
||||
@@ -504,10 +509,14 @@ fn reasoning_is_appended_only_when_show_reasoning_is_set() {
|
||||
let off = append_reasoning(base.clone(), Some("secret thinking".to_owned()), false);
|
||||
assert_eq!(off, "the answer");
|
||||
|
||||
// On: thinking is appended below the answer.
|
||||
let on = append_reasoning(base.clone(), Some("visible thinking".to_owned()), true);
|
||||
assert!(on.contains("the answer"));
|
||||
assert!(on.contains("visible thinking"));
|
||||
// On: thinking is appended below the answer in a collapsible <details> block (folded by
|
||||
// default, expandable in clients that support it).
|
||||
let on = append_reasoning(base.clone(), Some(" visible thinking ".to_owned()), true);
|
||||
assert!(on.starts_with("the answer"));
|
||||
assert!(on.contains("<details><summary>💭 Reasoning</summary>"));
|
||||
assert!(on.contains("</details>"));
|
||||
// The reasoning sits as its own markdown block (blank lines around it) and is trimmed.
|
||||
assert!(on.contains("\n\nvisible thinking\n\n"));
|
||||
|
||||
// On but empty or missing reasoning: nothing is appended.
|
||||
assert_eq!(
|
||||
|
||||
@@ -10,7 +10,9 @@ use super::wire::{ChatMessage, ContentPart, FilePart, ImageUrl, MessageContent};
|
||||
/// API; the 413 status branch in `chat.rs` is the backstop if a file slips past this guard.
|
||||
const MAX_FILE_BYTES: usize = 25 * 1024 * 1024;
|
||||
|
||||
pub fn convert_llm_messages_to_venice(messages: Vec<LLMMessage>) -> anyhow::Result<Vec<ChatMessage>> {
|
||||
pub fn convert_llm_messages_to_venice(
|
||||
messages: Vec<LLMMessage>,
|
||||
) -> anyhow::Result<Vec<ChatMessage>> {
|
||||
let mut venice_messages: Vec<ChatMessage> = Vec::with_capacity(messages.len());
|
||||
|
||||
for message in messages {
|
||||
|
||||
Reference in New Issue
Block a user