Skip to content

Commit 9975580

Browse files
authored
Merge pull request #5358 from ethanova/allow-assistant-message-edits
2 parents f572350 + 5bf78a3 commit 9975580

File tree

3 files changed

+144
-86
lines changed

3 files changed

+144
-86
lines changed

web-app/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
"fzf": "^0.5.2",
4646
"i18next": "^25.0.1",
4747
"katex": "^0.16.22",
48+
"lodash.clonedeep": "^4.5.0",
4849
"lodash.debounce": "^4.0.8",
4950
"lucide-react": "^0.522.0",
5051
"motion": "^12.10.5",
@@ -77,6 +78,7 @@
7778
"@eslint/js": "^9.22.0",
7879
"@tanstack/router-plugin": "^1.116.1",
7980
"@types/culori": "^2.1.1",
81+
"@types/lodash.clonedeep": "^4",
8082
"@types/lodash.debounce": "^4",
8183
"@types/node": "^22.14.1",
8284
"@types/react": "^19.0.10",

web-app/src/containers/ThreadContent.tsx

Lines changed: 81 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,6 @@ import {
2626
} from '@/components/ui/dialog'
2727
import { Button } from '@/components/ui/button'
2828
import { Textarea } from '@/components/ui/textarea'
29-
import { toast } from 'sonner'
3029
import {
3130
Tooltip,
3231
TooltipContent,
@@ -75,6 +74,69 @@ const CopyButton = ({ text }: { text: string }) => {
7574
)
7675
}
7776

77+
const EditDialog = ({
78+
message,
79+
setMessage,
80+
}: {
81+
message: string
82+
setMessage: (message: string) => void
83+
}) => {
84+
const { t } = useTranslation()
85+
const [draft, setDraft] = useState(message)
86+
87+
const handleSave = () => {
88+
if (draft !== message) {
89+
setMessage(draft)
90+
}
91+
}
92+
93+
return (
94+
<Dialog>
95+
<DialogTrigger>
96+
<Tooltip>
97+
<TooltipTrigger asChild>
98+
<div className="flex outline-0 items-center gap-1 hover:text-accent transition-colors cursor-pointer group relative">
99+
<IconPencil size={16} />
100+
</div>
101+
</TooltipTrigger>
102+
<TooltipContent>
103+
<p>{t('edit')}</p>
104+
</TooltipContent>
105+
</Tooltip>
106+
</DialogTrigger>
107+
<DialogContent className="w-3/4 h-3/4">
108+
<DialogHeader>
109+
<DialogTitle>{t('common:dialogs.editMessage.title')}</DialogTitle>
110+
<Textarea
111+
value={draft}
112+
onChange={(e) => setDraft(e.target.value)}
113+
className="mt-2 resize-none h-full w-full"
114+
onKeyDown={(e) => {
115+
// Prevent key from being captured by parent components
116+
e.stopPropagation()
117+
}}
118+
/>
119+
<DialogFooter className="mt-2 flex items-center">
120+
<DialogClose asChild>
121+
<Button variant="link" size="sm" className="hover:no-underline">
122+
Cancel
123+
</Button>
124+
</DialogClose>
125+
<DialogClose asChild>
126+
<Button
127+
disabled={draft === message || !draft}
128+
onClick={handleSave}
129+
>
130+
Save
131+
</Button>
132+
</DialogClose>
133+
</DialogFooter>
134+
</DialogHeader>
135+
</DialogContent>
136+
</Dialog>
137+
)
138+
}
139+
78140
// Use memo to prevent unnecessary re-renders, but allow re-renders when props change
79141
export const ThreadContent = memo(
80142
(
@@ -85,9 +147,9 @@ export const ThreadContent = memo(
85147
// eslint-disable-next-line @typescript-eslint/no-explicit-any
86148
streamTools?: any
87149
contextOverflowModal?: React.ReactNode | null
150+
updateMessage?: (item: ThreadMessage, message: string) => void
88151
}
89152
) => {
90-
const [message, setMessage] = useState(item.content?.[0]?.text?.value || '')
91153
const { t } = useTranslation()
92154

93155
// Use useMemo to stabilize the components prop
@@ -166,23 +228,6 @@ export const ThreadContent = memo(
166228
}
167229
}, [deleteMessage, getMessages, item])
168230

