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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user