diff --git a/apps/web/src/routes/agent/$threadId.tsx b/apps/web/src/routes/agent/$threadId.tsx new file mode 100644 index 000000000..1529699be --- /dev/null +++ b/apps/web/src/routes/agent/$threadId.tsx @@ -0,0 +1,787 @@ +import type { UIMessage, UIMessageChunk } from "ai"; +import type { RouterOutput } from "@/libs/orpc/client"; +import { useChat } from "@ai-sdk/react"; +import { t } from "@lingui/core/macro"; +import { Trans } from "@lingui/react/macro"; +import { eventIteratorToUnproxiedDataStream } from "@orpc/client"; +import { + ArchiveIcon, + ArrowClockwiseIcon, + ChatCircleDotsIcon, + ClockCounterClockwiseIcon, + CopyIcon, + DotsThreeVerticalIcon, + FileIcon, + PaperPlaneRightIcon, + PlusIcon, + SidebarSimpleIcon, + SparkleIcon, + SquaresFourIcon, + StopIcon, + TrashIcon, +} from "@phosphor-icons/react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { createFileRoute, Link, useNavigate } from "@tanstack/react-router"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { toast } from "sonner"; +import { Badge } from "@reactive-resume/ui/components/badge"; +import { Button } from "@reactive-resume/ui/components/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@reactive-resume/ui/components/dropdown-menu"; +import { ResizableGroup, ResizablePanel, ResizableSeparator } from "@reactive-resume/ui/components/resizable"; +import { ScrollArea } from "@reactive-resume/ui/components/scroll-area"; +import { Tabs, TabsList, TabsTrigger } from "@reactive-resume/ui/components/tabs"; +import { Textarea } from "@reactive-resume/ui/components/textarea"; +import { cn } from "@reactive-resume/utils/style"; +import { ResumePreview } from "@/components/resume/preview"; +import { useConfirm } from "@/hooks/use-confirm"; +import { getOrpcErrorMessage } from "@/libs/error-message"; +import { client, orpc, streamClient } from "@/libs/orpc/client"; + +type AgentThreadDetail = RouterOutput["agent"]["threads"]["get"]; + +export const Route = createFileRoute("/agent/$threadId")({ + component: RouteComponent, +}); + +function fileToBase64(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(String(reader.result).split(",")[1] ?? ""); + reader.onerror = reject; + reader.readAsDataURL(file); + }); +} + +function textFromMessage(message: UIMessage) { + return message.parts + .filter((part) => part.type === "text") + .map((part) => part.text) + .join("\n"); +} + +function parseAgentSseStream(stream: ReadableStream) { + let buffer = ""; + const eventBoundary = /\r?\n\r?\n/; + + return stream.pipeThrough( + new TransformStream({ + transform(chunk, controller) { + buffer += chunk; + + let boundary = eventBoundary.exec(buffer); + while (boundary) { + const event = buffer.slice(0, boundary.index); + buffer = buffer.slice(boundary.index + boundary[0].length); + + for (const line of event.split(/\r?\n/)) { + if (!line.startsWith("data:")) continue; + + const data = line.slice("data:".length).trimStart(); + if (!data || data === "[DONE]") continue; + + controller.enqueue(JSON.parse(data) as UIMessageChunk); + } + + boundary = eventBoundary.exec(buffer); + } + }, + }), + ); +} + +function ThreadSidebar({ activeThreadId }: { activeThreadId: string }) { + const { data: threads } = useQuery(orpc.agent.threads.list.queryOptions()); + + return ( + + ); +} + +function MessagePart({ + part, + onAnswer, + onRevert, + isReverting, +}: { + part: UIMessage["parts"][number]; + onAnswer: (toolCallId: string, answer: string) => void; + onRevert: (actionId: string) => void; + isReverting: boolean; +}) { + if (part.type === "text") return
{part.text}
; + + if (part.type === "reasoning") { + return ( +
+ + Thinking + +
{part.text}
+
+ ); + } + + if (part.type === "tool-ask_user_question") { + const input = + "input" in part && typeof part.input === "object" && part.input ? (part.input as Record) : {}; + const choices = Array.isArray(input.choices) + ? input.choices.filter((choice): choice is string => typeof choice === "string") + : []; + const question = typeof input.question === "string" ? input.question : t`The agent needs your input.`; + + return ( +
+
{question}
+
+ {choices.map((choice) => ( + + ))} +
+
+ ); + } + + if (part.type === "tool-fetch_url") { + const output = + "output" in part && typeof part.output === "object" && part.output + ? (part.output as Record) + : null; + return ( +
+ + Fetched URL + +
+

{typeof output?.url === "string" ? output.url : t`Waiting for fetch result...`}

+ {typeof output?.title === "string" ?

{output.title}

: null} +
+
+ ); + } + + if (part.type === "tool-apply_resume_patch") { + const output = + "output" in part && typeof part.output === "object" && part.output + ? (part.output as Record) + : null; + const actionId = typeof output?.actionId === "string" ? output.actionId : null; + + return ( +
+
+
+
{typeof output?.title === "string" ? output.title : t`Resume patch`}
+

+ Applied to the working resume. +

+
+ {actionId ? ( + + ) : null} +
+
+ ); + } + + if (part.type.startsWith("source-")) { + const url = "url" in part && typeof part.url === "string" ? part.url : null; + const title = "title" in part && typeof part.title === "string" && part.title.trim() ? part.title : null; + return url ? ( + + {title ? ( + <> + {title} + {url} + + ) : ( + {url} + )} + + ) : null; + } + + return null; +} + +function ChatMessage({ + message, + onAnswer, + onRevert, + isReverting, +}: { + message: UIMessage; + onAnswer: (toolCallId: string, answer: string) => void; + onRevert: (actionId: string) => void; + isReverting: boolean; +}) { + const isUser = message.role === "user"; + + return ( +
+
+ {message.parts.map((part, index) => ( + + ))} +
+
+ ); +} + +function AgentChat({ + threadId, + initialMessages, + isReadOnly, + readOnlyReason, + threadStatus, +}: { + threadId: string; + initialMessages: UIMessage[]; + isReadOnly: boolean; + readOnlyReason: "archived" | "missing" | null; + threadStatus: string; +}) { + const queryClient = useQueryClient(); + const navigate = useNavigate(); + const confirm = useConfirm(); + const fileInputRef = useRef(null); + const [input, setInput] = useState(""); + const [attachmentIds, setAttachmentIds] = useState([]); + const [attachmentLabels, setAttachmentLabels] = useState([]); + const [isUploading, setIsUploading] = useState(false); + const revertMutation = useMutation(orpc.agent.actions.revert.mutationOptions()); + const archiveMutation = useMutation(orpc.agent.threads.archive.mutationOptions()); + const deleteMutation = useMutation(orpc.agent.threads.delete.mutationOptions()); + const isArchived = threadStatus === "archived"; + + const handleArchive = () => { + archiveMutation.mutate( + { id: threadId }, + { + onSuccess: async () => { + toast.success(t`Thread archived.`); + await Promise.all([ + queryClient.invalidateQueries({ queryKey: orpc.agent.threads.list.queryKey() }), + queryClient.invalidateQueries({ queryKey: orpc.agent.threads.get.queryKey({ input: { id: threadId } }) }), + ]); + }, + onError: (error) => { + toast.error(getOrpcErrorMessage(error, { fallback: t`Failed to archive thread.` })); + }, + }, + ); + }; + + const handleDelete = async () => { + const confirmation = await confirm(t`Delete this agent thread?`, { + description: t`This action cannot be undone. Messages and the working draft will be removed.`, + }); + + if (!confirmation) return; + + deleteMutation.mutate( + { id: threadId }, + { + onSuccess: async () => { + toast.success(t`Thread deleted.`); + await queryClient.invalidateQueries({ queryKey: orpc.agent.threads.list.queryKey() }); + void navigate({ to: "/agent" }); + }, + onError: (error) => { + toast.error(getOrpcErrorMessage(error, { fallback: t`Failed to delete thread.` })); + }, + }, + ); + }; + + const transport = useMemo( + () => ({ + async sendMessages(options: { messages: UIMessage[]; abortSignal?: AbortSignal }) { + const message = options.messages.at(-1); + if (!message) throw new Error("No message to send."); + + return parseAgentSseStream( + eventIteratorToUnproxiedDataStream( + await streamClient.agent.messages.send({ threadId, message }, { signal: options.abortSignal }), + ), + ); + }, + async reconnectToStream() { + return parseAgentSseStream( + eventIteratorToUnproxiedDataStream(await streamClient.agent.messages.resume({ threadId })), + ); + }, + }), + [threadId], + ); + + const { messages, sendMessage, regenerate, setMessages, status, error, clearError, addToolOutput } = useChat({ + id: threadId, + messages: initialMessages, + resume: true, + transport, + }); + + useEffect(() => { + setMessages(initialMessages); + }, [initialMessages, setMessages]); + + const isStreaming = status === "submitted" || status === "streaming"; + + const send = () => { + const text = input.trim(); + if (!text || isReadOnly || isStreaming) return; + + const attachmentNote = + attachmentIds.length > 0 + ? `\n\nAttachments:\n${attachmentIds.map((id, index) => `- ${attachmentLabels[index]} (attachmentId: ${id})`).join("\n")}` + : ""; + + clearError(); + sendMessage({ text: `${text}${attachmentNote}` }); + setInput(""); + setAttachmentIds([]); + setAttachmentLabels([]); + }; + + const uploadFiles = async (files: FileList | null) => { + if (!files?.length) return; + + setIsUploading(true); + try { + for (const file of Array.from(files)) { + const attachment = await client.agent.attachments.create({ + threadId, + filename: file.name, + mediaType: file.type || "application/octet-stream", + data: await fileToBase64(file), + }); + setAttachmentIds((current) => [...current, attachment.id]); + setAttachmentLabels((current) => [...current, file.name]); + } + toast.success(t`Attachment uploaded.`); + } catch (error) { + toast.error(getOrpcErrorMessage(error, { fallback: t`Failed to upload attachment.` })); + } finally { + setIsUploading(false); + if (fileInputRef.current) fileInputRef.current.value = ""; + } + }; + + const stopRun = async () => { + const last = messages.at(-1); + await client.agent.messages.stop({ + threadId, + ...(last?.role === "assistant" ? { partialMessage: last } : {}), + }); + }; + + return ( +
+
+
+
+ Chat +
+
+ Use URLs, files, and direct instructions to refine the draft. +
+
+
+ + + + + + Thread actions + + + } + /> + + {!isArchived ? ( + + + Archive + + ) : null} + void handleDelete()} + > + + Delete + + + +
+
+ + {isReadOnly ? ( +
+ {readOnlyReason === "archived" ? ( + This thread is archived. New messages cannot be sent. + ) : ( + This thread is read-only because the working resume or AI provider is unavailable. + )} +
+ ) : null} + + +
+ {messages.length === 0 ? ( +
+ +

+ What should this resume target? +

+
+ {[ + t`Tailor this resume to a product manager job description.`, + t`Find weak bullets and rewrite them with stronger outcomes.`, + t`Compare this resume against a role URL and update keywords.`, + ].map((prompt) => ( + + ))} +
+
+ ) : null} + + {messages.map((message) => ( + { + addToolOutput({ tool: "ask_user_question", toolCallId, output: answer }); + sendMessage({ text: answer }); + }} + onRevert={(actionId) => + revertMutation.mutate( + { id: actionId }, + { + onSuccess: () => { + toast.success(t`Patch reverted.`); + void queryClient.invalidateQueries({ + queryKey: orpc.agent.threads.get.queryKey({ input: { id: threadId } }), + }); + }, + onError: (error) => + toast.error(getOrpcErrorMessage(error, { fallback: t`Could not revert this patch.` })), + }, + ) + } + /> + ))} + + {isStreaming ? ( +
+
+ Working... +
+
+ ) : null} + + {error ? ( +
+ {error.message} + {!isReadOnly ? ( + + ) : null} +
+ ) : null} +
+
+ +
{ + event.preventDefault(); + send(); + }} + > +
+ {attachmentLabels.length > 0 ? ( +
+ {attachmentLabels.map((label) => ( + + + {label} + + ))} +
+ ) : null} + +
+ void uploadFiles(event.target.files)} + /> + +