diff --git a/Cargo.lock b/Cargo.lock index 2f834b37a0..913a1ae339 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", @@ -640,7 +642,6 @@ version = "0.1.0" dependencies = [ "api-auth", "api-env", - "askama", "async-stripe", "async-stripe-billing", "axum 0.8.8", @@ -655,6 +656,7 @@ dependencies = [ "serde", "serde_json", "sqlx", + "template-support", "thiserror 2.0.18", "tokio", "tokio-util", @@ -10101,6 +10103,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" @@ -18397,6 +18413,7 @@ dependencies = [ name = "tauri-plugin-auth" version = "0.1.0" dependencies = [ + "dirs 6.0.0", "serde", "serde_json", "specta", @@ -18408,6 +18425,7 @@ dependencies = [ "tauri-plugin-store", "tauri-plugin-store2", "tauri-specta", + "template-support", "thiserror 2.0.18", "tokio", ] @@ -19027,9 +19045,11 @@ dependencies = [ "regex", "specta", "specta-typescript", + "sysinfo", "tauri", "tauri-plugin", "tauri-specta", + "template-support", "vergen-gix", ] @@ -19787,6 +19807,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 6ec67ff575..a87819d430 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" } @@ -98,6 +99,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/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/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..cdeb65a164 100644 --- a/apps/desktop/src/chat/support-mcp-tools.ts +++ b/apps/desktop/src/chat/support-mcp-tools.ts @@ -12,12 +12,9 @@ import type { SubscriptionItem, } from "@hypr/plugin-mcp"; -export type McpTextContentOutput = { - content: Array<{ - type: string; - text?: string; - }>; -}; +import type { McpTextContentOutput } from "./mcp-utils"; +import { parseMcpToolOutput } from "./mcp-utils"; +import { isRecord } from "./utils"; export type SupportMcpTools = { create_issue: { input: CreateIssueParams; output: McpTextContentOutput }; @@ -33,71 +30,6 @@ export type SupportMcpTools = { }; }; -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null; -} - -function readJsonText(output: unknown): unknown { - if (!isRecord(output) || !Array.isArray(output.content)) { - return null; - } - - const texts = 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"); - - if (!texts) { - return null; - } - - try { - return JSON.parse(texts); - } 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) && - 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) && @@ -133,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) && @@ -155,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/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..8507c1d5c9 --- /dev/null +++ b/apps/desktop/src/components/chat/content.tsx @@ -0,0 +1,77 @@ +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, type McpIndicator } from "./input"; + +export function ChatContent({ + sessionId, + messages, + sendMessage, + regenerate, + stop, + status, + error, + model, + handleSendMessage, + contextEntities, + onRemoveContextEntity, + isSystemPromptReady, + mcpIndicator, + 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; + mcpIndicator?: McpIndicator; + children?: React.ReactNode; +}) { + const disabled = + !model || + status !== "ready" || + (status === "ready" && !isSystemPromptReady); + + return ( + <> + {children ?? ( + + )} + + + handleSendMessage(content, parts, sendMessage) + } + isStreaming={status === "streaming" || status === "submitted"} + onStop={stop} + mcpIndicator={mcpIndicator} + /> + + ); +} diff --git a/apps/desktop/src/components/chat/context-bar.tsx b/apps/desktop/src/components/chat/context-bar.tsx index aafa1d2dba..268724f71e 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 = 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/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" - /> - )} , - ) => 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], - ); - - return ( - - {attachedSession && ( -
- Attached: {attachedSession.title || "Untitled"} -
- )} -
-
- -
- -
- {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 tiptapJsonToText(json: any): string { - if (!json || typeof json !== "object") { - return ""; - } - - if (json.type === "text") { - return json.text || ""; - } - - if (json.type === "mention") { - return `@${json.attrs?.label || json.attrs?.id || ""}`; - } - - if (json.content && Array.isArray(json.content)) { - return json.content.map(tiptapJsonToText).join(""); - } - - return ""; -} diff --git a/apps/desktop/src/components/chat/input/hooks.ts b/apps/desktop/src/components/chat/input/hooks.ts new file mode 100644 index 0000000000..ca6800b18b --- /dev/null +++ b/apps/desktop/src/components/chat/input/hooks.ts @@ -0,0 +1,176 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +import { commands as analyticsCommands } from "@hypr/plugin-analytics"; +import type { + JSONContent, + SlashCommandConfig, + TiptapEditor, +} from "@hypr/tiptap/chat"; +import { EMPTY_TIPTAP_DOC } from "@hypr/tiptap/shared"; + +import * as main from "../../../store/tinybase/store/main"; + +const draftsByKey = new Map(); + +export 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, + }; +} + +export 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]); +} + +export 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]); +} + +export 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 ""; + } + + if (json.type === "text") { + return json.text || ""; + } + + if (json.type === "mention") { + return `@${json.attrs?.label || json.attrs?.id || ""}`; + } + + if (json.content && Array.isArray(json.content)) { + return json.content.map(tiptapJsonToText).join(""); + } + + return ""; +} 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..ac6028af5a --- /dev/null +++ b/apps/desktop/src/components/chat/input/mcp.tsx @@ -0,0 +1,27 @@ +import { WrenchIcon } from "lucide-react"; + +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@hypr/ui/components/ui/tooltip"; + +export type McpIndicator = { type: "support" }; + +export function McpIndicatorBadge({ indicator }: { indicator: McpIndicator }) { + if (indicator.type === "support") { + return ( + + +
+ + Support MCP +
+
+ + Connected to support tools + +
+ ); + } +} 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..7fee8be33b 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/mcp-utils"; 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/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 7e5ec3256d..8e23fa3f98 100644 --- a/apps/desktop/src/components/chat/session.tsx +++ b/apps/desktop/src/components/chat/session.tsx @@ -1,22 +1,36 @@ 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 { isValidTiptapContent, json2md } from "@hypr/tiptap/shared"; -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 +41,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 +126,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 +141,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 +166,139 @@ 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]); - persistedAssistantIds.current.add(message.id); + const contextEntitiesRef = useRef(contextEntities); + contextEntitiesRef.current = contextEntities; + + // 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 }); +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; } - if (notePreview) { - s.push({ type: "note", preview: notePreview }); - } - return s; - }, [attachedSessionId, sessionTitle, sessionDate, wordCount, notePreview]); - - return useContextCollection(sources); + const md = json2md(parsed); + return md.trim() || undefined; + } catch { + return undefined; + } } function useTransport( - chatType: "general" | "support", attachedSessionId?: string, modelOverride?: LanguageModel, - extraTools?: Record, + extraTools?: ToolSet, systemPromptOverride?: string, ) { const registry = useToolRegistry(); @@ -240,21 +308,41 @@ function useTransport( const language = main.UI.useValue("ai_language", main.STORE_ID) ?? "en"; const [systemPrompt, setSystemPrompt] = useState(); - const { title, rawMd, createdAt } = useSession(attachedSessionId ?? ""); + const { title, rawMd, createdAt, event } = useSession( + attachedSessionId ?? "", + ); - const enhancedNoteIds = main.UI.useSliceRowIds( - main.INDEXES.enhancedNotesBySession, + const participantIds = main.UI.useSliceRowIds( + main.INDEXES.sessionParticipantsBySession, attachedSessionId ?? "", main.STORE_ID, ); - const firstEnhancedNoteId = enhancedNoteIds?.[0]; - const enhancedContent = main.UI.useCell( - "enhanced_notes", - firstEnhancedNoteId ?? "", - "content", + + 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 ?? "", @@ -314,19 +402,68 @@ function useTransport( }; }, [words, store]); - const chatContext = useMemo(() => { + const rawContentMd = useMemo( + () => tiptapJsonToMarkdown(rawMd as string | undefined), + [rawMd], + ); + + 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 chatContext: ChatContext = { + title: titleStr ?? null, + date: dateStr ?? null, + rawContent: rawContentMd ?? null, + enhancedContent: enhancedContent ?? null, + transcript: transcript ?? null, + }; + + if ( + !titleStr && + !dateStr && + words.length === 0 && + !rawContentMd && + !enhancedContent && + 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: rawContentMd, + participantCount: participantIds.length, + eventTitle: event?.title ?? undefined, }; - }, [attachedSessionId, title, rawMd, enhancedContent, createdAt, transcript]); + }, [ + attachedSessionId, + title, + createdAt, + rawContentMd, + enhancedContent, + words.length, + participantIds.length, + event, + transcript, + ]); + + const chatContext = useMemo(() => { + if (!sessionEntity) { + return null; + } + return renderTemplateContext(sessionEntity); + }, [sessionEntity]); useEffect(() => { if (systemPromptOverride) { @@ -344,11 +481,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 +504,39 @@ function useTransport( }, [language, chatContext, systemPromptOverride]); const effectiveSystemPrompt = systemPromptOverride ?? systemPrompt; + const isSystemPromptReady = + 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 { + ...localTools, + ...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..a8292e5cc1 100644 --- a/apps/desktop/src/components/chat/view.tsx +++ b/apps/desktop/src/components/chat/view.tsx @@ -1,15 +1,11 @@ -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"; @@ -40,26 +36,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} /> - {({ - 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-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 5f4cba4fe8..f7b4a8e3e1 100644 --- a/apps/desktop/src/components/main/body/chat/tab-content.tsx +++ b/apps/desktop/src/components/main/body/chat/tab-content.tsx @@ -1,22 +1,19 @@ 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 { - useFeedbackLanguageModel, - useLanguageModel, -} from "../../../../hooks/useLLMConnection"; -import { useSupportMCP } from "../../../../hooks/useSupportMCPTools"; +import { useFeedbackLanguageModel } from "../../../../hooks/useLLMConnection"; +import { useSupportMCP } from "../../../../hooks/useSupportMCP"; 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, @@ -27,44 +24,47 @@ import { StandardTabWrapper } from "../index"; export function TabContentChat({ tab, }: { - tab: Extract; + tab: Extract; }) { return ( - + ); } -function ChatTabView({ tab }: { tab: Extract }) { +function SupportChatTabView({ + tab, +}: { + tab: Extract; +}) { const groupId = tab.state.groupId ?? undefined; - const isSupport = tab.state.chatType === "support"; - const updateChatTabState = useTabs((state) => state.updateChatTabState); + const updateChatSupportTabState = useTabs( + (state) => state.updateChatSupportTabState, + ); 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; const onGroupCreated = useCallback( (newGroupId: string) => - updateChatTabState(tab, { + updateChatSupportTabState(tab, { ...tab.state, groupId: newGroupId, initialMessage: null, }), - [updateChatTabState, tab], + [updateChatSupportTabState, tab], ); const { handleSendMessage } = useChatActions({ @@ -72,43 +72,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,48 +112,54 @@ 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, + updateChatSupportTabState, + supportContextEntities, pendingElicitation, respondToElicitation, }: { - tab: Extract; - messages: HyprUIMessage[]; - sendMessage: (message: HyprUIMessage) => void; - regenerate: () => void; - stop: () => void; - status: "submitted" | "streaming" | "ready" | "error"; - error?: Error; - model: ReturnType; + tab: Extract; + 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"], sendMessage: (message: HyprUIMessage) => void, ) => void; - updateChatTabState: ( - tab: Extract, - state: Extract["state"], + updateChatSupportTabState: ( + 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 +167,9 @@ function ChatTabInner({ if ( !initialMessage || sentRef.current || - !model || + !feedbackModel || status !== "ready" || - !isReady + !isSystemPromptReady ) { return; } @@ -180,38 +180,41 @@ function ChatTabInner({ [{ type: "text", text: initialMessage }], sendMessage, ); - updateChatTabState(tab, { + updateChatSupportTabState(tab, { ...tab.state, initialMessage: null, }); }, [ tab, - model, + feedbackModel, status, - isReady, + isSystemPromptReady, handleSendMessage, sendMessage, - updateChatTabState, + updateChatSupportTabState, ]); - 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/chat/tab-item.tsx b/apps/desktop/src/components/main/body/chat/tab-item.tsx index 92e3fc465d..3a98fd9385 100644 --- a/apps/desktop/src/components/main/body/chat/tab-item.tsx +++ b/apps/desktop/src/components/main/body/chat/tab-item.tsx @@ -1,11 +1,10 @@ import { MessageCircle } from "lucide-react"; import { useShell } from "../../../../contexts/shell"; -import * as main from "../../../../store/tinybase/store/main"; import type { Tab } from "../../../../store/zustand/tabs"; import { type TabItem, TabItemBase } from "../shared"; -export const TabItemChat: TabItem> = ({ +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/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/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/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/useMCP.ts b/apps/desktop/src/hooks/useMCP.ts new file mode 100644 index 0000000000..c74470c0ce --- /dev/null +++ b/apps/desktop/src/hooks/useMCP.ts @@ -0,0 +1,117 @@ +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, error } = 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 && error) { + 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(true); + } + }; + + load(); + + return () => { + cancelled = true; + }; + }, [enabled, client, isConnected, error, 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/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/useSupportMCP.ts b/apps/desktop/src/hooks/useSupportMCP.ts new file mode 100644 index 0000000000..c84538528c --- /dev/null +++ b/apps/desktop/src/hooks/useSupportMCP.ts @@ -0,0 +1,13 @@ +import { collectSupportContextBlock } from "../chat/context/support-block"; +import { useMCP } from "./useMCP"; + +export function useSupportMCP(enabled: boolean, accessToken?: string | null) { + return useMCP({ + enabled, + endpoint: "/support/mcp", + clientName: "hyprnote-support-client", + accessToken, + promptName: "support_chat", + collectContext: collectSupportContextBlock, + }); +} diff --git a/apps/desktop/src/hooks/useSupportMCPTools.ts b/apps/desktop/src/hooks/useSupportMCPTools.ts deleted file mode 100644 index 7f04fd6476..0000000000 --- a/apps/desktop/src/hooks/useSupportMCPTools.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { useEffect, useState } from "react"; - -import { useAuth } from "../auth"; -import type { ContextItem } from "../chat/context-item"; -import { collectSupportContextBlock } from "./useContextCollection"; -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 [systemPrompt, setSystemPrompt] = useState(); - const [contextItems, setContextItems] = useState([]); - const [isReady, setIsReady] = useState(!enabled); - - useEffect(() => { - if (!enabled) { - setTools({}); - setSystemPrompt(undefined); - setContextItems([]); - setIsReady(true); - return; - } - - if (!isConnected || !client) { - setIsReady(false); - return; - } - - let cancelled = false; - - const load = async () => { - try { - const [{ items, block }, fetchedTools, prompt] = await Promise.all([ - collectSupportContextBlock(email, userId), - client.tools(), - client - .experimental_getPrompt({ name: "support_chat" }) - .catch(() => null), - ]); - - if (cancelled) return; - - setContextItems(items); - setTools(fetchedTools); - - 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) setIsReady(true); - } - }; - - load(); - - return () => { - cancelled = true; - }; - }, [enabled, client, isConnected, email, userId]); - - return { - tools, - systemPrompt, - contextItems, - 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/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/apps/web/content-collections.ts b/apps/web/content-collections.ts index 4a877e9c8a..f97dedbd0f 100644 --- a/apps/web/content-collections.ts +++ b/apps/web/content-collections.ts @@ -8,7 +8,6 @@ import rehypeSlug from "rehype-slug"; import remarkGfm from "remark-gfm"; import { z } from "zod"; -import { AUTHOR_NAMES } from "@/lib/team"; import { VersionPlatform } from "@/scripts/versioning"; async function embedGithubCode(content: string): Promise { 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 ( 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/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/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/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/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; }; 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..972f7d5d20 100644 --- a/plugins/misc/permissions/autogenerated/reference.md +++ b/plugins/misc/permissions/autogenerated/reference.md @@ -6,6 +6,7 @@ Default permissions for the plugin - `allow-get-git-hash` - `allow-get-fingerprint` +- `allow-get-device-info` - `allow-opinionated-md-to-html` - `allow-parse-meeting-link` @@ -177,6 +178,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..7cf9055a44 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-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-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::, ]) 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, } }