feat(web): add markdown syntax highlighting, slash commands, stop button, quote/reply

- PromptEditor with Prism.js syntax highlighting
- Slash commands (/run, /status, /images, /list, /help) in chat input
- Stop button to cancel ongoing generation
- Quote/reply and regenerate/continue actions in chat
- MiddlePanel with conversation timestamps and preview
- Pipeline progress in chat view
- Fix all remaining dark theme classes

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-07 04:09:00 +08:00
parent f43a640e64
commit e850613972
12 changed files with 375 additions and 213 deletions

View File

@@ -1,11 +1,34 @@
import { useState, useRef } from 'react';
import { Send } from 'lucide-react';
import { useState, useRef, useEffect } from 'react';
import { Send, Terminal, Image, Play, FileText } from 'lucide-react';
import { Button } from '@/components/ui/button';
const SLASH_COMMANDS = [
{ cmd: '/run', desc: '执行 pipeline 阶段', icon: Play },
{ cmd: '/status', desc: '查看管线进度', icon: Terminal },
{ cmd: '/images', desc: '生成图片', icon: Image },
{ cmd: '/list', desc: '列出可用账号', icon: FileText },
{ cmd: '/help', desc: '显示帮助', icon: Terminal },
];
export function ChatInput({ onSend, disabled }: { onSend: (content: string) => void; disabled?: boolean }) {
const [input, setInput] = useState('');
const [showCmds, setShowCmds] = useState(false);
const [cmdIdx, setCmdIdx] = useState(0);
const ref = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
if (input.startsWith('/')) {
setShowCmds(true);
setCmdIdx(0);
} else {
setShowCmds(false);
}
}, [input]);
const matchingCmds = input.startsWith('/')
? SLASH_COMMANDS.filter((c) => c.cmd.startsWith(input.split(' ')[0]))
: [];
const handleSend = () => {
if (!input.trim()) return;
onSend(input.trim());
@@ -14,6 +37,18 @@ export function ChatInput({ onSend, disabled }: { onSend: (content: string) => v
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (showCmds && matchingCmds.length > 0) {
if (e.key === 'Tab' || e.key === 'Enter') {
e.preventDefault();
const cmd = matchingCmds[cmdIdx % matchingCmds.length].cmd;
setInput(cmd + ' ');
setShowCmds(false);
return;
}
if (e.key === 'ArrowDown') { e.preventDefault(); setCmdIdx((i) => (i + 1) % matchingCmds.length); return; }
if (e.key === 'ArrowUp') { e.preventDefault(); setCmdIdx((i) => (i - 1 + matchingCmds.length) % matchingCmds.length); return; }
}
if (e.key === 'Escape') { setShowCmds(false); return; }
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
@@ -21,21 +56,43 @@ export function ChatInput({ onSend, disabled }: { onSend: (content: string) => v
};
return (
<div className="p-4 border-t border-zinc-200 bg-white">
<div className="flex items-end gap-2 bg-zinc-50 rounded-xl border border-zinc-200 px-4 py-2.5 focus-within:border-indigo-300 transition-colors">
<textarea
ref={ref}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
rows={1}
placeholder={disabled ? '等待回复中...' : '输入指令...'}
className="flex-1 bg-transparent text-sm resize-none outline-none placeholder:text-zinc-400 text-zinc-700"
disabled={disabled}
/>
<Button size="icon" variant="ghost" className="h-8 w-8 text-zinc-400 hover:text-indigo-600" onClick={handleSend} disabled={disabled}>
<Send size={16} />
</Button>
<div className="relative">
{showCmds && matchingCmds.length > 0 && (
<div className="mx-4 mb-0.5 bg-white border border-zinc-200 rounded-lg shadow-lg overflow-hidden">
{matchingCmds.map((c, i) => {
const Icon = c.icon;
return (
<button
key={c.cmd}
onClick={() => { setInput(c.cmd + ' '); setShowCmds(false); ref.current?.focus(); }}
className={`w-full flex items-center gap-2 px-3 py-1.5 text-sm transition-colors ${
i === (cmdIdx % matchingCmds.length) ? 'bg-indigo-50 text-indigo-700' : 'text-zinc-600 hover:bg-zinc-50'
}`}
>
<Icon size={14} />
<span className="font-mono font-medium">{c.cmd}</span>
<span className="text-zinc-400">{c.desc}</span>
</button>
);
})}
</div>
)}
<div className="p-4 border-t border-zinc-200 bg-white">
<div className="flex items-end gap-2 bg-zinc-50 rounded-xl border border-zinc-200 px-4 py-2.5 focus-within:border-indigo-300 transition-colors">
<textarea
ref={ref}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
rows={1}
placeholder={disabled ? '等待回复中...' : '输入指令或 / 查看命令...'}
className="flex-1 bg-transparent text-sm resize-none outline-none placeholder:text-zinc-400 text-zinc-700"
disabled={disabled}
/>
<Button size="icon" variant="ghost" className="h-8 w-8 text-zinc-400 hover:text-indigo-600" onClick={handleSend} disabled={disabled}>
<Send size={16} />
</Button>
</div>
</div>
</div>
);

View File

@@ -1,30 +1,76 @@
import { cn } from '@/lib/utils';
import { renderMarkdown } from '@/lib/markdown';
import { RefreshCw, ArrowRight, Quote } from 'lucide-react';
import type { Message } from '@/types';
export function ChatMessage({ message }: { message: Message }) {
interface Props {
message: Message;
onRegenerate?: (msgId: string) => void;
onContinue?: (msgId: string) => void;
onQuote?: (content: string) => void;
}
export function ChatMessage({ message, onRegenerate, onContinue, onQuote }: Props) {
const isUser = message.role === 'user';
const isEmpty = !message.content;
return (
<div className={cn('mb-4 flex', isUser ? 'justify-end' : 'justify-start')}>
<div
className={cn(
'max-w-[80%] rounded-xl px-4 py-2.5',
isUser
? 'bg-indigo-600 text-white'
: 'bg-white text-zinc-700 border border-zinc-200 shadow-sm'
)}
>
{isEmpty ? (
<span className="text-zinc-400 italic text-xs">...</span>
) : isUser ? (
<p className="text-sm leading-relaxed whitespace-pre-wrap">{message.content}</p>
) : (
<div
className="markdown-body text-sm leading-relaxed"
dangerouslySetInnerHTML={{ __html: renderMarkdown(message.content) }}
/>
<div className={cn('mb-4 flex group', isUser ? 'justify-end' : 'justify-start')}>
<div className="max-w-[80%]">
<div
className={cn(
'rounded-xl px-4 py-2.5',
isUser
? 'bg-indigo-600 text-white'
: 'bg-white text-zinc-700 border border-zinc-200 shadow-sm'
)}
>
{isEmpty ? (
<span className="text-zinc-400 italic text-xs">...</span>
) : isUser ? (
<p className="text-sm leading-relaxed whitespace-pre-wrap">{message.content}</p>
) : (
<div
className="markdown-body text-sm leading-relaxed"
dangerouslySetInnerHTML={{ __html: renderMarkdown(message.content) }}
/>
)}
</div>
{/* Action buttons - only for assistant messages */}
{!isUser && !isEmpty && (
<div className="flex gap-1 mt-1 opacity-0 group-hover:opacity-100 transition-opacity">
{onQuote && (
<button
onClick={() => onQuote(message.content)}
className="flex items-center gap-0.5 px-1.5 py-0.5 rounded text-[10px] text-zinc-400 hover:text-zinc-600 hover:bg-zinc-100 transition-colors"
title="引用"
>
<Quote size={10} />
</button>
)}
{onRegenerate && (
<button
onClick={() => onRegenerate(message.id)}
className="flex items-center gap-0.5 px-1.5 py-0.5 rounded text-[10px] text-zinc-400 hover:text-zinc-600 hover:bg-zinc-100 transition-colors"
title="重新生成"
>
<RefreshCw size={10} />
</button>
)}
{onContinue && (
<button
onClick={() => onContinue(message.id)}
className="flex items-center gap-0.5 px-1.5 py-0.5 rounded text-[10px] text-zinc-400 hover:text-zinc-600 hover:bg-zinc-100 transition-colors"
title="继续"
>
<ArrowRight size={10} />
</button>
)}
</div>
)}
</div>
</div>

View File

@@ -4,30 +4,28 @@ import { useAppStore } from '@/store';
import { useChat } from '@/hooks/useChat';
import { ChatMessage } from './ChatMessage';
import { ChatInput } from './ChatInput';
import { PipelineProgress } from './PipelineProgress';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Button } from '@/components/ui/button';
import { RefreshCw, Loader2 } from 'lucide-react';
import { RefreshCw, Loader2, StopCircle, X } from 'lucide-react';
import { api } from '@/lib/api';
import type { Account } from '@/types';
import type { Account, Message } from '@/types';
export function ChatView() {
const { conversationId } = useParams<{ conversationId?: string }>();
const navigate = useNavigate();
const { setConversations, selectedAccountId, setSelectedAccountId } = useAppStore();
const { messages, connected, thinking, toolStatus, send, createConversation } = useChat(conversationId || null);
const { messages, connected, thinking, toolStatus, pipeline, send, stop, createConversation } = useChat(conversationId || null);
const [manifestPath, setManifestPath] = useState<string | null>(null);
const [accounts, setAccounts] = useState<Account[]>([]);
const [quote, setQuote] = useState<string | null>(null);
const creatingRef = useRef(false);
useEffect(() => {
api.listAccounts().then(setAccounts).catch(() => {});
}, []);
useEffect(() => { api.listAccounts().then(setAccounts).catch(() => {}); }, []);
useEffect(() => {
fetch('/api/pipeline/conversations')
.then((r) => r.json())
.then(setConversations)
.catch(() => {});
.then((r) => r.json()).then(setConversations).catch(() => {});
}, [messages]);
useEffect(() => {
@@ -40,13 +38,11 @@ export function ChatView() {
}
}, [messages]);
// Inject account context when account is selected
useEffect(() => {
if (selectedAccountId && conversationId && connected && messages.length === 0) {
const account = accounts.find((a) => a.id === selectedAccountId);
if (account) {
const ctx = `已选择账号「${account.name}」:${account.description || ''}。生图:${account.imageModel} 视频:${account.videoModel} 画幅:${account.defaultFormat}`;
send(ctx);
send(`已选择账号「${account.name}」:${account.description || ''}。生图:${account.imageModel} 视频:${account.videoModel} 画幅:${account.defaultFormat}`);
}
}
}, [selectedAccountId, conversationId, connected]);
@@ -54,34 +50,48 @@ export function ChatView() {
const handleNewConversation = useCallback(() => {
if (creatingRef.current) return;
creatingRef.current = true;
createConversation('新对话', selectedAccountId || undefined);
setTimeout(() => {
fetch('/api/pipeline/conversations')
.then((r) => r.json())
.then((list) => {
setConversations(list);
if (list.length > 0) {
navigate(`/chat/${list[0].id}`);
}
creatingRef.current = false;
});
fetch('/api/pipeline/conversations').then((r) => r.json()).then((list) => {
setConversations(list);
if (list.length > 0) navigate(`/chat/${list[0].id}`);
creatingRef.current = false;
});
}, 500);
}, [createConversation, selectedAccountId, setConversations, navigate]);
}, [createConversation, selectedAccountId]);
const handleResume = async () => {
if (!manifestPath) return;
try {
await fetch('/api/pipeline/resume', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ manifest: manifestPath }),
});
} catch (e) {
console.error('Resume failed:', e);
}
await fetch('/api/pipeline/resume', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ manifest: manifestPath }),
});
};
const findPrecedingUser = useCallback((msgId: string): Message | null => {
const idx = messages.findIndex((m) => m.id === msgId);
if (idx <= 0) return null;
for (let i = idx - 1; i >= 0; i--) {
if (messages[i].role === 'user') return messages[i];
}
return null;
}, [messages]);
const handleRegenerate = useCallback((msgId: string) => {
const prev = findPrecedingUser(msgId);
if (prev) send(prev.content);
}, [findPrecedingUser, send]);
const handleContinue = useCallback(() => { send('请继续'); }, [send]);
const handleQuote = useCallback((content: string) => { setQuote(content.slice(0, 200)); }, []);
const handleSend = useCallback((content: string) => {
if (quote) { send(`> ${quote}\n\n${content}`); setQuote(null); }
else send(content);
}, [send, quote]);
const handleStop = useCallback(() => { stop(); }, [stop]);
if (!conversationId) {
return (
<div className="flex-1 flex flex-col items-center justify-center gap-4">
@@ -96,9 +106,7 @@ export function ChatView() {
className="h-9 rounded-md border border-zinc-200 bg-white px-3 text-sm text-zinc-700"
>
<option value=""></option>
{accounts.map((a) => (
<option key={a.id} value={a.id}>{a.name}</option>
))}
{accounts.map((a) => <option key={a.id} value={a.id}>{a.name}</option>)}
</select>
)}
<button
@@ -123,31 +131,49 @@ export function ChatView() {
</div>
<select
value={selectedAccountId || ''}
onChange={(e) => {
setSelectedAccountId(e.target.value || null);
}}
onChange={(e) => setSelectedAccountId(e.target.value || null)}
className="h-7 rounded border border-zinc-200 bg-zinc-50 px-2 text-xs text-zinc-600"
>
<option value=""></option>
{accounts.map((a) => (
<option key={a.id} value={a.id}>{a.name}</option>
))}
{accounts.map((a) => <option key={a.id} value={a.id}>{a.name}</option>)}
</select>
</div>
{manifestPath && (
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={handleResume}>
<RefreshCw size={12} className="mr-1" />
</Button>
)}
<div className="flex items-center gap-2">
{manifestPath && (
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={handleResume}>
<RefreshCw size={12} className="mr-1" />
</Button>
)}
{thinking && (
<Button size="sm" variant="outline" className="h-7 text-xs text-red-500 border-red-200 hover:bg-red-50" onClick={handleStop}>
<StopCircle size={12} className="mr-1" />
</Button>
)}
</div>
</div>
<ScrollArea className="flex-1 px-4 py-4">
{messages.map((msg) => (
<ChatMessage key={msg.id} message={msg} />
<ChatMessage
key={msg.id}
message={msg}
onRegenerate={msg.role === 'assistant' ? handleRegenerate : undefined}
onContinue={msg.role === 'assistant' ? handleContinue : undefined}
onQuote={handleQuote}
/>
))}
{thinking && (
{pipeline && (
<PipelineProgress
phase={pipeline.phase}
progress={pipeline.progress}
currentItem={pipeline.currentItem}
totalItems={pipeline.totalItems}
status={pipeline.status}
/>
)}
{thinking && !pipeline && (
<div className="flex items-center gap-2 text-zinc-400 text-sm py-2">
<Loader2 size={14} className="animate-spin" />
{toolStatus || '思考中...'}
@@ -155,7 +181,14 @@ export function ChatView() {
)}
</ScrollArea>
<ChatInput onSend={send} disabled={thinking} />
{quote && (
<div className="mx-4 px-3 py-1.5 bg-zinc-50 border border-zinc-200 rounded-lg flex items-center gap-2 text-xs text-zinc-500">
<span className="flex-1 truncate">: {quote}</span>
<button onClick={() => setQuote(null)} className="text-zinc-400 hover:text-zinc-600"><X size={12} /></button>
</div>
)}
<ChatInput onSend={handleSend} disabled={thinking} />
</div>
);
}