From 5ff0eeee04e2d6f06a20bd9327ad4f369ea1d11d Mon Sep 17 00:00:00 2001 From: netdex Date: Sun, 19 Apr 2026 10:25:02 -0400 Subject: [PATCH 1/8] openai: remove Realtime, Responses, and Conversations modules --- openai/Cargo.toml | 1 - openai/src/conversations/mod.rs | 23 -- openai/src/lib.rs | 11 - openai/src/protocol/conversations.rs | 14 - openai/src/protocol/mod.rs | 3 - openai/src/protocol/realtime.rs | 449 --------------------------- openai/src/protocol/responses.rs | 156 ---------- openai/src/realtime/mod.rs | 387 ----------------------- openai/src/responses/mod.rs | 146 --------- openai/src/tests/fixture/mod.rs | 8 - 10 files changed, 1198 deletions(-) delete mode 100644 openai/src/conversations/mod.rs delete mode 100644 openai/src/protocol/conversations.rs delete mode 100644 openai/src/protocol/realtime.rs delete mode 100644 openai/src/protocol/responses.rs delete mode 100644 openai/src/realtime/mod.rs delete mode 100644 openai/src/responses/mod.rs delete mode 100644 openai/src/tests/fixture/mod.rs diff --git a/openai/Cargo.toml b/openai/Cargo.toml index ceeeac4..e63a027 100644 --- a/openai/Cargo.toml +++ b/openai/Cargo.toml @@ -9,7 +9,6 @@ edition = "2021" [dependencies] tokio = { version = "1" } tokio-stream = { version = "0.1", features = ["sync"] } -tokio-tungstenite = { version = "0.28", features = ["native-tls"] } reqwest = { version = "0.13", features = ["json", "stream"] } tiktoken-rs = "0.9" diff --git a/openai/src/conversations/mod.rs b/openai/src/conversations/mod.rs deleted file mode 100644 index 7032b23..0000000 --- a/openai/src/conversations/mod.rs +++ /dev/null @@ -1,23 +0,0 @@ -//! https://platform.openai.com/docs/api-reference/conversations/create - -use reqwest::Method; -use serde_json::json; -use tracing::Level; - -pub use crate::protocol::conversations::Conversation; -use crate::{Client, Error}; - -impl Client { - #[tracing::instrument(level = Level::DEBUG, skip_all, err)] - pub async fn create_conversation(&self) -> Result { - let response = self - .shared - .request(Method::POST, "v1/conversations") - .body(&json!({})) - .send() - .await? - .json() - .await?; - Ok(response) - } -} diff --git a/openai/src/lib.rs b/openai/src/lib.rs index 3d68e99..0103484 100644 --- a/openai/src/lib.rs +++ b/openai/src/lib.rs @@ -6,10 +6,7 @@ //! A: Yes, and? pub mod chat; -pub mod conversations; mod protocol; -pub mod realtime; -pub mod responses; use std::{sync::Arc, time::Duration}; @@ -30,10 +27,6 @@ pub enum Error { Protocol(#[from] protocol::Error), #[error(transparent)] EventStream(#[from] eventsource_stream::EventStreamError), - #[error(transparent)] - WebSocket(#[from] tokio_tungstenite::tungstenite::Error), - #[error("Unexpected server response: {0:?}")] - UnexpectedResponse(Box), } #[derive(Clone)] @@ -126,7 +119,3 @@ impl Shared { } } -#[cfg(test)] -mod tests { - pub(crate) mod fixture; -} diff --git a/openai/src/protocol/conversations.rs b/openai/src/protocol/conversations.rs deleted file mode 100644 index 166cf0d..0000000 --- a/openai/src/protocol/conversations.rs +++ /dev/null @@ -1,14 +0,0 @@ -use std::collections::HashMap; - -use serde::Deserialize; -use serde_json::Value; - -/// https://platform.openai.com/docs/api-reference/conversations/create -#[derive(Debug, Clone, Deserialize)] -pub struct Conversation { - pub id: String, - pub object: String, - pub created_at: u64, - #[serde(default)] - pub metadata: HashMap, -} diff --git a/openai/src/protocol/mod.rs b/openai/src/protocol/mod.rs index 9eaafc4..d42a8e8 100644 --- a/openai/src/protocol/mod.rs +++ b/openai/src/protocol/mod.rs @@ -3,9 +3,6 @@ use strum_macros::{EnumIter, IntoStaticStr}; use thiserror::Error; pub mod chat; -pub mod conversations; -pub mod realtime; -pub mod responses; #[derive(Error, Debug, Clone, Deserialize, PartialEq, Eq)] #[error("{error_type}: {message} (param={param:?}, code={code:?}, event_id={event_id:?})")] diff --git a/openai/src/protocol/realtime.rs b/openai/src/protocol/realtime.rs deleted file mode 100644 index f395989..0000000 --- a/openai/src/protocol/realtime.rs +++ /dev/null @@ -1,449 +0,0 @@ -use derive_more::derive::Display; -use serde::{Deserialize, Deserializer, Serialize, Serializer}; - -use crate::ModelId; - -use super::{untagged_ok_result, Error, Result}; - -// TODO: maybe check that the string starts with X in the ctor -#[derive(Debug, Clone, Deserialize, Serialize, Display)] -#[serde(transparent)] -pub struct SessionId(String); - -#[derive(Debug, Clone, Deserialize, Serialize, Display)] -#[serde(transparent)] -pub struct ConversationId(String); - -#[derive(Debug, Clone, Deserialize, Serialize, Display)] -#[serde(transparent)] -pub struct ConversationItemId(String); - -#[derive(Debug, Clone, Deserialize, Serialize, Display)] -#[serde(transparent)] -pub struct FunctionCallId(String); - -#[derive(Debug, Clone, Deserialize, Serialize, Display)] -#[serde(transparent)] -pub struct EventId(String); - -#[derive(Debug, Clone, Deserialize, Serialize, Display)] -#[serde(transparent)] -pub struct ResponseId(String); - -#[derive(Debug, Clone)] -pub enum MaxResponseOutputTokens { - Finite(u32), - Infinite, -} -impl Serialize for MaxResponseOutputTokens { - fn serialize(&self, serializer: S) -> std::result::Result - where - S: Serializer, - { - match self { - Self::Finite(value) => serializer.serialize_u32(*value), - Self::Infinite => serializer.serialize_str("inf"), - } - } -} -impl<'de> Deserialize<'de> for MaxResponseOutputTokens { - fn deserialize(deserializer: D) -> std::result::Result - where - D: Deserializer<'de>, - { - let s = String::deserialize(deserializer)?; - match s.as_str() { - "inf" => Ok(Self::Infinite), - _ => match s.parse::() { - Ok(value) => Ok(Self::Finite(value)), - Err(_) => Err(serde::de::Error::invalid_type( - serde::de::Unexpected::Str(&s), - &"an integer", - )), - }, - } - } -} - -#[derive(Debug, Clone, Deserialize, Serialize)] -#[serde(rename_all = "snake_case")] -pub enum Modality { - Audio, - Text, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(transparent)] -pub struct CreateSessionRequest(pub SessionParameters); - -#[derive(Debug, Clone, Deserialize)] -pub struct CreateSessionResponseInner { - #[serde(flatten)] - pub session_parameters: SessionParameters, - pub client_secret: ClientSecret, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(transparent)] -pub struct CreateSessionResponse( - #[serde(deserialize_with = "untagged_ok_result::deserialize")] - pub Result, -); - -#[derive(Debug, Clone, Deserialize)] -pub struct ClientSecret { - pub value: String, - pub expires_at: u64, -} - -#[serde_with::skip_serializing_none] -#[derive(Debug, Clone, Default, Deserialize, Serialize)] -pub struct InferenceParameters { - #[serde(skip_serializing_if = "Vec::is_empty")] - pub modalities: Vec, - pub model: Option, - pub instructions: Option, - pub temperature: Option, - pub max_response_output_tokens: Option, -} - -#[serde_with::skip_serializing_none] -#[derive(Debug, Clone, Default, Deserialize, Serialize)] -pub struct SessionParameters { - // turn_detection - // input_audio_format - // input_audio_transcription - // voice - // output_audio_format - // tools - // tool_choice - #[serde(flatten)] - pub inference_parameters: InferenceParameters, -} - -#[derive(Debug, Clone, Default, Deserialize, Serialize)] -#[serde(rename_all = "snake_case")] -pub enum ConversationItemStatus { - #[default] - Incomplete, - InProgress, - Completed, -} - -#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)] -#[serde(rename_all = "lowercase")] -pub enum Role { - #[default] - User, - Assistant, - Function, -} - -#[serde_with::skip_serializing_none] -#[derive(Debug, Clone, Deserialize, Serialize)] -#[serde(tag = "type", rename_all = "snake_case")] -pub enum ConversationItemContent { - InputText { text: String }, - InputAudio { audio: String, transcript: String }, - ItemReference { id: ConversationItemId }, - Text { text: String }, -} - -#[serde_with::skip_serializing_none] -#[derive(Debug, Clone, Deserialize, Serialize)] -#[serde(tag = "type", rename_all = "snake_case")] -pub enum ConversationItemBody { - Message { - role: Role, - #[serde(skip_serializing_if = "Vec::is_empty")] - content: Vec, - }, - FunctionCall { - call_id: FunctionCallId, - name: String, - arguments: String, // TODO: nested json? - }, - FunctionCallOutput { - call_id: FunctionCallId, - output: String, // TODO: nested json? - }, -} - -#[serde_with::skip_serializing_none] -#[derive(Debug, Clone, Deserialize, Serialize)] -pub struct ConversationItem { - pub id: Option, - #[serde(default)] - pub status: ConversationItemStatus, - #[serde(flatten)] - pub body: ConversationItemBody, -} -impl ConversationItem { - pub fn input_text(message: impl Into) -> Self { - ConversationItem { - id: None, - status: ConversationItemStatus::Incomplete, - body: ConversationItemBody::Message { - role: Role::User, - content: vec![ConversationItemContent::InputText { - text: message.into(), - }], - }, - } - } -} - -#[derive(Debug, Clone, Default, Serialize)] -#[serde(rename_all = "snake_case")] -pub enum ResponseConversation { - #[default] - Auto, - None, -} - -#[serde_with::skip_serializing_none] -#[derive(Debug, Clone, Default, Serialize)] -pub struct ResponseParameters { - #[serde(flatten)] - pub inference_parameters: Option, - pub conversation: ResponseConversation, - // metadata - #[serde(skip_serializing_if = "Vec::is_empty")] - pub input: Vec, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(tag = "type", rename_all = "snake_case")] -pub enum ResponseStatusDetails { - Completed, - Cancelled { reason: String }, - Incomplete { reason: String }, - Failed { error: Error }, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum ResponseStatus { - Completed, - Cancelled, - Incomplete, - Failed, - InProgress, -} - -#[derive(Debug, Clone, Deserialize)] -pub struct InputTokenDetails { - pub cached_tokens: u32, - pub text_tokens: u32, - pub audio_tokens: u32, -} -#[derive(Debug, Clone, Deserialize)] -pub struct OutputTokenDetails { - pub text_tokens: u32, - pub audio_tokens: u32, -} -#[derive(Debug, Clone, Deserialize)] -pub struct Usage { - pub total_tokens: u32, - pub input_tokens: u32, - pub output_tokens: u32, - pub input_token_details: InputTokenDetails, - pub output_token_details: OutputTokenDetails, -} - -#[derive(Debug, Clone, Deserialize)] -pub struct Response { - pub id: ResponseId, - pub status: ResponseStatus, - pub status_details: Option, - pub output: Vec, - // metadata - pub usage: Option, - pub conversation_id: ConversationId, - #[serde(flatten)] - pub inference_parameters: InferenceParameters, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(tag = "type", rename_all = "snake_case")] -pub enum Part { - Text { - text: String, - }, - Audio { - audio: Option, - transcript: String, - }, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum RateLimitName { - Requests, - Tokens, -} - -#[derive(Debug, Clone, Deserialize)] -pub struct RateLimits { - pub name: RateLimitName, - pub limit: u32, - pub remaining: u32, - pub reset_seconds: f32, -} - -#[derive(Debug, Clone, Deserialize)] -pub struct ConversationItemCreated { - pub previous_item_id: Option, - pub item: ConversationItem, -} - -#[derive(Debug, Clone, Deserialize)] -pub struct ResponseOutputItem { - pub response_id: ResponseId, - pub output_index: u32, - pub item: ConversationItem, -} - -#[derive(Debug, Clone, Deserialize)] -pub struct ResponseContentPart { - pub response_id: ResponseId, - pub item_id: ConversationItemId, - pub output_index: u32, - pub content_index: u32, - pub part: Part, -} - -#[derive(Debug, Clone, Deserialize)] -pub struct ResponseTextDelta { - pub response_id: ResponseId, - pub item_id: ConversationItemId, - pub output_index: u32, - pub content_index: u32, - pub delta: String, -} - -#[derive(Debug, Clone, Deserialize)] -pub struct ResponseTextDone { - pub response_id: ResponseId, - pub item_id: ConversationItemId, - pub output_index: u32, - pub content_index: u32, - pub text: String, -} - -#[derive(Debug, Clone, Deserialize)] -pub struct ResponseFunctionCallArgumentsDelta { - pub response_id: ResponseId, - pub item_id: ConversationItemId, - pub output_index: u32, - pub call_id: FunctionCallId, - pub delta: String, -} - -#[derive(Debug, Clone, Deserialize)] -pub struct ResponseFunctionCallArgumentsDone { - pub response_id: ResponseId, - pub item_id: ConversationItemId, - pub output_index: u32, - pub call_id: FunctionCallId, - pub arguments: String, -} - -#[derive(Debug, Clone, Serialize)] -pub struct ClientEventRequest { - pub event_id: Option, - #[serde(flatten)] - pub event: ClientEvent, -} -impl<'a> TryFrom<&'a ClientEventRequest> for tokio_tungstenite::tungstenite::Message { - type Error = serde_json::Error; - - fn try_from(value: &'a ClientEventRequest) -> std::result::Result { - Ok(Self::Text(serde_json::to_string(value)?.into())) - } -} - -#[derive(Debug, Clone, Serialize)] -#[serde(tag = "type")] -pub enum ClientEvent { - #[serde(rename = "session.update")] - SessionUpdate { session: SessionParameters }, - #[serde(rename = "conversation.item.create")] - ConversationItemCreate { item: ConversationItem }, - // conversation.item.truncate - #[serde(rename = "conversation.item.delete")] - ConversationItemDelete { item_id: ConversationItemId }, - #[serde(rename = "response.create")] - ResponseCreate { response: ResponseParameters }, - #[serde(rename = "response.cancel")] - ResponseCancel { response_id: Option }, -} - -#[derive(Debug, Clone, Deserialize)] -pub struct ServerEventResponse { - pub event_id: String, - #[serde(flatten)] - #[serde(deserialize_with = "untagged_ok_result::deserialize")] - pub event: Result, -} -impl<'a> TryFrom<&'a tokio_tungstenite::tungstenite::Message> for ServerEventResponse { - type Error = serde_json::Error; - - fn try_from( - value: &'a tokio_tungstenite::tungstenite::Message, - ) -> std::result::Result { - match value { - tokio_tungstenite::tungstenite::Message::Text(value) => { - Ok(serde_json::from_str(value)?) - } - _ => Err(serde::de::Error::custom(format!( - "cannot convert to ServerEventResponse from: {:?}", - value - ))), - } - } -} - -#[derive(Debug, Clone, Deserialize)] -pub struct Session { - pub id: SessionId, - #[serde(flatten)] - pub session_parameters: SessionParameters, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(tag = "type")] -pub enum ServerEvent { - #[serde(rename = "session.created")] - SessionCreated { session: Session }, - #[serde(rename = "session.updated")] - SessionUpdated { session: Session }, - #[serde(rename = "conversation.updated")] - ConversationCreated { id: ConversationId }, - #[serde(rename = "conversation.item.created")] - ConversationItemCreated(ConversationItemCreated), - #[serde(rename = "conversation.item.deleted")] - ConversationItemDeleted { item_id: ConversationItemId }, - #[serde(rename = "response.created")] - ResponseCreated { response: Response }, - #[serde(rename = "response.done")] - ResponseDone { response: Response }, - #[serde(rename = "response.output_item.added")] - ResponseOutputItemAdded(ResponseOutputItem), - #[serde(rename = "response.output_item.done")] - ResponseOutputItemDone(ResponseOutputItem), - #[serde(rename = "response.content_part.added")] - ResponseContentPartAdded(ResponseContentPart), - #[serde(rename = "response.content_part.done")] - ResponseContentPartDone(ResponseContentPart), - #[serde(rename = "response.text.delta")] - ResponseTextDelta(ResponseTextDelta), - #[serde(rename = "response.text.done")] - ResponseTextDone(ResponseTextDone), - #[serde(rename = "response.function_call_arguments.delta")] - ResponseFunctionCallArgumentsDelta(ResponseFunctionCallArgumentsDelta), - #[serde(rename = "response.function_call_arguments.done")] - ResponseFunctionCallArgumentsDone(ResponseFunctionCallArgumentsDone), - #[serde(rename = "rate_limits.updated")] - RateLimitsUpdated { rate_limits: Vec }, -} diff --git a/openai/src/protocol/responses.rs b/openai/src/protocol/responses.rs deleted file mode 100644 index 2c16429..0000000 --- a/openai/src/protocol/responses.rs +++ /dev/null @@ -1,156 +0,0 @@ -use bon::Builder; -use serde::{Deserialize, Serialize}; - -use crate::{ - protocol::{ReasoningEffort, Role, StreamOptions, Verbosity}, - ModelId, -}; - -/// https://platform.openai.com/docs/api-reference/responses/create -#[serde_with::skip_serializing_none] -#[derive(Debug, Clone, Default, Serialize, Builder)] -pub struct Request { - pub input: Vec, - pub instructions: Option, - pub max_output_tokens: Option, - pub model: ModelId, - pub reasoning: Option, - pub context_management: Option>, - pub conversation: Option, - pub previous_response_id: Option, - pub store: Option, - pub(crate) stream: Option, - pub(crate) stream_options: Option, - pub temperature: Option, - pub top_p: Option, - pub verbosity: Option, -} - -#[serde_with::skip_serializing_none] -#[derive(Debug, Clone, Deserialize, Serialize)] -#[serde(default)] -pub struct Message { - pub role: Role, - pub content: Option, -} -impl Default for Message { - fn default() -> Self { - Self { - role: Role::User, - content: Default::default(), - } - } -} - -#[derive(Debug, Clone, Default, Serialize)] -pub struct ReasoningOptions { - pub effort: Option, -} -impl ReasoningOptions { - pub fn with_effort(effort: ReasoningEffort) -> Self { - Self { - effort: Some(effort), - } - } -} - -#[derive(Debug, Clone, Deserialize, Serialize)] -pub struct ContextManagementEntry { - #[serde(rename = "type")] - pub entry_type: ContextManagementType, - pub compact_threshold: Option, -} - -#[derive(Debug, Clone, Copy, Deserialize, Serialize)] -#[serde(rename_all = "lowercase")] -pub enum ContextManagementType { - Compaction, -} - -// https://platform.openai.com/docs/api-reference/responses/object -#[derive(Debug, Clone, Deserialize)] -pub struct Response { - pub id: String, - pub object: String, - pub model: ModelId, - #[serde(default)] - pub output: Vec, - pub conversation: Option, - pub previous_response_id: Option, - #[serde(default)] - pub store: bool, - pub usage: Option, -} - -#[derive(Debug, Clone, Deserialize)] -pub struct ConversationRef { - pub id: String, -} - -#[derive(Debug, Clone, Deserialize)] -pub struct Usage { - pub input_tokens: u32, - pub output_tokens: u32, - pub total_tokens: u32, - pub input_tokens_details: Option, - pub output_tokens_details: Option, -} - -#[derive(Debug, Clone, Deserialize)] -pub struct InputTokensDetails { - #[serde(default)] - pub cached_tokens: u32, -} - -#[derive(Debug, Clone, Deserialize)] -pub struct OutputTokensDetails { - #[serde(default)] - pub reasoning_tokens: u32, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(tag = "type")] -pub enum OutputItem { - #[serde(rename = "message")] - Message(OutputMessage), - #[serde(other)] - Unknown, -} - -#[derive(Debug, Clone, Deserialize)] -pub struct OutputMessage { - pub id: String, - pub role: Role, - #[serde(default)] - pub content: Vec, - pub status: Option, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(tag = "type")] -pub enum MessageContent { - #[serde(rename = "output_text")] - OutputText(OutputTextContent), - #[serde(other)] - Unknown, -} - -#[derive(Debug, Clone, Deserialize)] -pub struct OutputTextContent { - pub text: String, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(tag = "type")] -pub enum StreamEvent { - #[serde(rename = "response.created")] - ResponseCreated { response: Response }, - #[serde(rename = "response.completed")] - ResponseCompleted { response: Response }, - #[serde(rename = "response.output_text.delta")] - OutputTextDelta { delta: String }, - #[serde(rename = "response.output_text.done")] - OutputTextDone, - #[serde(other)] - Unknown, -} diff --git a/openai/src/realtime/mod.rs b/openai/src/realtime/mod.rs deleted file mode 100644 index 0837288..0000000 --- a/openai/src/realtime/mod.rs +++ /dev/null @@ -1,387 +0,0 @@ -use std::{collections::VecDeque, future::Future, sync::Arc}; - -use futures_util::{stream::SplitSink, Sink, SinkExt, Stream, StreamExt}; -use reqwest::Method; -use tokio::{ - net::TcpStream, - sync::{mpsc, Mutex}, -}; -use tokio_stream::wrappers::ReceiverStream; -use tokio_tungstenite::{ - tungstenite::{ - self, - client::IntoClientRequest, - http::{self}, - }, - MaybeTlsStream, WebSocketStream, -}; -use tracing::{Instrument, Level}; - -pub use crate::protocol::realtime::*; -use crate::{Client, Error}; - -#[derive(Clone)] -pub struct RealtimeSession { - session: Session, - state: Arc>, -} -struct State { - client_tx: SplitSink>, tungstenite::Message>, - request_tx: mpsc::Sender>>, -} -impl RealtimeSession { - pub async fn new(create_session_response: CreateSessionResponse) -> Result { - let ephemeral_key = create_session_response.0?.client_secret.value; - let mut request = "wss://api.openai.com/v1/realtime" - .into_client_request() - .unwrap(); - request.headers_mut().extend([ - ( - http::header::AUTHORIZATION, - format!("Bearer {}", ephemeral_key).parse().unwrap(), - ), - ( - "OpenAI-Beta".parse().unwrap(), - "realtime=v1".parse().unwrap(), - ), - ]); - - let (ws_stream, _) = tokio_tungstenite::connect_async(request).await?; - let (client_tx, mut client_rx) = ws_stream.split(); - - let event = client_rx.next_response().await?.unwrap().event?; - let session = match event { - ServerEvent::SessionCreated { session } => session, - _ => return Err(Error::UnexpectedResponse(Box::new(event))), - }; - - let (request_tx, mut request_rx) = mpsc::channel::>(1); - - // Task which feeds multiple senders from one receiver, switching to the - // next sender when the current sender hangs up without losing any - // values. - tokio::spawn(async move { - let mut buffer = VecDeque::new(); - let mut server_tx: Option> = None; - loop { - tokio::select! { - biased; - // We should always prioritize getting the next sender, - // since we would otherwise be buffering and increasing - // latency. - Some(tx) = request_rx.recv(), if server_tx.is_none() => 'branch: { - // new sender available, flush buffered events - while let Some(event) = buffer.pop_front() { - match tx.send(event).await { - Ok(_) => {}, - Err(mpsc::error::SendError(event)) => { - // new sender hung up, buffer event for next sender - tracing::trace!(?event, "new sender hang up"); - buffer.push_back(event); - break 'branch; - }, - } - } - server_tx = Some(tx); - }, - // client_rx must be constantly polled to keep the websocket - // alive, since this is how tokio-tungstenite responds to - // ping requests - response = client_rx.next_response() => { - match response.transpose() { - Some(response) => { - let event = response.and_then(|response| response.event.map_err(Error::Protocol)); - match event { - Ok(ServerEvent::RateLimitsUpdated { .. }) => {}, - Err(Error::Protocol(err)) if err.code.as_deref() == Some("response_cancel_not_active") => {}, - _ => { - match &server_tx { - Some(tx) => { - match tx.send(event).await { - Ok(_) => {}, - Err(mpsc::error::SendError(event)) => { - // sender hung up, buffer event for next sender - tracing::trace!(?event, "sender hang up"); - buffer.push_back(event); - server_tx = None; - }, - } - }, - None => { - // no active sender, buffer event for next sender - tracing::trace!(?event, "no active sender"); - buffer.push_back(event); - }, - } - } - } - }, - None => { - tracing::trace!("connection closed"); - // connection is dead, kill any active sender then kill ourselves - if let Some(tx) = server_tx { - let _ = tx.send(Err(Error::WebSocket(tungstenite::Error::ConnectionClosed))).await; - } - return; - } - } - }, - } - } - }); - - Ok(RealtimeSession { - session, - state: Arc::new(Mutex::new(State { - client_tx, - request_tx, - })), - }) - } - #[tracing::instrument(level = Level::DEBUG, skip_all, err)] - pub async fn session_update( - &self, - session_parameters: SessionParameters, - ) -> Result>, Error> { - let server_rx = self - .request(&ClientEventRequest { - event_id: None, - event: ClientEvent::SessionUpdate { - session: session_parameters, - }, - }) - .await?; - Ok(async move { - let event = server_rx.await?; - match event { - ServerEvent::SessionUpdated { session, .. } => Ok(session), - _ => Err(Error::UnexpectedResponse(Box::new(event))), - } - }) - } - - #[tracing::instrument(level = Level::DEBUG, skip_all, err)] - pub async fn conversation_item_create( - &self, - conversation_item: ConversationItem, - ) -> Result>, Error> { - let server_rx = self - .request(&ClientEventRequest { - event_id: None, - event: ClientEvent::ConversationItemCreate { - item: conversation_item, - }, - }) - .await?; - Ok(async move { - let event = server_rx.await?; - match event { - ServerEvent::ConversationItemCreated(message) => Ok(message), - _ => Err(Error::UnexpectedResponse(Box::new(event))), - } - }) - } - - #[tracing::instrument(level = Level::DEBUG, skip_all, err)] - pub async fn conversation_item_delete( - &self, - item_id: ConversationItemId, - ) -> Result>, Error> { - let server_rx = self - .request(&ClientEventRequest { - event_id: None, - event: ClientEvent::ConversationItemDelete { item_id }, - }) - .await?; - Ok(async move { - let event = server_rx.await?; - match event { - ServerEvent::ConversationItemDeleted { item_id } => Ok(item_id), - _ => Err(Error::UnexpectedResponse(Box::new(event))), - } - }) - } - - #[tracing::instrument(level = Level::DEBUG, skip_all, err)] - pub async fn response_create( - &self, - response_parameters: ResponseParameters, - ) -> Result>, Error> { - let (tx, rx) = mpsc::channel(4); - let mut guard = self.state.clone().lock_owned().await; - guard - .client_tx - .send_request(&ClientEventRequest { - event_id: None, - event: ClientEvent::ResponseCreate { - response: response_parameters, - }, - }) - .await?; - - // We can't simply return a stream using something like try_stream! - // here, since we want to cancel the response if the rx side is dropped. - tokio::spawn( - async move { - // guard needs to be held across tasks to prevent other request - // from barging - let State { - client_tx, - request_tx, - } = &mut *guard; - - let (server_tx, mut server_rx) = mpsc::channel(1); - request_tx.send(server_tx).await.unwrap(); - - let mut response_id = None; - let mut cancelled = false; - loop { - tokio::select! { - event = server_rx.recv() => { - let event = event.unwrap(); - let mut done = false; - match &event { - Ok(ServerEvent::ResponseCreated { response }) => { - response_id = Some(response.id.clone()); - }, - Ok(ServerEvent::ResponseDone {..}) => { - done = true; - }, - _ => {}, - } - let _ = tx.send(event).await; - if done { - return; - } - } - _ = tx.closed(), if !cancelled => { - // on rx hang up, cancel response by id if available, - // otherwise cancel latest response - client_tx.send_request(&ClientEventRequest { - event_id: None, - event: ClientEvent::ResponseCancel { - response_id: response_id.clone(), - }, - }).await.ok(); - cancelled = true; - } - } - } - } - .in_current_span(), - ); - Ok(ReceiverStream::new(rx)) - } - - async fn request( - &self, - request: &ClientEventRequest, - ) -> Result>, Error> { - let State { - client_tx, - request_tx, - } = &mut *self.state.lock().await; - client_tx.send_request(request).await?; - let (server_tx, mut server_rx) = mpsc::channel(1); - request_tx.send(server_tx).await.unwrap(); - Ok(async move { server_rx.recv().await.unwrap() }) - } - - pub fn info(&self) -> &Session { - &self.session - } -} - -impl Client { - #[tracing::instrument(level = Level::DEBUG, skip_all, err)] - pub async fn realtime( - &self, - session_parameters: SessionParameters, - ) -> Result { - let create_session_request = CreateSessionRequest(session_parameters); - tracing::debug!(?create_session_request); - let create_session_response: CreateSessionResponse = self - .shared - .request(Method::POST, "/v1/realtime/sessions") - .body(&create_session_request) - .beta("realtime=v1") - .send() - .await? - .json() - .await?; - tracing::debug!(?create_session_response); - RealtimeSession::new(create_session_response).await - } -} - -trait ServerEventResponseStream { - async fn next_response(&mut self) -> Result, Error>; -} -impl ServerEventResponseStream for S -where - S: Stream> + Unpin, -{ - async fn next_response(&mut self) -> Result, Error> { - while let Some(message) = self.next().await { - let message = message?; - if let tungstenite::Message::Text(_) = message { - let response = (&message) - .try_into() - .inspect(|response| tracing::debug!(?response)) - .inspect_err(|err| tracing::error!(%err)); - return Ok(Some(response?)); - } else { - tracing::trace!(?message, "discarding websocket message") - } - } - Ok(None) - } -} - -trait ClientEventRequestSink { - async fn send_request(&mut self, request: &ClientEventRequest) -> Result<(), Error>; -} -impl ClientEventRequestSink for S -where - S: Sink + Unpin, -{ - async fn send_request(&mut self, request: &ClientEventRequest) -> Result<(), Error> { - tracing::debug!(?request); - Ok(self.send(request.try_into()?).await?) - } -} - -#[cfg(test)] -mod tests { - use tracing_test::traced_test; - - use super::*; - use crate::{tests::fixture, ModelId}; - - #[tokio::test] - #[traced_test] - #[ignore] - async fn test_response() -> Result<(), Error> { - let client = fixture::client(); - let session = client - .realtime(SessionParameters { - inference_parameters: InferenceParameters { - model: Some(ModelId("gpt-4o-mini-realtime-preview".into())), - modalities: vec![Modality::Text], - ..Default::default() - }, - }) - .await?; - let prompt = ConversationItem::input_text("What is the capital of Canada?"); - let fut = session.conversation_item_create(prompt).await?; - let mut response_stream = session - .response_create(ResponseParameters::default()) - .await?; - let conversation_item_created = fut.await?; - println!("{:?}", conversation_item_created); - while let Some(message) = response_stream.next().await { - println!("{:?}", message?); - } - Ok(()) - } -} diff --git a/openai/src/responses/mod.rs b/openai/src/responses/mod.rs deleted file mode 100644 index ee06580..0000000 --- a/openai/src/responses/mod.rs +++ /dev/null @@ -1,146 +0,0 @@ -//! https://platform.openai.com/docs/guides/conversation-state?api-mode=responses - -use serde::Deserialize; -use tracing::Level; - -pub use crate::protocol::responses::{ - ContextManagementEntry, ContextManagementType, ConversationRef, InputTokensDetails, Message, - MessageContent, OutputItem, OutputMessage, OutputTextContent, ReasoningOptions, Request, - Response, StreamEvent, Usage, -}; -use crate::{Client, Error}; -use eventsource_stream::Eventsource; -use reqwest::Method; -use tokio_stream::{Stream, StreamExt}; - -impl Client { - #[tracing::instrument(level = Level::DEBUG, skip_all, err)] - pub async fn responses(&self, mut request: Request) -> Result { - request.stream = None; - request.stream_options = None; - tracing::debug!(?request); - let response = self - .shared - .request(Method::POST, "v1/responses") - .body(&request) - .send() - .await?; - let status = response.status(); - if status.is_success() { - let response: Response = response.json().await?; - tracing::debug!(?response); - Ok(response) - } else { - let response_err = response.error_for_status_ref().unwrap_err(); - let protocol_err = response.json::().await; - match protocol_err { - Ok(err) => Err(Error::Protocol(err.error)), - Err(err) => { - tracing::error!(?response_err, ?err, "unexpected responses error"); - Err(response_err.into()) - } - } - } - } - - #[tracing::instrument(level = Level::DEBUG, skip_all, err)] - pub async fn stream_responses( - &self, - mut request: Request, - ) -> Result>, Error> { - request.stream = Some(true); - request.stream_options = None; - tracing::debug!(?request); - - let response = self - .shared - .request(Method::POST, "v1/responses") - .body(&request) - .send() - .await?; - - let status = response.status(); - if status.is_success() { - let stream = response.bytes_stream().eventsource(); - Ok(stream.map_while(|event| match event { - Ok(event) => { - tracing::trace!(?event); - if event.data == "[DONE]" { - None - } else { - let parsed = - serde_json::from_str::(&event.data).map_err(Error::from); - Some(parsed) - } - } - Err(err) => { - tracing::trace!(?err, "responses SSE event error"); - Some(Err(err.into())) - } - })) - } else { - let response_err = response.error_for_status_ref().unwrap_err(); - let protocol_err = response.json::().await; - match protocol_err { - Ok(err) => Err(Error::Protocol(err.error)), - Err(err) => { - tracing::error!(?response_err, ?err, "unexpected responses stream error"); - Err(response_err.into()) - } - } - } - } -} - -#[derive(Debug, Deserialize)] -struct ErrorResponse { - error: crate::protocol::Error, -} - -#[cfg(test)] -mod tests { - use tokio_stream::StreamExt; - use tracing_test::traced_test; - - use super::*; - use crate::tests::fixture; - - #[tokio::test] - #[traced_test] - #[ignore] - async fn test_responses_basic() { - let client = fixture::client(); - let request = Request { - model: crate::ModelId("gpt-4.1-mini".into()), - input: vec![crate::protocol::responses::Message { - role: crate::protocol::Role::User, - content: Some("Say hello in one short sentence.".into()), - }], - ..Default::default() - }; - - let response = client.responses(request).await.unwrap(); - println!("{:#?}", response); - assert!(!response.id.is_empty()); - } - - #[tokio::test] - #[traced_test] - #[ignore] - async fn test_responses_stream() { - let client = fixture::client(); - let request = Request { - model: crate::ModelId("gpt-4.1-mini".into()), - input: vec![crate::protocol::responses::Message { - role: crate::protocol::Role::User, - content: Some("Count to three.".into()), - }], - ..Default::default() - }; - - let mut stream = client.stream_responses(request).await.unwrap(); - while let Some(event) = stream.next().await { - println!("{:?}", event); - } - } -} diff --git a/openai/src/tests/fixture/mod.rs b/openai/src/tests/fixture/mod.rs deleted file mode 100644 index 46c8a03..0000000 --- a/openai/src/tests/fixture/mod.rs +++ /dev/null @@ -1,8 +0,0 @@ -use crate::Client; - -pub fn client() -> Client { - let token = std::env::var("OPENAI_KEY").expect("OPENAI_KEY not specified"); - Client::new(token, "https://api.openai.com", Default::default()) - // let token = "no-key"; - // Client::new(token, "http://localhost:8080", Default::default()) -} From 8bc2ad748f0797f91d74f3d3904d378e304862b1 Mon Sep 17 00:00:00 2001 From: netdex Date: Sun, 19 Apr 2026 10:25:13 -0400 Subject: [PATCH 2/8] openai: add tool call support, ToolCallAccumulator, and normalize API base URL --- openai/Cargo.toml | 1 + openai/src/chat/chat_buffer.rs | 149 -------------- openai/src/chat/mod.rs | 365 +++++++++++++++++++++++++++++---- openai/src/lib.rs | 10 +- openai/src/protocol/chat.rs | 146 ++++++++++++- openai/tests/common/mod.rs | 72 +++++++ openai/tests/live_chat.rs | 140 +++++++++++++ 7 files changed, 686 insertions(+), 197 deletions(-) delete mode 100644 openai/src/chat/chat_buffer.rs create mode 100644 openai/tests/common/mod.rs create mode 100644 openai/tests/live_chat.rs diff --git a/openai/Cargo.toml b/openai/Cargo.toml index e63a027..890e51f 100644 --- a/openai/Cargo.toml +++ b/openai/Cargo.toml @@ -35,3 +35,4 @@ tokio = { version = "1", features = ["rt", "macros"] } # tracing-test = { version = "0.2.4", features = ["no-env-filter"] } tracing-test = "0.2.5" serde_path_to_error = "0.1" +toml = "0.8" diff --git a/openai/src/chat/chat_buffer.rs b/openai/src/chat/chat_buffer.rs deleted file mode 100644 index 7bfddb7..0000000 --- a/openai/src/chat/chat_buffer.rs +++ /dev/null @@ -1,149 +0,0 @@ -//! Simple wrapper for a conversation using the completions API - -use std::collections::VecDeque; - -use crate::{ - chat::{Message, Role, Usage}, - protocol::chat::{Completion, PartialCompletion}, -}; - -#[derive(Debug)] -pub struct ChatBuffer { - context: VecDeque, -} - -impl Default for ChatBuffer { - fn default() -> Self { - Self::new() - } -} - -impl ChatBuffer { - pub fn new() -> Self { - ChatBuffer { - context: VecDeque::new(), - } - } - - pub fn start_exchange(&mut self, system: Message, request: Message) -> Exchange { - Exchange { - system, - context: self.context.clone(), - request, - response: None, - usage: None, - completed: false, - } - } - - pub fn commit(&mut self, exchange: &Exchange) { - self.context = exchange.context.clone(); - self.context.push_back(exchange.request.clone()); - self.context.extend(exchange.response.iter().cloned()); - } - - pub fn enforce_context_limit(&mut self, limit_range: &[u32; 2]) { - if self.context_tokens() <= limit_range[1] { - return; - } - let mut idx = 0; - loop { - if self.context_tokens() <= limit_range[0] || idx >= self.context.len() { - break; - } - if self.context[idx].name.is_some() { - idx += 1; - } else { - self.context.remove(idx); - } - while let Some(message) = self.context.get(idx) { - if message.role == Role::User { - break; - } - self.context.remove(idx); - } - } - } - - pub fn clear(&mut self) { - self.context.clear(); - } - - fn context_tokens(&self) -> u32 { - self.context - .iter() - .map(|m| m.estimate_tokens()) - .sum::() - } - - pub fn context(&self) -> &VecDeque { - &self.context - } - - pub fn context_mut(&mut self) -> &mut VecDeque { - &mut self.context - } -} - -pub struct Exchange { - system: Message, - context: VecDeque, - request: Message, - response: Option, - usage: Option, - completed: bool, -} -impl Exchange { - pub fn partial(&mut self, cmpl: PartialCompletion) { - if let Some(message) = cmpl.choices.into_iter().next() { - let message = message.delta; - if let Some(content) = &message.content { - let content = content.replace("\n", ""); - if let Some(last_content) = self.response.as_mut().and_then(|x| x.content.as_mut()) - { - last_content.push_str(&content) - } else { - let message = Message { - role: Role::Assistant, - content: Some(content), - ..Default::default() - }; - self.response = Some(message); - } - } - } - if let Some(usage) = cmpl.usage { - self.usage = Some(usage); - } - } - - pub fn complete(&mut self, cmpl: Completion) { - let message = cmpl.choices.into_iter().next().unwrap().message; - self.usage = Some(cmpl.usage); - self.response = Some(message); - } - - pub fn set_completed(&mut self) { - self.completed = true; - } - - pub fn prompt(&self) -> Vec { - let mut messages = vec![]; - messages.push(self.system.clone()); - messages.extend(self.context.iter().cloned()); - messages.push(self.request.clone()); - messages - } - - pub fn response(&self) -> Option<&Message> { - self.response.as_ref() - } - - pub fn usage(&self) -> Option<&Usage> { - self.usage.as_ref() - } - - pub fn is_completed(&self) -> bool { - self.completed - } -} diff --git a/openai/src/chat/mod.rs b/openai/src/chat/mod.rs index 6358d15..0bf6bad 100644 --- a/openai/src/chat/mod.rs +++ b/openai/src/chat/mod.rs @@ -1,14 +1,73 @@ //! https://platform.openai.com/docs/api-reference/chat -mod chat_buffer; - use eventsource_stream::Eventsource; use reqwest::Method; use tokio_stream::{Stream, StreamExt}; use tracing::Level; -pub use crate::protocol::chat::{Message, PartialMessage, Request, Role, Usage}; -pub use chat_buffer::{ChatBuffer, Exchange}; +pub use crate::protocol::chat::{ + FunctionCall, FunctionDef, Message, PartialFunctionCall, PartialMessage, PartialToolCall, + Request, Role, Tool, ToolCall, ToolCallKind, ToolChoice, ToolChoiceMode, Usage, +}; + +/// Accumulates streaming `PartialToolCall` fragments (keyed by `index`) into +/// complete [`ToolCall`]s once the model stops emitting chunks. +/// +/// OpenAI sends tool-call id/name/type once on the first chunk under an index, +/// and then streams `function.arguments` as string fragments on subsequent +/// chunks. The accumulator concatenates them. +#[derive(Debug, Default, Clone)] +pub struct ToolCallAccumulator { + slots: Vec>, +} + +impl ToolCallAccumulator { + pub fn new() -> Self { + Self::default() + } + + pub fn push(&mut self, partial: PartialToolCall) { + let idx = partial.index as usize; + if self.slots.len() <= idx { + self.slots.resize(idx + 1, None); + } + let slot = self.slots[idx].get_or_insert_with(|| ToolCall { + id: String::new(), + kind: ToolCallKind::Function, + function: FunctionCall::default(), + }); + if let Some(id) = partial.id { + slot.id = id; + } + if let Some(kind) = partial.kind { + slot.kind = kind; + } + if let Some(function) = partial.function { + if let Some(name) = function.name { + slot.function.name = name; + } + if let Some(args) = function.arguments { + slot.function.arguments.push_str(&args); + } + } + } + + pub fn extend>(&mut self, iter: I) { + for partial in iter { + self.push(partial); + } + } + + /// Drain completed tool calls in index order. Partial slots (never filled) + /// are skipped. + pub fn finish(self) -> Vec { + self.slots.into_iter().flatten().collect() + } + + pub fn is_empty(&self) -> bool { + self.slots.iter().all(Option::is_none) + } +} use crate::{ protocol::{ @@ -107,54 +166,276 @@ impl Client { #[cfg(test)] mod tests { - use tokio_stream::StreamExt; - use tracing_test::traced_test; - use super::*; - use crate::tests::fixture; - #[tokio::test] - #[traced_test] - #[ignore] - async fn test_chat() { - let client = fixture::client(); + fn weather_tool() -> Tool { + Tool::function(FunctionDef { + name: "get_weather".into(), + description: Some("Get the current weather for a city".into()), + parameters: Some(serde_json::json!({ + "type": "object", + "properties": { + "location": { "type": "string" }, + "unit": { "type": "string", "enum": ["celsius", "fahrenheit"] } + }, + "required": ["location"], + "additionalProperties": false, + })), + strict: Some(true), + }) + } + + #[test] + fn tool_serializes_as_function_type() { + let json = serde_json::to_value(weather_tool()).unwrap(); + assert_eq!(json["type"], "function"); + assert_eq!(json["function"]["name"], "get_weather"); + assert_eq!(json["function"]["strict"], true); + assert_eq!(json["function"]["parameters"]["required"][0], "location"); + } + + #[test] + fn tool_choice_serialization_modes() { + assert_eq!( + serde_json::to_value(ToolChoice::Mode(ToolChoiceMode::Auto)).unwrap(), + serde_json::json!("auto") + ); + assert_eq!( + serde_json::to_value(ToolChoice::Mode(ToolChoiceMode::None)).unwrap(), + serde_json::json!("none") + ); + assert_eq!( + serde_json::to_value(ToolChoice::Mode(ToolChoiceMode::Required)).unwrap(), + serde_json::json!("required") + ); + let forced = serde_json::to_value(ToolChoice::function("get_weather")).unwrap(); + assert_eq!(forced["type"], "function"); + assert_eq!(forced["function"]["name"], "get_weather"); + } + + #[test] + fn request_includes_tools_and_tool_choice() { let request = Request { - messages: vec![chat::Message { - role: chat::Role::User, - content: Some("What is the capital city of Canada?".into()), + model: crate::ModelId("gpt-4o-mini".into()), + messages: vec![Message { + role: Role::User, + content: Some("weather in Tokyo?".into()), ..Default::default() }], + tools: Some(vec![weather_tool()]), + tool_choice: Some(ToolChoice::Mode(ToolChoiceMode::Auto)), + parallel_tool_calls: Some(false), ..Default::default() }; - let response = client.chat(request).await.unwrap(); - println!("{:#?}", response); - let content = &response - .choices - .first() - .unwrap() - .message - .content - .as_ref() - .unwrap(); - assert!(content.contains("Ottawa")); - } - - #[tokio::test] - #[traced_test] - #[ignore] - async fn test_stream() { - let client = fixture::client(); + let json = serde_json::to_value(&request).unwrap(); + assert!(json["tools"].is_array()); + assert_eq!(json["tools"][0]["function"]["name"], "get_weather"); + assert_eq!(json["tool_choice"], "auto"); + assert_eq!(json["parallel_tool_calls"], false); + // Omitted fields must not serialize. + assert!(json.get("temperature").is_none()); + } + + #[test] + fn request_omits_tool_fields_when_none() { let request = Request { - messages: vec![chat::Message { - role: chat::Role::User, - content: Some("What is the capital city of Canada?".into()), - ..Default::default() - }], + model: crate::ModelId("gpt-4o-mini".into()), + messages: vec![], ..Default::default() }; - let mut stream = client.stream(request).await.unwrap(); - while let Some(msg) = stream.next().await { - println!("{:?}", msg); + let json = serde_json::to_value(&request).unwrap(); + assert!(json.get("tools").is_none()); + assert!(json.get("tool_choice").is_none()); + assert!(json.get("parallel_tool_calls").is_none()); + } + + #[test] + fn deserialize_assistant_tool_call_completion() { + let raw = r#"{ + "id": "chatcmpl-abc", + "object": "chat.completion", + "created": 1700000000, + "model": "gpt-4o-mini", + "choices": [{ + "index": 0, + "finish_reason": "tool_calls", + "message": { + "role": "assistant", + "content": null, + "tool_calls": [{ + "id": "call_1", + "type": "function", + "function": { + "name": "get_weather", + "arguments": "{\"location\":\"Tokyo\"}" + } + }] + } + }], + "usage": { "prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15 } + }"#; + let completion: chat::Completion = serde_json::from_str(raw).unwrap(); + let choice = &completion.choices[0]; + assert_eq!(choice.finish_reason.as_deref(), Some("tool_calls")); + let calls = choice.message.tool_calls.as_ref().unwrap(); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].id, "call_1"); + assert_eq!(calls[0].kind, ToolCallKind::Function); + assert_eq!(calls[0].function.name, "get_weather"); + #[derive(serde::Deserialize)] + struct Args { + location: String, + } + let args: Args = calls[0].parse_arguments().unwrap(); + assert_eq!(args.location, "Tokyo"); + } + + #[test] + fn deserialize_llama_cpp_style_tool_call_without_id_or_type() { + // llama.cpp's OpenAI-compatible server may omit `id` and `type` on + // tool_call objects; clients must tolerate both. + let raw = r#"{ + "id": "chatcmpl-llama", + "object": "chat.completion", + "created": 1700000000, + "model": "llama-3.1-8b-instruct", + "choices": [{ + "index": 0, + "finish_reason": "tool_calls", + "message": { + "role": "assistant", + "content": null, + "tool_calls": [{ + "function": { + "name": "get_weather", + "arguments": "{\"location\":\"Tokyo\"}" + } + }] + } + }], + "usage": { "prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15 } + }"#; + let completion: chat::Completion = serde_json::from_str(raw).unwrap(); + let call = &completion.choices[0].message.tool_calls.as_ref().unwrap()[0]; + assert_eq!(call.id, ""); + assert_eq!(call.kind, ToolCallKind::Function); + assert_eq!(call.function.name, "get_weather"); + } + + #[test] + fn tool_result_message_roundtrips() { + let msg = Message::tool_result("call_1", "22C, clear"); + let json = serde_json::to_value(&msg).unwrap(); + assert_eq!(json["role"], "tool"); + assert_eq!(json["tool_call_id"], "call_1"); + assert_eq!(json["content"], "22C, clear"); + // Unused fields must not serialize. + assert!(json.get("name").is_none()); + assert!(json.get("tool_calls").is_none()); + let round: Message = serde_json::from_value(json).unwrap(); + assert_eq!(round.role, Role::Tool); + assert_eq!(round.tool_call_id.as_deref(), Some("call_1")); + } + + #[test] + fn accumulator_merges_streamed_chunks() { + // Canonical streaming pattern: first chunk carries id+name, subsequent + // chunks stream arguments as string fragments. OpenAI can also interleave + // multiple tool calls under different `index` values. + let chunks: Vec = vec![ + PartialToolCall { + index: 0, + id: Some("call_a".into()), + kind: Some(ToolCallKind::Function), + function: Some(PartialFunctionCall { + name: Some("get_weather".into()), + arguments: Some(String::new()), + }), + }, + PartialToolCall { + index: 1, + id: Some("call_b".into()), + kind: Some(ToolCallKind::Function), + function: Some(PartialFunctionCall { + name: Some("get_time".into()), + arguments: Some(String::new()), + }), + }, + PartialToolCall { + index: 0, + function: Some(PartialFunctionCall { + arguments: Some("{\"loc".into()), + ..Default::default() + }), + ..Default::default() + }, + PartialToolCall { + index: 1, + function: Some(PartialFunctionCall { + arguments: Some("{\"tz\":\"JST\"}".into()), + ..Default::default() + }), + ..Default::default() + }, + PartialToolCall { + index: 0, + function: Some(PartialFunctionCall { + arguments: Some("ation\":\"Tokyo\"}".into()), + ..Default::default() + }), + ..Default::default() + }, + ]; + let mut acc = ToolCallAccumulator::new(); + acc.extend(chunks); + let calls = acc.finish(); + assert_eq!(calls.len(), 2); + assert_eq!(calls[0].id, "call_a"); + assert_eq!(calls[0].function.name, "get_weather"); + assert_eq!(calls[0].function.arguments, "{\"location\":\"Tokyo\"}"); + assert_eq!(calls[1].id, "call_b"); + assert_eq!(calls[1].function.arguments, "{\"tz\":\"JST\"}"); + } + + #[test] + fn deserialize_stream_chunks_with_tool_call_deltas() { + // First delta: role + tool call skeleton. + let first = r#"{ + "id":"chatcmpl-1","object":"chat.completion.chunk","created":1,"model":"gpt-4o-mini", + "choices":[{"index":0,"delta":{"role":"assistant","tool_calls":[ + {"index":0,"id":"call_1","type":"function","function":{"name":"get_weather","arguments":""}} + ]},"finish_reason":null}] + }"#; + // Second delta: argument fragment, no id/name. + let second = r#"{ + "id":"chatcmpl-1","object":"chat.completion.chunk","created":1,"model":"gpt-4o-mini", + "choices":[{"index":0,"delta":{"tool_calls":[ + {"index":0,"function":{"arguments":"{\"location\":\"Tokyo\"}"}} + ]},"finish_reason":null}] + }"#; + // Final delta: finish_reason. + let third = r#"{ + "id":"chatcmpl-1","object":"chat.completion.chunk","created":1,"model":"gpt-4o-mini", + "choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}] + }"#; + let mut acc = ToolCallAccumulator::new(); + let mut last_finish = None; + for raw in [first, second, third] { + let partial: chat::PartialCompletion = serde_json::from_str(raw).unwrap(); + let choice = &partial.choices[0]; + if let Some(calls) = &choice.delta.tool_calls { + acc.extend(calls.clone()); + } + if choice.finish_reason.is_some() { + last_finish.clone_from(&choice.finish_reason); + } } + assert_eq!(last_finish.as_deref(), Some("tool_calls")); + let calls = acc.finish(); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].id, "call_1"); + assert_eq!(calls[0].function.name, "get_weather"); + assert_eq!(calls[0].function.arguments, "{\"location\":\"Tokyo\"}"); } + } diff --git a/openai/src/lib.rs b/openai/src/lib.rs index 0103484..aa88700 100644 --- a/openai/src/lib.rs +++ b/openai/src/lib.rs @@ -86,6 +86,14 @@ impl Client { api_endpoint: impl reqwest::IntoUrl, connection_policy: ConnectionPolicy, ) -> Self { + let mut api_base = api_endpoint.into_url().unwrap(); + // `Url::join("v1/...")` on a base whose path has no trailing slash + // replaces the last segment. That silently breaks endpoints like + // `https://host/llama.cpp`. Normalize by ensuring a trailing slash. + if !api_base.path().ends_with('/') { + let path = format!("{}/", api_base.path()); + api_base.set_path(&path); + } Self { shared: Arc::new(Shared { client: reqwest::Client::builder() @@ -93,7 +101,7 @@ impl Client { .connect_timeout(connection_policy.connect_timeout) .build() .unwrap(), - api_base: api_endpoint.into_url().unwrap(), + api_base, token: token.into(), }), } diff --git a/openai/src/protocol/chat.rs b/openai/src/protocol/chat.rs index 4537585..f6a106b 100644 --- a/openai/src/protocol/chat.rs +++ b/openai/src/protocol/chat.rs @@ -9,6 +9,114 @@ use crate::{ ModelId, }; +/// https://platform.openai.com/docs/guides/function-calling +#[serde_with::skip_serializing_none] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct FunctionDef { + pub name: String, + pub description: Option, + pub parameters: Option, + pub strict: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum Tool { + Function { function: FunctionDef }, +} + +impl Tool { + pub fn function(function: FunctionDef) -> Self { + Self::Function { function } + } +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, IntoStaticStr, EnumIter)] +#[serde(rename_all = "lowercase")] +pub enum ToolChoiceMode { + None, + Auto, + Required, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ToolChoiceNamed { + pub name: String, +} + +/// See https://platform.openai.com/docs/api-reference/chat/create#chat-create-tool_choice +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(untagged)] +pub enum ToolChoice { + Mode(ToolChoiceMode), + Function { + #[serde(rename = "type")] + kind: ToolCallKind, + function: ToolChoiceNamed, + }, +} + +impl ToolChoice { + pub fn function(name: impl Into) -> Self { + Self::Function { + kind: ToolCallKind::Function, + function: ToolChoiceNamed { name: name.into() }, + } + } +} + +#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ToolCallKind { + #[default] + Function, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct FunctionCall { + pub name: String, + /// Raw JSON string, as emitted by the model. Parse with `serde_json::from_str`. + pub arguments: String, +} + +#[serde_with::skip_serializing_none] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ToolCall { + /// Server-assigned id used to correlate the `role: tool` reply. OpenAI always + /// emits this; some OpenAI-compatible servers (e.g. llama.cpp) may omit it + /// and expect the client to skip `tool_call_id` on the reply. + #[serde(default)] + pub id: String, + #[serde(rename = "type", default)] + pub kind: ToolCallKind, + pub function: FunctionCall, +} + +impl ToolCall { + pub fn parse_arguments Deserialize<'de>>(&self) -> serde_json::Result { + serde_json::from_str(&self.function.arguments) + } +} + +/// Streaming fragment of a tool call. The model sends the `id`/`name` once and +/// then streams `arguments` as string fragments under the same `index`. +#[serde_with::skip_serializing_none] +#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)] +pub struct PartialToolCall { + pub index: u32, + pub id: Option, + #[serde(rename = "type")] + pub kind: Option, + pub function: Option, +} + +#[serde_with::skip_serializing_none] +#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)] +pub struct PartialFunctionCall { + pub name: Option, + pub arguments: Option, +} + #[serde_with::skip_serializing_none] #[derive(Debug, Clone, Default, Serialize, Builder)] pub struct Request { @@ -66,6 +174,12 @@ pub struct Request { /// more verbose responses. Currently supported values are low, medium, and /// high. pub verbosity: Option, + /// Tools (function definitions) exposed to the model. + pub tools: Option>, + /// Controls how the model selects tools. `None` defers to the server default (auto when tools are present). + pub tool_choice: Option, + /// Whether the model may emit multiple tool calls in a single assistant turn. + pub parallel_tool_calls: Option, // logit_bias pub(crate) stream: Option, pub(crate) stream_options: Option, @@ -78,6 +192,7 @@ pub enum Role { #[default] User, Assistant, + Tool, } #[serde_with::skip_serializing_none] @@ -87,6 +202,10 @@ pub struct Message { pub role: Role, pub content: Option, pub name: Option, + /// Assistant-authored tool calls. Present when the model asks to invoke tools. + pub tool_calls: Option>, + /// Set on `role: tool` messages that return a tool's output to the model. + pub tool_call_id: Option, } impl Message { pub fn estimate_tokens(&self) -> u32 { @@ -99,6 +218,15 @@ impl Message { 0 } } + + pub fn tool_result(tool_call_id: impl Into, content: impl Into) -> Self { + Self { + role: Role::Tool, + content: Some(content.into()), + tool_call_id: Some(tool_call_id.into()), + ..Default::default() + } + } } impl Default for Message { fn default() -> Self { @@ -106,26 +234,32 @@ impl Default for Message { role: Role::User, content: Default::default(), name: None, + tool_calls: None, + tool_call_id: None, } } } -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, Default, Deserialize)] pub struct PartialMessage { pub role: Option, // llama-cpp begins responses with content: null for some reason #[serde(default)] pub content: Option, + #[serde(default)] + pub tool_calls: Option>, } #[derive(Debug, Clone, Deserialize)] pub struct Choice { pub message: Message, - // finish_reason + #[serde(default)] + pub finish_reason: Option, // index } -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(default)] pub struct CompletionTokensDetails { pub accepted_prediction_tokens: u32, pub audio_tokens: u32, @@ -133,7 +267,8 @@ pub struct CompletionTokensDetails { pub rejected_prediction_tokens: u32, } -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(default)] pub struct PromptTokensDetails { pub audio_tokens: u32, pub cached_tokens: u32, @@ -151,7 +286,8 @@ pub struct Usage { #[derive(Debug, Clone, Deserialize)] pub struct PartialChoice { pub delta: PartialMessage, - // finish_reason + #[serde(default)] + pub finish_reason: Option, // index } diff --git a/openai/tests/common/mod.rs b/openai/tests/common/mod.rs new file mode 100644 index 0000000..b0a8412 --- /dev/null +++ b/openai/tests/common/mod.rs @@ -0,0 +1,72 @@ +//! Shared helpers for integration tests against a live OpenAI-compatible server +//! (OpenAI, llama.cpp, vLLM, Ollama, etc.). +//! +//! Config is read from the workspace `niinii.toml`. Tests acquire the client +//! and model via [`live_server!`], which skips (prints a notice and returns) +//! when the config file or required fields are missing — so `cargo test` is +//! always safe to run. +//! +//! Required in `niinii.toml`: +//! - `[chat].api_endpoint` +//! - `[chat].model` +//! +//! Optional: +//! - `openai_api_key` (defaults to `"no-key"` for local servers) + +use openai::{Client, ModelId}; + +const CONFIG_PATH: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/../niinii.toml"); + +pub struct LiveConfig { + pub endpoint: String, + pub model: String, + pub api_key: String, +} + +pub fn load_config() -> Option { + let text = std::fs::read_to_string(CONFIG_PATH).ok()?; + let v: toml::Value = toml::from_str(&text).ok()?; + let chat = v.get("chat")?; + let endpoint = chat.get("api_endpoint")?.as_str()?.to_string(); + let model = chat.get("model")?.as_str()?.to_string(); + let api_key = v + .get("openai_api_key") + .and_then(|k| k.as_str()) + .unwrap_or("no-key") + .to_string(); + Some(LiveConfig { + endpoint, + model, + api_key, + }) +} + +pub fn build(cfg: LiveConfig) -> (Client, ModelId) { + let model = ModelId(cfg.model); + let client = Client::new(cfg.api_key, cfg.endpoint, Default::default()); + (client, model) +} + +/// Acquire `(Client, ModelId)` or skip the enclosing test (prints a notice +/// and `return`s) if `niinii.toml` is missing or incomplete. The skip branch +/// is why this is a macro — a function can't return from its caller. +#[macro_export] +macro_rules! live_server { + () => { + match $crate::common::load_config() { + Some(cfg) => $crate::common::build(cfg), + None => { + fn f() {} + fn type_name_of(_: T) -> &'static str { + std::any::type_name::() + } + let test_name = type_name_of(f).strip_suffix("::f").unwrap_or(""); + eprintln!( + "SKIP {}: niinii.toml missing [chat].api_endpoint / [chat].model", + test_name + ); + return; + } + } + }; +} diff --git a/openai/tests/live_chat.rs b/openai/tests/live_chat.rs new file mode 100644 index 0000000..f0a7d91 --- /dev/null +++ b/openai/tests/live_chat.rs @@ -0,0 +1,140 @@ +//! Live integration tests for the Chat Completions API. +//! +//! These hit a real OpenAI-compatible server. They skip (with a printed notice) +//! unless `niinii.toml` has `[chat].api_endpoint` and `[chat].model`. See +//! [`common`] for details. + +mod common; + +use openai::chat::{ + FunctionDef, Message, Request, Role, Tool, ToolCallAccumulator, ToolChoice, ToolChoiceMode, +}; +use tokio_stream::StreamExt; +use tracing_test::traced_test; + +fn weather_tool() -> Tool { + Tool::function(FunctionDef { + name: "get_weather".into(), + description: Some("Get the current weather for a city".into()), + parameters: Some(serde_json::json!({ + "type": "object", + "properties": { + "location": { "type": "string" }, + "unit": { "type": "string", "enum": ["celsius", "fahrenheit"] } + }, + "required": ["location"], + "additionalProperties": false, + })), + strict: Some(true), + }) +} + +fn user(content: &str) -> Message { + Message { + role: Role::User, + content: Some(content.into()), + ..Default::default() + } +} + +#[tokio::test] +#[traced_test] +async fn chat_basic() { + let (client, model) = live_server!(); + let request = Request::builder() + .model(model) + .messages(vec![user("What is the capital city of Canada?")]) + .build(); + let response = client.chat(request).await.unwrap(); + let content = response.choices[0].message.content.as_deref().unwrap_or(""); + println!("{}", content); + assert!(content.contains("Ottawa")); +} + +#[tokio::test] +#[traced_test] +async fn chat_stream_basic() { + let (client, model) = live_server!(); + let request = Request::builder() + .model(model) + .messages(vec![user("What is the capital city of Canada?")]) + .build(); + let mut stream = client.stream(request).await.unwrap(); + while let Some(chunk) = stream.next().await { + println!("{:?}", chunk); + } +} + +#[tokio::test] +#[traced_test] +async fn chat_tool_call_loop() { + let (client, model) = live_server!(); + let tools = vec![weather_tool()]; + let mut messages = vec![user("What is the weather in Tokyo? Use the tool.")]; + + // Turn 1: expect the model to call the tool. + let req = Request::builder() + .model(model.clone()) + .messages(messages.clone()) + .tools(tools.clone()) + .tool_choice(ToolChoice::Mode(ToolChoiceMode::Auto)) + .build(); + let resp = client.chat(req).await.unwrap(); + let choice = resp.choices.into_iter().next().unwrap(); + assert_eq!(choice.finish_reason.as_deref(), Some("tool_calls")); + let assistant = choice.message; + let tool_calls = assistant.tool_calls.clone().expect("tool_calls present"); + assert_eq!(tool_calls.len(), 1); + assert_eq!(tool_calls[0].function.name, "get_weather"); + messages.push(assistant); + + // Turn 2: return a canned result and let the model compose a reply. + for call in &tool_calls { + messages.push(Message::tool_result( + &call.id, + r#"{"location":"Tokyo","temp_c":22,"conditions":"clear"}"#, + )); + } + let req = Request::builder() + .model(model) + .messages(messages) + .tools(tools) + .build(); + let resp = client.chat(req).await.unwrap(); + let choice = resp.choices.into_iter().next().unwrap(); + let content = choice.message.content.unwrap_or_default(); + println!("final: {}", content); + assert!(content.to_lowercase().contains("tokyo")); +} + +#[tokio::test] +#[traced_test] +async fn stream_tool_call_accumulates() { + let (client, model) = live_server!(); + let req = Request::builder() + .model(model) + .messages(vec![user("What is the weather in Tokyo? Use the tool.")]) + .tools(vec![weather_tool()]) + .tool_choice(ToolChoice::Mode(ToolChoiceMode::Required)) + .build(); + let mut stream = client.stream(req).await.unwrap(); + let mut acc = ToolCallAccumulator::new(); + let mut finish = None; + while let Some(chunk) = stream.next().await { + let chunk = chunk.unwrap(); + for choice in chunk.choices { + if let Some(calls) = choice.delta.tool_calls { + acc.extend(calls); + } + if choice.finish_reason.is_some() { + finish = choice.finish_reason; + } + } + } + assert_eq!(finish.as_deref(), Some("tool_calls")); + let calls = acc.finish(); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].function.name, "get_weather"); + let v: serde_json::Value = serde_json::from_str(&calls[0].function.arguments).unwrap(); + assert!(v.get("location").is_some()); +} From 8c2f7fdfb00ad64ad20cfc77e9c1d92db84ef997 Mon Sep 17 00:00:00 2001 From: netdex Date: Sun, 19 Apr 2026 10:25:25 -0400 Subject: [PATCH 3/8] niinii: remove DeepL, Realtime, and Responses translator backends --- niinii/src/settings.rs | 59 ------ niinii/src/translator/deepl.rs | 71 ------- niinii/src/translator/mod.rs | 31 +-- niinii/src/translator/realtime.rs | 162 --------------- niinii/src/translator/responses.rs | 264 ------------------------ niinii/src/view/translator/deepl.rs | 49 ----- niinii/src/view/translator/mod.rs | 10 +- niinii/src/view/translator/realtime.rs | 114 ---------- niinii/src/view/translator/responses.rs | 201 ------------------ niinii/src/view/tts.rs | 22 -- 10 files changed, 3 insertions(+), 980 deletions(-) delete mode 100644 niinii/src/translator/deepl.rs delete mode 100644 niinii/src/translator/realtime.rs delete mode 100644 niinii/src/translator/responses.rs delete mode 100644 niinii/src/view/translator/deepl.rs delete mode 100644 niinii/src/view/translator/realtime.rs delete mode 100644 niinii/src/view/translator/responses.rs delete mode 100644 niinii/src/view/tts.rs diff --git a/niinii/src/settings.rs b/niinii/src/settings.rs index 835a70f..3886b30 100644 --- a/niinii/src/settings.rs +++ b/niinii/src/settings.rs @@ -29,10 +29,7 @@ pub enum RubyTextType { #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, IntoStaticStr, EnumIter)] pub enum TranslatorType { - DeepL, Chat, - Realtime, - Responses, } #[derive(Clone, Deserialize, Serialize)] @@ -75,56 +72,6 @@ impl Default for ChatSettings { } } -#[derive(Clone, Deserialize, Serialize)] -#[serde(default)] -pub struct RealtimeSettings { - pub model: openai::ModelId, - pub system_prompt: String, - pub temperature: Option, -} -impl Default for RealtimeSettings { - fn default() -> Self { - Self { - model: Default::default(), - system_prompt: "You will translate the following visual novel script into English." - .into(), - temperature: None, - } - } -} - -#[derive(Clone, Deserialize, Serialize)] -#[serde(default)] -pub struct ResponsesSettings { - pub model: openai::ModelId, - pub system_prompt: String, - pub max_output_tokens: Option, - pub temperature: Option, - pub top_p: Option, - pub stream: bool, - pub store: bool, - pub compact_threshold: Option, - pub reasoning_effort: Option, - pub verbosity: Option, -} -impl Default for ResponsesSettings { - fn default() -> Self { - Self { - model: Default::default(), - system_prompt: "You will translate the following visual novel script into English." - .into(), - max_output_tokens: Some(128), - temperature: None, - top_p: None, - stream: true, - store: true, - compact_threshold: None, - reasoning_effort: None, - verbosity: None, - } - } -} - #[derive(Clone, Deserialize, Serialize)] #[serde(default)] pub struct Settings { @@ -145,11 +92,8 @@ pub struct Settings { pub translator_type: TranslatorType, pub auto_translate: bool, - pub deepl_api_key: String, pub openai_api_key: String, pub chat: ChatSettings, - pub realtime: RealtimeSettings, - pub responses: ResponsesSettings, pub vv_model_path: String, pub auto_tts_regex: Option, @@ -183,11 +127,8 @@ impl Default for Settings { translator_type: TranslatorType::Chat, auto_translate: false, - deepl_api_key: Default::default(), openai_api_key: Default::default(), chat: Default::default(), - realtime: Default::default(), - responses: Default::default(), vv_model_path: Default::default(), auto_tts_regex: None, diff --git a/niinii/src/translator/deepl.rs b/niinii/src/translator/deepl.rs deleted file mode 100644 index 0eb15ad..0000000 --- a/niinii/src/translator/deepl.rs +++ /dev/null @@ -1,71 +0,0 @@ -use async_trait::async_trait; -use enclose::enclose; - -use super::{Error, Translation, Translator}; -use crate::{ - settings::Settings, - view::{ - translator::{ViewDeepLTranslation, ViewDeepLTranslationUsage, ViewDeepLTranslator}, - View, - }, -}; - -pub struct DeepLTranslator; - -#[async_trait] -impl Translator for DeepLTranslator { - async fn translate( - &self, - settings: &Settings, - text: String, - ) -> Result, Error> { - let Settings { deepl_api_key, .. } = settings; - - // TODO: it would be great if there was an async version of this - let (deepl_text, deepl_usage) = tokio::task::spawn_blocking(enclose! { (text, deepl_api_key) move || { - let deepl = deepl_api::DeepL::new(deepl_api_key); - let deepl_text = deepl - .translate( - None, - deepl_api::TranslatableTextList { - source_language: Some("JA".into()), - target_language: "EN-US".into(), - texts: vec![text], - }, - )? - .first() - .unwrap() - .text - .trim() - .to_owned(); - let deepl_usage = deepl.usage_information()?; - Ok::<(String, deepl_api::UsageInformation), deepl_api::Error>((deepl_text, deepl_usage)) - }}) - .await - .unwrap()?; - - Ok(Box::new(DeepLTranslation { - source_text: text, - deepl_text, - deepl_usage, - })) - } - fn view<'a>(&'a self, _settings: &'a mut Settings) -> Box { - Box::new(ViewDeepLTranslator) - } -} - -#[derive(Debug)] -pub struct DeepLTranslation { - pub source_text: String, - pub deepl_text: String, - pub deepl_usage: deepl_api::UsageInformation, -} -impl Translation for DeepLTranslation { - fn view(&self) -> Box { - Box::new(ViewDeepLTranslation(self)) - } - fn view_usage(&self) -> Box { - Box::new(ViewDeepLTranslationUsage(self)) - } -} diff --git a/niinii/src/translator/mod.rs b/niinii/src/translator/mod.rs index 5f10932..61f1a0c 100644 --- a/niinii/src/translator/mod.rs +++ b/niinii/src/translator/mod.rs @@ -1,32 +1,3 @@ -use async_trait::async_trait; -use thiserror::Error; - -use crate::{settings::Settings, view::View}; - pub mod chat; -pub mod deepl; -pub mod realtime; -pub mod responses; - -#[derive(Error, Debug)] -pub enum Error { - #[error(transparent)] - DeepL(#[from] deepl_api::Error), - #[error(transparent)] - OpenAI(#[from] openai::Error), -} - -#[async_trait] -pub trait Translator: Send + Sync { - async fn translate( - &self, - settings: &Settings, - text: String, - ) -> Result, Error>; - fn view<'a>(&'a self, settings: &'a mut Settings) -> Box; -} -pub trait Translation: Send { - fn view(&self) -> Box; - fn view_usage(&self) -> Box; -} +pub use chat::{ChatHandle, ChatState, ContextEdit, ExchangeId, ExchangeView, Response}; diff --git a/niinii/src/translator/realtime.rs b/niinii/src/translator/realtime.rs deleted file mode 100644 index 38376ba..0000000 --- a/niinii/src/translator/realtime.rs +++ /dev/null @@ -1,162 +0,0 @@ -use std::{sync::Arc, time::Duration}; - -use async_trait::async_trait; -use enclose::enclose; -use futures::StreamExt; -use openai::{realtime::*, ConnectionPolicy}; -use tokio::sync::{Mutex, OnceCell, RwLock, RwLockReadGuard, RwLockWriteGuard}; -use tokio_util::sync::{CancellationToken, DropGuard}; - -use crate::{ - settings::{RealtimeSettings, Settings}, - view::{ - translator::{ - ViewRealtimeTranslation, ViewRealtimeTranslationUsage, ViewRealtimeTranslator, - }, - View, - }, -}; - -use super::{Error, Translation, Translator}; - -pub struct RealtimeTranslator { - client: openai::Client, - pub models: Vec, - session: RwLock>, -} -impl RealtimeTranslator { - pub async fn new(settings: &Settings) -> Self { - let client = openai::Client::new( - &settings.openai_api_key, - &settings.chat.api_endpoint, - ConnectionPolicy { - timeout: Duration::from_millis(settings.chat.timeout), - connect_timeout: Duration::from_millis(settings.chat.connection_timeout), - }, - ); - let models = client - .models() - .await - .inspect_err(|err| { - tracing::error!( - ?err, - "failed to query OpenAI models, no models will be available" - ) - }) - .unwrap_or_default(); - Self { - client, - models, - session: RwLock::new(OnceCell::new()), - } - } - async fn create_session( - &self, - settings: &RealtimeSettings, - ) -> Result { - self.client - .realtime(SessionParameters { - inference_parameters: InferenceParameters { - modalities: vec![Modality::Text], - model: Some(settings.model.clone()), - temperature: settings.temperature, - instructions: Some(settings.system_prompt.clone()), - ..Default::default() - }, - }) - .await - } - pub fn session(&self) -> RwLockReadGuard<'_, OnceCell> { - self.session.blocking_read() - } - pub fn session_mut(&self) -> RwLockWriteGuard<'_, OnceCell> { - self.session.blocking_write() - } -} - -#[async_trait] -impl Translator for RealtimeTranslator { - async fn translate( - &self, - settings: &Settings, - text: String, - ) -> Result, Error> { - let session = self.session.read().await; - let session = session - .get_or_try_init(|| self.create_session(&settings.realtime)) - .await?; - - // send both requests, in flight simultaneously - let fut = session - .conversation_item_create(ConversationItem::input_text(text)) - .await?; - let mut stream = session - .response_create(ResponseParameters { - ..Default::default() - }) - .await?; - fut.await?; - - let inner = Arc::new(Mutex::new(Inner::default())); - let token = CancellationToken::new(); - tokio::spawn(enclose! { (inner, token) async move { - loop { - tokio::select! { - Some(event) = stream.next() => { - match event { - Ok(ServerEvent::ResponseDone { response }) => { - let Inner { usage, ..} = &mut *inner.lock().await; - *usage = response.usage; - } - Ok(ServerEvent::ResponseTextDelta(delta)) => { - let Inner { text, .. } = &mut *inner.lock().await; - text.push_str(&delta.delta); - } - Ok(ServerEvent::ResponseTextDone(response_text)) => { - let Inner { text, .. } = &mut *inner.lock().await; - *text = response_text.text; - } - Ok(_) => {} - Err(err) => { - tracing::error!(%err, "stream"); - break; - } - } - } - _ = token.cancelled() => { - break; - } - else => { break } - } - } - }}); - - Ok(Box::new(RealtimeTranslation { - inner, - _guard: token.drop_guard(), - })) - } - - fn view<'a>(&'a self, settings: &'a mut Settings) -> Box { - Box::new(ViewRealtimeTranslator(self, settings)) - } -} - -#[derive(Default)] -pub struct Inner { - pub text: String, - pub usage: Option, -} -pub struct RealtimeTranslation { - pub inner: Arc>, - _guard: DropGuard, -} -impl Translation for RealtimeTranslation { - fn view(&self) -> Box { - Box::new(ViewRealtimeTranslation(self)) - } - - fn view_usage(&self) -> Box { - Box::new(ViewRealtimeTranslationUsage(self)) - } -} diff --git a/niinii/src/translator/responses.rs b/niinii/src/translator/responses.rs deleted file mode 100644 index 52eb748..0000000 --- a/niinii/src/translator/responses.rs +++ /dev/null @@ -1,264 +0,0 @@ -use std::{sync::Arc, time::Duration}; - -use async_trait::async_trait; -use enclose::enclose; -use openai::{ - conversations::Conversation, - responses::{ - self, ContextManagementEntry, ContextManagementType, Message, OutputItem, StreamEvent, - }, - ConnectionPolicy, ModelId, Role, -}; -use tokio::sync::Mutex; -use tokio_stream::StreamExt; -use tokio_util::sync::{CancellationToken, DropGuard}; -use tracing::Instrument; - -use crate::{ - settings::{ResponsesSettings, Settings}, - view::{ - translator::{ - ViewResponsesTranslation, ViewResponsesTranslationUsage, ViewResponsesTranslator, - }, - View, - }, -}; - -use super::{Error, Translation, Translator}; - -#[derive(Clone, Debug)] -pub struct ConversationInfo { - pub id: String, - pub created_at: u64, -} - -impl From for ConversationInfo { - fn from(value: Conversation) -> Self { - Self { - id: value.id, - created_at: value.created_at, - } - } -} - -pub struct ResponsesTranslator { - client: openai::Client, - pub models: Vec, - conversation: Mutex>, -} - -impl ResponsesTranslator { - pub async fn new(settings: &Settings) -> Self { - let client = openai::Client::new( - &settings.openai_api_key, - &settings.chat.api_endpoint, - ConnectionPolicy { - timeout: Duration::from_millis(settings.chat.timeout), - connect_timeout: Duration::from_millis(settings.chat.connection_timeout), - }, - ); - - let mut models = client - .models() - .await - .inspect_err(|err| { - tracing::error!( - ?err, - "failed to query OpenAI models, no models will be available" - ); - }) - .unwrap_or_default(); - models.sort(); - - Self { - client, - models, - conversation: Mutex::new(None), - } - } - - async fn ensure_conversation(&self) -> Result { - { - let guard = self.conversation.lock().await; - if let Some(existing) = guard.clone() { - return Ok(existing); - } - } - let conversation = self.client.create_conversation().await?; - let info = ConversationInfo::from(conversation); - *self.conversation.lock().await = Some(info.clone()); - Ok(info) - } - - fn build_request( - &self, - settings: &ResponsesSettings, - text: &str, - conversation_id: Option, - ) -> responses::Request { - let mut request = responses::Request::builder() - .model(settings.model.clone()) - .input(vec![Message { - role: Role::User, - content: Some(text.to_owned()), - }]) - .maybe_max_output_tokens(settings.max_output_tokens) - .maybe_temperature(settings.temperature) - .maybe_top_p(settings.top_p) - .maybe_verbosity(settings.verbosity) - .build(); - - request.conversation = conversation_id; - request.store = Some(settings.store); - if !settings.system_prompt.trim().is_empty() { - request.instructions = Some(settings.system_prompt.clone()); - } - if let Some(effort) = settings.reasoning_effort { - request.reasoning = Some(responses::ReasoningOptions::with_effort(effort)); - } - if let Some(compact_threshold) = settings.compact_threshold { - let threshold = compact_threshold.max(1000); - if threshold != compact_threshold { - tracing::warn!( - compact_threshold, - "compact_threshold must be >= 1000; clamping" - ); - } - request.context_management = Some(vec![ContextManagementEntry { - entry_type: ContextManagementType::Compaction, - compact_threshold: Some(threshold), - }]); - } - - request - } - - fn collect_text(output: &[OutputItem]) -> String { - let mut text = String::new(); - for item in output { - if let OutputItem::Message(message) = item { - if message.role == Role::Assistant { - for block in &message.content { - if let responses::MessageContent::OutputText(content) = block { - if !text.is_empty() { - text.push('\n'); - } - text.push_str(&content.text); - } - } - } - } - } - text - } - - pub fn conversation(&self) -> &Mutex> { - &self.conversation - } -} - -#[derive(Default)] -pub struct TranslationState { - pub text: String, - pub usage: Option, - pub response_id: Option, - pub completed: bool, -} - -pub struct ResponsesTranslation { - pub model: ModelId, - pub conversation_id: Option, - state: Arc>, - _guard: DropGuard, -} - -#[async_trait] -impl Translator for ResponsesTranslator { - async fn translate( - &self, - settings: &Settings, - text: String, - ) -> Result, Error> { - let responses_settings = &settings.responses; - let conversation = self.ensure_conversation().await?; - let request = self.build_request(responses_settings, &text, Some(conversation.id.clone())); - - let state = Arc::new(Mutex::new(TranslationState::default())); - let token = CancellationToken::new(); - - if responses_settings.stream { - let mut stream = self.client.stream_responses(request).await?; - let stream_state = Arc::clone(&state); - let stream_token = token.clone(); - tokio::spawn( - enclose! { (stream_state => state, stream_token => token) async move { - loop { - tokio::select! { - Some(event) = stream.next() => { - match event { - Ok(StreamEvent::OutputTextDelta { delta }) => { - state.lock().await.text.push_str(&delta); - } - Ok(StreamEvent::ResponseCompleted { response }) => { - let mut guard = state.lock().await; - guard.response_id = Some(response.id); - guard.usage = response.usage; - if !response.output.is_empty() { - guard.text = ResponsesTranslator::collect_text(&response.output); - } - guard.completed = true; - break; - } - Ok(StreamEvent::OutputTextDone) | Ok(StreamEvent::ResponseCreated { .. }) => {} - Ok(StreamEvent::Unknown) => {} - Err(err) => { - tracing::error!(%err, "responses stream"); - break; - } - } - } - _ = token.cancelled() => { - break; - } - else => { break; } - } - } - }.instrument(tracing::Span::current())}, - ); - } else { - let response = self.client.responses(request).await?; - let mut guard = state.lock().await; - guard.text = Self::collect_text(&response.output); - guard.response_id = Some(response.id); - guard.usage = response.usage; - guard.completed = true; - } - - Ok(Box::new(ResponsesTranslation { - model: responses_settings.model.clone(), - conversation_id: Some(conversation.id), - state, - _guard: token.drop_guard(), - })) - } - - fn view<'a>(&'a self, settings: &'a mut Settings) -> Box { - Box::new(ViewResponsesTranslator(self, settings)) - } -} - -impl Translation for ResponsesTranslation { - fn view(&self) -> Box { - Box::new(ViewResponsesTranslation(self)) - } - - fn view_usage(&self) -> Box { - Box::new(ViewResponsesTranslationUsage(self)) - } -} - -impl ResponsesTranslation { - pub fn state(&self) -> &Arc> { - &self.state - } -} diff --git a/niinii/src/view/translator/deepl.rs b/niinii/src/view/translator/deepl.rs deleted file mode 100644 index 1023099..0000000 --- a/niinii/src/view/translator/deepl.rs +++ /dev/null @@ -1,49 +0,0 @@ -use imgui::*; - -use crate::translator::deepl::DeepLTranslation; - -use crate::view::{ - mixins::{stroke_text, stroke_text_with_highlight}, - View, -}; - -pub struct ViewDeepLTranslator; -impl View for ViewDeepLTranslator { - fn ui(&mut self, _ui: &imgui::Ui) {} -} - -pub struct ViewDeepLTranslation<'a>(pub &'a DeepLTranslation); -impl View for ViewDeepLTranslation<'_> { - fn ui(&mut self, ui: &imgui::Ui) { - let _wrap_token = ui.push_text_wrap_pos_with_pos(0.0); - ui.text(""); // anchor for line wrapping - let draw_list = ui.get_window_draw_list(); - stroke_text_with_highlight( - ui, - &draw_list, - "[DeepL]", - 1.0, - Some(StyleColor::TextSelectedBg), - ); - ui.same_line(); - stroke_text(ui, &draw_list, &self.0.deepl_text, 1.0); - } -} - -pub struct ViewDeepLTranslationUsage<'a>(pub &'a DeepLTranslation); -impl View for ViewDeepLTranslationUsage<'_> { - fn ui(&mut self, ui: &imgui::Ui) { - let deepl_usage = &self.0.deepl_usage; - ui.same_line(); - let fraction = deepl_usage.character_count as f32 / deepl_usage.character_limit as f32; - ProgressBar::new(fraction) - .overlay_text(format!( - "usage: {}/{} ({:.2}%)", - deepl_usage.character_count, - deepl_usage.character_limit, - fraction * 100.0 - )) - .size([350.0, 0.0]) - .build(ui); - } -} diff --git a/niinii/src/view/translator/mod.rs b/niinii/src/view/translator/mod.rs index 76290df..825af27 100644 --- a/niinii/src/view/translator/mod.rs +++ b/niinii/src/view/translator/mod.rs @@ -1,9 +1,3 @@ -mod chat; -mod deepl; -mod realtime; -mod responses; +pub mod chat; -pub use chat::*; -pub use deepl::*; -pub use realtime::*; -pub use responses::*; +pub use chat::TranslatorWindow; diff --git a/niinii/src/view/translator/realtime.rs b/niinii/src/view/translator/realtime.rs deleted file mode 100644 index dc44686..0000000 --- a/niinii/src/view/translator/realtime.rs +++ /dev/null @@ -1,114 +0,0 @@ -use imgui::{SliderFlags, StyleColor, TableColumnSetup, TableFlags, TreeNodeFlags}; - -use crate::{ - settings::Settings, - translator::realtime::{RealtimeTranslation, RealtimeTranslator}, - view::{ - mixins::{checkbox_option_with_default, combo_list, stroke_text_with_highlight}, - View, - }, -}; - -pub struct ViewRealtimeTranslator<'a>(pub &'a RealtimeTranslator, pub &'a mut Settings); -impl View for ViewRealtimeTranslator<'_> { - fn ui(&mut self, ui: &imgui::Ui) { - let Self(translator, settings) = self; - let realtime = &mut settings.realtime; - ui.menu_bar(|| { - ui.menu("Session", || { - if ui.menu_item("Reset") { - translator.session_mut().take(); - } - }); - }); - if ui.collapsing_header("Session", TreeNodeFlags::DEFAULT_OPEN) { - if let Some(session) = translator.session().get() { - let info = session.info(); - let mut session_id = info.id.to_string(); - ui.input_text("Session ID", &mut session_id) - .read_only(true) - .build(); - } else { - ui.text_disabled("No active session"); - } - } - if ui.collapsing_header("Parameters", TreeNodeFlags::DEFAULT_OPEN) { - if let Some(_token) = ui.begin_table("##", 2) { - ui.table_next_column(); - checkbox_option_with_default( - ui, - &mut realtime.temperature, - 1.0, - |ui, temperature| { - ui.set_next_item_width(ui.current_font_size() * -8.0); - ui.slider_config("Temperature", 0.0f32, 2.0f32) - .display_format("%.2f") - .flags(SliderFlags::ALWAYS_CLAMP) - .build(temperature); - }, - ); - ui.table_next_column(); - ui.set_next_item_width(ui.current_font_size() * -8.0); - combo_list(ui, "Model", &translator.models, &mut realtime.model); - } - } - ui.child_window("context_window").build(|| { - if let Some(_t) = ui.begin_table_header_with_flags( - "context", - [ - TableColumnSetup::new(""), - TableColumnSetup::new(""), - TableColumnSetup::new("Role"), - TableColumnSetup::new("Message"), - ], - TableFlags::SIZING_STRETCH_PROP, - ) { - ui.table_next_column(); - ui.table_next_column(); - ui.table_next_column(); - ui.disabled(true, || { - ui.set_next_item_width(ui.current_font_size() * 6.0); - ui.text("System"); - }); - ui.table_next_column(); - ui.input_text_multiline( - "##", - &mut realtime.system_prompt, - [ui.content_region_avail()[0], 200.0], - ) - .build(); - } - }); - } -} - -pub struct ViewRealtimeTranslation<'a>(pub &'a RealtimeTranslation); -impl View for ViewRealtimeTranslation<'_> { - fn ui(&mut self, ui: &imgui::Ui) { - let _wrap_token = ui.push_text_wrap_pos_with_pos(0.0); - ui.text(""); // anchor for line wrapping - ui.same_line(); - let inner = self.0.inner.blocking_lock(); - let draw_list = ui.get_window_draw_list(); - stroke_text_with_highlight( - ui, - &draw_list, - "[ChatGPT]", - 1.0, - Some(StyleColor::NavHighlight), - ); - ui.same_line(); - stroke_text_with_highlight( - ui, - &draw_list, - &inner.text, - 1.0, - Some(StyleColor::TextSelectedBg), - ); - } -} - -pub struct ViewRealtimeTranslationUsage<'a>(pub &'a RealtimeTranslation); -impl View for ViewRealtimeTranslationUsage<'_> { - fn ui(&mut self, _ui: &imgui::Ui) {} -} diff --git a/niinii/src/view/translator/responses.rs b/niinii/src/view/translator/responses.rs deleted file mode 100644 index e6aaa9a..0000000 --- a/niinii/src/view/translator/responses.rs +++ /dev/null @@ -1,201 +0,0 @@ -use imgui::{ProgressBar, SliderFlags, StyleColor, TreeNodeFlags}; - -use crate::{ - settings::Settings, - translator::responses::{ResponsesTranslation, ResponsesTranslator}, - view::{ - mixins::{ - checkbox_option, checkbox_option_with_default, combo_enum, combo_list, ellipses, - help_marker, stroke_text_with_highlight, - }, - View, - }, -}; - -pub struct ViewResponsesTranslator<'a>(pub &'a ResponsesTranslator, pub &'a mut Settings); -impl View for ViewResponsesTranslator<'_> { - fn ui(&mut self, ui: &imgui::Ui) { - let Self(translator, settings) = self; - let mut reset_requested = false; - ui.menu_bar(|| { - ui.menu("Conversation", || { - if ui.menu_item("Reset") { - reset_requested = true; - } - }); - }); - if reset_requested { - translator.conversation().blocking_lock().take(); - } - - let responses = &mut settings.responses; - - if ui.collapsing_header("Conversation", TreeNodeFlags::DEFAULT_OPEN) { - let guard = translator.conversation().blocking_lock(); - if let Some(info) = guard.as_ref() { - let mut id = info.id.clone(); - ui.input_text("Conversation ID", &mut id) - .read_only(true) - .build(); - ui.text_disabled(format!("Created at: {}", info.created_at)); - } else { - ui.text_disabled("Conversation will be created on next translation"); - } - drop(guard); - } - - if ui.collapsing_header("Parameters", TreeNodeFlags::DEFAULT_OPEN) { - if let Some(_token) = ui.begin_table("##responses_parameters", 2) { - ui.table_next_column(); - ui.set_next_item_width(ui.current_font_size() * -8.0); - combo_list(ui, "Model", &translator.models, &mut responses.model); - ui.table_next_column(); - ui.checkbox("Stream", &mut responses.stream); - ui.same_line(); - help_marker(ui, "Use SSE responses for partial updates"); - - ui.table_next_column(); - responses.store = true; - ui.disabled(true, || { - ui.checkbox("Store response", &mut responses.store); - }); - ui.same_line(); - help_marker(ui, "Required for server-side conversation context"); - - ui.table_next_column(); - checkbox_option(ui, &mut responses.max_output_tokens, |ui, max_tokens| { - ui.set_next_item_width(ui.current_font_size() * -8.0); - ui.input_scalar("Max output tokens", max_tokens).build(); - }); - - ui.table_next_column(); - checkbox_option( - ui, - &mut responses.compact_threshold, - |ui, compact_threshold| { - ui.set_next_item_width(ui.current_font_size() * -8.0); - ui.input_scalar("Compact threshold", compact_threshold) - .build(); - ui.same_line(); - help_marker(ui, "Minimum 1000 tokens"); - }, - ); - - ui.table_next_column(); - checkbox_option_with_default( - ui, - &mut responses.temperature, - 1.0, - |ui, temperature| { - ui.set_next_item_width(ui.current_font_size() * -8.0); - ui.slider_config("Temperature", 0.0f32, 2.0f32) - .display_format("%.2f") - .flags(SliderFlags::ALWAYS_CLAMP) - .build(temperature); - }, - ); - ui.table_next_column(); - checkbox_option_with_default(ui, &mut responses.top_p, 1.0, |ui, top_p| { - ui.set_next_item_width(ui.current_font_size() * -8.0); - ui.slider_config("Top P", 0.0f32, 1.0f32) - .display_format("%.2f") - .flags(SliderFlags::ALWAYS_CLAMP) - .build(top_p); - }); - - ui.table_next_column(); - checkbox_option_with_default( - ui, - &mut responses.reasoning_effort, - openai::ReasoningEffort::Medium, - |ui, reasoning_effort| { - ui.set_next_item_width(ui.current_font_size() * -8.0); - combo_enum(ui, "Reasoning effort", reasoning_effort); - }, - ); - ui.table_next_column(); - checkbox_option_with_default( - ui, - &mut responses.verbosity, - openai::Verbosity::Medium, - |ui, verbosity| { - ui.set_next_item_width(ui.current_font_size() * -8.0); - combo_enum(ui, "Verbosity", verbosity); - }, - ); - } - } - - ui.child_window("responses_prompt").build(|| { - ui.input_text_multiline( - "System prompt", - &mut responses.system_prompt, - [ui.content_region_avail()[0], 200.0], - ) - .build(); - }); - } -} - -pub struct ViewResponsesTranslation<'a>(pub &'a ResponsesTranslation); -impl View for ViewResponsesTranslation<'_> { - fn ui(&mut self, ui: &imgui::Ui) { - let draw_list = ui.get_window_draw_list(); - let _wrap_token = ui.push_text_wrap_pos_with_pos(0.0); - ui.text(""); - ui.same_line(); - stroke_text_with_highlight( - ui, - &draw_list, - &format!("[{}]", self.0.model.as_ref()), - 1.0, - Some(StyleColor::NavHighlight), - ); - let state = self.0.state().blocking_lock(); - if !state.text.is_empty() { - ui.same_line(); - stroke_text_with_highlight( - ui, - &draw_list, - &state.text, - 1.0, - Some(StyleColor::TextSelectedBg), - ); - } - if !state.completed { - ui.same_line_with_spacing(0.0, 0.0); - stroke_text_with_highlight( - ui, - &draw_list, - ellipses(ui), - 1.0, - Some(StyleColor::TextSelectedBg), - ); - } - } -} - -pub struct ViewResponsesTranslationUsage<'a>(pub &'a ResponsesTranslation); -impl View for ViewResponsesTranslationUsage<'_> { - fn ui(&mut self, ui: &imgui::Ui) { - let state = self.0.state().blocking_lock(); - if let Some(usage) = &state.usage { - ui.same_line(); - ProgressBar::new(0.0) - .overlay_text(format!( - "{}: {} input + {} output ({} cached) = {}", - self.0.model.as_ref(), - usage.input_tokens, - usage.output_tokens, - usage - .input_tokens_details - .as_ref() - .map(|d| d.cached_tokens) - .unwrap_or_default(), - usage.total_tokens - )) - .size([500.0, 0.0]) - .build(ui); - } - } -} diff --git a/niinii/src/view/tts.rs b/niinii/src/view/tts.rs deleted file mode 100644 index 65df7e6..0000000 --- a/niinii/src/view/tts.rs +++ /dev/null @@ -1,22 +0,0 @@ -use imgui::*; - -use crate::{settings::Settings, tts::TtsEngine}; - -pub struct TtsEngineView<'a>(pub &'a TtsEngine, pub &'a mut Settings); -impl TtsEngineView<'_> { - pub fn ui(&mut self, ui: &Ui) { - let TtsEngineView(tts_engine, settings) = self; - tts_engine.show_tts(ui, settings); - } -} - -trait ViewTtsEngine { - fn show_tts(&self, ui: &Ui, settings: &mut Settings); -} -impl ViewTtsEngine for TtsEngine { - fn show_tts(&self, ui: &Ui, _settings: &mut Settings) { - if ui.button("shut up") { - self.stop(); - } - } -} From 592a051ad31a48a1c699b8b5ccb9cef6730ca375 Mon Sep 17 00:00:00 2001 From: netdex Date: Sun, 19 Apr 2026 10:25:30 -0400 Subject: [PATCH 4/8] niinii: refactor chat translator to command/event/state store --- niinii/src/translator/chat.rs | 732 ++++++++++++++++++++++++++++------ 1 file changed, 600 insertions(+), 132 deletions(-) diff --git a/niinii/src/translator/chat.rs b/niinii/src/translator/chat.rs index 86e5748..437ac21 100644 --- a/niinii/src/translator/chat.rs +++ b/niinii/src/translator/chat.rs @@ -1,161 +1,629 @@ -use std::{sync::Arc, time::Duration}; +//! Chat Completions backend for the translator runtime. +//! +//! Implements [`Backend`] for [`ChatBackend`]. Shape: +//! +//! - Commands (external, from the UI): translate, cancel, edit the context +//! buffer, refresh the models list. +//! - Events (internal, from adapter tasks): stream start, token deltas, +//! completion, failure, models refreshed. +//! - State (published as immutable snapshots): editable context buffer, +//! in-flight + completed exchanges, models list, last error. +//! +//! Per-request parameters (`TranslateConfig`) are snapshotted from `Settings` +//! at submission time; nothing in this module reads `Settings` live. -use async_trait::async_trait; +use std::{ + collections::{HashMap, VecDeque}, + sync::{ + atomic::{AtomicU64, Ordering}, + Arc, + }, + time::Duration, +}; + +use arc_swap::ArcSwap; use enclose::enclose; use openai::{ - chat::{self, ChatBuffer, Exchange, Message}, - ConnectionPolicy, ModelId, + chat::{ + self, Message, PartialToolCall, Role, ToolCall, ToolCallAccumulator, Tool, ToolChoice, Usage, + }, + ConnectionPolicy, ModelId, ReasoningEffort, ServiceTier, Verbosity, }; -use tokio::sync::Mutex; +use tokio::sync::mpsc; use tokio_stream::StreamExt; -use tokio_util::sync::{CancellationToken, DropGuard}; +use tokio_util::sync::CancellationToken; use tracing::Instrument; -use crate::{ - settings::Settings, - view::{ - translator::{ViewChatTranslation, ViewChatTranslationUsage, ViewChatTranslator}, - View, - }, -}; +use crate::settings::Settings; -use super::{Error, Translation, Translator}; +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct ExchangeId(pub u64); -pub struct ChatTranslator { - client: openai::Client, - pub models: Vec, - pub buffer: Arc>, -} -impl ChatTranslator { - pub async fn new(settings: &Settings) -> Self { - let client = openai::Client::new( - &settings.openai_api_key, - &settings.chat.api_endpoint, - ConnectionPolicy { - timeout: Duration::from_millis(settings.chat.timeout), - connect_timeout: Duration::from_millis(settings.chat.connection_timeout), - }, - ); - - let mut models = client - .models() - .await - .inspect_err(|err| { - tracing::error!( - ?err, - "failed to query OpenAI models, no models will be available" - ) - }) - .unwrap_or_default(); - models.sort(); +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct MsgId(pub u64); +#[derive(Clone, Debug)] +pub struct ContextMessage { + pub id: MsgId, + pub message: Message, +} + +/// Per-request parameters snapshotted from `Settings` when a translation is +/// submitted. The backend never reads `Settings` directly. +#[derive(Clone, Debug)] +pub struct TranslateConfig { + pub model: ModelId, + pub system_prompt: String, + pub max_context_tokens: [u32; 2], + pub temperature: Option, + pub top_p: Option, + pub max_tokens: Option, + pub presence_penalty: Option, + pub service_tier: Option, + pub reasoning_effort: Option, + pub verbosity: Option, + pub stream: bool, + pub tools: Vec, + pub tool_choice: Option, +} + +impl TranslateConfig { + pub fn from_settings(settings: &Settings) -> Self { + let c = &settings.chat; Self { - client, - models, - buffer: Arc::new(Mutex::new(ChatBuffer::new())), + model: c.model.clone(), + system_prompt: c.system_prompt.clone(), + max_context_tokens: c.max_context_tokens, + temperature: c.temperature, + top_p: c.top_p, + max_tokens: c.max_tokens, + presence_penalty: c.presence_penalty, + service_tier: c.service_tier, + reasoning_effort: c.reasoning_effort, + verbosity: c.verbosity, + stream: c.stream, + tools: Vec::new(), + tool_choice: None, } } } -#[async_trait] -impl Translator for ChatTranslator { - async fn translate( - &self, - settings: &Settings, +#[derive(Clone, Debug)] +pub enum Response { + Streaming { content: String, tool_calls: ToolCallAccumulator }, + Completed { content: String, tool_calls: Vec }, + Errored(Arc), + Cancelled, +} + +impl Response { + /// Text rendered for the assistant turn so far. Works during streaming + /// and post-completion. + pub fn content(&self) -> &str { + match self { + Response::Streaming { content, .. } | Response::Completed { content, .. } => content, + Response::Errored(_) | Response::Cancelled => "", + } + } + pub fn is_terminal(&self) -> bool { + !matches!(self, Response::Streaming { .. }) + } +} + +#[derive(Clone, Debug)] +pub struct ExchangeView { + pub id: ExchangeId, + pub model: ModelId, + pub user_message: Message, + pub response: Response, + pub usage: Option, +} + +#[derive(Clone, Debug, Default)] +pub struct ChatState { + pub context: VecDeque, + next_msg_id: u64, + pub exchanges: Vec, + pub models: Vec, + pub last_error: Option>, +} + +impl ChatState { + pub fn exchange(&self, id: ExchangeId) -> Option<&ExchangeView> { + self.exchanges.iter().find(|e| e.id == id) + } + fn mint_id(&mut self) -> MsgId { + let id = MsgId(self.next_msg_id); + self.next_msg_id += 1; + id + } + fn push_back(&mut self, message: Message) { + let id = self.mint_id(); + self.context.push_back(ContextMessage { id, message }); + } +} + +#[derive(Debug)] +pub enum ContextEdit { + Insert { idx: usize, message: Message }, + Delete(usize), + Swap(usize, usize), + SetContent { idx: usize, content: String }, + SetRole { idx: usize, role: Role }, + SetName { idx: usize, name: Option }, +} + +pub enum ChatCommand { + Translate { + id: ExchangeId, text: String, - ) -> Result, Error> { - let chat = &settings.chat; - - let exchange = { - let mut buffer = self.buffer.lock().await; - buffer.start_exchange( - Message { - role: chat::Role::System, - content: Some(chat.system_prompt.clone()), - ..Default::default() - }, - Message { - role: chat::Role::User, - content: Some(text.clone()), - ..Default::default() + config: Arc, + }, + Cancel(ExchangeId), + EditContext(ContextEdit), + ClearContext, + RefreshModels, +} + +pub enum ChatEvent { + Started { + id: ExchangeId, + model: ModelId, + user_message: Message, + }, + Delta { + id: ExchangeId, + content: String, + }, + ToolCallDelta { + id: ExchangeId, + partials: Vec, + }, + Completed { + id: ExchangeId, + usage: Option, + max_context_tokens: [u32; 2], + }, + Failed { + id: ExchangeId, + error: Arc, + }, + Cancelled { + id: ExchangeId, + }, + ModelsRefreshed(Vec), + Error(Arc), +} + +fn handle_command( + cmd: ChatCommand, + state: &mut ChatState, + client: &openai::Client, + inflight: &mut HashMap, + evt_tx: &mpsc::Sender, +) { + match cmd { + ChatCommand::Translate { id, text, config } => { + let user_message = Message { + role: Role::User, + content: Some(text), + ..Default::default() + }; + let prompt = build_prompt(state, &config, &user_message); + // Synchronously seed the exchange -- no channel trip needed since + // we're already holding the state. + reduce( + state, + ChatEvent::Started { + id, + model: config.model.clone(), + user_message, }, - ) - }; - - let chat_request = chat::Request::builder() - .model(chat.model.clone()) - .messages(exchange.prompt()) - .maybe_temperature(chat.temperature) - .maybe_top_p(chat.top_p) - .maybe_max_completion_tokens(chat.max_tokens) - .maybe_presence_penalty(chat.presence_penalty) - .maybe_service_tier(chat.service_tier) - .maybe_reasoning_effort(chat.reasoning_effort) - .build(); - - let exchange = Arc::new(Mutex::new(exchange)); - let token = CancellationToken::new(); - if chat.stream { - let mut stream = self.client.stream(chat_request).await?; - tokio::spawn( - enclose! { (self.buffer => buffer, token, exchange, chat.max_context_tokens => max_context_tokens) async move { - loop { - tokio::select! { - msg = stream.next() => match msg { - Some(Ok(cmpl)) => { - let mut exchange = exchange.lock().await; - exchange.partial(cmpl) - }, - Some(Err(err)) => { - tracing::error!(%err, "stream"); - break - }, - None => { - let mut buffer = buffer.lock().await; - let exchange = exchange.lock().await; - buffer.commit(&exchange); - buffer.enforce_context_limit(&max_context_tokens); - break - } - }, - _ = token.cancelled() => { - break - } - } - } - let mut exchange = exchange.lock().await; - exchange.set_completed(); - }.instrument(tracing::Span::current())}, ); - } else { - let cmpl = self.client.chat(chat_request).await?; - let mut exchange = exchange.lock().await; - exchange.complete(cmpl); - self.buffer.lock().await.commit(&exchange); + let cancel = CancellationToken::new(); + inflight.insert(id, cancel.clone()); + spawn_adapter(client.clone(), config, prompt, id, cancel, evt_tx.clone()); + } + ChatCommand::Cancel(id) => { + if let Some(tok) = inflight.remove(&id) { + tok.cancel(); + reduce(state, ChatEvent::Cancelled { id }); + } } + ChatCommand::EditContext(edit) => apply_edit(state, edit), + ChatCommand::ClearContext => state.context.clear(), + ChatCommand::RefreshModels => { + let client = client.clone(); + let tx = evt_tx.clone(); + tokio::spawn(async move { + match client.models().await { + Ok(mut models) => { + models.sort(); + let _ = tx.send(ChatEvent::ModelsRefreshed(models)).await; + } + Err(err) => { + tracing::error!(?err, "failed to refresh models"); + let _ = tx.send(ChatEvent::Error(Arc::from(err.to_string()))).await; + } + } + }); + } + } +} - Ok(Box::new(ChatTranslation { - model: chat.model.clone(), - exchange, - _guard: token.drop_guard(), - })) +fn reduce(state: &mut ChatState, event: ChatEvent) { + match event { + ChatEvent::Started { + id, + model, + user_message, + } => { + state.exchanges.push(ExchangeView { + id, + model, + user_message, + response: Response::Streaming { + content: String::new(), + tool_calls: ToolCallAccumulator::new(), + }, + usage: None, + }); + } + ChatEvent::Delta { id, content } => { + if let Some(ex) = find_mut(&mut state.exchanges, id) { + if let Response::Streaming { + content: acc, + .. + } = &mut ex.response + { + acc.push_str(&content); + } + } + } + ChatEvent::ToolCallDelta { id, partials } => { + if let Some(ex) = find_mut(&mut state.exchanges, id) { + if let Response::Streaming { tool_calls, .. } = &mut ex.response { + tool_calls.extend(partials); + } + } + } + ChatEvent::Completed { + id, + usage, + max_context_tokens, + } => { + let Some(ex) = find_mut(&mut state.exchanges, id) else { + return; + }; + let prior = std::mem::replace(&mut ex.response, Response::Cancelled); + let (content, tool_calls) = match prior { + Response::Streaming { + content, + tool_calls, + } => (content, tool_calls.finish()), + other => { + ex.response = other; + return; + } + }; + ex.response = Response::Completed { + content: content.clone(), + tool_calls: tool_calls.clone(), + }; + ex.usage = usage; + let assistant = Message { + role: Role::Assistant, + content: Some(content), + tool_calls: (!tool_calls.is_empty()).then_some(tool_calls), + ..Default::default() + }; + let user_clone = ex.user_message.clone(); + state.push_back(user_clone); + state.push_back(assistant); + enforce_context_limit(&mut state.context, &max_context_tokens); + } + ChatEvent::Failed { id, error } => { + if let Some(ex) = find_mut(&mut state.exchanges, id) { + ex.response = Response::Errored(error.clone()); + } + state.last_error = Some(error); + } + ChatEvent::Cancelled { id } => { + if let Some(ex) = find_mut(&mut state.exchanges, id) { + if let Response::Streaming { .. } = ex.response { + ex.response = Response::Cancelled; + } + } + } + ChatEvent::ModelsRefreshed(models) => state.models = models, + ChatEvent::Error(err) => state.last_error = Some(err), + } +} + +fn find_mut(exchanges: &mut [ExchangeView], id: ExchangeId) -> Option<&mut ExchangeView> { + exchanges.iter_mut().find(|e| e.id == id) +} + +fn build_prompt( + state: &ChatState, + config: &TranslateConfig, + user: &Message, +) -> Vec { + let mut prompt = Vec::with_capacity(state.context.len() + 2); + prompt.push(Message { + role: Role::System, + content: Some(config.system_prompt.clone()), + ..Default::default() + }); + prompt.extend(state.context.iter().map(|e| e.message.clone())); + prompt.push(user.clone()); + prompt +} + +fn apply_edit(state: &mut ChatState, edit: ContextEdit) { + let context = &mut state.context; + match edit { + ContextEdit::Insert { idx, message } => { + let idx = idx.min(context.len()); + let id = MsgId(state.next_msg_id); + state.next_msg_id += 1; + state.context.insert(idx, ContextMessage { id, message }); + } + ContextEdit::Delete(idx) => { + if idx < context.len() { + context.remove(idx); + } + } + ContextEdit::Swap(a, b) => { + if a < context.len() && b < context.len() { + context.swap(a, b); + } + } + ContextEdit::SetContent { idx, content } => { + if let Some(entry) = context.get_mut(idx) { + entry.message.content = Some(content); + } + } + ContextEdit::SetRole { idx, role } => { + if let Some(entry) = context.get_mut(idx) { + entry.message.role = role; + } + } + ContextEdit::SetName { idx, name } => { + if let Some(entry) = context.get_mut(idx) { + entry.message.name = name; + } + } } +} - fn view<'a>(&'a self, settings: &'a mut Settings) -> Box { - Box::new(ViewChatTranslator(self, settings)) +/// Trim the oldest non-pinned messages until token count is under +/// `limits[0]`. Messages with a `name` set are treated as pinned. +/// A trimmed message pulls along any following non-user messages so the +/// remaining buffer always starts at a user turn. +fn enforce_context_limit(context: &mut VecDeque, limits: &[u32; 2]) { + if count_tokens(context) <= limits[1] { + return; + } + let mut idx = 0; + while count_tokens(context) > limits[0] && idx < context.len() { + if context[idx].message.name.is_some() { + idx += 1; + continue; + } + context.remove(idx); + while let Some(entry) = context.get(idx) { + if entry.message.role == Role::User { + break; + } + context.remove(idx); + } } } -pub struct ChatTranslation { - pub model: ModelId, - pub exchange: Arc>, - _guard: DropGuard, +fn count_tokens(context: &VecDeque) -> u32 { + context.iter().map(|e| e.message.estimate_tokens()).sum() +} + +fn spawn_adapter( + client: openai::Client, + config: Arc, + prompt: Vec, + id: ExchangeId, + cancel: CancellationToken, + evt_tx: mpsc::Sender, +) { + tokio::spawn( + enclose! { (config) async move { + let req = chat::Request::builder() + .model(config.model.clone()) + .messages(prompt) + .maybe_temperature(config.temperature) + .maybe_top_p(config.top_p) + .maybe_max_completion_tokens(config.max_tokens) + .maybe_presence_penalty(config.presence_penalty) + .maybe_service_tier(config.service_tier) + .maybe_reasoning_effort(config.reasoning_effort) + .maybe_verbosity(config.verbosity) + .build(); + + let max_ctx = config.max_context_tokens; + if config.stream { + let mut stream = match client.stream(req).await { + Ok(s) => s, + Err(err) => { + let _ = evt_tx.send(ChatEvent::Failed { + id, + error: Arc::from(err.to_string()), + }).await; + return; + } + }; + let mut usage = None; + loop { + tokio::select! { + biased; + _ = cancel.cancelled() => { + let _ = evt_tx.send(ChatEvent::Cancelled { id }).await; + return; + } + chunk = stream.next() => match chunk { + Some(Ok(cmpl)) => { + if let Some(u) = cmpl.usage { usage = Some(u); } + for choice in cmpl.choices { + if let Some(content) = choice.delta.content { + let _ = evt_tx.send(ChatEvent::Delta { + id, + content: content.replace('\n', ""), + }).await; + } + if let Some(calls) = choice.delta.tool_calls { + let _ = evt_tx.send(ChatEvent::ToolCallDelta { + id, + partials: calls, + }).await; + } + } + } + Some(Err(err)) => { + let _ = evt_tx.send(ChatEvent::Failed { + id, + error: Arc::from(err.to_string()), + }).await; + return; + } + None => { + let _ = evt_tx.send(ChatEvent::Completed { + id, usage, max_context_tokens: max_ctx, + }).await; + return; + } + } + } + } + } else { + tokio::select! { + biased; + _ = cancel.cancelled() => { + let _ = evt_tx.send(ChatEvent::Cancelled { id }).await; + } + res = client.chat(req) => match res { + Ok(cmpl) => { + let usage = Some(cmpl.usage.clone()); + if let Some(choice) = cmpl.choices.into_iter().next() { + if let Some(content) = choice.message.content { + let _ = evt_tx.send(ChatEvent::Delta { id, content }).await; + } + if let Some(calls) = choice.message.tool_calls { + let partials = calls.into_iter().enumerate() + .map(|(i, call)| PartialToolCall { + index: i as u32, + id: Some(call.id), + kind: Some(call.kind), + function: Some(openai::chat::PartialFunctionCall { + name: Some(call.function.name), + arguments: Some(call.function.arguments), + }), + }) + .collect(); + let _ = evt_tx.send(ChatEvent::ToolCallDelta { id, partials }).await; + } + } + let _ = evt_tx.send(ChatEvent::Completed { + id, usage, max_context_tokens: max_ctx, + }).await; + } + Err(err) => { + let _ = evt_tx.send(ChatEvent::Failed { + id, error: Arc::from(err.to_string()), + }).await; + } + } + } + } + }.instrument(tracing::Span::current())}, + ); +} + +/// Handle to the chat backend task. Cheap to clone. All mutations go through +/// `cmd_tx`; reads go through `state` (wait-free snapshot). `next_id` is owned +/// here so `translate()` can return an `ExchangeId` synchronously. +#[derive(Clone)] +pub struct ChatHandle { + cmd_tx: mpsc::Sender, + state: Arc>, + next_id: Arc, } -impl Translation for ChatTranslation { - fn view(&self) -> Box { - Box::new(ViewChatTranslation(self)) + +impl ChatHandle { + pub fn state(&self) -> Arc { + self.state.load_full() + } + fn send(&self, cmd: ChatCommand) { + let _ = self.cmd_tx.try_send(cmd); + } + pub fn translate(&self, text: String, config: Arc) -> ExchangeId { + let id = ExchangeId(self.next_id.fetch_add(1, Ordering::Relaxed)); + self.send(ChatCommand::Translate { id, text, config }); + id + } + pub fn cancel(&self, id: ExchangeId) { + self.send(ChatCommand::Cancel(id)); } - fn view_usage(&self) -> Box { - Box::new(ViewChatTranslationUsage(self)) + pub fn edit_context(&self, edit: ContextEdit) { + self.send(ChatCommand::EditContext(edit)); } + pub fn clear_context(&self) { + self.send(ChatCommand::ClearContext); + } + pub fn refresh_models(&self) { + self.send(ChatCommand::RefreshModels); + } +} + +pub fn spawn(settings: &Settings) -> ChatHandle { + let client = openai::Client::new( + &settings.openai_api_key, + &settings.chat.api_endpoint, + ConnectionPolicy { + timeout: Duration::from_millis(settings.chat.timeout), + connect_timeout: Duration::from_millis(settings.chat.connection_timeout), + }, + ); + let (cmd_tx, mut cmd_rx) = mpsc::channel::(32); + let (evt_tx, mut evt_rx) = mpsc::channel::(256); + let state = Arc::new(ArcSwap::from_pointee(ChatState::default())); + + let state_writer = state.clone(); + let evt_tx_task = evt_tx.clone(); + tokio::spawn(async move { + let mut local = ChatState::default(); + let mut inflight: HashMap = HashMap::new(); + loop { + tokio::select! { + cmd = cmd_rx.recv() => match cmd { + Some(cmd) => { + handle_command(cmd, &mut local, &client, &mut inflight, &evt_tx_task); + } + None => break, + }, + evt = evt_rx.recv() => match evt { + Some(evt) => reduce(&mut local, evt), + None => break, + }, + } + while let Ok(evt) = evt_rx.try_recv() { + reduce(&mut local, evt); + } + state_writer.store(Arc::new(local.clone())); + } + }); + + let handle = ChatHandle { + cmd_tx, + state, + next_id: Arc::new(AtomicU64::new(0)), + }; + handle.refresh_models(); + handle } From 109c9f3d1e0cc6703d5b523e32734b9f1f25bddc Mon Sep 17 00:00:00 2001 From: netdex Date: Sun, 19 Apr 2026 10:25:37 -0400 Subject: [PATCH 5/8] niinii: add CachedRegex, refactor GlossView to own parser and clipboard polling --- niinii/src/support/mod.rs | 1 + niinii/src/support/regex.rs | 18 ++++ niinii/src/view/gloss.rs | 201 +++++++++++++++++++++++++++++------- 3 files changed, 183 insertions(+), 37 deletions(-) create mode 100644 niinii/src/support/regex.rs diff --git a/niinii/src/support/mod.rs b/niinii/src/support/mod.rs index e3aabce..2b516d4 100644 --- a/niinii/src/support/mod.rs +++ b/niinii/src/support/mod.rs @@ -2,3 +2,4 @@ pub mod docking; pub mod platform; +pub mod regex; diff --git a/niinii/src/support/regex.rs b/niinii/src/support/regex.rs new file mode 100644 index 0000000..996ac5c --- /dev/null +++ b/niinii/src/support/regex.rs @@ -0,0 +1,18 @@ +use fancy_regex::Regex; + +#[derive(Default)] +pub struct CachedRegex { + pattern: String, + regex: Option, +} + +impl CachedRegex { + pub fn get(&mut self, pattern: &str) -> Result<&Regex, fancy_regex::Error> { + if self.regex.is_none() || self.pattern != pattern { + self.regex = Some(Regex::new(pattern)?); + self.pattern.clear(); + self.pattern.push_str(pattern); + } + Ok(self.regex.as_ref().unwrap()) + } +} diff --git a/niinii/src/view/gloss.rs b/niinii/src/view/gloss.rs index f268d7e..518cc61 100644 --- a/niinii/src/view/gloss.rs +++ b/niinii/src/view/gloss.rs @@ -1,42 +1,73 @@ use std::cell::RefCell; -use std::collections::{HashMap, HashSet}; +use std::collections::{HashMap, HashSet, VecDeque}; +use std::time::{Duration, Instant}; +use futures::FutureExt; use ichiran::prelude::*; use imgui::*; +use tokio::task::JoinHandle; +use tracing::Instrument; use super::index::IndexView; use super::mixins::*; -use crate::parser::SyntaxTree; -use crate::renderer::context::Context; +use crate::parser::{self, Parser, SyntaxTree}; +use crate::renderer::context::{Context, ContextFlags}; use crate::settings::{RubyTextType, Settings}; -use crate::translator::Translation; +use crate::support::regex::CachedRegex; use crate::view::{raw::RawView, term::TermView}; +const CLIPBOARD_POLL_INTERVAL: Duration = Duration::from_millis(100); + enum View { Text(String), // TODO: display basic_split instead of plain text Interpret { ast: SyntaxTree }, } +/// Emitted from `GlossView::poll`. `ClipboardReceived` surfaces new clipboard +/// text to the caller so orchestration (parse + translate + clear) happens in +/// one place -- `GlossView` does not self-gloss on clipboard changes. +pub enum GlossEvent { + ClipboardReceived(String), + Completed(String), + Failed(parser::Error), +} + +/// Returned from `show_input` so the caller can dispatch. The input row owns +/// both the Gloss and Translate buttons so the UI stays together. +pub enum GlossInputAction { + Gloss(String), + Translate(String), +} + pub struct GlossView { + parser: Parser, + pending: Option>>, + match_regex: CachedRegex, + + input_text: String, + last_clipboard: String, + last_clipboard_poll: Instant, + + events: VecDeque, + view: Option, - translation: Option>, - translation_pending: bool, show_term_window: RefCell>, selected_clause: RefCell>, show_raw: bool, show_glossary: bool, } -impl Default for GlossView { - fn default() -> Self { - Self::new() - } -} + impl GlossView { - pub fn new() -> Self { + pub async fn new(settings: &Settings) -> Self { Self { + parser: Parser::new(settings).await, + pending: None, + match_regex: CachedRegex::default(), + input_text: String::new(), + last_clipboard: String::new(), + last_clipboard_poll: Instant::now(), + events: VecDeque::new(), view: None, - translation: None, - translation_pending: false, show_term_window: RefCell::new(HashSet::new()), selected_clause: RefCell::new(HashMap::new()), show_raw: false, @@ -44,13 +75,6 @@ impl GlossView { } } - pub fn set_text(&mut self, text: impl Into) { - self.view = Some(View::Text(text.into())); - } - - pub fn set_ast(&mut self, ast: SyntaxTree) { - self.view = Some(View::Interpret { ast }); - } pub fn ast(&self) -> Option<&SyntaxTree> { if let Some(View::Interpret { ast, .. }) = &self.view { Some(ast) @@ -59,15 +83,127 @@ impl GlossView { } } - pub fn set_translation_pending(&mut self, pending: bool) { - self.translation_pending = pending; + pub fn is_processing(&self) -> bool { + self.pending.is_some() } - pub fn set_translation(&mut self, tl: Option>) { - self.translation = tl; - self.translation_pending = false; + + pub fn input_text(&self) -> &str { + &self.input_text } - pub fn translation(&self) -> Option<&dyn Translation> { - self.translation.as_deref() + + /// Preprocess `text` through the configured match/replace regex and spawn + /// a parse. Aborts any prior in-flight parse. The preview text is shown + /// immediately; `poll` will transition to the parsed AST on completion. + /// Returns the post-regex text on success, or `None` if the regex yielded + /// empty text and nothing was spawned. + pub fn request( + &mut self, + text: &str, + settings: &Settings, + ) -> Result, parser::Error> { + let regex = self.match_regex.get(&settings.regex_match)?; + let text = regex + .replace_all(text, &settings.regex_replace) + .into_owned(); + let text = text.trim().to_owned(); + if text.is_empty() { + return Ok(None); + } + + if let Some(prev) = self.pending.take() { + prev.abort(); + } + self.view = Some(View::Text(text.clone())); + + let parser = self.parser.clone(); + let variants = if settings.more_variants { 5 } else { 1 }; + let spawn_text = text.clone(); + self.pending = Some(tokio::spawn( + async move { + parser.parse(&spawn_text, variants).await + } + .instrument(tracing::debug_span!("parse")), + )); + Ok(Some(text)) + } + + /// Drive clipboard watching and pending-parse completion. Returns an event + /// when a parse finishes so the caller can wire up auto-translate etc. + pub fn poll( + &mut self, + ui: &Ui, + ctx: &mut Context, + settings: &Settings, + ) -> Option { + if settings.watch_clipboard + && self.last_clipboard_poll.elapsed() >= CLIPBOARD_POLL_INTERVAL + { + self.last_clipboard_poll = Instant::now(); + if let Some(clipboard) = ui.clipboard_text() { + if clipboard != self.last_clipboard { + self.input_text.clone_from(&clipboard); + self.last_clipboard.clone_from(&clipboard); + // Ignore clipboard contents if they are unreasonably large + if clipboard.len() < 500 { + self.events + .push_back(GlossEvent::ClipboardReceived(clipboard)); + } + } + } + } + + if let Some(handle) = self.pending.as_mut() { + if let Some(poll) = handle.now_or_never() { + self.pending = None; + match poll { + Ok(Ok(ast)) => { + if ctx.flags().contains(ContextFlags::SUPPORTS_ATLAS_UPDATE) { + ctx.add_unknown_glyphs_from_root(&ast.root); + } + let text = ast.original_text.clone(); + self.view = Some(View::Interpret { ast }); + self.events.push_back(GlossEvent::Completed(text)); + } + Ok(Err(err)) => self.events.push_back(GlossEvent::Failed(err)), + // Aborted by a follow-up request; the replacement is already in flight. + Err(_) => {} + } + } + } + + self.events.pop_front() + } + + /// Render the manual-input row: textarea, Gloss button, Translate button. + /// Leaves the cursor on the same line so the caller can append adjacent + /// controls (e.g. a usage bar). Returns the action the user triggered. + pub fn show_input(&mut self, ui: &Ui) -> Option { + let mut action = None; + { + let _disabled = ui.begin_disabled(self.is_processing()); + let entered = ui + .input_text_multiline("##", &mut self.input_text, [0.0, 50.0]) + .enter_returns_true(true) + .build(); + let clicked = ui.button_with_size("Gloss", [120.0, 0.0]); + if entered || clicked { + action = Some(GlossInputAction::Gloss(self.input_text.clone())); + } + } + ui.same_line(); + + let enable_tl = self.ast().is_some_and(|ast| !ast.empty()); + let disable_tl = ui.begin_disabled(!enable_tl); + if ui.button_with_size("Translate", [120.0, 0.0]) { + if let Some(gloss) = self.ast() { + action = Some(GlossInputAction::Translate(gloss.original_text.clone())); + } + } + drop(disable_tl); + if !enable_tl && ui.is_item_hovered_with_flags(ItemHoveredFlags::ALLOW_WHEN_DISABLED) { + ui.tooltip(|| ui.text("Text does not require translation")); + } + action } fn term_window( @@ -271,15 +407,6 @@ impl GlossView { _ => {} } ui.new_line(); - if let Some(translation) = &self.translation { - translation.view().ui(ui); - } else if self.translation_pending { - ui.text_disabled("(waiting for translation"); - ui.same_line_with_spacing(0.0, 0.0); - ui.text_disabled(ellipses(ui)); - ui.same_line_with_spacing(0.0, 0.0); - ui.text_disabled(")"); - } // show all term windows, close if requested (this is actually witchcraft) self.show_term_window From 6d01dd962b59ffa8c52df7f45b3b95fba9f20804 Mon Sep 17 00:00:00 2001 From: netdex Date: Sun, 19 Apr 2026 10:25:45 -0400 Subject: [PATCH 6/8] niinii: refactor App and view windows to self-owned pattern, add StyleEditor --- niinii/Cargo.toml | 3 +- niinii/src/app.rs | 346 ++++++----------------------- niinii/src/renderer/mod.rs | 7 + niinii/src/view/inject.rs | 30 ++- niinii/src/view/mixins.rs | 16 +- niinii/src/view/mod.rs | 6 +- niinii/src/view/raw.rs | 4 +- niinii/src/view/settings.rs | 36 ++- niinii/src/view/style_editor.rs | 52 +++++ niinii/src/view/term.rs | 2 +- niinii/src/view/translator/chat.rs | 302 +++++++++++++++++-------- 11 files changed, 408 insertions(+), 396 deletions(-) create mode 100644 niinii/src/view/style_editor.rs diff --git a/niinii/Cargo.toml b/niinii/Cargo.toml index 4f330a5..0048e1b 100644 --- a/niinii/Cargo.toml +++ b/niinii/Cargo.toml @@ -31,8 +31,8 @@ tokio = { version = "1", features = ["macros", "rt-multi-thread", "sync"] } tokio-stream = { version = "0.1", features = ["sync"] } tokio-util = "0.7" futures = "0.3" -async-trait = "0.1" backon = "1.6" +arc-swap = "1.7" # diagnostics log = "0.4" @@ -62,7 +62,6 @@ itertools = "0.14" raw-window-handle-05 = { package = "raw-window-handle", version = "0.5.2" } copypasta = "0.10" flate2 = { version = "1.1" } -deepl-api = "0.4" dirs = "6.0" rodio = { version = "0.21.1", optional = true } # rayon = "1.5.1" diff --git a/niinii/src/app.rs b/niinii/src/app.rs index 5000b70..0608887 100644 --- a/niinii/src/app.rs +++ b/niinii/src/app.rs @@ -1,26 +1,17 @@ -use std::sync::Arc; - -use enclose::enclose; -use fancy_regex::Regex; use imgui::*; -use tokio::sync::mpsc; -use tracing::Instrument; use crate::{ - parser::{self, Parser, SyntaxTree}, renderer::context::{Context, ContextFlags}, - settings::{Settings, TranslatorType}, - support::docking::UiDocking, - translator::{ - self, chat::ChatTranslator, deepl::DeepLTranslator, realtime::RealtimeTranslator, - responses::ResponsesTranslator, Translation, Translator, - }, + settings::Settings, + support::{docking::UiDocking, regex::CachedRegex}, tts::{self, TtsEngine}, view::{ - gloss::GlossView, + gloss::{GlossEvent, GlossInputAction, GlossView}, inject::InjectView, - mixins::{ellipses, help_marker, stroke_text_with_highlight}, + mixins::{ellipses, stroke_text_with_highlight}, settings::SettingsView, + style_editor::StyleEditor, + translator::TranslatorWindow, }, }; @@ -29,137 +20,61 @@ const ERROR_MODAL_ID: &str = "Error"; #[derive(thiserror::Error, Debug)] enum Error { #[error(transparent)] - Gloss(#[from] parser::Error), - #[error(transparent)] - Translation(#[from] translator::Error), + Gloss(#[from] crate::parser::Error), #[error(transparent)] TextToSpeech(#[from] tts::Error), } -enum Message { - Gloss(Result), - Translation(Result, translator::Error>), -} - -#[derive(Debug)] -enum State { - Processing, - Completed, -} - pub struct App { - channel_tx: mpsc::UnboundedSender, - channel_rx: mpsc::UnboundedReceiver, - - input_text: String, - last_clipboard: String, - request_gloss_text: Option, - - show_settings: bool, show_metrics_window: bool, - show_style_editor: bool, - show_inject: bool, - show_translator: bool, no_inputs: bool, settings: Settings, - state: State, error: Option, - parser: Parser, - translator: Arc, tts: TtsEngine, gloss: GlossView, + translator_window: TranslatorWindow, + settings_view: SettingsView, + inject_view: InjectView, + style_editor: StyleEditor, + + auto_tts_regex: CachedRegex, } impl App { pub async fn new(settings: Settings) -> Self { - let (channel_tx, channel_rx) = tokio::sync::mpsc::unbounded_channel(); - let parser = Parser::new(&settings).await; - let translator: Arc = match settings.translator_type { - TranslatorType::DeepL => Arc::new(DeepLTranslator), - TranslatorType::Chat => Arc::new(ChatTranslator::new(&settings).await), - TranslatorType::Realtime => Arc::new(RealtimeTranslator::new(&settings).await), - TranslatorType::Responses => Arc::new(ResponsesTranslator::new(&settings).await), - }; let tts = TtsEngine::new(&settings); + let gloss = GlossView::new(&settings).await; + let translator_window = TranslatorWindow::new(&settings); App { - channel_tx, - channel_rx, - input_text: "".into(), - last_clipboard: "".into(), - request_gloss_text: None, - show_settings: false, show_metrics_window: false, - show_style_editor: false, - show_inject: false, - show_translator: false, no_inputs: false, settings, - state: State::Completed, error: None, - parser, - translator, tts, - gloss: GlossView::new(), + gloss, + translator_window, + settings_view: SettingsView::new(), + inject_view: InjectView::new(), + style_editor: StyleEditor::new(), + auto_tts_regex: CachedRegex::default(), } } - fn request_parse(&mut self, ui: &Ui, text: &str) { - let regex = Regex::new(&self.settings.regex_match); - match regex { - Ok(regex) => { - let text = regex - .replace_all(text, &self.settings.regex_replace) - .into_owned(); - let text = text.trim().to_owned(); - if text.is_empty() { - return; - } - - self.transition(ui, State::Processing); - self.gloss.set_text(text.clone()); - + fn request_gloss(&mut self, ui: &Ui, text: &str) { + match self.gloss.request(text, &self.settings) { + Ok(Some(processed)) => { if self.settings.auto_translate { - self.request_translation(ui, text.clone()); + self.translator_window.translate(&self.settings, processed); } else { - self.gloss.set_translation(None); + self.translator_window.clear_current(); } - - let Self { - channel_tx, parser, .. - } = self; - let variants = if self.settings.more_variants { 5 } else { 1 }; - tokio::spawn(enclose! { (channel_tx, parser) async move { - let span = tracing::debug_span!("parse"); - let ast = parser.parse(&text, variants).instrument(span).await; - let _ = channel_tx.send(Message::Gloss(ast)); - }}); } - Err(err) => self.error(ui, Error::Gloss(err.into())), + Ok(None) => {} + Err(err) => self.error(ui, Error::Gloss(err)), } } - fn request_translation(&mut self, _ui: &Ui, text: impl Into) { - let Self { - translator, - settings, - channel_tx, - gloss, - .. - } = self; - - gloss.set_translation(None); - gloss.set_translation_pending(true); - - let text = text.into(); - - tokio::spawn(enclose! { (translator, settings, channel_tx) async move { - let span = tracing::debug_span!("translation"); - let translation = translator.translate(&settings, text).instrument(span).await; - let _ = channel_tx.send(Message::Translation(translation)); - }}); - } - fn request_tts(&mut self, ui: &Ui, text: &str) { let span = tracing::debug_span!("tts"); let _enter = span.enter(); @@ -168,73 +83,39 @@ impl App { } } - fn transition(&mut self, _ui: &Ui, state: State) { - self.state = state; - } - fn error(&mut self, ui: &Ui, err: Error) { tracing::error!(%err); self.error = Some(err); ui.open_popup(ERROR_MODAL_ID); - self.transition(ui, State::Completed); } fn poll(&mut self, ui: &Ui, ctx: &mut Context) { - while let Ok(message) = self.channel_rx.try_recv() { - match message { - Message::Gloss(Ok(ast)) => { - if ctx.flags().contains(ContextFlags::SUPPORTS_ATLAS_UPDATE) { - ctx.add_unknown_glyphs_from_root(&ast.root); - } - let text = ast.original_text.clone(); - self.gloss.set_ast(ast); - if let Some(auto_tts_regex) = &self.settings.auto_tts_regex { - let regex = Regex::new(auto_tts_regex).ok(); - if let Some(regex) = regex { - let captures = regex.captures(&text).unwrap(); - if let Some(captures) = captures { - if let Some(cap) = captures.get(1) { - self.request_tts(ui, cap.as_str()); - } else { - self.request_tts(ui, &text); - } - } + while let Some(event) = self.gloss.poll(ui, ctx, &self.settings) { + match event { + GlossEvent::ClipboardReceived(text) => { + self.request_gloss(ui, &text); + } + GlossEvent::Completed(text) => { + if let Some(pattern) = self.settings.auto_tts_regex.clone() { + let tts_text = + self.auto_tts_regex.get(&pattern).ok().and_then(|regex| { + regex.captures(&text).unwrap().map(|captures| { + captures + .get(1) + .map(|cap| cap.as_str().to_owned()) + .unwrap_or_else(|| text.clone()) + }) + }); + if let Some(tts_text) = tts_text { + self.request_tts(ui, &tts_text); } } - self.transition(ui, State::Completed); - } - Message::Gloss(Err(err)) => { - self.error(ui, err.into()); - } - Message::Translation(Ok(translation)) => { - self.gloss.set_translation(Some(translation)); } - Message::Translation(Err(err)) => { - self.gloss.set_translation_pending(false); + GlossEvent::Failed(err) => { self.error(ui, err.into()); } } } - - if self.settings.watch_clipboard { - if let Some(clipboard) = ui.clipboard_text() { - if clipboard != self.last_clipboard { - self.input_text.clone_from(&clipboard); - self.last_clipboard.clone_from(&clipboard); - // Ignore clipboard contents if they are unreasonably large - if clipboard.len() < 500 { - self.request_gloss_text = Some(clipboard); - } - } - } - } - - if let State::Completed = &self.state { - if let Some(request_gloss_text) = self.request_gloss_text.clone() { - self.request_gloss_text = None; - self.request_parse(ui, &request_gloss_text); - } - }; } fn show_menu(&mut self, ctx: &mut Context, ui: &Ui) { @@ -245,16 +126,10 @@ impl App { ui.menu_item_config("Show input") .build_with_ref(&mut self.settings.show_manual_input); ui.separator(); - if ui.menu_item("Style Editor") { - self.show_style_editor = true; - } - if ui.menu_item("Translator") { - self.show_translator = true; - } + self.style_editor.show_menu_item(ui); + self.translator_window.show_menu_item(ui); ui.separator(); - if ui.menu_item("Settings") { - self.show_settings = true; - } + self.settings_view.show_menu_item(ui); ui.separator(); ui.menu_item_config("Disable interaction") .build_with_ref(&mut self.no_inputs); @@ -268,16 +143,16 @@ impl App { } if cfg!(feature = "hook") && !ctx.flags().contains(ContextFlags::SHARED_RENDER_CONTEXT) - && ui.menu_item("Inject") { - self.show_inject = true; + self.inject_view.show_menu_item(ui); } } ui.separator(); - let disable_state = ui.begin_disabled(matches!(self.state, State::Processing)); + let disable_state = ui.begin_disabled(self.gloss.is_processing()); if ui.menu_item("Translate") { if let Some(gloss) = self.gloss.ast() { - self.request_translation(ui, gloss.original_text.clone()); + let text = gloss.original_text.clone(); + self.translator_window.translate(&self.settings, text); } } if cfg!(feature = "voicevox") && ui.menu_item("Speak") { @@ -361,41 +236,21 @@ impl App { } self.poll(ui, ctx); - let disabled = matches!(self.state, State::Processing); if self.settings().show_manual_input { - let disable_input = ui.begin_disabled(disabled); - if ui - .input_text_multiline("##", &mut self.input_text, [0.0, 50.0]) - .enter_returns_true(true) - .build() - { - self.request_gloss_text = Some(self.input_text.clone()); - } - if ui.button_with_size("Gloss", [120.0, 0.0]) { - self.request_gloss_text = Some(self.input_text.clone()); - } - drop(disable_input); - ui.same_line(); - - let enable_tl = self.gloss.ast().is_some_and(|ast| !ast.empty()); - let disable_tl = ui.begin_disabled(!enable_tl); - if ui.button_with_size("Translate", [120.0, 0.0]) { - if let Some(gloss) = self.gloss.ast() { - self.request_translation(ui, gloss.original_text.clone()); + let action = self.gloss.show_input(ui); + self.translator_window.draw_current_usage(ui); + if let Some(action) = action { + match action { + GlossInputAction::Gloss(text) => self.request_gloss(ui, &text), + GlossInputAction::Translate(text) => { + self.translator_window.translate(&self.settings, text); + } } } - drop(disable_tl); - if !enable_tl - && ui.is_item_hovered_with_flags(ItemHoveredFlags::ALLOW_WHEN_DISABLED) - { - ui.tooltip(|| ui.text("Text does not require translation")); - } - if let Some(translation) = self.gloss.translation() { - translation.view_usage().ui(ui); - } } self.gloss.ui(ctx, ui, &self.settings); + self.translator_window.draw_current_exchange(ui); if ctx.font_atlas_dirty() { ui.new_line(); @@ -405,89 +260,24 @@ impl App { ui.same_line_with_spacing(0.0, 0.0); ui.text_disabled(")"); } - if let State::Processing = &self.state { + if self.gloss.is_processing() { ui.set_mouse_cursor(Some(MouseCursor::NotAllowed)); } }); - if self.show_settings { - self.show_settings(ctx, ui); - } + self.settings_view.ui(ctx, ui, &mut self.settings); + self.inject_view.ui(ui, &mut self.settings); + self.style_editor.ui(ui, &mut self.settings); + self.translator_window.ui(ui, &mut self.settings); if self.show_metrics_window { ui.show_metrics_window(&mut self.show_metrics_window); } - if self.show_style_editor { - self.show_style_editor(ui); - } - if self.show_inject { - self.show_inject(ctx, ui); - } - if self.show_translator { - self.show_translator(ctx, ui); - } if no_inputs && !toggle_hovered { unsafe { sys::igSetNextFrameWantCaptureMouse(false) } } } - fn show_settings(&mut self, ctx: &mut Context, ui: &mut Ui) { - if let Some(_token) = ui.window("Settings").always_auto_resize(true).begin() { - SettingsView(&mut self.settings).ui(ctx, ui); - ui.separator(); - if ui.button_with_size("OK", [120.0, 0.0]) { - self.show_settings = false; - } - ui.same_line(); - ui.text("* Restart to apply these changes"); - } - } - - fn show_inject(&mut self, ctx: &mut Context, ui: &mut Ui) { - if let Some(_token) = ui.window("Inject").always_auto_resize(true).begin() { - InjectView.ui(ctx, ui, &mut self.settings); - ui.separator(); - if ui.button_with_size("OK", [120.0, 0.0]) { - self.show_inject = false; - } - } - } - - fn show_translator(&mut self, _ctx: &mut Context, ui: &mut Ui) { - if let Some(_token) = ui - .window("Translator") - .size_constraints([600.0, 300.0], [1200.0, 1200.0]) - .opened(&mut self.show_translator) - .menu_bar(true) - .begin() - { - self.translator.view(&mut self.settings).ui(ui); - } - } - - fn show_style_editor(&mut self, ui: &Ui) { - let mut show_style_editor = self.show_style_editor; - ui.window("Style Editor") - .opened(&mut show_style_editor) - .menu_bar(true) - .build(|| { - ui.menu_bar(|| { - if ui.menu_item("Save") { - self.settings_mut().set_style(Some(&ui.clone_style())); - } - if ui.menu_item("Reset") { - self.settings_mut().set_style(None); - } - if self.settings.style.is_some() { - ui.menu_with_enabled("Style saved", false, || {}); - help_marker(ui, "Saved style will be restored on start-up. Reset will clear the stored style."); - } - }); - ui.show_default_style_editor(); - }); - self.show_style_editor = show_style_editor; - } - pub fn settings(&self) -> &Settings { &self.settings } diff --git a/niinii/src/renderer/mod.rs b/niinii/src/renderer/mod.rs index a9da722..7234b01 100644 --- a/niinii/src/renderer/mod.rs +++ b/niinii/src/renderer/mod.rs @@ -1,5 +1,6 @@ use std::path::PathBuf; +use imgui::internal::RawCast; use imgui_winit_support::WinitPlatform; use crate::support::platform; @@ -33,6 +34,12 @@ pub trait Renderer { } else { panic!("failed to initialize clipboard"); } + + // https://github.com/imgui-rs/imgui-rs/issues/773 + unsafe { + imgui.fonts().raw_mut().FontBuilderIO = + imgui_sys::ImGuiFreeType_GetBuilderForFreeType(); + } } fn create_platform( diff --git a/niinii/src/view/inject.rs b/niinii/src/view/inject.rs index 4015f94..e1e14d0 100644 --- a/niinii/src/view/inject.rs +++ b/niinii/src/view/inject.rs @@ -1,12 +1,34 @@ use imgui::*; -use crate::renderer::context::Context; use crate::settings::Settings; -#[derive(Debug)] -pub struct InjectView; +#[derive(Debug, Default)] +pub struct InjectView { + pub open: bool, +} impl InjectView { - pub fn ui(&mut self, _ctx: &mut Context, ui: &Ui, settings: &mut Settings) { + pub fn new() -> Self { + Self::default() + } + + pub fn show_menu_item(&mut self, ui: &Ui) { + if ui.menu_item("Inject") { + self.open = true; + } + } + + pub fn ui(&mut self, ui: &Ui, settings: &mut Settings) { + if !self.open { + return; + } + let Some(_window) = ui + .window("Inject") + .always_auto_resize(true) + .opened(&mut self.open) + .begin() + else { + return; + }; if CollapsingHeader::new("Remote Hook") .default_open(true) .build(ui) diff --git a/niinii/src/view/mixins.rs b/niinii/src/view/mixins.rs index 6738906..872f1f6 100644 --- a/niinii/src/view/mixins.rs +++ b/niinii/src/view/mixins.rs @@ -192,13 +192,13 @@ pub fn draw_kanji_text( .build(); } - let style = ui.clone_style(); + let item_spacing_x = unsafe { ui.style().item_spacing[0] }; let ul_thick = 4.0; let ul0 = [x, y + kanji_sz[1] + ul_thick / 2.0]; let ul1 = match underline { UnderlineMode::Normal => [x + w, y + kanji_sz[1] + ul_thick / 2.0], UnderlineMode::Pad => [ - x + w + style.item_spacing[0], + x + w + item_spacing_x, y + kanji_sz[1] + ul_thick / 2.0, ], UnderlineMode::None => ul0, @@ -240,8 +240,8 @@ pub fn wrap_line_with_spacing(ui: &Ui, expected_width: f32, spacing: f32) -> boo let max_width = ui.window_content_region_max()[0]; let visible_x = ui.window_pos()[0] + max_width; let last_x = ui.item_rect_max()[0]; - let style = ui.clone_style(); - let next_x = last_x + style.item_spacing[0] + expected_width; + let item_spacing_x = unsafe { ui.style().item_spacing[0] }; + let next_x = last_x + item_spacing_x + expected_width; // don't wrap if it will fit on the current line, or if it won't even fit on an empty line if next_x < visible_x || expected_width >= max_width { ui.same_line_with_spacing(0.0, spacing); @@ -328,10 +328,10 @@ where /// https://github.com/ocornut/imgui/issues/1901 pub fn spinner(ui: &Ui, radius: f32, thickness: f32, color: StyleColor) { - let style = ui.clone_style(); + let frame_padding_y = unsafe { ui.style().frame_padding[1] }; let now = ui.time() as f32; let pos = ui.cursor_screen_pos(); - let size = [radius * 2.0, (radius + style.frame_padding[1]) * 2.0]; + let size = [radius * 2.0, (radius + frame_padding_y) * 2.0]; ui.dummy(size); let draw_list = ui.get_window_draw_list(); @@ -341,9 +341,9 @@ pub fn spinner(ui: &Ui, radius: f32, thickness: f32, color: StyleColor) { let a_min = PI * 2.0 * start / (num_segments as f32); let a_max = PI * 2.0 * ((num_segments - 3) as f32) / (num_segments as f32); - let center = [pos[0] + radius, pos[1] + radius + style.frame_padding[1]]; + let center = [pos[0] + radius, pos[1] + radius + frame_padding_y]; - let mut points = vec![]; + let mut points = Vec::with_capacity(num_segments); for i in 0..num_segments { let a = a_min + ((i as f32) / (num_segments as f32)) * (a_max - a_min); points.push([ diff --git a/niinii/src/view/mod.rs b/niinii/src/view/mod.rs index 7855d93..de5ce4d 100644 --- a/niinii/src/view/mod.rs +++ b/niinii/src/view/mod.rs @@ -5,10 +5,6 @@ pub mod kanji; pub mod mixins; pub mod raw; pub mod settings; +pub mod style_editor; pub mod term; pub mod translator; -pub mod tts; - -pub trait View { - fn ui(&mut self, ui: &imgui::Ui); -} diff --git a/niinii/src/view/raw.rs b/niinii/src/view/raw.rs index 28ad6fd..b9017c3 100644 --- a/niinii/src/view/raw.rs +++ b/niinii/src/view/raw.rs @@ -151,7 +151,7 @@ fn add_gloss(ui: &Ui, gloss: &Gloss) { }); } fn add_conj(ui: &Ui, conj: &Conjugation) { - ui.tree_node_config(&"Conjugation".to_string()) + ui.tree_node_config("Conjugation") .default_open(true) .build(|| { if let Some(reading) = conj.reading() { @@ -174,7 +174,7 @@ fn add_conj(ui: &Ui, conj: &Conjugation) { } }); for via in conj.vias() { - ui.tree_node_config(&"Via".to_string()) + ui.tree_node_config("Via") .default_open(false) .build(|| { add_conj(ui, via); diff --git a/niinii/src/view/settings.rs b/niinii/src/view/settings.rs index 4ba7224..7149574 100644 --- a/niinii/src/view/settings.rs +++ b/niinii/src/view/settings.rs @@ -7,11 +7,34 @@ use crate::{ use super::mixins::{self, checkbox_option, combo_enum}; -pub struct SettingsView<'a>(pub &'a mut Settings); +#[derive(Default)] +pub struct SettingsView { + pub open: bool, +} + +impl SettingsView { + pub fn new() -> Self { + Self::default() + } -impl SettingsView<'_> { - pub fn ui(&mut self, ctx: &mut Context, ui: &Ui) { - let settings = &mut self.0; + pub fn show_menu_item(&mut self, ui: &Ui) { + if ui.menu_item("Settings") { + self.open = true; + } + } + + pub fn ui(&mut self, ctx: &mut Context, ui: &Ui, settings: &mut Settings) { + if !self.open { + return; + } + let Some(_window) = ui + .window("Settings") + .always_auto_resize(true) + .opened(&mut self.open) + .begin() + else { + return; + }; if CollapsingHeader::new("Ichiran") .default_open(true) .build(ui) @@ -57,9 +80,6 @@ impl SettingsView<'_> { { combo_enum(ui, "Translator*", &mut settings.translator_type); ui.checkbox("Auto-translate", &mut settings.auto_translate); - ui.input_text("DeepL API key", &mut settings.deepl_api_key) - .password(true) - .build(); ui.input_text("OpenAI API key*", &mut settings.openai_api_key) .password(true) .build(); @@ -120,5 +140,7 @@ impl SettingsView<'_> { } ui.checkbox("Transparent", &mut settings.transparent); } + ui.separator(); + ui.text_disabled("* Restart to apply these changes"); } } diff --git a/niinii/src/view/style_editor.rs b/niinii/src/view/style_editor.rs new file mode 100644 index 0000000..38bee30 --- /dev/null +++ b/niinii/src/view/style_editor.rs @@ -0,0 +1,52 @@ +use imgui::*; + +use crate::settings::Settings; + +use super::mixins::help_marker; + +#[derive(Default)] +pub struct StyleEditor { + pub open: bool, +} + +impl StyleEditor { + pub fn new() -> Self { + Self::default() + } + + pub fn show_menu_item(&mut self, ui: &Ui) { + if ui.menu_item("Style Editor") { + self.open = true; + } + } + + pub fn ui(&mut self, ui: &Ui, settings: &mut Settings) { + if !self.open { + return; + } + let Some(_window) = ui + .window("Style Editor") + .opened(&mut self.open) + .menu_bar(true) + .begin() + else { + return; + }; + ui.menu_bar(|| { + if ui.menu_item("Save") { + settings.set_style(Some(&ui.clone_style())); + } + if ui.menu_item("Reset") { + settings.set_style(None); + } + if settings.style.is_some() { + ui.menu_with_enabled("Style saved", false, || {}); + help_marker( + ui, + "Saved style will be restored on start-up. Reset will clear the stored style.", + ); + } + }); + ui.show_default_style_editor(); + } +} diff --git a/niinii/src/view/term.rs b/niinii/src/view/term.rs index fa42f4b..a280bf5 100644 --- a/niinii/src/view/term.rs +++ b/niinii/src/view/term.rs @@ -191,7 +191,7 @@ impl<'a> TermView<'a> { for vias in conj.flatten() { let base = *vias.first().unwrap(); - if CollapsingHeader::new(&base.reading().unwrap_or("Conjugation").to_string()) + if CollapsingHeader::new(base.reading().unwrap_or("Conjugation")) .default_open(true) .build(ui) { diff --git a/niinii/src/view/translator/chat.rs b/niinii/src/view/translator/chat.rs index a8cc1a4..d5a265f 100644 --- a/niinii/src/view/translator/chat.rs +++ b/niinii/src/view/translator/chat.rs @@ -1,37 +1,117 @@ +use std::collections::HashMap; +use std::sync::Arc; + use imgui::*; -use openai::chat::Role; +use openai::chat::{Message, Role, Usage}; +use openai::ModelId; use crate::{ - settings::Settings, - translator::chat::{ChatTranslation, ChatTranslator}, - view::mixins::{combo_list, drag_handle, help_marker}, -}; - -use crate::view::{ - mixins::{ - checkbox_option, checkbox_option_with_default, combo_enum, ellipses, - stroke_text_with_highlight, + settings::{Settings, TranslatorType}, + translator::chat::{ + self, ChatHandle, ContextEdit, ExchangeId, ExchangeView, MsgId, Response, + TranslateConfig, + }, + view::mixins::{ + checkbox_option, checkbox_option_with_default, combo_enum, combo_list, drag_handle, + ellipses, help_marker, stroke_text_with_highlight, }, - View, }; -pub struct ViewChatTranslator<'a>(pub &'a ChatTranslator, pub &'a mut Settings); -impl View for ViewChatTranslator<'_> { - fn ui(&mut self, ui: &Ui) { - let ViewChatTranslator(translator, settings) = self; - let mut chat = translator.buffer.blocking_lock(); +/// Owns the translator backend handle, the currently displayed exchange id, +/// and the per-message edit buffers for the context editor. Acts as both the +/// controller (submit/cancel translations) and the view (render the window +/// plus exchange readouts embedded in the main UI). +pub struct TranslatorWindow { + translator: ChatHandle, + current: Option, + buffers: HashMap, + pub open: bool, +} + +impl TranslatorWindow { + pub fn new(settings: &Settings) -> Self { + let translator = match settings.translator_type { + TranslatorType::Chat => chat::spawn(settings), + }; + Self { + translator, + current: None, + buffers: HashMap::new(), + open: false, + } + } + + /// Cancel any in-flight translation and submit a new one. The new id + /// becomes the "current" exchange rendered in the main UI. + pub fn translate(&mut self, settings: &Settings, text: String) { + if let Some(prev) = self.current { + self.translator.cancel(prev); + } + let config = Arc::new(TranslateConfig::from_settings(settings)); + self.current = Some(self.translator.translate(text, config)); + } + + /// Forget the current exchange without cancelling it. Used when a new + /// gloss arrives and the user has not opted into auto-translate. + pub fn clear_current(&mut self) { + self.current = None; + } + + /// Render the usage bar for the current exchange, if any. + pub fn draw_current_usage(&self, ui: &Ui) { + let Some(id) = self.current else { return }; + let state = self.translator.state(); + if let Some(ex) = state.exchange(id) { + if let Some(usage) = &ex.usage { + draw_usage(ui, &ex.model, usage); + } + } + } + + /// Render the full current exchange, if any. + pub fn draw_current_exchange(&self, ui: &Ui) { + let Some(id) = self.current else { return }; + let state = self.translator.state(); + if let Some(ex) = state.exchange(id) { + draw_exchange(ui, ex); + } + } + + pub fn show_menu_item(&mut self, ui: &Ui) { + if ui.menu_item("Translator") { + self.open = true; + } + } + + pub fn ui(&mut self, ui: &Ui, settings: &mut Settings) { + if !self.open { + return; + } + let Some(_window) = ui + .window("Translator") + .size_constraints([600.0, 300.0], [1200.0, 1200.0]) + .opened(&mut self.open) + .menu_bar(true) + .begin() + else { + return; + }; + let handle = &self.translator; + let state = handle.state(); let chatgpt = &mut settings.chat; + ui.menu_bar(|| { if ui.menu_item("Clear") { - chat.clear(); + handle.clear_context(); } }); + if ui.collapsing_header("Tuning", TreeNodeFlags::DEFAULT_OPEN) { let align = 10.0; if let Some(_token) = ui.begin_table("##", 2) { ui.table_next_column(); ui.set_next_item_width(ui.current_font_size() * -align); - combo_list(ui, "Model", &translator.models, &mut chatgpt.model); + combo_list(ui, "Model", &state.models, &mut chatgpt.model); ui.table_next_column(); ui.checkbox("Stream", &mut chatgpt.stream); ui.same_line(); @@ -121,6 +201,7 @@ impl View for ViewChatTranslator<'_> { ); } } + ui.child_window("context_window").build(|| { if let Some(_t) = ui.begin_table_header_with_flags( "context", @@ -133,10 +214,9 @@ impl View for ViewChatTranslator<'_> { ], TableFlags::SIZING_STRETCH_PROP, ) { + // System prompt row (static, from Settings). ui.table_next_column(); - ui.disabled(true, || { - drag_handle(ui); - }); + ui.disabled(true, || drag_handle(ui)); ui.table_next_column(); ui.table_next_column(); ui.disabled(true, || { @@ -157,13 +237,24 @@ impl View for ViewChatTranslator<'_> { ) .build(); + // Sweep buffers whose messages are gone; keep the rest. Stable + // MsgIds make this robust to reorder/insert/delete. + let live: std::collections::HashSet = + state.context.iter().map(|e| e.id).collect(); + self.buffers.retain(|id, _| live.contains(id)); + enum Interaction { Delete(usize), Swap(usize, usize), + SetRole(usize, Role), + SetName(usize, Option), + CommitContent(usize, String), } - let mut interact = None; - for (idx, message) in chat.context_mut().iter_mut().enumerate() { - let _id = ui.push_id_ptr(message); + let mut interact: Option = None; + + for (idx, entry) in state.context.iter().enumerate() { + let message = &entry.message; + let _id = ui.push_id_usize(idx); ui.table_next_column(); drag_handle(ui); if let Some(_tooltip) = ui @@ -187,39 +278,62 @@ impl View for ViewChatTranslator<'_> { ui.table_next_column(); let mut lock = message.name.is_some(); ui.disabled(message.role != Role::User, || { - ui.checkbox("##lock", &mut lock); + if ui.checkbox("##lock", &mut lock) { + interact = Some(Interaction::SetName( + idx, + lock.then(|| "info".to_string()), + )); + } }); - message.name = if lock { Some("info".into()) } else { None }; ui.table_next_column(); ui.group(|| { ui.set_next_item_width(ui.current_font_size() * 6.0); - combo_enum(ui, "##role", &mut message.role); + let mut role = message.role.clone(); + combo_enum(ui, "##role", &mut role); + if role != message.role { + interact = Some(Interaction::SetRole(idx, role)); + } }); - ui.table_next_column(); - if let Some(content) = &mut message.content { - ui.set_next_item_width(ui.content_region_avail()[0]); - ui.input_text("##content", content).build(); + let buf = self.buffers.entry(entry.id).or_insert_with(|| { + message.content.clone().unwrap_or_default() + }); + ui.set_next_item_width(ui.content_region_avail()[0]); + ui.input_text("##content", buf).build(); + if ui.is_item_deactivated_after_edit() { + interact = Some(Interaction::CommitContent(idx, buf.clone())); } } match interact { Some(Interaction::Delete(idx)) => { - chat.context_mut().remove(idx); + handle.edit_context(ContextEdit::Delete(idx)); + } + Some(Interaction::Swap(a, b)) => { + handle.edit_context(ContextEdit::Swap(a, b)); } - Some(Interaction::Swap(src, dst)) => { - chat.context_mut().swap(src, dst); + Some(Interaction::SetRole(idx, role)) => { + handle.edit_context(ContextEdit::SetRole { idx, role }); } - _ => {} + Some(Interaction::SetName(idx, name)) => { + handle.edit_context(ContextEdit::SetName { idx, name }); + } + Some(Interaction::CommitContent(idx, content)) => { + handle.edit_context(ContextEdit::SetContent { idx, content }); + } + None => {} } ui.table_next_column(); ui.table_next_column(); if ui.button_with_size("+", [ui.frame_height(), 0.0]) { - chat.context_mut().push_back(openai::chat::Message { - content: Some(String::new()), - ..Default::default() - }) + handle.edit_context(ContextEdit::Insert { + idx: state.context.len(), + message: Message { + content: Some(String::new()), + ..Default::default() + }, + }); } ui.table_next_column(); ui.table_next_column(); @@ -230,76 +344,86 @@ impl View for ViewChatTranslator<'_> { } } -pub struct ViewChatTranslation<'a>(pub &'a ChatTranslation); -impl View for ViewChatTranslation<'_> { - fn ui(&mut self, ui: &imgui::Ui) { - let _wrap_token = ui.push_text_wrap_pos_with_pos(0.0); - ui.text(""); // anchor for line wrapping +/// Render one exchange's assistant turn, streaming-aware. +fn draw_exchange(ui: &Ui, ex: &ExchangeView) { + let _wrap_token = ui.push_text_wrap_pos_with_pos(0.0); + ui.text(""); // anchor for line wrapping + ui.same_line(); + let draw_list = ui.get_window_draw_list(); + stroke_text_with_highlight( + ui, + &draw_list, + &format!("[{}]", ex.model.as_ref()), + 1.0, + Some(StyleColor::NavHighlight), + ); + let content = ex.response.content(); + if !content.is_empty() { ui.same_line(); - let ChatTranslation { - model, exchange, .. - } = self.0; - let exchange = exchange.blocking_lock(); - let draw_list = ui.get_window_draw_list(); stroke_text_with_highlight( ui, &draw_list, - &format!("[{}]", model.as_ref()), + content, 1.0, - Some(StyleColor::NavHighlight), + Some(StyleColor::TextSelectedBg), ); - for content in exchange.response().iter().flat_map(|c| c.content.as_ref()) { - ui.same_line(); + } + match &ex.response { + Response::Streaming { .. } => { + if content.is_empty() { + ui.same_line(); + } else { + ui.same_line_with_spacing(0.0, 0.0); + } stroke_text_with_highlight( ui, &draw_list, - content, + ellipses(ui), 1.0, Some(StyleColor::TextSelectedBg), ); } - if !exchange.is_completed() { - if exchange.response().is_none() { - ui.same_line(); - } else { - ui.same_line_with_spacing(0.0, 0.0); - } + Response::Errored(err) => { + ui.same_line(); stroke_text_with_highlight( ui, &draw_list, - ellipses(ui), + &format!("(error: {})", err), 1.0, - Some(StyleColor::TextSelectedBg), + Some(StyleColor::PlotLinesHovered), ); } - } -} - -pub struct ViewChatTranslationUsage<'a>(pub &'a ChatTranslation); -impl View for ViewChatTranslationUsage<'_> { - fn ui(&mut self, ui: &imgui::Ui) { - let ChatTranslation { - model, exchange, .. - } = self.0; - let exchange = exchange.blocking_lock(); - let usage = exchange.usage(); - if let Some(usage) = usage { + Response::Cancelled => { ui.same_line(); - ProgressBar::new(0.0) - .overlay_text(format!( - "{}: {} input + {} output ({} reasoning) = {}", - model.as_ref(), - usage.prompt_tokens, - usage.completion_tokens, - usage - .completion_tokens_details - .as_ref() - .map(|x| x.reasoning_tokens) - .unwrap_or_default(), - usage.total_tokens, - )) - .size([500.0, 0.0]) - .build(ui); + stroke_text_with_highlight( + ui, + &draw_list, + "(cancelled)", + 1.0, + Some(StyleColor::PlotLinesHovered), + ); } + Response::Completed { .. } => {} } } + +/// Render the usage progress bar for one exchange. +fn draw_usage(ui: &Ui, model: &ModelId, usage: &Usage) { + ui.same_line(); + ProgressBar::new(0.0) + .overlay_text(format!( + "{}: {} input + {} output ({} reasoning) = {}", + model.as_ref(), + usage.prompt_tokens, + usage.completion_tokens, + usage + .completion_tokens_details + .as_ref() + .map(|x| x.reasoning_tokens) + .unwrap_or_default(), + usage.total_tokens, + )) + .size([500.0, 0.0]) + .build(ui); +} + From 705a064804eaa4af4beeeed84110a72802a701e8 Mon Sep 17 00:00:00 2001 From: netdex Date: Sun, 19 Apr 2026 10:25:49 -0400 Subject: [PATCH 7/8] Cargo.lock: update --- Cargo.lock | 750 +++++++---------------------------------------------- 1 file changed, 97 insertions(+), 653 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 149c627..ef93153 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -18,15 +18,6 @@ version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" -[[package]] -name = "addr2line" -version = "0.25.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" -dependencies = [ - "gimli", -] - [[package]] name = "adler2" version = "2.0.1" @@ -120,61 +111,20 @@ dependencies = [ ] [[package]] -name = "anstream" -version = "0.6.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" -dependencies = [ - "anstyle", - "anstyle-parse", - "anstyle-query", - "anstyle-wincon", - "colorchoice", - "is_terminal_polyfill", - "utf8parse", -] - -[[package]] -name = "anstyle" -version = "1.0.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" - -[[package]] -name = "anstyle-parse" -version = "0.2.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" -dependencies = [ - "utf8parse", -] - -[[package]] -name = "anstyle-query" -version = "1.1.5" +name = "anyhow" +version = "1.0.101" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" -dependencies = [ - "windows-sys 0.61.2", -] +checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" [[package]] -name = "anstyle-wincon" -version = "3.0.11" +name = "arc-swap" +version = "1.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +checksum = "6a3a1fd6f75306b68087b831f025c712524bcb19aad54e557b1129cfa0a2b207" dependencies = [ - "anstyle", - "once_cell_polyfill", - "windows-sys 0.61.2", + "rustversion", ] -[[package]] -name = "anyhow" -version = "1.0.101" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" - [[package]] name = "arrayref" version = "0.3.9" @@ -193,17 +143,6 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b" -[[package]] -name = "async-trait" -version = "0.1.89" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.114", -] - [[package]] name = "atomic-waker" version = "1.1.2" @@ -249,27 +188,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "backtrace" -version = "0.3.76" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" -dependencies = [ - "addr2line", - "cfg-if", - "libc", - "miniz_oxide", - "object", - "rustc-demangle", - "windows-link 0.2.1", -] - -[[package]] -name = "base64" -version = "0.21.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" - [[package]] name = "base64" version = "0.22.1" @@ -318,15 +236,6 @@ version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" -[[package]] -name = "block-buffer" -version = "0.10.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" -dependencies = [ - "generic-array", -] - [[package]] name = "block-sys" version = "0.2.1" @@ -524,53 +433,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" dependencies = [ "iana-time-zone", - "js-sys", "num-traits", "serde", - "wasm-bindgen", "windows-link 0.2.1", ] -[[package]] -name = "clap" -version = "4.5.57" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6899ea499e3fb9305a65d5ebf6e3d2248c5fab291f300ad0a704fbe142eae31a" -dependencies = [ - "clap_builder", - "clap_derive", -] - -[[package]] -name = "clap_builder" -version = "4.5.57" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b12c8b680195a62a8364d16b8447b01b6c2c8f9aaf68bee653be34d4245e238" -dependencies = [ - "anstream", - "anstyle", - "clap_lex", - "strsim", -] - -[[package]] -name = "clap_derive" -version = "4.5.55" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "syn 2.0.114", -] - -[[package]] -name = "clap_lex" -version = "0.7.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32" - [[package]] name = "clipboard-win" version = "5.4.1" @@ -589,12 +456,6 @@ dependencies = [ "cc", ] -[[package]] -name = "colorchoice" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" - [[package]] name = "combine" version = "4.6.7" @@ -692,7 +553,7 @@ dependencies = [ "bitflags 1.3.2", "core-foundation 0.9.4", "core-graphics-types", - "foreign-types 0.5.0", + "foreign-types", "libc", ] @@ -747,15 +608,6 @@ dependencies = [ "windows 0.54.0", ] -[[package]] -name = "cpufeatures" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" -dependencies = [ - "libc", -] - [[package]] name = "crc32fast" version = "1.5.0" @@ -821,16 +673,6 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" -[[package]] -name = "crypto-common" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" -dependencies = [ - "generic-array", - "typenum", -] - [[package]] name = "csv-async" version = "1.3.1" @@ -950,25 +792,6 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f" -[[package]] -name = "data-encoding" -version = "2.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" - -[[package]] -name = "deepl-api" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "401aec68ac134121d3bd7ad11cae28b6359be76421aef8757f94f26690df31c6" -dependencies = [ - "chrono", - "clap", - "error-chain", - "reqwest 0.11.27", - "serde", -] - [[package]] name = "deranged" version = "0.5.5" @@ -1013,16 +836,6 @@ dependencies = [ "unicode-xid", ] -[[package]] -name = "digest" -version = "0.10.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" -dependencies = [ - "block-buffer", - "crypto-common", -] - [[package]] name = "dirs" version = "6.0.0" @@ -1146,16 +959,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "error-chain" -version = "0.12.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d2f06b9cac1506ece98fe3231e3cc9c4410ec3d5b1f24ae1c8946f0742cdefc" -dependencies = [ - "backtrace", - "version_check", -] - [[package]] name = "error-code" version = "3.3.2" @@ -1246,15 +1049,6 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" -[[package]] -name = "foreign-types" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" -dependencies = [ - "foreign-types-shared 0.1.1", -] - [[package]] name = "foreign-types" version = "0.5.0" @@ -1262,7 +1056,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" dependencies = [ "foreign-types-macros", - "foreign-types-shared 0.3.1", + "foreign-types-shared", ] [[package]] @@ -1276,12 +1070,6 @@ dependencies = [ "syn 2.0.114", ] -[[package]] -name = "foreign-types-shared" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" - [[package]] name = "foreign-types-shared" version = "0.3.1" @@ -1407,16 +1195,6 @@ dependencies = [ "windows-result 0.4.1", ] -[[package]] -name = "generic-array" -version = "0.14.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" -dependencies = [ - "typenum", - "version_check", -] - [[package]] name = "gethostname" version = "1.1.0" @@ -1454,12 +1232,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "gimli" -version = "0.32.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" - [[package]] name = "gl_generator" version = "0.14.0" @@ -1560,25 +1332,6 @@ dependencies = [ "gl_generator", ] -[[package]] -name = "h2" -version = "0.3.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d" -dependencies = [ - "bytes", - "fnv", - "futures-core", - "futures-sink", - "futures-util", - "http 0.2.12", - "indexmap 2.13.0", - "slab", - "tokio", - "tokio-util", - "tracing", -] - [[package]] name = "h2" version = "0.4.13" @@ -1590,7 +1343,7 @@ dependencies = [ "fnv", "futures-core", "futures-sink", - "http 1.4.0", + "http", "indexmap 2.13.0", "slab", "tokio", @@ -1639,17 +1392,6 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" -[[package]] -name = "http" -version = "0.2.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" -dependencies = [ - "bytes", - "fnv", - "itoa", -] - [[package]] name = "http" version = "1.4.0" @@ -1660,17 +1402,6 @@ dependencies = [ "itoa", ] -[[package]] -name = "http-body" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" -dependencies = [ - "bytes", - "http 0.2.12", - "pin-project-lite", -] - [[package]] name = "http-body" version = "1.0.1" @@ -1678,7 +1409,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" dependencies = [ "bytes", - "http 1.4.0", + "http", ] [[package]] @@ -1689,8 +1420,8 @@ checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" dependencies = [ "bytes", "futures-core", - "http 1.4.0", - "http-body 1.0.1", + "http", + "http-body", "pin-project-lite", ] @@ -1700,12 +1431,6 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" -[[package]] -name = "httpdate" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" - [[package]] name = "hudhook" version = "0.8.3" @@ -1723,30 +1448,6 @@ dependencies = [ "windows 0.54.0", ] -[[package]] -name = "hyper" -version = "0.14.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" -dependencies = [ - "bytes", - "futures-channel", - "futures-core", - "futures-util", - "h2 0.3.27", - "http 0.2.12", - "http-body 0.4.6", - "httparse", - "httpdate", - "itoa", - "pin-project-lite", - "socket2 0.5.10", - "tokio", - "tower-service", - "tracing", - "want", -] - [[package]] name = "hyper" version = "1.8.1" @@ -1757,9 +1458,9 @@ dependencies = [ "bytes", "futures-channel", "futures-core", - "h2 0.4.13", - "http 1.4.0", - "http-body 1.0.1", + "h2", + "http", + "http-body", "httparse", "itoa", "pin-project-lite", @@ -1775,8 +1476,8 @@ version = "0.27.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" dependencies = [ - "http 1.4.0", - "hyper 1.8.1", + "http", + "hyper", "hyper-util", "rustls", "rustls-pki-types", @@ -1785,38 +1486,25 @@ dependencies = [ "tower-service", ] -[[package]] -name = "hyper-tls" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905" -dependencies = [ - "bytes", - "hyper 0.14.32", - "native-tls", - "tokio", - "tokio-native-tls", -] - [[package]] name = "hyper-util" version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ - "base64 0.22.1", + "base64", "bytes", "futures-channel", "futures-util", - "http 1.4.0", - "http-body 1.0.1", - "hyper 1.8.1", + "http", + "http-body", + "hyper", "ipnet", "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.2", - "system-configuration 0.7.0", + "socket2", + "system-configuration", "tokio", "tower-service", "tracing", @@ -2089,12 +1777,6 @@ dependencies = [ "serde", ] -[[package]] -name = "is_terminal_polyfill" -version = "1.70.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" - [[package]] name = "itertools" version = "0.14.0" @@ -2354,23 +2036,6 @@ dependencies = [ "getrandom 0.2.17", ] -[[package]] -name = "native-tls" -version = "0.2.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e" -dependencies = [ - "libc", - "log", - "openssl", - "openssl-probe 0.1.6", - "openssl-sys", - "schannel", - "security-framework 2.11.1", - "security-framework-sys", - "tempfile", -] - [[package]] name = "ndk" version = "0.8.0" @@ -2429,11 +2094,10 @@ dependencies = [ name = "niinii" version = "0.2.0" dependencies = [ - "async-trait", + "arc-swap", "backon", "bitflags 2.10.0", "copypasta", - "deepl-api", "dirs", "enclose", "fancy-regex 0.17.0", @@ -2774,27 +2438,12 @@ dependencies = [ "objc2-metal", ] -[[package]] -name = "object" -version = "0.37.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" -dependencies = [ - "memchr", -] - [[package]] name = "once_cell" version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" -[[package]] -name = "once_cell_polyfill" -version = "1.70.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" - [[package]] name = "openai" version = "0.1.0" @@ -2806,7 +2455,7 @@ dependencies = [ "futures-util", "lazy_static", "log", - "reqwest 0.13.2", + "reqwest", "serde", "serde-untagged", "serde_json", @@ -2818,61 +2467,17 @@ dependencies = [ "tiktoken-rs", "tokio", "tokio-stream", - "tokio-tungstenite", + "toml 0.8.23", "tracing", "tracing-test", ] -[[package]] -name = "openssl" -version = "0.10.75" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" -dependencies = [ - "bitflags 2.10.0", - "cfg-if", - "foreign-types 0.3.2", - "libc", - "once_cell", - "openssl-macros", - "openssl-sys", -] - -[[package]] -name = "openssl-macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.114", -] - -[[package]] -name = "openssl-probe" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" - [[package]] name = "openssl-probe" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" -[[package]] -name = "openssl-sys" -version = "0.9.111" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321" -dependencies = [ - "cc", - "libc", - "pkg-config", - "vcpkg", -] - [[package]] name = "option-ext" version = "0.2.0" @@ -3038,7 +2643,7 @@ version = "3.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" dependencies = [ - "toml_edit", + "toml_edit 0.23.10+spec-1.0.0", ] [[package]] @@ -3072,7 +2677,7 @@ dependencies = [ "quinn-udp", "rustc-hash 2.1.1", "rustls", - "socket2 0.6.2", + "socket2", "thiserror 2.0.18", "tokio", "tracing", @@ -3110,7 +2715,7 @@ dependencies = [ "cfg_aliases 0.2.1", "libc", "once_cell", - "socket2 0.6.2", + "socket2", "tracing", "windows-sys 0.60.2", ] @@ -3258,62 +2863,22 @@ version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c" -[[package]] -name = "reqwest" -version = "0.11.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd67538700a17451e7cba03ac727fb961abb7607553461627b97de0b89cf4a62" -dependencies = [ - "base64 0.21.7", - "bytes", - "encoding_rs", - "futures-core", - "futures-util", - "h2 0.3.27", - "http 0.2.12", - "http-body 0.4.6", - "hyper 0.14.32", - "hyper-tls", - "ipnet", - "js-sys", - "log", - "mime", - "native-tls", - "once_cell", - "percent-encoding", - "pin-project-lite", - "rustls-pemfile", - "serde", - "serde_json", - "serde_urlencoded", - "sync_wrapper 0.1.2", - "system-configuration 0.5.1", - "tokio", - "tokio-native-tls", - "tower-service", - "url", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", - "winreg", -] - [[package]] name = "reqwest" version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801" dependencies = [ - "base64 0.22.1", + "base64", "bytes", "encoding_rs", "futures-core", "futures-util", - "h2 0.4.13", - "http 1.4.0", - "http-body 1.0.1", + "h2", + "http", + "http-body", "http-body-util", - "hyper 1.8.1", + "hyper", "hyper-rustls", "hyper-util", "js-sys", @@ -3327,7 +2892,7 @@ dependencies = [ "rustls-platform-verifier", "serde", "serde_json", - "sync_wrapper 1.0.2", + "sync_wrapper", "tokio", "tokio-rustls", "tokio-util", @@ -3367,12 +2932,6 @@ dependencies = [ "symphonia", ] -[[package]] -name = "rustc-demangle" -version = "0.1.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" - [[package]] name = "rustc-hash" version = "1.1.0" @@ -3440,19 +2999,10 @@ version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" dependencies = [ - "openssl-probe 0.2.1", + "openssl-probe", "rustls-pki-types", "schannel", - "security-framework 3.5.1", -] - -[[package]] -name = "rustls-pemfile" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" -dependencies = [ - "base64 0.21.7", + "security-framework", ] [[package]] @@ -3480,7 +3030,7 @@ dependencies = [ "rustls-native-certs", "rustls-platform-verifier-android", "rustls-webpki", - "security-framework 3.5.1", + "security-framework", "security-framework-sys", "webpki-root-certs", "windows-sys 0.61.2", @@ -3583,19 +3133,6 @@ dependencies = [ "tiny-skia", ] -[[package]] -name = "security-framework" -version = "2.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" -dependencies = [ - "bitflags 2.10.0", - "core-foundation 0.9.4", - "core-foundation-sys", - "libc", - "security-framework-sys", -] - [[package]] name = "security-framework" version = "3.5.1" @@ -3703,23 +3240,20 @@ dependencies = [ [[package]] name = "serde_spanned" -version = "1.0.4" +version = "0.6.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8bbf91e5a4d6315eee45e704372590b30e260ee83af6639d64557f51b067776" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" dependencies = [ - "serde_core", + "serde", ] [[package]] -name = "serde_urlencoded" -version = "0.7.1" +name = "serde_spanned" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +checksum = "f8bbf91e5a4d6315eee45e704372590b30e260ee83af6639d64557f51b067776" dependencies = [ - "form_urlencoded", - "itoa", - "ryu", - "serde", + "serde_core", ] [[package]] @@ -3728,7 +3262,7 @@ version = "3.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4fa237f2807440d238e0364a218270b98f767a00d3dada77b1c53ae88940e2e7" dependencies = [ - "base64 0.22.1", + "base64", "chrono", "hex", "indexmap 1.9.3", @@ -3753,17 +3287,6 @@ dependencies = [ "syn 2.0.114", ] -[[package]] -name = "sha1" -version = "0.10.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" -dependencies = [ - "cfg-if", - "cpufeatures", - "digest", -] - [[package]] name = "sharded-slab" version = "0.1.7" @@ -3888,16 +3411,6 @@ dependencies = [ "serde", ] -[[package]] -name = "socket2" -version = "0.5.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" -dependencies = [ - "libc", - "windows-sys 0.52.0", -] - [[package]] name = "socket2" version = "0.6.2" @@ -4128,12 +3641,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "sync_wrapper" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" - [[package]] name = "sync_wrapper" version = "1.0.2" @@ -4154,17 +3661,6 @@ dependencies = [ "syn 2.0.114", ] -[[package]] -name = "system-configuration" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7" -dependencies = [ - "bitflags 1.3.2", - "core-foundation 0.9.4", - "system-configuration-sys 0.5.0", -] - [[package]] name = "system-configuration" version = "0.7.0" @@ -4173,17 +3669,7 @@ checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" dependencies = [ "bitflags 2.10.0", "core-foundation 0.9.4", - "system-configuration-sys 0.6.0", -] - -[[package]] -name = "system-configuration-sys" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a75fb188eb626b924683e3b95e3a48e63551fcfb51949de2f06a9d91dbee93c9" -dependencies = [ - "core-foundation-sys", - "libc", + "system-configuration-sys", ] [[package]] @@ -4196,19 +3682,6 @@ dependencies = [ "libc", ] -[[package]] -name = "tempfile" -version = "3.24.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c" -dependencies = [ - "fastrand", - "getrandom 0.3.4", - "once_cell", - "rustix 1.1.3", - "windows-sys 0.61.2", -] - [[package]] name = "thiserror" version = "1.0.69" @@ -4265,7 +3738,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a19830747d9034cd9da43a60eaa8e552dfda7712424aebf187b7a60126bae0d" dependencies = [ "anyhow", - "base64 0.22.1", + "base64", "bstr", "fancy-regex 0.13.0", "lazy_static", @@ -4365,7 +3838,7 @@ dependencies = [ "mio", "pin-project-lite", "signal-hook-registry", - "socket2 0.6.2", + "socket2", "tokio-macros", "windows-sys 0.61.2", ] @@ -4381,16 +3854,6 @@ dependencies = [ "syn 2.0.114", ] -[[package]] -name = "tokio-native-tls" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" -dependencies = [ - "native-tls", - "tokio", -] - [[package]] name = "tokio-rustls" version = "0.26.4" @@ -4413,20 +3876,6 @@ dependencies = [ "tokio-util", ] -[[package]] -name = "tokio-tungstenite" -version = "0.28.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857" -dependencies = [ - "futures-util", - "log", - "native-tls", - "tokio", - "tokio-native-tls", - "tungstenite", -] - [[package]] name = "tokio-util" version = "0.7.18" @@ -4449,6 +3898,18 @@ dependencies = [ "serde", ] +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", + "toml_edit 0.22.27", +] + [[package]] name = "toml" version = "0.9.11+spec-1.1.0" @@ -4457,13 +3918,22 @@ checksum = "f3afc9a848309fe1aaffaed6e1546a7a14de1f935dc9d89d32afd9a44bab7c46" dependencies = [ "indexmap 2.13.0", "serde_core", - "serde_spanned", - "toml_datetime", + "serde_spanned 1.0.4", + "toml_datetime 0.7.5+spec-1.1.0", "toml_parser", "toml_writer", "winnow", ] +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + [[package]] name = "toml_datetime" version = "0.7.5+spec-1.1.0" @@ -4473,6 +3943,20 @@ dependencies = [ "serde_core", ] +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap 2.13.0", + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", + "toml_write", + "winnow", +] + [[package]] name = "toml_edit" version = "0.23.10+spec-1.0.0" @@ -4480,7 +3964,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269" dependencies = [ "indexmap 2.13.0", - "toml_datetime", + "toml_datetime 0.7.5+spec-1.1.0", "toml_parser", "winnow", ] @@ -4494,6 +3978,12 @@ dependencies = [ "winnow", ] +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + [[package]] name = "toml_writer" version = "1.0.6+spec-1.1.0" @@ -4509,7 +3999,7 @@ dependencies = [ "futures-core", "futures-util", "pin-project-lite", - "sync_wrapper 1.0.2", + "sync_wrapper", "tokio", "tower-layer", "tower-service", @@ -4524,8 +4014,8 @@ dependencies = [ "bitflags 2.10.0", "bytes", "futures-util", - "http 1.4.0", - "http-body 1.0.1", + "http", + "http-body", "iri-string", "pin-project-lite", "tower", @@ -4683,36 +4173,12 @@ version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" -[[package]] -name = "tungstenite" -version = "0.28.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" -dependencies = [ - "bytes", - "data-encoding", - "http 1.4.0", - "httparse", - "log", - "native-tls", - "rand", - "sha1", - "thiserror 2.0.18", - "utf-8", -] - [[package]] name = "typeid" version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" -[[package]] -name = "typenum" -version = "1.19.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" - [[package]] name = "unicode-ident" version = "1.0.22" @@ -4749,24 +4215,12 @@ dependencies = [ "serde", ] -[[package]] -name = "utf-8" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" - [[package]] name = "utf8_iter" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" -[[package]] -name = "utf8parse" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" - [[package]] name = "valuable" version = "0.1.1" @@ -5742,16 +5196,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "winreg" -version = "0.50.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" -dependencies = [ - "cfg-if", - "windows-sys 0.48.0", -] - [[package]] name = "winres" version = "0.1.12" From d3e1ff71d30d4cc9527abd2b4313eaa6df534997 Mon Sep 17 00:00:00 2001 From: netdex Date: Sun, 19 Apr 2026 10:36:10 -0400 Subject: [PATCH 8/8] add CLAUDE.md --- CLAUDE.md | 69 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..a13dfaa --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,69 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +niinii is a Windows desktop application for glossing and translating Japanese text, primarily used for assisted reading of visual novels. It provides real-time text segmentation (via Ichiran), dictionary lookups (JMDict/KANJIDIC2), furigana display, and LLM-based translation. The UI is built with imgui-rs. + +## Build Commands + +```bash +# Build (requires vcpkg with freetype installed) +# vcpkg install freetype:x64-windows-static-md +cargo build --release + +# Run +cargo run --release -p niinii + +# Run tests +cargo test --workspace + +# Run a single crate's tests +cargo test -p openai +cargo test -p ichiran + +# Optional features +cargo build --features tracing-tracy # Tracy profiler support +cargo build --features tracing-chrome # Chrome tracing support +cargo build --features voicevox # Text-to-speech (Windows only) +cargo build --features hook # DLL injection/hooking (Windows only) + +# 32-bit build (for hooking into 32-bit applications) +cargo +stable-i686-pc-windows-msvc build --target i686-pc-windows-msvc --release +``` + +## Architecture + +### Workspace Crates + +- **`niinii/`** — Main application crate (binary + cdylib). Contains the GUI, application logic, and glue between subsystems. +- **`openai/`** — Custom OpenAI API client library (Chat Completions, Realtime WebSocket, Responses API). Not published; built specifically for this project. +- **`ichiran/`** — Rust wrapper around `ichiran-cli`, a Common Lisp program for Japanese text segmentation. Manages a PostgreSQL subprocess and communicates via CLI invocations with S-expressions. Includes LRU caching for segments and kanji lookups. +- **`third-party/`** — Vendored/forked dependencies: `imgui-dx11-renderer`, `vvcore` (VOICEVOX), `eventsource-stream`. + +### Key Subsystems in `niinii/` + +- **`app.rs`** — Central `App` struct. Owns the parser, translator, TTS engine, and coordinates async message passing (gloss results, translations) via tokio mpsc channels. +- **`renderer/`** — Rendering backends implementing the `Renderer` trait: `glow_viewports` (OpenGL, cross-platform) and `d3d11` (Direct3D 11, Windows-only). Manages imgui context, font loading, and the main event loop. +- **`translator/`** — Currently one backend: `chat` (OpenAI Chat Completions). Organized as a command/event/state store: the UI sends `ChatCommand`s, a single writer task applies commands and reduces `ChatEvent`s emitted by adapter tasks, and publishes immutable `ChatState` snapshots via `ArcSwap`. UI reads are wait-free (`state.load_full()`) and never `async`. Per-request knobs are snapshotted into `TranslateConfig` at submission time; the backend never reads `Settings` live. +- **`view/`** — imgui UI components. Each top-level window (translator, settings, inject, style editor) is a persistent struct that owns its own `open: bool` and any edit-buffer state. Convention: `show_menu_item(ui)` to render the menu entry that opens it, and `ui(...)` self-renders the window with `.opened(&mut self.open)` and early-returns when closed. `App` holds one instance of each and calls `ui(...)` unconditionally each frame. +- **`settings.rs`** — Application configuration. Serialized to/from `niinii.toml` using serde. +- **`parser.rs`** — Wraps the `ichiran` crate to produce a `SyntaxTree` from Japanese input text. +- **`hook.rs`** — DLL injection support via `hudhook` for rendering the overlay inside another process (feature-gated). + +### Configuration + +The app reads `niinii.toml` at startup. This contains API keys, model settings, translation prompts, renderer choice, and UI preferences. Settings are written back on exit. + +### Dependencies and Patches + +The workspace uses a forked `imgui-rs` (branch `glow-viewports-mdpi`) patched for viewport and DPI support. The fork is referenced via `[patch.crates-io]` in the root `Cargo.toml`. The `freetype` library is required via vcpkg for font rendering. + +### Runtime Dependencies + +Japanese language support requires `ichiran-cli` and a PostgreSQL instance with the Ichiran database. Paths are configured in `niinii.toml`. The `data/` directory contains these runtime dependencies for packaged builds. + +## Target Platform + +Primary target is `x86_64-pc-windows-msvc`. Cross-platform support is possible via the Glow renderer but is not actively maintained. \ No newline at end of file