169-
const editMessage = useCallback(
170-
(messageId: string) => {
171-
const threadMessages = getMessages(item.thread_id)
172-
173-
const index = threadMessages.findIndex((msg) => msg.id === messageId)
174-
if (index === -1) return
175-
176-
// Delete all messages after the edited message
177-
for (let i = threadMessages.length - 1; i >= index; i--) {
178-
deleteMessage(threadMessages[i].thread_id, threadMessages[i].id)
179-
}
180-
181-
sendMessage(message)
182-
},
183-
[deleteMessage, getMessages, item.thread_id, message, sendMessage]
184-
)
185-
186231
const isToolCalls =
187232
item.metadata &&
188233
'tool_calls' in item.metadata &&
@@ -209,61 +254,14 @@ export const ThreadContent = memo(
209254
</div>
210255
</div>
211256
<div className="flex items-center justify-end gap-2 text-main-view-fg/60 text-xs mt-2">
212-
<Dialog>
213-
<DialogTrigger>
214-
<Tooltip>
215-
<TooltipTrigger asChild>
216-
<div className="flex outline-0 items-center gap-1 hover:text-accent transition-colors cursor-pointer group relative">
217-
<IconPencil size={16} />
218-
</div>
219-
</TooltipTrigger>
220-
<TooltipContent>
221-
<p>{t('edit')}</p>
222-
</TooltipContent>
223-
</Tooltip>
224-
</DialogTrigger>
225-
<DialogContent>
226-
<DialogHeader>
227-
<DialogTitle>{t('common:dialogs.editMessage.title')}</DialogTitle>
228-
<Textarea
229-
value={message}
230-
onChange={(e) => {
231-
setMessage(e.target.value)
232-
}}
233-
className="mt-2 resize-none"
234-
onKeyDown={(e) => {
235-
// Prevent key from being captured by parent components
236-
e.stopPropagation()
237-
}}
238-
/>
239-
<DialogFooter className="mt-2 flex items-center">
240-
<DialogClose asChild>
241-
<Button
242-
variant="link"
243-
size="sm"
244-
className="hover:no-underline"
245-
>
246-
Cancel
247-
</Button>
248-
</DialogClose>
249-
<DialogClose asChild>
250-
<Button
251-
disabled={!message}
252-
onClick={() => {
253-
editMessage(item.id)
254-
toast.success(t('common:toast.editMessage.title'), {
255-
id: 'edit-message',
256-
description: t('common:toast.editMessage.description'),
257-
})
258-
}}
259-
>
260-
Save
261-
</Button>
262-
</DialogClose>
263-
</DialogFooter>
264-
</DialogHeader>
265-
</DialogContent>
266-
</Dialog>
257+
<EditDialog
258+
message={item.content?.[0]?.text.value}
259+
setMessage={(message) => {
260+
if (item.updateMessage) {
261+
item.updateMessage(item, message)
262+
}
263+
}}
264+
/>
267265
<Tooltip>
268266
<TooltipTrigger asChild>
269267
<button
@@ -360,6 +358,12 @@ export const ThreadContent = memo(
360358
'hidden'
361359
)}
362360
>
361+
<EditDialog
362+
message={item.content?.[0]?.text.value}
363+
setMessage={(message) =>
364+
item.updateMessage && item.updateMessage(item, message)
365+
}
366+
/>
363367
<CopyButton text={item.content?.[0]?.text.value || ''} />
364368
<Tooltip>
365369
<TooltipTrigger asChild>
@@ -391,7 +395,9 @@ export const ThreadContent = memo(
391395
</DialogTrigger>
392396
<DialogContent>
393397
<DialogHeader>
394-
<DialogTitle>{t('common:dialogs.messageMetadata.title')}</DialogTitle>
398+
<DialogTitle>
399+
{t('common:dialogs.messageMetadata.title')}
400+
</DialogTitle>
395401
<div className="space-y-2">
396402
<div className="border border-main-view-fg/10 rounded-md overflow-hidden">
397403
<CodeEditor

web-app/src/routes/threads/$threadId.tsx

Lines changed: 61 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@ import { useEffect, useMemo, useRef, useState } from 'react'
22
import { createFileRoute, useParams } from '@tanstack/react-router'
33
import { UIEventHandler } from 'react'
44
import debounce from 'lodash.debounce'
5+
import cloneDeep from 'lodash.clonedeep'
56
import { cn } from '@/lib/utils'
67
import { ArrowDown } from 'lucide-react'
8+
import { Play } from 'lucide-react'
79

810
import HeaderPage from '@/containers/HeaderPage'
911
import { useThreads } from '@/hooks/useThreads'
@@ -18,7 +20,9 @@ import { useAppState } from '@/hooks/useAppState'
1820
import DropdownAssistant from '@/containers/DropdownAssistant'
1921
import { useAssistant } from '@/hooks/useAssistant'
2022
import { useAppearance } from '@/hooks/useAppearance'
23+
import { ContentType, ThreadMessage } from '@janhq/core'
2124
import { useTranslation } from '@/i18n/react-i18next-compat'
25+
import { useChat } from '@/hooks/useChat'
2226
import { useSmallScreen } from '@/hooks/useMediaQuery'
2327

2428
// as route.threadsDetail
@@ -38,6 +42,7 @@ function ThreadDetail() {
3842
const { setMessages } = useMessages()
3943
const { streamingContent } = useAppState()
4044
const { appMainViewBgColor, chatWidth } = useAppearance()
45+
const { sendMessage } = useChat()
4146
const isSmallScreen = useSmallScreen()
4247

4348
const { messages } = useMessages(
@@ -180,6 +185,26 @@ function ThreadDetail() {
180185
lastScrollTopRef.current = scrollTop
181186
}
182187

188+
const updateMessage = (item: ThreadMessage, message: string) => {
189+
const newMessages: ThreadMessage[] = messages.map((m) => {
190+
if (m.id === item.id) {
191+
const msg: ThreadMessage = cloneDeep(m)
192+
msg.content = [
193+
{
194+
type: ContentType.Text,
195+
text: {
196+
value: message,
197+
annotations: m.content[0].text?.annotations ?? [],
198+
},
199+
},
200+
]
201+
return msg
202+
}
203+
return m
204+
})
205+
setMessages(threadId, newMessages)
206+
}
207+
183208
// Use a shorter debounce time for more responsive scrolling
184209
const debouncedScroll = debounce(handleDOMScroll)
185210

@@ -193,10 +218,22 @@ function ThreadDetail() {
193218
// eslint-disable-next-line react-hooks/exhaustive-deps
194219
}, [])
195220

221+
// used when there is a sent/added user message and no assistant message (error or manual deletion)
222+
const generateAIResponse = () => {
223+
const latestUserMessage = messages[messages.length - 1]
224+
if (latestUserMessage?.content?.[0]?.text?.value) {
225+
sendMessage(latestUserMessage.content[0].text.value, false)
226+
}
227+
}
228+
196229
const threadModel = useMemo(() => thread?.model, [thread])
197230

198231
if (!messages || !threadModel) return null
199232

233+
const showScrollToBottomBtn = !isAtBottom && hasScrollbar
234+
const showGenerateAIResponseBtn =
235+
messages[messages.length - 1]?.role === 'user' && !streamingContent
236+
200237
return (
201238
<div className="flex flex-col h-full">
202239
<HeaderPage>
@@ -243,6 +280,7 @@ function ThreadDetail() {
243280
))
244281
}
245282
index={index}
283+
updateMessage={updateMessage}
246284
/>
247285
</div>
248286
)
@@ -266,19 +304,31 @@ function ThreadDetail() {
266304
appMainViewBgColor.a === 1
267305
? 'from-main-view/20 bg-gradient-to-b to-main-view backdrop-blur'
268306
: 'bg-transparent',
269-
!isAtBottom && hasScrollbar && 'visibility-visible opacity-100'
307+
(showScrollToBottomBtn || showGenerateAIResponseBtn) &&
308+
'visibility-visible opacity-100'
270309
)}
271310
>
272-
<div
273-
className="bg-main-view-fg/10 px-4 border border-main-view-fg/5 flex items-center justify-center rounded-xl gap-x-2 cursor-pointer pointer-events-auto"
274-
onClick={() => {
275-
scrollToBottom(true)
276-
setIsUserScrolling(false)
277-
}}
278-
>
279-
<p className="text-xs">{t('scrollToBottom')}</p>
280-
<ArrowDown size={12} />
281-
</div>
311+
{showScrollToBottomBtn && (
312+
<div
313+
className="bg-main-view-fg/10 px-4 border border-main-view-fg/5 flex items-center justify-center rounded-xl gap-x-2 cursor-pointer pointer-events-auto"
314+
onClick={() => {
315+
scrollToBottom(true)
316+
setIsUserScrolling(false)
317+
}}
318+
>
319+
<p className="text-xs">{t('scrollToBottom')}</p>
320+
<ArrowDown size={12} />
321+
</div>
322+
)}
323+
{showGenerateAIResponseBtn && (
324+
<div
325+
className="bg-main-view-fg/10 px-4 border border-main-view-fg/5 flex items-center justify-center rounded-xl gap-x-2 cursor-pointer pointer-events-auto"
326+
onClick={generateAIResponse}
327+
>
328+
<p className="text-xs">{t('Generate AI Response')}</p>
329+
<Play size={12} />
330+
</div>
331+
)}
282332
</div>
283333
<ChatInput model={threadModel} />
284334
</div>

0 commit comments

Comments
 (0)