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 ='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 "./style/work-flow.css";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { AISuggestionBar } from "@/components/ai-suggestion-bar";
|
||||
import { EditModal } from "@/components/ui/edit-modal";
|
||||
import { ErrorBoundary } from "@/components/ui/error-boundary";
|
||||
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 { useWorkflowData } from "./work-flow/use-workflow-data";
|
||||
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 { GlassIconButton } from '@/components/ui/glass-icon-button';
|
||||
import { SaveEditUseCase } from "@/app/service/usecase/SaveEditUseCase";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import SmartChatBox from "@/components/SmartChatBox/SmartChatBox";
|
||||
import { Drawer } from 'antd';
|
||||
|
||||
const WorkFlow = React.memo(function WorkFlow() {
|
||||
useEffect(() => {
|
||||
@ -24,10 +25,13 @@ const WorkFlow = React.memo(function WorkFlow() {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [isEditModalOpen, setIsEditModalOpen] = React.useState(false);
|
||||
const [activeEditTab, setActiveEditTab] = React.useState('1');
|
||||
const [isSmartChatBoxOpen, setIsSmartChatBoxOpen] = React.useState(true);
|
||||
|
||||
const searchParams = useSearchParams();
|
||||
const episodeId = searchParams.get('episodeId') || '';
|
||||
|
||||
const userId = JSON.parse(localStorage.getItem("currentUser") || '{}').id || NaN;
|
||||
|
||||
SaveEditUseCase.setProjectId(episodeId);
|
||||
// 使用自定义 hooks 管理状态
|
||||
const {
|
||||
@ -71,14 +75,6 @@ const WorkFlow = React.memo(function WorkFlow() {
|
||||
setIsEditModalOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleSuggestionClick = useCallback((suggestion: string) => {
|
||||
console.log('Selected suggestion:', suggestion);
|
||||
}, []);
|
||||
|
||||
const handleSubmit = useCallback((text: string) => {
|
||||
console.log('Submitted text:', text);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<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' && (
|
||||
<div className="h-[112px] w-[calc((100vh-6rem-200px)/9*16)]">
|
||||
<ThumbnailGrid
|
||||
isDisabledFocus={isEditModalOpen || isPauseWorkFlow}
|
||||
isDisabledFocus={isEditModalOpen || isPauseWorkFlow || isSmartChatBoxOpen}
|
||||
taskObject={taskObject}
|
||||
currentSketchIndex={currentSketchIndex}
|
||||
onSketchSelect={setCurrentSketchIndex}
|
||||
@ -175,14 +171,14 @@ const WorkFlow = React.memo(function WorkFlow() {
|
||||
<div className="absolute right-12 bottom-16 z-[49] flex gap-4">
|
||||
<GlassIconButton
|
||||
icon={isPauseWorkFlow ? Play : Pause}
|
||||
size='lg'
|
||||
size='md'
|
||||
tooltip={isPauseWorkFlow ? "Play" : "Pause"}
|
||||
onClick={() => setIsPauseWorkFlow(!isPauseWorkFlow)}
|
||||
/>
|
||||
{ !mode.includes('auto') && (
|
||||
<GlassIconButton
|
||||
icon={ChevronLast}
|
||||
size='lg'
|
||||
size='md'
|
||||
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>
|
||||
<EditModal
|
||||
isOpen={isEditModalOpen}
|
||||
|
||||
@ -112,11 +112,11 @@ export function ThumbnailGrid({
|
||||
// 组件挂载时自动聚焦
|
||||
if (thumbnailsRef.current && !isDisabledFocus) {
|
||||
thumbnailsRef.current.focus();
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [handleKeyDown]);
|
||||
}, [handleKeyDown, isDisabledFocus]);
|
||||
|
||||
// 确保在数据变化时保持焦点
|
||||
useEffect(() => {
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
const BASE_URL = 'https://77.smartvideo.py.qikongjian.com'
|
||||
// const BASE_URL = 'http://192.168.120.36:8000'
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
eslint: {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user