Commit c3f99f53 authored by edy's avatar edy

feat(ui): add chat message delete action

parent 286f1972
......@@ -31,6 +31,7 @@ import {
RedBookIcon,
RefreshIcon,
ThumbIcon,
TrashIcon,
getIntentSuggestionIcon,
renderExpertIcon
} from "./components/icons/AppIcons";
......@@ -404,7 +405,8 @@ export default function App() {
renameMessageTrace,
initializeMessageTrace,
setMessageTraceExpanded,
collapseMessageTrace
collapseMessageTrace,
removeMessageTrace
} = useMessageTraces();
const {
saveConfig,
......@@ -1024,6 +1026,28 @@ export default function App() {
}
}
function deleteMessage(messageId: string) {
if (!visibleSessionId) {
return;
}
updateSessionMessages(visibleSessionId, (current) => {
const nextMessages = current.filter((message) => message.id !== messageId);
return nextMessages.length === current.length ? current : nextMessages;
});
removeMessageTrace(messageId);
setMessageReactions((current) => {
if (!(messageId in current)) {
return current;
}
const { [messageId]: _removed, ...rest } = current;
return rest;
});
if (copiedToken === `message:${messageId}`) {
setCopiedToken("");
}
}
async function regenerateAssistantMessage(messageId: string) {
if (sending || !visibleSessionId || !sessionScopeProjectId) {
return;
......@@ -1219,6 +1243,7 @@ export default function App() {
},
copyIcon: <CopyIcon />,
copiedIcon: <CheckIcon />,
deleteIcon: <TrashIcon />,
regenerateIcon: <RefreshIcon />,
renderThumbIcon: (direction) => <ThumbIcon direction={direction} />,
renderMarkdownContent,
......@@ -1226,6 +1251,7 @@ export default function App() {
formatMessageTimestamp,
onMessageListScroll: handleMessageListScroll,
onCopyText: handleCopyText,
onDeleteMessage: deleteMessage,
onTraceExpandedChange: setMessageTraceExpanded,
onRegenerateAssistantMessage: regenerateAssistantMessage,
onToggleMessageReaction: toggleMessageReaction,
......
......@@ -309,6 +309,17 @@ export function CheckIcon() {
);
}
export function TrashIcon() {
return (
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" focusable="false">
<path d="M5 7h14" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
<path d="M10 11v6M14 11v6" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" />
<path d="M8 7l.6 12.2A2 2 0 0 0 10.6 21h2.8a2 2 0 0 0 2-1.8L16 7" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" />
<path d="M9 7V5.6A1.6 1.6 0 0 1 10.6 4h2.8A1.6 1.6 0 0 1 15 5.6V7" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
export function ArrowUpIcon() {
return (
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" focusable="false">
......
......@@ -87,6 +87,7 @@ interface ConversationWorkspaceViewProps {
}
copyIcon: ReactNode
copiedIcon: ReactNode
deleteIcon: ReactNode
regenerateIcon: ReactNode
renderThumbIcon: (direction: MessageReaction) => ReactNode
renderMarkdownContent: (
......@@ -105,6 +106,7 @@ interface ConversationWorkspaceViewProps {
formatMessageTimestamp: (value: string) => string
onMessageListScroll: (event: ReactUIEvent<HTMLDivElement>) => void
onCopyText: (token: string, text: string) => void | Promise<void>
onDeleteMessage: (messageId: string) => void
onTraceExpandedChange: (messageId: string, expanded: boolean) => void
onRegenerateAssistantMessage: (messageId: string) => void | Promise<void>
onToggleMessageReaction: (messageId: string, reaction: MessageReaction) => void
......@@ -187,6 +189,7 @@ export function ConversationWorkspaceView({
messageLabels,
copyIcon,
copiedIcon,
deleteIcon,
regenerateIcon,
renderThumbIcon,
renderMarkdownContent,
......@@ -194,6 +197,7 @@ export function ConversationWorkspaceView({
formatMessageTimestamp,
onMessageListScroll,
onCopyText,
onDeleteMessage,
onTraceExpandedChange,
onRegenerateAssistantMessage,
onToggleMessageReaction,
......@@ -342,6 +346,7 @@ export function ConversationWorkspaceView({
labels={messageLabels}
copyIcon={copyIcon}
copiedIcon={copiedIcon}
deleteIcon={deleteIcon}
regenerateIcon={regenerateIcon}
renderThumbIcon={renderThumbIcon}
renderMarkdownContent={renderMarkdownContent}
......@@ -349,6 +354,7 @@ export function ConversationWorkspaceView({
formatMessageTimestamp={formatMessageTimestamp}
onScroll={onMessageListScroll}
onCopyText={onCopyText}
onDeleteMessage={onDeleteMessage}
onTraceExpandedChange={onTraceExpandedChange}
onRegenerateAssistantMessage={onRegenerateAssistantMessage}
onToggleMessageReaction={onToggleMessageReaction}
......
......@@ -41,6 +41,7 @@ interface MessageListProps {
labels: MessageListLabels
copyIcon: ReactNode
copiedIcon: ReactNode
deleteIcon: ReactNode
regenerateIcon: ReactNode
renderThumbIcon: (direction: MessageReaction) => ReactNode
renderMarkdownContent: (
......@@ -55,6 +56,7 @@ interface MessageListProps {
formatMessageTimestamp: (value: string) => string
onScroll: (event: UIEvent<HTMLDivElement>) => void
onCopyText: (token: string, text: string) => void | Promise<void>
onDeleteMessage: (messageId: string) => void
onTraceExpandedChange: (messageId: string, expanded: boolean) => void
onRegenerateAssistantMessage: (messageId: string) => void | Promise<void>
onToggleMessageReaction: (messageId: string, reaction: MessageReaction) => void
......@@ -189,6 +191,7 @@ export function MessageList({
labels,
copyIcon,
copiedIcon,
deleteIcon,
regenerateIcon,
renderThumbIcon,
renderMarkdownContent,
......@@ -196,6 +199,7 @@ export function MessageList({
formatMessageTimestamp,
onScroll,
onCopyText,
onDeleteMessage,
onTraceExpandedChange,
onRegenerateAssistantMessage,
onToggleMessageReaction
......@@ -220,129 +224,155 @@ export function MessageList({
const traceDisplayLines = hasTrace ? getTraceDisplayLines(messageTrace?.items ?? []) : []
const traceTitle = getTraceStripTitle(messageTrace?.items ?? [], labels.thinking, message.statusLabel, message.statusDetail)
const canCopyMessage = Boolean(message.content.trim())
const hasVisibleAttachments = message.role === "user" && Array.isArray(message.attachments) && message.attachments.some((attachment) => attachment.localPath && attachment.name)
const canDeleteMessage = message.streamState !== "streaming" && (canCopyMessage || hasVisibleAttachments)
const copyToken = `message:${message.id}`
const reaction = messageReactions[message.id]
void reaction
return (
<article key={message.id} className={"message-card group " + message.role + (message.streamState ? " " + message.streamState : "")}>
<div className={"message-bubble" + (message.role === "assistant" ? " message-bubble-assistant" : " message-bubble-user")}>
{showReasoningStrip ? (
<div className={"message-trace" + (message.streamState === "streaming" ? " streaming" : "")} aria-live={message.streamState === "streaming" ? "polite" : undefined}>
<button
type="button"
className={"reasoning-strip" + (isTraceExpanded ? " expanded" : "") + (message.streamState === "error" ? " error" : "") + (message.streamState === "streaming" ? " streaming" : "") + (isWaitingForContent ? " waiting" : "")}
onClick={() => onTraceExpandedChange(message.id, !isTraceExpanded)}
aria-expanded={isTraceExpanded}
>
<span className="reasoning-strip-leading" aria-hidden="true">
<span className="reasoning-strip-dots">
<span className="reasoning-strip-dot" />
<span className="reasoning-strip-dot" />
<span className="reasoning-strip-dot" />
</span>
</span>
<span className="reasoning-strip-title">{traceTitle}</span>
<span className="reasoning-strip-action">{isTraceExpanded ? labels.hideTrace : labels.traceCollapsed}</span>
</button>
{isTraceExpanded ? (
<div className="message-trace-content" aria-label="公开推理摘要">
{traceDisplayLines.map((line) => {
return (
<p key={line.key} className={getTraceLineClassName(line.tone, line.kind)}>
<span className="message-trace-marker" aria-hidden="true" />
<span className="message-trace-main">
<span className="message-trace-row">
<span className="message-trace-text">{line.title}</span>
<span className="message-trace-time">{line.time}</span>
</span>
{line.detail ? <span className="message-trace-detail">{line.detail}</span> : null}
</span>
</p>
)
})}
</div>
) : null}
</div>
) : null}
{isWaitingForContent && videoStatusCard ? (
<div className="generation-status-card" aria-live="polite">
<div className="generation-status-leading">
<span className="thinking-spinner generation-status-spinner" aria-hidden="true" />
</div>
<div className="generation-status-body">
<span className="generation-status-kicker">抖音专家执行中</span>
<strong className="generation-status-title">{videoStatusCard.title}</strong>
{videoStatusCard.meta ? <span className="generation-status-meta">{videoStatusCard.meta}</span> : null}
{videoStatusCard.hint ? <span className="generation-status-hint">{videoStatusCard.hint}</span> : null}
<span className="generation-status-progress" aria-hidden="true" />
</div>
</div>
) : message.content ? (
message.role === "assistant" ? (
<div className="markdown-body">
{renderMarkdownContent(message.content, {
messageId: message.id,
copiedToken,
onCopy: onCopyText
})}
{message.streamState === "streaming" ? <span className="message-cursor" aria-hidden="true" /> : null}
</div>
) : (
<p className="message-plain-text">
{message.content}
{message.streamState === "streaming" ? <span className="message-cursor" aria-hidden="true" /> : null}
</p>
)
) : null}
{message.role === "user" ? <MessageAttachmentStrip attachments={message.attachments} /> : null}
</div>
<span className="message-timestamp" aria-hidden="true">{formatMessageTimestamp(message.createdAt)}</span>
{message.role === "assistant" && canCopyMessage ? (
<div className="message-card-actions">
const messageActions = (canCopyMessage || canDeleteMessage) ? (
<div className="message-card-actions">
{canCopyMessage ? (
<button
type="button"
className={"message-action-icon" + (copiedToken === copyToken ? " copied" : "")}
onClick={() => void onCopyText(copyToken, message.content)}
aria-label="复制消息"
title="复制消息"
>
{copiedToken === copyToken ? copiedIcon : copyIcon}
</button>
) : null}
{canDeleteMessage ? (
<button
type="button"
className="message-action-icon message-action-delete"
onClick={() => onDeleteMessage(message.id)}
aria-label="删除消息"
title="删除消息"
>
{deleteIcon}
</button>
) : null}
{message.role === "assistant" ? (
<>
<button
type="button"
className={"message-action-icon" + (copiedToken === copyToken ? " copied" : "")}
onClick={() => void onCopyText(copyToken, message.content)}
aria-label="复制消息"
title="复制消息"
className="hidden"
onClick={() => void onRegenerateAssistantMessage(message.id)}
disabled={sending}
aria-label="重新生成"
title="重新生成"
>
{copiedToken === copyToken ? copiedIcon : copyIcon}
{regenerateIcon}
</button>
{message.role === "assistant" ? (
<>
<button
type="button"
className="hidden"
onClick={() => void onRegenerateAssistantMessage(message.id)}
disabled={sending}
aria-label="重新生成"
title="重新生成"
>
{regenerateIcon}
</button>
<button
type="button"
className="hidden"
onClick={() => onToggleMessageReaction(message.id, "up")}
aria-label="赞"
title="赞"
>
{renderThumbIcon("up")}
</button>
<button
type="button"
className="hidden"
onClick={() => onToggleMessageReaction(message.id, "up")}
aria-label="赞"
title="赞"
>
{renderThumbIcon("up")}
</button>
<button
type="button"
className="hidden"
onClick={() => onToggleMessageReaction(message.id, "down")}
aria-label="踩"
title="踩"
>
{renderThumbIcon("down")}
</button>
</>
) : null}
</div>
) : null
return (
<article key={message.id} className={"message-card group " + message.role + (message.streamState ? " " + message.streamState : "")}>
<div className={"message-card-body message-card-body-" + message.role}>
<div className={"message-bubble" + (message.role === "assistant" ? " message-bubble-assistant" : " message-bubble-user")}>
{showReasoningStrip ? (
<div className={"message-trace" + (message.streamState === "streaming" ? " streaming" : "")} aria-live={message.streamState === "streaming" ? "polite" : undefined}>
<button
type="button"
className="hidden"
onClick={() => onToggleMessageReaction(message.id, "down")}
aria-label="踩"
title="踩"
className={"reasoning-strip" + (isTraceExpanded ? " expanded" : "") + (message.streamState === "error" ? " error" : "") + (message.streamState === "streaming" ? " streaming" : "") + (isWaitingForContent ? " waiting" : "")}
onClick={() => onTraceExpandedChange(message.id, !isTraceExpanded)}
aria-expanded={isTraceExpanded}
>
{renderThumbIcon("down")}
<span className="reasoning-strip-leading" aria-hidden="true">
<span className="reasoning-strip-dots">
<span className="reasoning-strip-dot" />
<span className="reasoning-strip-dot" />
<span className="reasoning-strip-dot" />
</span>
</span>
<span className="reasoning-strip-title">{traceTitle}</span>
<span className="reasoning-strip-action">{isTraceExpanded ? labels.hideTrace : labels.traceCollapsed}</span>
</button>
{isTraceExpanded ? (
<div className="message-trace-content" aria-label="公开推理摘要">
{traceDisplayLines.map((line) => {
return (
<p key={line.key} className={getTraceLineClassName(line.tone, line.kind)}>
<span className="message-trace-marker" aria-hidden="true" />
<span className="message-trace-main">
<span className="message-trace-row">
<span className="message-trace-text">{line.title}</span>
<span className="message-trace-time">{line.time}</span>
</span>
{line.detail ? <span className="message-trace-detail">{line.detail}</span> : null}
</span>
</p>
)
})}
</div>
) : null}
</div>
) : null}
{isWaitingForContent && videoStatusCard ? (
<div className="generation-status-card" aria-live="polite">
<div className="generation-status-leading">
<span className="thinking-spinner generation-status-spinner" aria-hidden="true" />
</div>
<div className="generation-status-body">
<span className="generation-status-kicker">抖音专家执行中</span>
<strong className="generation-status-title">{videoStatusCard.title}</strong>
{videoStatusCard.meta ? <span className="generation-status-meta">{videoStatusCard.meta}</span> : null}
{videoStatusCard.hint ? <span className="generation-status-hint">{videoStatusCard.hint}</span> : null}
<span className="generation-status-progress" aria-hidden="true" />
</div>
</div>
) : message.content ? (
message.role === "assistant" ? (
<div className="markdown-body">
{renderMarkdownContent(message.content, {
messageId: message.id,
copiedToken,
onCopy: onCopyText
})}
{message.streamState === "streaming" ? <span className="message-cursor" aria-hidden="true" /> : null}
</div>
) : (
<>
<p className="message-plain-text">
{message.content}
{message.streamState === "streaming" ? <span className="message-cursor" aria-hidden="true" /> : null}
</p>
{message.role === "user" ? <MessageAttachmentStrip attachments={message.attachments} /> : null}
</>
)
) : message.role === "user" ? (
<>
<MessageAttachmentStrip attachments={message.attachments} />
</>
) : null}
</div>
) : null}
<div className="message-card-meta">
<span className="message-timestamp" aria-hidden="true">{formatMessageTimestamp(message.createdAt)}</span>
{messageActions}
</div>
</div>
</article>
)
})}
......
......@@ -96,12 +96,23 @@ export function useMessageTraces() {
setMessageTraceExpanded(messageId, false)
}, [setMessageTraceExpanded])
const removeMessageTrace = useCallback((messageId: string) => {
setMessageTraces((current) => {
if (!(messageId in current)) {
return current
}
const { [messageId]: _removed, ...rest } = current
return rest
})
}, [])
return {
messageTraces,
renameMessageTrace,
initializeMessageTrace,
appendTrace,
setMessageTraceExpanded,
collapseMessageTrace
collapseMessageTrace,
removeMessageTrace
}
}
......@@ -747,10 +747,17 @@
}
.conversation-shell .message-bubble,
.conversation-shell .message-card-body,
.conversation-shell .message-bubble-assistant,
.conversation-shell .message-card.assistant .message-bubble {
max-width: 100%;
}
.conversation-shell .message-card-meta {
opacity: 0.76;
pointer-events: auto;
transform: translateY(0);
}
}
@media (max-width: 720px) {
......
......@@ -270,6 +270,33 @@
margin-top: 6px;
}
.message-card-body {
display: grid;
gap: 4px;
max-width: 100%;
}
.message-card-body-user {
justify-self: end;
}
.message-card-body-assistant {
justify-self: start;
}
.message-card-meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
min-height: 28px;
padding: 0 2px;
opacity: 0;
pointer-events: none;
transform: translateY(-2px);
transition: opacity 150ms ease, transform 150ms ease;
}
.thinking-spinner {
width: 16px;
height: 16px;
......@@ -819,30 +846,57 @@
.message-card-actions {
display: flex;
align-items: center;
gap: 8px;
margin-top: 12px;
justify-content: flex-start;
opacity: 0;
transition: opacity 150ms ease;
gap: 1px;
width: max-content;
margin-top: 0;
padding: 2px;
border: 1px solid rgba(203, 213, 225, 0.68);
border-radius: 999px;
background: rgba(248, 250, 252, 0.9);
box-shadow: 0 6px 18px rgba(15, 23, 42, 0.06);
backdrop-filter: blur(12px);
justify-content: flex-end;
pointer-events: auto;
opacity: 1;
}
.message-card:hover .message-card-actions {
.message-card:hover .message-card-meta,
.message-card:focus-within .message-card-meta {
pointer-events: auto;
opacity: 1;
transform: translateY(0);
}
.message-action-delete:hover {
background: #fef2f2;
color: #dc2626;
}
.message-action-icon {
width: 32px;
height: 32px;
width: 26px;
height: 26px;
padding: 0;
border: 0;
border-radius: 999px;
background: #ffffff;
color: #5f7773;
box-shadow: 0 10px 24px rgba(17, 24, 39, 0.08);
background: transparent;
color: #738196;
cursor: pointer;
box-shadow: none;
transition: background-color 150ms ease, color 150ms ease, transform 120ms ease;
}
.message-action-icon:hover {
background: #f4fbfa;
background: #eef6ff;
color: #0f67de;
}
.message-action-icon:active {
transform: scale(0.94);
}
.message-action-icon:focus-visible {
outline: 2px solid rgba(15, 103, 222, 0.28);
outline-offset: 2px;
}
.message-action-icon.copied {
......
......@@ -1014,6 +1014,23 @@
justify-content: flex-start;
}
.conversation-shell .message-card-body {
width: auto;
max-width: min(72%, 760px);
}
.conversation-shell .message-card-body-user {
margin-left: auto;
}
.conversation-shell .message-card-body-assistant {
max-width: min(100%, 880px);
}
.conversation-shell .message-card-body .message-bubble {
max-width: 100%;
}
.conversation-shell .message-bubble {
width: auto;
display: grid;
......@@ -1095,30 +1112,17 @@
}
.conversation-shell .message-timestamp {
position: absolute;
bottom: 0;
left: 4px;
z-index: 1;
position: static;
color: #7c8da3;
font-size: 11px;
line-height: 1;
opacity: 0;
pointer-events: none;
transition: opacity 140ms ease;
}
.conversation-shell .message-card.user .message-timestamp {
left: auto;
right: 4px;
}
.conversation-shell .message-card:hover .message-timestamp,
.conversation-shell .message-card:focus-within .message-timestamp {
opacity: 1;
pointer-events: none;
white-space: nowrap;
}
.conversation-shell .message-card-actions {
margin-top: 8px;
margin-right: 0;
}
.conversation-shell .composer-shell {
......
......@@ -73,6 +73,7 @@
.nav-item-icon svg,
.expert-chip-icon svg,
.message-action-icon svg,
.message-action-delete svg,
.composer-submit svg,
.attachment-trigger svg,
.markdown-code-copy svg {
......@@ -104,11 +105,13 @@
@apply mb-0;
}
.message-card.assistant .message-card-actions {
.message-card .message-card-meta {
@apply pointer-events-none;
}
.message-card.assistant:hover .message-card-actions {
.message-card:hover .message-card-meta,
.message-card:focus-within .message-card-meta,
.message-card .message-card-meta:focus-within {
@apply pointer-events-auto;
}
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment