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

@@ -2,21 +2,10 @@ import { WebSocket } from 'ws';
import { randomUUID } from 'crypto';
import { getDb } from '../db';
import { videoAgent } from '../agent';
import type { MessageParam, ToolUseBlock, TextBlock } from '@anthropic-ai/sdk/resources/messages.mjs';
import type Anthropic from '@anthropic-ai/sdk';
interface ChatMsg {
type: string;
conversationId?: string;
content?: string;
title?: string;
accountId?: string;
data?: Record<string, unknown>;
conversation_id?: string;
role?: string;
tool_calls?: string;
created_at?: string;
id?: string;
}
type MessageParam = Anthropic.MessageParam;
type ContentBlock = Anthropic.ContentBlock;
interface DbMessage {
id: string;
@@ -27,6 +16,10 @@ interface DbMessage {
created_at: string;
}
function filterContent(blocks: ContentBlock[]): ContentBlock[] {
return blocks.filter((b) => b.type === 'text' || b.type === 'tool_use');
}
function dbToAnthropic(msg: DbMessage): MessageParam {
if (msg.role === 'user') {
return { role: 'user', content: msg.content };
@@ -34,8 +27,8 @@ function dbToAnthropic(msg: DbMessage): MessageParam {
if (msg.role === 'assistant') {
if (msg.tool_calls) {
try {
const parsed = JSON.parse(msg.tool_calls);
return { role: 'assistant', content: parsed };
const parsed = JSON.parse(msg.tool_calls) as ContentBlock[];
return { role: 'assistant', content: filterContent(parsed) };
} catch {
return { role: 'assistant', content: msg.content };
}
@@ -47,7 +40,7 @@ function dbToAnthropic(msg: DbMessage): MessageParam {
const { tool_use_id, content } = JSON.parse(msg.content);
return {
role: 'user',
content: [{ type: 'tool_result', tool_use_id, content }],
content: [{ type: 'tool_result' as const, tool_use_id, content }],
};
} catch {
return { role: 'user', content: msg.content };
@@ -61,9 +54,8 @@ export function handleChat(ws: WebSocket) {
ws.on('message', async (raw) => {
try {
const msg: ChatMsg = JSON.parse(raw.toString());
const msg = JSON.parse(raw.toString());
// --- Init: load conversation history ---
if (msg.type === 'init') {
conversationId = msg.conversationId || randomUUID();
const history = getDb().prepare(
@@ -73,7 +65,6 @@ export function handleChat(ws: WebSocket) {
return;
}
// --- Create conversation ---
if (msg.type === 'create_conversation') {
const { title, accountId } = msg;
conversationId = randomUUID();
@@ -84,9 +75,12 @@ export function handleChat(ws: WebSocket) {
return;
}
// --- Chat with LLM ---
if (msg.type === 'chat') {
await handleChatMessage(ws, conversationId!, msg.content!);
if (!conversationId) {
ws.send(JSON.stringify({ type: 'error', data: { message: '没有活跃对话,请先创建或选择一个对话' } }));
return;
}
await handleChatMessage(ws, conversationId, msg.content);
}
} catch (e) {
console.error('WebSocket error:', e);
@@ -98,14 +92,12 @@ export function handleChat(ws: WebSocket) {
}
async function handleChatMessage(ws: WebSocket, convId: string, content: string) {
// 1. Save user message
const userMsgId = randomUUID();
getDb().prepare(
'INSERT INTO messages (id, conversation_id, role, content) VALUES (?, ?, ?, ?)'
).run(userMsgId, convId, 'user', content);
ws.send(JSON.stringify({ type: 'message', data: { id: userMsgId, role: 'user', content } }));
// Update conversation title if first message
const msgCount = getDb().prepare(
'SELECT COUNT(*) as count FROM messages WHERE conversation_id = ?'
).get(convId) as { count: number };
@@ -114,18 +106,14 @@ async function handleChatMessage(ws: WebSocket, convId: string, content: string)
getDb().prepare('UPDATE conversations SET title = ?, updated_at = datetime(\'now\') WHERE id = ?')
.run(title, convId);
}
// Update conversation timestamp
getDb().prepare('UPDATE conversations SET updated_at = datetime(\'now\') WHERE id = ?').run(convId);
// 2. Build message history for Anthropic
const history = getDb().prepare(
'SELECT * FROM messages WHERE conversation_id = ? AND id != ? ORDER BY created_at'
).all(convId, userMsgId) as DbMessage[];
const messages: MessageParam[] = history.map(dbToAnthropic);
// 3. Call LLM with tool loop
const client = videoAgent.getClient();
const model = videoAgent.getModel();
const systemPrompt = videoAgent.getSystemPrompt();
@@ -137,6 +125,7 @@ async function handleChatMessage(ws: WebSocket, convId: string, content: string)
let maxLoops = 10;
while (maxLoops-- > 0) {
console.log(`[chat] Calling LLM, loop ${9 - maxLoops}, messages: ${currentMessages.length}`);
const stream = client.messages.stream({
model,
max_tokens: 4096,
@@ -145,87 +134,58 @@ async function handleChatMessage(ws: WebSocket, convId: string, content: string)
messages: currentMessages,
});
let assistantContent = '';
let toolUseBlocks: { id: string; name: string; input: Record<string, unknown> }[] = [];
const assistantMsgId = randomUUID();
// Stream text
ws.send(JSON.stringify({ type: 'message_start', data: { id: assistantMsgId } }));
for await (const event of stream) {
if (event.type === 'content_block_delta') {
if (event.delta.type === 'text_delta') {
assistantContent += event.delta.text;
ws.send(JSON.stringify({
type: 'text_delta',
data: { id: assistantMsgId, text: event.delta.text },
}));
}
if (event.delta.type === 'input_json_delta') {
// Accumulating tool input — handled by SDK internally
}
}
if (event.type === 'content_block_start') {
if (event.content_block.type === 'tool_use') {
toolUseBlocks.push({
id: event.content_block.id,
name: event.content_block.name,
input: (event.content_block.input || {}) as Record<string, unknown>,
});
}
}
}
const finalMsg = await stream.finalMessage();
ws.send(JSON.stringify({ type: 'message_end', data: { id: assistantMsgId } }));
// Extract tool uses from final message
const toolUses: { id: string; name: string; input: Record<string, unknown> }[] = [];
const textBlocks: string[] = [];
const toolUses = finalMsg.content.filter((b): b is Anthropic.ToolUseBlock => b.type === 'tool_use');
const textBlocks = finalMsg.content.filter((b): b is Anthropic.TextBlock => b.type === 'text');
const finalText = textBlocks.map((b) => b.text).join('');
for (const block of finalMsg.content) {
if (block.type === 'text') {
textBlocks.push(block.text);
}
if (block.type === 'tool_use') {
toolUses.push({ id: block.id, name: block.name, input: block.input as Record<string, unknown> });
}
}
// No tool calls — save assistant message and done
// No tool calls — save and done
if (toolUses.length === 0) {
const finalText = textBlocks.join('');
getDb().prepare(
'INSERT INTO messages (id, conversation_id, role, content) VALUES (?, ?, ?, ?)'
).run(assistantMsgId, convId, 'assistant', finalText);
console.log(`[chat] Done, response: ${finalText.slice(0, 80)}`);
return;
}
// Has tool calls — save assistant message with tool_calls, execute tools, add results
// Save assistant message with filtered content (no thinking blocks)
const cleanContent = filterContent(finalMsg.content as ContentBlock[]);
getDb().prepare(
'INSERT INTO messages (id, conversation_id, role, content, tool_calls) VALUES (?, ?, ?, ?, ?)'
).run(assistantMsgId, convId, 'assistant', textBlocks.join('') || '(调用工具)', JSON.stringify(finalMsg.content));
).run(assistantMsgId, convId, 'assistant', finalText || '(调用工具)', JSON.stringify(cleanContent));
// Build assistant content blocks for Anthropic
const assistantBlocks: (TextBlock | ToolUseBlock)[] = finalMsg.content
.filter((b): b is TextBlock | ToolUseBlock => b.type === 'text' || b.type === 'tool_use');
currentMessages.push({ role: 'assistant', content: cleanContent });
currentMessages.push({ role: 'assistant', content: assistantBlocks });
// Execute tools and send results
const toolResults: { type: 'tool_result'; tool_use_id: string; content: string }[] = [];
// Execute tools
const toolResults: Anthropic.ToolResultBlockParam[] = [];
for (const tool of toolUses) {
ws.send(JSON.stringify({
type: 'tool_start',
data: { tool: tool.name, input: tool.input },
}));
console.log(`[chat] Executing tool: ${tool.name}`);
try {
const result = await videoAgent.executeTool(tool.name, tool.input);
const result = await videoAgent.executeTool(tool.name, tool.input as Record<string, unknown>);
toolResults.push({ type: 'tool_result', tool_use_id: tool.id, content: result });
// Save tool result to DB
const toolMsgId = randomUUID();
getDb().prepare(
'INSERT INTO messages (id, conversation_id, role, content) VALUES (?, ?, ?, ?)'
@@ -251,17 +211,11 @@ async function handleChatMessage(ws: WebSocket, convId: string, content: string)
}
}
// Add tool results to conversation
currentMessages.push({
role: 'user',
content: toolResults,
});
// Continue loop — LLM will process tool results and possibly call more tools or give final response
currentMessages.push({ role: 'user', content: toolResults });
}
} catch (err) {
const errMsg = (err as Error).message;
console.error('LLM error:', errMsg);
console.error('[chat] LLM error:', errMsg);
const errId = randomUUID();
getDb().prepare(
'INSERT INTO messages (id, conversation_id, role, content) VALUES (?, ?, ?, ?)'