From 55b5e6e57046dd3c76086248e92164681edf544a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8C=97=E6=9E=B3?= <7854742+wang_rumeng@user.noreply.gitee.com> Date: Sat, 23 Aug 2025 19:11:09 +0800 Subject: [PATCH] =?UTF-8?q?chatbox=E5=BC=80=E5=8F=91=E4=B8=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/constants.ts | 1 + components/SmartChatBox/DateDivider.tsx | 39 ++ components/SmartChatBox/InputBar.tsx | 139 +++++ components/SmartChatBox/LoadMoreButton.tsx | 25 + components/SmartChatBox/MessageRenderer.tsx | 126 +++++ components/SmartChatBox/ProgressBar.tsx | 26 + components/SmartChatBox/SmartChatBox.tsx | 147 +++++ components/SmartChatBox/api.ts | 507 ++++++++++++++++++ components/SmartChatBox/types.ts | 132 +++++ components/SmartChatBox/useMessages.ts | 212 ++++++++ components/SmartChatBox/utils.ts | 17 + components/pages/work-flow.tsx | 66 ++- components/pages/work-flow/thumbnail-grid.tsx | 4 +- next.config.js | 1 + 14 files changed, 1427 insertions(+), 15 deletions(-) create mode 100644 components/SmartChatBox/DateDivider.tsx create mode 100644 components/SmartChatBox/InputBar.tsx create mode 100644 components/SmartChatBox/LoadMoreButton.tsx create mode 100644 components/SmartChatBox/MessageRenderer.tsx create mode 100644 components/SmartChatBox/ProgressBar.tsx create mode 100644 components/SmartChatBox/SmartChatBox.tsx create mode 100644 components/SmartChatBox/api.ts create mode 100644 components/SmartChatBox/types.ts create mode 100644 components/SmartChatBox/useMessages.ts create mode 100644 components/SmartChatBox/utils.ts diff --git a/api/constants.ts b/api/constants.ts index 53e1866..95cd888 100644 --- a/api/constants.ts +++ b/api/constants.ts @@ -1,2 +1,3 @@ export const BASE_URL = process.env.NEXT_PUBLIC_SMART_API +// export const BASE_URL ='http://192.168.120.36:8000' // diff --git a/components/SmartChatBox/DateDivider.tsx b/components/SmartChatBox/DateDivider.tsx new file mode 100644 index 0000000..d6d7897 --- /dev/null +++ b/components/SmartChatBox/DateDivider.tsx @@ -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 ( +
+
+
+ {formatDate(timestamp)} +
+
+
+ ); +} \ No newline at end of file diff --git a/components/SmartChatBox/InputBar.tsx b/components/SmartChatBox/InputBar.tsx new file mode 100644 index 0000000..40b358b --- /dev/null +++ b/components/SmartChatBox/InputBar.tsx @@ -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(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) => { + 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 ( +
+ {/* 图片预览 */} + {imageUrl && ( +
+
+ 预览图 + +
+
+ )} + + {/* 上传进度 */} + {isUploading && ( +
+
+
+
+
+ )} + +
+ {/* 图片上传 */} + + + {/* 文本输入 */} + setText(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + handleSend(); + } + }} + data-alt="text-input" + /> + + {/* 发送按钮 */} + +
+
+ ); +} \ No newline at end of file diff --git a/components/SmartChatBox/LoadMoreButton.tsx b/components/SmartChatBox/LoadMoreButton.tsx new file mode 100644 index 0000000..dfcf1ef --- /dev/null +++ b/components/SmartChatBox/LoadMoreButton.tsx @@ -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 ( + + ); +} \ No newline at end of file diff --git a/components/SmartChatBox/MessageRenderer.tsx b/components/SmartChatBox/MessageRenderer.tsx new file mode 100644 index 0000000..f04df17 --- /dev/null +++ b/components/SmartChatBox/MessageRenderer.tsx @@ -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 ? ( + + 系统流程 + + ) : msg.role === "assistant" ? ( + + 助手 + + ) : null; + + // 状态图标和颜色 + const statusIcon = useMemo(() => { + if (!isUser) return null; + + switch (msg.status) { + case 'pending': + return ; + case 'success': + return ; + case 'error': + return ; + default: + return null; + } + }, [isUser, msg.status]); + + // 消息类型标签 + const typeLabel = useMemo(() => { + if (!isUser) return null; + + const isTask = msg.chatType !== 'chat'; + if (isTask) { + return ( + + 任务 + + ); + } + return ( + + 聊天 + + ); + }, [isUser, msg.chatType]); + + return ( + +
+ {/* Header */} +
+ {badge} +
+ {typeLabel} + {hhmm(msg.createdAt)} + {statusIcon} +
+
+ + {/* Content blocks */} +
+ {msg.blocks.map((b, idx) => { + switch (b.type) { + case "text": + return ( +

+ {b.text} +

+ ); + case "image": + return ( +
+ {b.alt +
+ ); + case "video": + return ( +
+
+ ); + case "audio": + return ( +
+
+
+ ); +} \ No newline at end of file diff --git a/components/SmartChatBox/ProgressBar.tsx b/components/SmartChatBox/ProgressBar.tsx new file mode 100644 index 0000000..78b7e4b --- /dev/null +++ b/components/SmartChatBox/ProgressBar.tsx @@ -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 ( +
+ {label ?
{label}
: null} +
+ +
+
{pct}%
+
+ ); +} \ No newline at end of file diff --git a/components/SmartChatBox/SmartChatBox.tsx b/components/SmartChatBox/SmartChatBox.tsx new file mode 100644 index 0000000..2bdfe31 --- /dev/null +++ b/components/SmartChatBox/SmartChatBox.tsx @@ -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 ( + + ); +} + +export default function SmartChatBox({ isSmartChatBoxOpen, setIsSmartChatBoxOpen, projectId, userId }: SmartChatBoxProps) { + // 消息列表引用 + const listRef = useRef(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 ( +
+ {/* Header */} +
+
+ Chat + {/* System push toggle */} + +
+
+ setIsSmartChatBoxOpen(false)} + /> +
+
+ + {/* Message list */} +
+ {/* Load more button */} + {hasMore && ( + + )} + + {/* Messages grouped by date */} +
+ {groupedMessages.map((group) => ( + + + {group.messages.map((message) => ( + + ))} + + ))} +
+ + {/* Loading indicator */} + {isLoading && !hasMore && ( +
+
+
+ )} + + {/* Error message */} + {error && ( +
+ {error.message} +
+ )} + + {/* Back to latest button */} + {isViewingHistory && ( + + )} +
+ + {/* Input */} + +
+ ); +} \ No newline at end of file diff --git a/components/SmartChatBox/api.ts b/components/SmartChatBox/api.ts new file mode 100644 index 0000000..35498f4 --- /dev/null +++ b/components/SmartChatBox/api.ts @@ -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>("/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 { + // 提取文本和图片 + 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>("/intelligent/chat", request); + } catch (error) { + console.error("发送消息失败:", error); + throw error; + } +} + +/** + * 重试发送消息 + */ +export async function retryMessage( + messageId: string, + config: ChatConfig +): Promise { + // TODO: 实现实际的重试逻辑,可能需要保存原始消息内容 + // 这里简单重用发送消息的接口 + return sendMessage([{ type: "text", text: "重试消息" }], config); +} \ No newline at end of file diff --git a/components/SmartChatBox/types.ts b/components/SmartChatBox/types.ts new file mode 100644 index 0000000..12b5547 --- /dev/null +++ b/components/SmartChatBox/types.ts @@ -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; + backToLatest: () => void; + isViewingHistory: boolean; +} + +export interface MessagesActions { + sendMessage: (blocks: MessageBlock[]) => Promise; + clearMessages: () => void; + retryMessage: (messageId: string) => Promise; +} + +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 { + 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; +} \ No newline at end of file diff --git a/components/SmartChatBox/useMessages.ts b/components/SmartChatBox/useMessages.ts new file mode 100644 index 0000000..e0068f3 --- /dev/null +++ b/components/SmartChatBox/useMessages.ts @@ -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([]); + const [latestMessages, setLatestMessages] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(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(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 + } + ]; +} \ No newline at end of file diff --git a/components/SmartChatBox/utils.ts b/components/SmartChatBox/utils.ts new file mode 100644 index 0000000..fa55e18 --- /dev/null +++ b/components/SmartChatBox/utils.ts @@ -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")}`; +} \ No newline at end of file diff --git a/components/pages/work-flow.tsx b/components/pages/work-flow.tsx index 5b8c011..cbc6d3f 100644 --- a/components/pages/work-flow.tsx +++ b/components/pages/work-flow.tsx @@ -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(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 (
@@ -157,7 +153,7 @@ const WorkFlow = React.memo(function WorkFlow() { {taskObject.currentStage !== 'final_video' && taskObject.currentStage !== 'script' && (
setIsPauseWorkFlow(!isPauseWorkFlow)} /> { !mode.includes('auto') && ( )} @@ -190,6 +186,50 @@ const WorkFlow = React.memo(function WorkFlow() { ) } + {/* 智能对话按钮 */} +
+ setIsSmartChatBoxOpen(true)} + /> +
+ + {/* 智能对话弹窗 */} + setIsSmartChatBoxOpen(false)} + > + + + window.removeEventListener('keydown', handleKeyDown); - }, [handleKeyDown]); + }, [handleKeyDown, isDisabledFocus]); // 确保在数据变化时保持焦点 useEffect(() => { diff --git a/next.config.js b/next.config.js index 45a8971..4fe354f 100644 --- a/next.config.js +++ b/next.config.js @@ -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: {