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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 34 additions & 16 deletions apps/desktop2/src/chat/tools.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,42 @@
import { tool } from "ai";
import { z } from "zod";

export const searchSessionsTool = tool({
description: "Search for sessions",
inputSchema: z.object({
query: z.string().describe("The query to search for"),
}),
execute: async () => {
return { results: [] };
},
});
import { searchFiltersSchema } from "../contexts/search/engine/types";
import type { SearchFilters, SearchHit } from "../contexts/search/engine/types";

export const tools = {
search_sessions: searchSessionsTool,
};
export interface ToolDependencies {
search: (query: string, filters?: SearchFilters | null) => Promise<SearchHit[]>;
}

export const toolFactories = {
search_sessions: (deps: ToolDependencies) => ({
description: `
Search for sessions (meeting notes) using query and filters.
Returns relevant sessions with their content.
`.trim(),
parameters: z.object({
query: z.string().describe("The search query to find relevant sessions"),
filters: searchFiltersSchema.optional().describe("Optional filters for the search query"),
}),
execute: async (params: { query: string; filters?: SearchFilters }) => {
const hits = await deps.search(params.query, params.filters || null);

const results = hits.slice(0, 5).map((hit) => ({
id: hit.document.id,
title: hit.document.title,
content: hit.document.content.slice(0, 500),
score: hit.score,
created_at: hit.document.created_at,
}));

return { results };
},
}),
} as const;

