diff --git a/app/ui/app/codegen/gotypes.gen.ts b/app/ui/app/codegen/gotypes.gen.ts index a077c8546..8825b6fa8 100644 --- a/app/ui/app/codegen/gotypes.gen.ts +++ b/app/ui/app/codegen/gotypes.gen.ts @@ -213,6 +213,44 @@ export class ChatResponse { return a; } } +export class MessageUpdateRequest { + content: string; + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.content = source["content"]; + } +} +export class MessageUpdateResponse { + index: number; + chatId: string; + message: Message; + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.index = source["index"]; + this.chatId = source["chatId"]; + this.message = this.convertValues(source["message"], Message); + } + + convertValues(a: any, classs: any, asMap: boolean = false): any { + if (!a) { + return a; + } + if (Array.isArray(a)) { + return (a as any[]).map(elem => this.convertValues(elem, classs)); + } else if ("object" === typeof a) { + if (asMap) { + for (const key of Object.keys(a)) { + a[key] = new classs(a[key]); + } + return a; + } + return new classs(a); + } + return a; + } +} export class Model { model: string; digest?: string; diff --git a/app/ui/app/components.json b/app/ui/app/components.json new file mode 100644 index 000000000..5e8cc8914 --- /dev/null +++ b/app/ui/app/components.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "default", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "tailwind.config.js", + "css": "src/index.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + } +} diff --git a/app/ui/app/package-lock.json b/app/ui/app/package-lock.json index 0dcd91a20..d6001c81c 100644 --- a/app/ui/app/package-lock.json +++ b/app/ui/app/package-lock.json @@ -26,6 +26,7 @@ "rehype-sanitize": "^6.0.0", "remark-math": "^6.0.0", "streamdown": "^1.4.0", + "tailwind-merge": "^3.4.0", "unist-builder": "^4.0.0", "unist-util-parents": "^3.0.0" }, @@ -12110,9 +12111,9 @@ "integrity": "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==" }, "node_modules/tailwind-merge": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.3.1.tgz", - "integrity": "sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g==", + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.4.0.tgz", + "integrity": "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g==", "license": "MIT", "funding": { "type": "github", diff --git a/app/ui/app/package.json b/app/ui/app/package.json index 5532e70f0..0b44ba7f2 100644 --- a/app/ui/app/package.json +++ b/app/ui/app/package.json @@ -35,6 +35,7 @@ "rehype-sanitize": "^6.0.0", "remark-math": "^6.0.0", "streamdown": "^1.4.0", + "tailwind-merge": "^3.4.0", "unist-builder": "^4.0.0", "unist-util-parents": "^3.0.0" }, diff --git a/app/ui/app/src/api.ts b/app/ui/app/src/api.ts index c8b2e1165..8d114866e 100644 --- a/app/ui/app/src/api.ts +++ b/app/ui/app/src/api.ts @@ -11,6 +11,7 @@ import { ChatRequest, Settings, User, + Message, } from "@/gotypes"; import { parseJsonlFromResponse } from "./util/jsonl-parsing"; import { ollamaClient as ollama } from "./lib/ollama-client"; @@ -300,6 +301,40 @@ export async function deleteChat(chatId: string): Promise { } } +export async function updateChatMessage( + chatId: string, + index: number, + content: string, +): Promise<{ + index: number; + chatId: string; + message: Message; +}> { + const response = await fetch( + `${API_BASE}/api/v1/chat/${chatId}/messages/${index}`, + { + method: "PATCH", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ content }), + }, + ); + + if (!response.ok) { + const errorMessage = await response.text(); + throw new Error(errorMessage || "Failed to update message"); + } + + const data = await response.json(); + + return { + index: data.index, + chatId: data.chatId, + message: new Message(data.message), + }; +} + // Get upstream information for model staleness checking export async function getModelUpstreamInfo( model: Model, diff --git a/app/ui/app/src/components/Chat.tsx b/app/ui/app/src/components/Chat.tsx index e0fcb4ff3..5884d7f9f 100644 --- a/app/ui/app/src/components/Chat.tsx +++ b/app/ui/app/src/components/Chat.tsx @@ -13,6 +13,7 @@ import { useChatError, useShouldShowStaleDisplay, useDismissStaleModel, + useUpdateChatMessage, } from "@/hooks/useChats"; import { useHealth } from "@/hooks/useHealth"; import { useMessageAutoscroll } from "@/hooks/useMessageAutoscroll"; @@ -47,6 +48,12 @@ export default function Chat({ chatId }: { chatId: string }) { index: number; originalMessage: Message; } | null>(null); + const [editingAssistantIndex, setEditingAssistantIndex] = useState< + number | null + >(null); + const [assistantEditError, setAssistantEditError] = useState( + null, + ); const prevChatIdRef = useRef(chatId); const chatFormCallbackRef = useRef< @@ -98,9 +105,14 @@ export default function Chat({ chatId }: { chatId: string }) { // Clear editing state when navigating to a different chat useEffect(() => { setEditingMessage(null); + setEditingAssistantIndex(null); + setAssistantEditError(null); }, [chatId]); const sendMessageMutation = useSendMessage(chatId); + const updateAssistantMessageMutation = useUpdateChatMessage( + chatId === "new" ? "" : chatId, + ); const { containerRef, handleNewUserMessage, spacerHeight } = useMessageAutoscroll({ @@ -186,6 +198,44 @@ export default function Chat({ chatId }: { chatId: string }) { } }; + const handleAssistantEditStart = (index: number) => { + setAssistantEditError(null); + setEditingAssistantIndex(index); + }; + + const handleAssistantEditCancel = () => { + if (updateAssistantMessageMutation.isPending) { + return; + } + setAssistantEditError(null); + setEditingAssistantIndex(null); + }; + + const handleAssistantEditSave = async (index: number, content: string) => { + if (updateAssistantMessageMutation.isPending) { + return; + } + + const trimmedContent = content.trim(); + if (!trimmedContent) { + setAssistantEditError("Response cannot be empty."); + return; + } + + try { + setAssistantEditError(null); + await updateAssistantMessageMutation.mutateAsync({ + index, + content: trimmedContent, + }); + setEditingAssistantIndex(null); + } catch (error) { + setAssistantEditError( + error instanceof Error ? error.message : "Failed to update message.", + ); + } + }; + const clearChatError = () => { queryClient.setQueryData( ["chatError", chatId === "new" ? "" : chatId], @@ -236,6 +286,12 @@ export default function Chat({ chatId }: { chatId: string }) { editingMessageIndex={editingMessage?.index} error={chatError} browserToolResult={browserToolResult} + onAssistantEditStart={handleAssistantEditStart} + onAssistantEditSave={handleAssistantEditSave} + onAssistantEditCancel={handleAssistantEditCancel} + assistantEditingIndex={editingAssistantIndex} + assistantEditIsSaving={updateAssistantMessageMutation.isPending} + assistantEditError={assistantEditError} /> diff --git a/app/ui/app/src/components/Message.tsx b/app/ui/app/src/components/Message.tsx index 1996a0d4e..4c156b9f8 100644 --- a/app/ui/app/src/components/Message.tsx +++ b/app/ui/app/src/components/Message.tsx @@ -3,8 +3,16 @@ import Thinking from "./Thinking"; import StreamingMarkdownContent from "./StreamingMarkdownContent"; import { ImageThumbnail } from "./ImageThumbnail"; import { isImageFile } from "@/utils/imageUtils"; -import CopyButton from "./CopyButton"; -import React, { useState, useMemo, useRef } from "react"; +import React, { useState, useMemo, useRef, useEffect } from "react"; +import { + CheckIcon, + PencilSquareIcon, + Square2StackIcon, +} from "@heroicons/react/24/outline"; +import { + MessageActions, + MessageAction, +} from "@/components/ai-elements/message"; const Message = React.memo( ({ @@ -15,6 +23,12 @@ const Message = React.memo( isFaded, browserToolResult, lastToolQuery, + onAssistantEditStart, + onAssistantEditSave, + onAssistantEditCancel, + assistantEditingIndex, + assistantEditIsSaving, + assistantEditError, }: { message: MessageType; onEditMessage?: (content: string, index: number) => void; @@ -24,6 +38,15 @@ const Message = React.memo( // TODO(drifkin): this type isn't right browserToolResult?: BrowserToolResult; lastToolQuery?: string; + onAssistantEditStart?: (index: number) => void; + onAssistantEditSave?: ( + index: number, + content: string, + ) => void | Promise; + onAssistantEditCancel?: () => void; + assistantEditingIndex?: number | null; + assistantEditIsSaving?: boolean; + assistantEditError?: string | null; }) => { if (message.role === "user") { return ( @@ -42,6 +65,13 @@ const Message = React.memo( isFaded={isFaded} browserToolResult={browserToolResult} lastToolQuery={lastToolQuery} + messageIndex={messageIndex} + onAssistantEditStart={onAssistantEditStart} + onAssistantEditSave={onAssistantEditSave} + onAssistantEditCancel={onAssistantEditCancel} + assistantEditingIndex={assistantEditingIndex} + assistantEditIsSaving={assistantEditIsSaving} + assistantEditError={assistantEditError} /> ); } @@ -53,7 +83,10 @@ const Message = React.memo( prevProps.messageIndex === nextProps.messageIndex && prevProps.isStreaming === nextProps.isStreaming && prevProps.isFaded === nextProps.isFaded && - prevProps.browserToolResult === nextProps.browserToolResult + prevProps.browserToolResult === nextProps.browserToolResult && + prevProps.assistantEditingIndex === nextProps.assistantEditingIndex && + prevProps.assistantEditIsSaving === nextProps.assistantEditIsSaving && + prevProps.assistantEditError === nextProps.assistantEditError ); }, ); @@ -880,6 +913,13 @@ function OtherRoleMessage({ isFaded, browserToolResult, lastToolQuery, + messageIndex, + onAssistantEditStart, + onAssistantEditSave, + onAssistantEditCancel, + assistantEditingIndex, + assistantEditIsSaving, + assistantEditError, }: { message: MessageType; previousMessage?: MessageType; @@ -888,8 +928,106 @@ function OtherRoleMessage({ // TODO(drifkin): this type isn't right browserToolResult?: BrowserToolResult; lastToolQuery?: string; + messageIndex?: number; + onAssistantEditStart?: (index: number) => void; + onAssistantEditSave?: ( + index: number, + content: string, + ) => void | Promise; + onAssistantEditCancel?: () => void; + assistantEditingIndex?: number | null; + assistantEditIsSaving?: boolean; + assistantEditError?: string | null; }) { const messageRef = useRef(null); + const [draftContent, setDraftContent] = useState(message.content || ""); + const [isCopied, setIsCopied] = useState(false); + const copyResetTimeoutRef = useRef(undefined); + + const isAssistantMessage = message.role === "assistant"; + const isEditingAssistant = + isAssistantMessage && + assistantEditingIndex !== null && + assistantEditingIndex !== undefined && + messageIndex !== undefined && + assistantEditingIndex === messageIndex; + + useEffect(() => { + if (isEditingAssistant) { + setDraftContent(message.content || ""); + } + }, [isEditingAssistant, message.content]); + + useEffect(() => { + setIsCopied(false); + }, [message.content]); + + useEffect(() => { + return () => { + if (copyResetTimeoutRef.current !== undefined) { + window.clearTimeout(copyResetTimeoutRef.current); + } + }; + }, []); + + const handleAssistantEditStart = () => { + if (onAssistantEditStart && messageIndex !== undefined) { + onAssistantEditStart(messageIndex); + } + }; + + const handleAssistantEditSave = async () => { + if (!onAssistantEditSave || messageIndex === undefined) { + return; + } + await onAssistantEditSave(messageIndex, draftContent); + }; + + const handleAssistantEditCancel = () => { + onAssistantEditCancel?.(); + }; + + const handleCopy = async () => { + const contentToCopy = message.content || ""; + if (!contentToCopy) { + return; + } + + const scheduleReset = () => { + if (copyResetTimeoutRef.current !== undefined) { + window.clearTimeout(copyResetTimeoutRef.current); + } + copyResetTimeoutRef.current = window.setTimeout(() => { + setIsCopied(false); + }, 2000); + }; + + try { + if (messageRef.current) { + const cloned = messageRef.current.cloneNode(true) as HTMLElement; + await navigator.clipboard.write([ + new ClipboardItem({ + "text/html": new Blob([cloned.innerHTML], { type: "text/html" }), + "text/plain": new Blob([contentToCopy], { type: "text/plain" }), + }), + ]); + } else { + await navigator.clipboard.writeText(contentToCopy); + } + + setIsCopied(true); + scheduleReset(); + } catch (error) { + console.error("Clipboard API failed, falling back to plain text", error); + try { + await navigator.clipboard.writeText(contentToCopy); + setIsCopied(true); + scheduleReset(); + } catch (fallbackError) { + console.error("Fallback copy also failed:", fallbackError); + } + } + }; return (
{message.role === "tool" ? ( + ) : isEditingAssistant ? ( + <> +