提交 dfc99e11 authored 作者: 王鹏飞's avatar 王鹏飞

feat: 新增 AI 图片和 AI 视频

上级 d32b7616
......@@ -22,3 +22,5 @@ dist-ssr
*.njsproj
*.sln
*.sw?
*.agents
*.claude
\ No newline at end of file
......@@ -10,7 +10,7 @@
"dependencies": {
"@ant-design/icons": "^5.4.0",
"@chuangkit/chuangkit-design": "^2.0.8",
"@ezijing/ai-react": "^1.0.37",
"@ezijing/ai-react": "^1.0.40",
"@fortaine/fetch-event-source": "^3.0.6",
"@reduxjs/toolkit": "^1.9.7",
"@wangeditor/editor": "^5.1.23",
......@@ -698,9 +698,9 @@
}
},
"node_modules/@ezijing/ai-core": {
"version": "1.0.37",
"resolved": "https://registry.npmjs.org/@ezijing/ai-core/-/ai-core-1.0.37.tgz",
"integrity": "sha512-LyT97TuYIZmgnRcHChJ06PND4d282hwSH5yCr/3DXPjcZPitogirvfC+I/5/dYsj2LpwpmEK65ips59KGtXt8A==",
"version": "1.0.40",
"resolved": "https://registry.npmjs.org/@ezijing/ai-core/-/ai-core-1.0.40.tgz",
"integrity": "sha512-IusFlzXsrpt/NDxUsXqOt3TNzKnlssgQs5xpx+KIc/UUzcKD32rt6bAEVTvkLr9NAUQF1PcRmqxKhicrVyMirw==",
"license": "MIT",
"dependencies": {
"axios": "^1.11.0",
......@@ -708,12 +708,12 @@
}
},
"node_modules/@ezijing/ai-react": {
"version": "1.0.37",
"resolved": "https://registry.npmjs.org/@ezijing/ai-react/-/ai-react-1.0.37.tgz",
"integrity": "sha512-u1Zywn2U2GHQMVTOqiTBcgib/dqqll41lmqsPAGL1CsRGH1FqfUac8IfZkmcFk9eDFhojf6F7QpSHd9/kNHMsg==",
"version": "1.0.40",
"resolved": "https://registry.npmjs.org/@ezijing/ai-react/-/ai-react-1.0.40.tgz",
"integrity": "sha512-IxpsKFG+rAdIx/0w1Wjx4tRKPgiZ7//7QS+vzvC4YIVfC5cR4I95fWavzGBujciJB39BoDfcq9JxLLuRbDNXPg==",
"license": "MIT",
"dependencies": {
"@ezijing/ai-core": "1.0.37"
"@ezijing/ai-core": "1.0.40"
},
"peerDependencies": {
"react": ">=16.8.0"
......
......@@ -13,7 +13,7 @@
"dependencies": {
"@ant-design/icons": "^5.4.0",
"@chuangkit/chuangkit-design": "^2.0.8",
"@ezijing/ai-react": "^1.0.37",
"@ezijing/ai-react": "^1.0.40",
"@fortaine/fetch-event-source": "^3.0.6",
"@reduxjs/toolkit": "^1.9.7",
"@wangeditor/editor": "^5.1.23",
......
{
"version": 1,
"skills": {
"vercel-react-best-practices": {
"source": "vercel-labs/agent-skills",
"sourceType": "github",
"computedHash": "3462ec83f862abb2d532953df33a4dbf87f4616849da5d4b5cc7c13601aaf997"
}
}
}
......@@ -57,6 +57,7 @@ import AITranslate from './menu/AITranslate'
import AISearch from './menu/AISearch'
import AIWrite from './menu/AIWrite'
import AIImage from './menu/AIImage'
import AIVideo from './menu/AIVideo'
import AIBaiduSearch from './menu/AIBaiduSearch'
import ImageModal from './components/image'
......@@ -128,6 +129,7 @@ const module = {
AISearch,
AIWrite,
AIImage,
AIVideo,
AIBaiduSearch,
],
}
......@@ -347,12 +349,13 @@ const WangEditorCustomer = (props, ref) => {
'justifyJustify',
'divider',
'|',
// 'AIImage',
'ImageAuto',
'AIImage',
// 'ImageAutoOnline',
'GalleryAuto',
// 'GalleryAutoOnline',
'VideoAuto',
'AIVideo',
'AudioAuto',
'insertTable',
'|',
......
......@@ -6,7 +6,7 @@ class AIImage extends BaseModalMenu {
super()
this.title = 'AI图片'
this.iconSvg = `<svg style="fill:none" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-pen"><path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z"/></svg>`
this.iconSvg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M20.7134 8.12811L20.4668 8.69379C20.2864 9.10792 19.7136 9.10792 19.5331 8.69379L19.2866 8.12811C18.8471 7.11947 18.0555 6.31641 17.0677 5.87708L16.308 5.53922C15.8973 5.35653 15.8973 4.75881 16.308 4.57612L17.0252 4.25714C18.0384 3.80651 18.8442 2.97373 19.2761 1.93083L19.5293 1.31953C19.7058 0.893489 20.2942 0.893489 20.4706 1.31953L20.7238 1.93083C21.1558 2.97373 21.9616 3.80651 22.9748 4.25714L23.6919 4.57612C24.1027 4.75881 24.1027 5.35653 23.6919 5.53922L22.9323 5.87708C21.9445 6.31641 21.1529 7.11947 20.7134 8.12811ZM2.9918 3H14V5H4V19L14 9L20 15V11H22V20.0066C22 20.5552 21.5447 21 21.0082 21H2.9918C2.44405 21 2 20.5551 2 20.0066V3.9934C2 3.44476 2.45531 3 2.9918 3ZM20 17.8284L14 11.8284L6.82843 19H20V17.8284ZM8 11C6.89543 11 6 10.1046 6 9C6 7.89543 6.89543 7 8 7C9.10457 7 10 7.89543 10 9C10 10.1046 9.10457 11 8 11Z"></path></svg>`
}
getValue(editor) {
return <AIImageModal key={Date.now()} editor={editor}></AIImageModal>
......
import BaseModalMenu from './common/BaseModalMenu'
import AIVideoModal from './common/AIVideoModal'
class AIVideo extends BaseModalMenu {
constructor() {
super()
this.title = 'AI视频'
this.iconSvg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M19.7134 8.12811L19.4668 8.69379C19.2864 9.10792 18.7136 9.10792 18.5331 8.69379L18.2866 8.12811C17.8471 7.11947 17.0555 6.31641 16.0677 5.87708L15.308 5.53922C14.8973 5.35653 14.8973 4.75881 15.308 4.57612L16.0252 4.25714C17.0384 3.80651 17.8442 2.97373 18.2761 1.93083L18.5293 1.31953C18.7058 0.893489 19.2942 0.893489 19.4706 1.31953L19.7238 1.93083C20.1558 2.97373 20.9616 3.80651 21.9748 4.25714L22.6919 4.57612C23.1027 4.75881 23.1027 5.35653 22.6919 5.53922L21.9323 5.87708C20.9445 6.31641 20.1529 7.11947 19.7134 8.12811ZM3.9934 3H13V5H5V19H19V11H21V20.0066C21 20.5552 20.5551 21 20.0066 21H3.9934C3.44476 21 3 20.5551 3 20.0066V3.9934C3 3.44476 3.44495 3 3.9934 3ZM10.6219 8.41459L15.5008 11.6672C15.6846 11.7897 15.7343 12.0381 15.6117 12.2219C15.5824 12.2658 15.5447 12.3035 15.5008 12.3328L10.6219 15.5854C10.4381 15.708 10.1897 15.6583 10.0672 15.4745C10.0234 15.4088 10 15.3316 10 15.2526V8.74741C10 8.52649 10.1791 8.34741 10.4 8.34741C10.479 8.34741 10.5562 8.37078 10.6219 8.41459Z"></path></svg>`
}
getValue(editor) {
return <AIVideoModal key={Date.now()} editor={editor}></AIVideoModal>
}
}
export default {
key: 'AIVideo',
factory() {
return new AIVideo()
},
}
import { useState, useEffect, useRef } from 'react'
import {
SendOutlined,
UndoOutlined,
CopyOutlined,
FileTextOutlined,
FileWordOutlined,
DownloadOutlined,
PlusCircleOutlined,
DeleteOutlined,
} from '@ant-design/icons'
import { ConfigProvider, Modal, Input, Button, Dropdown } from 'antd'
import { useState } from 'react'
import { SendOutlined, DownloadOutlined, CheckOutlined, PictureOutlined } from '@ant-design/icons'
import { saveAs } from 'file-saver'
import { ConfigProvider, Modal, Input, Button, Image, message, Row, Col, Card, Spin, Radio } from 'antd'
import { SlateTransforms, SlateEditor, SlateElement } from '@wangeditor/editor'
const { TextArea } = Input
import './AISearchModal.less'
import { usePaper } from '@/hooks/useWenku'
import { useCopyToClipboard } from 'react-use'
import { CircleEllipsis } from 'lucide-react'
import './AIImageModal.less'
import { useImage } from '@ezijing/ai-react'
import { uploadFileByUrl } from '@/utils/oss'
export default function AIModal() {
export default function AIImageModal(props) {
const { editor } = props
const [isModalOpen, setIsModalOpen] = useState(true)
const [content, setContent] = useState('')
const [textIndent, setTextIndent] = useState(0)
const prePromptRef = useRef(null)
const messageScrollRef = useRef(null)
const {
messages,
setMessages,
isLoading,
generateOutline,
generatePaper,
chapterTagRender,
getChaptersMarkdown,
addChapter,
updateChapter,
removeChapter,
} = usePaper()
useEffect(() => {
if (prePromptRef.current) {
const width = prePromptRef.current.offsetWidth + 10
setTextIndent(width)
}
}, [isModalOpen])
const [isEditMessage, setIsEditMessage] = useState(false)
useEffect(() => {
if (messageScrollRef.current && !isEditMessage) {
const scrollContainer = messageScrollRef.current
scrollContainer.scrollTop = scrollContainer.scrollHeight
}
}, [messages, isEditMessage])
const prePrompt = '帮我生成一篇课题报告,主题是:'
const [prompt, setPrompt] = useState('')
const [size, setSize] = useState('2048x2048')
const [style, setStyle] = useState('default')
const [generatedImages, setGeneratedImages] = useState([])
// 使用 @ezijing/ai-react 的 useImage 钩子
const { isLoading, error, generateImage } = useImage({
provider: 'volcano',
onSuccess: (response) => {
console.log('图片生成成功:', response)
if (response.urls && response.urls.length > 0) {
// 每次生成一张图片,添加到列表最前面,最多保留12张
setGeneratedImages((prev) => {
const newImages = [...response.urls, ...prev]
return newImages.slice(0, 12)
})
message.success('图片生成成功!')
}
},
onError: (error) => {
console.error('图片生成失败:', error)
message.error(`图片生成失败: ${error}`)
},
})
// 图片比例选项
const sizeOptions = [
{ label: '1:1', value: '2048x2048' },
{ label: '3:4', value: '1728x2304' },
{ label: '4:3', value: '2304x1728' },
{ label: '16:9', value: '2848x1600' },
{ label: '9:16', value: '1600x2848' },
{ label: '3:2', value: '2496x1664' },
{ label: '2:3', value: '1664x2496' },
{ label: '21:9', value: '3136x1344' },
]
// 风格选项
const styleOptions = [
{ label: '默认', value: 'default' },
{ label: '写实', value: 'realistic' },
{ label: '动漫', value: 'anime' },
{ label: '油画', value: 'oil_painting' },
{ label: '水彩', value: 'watercolor' },
{ label: '素描', value: 'sketch' },
]
const handleEnterSearch = (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
!isLoading && handleSearch()
if (!isLoading) {
handleGenerateImage()
}
}
const handleSearch = () => {
generateOutline(prePrompt + content)
setContent('')
setIsEditMessage(false)
}
const [, copyToClipboard] = useCopyToClipboard()
const handleGenerateImage = async () => {
if (!prompt.trim()) {
message.error('请输入图片描述!')
return
}
// 复制内容
const handleCopy = (msg) => {
copyToClipboard(getChaptersMarkdown(msg.chapters))
// 将风格信息添加到提示词中
let promptWithStyle = prompt
if (style !== 'default') {
const styleMap = {
realistic: '写实风格',
anime: '动漫风格',
oil_painting: '油画风格',
watercolor: '水彩风格',
sketch: '素描风格',
}
const styleText = styleMap[style] || style
promptWithStyle = `${styleText}${prompt}`
}
// 生成论文
const handleGeneratePaper = async (msg) => {
setMessages((prevMessages) => {
return [...prevMessages, { content: '正在生成长文...', role: 'ai', tips: '预计10分钟', queryID: msg.queryID }]
try {
await generateImage({
prompt: promptWithStyle,
size,
// 接口只支持生成一张图片,没有n参数
})
const paper = await generatePaper({
userQuery: msg.userQuery,
queryID: msg.queryID,
outline: getChaptersMarkdown(msg.chapters),
})
setMessages((prevMessages) => {
prevMessages.pop()
return [...prevMessages, { content: '已为您生成初稿,请点击下载', role: 'ai', queryID: msg.queryID, paper }]
} catch (error) {
console.error('生成图片失败:', error)
message.error('生成图片失败,请稍后重试')
}
}
// 移除空段落
const removeEmptyParagraph = (editor) => {
const nodeEntries = SlateEditor.nodes(editor, {
match: (node) => {
if (SlateElement.isElement(node)) {
if (node.type === 'paragraph') {
return true
}
}
return false
},
universal: true,
})
for (let nodeEntry of nodeEntries) {
const [node] = nodeEntry
if (node.children && node.children.length === 1 && node.children[0].text === '') {
SlateTransforms.removeNodes(editor)
}
let chapterMenuItems = [
{ key: 'add', label: '新增', icon: <PlusCircleOutlined style={{ fontSize: 14 }} /> },
{ key: 'remove', label: '删除', icon: <DeleteOutlined style={{ fontSize: 14 }} /> },
]
}
}
// 上传图片到OSS并插入编辑器 (标准图片)
const handleInsertImage = async (imageUrl) => {
try {
message.loading('正在上传图片...', 0)
const ossUrl = await uploadFileByUrl(imageUrl)
message.destroy()
if (editor) {
editor.restoreSelection()
removeEmptyParagraph(editor)
const handleMenuClick = async (e, chapter, message) => {
setIsEditMessage(true)
if (e.key === 'add') addChapter(chapter, message)
if (e.key === 'remove') removeChapter(chapter, message)
const oImg = {
type: 'paragraph',
textAlign: 'center',
children: [
{
type: 'image',
src: ossUrl,
alt: prompt || 'AI生成的图片',
href: '',
children: [{ text: '' }],
},
],
}
const MessageRender = ({ msg }) => {
if (msg.role === 'ai') {
if (msg.chapters && msg.chapters.length) {
return (
<div className={`message-item ${msg.role}`}>
<div className="message-box">
<div className="message-content">
{msg.chapters.map((item) => {
return (
<div className="chapter-item" key={item.id}>
<div className="chapter-left">
<div className="chapter-left-title">{chapterTagRender(item, msg.chapters)}</div>
<div className="line-dot">
<div className="dot"></div>
<div className="line"></div>
</div>
</div>
<div className="chapter-right">
<div className="chapter-right-content">
<Input
className="chapter-title"
placeholder="请输入标题"
defaultValue={item.title}
variant="borderless"
onBlur={(e) => updateChapter(item, msg, { ...item, title: e.target.value })}
/>
{item.level !== 1 && (
<Input
className="chapter-desc"
placeholder="请输入你的备注,如这个章节必须包含正反观点等。"
defaultValue={item.desc}
variant="borderless"
onBlur={(e) => updateChapter(item, msg, { ...item, desc: e.target.value })}
const oP = {
type: 'paragraph',
textAlign: 'center',
fontSize: '14px',
children: [{ text: prompt || 'AI生成的图片' }],
}
SlateTransforms.insertNodes(editor, [oImg, oP])
message.success('图片已插入编辑器!')
setIsModalOpen(false)
} else {
message.error('编辑器未找到')
}
} catch (error) {
message.destroy()
console.error('上传图片失败:', error)
message.error('上传图片失败,请稍后重试')
}
}
// 下载图片
const handleDownloadImage = (imageUrl) => {
saveAs(imageUrl, `ai_image_${Date.now()}.jpg`)
}
const GeneratedImageCard = ({ imageUrl, index }) => (
<Card
hoverable
className="generated-image-card"
cover={
<div className="image-container">
<Image
src={imageUrl}
alt={`AI生成图片 ${index + 1}`}
style={{ width: '100%', height: '200px', objectFit: 'cover' }}
preview={{ src: imageUrl }}
/>
)}
{/* <h3>{item.title}</h3> */}
{/* <p>{item.desc}</p> */}
</div>
<div className="chapter-right-tools">
{item.level !== 1 && (
<Dropdown
menu={{ items: chapterMenuItems, onClick: (e) => handleMenuClick(e, item, msg) }}
getPopupContainer={(triggerNode) => triggerNode.parentNode}>
<Button type="text" icon={<CircleEllipsis color="#222" size={20} />}></Button>
</Dropdown>
)}
</div>
</div>
</div>
)
})}
</div>
<div className="message-tools">
<Button
type="text"
size="small"
icon={<UndoOutlined />}
disabled={isLoading}
onClick={() => generateOutline(msg.userQuery)}>
换个大纲
</Button>
<Button
type="text"
size="small"
icon={<FileTextOutlined />}
disabled={isLoading}
onClick={() => handleGeneratePaper(msg)}>
生成长文
}
size="small">
<div className="image-actions">
<Button size="small" icon={<DownloadOutlined />} onClick={() => handleDownloadImage(imageUrl)}>
下载
</Button>
<Button type="text" size="small" icon={<CopyOutlined />} onClick={() => handleCopy(msg)}>
复制内容
<Button type="primary" size="small" icon={<CheckOutlined />} onClick={() => handleInsertImage(imageUrl)}>
插入
</Button>
</div>
</div>
</div>
</Card>
)
} else {
return (
<div className={`message-item ${msg.role}`}>
<div className="message-box">
<div className="message-content">
{msg.content}
{msg.paper?.downloadLink && (
<div className="message-file">
<div className="message-file-content">
<a href={msg.paper.downloadLink} target="_blank" rel="noreferrer">
<FileWordOutlined />
商业数据分析研究
</a>
</div>
<div className="message-file-tools">
<a href={msg.paper.downloadLink} target="_blank" rel="noreferrer">
<DownloadOutlined />
</a>
</div>
</div>
)}
</div>
</div>
</div>
)
}
} else {
return (
<div className={`message-item ${msg.role}`}>
<div className="message-box">
<div className="message-content">{msg.content}</div>
</div>
</div>
)
}
}
return (
<ConfigProvider theme={{ components: { Modal: { headerBg: '#f7f8fa', contentBg: '#f7f8fa' } } }}>
<Modal title="AI图片" open={isModalOpen} footer={null} onCancel={() => setIsModalOpen(false)} width={1000}>
<div className="message-scroll" ref={messageScrollRef}>
{messages.map((msg) => {
return <MessageRender msg={msg} key={msg.id}></MessageRender>
})}
</div>
<Modal title="AI图片生成" open={isModalOpen} footer={null} onCancel={() => setIsModalOpen(false)} width={1200}>
<div className="ai-image-generator">
{/* 输入区域 */}
<div className="input-container">
<div className="input-box">
<div className="edit-area">
{prePrompt && (
<span className="pre-prompt" ref={prePromptRef}>
{prePrompt}
</span>
)}
<TextArea
className="content"
autoSize
value={content}
placeholder="今天需要我做些什么?shift+enter换行"
onChange={(e) => setContent(e.target.value)}
autoSize={{ minRows: 3, maxRows: 6 }}
value={prompt}
placeholder="描述你想要生成的图片,例如:一只可爱的小猫在花园里玩耍,阳光明媚,风格温馨"
onChange={(e) => setPrompt(e.target.value)}
onKeyDown={handleEnterSearch}
style={{ textIndent }}
/>
</div>
<div className="input-tools">
{/* <Button type="text" icon={<UploadOutlined />} /> */}
<Button type="primary" size="large" icon={<SendOutlined />} onClick={handleSearch} loading={isLoading} />
<Button type="primary" icon={<SendOutlined />} onClick={handleGenerateImage} loading={isLoading}>
生成图片
</Button>
</div>
</div>
{/* 选项区域 */}
<div className="options-container" style={{ marginTop: 16 }}>
<Row gutter={[16, 16]}>
<Col span={12}>
<div className="option-group">
<div className="option-label">图片比例:</div>
<Radio.Group
options={sizeOptions}
onChange={(e) => setSize(e.target.value)}
value={size}
optionType="button"
buttonStyle="solid"
/>
</div>
</Col>
<Col span={12}>
<div className="option-group">
<div className="option-label">图片风格:</div>
<Radio.Group
options={styleOptions}
onChange={(e) => setStyle(e.target.value)}
value={style}
optionType="button"
buttonStyle="solid"
/>
</div>
</Col>
</Row>
</div>
</div>
<div className="upload-list"></div>
{/* 错误提示 */}
{error && (
<div className="error-message" style={{ marginTop: 16, color: '#ff4d4f' }}>
错误: {error}
</div>
)}
{/* 生成结果区域 */}
<div className="results-container" style={{ marginTop: 24 }}>
<div className="results-title">生成结果</div>
{isLoading ? (
<div className="loading-container">
<Spin tip="AI正在生成图片,请稍候..." size="large">
<div style={{ height: '200px' }} />
</Spin>
</div>
) : generatedImages.length > 0 ? (
<Row gutter={[16, 16]}>
{generatedImages.map((imageUrl, index) => (
<Col span={6} key={`${imageUrl}-${index}`}>
<GeneratedImageCard imageUrl={imageUrl} index={index} />
</Col>
))}
</Row>
) : (
<div className="empty-results">
<div style={{ textAlign: 'center', padding: '40px 0', color: '#999' }}>
<PictureOutlined style={{ fontSize: 48, marginBottom: 16 }} />
<div>输入描述并点击生成按钮,AI将为你创作图片</div>
</div>
</div>
)}
</div>
</div>
</Modal>
</ConfigProvider>
......
// AI图片生成器样式
.ai-image-generator {
.options-container {
margin-top: 16px;
padding: 16px;
background-color: #f8f9fa;
border-radius: 8px;
}
.option-group {
display: flex;
flex-direction: column;
gap: 8px;
.option-label {
font-size: 14px;
font-weight: 500;
color: #333;
margin-bottom: 8px;
}
}
.results-container {
margin-top: 24px;
.results-title {
font-size: 16px;
font-weight: 600;
color: #333;
margin-bottom: 16px;
}
.loading-container {
min-height: 200px;
}
.empty-results {
display: flex;
justify-content: center;
align-items: center;
min-height: 200px;
background-color: #f8f9fa;
border-radius: 8px;
border: 1px dashed #ddd;
}
}
.generated-image-card {
border: 1px solid #e8e8e8;
border-radius: 8px;
overflow: hidden;
transition: all 0.3s ease;
&:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
border-color: #1890ff;
}
.image-container {
width: 100%;
height: 200px;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
background-color: #f5f5f5;
}
.image-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 12px;
button {
flex: 1;
}
}
}
.error-message {
background-color: #fff2f0;
border: 1px solid #ffccc7;
border-radius: 6px;
padding: 12px;
margin-top: 16px;
color: #ff4d4f;
}
}
import { useRef, useState } from 'react'
import { SendOutlined, DownloadOutlined, CheckOutlined, VideoCameraOutlined } from '@ant-design/icons'
import { saveAs } from 'file-saver'
import { ConfigProvider, Modal, Input, Button, message, Row, Col, Card, Spin, Radio, InputNumber } from 'antd'
import { SlateTransforms, SlateEditor, SlateElement } from '@wangeditor/editor'
import { useVideo } from '@ezijing/ai-react'
import { uploadFileByUrl } from '@/utils/oss'
import './AISearchModal.less'
import './AIVideoModal.less'
const { TextArea } = Input
export default function AIVideoModal(props) {
const { editor } = props
const [isModalOpen, setIsModalOpen] = useState(true)
const [prompt, setPrompt] = useState('')
const [ratio, setRatio] = useState('16:9')
const [duration, setDuration] = useState(5)
const [generatedVideos, setGeneratedVideos] = useState([])
const lastGenerateParamsRef = useRef({ prompt: '', ratio: '16:9', duration: 5 })
const ratioOptions = [
{ label: '16:9', value: '16:9' },
{ label: '4:3', value: '4:3' },
{ label: '1:1', value: '1:1' },
{ label: '3:4', value: '3:4' },
{ label: '9:16', value: '9:16' },
{ label: '21:9', value: '21:9' },
]
const { isLoading, error, generateVideo } = useVideo({
provider: 'volcano',
onSuccess: (response) => {
console.log('视频生成成功:', response)
if (response.url) {
const { prompt: usedPrompt, ratio: usedRatio, duration: usedDuration } = lastGenerateParamsRef.current
setGeneratedVideos((prev) => {
const nextVideos = [
{
id: response.id || response.url,
url: response.url,
prompt: usedPrompt,
ratio: usedRatio,
duration: usedDuration,
},
...prev,
]
return nextVideos.slice(0, 8)
})
message.success('视频生成成功!')
}
},
onError: (videoError) => {
console.error('视频生成失败:', videoError)
message.error(`视频生成失败: ${videoError}`)
},
})
const handleEnterGenerate = (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
if (!isLoading) {
handleGenerateVideo()
}
}
}
const handleGenerateVideo = async () => {
if (!prompt.trim()) {
message.error('请输入视频描述!')
return
}
const normalizedDuration = Math.min(12, Math.max(2, Math.round(Number(duration) || 5)))
const normalizedPrompt = prompt.trim()
setDuration(normalizedDuration)
lastGenerateParamsRef.current = {
prompt: normalizedPrompt,
ratio,
duration: normalizedDuration,
}
try {
await generateVideo({
prompt: normalizedPrompt,
ratio,
duration: normalizedDuration,
})
} catch (videoError) {
console.error('生成视频失败:', videoError)
message.error('生成视频失败,请稍后重试')
}
}
// 移除空段落
const removeEmptyParagraph = (editor) => {
const nodeEntries = SlateEditor.nodes(editor, {
match: (node) => {
if (SlateElement.isElement(node)) {
if (node.type === 'paragraph') {
return true
}
}
return false
},
universal: true,
})
for (let nodeEntry of nodeEntries) {
const [node] = nodeEntry
if (node.children && node.children.length === 1 && node.children[0].text === '') {
SlateTransforms.removeNodes(editor)
}
}
}
// 上传并插入视频 (标准视频)
const handleInsertVideo = async (videoUrl, videoPrompt) => {
try {
message.loading('正在上传视频...', 0)
const ossUrl = await uploadFileByUrl(videoUrl)
message.destroy()
if (editor) {
editor.restoreSelection()
removeEmptyParagraph(editor)
const nodes = [
{
type: 'video',
src: ossUrl,
children: [{ text: '' }],
},
{
type: 'paragraph',
textAlign: 'center',
fontSize: '14px',
children: [{ text: videoPrompt || 'AI生成的视频' }],
},
]
SlateTransforms.insertNodes(editor, nodes)
message.success('视频已插入编辑器!')
setIsModalOpen(false)
} else {
message.error('编辑器未找到')
}
} catch (uploadError) {
message.destroy()
console.error('上传视频失败:', uploadError)
message.error('上传视频失败,请稍后重试')
}
}
const handleDownloadVideo = (videoUrl) => {
saveAs(videoUrl, `ai_video_${Date.now()}.mp4`)
}
const GeneratedVideoCard = ({ videoItem, index }) => (
<Card
hoverable
className="generated-video-card"
cover={
<div className="video-container">
<video src={videoItem.url} controls preload="metadata" />
</div>
}
size="small">
<div className="video-meta">{videoItem.prompt || `AI生成视频 ${index + 1}`}</div>
<div className="video-meta">
比例: {videoItem.ratio || '16:9'} | 时长: {videoItem.duration || 5}s
</div>
<div className="video-actions">
<Button size="small" icon={<DownloadOutlined />} onClick={() => handleDownloadVideo(videoItem.url)}>
下载
</Button>
<Button
type="primary"
size="small"
icon={<CheckOutlined />}
onClick={() => handleInsertVideo(videoItem.url, videoItem.prompt)}>
插入
</Button>
</div>
</Card>
)
return (
<ConfigProvider theme={{ components: { Modal: { headerBg: '#f7f8fa', contentBg: '#f7f8fa' } } }}>
<Modal title="AI视频生成" open={isModalOpen} footer={null} onCancel={() => setIsModalOpen(false)} width={1200}>
<div className="ai-video-generator">
<div className="input-container">
<div className="input-box">
<div className="edit-area">
<TextArea
className="content"
autoSize={{ minRows: 3, maxRows: 6 }}
value={prompt}
placeholder="描述你想要生成的视频,例如:海边日落时海浪轻拍沙滩,镜头缓慢推进,氛围安静治愈"
onChange={(e) => setPrompt(e.target.value)}
onKeyDown={handleEnterGenerate}
/>
</div>
<div className="input-tools">
<Button type="primary" icon={<SendOutlined />} onClick={handleGenerateVideo} loading={isLoading}>
生成视频
</Button>
</div>
</div>
<div className="options-container" style={{ marginTop: 16 }}>
<Row gutter={[16, 16]}>
<Col span={16}>
<div className="option-group">
<div className="option-label">视频比例:</div>
<Radio.Group
options={ratioOptions}
onChange={(e) => setRatio(e.target.value)}
value={ratio}
optionType="button"
buttonStyle="solid"
/>
</div>
</Col>
<Col span={8}>
<div className="option-group">
<div className="option-label">视频时长(2-12秒):</div>
<InputNumber
min={2}
max={12}
step={1}
precision={0}
value={duration}
onChange={(value) => setDuration(Number.isFinite(value) ? Math.round(value) : 5)}
style={{ width: '100%' }}
/>
</div>
</Col>
</Row>
</div>
</div>
{error && (
<div className="error-message" style={{ marginTop: 16, color: '#ff4d4f' }}>
错误: {error}
</div>
)}
<div className="results-container" style={{ marginTop: 24 }}>
<div className="results-title">生成结果</div>
{isLoading ? (
<div className="loading-container">
<Spin tip="AI正在生成视频,请稍候..." size="large">
<div style={{ height: '220px' }} />
</Spin>
</div>
) : generatedVideos.length > 0 ? (
<Row gutter={[16, 16]}>
{generatedVideos.map((videoItem, index) => (
<Col span={8} key={videoItem.id || `${videoItem.url}-${index}`}>
<GeneratedVideoCard videoItem={videoItem} index={index} />
</Col>
))}
</Row>
) : (
<div className="empty-results">
<div style={{ textAlign: 'center', padding: '40px 0', color: '#999' }}>
<VideoCameraOutlined style={{ fontSize: 48, marginBottom: 16 }} />
<div>输入描述并点击生成按钮,AI将为你创作视频</div>
</div>
</div>
)}
</div>
</div>
</Modal>
</ConfigProvider>
)
}
.ai-video-generator {
.options-container {
padding: 16px;
background-color: #f8f9fa;
border-radius: 8px;
}
.option-group {
display: flex;
flex-direction: column;
gap: 8px;
.option-label {
font-size: 14px;
font-weight: 500;
color: #333;
margin-bottom: 8px;
}
}
.results-container {
margin-top: 24px;
.results-title {
font-size: 16px;
font-weight: 600;
color: #333;
margin-bottom: 16px;
}
.loading-container {
min-height: 220px;
.ant-spin {
width: 100%;
}
.ant-spin-text {
white-space: nowrap;
}
}
.empty-results {
display: flex;
justify-content: center;
align-items: center;
min-height: 220px;
background-color: #f8f9fa;
border-radius: 8px;
border: 1px dashed #ddd;
}
}
.generated-video-card {
border: 1px solid #e8e8e8;
border-radius: 8px;
overflow: hidden;
transition: all 0.3s ease;
&:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
border-color: #1890ff;
}
.video-container {
width: 100%;
height: 220px;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
background-color: #111;
video {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.video-meta {
padding: 12px 12px 0;
color: #666;
font-size: 12px;
min-height: 36px;
line-height: 18px;
word-break: break-all;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
}
.video-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 12px;
button {
flex: 1;
}
}
}
.error-message {
background-color: #fff2f0;
border: 1px solid #ffccc7;
border-radius: 6px;
padding: 12px;
margin-top: 16px;
color: #ff4d4f;
}
}
import { useState, useEffect, useCallback } from 'react'
import md5 from 'js-md5'
import axios from 'axios'
import { fetchEventSource } from '@fortaine/fetch-event-source'
export function useAI() {
const options = [
{ label: '文心一言', value: 'yiyan' },
{ label: 'DeepSeek', value: 'deepseek' },
{ label: '通义千问', value: 'qwen' },
// { label: '天工', value: 'tiangong' },
]
const [ai, setAI] = useState(localStorage.getItem('ai') || 'yiyan')
......@@ -34,9 +31,6 @@ export function useAI() {
case 'qwen':
await qwen(data)
break
case 'tiangong':
await tiangong(data)
break
default:
throw new Error('未找到对应的 AI 配置')
}
......@@ -46,7 +40,7 @@ export function useAI() {
setIsLoading(false)
}
},
[ai] // 依赖 `ai`,当 `ai` 变化时,重新创建 `post`
[ai],
)
async function yiyan(data) {
......@@ -54,7 +48,7 @@ export function useAI() {
const AK = 'wY7bvMpkWeZbDVq9w3EDvpjU'
const SK = 'XJwpiJWxs5HXkOtbo6tQrvYPZFJAWdAy'
const resp = await axios.post(
`/api/qianfan/oauth/2.0/token?grant_type=client_credentials&client_id=${AK}&client_secret=${SK}`
`/api/qianfan/oauth/2.0/token?grant_type=client_credentials&client_id=${AK}&client_secret=${SK}`,
)
return resp.data.access_token
}
......@@ -62,7 +56,7 @@ export function useAI() {
`/api/qianfan/rpc/2.0/ai_custom/v1/wenxinworkshop/chat/eb-instant?access_token=${await getAccessToken()}`,
{
messages: [{ role: 'user', content: data.content }],
}
},
)
setMessages((prevMessages) => [...prevMessages, { role: 'assistant', content: resp.data.result }])
}
......@@ -77,7 +71,7 @@ export function useAI() {
},
{
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
}
},
)
if (resp.data) {
const [choice = {}] = resp.data.choices
......@@ -95,7 +89,7 @@ export function useAI() {
},
{
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
}
},
)
if (resp.data) {
const [choice = {}] = resp.data.choices
......@@ -103,49 +97,9 @@ export function useAI() {
}
}
async function tiangong(data) {
const appKey = 'a8701b73637562d33a53c668a90ee3be'
const appSecret = 'e191593f486bb88a39c634f46926762dddc97b9082e192af'
const timestamp = Math.floor(Date.now() / 1000)
const sign = md5(`${appKey}${appSecret}${timestamp}`)
return await fetchEventSource('/api/tiangong/sky-saas-writing/api/v1/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json', app_key: appKey, sign, timestamp, stream: 'true' },
body: JSON.stringify({
chat_history: [{ role: 'user', content: data.content }],
stream_resp_type: 'update',
}),
async onopen(response) {
console.log(response)
if (response.ok) {
return response
} else {
throw response
}
},
onmessage(res) {
console.log(res.data)
const message = JSON.parse(res.data)
if (message.type !== 1) return
setMessages((prevMessages) => {
const messageId = message.conversation_id
const messageIndex = prevMessages.findIndex((message) => message.id === messageId)
const content = message?.arguments?.[0]?.messages?.[0]?.text || ''
if (messageIndex === -1) {
return [...prevMessages, { id: messageId, role: 'assistant', content }]
} else {
return prevMessages.map((msg) => (msg.id === messageId ? { ...msg, content } : msg))
}
})
setIsLoading(false)
},
onerror(err) {
setIsLoading(false)
throw err
},
})
}
return { ai, setAI, options, post, messages, isLoading }
}
export function useGenerateImage() {}
export function useGenerateVideo() {}
import { useEffect, useState, useCallback } from 'react'
import { fetchEventSource } from '@fortaine/fetch-event-source'
// 获取本地存储中的令牌
function getToken() {
return window.localStorage.getItem('kiwi.token') || ''
}
// 生成请求头部信息
const getHeaders = () => {
const token = getToken()
const appId = 'TzEU5jPk2tu80266'
const appSecret = '0a006048a4480481b18fef1405120b83'
const timestamp = Math.floor(Date.now() / 1000)
return {
Authorization: token,
AppId: appId,
AppSecret: appSecret,
Timestamp: timestamp,
'Content-Type': 'application/json'
}
}
// 默认的响应打开处理函数
const defaultOnOpen = async response => {
if (!response.ok) {
throw response
}
return response
}
// 默认的消息处理函数
const defaultOnMessage = setMessages => res => {
const message = JSON.parse(res.data)
setMessages(prevMessages => [...prevMessages, message])
}
// 自定义 Hook,用于使用 Fetch 事件源
export function useFetchEventSource(url, options = {}) {
const [isLoading, setLoading] = useState(false)
const [messages, setMessages] = useState([])
const [error, setError] = useState(null)
// 定义 fetch 函数
const fetch = useCallback(() => {
setLoading(true)
const headers = getHeaders()
const defaultOptions = {
method: 'POST',
headers,
onopen: defaultOnOpen,
onmessage: defaultOnMessage(setMessages),
onerror: err => setError(err)
}
fetchEventSource(url, { ...defaultOptions, ...options })
.catch(err => setError(err))
.finally(() => setLoading(false))
}, [url, options])
// 在组件挂载时执行 fetch 函数
useEffect(() => {
fetch()
}, [fetch])
return { isLoading, error, messages, fetch }
}
import md5 from 'js-md5'
import { fetchEventSource } from '@fortaine/fetch-event-source'
import { useState, useCallback } from 'react'
export function useAIChat() {
const authKey = 'f3846153ba784b6d86bdcd5533259c88'
const authSecret = 'HO4IyLEwEOHpeOXBxaLQUOqWslJRGs1M'
const [messages, setMessages] = useState([])
const [chatId, setChatId] = useState(null)
const [isLoading, setIsLoading] = useState(false)
const addMessage = useCallback((message) => {
setMessages((prevMessages) => [...prevMessages, message])
}, [])
const updateMessages = useCallback((newMessage) => {
setMessages((prevMessages) => {
const existingMessage = prevMessages.find((msg) => msg.conversationId === newMessage.conversationId)
const content = newMessage.content === '\n' ? '<br/>' : newMessage.content || ''
if (existingMessage) {
// 更新现有消息
return prevMessages.map((msg) =>
msg.conversationId === newMessage.conversationId ? { ...msg, content: msg.content + content } : msg
)
} else {
// 新增消息
return [...prevMessages, { ...newMessage, content, role_type: 'ai', time: Date.now() }]
}
})
}, [])
const post = useCallback(
async (data) => {
const timestamp = Date.now()
const sign = md5(`${authKey}${authSecret}${timestamp}`)
// 插入用户消息
addMessage({
role_type: 'user',
content: data.content,
conversationId: `user-${timestamp}`, // 生成一个唯一的 conversationId
time: Date.now(),
})
setIsLoading(true)
try {
await fetchEventSource('/api/tiangong/openapi/agent/chat/stream/v1', {
method: 'POST',
headers: {
authKey,
timestamp,
sign,
'Content-Type': 'application/json',
},
body: JSON.stringify({ ...data, chatId, agentId: authKey }),
onopen(response) {
if (!response.ok) {
throw new Error('Network response was not ok')
}
},
onmessage(event) {
const message = JSON.parse(event.data)
setChatId(message.chatId)
updateMessages(message)
},
onerror(error) {
console.error('Fetch error:', error)
},
onclose() {
setIsLoading(false)
},
})
} catch (error) {
console.error('Post error:', error)
setIsLoading(false)
}
},
[authKey, authSecret, chatId, addMessage, updateMessages]
)
return { messages, isLoading, post }
}
......@@ -12,7 +12,7 @@ async function getOSSConfig() {
...data,
accessKeyId: data.AccessKeyId,
accessKeySecret: data.AccessKeySecret,
stsToken: data.SecurityToken
stsToken: data.SecurityToken,
}
} catch (error) {
handleError(error, 'Failed to retrieve STS token')
......@@ -32,7 +32,7 @@ async function createOSSInstance() {
refreshSTSToken: async () => {
return await getOSSConfig()
},
refreshSTSTokenInterval: 14 * 60 * 1000
refreshSTSTokenInterval: 14 * 60 * 1000,
})
return ossInstance
} catch (error) {
......@@ -44,9 +44,30 @@ async function createOSSInstance() {
export async function uploadFile(file) {
try {
const oss = await createOSSInstance()
const fileExt = file.name?.substring(file.name.lastIndexOf('.')) || '.png'
const fileName = `${new Date().getTime()}-${Math.random() * 1000}${fileExt}`
const result = await oss.put(fileName, file)
const fileName = typeof file.name === 'string' ? file.name : ''
const lastDotIndex = fileName.lastIndexOf('.')
const extFromName = lastDotIndex > -1 ? fileName.slice(lastDotIndex).toLowerCase() : ''
const mimeType = (file.type || '').toLowerCase()
const mimeExtMap = {
'image/jpeg': '.jpg',
'image/png': '.png',
'image/gif': '.gif',
'image/webp': '.webp',
'image/svg+xml': '.svg',
'audio/mpeg': '.mp3',
'audio/wav': '.wav',
'audio/ogg': '.ogg',
'audio/mp4': '.m4a',
'video/mp4': '.mp4',
'video/webm': '.webm',
'video/ogg': '.ogv',
'video/quicktime': '.mov',
}
const fileExt = extFromName || mimeExtMap[mimeType] || '.bin'
const generatedFileName = `${new Date().getTime()}-${Math.random() * 1000}${fileExt}`
const result = await oss.put(generatedFileName, file)
return result.url
} catch (error) {
handleError(error, 'Failed to upload file')
......@@ -56,6 +77,7 @@ export async function uploadFile(file) {
// 上传通过URL获取的文件
export async function uploadFileByUrl(url) {
try {
url = url.replace('https://ark-content-generation-cn-beijing.tos-cn-beijing.volces.com', '/api/ai_file')
const res = await axios.get(url, { responseType: 'blob' })
return await uploadFile(res.data)
} catch (error) {
......
......@@ -37,9 +37,29 @@ export default defineConfig(() => {
// changeOrigin: true,
// rewrite: (path) => path.replace(/^\/api\/wenku/, '/'),
// },
// '/api/qianfan': {
// target: 'https://qianfan.baidubce.com',
// changeOrigin: true,
// rewrite: (path) => path.replace(/^\/api\/qianfan/, ''),
// },
'/api/volcano': {
target: 'https://ark.cn-beijing.volces.com',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api\/volcano/, ''),
},
'/api/xiling': {
target: 'https://open.xiling.baidu.com',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api\/xiling/, ''),
},
'/api/ai_file': {
target: 'https://ark-content-generation-cn-beijing.tos-cn-beijing.volces.com',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api\/ai_file/, ''),
},
'/api': {
// target: 'https://zijingebook.ezijing.com',
target: 'http://localhost:7419',
target: 'https://zijingebook.ezijing.com',
// target: 'http://localhost:7419',
changeOrigin: true,
secure: false,
},
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论