chatbox开发中

This commit is contained in:
北枳 2025-08-23 19:11:09 +08:00
parent f41f3b35e4
commit 55b5e6e570
14 changed files with 1427 additions and 15 deletions

View File

@ -1,2 +1,3 @@
export const BASE_URL = process.env.NEXT_PUBLIC_SMART_API
// export const BASE_URL ='http://192.168.120.36:8000'
//

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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);
}

View 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;
}

View 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
}
];
}

View 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")}`;
}

View File

@ -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}

View File

@ -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(() => {

View File

@ -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: {