export type Tools = {
[K in keyof typeof tools]: {
input: Parameters<NonNullable<(typeof tools)[K]["execute"]>>[0];
output: Awaited<ReturnType<NonNullable<(typeof tools)[K]["execute"]>>>;
[K in keyof typeof toolFactories]: {
input: Parameters<ReturnType<typeof toolFactories[K]>["execute"]>[0];
output: Awaited<ReturnType<ReturnType<typeof toolFactories[K]>["execute"]>>;
};
};

Expand Down
16 changes: 7 additions & 9 deletions apps/desktop2/src/chat/transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,19 @@ import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
import type { ChatRequestOptions, ChatTransport, UIMessageChunk } from "ai";
import { convertToModelMessages, smoothStream, stepCountIs, streamText } from "ai";

import { searchSessionsTool } from "./tools";
import { ToolRegistry } from "../contexts/tool";
import type { HyprUIMessage } from "./types";

const modelName = "google/gemini-2.5-flash-lite";
const provider = createOpenAICompatible({
name: "openrouter",
baseURL: "https://openrouter.ai/api/v1",
apiKey: "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",
});

export class CustomChatTransport implements ChatTransport<HyprUIMessage> {
constructor(private registry: ToolRegistry) {}

async sendMessages(
options:
& {
Expand All @@ -21,8 +25,8 @@ export class CustomChatTransport implements ChatTransport<HyprUIMessage> {
& { trigger: "submit-message" | "regenerate-message"; messageId: string | undefined }
& ChatRequestOptions,
): Promise<ReadableStream<UIMessageChunk>> {
const model = provider.chatModel("openai/gpt-5-mini");
const tools = this.getTools();
const model = provider.chatModel(modelName);
const tools = this.registry.getForTransport();

const result = streamText({
model,
Expand Down Expand Up @@ -50,10 +54,4 @@ export class CustomChatTransport implements ChatTransport<HyprUIMessage> {
async reconnectToStream(): Promise<ReadableStream<UIMessageChunk> | null> {
return null;
}

private getTools(): Parameters<typeof streamText>[0]["tools"] {
return {
search_sessions: searchSessionsTool,
};
}
}
73 changes: 5 additions & 68 deletions apps/desktop2/src/components/chat/body.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import type { ChatStatus } from "ai";
import { Loader2, MessageCircle, RotateCcw, X } from "lucide-react";
import { MessageCircle } from "lucide-react";
import { useEffect, useRef } from "react";

import { cn } from "@hypr/ui/lib/utils";
import type { HyprUIMessage } from "../../chat/types";
import { useShell } from "../../contexts/shell";
import { ChatBodyMessage } from "./message";
import { ErrorMessage } from "./message/error";
import { LoadingMessage } from "./message/loading";
import { NormalMessage } from "./message/normal";
import { hasRenderableContent } from "./shared";

export function ChatBody({
Expand Down Expand Up @@ -95,7 +97,7 @@ function ChatBodyNonEmpty({
return (
<div className="flex flex-col">
{messages.map((message, index) => (
<ChatBodyMessage
<NormalMessage
key={message.id}
message={message}
handleReload={message.role === "assistant" && index === lastAssistantIndex && onReload ? onReload : undefined}
Expand All @@ -106,68 +108,3 @@ function ChatBodyNonEmpty({
</div>
);
}

function LoadingMessage({ onCancelAndRetry }: { onCancelAndRetry?: () => void }) {
return (
<div className="flex px-4 py-2 justify-start">
<div
className={cn([
"max-w-[80%] rounded-2xl px-4 py-2 bg-gray-100 text-gray-800",
onCancelAndRetry && "relative group",
])}
>
<div className="flex items-center gap-2">
<Loader2 className="w-4 h-4 animate-spin" />
<span className="text-sm">Thinking...</span>
</div>
{onCancelAndRetry && (
<button
onClick={onCancelAndRetry}
className={cn([
"absolute -top-1 -right-1",
"opacity-0 group-hover:opacity-100",
"transition-opacity",
"p-1 rounded-full",
"bg-gray-200 hover:bg-gray-300",
"text-gray-600 hover:text-gray-800",
])}
aria-label="Cancel and retry"
>
<X className="w-3 h-3" />
</button>
)}
</div>
</div>
);
}

function ErrorMessage({ error, onRetry }: { error: Error; onRetry?: () => void }) {
return (
<div className="flex px-4 py-2 justify-start">
<div
className={cn([
"max-w-[80%] rounded-2xl px-4 py-2 bg-red-50 text-red-600 border border-red-200",
onRetry && "relative group",
])}
>
<p className="text-sm">{error.message}</p>
{onRetry && (
<button
onClick={onRetry}
className={cn([
"absolute -top-1 -right-1",
"opacity-0 group-hover:opacity-100",
"transition-opacity",
"p-1 rounded-full",
"bg-red-100 hover:bg-red-200",
"text-red-600 hover:text-red-800",
])}
aria-label="Retry"
>
<RotateCcw className="w-3 h-3" />
</button>
)}
</div>
</div>
);
}
21 changes: 21 additions & 0 deletions apps/desktop2/src/components/chat/message/error.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { RotateCcw } from "lucide-react";

import { ActionButton, MessageBubble, MessageContainer } from "./shared";

export function ErrorMessage({ error, onRetry }: { error: Error; onRetry?: () => void }) {
return (
<MessageContainer align="start">
<MessageBubble variant="error" withActionButton={!!onRetry}>
<p className="text-sm">{error.message}</p>
{onRetry && (
<ActionButton
onClick={onRetry}
variant="error"
icon={RotateCcw}
label="Retry"
/>
)}
</MessageBubble>
</MessageContainer>
);
}
24 changes: 24 additions & 0 deletions apps/desktop2/src/components/chat/message/loading.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { Loader2, X } from "lucide-react";

import { ActionButton, MessageBubble, MessageContainer } from "./shared";

export function LoadingMessage({ onCancelAndRetry }: { onCancelAndRetry?: () => void }) {
return (
<MessageContainer align="start">
<MessageBubble variant="loading" withActionButton={!!onCancelAndRetry}>
<div className="flex items-center gap-2">
<Loader2 className="w-4 h-4 animate-spin" />
<span className="text-sm">Thinking...</span>
</div>
{onCancelAndRetry && (
<ActionButton
onClick={onCancelAndRetry}
variant="default"
icon={X}
label="Cancel and retry"
/>
)}
</MessageBubble>
</MessageContainer>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,14 @@ import { formatDistanceToNow } from "date-fns";
import { BrainIcon, RotateCcw } from "lucide-react";
import { Streamdown } from "streamdown";

import { cn } from "@hypr/ui/lib/utils";
import type { ToolPartType } from "../../../chat/tools";
import type { HyprUIMessage } from "../../../chat/types";
import { hasRenderableContent } from "../shared";
import { Disclosure } from "./shared";
import { ActionButton, Disclosure, MessageBubble, MessageContainer } from "./shared";
import { Tool } from "./tool";
import type { Part } from "./types";

export function ChatBodyMessage({ message, handleReload }: { message: HyprUIMessage; handleReload?: () => void }) {
export function NormalMessage({ message, handleReload }: { message: HyprUIMessage; handleReload?: () => void }) {
const isUser = message.role === "user";

const shouldShowTimestamp = message.metadata?.createdAt
Expand All @@ -22,45 +21,29 @@ export function ChatBodyMessage({ message, handleReload }: { message: HyprUIMess
}

return (
<div
className={cn([
"flex px-4 py-2",
isUser ? "justify-end" : "justify-start",
])}
>
<MessageContainer align={isUser ? "end" : "start"}>
<div className="flex flex-col max-w-[80%]">
<div
className={cn([
"rounded-2xl px-4 py-2",
isUser ? "bg-blue-100 text-gray-800" : "bg-gray-100 text-gray-800",
!isUser && "relative group",
])}
<MessageBubble
variant={isUser ? "user" : "assistant"}
withActionButton={!isUser && !!handleReload}
>
{message.parts.map((part, i) => <Part key={i} part={part as Part} />)}
{!isUser && handleReload && (
<button
<ActionButton
onClick={handleReload}
className={cn([
"absolute -top-1 -right-1",
"opacity-0 group-hover:opacity-100",
"transition-opacity",
"p-1 rounded-full",
"bg-gray-200 hover:bg-gray-300",
"text-gray-600 hover:text-gray-800",
])}
aria-label="Reload message"
>
<RotateCcw className="w-3 h-3" />
</button>
variant="default"
icon={RotateCcw}
label="Reload message"
/>
)}
</div>
</MessageBubble>
{shouldShowTimestamp && message.metadata?.createdAt && (
<div className="text-xs text-gray-400 mt-1 px-2">
{formatDistanceToNow(message.metadata.createdAt, { addSuffix: true })}
</div>
)}
</div>
</div>
</MessageContainer>
);
}

Expand Down
Loading
Loading