From f3cb4b9374bd7cf9f44ffcfd213ac27c8585ab4a Mon Sep 17 00:00:00 2001 From: Yujong Lee Date: Fri, 13 Feb 2026 11:19:20 +0900 Subject: [PATCH 1/9] enable chat by default --- plugins/flag/src/feature.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/plugins/flag/src/feature.rs b/plugins/flag/src/feature.rs index d7a2f7bf28..2adc945078 100644 --- a/plugins/flag/src/feature.rs +++ b/plugins/flag/src/feature.rs @@ -21,10 +21,7 @@ pub enum Feature { impl Feature { pub fn strategy(&self) -> FlagStrategy { match self { - Feature::Chat => match option_env!("CHAT") { - Some(_) => FlagStrategy::Hardcoded(true), - None => FlagStrategy::Debug, - }, + Feature::Chat => FlagStrategy::Hardcoded(true), } } } From d590e78ea92bb6c69fe7876a960c2f826d6361d3 Mon Sep 17 00:00:00 2001 From: Yujong Lee Date: Fri, 13 Feb 2026 12:41:14 +0900 Subject: [PATCH 2/9] various openrouter client improvements --- Cargo.lock | 1 + apps/api/openapi.gen.json | 40 ++ crates/llm-proxy/Cargo.toml | 1 + crates/llm-proxy/src/provider/openrouter.rs | 60 +- crates/openrouter/Cargo.toml | 1 + crates/openrouter/src/client.rs | 208 +++++- crates/openrouter/src/types/content.rs | 21 + crates/openrouter/src/types/mod.rs | 2 + crates/openrouter/src/types/responses.rs | 741 ++++++++++++++++++++ 9 files changed, 1043 insertions(+), 32 deletions(-) create mode 100644 crates/openrouter/src/types/responses.rs diff --git a/Cargo.lock b/Cargo.lock index c74073690c..a09a8e56e3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10981,6 +10981,7 @@ dependencies = [ "backon", "bytes", "futures-util", + "openrouter", "reqwest 0.13.2", "sentry", "serde", diff --git a/apps/api/openapi.gen.json b/apps/api/openapi.gen.json index 9face1dc38..4d97a7b4e3 100644 --- a/apps/api/openapi.gen.json +++ b/apps/api/openapi.gen.json @@ -729,6 +729,15 @@ "status": { "$ref": "#/components/schemas/PipelineStatus" }, + "tokens": { + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/components/schemas/TranscriptToken" + } + }, "transcript": { "type": [ "string", @@ -737,6 +746,37 @@ } } }, + "TranscriptToken": { + "type": "object", + "required": [ + "text", + "startMs", + "endMs" + ], + "properties": { + "endMs": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "speaker": { + "type": [ + "integer", + "null" + ], + "format": "int32", + "minimum": 0 + }, + "startMs": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "text": { + "type": "string" + } + } + }, "WebhookResponse": { "type": "object", "required": [ diff --git a/crates/llm-proxy/Cargo.toml b/crates/llm-proxy/Cargo.toml index f2c8ae5f8f..28161de4ae 100644 --- a/crates/llm-proxy/Cargo.toml +++ b/crates/llm-proxy/Cargo.toml @@ -6,6 +6,7 @@ edition = "2024" [dependencies] hypr-analytics = { workspace = true } hypr-api-env = { workspace = true } +hypr-openrouter = { workspace = true } async-stream = { workspace = true } axum = { workspace = true } diff --git a/crates/llm-proxy/src/provider/openrouter.rs b/crates/llm-proxy/src/provider/openrouter.rs index a2815ead48..d89430a30d 100644 --- a/crates/llm-proxy/src/provider/openrouter.rs +++ b/crates/llm-proxy/src/provider/openrouter.rs @@ -1,3 +1,4 @@ +use hypr_openrouter::{Client as OpenRouterClient, Error as OpenRouterError}; use reqwest::Client; use serde::Deserialize; @@ -130,39 +131,38 @@ impl Provider for OpenRouterProvider { let generation_id = generation_id.to_string(); Box::pin(async move { - #[derive(Deserialize)] - struct OpenRouterGenerationResponse { - data: OpenRouterGenerationData, - } - - #[derive(Deserialize)] - struct OpenRouterGenerationData { - total_cost: f64, - } + let openrouter = OpenRouterClient::new(api_key).with_http_client(client); - let url = format!( - "https://openrouter.ai/api/v1/generation?id={}", - generation_id - ); - - let response = client - .get(&url) - .header("Authorization", format!("Bearer {}", api_key)) - .send() + match openrouter + .generation_total_cost_with_retry(&generation_id, 3) .await - .ok()?; - - if !response.status().is_success() { - tracing::warn!( - http_status = %response.status().as_u16(), - generation_id = %generation_id, - "generation_metadata_fetch_failed" - ); - return None; + { + Ok(cost) => cost, + Err(OpenRouterError::Api { status, .. }) if status == 404 => { + tracing::debug!( + http_status = %status, + generation_id = %generation_id, + "generation_metadata_unavailable" + ); + None + } + Err(OpenRouterError::Api { status, .. }) => { + tracing::warn!( + http_status = %status, + generation_id = %generation_id, + "generation_metadata_fetch_failed" + ); + None + } + Err(err) => { + tracing::warn!( + error = %err, + generation_id = %generation_id, + "generation_metadata_fetch_failed" + ); + None + } } - - let data: OpenRouterGenerationResponse = response.json().await.ok()?; - Some(data.data.total_cost) }) } } diff --git a/crates/openrouter/Cargo.toml b/crates/openrouter/Cargo.toml index 32118db21d..9345078b7e 100644 --- a/crates/openrouter/Cargo.toml +++ b/crates/openrouter/Cargo.toml @@ -11,6 +11,7 @@ reqwest = { workspace = true, features = ["json", "stream"] } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } thiserror = { workspace = true } +tokio = { workspace = true, features = ["time"] } [dev-dependencies] tokio = { workspace = true, features = ["rt", "macros"] } diff --git a/crates/openrouter/src/client.rs b/crates/openrouter/src/client.rs index 6b218c987a..2b774e1d2d 100644 --- a/crates/openrouter/src/client.rs +++ b/crates/openrouter/src/client.rs @@ -1,9 +1,12 @@ use std::pin::Pin; use futures_util::{Stream, StreamExt}; +use serde::Deserialize; +use tokio::time::{Duration, sleep}; use crate::error::Error; use crate::types::{ChatCompletionChunk, ChatCompletionRequest, ChatCompletionResponse}; +use crate::types::{ResponsesRequest, ResponsesResponse, ResponsesStreamOutputItem}; #[derive(Debug, Clone)] pub struct Client { @@ -13,9 +16,9 @@ pub struct Client { } #[derive(serde::Serialize)] -struct RequestBody<'a> { +struct RequestBody<'a, T> { #[serde(flatten)] - request: &'a ChatCompletionRequest, + request: &'a T, stream: bool, } @@ -33,6 +36,11 @@ impl Client { self } + pub fn with_http_client(mut self, client: reqwest::Client) -> Self { + self.client = client; + self + } + pub async fn chat_completion( &self, req: &ChatCompletionRequest, @@ -87,6 +95,117 @@ impl Client { let byte_stream = resp.bytes_stream(); Ok(Box::pin(parse_sse_stream(byte_stream))) } + + pub async fn responses(&self, req: &ResponsesRequest) -> Result { + let url = format!("{}/responses", self.base_url); + let body = RequestBody { + request: req, + stream: false, + }; + + let resp = self + .client + .post(&url) + .header("Authorization", format!("Bearer {}", self.api_key)) + .json(&body) + .send() + .await?; + + if !resp.status().is_success() { + let status = resp.status().as_u16(); + let message = resp.text().await.unwrap_or_default(); + return Err(Error::Api { status, message }); + } + + Ok(resp.json().await?) + } + + pub async fn responses_stream( + &self, + req: &ResponsesRequest, + ) -> Result> + Send>>, Error> + { + let url = format!("{}/responses", self.base_url); + let body = RequestBody { + request: req, + stream: true, + }; + + let resp = self + .client + .post(&url) + .header("Authorization", format!("Bearer {}", self.api_key)) + .json(&body) + .send() + .await?; + + if !resp.status().is_success() { + let status = resp.status().as_u16(); + let message = resp.text().await.unwrap_or_default(); + return Err(Error::Api { status, message }); + } + + let byte_stream = resp.bytes_stream(); + Ok(Box::pin(parse_responses_sse_stream(byte_stream))) + } + + pub async fn generation_total_cost_with_retry( + &self, + generation_id: &str, + max_attempts: usize, + ) -> Result, Error> { + if !generation_id.starts_with("gen-") { + return Ok(None); + } + + let max_attempts = max_attempts.max(1); + for attempt in 1..=max_attempts { + match self.generation_total_cost(generation_id).await { + Ok(cost) => return Ok(cost), + Err(Error::Api { status, .. }) + if (status == 429 || status >= 500) && attempt < max_attempts => + { + sleep(Duration::from_millis((attempt as u64) * 200)).await; + } + Err(err) => return Err(err), + } + } + + Ok(None) + } + + async fn generation_total_cost(&self, generation_id: &str) -> Result, Error> { + #[derive(Deserialize)] + struct GenerationResponse { + data: GenerationData, + } + + #[derive(Deserialize)] + struct GenerationData { + total_cost: f64, + } + + let url = format!("{}/generation?id={}", self.base_url, generation_id); + let resp = self + .client + .get(&url) + .header("Authorization", format!("Bearer {}", self.api_key)) + .send() + .await?; + + if resp.status().as_u16() == 404 { + return Ok(None); + } + + if !resp.status().is_success() { + let status = resp.status().as_u16(); + let message = resp.text().await.unwrap_or_default(); + return Err(Error::Api { status, message }); + } + + let body: GenerationResponse = resp.json().await?; + Ok(Some(body.data.total_cost)) + } } fn process_line(line: &str) -> Option> { @@ -153,3 +272,88 @@ fn parse_sse_stream( } } } + +fn process_responses_line(line: &str) -> Option> { + let line = line.trim(); + if line.is_empty() { + return None; + } + + if let Some(data) = line.strip_prefix("data: ") { + let data = data.trim(); + if data == "[DONE]" { + return None; + } + return Some( + serde_json::from_str::(data) + .map_err(|e| Error::Stream(e.to_string())), + ); + } + + if line.starts_with("event:") { + return None; + } + + if let Ok(value) = serde_json::from_str::(line) { + if let Some(obj) = value.as_object() { + if obj.contains_key("type") { + return Some( + serde_json::from_str::(line) + .map_err(|e| Error::Stream(e.to_string())), + ); + } + } + } + + None +} + +fn parse_responses_sse_stream( + byte_stream: impl Stream> + Send + 'static, +) -> impl Stream> + Send { + async_stream::stream! { + let mut buffer = Vec::::new(); + futures_util::pin_mut!(byte_stream); + + while let Some(chunk) = byte_stream.next().await { + let chunk = match chunk { + Ok(c) => c, + Err(e) => { + yield Err(Error::Http(e)); + break; + } + }; + + buffer.extend_from_slice(&chunk); + + while let Some(newline_pos) = buffer.iter().position(|&b| b == b'\n') { + let line_bytes = buffer[..newline_pos].to_vec(); + buffer = buffer[newline_pos + 1..].to_vec(); + + let line = match std::str::from_utf8(&line_bytes) { + Ok(s) => s, + Err(e) => { + yield Err(Error::Stream(e.to_string())); + continue; + } + }; + + if let Some(result) = process_responses_line(line) { + match result { + Ok(item) => yield Ok(item), + Err(e) => yield Err(e), + } + } + } + } + + if !buffer.is_empty() + && let Ok(line) = std::str::from_utf8(&buffer) + && let Some(result) = process_responses_line(line) { + match result { + Ok(item) => yield Ok(item), + Err(e) => yield Err(e), + } + } + } +} diff --git a/crates/openrouter/src/types/content.rs b/crates/openrouter/src/types/content.rs index 96d471f252..fca541f1ed 100644 --- a/crates/openrouter/src/types/content.rs +++ b/crates/openrouter/src/types/content.rs @@ -70,6 +70,15 @@ impl ContentPart { } } + pub fn image_url_with_detail(url: impl Into, detail: ImageDetail) -> Self { + Self::ImageUrl { + image_url: ImageUrlContent { + url: url.into(), + detail: Some(detail), + }, + } + } + pub fn input_audio(data: impl Into, format: impl Into) -> Self { Self::InputAudio { input_audio: InputAudioContent { @@ -78,6 +87,18 @@ impl ContentPart { }, } } + + pub fn input_video(url: impl Into) -> Self { + Self::InputVideo { + video_url: VideoUrlContent { url: url.into() }, + } + } + + pub fn video_url(url: impl Into) -> Self { + Self::VideoUrl { + video_url: VideoUrlContent { url: url.into() }, + } + } } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/crates/openrouter/src/types/mod.rs b/crates/openrouter/src/types/mod.rs index b3f12ae289..a8ae4e0296 100644 --- a/crates/openrouter/src/types/mod.rs +++ b/crates/openrouter/src/types/mod.rs @@ -3,9 +3,11 @@ mod message; mod provider; mod request; mod response; +mod responses; pub use content::*; pub use message::*; pub use provider::*; pub use request::*; pub use response::*; +pub use responses::*; diff --git a/crates/openrouter/src/types/responses.rs b/crates/openrouter/src/types/responses.rs new file mode 100644 index 0000000000..ca1a210045 --- /dev/null +++ b/crates/openrouter/src/types/responses.rs @@ -0,0 +1,741 @@ +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum ResponseRole { + User, + System, + Assistant, + Developer, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type")] +pub enum ResponseInputContent { + #[serde(rename = "input_text")] + InputText { text: String }, + #[serde(rename = "input_image")] + InputImage { + image_url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + detail: Option, + }, + #[serde(rename = "input_file")] + InputFile { + #[serde(skip_serializing_if = "Option::is_none")] + file_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + file_data: Option, + #[serde(skip_serializing_if = "Option::is_none")] + filename: Option, + #[serde(skip_serializing_if = "Option::is_none")] + file_url: Option, + }, + #[serde(rename = "input_audio")] + InputAudio { input_audio: InputAudio }, + #[serde(rename = "input_video")] + InputVideo { video_url: String }, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum ResponsesImageDetail { + Auto, + High, + Low, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct InputAudio { + pub data: String, + pub format: AudioFormat, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum AudioFormat { + Mp3, + Wav, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ResponseInputMessage { + #[serde(skip_serializing_if = "Option::is_none")] + pub id: Option, + #[serde(rename = "type", skip_serializing_if = "Option::is_none")] + pub r#type: Option, + pub role: ResponseRole, + pub content: ResponseMessageContent, +} + +impl ResponseInputMessage { + pub fn user(content: impl Into) -> Self { + Self { + id: None, + r#type: Some("message".to_string()), + role: ResponseRole::User, + content: content.into(), + } + } + + pub fn system(content: impl Into) -> Self { + Self { + id: None, + r#type: Some("message".to_string()), + role: ResponseRole::System, + content: content.into(), + } + } + + pub fn assistant(content: impl Into) -> Self { + Self { + id: None, + r#type: Some("message".to_string()), + role: ResponseRole::Assistant, + content: content.into(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(untagged)] +pub enum ResponseMessageContent { + Text(String), + Parts(Vec), +} + +impl From for ResponseMessageContent { + fn from(s: String) -> Self { + ResponseMessageContent::Text(s) + } +} + +impl From<&str> for ResponseMessageContent { + fn from(s: &str) -> Self { + ResponseMessageContent::Text(s.to_string()) + } +} + +impl From> for ResponseMessageContent { + fn from(parts: Vec) -> Self { + ResponseMessageContent::Parts(parts) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ResponseFunctionCall { + #[serde(rename = "type")] + pub r#type: String, + pub call_id: String, + pub name: String, + pub arguments: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub status: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ResponseFunctionCallOutput { + #[serde(rename = "type")] + pub r#type: String, + pub call_id: String, + pub output: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub status: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ToolCallStatus { + InProgress, + Completed, + Incomplete, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(untagged)] +pub enum ResponseInputItem { + Message(ResponseInputMessage), + FunctionCall(ResponseFunctionCall), + FunctionCallOutput(ResponseFunctionCallOutput), +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(untagged)] +pub enum ResponseInput { + Text(String), + Items(Vec), +} + +impl From for ResponseInput { + fn from(s: String) -> Self { + ResponseInput::Text(s) + } +} + +impl From<&str> for ResponseInput { + fn from(s: &str) -> Self { + ResponseInput::Text(s.to_string()) + } +} + +impl From> for ResponseInput { + fn from(items: Vec) -> Self { + ResponseInput::Items(items) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ResponseFunctionTool { + #[serde(rename = "type")] + pub r#type: String, + pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub parameters: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub strict: Option, +} + +impl ResponseFunctionTool { + pub fn new( + name: impl Into, + description: impl Into, + parameters: serde_json::Value, + ) -> Self { + Self { + r#type: "function".to_string(), + name: name.into(), + description: Some(description.into()), + parameters: Some(parameters), + strict: None, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(untagged)] +pub enum ResponseTool { + Function(ResponseFunctionTool), + WebSearchPreview { r#type: String }, + WebSearch { r#type: String }, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(untagged)] +pub enum ResponseToolChoice { + String(String), + Specific { r#type: String, name: String }, +} + +impl ResponseToolChoice { + pub fn auto() -> Self { + Self::String("auto".to_string()) + } + + pub fn none() -> Self { + Self::String("none".to_string()) + } + + pub fn required() -> Self { + Self::String("required".to_string()) + } + + pub fn function(name: impl Into) -> Self { + Self::Specific { + r#type: "function".to_string(), + name: name.into(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type")] +pub enum ResponseTextFormat { + #[serde(rename = "text")] + Text, + #[serde(rename = "json_object")] + JsonObject, + #[serde(rename = "json_schema")] + JsonSchema { + name: String, + #[serde(skip_serializing_if = "Option::is_none")] + description: Option, + schema: serde_json::Value, + #[serde(skip_serializing_if = "Option::is_none")] + strict: Option, + }, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ResponseTextConfig { + #[serde(skip_serializing_if = "Option::is_none")] + pub format: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub verbosity: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum ResponseTextVerbosity { + High, + Low, + Medium, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum ResponsesReasoningEffort { + Xhigh, + High, + Medium, + Low, + Minimal, + None, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum ResponsesReasoningSummaryVerbosity { + Auto, + Concise, + Detailed, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ResponsesReasoningConfig { + #[serde(skip_serializing_if = "Option::is_none")] + pub effort: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub summary: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub max_tokens: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub enabled: Option, +} + +#[derive(Debug, Clone, Serialize)] +pub struct ResponsesRequest { + pub input: ResponseInput, + #[serde(skip_serializing_if = "Option::is_none")] + pub instructions: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub metadata: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub tools: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub tool_choice: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub parallel_tool_calls: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub model: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub models: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub text: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub reasoning: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub max_output_tokens: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub temperature: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub top_p: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub top_logprobs: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub max_tool_calls: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub presence_penalty: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub frequency_penalty: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub top_k: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub modalities: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub previous_response_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub user: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub session_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub provider: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum ResponsesModality { + Text, + Image, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct ResponsesProviderPreferences { + #[serde(skip_serializing_if = "Option::is_none")] + pub allow_fallbacks: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub require_parameters: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub data_collection: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub order: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub only: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub ignore: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub quantizations: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub sort: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub max_price: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub preferred_min_throughput: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub preferred_max_latency: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub zdr: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub enforce_distillable_text: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum ResponsesDataCollection { + Allow, + Deny, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum ResponsesQuantization { + Int4, + Int8, + Fp4, + Fp6, + Fp8, + Fp16, + Bf16, + Fp32, + Unknown, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum ResponsesProviderSort { + Price, + Throughput, + Latency, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ResponsesMaxPrice { + #[serde(skip_serializing_if = "Option::is_none")] + pub prompt: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub completion: Option, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct ResponsesResponse { + pub id: String, + pub object: String, + pub created_at: f64, + pub model: String, + pub status: ResponsesStatus, + #[serde(default)] + pub completed_at: Option, + pub output: Vec, + #[serde(default)] + pub output_text: Option, + #[serde(default)] + pub error: Option, + #[serde(default)] + pub usage: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ResponsesStatus { + Completed, + Incomplete, + InProgress, + Failed, + Cancelled, + Queued, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum ResponsesOutputItem { + #[serde(rename = "message")] + Message(ResponsesOutputMessage), + #[serde(rename = "reasoning")] + Reasoning(ResponsesOutputReasoning), + #[serde(rename = "function_call")] + FunctionCall(ResponsesOutputFunctionCall), + #[serde(rename = "web_search_call")] + WebSearchCall(ResponsesOutputWebSearchCall), + #[serde(rename = "file_search_call")] + FileSearchCall(ResponsesOutputFileSearchCall), + #[serde(rename = "image_generation_call")] + ImageGenerationCall(ResponsesOutputImageGenerationCall), +} + +#[derive(Debug, Clone, Deserialize)] +pub struct ResponsesOutputMessage { + pub id: String, + pub role: String, + #[serde(rename = "type")] + pub r#type: String, + pub status: ResponsesOutputMessageStatus, + pub content: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ResponsesOutputMessageStatus { + Completed, + Incomplete, + InProgress, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum ResponsesOutputContent { + #[serde(rename = "output_text")] + OutputText { + text: String, + #[serde(default)] + annotations: Vec, + }, + #[serde(rename = "refusal")] + Refusal { refusal: String }, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum ResponsesAnnotation { + FileCitation { + file_id: String, + filename: String, + index: f64, + }, + UrlCitation { + url: String, + title: String, + start_index: f64, + end_index: f64, + }, + FilePath { + file_id: String, + index: f64, + }, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct ResponsesOutputReasoning { + pub id: String, + #[serde(default)] + pub content: Vec, + pub summary: Vec, + #[serde(default)] + pub encrypted_content: Option, + #[serde(default)] + pub status: Option, + #[serde(default)] + pub signature: Option, + #[serde(default)] + pub format: Option, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct ResponsesReasoningTextContent { + pub text: String, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct ResponsesReasoningSummaryText { + pub text: String, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ResponsesReasoningStatus { + Completed, + Incomplete, + InProgress, +} + +#[derive(Debug, Clone, Deserialize)] +pub enum ResponsesReasoningFormat { + #[serde(rename = "unknown")] + Unknown, + #[serde(rename = "openai-responses-v1")] + OpenaiResponsesV1, + #[serde(rename = "azure-openai-responses-v1")] + AzureOpenaiResponsesV1, + #[serde(rename = "xai-responses-v1")] + XaiResponsesV1, + #[serde(rename = "anthropic-claude-v1")] + AnthropicClaudeV1, + #[serde(rename = "google-gemini-v1")] + GoogleGeminiV1, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct ResponsesOutputFunctionCall { + pub id: String, + pub name: String, + pub arguments: String, + pub call_id: String, + #[serde(default)] + pub status: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ResponsesFunctionCallStatus { + Completed, + Incomplete, + InProgress, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct ResponsesOutputWebSearchCall { + pub id: String, + pub status: ResponsesWebSearchStatus, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ResponsesWebSearchStatus { + Completed, + Searching, + InProgress, + Failed, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct ResponsesOutputFileSearchCall { + pub id: String, + pub queries: Vec, + pub status: ResponsesWebSearchStatus, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct ResponsesOutputImageGenerationCall { + pub id: String, + #[serde(default)] + pub result: Option, + pub status: ResponsesImageGenerationStatus, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ResponsesImageGenerationStatus { + InProgress, + Completed, + Generating, + Failed, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct ResponsesError { + pub code: String, + pub message: String, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct ResponsesUsage { + pub input_tokens: f64, + pub input_tokens_details: ResponsesInputTokensDetails, + pub output_tokens: f64, + pub output_tokens_details: ResponsesOutputTokensDetails, + pub total_tokens: f64, + #[serde(default)] + pub cost: Option, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct ResponsesInputTokensDetails { + pub cached_tokens: f64, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct ResponsesOutputTokensDetails { + pub reasoning_tokens: f64, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct ResponsesStreamEvent { + pub event: Option, + pub data: Option, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct ResponsesStreamData { + #[serde(default)] + pub id: Option, + #[serde(default)] + pub r#type: Option, + #[serde(default)] + pub output: Option>, + #[serde(default)] + pub content: Option, + #[serde(default)] + pub delta: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum ResponsesStreamOutputItem { + #[serde(rename = "response.output_text.delta")] + OutputTextDelta { + index: f64, + #[serde(default)] + delta: Option, + }, + #[serde(rename = "response.output_text.done")] + OutputTextDone { index: f64, text: String }, + #[serde(rename = "response.created")] + ResponseCreated { response: ResponsesResponseSummary }, + #[serde(rename = "response.completed")] + ResponseCompleted { response: ResponsesResponse }, + #[serde(rename = "response.output_item.added")] + OutputItemAdded { + output_index: f64, + item: ResponsesStreamItemSummary, + }, + #[serde(rename = "response.output_item.done")] + OutputItemDone { + output_index: f64, + item: ResponsesStreamItemSummary, + }, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct ResponsesResponseSummary { + pub id: String, + pub status: ResponsesStatus, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct ResponsesStreamItemSummary { + pub id: String, + #[serde(rename = "type")] + pub r#type: String, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct ResponsesStreamDelta { + #[serde(default)] + pub content: Option, +} From c16e09b2e34d93a2f1348e0a82f2f4af85f5752b Mon Sep 17 00:00:00 2001 From: Yujong Lee Date: Fri, 13 Feb 2026 14:50:20 +0900 Subject: [PATCH 3/9] wip --- Cargo.lock | 16 +- Cargo.toml | 1 + apps/desktop/src/chat/context-item.ts | 120 ++++++- apps/desktop/src/chat/context/composer.ts | 18 + apps/desktop/src/chat/context/device-info.ts | 28 ++ apps/desktop/src/chat/context/registry.ts | 142 ++++++++ .../desktop/src/chat/context/support-block.ts | 54 +++ apps/desktop/src/chat/mcp-utils.ts | 45 +++ apps/desktop/src/chat/support-mcp-tools.ts | 35 +- apps/desktop/src/chat/transport.ts | 27 +- apps/desktop/src/chat/types.ts | 4 +- apps/desktop/src/chat/utils.ts | 3 + apps/desktop/src/components/chat/content.tsx | 74 ++++ .../src/components/chat/context-bar.tsx | 139 +++++-- apps/desktop/src/components/chat/input.tsx | 249 ++++++++----- .../src/components/chat/message/normal.tsx | 19 +- .../src/components/chat/message/shared.tsx | 6 + .../components/chat/message/tool/generic.tsx | 37 +- .../components/chat/message/tool/search.tsx | 45 ++- apps/desktop/src/components/chat/session.tsx | 338 +++++++++++------- apps/desktop/src/components/chat/view.tsx | 87 +---- .../components/main/body/chat/tab-content.tsx | 227 +++++++----- .../note-input/enhanced/enhance-error.tsx | 49 +++ .../sessions/note-input/enhanced/index.tsx | 11 +- .../main/body/sessions/note-input/header.tsx | 24 -- apps/desktop/src/contexts/shell/chat.ts | 5 +- .../src/contexts/tool-registry/core.ts | 60 +--- apps/desktop/src/hooks/autoEnhance/runner.ts | 34 -- .../desktop/src/hooks/useContextCollection.ts | 149 -------- apps/desktop/src/hooks/useSupportMCPTools.ts | 36 +- .../desktop/src/store/zustand/chat-context.ts | 58 +++ crates/api-support/Cargo.toml | 2 +- crates/api-support/src/github.rs | 76 ++-- crates/api-support/src/mcp/prompts.rs | 13 + crates/api-support/src/mcp/prompts/mod.rs | 3 - .../src/mcp/prompts/support_chat.rs | 10 - crates/api-support/src/routes/feedback.rs | 11 +- crates/template-support/Cargo.toml | 10 + .../askama.toml | 0 .../assets/_device_info.md.jinja | 0 .../assets/bug_report.md.jinja | 0 .../assets/feature_request.md.jinja | 0 .../assets/log_analysis.md.jinja | 0 .../assets/support_chat.md.jinja | 0 crates/template-support/src/lib.rs | 99 +++++ plugins/auth/Cargo.toml | 2 + plugins/auth/js/bindings.gen.ts | 9 + .../commands/get_account_info.toml | 13 + .../permissions/autogenerated/reference.md | 27 ++ plugins/auth/permissions/default.toml | 2 +- plugins/auth/permissions/schemas/schema.json | 16 +- plugins/auth/src/commands.rs | 8 + plugins/auth/src/ext.rs | 56 +++ plugins/auth/src/lib.rs | 24 ++ plugins/misc/Cargo.toml | 2 + plugins/misc/build.rs | 1 + plugins/misc/js/bindings.gen.ts | 188 +++++----- .../commands/get_device_info.toml | 13 + .../permissions/autogenerated/reference.md | 28 ++ plugins/misc/permissions/default.toml | 1 + plugins/misc/permissions/schemas/schema.json | 16 +- plugins/misc/src/commands.rs | 9 + plugins/misc/src/ext.rs | 13 + plugins/misc/src/lib.rs | 1 + 64 files changed, 1839 insertions(+), 954 deletions(-) create mode 100644 apps/desktop/src/chat/context/composer.ts create mode 100644 apps/desktop/src/chat/context/device-info.ts create mode 100644 apps/desktop/src/chat/context/registry.ts create mode 100644 apps/desktop/src/chat/context/support-block.ts create mode 100644 apps/desktop/src/chat/mcp-utils.ts create mode 100644 apps/desktop/src/chat/utils.ts create mode 100644 apps/desktop/src/components/chat/content.tsx create mode 100644 apps/desktop/src/components/main/body/sessions/note-input/enhanced/enhance-error.tsx delete mode 100644 apps/desktop/src/hooks/useContextCollection.ts create mode 100644 apps/desktop/src/store/zustand/chat-context.ts create mode 100644 crates/api-support/src/mcp/prompts.rs delete mode 100644 crates/api-support/src/mcp/prompts/mod.rs delete mode 100644 crates/api-support/src/mcp/prompts/support_chat.rs create mode 100644 crates/template-support/Cargo.toml rename crates/{api-support => template-support}/askama.toml (100%) rename crates/{api-support => template-support}/assets/_device_info.md.jinja (100%) rename crates/{api-support => template-support}/assets/bug_report.md.jinja (100%) rename crates/{api-support => template-support}/assets/feature_request.md.jinja (100%) rename crates/{api-support => template-support}/assets/log_analysis.md.jinja (100%) rename crates/{api-support => template-support}/assets/support_chat.md.jinja (100%) create mode 100644 crates/template-support/src/lib.rs create mode 100644 plugins/auth/permissions/autogenerated/commands/get_account_info.toml create mode 100644 plugins/misc/permissions/autogenerated/commands/get_device_info.toml diff --git a/Cargo.lock b/Cargo.lock index a09a8e56e3..b390ed6f26 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -640,7 +640,6 @@ version = "0.1.0" dependencies = [ "api-auth", "api-env", - "askama", "async-stripe", "async-stripe-billing", "axum 0.8.8", @@ -655,6 +654,7 @@ dependencies = [ "serde", "serde_json", "sqlx", + "template-support", "thiserror 2.0.18", "tokio", "tokio-util", @@ -18396,6 +18396,7 @@ dependencies = [ name = "tauri-plugin-auth" version = "0.1.0" dependencies = [ + "dirs 6.0.0", "serde", "serde_json", "specta", @@ -18407,6 +18408,7 @@ dependencies = [ "tauri-plugin-store", "tauri-plugin-store2", "tauri-specta", + "template-support", "thiserror 2.0.18", "tokio", ] @@ -19026,9 +19028,11 @@ dependencies = [ "regex", "specta", "specta-typescript", + "sysinfo", "tauri", "tauri-plugin", "tauri-specta", + "template-support", "vergen-gix", ] @@ -19786,6 +19790,16 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "template-support" +version = "0.1.0" +dependencies = [ + "askama", + "serde", + "specta", + "utoipa", +] + [[package]] name = "ten-vad-rs" version = "0.1.4" diff --git a/Cargo.toml b/Cargo.toml index 413862fbdc..6df8f960d4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -98,6 +98,7 @@ hypr-tcc = { path = "crates/tcc", package = "tcc" } hypr-template-app = { path = "crates/template-app", package = "template-app" } hypr-template-app-legacy = { path = "crates/template-app-legacy", package = "template-app-legacy" } hypr-template-eval = { path = "crates/template-eval", package = "template-eval" } +hypr-template-support = { path = "crates/template-support", package = "template-support" } hypr-tiptap = { path = "crates/tiptap", package = "tiptap" } hypr-transcribe-aws = { path = "crates/transcribe-aws", package = "transcribe-aws" } hypr-transcribe-azure = { path = "crates/transcribe-azure", package = "transcribe-azure" } diff --git a/apps/desktop/src/chat/context-item.ts b/apps/desktop/src/chat/context-item.ts index 1581039d37..76cd5dda6e 100644 --- a/apps/desktop/src/chat/context-item.ts +++ b/apps/desktop/src/chat/context-item.ts @@ -1,12 +1,112 @@ -export type ContextItem = { - key: string; - label: string; - tooltip: string; +import type { AccountInfo } from "@hypr/plugin-auth"; +import type { DeviceInfo } from "@hypr/plugin-misc"; +import type { ChatContext } from "@hypr/plugin-template"; + +import type { HyprUIMessage } from "./types"; +import { isRecord } from "./utils"; + +export type ContextEntity = + | { + kind: "session"; + key: string; + chatContext: ChatContext; + wordCount?: number; + rawNotePreview?: string; + participantCount?: number; + eventTitle?: string; + removable?: boolean; + } + | ({ kind: "account"; key: string } & Partial) + | ({ + kind: "device"; + key: string; + } & Partial); + +export type ContextEntityKind = ContextEntity["kind"]; + +type ToolOutputAvailablePart = { + type: string; + state: "output-available"; + output?: unknown; +}; + +function isToolOutputAvailablePart( + value: unknown, +): value is ToolOutputAvailablePart { + return ( + isRecord(value) && + typeof value.type === "string" && + value.state === "output-available" + ); +} + +function parseSearchSessionsOutput(output: unknown): ContextEntity[] { + if (!isRecord(output) || !Array.isArray(output.results)) { + return []; + } + + return output.results.flatMap((item): ContextEntity[] => { + if (!isRecord(item)) { + return []; + } + + if (typeof item.id !== "string" && typeof item.id !== "number") { + return []; + } + + const title = typeof item.title === "string" ? item.title : null; + const content = typeof item.content === "string" ? item.content : null; + + return [ + { + kind: "session", + key: `session:search:${item.id}`, + chatContext: { + title, + date: null, + rawContent: content, + enhancedContent: null, + transcript: null, + }, + rawNotePreview: content?.slice(0, 120) ?? undefined, + removable: true, + }, + ]; + }); +} + +const toolEntityExtractors: Record< + string, + (output: unknown) => ContextEntity[] +> = { + search_sessions: parseSearchSessionsOutput, }; -export type ContextSource = - | { type: "account"; email?: string; userId?: string } - | { type: "device" } - | { type: "session"; title?: string; date?: string } - | { type: "transcript"; wordCount?: number } - | { type: "note"; preview?: string }; +export function extractToolContextEntities( + messages: Array>, +): ContextEntity[] { + const seen = new Set(); + const entities: ContextEntity[] = []; + + for (const message of messages) { + if (!Array.isArray(message.parts)) continue; + for (const part of message.parts) { + if (!isToolOutputAvailablePart(part) || !part.type.startsWith("tool-")) { + continue; + } + + const toolName = part.type.slice(5); + const extractor = toolEntityExtractors[toolName]; + if (!extractor) continue; + + for (const entity of extractor(part.output)) { + if (!seen.has(entity.key)) { + seen.add(entity.key); + entities.push(entity); + } + } + } + } + + return entities; +} diff --git a/apps/desktop/src/chat/context/composer.ts b/apps/desktop/src/chat/context/composer.ts new file mode 100644 index 0000000000..1e2871d2b6 --- /dev/null +++ b/apps/desktop/src/chat/context/composer.ts @@ -0,0 +1,18 @@ +import type { ContextEntity } from "../context-item"; + +export function composeContextEntities( + groups: ContextEntity[][], +): ContextEntity[] { + const seen = new Set(); + const merged: ContextEntity[] = []; + + for (const group of groups) { + for (const entity of group) { + if (seen.has(entity.key)) continue; + seen.add(entity.key); + merged.push(entity); + } + } + + return merged; +} diff --git a/apps/desktop/src/chat/context/device-info.ts b/apps/desktop/src/chat/context/device-info.ts new file mode 100644 index 0000000000..4e23f7d7b8 --- /dev/null +++ b/apps/desktop/src/chat/context/device-info.ts @@ -0,0 +1,28 @@ +import { commands as miscCommands } from "@hypr/plugin-misc"; + +import type { ContextEntity } from "../context-item"; + +export async function collectDeviceEntity(): Promise< + Extract +> { + let deviceContext: Extract = { + kind: "device", + key: "support:device", + }; + + try { + const deviceContextResult = await miscCommands.getDeviceInfo( + navigator.language || "en", + ); + if (deviceContextResult.status === "ok") { + deviceContext = { + ...deviceContext, + ...deviceContextResult.data, + }; + } + } catch (error) { + console.error("Failed to collect device context:", error); + } + + return deviceContext; +} diff --git a/apps/desktop/src/chat/context/registry.ts b/apps/desktop/src/chat/context/registry.ts new file mode 100644 index 0000000000..95eb2c04d1 --- /dev/null +++ b/apps/desktop/src/chat/context/registry.ts @@ -0,0 +1,142 @@ +import { CalendarIcon, MonitorIcon, UserIcon } from "lucide-react"; + +import type { ChatContext } from "@hypr/plugin-template"; + +import type { ContextEntity, ContextEntityKind } from "../context-item"; + +export type ContextChipProps = { + key: string; + icon: React.ComponentType<{ className?: string }>; + label: string; + tooltip: string; + removable?: boolean; +}; + +type EntityRenderer = { + toChip: (entity: E) => ContextChipProps | null; + toPromptBlock: (entity: E) => string | null; + toTemplateContext: (entity: E) => ChatContext | null; +}; + +type ExtractEntity = Extract< + ContextEntity, + { kind: K } +>; + +type RendererMap = { + [K in ContextEntityKind]: EntityRenderer>; +}; + +const renderers: RendererMap = { + session: { + toChip: (entity) => { + const { chatContext } = entity; + if ( + !chatContext.title && + !chatContext.date && + !entity.wordCount && + !entity.rawNotePreview && + entity.participantCount === undefined && + !entity.eventTitle + ) { + return null; + } + const lines: string[] = []; + if (chatContext.title) lines.push(chatContext.title); + if (chatContext.date) lines.push(chatContext.date); + if (entity.wordCount && entity.wordCount > 0) { + lines.push(`Transcript: ${entity.wordCount.toLocaleString()} words`); + } + if (entity.participantCount !== undefined) { + lines.push(`Participants: ${entity.participantCount}`); + } + if (entity.eventTitle) { + lines.push(`Event: ${entity.eventTitle}`); + } + if (entity.rawNotePreview) { + const truncated = + entity.rawNotePreview.length > 120 + ? `${entity.rawNotePreview.slice(0, 120)}...` + : entity.rawNotePreview; + lines.push(`Raw note: ${truncated}`); + } + return { + key: entity.key, + icon: CalendarIcon, + label: chatContext.title || "Session", + tooltip: lines.join("\n"), + removable: entity.removable, + }; + }, + toPromptBlock: () => null, + toTemplateContext: (entity) => entity.chatContext, + }, + + account: { + toChip: (entity) => { + if (!entity.email && !entity.userId) return null; + const lines: string[] = []; + if (entity.email) lines.push(entity.email); + if (entity.userId) lines.push(`ID: ${entity.userId}`); + return { + key: entity.key, + icon: UserIcon, + label: "Account", + tooltip: lines.join("\n"), + }; + }, + toPromptBlock: (entity) => { + const lines: string[] = []; + if (entity.email) lines.push(`- Email: ${entity.email}`); + if (entity.userId) lines.push(`- User ID: ${entity.userId}`); + return lines.length > 0 ? lines.join("\n") : null; + }, + toTemplateContext: () => null, + }, + + device: { + toChip: (entity) => { + const lines: string[] = []; + if (entity.platform) lines.push(`Platform: ${entity.platform}`); + if (entity.arch) lines.push(`Architecture: ${entity.arch}`); + if (entity.osVersion) lines.push(`OS Version: ${entity.osVersion}`); + if (entity.appVersion) lines.push(`App: ${entity.appVersion}`); + if (entity.buildHash) lines.push(`Build: ${entity.buildHash}`); + if (entity.locale) lines.push(`Locale: ${entity.locale}`); + return { + key: entity.key, + icon: MonitorIcon, + label: "Device", + tooltip: lines.join("\n"), + }; + }, + toPromptBlock: (entity) => { + const lines: string[] = []; + if (entity.platform) lines.push(`- Platform: ${entity.platform}`); + if (entity.arch) lines.push(`- Architecture: ${entity.arch}`); + if (entity.osVersion) lines.push(`- OS Version: ${entity.osVersion}`); + if (entity.appVersion) lines.push(`- App: ${entity.appVersion}`); + if (entity.buildHash) lines.push(`- Build: ${entity.buildHash}`); + if (entity.locale) lines.push(`- Locale: ${entity.locale}`); + return lines.length > 0 ? lines.join("\n") : null; + }, + toTemplateContext: () => null, + }, +} satisfies RendererMap; + +export function renderChip(entity: ContextEntity): ContextChipProps | null { + const renderer = renderers[entity.kind] as EntityRenderer; + return renderer.toChip(entity); +} + +export function renderPromptBlock(entity: ContextEntity): string | null { + const renderer = renderers[entity.kind] as EntityRenderer; + return renderer.toPromptBlock(entity); +} + +export function renderTemplateContext( + entity: ContextEntity, +): ChatContext | null { + const renderer = renderers[entity.kind] as EntityRenderer; + return renderer.toTemplateContext(entity); +} diff --git a/apps/desktop/src/chat/context/support-block.ts b/apps/desktop/src/chat/context/support-block.ts new file mode 100644 index 0000000000..7443b96231 --- /dev/null +++ b/apps/desktop/src/chat/context/support-block.ts @@ -0,0 +1,54 @@ +import { commands as authCommands } from "@hypr/plugin-auth"; + +import type { ContextEntity } from "../context-item"; +import { collectDeviceEntity } from "./device-info"; +import { renderPromptBlock } from "./registry"; + +async function collectAccountEntity(): Promise | null> { + try { + const result = await authCommands.getAccountInfo(); + if (result.status === "ok" && result.data) { + return { + kind: "account", + key: "support:account", + ...result.data, + }; + } + } catch (error) { + console.error("Failed to collect account info:", error); + } + return null; +} + +export async function collectSupportContextBlock(): Promise<{ + entities: ContextEntity[]; + block: string | null; +}> { + const entities: ContextEntity[] = []; + + const accountEntity = await collectAccountEntity(); + if (accountEntity) { + entities.push(accountEntity); + } + + const deviceEntity = await collectDeviceEntity(); + entities.push(deviceEntity); + + const blockLines = entities + .map(renderPromptBlock) + .filter((line): line is string => line !== null); + + if (blockLines.length === 0) { + return { entities, block: null }; + } + + return { + entities, + block: + "---\nThe following is automatically collected context about the current user and their environment. Use it when filing issues or diagnosing problems.\n\n" + + blockLines.join("\n"), + }; +} diff --git a/apps/desktop/src/chat/mcp-utils.ts b/apps/desktop/src/chat/mcp-utils.ts new file mode 100644 index 0000000000..a1a2506d3d --- /dev/null +++ b/apps/desktop/src/chat/mcp-utils.ts @@ -0,0 +1,45 @@ +import { isRecord } from "./utils"; + +export type McpTextContentOutput = { + content: Array<{ + type: string; + text?: string; + }>; +}; + +export function extractMcpOutputText(output: unknown): string | null { + if (!isRecord(output) || !Array.isArray(output.content)) { + return null; + } + + const text = output.content + .filter( + (item): item is { type: string; text: string } => + isRecord(item) && item.type === "text" && typeof item.text === "string", + ) + .map((item) => item.text) + .join("\n"); + + return text || null; +} + +export function readMcpJsonText(output: unknown): unknown { + const text = extractMcpOutputText(output); + if (!text) { + return null; + } + + try { + return JSON.parse(text); + } catch { + return null; + } +} + +export function parseMcpToolOutput( + output: unknown, + guard: (value: unknown) => value is T, +): T | null { + const value = readMcpJsonText(output); + return guard(value) ? value : null; +} diff --git a/apps/desktop/src/chat/support-mcp-tools.ts b/apps/desktop/src/chat/support-mcp-tools.ts index 1cd5df775e..8eb24d7202 100644 --- a/apps/desktop/src/chat/support-mcp-tools.ts +++ b/apps/desktop/src/chat/support-mcp-tools.ts @@ -12,6 +12,8 @@ import type { SubscriptionItem, } from "@hypr/plugin-mcp"; +import { isRecord } from "./utils"; + export type McpTextContentOutput = { content: Array<{ type: string; @@ -33,16 +35,12 @@ export type SupportMcpTools = { }; }; -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null; -} - -function readJsonText(output: unknown): unknown { +export function extractMcpOutputText(output: unknown): string | null { if (!isRecord(output) || !Array.isArray(output.content)) { return null; } - const texts = output.content + const text = output.content .filter( (item): item is { type: string; text: string } => isRecord(item) && item.type === "text" && typeof item.text === "string", @@ -50,33 +48,22 @@ function readJsonText(output: unknown): unknown { .map((item) => item.text) .join("\n"); - if (!texts) { + return text || null; +} + +function readJsonText(output: unknown): unknown { + const text = extractMcpOutputText(output); + if (!text) { return null; } try { - return JSON.parse(texts); + return JSON.parse(text); } catch { return null; } } -export function extractMcpOutputText(output: unknown): string | null { - if (!isRecord(output) || !Array.isArray(output.content)) { - return null; - } - - const text = output.content - .filter( - (item): item is { type: string; text: string } => - isRecord(item) && item.type === "text" && typeof item.text === "string", - ) - .map((item) => item.text) - .join("\n"); - - return text || null; -} - function isSearchIssueItem(value: unknown): value is SearchIssueItem { return ( isRecord(value) && diff --git a/apps/desktop/src/chat/transport.ts b/apps/desktop/src/chat/transport.ts index ce2704cd5b..9ed91c4b90 100644 --- a/apps/desktop/src/chat/transport.ts +++ b/apps/desktop/src/chat/transport.ts @@ -4,10 +4,11 @@ import { type LanguageModel, stepCountIs, ToolLoopAgent, + type ToolSet, } from "ai"; -import { type ToolRegistry } from "../contexts/tool"; import type { HyprUIMessage } from "./types"; +import { isRecord } from "./utils"; const MAX_TOOL_STEPS = 5; const MESSAGE_WINDOW_THRESHOLD = 20; @@ -15,26 +16,18 @@ const MESSAGE_WINDOW_SIZE = 10; export class CustomChatTransport implements ChatTransport { constructor( - private registry: ToolRegistry, private model: LanguageModel, - private chatType: "general" | "support", + private tools: ToolSet, private systemPrompt?: string, - private extraTools?: Record, ) {} sendMessages: ChatTransport["sendMessages"] = async ( options, ) => { - const scope = this.chatType === "support" ? "chat-support" : "chat-general"; - const tools = { - ...this.registry.getTools(scope), - ...this.extraTools, - }; - const agent = new ToolLoopAgent({ model: this.model, instructions: this.systemPrompt, - tools, + tools: this.tools, stopWhen: stepCountIs(MAX_TOOL_STEPS), prepareStep: async ({ messages }) => { if (messages.length > MESSAGE_WINDOW_THRESHOLD) { @@ -58,7 +51,17 @@ export class CustomChatTransport implements ChatTransport { }, onError: (error: unknown) => { console.error(error); - return error instanceof Error ? error.message : String(error); + if (error instanceof Error) { + return `${error.name}: ${error.message}`; + } + if (isRecord(error) && typeof error.message === "string") { + return error.message; + } + try { + return JSON.stringify(error); + } catch { + return String(error); + } }, }); }; diff --git a/apps/desktop/src/chat/types.ts b/apps/desktop/src/chat/types.ts index f8db872586..69dc9eb872 100644 --- a/apps/desktop/src/chat/types.ts +++ b/apps/desktop/src/chat/types.ts @@ -1,9 +1,9 @@ import type { UIMessage } from "ai"; import { z } from "zod"; -export const messageMetadataSchema = z.object({ +const messageMetadataSchema = z.object({ createdAt: z.number().optional(), }); -export type MessageMetadata = z.infer; +type MessageMetadata = z.infer; export type HyprUIMessage = UIMessage; diff --git a/apps/desktop/src/chat/utils.ts b/apps/desktop/src/chat/utils.ts new file mode 100644 index 0000000000..65324b0049 --- /dev/null +++ b/apps/desktop/src/chat/utils.ts @@ -0,0 +1,3 @@ +export function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} diff --git a/apps/desktop/src/components/chat/content.tsx b/apps/desktop/src/components/chat/content.tsx new file mode 100644 index 0000000000..70fb431c15 --- /dev/null +++ b/apps/desktop/src/components/chat/content.tsx @@ -0,0 +1,74 @@ +import type { ChatStatus } from "ai"; + +import type { ContextEntity } from "../../chat/context-item"; +import type { HyprUIMessage } from "../../chat/types"; +import type { useLanguageModel } from "../../hooks/useLLMConnection"; +import { ChatBody } from "./body"; +import { ContextBar } from "./context-bar"; +import { ChatMessageInput } from "./input"; + +export function ChatContent({ + sessionId, + messages, + sendMessage, + regenerate, + stop, + status, + error, + model, + handleSendMessage, + contextEntities, + onRemoveContextEntity, + isSystemPromptReady, + children, +}: { + sessionId: string; + messages: HyprUIMessage[]; + sendMessage: (message: HyprUIMessage) => void; + regenerate: () => void; + stop: () => void; + status: ChatStatus; + error?: Error; + model: ReturnType; + handleSendMessage: ( + content: string, + parts: HyprUIMessage["parts"], + sendMessage: (message: HyprUIMessage) => void, + ) => void; + contextEntities: ContextEntity[]; + onRemoveContextEntity?: (key: string) => void; + isSystemPromptReady: boolean; + children?: React.ReactNode; +}) { + const disabled = + !model || + status !== "ready" || + (status === "ready" && !isSystemPromptReady); + + return ( + <> + {children ?? ( + + )} + + + handleSendMessage(content, parts, sendMessage) + } + isStreaming={status === "streaming" || status === "submitted"} + onStop={stop} + /> + + ); +} diff --git a/apps/desktop/src/components/chat/context-bar.tsx b/apps/desktop/src/components/chat/context-bar.tsx index aafa1d2dba..ac2b2e2a2e 100644 --- a/apps/desktop/src/components/chat/context-bar.tsx +++ b/apps/desktop/src/components/chat/context-bar.tsx @@ -1,54 +1,83 @@ -import { useEffect, useRef, useState } from "react"; +import { ChevronUpIcon, XIcon } from "lucide-react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { Tooltip, TooltipContent, TooltipTrigger, } from "@hypr/ui/components/ui/tooltip"; +import { cn } from "@hypr/utils"; -import type { ContextItem } from "../../chat/context-item"; +import type { ContextEntity } from "../../chat/context-item"; +import { type ContextChipProps, renderChip } from "../../chat/context/registry"; + +function ContextChip({ + chip, + onRemove, +}: { + chip: ContextChipProps; + onRemove?: (key: string) => void; +}) { + const Icon = chip.icon; -function ContextChip({ item }: { item: ContextItem }) { return ( - - {item.label} + + {Icon && } + {chip.label} + {chip.removable && onRemove && ( + + )} - - {item.tooltip} + + {chip.tooltip} ); } -function OverflowChip({ items }: { items: ContextItem[] }) { - const label = items.map((i) => i.label).join(", "); - - return ( - - - - +{items.length} - - - {label} - +export function ContextBar({ + entities, + onRemoveEntity, +}: { + entities: ContextEntity[]; + onRemoveEntity?: (key: string) => void; +}) { + const chips = useMemo( + () => + entities.map(renderChip).filter((c): c is ContextChipProps => c !== null), + [entities], ); -} -export function ContextBar({ items }: { items: ContextItem[] }) { const innerRef = useRef(null); - const [visibleCount, setVisibleCount] = useState(items.length); + const [visibleCount, setVisibleCount] = useState(chips.length); + const [expanded, setExpanded] = useState(false); useEffect(() => { - setVisibleCount(items.length); - }, [items.length]); + setVisibleCount(chips.length); + }, [chips.length]); useEffect(() => { + if (expanded) return; + const inner = innerRef.current; - if (!inner || items.length === 0) return; + if (!inner || chips.length === 0) return; const measure = () => { const children = Array.from(inner.children) as HTMLElement[]; @@ -56,17 +85,17 @@ export function ContextBar({ items }: { items: ContextItem[] }) { const containerRight = inner.getBoundingClientRect().right; const gap = 6; - const overflowChipWidth = 40; + const expandButtonWidth = 28; let count = 0; for (let i = 0; i < children.length; i++) { const child = children[i]; const childRight = child.getBoundingClientRect().right; - if (i < items.length) { - const needsOverflow = i < items.length - 1; + if (i < chips.length) { + const needsOverflow = i < chips.length - 1; const threshold = needsOverflow - ? containerRight - overflowChipWidth - gap + ? containerRight - expandButtonWidth - gap : containerRight; if (childRight <= threshold) { @@ -77,7 +106,7 @@ export function ContextBar({ items }: { items: ContextItem[] }) { } } - if (count < items.length && count === 0) { + if (count < chips.length && count === 0) { count = 1; } @@ -89,23 +118,57 @@ export function ContextBar({ items }: { items: ContextItem[] }) { measure(); return () => observer.disconnect(); - }, [items]); + }, [chips, expanded]); + + useEffect(() => { + setExpanded(false); + }, [chips.length]); - if (items.length === 0) return null; + if (chips.length === 0) return null; - const visible = items.slice(0, visibleCount); - const overflow = items.slice(visibleCount); + const hasOverflow = visibleCount < chips.length; + const displayChips = expanded ? chips : chips.slice(0, visibleCount); return ( -
+
+ {expanded && ( +
+
+ {chips.slice(visibleCount).map((chip) => ( + + ))} +
+
+ )}
- {visible.map((item) => ( - + {displayChips.map((chip) => ( + ))} - {overflow.length > 0 && } + {hasOverflow && ( + + )}
); diff --git a/apps/desktop/src/components/chat/input.tsx b/apps/desktop/src/components/chat/input.tsx index 43ac678e34..bb8911463e 100644 --- a/apps/desktop/src/components/chat/input.tsx +++ b/apps/desktop/src/components/chat/input.tsx @@ -18,124 +18,48 @@ import { cn } from "@hypr/utils"; import { useShell } from "../../contexts/shell"; import * as main from "../../store/tinybase/store/main"; -let _draft: JSONContent | undefined; +const draftsByKey = new Map(); export function ChatMessageInput({ + draftKey, onSendMessage, disabled: disabledProp, - attachedSession, isStreaming, onStop, }: { + draftKey: string; onSendMessage: ( content: string, parts: Array<{ type: "text"; text: string }>, ) => void; disabled?: boolean | { disabled: boolean; message?: string }; - attachedSession?: { id: string; title?: string }; isStreaming?: boolean; onStop?: () => void; }) { const editorRef = useRef<{ editor: TiptapEditor | null }>(null); - const [hasContent, setHasContent] = useState(false); - const initialContent = useRef(_draft ?? EMPTY_TIPTAP_DOC); - const chatShortcuts = main.UI.useResultTable( - main.QUERIES.visibleChatShortcuts, - main.STORE_ID, - ); - const sessions = main.UI.useResultTable( - main.QUERIES.timelineSessions, - main.STORE_ID, - ); - const disabled = typeof disabledProp === "object" ? disabledProp.disabled : disabledProp; - const handleSubmit = useCallback(() => { - const json = editorRef.current?.editor?.getJSON(); - const text = tiptapJsonToText(json).trim(); - - if (!text || disabled) { - return; - } - - void analyticsCommands.event({ event: "message_sent" }); - onSendMessage(text, [{ type: "text", text }]); - editorRef.current?.editor?.commands.clearContent(); - _draft = undefined; - }, [disabled, onSendMessage]); - - useEffect(() => { - const editor = editorRef.current?.editor; - if (!editor || editor.isDestroyed || !editor.isInitialized) { - return; - } - - if (!disabled) { - editor.commands.focus(); - } - }, [disabled]); - - const handleEditorUpdate = useCallback((json: JSONContent) => { - const text = tiptapJsonToText(json).trim(); - setHasContent(text.length > 0); - _draft = json; - }, []); - - const slashCommandConfig: SlashCommandConfig = useMemo( - () => ({ - handleSearch: async (query: string) => { - const results: { - id: string; - type: string; - label: string; - content?: string; - }[] = []; - const lowerQuery = query.toLowerCase(); - - Object.entries(chatShortcuts).forEach(([rowId, row]) => { - const title = row.title as string | undefined; - const content = row.content as string | undefined; - if (title && content && title.toLowerCase().includes(lowerQuery)) { - results.push({ - id: rowId, - type: "chat_shortcut", - label: title, - content, - }); - } - }); - - Object.entries(sessions).forEach(([rowId, row]) => { - const title = row.title as string | undefined; - if (title && title.toLowerCase().includes(lowerQuery)) { - results.push({ - id: rowId, - type: "session", - label: title, - }); - } - }); - - return results.slice(0, 5); - }, - }), - [chatShortcuts, sessions], - ); + const { hasContent, initialContent, handleEditorUpdate } = useDraftState({ + draftKey, + }); + const handleSubmit = useSubmit({ + draftKey, + editorRef, + disabled, + onSendMessage, + }); + useAutoFocusEditor({ editorRef, disabled }); + const slashCommandConfig = useSlashCommandConfig(); return ( - {attachedSession && ( -
- Attached: {attachedSession.title || "Untitled"} -
- )}
{ return ""; }; +function useDraftState({ draftKey }: { draftKey: string }) { + const [hasContent, setHasContent] = useState(false); + const initialContent = useRef(draftsByKey.get(draftKey) ?? EMPTY_TIPTAP_DOC); + + const handleEditorUpdate = useCallback( + (json: JSONContent) => { + const text = tiptapJsonToText(json).trim(); + setHasContent(text.length > 0); + draftsByKey.set(draftKey, json); + }, + [draftKey], + ); + + return { + hasContent, + initialContent: initialContent.current, + handleEditorUpdate, + }; +} + +function useSubmit({ + draftKey, + editorRef, + disabled, + onSendMessage, +}: { + draftKey: string; + editorRef: React.RefObject<{ editor: TiptapEditor | null } | null>; + disabled?: boolean; + onSendMessage: ( + content: string, + parts: Array<{ type: "text"; text: string }>, + ) => void; +}) { + return useCallback(() => { + const json = editorRef.current?.editor?.getJSON(); + const text = tiptapJsonToText(json).trim(); + + if (!text || disabled) { + return; + } + + void analyticsCommands.event({ event: "message_sent" }); + onSendMessage(text, [{ type: "text", text }]); + editorRef.current?.editor?.commands.clearContent(); + draftsByKey.delete(draftKey); + }, [draftKey, editorRef, disabled, onSendMessage]); +} + +function useAutoFocusEditor({ + editorRef, + disabled, +}: { + editorRef: React.RefObject<{ editor: TiptapEditor | null } | null>; + disabled?: boolean; +}) { + useEffect(() => { + if (disabled) { + return; + } + + let rafId: number | null = null; + let attempts = 0; + const maxAttempts = 20; + + const focusWhenReady = () => { + const editor = editorRef.current?.editor; + + if (editor && !editor.isDestroyed && editor.isInitialized) { + editor.commands.focus(); + return; + } + + if (attempts >= maxAttempts) { + return; + } + + attempts += 1; + rafId = window.requestAnimationFrame(focusWhenReady); + }; + + focusWhenReady(); + + return () => { + if (rafId !== null) { + window.cancelAnimationFrame(rafId); + } + }; + }, [editorRef, disabled]); +} + +function useSlashCommandConfig(): SlashCommandConfig { + const chatShortcuts = main.UI.useResultTable( + main.QUERIES.visibleChatShortcuts, + main.STORE_ID, + ); + const sessions = main.UI.useResultTable( + main.QUERIES.timelineSessions, + main.STORE_ID, + ); + + return useMemo( + () => ({ + handleSearch: async (query: string) => { + const results: { + id: string; + type: string; + label: string; + content?: string; + }[] = []; + const lowerQuery = query.toLowerCase(); + + Object.entries(chatShortcuts).forEach(([rowId, row]) => { + const title = row.title as string | undefined; + const content = row.content as string | undefined; + if (title && content && title.toLowerCase().includes(lowerQuery)) { + results.push({ + id: rowId, + type: "chat_shortcut", + label: title, + content, + }); + } + }); + + Object.entries(sessions).forEach(([rowId, row]) => { + const title = row.title as string | undefined; + if (title && title.toLowerCase().includes(lowerQuery)) { + results.push({ + id: rowId, + type: "session", + label: title, + }); + } + }); + + return results.slice(0, 5); + }, + }), + [chatShortcuts, sessions], + ); +} + function tiptapJsonToText(json: any): string { if (!json || typeof json !== "object") { return ""; diff --git a/apps/desktop/src/components/chat/message/normal.tsx b/apps/desktop/src/components/chat/message/normal.tsx index 5d1edf546d..75f737e184 100644 --- a/apps/desktop/src/components/chat/message/normal.tsx +++ b/apps/desktop/src/components/chat/message/normal.tsx @@ -1,5 +1,5 @@ import { BrainIcon, CheckIcon, CopyIcon, RotateCcwIcon } from "lucide-react"; -import { useCallback, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { Streamdown } from "streamdown"; import type { HyprUIMessage } from "../../../chat/types"; @@ -26,13 +26,28 @@ export function NormalMessage({ }) { const isUser = message.role === "user"; const [copied, setCopied] = useState(false); + const copiedResetTimeoutRef = useRef(null); + + useEffect(() => { + return () => { + if (copiedResetTimeoutRef.current !== null) { + window.clearTimeout(copiedResetTimeoutRef.current); + } + }; + }, []); const handleCopy = useCallback(async () => { const text = getMessageText(message); try { await navigator.clipboard.writeText(text); + if (copiedResetTimeoutRef.current !== null) { + window.clearTimeout(copiedResetTimeoutRef.current); + } setCopied(true); - setTimeout(() => setCopied(false), 2000); + copiedResetTimeoutRef.current = window.setTimeout(() => { + setCopied(false); + copiedResetTimeoutRef.current = null; + }, 2000); } catch { // ignore } diff --git a/apps/desktop/src/components/chat/message/shared.tsx b/apps/desktop/src/components/chat/message/shared.tsx index 9326b6307f..37c546924a 100644 --- a/apps/desktop/src/components/chat/message/shared.tsx +++ b/apps/desktop/src/components/chat/message/shared.tsx @@ -103,11 +103,17 @@ export function Disclosure({ ])} > { + if (disabled) { + event.preventDefault(); + } + }} className={cn([ "w-full", "text-xs text-neutral-500", "select-none list-none marker:hidden", "flex items-center gap-2", + disabled && "cursor-default", ])} > {disabled ? : null} diff --git a/apps/desktop/src/components/chat/message/tool/generic.tsx b/apps/desktop/src/components/chat/message/tool/generic.tsx index f43ddc0e89..14ae583fda 100644 --- a/apps/desktop/src/components/chat/message/tool/generic.tsx +++ b/apps/desktop/src/components/chat/message/tool/generic.tsx @@ -1,5 +1,6 @@ import { WrenchIcon } from "lucide-react"; +import { extractMcpOutputText } from "../../../../chat/support-mcp-tools"; import { Disclosure } from "../shared"; import { ToolCard, @@ -14,22 +15,25 @@ function formatToolName(name: string): string { return name.replace(/_/g, " ").replace(/^\w/, (c) => c.toUpperCase()); } -function extractOutputText(output: unknown): string | null { - if (!output || typeof output !== "object") return null; - const obj = output as Record; - if (Array.isArray(obj.content)) { - const texts = obj.content - .filter( - (c: unknown) => - typeof c === "object" && - c !== null && - (c as Record).type === "text" && - (c as Record).text, - ) - .map((c: unknown) => (c as { text: string }).text); - if (texts.length > 0) return texts.join("\n"); +function formatOutputText(output: unknown): string | null { + const mcpText = extractMcpOutputText(output); + if (mcpText) { + return mcpText; + } + + if (typeof output === "string") { + return output; + } + + if (output === null || output === undefined) { + return null; + } + + try { + return JSON.stringify(output, null, 2); + } catch { + return String(output); } - return null; } export function ToolGeneric({ part }: { part: Record }) { @@ -63,8 +67,7 @@ export function ToolGeneric({ part }: { part: Record }) { } if (done || failed) { - const outputText = - done && part.output ? extractOutputText(part.output) : null; + const outputText = done ? formatOutputText(part.output) : null; return ( ; type Part = Parameters[0]["part"]; +type SearchResult = { + id: string; +}; + +function parseSearchResults(output: unknown): SearchResult[] { + if (!output || typeof output !== "object" || !("results" in output)) { + return []; + } + + const { results } = output as { results?: unknown }; + if (!Array.isArray(results)) { + return []; + } + + return results.flatMap((result): SearchResult[] => { + if (!result || typeof result !== "object") { + return []; + } + + const { id } = result as { id?: unknown }; + if (typeof id !== "string") { + return []; + } + + return [{ id }]; + }); +} export const ToolSearchSessions: Renderer = ({ part }) => { const { running: disabled } = useToolState(part); @@ -50,12 +77,8 @@ const getTitle = (part: Part) => { }; function RenderContent({ part }: { part: Part }) { - if ( - part.state === "output-available" && - part.output && - "results" in part.output - ) { - const { results } = part.output; + if (part.state === "output-available") { + const results = parseSearchResults(part.output); if (!results || results.length === 0) { return ( @@ -69,7 +92,7 @@ function RenderContent({ part }: { part: Part }) {
- {results.map((result: any, index: number) => ( + {results.map((result, index: number) => ( +
+ ); } diff --git a/apps/desktop/src/components/chat/session.tsx b/apps/desktop/src/components/chat/session.tsx index 7e5ec3256d..9f98b04a7e 100644 --- a/apps/desktop/src/components/chat/session.tsx +++ b/apps/desktop/src/components/chat/session.tsx @@ -1,22 +1,35 @@ import { useChat } from "@ai-sdk/react"; import type { ChatStatus } from "ai"; -import type { LanguageModel } from "ai"; -import { type ReactNode, useEffect, useMemo, useRef, useState } from "react"; +import type { LanguageModel, ToolSet } from "ai"; +import { + type ReactNode, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; import { + type ChatContext, commands as templateCommands, type Transcript, } from "@hypr/plugin-template"; -import type { ContextItem, ContextSource } from "../../chat/context-item"; +import { + type ContextEntity, + extractToolContextEntities, +} from "../../chat/context-item"; +import { composeContextEntities } from "../../chat/context/composer"; +import { renderTemplateContext } from "../../chat/context/registry"; import { CustomChatTransport } from "../../chat/transport"; import type { HyprUIMessage } from "../../chat/types"; import { useToolRegistry } from "../../contexts/tool"; import { useSession } from "../../hooks/tinybase"; -import { useContextCollection } from "../../hooks/useContextCollection"; import { useCreateChatMessage } from "../../hooks/useCreateChatMessage"; import { useLanguageModel } from "../../hooks/useLLMConnection"; import * as main from "../../store/tinybase/store/main"; +import { useChatContext } from "../../store/zustand/chat-context"; import { id } from "../../utils"; import { buildSegments, SegmentKey, type WordLike } from "../../utils/segment"; import { @@ -27,48 +40,45 @@ import { interface ChatSessionProps { sessionId: string; chatGroupId?: string; - chatType?: "general" | "support"; attachedSessionId?: string; modelOverride?: LanguageModel; - extraTools?: Record; + extraTools?: ToolSet; systemPromptOverride?: string; children: (props: { + sessionId: string; messages: HyprUIMessage[]; sendMessage: (message: HyprUIMessage) => void; regenerate: () => void; stop: () => void; status: ChatStatus; error?: Error; - contextItems: ContextItem[]; + contextEntities: ContextEntity[]; + onRemoveContextEntity: (key: string) => void; + isSystemPromptReady: boolean; }) => ReactNode; } export function ChatSession({ sessionId, chatGroupId, - chatType = "general", attachedSessionId, modelOverride, extraTools, systemPromptOverride, children, }: ChatSessionProps) { - const { transport, sessionTitle, sessionDate, wordCount, notePreview } = - useTransport( - chatType, - attachedSessionId, - modelOverride, - extraTools, - systemPromptOverride, - ); - - const contextItems = useSessionContextItems( + const { transport, sessionEntity, isSystemPromptReady } = useTransport( attachedSessionId, - sessionTitle, - sessionDate, - wordCount, - notePreview, + modelOverride, + extraTools, + systemPromptOverride, ); + + const persistContext = useChatContext((s) => s.persistContext); + const persistedCtx = useChatContext((s) => + chatGroupId ? s.contexts[chatGroupId] : undefined, + ); + const store = main.UI.useStore(main.STORE_ID); const createChatMessage = useCreateChatMessage(); @@ -115,13 +125,14 @@ export function ChatSession({ ); const prevMessagesRef = useRef(initialMessages); - useEffect(() => { - persistedAssistantIds.current = new Set( - initialAssistantMessages.map((message) => message.id), - ); - }, [initialAssistantMessages]); - - const { messages, sendMessage, regenerate, stop, status, error } = useChat({ + const { + messages, + sendMessage: rawSendMessage, + regenerate, + stop, + status, + error, + } = useChat({ id: sessionId, messages: initialMessages, generateId: () => id(), @@ -129,6 +140,12 @@ export function ChatSession({ onError: console.error, }); + useEffect(() => { + persistedAssistantIds.current = new Set( + initialAssistantMessages.map((m) => m.id), + ); + }, [initialAssistantMessages]); + useEffect(() => { if (!chatGroupId || !store) { prevMessagesRef.current = messages; @@ -148,89 +165,120 @@ export function ChatSession({ } } + if (status === "ready") { + for (const message of messages) { + if ( + message.role !== "assistant" || + persistedAssistantIds.current.has(message.id) + ) { + continue; + } + + const content = message.parts + .filter( + (p): p is Extract => p.type === "text", + ) + .map((p) => p.text) + .join(""); + + createChatMessage({ + id: message.id, + chat_group_id: chatGroupId, + content, + role: "assistant", + parts: message.parts, + metadata: message.metadata, + }); + + persistedAssistantIds.current.add(message.id); + } + } + prevMessagesRef.current = messages; - }, [chatGroupId, messages, store]); + }, [chatGroupId, messages, status, store, createChatMessage]); + + const toolEntities = useMemo( + () => extractToolContextEntities(messages), + [messages], + ); + + const [removedKeys, setRemovedKeys] = useState>(new Set()); useEffect(() => { - if (!chatGroupId || status !== "ready") { - return; - } + setRemovedKeys(new Set()); + }, [sessionId, chatGroupId]); - for (const message of messages) { - if ( - message.role !== "assistant" || - persistedAssistantIds.current.has(message.id) - ) { - continue; - } + const handleRemoveContextEntity = useCallback((key: string) => { + setRemovedKeys((prev) => new Set(prev).add(key)); + }, []); - const content = message.parts - .filter((part) => part.type === "text") - .map((part) => (part.type === "text" ? part.text : "")) - .join(""); - - createChatMessage({ - id: message.id, - chat_group_id: chatGroupId, - content, - role: "assistant", - parts: message.parts, - metadata: message.metadata, - }); + const ephemeralEntities = useMemo(() => { + const sessionEntities: ContextEntity[] = sessionEntity + ? [sessionEntity] + : []; + const filtered = toolEntities.filter((e) => !removedKeys.has(e.key)); + return composeContextEntities([sessionEntities, filtered]); + }, [sessionEntity, toolEntities, removedKeys]); + + const persistedEntities = persistedCtx?.contextEntities ?? []; + + const contextEntities = useMemo(() => { + return composeContextEntities([persistedEntities, ephemeralEntities]); + }, [persistedEntities, ephemeralEntities]); + + const contextEntitiesRef = useRef(contextEntities); + contextEntitiesRef.current = contextEntities; - persistedAssistantIds.current.add(message.id); + // When chatGroupId first becomes defined (after first message creates the group), + // persist the current context so it survives mode transitions. + const prevChatGroupIdRef = useRef(chatGroupId); + useEffect(() => { + if (chatGroupId && !prevChatGroupIdRef.current) { + persistContext( + chatGroupId, + attachedSessionId ?? null, + contextEntitiesRef.current, + ); } - }, [chatGroupId, createChatMessage, messages, status]); + prevChatGroupIdRef.current = chatGroupId; + }, [chatGroupId, attachedSessionId, persistContext]); + + const sendMessage = useCallback( + (message: HyprUIMessage) => { + if (chatGroupId) { + persistContext( + chatGroupId, + attachedSessionId ?? null, + contextEntitiesRef.current, + ); + } + rawSendMessage(message); + }, + [chatGroupId, attachedSessionId, persistContext, rawSendMessage], + ); return (
{children({ + sessionId, messages, sendMessage, regenerate, stop, status, error, - contextItems, + contextEntities, + onRemoveContextEntity: handleRemoveContextEntity, + isSystemPromptReady, })}
); } -function useSessionContextItems( - attachedSessionId?: string, - sessionTitle?: string | null, - sessionDate?: string | null, - wordCount?: number, - notePreview?: string | null, -): ContextItem[] { - const sources = useMemo(() => { - if (!attachedSessionId) return []; - const s: ContextSource[] = []; - if (sessionTitle || sessionDate) { - s.push({ - type: "session", - title: sessionTitle ?? undefined, - date: sessionDate ?? undefined, - }); - } - if (wordCount && wordCount > 0) { - s.push({ type: "transcript", wordCount }); - } - if (notePreview) { - s.push({ type: "note", preview: notePreview }); - } - return s; - }, [attachedSessionId, sessionTitle, sessionDate, wordCount, notePreview]); - - return useContextCollection(sources); -} - function useTransport( - chatType: "general" | "support", attachedSessionId?: string, modelOverride?: LanguageModel, - extraTools?: Record, + extraTools?: ToolSet, systemPromptOverride?: string, ) { const registry = useToolRegistry(); @@ -240,18 +288,13 @@ function useTransport( const language = main.UI.useValue("ai_language", main.STORE_ID) ?? "en"; const [systemPrompt, setSystemPrompt] = useState(); - const { title, rawMd, createdAt } = useSession(attachedSessionId ?? ""); - - const enhancedNoteIds = main.UI.useSliceRowIds( - main.INDEXES.enhancedNotesBySession, + const { title, rawMd, createdAt, event } = useSession( attachedSessionId ?? "", - main.STORE_ID, ); - const firstEnhancedNoteId = enhancedNoteIds?.[0]; - const enhancedContent = main.UI.useCell( - "enhanced_notes", - firstEnhancedNoteId ?? "", - "content", + + const participantIds = main.UI.useSliceRowIds( + main.INDEXES.sessionParticipantsBySession, + attachedSessionId ?? "", main.STORE_ID, ); @@ -314,19 +357,62 @@ function useTransport( }; }, [words, store]); - const chatContext = useMemo(() => { + const sessionEntity = useMemo((): Extract< + ContextEntity, + { kind: "session" } + > | null => { if (!attachedSessionId) { return null; } + const titleStr = (title as string) || undefined; + const dateStr = (createdAt as string) || undefined; + const rawContentStr = (rawMd as string) || undefined; + const chatContext: ChatContext = { + title: titleStr ?? null, + date: dateStr ?? null, + rawContent: rawContentStr ?? null, + enhancedContent: null, + transcript: transcript ?? null, + }; + + if ( + !titleStr && + !dateStr && + words.length === 0 && + !rawContentStr && + participantIds.length === 0 && + !event?.title + ) { + return null; + } + return { - title: (title as string) || null, - date: (createdAt as string) || null, - rawContent: (rawMd as string) || null, - enhancedContent: (enhancedContent as string) || null, - transcript, + kind: "session", + key: "session:info", + chatContext, + wordCount: words.length > 0 ? words.length : undefined, + rawNotePreview: rawContentStr, + participantCount: participantIds.length, + eventTitle: event?.title ?? undefined, }; - }, [attachedSessionId, title, rawMd, enhancedContent, createdAt, transcript]); + }, [ + attachedSessionId, + title, + createdAt, + rawMd, + words.length, + participantIds.length, + event, + transcript, + ]); + + const chatContext = useMemo(() => { + if (!sessionEntity) { + return null; + } + return renderTemplateContext(sessionEntity); + }, [sessionEntity]); useEffect(() => { if (systemPromptOverride) { @@ -344,11 +430,22 @@ function useTransport( }, }) .then((result) => { - if (!stale && result.status === "ok") { + if (stale) { + return; + } + + if (result.status === "ok") { setSystemPrompt(result.data); + } else { + setSystemPrompt(""); } }) - .catch(console.error); + .catch((error) => { + console.error(error); + if (!stale) { + setSystemPrompt(""); + } + }); return () => { stale = true; @@ -356,30 +453,27 @@ function useTransport( }, [language, chatContext, systemPromptOverride]); const effectiveSystemPrompt = systemPromptOverride ?? systemPrompt; + const isSystemPromptReady = + typeof systemPromptOverride === "string" || systemPrompt !== undefined; + + const tools = useMemo(() => { + return { + ...registry.getTools("chat-general"), + ...extraTools, + }; + }, [registry, extraTools]); const transport = useMemo(() => { if (!model) { return null; } - return new CustomChatTransport( - registry, - model, - chatType, - effectiveSystemPrompt, - extraTools, - ); - }, [registry, model, chatType, effectiveSystemPrompt, extraTools]); - - const sessionTitle = (title as string) || null; - const sessionDate = (createdAt as string) || null; - const notePreview = (enhancedContent as string) || null; + return new CustomChatTransport(model, tools, effectiveSystemPrompt); + }, [model, tools, effectiveSystemPrompt]); return { transport, - sessionTitle, - sessionDate, - wordCount: words.length, - notePreview, + sessionEntity, + isSystemPromptReady, }; } diff --git a/apps/desktop/src/components/chat/view.tsx b/apps/desktop/src/components/chat/view.tsx index 3fe954485a..72b3849d11 100644 --- a/apps/desktop/src/components/chat/view.tsx +++ b/apps/desktop/src/components/chat/view.tsx @@ -1,15 +1,10 @@ -import { useCallback, useMemo } from "react"; +import { useCallback } from "react"; -import type { ContextItem } from "../../chat/context-item"; -import type { HyprUIMessage } from "../../chat/types"; import { useShell } from "../../contexts/shell"; -import { useSession } from "../../hooks/tinybase"; import { useLanguageModel } from "../../hooks/useLLMConnection"; import { useTabs } from "../../store/zustand/tabs"; -import { ChatBody } from "./body"; -import { ContextBar } from "./context-bar"; +import { ChatContent } from "./content"; import { ChatHeader } from "./header"; -import { ChatMessageInput } from "./input"; import { ChatSession } from "./session"; import { useChatActions, useStableSessionId } from "./use-chat-actions"; @@ -75,86 +70,14 @@ export function ChatView() { chatGroupId={groupId} attachedSessionId={attachedSessionId} > - {({ - messages, - sendMessage, - regenerate, - stop, - status, - error, - contextItems, - }) => ( - ( + )}
); } - -function ChatViewContent({ - messages, - sendMessage, - regenerate, - stop, - status, - error, - model, - handleSendMessage, - attachedSessionId, - contextItems, -}: { - messages: HyprUIMessage[]; - sendMessage: (message: HyprUIMessage) => void; - regenerate: () => void; - stop: () => void; - status: "submitted" | "streaming" | "ready" | "error"; - error?: Error; - model: ReturnType; - handleSendMessage: ( - content: string, - parts: any[], - sendMessage: (message: HyprUIMessage) => void, - ) => void; - attachedSessionId?: string; - contextItems: ContextItem[]; -}) { - const { title } = useSession(attachedSessionId ?? ""); - - const attachedSession = useMemo(() => { - if (!attachedSessionId) return undefined; - return { id: attachedSessionId, title: (title as string) || undefined }; - }, [attachedSessionId, title]); - - return ( - <> - - - - handleSendMessage(content, parts, sendMessage) - } - attachedSession={attachedSession} - isStreaming={status === "streaming" || status === "submitted"} - onStop={stop} - /> - - ); -} diff --git a/apps/desktop/src/components/main/body/chat/tab-content.tsx b/apps/desktop/src/components/main/body/chat/tab-content.tsx index 5f4cba4fe8..1e1d48fc44 100644 --- a/apps/desktop/src/components/main/body/chat/tab-content.tsx +++ b/apps/desktop/src/components/main/body/chat/tab-content.tsx @@ -1,10 +1,11 @@ import { Loader2 } from "lucide-react"; -import { useCallback, useEffect, useMemo, useRef } from "react"; +import { useCallback, useEffect, useRef } from "react"; import { cn } from "@hypr/utils"; import { useAuth } from "../../../../auth"; -import type { ContextItem } from "../../../../chat/context-item"; +import type { ContextEntity } from "../../../../chat/context-item"; +import { composeContextEntities } from "../../../../chat/context/composer"; import type { HyprUIMessage } from "../../../../chat/types"; import { ElicitationProvider } from "../../../../contexts/elicitation"; import { @@ -12,11 +13,11 @@ import { useLanguageModel, } from "../../../../hooks/useLLMConnection"; import { useSupportMCP } from "../../../../hooks/useSupportMCPTools"; +import { useChatContext } from "../../../../store/zustand/chat-context"; import type { Tab } from "../../../../store/zustand/tabs"; import { useTabs } from "../../../../store/zustand/tabs"; import { ChatBody } from "../../../chat/body"; -import { ContextBar } from "../../../chat/context-bar"; -import { ChatMessageInput } from "../../../chat/input"; +import { ChatContent } from "../../../chat/content"; import { ChatSession } from "../../../chat/session"; import { useChatActions, @@ -29,31 +30,80 @@ export function TabContentChat({ }: { tab: Extract; }) { + const isSupport = tab.state.chatType === "support"; + return ( - + {isSupport ? ( + + ) : ( + + )} ); } -function ChatTabView({ tab }: { tab: Extract }) { +function GeneralChatTabView({ tab }: { tab: Extract }) { + const groupId = tab.state.groupId ?? undefined; + const updateChatTabState = useTabs((state) => state.updateChatTabState); + const stableSessionId = useStableSessionId(groupId); + const model = useLanguageModel(); + + const persistedCtx = useChatContext((s) => + groupId ? s.contexts[groupId] : undefined, + ); + const attachedSessionId = persistedCtx?.attachedSessionId ?? undefined; + + const onGroupCreated = useCallback( + (newGroupId: string) => + updateChatTabState(tab, { + ...tab.state, + groupId: newGroupId, + initialMessage: null, + }), + [updateChatTabState, tab], + ); + + const { handleSendMessage } = useChatActions({ + groupId, + onGroupCreated, + }); + + return ( +
+ + {(sessionProps) => ( + + )} + +
+ ); +} + +function SupportChatTabView({ tab }: { tab: Extract }) { const groupId = tab.state.groupId ?? undefined; - const isSupport = tab.state.chatType === "support"; const updateChatTabState = useTabs((state) => state.updateChatTabState); const { session } = useAuth(); const stableSessionId = useStableSessionId(groupId); - const userModel = useLanguageModel(); const feedbackModel = useFeedbackLanguageModel(); - const model = isSupport ? feedbackModel : userModel; const { tools: mcpTools, systemPrompt, - contextItems: supportContextItems, + contextEntities: supportContextEntities, pendingElicitation, respondToElicitation, isReady, - } = useSupportMCP(isSupport, session?.access_token); + } = useSupportMCP(true, session?.access_token); const mcpToolCount = Object.keys(mcpTools).length; @@ -72,43 +122,37 @@ function ChatTabView({ tab }: { tab: Extract }) { onGroupCreated, }); - const waitingForMcp = isSupport && !isReady; + if (!isReady) { + return ( +
+
+
+ + Preparing support chat... +
+
+
+ ); + } return ( -
+
- {({ - messages, - sendMessage, - regenerate, - stop, - status, - error, - contextItems: sessionContextItems, - }) => ( - ( + @@ -118,32 +162,30 @@ function ChatTabView({ tab }: { tab: Extract }) { ); } -function ChatTabInner({ +function SupportChatTabInner({ tab, - messages, - sendMessage, - regenerate, - stop, - status, - error, - model, + sessionProps, + feedbackModel, handleSendMessage, updateChatTabState, - waitingForMcp, - isReady, - supportContextItems, - sessionContextItems, + supportContextEntities, pendingElicitation, respondToElicitation, }: { tab: Extract; - messages: HyprUIMessage[]; - sendMessage: (message: HyprUIMessage) => void; - regenerate: () => void; - stop: () => void; - status: "submitted" | "streaming" | "ready" | "error"; - error?: Error; - model: ReturnType; + sessionProps: { + sessionId: string; + messages: HyprUIMessage[]; + sendMessage: (message: HyprUIMessage) => void; + regenerate: () => void; + stop: () => void; + status: "submitted" | "streaming" | "ready" | "error"; + error?: Error; + contextEntities: ContextEntity[]; + onRemoveContextEntity: (key: string) => void; + isSystemPromptReady: boolean; + }; + feedbackModel: ReturnType; handleSendMessage: ( content: string, parts: HyprUIMessage["parts"], @@ -153,13 +195,21 @@ function ChatTabInner({ tab: Extract, state: Extract["state"], ) => void; - waitingForMcp: boolean; - isReady: boolean; - supportContextItems?: ContextItem[]; - sessionContextItems: ContextItem[]; + supportContextEntities: ContextEntity[]; pendingElicitation?: { message: string } | null; respondToElicitation?: (approved: boolean) => void; }) { + const { + messages, + sendMessage, + regenerate, + stop, + status, + error, + contextEntities, + onRemoveContextEntity, + isSystemPromptReady, + } = sessionProps; const sentRef = useRef(false); useEffect(() => { @@ -167,9 +217,9 @@ function ChatTabInner({ if ( !initialMessage || sentRef.current || - !model || + !feedbackModel || status !== "ready" || - !isReady + !isSystemPromptReady ) { return; } @@ -186,32 +236,34 @@ function ChatTabInner({ }); }, [ tab, - model, + feedbackModel, status, - isReady, + isSystemPromptReady, handleSendMessage, sendMessage, updateChatTabState, ]); - const mergedContextItems = useMemo( - () => [...(supportContextItems ?? []), ...sessionContextItems], - [supportContextItems, sessionContextItems], - ); - - if (waitingForMcp) { - return ( -
-
- - Preparing support chat... -
-
- ); - } + const mergedContextEntities = composeContextEntities([ + contextEntities, + supportContextEntities, + ]); return ( - <> + - - - handleSendMessage(content, parts, sendMessage) - } - isStreaming={status === "streaming" || status === "submitted"} - onStop={stop} - /> - + ); } diff --git a/apps/desktop/src/components/main/body/sessions/note-input/enhanced/enhance-error.tsx b/apps/desktop/src/components/main/body/sessions/note-input/enhanced/enhance-error.tsx new file mode 100644 index 0000000000..43d3f94cd6 --- /dev/null +++ b/apps/desktop/src/components/main/body/sessions/note-input/enhanced/enhance-error.tsx @@ -0,0 +1,49 @@ +import { AlertCircleIcon, RefreshCwIcon } from "lucide-react"; + +import { Button } from "@hypr/ui/components/ui/button"; + +import { useAITask } from "../../../../../../contexts/ai-task"; +import { useLanguageModel } from "../../../../../../hooks/useLLMConnection"; +import { createTaskId } from "../../../../../../store/zustand/ai-task/task-configs"; + +export function EnhanceError({ + sessionId, + enhancedNoteId, + error, +}: { + sessionId: string; + enhancedNoteId: string; + error: Error | undefined; +}) { + const model = useLanguageModel(); + const generate = useAITask((state) => state.generate); + + const handleRetry = () => { + if (!model) return; + + const taskId = createTaskId(enhancedNoteId, "enhance"); + void generate(taskId, { + model, + taskType: "enhance", + args: { sessionId, enhancedNoteId }, + }); + }; + + return ( +
+ +

+ {error?.message || "Something went wrong while generating the summary."} +

+ +
+ ); +} diff --git a/apps/desktop/src/components/main/body/sessions/note-input/enhanced/index.tsx b/apps/desktop/src/components/main/body/sessions/note-input/enhanced/index.tsx index 4d4fd9d0c7..9a6326a526 100644 --- a/apps/desktop/src/components/main/body/sessions/note-input/enhanced/index.tsx +++ b/apps/desktop/src/components/main/body/sessions/note-input/enhanced/index.tsx @@ -8,6 +8,7 @@ import * as main from "../../../../../../store/tinybase/store/main"; import { createTaskId } from "../../../../../../store/zustand/ai-task/task-configs"; import { ConfigError } from "./config-error"; import { EnhancedEditor } from "./editor"; +import { EnhanceError } from "./enhance-error"; import { StreamingView } from "./streaming"; export const Enhanced = forwardRef< @@ -16,7 +17,7 @@ export const Enhanced = forwardRef< >(({ sessionId, enhancedNoteId, onNavigateToTitle }, ref) => { const taskId = createTaskId(enhancedNoteId, "enhance"); const llmStatus = useLLMConnectionStatus(); - const { status } = useAITaskTask(taskId, "enhance"); + const { status, error } = useAITaskTask(taskId, "enhance"); const content = main.UI.useCell( "enhanced_notes", enhancedNoteId, @@ -37,7 +38,13 @@ export const Enhanced = forwardRef< } if (status === "error") { - return null; + return ( + + ); } if (status === "generating") { diff --git a/apps/desktop/src/components/main/body/sessions/note-input/header.tsx b/apps/desktop/src/components/main/body/sessions/note-input/header.tsx index 266ae01059..72d4fe62be 100644 --- a/apps/desktop/src/components/main/body/sessions/note-input/header.tsx +++ b/apps/desktop/src/components/main/body/sessions/note-input/header.tsx @@ -386,18 +386,6 @@ function CreateOtherFormatButton({ enhancedNoteId: pendingNote.id, templateId: pendingNote.templateId, }, - onComplete: (text) => { - if (text && store) { - try { - const jsonContent = md2json(text); - store.setPartialRow("enhanced_notes", pendingNote.id, { - content: JSON.stringify(jsonContent), - }); - } catch (error) { - console.error("Failed to convert markdown to JSON:", error); - } - } - }, }); } }, [pendingNote, model, sessionId, enhanceTask.start]); @@ -725,18 +713,6 @@ function useEnhanceLogic(sessionId: string, enhancedNoteId: string) { enhancedNoteId, templateId: templateId ?? undefined, }, - onComplete: (text) => { - if (text && store) { - try { - const jsonContent = md2json(text); - store.setPartialRow("enhanced_notes", enhancedNoteId, { - content: JSON.stringify(jsonContent), - }); - } catch (error) { - console.error("Failed to convert markdown to JSON:", error); - } - } - }, }); }, [model, enhanceTask.start, sessionId, enhancedNoteId], diff --git a/apps/desktop/src/contexts/shell/chat.ts b/apps/desktop/src/contexts/shell/chat.ts index c97a28c0fc..6099f13536 100644 --- a/apps/desktop/src/contexts/shell/chat.ts +++ b/apps/desktop/src/contexts/shell/chat.ts @@ -1,6 +1,6 @@ -import { useState } from "react"; import { useHotkeys } from "react-hotkeys-hook"; +import { useChatContext } from "../../store/zustand/chat-context"; import { useTabs } from "../../store/zustand/tabs"; export type { ChatEvent, ChatMode } from "../../store/zustand/tabs"; @@ -9,7 +9,8 @@ export function useChatMode() { const mode = useTabs((state) => state.chatMode); const transitionChatMode = useTabs((state) => state.transitionChatMode); - const [groupId, setGroupId] = useState(undefined); + const groupId = useChatContext((state) => state.groupId); + const setGroupId = useChatContext((state) => state.setGroupId); useHotkeys( "mod+j", diff --git a/apps/desktop/src/contexts/tool-registry/core.ts b/apps/desktop/src/contexts/tool-registry/core.ts index f9a69afe97..63bb900002 100644 --- a/apps/desktop/src/contexts/tool-registry/core.ts +++ b/apps/desktop/src/contexts/tool-registry/core.ts @@ -1,8 +1,4 @@ -export type ToolScope = - | "chat-general" - | "chat-support" - | "enhancing" - | (string & {}); +export type ToolScope = "chat-general" | "enhancing"; interface ToolEntry { id: symbol; @@ -15,35 +11,10 @@ export interface ToolRegistry { register(scopes: ToolScope | ToolScope[], key: string, tool: TTool): symbol; unregister(id: symbol): void; getTools(scope?: ToolScope): Record; - invoke(scope: ToolScope, key: string, input: unknown): Promise; - clear(): void; } export function createToolRegistry(): ToolRegistry { const entries = new Map>(); - const scopeIndex = new Map>(); - - const indexTool = (entry: ToolEntry) => { - for (const scope of entry.scopes) { - const scopedIndex = scopeIndex.get(scope) ?? new Map(); - scopedIndex.set(entry.key, entry.id); - scopeIndex.set(scope, scopedIndex); - } - }; - - const removeFromIndex = (entry: ToolEntry) => { - for (const scope of entry.scopes) { - const scopedIndex = scopeIndex.get(scope); - if (!scopedIndex) { - continue; - } - - scopedIndex.delete(entry.key); - if (scopedIndex.size === 0) { - scopeIndex.delete(scope); - } - } - }; return { register(scopes, key, tool) { @@ -56,7 +27,6 @@ export function createToolRegistry(): ToolRegistry { tool, }; entries.set(id, entry); - indexTool(entry); return id; }, @@ -67,7 +37,6 @@ export function createToolRegistry(): ToolRegistry { } entries.delete(id); - removeFromIndex(entry); }, getTools(scope) { @@ -78,32 +47,5 @@ export function createToolRegistry(): ToolRegistry { return acc; }, {}); }, - - async invoke(scope, key, input) { - const scopedIndex = scopeIndex.get(scope); - const id = scopedIndex?.get(key); - if (!id) { - throw new Error(`Tool "${key}" not found in scope "${scope}"`); - } - - const entry = entries.get(id); - if (!entry) { - throw new Error(`Tool "${key}" not found in scope "${scope}"`); - } - - const execute = (entry.tool as any)?.execute; - if (typeof execute !== "function") { - throw new Error( - `Tool "${key}" in scope "${scope}" does not implement execute()`, - ); - } - - return await execute(input); - }, - - clear() { - entries.clear(); - scopeIndex.clear(); - }, }; } diff --git a/apps/desktop/src/hooks/autoEnhance/runner.ts b/apps/desktop/src/hooks/autoEnhance/runner.ts index 8b7c4715ea..d7edb4981e 100644 --- a/apps/desktop/src/hooks/autoEnhance/runner.ts +++ b/apps/desktop/src/hooks/autoEnhance/runner.ts @@ -179,40 +179,6 @@ export function useAutoEnhanceRunner( model, taskType: "enhance", args: { sessionId, enhancedNoteId }, - onComplete: (text) => { - if (!text || !store) return; - try { - const jsonContent = md2json(text); - store.setPartialRow("enhanced_notes", enhancedNoteId, { - content: JSON.stringify(jsonContent), - }); - - const currentTitle = store.getCell("sessions", sessionId, "title"); - const trimmedTitle = - typeof currentTitle === "string" ? currentTitle.trim() : ""; - - if (!trimmedTitle && model) { - const titleTaskId = createTaskId(sessionId, "title"); - void generate(titleTaskId, { - model, - taskType: "title", - args: { sessionId }, - onComplete: (titleText) => { - if (titleText && store) { - const trimmed = titleText.trim(); - if (trimmed && trimmed !== "") { - store.setPartialRow("sessions", sessionId, { - title: trimmed, - }); - } - } - }, - }); - } - } catch (error) { - console.error("Failed to convert markdown to JSON:", error); - } - }, }); return { type: "started", noteId: enhancedNoteId }; diff --git a/apps/desktop/src/hooks/useContextCollection.ts b/apps/desktop/src/hooks/useContextCollection.ts deleted file mode 100644 index 3fd7562122..0000000000 --- a/apps/desktop/src/hooks/useContextCollection.ts +++ /dev/null @@ -1,149 +0,0 @@ -import { getVersion } from "@tauri-apps/api/app"; -import { version as osVersion, platform } from "@tauri-apps/plugin-os"; -import { useEffect, useState } from "react"; - -import { commands as miscCommands } from "@hypr/plugin-misc"; - -import type { ContextItem, ContextSource } from "../chat/context-item"; - -function buildItem(source: ContextSource): ContextItem | null { - switch (source.type) { - case "account": { - if (!source.email && !source.userId) return null; - const lines: string[] = []; - if (source.email) lines.push(source.email); - if (source.userId) lines.push(`ID: ${source.userId}`); - return { - key: "support:account", - label: "Account", - tooltip: lines.join("\n"), - }; - } - case "session": { - if (!source.title && !source.date) return null; - const lines: string[] = []; - if (source.title) lines.push(source.title); - if (source.date) lines.push(source.date); - return { - key: "session:info", - label: source.title || "Session", - tooltip: lines.join("\n"), - }; - } - case "transcript": { - if (!source.wordCount) return null; - return { - key: "session:transcript", - label: "Transcript", - tooltip: `${source.wordCount.toLocaleString()} words`, - }; - } - case "note": { - if (!source.preview) return null; - const truncated = - source.preview.length > 120 - ? `${source.preview.slice(0, 120)}...` - : source.preview; - return { - key: "session:note", - label: "Note", - tooltip: truncated, - }; - } - default: - return null; - } -} - -async function collectDeviceInfo(): Promise { - const lines: string[] = []; - try { - const [appVersion, os, gitHashResult] = await Promise.all([ - getVersion(), - osVersion(), - miscCommands.getGitHash(), - ]); - const gitHash = gitHashResult.status === "ok" ? gitHashResult.data : null; - lines.push(`Platform: ${platform()}`); - lines.push(`OS: ${os}`); - lines.push(`App: ${appVersion}`); - if (gitHash) lines.push(`Build: ${gitHash}`); - } catch {} - - const locale = navigator.language || "en"; - lines.push(`Locale: ${locale}`); - - return { - key: "support:device", - label: "Device", - tooltip: lines.join("\n"), - }; -} - -export async function collectSupportContextBlock( - email?: string, - userId?: string, -): Promise<{ items: ContextItem[]; block: string | null }> { - const items: ContextItem[] = []; - const blockLines: string[] = []; - - if (email || userId) { - const accountItem = buildItem({ type: "account", email, userId }); - if (accountItem) items.push(accountItem); - if (email) blockLines.push(`- Email: ${email}`); - if (userId) blockLines.push(`- User ID: ${userId}`); - } - - const deviceItem = await collectDeviceInfo(); - items.push(deviceItem); - - const deviceParts = deviceItem.tooltip.split("\n"); - for (const part of deviceParts) { - blockLines.push(`- ${part}`); - } - - if (blockLines.length === 0) { - return { items, block: null }; - } - - return { - items, - block: - "---\nThe following is automatically collected context about the current user and their environment. Use it when filing issues or diagnosing problems.\n\n" + - blockLines.join("\n"), - }; -} - -export function useContextCollection(sources: ContextSource[]): ContextItem[] { - const [items, setItems] = useState([]); - - const hasDevice = sources.some((s) => s.type === "device"); - - const syncItems = sources - .filter((s) => s.type !== "device") - .map(buildItem) - .filter((item): item is ContextItem => item !== null); - - useEffect(() => { - if (!hasDevice) { - setItems(syncItems); - return; - } - - let stale = false; - collectDeviceInfo().then((deviceItem) => { - if (!stale) { - setItems([...syncItems, deviceItem]); - } - }); - return () => { - stale = true; - }; - }, [ - hasDevice, - syncItems.map((i) => `${i.key}:${i.label}:${i.tooltip}`).join(), - ]); - - if (!hasDevice) return syncItems; - return items; -} diff --git a/apps/desktop/src/hooks/useSupportMCPTools.ts b/apps/desktop/src/hooks/useSupportMCPTools.ts index 7f04fd6476..8f3df913b0 100644 --- a/apps/desktop/src/hooks/useSupportMCPTools.ts +++ b/apps/desktop/src/hooks/useSupportMCPTools.ts @@ -1,32 +1,26 @@ +import type { ToolSet } from "ai"; import { useEffect, useState } from "react"; -import { useAuth } from "../auth"; -import type { ContextItem } from "../chat/context-item"; -import { collectSupportContextBlock } from "./useContextCollection"; +import type { ContextEntity } from "../chat/context-item"; +import { collectSupportContextBlock } from "../chat/context/support-block"; import { useMCPClient } from "./useMCPClient"; import { useMCPElicitation } from "./useMCPElicitation"; -export type { ContextItem }; - export function useSupportMCP(enabled: boolean, accessToken?: string | null) { - const { session } = useAuth(); - const email = session?.user?.email; - const userId = session?.user?.id; - const { client, isConnected } = useMCPClient(enabled, accessToken); const { pendingElicitation, respondToElicitation } = useMCPElicitation(client); - const [tools, setTools] = useState>({}); + const [tools, setTools] = useState({}); const [systemPrompt, setSystemPrompt] = useState(); - const [contextItems, setContextItems] = useState([]); + const [contextEntities, setContextEntities] = useState([]); const [isReady, setIsReady] = useState(!enabled); useEffect(() => { if (!enabled) { setTools({}); setSystemPrompt(undefined); - setContextItems([]); + setContextEntities([]); setIsReady(true); return; } @@ -40,8 +34,8 @@ export function useSupportMCP(enabled: boolean, accessToken?: string | null) { const load = async () => { try { - const [{ items, block }, fetchedTools, prompt] = await Promise.all([ - collectSupportContextBlock(email, userId), + const [{ entities, block }, fetchedTools, prompt] = await Promise.all([ + collectSupportContextBlock(), client.tools(), client .experimental_getPrompt({ name: "support_chat" }) @@ -50,8 +44,8 @@ export function useSupportMCP(enabled: boolean, accessToken?: string | null) { if (cancelled) return; - setContextItems(items); - setTools(fetchedTools); + setContextEntities(entities); + setTools(fetchedTools as ToolSet); let mcpPrompt: string | undefined; if (prompt?.messages) { @@ -71,7 +65,11 @@ export function useSupportMCP(enabled: boolean, accessToken?: string | null) { setIsReady(true); } catch (error) { console.error("Failed to load MCP resources:", error); - if (!cancelled) setIsReady(true); + if (cancelled) return; + setTools({}); + setSystemPrompt(undefined); + setContextEntities([]); + setIsReady(false); } }; @@ -80,12 +78,12 @@ export function useSupportMCP(enabled: boolean, accessToken?: string | null) { return () => { cancelled = true; }; - }, [enabled, client, isConnected, email, userId]); + }, [enabled, client, isConnected]); return { tools, systemPrompt, - contextItems, + contextEntities, pendingElicitation, respondToElicitation, isReady, diff --git a/apps/desktop/src/store/zustand/chat-context.ts b/apps/desktop/src/store/zustand/chat-context.ts new file mode 100644 index 0000000000..b3d1d7d90f --- /dev/null +++ b/apps/desktop/src/store/zustand/chat-context.ts @@ -0,0 +1,58 @@ +import { create } from "zustand"; + +import type { ContextEntity } from "../../chat/context-item"; + +type PerGroupContext = { + attachedSessionId: string | null; + contextEntities: ContextEntity[]; +}; + +interface ChatContextState { + groupId: string | undefined; + contexts: Record; +} + +interface ChatContextActions { + setGroupId: (groupId: string | undefined) => void; + persistContext: ( + groupId: string, + attachedSessionId: string | null, + entities: ContextEntity[], + ) => void; + getPersistedContext: (groupId: string) => PerGroupContext | undefined; +} + +export const useChatContext = create( + (set, get) => ({ + groupId: undefined, + contexts: {}, + setGroupId: (groupId) => set({ groupId }), + persistContext: (groupId, attachedSessionId, entities) => { + const prev = get().contexts[groupId]; + const prevEntities = prev?.contextEntities ?? []; + + const seen = new Set(); + const merged: ContextEntity[] = []; + for (const e of prevEntities) { + if (!seen.has(e.key)) { + seen.add(e.key); + merged.push(e); + } + } + for (const e of entities) { + if (!seen.has(e.key)) { + seen.add(e.key); + merged.push(e); + } + } + + set({ + contexts: { + ...get().contexts, + [groupId]: { attachedSessionId, contextEntities: merged }, + }, + }); + }, + getPersistedContext: (groupId) => get().contexts[groupId], + }), +); diff --git a/crates/api-support/Cargo.toml b/crates/api-support/Cargo.toml index 3facbb2a74..1a0a5b40dd 100644 --- a/crates/api-support/Cargo.toml +++ b/crates/api-support/Cargo.toml @@ -9,6 +9,7 @@ hypr-api-env = { workspace = true } hypr-llm-proxy = { workspace = true } hypr-mcp = { workspace = true } hypr-openrouter = { workspace = true } +hypr-template-support = { workspace = true } reqwest = { workspace = true } urlencoding = { workspace = true } @@ -22,7 +23,6 @@ jsonwebtoken = { workspace = true } octocrab = "0.49" sqlx = { workspace = true, features = ["runtime-tokio", "tls-rustls", "postgres", "json"] } -askama = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } thiserror = { workspace = true } diff --git a/crates/api-support/src/github.rs b/crates/api-support/src/github.rs index 06052164bc..7891cb0fc8 100644 --- a/crates/api-support/src/github.rs +++ b/crates/api-support/src/github.rs @@ -1,5 +1,3 @@ -use askama::Template; - use crate::error::{Result, SupportError}; use crate::logs; use crate::state::AppState; @@ -7,35 +5,6 @@ use crate::state::AppState; const GITHUB_OWNER: &str = "fastrepl"; const GITHUB_REPO: &str = "hyprnote"; -#[derive(Template)] -#[template(path = "bug_report.md.jinja")] -struct BugReportBody<'a> { - description: &'a str, - platform: &'a str, - arch: &'a str, - os_version: &'a str, - app_version: &'a str, - source: &'a str, -} - -#[derive(Template)] -#[template(path = "feature_request.md.jinja")] -struct FeatureRequestBody<'a> { - description: &'a str, - platform: &'a str, - arch: &'a str, - os_version: &'a str, - app_version: &'a str, - source: &'a str, -} - -#[derive(Template)] -#[template(path = "log_analysis.md.jinja")] -struct LogAnalysisComment<'a> { - summary_section: &'a str, - tail: &'a str, -} - pub(crate) struct BugReportInput<'a> { pub description: &'a str, pub platform: &'a str, @@ -61,15 +30,16 @@ pub(crate) async fn submit_bug_report( ) -> Result { let (description, title) = make_title(input.description, "Bug Report"); - let body = BugReportBody { - description: &description, - platform: input.platform, - arch: input.arch, - os_version: input.os_version, - app_version: input.app_version, - source: input.source, - } - .render() + let body = hypr_template_support::render_bug_report( + hypr_template_support::SupportIssueTemplateInput { + description: &description, + platform: input.platform, + arch: input.arch, + os_version: input.os_version, + app_version: input.app_version, + source: input.source, + }, + ) .map_err(|e| SupportError::Internal(e.to_string()))?; let labels = vec!["product/desktop".to_string()]; @@ -88,15 +58,16 @@ pub(crate) async fn submit_feature_request( ) -> Result { let (description, title) = make_title(input.description, "Feature Request"); - let body = FeatureRequestBody { - description: &description, - platform: input.platform, - arch: input.arch, - os_version: input.os_version, - app_version: input.app_version, - source: input.source, - } - .render() + let body = hypr_template_support::render_feature_request( + hypr_template_support::SupportIssueTemplateInput { + description: &description, + platform: input.platform, + arch: input.arch, + os_version: input.os_version, + app_version: input.app_version, + source: input.source, + }, + ) .map_err(|e| SupportError::Internal(e.to_string()))?; let category_id = &state.config.github.github_discussion_category_id; @@ -136,11 +107,8 @@ async fn attach_log_analysis(state: &AppState, issue_number: u64, log_text: &str }; let tail = logs::safe_tail(log_text, 10000); - let comment = LogAnalysisComment { - summary_section: &summary_section, - tail, - }; - let log_comment = comment.render().unwrap_or_default(); + let log_comment = + hypr_template_support::render_log_analysis(&summary_section, tail).unwrap_or_default(); let _ = add_issue_comment(state, issue_number, &log_comment).await; } diff --git a/crates/api-support/src/mcp/prompts.rs b/crates/api-support/src/mcp/prompts.rs new file mode 100644 index 0000000000..7e9812cb5e --- /dev/null +++ b/crates/api-support/src/mcp/prompts.rs @@ -0,0 +1,13 @@ +use rmcp::{ErrorData as McpError, model::*}; + +pub(crate) fn support_chat() -> Result { + hypr_template_support::render_support_chat() + .map_err(|e| McpError::internal_error(e.to_string(), None)) + .map(|content| GetPromptResult { + description: Some("System prompt for the Hyprnote support chat".to_string()), + messages: vec![PromptMessage::new_text( + PromptMessageRole::Assistant, + content, + )], + }) +} diff --git a/crates/api-support/src/mcp/prompts/mod.rs b/crates/api-support/src/mcp/prompts/mod.rs deleted file mode 100644 index a8c5d49dc1..0000000000 --- a/crates/api-support/src/mcp/prompts/mod.rs +++ /dev/null @@ -1,3 +0,0 @@ -mod support_chat; - -pub(crate) use support_chat::support_chat; diff --git a/crates/api-support/src/mcp/prompts/support_chat.rs b/crates/api-support/src/mcp/prompts/support_chat.rs deleted file mode 100644 index 8ae232698c..0000000000 --- a/crates/api-support/src/mcp/prompts/support_chat.rs +++ /dev/null @@ -1,10 +0,0 @@ -use askama::Template; -use rmcp::{ErrorData as McpError, model::*}; - -#[derive(Template, Default)] -#[template(path = "support_chat.md.jinja")] -struct SupportChatPrompt; - -pub(crate) fn support_chat() -> Result { - hypr_mcp::render_prompt::("System prompt for the Hyprnote support chat") -} diff --git a/crates/api-support/src/routes/feedback.rs b/crates/api-support/src/routes/feedback.rs index 95225319fa..47c8ed360f 100644 --- a/crates/api-support/src/routes/feedback.rs +++ b/crates/api-support/src/routes/feedback.rs @@ -1,19 +1,12 @@ use axum::{Json, extract::State}; use serde::{Deserialize, Serialize}; +pub use hypr_template_support::DeviceInfo; + use crate::error::SupportError; use crate::github::{self, BugReportInput, FeatureRequestInput}; use crate::state::AppState; -#[derive(Debug, Deserialize, utoipa::ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct DeviceInfo { - pub platform: String, - pub arch: String, - pub os_version: String, - pub app_version: String, -} - #[derive(Debug, Default, Deserialize, utoipa::ToSchema)] #[serde(rename_all = "lowercase")] pub enum FeedbackType { diff --git a/crates/template-support/Cargo.toml b/crates/template-support/Cargo.toml new file mode 100644 index 0000000000..1b32ae7872 --- /dev/null +++ b/crates/template-support/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "template-support" +version = "0.1.0" +edition = "2024" + +[dependencies] +askama = { workspace = true } +serde = { workspace = true, features = ["derive"] } +specta = { workspace = true, features = ["derive"] } +utoipa = { workspace = true } diff --git a/crates/api-support/askama.toml b/crates/template-support/askama.toml similarity index 100% rename from crates/api-support/askama.toml rename to crates/template-support/askama.toml diff --git a/crates/api-support/assets/_device_info.md.jinja b/crates/template-support/assets/_device_info.md.jinja similarity index 100% rename from crates/api-support/assets/_device_info.md.jinja rename to crates/template-support/assets/_device_info.md.jinja diff --git a/crates/api-support/assets/bug_report.md.jinja b/crates/template-support/assets/bug_report.md.jinja similarity index 100% rename from crates/api-support/assets/bug_report.md.jinja rename to crates/template-support/assets/bug_report.md.jinja diff --git a/crates/api-support/assets/feature_request.md.jinja b/crates/template-support/assets/feature_request.md.jinja similarity index 100% rename from crates/api-support/assets/feature_request.md.jinja rename to crates/template-support/assets/feature_request.md.jinja diff --git a/crates/api-support/assets/log_analysis.md.jinja b/crates/template-support/assets/log_analysis.md.jinja similarity index 100% rename from crates/api-support/assets/log_analysis.md.jinja rename to crates/template-support/assets/log_analysis.md.jinja diff --git a/crates/api-support/assets/support_chat.md.jinja b/crates/template-support/assets/support_chat.md.jinja similarity index 100% rename from crates/api-support/assets/support_chat.md.jinja rename to crates/template-support/assets/support_chat.md.jinja diff --git a/crates/template-support/src/lib.rs b/crates/template-support/src/lib.rs new file mode 100644 index 0000000000..13c86ee632 --- /dev/null +++ b/crates/template-support/src/lib.rs @@ -0,0 +1,99 @@ +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, specta::Type, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct AccountInfo { + pub user_id: String, + pub email: Option, + pub full_name: Option, + pub avatar_url: Option, + pub stripe_customer_id: Option, +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, specta::Type, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct DeviceInfo { + pub platform: String, + pub arch: String, + pub os_version: String, + pub app_version: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub build_hash: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub locale: Option, +} + +#[derive(askama::Template)] +#[template(path = "bug_report.md.jinja")] +struct BugReportBody<'a> { + description: &'a str, + platform: &'a str, + arch: &'a str, + os_version: &'a str, + app_version: &'a str, + source: &'a str, +} + +#[derive(askama::Template)] +#[template(path = "feature_request.md.jinja")] +struct FeatureRequestBody<'a> { + description: &'a str, + platform: &'a str, + arch: &'a str, + os_version: &'a str, + app_version: &'a str, + source: &'a str, +} + +#[derive(askama::Template)] +#[template(path = "log_analysis.md.jinja")] +struct LogAnalysisComment<'a> { + summary_section: &'a str, + tail: &'a str, +} + +#[derive(askama::Template, Default)] +#[template(path = "support_chat.md.jinja")] +struct SupportChatPrompt; + +pub struct SupportIssueTemplateInput<'a> { + pub description: &'a str, + pub platform: &'a str, + pub arch: &'a str, + pub os_version: &'a str, + pub app_version: &'a str, + pub source: &'a str, +} + +pub fn render_bug_report(input: SupportIssueTemplateInput<'_>) -> Result { + askama::Template::render(&BugReportBody { + description: input.description, + platform: input.platform, + arch: input.arch, + os_version: input.os_version, + app_version: input.app_version, + source: input.source, + }) +} + +pub fn render_feature_request( + input: SupportIssueTemplateInput<'_>, +) -> Result { + askama::Template::render(&FeatureRequestBody { + description: input.description, + platform: input.platform, + arch: input.arch, + os_version: input.os_version, + app_version: input.app_version, + source: input.source, + }) +} + +pub fn render_log_analysis(summary_section: &str, tail: &str) -> Result { + askama::Template::render(&LogAnalysisComment { + summary_section, + tail, + }) +} + +pub fn render_support_chat() -> Result { + askama::Template::render(&SupportChatPrompt) +} diff --git a/plugins/auth/Cargo.toml b/plugins/auth/Cargo.toml index 57c70b357f..2175a2bd9e 100644 --- a/plugins/auth/Cargo.toml +++ b/plugins/auth/Cargo.toml @@ -11,12 +11,14 @@ description = "" tauri-plugin = { workspace = true, features = ["build"] } [dev-dependencies] +dirs = { workspace = true } specta-typescript = { workspace = true } tauri-plugin-store = { workspace = true } tokio = { workspace = true, features = ["macros"] } [dependencies] hypr-supabase-auth = { workspace = true } +hypr-template-support = { workspace = true } tauri-plugin-store2 = { workspace = true } tauri = { workspace = true, features = ["test"] } diff --git a/plugins/auth/js/bindings.gen.ts b/plugins/auth/js/bindings.gen.ts index 391bccf07c..eaf10f7bec 100644 --- a/plugins/auth/js/bindings.gen.ts +++ b/plugins/auth/js/bindings.gen.ts @@ -45,6 +45,14 @@ async clear() : Promise> { if(e instanceof Error) throw e; else return { status: "error", error: e as any }; } +}, +async getAccountInfo() : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("plugin:auth|get_account_info") }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} } } @@ -58,6 +66,7 @@ async clear() : Promise> { /** user-defined types **/ +export type AccountInfo = { userId: string; email: string | null; fullName: string | null; avatarUrl: string | null; stripeCustomerId: string | null } export type Claims = { sub: string; email?: string | null; entitlements?: string[]; subscription_status?: SubscriptionStatus | null; trial_end?: number | null } export type SubscriptionStatus = "incomplete" | "incomplete_expired" | "trialing" | "active" | "past_due" | "canceled" | "unpaid" | "paused" diff --git a/plugins/auth/permissions/autogenerated/commands/get_account_info.toml b/plugins/auth/permissions/autogenerated/commands/get_account_info.toml new file mode 100644 index 0000000000..5ef07146eb --- /dev/null +++ b/plugins/auth/permissions/autogenerated/commands/get_account_info.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-get-account-info" +description = "Enables the get_account_info command without any pre-configured scope." +commands.allow = ["get_account_info"] + +[[permission]] +identifier = "deny-get-account-info" +description = "Denies the get_account_info command without any pre-configured scope." +commands.deny = ["get_account_info"] diff --git a/plugins/auth/permissions/autogenerated/reference.md b/plugins/auth/permissions/autogenerated/reference.md index a906c7d805..b28a50585f 100644 --- a/plugins/auth/permissions/autogenerated/reference.md +++ b/plugins/auth/permissions/autogenerated/reference.md @@ -9,6 +9,7 @@ Default permissions for the plugin - `allow-set-item` - `allow-remove-item` - `allow-clear` +- `allow-get-account-info` ## Permission Table @@ -74,6 +75,32 @@ Denies the decode_claims command without any pre-configured scope. +`auth:allow-get-account-info` + + + + +Enables the get_account_info command without any pre-configured scope. + + + + + + + +`auth:deny-get-account-info` + + + + +Denies the get_account_info command without any pre-configured scope. + + + + + + + `auth:allow-get-item` diff --git a/plugins/auth/permissions/default.toml b/plugins/auth/permissions/default.toml index 25cb78b54c..755f307ddc 100644 --- a/plugins/auth/permissions/default.toml +++ b/plugins/auth/permissions/default.toml @@ -1,3 +1,3 @@ [default] description = "Default permissions for the plugin" -permissions = ["allow-decode-claims", "allow-get-item", "allow-set-item", "allow-remove-item", "allow-clear"] +permissions = ["allow-decode-claims", "allow-get-item", "allow-set-item", "allow-remove-item", "allow-clear", "allow-get-account-info"] diff --git a/plugins/auth/permissions/schemas/schema.json b/plugins/auth/permissions/schemas/schema.json index c8d12aa687..adfd0a2e8b 100644 --- a/plugins/auth/permissions/schemas/schema.json +++ b/plugins/auth/permissions/schemas/schema.json @@ -318,6 +318,18 @@ "const": "deny-decode-claims", "markdownDescription": "Denies the decode_claims command without any pre-configured scope." }, + { + "description": "Enables the get_account_info command without any pre-configured scope.", + "type": "string", + "const": "allow-get-account-info", + "markdownDescription": "Enables the get_account_info command without any pre-configured scope." + }, + { + "description": "Denies the get_account_info command without any pre-configured scope.", + "type": "string", + "const": "deny-get-account-info", + "markdownDescription": "Denies the get_account_info command without any pre-configured scope." + }, { "description": "Enables the get_item command without any pre-configured scope.", "type": "string", @@ -355,10 +367,10 @@ "markdownDescription": "Denies the set_item command without any pre-configured scope." }, { - "description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-decode-claims`\n- `allow-get-item`\n- `allow-set-item`\n- `allow-remove-item`\n- `allow-clear`", + "description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-decode-claims`\n- `allow-get-item`\n- `allow-set-item`\n- `allow-remove-item`\n- `allow-clear`\n- `allow-get-account-info`", "type": "string", "const": "default", - "markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-decode-claims`\n- `allow-get-item`\n- `allow-set-item`\n- `allow-remove-item`\n- `allow-clear`" + "markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-decode-claims`\n- `allow-get-item`\n- `allow-set-item`\n- `allow-remove-item`\n- `allow-clear`\n- `allow-get-account-info`" } ] } diff --git a/plugins/auth/src/commands.rs b/plugins/auth/src/commands.rs index 5a3d50858d..11ba037276 100644 --- a/plugins/auth/src/commands.rs +++ b/plugins/auth/src/commands.rs @@ -6,6 +6,14 @@ pub(crate) fn decode_claims(token: String) -> Result( + app: tauri::AppHandle, +) -> Result, String> { + app.get_account_info().map_err(|e| e.to_string()) +} + #[tauri::command] #[specta::specta] pub(crate) async fn get_item( diff --git a/plugins/auth/src/ext.rs b/plugins/auth/src/ext.rs index 31a8f47f56..871641c4ee 100644 --- a/plugins/auth/src/ext.rs +++ b/plugins/auth/src/ext.rs @@ -1,10 +1,52 @@ +use hypr_template_support::AccountInfo; use tauri_plugin_store2::Store2PluginExt; +pub(crate) fn parse_account_info(scope_str: &str) -> Result, crate::Error> { + let entries: serde_json::Map = serde_json::from_str(scope_str)?; + + // Supabase SDK stores the session under a key matching `sb-{ref}-auth-token` + let session_str = entries + .iter() + .find_map(|(k, v)| k.ends_with("-auth-token").then(|| v.as_str()).flatten()); + + let Some(session_str) = session_str else { + return Ok(None); + }; + + #[derive(serde::Deserialize)] + struct Session { + user: SessionUser, + } + #[derive(serde::Deserialize)] + struct SessionUser { + id: String, + email: Option, + user_metadata: Option, + } + #[derive(serde::Deserialize)] + struct UserMetadata { + full_name: Option, + avatar_url: Option, + stripe_customer_id: Option, + } + + let session: Session = serde_json::from_str(session_str)?; + let metadata = session.user.user_metadata; + Ok(Some(AccountInfo { + user_id: session.user.id, + email: session.user.email, + full_name: metadata.as_ref().and_then(|m| m.full_name.clone()), + avatar_url: metadata.as_ref().and_then(|m| m.avatar_url.clone()), + stripe_customer_id: metadata.as_ref().and_then(|m| m.stripe_customer_id.clone()), + })) +} + pub trait AuthPluginExt { fn get_item(&self, key: String) -> Result, crate::Error>; fn set_item(&self, key: String, value: String) -> Result<(), crate::Error>; fn remove_item(&self, key: String) -> Result<(), crate::Error>; fn clear_auth(&self) -> Result<(), crate::Error>; + fn get_account_info(&self) -> Result, crate::Error>; } impl> crate::AuthPluginExt for T { @@ -33,4 +75,18 @@ impl> crate::AuthPluginExt for T { store.save()?; Ok(()) } + + fn get_account_info(&self) -> Result, crate::Error> { + let raw_store = self.store2().store()?; + + let scope_str = match raw_store + .get(crate::PLUGIN_NAME) + .and_then(|v| v.as_str().map(String::from)) + { + Some(s) => s, + None => return Ok(None), + }; + + parse_account_info(&scope_str) + } } diff --git a/plugins/auth/src/lib.rs b/plugins/auth/src/lib.rs index bb7f818629..7c1ab8924e 100644 --- a/plugins/auth/src/lib.rs +++ b/plugins/auth/src/lib.rs @@ -17,6 +17,7 @@ fn make_specta_builder() -> tauri_specta::Builder { commands::set_item::, commands::remove_item::, commands::clear::, + commands::get_account_info::, ]) .typ::() .error_handling(tauri_specta::ErrorHandlingMode::Result) @@ -68,4 +69,27 @@ mod test { let _ = app.set_item("test_key".to_string(), "test_value".to_string()); let _ = app.get_item("test_key".to_string()); } + + #[test] + fn test_parse_account_info() { + let store_path = dirs::data_dir() + .unwrap() + .join("hyprnote") + .join("store.json"); + + let store_content: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&store_path).unwrap()).unwrap(); + + let scope_str = store_content[PLUGIN_NAME].as_str().unwrap(); + let result = ext::parse_account_info(scope_str).unwrap(); + let info = result.expect("should have account info"); + + assert!(!info.user_id.is_empty()); + assert!(info.email.is_some()); + assert!(info.full_name.is_some()); + assert!(info.avatar_url.is_some()); + assert!(info.stripe_customer_id.is_some()); + + eprintln!("{:#?}", info); + } } diff --git a/plugins/misc/Cargo.toml b/plugins/misc/Cargo.toml index b40136dd53..6d34b71e24 100644 --- a/plugins/misc/Cargo.toml +++ b/plugins/misc/Cargo.toml @@ -18,6 +18,7 @@ specta-typescript = { workspace = true } [dependencies] hypr-buffer = { workspace = true } hypr-host = { workspace = true } +hypr-template-support = { workspace = true } tauri = { workspace = true, features = ["test"] } tauri-specta = { workspace = true, features = ["derive", "typescript"] } @@ -25,3 +26,4 @@ tauri-specta = { workspace = true, features = ["derive", "typescript"] } lazy_static = { workspace = true } regex = { workspace = true } specta = { workspace = true } +sysinfo = { workspace = true } diff --git a/plugins/misc/build.rs b/plugins/misc/build.rs index e25b3af398..66f2feffd4 100644 --- a/plugins/misc/build.rs +++ b/plugins/misc/build.rs @@ -1,6 +1,7 @@ const COMMANDS: &[&str] = &[ "get_git_hash", "get_fingerprint", + "get_device_info", "opinionated_md_to_html", "delete_session_folder", "parse_meeting_link", diff --git a/plugins/misc/js/bindings.gen.ts b/plugins/misc/js/bindings.gen.ts index 16808270c5..aed4f56f19 100644 --- a/plugins/misc/js/bindings.gen.ts +++ b/plugins/misc/js/bindings.gen.ts @@ -1,108 +1,134 @@ // @ts-nocheck +/** tauri-specta globals **/ +import { + Channel as TAURI_CHANNEL, + invoke as TAURI_INVOKE, +} from "@tauri-apps/api/core"; +import * as TAURI_API_EVENT from "@tauri-apps/api/event"; +import { type WebviewWindow as __WebviewWindow__ } from "@tauri-apps/api/webviewWindow"; // This file was generated by [tauri-specta](https://github.com/oscartbeaumont/tauri-specta). Do not edit this file manually. /** user-defined commands **/ - export const commands = { -async getGitHash() : Promise> { + async getGitHash(): Promise> { try { - return { status: "ok", data: await TAURI_INVOKE("plugin:misc|get_git_hash") }; -} catch (e) { - if(e instanceof Error) throw e; - else return { status: "error", error: e as any }; -} -}, -async getFingerprint() : Promise> { + return { + status: "ok", + data: await TAURI_INVOKE("plugin:misc|get_git_hash"), + }; + } catch (e) { + if (e instanceof Error) throw e; + else return { status: "error", error: e as any }; + } + }, + async getFingerprint(): Promise> { try { - return { status: "ok", data: await TAURI_INVOKE("plugin:misc|get_fingerprint") }; -} catch (e) { - if(e instanceof Error) throw e; - else return { status: "error", error: e as any }; -} -}, -async opinionatedMdToHtml(text: string) : Promise> { + return { + status: "ok", + data: await TAURI_INVOKE("plugin:misc|get_fingerprint"), + }; + } catch (e) { + if (e instanceof Error) throw e; + else return { status: "error", error: e as any }; + } + }, + async getDeviceInfo( + locale: string | null, + ): Promise> { try { - return { status: "ok", data: await TAURI_INVOKE("plugin:misc|opinionated_md_to_html", { text }) }; -} catch (e) { - if(e instanceof Error) throw e; - else return { status: "error", error: e as any }; -} -}, -async parseMeetingLink(text: string) : Promise { + return { + status: "ok", + data: await TAURI_INVOKE("plugin:misc|get_device_info", { + locale, + }), + }; + } catch (e) { + if (e instanceof Error) throw e; + else return { status: "error", error: e as any }; + } + }, + async opinionatedMdToHtml(text: string): Promise> { + try { + return { + status: "ok", + data: await TAURI_INVOKE("plugin:misc|opinionated_md_to_html", { + text, + }), + }; + } catch (e) { + if (e instanceof Error) throw e; + else return { status: "error", error: e as any }; + } + }, + async parseMeetingLink(text: string): Promise { return await TAURI_INVOKE("plugin:misc|parse_meeting_link", { text }); -} -} + }, +}; /** user-defined events **/ - - /** user-defined constants **/ - - /** user-defined types **/ - - -/** tauri-specta globals **/ - -import { - invoke as TAURI_INVOKE, - Channel as TAURI_CHANNEL, -} from "@tauri-apps/api/core"; -import * as TAURI_API_EVENT from "@tauri-apps/api/event"; -import { type WebviewWindow as __WebviewWindow__ } from "@tauri-apps/api/webviewWindow"; +export type DeviceInfo = { + platform: string; + arch: string; + osVersion: string; + appVersion: string; + buildHash?: string | null; + locale?: string | null; +}; type __EventObj__ = { - listen: ( - cb: TAURI_API_EVENT.EventCallback, - ) => ReturnType>; - once: ( - cb: TAURI_API_EVENT.EventCallback, - ) => ReturnType>; - emit: null extends T - ? (payload?: T) => ReturnType - : (payload: T) => ReturnType; + listen: ( + cb: TAURI_API_EVENT.EventCallback, + ) => ReturnType>; + once: ( + cb: TAURI_API_EVENT.EventCallback, + ) => ReturnType>; + emit: null extends T + ? (payload?: T) => ReturnType + : (payload: T) => ReturnType; }; export type Result = - | { status: "ok"; data: T } - | { status: "error"; error: E }; + | { status: "ok"; data: T } + | { status: "error"; error: E }; function __makeEvents__>( - mappings: Record, + mappings: Record, ) { - return new Proxy( - {} as unknown as { - [K in keyof T]: __EventObj__ & { - (handle: __WebviewWindow__): __EventObj__; - }; - }, - { - get: (_, event) => { - const name = mappings[event as keyof T]; - - return new Proxy((() => {}) as any, { - apply: (_, __, [window]: [__WebviewWindow__]) => ({ - listen: (arg: any) => window.listen(name, arg), - once: (arg: any) => window.once(name, arg), - emit: (arg: any) => window.emit(name, arg), - }), - get: (_, command: keyof __EventObj__) => { - switch (command) { - case "listen": - return (arg: any) => TAURI_API_EVENT.listen(name, arg); - case "once": - return (arg: any) => TAURI_API_EVENT.once(name, arg); - case "emit": - return (arg: any) => TAURI_API_EVENT.emit(name, arg); - } - }, - }); - }, - }, - ); + return new Proxy( + {} as unknown as { + [K in keyof T]: __EventObj__ & { + (handle: __WebviewWindow__): __EventObj__; + }; + }, + { + get: (_, event) => { + const name = mappings[event as keyof T]; + + return new Proxy((() => {}) as any, { + apply: (_, __, [window]: [__WebviewWindow__]) => ({ + listen: (arg: any) => window.listen(name, arg), + once: (arg: any) => window.once(name, arg), + emit: (arg: any) => window.emit(name, arg), + }), + get: (_, command: keyof __EventObj__) => { + switch (command) { + case "listen": + return (arg: any) => TAURI_API_EVENT.listen(name, arg); + case "once": + return (arg: any) => TAURI_API_EVENT.once(name, arg); + case "emit": + return (arg: any) => TAURI_API_EVENT.emit(name, arg); + } + }, + }); + }, + }, + ); } diff --git a/plugins/misc/permissions/autogenerated/commands/get_device_info.toml b/plugins/misc/permissions/autogenerated/commands/get_device_info.toml new file mode 100644 index 0000000000..ef844bfb60 --- /dev/null +++ b/plugins/misc/permissions/autogenerated/commands/get_device_info.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-get-device-info" +description = "Enables the get_device_info command without any pre-configured scope." +commands.allow = ["get_device_info"] + +[[permission]] +identifier = "deny-get-device-info" +description = "Denies the get_device_info command without any pre-configured scope." +commands.deny = ["get_device_info"] diff --git a/plugins/misc/permissions/autogenerated/reference.md b/plugins/misc/permissions/autogenerated/reference.md index 8c5e621302..b10c3aeaca 100644 --- a/plugins/misc/permissions/autogenerated/reference.md +++ b/plugins/misc/permissions/autogenerated/reference.md @@ -6,6 +6,8 @@ Default permissions for the plugin - `allow-get-git-hash` - `allow-get-fingerprint` +- `allow-get-device-info` +- `allow-get-support-device-context` - `allow-opinionated-md-to-html` - `allow-parse-meeting-link` @@ -177,6 +179,32 @@ Denies the delete_session_folder command without any pre-configured scope. +`misc:allow-get-device-info` + + + + +Enables the get_device_info command without any pre-configured scope. + + + + + + + +`misc:deny-get-device-info` + + + + +Denies the get_device_info command without any pre-configured scope. + + + + + + + `misc:allow-get-fingerprint` diff --git a/plugins/misc/permissions/default.toml b/plugins/misc/permissions/default.toml index d04f4c8860..70af82edc1 100644 --- a/plugins/misc/permissions/default.toml +++ b/plugins/misc/permissions/default.toml @@ -3,6 +3,7 @@ description = "Default permissions for the plugin" permissions = [ "allow-get-git-hash", "allow-get-fingerprint", + "allow-get-device-info", "allow-opinionated-md-to-html", "allow-parse-meeting-link", ] diff --git a/plugins/misc/permissions/schemas/schema.json b/plugins/misc/permissions/schemas/schema.json index e38182387b..14515fdfa1 100644 --- a/plugins/misc/permissions/schemas/schema.json +++ b/plugins/misc/permissions/schemas/schema.json @@ -366,6 +366,18 @@ "const": "deny-delete-session-folder", "markdownDescription": "Denies the delete_session_folder command without any pre-configured scope." }, + { + "description": "Enables the get_device_info command without any pre-configured scope.", + "type": "string", + "const": "allow-get-device-info", + "markdownDescription": "Enables the get_device_info command without any pre-configured scope." + }, + { + "description": "Denies the get_device_info command without any pre-configured scope.", + "type": "string", + "const": "deny-get-device-info", + "markdownDescription": "Denies the get_device_info command without any pre-configured scope." + }, { "description": "Enables the get_fingerprint command without any pre-configured scope.", "type": "string", @@ -427,10 +439,10 @@ "markdownDescription": "Denies the reveal_session_in_finder command without any pre-configured scope." }, { - "description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-get-git-hash`\n- `allow-get-fingerprint`\n- `allow-opinionated-md-to-html`\n- `allow-parse-meeting-link`", + "description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-get-git-hash`\n- `allow-get-fingerprint`\n- `allow-get-device-info`\n- `allow-get-support-device-context`\n- `allow-opinionated-md-to-html`\n- `allow-parse-meeting-link`", "type": "string", "const": "default", - "markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-get-git-hash`\n- `allow-get-fingerprint`\n- `allow-opinionated-md-to-html`\n- `allow-parse-meeting-link`" + "markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-get-git-hash`\n- `allow-get-fingerprint`\n- `allow-get-device-info`\n- `allow-get-support-device-context`\n- `allow-opinionated-md-to-html`\n- `allow-parse-meeting-link`" } ] } diff --git a/plugins/misc/src/commands.rs b/plugins/misc/src/commands.rs index f5aef6c1bb..b5c5cc663a 100644 --- a/plugins/misc/src/commands.rs +++ b/plugins/misc/src/commands.rs @@ -14,6 +14,15 @@ pub async fn get_fingerprint( Ok(app.misc().get_fingerprint()) } +#[tauri::command] +#[specta::specta] +pub async fn get_device_info( + app: tauri::AppHandle, + locale: Option, +) -> Result { + Ok(app.misc().get_device_info(locale)) +} + #[tauri::command] #[specta::specta] pub async fn opinionated_md_to_html( diff --git a/plugins/misc/src/ext.rs b/plugins/misc/src/ext.rs index 5459c42818..e24800b782 100644 --- a/plugins/misc/src/ext.rs +++ b/plugins/misc/src/ext.rs @@ -1,3 +1,5 @@ +use hypr_template_support::DeviceInfo; + pub struct Misc<'a, R: tauri::Runtime, M: tauri::Manager> { #[allow(dead_code)] manager: &'a M, @@ -13,6 +15,17 @@ impl<'a, R: tauri::Runtime, M: tauri::Manager> Misc<'a, R, M> { hypr_host::fingerprint() } + pub fn get_device_info(&self, locale: Option) -> DeviceInfo { + DeviceInfo { + platform: std::env::consts::OS.to_string(), + arch: std::env::consts::ARCH.to_string(), + os_version: sysinfo::System::long_os_version().unwrap_or_default(), + app_version: self.manager.package_info().version.to_string(), + build_hash: Some(self.get_git_hash()), + locale, + } + } + pub fn opinionated_md_to_html(&self, text: impl AsRef) -> Result { hypr_buffer::opinionated_md_to_html(text.as_ref()).map_err(|e| e.to_string()) } diff --git a/plugins/misc/src/lib.rs b/plugins/misc/src/lib.rs index ac1090507a..daa02a01e2 100644 --- a/plugins/misc/src/lib.rs +++ b/plugins/misc/src/lib.rs @@ -11,6 +11,7 @@ fn make_specta_builder() -> tauri_specta::Builder { .commands(tauri_specta::collect_commands![ commands::get_git_hash::, commands::get_fingerprint::, + commands::get_device_info::, commands::opinionated_md_to_html::, commands::parse_meeting_link::, ]) From 013f7c25890e3dfab1f8a7866bc7f6d218fdeb11 Mon Sep 17 00:00:00 2001 From: Yujong Lee Date: Fri, 13 Feb 2026 15:36:21 +0900 Subject: [PATCH 4/9] mcp refactor --- apps/desktop/src/chat/support-mcp-tools.ts | 82 ++++---------- .../components/chat/message/tool/generic.tsx | 2 +- .../components/chat/message/tool/shared.tsx | 2 +- apps/desktop/src/components/chat/session.tsx | 14 ++- apps/desktop/src/hooks/useMCP.ts | 105 ++++++++++++++++++ apps/desktop/src/hooks/useMCPClient.ts | 12 +- apps/desktop/src/hooks/useSupportMCPTools.ts | 96 ++-------------- 7 files changed, 158 insertions(+), 155 deletions(-) create mode 100644 apps/desktop/src/hooks/useMCP.ts diff --git a/apps/desktop/src/chat/support-mcp-tools.ts b/apps/desktop/src/chat/support-mcp-tools.ts index 8eb24d7202..cdeb65a164 100644 --- a/apps/desktop/src/chat/support-mcp-tools.ts +++ b/apps/desktop/src/chat/support-mcp-tools.ts @@ -12,15 +12,10 @@ import type { SubscriptionItem, } from "@hypr/plugin-mcp"; +import type { McpTextContentOutput } from "./mcp-utils"; +import { parseMcpToolOutput } from "./mcp-utils"; import { isRecord } from "./utils"; -export type McpTextContentOutput = { - content: Array<{ - type: string; - text?: string; - }>; -}; - export type SupportMcpTools = { create_issue: { input: CreateIssueParams; output: McpTextContentOutput }; add_comment: { input: AddCommentParams; output: McpTextContentOutput }; @@ -35,56 +30,6 @@ export type SupportMcpTools = { }; }; -export function extractMcpOutputText(output: unknown): string | null { - if (!isRecord(output) || !Array.isArray(output.content)) { - return null; - } - - const text = output.content - .filter( - (item): item is { type: string; text: string } => - isRecord(item) && item.type === "text" && typeof item.text === "string", - ) - .map((item) => item.text) - .join("\n"); - - return text || null; -} - -function readJsonText(output: unknown): unknown { - const text = extractMcpOutputText(output); - if (!text) { - return null; - } - - try { - return JSON.parse(text); - } catch { - return null; - } -} - -function isSearchIssueItem(value: unknown): value is SearchIssueItem { - return ( - isRecord(value) && - typeof value.number === "number" && - typeof value.title === "string" && - typeof value.state === "string" && - typeof value.url === "string" && - typeof value.created_at === "string" && - Array.isArray(value.labels) && - value.labels.every((label) => typeof label === "string") - ); -} - -function parseToolOutput( - output: unknown, - guard: (value: unknown) => value is T, -): T | null { - const value = readJsonText(output); - return guard(value) ? value : null; -} - function isCreateIssueOutput(value: unknown): value is CreateIssueOutput { return ( isRecord(value) && @@ -120,6 +65,19 @@ function isSubscriptionItem(value: unknown): value is SubscriptionItem { ); } +function isSearchIssueItem(value: unknown): value is SearchIssueItem { + return ( + isRecord(value) && + typeof value.number === "number" && + typeof value.title === "string" && + typeof value.state === "string" && + typeof value.url === "string" && + typeof value.created_at === "string" && + Array.isArray(value.labels) && + value.labels.every((label) => typeof label === "string") + ); +} + function isSearchIssuesOutput(value: unknown): value is SearchIssuesOutput { return ( isRecord(value) && @@ -142,29 +100,29 @@ function isBillingPortalOutput( export function parseCreateIssueOutput( output: unknown, ): CreateIssueOutput | null { - return parseToolOutput(output, isCreateIssueOutput); + return parseMcpToolOutput(output, isCreateIssueOutput); } export function parseAddCommentOutput( output: unknown, ): AddCommentOutput | null { - return parseToolOutput(output, isAddCommentOutput); + return parseMcpToolOutput(output, isAddCommentOutput); } export function parseSearchIssuesOutput( output: unknown, ): SearchIssuesOutput | null { - return parseToolOutput(output, isSearchIssuesOutput); + return parseMcpToolOutput(output, isSearchIssuesOutput); } export function parseListSubscriptionsOutput( output: unknown, ): SubscriptionItem[] | null { - return parseToolOutput(output, isSubscriptionList); + return parseMcpToolOutput(output, isSubscriptionList); } export function parseCreateBillingPortalSessionOutput( output: unknown, ): CreateBillingPortalSessionOutput | null { - return parseToolOutput(output, isBillingPortalOutput); + return parseMcpToolOutput(output, isBillingPortalOutput); } diff --git a/apps/desktop/src/components/chat/message/tool/generic.tsx b/apps/desktop/src/components/chat/message/tool/generic.tsx index 14ae583fda..7fee8be33b 100644 --- a/apps/desktop/src/components/chat/message/tool/generic.tsx +++ b/apps/desktop/src/components/chat/message/tool/generic.tsx @@ -1,6 +1,6 @@ import { WrenchIcon } from "lucide-react"; -import { extractMcpOutputText } from "../../../../chat/support-mcp-tools"; +import { extractMcpOutputText } from "../../../../chat/mcp-utils"; import { Disclosure } from "../shared"; import { ToolCard, diff --git a/apps/desktop/src/components/chat/message/tool/shared.tsx b/apps/desktop/src/components/chat/message/tool/shared.tsx index d6ad52d548..0cf23e8416 100644 --- a/apps/desktop/src/components/chat/message/tool/shared.tsx +++ b/apps/desktop/src/components/chat/message/tool/shared.tsx @@ -10,7 +10,7 @@ import { Streamdown } from "streamdown"; import { cn } from "@hypr/utils"; -import { extractMcpOutputText } from "../../../../chat/support-mcp-tools"; +import { extractMcpOutputText } from "../../../../chat/mcp-utils"; import { useElicitation } from "../../../../contexts/elicitation"; export function ToolCard({ diff --git a/apps/desktop/src/components/chat/session.tsx b/apps/desktop/src/components/chat/session.tsx index 9f98b04a7e..ec7d43ca83 100644 --- a/apps/desktop/src/components/chat/session.tsx +++ b/apps/desktop/src/components/chat/session.tsx @@ -457,8 +457,20 @@ function useTransport( typeof systemPromptOverride === "string" || systemPrompt !== undefined; const tools = useMemo(() => { + const localTools = registry.getTools("chat-general"); + + if (extraTools && import.meta.env.DEV) { + for (const key of Object.keys(extraTools)) { + if (key in localTools) { + console.warn( + `[ChatSession] Tool name collision: "${key}" exists in both local registry and extraTools. extraTools will take precedence.`, + ); + } + } + } + return { - ...registry.getTools("chat-general"), + ...localTools, ...extraTools, }; }, [registry, extraTools]); diff --git a/apps/desktop/src/hooks/useMCP.ts b/apps/desktop/src/hooks/useMCP.ts new file mode 100644 index 0000000000..aac5b9d652 --- /dev/null +++ b/apps/desktop/src/hooks/useMCP.ts @@ -0,0 +1,105 @@ +import type { ToolSet } from "ai"; +import { useEffect, useState } from "react"; + +import type { ContextEntity } from "../chat/context-item"; +import type { MCPClientConfig } from "./useMCPClient"; +import { useMCPClient } from "./useMCPClient"; +import { useMCPElicitation } from "./useMCPElicitation"; + +export interface MCPConfig extends MCPClientConfig { + enabled: boolean; + accessToken?: string | null; + promptName?: string; + collectContext?: () => Promise<{ + entities: ContextEntity[]; + block: string | null; + }>; +} + +export function useMCP(config: MCPConfig) { + const { enabled, accessToken, promptName, collectContext } = config; + const { client, isConnected } = useMCPClient(enabled, config, accessToken); + const { pendingElicitation, respondToElicitation } = + useMCPElicitation(client); + + const [tools, setTools] = useState({}); + const [systemPrompt, setSystemPrompt] = useState(); + const [contextEntities, setContextEntities] = useState([]); + const [isReady, setIsReady] = useState(!enabled); + + useEffect(() => { + if (!enabled) { + setTools({}); + setSystemPrompt(undefined); + setContextEntities([]); + setIsReady(true); + return; + } + + if (!isConnected || !client) { + setIsReady(false); + return; + } + + let cancelled = false; + + const load = async () => { + try { + const [contextResult, fetchedTools, prompt] = await Promise.all([ + collectContext?.() ?? Promise.resolve({ entities: [], block: null }), + client.tools(), + promptName + ? client + .experimental_getPrompt({ name: promptName }) + .catch(() => null) + : Promise.resolve(null), + ]); + + if (cancelled) return; + + setContextEntities(contextResult.entities); + setTools(fetchedTools as ToolSet); + + let mcpPrompt: string | undefined; + if (prompt?.messages) { + mcpPrompt = prompt.messages + .map((m: { content: { type: string; text?: string } | string }) => { + if (typeof m.content === "string") return m.content; + if (m.content.type === "text" && m.content.text) + return m.content.text; + return ""; + }) + .filter(Boolean) + .join("\n\n"); + } + setSystemPrompt( + [mcpPrompt, contextResult.block].filter(Boolean).join("\n\n") || + undefined, + ); + setIsReady(true); + } catch (error) { + console.error("Failed to load MCP resources:", error); + if (cancelled) return; + setTools({}); + setSystemPrompt(undefined); + setContextEntities([]); + setIsReady(false); + } + }; + + load(); + + return () => { + cancelled = true; + }; + }, [enabled, client, isConnected, promptName, collectContext]); + + return { + tools, + systemPrompt, + contextEntities, + pendingElicitation, + respondToElicitation, + isReady, + }; +} diff --git a/apps/desktop/src/hooks/useMCPClient.ts b/apps/desktop/src/hooks/useMCPClient.ts index 91fe61534d..c42af27c4b 100644 --- a/apps/desktop/src/hooks/useMCPClient.ts +++ b/apps/desktop/src/hooks/useMCPClient.ts @@ -6,8 +6,14 @@ import { TauriMCPTransport } from "./tauri-mcp-transport"; const TIMEOUT_MS = 5_000; +export interface MCPClientConfig { + endpoint: string; + clientName: string; +} + export function useMCPClient( enabled: boolean, + config: MCPClientConfig, accessToken?: string | null, ): { client: MCPClient | null; isConnected: boolean; error: Error | null } { const [client, setClient] = useState(null); @@ -32,7 +38,7 @@ export function useMCPClient( const init = async () => { try { - const mcpUrl = new URL("/support/mcp", env.VITE_API_URL).toString(); + const mcpUrl = new URL(config.endpoint, env.VITE_API_URL).toString(); const headers: Record = {}; if (accessToken) { @@ -43,7 +49,7 @@ export function useMCPClient( const created = await createMCPClient({ transport, - name: "hyprnote-support-client", + name: config.clientName, capabilities: { elicitation: {} }, onUncaughtError: (err) => { const msg = err instanceof Error ? err.message : String(err); @@ -83,7 +89,7 @@ export function useMCPClient( clientRef.current = null; setClient(null); }; - }, [enabled, accessToken]); + }, [enabled, config.endpoint, config.clientName, accessToken]); return { client, isConnected, error }; } diff --git a/apps/desktop/src/hooks/useSupportMCPTools.ts b/apps/desktop/src/hooks/useSupportMCPTools.ts index 8f3df913b0..c84538528c 100644 --- a/apps/desktop/src/hooks/useSupportMCPTools.ts +++ b/apps/desktop/src/hooks/useSupportMCPTools.ts @@ -1,91 +1,13 @@ -import type { ToolSet } from "ai"; -import { useEffect, useState } from "react"; - -import type { ContextEntity } from "../chat/context-item"; import { collectSupportContextBlock } from "../chat/context/support-block"; -import { useMCPClient } from "./useMCPClient"; -import { useMCPElicitation } from "./useMCPElicitation"; +import { useMCP } from "./useMCP"; export function useSupportMCP(enabled: boolean, accessToken?: string | null) { - const { client, isConnected } = useMCPClient(enabled, accessToken); - const { pendingElicitation, respondToElicitation } = - useMCPElicitation(client); - - const [tools, setTools] = useState({}); - const [systemPrompt, setSystemPrompt] = useState(); - const [contextEntities, setContextEntities] = useState([]); - const [isReady, setIsReady] = useState(!enabled); - - useEffect(() => { - if (!enabled) { - setTools({}); - setSystemPrompt(undefined); - setContextEntities([]); - setIsReady(true); - return; - } - - if (!isConnected || !client) { - setIsReady(false); - return; - } - - let cancelled = false; - - const load = async () => { - try { - const [{ entities, block }, fetchedTools, prompt] = await Promise.all([ - collectSupportContextBlock(), - client.tools(), - client - .experimental_getPrompt({ name: "support_chat" }) - .catch(() => null), - ]); - - if (cancelled) return; - - setContextEntities(entities); - setTools(fetchedTools as ToolSet); - - let mcpPrompt: string | undefined; - if (prompt?.messages) { - mcpPrompt = prompt.messages - .map((m: { content: { type: string; text?: string } | string }) => { - if (typeof m.content === "string") return m.content; - if (m.content.type === "text" && m.content.text) - return m.content.text; - return ""; - }) - .filter(Boolean) - .join("\n\n"); - } - setSystemPrompt( - [mcpPrompt, block].filter(Boolean).join("\n\n") || undefined, - ); - setIsReady(true); - } catch (error) { - console.error("Failed to load MCP resources:", error); - if (cancelled) return; - setTools({}); - setSystemPrompt(undefined); - setContextEntities([]); - setIsReady(false); - } - }; - - load(); - - return () => { - cancelled = true; - }; - }, [enabled, client, isConnected]); - - return { - tools, - systemPrompt, - contextEntities, - pendingElicitation, - respondToElicitation, - isReady, - }; + return useMCP({ + enabled, + endpoint: "/support/mcp", + clientName: "hyprnote-support-client", + accessToken, + promptName: "support_chat", + collectContext: collectSupportContextBlock, + }); } From dbdec2d05b0be2de3ac78168b2841ad024c6c7b5 Mon Sep 17 00:00:00 2001 From: Yujong Lee Date: Fri, 13 Feb 2026 16:01:15 +0900 Subject: [PATCH 5/9] wip --- apps/desktop/src/components/chat/content.tsx | 5 +- apps/desktop/src/components/chat/header.tsx | 10 -- .../chat/{input.tsx => input/hooks.ts} | 133 +--------------- .../src/components/chat/input/index.tsx | 142 ++++++++++++++++++ .../desktop/src/components/chat/input/mcp.tsx | 63 ++++++++ apps/desktop/src/components/chat/view.tsx | 22 +-- .../src/components/main-app-layout.tsx | 8 +- .../components/main/body/chat/tab-content.tsx | 91 +++-------- .../components/main/body/chat/tab-item.tsx | 16 +- .../src/components/main/body/index.tsx | 22 +-- .../components/main/sidebar/profile/index.tsx | 9 +- apps/desktop/src/store/zustand/tabs/basic.ts | 5 +- .../src/store/zustand/tabs/chat-mode.test.ts | 22 ++- .../src/store/zustand/tabs/chat-mode.ts | 2 +- apps/desktop/src/store/zustand/tabs/schema.ts | 11 +- apps/desktop/src/store/zustand/tabs/state.ts | 8 +- .../permissions/autogenerated/reference.md | 1 - plugins/misc/permissions/schemas/schema.json | 4 +- .../tray/src/menu_items/help_report_bug.rs | 7 +- .../src/menu_items/help_suggest_feature.rs | 7 +- plugins/windows/js/bindings.gen.ts | 5 +- plugins/windows/src/tab/mod.rs | 4 +- plugins/windows/src/tab/state.rs | 12 -- 23 files changed, 291 insertions(+), 318 deletions(-) rename apps/desktop/src/components/chat/{input.tsx => input/hooks.ts} (53%) create mode 100644 apps/desktop/src/components/chat/input/index.tsx create mode 100644 apps/desktop/src/components/chat/input/mcp.tsx diff --git a/apps/desktop/src/components/chat/content.tsx b/apps/desktop/src/components/chat/content.tsx index 70fb431c15..8507c1d5c9 100644 --- a/apps/desktop/src/components/chat/content.tsx +++ b/apps/desktop/src/components/chat/content.tsx @@ -5,7 +5,7 @@ import type { HyprUIMessage } from "../../chat/types"; import type { useLanguageModel } from "../../hooks/useLLMConnection"; import { ChatBody } from "./body"; import { ContextBar } from "./context-bar"; -import { ChatMessageInput } from "./input"; +import { ChatMessageInput, type McpIndicator } from "./input"; export function ChatContent({ sessionId, @@ -20,6 +20,7 @@ export function ChatContent({ contextEntities, onRemoveContextEntity, isSystemPromptReady, + mcpIndicator, children, }: { sessionId: string; @@ -38,6 +39,7 @@ export function ChatContent({ contextEntities: ContextEntity[]; onRemoveContextEntity?: (key: string) => void; isSystemPromptReady: boolean; + mcpIndicator?: McpIndicator; children?: React.ReactNode; }) { const disabled = @@ -68,6 +70,7 @@ export function ChatContent({ } isStreaming={status === "streaming" || status === "submitted"} onStop={stop} + mcpIndicator={mcpIndicator} /> ); diff --git a/apps/desktop/src/components/chat/header.tsx b/apps/desktop/src/components/chat/header.tsx index b59a8e8f0e..4a0e152935 100644 --- a/apps/desktop/src/components/chat/header.tsx +++ b/apps/desktop/src/components/chat/header.tsx @@ -1,6 +1,5 @@ import { ChevronDown, - Maximize2Icon, MessageCircle, PanelRightIcon, PictureInPicture2Icon, @@ -25,13 +24,11 @@ export function ChatHeader({ onNewChat, onSelectChat, handleClose, - onOpenInTab, }: { currentChatGroupId: string | undefined; onNewChat: () => void; onSelectChat: (chatGroupId: string) => void; handleClose: () => void; - onOpenInTab?: () => void; }) { const { chat } = useShell(); @@ -56,13 +53,6 @@ export function ChatHeader({
- {onOpenInTab && ( - } - onClick={onOpenInTab} - title="Open in tab" - /> - )} (); -export function ChatMessageInput({ - draftKey, - onSendMessage, - disabled: disabledProp, - isStreaming, - onStop, -}: { - draftKey: string; - onSendMessage: ( - content: string, - parts: Array<{ type: "text"; text: string }>, - ) => void; - disabled?: boolean | { disabled: boolean; message?: string }; - isStreaming?: boolean; - onStop?: () => void; -}) { - const editorRef = useRef<{ editor: TiptapEditor | null }>(null); - const disabled = - typeof disabledProp === "object" ? disabledProp.disabled : disabledProp; - - const { hasContent, initialContent, handleEditorUpdate } = useDraftState({ - draftKey, - }); - const handleSubmit = useSubmit({ - draftKey, - editorRef, - disabled, - onSendMessage, - }); - useAutoFocusEditor({ editorRef, disabled }); - const slashCommandConfig = useSlashCommandConfig(); - - return ( - -
-
- -
- -
- {isStreaming ? ( - - ) : ( - - )} -
-
- {hasContent && ( - - Enter to send, Shift + Enter for new line - - )} -
- ); -} - -function Container({ children }: { children: React.ReactNode }) { - const { chat } = useShell(); - - return ( -
-
- {children} -
-
- ); -} - -const ChatPlaceholder: PlaceholderFunction = ({ node, pos }) => { - "use no memo"; - if (node.type.name === "paragraph" && pos === 0) { - return ( -

- Ask & search about anything, or be creative! -

- ); - } - return ""; -}; - -function useDraftState({ draftKey }: { draftKey: string }) { +export function useDraftState({ draftKey }: { draftKey: string }) { const [hasContent, setHasContent] = useState(false); const initialContent = useRef(draftsByKey.get(draftKey) ?? EMPTY_TIPTAP_DOC); @@ -153,7 +32,7 @@ function useDraftState({ draftKey }: { draftKey: string }) { }; } -function useSubmit({ +export function useSubmit({ draftKey, editorRef, disabled, @@ -182,7 +61,7 @@ function useSubmit({ }, [draftKey, editorRef, disabled, onSendMessage]); } -function useAutoFocusEditor({ +export function useAutoFocusEditor({ editorRef, disabled, }: { @@ -224,7 +103,7 @@ function useAutoFocusEditor({ }, [editorRef, disabled]); } -function useSlashCommandConfig(): SlashCommandConfig { +export function useSlashCommandConfig(): SlashCommandConfig { const chatShortcuts = main.UI.useResultTable( main.QUERIES.visibleChatShortcuts, main.STORE_ID, diff --git a/apps/desktop/src/components/chat/input/index.tsx b/apps/desktop/src/components/chat/input/index.tsx new file mode 100644 index 0000000000..905acac653 --- /dev/null +++ b/apps/desktop/src/components/chat/input/index.tsx @@ -0,0 +1,142 @@ +import { CircleArrowUpIcon, SquareIcon } from "lucide-react"; +import { useRef } from "react"; + +import type { TiptapEditor } from "@hypr/tiptap/chat"; +import ChatEditor from "@hypr/tiptap/chat"; +import type { PlaceholderFunction } from "@hypr/tiptap/shared"; +import { Button } from "@hypr/ui/components/ui/button"; +import { cn } from "@hypr/utils"; + +import { useShell } from "../../../contexts/shell"; +import { + useAutoFocusEditor, + useDraftState, + useSlashCommandConfig, + useSubmit, +} from "./hooks"; +import { type McpIndicator, McpIndicatorBadge } from "./mcp"; + +export type { McpIndicator } from "./mcp"; + +export function ChatMessageInput({ + draftKey, + onSendMessage, + disabled: disabledProp, + isStreaming, + onStop, + mcpIndicator, +}: { + draftKey: string; + onSendMessage: ( + content: string, + parts: Array<{ type: "text"; text: string }>, + ) => void; + disabled?: boolean | { disabled: boolean; message?: string }; + isStreaming?: boolean; + onStop?: () => void; + mcpIndicator?: McpIndicator; +}) { + const editorRef = useRef<{ editor: TiptapEditor | null }>(null); + const disabled = + typeof disabledProp === "object" ? disabledProp.disabled : disabledProp; + + const { hasContent, initialContent, handleEditorUpdate } = useDraftState({ + draftKey, + }); + const handleSubmit = useSubmit({ + draftKey, + editorRef, + disabled, + onSendMessage, + }); + useAutoFocusEditor({ editorRef, disabled }); + const slashCommandConfig = useSlashCommandConfig(); + + return ( + +
+
+ +
+ +
+ {mcpIndicator ? ( + + ) : ( +
+ )} + {isStreaming ? ( + + ) : ( + + )} +
+
+ {hasContent && ( + + Enter to send, Shift + Enter for new line + + )} + + ); +} + +function Container({ children }: { children: React.ReactNode }) { + const { chat } = useShell(); + + return ( +
+
+ {children} +
+
+ ); +} + +const ChatPlaceholder: PlaceholderFunction = ({ node, pos }) => { + "use no memo"; + if (node.type.name === "paragraph" && pos === 0) { + return ( +

+ Ask & search about anything, or be creative! +

+ ); + } + return ""; +}; diff --git a/apps/desktop/src/components/chat/input/mcp.tsx b/apps/desktop/src/components/chat/input/mcp.tsx new file mode 100644 index 0000000000..da5ccb68ee --- /dev/null +++ b/apps/desktop/src/components/chat/input/mcp.tsx @@ -0,0 +1,63 @@ +import { LockIcon } from "lucide-react"; + +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@hypr/ui/components/ui/tooltip"; + +export type McpIndicator = + | { type: "support" } + | { type: "pro"; enabled: boolean }; + +export function McpIndicatorBadge({ indicator }: { indicator: McpIndicator }) { + if (indicator.type === "support") { + return ( + + +
+ + + Support MCP + +
+
+ + This chat is powered by a support MCP server + +
+ ); + } + + if (indicator.enabled) { + return ( + + +
+ + + Pro MCP + +
+
+ + Pro MCP tools are active + +
+ ); + } + + return ( + + +
+ + Pro MCP +
+
+ + Upgrade to Pro to unlock MCP tools + +
+ ); +} diff --git a/apps/desktop/src/components/chat/view.tsx b/apps/desktop/src/components/chat/view.tsx index 72b3849d11..a2c4fa6ac4 100644 --- a/apps/desktop/src/components/chat/view.tsx +++ b/apps/desktop/src/components/chat/view.tsx @@ -35,26 +35,6 @@ export function ChatView() { [setGroupId], ); - const openNew = useTabs((state) => state.openNew); - const tabs = useTabs((state) => state.tabs); - const updateChatTabState = useTabs((state) => state.updateChatTabState); - - const handleOpenInTab = useCallback(() => { - const existingChatTab = tabs.find((t) => t.type === "chat"); - openNew({ - type: "chat", - state: { groupId: groupId ?? null, initialMessage: null, chatType: null }, - }); - if (existingChatTab) { - updateChatTabState(existingChatTab, { - groupId: groupId ?? null, - initialMessage: null, - chatType: null, - }); - } - chat.sendEvent({ type: "OPEN_TAB" }); - }, [openNew, tabs, updateChatTabState, groupId, chat]); - return (
chat.sendEvent({ type: "CLOSE" })} - onOpenInTab={handleOpenInTab} /> )} diff --git a/apps/desktop/src/components/main-app-layout.tsx b/apps/desktop/src/components/main-app-layout.tsx index 8aa00beb08..17dd0a852e 100644 --- a/apps/desktop/src/components/main-app-layout.tsx +++ b/apps/desktop/src/components/main-app-layout.tsx @@ -90,12 +90,12 @@ const useNavigationEvents = () => { openNewNote(); } else { openNew(payload.tab); - if (payload.tab.type === "chat") { + if (payload.tab.type === "chat_support") { if (payload.tab.state) { - const { tabs, updateChatTabState } = useTabs.getState(); - const chatTab = tabs.find((t) => t.type === "chat"); + const { tabs, updateChatSupportTabState } = useTabs.getState(); + const chatTab = tabs.find((t) => t.type === "chat_support"); if (chatTab) { - updateChatTabState(chatTab, payload.tab.state); + updateChatSupportTabState(chatTab, payload.tab.state); } } transitionChatMode({ type: "OPEN_TAB" }); diff --git a/apps/desktop/src/components/main/body/chat/tab-content.tsx b/apps/desktop/src/components/main/body/chat/tab-content.tsx index 1e1d48fc44..4a6bbc2211 100644 --- a/apps/desktop/src/components/main/body/chat/tab-content.tsx +++ b/apps/desktop/src/components/main/body/chat/tab-content.tsx @@ -8,12 +8,8 @@ import type { ContextEntity } from "../../../../chat/context-item"; import { composeContextEntities } from "../../../../chat/context/composer"; import type { HyprUIMessage } from "../../../../chat/types"; import { ElicitationProvider } from "../../../../contexts/elicitation"; -import { - useFeedbackLanguageModel, - useLanguageModel, -} from "../../../../hooks/useLLMConnection"; +import { useFeedbackLanguageModel } from "../../../../hooks/useLLMConnection"; import { useSupportMCP } from "../../../../hooks/useSupportMCPTools"; -import { useChatContext } from "../../../../store/zustand/chat-context"; import type { Tab } from "../../../../store/zustand/tabs"; import { useTabs } from "../../../../store/zustand/tabs"; import { ChatBody } from "../../../chat/body"; @@ -28,70 +24,24 @@ import { StandardTabWrapper } from "../index"; export function TabContentChat({ tab, }: { - tab: Extract; + tab: Extract; }) { - const isSupport = tab.state.chatType === "support"; - return ( - {isSupport ? ( - - ) : ( - - )} + ); } -function GeneralChatTabView({ tab }: { tab: Extract }) { +function SupportChatTabView({ + tab, +}: { + tab: Extract; +}) { const groupId = tab.state.groupId ?? undefined; - const updateChatTabState = useTabs((state) => state.updateChatTabState); - const stableSessionId = useStableSessionId(groupId); - const model = useLanguageModel(); - - const persistedCtx = useChatContext((s) => - groupId ? s.contexts[groupId] : undefined, - ); - const attachedSessionId = persistedCtx?.attachedSessionId ?? undefined; - - const onGroupCreated = useCallback( - (newGroupId: string) => - updateChatTabState(tab, { - ...tab.state, - groupId: newGroupId, - initialMessage: null, - }), - [updateChatTabState, tab], - ); - - const { handleSendMessage } = useChatActions({ - groupId, - onGroupCreated, - }); - - return ( -
- - {(sessionProps) => ( - - )} - -
+ const updateChatSupportTabState = useTabs( + (state) => state.updateChatSupportTabState, ); -} - -function SupportChatTabView({ tab }: { tab: Extract }) { - const groupId = tab.state.groupId ?? undefined; - const updateChatTabState = useTabs((state) => state.updateChatTabState); const { session } = useAuth(); const stableSessionId = useStableSessionId(groupId); @@ -109,12 +59,12 @@ function SupportChatTabView({ tab }: { tab: Extract }) { const onGroupCreated = useCallback( (newGroupId: string) => - updateChatTabState(tab, { + updateChatSupportTabState(tab, { ...tab.state, groupId: newGroupId, initialMessage: null, }), - [updateChatTabState, tab], + [updateChatSupportTabState, tab], ); const { handleSendMessage } = useChatActions({ @@ -151,7 +101,7 @@ function SupportChatTabView({ tab }: { tab: Extract }) { sessionProps={sessionProps} feedbackModel={feedbackModel} handleSendMessage={handleSendMessage} - updateChatTabState={updateChatTabState} + updateChatSupportTabState={updateChatSupportTabState} supportContextEntities={supportContextEntities} pendingElicitation={pendingElicitation} respondToElicitation={respondToElicitation} @@ -167,12 +117,12 @@ function SupportChatTabInner({ sessionProps, feedbackModel, handleSendMessage, - updateChatTabState, + updateChatSupportTabState, supportContextEntities, pendingElicitation, respondToElicitation, }: { - tab: Extract; + tab: Extract; sessionProps: { sessionId: string; messages: HyprUIMessage[]; @@ -191,9 +141,9 @@ function SupportChatTabInner({ parts: HyprUIMessage["parts"], sendMessage: (message: HyprUIMessage) => void, ) => void; - updateChatTabState: ( - tab: Extract, - state: Extract["state"], + updateChatSupportTabState: ( + tab: Extract, + state: Extract["state"], ) => void; supportContextEntities: ContextEntity[]; pendingElicitation?: { message: string } | null; @@ -230,7 +180,7 @@ function SupportChatTabInner({ [{ type: "text", text: initialMessage }], sendMessage, ); - updateChatTabState(tab, { + updateChatSupportTabState(tab, { ...tab.state, initialMessage: null, }); @@ -241,7 +191,7 @@ function SupportChatTabInner({ isSystemPromptReady, handleSendMessage, sendMessage, - updateChatTabState, + updateChatSupportTabState, ]); const mergedContextEntities = composeContextEntities([ @@ -263,6 +213,7 @@ function SupportChatTabInner({ contextEntities={mergedContextEntities} onRemoveContextEntity={onRemoveContextEntity} isSystemPromptReady={isSystemPromptReady} + mcpIndicator={{ type: "support" }} > > = ({ +export const TabItemChat: TabItem> = ({ tab, tabIndex, handleCloseThis, @@ -16,23 +15,14 @@ export const TabItemChat: TabItem> = ({ handleUnpinThis, }) => { const { chat } = useShell(); - const chatTitle = main.UI.useCell( - "chat_groups", - tab.state.groupId || "", - "title", - main.STORE_ID, - ); - - const isSupport = tab.state.chatType === "support"; - return ( } - title={isSupport ? "Chat (Support)" : chatTitle || "Chat"} + title="Chat (Support)" selected={tab.active} pinned={tab.pinned} tabIndex={tabIndex} - accent={isSupport ? "blue" : "neutral"} + accent="blue" handleCloseThis={() => { chat.sendEvent({ type: "CLOSE" }); handleCloseThis(tab); diff --git a/apps/desktop/src/components/main/body/index.tsx b/apps/desktop/src/components/main/body/index.tsx index bad06e89c4..d34c7b8e81 100644 --- a/apps/desktop/src/components/main/body/index.tsx +++ b/apps/desktop/src/components/main/body/index.tsx @@ -564,7 +564,7 @@ function TabItem({ /> ); } - if (tab.type === "chat") { + if (tab.type === "chat_support") { return ( ; } - if (tab.type === "chat") { + if (tab.type === "chat_support") { return ; } if (tab.type === "onboarding") { @@ -681,7 +681,7 @@ function TabChatButton({ if ( currentTab?.type === "ai" || currentTab?.type === "settings" || - currentTab?.type === "chat" || + currentTab?.type === "chat_support" || currentTab?.type === "onboarding" ) { return null; @@ -888,7 +888,7 @@ function useTabsShortcuts() { } else if (currentTab.pinned) { unpin(currentTab); } else { - if (currentTab.type === "chat") { + if (currentTab.type === "chat_support") { chat.sendEvent({ type: "CLOSE" }); } close(currentTab); @@ -1033,20 +1033,6 @@ function useTabsShortcuts() { [newNoteAndListen], ); - useHotkeys( - "mod+shift+j", - () => { - openNew({ type: "chat" }); - chat.sendEvent({ type: "OPEN_TAB" }); - }, - { - preventDefault: true, - enableOnFormTags: true, - enableOnContentEditable: true, - }, - [openNew, chat], - ); - return {}; } diff --git a/apps/desktop/src/components/main/sidebar/profile/index.tsx b/apps/desktop/src/components/main/sidebar/profile/index.tsx index 5850b3f9e8..902bb158c9 100644 --- a/apps/desktop/src/components/main/sidebar/profile/index.tsx +++ b/apps/desktop/src/components/main/sidebar/profile/index.tsx @@ -129,13 +129,12 @@ export function ProfileSection({ onExpandChange }: ProfileSectionProps = {}) { const state = { groupId: null, initialMessage: "I need help.", - chatType: "support" as const, }; - openNew({ type: "chat", state }); - const { tabs, updateChatTabState } = useTabs.getState(); - const existingChatTab = tabs.find((t) => t.type === "chat"); + openNew({ type: "chat_support", state }); + const { tabs, updateChatSupportTabState } = useTabs.getState(); + const existingChatTab = tabs.find((t) => t.type === "chat_support"); if (existingChatTab) { - updateChatTabState(existingChatTab, state); + updateChatSupportTabState(existingChatTab, state); } transitionChatMode({ type: "OPEN_TAB" }); closeMenu(); diff --git a/apps/desktop/src/store/zustand/tabs/basic.ts b/apps/desktop/src/store/zustand/tabs/basic.ts index dbb0d99828..b383866089 100644 --- a/apps/desktop/src/store/zustand/tabs/basic.ts +++ b/apps/desktop/src/store/zustand/tabs/basic.ts @@ -135,7 +135,7 @@ export const createBasicSlice = < } const shouldResetChatMode = - tabToClose.type === "chat" && get().chatMode === "FullTab"; + tabToClose.type === "chat_support" && get().chatMode === "FullTab"; const remainingTabs = tabs.filter((t) => !isSameTab(t, tab)); if (remainingTabs.length === 0) { @@ -181,7 +181,8 @@ export const createBasicSlice = < } const isRemovingChatTab = - tabToKeep.type !== "chat" && tabs.some((t) => t.type === "chat"); + tabToKeep.type !== "chat_support" && + tabs.some((t) => t.type === "chat_support"); const shouldResetChatMode = isRemovingChatTab && get().chatMode === "FullTab"; diff --git a/apps/desktop/src/store/zustand/tabs/chat-mode.test.ts b/apps/desktop/src/store/zustand/tabs/chat-mode.test.ts index f384852c4d..5045dd11ae 100644 --- a/apps/desktop/src/store/zustand/tabs/chat-mode.test.ts +++ b/apps/desktop/src/store/zustand/tabs/chat-mode.test.ts @@ -4,7 +4,7 @@ import { useTabs } from "."; import { createSessionTab, resetTabsStore } from "./test-utils"; const openChatTab = () => { - useTabs.getState().openNew({ type: "chat" }); + useTabs.getState().openNew({ type: "chat_support" }); }; describe("Chat Mode", () => { @@ -53,11 +53,15 @@ describe("Chat Mode + Tab Sync", () => { openChatTab(); useTabs.getState().transitionChatMode({ type: "OPEN_TAB" }); expect(useTabs.getState().chatMode).toBe("FullTab"); - expect(useTabs.getState().tabs.some((t) => t.type === "chat")).toBe(true); + expect(useTabs.getState().tabs.some((t) => t.type === "chat_support")).toBe( + true, + ); useTabs.getState().transitionChatMode({ type: "TOGGLE" }); expect(useTabs.getState().chatMode).toBe("FloatingClosed"); - expect(useTabs.getState().tabs.some((t) => t.type === "chat")).toBe(false); + expect(useTabs.getState().tabs.some((t) => t.type === "chat_support")).toBe( + false, + ); }); test("leaving FullTab via CLOSE closes the chat tab", () => { @@ -66,14 +70,18 @@ describe("Chat Mode + Tab Sync", () => { useTabs.getState().transitionChatMode({ type: "CLOSE" }); expect(useTabs.getState().chatMode).toBe("FloatingClosed"); - expect(useTabs.getState().tabs.some((t) => t.type === "chat")).toBe(false); + expect(useTabs.getState().tabs.some((t) => t.type === "chat_support")).toBe( + false, + ); }); test("closing chat tab directly resets mode from FullTab", () => { openChatTab(); useTabs.getState().transitionChatMode({ type: "OPEN_TAB" }); - const chatTab = useTabs.getState().tabs.find((t) => t.type === "chat")!; + const chatTab = useTabs + .getState() + .tabs.find((t) => t.type === "chat_support")!; useTabs.getState().close(chatTab); expect(useTabs.getState().chatMode).toBe("FloatingClosed"); }); @@ -89,7 +97,9 @@ describe("Chat Mode + Tab Sync", () => { .tabs.find((t) => t.type === "sessions")!; useTabs.getState().closeOthers(sessionTab); expect(useTabs.getState().chatMode).toBe("FloatingClosed"); - expect(useTabs.getState().tabs.some((t) => t.type === "chat")).toBe(false); + expect(useTabs.getState().tabs.some((t) => t.type === "chat_support")).toBe( + false, + ); }); test("closeAll resets mode from FullTab", () => { diff --git a/apps/desktop/src/store/zustand/tabs/chat-mode.ts b/apps/desktop/src/store/zustand/tabs/chat-mode.ts index 79dbd8dc4c..e53ba7a912 100644 --- a/apps/desktop/src/store/zustand/tabs/chat-mode.ts +++ b/apps/desktop/src/store/zustand/tabs/chat-mode.ts @@ -80,7 +80,7 @@ export const createChatModeSlice = < set({ chatMode: nextMode } as Partial); if (currentMode === "FullTab" && nextMode !== "FullTab") { - const chatTab = get().tabs.find((t) => t.type === "chat"); + const chatTab = get().tabs.find((t) => t.type === "chat_support"); if (chatTab) { get().close(chatTab); } diff --git a/apps/desktop/src/store/zustand/tabs/schema.ts b/apps/desktop/src/store/zustand/tabs/schema.ts index a71ee9fbf8..bd4849016e 100644 --- a/apps/desktop/src/store/zustand/tabs/schema.ts +++ b/apps/desktop/src/store/zustand/tabs/schema.ts @@ -109,7 +109,7 @@ export type Tab = state: SearchState; }) | (BaseTab & { - type: "chat"; + type: "chat_support"; state: ChatState; }) | (BaseTab & { type: "onboarding" }); @@ -208,14 +208,13 @@ export const getDefaultState = (tab: TabInput): Tab => { type: "search", state: tab.state ?? { selectedTypes: null, initialQuery: null }, }; - case "chat": + case "chat_support": return { ...base, - type: "chat", + type: "chat_support", state: tab.state ?? { groupId: null, initialMessage: null, - chatType: null, }, }; case "onboarding": @@ -260,8 +259,8 @@ export const uniqueIdfromTab = (tab: Tab): string => { return `ai`; case "search": return `search`; - case "chat": - return `chat`; + case "chat_support": + return `chat_support`; case "onboarding": return `onboarding`; } diff --git a/apps/desktop/src/store/zustand/tabs/state.ts b/apps/desktop/src/store/zustand/tabs/state.ts index adf60bf741..dd9ecc1d46 100644 --- a/apps/desktop/src/store/zustand/tabs/state.ts +++ b/apps/desktop/src/store/zustand/tabs/state.ts @@ -42,9 +42,9 @@ export type StateBasicActions = { tab: Tab, state: Extract["state"], ) => void; - updateChatTabState: ( + updateChatSupportTabState: ( tab: Tab, - state: Extract["state"], + state: Extract["state"], ) => void; }; @@ -69,8 +69,8 @@ export const createStateUpdaterSlice = ( updateTabState(tab, "settings", state, get, set), updateSearchTabState: (tab, state) => updateTabState(tab, "search", state, get, set), - updateChatTabState: (tab, state) => - updateTabState(tab, "chat", state, get, set), + updateChatSupportTabState: (tab, state) => + updateTabState(tab, "chat_support", state, get, set), }); const updateTabState = ( diff --git a/plugins/misc/permissions/autogenerated/reference.md b/plugins/misc/permissions/autogenerated/reference.md index b10c3aeaca..972f7d5d20 100644 --- a/plugins/misc/permissions/autogenerated/reference.md +++ b/plugins/misc/permissions/autogenerated/reference.md @@ -7,7 +7,6 @@ Default permissions for the plugin - `allow-get-git-hash` - `allow-get-fingerprint` - `allow-get-device-info` -- `allow-get-support-device-context` - `allow-opinionated-md-to-html` - `allow-parse-meeting-link` diff --git a/plugins/misc/permissions/schemas/schema.json b/plugins/misc/permissions/schemas/schema.json index 14515fdfa1..7cf9055a44 100644 --- a/plugins/misc/permissions/schemas/schema.json +++ b/plugins/misc/permissions/schemas/schema.json @@ -439,10 +439,10 @@ "markdownDescription": "Denies the reveal_session_in_finder command without any pre-configured scope." }, { - "description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-get-git-hash`\n- `allow-get-fingerprint`\n- `allow-get-device-info`\n- `allow-get-support-device-context`\n- `allow-opinionated-md-to-html`\n- `allow-parse-meeting-link`", + "description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-get-git-hash`\n- `allow-get-fingerprint`\n- `allow-get-device-info`\n- `allow-opinionated-md-to-html`\n- `allow-parse-meeting-link`", "type": "string", "const": "default", - "markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-get-git-hash`\n- `allow-get-fingerprint`\n- `allow-get-device-info`\n- `allow-get-support-device-context`\n- `allow-opinionated-md-to-html`\n- `allow-parse-meeting-link`" + "markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-get-git-hash`\n- `allow-get-fingerprint`\n- `allow-get-device-info`\n- `allow-opinionated-md-to-html`\n- `allow-parse-meeting-link`" } ] } diff --git a/plugins/tray/src/menu_items/help_report_bug.rs b/plugins/tray/src/menu_items/help_report_bug.rs index b6400d717c..19a3b16495 100644 --- a/plugins/tray/src/menu_items/help_report_bug.rs +++ b/plugins/tray/src/menu_items/help_report_bug.rs @@ -16,17 +16,14 @@ impl MenuItemHandler for HelpReportBug { } fn handle(app: &AppHandle) { - use tauri_plugin_windows::{ - AppWindow, ChatState, ChatType, OpenTab, TabInput, WindowsPluginExt, - }; + use tauri_plugin_windows::{AppWindow, ChatState, OpenTab, TabInput, WindowsPluginExt}; use tauri_specta::Event; if app.windows().show(AppWindow::Main).is_ok() { let event = OpenTab { - tab: TabInput::Chat { + tab: TabInput::ChatSupport { state: Some(ChatState { initial_message: Some("I'd like to report a bug.".to_string()), - chat_type: Some(ChatType::Support), ..Default::default() }), }, diff --git a/plugins/tray/src/menu_items/help_suggest_feature.rs b/plugins/tray/src/menu_items/help_suggest_feature.rs index 217249f084..21151a9bec 100644 --- a/plugins/tray/src/menu_items/help_suggest_feature.rs +++ b/plugins/tray/src/menu_items/help_suggest_feature.rs @@ -16,17 +16,14 @@ impl MenuItemHandler for HelpSuggestFeature { } fn handle(app: &AppHandle) { - use tauri_plugin_windows::{ - AppWindow, ChatState, ChatType, OpenTab, TabInput, WindowsPluginExt, - }; + use tauri_plugin_windows::{AppWindow, ChatState, OpenTab, TabInput, WindowsPluginExt}; use tauri_specta::Event; if app.windows().show(AppWindow::Main).is_ok() { let event = OpenTab { - tab: TabInput::Chat { + tab: TabInput::ChatSupport { state: Some(ChatState { initial_message: Some("I'd like to suggest a feature.".to_string()), - chat_type: Some(ChatType::Support), ..Default::default() }), }, diff --git a/plugins/windows/js/bindings.gen.ts b/plugins/windows/js/bindings.gen.ts index 67bce5ff89..18a08372da 100644 --- a/plugins/windows/js/bindings.gen.ts +++ b/plugins/windows/js/bindings.gen.ts @@ -74,8 +74,7 @@ export type AiTab = "transcription" | "intelligence" | "templates" | "shortcuts" export type AppWindow = { type: "main" } | { type: "control" } export type ChangelogState = { previous: string | null; current: string } export type ChatShortcutsState = { isWebMode: boolean | null; selectedMineId: string | null; selectedWebIndex: number | null } -export type ChatState = { groupId: string | null; initialMessage: string | null; chatType: ChatType | null } -export type ChatType = "regular" | "support" +export type ChatState = { groupId: string | null; initialMessage: string | null } export type ContactsState = { selectedOrganization: string | null; selectedPerson: string | null } export type EditorView = { type: "raw" } | { type: "transcript" } | { type: "enhanced"; id: string } | { type: "attachments" } export type ExtensionsState = { selectedExtension: string | null } @@ -85,7 +84,7 @@ export type OpenTab = { tab: TabInput } export type PromptsState = { selectedTask: string | null } export type SearchState = { selectedTypes: string[] | null; initialQuery: string | null } export type SessionsState = { view: EditorView | null; autoStart: boolean | null } -export type TabInput = { type: "sessions"; id: string; state?: SessionsState | null } | { type: "contacts"; state?: ContactsState | null } | { type: "templates"; state?: TemplatesState | null } | { type: "prompts"; state?: PromptsState | null } | { type: "chat_shortcuts"; state?: ChatShortcutsState | null } | { type: "extensions"; state?: ExtensionsState | null } | { type: "humans"; id: string } | { type: "organizations"; id: string } | { type: "folders"; id: string | null } | { type: "empty" } | { type: "extension"; extensionId: string; state?: Partial<{ [key in string]: JsonValue }> | null } | { type: "calendar" } | { type: "changelog"; state: ChangelogState } | { type: "settings" } | { type: "ai"; state?: AiState | null } | { type: "search"; state?: SearchState | null } | { type: "chat"; state?: ChatState | null } | { type: "onboarding" } +export type TabInput = { type: "sessions"; id: string; state?: SessionsState | null } | { type: "contacts"; state?: ContactsState | null } | { type: "templates"; state?: TemplatesState | null } | { type: "prompts"; state?: PromptsState | null } | { type: "chat_shortcuts"; state?: ChatShortcutsState | null } | { type: "extensions"; state?: ExtensionsState | null } | { type: "humans"; id: string } | { type: "organizations"; id: string } | { type: "folders"; id: string | null } | { type: "empty" } | { type: "extension"; extensionId: string; state?: Partial<{ [key in string]: JsonValue }> | null } | { type: "calendar" } | { type: "changelog"; state: ChangelogState } | { type: "settings" } | { type: "ai"; state?: AiState | null } | { type: "search"; state?: SearchState | null } | { type: "chat_support"; state?: ChatState | null } | { type: "onboarding" } export type TemplatesState = { showHomepage: boolean | null; isWebMode: boolean | null; selectedMineId: string | null; selectedWebIndex: number | null } export type VisibilityEvent = { window: AppWindow; visible: boolean } export type WindowDestroyed = { window: AppWindow } diff --git a/plugins/windows/src/tab/mod.rs b/plugins/windows/src/tab/mod.rs index 2b09c36e5b..c5bc8fa3b9 100644 --- a/plugins/windows/src/tab/mod.rs +++ b/plugins/windows/src/tab/mod.rs @@ -75,8 +75,8 @@ common_derives! { #[serde(skip_serializing_if = "Option::is_none")] state: Option, }, - #[serde(rename = "chat")] - Chat { + #[serde(rename = "chat_support")] + ChatSupport { #[serde(skip_serializing_if = "Option::is_none")] state: Option, }, diff --git a/plugins/windows/src/tab/state.rs b/plugins/windows/src/tab/state.rs index 0b56331ba0..bfb80b4acc 100644 --- a/plugins/windows/src/tab/state.rs +++ b/plugins/windows/src/tab/state.rs @@ -94,22 +94,10 @@ crate::common_derives! { } } -crate::common_derives! { - #[derive(Default)] - pub enum ChatType { - #[default] - #[serde(rename = "regular")] - Regular, - #[serde(rename = "support")] - Support, - } -} - crate::common_derives! { #[derive(Default)] pub struct ChatState { pub group_id: Option, pub initial_message: Option, - pub chat_type: Option, } } From 6506db6af4234c5891dbb7441ce65eced1cac5e9 Mon Sep 17 00:00:00 2001 From: Yujong Lee Date: Fri, 13 Feb 2026 16:53:44 +0900 Subject: [PATCH 6/9] some research-mcp infra work --- Cargo.lock | 16 +++++ Cargo.toml | 1 + apps/api/Cargo.toml | 1 + apps/api/openapi.gen.json | 12 ++++ apps/api/src/env.rs | 3 + apps/api/src/main.rs | 5 ++ .../desktop/src/components/chat/input/mcp.tsx | 48 ++------------- apps/desktop/src/components/chat/view.tsx | 12 +++- .../components/main/body/chat/tab-content.tsx | 2 +- apps/desktop/src/hooks/useMCP.ts | 16 ++++- apps/desktop/src/hooks/useResearchMCP.ts | 11 ++++ ...useSupportMCPTools.ts => useSupportMCP.ts} | 0 crates/api-research/Cargo.toml | 1 + .../assets/research_chat.md.jinja | 3 +- crates/api-research/src/config.rs | 1 + crates/api-research/src/lib.rs | 3 +- crates/api-research/src/mcp/mod.rs | 7 +-- crates/api-research/src/mcp/server.rs | 15 +++++ crates/api-research/src/mcp/tools/mod.rs | 2 + crates/api-research/src/mcp/tools/read_url.rs | 16 +++++ crates/api-research/src/routes.rs | 12 ++++ crates/api-research/src/state.rs | 9 ++- crates/jina/Cargo.toml | 16 +++++ crates/jina/src/client.rs | 58 +++++++++++++++++++ crates/jina/src/error.rs | 22 +++++++ crates/jina/src/lib.rs | 51 ++++++++++++++++ crates/jina/src/reader.rs | 19 ++++++ packages/api-client/src/generated/index.ts | 2 +- .../api-client/src/generated/types.gen.ts | 10 ++++ 29 files changed, 318 insertions(+), 56 deletions(-) create mode 100644 apps/desktop/src/hooks/useResearchMCP.ts rename apps/desktop/src/hooks/{useSupportMCPTools.ts => useSupportMCP.ts} (100%) create mode 100644 crates/api-research/src/mcp/tools/read_url.rs create mode 100644 crates/api-research/src/routes.rs create mode 100644 crates/jina/Cargo.toml create mode 100644 crates/jina/src/client.rs create mode 100644 crates/jina/src/error.rs create mode 100644 crates/jina/src/lib.rs create mode 100644 crates/jina/src/reader.rs diff --git a/Cargo.lock b/Cargo.lock index b390ed6f26..2dfabe8a8f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -518,6 +518,7 @@ dependencies = [ "api-calendar", "api-env", "api-nango", + "api-research", "api-subscription", "api-support", "axum 0.8.8", @@ -603,6 +604,7 @@ dependencies = [ "askama", "axum 0.8.8", "exa", + "jina", "mcp", "rmcp", "serde", @@ -10100,6 +10102,20 @@ dependencies = [ "jiff-tzdb", ] +[[package]] +name = "jina" +version = "0.1.0" +dependencies = [ + "reqwest 0.13.2", + "schemars 1.2.1", + "serde", + "serde_json", + "specta", + "thiserror 2.0.18", + "tokio", + "url", +] + [[package]] name = "jni" version = "0.21.1" diff --git a/Cargo.toml b/Cargo.toml index 6df8f960d4..a2d775684b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -66,6 +66,7 @@ hypr-host = { path = "crates/host", package = "host" } hypr-http = { path = "crates/http", package = "hypr-http-utils" } hypr-importer-core = { path = "crates/importer-core", package = "importer-core" } hypr-intercept = { path = "crates/intercept", package = "intercept" } +hypr-jina = { path = "crates/jina", package = "jina" } hypr-kyutai = { path = "crates/kyutai", package = "kyutai" } hypr-language = { path = "crates/language", package = "language" } hypr-llama = { path = "crates/llama", package = "llama" } diff --git a/apps/api/Cargo.toml b/apps/api/Cargo.toml index b049b480a3..7687f55919 100644 --- a/apps/api/Cargo.toml +++ b/apps/api/Cargo.toml @@ -9,6 +9,7 @@ hypr-api-auth = { workspace = true } hypr-api-calendar = { workspace = true } hypr-api-env = { workspace = true } hypr-api-nango = { workspace = true } +hypr-api-research = { workspace = true } hypr-api-subscription = { workspace = true } hypr-api-support = { workspace = true } hypr-llm-proxy = { workspace = true } diff --git a/apps/api/openapi.gen.json b/apps/api/openapi.gen.json index 4d97a7b4e3..7635e2a8b8 100644 --- a/apps/api/openapi.gen.json +++ b/apps/api/openapi.gen.json @@ -466,6 +466,18 @@ "arch": { "type": "string" }, + "buildHash": { + "type": [ + "string", + "null" + ] + }, + "locale": { + "type": [ + "string", + "null" + ] + }, "osVersion": { "type": "string" }, diff --git a/apps/api/src/env.rs b/apps/api/src/env.rs index e6e8aef0c9..f3e84a9c82 100644 --- a/apps/api/src/env.rs +++ b/apps/api/src/env.rs @@ -27,6 +27,9 @@ pub struct Env { #[serde(flatten)] pub support_database: hypr_api_support::SupportDatabaseEnv, + pub exa_api_key: String, + pub jina_api_key: String, + #[serde(flatten)] pub llm: hypr_llm_proxy::Env, #[serde(flatten)] diff --git a/apps/api/src/main.rs b/apps/api/src/main.rs index 54ef8769d1..6c7e3bdf16 100644 --- a/apps/api/src/main.rs +++ b/apps/api/src/main.rs @@ -54,6 +54,10 @@ async fn app() -> Router { &env.supabase, auth_state_support.clone(), ); + let research_config = hypr_api_research::ResearchConfig { + exa_api_key: env.exa_api_key.clone(), + jina_api_key: env.jina_api_key.clone(), + }; let webhook_routes = Router::new().nest( "/nango", @@ -63,6 +67,7 @@ async fn app() -> Router { let pro_routes = Router::new() .merge(hypr_transcribe_proxy::listen_router(stt_config.clone())) .merge(hypr_llm_proxy::chat_completions_router(llm_config.clone())) + .merge(hypr_api_research::router(research_config)) .nest("/stt", hypr_transcribe_proxy::router(stt_config)) .nest("/llm", hypr_llm_proxy::router(llm_config)) .nest("/calendar", hypr_api_calendar::router(calendar_config)) diff --git a/apps/desktop/src/components/chat/input/mcp.tsx b/apps/desktop/src/components/chat/input/mcp.tsx index da5ccb68ee..ac6028af5a 100644 --- a/apps/desktop/src/components/chat/input/mcp.tsx +++ b/apps/desktop/src/components/chat/input/mcp.tsx @@ -1,4 +1,4 @@ -import { LockIcon } from "lucide-react"; +import { WrenchIcon } from "lucide-react"; import { Tooltip, @@ -6,58 +6,22 @@ import { TooltipTrigger, } from "@hypr/ui/components/ui/tooltip"; -export type McpIndicator = - | { type: "support" } - | { type: "pro"; enabled: boolean }; +export type McpIndicator = { type: "support" }; export function McpIndicatorBadge({ indicator }: { indicator: McpIndicator }) { if (indicator.type === "support") { return ( -
- - - Support MCP - +
+ + Support MCP
- This chat is powered by a support MCP server + Connected to support tools ); } - - if (indicator.enabled) { - return ( - - -
- - - Pro MCP - -
-
- - Pro MCP tools are active - -
- ); - } - - return ( - - -
- - Pro MCP -
-
- - Upgrade to Pro to unlock MCP tools - -
- ); } diff --git a/apps/desktop/src/components/chat/view.tsx b/apps/desktop/src/components/chat/view.tsx index a2c4fa6ac4..a8292e5cc1 100644 --- a/apps/desktop/src/components/chat/view.tsx +++ b/apps/desktop/src/components/chat/view.tsx @@ -3,6 +3,7 @@ import { useCallback } from "react"; import { useShell } from "../../contexts/shell"; import { useLanguageModel } from "../../hooks/useLLMConnection"; import { useTabs } from "../../store/zustand/tabs"; +import { ChatBody } from "./body"; import { ChatContent } from "./content"; import { ChatHeader } from "./header"; import { ChatSession } from "./session"; @@ -54,8 +55,15 @@ export function ChatView() { {...sessionProps} model={model} handleSendMessage={handleSendMessage} - mcpIndicator={{ type: "pro", enabled: false }} - /> + > + + )}
diff --git a/apps/desktop/src/components/main/body/chat/tab-content.tsx b/apps/desktop/src/components/main/body/chat/tab-content.tsx index 4a6bbc2211..f7b4a8e3e1 100644 --- a/apps/desktop/src/components/main/body/chat/tab-content.tsx +++ b/apps/desktop/src/components/main/body/chat/tab-content.tsx @@ -9,7 +9,7 @@ import { composeContextEntities } from "../../../../chat/context/composer"; import type { HyprUIMessage } from "../../../../chat/types"; import { ElicitationProvider } from "../../../../contexts/elicitation"; import { useFeedbackLanguageModel } from "../../../../hooks/useLLMConnection"; -import { useSupportMCP } from "../../../../hooks/useSupportMCPTools"; +import { useSupportMCP } from "../../../../hooks/useSupportMCP"; import type { Tab } from "../../../../store/zustand/tabs"; import { useTabs } from "../../../../store/zustand/tabs"; import { ChatBody } from "../../../chat/body"; diff --git a/apps/desktop/src/hooks/useMCP.ts b/apps/desktop/src/hooks/useMCP.ts index aac5b9d652..a71a171050 100644 --- a/apps/desktop/src/hooks/useMCP.ts +++ b/apps/desktop/src/hooks/useMCP.ts @@ -18,7 +18,11 @@ export interface MCPConfig extends MCPClientConfig { export function useMCP(config: MCPConfig) { const { enabled, accessToken, promptName, collectContext } = config; - const { client, isConnected } = useMCPClient(enabled, config, accessToken); + const { client, isConnected, error } = useMCPClient( + enabled, + config, + accessToken, + ); const { pendingElicitation, respondToElicitation } = useMCPElicitation(client); @@ -36,6 +40,14 @@ export function useMCP(config: MCPConfig) { return; } + if (isConnected && !client && error) { + setTools({}); + setSystemPrompt(undefined); + setContextEntities([]); + setIsReady(true); + return; + } + if (!isConnected || !client) { setIsReady(false); return; @@ -92,7 +104,7 @@ export function useMCP(config: MCPConfig) { return () => { cancelled = true; }; - }, [enabled, client, isConnected, promptName, collectContext]); + }, [enabled, client, isConnected, error, promptName, collectContext]); return { tools, diff --git a/apps/desktop/src/hooks/useResearchMCP.ts b/apps/desktop/src/hooks/useResearchMCP.ts new file mode 100644 index 0000000000..b4772d8170 --- /dev/null +++ b/apps/desktop/src/hooks/useResearchMCP.ts @@ -0,0 +1,11 @@ +import { useMCP } from "./useMCP"; + +export function useResearchMCP(enabled: boolean, accessToken?: string | null) { + return useMCP({ + enabled, + endpoint: "/research/mcp", + clientName: "hyprnote-research-client", + accessToken, + promptName: "research_chat", + }); +} diff --git a/apps/desktop/src/hooks/useSupportMCPTools.ts b/apps/desktop/src/hooks/useSupportMCP.ts similarity index 100% rename from apps/desktop/src/hooks/useSupportMCPTools.ts rename to apps/desktop/src/hooks/useSupportMCP.ts diff --git a/crates/api-research/Cargo.toml b/crates/api-research/Cargo.toml index 4af77163a9..ddc46e2352 100644 --- a/crates/api-research/Cargo.toml +++ b/crates/api-research/Cargo.toml @@ -5,6 +5,7 @@ edition = "2024" [dependencies] hypr-exa = { workspace = true } +hypr-jina = { workspace = true } hypr-mcp = { workspace = true } axum = { workspace = true } diff --git a/crates/api-research/assets/research_chat.md.jinja b/crates/api-research/assets/research_chat.md.jinja index 6a990de1fd..c9b92dd572 100644 --- a/crates/api-research/assets/research_chat.md.jinja +++ b/crates/api-research/assets/research_chat.md.jinja @@ -7,6 +7,7 @@ Your role is to help users with: - Gathering research materials to support their notes When a user asks you to find information, use the `search` tool to search the web. -When a user wants to read the contents of specific URLs, use the `get_contents` tool to retrieve the text content. +When a user wants to read the contents of specific URLs, use the `get_contents` tool or the `read_url` tool to retrieve the text content. +Use `read_url` when you need a single URL converted to clean markdown text. Keep your responses concise and informative. Provide sources and links when sharing research findings. diff --git a/crates/api-research/src/config.rs b/crates/api-research/src/config.rs index a93ca01ad9..ca646a7c6f 100644 --- a/crates/api-research/src/config.rs +++ b/crates/api-research/src/config.rs @@ -1,4 +1,5 @@ #[derive(Clone)] pub struct ResearchConfig { pub exa_api_key: String, + pub jina_api_key: String, } diff --git a/crates/api-research/src/lib.rs b/crates/api-research/src/lib.rs index 2a4273fe60..f076c6d2b7 100644 --- a/crates/api-research/src/lib.rs +++ b/crates/api-research/src/lib.rs @@ -1,6 +1,7 @@ mod config; mod mcp; +mod routes; mod state; pub use config::ResearchConfig; -pub use mcp::mcp_service; +pub use routes::router; diff --git a/crates/api-research/src/mcp/mod.rs b/crates/api-research/src/mcp/mod.rs index 04e755e6df..dedc600130 100644 --- a/crates/api-research/src/mcp/mod.rs +++ b/crates/api-research/src/mcp/mod.rs @@ -2,15 +2,12 @@ mod prompts; mod server; mod tools; -use crate::config::ResearchConfig; use crate::state::AppState; use server::ResearchMcpServer; -pub fn mcp_service( - config: ResearchConfig, +pub(crate) fn mcp_service( + state: AppState, ) -> rmcp::transport::streamable_http_server::StreamableHttpService { - let state = AppState::new(config); - hypr_mcp::create_service(move || Ok(ResearchMcpServer::new(state.clone()))) } diff --git a/crates/api-research/src/mcp/server.rs b/crates/api-research/src/mcp/server.rs index 46d6a715c5..e8d00386ef 100644 --- a/crates/api-research/src/mcp/server.rs +++ b/crates/api-research/src/mcp/server.rs @@ -55,6 +55,21 @@ impl ResearchMcpServer { ) -> Result { tools::get_contents(&self.state, params).await } + + #[tool( + description = "Read a URL and convert it to clean, LLM-friendly markdown text. Powered by Jina Reader.", + annotations( + read_only_hint = true, + destructive_hint = false, + open_world_hint = true + ) + )] + async fn read_url( + &self, + Parameters(params): Parameters, + ) -> Result { + tools::read_url(&self.state, params).await + } } #[tool_handler] diff --git a/crates/api-research/src/mcp/tools/mod.rs b/crates/api-research/src/mcp/tools/mod.rs index 46086272ba..cc99d0b794 100644 --- a/crates/api-research/src/mcp/tools/mod.rs +++ b/crates/api-research/src/mcp/tools/mod.rs @@ -1,5 +1,7 @@ mod get_contents; +mod read_url; mod search; pub(crate) use get_contents::get_contents; +pub(crate) use read_url::read_url; pub(crate) use search::search; diff --git a/crates/api-research/src/mcp/tools/read_url.rs b/crates/api-research/src/mcp/tools/read_url.rs new file mode 100644 index 0000000000..777b2d85fc --- /dev/null +++ b/crates/api-research/src/mcp/tools/read_url.rs @@ -0,0 +1,16 @@ +use rmcp::{ErrorData as McpError, model::*}; + +use crate::state::AppState; + +pub(crate) async fn read_url( + state: &AppState, + params: hypr_jina::ReadUrlRequest, +) -> Result { + let text = state + .jina + .read_url(params) + .await + .map_err(|e: hypr_jina::Error| McpError::internal_error(e.to_string(), None))?; + + Ok(CallToolResult::success(vec![Content::text(text)])) +} diff --git a/crates/api-research/src/routes.rs b/crates/api-research/src/routes.rs new file mode 100644 index 0000000000..0dbe03396e --- /dev/null +++ b/crates/api-research/src/routes.rs @@ -0,0 +1,12 @@ +use axum::Router; + +use crate::config::ResearchConfig; +use crate::mcp::mcp_service; +use crate::state::AppState; + +pub fn router(config: ResearchConfig) -> Router { + let state = AppState::new(config); + let mcp = mcp_service(state); + + Router::new().nest("/research", Router::new().nest_service("/mcp", mcp)) +} diff --git a/crates/api-research/src/state.rs b/crates/api-research/src/state.rs index 72a1bb4486..0594dd9e4e 100644 --- a/crates/api-research/src/state.rs +++ b/crates/api-research/src/state.rs @@ -1,10 +1,12 @@ use hypr_exa::ExaClient; +use hypr_jina::JinaClient; use crate::config::ResearchConfig; #[derive(Clone)] pub(crate) struct AppState { pub(crate) exa: ExaClient, + pub(crate) jina: JinaClient, } impl AppState { @@ -14,6 +16,11 @@ impl AppState { .build() .expect("failed to build Exa client"); - Self { exa } + let jina = JinaClient::builder() + .api_key(config.jina_api_key) + .build() + .expect("failed to build Jina client"); + + Self { exa, jina } } } diff --git a/crates/jina/Cargo.toml b/crates/jina/Cargo.toml new file mode 100644 index 0000000000..ad987392fb --- /dev/null +++ b/crates/jina/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "jina" +version = "0.1.0" +edition = "2024" + +[dependencies] +reqwest = { workspace = true } +schemars = { workspace = true } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +specta = { workspace = true, features = ["derive", "serde_json"] } +thiserror = { workspace = true } +url = { workspace = true } + +[dev-dependencies] +tokio = { workspace = true, features = ["rt", "macros"] } diff --git a/crates/jina/src/client.rs b/crates/jina/src/client.rs new file mode 100644 index 0000000000..b3e18d4e5e --- /dev/null +++ b/crates/jina/src/client.rs @@ -0,0 +1,58 @@ +#[derive(Clone, Default)] +pub struct JinaClientBuilder { + api_key: Option, +} + +#[derive(Clone)] +pub struct JinaClient { + pub(crate) client: reqwest::Client, +} + +impl JinaClient { + pub fn builder() -> JinaClientBuilder { + JinaClientBuilder::default() + } +} + +impl JinaClientBuilder { + pub fn api_key(mut self, api_key: impl Into) -> Self { + self.api_key = Some(api_key.into()); + self + } + + pub fn build(self) -> Result { + let api_key = self.api_key.ok_or(crate::Error::MissingApiKey)?; + + let mut headers = reqwest::header::HeaderMap::new(); + + let auth_str = format!("Bearer {}", api_key); + let mut auth_value = reqwest::header::HeaderValue::from_str(&auth_str) + .map_err(|_| crate::Error::InvalidApiKey)?; + auth_value.set_sensitive(true); + + headers.insert(reqwest::header::AUTHORIZATION, auth_value); + headers.insert( + reqwest::header::ACCEPT, + reqwest::header::HeaderValue::from_static("text/plain"), + ); + + let client = reqwest::Client::builder() + .default_headers(headers) + .build()?; + + Ok(JinaClient { client }) + } +} + +pub(crate) async fn check_response( + response: reqwest::Response, +) -> Result { + let status = response.status(); + if status.is_success() { + Ok(response) + } else { + let status_code = status.as_u16(); + let body = response.text().await.unwrap_or_default(); + Err(crate::Error::Api(status_code, body)) + } +} diff --git a/crates/jina/src/error.rs b/crates/jina/src/error.rs new file mode 100644 index 0000000000..b1d72115b1 --- /dev/null +++ b/crates/jina/src/error.rs @@ -0,0 +1,22 @@ +use serde::{Serialize, ser::Serializer}; + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("API error (status {0}): {1}")] + Api(u16, String), + #[error(transparent)] + Request(#[from] reqwest::Error), + #[error("missing api key")] + MissingApiKey, + #[error("invalid api key")] + InvalidApiKey, +} + +impl Serialize for Error { + fn serialize(&self, serializer: S) -> std::result::Result + where + S: Serializer, + { + serializer.serialize_str(self.to_string().as_ref()) + } +} diff --git a/crates/jina/src/lib.rs b/crates/jina/src/lib.rs new file mode 100644 index 0000000000..5d1e8b6a1c --- /dev/null +++ b/crates/jina/src/lib.rs @@ -0,0 +1,51 @@ +mod client; +mod error; +mod reader; + +pub use client::*; +pub use error::*; +pub use reader::*; + +macro_rules! common_derives { + ($item:item) => { + #[derive( + Debug, + Eq, + PartialEq, + Clone, + serde::Serialize, + serde::Deserialize, + specta::Type, + schemars::JsonSchema, + )] + $item + }; +} + +pub(crate) use common_derives; + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + #[ignore] + async fn test_read_url() { + let client = JinaClientBuilder::default() + .api_key("test-key") + .build() + .unwrap(); + + let _ = client + .read_url(ReadUrlRequest { + url: "https://example.com".to_string(), + }) + .await; + } + + #[test] + fn test_build_missing_api_key() { + let result = JinaClientBuilder::default().build(); + assert!(result.is_err()); + } +} diff --git a/crates/jina/src/reader.rs b/crates/jina/src/reader.rs new file mode 100644 index 0000000000..2d191bfd31 --- /dev/null +++ b/crates/jina/src/reader.rs @@ -0,0 +1,19 @@ +use crate::client::{JinaClient, check_response}; +use crate::common_derives; + +common_derives! { + pub struct ReadUrlRequest { + #[schemars(description = "The URL to read and convert to markdown")] + pub url: String, + } +} + +impl JinaClient { + pub async fn read_url(&self, req: ReadUrlRequest) -> Result { + let url = format!("https://r.jina.ai/{}", req.url); + + let response = self.client.get(&url).send().await?; + let response = check_response(response).await?; + Ok(response.text().await?) + } +} diff --git a/packages/api-client/src/generated/index.ts b/packages/api-client/src/generated/index.ts index e92ab3e2ab..b1d72843bb 100644 --- a/packages/api-client/src/generated/index.ts +++ b/packages/api-client/src/generated/index.ts @@ -1,4 +1,4 @@ // This file is auto-generated by @hey-api/openapi-ts export { canStartTrial, createConnectSession, createEvent, handler, listCalendars, listEvents, nangoWebhook, type Options, startTrial, submit } from './sdk.gen'; -export type { CanStartTrialData, CanStartTrialErrors, CanStartTrialReason, CanStartTrialResponse, CanStartTrialResponse2, CanStartTrialResponses, ClientOptions, ConnectSessionResponse, CreateConnectSessionData, CreateConnectSessionErrors, CreateConnectSessionResponse, CreateConnectSessionResponses, CreateEventData, CreateEventErrors, CreateEventRequest, CreateEventResponse, CreateEventResponse2, CreateEventResponses, DeviceInfo, EventAttendee, EventDateTime, FeedbackRequest, FeedbackResponse, FeedbackType, HandlerData, HandlerErrors, HandlerResponse, HandlerResponses, Interval, ListCalendarsData, ListCalendarsErrors, ListCalendarsRequest, ListCalendarsResponse, ListCalendarsResponse2, ListCalendarsResponses, ListEventsData, ListEventsErrors, ListEventsRequest, ListEventsResponse, ListEventsResponse2, ListEventsResponses, NangoWebhookData, NangoWebhookErrors, NangoWebhookResponse, NangoWebhookResponses, PipelineStatus, StartTrialData, StartTrialErrors, StartTrialReason, StartTrialResponse, StartTrialResponse2, StartTrialResponses, SttStatusResponse, SubmitData, SubmitError, SubmitErrors, SubmitResponse, SubmitResponses, WebhookResponse } from './types.gen'; +export type { CanStartTrialData, CanStartTrialErrors, CanStartTrialReason, CanStartTrialResponse, CanStartTrialResponse2, CanStartTrialResponses, ClientOptions, ConnectSessionResponse, CreateConnectSessionData, CreateConnectSessionErrors, CreateConnectSessionResponse, CreateConnectSessionResponses, CreateEventData, CreateEventErrors, CreateEventRequest, CreateEventResponse, CreateEventResponse2, CreateEventResponses, DeviceInfo, EventAttendee, EventDateTime, FeedbackRequest, FeedbackResponse, FeedbackType, HandlerData, HandlerErrors, HandlerResponse, HandlerResponses, Interval, ListCalendarsData, ListCalendarsErrors, ListCalendarsRequest, ListCalendarsResponse, ListCalendarsResponse2, ListCalendarsResponses, ListEventsData, ListEventsErrors, ListEventsRequest, ListEventsResponse, ListEventsResponse2, ListEventsResponses, NangoWebhookData, NangoWebhookErrors, NangoWebhookResponse, NangoWebhookResponses, PipelineStatus, StartTrialData, StartTrialErrors, StartTrialReason, StartTrialResponse, StartTrialResponse2, StartTrialResponses, SttStatusResponse, SubmitData, SubmitError, SubmitErrors, SubmitResponse, SubmitResponses, TranscriptToken, WebhookResponse } from './types.gen'; diff --git a/packages/api-client/src/generated/types.gen.ts b/packages/api-client/src/generated/types.gen.ts index fe3cde8dc3..d83a97771f 100644 --- a/packages/api-client/src/generated/types.gen.ts +++ b/packages/api-client/src/generated/types.gen.ts @@ -34,6 +34,8 @@ export type CreateEventResponse = { export type DeviceInfo = { appVersion: string; arch: string; + buildHash?: string | null; + locale?: string | null; osVersion: string; platform: string; }; @@ -103,9 +105,17 @@ export type StartTrialResponse = { export type SttStatusResponse = { error?: string | null; status: PipelineStatus; + tokens?: Array | null; transcript?: string | null; }; +export type TranscriptToken = { + endMs: number; + speaker?: number | null; + startMs: number; + text: string; +}; + export type WebhookResponse = { status: string; }; From f69ca4016ae710081d72330f8dfa6c26ddaebaba Mon Sep 17 00:00:00 2001 From: Yujong Lee Date: Fri, 13 Feb 2026 16:56:28 +0900 Subject: [PATCH 7/9] content loading fix --- apps/desktop/src/components/chat/session.tsx | 63 ++++++++++++++++++-- 1 file changed, 57 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/components/chat/session.tsx b/apps/desktop/src/components/chat/session.tsx index ec7d43ca83..8e23fa3f98 100644 --- a/apps/desktop/src/components/chat/session.tsx +++ b/apps/desktop/src/components/chat/session.tsx @@ -15,6 +15,7 @@ import { commands as templateCommands, type Transcript, } from "@hypr/plugin-template"; +import { isValidTiptapContent, json2md } from "@hypr/tiptap/shared"; import { type ContextEntity, @@ -275,6 +276,25 @@ export function ChatSession({ ); } +function tiptapJsonToMarkdown( + tiptapJson: string | undefined, +): string | undefined { + if (typeof tiptapJson !== "string" || !tiptapJson.trim()) { + return undefined; + } + + try { + const parsed = JSON.parse(tiptapJson); + if (!isValidTiptapContent(parsed)) { + return undefined; + } + const md = json2md(parsed); + return md.trim() || undefined; + } catch { + return undefined; + } +} + function useTransport( attachedSessionId?: string, modelOverride?: LanguageModel, @@ -298,6 +318,31 @@ function useTransport( main.STORE_ID, ); + const enhancedNoteIds = main.UI.useSliceRowIds( + main.INDEXES.enhancedNotesBySession, + attachedSessionId ?? "", + main.STORE_ID, + ); + + const enhancedContent = useMemo((): string | undefined => { + if (!store || !enhancedNoteIds || enhancedNoteIds.length === 0) { + return undefined; + } + + const parts: string[] = []; + for (const noteId of enhancedNoteIds) { + const content = store.getCell("enhanced_notes", noteId, "content") as + | string + | undefined; + const md = tiptapJsonToMarkdown(content); + if (md) { + parts.push(md); + } + } + + return parts.length > 0 ? parts.join("\n\n---\n\n") : undefined; + }, [store, enhancedNoteIds]); + const transcriptIds = main.UI.useSliceRowIds( main.INDEXES.transcriptBySession, attachedSessionId ?? "", @@ -357,6 +402,11 @@ function useTransport( }; }, [words, store]); + const rawContentMd = useMemo( + () => tiptapJsonToMarkdown(rawMd as string | undefined), + [rawMd], + ); + const sessionEntity = useMemo((): Extract< ContextEntity, { kind: "session" } @@ -367,12 +417,11 @@ function useTransport( const titleStr = (title as string) || undefined; const dateStr = (createdAt as string) || undefined; - const rawContentStr = (rawMd as string) || undefined; const chatContext: ChatContext = { title: titleStr ?? null, date: dateStr ?? null, - rawContent: rawContentStr ?? null, - enhancedContent: null, + rawContent: rawContentMd ?? null, + enhancedContent: enhancedContent ?? null, transcript: transcript ?? null, }; @@ -380,7 +429,8 @@ function useTransport( !titleStr && !dateStr && words.length === 0 && - !rawContentStr && + !rawContentMd && + !enhancedContent && participantIds.length === 0 && !event?.title ) { @@ -392,7 +442,7 @@ function useTransport( key: "session:info", chatContext, wordCount: words.length > 0 ? words.length : undefined, - rawNotePreview: rawContentStr, + rawNotePreview: rawContentMd, participantCount: participantIds.length, eventTitle: event?.title ?? undefined, }; @@ -400,7 +450,8 @@ function useTransport( attachedSessionId, title, createdAt, - rawMd, + rawContentMd, + enhancedContent, words.length, participantIds.length, event, From 95b2fe8d85cff298429ef696dc121e5c1232ea8a Mon Sep 17 00:00:00 2001 From: Yujong Lee Date: Fri, 13 Feb 2026 17:06:46 +0900 Subject: [PATCH 8/9] fix: resolve CI typecheck errors in web app - Fix AUTHOR_NAMES unused in content-collections.ts - Add fallbacks for optional article.title and meta_description in blog routes Co-authored-by: Cursor --- apps/web/content-collections.ts | 4 +++- apps/web/src/routes/_view/blog/$slug.tsx | 23 +++++++++++++---------- apps/web/src/routes/_view/blog/index.tsx | 4 ++-- apps/web/src/routes/_view/index.tsx | 2 +- 4 files changed, 19 insertions(+), 14 deletions(-) diff --git a/apps/web/content-collections.ts b/apps/web/content-collections.ts index 8167e0e8c3..17b38a7283 100644 --- a/apps/web/content-collections.ts +++ b/apps/web/content-collections.ts @@ -11,6 +11,8 @@ import { z } from "zod"; import { AUTHOR_NAMES } from "@/lib/team"; import { VersionPlatform } from "@/scripts/versioning"; +const authorNames = AUTHOR_NAMES; + async function embedGithubCode(content: string): Promise { const githubCodeRegex = //g; let result = content; @@ -87,7 +89,7 @@ const articles = defineCollection({ display_title: z.string().optional(), meta_title: z.string(), meta_description: z.string(), - author: z.enum(AUTHOR_NAMES as [string, ...string[]]), + author: z.enum(authorNames as [string, ...string[]]), date: z.string(), coverImage: z.string().optional(), featured: z.boolean().optional(), diff --git a/apps/web/src/routes/_view/blog/$slug.tsx b/apps/web/src/routes/_view/blog/$slug.tsx index 335fde6b0f..adb702bde6 100644 --- a/apps/web/src/routes/_view/blog/$slug.tsx +++ b/apps/web/src/routes/_view/blog/$slug.tsx @@ -45,22 +45,24 @@ export const Route = createFileRoute("/_view/blog/$slug")({ const { article } = loaderData; const url = `https://hyprnote.com/blog/${article.slug}`; + const title = article.title ?? ""; + const metaDescription = article.meta_description ?? ""; const ogImage = article.coverImage || - `https://hyprnote.com/og?type=blog&title=${encodeURIComponent(article.title)}${article.author ? `&author=${encodeURIComponent(article.author)}` : ""}${article.date ? `&date=${encodeURIComponent(new Date(article.date).toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric" }))}` : ""}&v=1`; + `https://hyprnote.com/og?type=blog&title=${encodeURIComponent(title)}${article.author ? `&author=${encodeURIComponent(article.author)}` : ""}${article.date ? `&date=${encodeURIComponent(new Date(article.date).toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric" }))}` : ""}&v=1`; return { meta: [ - { title: `${article.title} - Hyprnote Blog` }, - { name: "description", content: article.meta_description }, + { title: `${title} - Hyprnote Blog` }, + { name: "description", content: metaDescription }, { tag: "link", attrs: { rel: "canonical", href: url } }, { property: "og:title", - content: `${article.title} - Hyprnote Blog`, + content: `${title} - Hyprnote Blog`, }, { property: "og:description", - content: article.meta_description, + content: metaDescription, }, { property: "og:type", content: "article" }, { property: "og:url", content: url }, @@ -68,11 +70,11 @@ export const Route = createFileRoute("/_view/blog/$slug")({ { name: "twitter:card", content: "summary_large_image" }, { name: "twitter:title", - content: `${article.title} - Hyprnote Blog`, + content: `${title} - Hyprnote Blog`, }, { name: "twitter:description", - content: article.meta_description, + content: metaDescription, }, { name: "twitter:image", content: ogImage }, ...(article.author @@ -435,9 +437,10 @@ function TableOfContents({ } function RelatedArticleCard({ article }: { article: any }) { + const title = article.title ?? ""; const ogImage = article.coverImage || - `https://hyprnote.com/og?type=blog&title=${encodeURIComponent(article.title)}${article.author ? `&author=${encodeURIComponent(article.author)}` : ""}${article.date ? `&date=${encodeURIComponent(new Date(article.date).toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric" }))}` : ""}&v=1`; + `https://hyprnote.com/og?type=blog&title=${encodeURIComponent(title)}${article.author ? `&author=${encodeURIComponent(article.author)}` : ""}${article.date ? `&date=${encodeURIComponent(new Date(article.date).toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric" }))}` : ""}&v=1`; return ( {article.title}

- {article.title} + {title}

{article.summary} diff --git a/apps/web/src/routes/_view/blog/index.tsx b/apps/web/src/routes/_view/blog/index.tsx index 8d3e7876c8..ed9dc82aaa 100644 --- a/apps/web/src/routes/_view/blog/index.tsx +++ b/apps/web/src/routes/_view/blog/index.tsx @@ -378,7 +378,7 @@ function MostRecentFeaturedCard({ article }: { article: Article }) { {hasCoverImage && ( setCoverImageLoaded(true)} onError={() => setCoverImageError(true)} @@ -470,7 +470,7 @@ function OtherFeaturedCard({ > {article.title} { const ogImage = article.coverImage || - `https://hyprnote.com/og?type=blog&title=${encodeURIComponent(article.title)}${article.author ? `&author=${encodeURIComponent(article.author)}` : ""}${article.date ? `&date=${encodeURIComponent(new Date(article.date).toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric" }))}` : ""}&v=1`; + `https://hyprnote.com/og?type=blog&title=${encodeURIComponent(article.title ?? "")}${article.author ? `&author=${encodeURIComponent(article.author)}` : ""}${article.date ? `&date=${encodeURIComponent(new Date(article.date).toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric" }))}` : ""}&v=1`; return ( Date: Fri, 13 Feb 2026 17:09:22 +0900 Subject: [PATCH 9/9] fix: MCP error recovery and context bar collapse button visibility - useMCP: set isReady=true in catch block to allow chat fallback on transient errors - context-bar: always slice displayChips so expand/collapse button stays visible Co-authored-by: Cursor --- apps/desktop/src/components/chat/context-bar.tsx | 2 +- apps/desktop/src/hooks/useMCP.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/components/chat/context-bar.tsx b/apps/desktop/src/components/chat/context-bar.tsx index ac2b2e2a2e..268724f71e 100644 --- a/apps/desktop/src/components/chat/context-bar.tsx +++ b/apps/desktop/src/components/chat/context-bar.tsx @@ -127,7 +127,7 @@ export function ContextBar({ if (chips.length === 0) return null; const hasOverflow = visibleCount < chips.length; - const displayChips = expanded ? chips : chips.slice(0, visibleCount); + const displayChips = chips.slice(0, visibleCount); return (

diff --git a/apps/desktop/src/hooks/useMCP.ts b/apps/desktop/src/hooks/useMCP.ts index a71a171050..c74470c0ce 100644 --- a/apps/desktop/src/hooks/useMCP.ts +++ b/apps/desktop/src/hooks/useMCP.ts @@ -95,7 +95,7 @@ export function useMCP(config: MCPConfig) { setTools({}); setSystemPrompt(undefined); setContextEntities([]); - setIsReady(false); + setIsReady(true); } };