Compare commits
4 Commits
5eb922b1eb
...
feat/agent
| Author | SHA1 | Date | |
|---|---|---|---|
| 770657838c | |||
| 8dff0217b5 | |||
| 33ead4d6e7 | |||
| 504862343d |
@@ -44,7 +44,7 @@
|
|||||||
"id": "cosyvoice-v3-plus-bailian-155c1d86a5564d4ca981147d79e309b1",
|
"id": "cosyvoice-v3-plus-bailian-155c1d86a5564d4ca981147d79e309b1",
|
||||||
"model": "cosyvoice-v3-plus",
|
"model": "cosyvoice-v3-plus",
|
||||||
"instruction": "用沉稳有力的男性声音朗读,语速适中,语气坚定有力,像是一个有经历有力量的人在平静地讲述生活的方向",
|
"instruction": "用沉稳有力的男性声音朗读,语速适中,语气坚定有力,像是一个有经历有力量的人在平静地讲述生活的方向",
|
||||||
"style": "沉稳有力男声(v3-plus模型)"
|
"style": "沉稳有力男声"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "六沉",
|
"name": "六沉",
|
||||||
@@ -53,5 +53,7 @@
|
|||||||
"instruction": "音量由正常对话迅速增强至高喊,性格直率,情绪易激动且外露",
|
"instruction": "音量由正常对话迅速增强至高喊,性格直率,情绪易激动且外露",
|
||||||
"style": "直率激动,由低到高"
|
"style": "直率激动,由低到高"
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
"gptImageApiKey": "sk-m5inhwXqrbcBL6NNKOe7kTdhX8M31azvAvDvtSPGS71rRzd8",
|
||||||
|
"gptImageApiBaseUrl": "https://yunwu.ai"
|
||||||
}
|
}
|
||||||
@@ -116,7 +116,7 @@ digraph creation_flow {
|
|||||||
|
|
||||||
| # | 问题 | 默认值 | 说明 |
|
| # | 问题 | 默认值 | 说明 |
|
||||||
|---|------|--------|------|
|
|---|------|--------|------|
|
||||||
| 12 | TTS 音色? | config.json 全局 ttsVoice | account.json 的 ttsVoice。从 config.json 的 `ttsVoices` 音色库中选择(如"斯内普"、"布拉德"),也可直接填音色 ID |
|
| 12 | TTS 音色? | config.json 全局 ttsVoice | account.json 的 ttsVoice。从 config.json 的 `ttsVoices` 音色库中选择(如"斯内普"),也可直接填音色 ID |
|
||||||
| 13 | TTS 语气指令? | 无 | account.json 的 ttsInstruction,描述期望的语气风格 |
|
| 13 | TTS 语气指令? | 无 | account.json 的 ttsInstruction,描述期望的语气风格 |
|
||||||
| 14 | 背景音乐偏好? | 无 | account.json 的 capcut.defaultBGM。提供 URL 或描述风格,Agent 辅助查找 |
|
| 14 | 背景音乐偏好? | 无 | account.json 的 capcut.defaultBGM。提供 URL 或描述风格,Agent 辅助查找 |
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,22 @@
|
|||||||
import { useState, useCallback } from 'react';
|
import { useState, useCallback } from 'react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { renderMarkdown } from '@/lib/markdown';
|
import { renderMarkdown } from '@/lib/markdown';
|
||||||
import { Bot, User, Copy, Trash2, ChevronDown, ChevronRight, RefreshCw, ArrowRight, Quote, Brain, X, ChevronLeft, ChevronRight as ChevronRightIcon, Play } from 'lucide-react';
|
import { Bot, User, Copy, Trash2, ChevronDown, ChevronRight, RefreshCw, ArrowRight, Quote, Brain, X, ChevronLeft, ChevronRight as ChevronRightIcon, Play, Loader2, CheckCircle2, XCircle, Image as ImageIcon, Film } from 'lucide-react';
|
||||||
import { ConfirmDialog } from '@/components/ui/confirm';
|
import { ConfirmDialog } from '@/components/ui/confirm';
|
||||||
import { useConfirm } from '@/hooks/useConfirm';
|
import { useConfirm } from '@/hooks/useConfirm';
|
||||||
import { useToast } from '@/hooks/useToast';
|
import { useToast } from '@/hooks/useToast';
|
||||||
import type { Message } from '@/types';
|
import type { Message } from '@/types';
|
||||||
|
|
||||||
|
const TOOL_LABELS: Record<string, string> = {
|
||||||
|
generate_images: '图片生成',
|
||||||
|
generate_videos: '视频生成',
|
||||||
|
upload_images: '图片上传',
|
||||||
|
generate_tts: '语音合成',
|
||||||
|
assemble_draft: '草稿组装',
|
||||||
|
confirm_images: '图片确认',
|
||||||
|
get_pipeline_status: '流水线状态',
|
||||||
|
};
|
||||||
|
|
||||||
type InlineAsset = NonNullable<Message['assets']>[number];
|
type InlineAsset = NonNullable<Message['assets']>[number];
|
||||||
|
|
||||||
function AssetLightbox({ assets, currentIndex, onClose, onNavigate }: {
|
function AssetLightbox({ assets, currentIndex, onClose, onNavigate }: {
|
||||||
@@ -49,6 +59,21 @@ function AssetLightbox({ assets, currentIndex, onClose, onNavigate }: {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function SkeletonCard({ type }: { type: 'image' | 'video' }) {
|
||||||
|
return (
|
||||||
|
<div className="w-24 h-24 rounded-lg border border-zinc-200/80 bg-gradient-to-br from-zinc-50 to-zinc-100/80 relative overflow-hidden">
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
|
{type === 'image' ? (
|
||||||
|
<ImageIcon size={18} className="text-zinc-300/70" />
|
||||||
|
) : (
|
||||||
|
<Film size={18} className="text-zinc-300/70" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="absolute inset-0 skeleton-shimmer" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
type MessageState = 'streaming' | 'thinking' | 'done';
|
type MessageState = 'streaming' | 'thinking' | 'done';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -93,36 +118,110 @@ export function ChatMessage({ message, isLast, isThinking, onRegenerate, onConti
|
|||||||
if (ok) onDelete?.(message.id);
|
if (ok) onDelete?.(message.id);
|
||||||
}, [message.id, onDelete, confirm]);
|
}, [message.id, onDelete, confirm]);
|
||||||
|
|
||||||
// Tool messages
|
// Tool messages - inline card
|
||||||
if (isTool) {
|
if (isTool) {
|
||||||
let toolName = '';
|
const toolName = message.toolName || '';
|
||||||
try { const p = JSON.parse(message.content); toolName = p.tool || ''; } catch {}
|
const label = TOOL_LABELS[toolName] || toolName;
|
||||||
|
const status = message.toolStatus || 'done';
|
||||||
|
const isRunning = status === 'running';
|
||||||
|
const isError = status === 'error';
|
||||||
|
|
||||||
|
// Extract input summary
|
||||||
|
let inputSummary = '';
|
||||||
|
if (message.toolInput) {
|
||||||
|
const inp = message.toolInput;
|
||||||
|
if (inp.count) inputSummary = `${inp.count}张`;
|
||||||
|
else if (inp.manifestPath) inputSummary = (inp.manifestPath as string).split(/[\\/]/).pop()?.replace(/\.json$/, '') || '';
|
||||||
|
else if (inp.script) inputSummary = `${String(inp.script).slice(0, 30)}...`;
|
||||||
|
else {
|
||||||
|
const keys = Object.keys(inp).slice(0, 2);
|
||||||
|
inputSummary = keys.map(k => `${k}: ${JSON.stringify(inp[k]).slice(0, 20)}`).join(', ');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Media tool skeleton logic
|
||||||
|
const isMediaTool = toolName === 'generate_images' || toolName === 'generate_videos';
|
||||||
|
const mediaType: 'image' | 'video' = toolName === 'generate_images' ? 'image' : 'video';
|
||||||
|
const expectedCount = isMediaTool && message.toolInput?.count ? (message.toolInput.count as number) : 0;
|
||||||
|
const assetCount = message.assets?.length || 0;
|
||||||
|
const remainingCount = isRunning ? Math.max(0, expectedCount - assetCount) : 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="py-1 px-3">
|
<div className="py-1">
|
||||||
<div className="flex items-center gap-2 mb-1.5">
|
<div className={cn(
|
||||||
<Bot size={13} className="text-zinc-400" />
|
'rounded-xl border transition-all duration-300 overflow-hidden',
|
||||||
<span className="text-xs text-zinc-400">工具调用: {toolName}</span>
|
isRunning ? 'border-indigo-200 bg-gradient-to-r from-indigo-50/60 to-violet-50/40' :
|
||||||
</div>
|
isError ? 'border-red-200 bg-red-50/40' :
|
||||||
{message.assets && message.assets.length > 0 && (
|
'border-zinc-200/80 bg-zinc-50/60'
|
||||||
<div className="flex flex-wrap gap-2">
|
)}>
|
||||||
{message.assets.map((asset, i) => (
|
{/* Header */}
|
||||||
<button
|
<div className="flex items-center gap-2 px-3 py-2">
|
||||||
key={i}
|
{isRunning ? (
|
||||||
onClick={() => setPreviewIndex(i)}
|
<Loader2 size={14} className="text-indigo-500 animate-spin" />
|
||||||
className="relative group/thumb rounded-lg overflow-hidden border border-zinc-200 hover:border-indigo-300 transition-colors"
|
) : isError ? (
|
||||||
>
|
<XCircle size={14} className="text-red-400" />
|
||||||
{asset.type === 'image' ? (
|
) : (
|
||||||
<img src={asset.url} alt={asset.name} className="w-20 h-20 object-cover" />
|
<CheckCircle2 size={14} className="text-emerald-500" />
|
||||||
) : (
|
)}
|
||||||
<div className="w-20 h-20 bg-zinc-900 flex items-center justify-center">
|
<span className="text-xs font-medium text-zinc-600">{label}</span>
|
||||||
<Play size={20} className="text-white" />
|
<span className={cn(
|
||||||
</div>
|
'text-[10px] px-1.5 py-0.5 rounded-full',
|
||||||
)}
|
isRunning ? 'bg-indigo-100 text-indigo-600' :
|
||||||
<div className="absolute inset-0 bg-black/0 group-hover/thumb:bg-black/10 transition-colors" />
|
isError ? 'bg-red-100 text-red-600' :
|
||||||
</button>
|
'bg-emerald-100/80 text-emerald-600'
|
||||||
))}
|
)}>
|
||||||
|
{isRunning ? '执行中' : isError ? '失败' : '完成'}
|
||||||
|
</span>
|
||||||
|
{inputSummary && (
|
||||||
|
<span className="text-[10px] text-zinc-400 truncate ml-auto">{inputSummary}</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
{/* Assets + Skeleton cards grid */}
|
||||||
|
{(message.assets && message.assets.length > 0) || (isRunning && isMediaTool && remainingCount > 0) ? (
|
||||||
|
<div className="px-3 pb-3 pt-1">
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{message.assets?.map((asset, i) => (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
onClick={() => setPreviewIndex(i)}
|
||||||
|
className="relative group/thumb rounded-lg overflow-hidden border border-zinc-200/80 hover:border-indigo-300 hover:shadow-md transition-all duration-200"
|
||||||
|
>
|
||||||
|
{asset.type === 'image' ? (
|
||||||
|
<img src={asset.url} alt={asset.name} className="w-24 h-24 object-cover" loading="lazy" />
|
||||||
|
) : (
|
||||||
|
<div className="w-24 h-24 relative bg-zinc-900 overflow-hidden">
|
||||||
|
<video
|
||||||
|
src={asset.url}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
muted
|
||||||
|
preload="metadata"
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
|
<div className="w-8 h-8 rounded-full bg-white/25 backdrop-blur-[2px] flex items-center justify-center shadow-sm">
|
||||||
|
<Play size={14} className="text-white ml-0.5" fill="white" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="absolute inset-0 bg-black/0 group-hover/thumb:bg-black/10 transition-colors" />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{isRunning && isMediaTool && remainingCount > 0 && Array.from({ length: remainingCount }, (_, i) => (
|
||||||
|
<SkeletonCard key={`skel-${i}`} type={mediaType} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : isRunning ? (
|
||||||
|
<div className="px-3 pb-3">
|
||||||
|
<div className="tool-progress-track h-1.5 w-full rounded-full bg-indigo-100/60 overflow-hidden">
|
||||||
|
<div className="tool-shimmer-bar h-full" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Lightbox */}
|
||||||
{previewIndex !== null && message.assets && (
|
{previewIndex !== null && message.assets && (
|
||||||
<AssetLightbox
|
<AssetLightbox
|
||||||
assets={message.assets}
|
assets={message.assets}
|
||||||
|
|||||||
@@ -220,18 +220,16 @@ export function ChatView() {
|
|||||||
{/* Message list */}
|
{/* Message list */}
|
||||||
<div ref={scrollRef} className="flex-1 overflow-y-auto px-4 py-4">
|
<div ref={scrollRef} className="flex-1 overflow-y-auto px-4 py-4">
|
||||||
{messages.map((msg, i) => (
|
{messages.map((msg, i) => (
|
||||||
msg.role !== 'tool' && (
|
<ChatMessage
|
||||||
<ChatMessage
|
key={msg.id}
|
||||||
key={msg.id}
|
message={msg}
|
||||||
message={msg}
|
isLast={i === messages.length - 1}
|
||||||
isLast={i === messages.length - 1}
|
isThinking={thinking}
|
||||||
isThinking={thinking}
|
onRegenerate={msg.role === 'assistant' ? handleRegenerate : undefined}
|
||||||
onRegenerate={msg.role === 'assistant' ? handleRegenerate : undefined}
|
onContinue={msg.role === 'assistant' ? handleContinue : undefined}
|
||||||
onContinue={msg.role === 'assistant' ? handleContinue : undefined}
|
onQuote={handleQuote}
|
||||||
onQuote={handleQuote}
|
onDelete={handleDeleteMsg}
|
||||||
onDelete={handleDeleteMsg}
|
/>
|
||||||
/>
|
|
||||||
)
|
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{pipeline && (
|
{pipeline && (
|
||||||
@@ -244,7 +242,7 @@ export function ChatView() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{thinking && !pipeline && (
|
{thinking && !pipeline && !messages.some(m => m.role === 'tool' && m.toolStatus === 'running') && (
|
||||||
<div className="flex items-center gap-2 text-zinc-400 text-sm py-2">
|
<div className="flex items-center gap-2 text-zinc-400 text-sm py-2">
|
||||||
<Loader2 size={14} className="animate-spin text-indigo-400" />
|
<Loader2 size={14} className="animate-spin text-indigo-400" />
|
||||||
{toolStatus || '思考中...'}
|
{toolStatus || '思考中...'}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { CheckCircle2, Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
phase: string;
|
phase: string;
|
||||||
@@ -8,34 +9,49 @@ interface Props {
|
|||||||
status?: string;
|
status?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const PHASES = [
|
||||||
|
{ key: 'images', label: '图片' },
|
||||||
|
{ key: 'upload', label: '上传' },
|
||||||
|
{ key: 'videos', label: '视频' },
|
||||||
|
{ key: 'tts', label: '配音' },
|
||||||
|
{ key: 'assemble', label: '成片' },
|
||||||
|
];
|
||||||
|
|
||||||
export function PipelineProgress({ phase, progress, currentItem, totalItems, status }: Props) {
|
export function PipelineProgress({ phase, progress, currentItem, totalItems, status }: Props) {
|
||||||
const phaseLabel: Record<string, string> = {
|
const currentIdx = PHASES.findIndex(p => p.key === phase);
|
||||||
images: '生成图片',
|
|
||||||
upload: '上传素材',
|
|
||||||
videos: '生成视频',
|
|
||||||
tts: '配音',
|
|
||||||
assemble: '成片组装',
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-gradient-to-br from-zinc-900 to-zinc-800 border border-zinc-700/50 rounded-xl p-3.5 my-2 shadow-lg shadow-zinc-900/20">
|
<div className="bg-gradient-to-br from-zinc-900 to-zinc-800 border border-zinc-700/50 rounded-xl p-3.5 my-2 shadow-lg shadow-zinc-900/20">
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center gap-1.5">
|
||||||
<span className="text-xs text-zinc-400 font-medium">
|
{PHASES.map((p, i) => {
|
||||||
{phaseLabel[phase] || phase}
|
const isDone = i < currentIdx || (i === currentIdx && progress >= 100);
|
||||||
{currentItem && totalItems ? ` (${currentItem}/${totalItems})` : ''}
|
const isActive = i === currentIdx && progress < 100;
|
||||||
</span>
|
|
||||||
<span className="text-xs font-mono text-zinc-500">{progress}%</span>
|
return (
|
||||||
|
<div key={p.key} className={cn(
|
||||||
|
'flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs font-medium transition-all duration-300',
|
||||||
|
isDone ? 'bg-emerald-500/15 text-emerald-400' :
|
||||||
|
isActive ? 'bg-indigo-500/15 text-indigo-400 ring-1 ring-indigo-400/30' :
|
||||||
|
'bg-zinc-800/50 text-zinc-600'
|
||||||
|
)}>
|
||||||
|
{isDone ? (
|
||||||
|
<CheckCircle2 size={12} className="text-emerald-400" />
|
||||||
|
) : isActive ? (
|
||||||
|
<Loader2 size={12} className="text-indigo-400 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<div className="w-1.5 h-1.5 rounded-full bg-zinc-600" />
|
||||||
|
)}
|
||||||
|
<span>{p.label}</span>
|
||||||
|
{isActive && currentItem != null && totalItems != null && (
|
||||||
|
<span className="text-[10px] opacity-60 ml-0.5">
|
||||||
|
{currentItem}/{totalItems}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full h-1.5 bg-zinc-700/50 rounded-full overflow-hidden">
|
{status && <p className="text-xs text-zinc-500 mt-2">{status}</p>}
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'h-full rounded-full transition-all duration-700 ease-out',
|
|
||||||
progress < 100 ? 'bg-gradient-to-r from-blue-500 to-indigo-400' : 'bg-gradient-to-r from-emerald-500 to-green-400'
|
|
||||||
)}
|
|
||||||
style={{ width: `${progress}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{status && <p className="text-xs text-zinc-500 mt-1.5">{status}</p>}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,21 +64,60 @@ export function useChat(conversationId: string | null) {
|
|||||||
};
|
};
|
||||||
const onToolStart = (data: Record<string, unknown>) => {
|
const onToolStart = (data: Record<string, unknown>) => {
|
||||||
setToolStatus(`执行: ${data.tool}...`);
|
setToolStatus(`执行: ${data.tool}...`);
|
||||||
};
|
const toolId = `tool-${Date.now()}`;
|
||||||
const onToolResult = (data: Record<string, unknown>) => {
|
|
||||||
setToolStatus(null);
|
|
||||||
setThinking(true);
|
|
||||||
setMessages((prev) => [...prev, {
|
setMessages((prev) => [...prev, {
|
||||||
id: `tool-${Date.now()}`,
|
id: toolId,
|
||||||
role: 'tool' as const,
|
role: 'tool' as const,
|
||||||
content: JSON.stringify({ tool: data.tool, result: data.result }),
|
content: '',
|
||||||
assets: (data.assets as Message['assets']) || undefined,
|
toolName: data.tool as string,
|
||||||
|
toolStatus: 'running' as const,
|
||||||
|
toolInput: (data.input as Record<string, unknown>) || undefined,
|
||||||
created_at: new Date().toISOString(),
|
created_at: new Date().toISOString(),
|
||||||
conversation_id: '',
|
conversation_id: '',
|
||||||
}]);
|
}]);
|
||||||
};
|
};
|
||||||
|
const onToolResult = (data: Record<string, unknown>) => {
|
||||||
|
setToolStatus(null);
|
||||||
|
setThinking(true);
|
||||||
|
setMessages((prev) => {
|
||||||
|
const idx = prev.findIndex((m) => m.role === 'tool' && m.toolStatus === 'running' && m.toolName === data.tool);
|
||||||
|
if (idx >= 0) {
|
||||||
|
const updated = [...prev];
|
||||||
|
updated[idx] = {
|
||||||
|
...updated[idx],
|
||||||
|
content: JSON.stringify({ tool: data.tool, result: data.result }),
|
||||||
|
assets: (data.assets as Message['assets']) || undefined,
|
||||||
|
toolStatus: 'done' as const,
|
||||||
|
};
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
return [...prev, {
|
||||||
|
id: `tool-${Date.now()}`,
|
||||||
|
role: 'tool' as const,
|
||||||
|
content: JSON.stringify({ tool: data.tool, result: data.result }),
|
||||||
|
assets: (data.assets as Message['assets']) || undefined,
|
||||||
|
toolName: data.tool as string,
|
||||||
|
toolStatus: 'done' as const,
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
conversation_id: '',
|
||||||
|
}];
|
||||||
|
});
|
||||||
|
};
|
||||||
const onToolError = (data: Record<string, unknown>) => {
|
const onToolError = (data: Record<string, unknown>) => {
|
||||||
setToolStatus(`失败: ${data.tool}`);
|
setToolStatus(`失败: ${data.tool}`);
|
||||||
|
setMessages((prev) => {
|
||||||
|
const idx = prev.findIndex((m) => m.role === 'tool' && m.toolStatus === 'running' && m.toolName === data.tool);
|
||||||
|
if (idx >= 0) {
|
||||||
|
const updated = [...prev];
|
||||||
|
updated[idx] = {
|
||||||
|
...updated[idx],
|
||||||
|
content: JSON.stringify({ tool: data.tool, error: data.error }),
|
||||||
|
toolStatus: 'error' as const,
|
||||||
|
};
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
return prev;
|
||||||
|
});
|
||||||
setTimeout(() => setToolStatus(null), 4000);
|
setTimeout(() => setToolStatus(null), 4000);
|
||||||
};
|
};
|
||||||
const onPipelineProgress = (data: Record<string, unknown>) => {
|
const onPipelineProgress = (data: Record<string, unknown>) => {
|
||||||
|
|||||||
@@ -38,6 +38,43 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Tool card progress - liquid fill animation */
|
||||||
|
@keyframes liquid-fill {
|
||||||
|
0% { width: 5%; opacity: 0.5; }
|
||||||
|
20% { width: 30%; opacity: 0.9; }
|
||||||
|
40% { width: 55%; opacity: 1; }
|
||||||
|
60% { width: 75%; opacity: 0.95; }
|
||||||
|
80% { width: 92%; opacity: 0.85; }
|
||||||
|
90% { width: 100%; opacity: 0.7; }
|
||||||
|
100% { width: 5%; opacity: 0.5; }
|
||||||
|
}
|
||||||
|
@keyframes liquid-glow {
|
||||||
|
0%, 100% { box-shadow: 0 0 4px rgba(99, 102, 241, 0.3); }
|
||||||
|
50% { box-shadow: 0 0 12px rgba(99, 102, 241, 0.5); }
|
||||||
|
}
|
||||||
|
.tool-shimmer-bar {
|
||||||
|
animation: liquid-fill 2.4s cubic-bezier(0.4, 0, 0.2, 1) infinite;
|
||||||
|
border-radius: 9999px;
|
||||||
|
background: linear-gradient(90deg,
|
||||||
|
rgba(99, 102, 241, 0.8),
|
||||||
|
rgba(139, 92, 246, 0.9),
|
||||||
|
rgba(99, 102, 241, 0.7));
|
||||||
|
background-size: 200% 100%;
|
||||||
|
}
|
||||||
|
.tool-progress-track {
|
||||||
|
animation: liquid-glow 2.4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Skeleton card shimmer */
|
||||||
|
@keyframes shimmer-slide {
|
||||||
|
0% { transform: translateX(-100%); }
|
||||||
|
100% { transform: translateX(100%); }
|
||||||
|
}
|
||||||
|
.skeleton-shimmer {
|
||||||
|
background: linear-gradient(90deg, transparent 0%, rgba(255, 255, 255, 0.5) 50%, transparent 100%);
|
||||||
|
animation: shimmer-slide 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
/* GitHub-style Markdown - light theme */
|
/* GitHub-style Markdown - light theme */
|
||||||
@layer components {
|
@layer components {
|
||||||
.markdown-body {
|
.markdown-body {
|
||||||
|
|||||||
@@ -101,6 +101,9 @@ export interface Message {
|
|||||||
tool_calls?: unknown;
|
tool_calls?: unknown;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
assets?: Array<{ type: 'image' | 'video'; url: string; name: string }>;
|
assets?: Array<{ type: 'image' | 'video'; url: string; name: string }>;
|
||||||
|
toolName?: string;
|
||||||
|
toolStatus?: 'running' | 'done' | 'error';
|
||||||
|
toolInput?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Asset {
|
export interface Asset {
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -132,6 +132,9 @@ assetsRouter.get('/file', (req, res) => {
|
|||||||
const fullPath = path.resolve(PROJECT_ROOT, filePath);
|
const fullPath = path.resolve(PROJECT_ROOT, filePath);
|
||||||
if (!fullPath.startsWith(PROJECT_ROOT)) return res.status(403).send('Forbidden');
|
if (!fullPath.startsWith(PROJECT_ROOT)) return res.status(403).send('Forbidden');
|
||||||
if (!fss.existsSync(fullPath)) return res.status(404).send('Not found');
|
if (!fss.existsSync(fullPath)) return res.status(404).send('Not found');
|
||||||
|
// Set correct Content-Type for video files
|
||||||
|
if (fullPath.endsWith('.mp4')) res.setHeader('Content-Type', 'video/mp4');
|
||||||
|
else if (fullPath.endsWith('.webm')) res.setHeader('Content-Type', 'video/webm');
|
||||||
// Cache static assets for 1 hour
|
// Cache static assets for 1 hour
|
||||||
res.setHeader('Cache-Control', 'public, max-age=3600');
|
res.setHeader('Cache-Control', 'public, max-age=3600');
|
||||||
res.sendFile(fullPath);
|
res.sendFile(fullPath);
|
||||||
|
|||||||
Reference in New Issue
Block a user