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:
@@ -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 (?, ?, ?, ?)'
|
||||
|
||||
Reference in New Issue
Block a user