feat(chat): add message editing & regeneration (M8.1)
Pencil icon on hover for user messages to edit inline. Regenerate icon on assistant messages to re-send from the same prompt. Editing clears all subsequent messages and triggers a fresh AI response. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
9f6158ddd2
commit
61a6e88667
@@ -1,25 +1,73 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex animate-fade-up-fast"
|
||||
class="group/msg flex animate-fade-up-fast"
|
||||
:class="isUser ? 'justify-end' : 'justify-start'"
|
||||
:style="{ animationDelay: `${index * 30}ms` }"
|
||||
>
|
||||
<div
|
||||
class="max-w-[85%] md:max-w-[75%] rounded-2xl px-4 py-3 transition-all duration-300"
|
||||
:class="[bubbleClasses, { 'cursor-pointer': hasContext }]"
|
||||
@click="handleBubbleClick"
|
||||
>
|
||||
<div class="relative max-w-[85%] md:max-w-[75%]">
|
||||
<!-- Action buttons (hover on desktop, always visible on touch) -->
|
||||
<div
|
||||
v-if="!isUser"
|
||||
class="chat-markdown text-sm leading-relaxed break-words"
|
||||
:class="isDark ? 'text-white/90' : 'text-gray-800'"
|
||||
v-html="renderedMarkdown"
|
||||
/>
|
||||
<p
|
||||
v-else
|
||||
class="text-sm leading-relaxed whitespace-pre-wrap break-words"
|
||||
:class="isDark ? 'text-white/90' : 'text-gray-800'"
|
||||
>{{ displayText }}</p>
|
||||
v-if="!isEditing"
|
||||
class="absolute -top-3 opacity-0 group-hover/msg:opacity-100 transition-opacity duration-200 z-10 flex gap-1"
|
||||
:class="isUser ? 'right-1' : 'left-1'"
|
||||
>
|
||||
<button
|
||||
v-if="isUser"
|
||||
class="glass-button-sm !h-7 !min-h-0 !w-7 !min-w-0 !px-0 flex items-center justify-center text-white/60 hover:text-white/90"
|
||||
title="Edit message"
|
||||
@click.stop="startEditing"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" viewBox="0 0 20 20" fill="currentColor"><path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z" /></svg>
|
||||
</button>
|
||||
<button
|
||||
v-if="!isUser"
|
||||
class="glass-button-sm !h-7 !min-h-0 !w-7 !min-w-0 !px-0 flex items-center justify-center text-white/60 hover:text-white/90"
|
||||
title="Regenerate response"
|
||||
@click.stop="$emit('regenerate')"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z" clip-rule="evenodd" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="rounded-2xl px-4 py-3 transition-all duration-300"
|
||||
:class="[bubbleClasses, { 'cursor-pointer': hasContext }]"
|
||||
@click="handleBubbleClick"
|
||||
>
|
||||
<!-- Edit mode -->
|
||||
<div v-if="isEditing" class="space-y-2" @click.stop>
|
||||
<textarea
|
||||
ref="editTextareaRef"
|
||||
v-model="editContent"
|
||||
class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-sm text-white/90 resize-none focus:outline-none focus:border-accent/50"
|
||||
rows="3"
|
||||
@keydown.enter.exact="submitEdit"
|
||||
@keydown.escape="cancelEdit"
|
||||
/>
|
||||
<div class="flex gap-2 justify-end">
|
||||
<button
|
||||
class="glass-button-sm !h-7 !min-h-0 text-xs text-white/60 hover:text-white/90 px-3"
|
||||
@click="cancelEdit"
|
||||
>Cancel</button>
|
||||
<button
|
||||
class="glass-button-sm !h-7 !min-h-0 text-xs bg-accent/20 text-accent hover:bg-accent/30 px-3"
|
||||
@click="submitEdit"
|
||||
>Save & Resend</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Normal display -->
|
||||
<template v-else>
|
||||
<div
|
||||
v-if="!isUser"
|
||||
class="chat-markdown text-sm leading-relaxed break-words text-white/90"
|
||||
v-html="renderedMarkdown"
|
||||
/>
|
||||
<p
|
||||
v-else
|
||||
class="text-sm leading-relaxed whitespace-pre-wrap break-words text-white/90"
|
||||
>{{ displayText }}</p>
|
||||
</template>
|
||||
|
||||
<div v-if="inlineFilms.length > 0" class="mt-3 space-y-1" @click.stop>
|
||||
<FilmCard
|
||||
@@ -109,9 +157,9 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 mt-1.5">
|
||||
<span class="text-[10px] select-none"
|
||||
:class="isDark ? 'text-white/30' : 'text-gray-400'">{{ formattedTime }}</span>
|
||||
<div v-if="!isEditing" class="flex items-center gap-2 mt-1.5">
|
||||
<span class="text-[10px] select-none text-white/30">{{ formattedTime }}</span>
|
||||
<span v-if="message.editedAt" class="text-[10px] text-white/25 select-none">(edited)</span>
|
||||
<button
|
||||
v-if="inlineFilms.length > 1"
|
||||
class="text-[10px] text-accent/70 hover:text-accent transition-colors"
|
||||
@@ -183,12 +231,13 @@
|
||||
View brief →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { computed, ref, nextTick } from 'vue'
|
||||
import MarkdownIt from 'markdown-it'
|
||||
import type { Message, WebSearchResult } from '@aiui/core/types/message'
|
||||
import type { Film, Song, Podcast, Book, TVSeries, ImageItem, Place } from '@aiui/core/types/content'
|
||||
@@ -215,7 +264,43 @@ const props = withDefaults(
|
||||
{ triggeringQuery: '' }
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
edit: [messageId: string, newContent: string]
|
||||
regenerate: []
|
||||
}>()
|
||||
|
||||
const { isDark } = useTheme()
|
||||
|
||||
// Editing state
|
||||
const isEditing = ref(false)
|
||||
const editContent = ref('')
|
||||
const editTextareaRef = ref<HTMLTextAreaElement | null>(null)
|
||||
|
||||
function startEditing() {
|
||||
editContent.value = props.message.content
|
||||
isEditing.value = true
|
||||
nextTick(() => {
|
||||
editTextareaRef.value?.focus()
|
||||
// Auto-size textarea
|
||||
if (editTextareaRef.value) {
|
||||
editTextareaRef.value.style.height = 'auto'
|
||||
editTextareaRef.value.style.height = editTextareaRef.value.scrollHeight + 'px'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
isEditing.value = false
|
||||
editContent.value = ''
|
||||
}
|
||||
|
||||
function submitEdit() {
|
||||
const trimmed = editContent.value.trim()
|
||||
if (!trimmed) return
|
||||
emit('edit', props.message.id, trimmed)
|
||||
isEditing.value = false
|
||||
editContent.value = ''
|
||||
}
|
||||
const { getContextualInlineContent, stripContentTags, stripMarkdownLinks, updatePanelFromText, panelOpen, availableTabs, setActiveTab, openFilmDetail, openBookDetail, openTVSeriesDetail, openImageDetail, openPlaceDetail, openSongDetail, openPodcastDetail, openArticleDetail, openWebsiteDetail, closeFilmDetail, closeBookDetail, closeTVSeriesDetail, closeImageDetail, closePlaceDetail, closeSongDetail, closePodcastDetail } = useContentPanel()
|
||||
const codeContext = useCodeContext()
|
||||
|
||||
|
||||
@@ -57,6 +57,8 @@
|
||||
:message="messages[virtualRow.index]"
|
||||
:index="virtualRow.index"
|
||||
:triggering-query="getTriggeringQuery(messages, virtualRow.index)"
|
||||
@edit="handleEdit"
|
||||
@regenerate="handleRegenerate"
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
@@ -112,7 +114,7 @@ defineEmits<{
|
||||
}>()
|
||||
|
||||
const chatStore = useChatStore()
|
||||
const { sendMessage, stopGeneration } = useAI()
|
||||
const { sendMessage, stopGeneration, editAndResend, regenerateLastResponse } = useAI()
|
||||
const { isDark } = useTheme()
|
||||
const { updatePanelFromText, panelOpen, activeTab, availableTabs, setActiveTab } = useContentPanel()
|
||||
import { useCodeContext } from '@/composables/useCodeContext'
|
||||
@@ -163,6 +165,14 @@ function handleStop() {
|
||||
stopGeneration()
|
||||
}
|
||||
|
||||
async function handleEdit(messageId: string, newContent: string) {
|
||||
await editAndResend(messageId, newContent)
|
||||
}
|
||||
|
||||
async function handleRegenerate() {
|
||||
await regenerateLastResponse()
|
||||
}
|
||||
|
||||
function handleExtract(text: string) {
|
||||
// Run content extraction without sending to AI
|
||||
const convId = chatStore.activeConversationId ?? chatStore.createConversation()
|
||||
|
||||
@@ -355,9 +355,118 @@ export function useAI() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Edit a user message and regenerate the AI response.
|
||||
* Clears all messages after the edited message, then re-sends.
|
||||
*/
|
||||
async function editAndResend(messageId: string, newContent: string) {
|
||||
const convId = chatStore.activeConversationId
|
||||
if (!convId) return
|
||||
|
||||
const conv = chatStore.activeConversation
|
||||
if (!conv) return
|
||||
|
||||
const msgIndex = conv.messages.findIndex((m) => m.id === messageId)
|
||||
if (msgIndex === -1) return
|
||||
|
||||
// Update the message content
|
||||
chatStore.updateMessageContent(convId, messageId, newContent)
|
||||
|
||||
// Delete all messages after this one
|
||||
chatStore.deleteMessagesAfter(convId, msgIndex + 1)
|
||||
|
||||
// Re-send (creates new assistant message and streams)
|
||||
await resendLastUserMessage()
|
||||
}
|
||||
|
||||
/**
|
||||
* Regenerate the last assistant response.
|
||||
* Deletes the last assistant message and re-sends with the same user message.
|
||||
*/
|
||||
async function regenerateLastResponse() {
|
||||
const convId = chatStore.activeConversationId
|
||||
if (!convId) return
|
||||
|
||||
const conv = chatStore.activeConversation
|
||||
if (!conv || conv.messages.length === 0) return
|
||||
|
||||
// Find the last assistant message and remove it
|
||||
const lastIndex = conv.messages.length - 1
|
||||
if (conv.messages[lastIndex].role === 'assistant') {
|
||||
chatStore.deleteMessagesAfter(convId, lastIndex)
|
||||
}
|
||||
|
||||
await resendLastUserMessage()
|
||||
}
|
||||
|
||||
/** Internal: re-send from the current last user message */
|
||||
async function resendLastUserMessage() {
|
||||
const convId = chatStore.activeConversationId
|
||||
if (!convId) return
|
||||
|
||||
const conv = chatStore.activeConversation
|
||||
if (!conv || conv.messages.length === 0) return
|
||||
|
||||
const lastUserMsg = [...conv.messages].reverse().find((m) => m.role === 'user')
|
||||
if (!lastUserMsg) return
|
||||
|
||||
const provider = activeProvider.value
|
||||
currentAbort = new AbortController()
|
||||
const signal = currentAbort.signal
|
||||
const cid = convId
|
||||
|
||||
const assistantMsg = chatStore.addMessage(cid, { role: 'assistant', content: '' })
|
||||
if (!assistantMsg) return
|
||||
|
||||
chatStore.isStreaming = true
|
||||
|
||||
let systemPrompt = SYSTEM_PROMPT
|
||||
if (chatStore.webSearchEnabled) {
|
||||
systemPrompt += `
|
||||
|
||||
**Web search:** You have access to WebSearch and WebFetch tools. Use them to look up current information, news, and facts when the user asks. You can search the web and fetch page content. Web search is enabled for this session—do not tell the user it is unavailable.`
|
||||
}
|
||||
if (chatStore.webSearchEnabled && lastUserMsg.content.trim()) {
|
||||
const results = await searchWeb(lastUserMsg.content)
|
||||
if (results.length > 0) {
|
||||
systemPrompt += formatWebSearchContext(results)
|
||||
chatStore.setMessageWebResults(cid, assistantMsg.id, results)
|
||||
}
|
||||
}
|
||||
|
||||
const history: ChatMessage[] = chatStore.messages
|
||||
.filter((m) => m.id !== assistantMsg.id)
|
||||
.map((m) => ({ role: m.role as 'user' | 'assistant', content: m.content }))
|
||||
|
||||
const onToken = (text: string) => chatStore.appendToLastMessage(cid, text)
|
||||
const onError = (err: string) => {
|
||||
console.error(`[AIUI ${provider}]`, err)
|
||||
chatStore.appendToLastMessage(cid, `⚠ ${err}`)
|
||||
}
|
||||
|
||||
try {
|
||||
if (provider === 'claude') {
|
||||
await streamClaude(history, onToken, onError, systemPrompt, chatStore.webSearchEnabled, signal)
|
||||
} else if (provider === 'openrouter') {
|
||||
await streamOpenRouter(history, onToken, onError, systemPrompt, signal)
|
||||
} else {
|
||||
await streamMock(history, onToken, signal)
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === 'AbortError') return
|
||||
const msg = err instanceof Error ? err.message : String(err)
|
||||
chatStore.appendToLastMessage(cid, `\n\n⚠ Connection error: ${msg}`)
|
||||
} finally {
|
||||
currentAbort = null
|
||||
chatStore.isStreaming = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
sendMessage,
|
||||
stopGeneration,
|
||||
editAndResend,
|
||||
regenerateLastResponse,
|
||||
activeProvider,
|
||||
activeModel,
|
||||
availableProviders,
|
||||
|
||||
@@ -244,6 +244,28 @@ export const useChatStore = defineStore('chat', () => {
|
||||
}
|
||||
}
|
||||
|
||||
/** Update a message's content (for editing) */
|
||||
function updateMessageContent(conversationId: string, messageId: string, newContent: string) {
|
||||
const conv = conversations.value.get(conversationId)
|
||||
if (!conv) return
|
||||
const msg = conv.messages.find((m: { id: string }) => m.id === messageId)
|
||||
if (msg) {
|
||||
msg.content = newContent
|
||||
msg.editedAt = Date.now()
|
||||
conv.updatedAt = Date.now()
|
||||
debouncedIDBSave(conv)
|
||||
}
|
||||
}
|
||||
|
||||
/** Delete all messages after the given index (inclusive of afterIndex) */
|
||||
function deleteMessagesAfter(conversationId: string, afterIndex: number) {
|
||||
const conv = conversations.value.get(conversationId)
|
||||
if (!conv) return
|
||||
conv.messages.splice(afterIndex)
|
||||
conv.updatedAt = Date.now()
|
||||
debouncedIDBSave(conv)
|
||||
}
|
||||
|
||||
return {
|
||||
conversations,
|
||||
activeConversationId,
|
||||
@@ -263,5 +285,7 @@ export const useChatStore = defineStore('chat', () => {
|
||||
toggleChatCollapse,
|
||||
setActiveConversation,
|
||||
deleteConversation,
|
||||
updateMessageContent,
|
||||
deleteMessagesAfter,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -21,6 +21,8 @@ export interface Message {
|
||||
status?: 'sending' | 'sent' | 'delivered' | 'read' | 'error'
|
||||
/** Web search results (articles, links) attached when query used web search */
|
||||
webResults?: WebSearchResult[]
|
||||
/** Timestamp of last edit (set when user edits a sent message) */
|
||||
editedAt?: number
|
||||
}
|
||||
|
||||
export interface Reaction {
|
||||
|
||||
Reference in New Issue
Block a user