thepopebot 1.2.33 → 1.2.35
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/ai/index.js +5 -3
- package/lib/chat/api.js +3 -1
- package/lib/chat/components/app-sidebar.js +1 -1
- package/lib/chat/components/app-sidebar.jsx +1 -1
- package/lib/chat/components/chat-input.js +1 -1
- package/lib/chat/components/chat-input.jsx +1 -1
- package/lib/chat/components/chat-page.js +3 -1
- package/lib/chat/components/chat-page.jsx +5 -1
- package/lib/chat/components/chat.js +30 -6
- package/lib/chat/components/chat.jsx +41 -6
- package/lib/chat/components/greeting.js +2 -5
- package/lib/chat/components/greeting.jsx +3 -6
- package/lib/chat/components/message.js +161 -41
- package/lib/chat/components/message.jsx +171 -48
- package/lib/chat/components/messages.js +5 -3
- package/lib/chat/components/messages.jsx +4 -2
- package/package.json +1 -1
package/lib/ai/index.js
CHANGED
|
@@ -102,14 +102,16 @@ async function chat(threadId, message, attachments = [], options = {}) {
|
|
|
102
102
|
* @param {string} threadId - Conversation thread ID
|
|
103
103
|
* @param {string} message - User's message text
|
|
104
104
|
* @param {Array} [attachments=[]] - Image/PDF attachments: { category, mimeType, dataUrl }
|
|
105
|
-
* @param {object} [options] - { userId, chatTitle } for DB persistence
|
|
105
|
+
* @param {object} [options] - { userId, chatTitle, skipUserPersist } for DB persistence
|
|
106
106
|
* @returns {AsyncIterableIterator<string>} Stream of text chunks
|
|
107
107
|
*/
|
|
108
108
|
async function* chatStream(threadId, message, attachments = [], options = {}) {
|
|
109
109
|
const agent = await getAgent();
|
|
110
110
|
|
|
111
|
-
// Save user message to DB
|
|
112
|
-
|
|
111
|
+
// Save user message to DB (skip on regeneration — message already exists)
|
|
112
|
+
if (!options.skipUserPersist) {
|
|
113
|
+
persistMessage(threadId, 'user', message || '[attachment]', options);
|
|
114
|
+
}
|
|
113
115
|
|
|
114
116
|
// Build content blocks: text + any image/PDF attachments as vision
|
|
115
117
|
const content = [];
|
package/lib/chat/api.js
CHANGED
|
@@ -12,7 +12,7 @@ export async function POST(request) {
|
|
|
12
12
|
}
|
|
13
13
|
|
|
14
14
|
const body = await request.json();
|
|
15
|
-
const { messages, chatId: rawChatId } = body;
|
|
15
|
+
const { messages, chatId: rawChatId, trigger } = body;
|
|
16
16
|
|
|
17
17
|
if (!messages?.length) {
|
|
18
18
|
return Response.json({ error: 'No messages' }, { status: 400 });
|
|
@@ -72,8 +72,10 @@ export async function POST(request) {
|
|
|
72
72
|
},
|
|
73
73
|
execute: async ({ writer }) => {
|
|
74
74
|
// chatStream handles: save user msg, invoke agent, save assistant msg, auto-title
|
|
75
|
+
const skipUserPersist = trigger === 'regenerate-message';
|
|
75
76
|
const chunks = chatStream(threadId, userText, attachments, {
|
|
76
77
|
userId: session.user.id,
|
|
78
|
+
skipUserPersist,
|
|
77
79
|
});
|
|
78
80
|
|
|
79
81
|
// Signal start of assistant message
|
|
@@ -34,7 +34,7 @@ function AppSidebar({ user }) {
|
|
|
34
34
|
/* @__PURE__ */ jsxs("div", { className: collapsed ? "flex justify-center" : "flex items-center justify-between", children: [
|
|
35
35
|
!collapsed && /* @__PURE__ */ jsxs("span", { className: "px-2 font-semibold text-lg", children: [
|
|
36
36
|
"The Pope Bot ",
|
|
37
|
-
/* @__PURE__ */ jsxs("span", { className: "text-
|
|
37
|
+
/* @__PURE__ */ jsxs("span", { className: "text-[11px] font-normal text-muted-foreground", children: [
|
|
38
38
|
"v",
|
|
39
39
|
pkg.version
|
|
40
40
|
] })
|
|
@@ -39,7 +39,7 @@ export function AppSidebar({ user }) {
|
|
|
39
39
|
{/* Top row: brand name + toggle icon (open) or just toggle icon (collapsed) */}
|
|
40
40
|
<div className={collapsed ? 'flex justify-center' : 'flex items-center justify-between'}>
|
|
41
41
|
{!collapsed && (
|
|
42
|
-
<span className="px-2 font-semibold text-lg">The Pope Bot <span className="text-
|
|
42
|
+
<span className="px-2 font-semibold text-lg">The Pope Bot <span className="text-[11px] font-normal text-muted-foreground">v{pkg.version}</span></span>
|
|
43
43
|
)}
|
|
44
44
|
<Tooltip>
|
|
45
45
|
<TooltipTrigger asChild>
|
|
@@ -118,7 +118,7 @@ function ChatInput({ input, setInput, onSubmit, status, stop, files, setFiles })
|
|
|
118
118
|
}
|
|
119
119
|
};
|
|
120
120
|
const canSend = input.trim() || files.length > 0;
|
|
121
|
-
return /* @__PURE__ */ jsx("div", { className: "mx-auto w-full max-w-4xl px-
|
|
121
|
+
return /* @__PURE__ */ jsx("div", { className: "mx-auto w-full max-w-4xl px-4 pb-4 md:px-6", children: /* @__PURE__ */ jsx("form", { onSubmit: handleSubmit, className: "relative", children: /* @__PURE__ */ jsxs(
|
|
122
122
|
"div",
|
|
123
123
|
{
|
|
124
124
|
className: cn(
|
|
@@ -115,7 +115,7 @@ export function ChatInput({ input, setInput, onSubmit, status, stop, files, setF
|
|
|
115
115
|
const canSend = input.trim() || files.length > 0;
|
|
116
116
|
|
|
117
117
|
return (
|
|
118
|
-
<div className="mx-auto w-full max-w-4xl px-
|
|
118
|
+
<div className="mx-auto w-full max-w-4xl px-4 pb-4 md:px-6">
|
|
119
119
|
<form onSubmit={handleSubmit} className="relative">
|
|
120
120
|
<div
|
|
121
121
|
className={cn(
|
|
@@ -16,7 +16,9 @@ function ChatPage({ session, needsSetup, chatId }) {
|
|
|
16
16
|
} else {
|
|
17
17
|
window.history.pushState({}, "", "/");
|
|
18
18
|
}
|
|
19
|
-
|
|
19
|
+
setResolvedChatId(null);
|
|
20
|
+
setInitialMessages([]);
|
|
21
|
+
setActiveChatId(id || null);
|
|
20
22
|
}, []);
|
|
21
23
|
useEffect(() => {
|
|
22
24
|
const onPopState = () => {
|
|
@@ -26,7 +26,11 @@ export function ChatPage({ session, needsSetup, chatId }) {
|
|
|
26
26
|
} else {
|
|
27
27
|
window.history.pushState({}, '', '/');
|
|
28
28
|
}
|
|
29
|
-
|
|
29
|
+
// Clear current chat immediately — unmounts Chat via the
|
|
30
|
+
// {resolvedChatId && ...} guard before the effect runs
|
|
31
|
+
setResolvedChatId(null);
|
|
32
|
+
setInitialMessages([]);
|
|
33
|
+
setActiveChatId(id || null);
|
|
30
34
|
}, []);
|
|
31
35
|
|
|
32
36
|
// Browser back/forward
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
3
3
|
import { useChat } from "@ai-sdk/react";
|
|
4
4
|
import { DefaultChatTransport } from "ai";
|
|
5
|
-
import { useState, useEffect, useRef, useMemo } from "react";
|
|
5
|
+
import { useState, useEffect, useRef, useMemo, useCallback } from "react";
|
|
6
6
|
import { Messages } from "./messages.js";
|
|
7
7
|
import { ChatInput } from "./chat-input.js";
|
|
8
8
|
import { ChatHeader } from "./chat-header.js";
|
|
@@ -23,7 +23,9 @@ function Chat({ chatId, initialMessages = [] }) {
|
|
|
23
23
|
status,
|
|
24
24
|
stop,
|
|
25
25
|
error,
|
|
26
|
-
sendMessage
|
|
26
|
+
sendMessage,
|
|
27
|
+
regenerate,
|
|
28
|
+
setMessages
|
|
27
29
|
} = useChat({
|
|
28
30
|
id: chatId,
|
|
29
31
|
messages: initialMessages,
|
|
@@ -31,13 +33,13 @@ function Chat({ chatId, initialMessages = [] }) {
|
|
|
31
33
|
onError: (err) => console.error("Chat error:", err)
|
|
32
34
|
});
|
|
33
35
|
useEffect(() => {
|
|
34
|
-
if (!hasNavigated.current && messages.length >=
|
|
36
|
+
if (!hasNavigated.current && messages.length >= 1 && status !== "ready" && window.location.pathname !== `/chat/${chatId}`) {
|
|
35
37
|
hasNavigated.current = true;
|
|
36
38
|
window.history.replaceState({}, "", `/chat/${chatId}`);
|
|
37
39
|
window.dispatchEvent(new Event("chatsupdated"));
|
|
38
40
|
setTimeout(() => window.dispatchEvent(new Event("chatsupdated")), 5e3);
|
|
39
41
|
}
|
|
40
|
-
}, [messages.length, chatId]);
|
|
42
|
+
}, [messages.length, status, chatId]);
|
|
41
43
|
const handleSend = () => {
|
|
42
44
|
if (!input.trim() && files.length === 0) return;
|
|
43
45
|
const text = input;
|
|
@@ -56,9 +58,31 @@ function Chat({ chatId, initialMessages = [] }) {
|
|
|
56
58
|
sendMessage({ text: text || void 0, files: fileParts });
|
|
57
59
|
}
|
|
58
60
|
};
|
|
61
|
+
const handleRetry = useCallback((message) => {
|
|
62
|
+
if (message.role === "assistant") {
|
|
63
|
+
regenerate({ messageId: message.id });
|
|
64
|
+
} else {
|
|
65
|
+
const idx = messages.findIndex((m) => m.id === message.id);
|
|
66
|
+
const nextAssistant = messages.slice(idx + 1).find((m) => m.role === "assistant");
|
|
67
|
+
if (nextAssistant) {
|
|
68
|
+
regenerate({ messageId: nextAssistant.id });
|
|
69
|
+
} else {
|
|
70
|
+
const text = message.parts?.filter((p) => p.type === "text").map((p) => p.text).join("\n") || message.content || "";
|
|
71
|
+
if (text.trim()) {
|
|
72
|
+
sendMessage({ text });
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}, [messages, regenerate, sendMessage]);
|
|
77
|
+
const handleEdit = useCallback((message, newText) => {
|
|
78
|
+
const idx = messages.findIndex((m) => m.id === message.id);
|
|
79
|
+
if (idx === -1) return;
|
|
80
|
+
setMessages(messages.slice(0, idx));
|
|
81
|
+
sendMessage({ text: newText });
|
|
82
|
+
}, [messages, setMessages, sendMessage]);
|
|
59
83
|
return /* @__PURE__ */ jsxs("div", { className: "flex h-svh flex-col", children: [
|
|
60
84
|
/* @__PURE__ */ jsx(ChatHeader, { chatId }),
|
|
61
|
-
messages.length === 0 ? /* @__PURE__ */ jsx("div", { className: "flex flex-1 flex-col items-center justify-center px-
|
|
85
|
+
messages.length === 0 ? /* @__PURE__ */ jsx("div", { className: "flex flex-1 flex-col items-center justify-center px-4 md:px-6", children: /* @__PURE__ */ jsxs("div", { className: "w-full max-w-4xl", children: [
|
|
62
86
|
/* @__PURE__ */ jsx(Greeting, {}),
|
|
63
87
|
error && /* @__PURE__ */ jsx("div", { className: "mt-4 rounded-lg border border-destructive/50 bg-destructive/10 px-4 py-2 text-sm text-destructive", children: error.message || "Something went wrong. Please try again." }),
|
|
64
88
|
/* @__PURE__ */ jsx("div", { className: "mt-4", children: /* @__PURE__ */ jsx(
|
|
@@ -74,7 +98,7 @@ function Chat({ chatId, initialMessages = [] }) {
|
|
|
74
98
|
}
|
|
75
99
|
) })
|
|
76
100
|
] }) }) : /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
77
|
-
/* @__PURE__ */ jsx(Messages, { messages, status }),
|
|
101
|
+
/* @__PURE__ */ jsx(Messages, { messages, status, onRetry: handleRetry, onEdit: handleEdit }),
|
|
78
102
|
error && /* @__PURE__ */ jsx("div", { className: "mx-auto w-full max-w-4xl px-2 md:px-4", children: /* @__PURE__ */ jsx("div", { className: "rounded-lg border border-destructive/50 bg-destructive/10 px-4 py-2 text-sm text-destructive", children: error.message || "Something went wrong. Please try again." }) }),
|
|
79
103
|
/* @__PURE__ */ jsx(
|
|
80
104
|
ChatInput,
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { useChat } from '@ai-sdk/react';
|
|
4
4
|
import { DefaultChatTransport } from 'ai';
|
|
5
|
-
import { useState, useEffect, useRef, useMemo } from 'react';
|
|
5
|
+
import { useState, useEffect, useRef, useMemo, useCallback } from 'react';
|
|
6
6
|
import { Messages } from './messages.js';
|
|
7
7
|
import { ChatInput } from './chat-input.js';
|
|
8
8
|
import { ChatHeader } from './chat-header.js';
|
|
@@ -28,6 +28,8 @@ export function Chat({ chatId, initialMessages = [] }) {
|
|
|
28
28
|
stop,
|
|
29
29
|
error,
|
|
30
30
|
sendMessage,
|
|
31
|
+
regenerate,
|
|
32
|
+
setMessages,
|
|
31
33
|
} = useChat({
|
|
32
34
|
id: chatId,
|
|
33
35
|
messages: initialMessages,
|
|
@@ -35,16 +37,16 @@ export function Chat({ chatId, initialMessages = [] }) {
|
|
|
35
37
|
onError: (err) => console.error('Chat error:', err),
|
|
36
38
|
});
|
|
37
39
|
|
|
38
|
-
// After first
|
|
40
|
+
// After first message sent, update URL and notify sidebar
|
|
39
41
|
useEffect(() => {
|
|
40
|
-
if (!hasNavigated.current && messages.length >=
|
|
42
|
+
if (!hasNavigated.current && messages.length >= 1 && status !== 'ready' && window.location.pathname !== `/chat/${chatId}`) {
|
|
41
43
|
hasNavigated.current = true;
|
|
42
44
|
window.history.replaceState({}, '', `/chat/${chatId}`);
|
|
43
45
|
window.dispatchEvent(new Event('chatsupdated'));
|
|
44
46
|
// Dispatch again after delay to pick up async title update
|
|
45
47
|
setTimeout(() => window.dispatchEvent(new Event('chatsupdated')), 5000);
|
|
46
48
|
}
|
|
47
|
-
}, [messages.length, chatId]);
|
|
49
|
+
}, [messages.length, status, chatId]);
|
|
48
50
|
|
|
49
51
|
const handleSend = () => {
|
|
50
52
|
if (!input.trim() && files.length === 0) return;
|
|
@@ -67,11 +69,44 @@ export function Chat({ chatId, initialMessages = [] }) {
|
|
|
67
69
|
}
|
|
68
70
|
};
|
|
69
71
|
|
|
72
|
+
const handleRetry = useCallback((message) => {
|
|
73
|
+
if (message.role === 'assistant') {
|
|
74
|
+
regenerate({ messageId: message.id });
|
|
75
|
+
} else {
|
|
76
|
+
// User message — find the next assistant message and regenerate it
|
|
77
|
+
const idx = messages.findIndex((m) => m.id === message.id);
|
|
78
|
+
const nextAssistant = messages.slice(idx + 1).find((m) => m.role === 'assistant');
|
|
79
|
+
if (nextAssistant) {
|
|
80
|
+
regenerate({ messageId: nextAssistant.id });
|
|
81
|
+
} else {
|
|
82
|
+
// No assistant response yet — extract text and resend
|
|
83
|
+
const text =
|
|
84
|
+
message.parts
|
|
85
|
+
?.filter((p) => p.type === 'text')
|
|
86
|
+
.map((p) => p.text)
|
|
87
|
+
.join('\n') ||
|
|
88
|
+
message.content ||
|
|
89
|
+
'';
|
|
90
|
+
if (text.trim()) {
|
|
91
|
+
sendMessage({ text });
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}, [messages, regenerate, sendMessage]);
|
|
96
|
+
|
|
97
|
+
const handleEdit = useCallback((message, newText) => {
|
|
98
|
+
const idx = messages.findIndex((m) => m.id === message.id);
|
|
99
|
+
if (idx === -1) return;
|
|
100
|
+
// Truncate conversation to before this message, then send edited text
|
|
101
|
+
setMessages(messages.slice(0, idx));
|
|
102
|
+
sendMessage({ text: newText });
|
|
103
|
+
}, [messages, setMessages, sendMessage]);
|
|
104
|
+
|
|
70
105
|
return (
|
|
71
106
|
<div className="flex h-svh flex-col">
|
|
72
107
|
<ChatHeader chatId={chatId} />
|
|
73
108
|
{messages.length === 0 ? (
|
|
74
|
-
<div className="flex flex-1 flex-col items-center justify-center px-
|
|
109
|
+
<div className="flex flex-1 flex-col items-center justify-center px-4 md:px-6">
|
|
75
110
|
<div className="w-full max-w-4xl">
|
|
76
111
|
<Greeting />
|
|
77
112
|
{error && (
|
|
@@ -94,7 +129,7 @@ export function Chat({ chatId, initialMessages = [] }) {
|
|
|
94
129
|
</div>
|
|
95
130
|
) : (
|
|
96
131
|
<>
|
|
97
|
-
<Messages messages={messages} status={status} />
|
|
132
|
+
<Messages messages={messages} status={status} onRetry={handleRetry} onEdit={handleEdit} />
|
|
98
133
|
{error && (
|
|
99
134
|
<div className="mx-auto w-full max-w-4xl px-2 md:px-4">
|
|
100
135
|
<div className="rounded-lg border border-destructive/50 bg-destructive/10 px-4 py-2 text-sm text-destructive">
|
|
@@ -1,10 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
|
-
import { jsx
|
|
2
|
+
import { jsx } from "react/jsx-runtime";
|
|
3
3
|
function Greeting() {
|
|
4
|
-
return /* @__PURE__ */
|
|
5
|
-
/* @__PURE__ */ jsx("div", { className: "font-semibold text-xl md:text-2xl text-foreground", children: "Hello there!" }),
|
|
6
|
-
/* @__PURE__ */ jsx("div", { className: "text-xl text-muted-foreground md:text-2xl", children: "How can I help you today?" })
|
|
7
|
-
] });
|
|
4
|
+
return /* @__PURE__ */ jsx("div", { className: "w-full text-center", children: /* @__PURE__ */ jsx("div", { className: "font-semibold text-2xl md:text-3xl text-foreground", children: "Hello! How can I help?" }) });
|
|
8
5
|
}
|
|
9
6
|
export {
|
|
10
7
|
Greeting
|
|
@@ -2,12 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
export function Greeting() {
|
|
4
4
|
return (
|
|
5
|
-
<div className="w-full">
|
|
6
|
-
<div className="font-semibold text-
|
|
7
|
-
Hello
|
|
8
|
-
</div>
|
|
9
|
-
<div className="text-xl text-muted-foreground md:text-2xl">
|
|
10
|
-
How can I help you today?
|
|
5
|
+
<div className="w-full text-center">
|
|
6
|
+
<div className="font-semibold text-2xl md:text-3xl text-foreground">
|
|
7
|
+
Hello! How can I help?
|
|
11
8
|
</div>
|
|
12
9
|
</div>
|
|
13
10
|
);
|
|
@@ -1,59 +1,179 @@
|
|
|
1
1
|
"use client";
|
|
2
|
-
import { jsx, jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useState, useRef, useEffect } from "react";
|
|
3
4
|
import { Streamdown } from "streamdown";
|
|
4
5
|
import { cn } from "../utils.js";
|
|
5
|
-
import { SpinnerIcon, FileTextIcon } from "./icons.js";
|
|
6
|
-
function PreviewMessage({ message, isLoading }) {
|
|
6
|
+
import { SpinnerIcon, FileTextIcon, CopyIcon, CheckIcon, RefreshIcon, SquarePenIcon } from "./icons.js";
|
|
7
|
+
function PreviewMessage({ message, isLoading, onRetry, onEdit }) {
|
|
7
8
|
const isUser = message.role === "user";
|
|
9
|
+
const [copied, setCopied] = useState(false);
|
|
10
|
+
const [editing, setEditing] = useState(false);
|
|
11
|
+
const [editText, setEditText] = useState("");
|
|
12
|
+
const textareaRef = useRef(null);
|
|
8
13
|
const text = message.parts?.filter((p) => p.type === "text").map((p) => p.text).join("\n") || message.content || "";
|
|
9
14
|
const fileParts = message.parts?.filter((p) => p.type === "file") || [];
|
|
10
15
|
const imageParts = fileParts.filter((p) => p.mediaType?.startsWith("image/"));
|
|
11
16
|
const otherFileParts = fileParts.filter((p) => !p.mediaType?.startsWith("image/"));
|
|
17
|
+
const handleCopy = async () => {
|
|
18
|
+
try {
|
|
19
|
+
await navigator.clipboard.writeText(text);
|
|
20
|
+
setCopied(true);
|
|
21
|
+
setTimeout(() => setCopied(false), 2e3);
|
|
22
|
+
} catch {
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
const handleEditStart = () => {
|
|
26
|
+
setEditText(text);
|
|
27
|
+
setEditing(true);
|
|
28
|
+
};
|
|
29
|
+
const handleEditCancel = () => {
|
|
30
|
+
setEditing(false);
|
|
31
|
+
setEditText("");
|
|
32
|
+
};
|
|
33
|
+
const handleEditSubmit = () => {
|
|
34
|
+
const trimmed = editText.trim();
|
|
35
|
+
if (trimmed && trimmed !== text) {
|
|
36
|
+
onEdit?.(message, trimmed);
|
|
37
|
+
}
|
|
38
|
+
setEditing(false);
|
|
39
|
+
setEditText("");
|
|
40
|
+
};
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
if (editing && textareaRef.current) {
|
|
43
|
+
const ta = textareaRef.current;
|
|
44
|
+
ta.focus();
|
|
45
|
+
ta.style.height = "auto";
|
|
46
|
+
ta.style.height = `${ta.scrollHeight}px`;
|
|
47
|
+
ta.setSelectionRange(ta.value.length, ta.value.length);
|
|
48
|
+
}
|
|
49
|
+
}, [editing]);
|
|
12
50
|
return /* @__PURE__ */ jsx(
|
|
13
51
|
"div",
|
|
14
52
|
{
|
|
15
53
|
className: cn(
|
|
16
|
-
"flex gap-4 w-full",
|
|
54
|
+
"group flex gap-4 w-full",
|
|
17
55
|
isUser ? "justify-end" : "justify-start"
|
|
18
56
|
),
|
|
19
|
-
children: /* @__PURE__ */ jsxs(
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
57
|
+
children: /* @__PURE__ */ jsx("div", { className: "flex flex-col max-w-[80%]", children: editing ? /* @__PURE__ */ jsxs("div", { className: "flex flex-col gap-2", children: [
|
|
58
|
+
/* @__PURE__ */ jsx(
|
|
59
|
+
"textarea",
|
|
60
|
+
{
|
|
61
|
+
ref: textareaRef,
|
|
62
|
+
value: editText,
|
|
63
|
+
onChange: (e) => {
|
|
64
|
+
setEditText(e.target.value);
|
|
65
|
+
e.target.style.height = "auto";
|
|
66
|
+
e.target.style.height = `${e.target.scrollHeight}px`;
|
|
67
|
+
},
|
|
68
|
+
onKeyDown: (e) => {
|
|
69
|
+
if (e.key === "Enter" && !e.shiftKey) {
|
|
70
|
+
e.preventDefault();
|
|
71
|
+
handleEditSubmit();
|
|
72
|
+
}
|
|
73
|
+
if (e.key === "Escape") {
|
|
74
|
+
handleEditCancel();
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
className: "w-full resize-none rounded-xl border border-border bg-muted px-4 py-3 text-sm leading-relaxed text-foreground focus:outline-none focus:ring-1 focus:ring-primary",
|
|
78
|
+
rows: 1
|
|
79
|
+
}
|
|
80
|
+
),
|
|
81
|
+
/* @__PURE__ */ jsxs("div", { className: "flex justify-end gap-2", children: [
|
|
82
|
+
/* @__PURE__ */ jsx(
|
|
83
|
+
"button",
|
|
84
|
+
{
|
|
85
|
+
onClick: handleEditCancel,
|
|
86
|
+
className: "rounded-md px-3 py-1 text-xs text-muted-foreground hover:text-foreground",
|
|
87
|
+
children: "Cancel"
|
|
88
|
+
}
|
|
25
89
|
),
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
90
|
+
/* @__PURE__ */ jsx(
|
|
91
|
+
"button",
|
|
92
|
+
{
|
|
93
|
+
onClick: handleEditSubmit,
|
|
94
|
+
className: "rounded-md bg-primary px-3 py-1 text-xs text-primary-foreground hover:opacity-80",
|
|
95
|
+
children: "Send"
|
|
96
|
+
}
|
|
97
|
+
)
|
|
98
|
+
] })
|
|
99
|
+
] }) : /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
100
|
+
/* @__PURE__ */ jsxs(
|
|
101
|
+
"div",
|
|
102
|
+
{
|
|
103
|
+
className: cn(
|
|
104
|
+
"rounded-xl px-4 py-3 text-sm leading-relaxed",
|
|
105
|
+
isUser ? "bg-primary text-primary-foreground" : "bg-muted text-foreground"
|
|
106
|
+
),
|
|
107
|
+
children: [
|
|
108
|
+
imageParts.length > 0 && /* @__PURE__ */ jsx("div", { className: "mb-2 flex flex-wrap gap-2", children: imageParts.map((part, i) => /* @__PURE__ */ jsx(
|
|
109
|
+
"img",
|
|
110
|
+
{
|
|
111
|
+
src: part.url,
|
|
112
|
+
alt: "attachment",
|
|
113
|
+
className: "max-h-64 max-w-full rounded-lg object-contain"
|
|
114
|
+
},
|
|
115
|
+
i
|
|
116
|
+
)) }),
|
|
117
|
+
otherFileParts.length > 0 && /* @__PURE__ */ jsx("div", { className: "mb-2 flex flex-wrap gap-2", children: otherFileParts.map((part, i) => /* @__PURE__ */ jsxs(
|
|
118
|
+
"div",
|
|
119
|
+
{
|
|
120
|
+
className: cn(
|
|
121
|
+
"inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-xs",
|
|
122
|
+
isUser ? "bg-primary-foreground/20" : "bg-foreground/10"
|
|
123
|
+
),
|
|
124
|
+
children: [
|
|
125
|
+
/* @__PURE__ */ jsx(FileTextIcon, { size: 12 }),
|
|
126
|
+
/* @__PURE__ */ jsx("span", { className: "max-w-[150px] truncate", children: part.name || part.mediaType || "file" })
|
|
127
|
+
]
|
|
128
|
+
},
|
|
129
|
+
i
|
|
130
|
+
)) }),
|
|
131
|
+
text ? isUser ? /* @__PURE__ */ jsx("div", { className: "whitespace-pre-wrap break-words", children: text }) : /* @__PURE__ */ jsx(Streamdown, { mode: isLoading ? "streaming" : "static", children: text }) : isLoading ? /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2 text-muted-foreground", children: [
|
|
132
|
+
/* @__PURE__ */ jsx(SpinnerIcon, { size: 14 }),
|
|
133
|
+
/* @__PURE__ */ jsx("span", { children: "Working..." })
|
|
134
|
+
] }) : null
|
|
135
|
+
]
|
|
136
|
+
}
|
|
137
|
+
),
|
|
138
|
+
!isLoading && text && /* @__PURE__ */ jsxs(
|
|
139
|
+
"div",
|
|
140
|
+
{
|
|
141
|
+
className: cn(
|
|
142
|
+
"flex gap-1 mt-1 opacity-0 transition-opacity group-hover:opacity-100",
|
|
143
|
+
isUser ? "justify-end" : "justify-start"
|
|
144
|
+
),
|
|
145
|
+
children: [
|
|
146
|
+
/* @__PURE__ */ jsx(
|
|
147
|
+
"button",
|
|
148
|
+
{
|
|
149
|
+
onClick: handleCopy,
|
|
150
|
+
className: "rounded-md p-1 text-muted-foreground hover:text-foreground hover:bg-muted",
|
|
151
|
+
"aria-label": "Copy message",
|
|
152
|
+
children: copied ? /* @__PURE__ */ jsx(CheckIcon, { size: 14 }) : /* @__PURE__ */ jsx(CopyIcon, { size: 14 })
|
|
153
|
+
}
|
|
154
|
+
),
|
|
155
|
+
onRetry && /* @__PURE__ */ jsx(
|
|
156
|
+
"button",
|
|
157
|
+
{
|
|
158
|
+
onClick: () => onRetry(message),
|
|
159
|
+
className: "rounded-md p-1 text-muted-foreground hover:text-foreground hover:bg-muted",
|
|
160
|
+
"aria-label": "Retry",
|
|
161
|
+
children: /* @__PURE__ */ jsx(RefreshIcon, { size: 14 })
|
|
162
|
+
}
|
|
163
|
+
),
|
|
164
|
+
isUser && onEdit && /* @__PURE__ */ jsx(
|
|
165
|
+
"button",
|
|
166
|
+
{
|
|
167
|
+
onClick: handleEditStart,
|
|
168
|
+
className: "rounded-md p-1 text-muted-foreground hover:text-foreground hover:bg-muted",
|
|
169
|
+
"aria-label": "Edit message",
|
|
170
|
+
children: /* @__PURE__ */ jsx(SquarePenIcon, { size: 14 })
|
|
171
|
+
}
|
|
172
|
+
)
|
|
173
|
+
]
|
|
174
|
+
}
|
|
175
|
+
)
|
|
176
|
+
] }) })
|
|
57
177
|
}
|
|
58
178
|
);
|
|
59
179
|
}
|
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
+
import { useState, useRef, useEffect } from 'react';
|
|
3
4
|
import { Streamdown } from 'streamdown';
|
|
4
5
|
import { cn } from '../utils.js';
|
|
5
|
-
import { SpinnerIcon, FileTextIcon } from './icons.js';
|
|
6
|
+
import { SpinnerIcon, FileTextIcon, CopyIcon, CheckIcon, RefreshIcon, SquarePenIcon } from './icons.js';
|
|
6
7
|
|
|
7
|
-
export function PreviewMessage({ message, isLoading }) {
|
|
8
|
+
export function PreviewMessage({ message, isLoading, onRetry, onEdit }) {
|
|
8
9
|
const isUser = message.role === 'user';
|
|
10
|
+
const [copied, setCopied] = useState(false);
|
|
11
|
+
const [editing, setEditing] = useState(false);
|
|
12
|
+
const [editText, setEditText] = useState('');
|
|
13
|
+
const textareaRef = useRef(null);
|
|
9
14
|
|
|
10
15
|
// Extract text from parts (AI SDK v5+) or fall back to content
|
|
11
16
|
const text =
|
|
@@ -21,65 +26,183 @@ export function PreviewMessage({ message, isLoading }) {
|
|
|
21
26
|
const imageParts = fileParts.filter((p) => p.mediaType?.startsWith('image/'));
|
|
22
27
|
const otherFileParts = fileParts.filter((p) => !p.mediaType?.startsWith('image/'));
|
|
23
28
|
|
|
29
|
+
const handleCopy = async () => {
|
|
30
|
+
try {
|
|
31
|
+
await navigator.clipboard.writeText(text);
|
|
32
|
+
setCopied(true);
|
|
33
|
+
setTimeout(() => setCopied(false), 2000);
|
|
34
|
+
} catch {}
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const handleEditStart = () => {
|
|
38
|
+
setEditText(text);
|
|
39
|
+
setEditing(true);
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const handleEditCancel = () => {
|
|
43
|
+
setEditing(false);
|
|
44
|
+
setEditText('');
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const handleEditSubmit = () => {
|
|
48
|
+
const trimmed = editText.trim();
|
|
49
|
+
if (trimmed && trimmed !== text) {
|
|
50
|
+
onEdit?.(message, trimmed);
|
|
51
|
+
}
|
|
52
|
+
setEditing(false);
|
|
53
|
+
setEditText('');
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
// Auto-resize and focus textarea when entering edit mode
|
|
57
|
+
useEffect(() => {
|
|
58
|
+
if (editing && textareaRef.current) {
|
|
59
|
+
const ta = textareaRef.current;
|
|
60
|
+
ta.focus();
|
|
61
|
+
ta.style.height = 'auto';
|
|
62
|
+
ta.style.height = `${ta.scrollHeight}px`;
|
|
63
|
+
// Move cursor to end
|
|
64
|
+
ta.setSelectionRange(ta.value.length, ta.value.length);
|
|
65
|
+
}
|
|
66
|
+
}, [editing]);
|
|
67
|
+
|
|
24
68
|
return (
|
|
25
69
|
<div
|
|
26
70
|
className={cn(
|
|
27
|
-
'flex gap-4 w-full',
|
|
71
|
+
'group flex gap-4 w-full',
|
|
28
72
|
isUser ? 'justify-end' : 'justify-start'
|
|
29
73
|
)}
|
|
30
74
|
>
|
|
31
|
-
<div
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
key
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
75
|
+
<div className="flex flex-col max-w-[80%]">
|
|
76
|
+
{editing ? (
|
|
77
|
+
<div className="flex flex-col gap-2">
|
|
78
|
+
<textarea
|
|
79
|
+
ref={textareaRef}
|
|
80
|
+
value={editText}
|
|
81
|
+
onChange={(e) => {
|
|
82
|
+
setEditText(e.target.value);
|
|
83
|
+
e.target.style.height = 'auto';
|
|
84
|
+
e.target.style.height = `${e.target.scrollHeight}px`;
|
|
85
|
+
}}
|
|
86
|
+
onKeyDown={(e) => {
|
|
87
|
+
if (e.key === 'Enter' && !e.shiftKey) {
|
|
88
|
+
e.preventDefault();
|
|
89
|
+
handleEditSubmit();
|
|
90
|
+
}
|
|
91
|
+
if (e.key === 'Escape') {
|
|
92
|
+
handleEditCancel();
|
|
93
|
+
}
|
|
94
|
+
}}
|
|
95
|
+
className="w-full resize-none rounded-xl border border-border bg-muted px-4 py-3 text-sm leading-relaxed text-foreground focus:outline-none focus:ring-1 focus:ring-primary"
|
|
96
|
+
rows={1}
|
|
97
|
+
/>
|
|
98
|
+
<div className="flex justify-end gap-2">
|
|
99
|
+
<button
|
|
100
|
+
onClick={handleEditCancel}
|
|
101
|
+
className="rounded-md px-3 py-1 text-xs text-muted-foreground hover:text-foreground"
|
|
102
|
+
>
|
|
103
|
+
Cancel
|
|
104
|
+
</button>
|
|
105
|
+
<button
|
|
106
|
+
onClick={handleEditSubmit}
|
|
107
|
+
className="rounded-md bg-primary px-3 py-1 text-xs text-primary-foreground hover:opacity-80"
|
|
108
|
+
>
|
|
109
|
+
Send
|
|
110
|
+
</button>
|
|
111
|
+
</div>
|
|
49
112
|
</div>
|
|
50
|
-
)
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
113
|
+
) : (
|
|
114
|
+
<>
|
|
115
|
+
<div
|
|
116
|
+
className={cn(
|
|
117
|
+
'rounded-xl px-4 py-3 text-sm leading-relaxed',
|
|
118
|
+
isUser
|
|
119
|
+
? 'bg-primary text-primary-foreground'
|
|
120
|
+
: 'bg-muted text-foreground'
|
|
121
|
+
)}
|
|
122
|
+
>
|
|
123
|
+
{imageParts.length > 0 && (
|
|
124
|
+
<div className="mb-2 flex flex-wrap gap-2">
|
|
125
|
+
{imageParts.map((part, i) => (
|
|
126
|
+
<img
|
|
127
|
+
key={i}
|
|
128
|
+
src={part.url}
|
|
129
|
+
alt="attachment"
|
|
130
|
+
className="max-h-64 max-w-full rounded-lg object-contain"
|
|
131
|
+
/>
|
|
132
|
+
))}
|
|
133
|
+
</div>
|
|
134
|
+
)}
|
|
135
|
+
{otherFileParts.length > 0 && (
|
|
136
|
+
<div className="mb-2 flex flex-wrap gap-2">
|
|
137
|
+
{otherFileParts.map((part, i) => (
|
|
138
|
+
<div
|
|
139
|
+
key={i}
|
|
140
|
+
className={cn(
|
|
141
|
+
'inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-xs',
|
|
142
|
+
isUser
|
|
143
|
+
? 'bg-primary-foreground/20'
|
|
144
|
+
: 'bg-foreground/10'
|
|
145
|
+
)}
|
|
146
|
+
>
|
|
147
|
+
<FileTextIcon size={12} />
|
|
148
|
+
<span className="max-w-[150px] truncate">
|
|
149
|
+
{part.name || part.mediaType || 'file'}
|
|
150
|
+
</span>
|
|
151
|
+
</div>
|
|
152
|
+
))}
|
|
153
|
+
</div>
|
|
154
|
+
)}
|
|
155
|
+
{text ? (
|
|
156
|
+
isUser ? (
|
|
157
|
+
<div className="whitespace-pre-wrap break-words">{text}</div>
|
|
158
|
+
) : (
|
|
159
|
+
<Streamdown mode={isLoading ? 'streaming' : 'static'}>{text}</Streamdown>
|
|
160
|
+
)
|
|
161
|
+
) : isLoading ? (
|
|
162
|
+
<div className="flex items-center gap-2 text-muted-foreground">
|
|
163
|
+
<SpinnerIcon size={14} />
|
|
164
|
+
<span>Working...</span>
|
|
165
|
+
</div>
|
|
166
|
+
) : null}
|
|
167
|
+
</div>
|
|
168
|
+
|
|
169
|
+
{/* Action toolbar */}
|
|
170
|
+
{!isLoading && text && (
|
|
54
171
|
<div
|
|
55
|
-
key={i}
|
|
56
172
|
className={cn(
|
|
57
|
-
'
|
|
58
|
-
isUser
|
|
59
|
-
? 'bg-primary-foreground/20'
|
|
60
|
-
: 'bg-foreground/10'
|
|
173
|
+
'flex gap-1 mt-1 opacity-0 transition-opacity group-hover:opacity-100',
|
|
174
|
+
isUser ? 'justify-end' : 'justify-start'
|
|
61
175
|
)}
|
|
62
176
|
>
|
|
63
|
-
<
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
177
|
+
<button
|
|
178
|
+
onClick={handleCopy}
|
|
179
|
+
className="rounded-md p-1 text-muted-foreground hover:text-foreground hover:bg-muted"
|
|
180
|
+
aria-label="Copy message"
|
|
181
|
+
>
|
|
182
|
+
{copied ? <CheckIcon size={14} /> : <CopyIcon size={14} />}
|
|
183
|
+
</button>
|
|
184
|
+
{onRetry && (
|
|
185
|
+
<button
|
|
186
|
+
onClick={() => onRetry(message)}
|
|
187
|
+
className="rounded-md p-1 text-muted-foreground hover:text-foreground hover:bg-muted"
|
|
188
|
+
aria-label="Retry"
|
|
189
|
+
>
|
|
190
|
+
<RefreshIcon size={14} />
|
|
191
|
+
</button>
|
|
192
|
+
)}
|
|
193
|
+
{isUser && onEdit && (
|
|
194
|
+
<button
|
|
195
|
+
onClick={handleEditStart}
|
|
196
|
+
className="rounded-md p-1 text-muted-foreground hover:text-foreground hover:bg-muted"
|
|
197
|
+
aria-label="Edit message"
|
|
198
|
+
>
|
|
199
|
+
<SquarePenIcon size={14} />
|
|
200
|
+
</button>
|
|
201
|
+
)}
|
|
67
202
|
</div>
|
|
68
|
-
)
|
|
69
|
-
|
|
203
|
+
)}
|
|
204
|
+
</>
|
|
70
205
|
)}
|
|
71
|
-
{text ? (
|
|
72
|
-
isUser ? (
|
|
73
|
-
<div className="whitespace-pre-wrap break-words">{text}</div>
|
|
74
|
-
) : (
|
|
75
|
-
<Streamdown mode={isLoading ? 'streaming' : 'static'}>{text}</Streamdown>
|
|
76
|
-
)
|
|
77
|
-
) : isLoading ? (
|
|
78
|
-
<div className="flex items-center gap-2 text-muted-foreground">
|
|
79
|
-
<SpinnerIcon size={14} />
|
|
80
|
-
<span>Working...</span>
|
|
81
|
-
</div>
|
|
82
|
-
) : null}
|
|
83
206
|
</div>
|
|
84
207
|
</div>
|
|
85
208
|
);
|
|
@@ -4,7 +4,7 @@ import { useRef, useEffect, useState } from "react";
|
|
|
4
4
|
import { PreviewMessage, ThinkingMessage } from "./message.js";
|
|
5
5
|
import { Greeting } from "./greeting.js";
|
|
6
6
|
import { ArrowDown } from "lucide-react";
|
|
7
|
-
function Messages({ messages, status }) {
|
|
7
|
+
function Messages({ messages, status, onRetry, onEdit }) {
|
|
8
8
|
const containerRef = useRef(null);
|
|
9
9
|
const endRef = useRef(null);
|
|
10
10
|
const [isAtBottom, setIsAtBottom] = useState(true);
|
|
@@ -32,13 +32,15 @@ function Messages({ messages, status }) {
|
|
|
32
32
|
{
|
|
33
33
|
className: "absolute inset-0 touch-pan-y overflow-y-auto",
|
|
34
34
|
ref: containerRef,
|
|
35
|
-
children: /* @__PURE__ */ jsxs("div", { className: "mx-auto flex min-w-0 max-w-4xl flex-col gap-4 px-
|
|
35
|
+
children: /* @__PURE__ */ jsxs("div", { className: "mx-auto flex min-w-0 max-w-4xl flex-col gap-4 px-4 py-4 md:gap-6 md:px-6", children: [
|
|
36
36
|
messages.length === 0 && /* @__PURE__ */ jsx(Greeting, {}),
|
|
37
37
|
messages.map((message, index) => /* @__PURE__ */ jsx(
|
|
38
38
|
PreviewMessage,
|
|
39
39
|
{
|
|
40
40
|
message,
|
|
41
|
-
isLoading: status === "streaming" && index === messages.length - 1
|
|
41
|
+
isLoading: status === "streaming" && index === messages.length - 1,
|
|
42
|
+
onRetry,
|
|
43
|
+
onEdit
|
|
42
44
|
},
|
|
43
45
|
message.id
|
|
44
46
|
)),
|
|
@@ -5,7 +5,7 @@ import { PreviewMessage, ThinkingMessage } from './message.js';
|
|
|
5
5
|
import { Greeting } from './greeting.js';
|
|
6
6
|
import { ArrowDown } from 'lucide-react';
|
|
7
7
|
|
|
8
|
-
export function Messages({ messages, status }) {
|
|
8
|
+
export function Messages({ messages, status, onRetry, onEdit }) {
|
|
9
9
|
const containerRef = useRef(null);
|
|
10
10
|
const endRef = useRef(null);
|
|
11
11
|
const [isAtBottom, setIsAtBottom] = useState(true);
|
|
@@ -41,7 +41,7 @@ export function Messages({ messages, status }) {
|
|
|
41
41
|
className="absolute inset-0 touch-pan-y overflow-y-auto"
|
|
42
42
|
ref={containerRef}
|
|
43
43
|
>
|
|
44
|
-
<div className="mx-auto flex min-w-0 max-w-4xl flex-col gap-4 px-
|
|
44
|
+
<div className="mx-auto flex min-w-0 max-w-4xl flex-col gap-4 px-4 py-4 md:gap-6 md:px-6">
|
|
45
45
|
{messages.length === 0 && <Greeting />}
|
|
46
46
|
|
|
47
47
|
{messages.map((message, index) => (
|
|
@@ -49,6 +49,8 @@ export function Messages({ messages, status }) {
|
|
|
49
49
|
key={message.id}
|
|
50
50
|
message={message}
|
|
51
51
|
isLoading={status === 'streaming' && index === messages.length - 1}
|
|
52
|
+
onRetry={onRetry}
|
|
53
|
+
onEdit={onEdit}
|
|
52
54
|
/>
|
|
53
55
|
))}
|
|
54
56
|
|