v0.1.4-p4
parent
3ca7eff38b
commit
08b35d632b
|
|
@ -216,11 +216,10 @@
|
|||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
border: 1px dashed color-mix(in oklab, #6f94ff 46%, var(--line) 54%);
|
||||
border: 1px dashed color-mix(in oklab, #aeb7c4 52%, var(--line) 48%);
|
||||
border-radius: 14px;
|
||||
background:
|
||||
linear-gradient(180deg, color-mix(in oklab, var(--panel) 68%, #dfe9ff 32%), color-mix(in oklab, var(--panel) 82%, #d5e3ff 18%));
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.22);
|
||||
background: color-mix(in oklab, #eef1f4 82%, var(--panel) 18%);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.16);
|
||||
}
|
||||
|
||||
.ops-staged-submission-item {
|
||||
|
|
@ -229,9 +228,9 @@
|
|||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid color-mix(in oklab, #7aa2ff 18%, var(--line) 82%);
|
||||
border: 1px solid color-mix(in oklab, #bcc5cf 26%, var(--line) 74%);
|
||||
border-radius: 12px;
|
||||
background: color-mix(in oklab, var(--panel) 84%, white 16%);
|
||||
background: color-mix(in oklab, #f5f7f9 88%, var(--panel) 12%);
|
||||
}
|
||||
|
||||
.ops-staged-submission-index {
|
||||
|
|
@ -242,9 +241,9 @@
|
|||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 11px;
|
||||
color: var(--brand);
|
||||
background: color-mix(in oklab, var(--panel) 64%, white 36%);
|
||||
border: 1px solid color-mix(in oklab, var(--line) 70%, transparent);
|
||||
color: color-mix(in oklab, var(--text) 72%, var(--muted) 28%);
|
||||
background: color-mix(in oklab, #ffffff 72%, #e5e9ee 28%);
|
||||
border: 1px solid color-mix(in oklab, #c7ced6 46%, transparent);
|
||||
}
|
||||
|
||||
.ops-staged-submission-body {
|
||||
|
|
@ -272,12 +271,12 @@
|
|||
.ops-staged-submission-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border: 1px solid color-mix(in oklab, var(--line) 74%, transparent);
|
||||
border: 1px solid color-mix(in oklab, #c3cad2 44%, transparent);
|
||||
border-radius: 999px;
|
||||
padding: 2px 8px;
|
||||
font-size: 11px;
|
||||
color: var(--subtitle);
|
||||
background: color-mix(in oklab, var(--panel) 82%, transparent);
|
||||
background: color-mix(in oklab, #ffffff 74%, #e8edf2 26%);
|
||||
}
|
||||
|
||||
.ops-staged-submission-actions {
|
||||
|
|
@ -290,9 +289,9 @@
|
|||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
border: 1px solid color-mix(in oklab, var(--line) 76%, transparent);
|
||||
border: 1px solid color-mix(in oklab, #c2c9d2 42%, transparent);
|
||||
border-radius: 999px;
|
||||
background: color-mix(in oklab, var(--panel) 88%, white 12%);
|
||||
background: color-mix(in oklab, #fbfcfd 82%, #e6ebf0 18%);
|
||||
color: var(--icon-muted);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
|
@ -303,8 +302,8 @@
|
|||
|
||||
.ops-staged-submission-icon-btn:hover:not(:disabled) {
|
||||
color: var(--icon);
|
||||
background: color-mix(in oklab, var(--panel) 70%, var(--brand-soft) 30%);
|
||||
border-color: color-mix(in oklab, var(--brand) 38%, var(--line) 62%);
|
||||
background: color-mix(in oklab, #edf1f5 78%, #d8dde4 22%);
|
||||
border-color: color-mix(in oklab, #97a3af 34%, var(--line) 66%);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { ArrowUp, ChevronLeft, Clock3, Command, Download, Eye, FileText, Mic, Paperclip, Pencil, Plus, RefreshCw, RotateCcw, Square, Trash2, X } from 'lucide-react';
|
||||
import type { Components } from 'react-markdown';
|
||||
import type { ChangeEventHandler, KeyboardEventHandler, RefObject } from 'react';
|
||||
import { memo, type ChangeEventHandler, type KeyboardEventHandler, type RefObject } from 'react';
|
||||
|
||||
import { LucentIconButton } from '../../../components/lucent/LucentIconButton';
|
||||
import nanobotLogo from '../../../assets/nanobot-logo.png';
|
||||
|
|
@ -121,6 +121,145 @@ interface DashboardChatPanelProps {
|
|||
onSubmitAction: () => Promise<void> | void;
|
||||
}
|
||||
|
||||
interface DashboardChatTranscriptProps {
|
||||
conversation: ChatMessage[];
|
||||
isZh: boolean;
|
||||
labels: DashboardChatPanelLabels;
|
||||
chatScrollRef: RefObject<HTMLDivElement | null>;
|
||||
onChatScroll: () => void;
|
||||
expandedProgressByKey: Record<string, boolean>;
|
||||
expandedUserByKey: Record<string, boolean>;
|
||||
deletingMessageIdMap: Record<number, boolean>;
|
||||
feedbackSavingByMessageId: Record<number, boolean>;
|
||||
markdownComponents: Components;
|
||||
workspaceDownloadExtensionSet: ReadonlySet<string>;
|
||||
onToggleProgressExpand: (key: string) => void;
|
||||
onToggleUserExpand: (key: string) => void;
|
||||
onEditUserPrompt: (text: string) => void;
|
||||
onCopyUserPrompt: (text: string) => Promise<void> | void;
|
||||
onDeleteConversationMessage: (message: ChatMessage) => Promise<void> | void;
|
||||
onOpenWorkspacePath: (path: string) => Promise<void> | void;
|
||||
onSubmitAssistantFeedback: (message: ChatMessage, direction: 'up' | 'down') => Promise<void> | void;
|
||||
onQuoteAssistantReply: (message: ChatMessage) => void;
|
||||
onCopyAssistantReply: (text: string) => Promise<void> | void;
|
||||
isThinking: boolean;
|
||||
}
|
||||
|
||||
const MemoizedChatTranscript = memo(function MemoizedChatTranscript({
|
||||
conversation,
|
||||
isZh,
|
||||
labels,
|
||||
chatScrollRef,
|
||||
onChatScroll,
|
||||
expandedProgressByKey,
|
||||
expandedUserByKey,
|
||||
deletingMessageIdMap,
|
||||
feedbackSavingByMessageId,
|
||||
markdownComponents,
|
||||
workspaceDownloadExtensionSet,
|
||||
onToggleProgressExpand,
|
||||
onToggleUserExpand,
|
||||
onEditUserPrompt,
|
||||
onCopyUserPrompt,
|
||||
onDeleteConversationMessage,
|
||||
onOpenWorkspacePath,
|
||||
onSubmitAssistantFeedback,
|
||||
onQuoteAssistantReply,
|
||||
onCopyAssistantReply,
|
||||
isThinking,
|
||||
}: DashboardChatTranscriptProps) {
|
||||
return (
|
||||
<div className="ops-chat-scroll" ref={chatScrollRef} onScroll={onChatScroll}>
|
||||
{conversation.length === 0 ? (
|
||||
<div className="ops-chat-empty">
|
||||
{labels.noConversation}
|
||||
</div>
|
||||
) : (
|
||||
<DashboardConversationMessages
|
||||
conversation={conversation}
|
||||
isZh={isZh}
|
||||
labels={{
|
||||
badReply: labels.badReply,
|
||||
copyPrompt: labels.copyPrompt,
|
||||
copyReply: labels.copyReply,
|
||||
deleteMessage: labels.deleteMessage,
|
||||
download: labels.download,
|
||||
editPrompt: labels.editPrompt,
|
||||
fileNotPreviewable: labels.fileNotPreviewable,
|
||||
goodReply: labels.goodReply,
|
||||
previewTitle: labels.previewTitle,
|
||||
quoteReply: labels.quoteReply,
|
||||
quotedReplyLabel: labels.quotedReplyLabel,
|
||||
user: labels.user,
|
||||
you: labels.you,
|
||||
}}
|
||||
expandedProgressByKey={expandedProgressByKey}
|
||||
expandedUserByKey={expandedUserByKey}
|
||||
deletingMessageIdMap={deletingMessageIdMap}
|
||||
feedbackSavingByMessageId={feedbackSavingByMessageId}
|
||||
markdownComponents={markdownComponents}
|
||||
workspaceDownloadExtensionSet={workspaceDownloadExtensionSet}
|
||||
onToggleProgressExpand={onToggleProgressExpand}
|
||||
onToggleUserExpand={onToggleUserExpand}
|
||||
onEditUserPrompt={onEditUserPrompt}
|
||||
onCopyUserPrompt={onCopyUserPrompt}
|
||||
onDeleteConversationMessage={onDeleteConversationMessage}
|
||||
onOpenWorkspacePath={onOpenWorkspacePath}
|
||||
onSubmitAssistantFeedback={onSubmitAssistantFeedback}
|
||||
onQuoteAssistantReply={onQuoteAssistantReply}
|
||||
onCopyAssistantReply={onCopyAssistantReply}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isThinking ? (
|
||||
<div className="ops-chat-row is-assistant">
|
||||
<div className="ops-chat-item is-assistant">
|
||||
<div className="ops-avatar bot" title="Nanobot">
|
||||
<img src={nanobotLogo} alt="Nanobot" />
|
||||
</div>
|
||||
<div className="ops-thinking-bubble">
|
||||
<div className="ops-thinking-cloud">
|
||||
<span className="dot" />
|
||||
<span className="dot" />
|
||||
<span className="dot" />
|
||||
</div>
|
||||
<div className="ops-thinking-text">{labels.thinking}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div />
|
||||
</div>
|
||||
);
|
||||
}, (prev, next) => (
|
||||
prev.conversation === next.conversation
|
||||
&& prev.isZh === next.isZh
|
||||
&& prev.isThinking === next.isThinking
|
||||
&& prev.chatScrollRef === next.chatScrollRef
|
||||
&& prev.expandedProgressByKey === next.expandedProgressByKey
|
||||
&& prev.expandedUserByKey === next.expandedUserByKey
|
||||
&& prev.deletingMessageIdMap === next.deletingMessageIdMap
|
||||
&& prev.feedbackSavingByMessageId === next.feedbackSavingByMessageId
|
||||
&& prev.markdownComponents === next.markdownComponents
|
||||
&& prev.workspaceDownloadExtensionSet === next.workspaceDownloadExtensionSet
|
||||
&& prev.labels.badReply === next.labels.badReply
|
||||
&& prev.labels.copyPrompt === next.labels.copyPrompt
|
||||
&& prev.labels.copyReply === next.labels.copyReply
|
||||
&& prev.labels.deleteMessage === next.labels.deleteMessage
|
||||
&& prev.labels.download === next.labels.download
|
||||
&& prev.labels.editPrompt === next.labels.editPrompt
|
||||
&& prev.labels.fileNotPreviewable === next.labels.fileNotPreviewable
|
||||
&& prev.labels.goodReply === next.labels.goodReply
|
||||
&& prev.labels.noConversation === next.labels.noConversation
|
||||
&& prev.labels.previewTitle === next.labels.previewTitle
|
||||
&& prev.labels.quoteReply === next.labels.quoteReply
|
||||
&& prev.labels.quotedReplyLabel === next.labels.quotedReplyLabel
|
||||
&& prev.labels.thinking === next.labels.thinking
|
||||
&& prev.labels.user === next.labels.user
|
||||
&& prev.labels.you === next.labels.you
|
||||
));
|
||||
|
||||
export function DashboardChatPanel({
|
||||
conversation,
|
||||
isZh,
|
||||
|
|
@ -195,68 +334,29 @@ export function DashboardChatPanel({
|
|||
const hasComposerDraft = Boolean(String(command || '').trim()) || pendingAttachments.length > 0 || Boolean(quotedReply);
|
||||
return (
|
||||
<div className={`ops-chat-frame ${isChatEnabled ? '' : 'is-disabled'}`}>
|
||||
<div className="ops-chat-scroll" ref={chatScrollRef} onScroll={onChatScroll}>
|
||||
{conversation.length === 0 ? (
|
||||
<div className="ops-chat-empty">
|
||||
{labels.noConversation}
|
||||
</div>
|
||||
) : (
|
||||
<DashboardConversationMessages
|
||||
conversation={conversation}
|
||||
isZh={isZh}
|
||||
labels={{
|
||||
badReply: labels.badReply,
|
||||
copyPrompt: labels.copyPrompt,
|
||||
copyReply: labels.copyReply,
|
||||
deleteMessage: labels.deleteMessage,
|
||||
download: labels.download,
|
||||
editPrompt: labels.editPrompt,
|
||||
fileNotPreviewable: labels.fileNotPreviewable,
|
||||
goodReply: labels.goodReply,
|
||||
previewTitle: labels.previewTitle,
|
||||
quoteReply: labels.quoteReply,
|
||||
quotedReplyLabel: labels.quotedReplyLabel,
|
||||
user: labels.user,
|
||||
you: labels.you,
|
||||
}}
|
||||
expandedProgressByKey={expandedProgressByKey}
|
||||
expandedUserByKey={expandedUserByKey}
|
||||
deletingMessageIdMap={deletingMessageIdMap}
|
||||
feedbackSavingByMessageId={feedbackSavingByMessageId}
|
||||
markdownComponents={markdownComponents}
|
||||
workspaceDownloadExtensionSet={workspaceDownloadExtensionSet}
|
||||
onToggleProgressExpand={onToggleProgressExpand}
|
||||
onToggleUserExpand={onToggleUserExpand}
|
||||
onEditUserPrompt={onEditUserPrompt}
|
||||
onCopyUserPrompt={onCopyUserPrompt}
|
||||
onDeleteConversationMessage={onDeleteConversationMessage}
|
||||
onOpenWorkspacePath={onOpenWorkspacePath}
|
||||
onSubmitAssistantFeedback={onSubmitAssistantFeedback}
|
||||
onQuoteAssistantReply={onQuoteAssistantReply}
|
||||
onCopyAssistantReply={onCopyAssistantReply}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isThinking ? (
|
||||
<div className="ops-chat-row is-assistant">
|
||||
<div className="ops-chat-item is-assistant">
|
||||
<div className="ops-avatar bot" title="Nanobot">
|
||||
<img src={nanobotLogo} alt="Nanobot" />
|
||||
</div>
|
||||
<div className="ops-thinking-bubble">
|
||||
<div className="ops-thinking-cloud">
|
||||
<span className="dot" />
|
||||
<span className="dot" />
|
||||
<span className="dot" />
|
||||
</div>
|
||||
<div className="ops-thinking-text">{labels.thinking}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div />
|
||||
</div>
|
||||
<MemoizedChatTranscript
|
||||
conversation={conversation}
|
||||
isZh={isZh}
|
||||
labels={labels}
|
||||
chatScrollRef={chatScrollRef}
|
||||
onChatScroll={onChatScroll}
|
||||
expandedProgressByKey={expandedProgressByKey}
|
||||
expandedUserByKey={expandedUserByKey}
|
||||
deletingMessageIdMap={deletingMessageIdMap}
|
||||
feedbackSavingByMessageId={feedbackSavingByMessageId}
|
||||
markdownComponents={markdownComponents}
|
||||
workspaceDownloadExtensionSet={workspaceDownloadExtensionSet}
|
||||
onToggleProgressExpand={onToggleProgressExpand}
|
||||
onToggleUserExpand={onToggleUserExpand}
|
||||
onEditUserPrompt={onEditUserPrompt}
|
||||
onCopyUserPrompt={onCopyUserPrompt}
|
||||
onDeleteConversationMessage={onDeleteConversationMessage}
|
||||
onOpenWorkspacePath={onOpenWorkspacePath}
|
||||
onSubmitAssistantFeedback={onSubmitAssistantFeedback}
|
||||
onQuoteAssistantReply={onQuoteAssistantReply}
|
||||
onCopyAssistantReply={onCopyAssistantReply}
|
||||
isThinking={isThinking}
|
||||
/>
|
||||
|
||||
<div className="ops-chat-dock">
|
||||
{stagedSubmissions.length > 0 ? (
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { ChevronDown, ChevronUp, Copy, Download, Eye, FileText, Pencil, Reply, ThumbsDown, ThumbsUp, Trash2, UserRound } from 'lucide-react';
|
||||
import { memo } from 'react';
|
||||
import ReactMarkdown, { type Components } from 'react-markdown';
|
||||
import rehypeRaw from 'rehype-raw';
|
||||
import rehypeSanitize from 'rehype-sanitize';
|
||||
|
|
@ -50,6 +51,29 @@ interface DashboardConversationMessagesProps {
|
|||
onCopyAssistantReply: (text: string) => Promise<void> | void;
|
||||
}
|
||||
|
||||
interface DashboardConversationMessageRowProps {
|
||||
item: ChatMessage;
|
||||
itemKey: string;
|
||||
isZh: boolean;
|
||||
labels: DashboardConversationLabels;
|
||||
showDateDivider: boolean;
|
||||
expandedProgress: boolean;
|
||||
expandedUser: boolean;
|
||||
isDeleting: boolean;
|
||||
isFeedbackSaving: boolean;
|
||||
markdownComponents: Components;
|
||||
workspaceDownloadExtensionSet: ReadonlySet<string>;
|
||||
onToggleProgressExpand: (key: string) => void;
|
||||
onToggleUserExpand: (key: string) => void;
|
||||
onEditUserPrompt: (text: string) => void;
|
||||
onCopyUserPrompt: (text: string) => Promise<void> | void;
|
||||
onDeleteConversationMessage: (message: ChatMessage) => Promise<void> | void;
|
||||
onOpenWorkspacePath: (path: string) => Promise<void> | void;
|
||||
onSubmitAssistantFeedback: (message: ChatMessage, direction: 'up' | 'down') => Promise<void> | void;
|
||||
onQuoteAssistantReply: (message: ChatMessage) => void;
|
||||
onCopyAssistantReply: (text: string) => Promise<void> | void;
|
||||
}
|
||||
|
||||
function shouldCollapseProgress(text: string) {
|
||||
const normalized = String(text || '').trim();
|
||||
if (!normalized) return false;
|
||||
|
|
@ -65,6 +89,261 @@ function getConversationItemKey(item: ChatMessage, idx: number) {
|
|||
return `temp:${item.role}:${item.kind || 'final'}:${item.ts}:${idx}`;
|
||||
}
|
||||
|
||||
function sameAttachments(left?: string[], right?: string[]) {
|
||||
const aa = left || [];
|
||||
const bb = right || [];
|
||||
if (aa.length !== bb.length) return false;
|
||||
return aa.every((value, index) => value === bb[index]);
|
||||
}
|
||||
|
||||
const DashboardConversationMessageRow = memo(function DashboardConversationMessageRow({
|
||||
item,
|
||||
itemKey,
|
||||
isZh,
|
||||
labels,
|
||||
showDateDivider,
|
||||
expandedProgress,
|
||||
expandedUser,
|
||||
isDeleting,
|
||||
isFeedbackSaving,
|
||||
markdownComponents,
|
||||
workspaceDownloadExtensionSet,
|
||||
onToggleProgressExpand,
|
||||
onToggleUserExpand,
|
||||
onEditUserPrompt,
|
||||
onCopyUserPrompt,
|
||||
onDeleteConversationMessage,
|
||||
onOpenWorkspacePath,
|
||||
onSubmitAssistantFeedback,
|
||||
onQuoteAssistantReply,
|
||||
onCopyAssistantReply,
|
||||
}: DashboardConversationMessageRowProps) {
|
||||
const isProgressBubble = item.role !== 'user' && (item.kind || 'final') === 'progress';
|
||||
const isUserBubble = item.role === 'user';
|
||||
const fullText = String(item.text || '');
|
||||
const summaryText = isProgressBubble ? summarizeProgressText(fullText, isZh) : fullText;
|
||||
const hasSummary = isProgressBubble && summaryText.trim().length > 0 && summaryText.trim() !== fullText.trim();
|
||||
const progressCollapsible = isProgressBubble && (hasSummary || shouldCollapseProgress(fullText));
|
||||
const normalizedUserText = isUserBubble ? normalizeUserMessageText(fullText) : '';
|
||||
const userLineCount = isUserBubble ? normalizedUserText.split('\n').length : 0;
|
||||
const userCollapsible = isUserBubble && userLineCount > 5;
|
||||
const collapsible = isProgressBubble ? progressCollapsible : userCollapsible;
|
||||
const expanded = isProgressBubble ? expandedProgress : expandedUser;
|
||||
const displayText = isProgressBubble && !expanded ? summaryText : fullText;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={itemKey}
|
||||
data-chat-message-id={item.id ? String(item.id) : undefined}
|
||||
>
|
||||
{showDateDivider ? (
|
||||
<div className="ops-chat-date-divider" aria-label={formatConversationDate(item.ts, isZh)}>
|
||||
<span>{formatConversationDate(item.ts, isZh)}</span>
|
||||
</div>
|
||||
) : null}
|
||||
<div className={`ops-chat-row ${item.role === 'user' ? 'is-user' : 'is-assistant'}`}>
|
||||
<div className={`ops-chat-item ${item.role === 'user' ? 'is-user' : 'is-assistant'}`}>
|
||||
{item.role !== 'user' && (
|
||||
<div className="ops-avatar bot" title="Nanobot">
|
||||
<img src={nanobotLogo} alt="Nanobot" />
|
||||
</div>
|
||||
)}
|
||||
{item.role === 'user' ? (
|
||||
<div className="ops-chat-hover-actions ops-chat-hover-actions-user">
|
||||
<LucentIconButton
|
||||
className="ops-chat-inline-action"
|
||||
onClick={() => onEditUserPrompt(item.text)}
|
||||
tooltip={labels.editPrompt}
|
||||
aria-label={labels.editPrompt}
|
||||
>
|
||||
<Pencil size={13} />
|
||||
</LucentIconButton>
|
||||
<LucentIconButton
|
||||
className="ops-chat-inline-action"
|
||||
onClick={() => void onCopyUserPrompt(item.text)}
|
||||
tooltip={labels.copyPrompt}
|
||||
aria-label={labels.copyPrompt}
|
||||
>
|
||||
<Copy size={13} />
|
||||
</LucentIconButton>
|
||||
<LucentIconButton
|
||||
className="ops-chat-inline-action"
|
||||
onClick={() => void onDeleteConversationMessage(item)}
|
||||
disabled={isDeleting}
|
||||
tooltip={labels.deleteMessage}
|
||||
aria-label={labels.deleteMessage}
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
</LucentIconButton>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className={`ops-chat-bubble ${item.role === 'user' ? 'user' : 'assistant'} ${(item.kind || 'final') === 'progress' ? 'progress' : ''}`}>
|
||||
<div className="ops-chat-meta">
|
||||
<span>{item.role === 'user' ? labels.you : 'Nanobot'}</span>
|
||||
<div className="ops-chat-meta-right">
|
||||
<span className="mono">{formatClock(item.ts)}</span>
|
||||
{collapsible ? (
|
||||
<LucentIconButton
|
||||
className="ops-chat-expand-icon-btn"
|
||||
onClick={() => {
|
||||
if (isProgressBubble) {
|
||||
onToggleProgressExpand(itemKey);
|
||||
return;
|
||||
}
|
||||
onToggleUserExpand(itemKey);
|
||||
}}
|
||||
tooltip={expanded ? (isZh ? '收起' : 'Collapse') : (isZh ? '展开' : 'Expand')}
|
||||
aria-label={expanded ? (isZh ? '收起' : 'Collapse') : (isZh ? '展开' : 'Expand')}
|
||||
>
|
||||
{expanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
|
||||
</LucentIconButton>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className={`ops-chat-text ${collapsible && !expanded ? (isUserBubble ? 'is-collapsed-user' : 'is-collapsed') : ''}`}>
|
||||
{item.text ? (
|
||||
item.role === 'user' ? (
|
||||
<>
|
||||
{item.quoted_reply ? (
|
||||
<div className="ops-user-quoted-reply">
|
||||
<div className="ops-user-quoted-label">{labels.quotedReplyLabel}</div>
|
||||
<div className="ops-user-quoted-text">{normalizeAssistantMessageText(item.quoted_reply)}</div>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="whitespace-pre-wrap">{normalizeUserMessageText(displayText)}</div>
|
||||
</>
|
||||
) : (
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[rehypeRaw, [rehypeSanitize, MARKDOWN_SANITIZE_SCHEMA]]}
|
||||
components={markdownComponents}
|
||||
>
|
||||
{decorateWorkspacePathsForMarkdown(displayText)}
|
||||
</ReactMarkdown>
|
||||
)
|
||||
) : null}
|
||||
{(item.attachments || []).length > 0 ? (
|
||||
<div className="ops-chat-attachments">
|
||||
{(item.attachments || []).map((rawPath) => {
|
||||
const filePath = normalizeDashboardAttachmentPath(rawPath);
|
||||
const fileAction = workspaceFileAction(filePath, workspaceDownloadExtensionSet);
|
||||
const filename = filePath.split('/').pop() || filePath;
|
||||
return (
|
||||
<a
|
||||
key={`${item.ts}-${filePath}`}
|
||||
className="ops-attach-link mono"
|
||||
href="#"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
void onOpenWorkspacePath(filePath);
|
||||
}}
|
||||
title={fileAction === 'download' ? labels.download : fileAction === 'preview' ? labels.previewTitle : labels.fileNotPreviewable}
|
||||
>
|
||||
{fileAction === 'download' ? (
|
||||
<Download size={12} className="ops-attach-link-icon" />
|
||||
) : fileAction === 'preview' ? (
|
||||
<Eye size={12} className="ops-attach-link-icon" />
|
||||
) : (
|
||||
<FileText size={12} className="ops-attach-link-icon" />
|
||||
)}
|
||||
<span className="ops-attach-link-name">{filename}</span>
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
{item.role === 'assistant' && !isProgressBubble ? (
|
||||
<div className="ops-chat-reply-actions">
|
||||
<LucentIconButton
|
||||
className={`ops-chat-inline-action ${item.feedback === 'up' ? 'active-up' : ''}`}
|
||||
onClick={() => void onSubmitAssistantFeedback(item, 'up')}
|
||||
disabled={isFeedbackSaving}
|
||||
tooltip={labels.goodReply}
|
||||
aria-label={labels.goodReply}
|
||||
>
|
||||
<ThumbsUp size={13} />
|
||||
</LucentIconButton>
|
||||
<LucentIconButton
|
||||
className={`ops-chat-inline-action ${item.feedback === 'down' ? 'active-down' : ''}`}
|
||||
onClick={() => void onSubmitAssistantFeedback(item, 'down')}
|
||||
disabled={isFeedbackSaving}
|
||||
tooltip={labels.badReply}
|
||||
aria-label={labels.badReply}
|
||||
>
|
||||
<ThumbsDown size={13} />
|
||||
</LucentIconButton>
|
||||
<LucentIconButton
|
||||
className="ops-chat-inline-action"
|
||||
onClick={() => onQuoteAssistantReply(item)}
|
||||
tooltip={labels.quoteReply}
|
||||
aria-label={labels.quoteReply}
|
||||
>
|
||||
<Reply size={13} />
|
||||
</LucentIconButton>
|
||||
<LucentIconButton
|
||||
className="ops-chat-inline-action"
|
||||
onClick={() => void onCopyAssistantReply(item.text)}
|
||||
tooltip={labels.copyReply}
|
||||
aria-label={labels.copyReply}
|
||||
>
|
||||
<Copy size={13} />
|
||||
</LucentIconButton>
|
||||
<LucentIconButton
|
||||
className="ops-chat-inline-action"
|
||||
onClick={() => void onDeleteConversationMessage(item)}
|
||||
disabled={isDeleting}
|
||||
tooltip={labels.deleteMessage}
|
||||
aria-label={labels.deleteMessage}
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
</LucentIconButton>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{item.role === 'user' && (
|
||||
<div className="ops-avatar user" title={labels.user}>
|
||||
<UserRound size={18} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}, (prev, next) => (
|
||||
prev.itemKey === next.itemKey
|
||||
&& prev.isZh === next.isZh
|
||||
&& prev.showDateDivider === next.showDateDivider
|
||||
&& prev.expandedProgress === next.expandedProgress
|
||||
&& prev.expandedUser === next.expandedUser
|
||||
&& prev.isDeleting === next.isDeleting
|
||||
&& prev.isFeedbackSaving === next.isFeedbackSaving
|
||||
&& prev.markdownComponents === next.markdownComponents
|
||||
&& prev.workspaceDownloadExtensionSet === next.workspaceDownloadExtensionSet
|
||||
&& prev.labels.badReply === next.labels.badReply
|
||||
&& prev.labels.copyPrompt === next.labels.copyPrompt
|
||||
&& prev.labels.copyReply === next.labels.copyReply
|
||||
&& prev.labels.deleteMessage === next.labels.deleteMessage
|
||||
&& prev.labels.download === next.labels.download
|
||||
&& prev.labels.editPrompt === next.labels.editPrompt
|
||||
&& prev.labels.fileNotPreviewable === next.labels.fileNotPreviewable
|
||||
&& prev.labels.goodReply === next.labels.goodReply
|
||||
&& prev.labels.previewTitle === next.labels.previewTitle
|
||||
&& prev.labels.quoteReply === next.labels.quoteReply
|
||||
&& prev.labels.quotedReplyLabel === next.labels.quotedReplyLabel
|
||||
&& prev.labels.user === next.labels.user
|
||||
&& prev.labels.you === next.labels.you
|
||||
&& prev.item.id === next.item.id
|
||||
&& prev.item.role === next.item.role
|
||||
&& prev.item.text === next.item.text
|
||||
&& prev.item.quoted_reply === next.item.quoted_reply
|
||||
&& prev.item.ts === next.item.ts
|
||||
&& prev.item.kind === next.item.kind
|
||||
&& prev.item.feedback === next.item.feedback
|
||||
&& sameAttachments(prev.item.attachments, next.item.attachments)
|
||||
));
|
||||
|
||||
export function DashboardConversationMessages({
|
||||
conversation,
|
||||
isZh,
|
||||
|
|
@ -89,202 +368,34 @@ export function DashboardConversationMessages({
|
|||
<>
|
||||
{conversation.map((item, idx) => {
|
||||
const itemKey = getConversationItemKey(item, idx);
|
||||
const isProgressBubble = item.role !== 'user' && (item.kind || 'final') === 'progress';
|
||||
const isUserBubble = item.role === 'user';
|
||||
const fullText = String(item.text || '');
|
||||
const summaryText = isProgressBubble ? summarizeProgressText(fullText, isZh) : fullText;
|
||||
const hasSummary = isProgressBubble && summaryText.trim().length > 0 && summaryText.trim() !== fullText.trim();
|
||||
const progressCollapsible = isProgressBubble && (hasSummary || shouldCollapseProgress(fullText));
|
||||
const normalizedUserText = isUserBubble ? normalizeUserMessageText(fullText) : '';
|
||||
const userLineCount = isUserBubble ? normalizedUserText.split('\n').length : 0;
|
||||
const userCollapsible = isUserBubble && userLineCount > 5;
|
||||
const collapsible = isProgressBubble ? progressCollapsible : userCollapsible;
|
||||
const expanded = isProgressBubble ? Boolean(expandedProgressByKey[itemKey]) : Boolean(expandedUserByKey[itemKey]);
|
||||
const displayText = isProgressBubble && !expanded ? summaryText : fullText;
|
||||
const currentDayKey = new Date(item.ts).toDateString();
|
||||
const prevDayKey = idx > 0 ? new Date(conversation[idx - 1].ts).toDateString() : '';
|
||||
const showDateDivider = idx === 0 || currentDayKey !== prevDayKey;
|
||||
const isDeleting = Boolean(item.id && deletingMessageIdMap[item.id]);
|
||||
|
||||
return (
|
||||
<div
|
||||
<DashboardConversationMessageRow
|
||||
key={itemKey}
|
||||
data-chat-message-id={item.id ? String(item.id) : undefined}
|
||||
>
|
||||
{showDateDivider ? (
|
||||
<div className="ops-chat-date-divider" aria-label={formatConversationDate(item.ts, isZh)}>
|
||||
<span>{formatConversationDate(item.ts, isZh)}</span>
|
||||
</div>
|
||||
) : null}
|
||||
<div className={`ops-chat-row ${item.role === 'user' ? 'is-user' : 'is-assistant'}`}>
|
||||
<div className={`ops-chat-item ${item.role === 'user' ? 'is-user' : 'is-assistant'}`}>
|
||||
{item.role !== 'user' && (
|
||||
<div className="ops-avatar bot" title="Nanobot">
|
||||
<img src={nanobotLogo} alt="Nanobot" />
|
||||
</div>
|
||||
)}
|
||||
{item.role === 'user' ? (
|
||||
<div className="ops-chat-hover-actions ops-chat-hover-actions-user">
|
||||
<LucentIconButton
|
||||
className="ops-chat-inline-action"
|
||||
onClick={() => onEditUserPrompt(item.text)}
|
||||
tooltip={labels.editPrompt}
|
||||
aria-label={labels.editPrompt}
|
||||
>
|
||||
<Pencil size={13} />
|
||||
</LucentIconButton>
|
||||
<LucentIconButton
|
||||
className="ops-chat-inline-action"
|
||||
onClick={() => void onCopyUserPrompt(item.text)}
|
||||
tooltip={labels.copyPrompt}
|
||||
aria-label={labels.copyPrompt}
|
||||
>
|
||||
<Copy size={13} />
|
||||
</LucentIconButton>
|
||||
<LucentIconButton
|
||||
className="ops-chat-inline-action"
|
||||
onClick={() => void onDeleteConversationMessage(item)}
|
||||
disabled={isDeleting}
|
||||
tooltip={labels.deleteMessage}
|
||||
aria-label={labels.deleteMessage}
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
</LucentIconButton>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className={`ops-chat-bubble ${item.role === 'user' ? 'user' : 'assistant'} ${(item.kind || 'final') === 'progress' ? 'progress' : ''}`}>
|
||||
<div className="ops-chat-meta">
|
||||
<span>{item.role === 'user' ? labels.you : 'Nanobot'}</span>
|
||||
<div className="ops-chat-meta-right">
|
||||
<span className="mono">{formatClock(item.ts)}</span>
|
||||
{collapsible ? (
|
||||
<LucentIconButton
|
||||
className="ops-chat-expand-icon-btn"
|
||||
onClick={() => {
|
||||
if (isProgressBubble) {
|
||||
onToggleProgressExpand(itemKey);
|
||||
return;
|
||||
}
|
||||
onToggleUserExpand(itemKey);
|
||||
}}
|
||||
tooltip={expanded ? (isZh ? '收起' : 'Collapse') : (isZh ? '展开' : 'Expand')}
|
||||
aria-label={expanded ? (isZh ? '收起' : 'Collapse') : (isZh ? '展开' : 'Expand')}
|
||||
>
|
||||
{expanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
|
||||
</LucentIconButton>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className={`ops-chat-text ${collapsible && !expanded ? (isUserBubble ? 'is-collapsed-user' : 'is-collapsed') : ''}`}>
|
||||
{item.text ? (
|
||||
item.role === 'user' ? (
|
||||
<>
|
||||
{item.quoted_reply ? (
|
||||
<div className="ops-user-quoted-reply">
|
||||
<div className="ops-user-quoted-label">{labels.quotedReplyLabel}</div>
|
||||
<div className="ops-user-quoted-text">{normalizeAssistantMessageText(item.quoted_reply)}</div>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="whitespace-pre-wrap">{normalizeUserMessageText(displayText)}</div>
|
||||
</>
|
||||
) : (
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[rehypeRaw, [rehypeSanitize, MARKDOWN_SANITIZE_SCHEMA]]}
|
||||
components={markdownComponents}
|
||||
>
|
||||
{decorateWorkspacePathsForMarkdown(displayText)}
|
||||
</ReactMarkdown>
|
||||
)
|
||||
) : null}
|
||||
{(item.attachments || []).length > 0 ? (
|
||||
<div className="ops-chat-attachments">
|
||||
{(item.attachments || []).map((rawPath) => {
|
||||
const filePath = normalizeDashboardAttachmentPath(rawPath);
|
||||
const fileAction = workspaceFileAction(filePath, workspaceDownloadExtensionSet);
|
||||
const filename = filePath.split('/').pop() || filePath;
|
||||
return (
|
||||
<a
|
||||
key={`${item.ts}-${filePath}`}
|
||||
className="ops-attach-link mono"
|
||||
href="#"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
void onOpenWorkspacePath(filePath);
|
||||
}}
|
||||
title={fileAction === 'download' ? labels.download : fileAction === 'preview' ? labels.previewTitle : labels.fileNotPreviewable}
|
||||
>
|
||||
{fileAction === 'download' ? (
|
||||
<Download size={12} className="ops-attach-link-icon" />
|
||||
) : fileAction === 'preview' ? (
|
||||
<Eye size={12} className="ops-attach-link-icon" />
|
||||
) : (
|
||||
<FileText size={12} className="ops-attach-link-icon" />
|
||||
)}
|
||||
<span className="ops-attach-link-name">{filename}</span>
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
{item.role === 'assistant' && !isProgressBubble ? (
|
||||
<div className="ops-chat-reply-actions">
|
||||
<LucentIconButton
|
||||
className={`ops-chat-inline-action ${item.feedback === 'up' ? 'active-up' : ''}`}
|
||||
onClick={() => void onSubmitAssistantFeedback(item, 'up')}
|
||||
disabled={Boolean(item.id && feedbackSavingByMessageId[item.id])}
|
||||
tooltip={labels.goodReply}
|
||||
aria-label={labels.goodReply}
|
||||
>
|
||||
<ThumbsUp size={13} />
|
||||
</LucentIconButton>
|
||||
<LucentIconButton
|
||||
className={`ops-chat-inline-action ${item.feedback === 'down' ? 'active-down' : ''}`}
|
||||
onClick={() => void onSubmitAssistantFeedback(item, 'down')}
|
||||
disabled={Boolean(item.id && feedbackSavingByMessageId[item.id])}
|
||||
tooltip={labels.badReply}
|
||||
aria-label={labels.badReply}
|
||||
>
|
||||
<ThumbsDown size={13} />
|
||||
</LucentIconButton>
|
||||
<LucentIconButton
|
||||
className="ops-chat-inline-action"
|
||||
onClick={() => onQuoteAssistantReply(item)}
|
||||
tooltip={labels.quoteReply}
|
||||
aria-label={labels.quoteReply}
|
||||
>
|
||||
<Reply size={13} />
|
||||
</LucentIconButton>
|
||||
<LucentIconButton
|
||||
className="ops-chat-inline-action"
|
||||
onClick={() => void onCopyAssistantReply(item.text)}
|
||||
tooltip={labels.copyReply}
|
||||
aria-label={labels.copyReply}
|
||||
>
|
||||
<Copy size={13} />
|
||||
</LucentIconButton>
|
||||
<LucentIconButton
|
||||
className="ops-chat-inline-action"
|
||||
onClick={() => void onDeleteConversationMessage(item)}
|
||||
disabled={isDeleting}
|
||||
tooltip={labels.deleteMessage}
|
||||
aria-label={labels.deleteMessage}
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
</LucentIconButton>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{item.role === 'user' && (
|
||||
<div className="ops-avatar user" title={labels.user}>
|
||||
<UserRound size={18} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
item={item}
|
||||
itemKey={itemKey}
|
||||
isZh={isZh}
|
||||
labels={labels}
|
||||
showDateDivider={showDateDivider}
|
||||
expandedProgress={Boolean(expandedProgressByKey[itemKey])}
|
||||
expandedUser={Boolean(expandedUserByKey[itemKey])}
|
||||
isDeleting={Boolean(item.id && deletingMessageIdMap[item.id])}
|
||||
isFeedbackSaving={Boolean(item.id && feedbackSavingByMessageId[item.id])}
|
||||
markdownComponents={markdownComponents}
|
||||
workspaceDownloadExtensionSet={workspaceDownloadExtensionSet}
|
||||
onToggleProgressExpand={onToggleProgressExpand}
|
||||
onToggleUserExpand={onToggleUserExpand}
|
||||
onEditUserPrompt={onEditUserPrompt}
|
||||
onCopyUserPrompt={onCopyUserPrompt}
|
||||
onDeleteConversationMessage={onDeleteConversationMessage}
|
||||
onOpenWorkspacePath={onOpenWorkspacePath}
|
||||
onSubmitAssistantFeedback={onSubmitAssistantFeedback}
|
||||
onQuoteAssistantReply={onQuoteAssistantReply}
|
||||
onCopyAssistantReply={onCopyAssistantReply}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
|
|
|
|||
Loading…
Reference in New Issue