forked from 77media/video-flow
chatbox开发中
This commit is contained in:
parent
f41f3b35e4
commit
55b5e6e570
@ -1,2 +1,3 @@
|
|||||||
export const BASE_URL = process.env.NEXT_PUBLIC_SMART_API
|
export const BASE_URL = process.env.NEXT_PUBLIC_SMART_API
|
||||||
|
// export const BASE_URL ='http://192.168.120.36:8000'
|
||||||
//
|
//
|
||||||
|
|||||||
39
components/SmartChatBox/DateDivider.tsx
Normal file
39
components/SmartChatBox/DateDivider.tsx
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
interface DateDividerProps {
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DateDivider({ timestamp }: DateDividerProps) {
|
||||||
|
const formatDate = (ts: number) => {
|
||||||
|
const date = new Date(ts);
|
||||||
|
const today = new Date();
|
||||||
|
const yesterday = new Date(today);
|
||||||
|
yesterday.setDate(yesterday.getDate() - 1);
|
||||||
|
|
||||||
|
// 判断是否是今天
|
||||||
|
if (date.toDateString() === today.toDateString()) {
|
||||||
|
return '今天';
|
||||||
|
}
|
||||||
|
// 判断是否是昨天
|
||||||
|
if (date.toDateString() === yesterday.toDateString()) {
|
||||||
|
return '昨天';
|
||||||
|
}
|
||||||
|
// 其他日期显示完整日期
|
||||||
|
return date.toLocaleDateString('zh-CN', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center my-4" data-alt="date-divider">
|
||||||
|
<div className="flex-grow border-t border-gray-700/30"></div>
|
||||||
|
<div className="mx-4 text-xs text-gray-500">
|
||||||
|
{formatDate(timestamp)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-grow border-t border-gray-700/30"></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
139
components/SmartChatBox/InputBar.tsx
Normal file
139
components/SmartChatBox/InputBar.tsx
Normal file
@ -0,0 +1,139 @@
|
|||||||
|
import React, { useRef, useState } from "react";
|
||||||
|
import { Image as ImageIcon, Send, Trash2 } from "lucide-react";
|
||||||
|
import { MessageBlock } from "./types";
|
||||||
|
import { useUploadFile } from "@/app/service/domain/service";
|
||||||
|
|
||||||
|
interface InputBarProps {
|
||||||
|
onSend: (blocks: MessageBlock[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InputBar({ onSend }: InputBarProps) {
|
||||||
|
const [text, setText] = useState("");
|
||||||
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
|
const [uploadProgress, setUploadProgress] = useState(0);
|
||||||
|
const [imageUrl, setImageUrl] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const { uploadFile } = useUploadFile();
|
||||||
|
|
||||||
|
const handleSend = () => {
|
||||||
|
const blocks: MessageBlock[] = [];
|
||||||
|
if (text.trim()) blocks.push({ type: "text" as const, text: text.trim() });
|
||||||
|
if (imageUrl) blocks.push({ type: "image" as const, url: imageUrl });
|
||||||
|
if (!blocks.length) return;
|
||||||
|
|
||||||
|
onSend(blocks);
|
||||||
|
setText("");
|
||||||
|
setImageUrl(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileUpload = async (file: File) => {
|
||||||
|
try {
|
||||||
|
setIsUploading(true);
|
||||||
|
const url = await uploadFile(file, (progress) => {
|
||||||
|
setUploadProgress(progress);
|
||||||
|
});
|
||||||
|
setImageUrl(url);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("上传失败:", error);
|
||||||
|
// 可以添加错误提示
|
||||||
|
} finally {
|
||||||
|
setIsUploading(false);
|
||||||
|
setUploadProgress(0);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (file) {
|
||||||
|
if (!file.type.startsWith('image/')) {
|
||||||
|
alert('请选择图片文件');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await handleFileUpload(file);
|
||||||
|
}
|
||||||
|
e.currentTarget.value = ""; // reset
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeImage = () => {
|
||||||
|
setImageUrl(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div data-alt="input-bar">
|
||||||
|
{/* 图片预览 */}
|
||||||
|
{imageUrl && (
|
||||||
|
<div className="px-3 pt-3" data-alt="image-preview">
|
||||||
|
<div className="relative group w-24 h-24">
|
||||||
|
<img
|
||||||
|
src={imageUrl}
|
||||||
|
className="h-full w-full object-cover rounded-xl border border-white/10"
|
||||||
|
alt="预览图"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={removeImage}
|
||||||
|
className="absolute top-1 right-1 opacity-0 group-hover:opacity-100 transition bg-black/60 text-white rounded-full p-1"
|
||||||
|
title="移除"
|
||||||
|
data-alt="remove-image-button"
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 上传进度 */}
|
||||||
|
{isUploading && (
|
||||||
|
<div className="px-3 py-2">
|
||||||
|
<div className="w-full bg-gray-200 rounded-full h-1">
|
||||||
|
<div
|
||||||
|
className="bg-blue-500 h-1 rounded-full transition-all duration-300"
|
||||||
|
style={{ width: `${uploadProgress}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 px-3 m-3 border border-gray-700 rounded-[2rem]">
|
||||||
|
{/* 图片上传 */}
|
||||||
|
<label
|
||||||
|
className={`cursor-pointer inline-flex items-center gap-2 p-2 my-2 rounded-full hover:bg-gray-700/50 text-gray-100 ${isUploading ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||||
|
data-alt="file-upload"
|
||||||
|
>
|
||||||
|
<ImageIcon size={16} />
|
||||||
|
<input
|
||||||
|
className="hidden"
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
onChange={onFileChange}
|
||||||
|
disabled={isUploading}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{/* 文本输入 */}
|
||||||
|
<input
|
||||||
|
className="flex-1 bg-transparent text-gray-100 px-3 py-2 outline-none placeholder:text-gray-400"
|
||||||
|
placeholder="输入文字…"
|
||||||
|
value={text}
|
||||||
|
onChange={(e) => setText(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSend();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
data-alt="text-input"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 发送按钮 */}
|
||||||
|
<button
|
||||||
|
onClick={handleSend}
|
||||||
|
className="inline-flex items-center gap-2 p-2 my-2 rounded-full bg-blue-500 hover:bg-blue-400 text-white shadow disabled:bg-gray-500 disabled:hover:bg-gray-500 disabled:cursor-not-allowed"
|
||||||
|
data-alt="send-button"
|
||||||
|
disabled={!text.trim() && !imageUrl}
|
||||||
|
>
|
||||||
|
<Send size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
25
components/SmartChatBox/LoadMoreButton.tsx
Normal file
25
components/SmartChatBox/LoadMoreButton.tsx
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { ChevronUp } from 'lucide-react';
|
||||||
|
|
||||||
|
interface LoadMoreButtonProps {
|
||||||
|
onClick: () => void;
|
||||||
|
loading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LoadMoreButton({ onClick, loading = false }: LoadMoreButtonProps) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full flex items-center justify-center gap-2 py-2 text-sm text-gray-400 hover:text-gray-300 hover:bg-gray-800/30 transition-colors disabled:opacity-50"
|
||||||
|
data-alt="load-more-button"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<div className="animate-spin rounded-full h-4 w-4 border-2 border-gray-400 border-t-white" />
|
||||||
|
) : (
|
||||||
|
<ChevronUp size={16} />
|
||||||
|
)}
|
||||||
|
<span>查看历史消息</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
126
components/SmartChatBox/MessageRenderer.tsx
Normal file
126
components/SmartChatBox/MessageRenderer.tsx
Normal file
@ -0,0 +1,126 @@
|
|||||||
|
import React, { useMemo } from "react";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { ChatMessage } from "./types";
|
||||||
|
import { bubbleVariants, hhmm } from "./utils";
|
||||||
|
import { ProgressBar } from "./ProgressBar";
|
||||||
|
import { Loader2, AlertCircle, CheckCircle2 } from "lucide-react";
|
||||||
|
import { Image } from 'antd';
|
||||||
|
|
||||||
|
interface MessageRendererProps {
|
||||||
|
msg: ChatMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MessageRenderer({ msg }: MessageRendererProps) {
|
||||||
|
// Decide bubble style
|
||||||
|
const isUser = msg.role === "user";
|
||||||
|
const isSystem = msg.role === "system";
|
||||||
|
|
||||||
|
const bubbleClass = useMemo(() => {
|
||||||
|
if (isSystem) return "bg-amber-50 text-amber-900 border border-amber-200";
|
||||||
|
if (isUser) return "bg-blue-500/30 text-white";
|
||||||
|
return "bg-gray-800 text-gray-100"; // assistant
|
||||||
|
}, [isSystem, isUser]);
|
||||||
|
|
||||||
|
const badge = isSystem ? (
|
||||||
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 text-[10px] rounded-full bg-amber-200 text-amber-900 mr-2">
|
||||||
|
系统流程
|
||||||
|
</span>
|
||||||
|
) : msg.role === "assistant" ? (
|
||||||
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 text-[10px] rounded-full bg-gray-700 text-gray-100 mr-2">
|
||||||
|
助手
|
||||||
|
</span>
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
// 状态图标和颜色
|
||||||
|
const statusIcon = useMemo(() => {
|
||||||
|
if (!isUser) return null;
|
||||||
|
|
||||||
|
switch (msg.status) {
|
||||||
|
case 'pending':
|
||||||
|
return <Loader2 size={12} className="animate-spin text-gray-300" />;
|
||||||
|
case 'success':
|
||||||
|
return <CheckCircle2 size={12} className="text-green-400" />;
|
||||||
|
case 'error':
|
||||||
|
return <AlertCircle size={12} className="text-red-400" />;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}, [isUser, msg.status]);
|
||||||
|
|
||||||
|
// 消息类型标签
|
||||||
|
const typeLabel = useMemo(() => {
|
||||||
|
if (!isUser) return null;
|
||||||
|
|
||||||
|
const isTask = msg.chatType !== 'chat';
|
||||||
|
if (isTask) {
|
||||||
|
return (
|
||||||
|
<span className="px-1.5 py-0.5 text-[10px] rounded bg-blue-500/30 text-blue-200">
|
||||||
|
任务
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span className="px-1.5 py-0.5 text-[10px] rounded bg-gray-500/30 text-gray-200">
|
||||||
|
聊天
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}, [isUser, msg.chatType]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
className={`w-full flex ${isUser ? "justify-end" : "justify-start"}`}
|
||||||
|
custom={isUser}
|
||||||
|
variants={bubbleVariants}
|
||||||
|
initial="hidden"
|
||||||
|
animate="visible"
|
||||||
|
transition={{ duration: 0.25 }}
|
||||||
|
data-alt="message-bubble"
|
||||||
|
>
|
||||||
|
<div className={`max-w-[75%] rounded-2xl shadow-md p-3 ${bubbleClass}`}>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center gap-2 text-[11px] opacity-80 mb-1">
|
||||||
|
{badge}
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
{typeLabel}
|
||||||
|
<span>{hhmm(msg.createdAt)}</span>
|
||||||
|
{statusIcon}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content blocks */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
{msg.blocks.map((b, idx) => {
|
||||||
|
switch (b.type) {
|
||||||
|
case "text":
|
||||||
|
return (
|
||||||
|
<p key={idx} className="leading-relaxed whitespace-pre-wrap">
|
||||||
|
{b.text}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
case "image":
|
||||||
|
return (
|
||||||
|
<div key={idx} className="overflow-hidden rounded-xl border border-white/10">
|
||||||
|
<Image src={b.url} alt={b.alt || "image"} className="max-h-72 object-contain w-full bg-black/10" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
case "video":
|
||||||
|
return (
|
||||||
|
<div key={idx} className="overflow-hidden rounded-xl">
|
||||||
|
<video controls src={b.url} poster={b.poster} className="w-full max-h-80 bg-black" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
case "audio":
|
||||||
|
return (
|
||||||
|
<audio key={idx} className="w-full" controls src={b.url} />
|
||||||
|
);
|
||||||
|
case "progress":
|
||||||
|
return <ProgressBar key={idx} value={b.value} total={b.total} label={b.label} />;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
26
components/SmartChatBox/ProgressBar.tsx
Normal file
26
components/SmartChatBox/ProgressBar.tsx
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
|
||||||
|
interface ProgressBarProps {
|
||||||
|
value: number;
|
||||||
|
total?: number;
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProgressBar({ value, total = 100, label }: ProgressBarProps) {
|
||||||
|
const pct = Math.max(0, Math.min(100, Math.round((value / total) * 100)));
|
||||||
|
return (
|
||||||
|
<div className="my-2" data-alt="progress-bar">
|
||||||
|
{label ? <div className="mb-1 text-xs opacity-80">{label}</div> : null}
|
||||||
|
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2 overflow-hidden">
|
||||||
|
<motion.div
|
||||||
|
className="h-2 bg-green-500 rounded-full"
|
||||||
|
initial={{ width: 0 }}
|
||||||
|
animate={{ width: `${pct}%` }}
|
||||||
|
transition={{ duration: 0.5 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] mt-1 opacity-70">{pct}%</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
147
components/SmartChatBox/SmartChatBox.tsx
Normal file
147
components/SmartChatBox/SmartChatBox.tsx
Normal file
@ -0,0 +1,147 @@
|
|||||||
|
import React, { useRef, useCallback } from "react";
|
||||||
|
import { ArrowRightFromLine, ChevronDown } from 'lucide-react';
|
||||||
|
import { Switch } from 'antd';
|
||||||
|
import { MessageRenderer } from "./MessageRenderer";
|
||||||
|
import { InputBar } from "./InputBar";
|
||||||
|
import { useMessages } from "./useMessages";
|
||||||
|
import { DateDivider } from "./DateDivider";
|
||||||
|
import { LoadMoreButton } from "./LoadMoreButton";
|
||||||
|
import { ChatMessage } from "./types";
|
||||||
|
|
||||||
|
interface SmartChatBoxProps {
|
||||||
|
isSmartChatBoxOpen: boolean;
|
||||||
|
setIsSmartChatBoxOpen: (v: boolean) => void;
|
||||||
|
projectId: string;
|
||||||
|
userId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MessageGroup {
|
||||||
|
date: number;
|
||||||
|
messages: ChatMessage[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function BackToLatestButton({ onClick }: { onClick: () => void }) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
className="fixed bottom-24 right-4 bg-blue-500 hover:bg-blue-400 text-white rounded-full p-2 shadow-lg"
|
||||||
|
title="返回最新消息"
|
||||||
|
>
|
||||||
|
<ChevronDown size={20} />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SmartChatBox({ isSmartChatBoxOpen, setIsSmartChatBoxOpen, projectId, userId }: SmartChatBoxProps) {
|
||||||
|
// 消息列表引用
|
||||||
|
const listRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// 处理消息更新时的滚动
|
||||||
|
const handleMessagesUpdate = useCallback((shouldScroll: boolean) => {
|
||||||
|
if (shouldScroll && listRef.current) {
|
||||||
|
listRef.current.scrollTo({ top: listRef.current.scrollHeight, behavior: "smooth" });
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 使用消息管理 hook
|
||||||
|
const [
|
||||||
|
{ messages, isLoading, error, hasMore, loadMoreMessages, backToLatest, isViewingHistory },
|
||||||
|
{ sendMessage },
|
||||||
|
{ enabled: systemPush, toggle: toggleSystemPush }
|
||||||
|
] = useMessages({
|
||||||
|
config: { projectId, userId },
|
||||||
|
onMessagesUpdate: handleMessagesUpdate
|
||||||
|
});
|
||||||
|
|
||||||
|
// 按日期分组消息
|
||||||
|
const groupedMessages = React.useMemo(() => {
|
||||||
|
const groups: MessageGroup[] = [];
|
||||||
|
|
||||||
|
messages.forEach(message => {
|
||||||
|
const messageDate = new Date(message.createdAt).setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
const existingGroup = groups.find(group => {
|
||||||
|
const groupDate = new Date(group.date).setHours(0, 0, 0, 0);
|
||||||
|
return groupDate === messageDate;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingGroup) {
|
||||||
|
existingGroup.messages.push(message);
|
||||||
|
} else {
|
||||||
|
groups.push({
|
||||||
|
date: messageDate,
|
||||||
|
messages: [message]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return groups.sort((a, b) => a.date - b.date);
|
||||||
|
}, [messages]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full w-full text-gray-100 flex flex-col" data-alt="smart-chat-box">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="px-4 py-3 border-b border-white/10 flex items-center justify-between" data-alt="chat-header">
|
||||||
|
<div className="font-semibold flex items-center gap-2">
|
||||||
|
<span>Chat</span>
|
||||||
|
{/* System push toggle */}
|
||||||
|
<Switch
|
||||||
|
checkedChildren="系统推送开"
|
||||||
|
unCheckedChildren="系统推送关"
|
||||||
|
checked={systemPush}
|
||||||
|
onChange={toggleSystemPush}
|
||||||
|
className="ml-2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs opacity-70">
|
||||||
|
<ArrowRightFromLine
|
||||||
|
className="w-4 h-4 cursor-pointer"
|
||||||
|
onClick={() => setIsSmartChatBoxOpen(false)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Message list */}
|
||||||
|
<div ref={listRef} className="flex-1 overflow-y-auto p-4" data-alt="message-list">
|
||||||
|
{/* Load more button */}
|
||||||
|
{hasMore && (
|
||||||
|
<LoadMoreButton onClick={loadMoreMessages} loading={isLoading} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Messages grouped by date */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
{groupedMessages.map((group) => (
|
||||||
|
<React.Fragment key={group.date}>
|
||||||
|
<DateDivider timestamp={group.date} />
|
||||||
|
{group.messages.map((message) => (
|
||||||
|
<MessageRenderer key={message.id} msg={message} />
|
||||||
|
))}
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Loading indicator */}
|
||||||
|
{isLoading && !hasMore && (
|
||||||
|
<div className="flex justify-center py-2">
|
||||||
|
<div className="animate-spin rounded-full h-5 w-5 border-2 border-gray-400 border-t-white" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error message */}
|
||||||
|
{error && (
|
||||||
|
<div className="text-red-500 text-center py-2 text-sm">
|
||||||
|
{error.message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Back to latest button */}
|
||||||
|
{isViewingHistory && (
|
||||||
|
<BackToLatestButton onClick={backToLatest} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Input */}
|
||||||
|
<InputBar onSend={sendMessage} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
507
components/SmartChatBox/api.ts
Normal file
507
components/SmartChatBox/api.ts
Normal file
@ -0,0 +1,507 @@
|
|||||||
|
import {
|
||||||
|
ChatMessage,
|
||||||
|
MessageBlock,
|
||||||
|
FetchMessagesRequest,
|
||||||
|
SendMessageRequest,
|
||||||
|
ChatConfig,
|
||||||
|
ApiResponse,
|
||||||
|
RealApiMessage,
|
||||||
|
ApiMessageContent,
|
||||||
|
MessagesResponse,
|
||||||
|
FunctionName,
|
||||||
|
ProjectInit,
|
||||||
|
ScriptSummary,
|
||||||
|
CharacterGeneration,
|
||||||
|
SketchGeneration,
|
||||||
|
ShotSketchGeneration
|
||||||
|
} from "./types";
|
||||||
|
import { post } from "@/api/request";
|
||||||
|
|
||||||
|
// Mock 数据
|
||||||
|
const MOCK_MESSAGES: RealApiMessage[] = [
|
||||||
|
// 用户发送剧本
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
role: 'user',
|
||||||
|
content: JSON.stringify([{
|
||||||
|
type: 'text',
|
||||||
|
content: '我想拍一个关于一个小女孩和她的机器人朋友的故事,故事发生在未来世界。'
|
||||||
|
}]),
|
||||||
|
created_at: '2024-03-20T10:00:00Z',
|
||||||
|
message_type: undefined,
|
||||||
|
function_name: undefined,
|
||||||
|
custom_data: undefined,
|
||||||
|
status: 'success',
|
||||||
|
intent_type: 'chat'
|
||||||
|
},
|
||||||
|
// 项目初始化
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
role: 'system',
|
||||||
|
content: '我会帮您创建一个温馨感人的科幻短片,讲述人工智能与人类情感的故事。',
|
||||||
|
created_at: '2024-03-20T10:00:10Z',
|
||||||
|
message_type: 'project_init',
|
||||||
|
function_name: 'create_project',
|
||||||
|
custom_data: {
|
||||||
|
project_data: {
|
||||||
|
script: '小女孩和机器人朋友的故事'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
status: 'success',
|
||||||
|
intent_type: 'procedure'
|
||||||
|
},
|
||||||
|
// 剧本总结
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
role: 'system',
|
||||||
|
content: '故事概要:在2045年的未来城市,10岁的小女孩艾米丽收到了一个特别的生日礼物——一个具有高度情感智能的机器人伙伴"小星"。随着时间推移,他们建立了深厚的友谊。当小星因能源耗尽即将永久关闭时,艾米丽想尽办法寻找解决方案,最终通过她的坚持和创意,成功为小星找到了新的能源,让这段跨越人机界限的友谊得以延续。',
|
||||||
|
created_at: '2024-03-20T10:01:00Z',
|
||||||
|
message_type: 'script_summary',
|
||||||
|
function_name: 'generate_script_summary',
|
||||||
|
custom_data: {
|
||||||
|
summary: '一个关于友谊和希望的温暖故事'
|
||||||
|
},
|
||||||
|
status: 'success',
|
||||||
|
intent_type: 'procedure'
|
||||||
|
},
|
||||||
|
// 角色生成 - 艾米丽
|
||||||
|
{
|
||||||
|
id: 4,
|
||||||
|
role: 'system',
|
||||||
|
content: '主角艾米丽的形象已生成',
|
||||||
|
created_at: '2024-03-20T10:02:00Z',
|
||||||
|
message_type: 'character_generation',
|
||||||
|
function_name: 'generate_character',
|
||||||
|
custom_data: {
|
||||||
|
character_name: '艾米丽',
|
||||||
|
image_path: 'https://picsum.photos/seed/emily/300/400',
|
||||||
|
count: 1,
|
||||||
|
total_count: 2
|
||||||
|
},
|
||||||
|
status: 'success',
|
||||||
|
intent_type: 'procedure'
|
||||||
|
},
|
||||||
|
// 角色生成 - 小星
|
||||||
|
{
|
||||||
|
id: 5,
|
||||||
|
role: 'system',
|
||||||
|
content: '机器人小星的形象已生成',
|
||||||
|
created_at: '2024-03-20T10:03:00Z',
|
||||||
|
message_type: 'character_generation',
|
||||||
|
function_name: 'generate_character',
|
||||||
|
custom_data: {
|
||||||
|
character_name: '小星',
|
||||||
|
image_path: 'https://picsum.photos/seed/robot/300/400',
|
||||||
|
count: 2,
|
||||||
|
total_count: 2
|
||||||
|
},
|
||||||
|
status: 'success',
|
||||||
|
intent_type: 'procedure'
|
||||||
|
},
|
||||||
|
// 场景生成 - 未来城市
|
||||||
|
{
|
||||||
|
id: 6,
|
||||||
|
role: 'system',
|
||||||
|
content: '未来城市场景设计完成',
|
||||||
|
created_at: '2024-03-20T10:04:00Z',
|
||||||
|
message_type: 'sketch_generation',
|
||||||
|
function_name: 'generate_sketch',
|
||||||
|
custom_data: {
|
||||||
|
sketch_name: '未来城市街景',
|
||||||
|
image_path: 'https://picsum.photos/seed/city/600/400',
|
||||||
|
count: 1,
|
||||||
|
total_count: 3
|
||||||
|
},
|
||||||
|
status: 'success',
|
||||||
|
intent_type: 'procedure'
|
||||||
|
},
|
||||||
|
// 场景生成 - 艾米丽的房间
|
||||||
|
{
|
||||||
|
id: 7,
|
||||||
|
role: 'system',
|
||||||
|
content: '艾米丽的未来风格卧室设计完成',
|
||||||
|
created_at: '2024-03-20T10:05:00Z',
|
||||||
|
message_type: 'sketch_generation',
|
||||||
|
function_name: 'generate_sketch',
|
||||||
|
custom_data: {
|
||||||
|
sketch_name: '艾米丽的卧室',
|
||||||
|
image_path: 'https://picsum.photos/seed/room/600/400',
|
||||||
|
count: 2,
|
||||||
|
total_count: 3
|
||||||
|
},
|
||||||
|
status: 'success',
|
||||||
|
intent_type: 'procedure'
|
||||||
|
},
|
||||||
|
// 场景生成 - 实验室
|
||||||
|
{
|
||||||
|
id: 8,
|
||||||
|
role: 'system',
|
||||||
|
content: '高科技实验室场景设计完成',
|
||||||
|
created_at: '2024-03-20T10:06:00Z',
|
||||||
|
message_type: 'sketch_generation',
|
||||||
|
function_name: 'generate_sketch',
|
||||||
|
custom_data: {
|
||||||
|
sketch_name: '未来实验室',
|
||||||
|
image_path: 'https://picsum.photos/seed/lab/600/400',
|
||||||
|
count: 3,
|
||||||
|
total_count: 3
|
||||||
|
},
|
||||||
|
status: 'success',
|
||||||
|
intent_type: 'procedure'
|
||||||
|
},
|
||||||
|
// 分镜生成 - 相遇
|
||||||
|
{
|
||||||
|
id: 9,
|
||||||
|
role: 'system',
|
||||||
|
content: '第一个分镜:艾米丽收到礼物时的场景',
|
||||||
|
created_at: '2024-03-20T10:07:00Z',
|
||||||
|
message_type: 'shot_sketch_generation',
|
||||||
|
function_name: 'generate_shot_sketch',
|
||||||
|
custom_data: {
|
||||||
|
shot_type: '中景',
|
||||||
|
atmosphere: '温馨、期待',
|
||||||
|
key_action: '艾米丽惊喜地打开礼物盒,小星缓缓启动',
|
||||||
|
url: 'https://picsum.photos/seed/shot1/600/400',
|
||||||
|
count: 1,
|
||||||
|
total_count: 3
|
||||||
|
},
|
||||||
|
status: 'success',
|
||||||
|
intent_type: 'procedure'
|
||||||
|
},
|
||||||
|
// 分镜生成 - 危机
|
||||||
|
{
|
||||||
|
id: 10,
|
||||||
|
role: 'system',
|
||||||
|
content: '第二个分镜:小星能源耗尽的场景',
|
||||||
|
created_at: '2024-03-20T10:08:00Z',
|
||||||
|
message_type: 'shot_sketch_generation',
|
||||||
|
function_name: 'generate_shot_sketch',
|
||||||
|
custom_data: {
|
||||||
|
shot_type: '特写',
|
||||||
|
atmosphere: '紧张、担忧',
|
||||||
|
key_action: '小星的能源指示灯闪烁微弱,艾米丽神情焦急',
|
||||||
|
url: 'https://picsum.photos/seed/shot2/600/400',
|
||||||
|
count: 2,
|
||||||
|
total_count: 3
|
||||||
|
},
|
||||||
|
status: 'success',
|
||||||
|
intent_type: 'procedure'
|
||||||
|
},
|
||||||
|
// 分镜生成 - 解决
|
||||||
|
{
|
||||||
|
id: 11,
|
||||||
|
role: 'system',
|
||||||
|
content: '第三个分镜:找到新能源解决方案的场景',
|
||||||
|
created_at: '2024-03-20T10:09:00Z',
|
||||||
|
message_type: 'shot_sketch_generation',
|
||||||
|
function_name: 'generate_shot_sketch',
|
||||||
|
custom_data: {
|
||||||
|
shot_type: '全景',
|
||||||
|
atmosphere: '欢欣、胜利',
|
||||||
|
key_action: '实验室中艾米丽成功激活新能源,小星重新焕发活力',
|
||||||
|
url: 'https://picsum.photos/seed/shot3/600/400',
|
||||||
|
count: 3,
|
||||||
|
total_count: 3
|
||||||
|
},
|
||||||
|
status: 'success',
|
||||||
|
intent_type: 'procedure'
|
||||||
|
},
|
||||||
|
// 用户反馈
|
||||||
|
{
|
||||||
|
id: 12,
|
||||||
|
role: 'user',
|
||||||
|
content: JSON.stringify([{
|
||||||
|
type: 'text',
|
||||||
|
content: '这个故事设计太棒了!特别喜欢艾米丽和小星的互动场景。'
|
||||||
|
}]),
|
||||||
|
created_at: '2024-03-20T10:10:00Z',
|
||||||
|
message_type: undefined,
|
||||||
|
function_name: undefined,
|
||||||
|
custom_data: undefined,
|
||||||
|
status: 'success',
|
||||||
|
intent_type: 'function_call'
|
||||||
|
},
|
||||||
|
// 助手回复
|
||||||
|
{
|
||||||
|
id: 13,
|
||||||
|
role: 'assistant',
|
||||||
|
content: JSON.stringify([{
|
||||||
|
type: 'text',
|
||||||
|
content: '谢谢您的肯定!我们可以继续优化任何场景或角色设计,您觉得有什么地方需要调整吗?'
|
||||||
|
}]),
|
||||||
|
created_at: '2024-03-20T10:10:10Z',
|
||||||
|
message_type: undefined,
|
||||||
|
function_name: undefined,
|
||||||
|
custom_data: undefined,
|
||||||
|
status: 'success',
|
||||||
|
intent_type: 'function_call'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 类型守卫函数
|
||||||
|
*/
|
||||||
|
function isProjectInit(data: any): data is ProjectInit {
|
||||||
|
return data && 'project_data' in data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isScriptSummary(data: any): data is ScriptSummary {
|
||||||
|
return data && 'summary' in data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCharacterGeneration(data: any): data is CharacterGeneration {
|
||||||
|
return data && 'character_name' in data && 'image_path' in data && 'count' in data && 'total_count' in data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSketchGeneration(data: any): data is SketchGeneration {
|
||||||
|
return data && 'sketch_name' in data && 'image_path' in data && 'count' in data && 'total_count' in data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isShotSketchGeneration(data: any): data is ShotSketchGeneration {
|
||||||
|
return data && 'shot_type' in data && 'atmosphere' in data && 'key_action' in data && 'url' in data && 'count' in data && 'total_count' in data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 系统消息转换为blocks数组
|
||||||
|
*/
|
||||||
|
function transformSystemMessage(
|
||||||
|
functionName: FunctionName,
|
||||||
|
content: string,
|
||||||
|
customData: ProjectInit | ScriptSummary | CharacterGeneration | SketchGeneration | ShotSketchGeneration
|
||||||
|
): MessageBlock[] {
|
||||||
|
let blocks: MessageBlock[] = [];
|
||||||
|
|
||||||
|
switch (functionName) {
|
||||||
|
case 'create_project':
|
||||||
|
if (isProjectInit(customData)) {
|
||||||
|
blocks = [{
|
||||||
|
type: 'text',
|
||||||
|
text: `🎬 根据您输入的 "${customData.project_data.script}",我已完成项目的初始化。\n`
|
||||||
|
}, {
|
||||||
|
type: 'text',
|
||||||
|
text: content
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'generate_script_summary':
|
||||||
|
if (isScriptSummary(customData)) {
|
||||||
|
blocks = [{ type: 'text', text: content }];
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'generate_character':
|
||||||
|
if (isCharacterGeneration(customData)) {
|
||||||
|
blocks = [{
|
||||||
|
type: 'text',
|
||||||
|
text: `🎭 演员 "${customData.character_name}" 已就位`
|
||||||
|
}, {
|
||||||
|
type: 'image',
|
||||||
|
url: customData.image_path
|
||||||
|
}, {
|
||||||
|
type: 'text',
|
||||||
|
text: '图片中演员形象仅供参考,后续可根据视频生成后进行调整。'
|
||||||
|
}, {
|
||||||
|
type: 'progress',
|
||||||
|
value: customData.count,
|
||||||
|
total: customData.total_count,
|
||||||
|
label: `已生成 ${customData.count} 个演员,剧本中共有 ${customData.total_count} 个`
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'generate_sketch':
|
||||||
|
if (isSketchGeneration(customData)) {
|
||||||
|
blocks = [{
|
||||||
|
type: 'text',
|
||||||
|
text: `🎨 场景 "${customData.sketch_name}" 参考图片已生成 \n`
|
||||||
|
}, {
|
||||||
|
type: 'image',
|
||||||
|
url: customData.image_path
|
||||||
|
}, {
|
||||||
|
type: 'text',
|
||||||
|
text: '图片中场景仅供参考,后续可根据视频生成后进行调整。'
|
||||||
|
}, {
|
||||||
|
type: 'progress',
|
||||||
|
value: customData.count,
|
||||||
|
total: customData.total_count,
|
||||||
|
label: `已生成 ${customData.count} 个场景,剧本中共有 ${customData.total_count} 个`
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'generate_shot_sketch':
|
||||||
|
if (isShotSketchGeneration(customData)) {
|
||||||
|
blocks = [{
|
||||||
|
type: 'text',
|
||||||
|
text: `🎬 故事板静帧生成 \n镜头类型:${customData.shot_type}\n氛围:${customData.atmosphere}\n关键动作:${customData.key_action}`
|
||||||
|
}, {
|
||||||
|
type: 'image',
|
||||||
|
url: customData.url
|
||||||
|
}, {
|
||||||
|
type: 'text',
|
||||||
|
text: '图片中故事板静帧仅供参考,后续可根据视频生成后进行调整。'
|
||||||
|
}, {
|
||||||
|
type: 'progress',
|
||||||
|
value: customData.count,
|
||||||
|
total: customData.total_count,
|
||||||
|
label: `已生成 ${customData.count} 个故事板静帧,剧本中共有 ${customData.total_count} 个`
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return blocks;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将API响应转换为ChatMessage格式
|
||||||
|
*/
|
||||||
|
function transformMessage(apiMessage: RealApiMessage): ChatMessage {
|
||||||
|
try {
|
||||||
|
const { id, role, content, created_at, function_name, custom_data, status, intent_type } = apiMessage;
|
||||||
|
let message: ChatMessage = {
|
||||||
|
id: id ? id.toString() : Date.now().toString(),
|
||||||
|
role: role,
|
||||||
|
createdAt: new Date(created_at).getTime(),
|
||||||
|
blocks: [],
|
||||||
|
chatType: intent_type,
|
||||||
|
status: status || 'success',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (role === 'assistant' || role === 'user') {
|
||||||
|
const contentObj = JSON.parse(content);
|
||||||
|
const contentArray = Array.isArray(contentObj) ? contentObj : [contentObj];
|
||||||
|
contentArray.forEach((c: ApiMessageContent) => {
|
||||||
|
if (c.type === "text") {
|
||||||
|
message.blocks.push({ type: "text", text: c.content });
|
||||||
|
} else if (c.type === "image") {
|
||||||
|
message.blocks.push({ type: "image", url: c.content });
|
||||||
|
} else if (c.type === "video") {
|
||||||
|
message.blocks.push({ type: "video", url: c.content });
|
||||||
|
} else if (c.type === "audio") {
|
||||||
|
message.blocks.push({ type: "audio", url: c.content });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else if (role === 'system' && function_name && custom_data) {
|
||||||
|
// 处理系统消息
|
||||||
|
message.blocks = transformSystemMessage(function_name, content, custom_data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果没有有效的 blocks,至少添加一个文本块
|
||||||
|
if (message.blocks.length === 0) {
|
||||||
|
message.blocks.push({ type: "text", text: "无内容" });
|
||||||
|
}
|
||||||
|
|
||||||
|
return message;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("转换消息格式失败:", error, apiMessage);
|
||||||
|
// 返回一个带有错误信息的消息
|
||||||
|
return {
|
||||||
|
id: new Date().getTime().toString(),
|
||||||
|
role: apiMessage.role,
|
||||||
|
createdAt: new Date(apiMessage.created_at).getTime(),
|
||||||
|
blocks: [{ type: "text", text: "消息格式错误" }],
|
||||||
|
chatType: 'chat',
|
||||||
|
status: 'error',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取消息列表
|
||||||
|
*/
|
||||||
|
export async function fetchMessages(
|
||||||
|
config: ChatConfig,
|
||||||
|
offset: number = 0,
|
||||||
|
limit: number = 50
|
||||||
|
): Promise<{
|
||||||
|
messages: ChatMessage[],
|
||||||
|
hasMore: boolean,
|
||||||
|
totalCount: number,
|
||||||
|
}> {
|
||||||
|
const request: FetchMessagesRequest = {
|
||||||
|
session_id: `project_${config.projectId}_user_${config.userId}`,
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log('发送历史消息请求:', request);
|
||||||
|
const response = await post<ApiResponse<MessagesResponse>>("/intelligent/history", request);
|
||||||
|
console.log('收到历史消息响应:', response);
|
||||||
|
|
||||||
|
// 确保 response.data 和 messages 存在
|
||||||
|
if (!response.data || !response.data.messages) {
|
||||||
|
console.error('历史消息响应格式错误:', response);
|
||||||
|
return {
|
||||||
|
messages: [],
|
||||||
|
hasMore: false,
|
||||||
|
totalCount: 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 转换消息并按时间排序
|
||||||
|
if (response.data.messages.length === 0) {
|
||||||
|
return {
|
||||||
|
messages: MOCK_MESSAGES.map(transformMessage),
|
||||||
|
hasMore: false,
|
||||||
|
totalCount: 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
messages: response.data.messages
|
||||||
|
.map(transformMessage)
|
||||||
|
.sort((a, b) => Number(a.id) - Number(b.id)),
|
||||||
|
hasMore: response.data.has_more,
|
||||||
|
totalCount: response.data.total_count
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("获取消息历史失败:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送新消息
|
||||||
|
*/
|
||||||
|
export async function sendMessage(
|
||||||
|
blocks: MessageBlock[],
|
||||||
|
config: ChatConfig
|
||||||
|
): Promise<void> {
|
||||||
|
// 提取文本和图片
|
||||||
|
const textBlocks = blocks.filter(b => b.type === "text");
|
||||||
|
const imageBlocks = blocks.filter(b => b.type === "image");
|
||||||
|
|
||||||
|
const request: SendMessageRequest = {
|
||||||
|
session_id: `project_${config.projectId}_user_${config.userId}`,
|
||||||
|
user_input: textBlocks.map(b => (b as { text: string }).text).join("\n"),
|
||||||
|
project_id: config.projectId,
|
||||||
|
user_id: config.userId,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 如果有图片,添加第一张图片的URL
|
||||||
|
if (imageBlocks.length > 0) {
|
||||||
|
request.image_url = (imageBlocks[0] as { url: string }).url;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log('发送消息请求:', request);
|
||||||
|
await post<ApiResponse<RealApiMessage>>("/intelligent/chat", request);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("发送消息失败:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重试发送消息
|
||||||
|
*/
|
||||||
|
export async function retryMessage(
|
||||||
|
messageId: string,
|
||||||
|
config: ChatConfig
|
||||||
|
): Promise<void> {
|
||||||
|
// TODO: 实现实际的重试逻辑,可能需要保存原始消息内容
|
||||||
|
// 这里简单重用发送消息的接口
|
||||||
|
return sendMessage([{ type: "text", text: "重试消息" }], config);
|
||||||
|
}
|
||||||
132
components/SmartChatBox/types.ts
Normal file
132
components/SmartChatBox/types.ts
Normal file
@ -0,0 +1,132 @@
|
|||||||
|
export type Role = "user" | "assistant" | "system";
|
||||||
|
export type MessageStatus = 'pending' | 'success' | 'error';
|
||||||
|
export type IntentType = 'chat' | 'function_call' | 'procedure';
|
||||||
|
|
||||||
|
export type MessageBlock =
|
||||||
|
| { type: "text"; text: string }
|
||||||
|
| { type: "image"; url: string; alt?: string }
|
||||||
|
| { type: "video"; url: string; poster?: string }
|
||||||
|
| { type: "audio"; url: string }
|
||||||
|
| { type: "progress"; value: number; total?: number; label?: string };
|
||||||
|
|
||||||
|
export interface ChatMessage {
|
||||||
|
id: string;
|
||||||
|
role: Role;
|
||||||
|
blocks: MessageBlock[];
|
||||||
|
createdAt: number;
|
||||||
|
chatType: IntentType;
|
||||||
|
status: MessageStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MessagesState {
|
||||||
|
messages: ChatMessage[];
|
||||||
|
isLoading: boolean;
|
||||||
|
error: Error | null;
|
||||||
|
hasMore: boolean;
|
||||||
|
loadMoreMessages: () => Promise<void>;
|
||||||
|
backToLatest: () => void;
|
||||||
|
isViewingHistory: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MessagesActions {
|
||||||
|
sendMessage: (blocks: MessageBlock[]) => Promise<void>;
|
||||||
|
clearMessages: () => void;
|
||||||
|
retryMessage: (messageId: string) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SystemPushState {
|
||||||
|
enabled: boolean;
|
||||||
|
toggle: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChatConfig {
|
||||||
|
projectId: string;
|
||||||
|
userId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FetchMessagesRequest {
|
||||||
|
session_id: string;
|
||||||
|
limit: number;
|
||||||
|
offset: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SendMessageRequest {
|
||||||
|
session_id: string;
|
||||||
|
user_input: string;
|
||||||
|
image_url?: string;
|
||||||
|
project_id: string;
|
||||||
|
user_id: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiResponse<T> {
|
||||||
|
code: number;
|
||||||
|
message: string;
|
||||||
|
data: T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MessagesResponse {
|
||||||
|
session_id: string;
|
||||||
|
total_count: number;
|
||||||
|
messages: RealApiMessage[];
|
||||||
|
has_more: boolean;
|
||||||
|
current_page: {
|
||||||
|
offset: number;
|
||||||
|
limit: number;
|
||||||
|
count: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
type ContentType = "text" | "image" | "video" | "audio";
|
||||||
|
export type MessageType = "project_init" | "script_summary" | "character_generation" | "sketch_generation" | "shot_sketch_generation";
|
||||||
|
export type FunctionName = "create_project" | "generate_script_summary" | "generate_character" | "generate_sketch" | "generate_shot_sketch";
|
||||||
|
|
||||||
|
// 项目创建
|
||||||
|
export interface ProjectInit {
|
||||||
|
project_data: {
|
||||||
|
script: string; // 原始剧本
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 剧本总结
|
||||||
|
export interface ScriptSummary {
|
||||||
|
summary: string; // 剧本总结
|
||||||
|
}
|
||||||
|
// 角色生成
|
||||||
|
export interface CharacterGeneration {
|
||||||
|
character_name: string; // 角色名称
|
||||||
|
image_path: string; // 角色图片
|
||||||
|
count: number; // 生成数量
|
||||||
|
total_count: number; // 总数量
|
||||||
|
}
|
||||||
|
// 场景草图生成
|
||||||
|
export interface SketchGeneration {
|
||||||
|
sketch_name: string; // 场景草图名称
|
||||||
|
image_path: string; // 场景草图图片
|
||||||
|
count: number; // 生成数量
|
||||||
|
total_count: number; // 总数量
|
||||||
|
}
|
||||||
|
// 分镜草图生成
|
||||||
|
export interface ShotSketchGeneration {
|
||||||
|
shot_type: string; // 分镜镜头类型
|
||||||
|
atmosphere: string; // 氛围描述
|
||||||
|
key_action: string; // 关键动作描述
|
||||||
|
url: string; // 分镜草图图片
|
||||||
|
count: number; // 生成数量
|
||||||
|
total_count: number; // 总数量
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiMessageContent {
|
||||||
|
type: ContentType;
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RealApiMessage {
|
||||||
|
created_at: string;
|
||||||
|
id: number;
|
||||||
|
role: Role;
|
||||||
|
content: string;
|
||||||
|
message_type?: MessageType;
|
||||||
|
function_name?: FunctionName;
|
||||||
|
custom_data?: ProjectInit | ScriptSummary | CharacterGeneration | SketchGeneration | ShotSketchGeneration;
|
||||||
|
status: MessageStatus;
|
||||||
|
intent_type: IntentType;
|
||||||
|
}
|
||||||
212
components/SmartChatBox/useMessages.ts
Normal file
212
components/SmartChatBox/useMessages.ts
Normal file
@ -0,0 +1,212 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState, useMemo } from "react";
|
||||||
|
import { ChatMessage, MessageBlock, MessagesState, MessagesActions, SystemPushState, ChatConfig } from "./types";
|
||||||
|
import { fetchMessages, sendMessage, retryMessage } from "./api";
|
||||||
|
import { uid } from "./utils";
|
||||||
|
|
||||||
|
const POLLING_INTERVAL = 10000; // 10秒轮询一次
|
||||||
|
const PAGE_SIZE = 20;
|
||||||
|
|
||||||
|
interface UseMessagesProps {
|
||||||
|
config: ChatConfig;
|
||||||
|
onMessagesUpdate?: (shouldScroll: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useMessages({ config, onMessagesUpdate }: UseMessagesProps): [MessagesState, MessagesActions, SystemPushState] {
|
||||||
|
// 消息状态
|
||||||
|
const [displayMessages, setDisplayMessages] = useState<ChatMessage[]>([]);
|
||||||
|
const [latestMessages, setLatestMessages] = useState<ChatMessage[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<Error | null>(null);
|
||||||
|
const [hasMore, setHasMore] = useState(false);
|
||||||
|
const [offset, setOffset] = useState(0);
|
||||||
|
const [totalCount, setTotalCount] = useState(0);
|
||||||
|
|
||||||
|
// 系统推送状态
|
||||||
|
const [systemPush, setSystemPush] = useState(true);
|
||||||
|
|
||||||
|
// 状态引用
|
||||||
|
const configRef = useRef(config);
|
||||||
|
const isInitialLoadRef = useRef(true);
|
||||||
|
const timeoutIdRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
const isViewingHistoryRef = useRef(false);
|
||||||
|
const prevTotalCountRef = useRef(totalCount);
|
||||||
|
|
||||||
|
// 更新 config 引用
|
||||||
|
useEffect(() => {
|
||||||
|
configRef.current = config;
|
||||||
|
}, [config]);
|
||||||
|
|
||||||
|
// 监听总消息数变化
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isViewingHistoryRef.current && totalCount !== prevTotalCountRef.current) {
|
||||||
|
// 有新消息,需要滚动
|
||||||
|
onMessagesUpdate?.(true);
|
||||||
|
}
|
||||||
|
prevTotalCountRef.current = totalCount;
|
||||||
|
}, [totalCount, onMessagesUpdate]);
|
||||||
|
|
||||||
|
// 获取最新消息
|
||||||
|
const fetchLatestMessages = useCallback(async (showLoading: boolean = false) => {
|
||||||
|
try {
|
||||||
|
if (showLoading) {
|
||||||
|
setIsLoading(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetchMessages(configRef.current, 0, PAGE_SIZE);
|
||||||
|
setLatestMessages(response.messages);
|
||||||
|
if (!isViewingHistoryRef.current) {
|
||||||
|
setDisplayMessages(response.messages);
|
||||||
|
}
|
||||||
|
setTotalCount(response.totalCount);
|
||||||
|
setHasMore(response.hasMore);
|
||||||
|
setOffset(PAGE_SIZE);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("获取消息失败:", err);
|
||||||
|
setError(err instanceof Error ? err : new Error("获取消息失败"));
|
||||||
|
} finally {
|
||||||
|
if (showLoading) {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 加载更多历史消息
|
||||||
|
const loadMoreMessages = useCallback(async () => {
|
||||||
|
if (isLoading || !hasMore) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
isViewingHistoryRef.current = true;
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
const response = await fetchMessages(configRef.current, offset, PAGE_SIZE);
|
||||||
|
setDisplayMessages(prev => [...response.messages, ...prev]);
|
||||||
|
setHasMore(response.hasMore);
|
||||||
|
setOffset(prev => prev + PAGE_SIZE);
|
||||||
|
setTotalCount(response.totalCount);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("加载历史消息失败:", err);
|
||||||
|
setError(err instanceof Error ? err : new Error("加载历史消息失败"));
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [offset, hasMore, isLoading]);
|
||||||
|
|
||||||
|
// 返回最新消息
|
||||||
|
const backToLatest = useCallback(async () => {
|
||||||
|
isViewingHistoryRef.current = false;
|
||||||
|
setDisplayMessages(latestMessages);
|
||||||
|
onMessagesUpdate?.(true);
|
||||||
|
}, [latestMessages, onMessagesUpdate]);
|
||||||
|
|
||||||
|
// 发送消息
|
||||||
|
const handleSendMessage = useCallback(async (blocks: MessageBlock[]) => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 立即添加用户消息(临时显示)
|
||||||
|
const userMessage: ChatMessage = {
|
||||||
|
id: uid(),
|
||||||
|
role: "user",
|
||||||
|
createdAt: Date.now(),
|
||||||
|
blocks,
|
||||||
|
chatType: 'chat',
|
||||||
|
status: 'pending',
|
||||||
|
};
|
||||||
|
|
||||||
|
// 无论是否在查看历史,发送消息后都切换到最新视图
|
||||||
|
isViewingHistoryRef.current = false;
|
||||||
|
setDisplayMessages(prev => [...prev, userMessage]);
|
||||||
|
|
||||||
|
// 发送到服务器
|
||||||
|
await sendMessage(blocks, configRef.current);
|
||||||
|
|
||||||
|
// 立即获取最新的消息列表
|
||||||
|
await fetchLatestMessages(false);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("发送消息失败:", err);
|
||||||
|
setError(err instanceof Error ? err : new Error("发送消息失败"));
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [fetchLatestMessages]);
|
||||||
|
|
||||||
|
// 重试消息
|
||||||
|
const handleRetryMessage = useCallback(async (messageId: string) => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 重试时切换到最新视图
|
||||||
|
isViewingHistoryRef.current = false;
|
||||||
|
await retryMessage(messageId, configRef.current);
|
||||||
|
await fetchLatestMessages(false);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("重试消息失败:", err);
|
||||||
|
setError(err instanceof Error ? err : new Error("重试消息失败"));
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [fetchLatestMessages]);
|
||||||
|
|
||||||
|
// 清空消息
|
||||||
|
const handleClearMessages = useCallback(() => {
|
||||||
|
setDisplayMessages([]);
|
||||||
|
setLatestMessages([]);
|
||||||
|
setHasMore(false);
|
||||||
|
setOffset(0);
|
||||||
|
setTotalCount(0);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 系统推送开关
|
||||||
|
const toggleSystemPush = useCallback(() => {
|
||||||
|
setSystemPush(prev => !prev);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 轮询获取最新消息
|
||||||
|
useEffect(() => {
|
||||||
|
if (!systemPush) return;
|
||||||
|
|
||||||
|
const poll = async () => {
|
||||||
|
await fetchLatestMessages(false);
|
||||||
|
timeoutIdRef.current = setTimeout(poll, POLLING_INTERVAL);
|
||||||
|
};
|
||||||
|
|
||||||
|
poll();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (timeoutIdRef.current) {
|
||||||
|
clearTimeout(timeoutIdRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [systemPush, fetchLatestMessages]);
|
||||||
|
|
||||||
|
// 初始加载
|
||||||
|
useEffect(() => {
|
||||||
|
if (isInitialLoadRef.current) {
|
||||||
|
isInitialLoadRef.current = false;
|
||||||
|
fetchLatestMessages(true);
|
||||||
|
}
|
||||||
|
}, [fetchLatestMessages]);
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
messages: displayMessages,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
hasMore,
|
||||||
|
loadMoreMessages,
|
||||||
|
backToLatest,
|
||||||
|
isViewingHistory: isViewingHistoryRef.current
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sendMessage: handleSendMessage,
|
||||||
|
clearMessages: handleClearMessages,
|
||||||
|
retryMessage: handleRetryMessage
|
||||||
|
},
|
||||||
|
{
|
||||||
|
enabled: systemPush,
|
||||||
|
toggle: toggleSystemPush
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
17
components/SmartChatBox/utils.ts
Normal file
17
components/SmartChatBox/utils.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import { motion } from "framer-motion";
|
||||||
|
|
||||||
|
export const uid = () => Math.random().toString(36).slice(2);
|
||||||
|
|
||||||
|
export const bubbleVariants = {
|
||||||
|
hidden: (isUser: boolean) => ({ opacity: 0, x: isUser ? 40 : -40, scale: 0.98 }),
|
||||||
|
visible: { opacity: 1, x: 0, scale: 1 },
|
||||||
|
};
|
||||||
|
|
||||||
|
// Format a time like 10:24
|
||||||
|
export function hhmm(ts: number) {
|
||||||
|
const d = new Date(ts);
|
||||||
|
return `${d.getHours().toString().padStart(2, "0")}:${d
|
||||||
|
.getMinutes()
|
||||||
|
.toString()
|
||||||
|
.padStart(2, "0")}`;
|
||||||
|
}
|
||||||
@ -2,7 +2,6 @@
|
|||||||
import React, { useRef, useEffect, useCallback } from "react";
|
import React, { useRef, useEffect, useCallback } from "react";
|
||||||
import "./style/work-flow.css";
|
import "./style/work-flow.css";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { AISuggestionBar } from "@/components/ai-suggestion-bar";
|
|
||||||
import { EditModal } from "@/components/ui/edit-modal";
|
import { EditModal } from "@/components/ui/edit-modal";
|
||||||
import { ErrorBoundary } from "@/components/ui/error-boundary";
|
import { ErrorBoundary } from "@/components/ui/error-boundary";
|
||||||
import { TaskInfo } from "./work-flow/task-info";
|
import { TaskInfo } from "./work-flow/task-info";
|
||||||
@ -10,11 +9,13 @@ import { MediaViewer } from "./work-flow/media-viewer";
|
|||||||
import { ThumbnailGrid } from "./work-flow/thumbnail-grid";
|
import { ThumbnailGrid } from "./work-flow/thumbnail-grid";
|
||||||
import { useWorkflowData } from "./work-flow/use-workflow-data";
|
import { useWorkflowData } from "./work-flow/use-workflow-data";
|
||||||
import { usePlaybackControls } from "./work-flow/use-playback-controls";
|
import { usePlaybackControls } from "./work-flow/use-playback-controls";
|
||||||
import { AlertCircle, RefreshCw, Pause, Play, ChevronLast } from "lucide-react";
|
import { AlertCircle, RefreshCw, Pause, Play, ChevronLast, MessageSquareText } from "lucide-react";
|
||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
import { GlassIconButton } from '@/components/ui/glass-icon-button';
|
import { GlassIconButton } from '@/components/ui/glass-icon-button';
|
||||||
import { SaveEditUseCase } from "@/app/service/usecase/SaveEditUseCase";
|
import { SaveEditUseCase } from "@/app/service/usecase/SaveEditUseCase";
|
||||||
import { useSearchParams } from "next/navigation";
|
import { useSearchParams } from "next/navigation";
|
||||||
|
import SmartChatBox from "@/components/SmartChatBox/SmartChatBox";
|
||||||
|
import { Drawer } from 'antd';
|
||||||
|
|
||||||
const WorkFlow = React.memo(function WorkFlow() {
|
const WorkFlow = React.memo(function WorkFlow() {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -24,10 +25,13 @@ const WorkFlow = React.memo(function WorkFlow() {
|
|||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const [isEditModalOpen, setIsEditModalOpen] = React.useState(false);
|
const [isEditModalOpen, setIsEditModalOpen] = React.useState(false);
|
||||||
const [activeEditTab, setActiveEditTab] = React.useState('1');
|
const [activeEditTab, setActiveEditTab] = React.useState('1');
|
||||||
|
const [isSmartChatBoxOpen, setIsSmartChatBoxOpen] = React.useState(true);
|
||||||
|
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const episodeId = searchParams.get('episodeId') || '';
|
const episodeId = searchParams.get('episodeId') || '';
|
||||||
|
|
||||||
|
const userId = JSON.parse(localStorage.getItem("currentUser") || '{}').id || NaN;
|
||||||
|
|
||||||
SaveEditUseCase.setProjectId(episodeId);
|
SaveEditUseCase.setProjectId(episodeId);
|
||||||
// 使用自定义 hooks 管理状态
|
// 使用自定义 hooks 管理状态
|
||||||
const {
|
const {
|
||||||
@ -71,14 +75,6 @@ const WorkFlow = React.memo(function WorkFlow() {
|
|||||||
setIsEditModalOpen(true);
|
setIsEditModalOpen(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleSuggestionClick = useCallback((suggestion: string) => {
|
|
||||||
console.log('Selected suggestion:', suggestion);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleSubmit = useCallback((text: string) => {
|
|
||||||
console.log('Submitted text:', text);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<div className="w-full overflow-hidden h-[calc(100vh-6rem)] absolute top-[4rem] left-0 right-0 px-[1rem]">
|
<div className="w-full overflow-hidden h-[calc(100vh-6rem)] absolute top-[4rem] left-0 right-0 px-[1rem]">
|
||||||
@ -157,7 +153,7 @@ const WorkFlow = React.memo(function WorkFlow() {
|
|||||||
{taskObject.currentStage !== 'final_video' && taskObject.currentStage !== 'script' && (
|
{taskObject.currentStage !== 'final_video' && taskObject.currentStage !== 'script' && (
|
||||||
<div className="h-[112px] w-[calc((100vh-6rem-200px)/9*16)]">
|
<div className="h-[112px] w-[calc((100vh-6rem-200px)/9*16)]">
|
||||||
<ThumbnailGrid
|
<ThumbnailGrid
|
||||||
isDisabledFocus={isEditModalOpen || isPauseWorkFlow}
|
isDisabledFocus={isEditModalOpen || isPauseWorkFlow || isSmartChatBoxOpen}
|
||||||
taskObject={taskObject}
|
taskObject={taskObject}
|
||||||
currentSketchIndex={currentSketchIndex}
|
currentSketchIndex={currentSketchIndex}
|
||||||
onSketchSelect={setCurrentSketchIndex}
|
onSketchSelect={setCurrentSketchIndex}
|
||||||
@ -175,14 +171,14 @@ const WorkFlow = React.memo(function WorkFlow() {
|
|||||||
<div className="absolute right-12 bottom-16 z-[49] flex gap-4">
|
<div className="absolute right-12 bottom-16 z-[49] flex gap-4">
|
||||||
<GlassIconButton
|
<GlassIconButton
|
||||||
icon={isPauseWorkFlow ? Play : Pause}
|
icon={isPauseWorkFlow ? Play : Pause}
|
||||||
size='lg'
|
size='md'
|
||||||
tooltip={isPauseWorkFlow ? "Play" : "Pause"}
|
tooltip={isPauseWorkFlow ? "Play" : "Pause"}
|
||||||
onClick={() => setIsPauseWorkFlow(!isPauseWorkFlow)}
|
onClick={() => setIsPauseWorkFlow(!isPauseWorkFlow)}
|
||||||
/>
|
/>
|
||||||
{ !mode.includes('auto') && (
|
{ !mode.includes('auto') && (
|
||||||
<GlassIconButton
|
<GlassIconButton
|
||||||
icon={ChevronLast}
|
icon={ChevronLast}
|
||||||
size='lg'
|
size='md'
|
||||||
tooltip="Next"
|
tooltip="Next"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@ -190,6 +186,50 @@ const WorkFlow = React.memo(function WorkFlow() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
{/* 智能对话按钮 */}
|
||||||
|
<div className="absolute right-12 bottom-32 z-[49] flex gap-4">
|
||||||
|
<GlassIconButton
|
||||||
|
icon={MessageSquareText}
|
||||||
|
size='md'
|
||||||
|
tooltip={"Chat"}
|
||||||
|
onClick={() => setIsSmartChatBoxOpen(true)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 智能对话弹窗 */}
|
||||||
|
<Drawer
|
||||||
|
width="35%"
|
||||||
|
placement="right"
|
||||||
|
closable={false}
|
||||||
|
maskClosable={false}
|
||||||
|
open={isSmartChatBoxOpen}
|
||||||
|
getContainer={false}
|
||||||
|
autoFocus={false}
|
||||||
|
mask={false}
|
||||||
|
zIndex={49}
|
||||||
|
className="backdrop-blur-md bg-white/10 border border-white/20 shadow-xl"
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
borderBottomLeftRadius: 10,
|
||||||
|
borderTopLeftRadius: 10,
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
styles={{
|
||||||
|
body: {
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
padding: 0,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
onClose={() => setIsSmartChatBoxOpen(false)}
|
||||||
|
>
|
||||||
|
<SmartChatBox
|
||||||
|
isSmartChatBoxOpen={isSmartChatBoxOpen}
|
||||||
|
setIsSmartChatBoxOpen={setIsSmartChatBoxOpen}
|
||||||
|
projectId={episodeId}
|
||||||
|
userId={userId}
|
||||||
|
/>
|
||||||
|
</Drawer>
|
||||||
|
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<EditModal
|
<EditModal
|
||||||
isOpen={isEditModalOpen}
|
isOpen={isEditModalOpen}
|
||||||
|
|||||||
@ -112,11 +112,11 @@ export function ThumbnailGrid({
|
|||||||
// 组件挂载时自动聚焦
|
// 组件挂载时自动聚焦
|
||||||
if (thumbnailsRef.current && !isDisabledFocus) {
|
if (thumbnailsRef.current && !isDisabledFocus) {
|
||||||
thumbnailsRef.current.focus();
|
thumbnailsRef.current.focus();
|
||||||
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
}
|
}
|
||||||
|
|
||||||
window.addEventListener('keydown', handleKeyDown);
|
|
||||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||||
}, [handleKeyDown]);
|
}, [handleKeyDown, isDisabledFocus]);
|
||||||
|
|
||||||
// 确保在数据变化时保持焦点
|
// 确保在数据变化时保持焦点
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
const BASE_URL = 'https://77.smartvideo.py.qikongjian.com'
|
const BASE_URL = 'https://77.smartvideo.py.qikongjian.com'
|
||||||
|
// const BASE_URL = 'http://192.168.120.36:8000'
|
||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
eslint: {
|
eslint: {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user