fix:初始化
This commit is contained in:
69
src/App.css
Normal file
69
src/App.css
Normal file
@@ -0,0 +1,69 @@
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
|
||||
monospace;
|
||||
}
|
||||
|
||||
.markdown-content {
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.markdown-content h1,
|
||||
.markdown-content h2,
|
||||
.markdown-content h3,
|
||||
.markdown-content h4,
|
||||
.markdown-content h5,
|
||||
.markdown-content h6 {
|
||||
margin-top: 24px;
|
||||
margin-bottom: 16px;
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.markdown-content p {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.markdown-content ul,
|
||||
.markdown-content ol {
|
||||
padding-left: 2em;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.markdown-content li {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.markdown-content blockquote {
|
||||
padding: 0 1em;
|
||||
color: #6a737d;
|
||||
border-left: 0.25em solid #dfe2e5;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.markdown-content code {
|
||||
padding: 0.2em 0.4em;
|
||||
margin: 0;
|
||||
font-size: 85%;
|
||||
background-color: rgba(27,31,35,0.05);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.markdown-content pre {
|
||||
padding: 16px;
|
||||
overflow: auto;
|
||||
font-size: 85%;
|
||||
line-height: 1.45;
|
||||
background-color: #f6f8fa;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
9
src/App.test.tsx
Normal file
9
src/App.test.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import React from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import App from './App';
|
||||
|
||||
test('renders learn react link', () => {
|
||||
render(<App />);
|
||||
const linkElement = screen.getByText(/learn react/i);
|
||||
expect(linkElement).toBeInTheDocument();
|
||||
});
|
||||
75
src/App.tsx
Normal file
75
src/App.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { ConfigProvider, App as AntdApp, Spin } from 'antd';
|
||||
import zhCN from 'antd/locale/zh_CN';
|
||||
import { OllamaProvider } from './contexts/OllamaContext';
|
||||
import MainLayout from './components/MainLayout';
|
||||
import NovelList from './pages/NovelList';
|
||||
import NovelDetail from './pages/NovelDetail';
|
||||
import NovelGenerate from './pages/NovelGenerate';
|
||||
import ModelSettings from './pages/ModelSettings';
|
||||
import UserManual from './pages/UserManual';
|
||||
import SkillsPage from './pages/SkillsPage';
|
||||
import InitPage from './pages/InitPage';
|
||||
import ConversationsPage from './pages/ConversationsPage';
|
||||
import { indexedDBStorage } from './utils/indexedDB';
|
||||
import './App.css';
|
||||
|
||||
function App() {
|
||||
const [isDBReady, setIsDBReady] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const initDB = async () => {
|
||||
try {
|
||||
await indexedDBStorage.init();
|
||||
setIsDBReady(true);
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize IndexedDB:', error);
|
||||
// 即使初始化失败也继续运行,因为 IndexedDB 操作会自动重试
|
||||
setIsDBReady(true);
|
||||
}
|
||||
};
|
||||
initDB();
|
||||
}, []);
|
||||
|
||||
if (!isDBReady) {
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
height: '100vh',
|
||||
flexDirection: 'column'
|
||||
}}>
|
||||
<Spin size="large" />
|
||||
<div style={{ marginTop: '16px', color: '#666' }}>正在初始化数据库...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ConfigProvider locale={zhCN}>
|
||||
<AntdApp>
|
||||
<OllamaProvider>
|
||||
<Router>
|
||||
<Routes>
|
||||
<Route path="/" element={<MainLayout />}>
|
||||
<Route index element={<Navigate to="/novels" replace />} />
|
||||
<Route path="novels" element={<NovelList />} />
|
||||
<Route path="novels/:id" element={<NovelDetail />} />
|
||||
<Route path="novels/:id/generate" element={<NovelGenerate />} />
|
||||
<Route path="chat/conversations" element={<ConversationsPage />} />
|
||||
<Route path="agents/skills" element={<SkillsPage />} />
|
||||
<Route path="settings/model" element={<ModelSettings />} />
|
||||
<Route path="settings/init" element={<InitPage />} />
|
||||
<Route path="manual" element={<UserManual />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</Router>
|
||||
</OllamaProvider>
|
||||
</AntdApp>
|
||||
</ConfigProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
63
src/components/Header.tsx
Normal file
63
src/components/Header.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import React from 'react';
|
||||
import { Layout, Button, Space } from 'antd';
|
||||
import { BookOutlined, QuestionCircleOutlined } from '@ant-design/icons';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
const { Header: AntHeader } = Layout;
|
||||
|
||||
const Header: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleUserManualClick = () => {
|
||||
// 导航到使用手册页面
|
||||
navigate('/manual');
|
||||
};
|
||||
|
||||
return (
|
||||
<AntHeader style={{
|
||||
background: '#ffffff',
|
||||
borderBottom: '1px solid #e8e8e8',
|
||||
padding: '0 24px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
height: '64px',
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 220,
|
||||
right: 0,
|
||||
zIndex: 1000,
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.06)'
|
||||
}}>
|
||||
<div style={{
|
||||
fontSize: '18px',
|
||||
fontWeight: 600,
|
||||
color: '#1890ff',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px'
|
||||
}}>
|
||||
<BookOutlined />
|
||||
<span>AI 小说创作系统</span>
|
||||
</div>
|
||||
|
||||
<Space size="middle">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<QuestionCircleOutlined />}
|
||||
onClick={handleUserManualClick}
|
||||
style={{
|
||||
color: '#595959',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px'
|
||||
}}
|
||||
>
|
||||
使用手册
|
||||
</Button>
|
||||
</Space>
|
||||
</AntHeader>
|
||||
);
|
||||
};
|
||||
|
||||
export default Header;
|
||||
12
src/components/MainLayout.css
Normal file
12
src/components/MainLayout.css
Normal file
@@ -0,0 +1,12 @@
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.spin-animation {
|
||||
animation: spin 3s linear infinite;
|
||||
}
|
||||
388
src/components/MainLayout.tsx
Normal file
388
src/components/MainLayout.tsx
Normal file
@@ -0,0 +1,388 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Layout, Menu, Alert, Space, Button } from 'antd';
|
||||
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
|
||||
import { BookOutlined, SettingOutlined, AppstoreOutlined, ReloadOutlined, RobotOutlined, ThunderboltOutlined, ClearOutlined, MessageOutlined } from '@ant-design/icons';
|
||||
import { indexedDBStorage } from '../utils/indexedDB';
|
||||
import { useOllama } from '../contexts/OllamaContext';
|
||||
import Header from './Header';
|
||||
import './MainLayout.css';
|
||||
|
||||
const { Sider, Content, Footer } = Layout;
|
||||
|
||||
const MainLayout: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [showAlert, setShowAlert] = useState<boolean>(true);
|
||||
const [dbInitialized, setDbInitialized] = useState<boolean>(false);
|
||||
const { status, refreshModels } = useOllama();
|
||||
|
||||
const menuItems = [
|
||||
{
|
||||
key: '/novels',
|
||||
icon: <BookOutlined />,
|
||||
label: '小说管理',
|
||||
},
|
||||
{
|
||||
key: '/chat',
|
||||
icon: <MessageOutlined />,
|
||||
label: '聊天',
|
||||
children: [
|
||||
{
|
||||
key: '/chat/conversations',
|
||||
icon: <MessageOutlined />,
|
||||
label: '会话',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: '/agents',
|
||||
icon: <RobotOutlined />,
|
||||
label: '智能体',
|
||||
children: [
|
||||
{
|
||||
key: '/agents/skills',
|
||||
icon: <ThunderboltOutlined />,
|
||||
label: '技能',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: '/settings',
|
||||
icon: <SettingOutlined />,
|
||||
label: '设置',
|
||||
children: [
|
||||
{
|
||||
key: '/settings/model',
|
||||
icon: <AppstoreOutlined />,
|
||||
label: '模型',
|
||||
},
|
||||
{
|
||||
key: '/settings/init',
|
||||
icon: <ClearOutlined />,
|
||||
label: '初始化',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const handleMenuClick = ({ key }: { key: string }) => {
|
||||
navigate(key);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// 初始化 IndexedDB
|
||||
const initDB = async () => {
|
||||
try {
|
||||
await indexedDBStorage.init();
|
||||
setDbInitialized(true);
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize IndexedDB:', error);
|
||||
}
|
||||
};
|
||||
|
||||
initDB();
|
||||
}, []);
|
||||
|
||||
|
||||
const formatModelSize = (bytes?: number) => {
|
||||
if (!bytes) return '未知大小';
|
||||
const gb = bytes / (1024 * 1024 * 1024);
|
||||
return `${gb.toFixed(2)} GB`;
|
||||
};
|
||||
|
||||
const getCurrentModelInfo = () => {
|
||||
// 如果没有配置模型,直接显示"暂未配置"
|
||||
if (!status.currentModel) {
|
||||
return '暂未配置';
|
||||
}
|
||||
|
||||
// 检查模型是否在本地已安装列表中
|
||||
const model = status.availableModels.find(m => m.name === status.currentModel);
|
||||
if (!model) {
|
||||
// 模型未安装,只显示模型名称
|
||||
return status.currentModel;
|
||||
}
|
||||
|
||||
// 模型已安装且可用,显示详细信息
|
||||
return `${status.currentModel} (${formatModelSize(model.size)})`;
|
||||
};
|
||||
|
||||
const getCurrentModelStatus = () => {
|
||||
// 返回当前模型的状态类型
|
||||
if (!status.currentModel) {
|
||||
return 'error'; // 未配置
|
||||
}
|
||||
|
||||
if (!status.isConnected) {
|
||||
return 'warning'; // 服务未连接
|
||||
}
|
||||
|
||||
const model = status.availableModels.find(m => m.name === status.currentModel);
|
||||
if (!model) {
|
||||
return 'warning'; // 模型未安装
|
||||
}
|
||||
|
||||
return 'success'; // 正常
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout style={{ minHeight: '100vh', background: '#f5f7fa' }}>
|
||||
<Header />
|
||||
<Sider
|
||||
width={220}
|
||||
style={{
|
||||
background: '#ffffff',
|
||||
borderRight: '1px solid #e8e8e8',
|
||||
height: '100vh',
|
||||
position: 'fixed',
|
||||
left: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
boxShadow: '2px 0 8px rgba(0,0,0,0.06)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column'
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
position: 'relative',
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: '100%'
|
||||
}}>
|
||||
<div style={{
|
||||
height: '64px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderBottom: '1px solid #f0f0f0',
|
||||
padding: '0 16px',
|
||||
gap: '8px'
|
||||
}}>
|
||||
<RobotOutlined
|
||||
style={{
|
||||
color: '#1890ff',
|
||||
fontSize: '20px'
|
||||
}}
|
||||
className="spin-animation"
|
||||
/>
|
||||
<div style={{
|
||||
color: '#1890ff',
|
||||
fontSize: '16px',
|
||||
fontWeight: 600,
|
||||
textAlign: 'center',
|
||||
letterSpacing: '0.5px'
|
||||
}}>
|
||||
AI 小说创作
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<Menu
|
||||
mode="inline"
|
||||
selectedKeys={[location.pathname]}
|
||||
items={menuItems}
|
||||
onClick={handleMenuClick}
|
||||
style={{
|
||||
borderRight: 0,
|
||||
background: 'transparent',
|
||||
fontSize: '14px',
|
||||
flex: 1,
|
||||
overflowY: 'auto',
|
||||
paddingBottom: '50px'
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 底部品牌信息 - 悬浮固定 */}
|
||||
<div
|
||||
onClick={() => window.open('https://juejin.cn/user/3634340804698010', '_blank')}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
padding: '12px 16px',
|
||||
textAlign: 'center',
|
||||
borderTop: '1px solid #f0f0f0',
|
||||
background: '#fafafa',
|
||||
zIndex: 10,
|
||||
boxShadow: '0 -2px 8px rgba(0,0,0,0.05)',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.3s ease'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = '#f0f9ff';
|
||||
e.currentTarget.style.boxShadow = '0 -2px 12px rgba(24, 144, 255, 0.1)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = '#fafafa';
|
||||
e.currentTarget.style.boxShadow = '0 -2px 8px rgba(0,0,0,0.05)';
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
fontSize: '11px',
|
||||
color: '#8c8c8c',
|
||||
fontWeight: 500,
|
||||
letterSpacing: '0.3px',
|
||||
lineHeight: '1.5'
|
||||
}}>
|
||||
<div>代码老中医出品</div>
|
||||
<div style={{
|
||||
fontSize: '10px',
|
||||
color: '#bfbfbf',
|
||||
marginTop: '2px'
|
||||
}}>
|
||||
v0.0.1
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Sider>
|
||||
|
||||
<Layout style={{ marginLeft: 220, marginTop: '64px' }}>
|
||||
<Content style={{
|
||||
padding: '20px',
|
||||
background: '#f5f7fa',
|
||||
minHeight: 'calc(100vh - 184px)'
|
||||
}}>
|
||||
{!dbInitialized && (
|
||||
<div style={{ textAlign: 'center', padding: '40px' }}>
|
||||
<div>正在初始化数据库...</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{dbInitialized && (!status.isConnected || status.modelCount === 0 || getCurrentModelStatus() === 'error') && showAlert && !location.pathname.startsWith('/settings') && (
|
||||
<Alert
|
||||
message={
|
||||
!status.isConnected ? 'Ollama 服务未连接' :
|
||||
status.modelCount === 0 ? '未检测到 AI 模型' :
|
||||
'模型暂未配置'
|
||||
}
|
||||
description={
|
||||
!status.isConnected
|
||||
? '请确保 Ollama 服务正在运行。在终端中执行命令:ollama serve'
|
||||
: status.modelCount === 0
|
||||
? '请先安装 AI 模型。在终端中执行命令:ollama pull qwen3:8b'
|
||||
: '请前往配置页面选择并配置可用模型'
|
||||
}
|
||||
type={!status.isConnected ? 'error' : 'warning'}
|
||||
showIcon
|
||||
closable
|
||||
style={{ marginBottom: '16px' }}
|
||||
onClose={() => setShowAlert(false)}
|
||||
action={
|
||||
<button
|
||||
onClick={() => navigate('/settings/model')}
|
||||
style={{
|
||||
color: '#1890ff',
|
||||
textDecoration: 'none',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
padding: 0,
|
||||
font: 'inherit'
|
||||
}}
|
||||
>
|
||||
前往配置
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div style={{
|
||||
background: 'white',
|
||||
padding: '24px',
|
||||
borderRadius: '12px',
|
||||
minHeight: 'calc(100vh - 264px)',
|
||||
boxShadow: '0 1px 3px rgba(0,0,0,0.08)'
|
||||
}}>
|
||||
<Outlet />
|
||||
</div>
|
||||
</Content>
|
||||
|
||||
<Footer style={{
|
||||
background: 'white',
|
||||
borderTop: '1px solid #f0f0f0',
|
||||
padding: '8px 20px',
|
||||
position: 'fixed',
|
||||
bottom: 0,
|
||||
left: 220,
|
||||
right: 0,
|
||||
zIndex: 1000,
|
||||
height: '40px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
fontSize: '12px'
|
||||
}}>
|
||||
<Space size={20}>
|
||||
<Space size={6}>
|
||||
<span style={{ color: '#8c8c8c' }}>Ollama 状态:</span>
|
||||
<span style={{
|
||||
color: status.isConnected ? '#52c41a' : '#ff4d4f',
|
||||
fontWeight: 500
|
||||
}}>
|
||||
{status.isConnected ? '已连接' : '未连接'}
|
||||
</span>
|
||||
</Space>
|
||||
|
||||
<Space size={6}>
|
||||
<span style={{ color: '#8c8c8c' }}>模型数量:</span>
|
||||
<span style={{
|
||||
color: status.modelCount > 0 ? '#52c41a' : '#faad14',
|
||||
fontWeight: 500
|
||||
}}>
|
||||
{status.modelCount} 个
|
||||
</span>
|
||||
</Space>
|
||||
|
||||
<Space size={6}>
|
||||
<span style={{ color: '#8c8c8c' }}>当前模型:</span>
|
||||
<span style={{
|
||||
color: getCurrentModelStatus() === 'success' ? '#52c41a' :
|
||||
getCurrentModelStatus() === 'warning' ? '#faad14' : '#ff4d4f',
|
||||
fontWeight: 500,
|
||||
maxWidth: '300px',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap'
|
||||
}}>
|
||||
{getCurrentModelInfo()}
|
||||
</span>
|
||||
{getCurrentModelStatus() === 'error' && (
|
||||
<button
|
||||
onClick={() => navigate('/settings/model')}
|
||||
style={{
|
||||
background: '#ff4d4f',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
padding: '2px 8px',
|
||||
fontSize: '11px',
|
||||
cursor: 'pointer',
|
||||
marginLeft: '8px'
|
||||
}}
|
||||
>
|
||||
前往配置
|
||||
</button>
|
||||
)}
|
||||
</Space>
|
||||
</Space>
|
||||
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={refreshModels}
|
||||
loading={status.isLoading}
|
||||
style={{ color: '#1890ff' }}
|
||||
>
|
||||
刷新状态
|
||||
</Button>
|
||||
</Footer>
|
||||
</Layout>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default MainLayout;
|
||||
172
src/contexts/OllamaContext.tsx
Normal file
172
src/contexts/OllamaContext.tsx
Normal file
@@ -0,0 +1,172 @@
|
||||
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
||||
import { OllamaService } from '../utils/ollama';
|
||||
import { storage } from '../utils/indexedDB';
|
||||
import { SystemConfig } from '../types';
|
||||
|
||||
interface OllamaStatus {
|
||||
isConnected: boolean;
|
||||
modelCount: number;
|
||||
currentModel: string;
|
||||
availableModels: any[];
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
config: SystemConfig | null;
|
||||
}
|
||||
|
||||
interface OllamaContextType {
|
||||
status: OllamaStatus;
|
||||
checkStatus: () => Promise<void>;
|
||||
refreshModels: () => Promise<void>;
|
||||
chat: (message: string, context: string) => Promise<string>;
|
||||
isReady: boolean;
|
||||
}
|
||||
|
||||
const OllamaContext = createContext<OllamaContextType | undefined>(undefined);
|
||||
|
||||
export const OllamaProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
|
||||
const [status, setStatus] = useState<OllamaStatus>({
|
||||
isConnected: false,
|
||||
modelCount: 0,
|
||||
currentModel: '',
|
||||
availableModels: [],
|
||||
isLoading: true,
|
||||
error: null,
|
||||
config: null
|
||||
});
|
||||
|
||||
const getService = async (): Promise<OllamaService | null> => {
|
||||
try {
|
||||
// 优先从 localStorage 读取配置,如果没有则从 IndexedDB 读取
|
||||
let config: SystemConfig;
|
||||
const localConfigData = localStorage.getItem('ai_system_config');
|
||||
if (localConfigData) {
|
||||
config = JSON.parse(localConfigData) as SystemConfig;
|
||||
} else {
|
||||
config = await storage.getSystemConfig();
|
||||
}
|
||||
|
||||
if (!config.model || !config.ollamaUrl) {
|
||||
setStatus(prev => ({
|
||||
...prev,
|
||||
error: '请先在设置中配置AI模型'
|
||||
}));
|
||||
return null;
|
||||
}
|
||||
|
||||
return new OllamaService(config);
|
||||
} catch (error) {
|
||||
console.error('获取AI服务失败:', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const checkStatus = async () => {
|
||||
setStatus(prev => ({ ...prev, isLoading: true, error: null }));
|
||||
|
||||
try {
|
||||
// 优先从 localStorage 读取配置,如果没有则从 IndexedDB 读取
|
||||
let config: SystemConfig;
|
||||
const localConfigData = localStorage.getItem('ai_system_config');
|
||||
if (localConfigData) {
|
||||
config = JSON.parse(localConfigData) as SystemConfig;
|
||||
} else {
|
||||
config = await storage.getSystemConfig();
|
||||
}
|
||||
|
||||
// 如果没有配置模型,设置为空字符串
|
||||
if (!config.model) {
|
||||
config.model = '';
|
||||
}
|
||||
|
||||
setStatus(prev => ({ ...prev, config }));
|
||||
|
||||
const ollamaService = new OllamaService(config);
|
||||
|
||||
// 检查连接状态
|
||||
const isConnected = await ollamaService.testConnection();
|
||||
|
||||
if (!isConnected) {
|
||||
setStatus({
|
||||
isConnected: false,
|
||||
modelCount: 0,
|
||||
currentModel: config.model || '',
|
||||
availableModels: [],
|
||||
isLoading: false,
|
||||
error: 'Ollama 服务未连接',
|
||||
config
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取模型列表
|
||||
const models = await ollamaService.getAvailableModelsWithInfo();
|
||||
|
||||
// 验证当前配置的模型是否可用
|
||||
const currentModel = config.model || '';
|
||||
const isCurrentModelValid = currentModel && models.some(m => m.name === currentModel);
|
||||
|
||||
setStatus({
|
||||
isConnected: true,
|
||||
modelCount: models.length,
|
||||
currentModel: currentModel,
|
||||
availableModels: models,
|
||||
isLoading: false,
|
||||
error: !isCurrentModelValid && currentModel ? '当前配置的模型未安装' : null,
|
||||
config
|
||||
});
|
||||
} catch (error) {
|
||||
setStatus({
|
||||
isConnected: false,
|
||||
modelCount: 0,
|
||||
currentModel: '',
|
||||
availableModels: [],
|
||||
isLoading: false,
|
||||
error: '状态检查失败',
|
||||
config: null
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const refreshModels = async () => {
|
||||
await checkStatus();
|
||||
};
|
||||
|
||||
const chat = async (message: string, context: string): Promise<string> => {
|
||||
const service = await getService();
|
||||
if (!service) {
|
||||
throw new Error('AI服务未配置或连接失败');
|
||||
}
|
||||
|
||||
if (!status.config) {
|
||||
throw new Error('请先配置AI模型');
|
||||
}
|
||||
|
||||
return await service.chat(message, context);
|
||||
};
|
||||
|
||||
|
||||
const isReady: boolean = status.isConnected && !!status.config && !!status.config.model;
|
||||
|
||||
useEffect(() => {
|
||||
checkStatus();
|
||||
|
||||
// 每 30 秒自动检查一次状态
|
||||
const interval = setInterval(checkStatus, 30000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<OllamaContext.Provider value={{ status, checkStatus, refreshModels, chat, isReady }}>
|
||||
{children}
|
||||
</OllamaContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useOllama = () => {
|
||||
const context = useContext(OllamaContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useOllama must be used within an OllamaProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
13
src/index.css
Normal file
13
src/index.css
Normal file
@@ -0,0 +1,13 @@
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
|
||||
monospace;
|
||||
}
|
||||
19
src/index.tsx
Normal file
19
src/index.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import './index.css';
|
||||
import App from './App';
|
||||
import reportWebVitals from './reportWebVitals';
|
||||
|
||||
const root = ReactDOM.createRoot(
|
||||
document.getElementById('root') as HTMLElement
|
||||
);
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
// If you want to start measuring performance in your app, pass a function
|
||||
// to log results (for example: reportWebVitals(console.log))
|
||||
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
|
||||
reportWebVitals();
|
||||
1
src/logo.svg
Normal file
1
src/logo.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 841.9 595.3"><g fill="#61DAFB"><path d="M666.3 296.5c0-32.5-40.7-63.3-103.1-82.4 14.4-63.6 8-114.2-20.2-130.4-6.5-3.8-14.1-5.6-22.4-5.6v22.3c4.6 0 8.3.9 11.4 2.6 13.6 7.8 19.5 37.5 14.9 75.7-1.1 9.4-2.9 19.3-5.1 29.4-19.6-4.8-41-8.5-63.5-10.9-13.5-18.5-27.5-35.3-41.6-50 32.6-30.3 63.2-46.9 84-46.9V78c-27.5 0-63.5 19.6-99.9 53.6-36.4-33.8-72.4-53.2-99.9-53.2v22.3c20.7 0 51.4 16.5 84 46.6-14 14.7-28 31.4-41.3 49.9-22.6 2.4-44 6.1-63.6 11-2.3-10-4-19.7-5.2-29-4.7-38.2 1.1-67.9 14.6-75.8 3-1.8 6.9-2.6 11.5-2.6V78.5c-8.4 0-16 1.8-22.6 5.6-28.1 16.2-34.4 66.7-19.9 130.1-62.2 19.2-102.7 49.9-102.7 82.3 0 32.5 40.7 63.3 103.1 82.4-14.4 63.6-8 114.2 20.2 130.4 6.5 3.8 14.1 5.6 22.5 5.6 27.5 0 63.5-19.6 99.9-53.6 36.4 33.8 72.4 53.2 99.9 53.2 8.4 0 16-1.8 22.6-5.6 28.1-16.2 34.4-66.7 19.9-130.1 62-19.1 102.5-49.9 102.5-82.3zm-130.2-66.7c-3.7 12.9-8.3 26.2-13.5 39.5-4.1-8-8.4-16-13.1-24-4.6-8-9.5-15.8-14.4-23.4 14.2 2.1 27.9 4.7 41 7.9zm-45.8 106.5c-7.8 13.5-15.8 26.3-24.1 38.2-14.9 1.3-30 2-45.2 2-15.1 0-30.2-.7-45-1.9-8.3-11.9-16.4-24.6-24.2-38-7.6-13.1-14.5-26.4-20.8-39.8 6.2-13.4 13.2-26.8 20.7-39.9 7.8-13.5 15.8-26.3 24.1-38.2 14.9-1.3 30-2 45.2-2 15.1 0 30.2.7 45 1.9 8.3 11.9 16.4 24.6 24.2 38 7.6 13.1 14.5 26.4 20.8 39.8-6.3 13.4-13.2 26.8-20.7 39.9zm32.3-13c5.4 13.4 10 26.8 13.8 39.8-13.1 3.2-26.9 5.9-41.2 8 4.9-7.7 9.8-15.6 14.4-23.7 4.6-8 8.9-16.1 13-24.1zM421.2 430c-9.3-9.6-18.6-20.3-27.8-32 9 .4 18.2.7 27.5.7 9.4 0 18.7-.2 27.8-.7-9 11.7-18.3 22.4-27.5 32zm-74.4-58.9c-14.2-2.1-27.9-4.7-41-7.9 3.7-12.9 8.3-26.2 13.5-39.5 4.1 8 8.4 16 13.1 24 4.7 8 9.5 15.8 14.4 23.4zM420.7 163c9.3 9.6 18.6 20.3 27.8 32-9-.4-18.2-.7-27.5-.7-9.4 0-18.7.2-27.8.7 9-11.7 18.3-22.4 27.5-32zm-74 58.9c-4.9 7.7-9.8 15.6-14.4 23.7-4.6 8-8.9 16-13 24-5.4-13.4-10-26.8-13.8-39.8 13.1-3.1 26.9-5.8 41.2-7.9zm-90.5 125.2c-35.4-15.1-58.3-34.9-58.3-50.6 0-15.7 22.9-35.6 58.3-50.6 8.6-3.7 18-7 27.7-10.1 5.7 19.6 13.2 40 22.5 60.9-9.2 20.8-16.6 41.1-22.2 60.6-9.9-3.1-19.3-6.5-28-10.2zM310 490c-13.6-7.8-19.5-37.5-14.9-75.7 1.1-9.4 2.9-19.3 5.1-29.4 19.6 4.8 41 8.5 63.5 10.9 13.5 18.5 27.5 35.3 41.6 50-32.6 30.3-63.2 46.9-84 46.9-4.5-.1-8.3-1-11.3-2.7zm237.2-76.2c4.7 38.2-1.1 67.9-14.6 75.8-3 1.8-6.9 2.6-11.5 2.6-20.7 0-51.4-16.5-84-46.6 14-14.7 28-31.4 41.3-49.9 22.6-2.4 44-6.1 63.6-11 2.3 10.1 4.1 19.8 5.2 29.1zm38.5-66.7c-8.6 3.7-18 7-27.7 10.1-5.7-19.6-13.2-40-22.5-60.9 9.2-20.8 16.6-41.1 22.2-60.6 9.9 3.1 19.3 6.5 28.1 10.2 35.4 15.1 58.3 34.9 58.3 50.6-.1 15.7-23 35.6-58.4 50.6zM320.8 78.4z"/><circle cx="420.9" cy="296.5" r="45.7"/><path d="M520.5 78.1z"/></g></svg>
|
||||
|
After Width: | Height: | Size: 2.6 KiB |
845
src/pages/ConversationsPage.tsx
Normal file
845
src/pages/ConversationsPage.tsx
Normal file
@@ -0,0 +1,845 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Layout,
|
||||
List,
|
||||
Input,
|
||||
Button,
|
||||
Avatar,
|
||||
Typography,
|
||||
Space,
|
||||
Modal,
|
||||
Form,
|
||||
Select,
|
||||
Tag,
|
||||
message,
|
||||
Dropdown,
|
||||
Popconfirm,
|
||||
Alert
|
||||
} from 'antd';
|
||||
import {
|
||||
SendOutlined,
|
||||
PlusOutlined,
|
||||
RobotOutlined,
|
||||
UserOutlined,
|
||||
SettingOutlined,
|
||||
MessageOutlined,
|
||||
DeleteOutlined,
|
||||
EditOutlined,
|
||||
PlusSquareOutlined
|
||||
} from '@ant-design/icons';
|
||||
|
||||
const { Sider, Content } = Layout;
|
||||
const { Title, Text } = Typography;
|
||||
const { TextArea } = Input;
|
||||
|
||||
interface Message {
|
||||
id: string;
|
||||
content: string;
|
||||
role: 'user' | 'assistant';
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
interface Conversation {
|
||||
id: string;
|
||||
title: string;
|
||||
botName: string;
|
||||
botType: 'feishu' | 'other';
|
||||
lastMessage: string;
|
||||
timestamp: string;
|
||||
messages: Message[];
|
||||
}
|
||||
|
||||
interface BotConfig {
|
||||
id: string;
|
||||
name: string;
|
||||
type: 'feishu' | 'other';
|
||||
appId: string;
|
||||
appSecret: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const ConversationsPage: React.FC = () => {
|
||||
const [conversations, setConversations] = useState<Conversation[]>([]);
|
||||
const [selectedConversation, setSelectedConversation] = useState<Conversation | null>(null);
|
||||
const [messageInput, setMessageInput] = useState('');
|
||||
const [botConfigs, setBotConfigs] = useState<BotConfig[]>([]);
|
||||
const [showBotModal, setShowBotModal] = useState(false);
|
||||
const [showConversationModal, setShowConversationModal] = useState(false);
|
||||
const [editingBot, setEditingBot] = useState<BotConfig | null>(null);
|
||||
const [form] = Form.useForm();
|
||||
const [conversationForm] = Form.useForm();
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
const loadData = () => {
|
||||
// 加载飞书机器人配置
|
||||
const savedBots = localStorage.getItem('botConfigs');
|
||||
if (savedBots) {
|
||||
setBotConfigs(JSON.parse(savedBots));
|
||||
} else {
|
||||
// 默认配置示例
|
||||
const defaultBots: BotConfig[] = [
|
||||
{
|
||||
id: '1',
|
||||
name: '助手机器人',
|
||||
type: 'feishu',
|
||||
appId: 'cli_xxxxxxxxxxxxx',
|
||||
appSecret: 'xxxxxxxxxxxxx',
|
||||
description: '用于日常事务处理和提醒'
|
||||
}
|
||||
];
|
||||
setBotConfigs(defaultBots);
|
||||
localStorage.setItem('botConfigs', JSON.stringify(defaultBots));
|
||||
}
|
||||
|
||||
// 加载会话列表
|
||||
const savedConversations = localStorage.getItem('conversations');
|
||||
if (savedConversations) {
|
||||
setConversations(JSON.parse(savedConversations));
|
||||
} else {
|
||||
// 默认会话示例
|
||||
const defaultConversations: Conversation[] = [
|
||||
{
|
||||
id: '1',
|
||||
title: '欢迎使用飞书机器人',
|
||||
botName: '助手机器人',
|
||||
botType: 'feishu',
|
||||
lastMessage: '你好!我是飞书助手机器人,有什么可以帮助您的吗?',
|
||||
timestamp: new Date().toISOString(),
|
||||
messages: [
|
||||
{
|
||||
id: '1',
|
||||
content: '你好!我是飞书助手机器人,有什么可以帮助您的吗?',
|
||||
role: 'assistant',
|
||||
timestamp: new Date().toISOString()
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
setConversations(defaultConversations);
|
||||
localStorage.setItem('conversations', JSON.stringify(defaultConversations));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSendMessage = async () => {
|
||||
if (!messageInput.trim() || !selectedConversation) return;
|
||||
|
||||
const newMessage: Message = {
|
||||
id: Date.now().toString(),
|
||||
content: messageInput,
|
||||
role: 'user',
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
|
||||
const updatedConversation = {
|
||||
...selectedConversation,
|
||||
messages: [...selectedConversation.messages, newMessage],
|
||||
lastMessage: messageInput,
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
|
||||
setSelectedConversation(updatedConversation);
|
||||
setConversations(conversations.map(conv =>
|
||||
conv.id === selectedConversation.id ? updatedConversation : conv
|
||||
));
|
||||
|
||||
// 保存到本地存储
|
||||
localStorage.setItem('conversations', JSON.stringify(
|
||||
conversations.map(conv => conv.id === selectedConversation.id ? updatedConversation : conv)
|
||||
));
|
||||
|
||||
setMessageInput('');
|
||||
|
||||
// 模拟飞书应用回复
|
||||
setTimeout(() => {
|
||||
const botReply: Message = {
|
||||
id: (Date.now() + 1).toString(),
|
||||
content: `收到您的消息:"${messageInput}"。这是一个模拟的飞书应用回复。在实际使用中,这里会调用飞书自建应用API来发送消息。`,
|
||||
role: 'assistant',
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
|
||||
const updatedWithReply = {
|
||||
...updatedConversation,
|
||||
messages: [...updatedConversation.messages, botReply],
|
||||
lastMessage: botReply.content,
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
|
||||
setSelectedConversation(updatedWithReply);
|
||||
setConversations(conversations.map(conv =>
|
||||
conv.id === selectedConversation.id ? updatedWithReply : conv
|
||||
));
|
||||
|
||||
localStorage.setItem('conversations', JSON.stringify(
|
||||
conversations.map(conv => conv.id === selectedConversation.id ? updatedWithReply : conv)
|
||||
));
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
|
||||
const handleSubmitConversation = () => {
|
||||
conversationForm.validateFields().then(values => {
|
||||
const selectedBot = botConfigs.find(bot => bot.id === values.botId);
|
||||
if (!selectedBot) return;
|
||||
|
||||
const newConversation: Conversation = {
|
||||
id: Date.now().toString(),
|
||||
title: values.title,
|
||||
botName: selectedBot.name,
|
||||
botType: selectedBot.type,
|
||||
lastMessage: '新会话已创建',
|
||||
timestamp: new Date().toISOString(),
|
||||
messages: []
|
||||
};
|
||||
|
||||
setConversations([newConversation, ...conversations]);
|
||||
localStorage.setItem('conversations', JSON.stringify([newConversation, ...conversations]));
|
||||
setShowConversationModal(false);
|
||||
conversationForm.resetFields();
|
||||
setSelectedConversation(newConversation);
|
||||
message.success('会话创建成功');
|
||||
});
|
||||
};
|
||||
|
||||
const handleAddBot = () => {
|
||||
setEditingBot(null);
|
||||
form.resetFields();
|
||||
setShowBotModal(true);
|
||||
};
|
||||
|
||||
const handleEditBot = (bot: BotConfig) => {
|
||||
setEditingBot(bot);
|
||||
form.setFieldsValue(bot);
|
||||
setShowBotModal(true);
|
||||
};
|
||||
|
||||
const handleDeleteBot = (botId: string) => {
|
||||
const updatedBots = botConfigs.filter(bot => bot.id !== botId);
|
||||
setBotConfigs(updatedBots);
|
||||
localStorage.setItem('botConfigs', JSON.stringify(updatedBots));
|
||||
message.success('机器人配置已删除');
|
||||
};
|
||||
|
||||
const handleSubmitBot = () => {
|
||||
form.validateFields().then(values => {
|
||||
if (editingBot) {
|
||||
// 编辑模式
|
||||
const updatedBot: BotConfig = {
|
||||
...editingBot,
|
||||
name: values.name,
|
||||
type: values.type,
|
||||
appId: values.appId,
|
||||
appSecret: values.appSecret,
|
||||
description: values.description
|
||||
};
|
||||
const updatedBots = botConfigs.map(bot =>
|
||||
bot.id === editingBot.id ? updatedBot : bot
|
||||
);
|
||||
setBotConfigs(updatedBots);
|
||||
localStorage.setItem('botConfigs', JSON.stringify(updatedBots));
|
||||
message.success('飞书应用配置已更新');
|
||||
} else {
|
||||
// 新增模式
|
||||
const newBot: BotConfig = {
|
||||
id: Date.now().toString(),
|
||||
name: values.name,
|
||||
type: values.type,
|
||||
appId: values.appId,
|
||||
appSecret: values.appSecret,
|
||||
description: values.description
|
||||
};
|
||||
setBotConfigs([...botConfigs, newBot]);
|
||||
localStorage.setItem('botConfigs', JSON.stringify([...botConfigs, newBot]));
|
||||
message.success('飞书应用配置已添加');
|
||||
}
|
||||
setShowBotModal(false);
|
||||
form.resetFields();
|
||||
setEditingBot(null);
|
||||
});
|
||||
};
|
||||
|
||||
const handleDeleteConversation = (conversationId: string) => {
|
||||
const updatedConversations = conversations.filter(conv => conv.id !== conversationId);
|
||||
setConversations(updatedConversations);
|
||||
localStorage.setItem('conversations', JSON.stringify(updatedConversations));
|
||||
if (selectedConversation?.id === conversationId) {
|
||||
setSelectedConversation(null);
|
||||
}
|
||||
message.success('会话已删除');
|
||||
};
|
||||
|
||||
const getBotItems = () => {
|
||||
return [
|
||||
{
|
||||
key: 'add',
|
||||
label: (
|
||||
<div onClick={() => handleAddBot()}>
|
||||
<PlusSquareOutlined style={{ marginRight: '8px' }} />
|
||||
添加飞书应用
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
type: 'divider' as const,
|
||||
},
|
||||
...botConfigs.map(bot => ({
|
||||
key: bot.id,
|
||||
label: (
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span>
|
||||
<RobotOutlined style={{ marginRight: '8px', color: '#1890ff' }} />
|
||||
{bot.name}
|
||||
<Tag color={bot.type === 'feishu' ? 'blue' : 'green'} style={{ marginLeft: '8px' }}>
|
||||
{bot.type === 'feishu' ? '飞书' : '其他'}
|
||||
</Tag>
|
||||
</span>
|
||||
<Space size={4}>
|
||||
<EditOutlined
|
||||
style={{ color: '#52c41a', cursor: 'pointer' }}
|
||||
onClick={(e) => { e.stopPropagation(); handleEditBot(bot); }}
|
||||
/>
|
||||
<Popconfirm
|
||||
title="确认删除"
|
||||
description="确定要删除这个飞书应用配置吗?"
|
||||
onConfirm={(e) => {
|
||||
e?.stopPropagation();
|
||||
handleDeleteBot(bot.id);
|
||||
}}
|
||||
okText="确定"
|
||||
cancelText="取消"
|
||||
>
|
||||
<DeleteOutlined
|
||||
style={{ color: '#ff4d4f', cursor: 'pointer' }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
</div>
|
||||
)
|
||||
}))
|
||||
];
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ height: 'calc(100vh - 184px)' }}>
|
||||
<div style={{ marginBottom: '16px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div>
|
||||
<Title level={2} style={{ margin: 0, display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||
<MessageOutlined style={{ color: '#1890ff' }} />
|
||||
飞书机器人会话
|
||||
</Title>
|
||||
<Text type="secondary" style={{ marginTop: '8px', display: 'block' }}>
|
||||
与飞书自建应用进行对话,指挥AI助手完成各种任务
|
||||
</Text>
|
||||
</div>
|
||||
<Dropdown
|
||||
menu={{ items: getBotItems() }}
|
||||
trigger={['click']}
|
||||
placement="bottomRight"
|
||||
>
|
||||
<Button icon={<SettingOutlined />}>
|
||||
飞书应用配置 ({botConfigs.length})
|
||||
</Button>
|
||||
</Dropdown>
|
||||
</div>
|
||||
|
||||
<Layout style={{
|
||||
background: 'white',
|
||||
borderRadius: '12px',
|
||||
height: 'calc(100% - 80px)',
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
{/* 左侧会话列表 */}
|
||||
<Sider
|
||||
width={320}
|
||||
style={{
|
||||
background: '#fafafa',
|
||||
borderRight: '1px solid #f0f0f0',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column'
|
||||
}}
|
||||
>
|
||||
<div style={{ padding: '16px', borderBottom: '1px solid #f0f0f0' }}>
|
||||
<Input.Search
|
||||
placeholder="搜索会话..."
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 飞书应用列表 */}
|
||||
<div style={{ padding: '12px 16px', borderBottom: '1px solid #f0f0f0' }}>
|
||||
<div style={{ fontSize: '12px', color: '#999', marginBottom: '8px', fontWeight: 'bold' }}>
|
||||
飞书应用
|
||||
</div>
|
||||
{botConfigs.length > 0 ? (
|
||||
<Space direction="vertical" style={{ width: '100%' }} size={8}>
|
||||
{botConfigs.map(bot => {
|
||||
const hasConversation = conversations.some(conv => conv.botName === bot.name);
|
||||
return (
|
||||
<div
|
||||
key={bot.id}
|
||||
onClick={() => {
|
||||
if (hasConversation) {
|
||||
const existingConv = conversations.find(conv => conv.botName === bot.name);
|
||||
if (existingConv) {
|
||||
setSelectedConversation(existingConv);
|
||||
}
|
||||
} else {
|
||||
// 创建新会话
|
||||
const newConversation: Conversation = {
|
||||
id: Date.now().toString(),
|
||||
title: bot.name,
|
||||
botName: bot.name,
|
||||
botType: bot.type,
|
||||
lastMessage: '新会话已创建',
|
||||
timestamp: new Date().toISOString(),
|
||||
messages: []
|
||||
};
|
||||
setConversations([newConversation, ...conversations]);
|
||||
localStorage.setItem('conversations', JSON.stringify([newConversation, ...conversations]));
|
||||
setSelectedConversation(newConversation);
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
padding: '10px 12px',
|
||||
background: selectedConversation?.botName === bot.name ? '#e6f7ff' : 'white',
|
||||
borderRadius: '8px',
|
||||
cursor: 'pointer',
|
||||
border: '1px solid #f0f0f0',
|
||||
transition: 'all 0.3s'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (selectedConversation?.botName !== bot.name) {
|
||||
e.currentTarget.style.background = '#f5f5f5';
|
||||
e.currentTarget.style.borderColor = '#d9d9d9';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (selectedConversation?.botName !== bot.name) {
|
||||
e.currentTarget.style.background = 'white';
|
||||
e.currentTarget.style.borderColor = '#f0f0f0';
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Avatar
|
||||
icon={<RobotOutlined />}
|
||||
style={{ backgroundColor: '#1890ff' }}
|
||||
/>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<Text strong style={{ fontSize: '14px' }}>
|
||||
{bot.name}
|
||||
</Text>
|
||||
<Tag color={bot.type === 'feishu' ? 'blue' : 'green'}>
|
||||
{bot.type === 'feishu' ? '飞书' : '其他'}
|
||||
</Tag>
|
||||
</div>
|
||||
<Text type="secondary" style={{ fontSize: '11px' }}>
|
||||
{hasConversation ? '点击继续对话' : '点击开始对话'}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</Space>
|
||||
) : (
|
||||
<div style={{ textAlign: 'center', padding: '20px 0', color: '#999' }}>
|
||||
<Text type="secondary">暂无飞书应用</Text>
|
||||
<div style={{ marginTop: '8px' }}>
|
||||
<Button type="link" size="small" onClick={handleAddBot}>
|
||||
添加飞书应用
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 会话列表 */}
|
||||
<div style={{ padding: '12px 16px 0', flex: 1, overflowY: 'auto' }}>
|
||||
<div style={{ fontSize: '12px', color: '#999', marginBottom: '8px', fontWeight: 'bold' }}>
|
||||
会话列表
|
||||
</div>
|
||||
<List
|
||||
dataSource={conversations}
|
||||
renderItem={(conversation) => (
|
||||
<List.Item
|
||||
key={conversation.id}
|
||||
onClick={() => setSelectedConversation(conversation)}
|
||||
style={{
|
||||
padding: '12px',
|
||||
cursor: 'pointer',
|
||||
background: selectedConversation?.id === conversation.id ? '#e6f7ff' : 'transparent',
|
||||
borderRadius: '8px',
|
||||
marginBottom: '8px',
|
||||
border: '1px solid #f0f0f0',
|
||||
transition: 'all 0.3s'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (selectedConversation?.id !== conversation.id) {
|
||||
e.currentTarget.style.background = '#f5f5f5';
|
||||
e.currentTarget.style.borderColor = '#d9d9d9';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (selectedConversation?.id !== conversation.id) {
|
||||
e.currentTarget.style.background = 'transparent';
|
||||
e.currentTarget.style.borderColor = '#f0f0f0';
|
||||
}
|
||||
}}
|
||||
>
|
||||
<List.Item.Meta
|
||||
avatar={
|
||||
<Avatar
|
||||
icon={<RobotOutlined />}
|
||||
style={{ backgroundColor: '#1890ff' }}
|
||||
/>
|
||||
}
|
||||
title={
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Text strong ellipsis style={{ maxWidth: '140px' }}>
|
||||
{conversation.title}
|
||||
</Text>
|
||||
<Tag color={conversation.botType === 'feishu' ? 'blue' : 'green'}>
|
||||
{conversation.botType === 'feishu' ? '飞书' : '其他'}
|
||||
</Tag>
|
||||
</div>
|
||||
}
|
||||
description={
|
||||
<div>
|
||||
<Text ellipsis style={{ fontSize: '11px', color: '#666' }}>
|
||||
{conversation.lastMessage}
|
||||
</Text>
|
||||
<Text style={{ fontSize: '10px', color: '#999', display: 'block', marginTop: '2px' }}>
|
||||
{new Date(conversation.timestamp).toLocaleString()}
|
||||
</Text>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</Sider>
|
||||
|
||||
{/* 右侧会话内容 */}
|
||||
<Content style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
{selectedConversation ? (
|
||||
<>
|
||||
{/* 会话头部 */}
|
||||
<div style={{
|
||||
padding: '16px 24px',
|
||||
borderBottom: '1px solid #f0f0f0',
|
||||
background: '#fafafa'
|
||||
}}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Space>
|
||||
<Avatar icon={<RobotOutlined />} style={{ backgroundColor: '#1890ff' }} />
|
||||
<div>
|
||||
<Title level={4} style={{ margin: 0 }}>
|
||||
{selectedConversation.title}
|
||||
</Title>
|
||||
<Text type="secondary" style={{ fontSize: '12px' }}>
|
||||
飞书机器人:{selectedConversation.botName}
|
||||
</Text>
|
||||
</div>
|
||||
</Space>
|
||||
<Popconfirm
|
||||
title="确认删除"
|
||||
description="确定要删除这个会话吗?"
|
||||
onConfirm={() => handleDeleteConversation(selectedConversation.id)}
|
||||
okText="确定"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Button danger icon={<DeleteOutlined />}>
|
||||
删除会话
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 消息列表 */}
|
||||
<div style={{
|
||||
flex: 1,
|
||||
padding: '24px',
|
||||
overflowY: 'auto',
|
||||
background: 'white'
|
||||
}}>
|
||||
{selectedConversation.messages.length === 0 ? (
|
||||
<div style={{
|
||||
textAlign: 'center',
|
||||
padding: '100px 0',
|
||||
color: '#999'
|
||||
}}>
|
||||
<MessageOutlined style={{ fontSize: '64px', color: '#d9d9d9', marginBottom: '16px' }} />
|
||||
<div>暂无消息,开始与机器人对话吧!</div>
|
||||
</div>
|
||||
) : (
|
||||
<Space direction="vertical" style={{ width: '100%' }} size={16}>
|
||||
{selectedConversation.messages.map((message) => (
|
||||
<div
|
||||
key={message.id}
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: message.role === 'user' ? 'flex-end' : 'flex-start',
|
||||
marginBottom: '16px'
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
maxWidth: '70%',
|
||||
display: 'flex',
|
||||
gap: '8px',
|
||||
alignItems: 'flex-start'
|
||||
}}>
|
||||
{message.role === 'assistant' && (
|
||||
<Avatar icon={<RobotOutlined />} style={{ backgroundColor: '#1890ff' }} />
|
||||
)}
|
||||
<div>
|
||||
{message.role === 'assistant' && (
|
||||
<Text type="secondary" style={{ fontSize: '12px', marginLeft: '8px' }}>
|
||||
{selectedConversation.botName}
|
||||
</Text>
|
||||
)}
|
||||
<div style={{
|
||||
background: message.role === 'user' ? '#1890ff' : '#f5f5f5',
|
||||
color: message.role === 'user' ? 'white' : 'black',
|
||||
padding: '12px 16px',
|
||||
borderRadius: '8px',
|
||||
marginTop: '4px',
|
||||
wordBreak: 'break-word'
|
||||
}}>
|
||||
{message.content}
|
||||
</div>
|
||||
<Text type="secondary" style={{ fontSize: '11px', marginTop: '4px', display: 'block' }}>
|
||||
{new Date(message.timestamp).toLocaleString()}
|
||||
</Text>
|
||||
</div>
|
||||
{message.role === 'user' && (
|
||||
<Avatar icon={<UserOutlined />} style={{ backgroundColor: '#52c41a' }} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</Space>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 消息输入区 */}
|
||||
<div style={{
|
||||
padding: '16px 24px',
|
||||
borderTop: '1px solid #f0f0f0',
|
||||
background: '#fafafa'
|
||||
}}>
|
||||
<Space.Compact style={{ width: '100%' }}>
|
||||
<TextArea
|
||||
value={messageInput}
|
||||
onChange={(e) => setMessageInput(e.target.value)}
|
||||
placeholder="输入消息,按Enter发送,Shift+Enter换行"
|
||||
autoSize={{ minRows: 1, maxRows: 4 }}
|
||||
onPressEnter={(e) => {
|
||||
if (!e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSendMessage();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<SendOutlined />}
|
||||
onClick={handleSendMessage}
|
||||
style={{ height: 'auto' }}
|
||||
>
|
||||
发送
|
||||
</Button>
|
||||
</Space.Compact>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div style={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: 'white'
|
||||
}}>
|
||||
<div style={{ textAlign: 'center', color: '#999' }}>
|
||||
<MessageOutlined style={{ fontSize: '64px', color: '#d9d9d9', marginBottom: '16px' }} />
|
||||
<div style={{ fontSize: '16px', marginBottom: '16px' }}>
|
||||
选择左侧的飞书应用开始对话
|
||||
</div>
|
||||
{botConfigs.length === 0 && (
|
||||
<div>
|
||||
<div style={{ marginBottom: '16px' }}>或先添加飞书应用配置</div>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={handleAddBot}
|
||||
>
|
||||
添加飞书应用
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Content>
|
||||
</Layout>
|
||||
|
||||
{/* 飞书应用配置弹窗 */}
|
||||
<Modal
|
||||
title={
|
||||
<Space>
|
||||
<RobotOutlined style={{ color: '#1890ff' }} />
|
||||
{editingBot ? '编辑飞书应用配置' : '添加飞书自建应用'}
|
||||
</Space>
|
||||
}
|
||||
open={showBotModal}
|
||||
onOk={handleSubmitBot}
|
||||
onCancel={() => {
|
||||
setShowBotModal(false);
|
||||
form.resetFields();
|
||||
setEditingBot(null);
|
||||
}}
|
||||
width={700}
|
||||
okText="确定"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Alert
|
||||
message="飞书自建应用配置说明"
|
||||
description={
|
||||
<div>
|
||||
<p>1. 在飞书开放平台创建自建应用</p>
|
||||
<p>2. 在「凭证与基础信息」页面复制 App ID 和 App Secret</p>
|
||||
<p>3. 在「权限管理」配置必要权限(如 im:message、im:resource 等)</p>
|
||||
</div>
|
||||
}
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: '16px' }}
|
||||
/>
|
||||
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
style={{ marginTop: '24px' }}
|
||||
initialValues={{
|
||||
type: 'feishu'
|
||||
}}
|
||||
>
|
||||
<Form.Item
|
||||
name="name"
|
||||
label="应用名称"
|
||||
rules={[{ required: true, message: '请输入应用名称' }]}
|
||||
>
|
||||
<Input placeholder="例如:AI助手" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="type"
|
||||
label="应用类型"
|
||||
rules={[{ required: true, message: '请选择应用类型' }]}
|
||||
>
|
||||
<Select>
|
||||
<Select.Option value="feishu">飞书自建应用</Select.Option>
|
||||
<Select.Option value="other">其他应用</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="appId"
|
||||
label="App ID"
|
||||
rules={[{ required: true, message: '请输入 App ID' }]}
|
||||
>
|
||||
<Input
|
||||
placeholder="cli_xxxxxxxxxxxxx"
|
||||
prefix={<span style={{ color: '#999' }}>App ID:</span>}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="appSecret"
|
||||
label="App Secret"
|
||||
rules={[{ required: true, message: '请输入 App Secret' }]}
|
||||
>
|
||||
<Input.Password
|
||||
placeholder="xxxxxxxxxxxxx"
|
||||
prefix={<span style={{ color: '#999' }}>Secret:</span>}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="description"
|
||||
label="应用描述"
|
||||
>
|
||||
<TextArea
|
||||
placeholder="描述应用的用途和功能"
|
||||
rows={3}
|
||||
showCount
|
||||
maxLength={200}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
{/* 新建会话弹窗 */}
|
||||
<Modal
|
||||
title={
|
||||
<Space>
|
||||
<MessageOutlined style={{ color: '#1890ff' }} />
|
||||
新建会话
|
||||
</Space>
|
||||
}
|
||||
open={showConversationModal}
|
||||
onOk={handleSubmitConversation}
|
||||
onCancel={() => {
|
||||
setShowConversationModal(false);
|
||||
conversationForm.resetFields();
|
||||
}}
|
||||
okText="创建"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Form
|
||||
form={conversationForm}
|
||||
layout="vertical"
|
||||
style={{ marginTop: '24px' }}
|
||||
>
|
||||
<Form.Item
|
||||
name="title"
|
||||
label="会话标题"
|
||||
rules={[{ required: true, message: '请输入会话标题' }]}
|
||||
>
|
||||
<Input placeholder="例如:工作安排助手" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="botId"
|
||||
label="选择飞书应用"
|
||||
rules={[{ required: true, message: '请选择飞书应用' }]}
|
||||
>
|
||||
<Select placeholder="选择要使用的飞书应用">
|
||||
{botConfigs.map(bot => (
|
||||
<Select.Option key={bot.id} value={bot.id}>
|
||||
<Space>
|
||||
<RobotOutlined />
|
||||
{bot.name}
|
||||
<Tag color={bot.type === 'feishu' ? 'blue' : 'green'}>
|
||||
{bot.type === 'feishu' ? '飞书' : '其他'}
|
||||
</Tag>
|
||||
</Space>
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConversationsPage;
|
||||
816
src/pages/InitPage.tsx
Normal file
816
src/pages/InitPage.tsx
Normal file
@@ -0,0 +1,816 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Card,
|
||||
Button,
|
||||
Typography,
|
||||
Alert,
|
||||
Space,
|
||||
Modal,
|
||||
message,
|
||||
Divider,
|
||||
Row,
|
||||
Col,
|
||||
Statistic,
|
||||
Progress,
|
||||
Steps,
|
||||
Descriptions,
|
||||
Input
|
||||
} from 'antd';
|
||||
import {
|
||||
ClearOutlined,
|
||||
ReloadOutlined,
|
||||
CheckCircleOutlined,
|
||||
ExclamationCircleOutlined,
|
||||
DatabaseOutlined,
|
||||
SettingOutlined,
|
||||
DeleteOutlined,
|
||||
SafetyOutlined,
|
||||
RocketOutlined,
|
||||
CloudUploadOutlined,
|
||||
FundOutlined
|
||||
} from '@ant-design/icons';
|
||||
|
||||
const { Title, Text, Paragraph } = Typography;
|
||||
|
||||
const InitPage: React.FC = () => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [currentStep, setCurrentStep] = useState(0);
|
||||
const [initStatus, setInitStatus] = useState<{
|
||||
step: number;
|
||||
message: string;
|
||||
status: 'process' | 'finish' | 'error' | 'wait';
|
||||
}>({ step: 0, message: '等待初始化...', status: 'wait' });
|
||||
const [dbStats, setDbStats] = useState({
|
||||
novels: 0,
|
||||
chapters: 0,
|
||||
settings: 0
|
||||
});
|
||||
const [showConfirmModal, setShowConfirmModal] = useState(false);
|
||||
const [isFirstRun, setIsFirstRun] = useState(false);
|
||||
const [initMode, setInitMode] = useState<'reset' | 'setup'>('reset');
|
||||
|
||||
const getDbStats = async () => {
|
||||
try {
|
||||
const { storage } = await import('../utils/indexedDB');
|
||||
const novels = await storage.getNovels();
|
||||
const settings = localStorage.getItem('ollamaSettings');
|
||||
|
||||
let totalChapters = 0;
|
||||
for (const novel of novels) {
|
||||
const chapters = await storage.getChapters(novel.id);
|
||||
totalChapters += chapters.length;
|
||||
}
|
||||
|
||||
setDbStats({
|
||||
novels: novels.length,
|
||||
chapters: totalChapters,
|
||||
settings: settings ? 1 : 0
|
||||
});
|
||||
|
||||
// 检查是否是首次运行
|
||||
const hasRunBefore = localStorage.getItem('aiNovelInitialized');
|
||||
const isFirstRunDetected = !hasRunBefore && novels.length === 0 && !settings;
|
||||
setIsFirstRun(isFirstRunDetected);
|
||||
|
||||
if (isFirstRunDetected) {
|
||||
setInitMode('setup');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取数据库统计失败:', error);
|
||||
// 如果获取失败,可能是首次运行
|
||||
const hasRunBefore = localStorage.getItem('aiNovelInitialized');
|
||||
if (!hasRunBefore) {
|
||||
setIsFirstRun(true);
|
||||
setInitMode('setup');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
getDbStats();
|
||||
}, []);
|
||||
|
||||
const handleInit = async () => {
|
||||
setShowConfirmModal(true);
|
||||
};
|
||||
|
||||
const confirmInit = async () => {
|
||||
setShowConfirmModal(false);
|
||||
setLoading(true);
|
||||
setCurrentStep(0);
|
||||
|
||||
try {
|
||||
if (initMode === 'setup') {
|
||||
// 首次安装模式
|
||||
await firstTimeSetup();
|
||||
} else {
|
||||
// 重置模式
|
||||
await systemReset();
|
||||
}
|
||||
|
||||
// 标记系统已初始化
|
||||
localStorage.setItem('aiNovelInitialized', new Date().toISOString());
|
||||
|
||||
setCurrentStep(currentStep + 1);
|
||||
message.success(initMode === 'setup' ? '系统配置完成!' : '系统重置完成!');
|
||||
|
||||
// 重新获取统计数据
|
||||
setTimeout(() => {
|
||||
getDbStats();
|
||||
}, 1000);
|
||||
|
||||
} catch (error: any) {
|
||||
setInitStatus({
|
||||
step: currentStep + 1,
|
||||
message: `初始化失败: ${error.message}`,
|
||||
status: 'error'
|
||||
});
|
||||
message.error('系统初始化失败,请查看错误信息');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const firstTimeSetup = async () => {
|
||||
const setupSteps = [
|
||||
// 步骤1:初始化IndexedDB
|
||||
{
|
||||
step: 0,
|
||||
message: '正在初始化IndexedDB数据库...',
|
||||
action: async () => {
|
||||
await reinitializeIndexedDB();
|
||||
}
|
||||
},
|
||||
// 步骤2:创建默认配置
|
||||
{
|
||||
step: 1,
|
||||
message: '正在创建默认配置...',
|
||||
action: async () => {
|
||||
restoreDefaultSettings();
|
||||
}
|
||||
},
|
||||
// 步骤3:初始化技能库
|
||||
{
|
||||
step: 2,
|
||||
message: '正在初始化技能库...',
|
||||
action: async () => {
|
||||
await initializeSkills();
|
||||
}
|
||||
},
|
||||
// 步骤4:配置系统参数
|
||||
{
|
||||
step: 3,
|
||||
message: '正在配置系统参数...',
|
||||
action: async () => {
|
||||
await configureSystem();
|
||||
}
|
||||
},
|
||||
// 步骤5:验证系统状态
|
||||
{
|
||||
step: 4,
|
||||
message: '正在验证系统状态...',
|
||||
action: async () => {
|
||||
await verifySystem();
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
for (const { step, message, action } of setupSteps) {
|
||||
setCurrentStep(step);
|
||||
setInitStatus({ step: step + 1, message, status: 'process' });
|
||||
|
||||
await action();
|
||||
|
||||
setInitStatus({
|
||||
step: step + 1,
|
||||
message: `${message.replace('正在', '')}完成`,
|
||||
status: 'finish'
|
||||
});
|
||||
await sleep(800);
|
||||
}
|
||||
};
|
||||
|
||||
const systemReset = async () => {
|
||||
// 步骤1:清除IndexedDB数据
|
||||
setCurrentStep(0);
|
||||
setInitStatus({ step: 1, message: '正在清除IndexedDB数据...', status: 'process' });
|
||||
|
||||
await clearIndexedDB();
|
||||
|
||||
setInitStatus({ step: 1, message: 'IndexedDB数据清除完成', status: 'finish' });
|
||||
await sleep(500);
|
||||
|
||||
// 步骤2:清除本地存储配置
|
||||
setCurrentStep(1);
|
||||
setInitStatus({ step: 2, message: '正在清除本地存储配置...', status: 'process' });
|
||||
|
||||
clearLocalStorage();
|
||||
|
||||
setInitStatus({ step: 2, message: '本地存储配置清除完成', status: 'finish' });
|
||||
await sleep(500);
|
||||
|
||||
// 步骤3:重新初始化IndexedDB
|
||||
setCurrentStep(2);
|
||||
setInitStatus({ step: 3, message: '正在重新初始化IndexedDB...', status: 'process' });
|
||||
|
||||
await reinitializeIndexedDB();
|
||||
|
||||
setInitStatus({ step: 3, message: 'IndexedDB重新初始化完成', status: 'finish' });
|
||||
await sleep(500);
|
||||
|
||||
// 步骤4:恢复默认配置
|
||||
setCurrentStep(3);
|
||||
setInitStatus({ step: 4, message: '正在恢复默认配置...', status: 'process' });
|
||||
|
||||
restoreDefaultSettings();
|
||||
|
||||
setInitStatus({ step: 4, message: '默认配置恢复完成', status: 'finish' });
|
||||
};
|
||||
|
||||
const clearIndexedDB = async () => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.deleteDatabase('AINovelDatabase');
|
||||
|
||||
request.onsuccess = () => {
|
||||
console.log('IndexedDB删除成功');
|
||||
resolve(true);
|
||||
};
|
||||
|
||||
request.onerror = () => {
|
||||
console.error('IndexedDB删除失败');
|
||||
reject(new Error('IndexedDB删除失败'));
|
||||
};
|
||||
|
||||
request.onblocked = () => {
|
||||
console.warn('IndexedDB删除被阻止,正在重试...');
|
||||
setTimeout(() => {
|
||||
clearIndexedDB().then(resolve).catch(reject);
|
||||
}, 1000);
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const clearLocalStorage = () => {
|
||||
// 清除所有Ollama相关的设置
|
||||
const keysToRemove: string[] = [];
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const key = localStorage.key(i);
|
||||
if (key && (key.includes('ollama') || key.includes('model') || key.includes('settings'))) {
|
||||
keysToRemove.push(key);
|
||||
}
|
||||
}
|
||||
keysToRemove.forEach(key => localStorage.removeItem(key));
|
||||
console.log('清除了', keysToRemove.length, '个本地存储项');
|
||||
};
|
||||
|
||||
const reinitializeIndexedDB = async () => {
|
||||
const { indexedDBStorage } = await import('../utils/indexedDB');
|
||||
await indexedDBStorage.init();
|
||||
console.log('IndexedDB重新初始化完成');
|
||||
};
|
||||
|
||||
const restoreDefaultSettings = () => {
|
||||
// 恢复默认的Ollama设置
|
||||
const defaultSettings = {
|
||||
apiUrl: 'http://localhost:11434',
|
||||
model: '',
|
||||
temperature: 0.7,
|
||||
topP: 0.9,
|
||||
maxTokens: 2000
|
||||
};
|
||||
|
||||
localStorage.setItem('ollamaSettings', JSON.stringify(defaultSettings));
|
||||
console.log('默认配置恢复完成');
|
||||
};
|
||||
|
||||
const initializeSkills = async () => {
|
||||
// 初始化默认技能库
|
||||
const defaultSkills = [
|
||||
{
|
||||
id: 'skill_1',
|
||||
name: '小说设定生成',
|
||||
description: '根据用户的小说创意,自动生成完整的小说设定',
|
||||
category: '创作辅助',
|
||||
prompt: '你是一个专业的小说设定助手...',
|
||||
createdAt: new Date().toISOString()
|
||||
},
|
||||
{
|
||||
id: 'skill_2',
|
||||
name: '章节内容创作',
|
||||
description: '根据章节标题和细纲,自动创作具体的章节内容',
|
||||
category: '内容创作',
|
||||
prompt: '你是一个专业的小说作家...',
|
||||
createdAt: new Date().toISOString()
|
||||
}
|
||||
];
|
||||
|
||||
// 可以存储到 localStorage 或 IndexedDB
|
||||
localStorage.setItem('defaultSkills', JSON.stringify(defaultSkills));
|
||||
console.log('技能库初始化完成');
|
||||
};
|
||||
|
||||
const configureSystem = async () => {
|
||||
// 配置其他系统参数
|
||||
const systemConfig = {
|
||||
version: '0.0.1',
|
||||
theme: 'light',
|
||||
language: 'zh-CN',
|
||||
autoSave: true,
|
||||
maxHistory: 50
|
||||
};
|
||||
|
||||
localStorage.setItem('systemConfig', JSON.stringify(systemConfig));
|
||||
console.log('系统参数配置完成');
|
||||
};
|
||||
|
||||
const verifySystem = async () => {
|
||||
// 验证各个组件是否正常工作
|
||||
const checks = [
|
||||
{
|
||||
name: 'IndexedDB',
|
||||
check: async () => {
|
||||
const { indexedDBStorage } = await import('../utils/indexedDB');
|
||||
await indexedDBStorage.init();
|
||||
return true;
|
||||
}
|
||||
},
|
||||
{
|
||||
name: '本地存储',
|
||||
check: () => {
|
||||
try {
|
||||
localStorage.setItem('test', 'test');
|
||||
localStorage.removeItem('test');
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
for (const { name, check } of checks) {
|
||||
const result = await check();
|
||||
if (!result) {
|
||||
throw new Error(`${name}验证失败`);
|
||||
}
|
||||
console.log(`${name}验证通过`);
|
||||
}
|
||||
};
|
||||
|
||||
const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
|
||||
|
||||
const getSteps = () => {
|
||||
if (initMode === 'setup') {
|
||||
return [
|
||||
{
|
||||
title: '初始化数据库',
|
||||
description: '创建IndexedDB数据库和存储结构',
|
||||
icon: <DatabaseOutlined />
|
||||
},
|
||||
{
|
||||
title: '创建默认配置',
|
||||
description: '设置系统默认的Ollama配置',
|
||||
icon: <SettingOutlined />
|
||||
},
|
||||
{
|
||||
title: '初始化技能库',
|
||||
description: '加载默认的AI创作技能',
|
||||
icon: <FundOutlined />
|
||||
},
|
||||
{
|
||||
title: '配置系统参数',
|
||||
description: '设置主题、语言等系统参数',
|
||||
icon: <CloudUploadOutlined />
|
||||
},
|
||||
{
|
||||
title: '验证系统状态',
|
||||
description: '检查所有组件是否正常工作',
|
||||
icon: <SafetyOutlined />
|
||||
}
|
||||
];
|
||||
} else {
|
||||
return [
|
||||
{
|
||||
title: '清除IndexedDB',
|
||||
description: '删除所有存储的小说、章节等数据',
|
||||
icon: <DatabaseOutlined />
|
||||
},
|
||||
{
|
||||
title: '清除本地配置',
|
||||
description: '清除所有本地存储的配置信息',
|
||||
icon: <DeleteOutlined />
|
||||
},
|
||||
{
|
||||
title: '重新初始化',
|
||||
description: '重新创建数据库和存储结构',
|
||||
icon: <ReloadOutlined />
|
||||
},
|
||||
{
|
||||
title: '恢复默认配置',
|
||||
description: '设置系统的默认配置参数',
|
||||
icon: <SettingOutlined />
|
||||
}
|
||||
];
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ marginBottom: '24px' }}>
|
||||
<Title level={2} style={{ margin: 0, display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||
{initMode === 'setup' ? (
|
||||
<RocketOutlined style={{ color: '#52c41a' }} />
|
||||
) : (
|
||||
<ClearOutlined style={{ color: '#1890ff' }} />
|
||||
)}
|
||||
{initMode === 'setup' ? '系统初始化配置' : '系统重置'}
|
||||
</Title>
|
||||
<Text type="secondary" style={{ marginTop: '8px', display: 'block' }}>
|
||||
{initMode === 'setup'
|
||||
? '首次使用本系统,一键配置所有必要的组件和参数'
|
||||
: '一键重置系统所有数据和配置到初始状态'}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{initMode === 'setup' ? (
|
||||
<Alert
|
||||
message="欢迎使用AI小说创作系统"
|
||||
description="检测到您是首次使用本系统,我们将为您自动配置必要的组件和设置,整个过程大约需要10-20秒。"
|
||||
type="info"
|
||||
showIcon
|
||||
icon={<RocketOutlined />}
|
||||
style={{ marginBottom: '24px' }}
|
||||
/>
|
||||
) : (
|
||||
<Alert
|
||||
message="危险操作警告"
|
||||
description="此操作将清除所有数据,包括小说、章节、配置等,且不可恢复。请谨慎操作!"
|
||||
type="warning"
|
||||
showIcon
|
||||
icon={<ExclamationCircleOutlined />}
|
||||
style={{ marginBottom: '24px' }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Row gutter={16} style={{ marginBottom: '24px' }}>
|
||||
<Col span={8}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="小说数量"
|
||||
value={dbStats.novels}
|
||||
prefix={<DatabaseOutlined />}
|
||||
valueStyle={{ color: dbStats.novels > 0 ? '#1890ff' : '#999' }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="章节数量"
|
||||
value={dbStats.chapters}
|
||||
prefix={<DatabaseOutlined />}
|
||||
valueStyle={{ color: dbStats.chapters > 0 ? '#52c41a' : '#999' }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="配置文件"
|
||||
value={dbStats.settings}
|
||||
prefix={<SettingOutlined />}
|
||||
valueStyle={{ color: dbStats.settings > 0 ? '#faad14' : '#999' }}
|
||||
suffix="个"
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Card
|
||||
title="初始化进度"
|
||||
extra={
|
||||
<Button
|
||||
type={initMode === 'setup' ? 'primary' : 'primary'}
|
||||
danger={initMode !== 'setup'}
|
||||
icon={initMode === 'setup' ? <RocketOutlined /> : <ClearOutlined />}
|
||||
onClick={handleInit}
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
size="large"
|
||||
>
|
||||
{loading
|
||||
? (initMode === 'setup' ? '配置中...' : '初始化中...')
|
||||
: (initMode === 'setup' ? '开始配置' : '开始初始化')}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{loading && (
|
||||
<div style={{ marginBottom: '24px' }}>
|
||||
<Steps
|
||||
current={currentStep}
|
||||
status={initStatus.status}
|
||||
items={getSteps().map((step, index) => ({
|
||||
...step,
|
||||
status: index < currentStep ? 'finish' :
|
||||
index === currentStep ? initStatus.status : 'wait'
|
||||
}))}
|
||||
/>
|
||||
|
||||
<Divider />
|
||||
|
||||
<div style={{
|
||||
padding: '16px',
|
||||
background: initStatus.status === 'error' ? '#fff2f0' :
|
||||
initStatus.status === 'finish' ? '#f6ffed' : '#e6f7ff',
|
||||
borderRadius: '8px',
|
||||
border: `1px solid ${initStatus.status === 'error' ? '#ffccc7' :
|
||||
initStatus.status === 'finish' ? '#b7eb8f' : '#91d5ff'}`
|
||||
}}>
|
||||
<Space>
|
||||
{initStatus.status === 'process' && <ReloadOutlined spin style={{ color: '#1890ff' }} />}
|
||||
{initStatus.status === 'finish' && <CheckCircleOutlined style={{ color: '#52c41a' }} />}
|
||||
{initStatus.status === 'error' && <ExclamationCircleOutlined style={{ color: '#ff4d4f' }} />}
|
||||
<Text strong>{initStatus.message}</Text>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
{currentStep < getSteps().length && (
|
||||
<div style={{ marginTop: '16px' }}>
|
||||
<Progress
|
||||
percent={Math.round(((currentStep + 1) / getSteps().length) * 100)}
|
||||
status={initStatus.status === 'error' ? 'exception' : 'active'}
|
||||
strokeColor={{
|
||||
'0%': '#108ee9',
|
||||
'100%': '#87d068',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && currentStep === 0 && (
|
||||
<div>
|
||||
{initMode === 'setup' ? (
|
||||
<div>
|
||||
<Descriptions title="首次配置将执行以下操作" bordered column={1}>
|
||||
<Descriptions.Item
|
||||
label={<Space><DatabaseOutlined />初始化数据库</Space>}
|
||||
>
|
||||
创建IndexedDB数据库和存储结构,用于存储小说、章节等数据
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item
|
||||
label={<Space><SettingOutlined />创建默认配置</Space>}
|
||||
>
|
||||
设置Ollama服务连接参数和AI模型配置
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item
|
||||
label={<Space><FundOutlined />初始化技能库</Space>}
|
||||
>
|
||||
加载默认的AI创作技能模板,如小说设定生成、章节创作等
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item
|
||||
label={<Space><CloudUploadOutlined />配置系统参数</Space>}
|
||||
>
|
||||
设置界面主题、语言、自动保存等系统参数
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item
|
||||
label={<Space><SafetyOutlined />验证系统状态</Space>}
|
||||
>
|
||||
检查数据库连接、本地存储等组件是否正常工作
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Alert
|
||||
message="配置完成后您将获得"
|
||||
description={
|
||||
<ul style={{ margin: '8px 0', paddingLeft: '20px' }}>
|
||||
<li>完整的数据存储系统</li>
|
||||
<li>预配置的AI模型连接参数</li>
|
||||
<li>丰富的AI创作技能库</li>
|
||||
<li>优化的系统使用体验</li>
|
||||
</ul>
|
||||
}
|
||||
type="success"
|
||||
showIcon
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<Descriptions title="初始化将执行以下操作" bordered column={1}>
|
||||
<Descriptions.Item
|
||||
label={<Space><DatabaseOutlined />清除IndexedDB</Space>}
|
||||
>
|
||||
删除所有存储的小说、章节、草稿等数据
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item
|
||||
label={<Space><DeleteOutlined />清除本地配置</Space>}
|
||||
>
|
||||
清除所有本地存储的Ollama设置和用户配置
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item
|
||||
label={<Space><ReloadOutlined />重新初始化</Space>}
|
||||
>
|
||||
重新创建数据库结构和存储对象
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item
|
||||
label={<Space><SettingOutlined />恢复默认配置</Space>}
|
||||
>
|
||||
设置系统默认的参数配置
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Alert
|
||||
message="建议在以下情况下执行初始化"
|
||||
description={
|
||||
<ul style={{ margin: '8px 0', paddingLeft: '20px' }}>
|
||||
<li>系统出现异常数据错误</li>
|
||||
<li>数据库损坏或无法正常访问</li>
|
||||
<li>需要完全清除所有数据重新开始</li>
|
||||
<li>升级到不兼容的新版本</li>
|
||||
</ul>
|
||||
}
|
||||
type="info"
|
||||
showIcon
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentStep === getSteps().length && initStatus.status === 'finish' && (
|
||||
<div>
|
||||
<Alert
|
||||
message={initMode === 'setup' ? '系统配置完成!' : '系统初始化成功'}
|
||||
description={initMode === 'setup'
|
||||
? '所有必要的组件和参数已配置完成,系统现在可以正常使用了。'
|
||||
: '所有数据和配置已重置到初始状态,系统现在可以正常使用了。'}
|
||||
type="success"
|
||||
showIcon
|
||||
style={{ marginBottom: '16px' }}
|
||||
/>
|
||||
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
{initMode === 'setup' ? (
|
||||
<>
|
||||
<Card size="small" style={{ background: '#f6ffed', border: '1px solid #b7eb8f' }}>
|
||||
<Space>
|
||||
<CheckCircleOutlined style={{ color: '#52c41a', fontSize: '20px' }} />
|
||||
<div>
|
||||
<div style={{ fontWeight: 'bold' }}>数据库已初始化</div>
|
||||
<div style={{ fontSize: '12px', color: '#666' }}>IndexedDB数据库和存储结构已创建完成</div>
|
||||
</div>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
<Card size="small" style={{ background: '#f6ffed', border: '1px solid #b7eb8f' }}>
|
||||
<Space>
|
||||
<CheckCircleOutlined style={{ color: '#52c41a', fontSize: '20px' }} />
|
||||
<div>
|
||||
<div style={{ fontWeight: 'bold' }}>配置已设置</div>
|
||||
<div style={{ fontSize: '12px', color: '#666' }}>Ollama服务和AI模型配置已设置完成</div>
|
||||
</div>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
<Card size="small" style={{ background: '#f6ffed', border: '1px solid #b7eb8f' }}>
|
||||
<Space>
|
||||
<CheckCircleOutlined style={{ color: '#52c41a', fontSize: '20px' }} />
|
||||
<div>
|
||||
<div style={{ fontWeight: 'bold' }}>技能库已加载</div>
|
||||
<div style={{ fontSize: '12px', color: '#666' }}>默认AI创作技能模板已加载完成</div>
|
||||
</div>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
<Card size="small" style={{ background: '#f6ffed', border: '1px solid #b7eb8f' }}>
|
||||
<Space>
|
||||
<CheckCircleOutlined style={{ color: '#52c41a', fontSize: '20px' }} />
|
||||
<div>
|
||||
<div style={{ fontWeight: 'bold' }}>系统已就绪</div>
|
||||
<div style={{ fontSize: '12px', color: '#666' }}>所有组件运行正常,可以开始创作之旅</div>
|
||||
</div>
|
||||
</Space>
|
||||
</Card>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Card size="small" style={{ background: '#f6ffed', border: '1px solid #b7eb8f' }}>
|
||||
<Space>
|
||||
<CheckCircleOutlined style={{ color: '#52c41a', fontSize: '20px' }} />
|
||||
<div>
|
||||
<div style={{ fontWeight: 'bold' }}>IndexedDB已重置</div>
|
||||
<div style={{ fontSize: '12px', color: '#666' }}>数据库已重新创建,可以开始新的创作</div>
|
||||
</div>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
<Card size="small" style={{ background: '#f6ffed', border: '1px solid #b7eb8f' }}>
|
||||
<Space>
|
||||
<CheckCircleOutlined style={{ color: '#52c41a', fontSize: '20px' }} />
|
||||
<div>
|
||||
<div style={{ fontWeight: 'bold' }}>配置已恢复</div>
|
||||
<div style={{ fontSize: '12px', color: '#666' }}>系统配置已恢复到默认设置</div>
|
||||
</div>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
<Card size="small" style={{ background: '#f6ffed', border: '1px solid #b7eb8f' }}>
|
||||
<Space>
|
||||
<SafetyOutlined style={{ color: '#52c41a', fontSize: '20px' }} />
|
||||
<div>
|
||||
<div style={{ fontWeight: 'bold' }}>系统就绪</div>
|
||||
<div style={{ fontSize: '12px', color: '#666' }}>所有组件运行正常,可以开始使用</div>
|
||||
</div>
|
||||
</Space>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
</Space>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
title={
|
||||
<Space>
|
||||
{initMode === 'setup' ? (
|
||||
<RocketOutlined style={{ color: '#52c41a' }} />
|
||||
) : (
|
||||
<ExclamationCircleOutlined style={{ color: '#faad14' }} />
|
||||
)}
|
||||
{initMode === 'setup' ? '确认开始系统配置' : '确认初始化系统'}
|
||||
</Space>
|
||||
}
|
||||
open={showConfirmModal}
|
||||
onOk={confirmInit}
|
||||
onCancel={() => setShowConfirmModal(false)}
|
||||
okText={initMode === 'setup' ? '开始配置' : '确认初始化'}
|
||||
cancelText="取消"
|
||||
okButtonProps={{ danger: initMode !== 'setup' }}
|
||||
>
|
||||
{initMode === 'setup' ? (
|
||||
<Alert
|
||||
message="准备开始系统配置"
|
||||
description={
|
||||
<div>
|
||||
<p>系统将为您自动配置以下组件:</p>
|
||||
<ul style={{ margin: '8px 0', paddingLeft: '20px' }}>
|
||||
<li>创建IndexedDB数据库结构</li>
|
||||
<li>设置Ollama服务连接参数</li>
|
||||
<li>加载默认AI创作技能库</li>
|
||||
<li>配置系统界面和功能参数</li>
|
||||
<li>验证所有组件正常工作</li>
|
||||
</ul>
|
||||
<p style={{ color: '#52c41a', fontWeight: 'bold' }}>
|
||||
整个过程大约需要10-20秒,请耐心等待。
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
type="info"
|
||||
showIcon
|
||||
/>
|
||||
) : (
|
||||
<Alert
|
||||
message="此操作不可逆"
|
||||
description={
|
||||
<div>
|
||||
<p>您即将执行系统初始化操作,这将:</p>
|
||||
<ul style={{ margin: '8px 0', paddingLeft: '20px' }}>
|
||||
<li><strong>永久删除</strong>所有小说数据({dbStats.novels}个小说)</li>
|
||||
<li><strong>永久删除</strong>所有章节内容({dbStats.chapters}个章节)</li>
|
||||
<li><strong>清除所有</strong>配置和设置</li>
|
||||
<li><strong>重置系统</strong>到初始状态</li>
|
||||
</ul>
|
||||
<p style={{ color: '#ff4d4f', fontWeight: 'bold' }}>
|
||||
这些数据将无法恢复!请确保已做好数据备份。
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
type="error"
|
||||
showIcon
|
||||
/>
|
||||
)}
|
||||
|
||||
{initMode !== 'setup' && (
|
||||
<div style={{ marginTop: '16px' }}>
|
||||
<Text strong>请输入 "CONFIRM" 来确认此操作:</Text>
|
||||
<Input.Password
|
||||
placeholder="输入 CONFIRM 确认"
|
||||
onChange={(e) => {
|
||||
// 可以添加额外的确认逻辑
|
||||
}}
|
||||
style={{ marginTop: '8px' }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default InitPage;
|
||||
468
src/pages/ModelSettings.tsx
Normal file
468
src/pages/ModelSettings.tsx
Normal file
@@ -0,0 +1,468 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Card, Form, Input, InputNumber, Button, Space, Divider, Select, Spin, Tag, App } from 'antd';
|
||||
import { ApiOutlined, SaveOutlined, SyncOutlined, CheckCircleOutlined } from '@ant-design/icons';
|
||||
import { OllamaService } from '../utils/ollama';
|
||||
import { useOllama } from '../contexts/OllamaContext';
|
||||
|
||||
const { Option } = Select;
|
||||
|
||||
interface ModelInfo {
|
||||
name: string;
|
||||
size?: number;
|
||||
modified?: string;
|
||||
}
|
||||
|
||||
const ModelSettings: React.FC = () => {
|
||||
const [form] = Form.useForm();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [detectingModels, setDetectingModels] = useState(false);
|
||||
const [availableModels, setAvailableModels] = useState<ModelInfo[]>([]);
|
||||
const [connectionStatus, setConnectionStatus] = useState<'unknown' | 'success' | 'error'>('unknown');
|
||||
const [currentModel, setCurrentModel] = useState<string>('');
|
||||
const { status, refreshModels } = useOllama();
|
||||
const { message: messageApi } = App.useApp();
|
||||
|
||||
useEffect(() => {
|
||||
const loadConfig = async () => {
|
||||
// 从 localStorage 加载配置
|
||||
const configData = localStorage.getItem('ai_system_config');
|
||||
const config = configData ? JSON.parse(configData) : {
|
||||
ollamaUrl: 'http://localhost:11434',
|
||||
model: '',
|
||||
temperature: 0.7,
|
||||
topP: 0.9,
|
||||
maxTokens: 2000
|
||||
};
|
||||
|
||||
form.setFieldsValue(config);
|
||||
setCurrentModel(config.model || '');
|
||||
|
||||
// 如果已连接且有模型,自动显示当前状态
|
||||
if (status.isConnected && status.availableModels.length > 0) {
|
||||
setAvailableModels(status.availableModels);
|
||||
setConnectionStatus('success');
|
||||
}
|
||||
};
|
||||
loadConfig();
|
||||
}, [form, status]);
|
||||
|
||||
const handleDetectModels = async () => {
|
||||
setDetectingModels(true);
|
||||
setConnectionStatus('unknown');
|
||||
try {
|
||||
const values = await form.validateFields(['ollamaUrl']);
|
||||
const ollamaService = new OllamaService(values);
|
||||
|
||||
// 测试连接
|
||||
const isConnected = await ollamaService.testConnection();
|
||||
if (!isConnected) {
|
||||
setConnectionStatus('error');
|
||||
messageApi.open({
|
||||
type: 'error',
|
||||
content: '无法连接到 Ollama 服务,请检查服务地址和状态',
|
||||
});
|
||||
setAvailableModels([]);
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取模型列表
|
||||
const models = await ollamaService.getAvailableModelsWithInfo();
|
||||
setAvailableModels(models);
|
||||
setConnectionStatus('success');
|
||||
|
||||
if (models.length === 0) {
|
||||
messageApi.open({
|
||||
type: 'warning',
|
||||
content: '未检测到已安装的模型,请先使用 ollama pull 命令安装模型',
|
||||
});
|
||||
} else {
|
||||
messageApi.open({
|
||||
type: 'success',
|
||||
content: `成功检测到 ${models.length} 个已安装模型`,
|
||||
});
|
||||
|
||||
// 如果当前模型不在列表中,清空选择
|
||||
if (currentModel && !models.find(m => m.name === currentModel)) {
|
||||
form.setFieldValue('model', undefined);
|
||||
setCurrentModel('');
|
||||
messageApi.open({
|
||||
type: 'warning',
|
||||
content: '当前选择的模型未在本地安装,请重新选择',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 刷新全局状态
|
||||
await refreshModels();
|
||||
} catch (error) {
|
||||
setConnectionStatus('error');
|
||||
messageApi.open({
|
||||
type: 'error',
|
||||
content: '模型检测失败,请检查 Ollama 服务状态',
|
||||
});
|
||||
setAvailableModels([]);
|
||||
} finally {
|
||||
setDetectingModels(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTestConnection = async () => {
|
||||
setTesting(true);
|
||||
setConnectionStatus('unknown');
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
const ollamaService = new OllamaService(values);
|
||||
const isConnected = await ollamaService.testConnection();
|
||||
|
||||
if (isConnected) {
|
||||
setConnectionStatus('success');
|
||||
messageApi.open({
|
||||
type: 'success',
|
||||
content: 'Ollama 服务连接成功!',
|
||||
});
|
||||
await refreshModels();
|
||||
} else {
|
||||
setConnectionStatus('error');
|
||||
messageApi.open({
|
||||
type: 'error',
|
||||
content: 'Ollama 服务连接失败,请检查服务地址',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
setConnectionStatus('error');
|
||||
messageApi.open({
|
||||
type: 'error',
|
||||
content: '连接测试失败,请检查配置',
|
||||
});
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleModelChange = (value: string) => {
|
||||
setCurrentModel(value);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
|
||||
// 验证选择的模型是否可用
|
||||
if (currentModel && availableModels.length > 0) {
|
||||
const modelExists = availableModels.find(m => m.name === currentModel);
|
||||
if (!modelExists) {
|
||||
messageApi.open({
|
||||
type: 'error',
|
||||
content: '请选择本地已安装的模型,或点击"检测模型"刷新列表',
|
||||
});
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 确保包含当前选择的模型
|
||||
const configToSave = {
|
||||
...values,
|
||||
model: currentModel || values.model
|
||||
};
|
||||
|
||||
// 保存到 localStorage
|
||||
localStorage.setItem('ai_system_config', JSON.stringify(configToSave));
|
||||
|
||||
// 显示保存成功提示
|
||||
messageApi.open({
|
||||
type: 'success',
|
||||
content: '配置保存成功!',
|
||||
});
|
||||
|
||||
// 更新当前模型显示
|
||||
setCurrentModel(configToSave.model);
|
||||
|
||||
// 刷新全局状态
|
||||
await refreshModels();
|
||||
} catch (error) {
|
||||
messageApi.open({
|
||||
type: 'error',
|
||||
content: '配置保存失败,请检查输入',
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatModelSize = (bytes?: number) => {
|
||||
if (!bytes) return '未知';
|
||||
const gb = bytes / (1024 * 1024 * 1024);
|
||||
return `${gb.toFixed(2)} GB`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ marginBottom: '24px' }}>
|
||||
<h2 style={{ margin: 0, fontSize: '20px', fontWeight: 600, color: '#262626' }}>模型设置</h2>
|
||||
<p style={{ margin: '8px 0 0 0', color: '#8c8c8c', fontSize: '14px' }}>配置本地 Ollama 服务和 AI 模型参数</p>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
background: 'white',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #f0f0f0',
|
||||
marginBottom: '16px',
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
<div style={{
|
||||
padding: '16px 24px',
|
||||
background: '#fafafa',
|
||||
borderBottom: '1px solid #f0f0f0',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px'
|
||||
}}>
|
||||
<div style={{
|
||||
width: '8px',
|
||||
height: '8px',
|
||||
borderRadius: '50%',
|
||||
background: connectionStatus === 'success' ? '#52c41a' : connectionStatus === 'error' ? '#ff4d4f' : '#faad14'
|
||||
}} />
|
||||
<div>
|
||||
<div style={{ fontWeight: 500, color: '#262626' }}>
|
||||
{connectionStatus === 'success' ? '服务正常' : connectionStatus === 'error' ? '服务异常' : '未检测'}
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: '#8c8c8c' }}>
|
||||
{availableModels.length > 0
|
||||
? `已安装 ${availableModels.length} 个模型`
|
||||
: connectionStatus === 'success'
|
||||
? '未安装模型'
|
||||
: '请先连接服务'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card bordered={false} style={{ boxShadow: 'none' }}>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
initialValues={{
|
||||
ollamaUrl: 'http://localhost:11434',
|
||||
model: '',
|
||||
temperature: 0.7,
|
||||
topP: 0.9,
|
||||
maxTokens: 2000
|
||||
}}
|
||||
>
|
||||
<div style={{ marginBottom: '24px' }}>
|
||||
<div style={{
|
||||
fontSize: '14px',
|
||||
fontWeight: 500,
|
||||
color: '#262626',
|
||||
marginBottom: '16px'
|
||||
}}>
|
||||
服务配置
|
||||
</div>
|
||||
|
||||
<Form.Item
|
||||
label={<span style={{ color: '#595959' }}>Ollama 服务地址</span>}
|
||||
name="ollamaUrl"
|
||||
rules={[{ required: true, message: '请输入Ollama服务地址' }]}
|
||||
>
|
||||
<Input
|
||||
placeholder="http://localhost:11434"
|
||||
style={{ borderRadius: '6px' }}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Space style={{ marginBottom: '16px' }}>
|
||||
<Button
|
||||
icon={<ApiOutlined />}
|
||||
onClick={handleTestConnection}
|
||||
loading={testing}
|
||||
style={{ borderRadius: '6px' }}
|
||||
>
|
||||
测试连接
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<SyncOutlined />}
|
||||
onClick={handleDetectModels}
|
||||
loading={detectingModels}
|
||||
style={{ borderRadius: '6px' }}
|
||||
>
|
||||
检测模型
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '24px' }}>
|
||||
<div style={{
|
||||
fontSize: '14px',
|
||||
fontWeight: 500,
|
||||
color: '#262626',
|
||||
marginBottom: '16px'
|
||||
}}>
|
||||
模型选择
|
||||
</div>
|
||||
|
||||
<Form.Item
|
||||
label={
|
||||
<Space>
|
||||
<span style={{ color: '#595959' }}>AI 模型</span>
|
||||
{availableModels.length > 0 && (
|
||||
<Tag color="success">{availableModels.length} 个可用</Tag>
|
||||
)}
|
||||
</Space>
|
||||
}
|
||||
name="model"
|
||||
rules={[{
|
||||
required: true,
|
||||
message: '请选择AI模型'
|
||||
}]}
|
||||
tooltip="只能选择本地已安装的模型"
|
||||
>
|
||||
<Select
|
||||
placeholder={availableModels.length === 0 ? "请先点击检测模型" : "选择AI模型"}
|
||||
showSearch
|
||||
allowClear
|
||||
onChange={handleModelChange}
|
||||
notFoundContent={detectingModels ? <Spin size="small" /> : "未检测到可用模型"}
|
||||
disabled={availableModels.length === 0}
|
||||
style={{ borderRadius: '6px' }}
|
||||
>
|
||||
{availableModels.map(model => (
|
||||
<Option key={model.name} value={model.name}>
|
||||
<Space>
|
||||
<span>{model.name}</span>
|
||||
{model.size && (
|
||||
<span style={{ color: '#8c8c8c', fontSize: '12px' }}>
|
||||
({formatModelSize(model.size)})
|
||||
</span>
|
||||
)}
|
||||
</Space>
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
{currentModel && (
|
||||
<div style={{
|
||||
padding: '12px',
|
||||
background: '#f6ffed',
|
||||
border: '1px solid #b7eb8f',
|
||||
borderRadius: '6px',
|
||||
marginBottom: '16px'
|
||||
}}>
|
||||
<Space>
|
||||
<CheckCircleOutlined style={{ color: '#52c41a' }} />
|
||||
<span style={{ color: '#52c41a', fontSize: '14px' }}>
|
||||
当前使用模型: {currentModel}
|
||||
</span>
|
||||
</Space>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '24px' }}>
|
||||
<div style={{
|
||||
fontSize: '14px',
|
||||
fontWeight: 500,
|
||||
color: '#262626',
|
||||
marginBottom: '16px'
|
||||
}}>
|
||||
生成参数
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '16px' }}>
|
||||
<Form.Item
|
||||
label={<span style={{ color: '#595959' }}>温度</span>}
|
||||
name="temperature"
|
||||
rules={[{ required: true, message: '请输入温度值' }]}
|
||||
tooltip="控制生成文本的随机性"
|
||||
>
|
||||
<InputNumber
|
||||
min={0}
|
||||
max={2}
|
||||
step={0.1}
|
||||
precision={1}
|
||||
style={{ width: '100%', borderRadius: '6px' }}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label={<span style={{ color: '#595959' }}>Top P</span>}
|
||||
name="topP"
|
||||
rules={[{ required: true, message: '请输入Top P值' }]}
|
||||
tooltip="控制生成文本的多样性"
|
||||
>
|
||||
<InputNumber
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.1}
|
||||
precision={1}
|
||||
style={{ width: '100%', borderRadius: '6px' }}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label={<span style={{ color: '#595959' }}>最大 Tokens</span>}
|
||||
name="maxTokens"
|
||||
rules={[{ required: true, message: '请输入最大生成长度' }]}
|
||||
>
|
||||
<InputNumber
|
||||
min={100}
|
||||
max={8000}
|
||||
step={100}
|
||||
style={{ width: '100%', borderRadius: '6px' }}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Form.Item style={{ marginBottom: 0 }}>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<SaveOutlined />}
|
||||
onClick={handleSave}
|
||||
loading={loading}
|
||||
size="large"
|
||||
style={{ borderRadius: '6px', minWidth: '120px' }}
|
||||
>
|
||||
保存配置
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
<Divider style={{ margin: '32px 0' }} />
|
||||
|
||||
<div>
|
||||
<div style={{
|
||||
fontSize: '14px',
|
||||
fontWeight: 500,
|
||||
color: '#262626',
|
||||
marginBottom: '16px'
|
||||
}}>
|
||||
使用说明
|
||||
</div>
|
||||
<ul style={{
|
||||
margin: 0,
|
||||
paddingLeft: '20px',
|
||||
color: '#595959',
|
||||
fontSize: '14px',
|
||||
lineHeight: '1.8'
|
||||
}}>
|
||||
<li>确保 Ollama 服务正在运行(默认端口 11434)</li>
|
||||
<li>点击"测试连接"验证 Ollama 服务是否正常</li>
|
||||
<li>点击"检测模型"获取本地已安装的模型列表</li>
|
||||
<li>模型选择限制为只能使用本地已安装的模型</li>
|
||||
<li>如需新模型,请使用命令:ollama pull 模型名</li>
|
||||
<li>推荐:qwen3:8b起步</li>
|
||||
<li>温度参数控制生成文本的随机性,建议值为 0.7</li>
|
||||
</ul>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModelSettings;
|
||||
1421
src/pages/NovelDetail.tsx
Normal file
1421
src/pages/NovelDetail.tsx
Normal file
File diff suppressed because it is too large
Load Diff
1339
src/pages/NovelGenerate.tsx
Normal file
1339
src/pages/NovelGenerate.tsx
Normal file
File diff suppressed because it is too large
Load Diff
414
src/pages/NovelList.tsx
Normal file
414
src/pages/NovelList.tsx
Normal file
@@ -0,0 +1,414 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Card, Button, Modal, Form, Input, message, Popconfirm, Row, Col, Tag, Select } from 'antd';
|
||||
import { PlusOutlined, EditOutlined, DeleteOutlined, BookOutlined, EyeOutlined } from '@ant-design/icons';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { storage } from '../utils/indexedDB';
|
||||
import { Novel } from '../types';
|
||||
|
||||
const { TextArea } = Input;
|
||||
const { Option } = Select;
|
||||
|
||||
// 小说题材选项
|
||||
const NOVEL_GENRES = [
|
||||
'穿越',
|
||||
'都市',
|
||||
'修仙',
|
||||
'武侠',
|
||||
'玄幻',
|
||||
'科幻',
|
||||
'言情',
|
||||
'历史',
|
||||
'游戏',
|
||||
'灵异',
|
||||
'军事',
|
||||
'悬疑',
|
||||
'其他'
|
||||
];
|
||||
|
||||
const NovelList: React.FC = () => {
|
||||
const [novels, setNovels] = useState<Novel[]>([]);
|
||||
const [isModalVisible, setIsModalVisible] = useState(false);
|
||||
const [viewModalVisible, setViewModalVisible] = useState(false);
|
||||
const [editingNovel, setEditingNovel] = useState<Novel | null>(null);
|
||||
const [viewingNovel, setViewingNovel] = useState<Novel | null>(null);
|
||||
const [form] = Form.useForm();
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
loadNovels();
|
||||
}, []);
|
||||
|
||||
const loadNovels = async () => {
|
||||
try {
|
||||
const loadedNovels = await storage.getNovels();
|
||||
setNovels(loadedNovels);
|
||||
} catch (error) {
|
||||
console.error('加载小说列表失败:', error);
|
||||
message.error('加载小说列表失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddNovel = () => {
|
||||
setEditingNovel(null);
|
||||
form.resetFields();
|
||||
setIsModalVisible(true);
|
||||
};
|
||||
|
||||
const handleViewNovel = (novel: Novel) => {
|
||||
setViewingNovel(novel);
|
||||
setViewModalVisible(true);
|
||||
};
|
||||
|
||||
const handleEditNovel = (novel: Novel) => {
|
||||
setEditingNovel(novel);
|
||||
form.setFieldsValue({
|
||||
title: novel.title,
|
||||
genre: novel.genre === '未分类' ? undefined : novel.genre,
|
||||
outline: novel.outline
|
||||
});
|
||||
setIsModalVisible(true);
|
||||
};
|
||||
|
||||
const handleDeleteNovel = async (id: string) => {
|
||||
try {
|
||||
await storage.deleteNovel(id);
|
||||
await loadNovels();
|
||||
message.success('小说删除成功');
|
||||
} catch (error) {
|
||||
console.error('删除小说失败:', error);
|
||||
message.error('删除失败,请重试');
|
||||
}
|
||||
};
|
||||
|
||||
const handleModalOk = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
|
||||
if (editingNovel) {
|
||||
await storage.updateNovel(editingNovel.id, {
|
||||
...values,
|
||||
genre: values.genre || '未分类'
|
||||
});
|
||||
message.success('小说更新成功');
|
||||
} else {
|
||||
const newNovel: Novel = {
|
||||
id: Date.now().toString(),
|
||||
title: values.title,
|
||||
genre: values.genre || '未分类',
|
||||
outline: values.outline || '',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString()
|
||||
};
|
||||
await storage.addNovel(newNovel);
|
||||
message.success('小说创建成功,请前往AI生成页面完善设定');
|
||||
}
|
||||
|
||||
await loadNovels();
|
||||
setIsModalVisible(false);
|
||||
form.resetFields();
|
||||
} catch (error) {
|
||||
console.error('保存小说失败:', error);
|
||||
message.error('保存失败,请重试');
|
||||
}
|
||||
};
|
||||
|
||||
const handleModalCancel = () => {
|
||||
setIsModalVisible(false);
|
||||
form.resetFields();
|
||||
setEditingNovel(null);
|
||||
};
|
||||
|
||||
const handleCardClick = (id: string) => {
|
||||
navigate(`/novels/${id}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ marginBottom: '24px' }}>
|
||||
<h2 style={{ margin: 0, fontSize: '20px', fontWeight: 600, color: '#262626' }}>我的小说</h2>
|
||||
<p style={{ margin: '8px 0 0 0', color: '#8c8c8c', fontSize: '14px' }}>管理和创作您的 AI 小说作品</p>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '24px' }}>
|
||||
<div style={{ fontSize: '14px', color: '#595959' }}>
|
||||
共 {novels.length} 部小说
|
||||
</div>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={handleAddNovel}
|
||||
size="large"
|
||||
style={{ borderRadius: '6px' }}
|
||||
>
|
||||
新建小说
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{novels.length === 0 ? (
|
||||
<div style={{
|
||||
textAlign: 'center',
|
||||
padding: '80px 20px',
|
||||
background: 'white',
|
||||
borderRadius: '8px',
|
||||
border: '1px dashed #d9d9d9'
|
||||
}}>
|
||||
<BookOutlined style={{ fontSize: '48px', color: '#d9d9d9', marginBottom: '16px' }} />
|
||||
<div style={{ fontSize: '16px', color: '#595959', marginBottom: '8px' }}>
|
||||
还没有小说,开始创作您的第一部作品吧!
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#8c8c8c' }}>
|
||||
点击"新建小说"按钮开始您的创作之旅
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Row gutter={[16, 16]}>
|
||||
{novels.map((novel) => (
|
||||
<Col xs={24} sm={12} md={8} lg={6} key={novel.id}>
|
||||
<Card
|
||||
hoverable
|
||||
style={{
|
||||
height: '100%',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #f0f0f0',
|
||||
transition: 'all 0.3s ease'
|
||||
}}
|
||||
onClick={() => handleCardClick(novel.id)}
|
||||
actions={[
|
||||
<EyeOutlined
|
||||
key="view"
|
||||
style={{ color: '#52c41a' }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleViewNovel(novel);
|
||||
}}
|
||||
/>,
|
||||
<EditOutlined
|
||||
key="edit"
|
||||
style={{ color: '#1890ff' }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleEditNovel(novel);
|
||||
}}
|
||||
/>,
|
||||
<Popconfirm
|
||||
title="确认删除"
|
||||
description="确定要删除这部小说吗?所有章节内容也将被删除。"
|
||||
onConfirm={(e) => {
|
||||
e?.stopPropagation();
|
||||
handleDeleteNovel(novel.id);
|
||||
}}
|
||||
onCancel={(e) => e?.stopPropagation()}
|
||||
okText="确定"
|
||||
cancelText="取消"
|
||||
>
|
||||
<DeleteOutlined
|
||||
key="delete"
|
||||
style={{ color: '#ff4d4f' }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</Popconfirm>
|
||||
]}
|
||||
>
|
||||
<Card.Meta
|
||||
avatar={
|
||||
<div style={{
|
||||
width: '40px',
|
||||
height: '40px',
|
||||
borderRadius: '8px',
|
||||
background: '#e6f7ff',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}>
|
||||
<BookOutlined style={{ fontSize: '20px', color: '#1890ff' }} />
|
||||
</div>
|
||||
}
|
||||
title={
|
||||
<div style={{
|
||||
fontSize: '16px',
|
||||
fontWeight: 500,
|
||||
color: '#262626',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap'
|
||||
}}>
|
||||
{novel.title}
|
||||
</div>
|
||||
}
|
||||
description={
|
||||
<div>
|
||||
<div style={{ marginBottom: '8px' }}>
|
||||
<Tag color="blue">{novel.genre}</Tag>
|
||||
{novel.generatedSettings && (
|
||||
<Tag color="green">已完善设定</Tag>
|
||||
)}
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: '12px',
|
||||
color: '#8c8c8c',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
lineHeight: '1.5'
|
||||
}}>
|
||||
{novel.outline || '暂无大纲'}
|
||||
</div>
|
||||
<div style={{ marginTop: '8px', fontSize: '11px', color: '#bfbfbf' }}>
|
||||
创建于 {new Date(novel.createdAt).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
title={editingNovel ? '编辑小说' : '新建小说'}
|
||||
open={isModalVisible}
|
||||
onOk={handleModalOk}
|
||||
onCancel={handleModalCancel}
|
||||
width={600}
|
||||
okText="确定"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
>
|
||||
<Form.Item
|
||||
label="书名"
|
||||
name="title"
|
||||
rules={[{ required: true, message: '请输入书名' }]}
|
||||
>
|
||||
<Input placeholder="请输入书名" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="题材类型"
|
||||
name="genre"
|
||||
rules={[{ required: true, message: '请选择题材类型' }]}
|
||||
>
|
||||
<Select
|
||||
placeholder="请选择题材类型"
|
||||
size="large"
|
||||
showSearch
|
||||
allowClear
|
||||
>
|
||||
{NOVEL_GENRES.map(genre => (
|
||||
<Option key={genre} value={genre}>
|
||||
{genre}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="初步想法"
|
||||
name="outline"
|
||||
extra="可以简单描述您的初步想法,也可以留空,后续让AI帮您生成"
|
||||
>
|
||||
<TextArea
|
||||
rows={4}
|
||||
placeholder="简单描述您的创作想法..."
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title="小说详情"
|
||||
open={viewModalVisible}
|
||||
onCancel={() => setViewModalVisible(false)}
|
||||
width={700}
|
||||
footer={[
|
||||
<Button key="close" onClick={() => setViewModalVisible(false)}>
|
||||
关闭
|
||||
</Button>
|
||||
]}
|
||||
>
|
||||
{viewingNovel && (
|
||||
<div style={{ padding: '16px 0' }}>
|
||||
<div style={{ marginBottom: '24px' }}>
|
||||
<div style={{ fontSize: '14px', color: '#595959', marginBottom: '8px' }}>
|
||||
书名
|
||||
</div>
|
||||
<div style={{
|
||||
padding: '12px',
|
||||
background: '#f5f5f5',
|
||||
borderRadius: '6px',
|
||||
fontSize: '16px',
|
||||
fontWeight: 500
|
||||
}}>
|
||||
{viewingNovel.title}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '24px' }}>
|
||||
<div style={{ fontSize: '14px', color: '#595959', marginBottom: '8px' }}>
|
||||
题材
|
||||
</div>
|
||||
<div style={{
|
||||
padding: '12px',
|
||||
background: '#f5f5f5',
|
||||
borderRadius: '6px'
|
||||
}}>
|
||||
<Tag color="blue">{viewingNovel.genre}</Tag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{viewingNovel.generatedSettings && (
|
||||
<>
|
||||
<div style={{ marginBottom: '24px' }}>
|
||||
<div style={{ fontSize: '14px', color: '#595959', marginBottom: '8px' }}>
|
||||
AI生成设定
|
||||
</div>
|
||||
<div style={{
|
||||
padding: '12px',
|
||||
background: '#f0f9ff',
|
||||
borderRadius: '6px',
|
||||
marginBottom: '12px'
|
||||
}}>
|
||||
<div><strong>目标字数:</strong>{viewingNovel.generatedSettings.targetWordCount}字</div>
|
||||
<div><strong>章节数量:</strong>{viewingNovel.generatedSettings.chapterCount}章</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div style={{ marginBottom: '24px' }}>
|
||||
<div style={{ fontSize: '14px', color: '#595959', marginBottom: '8px' }}>
|
||||
大纲
|
||||
</div>
|
||||
<div style={{
|
||||
padding: '12px',
|
||||
background: '#f5f5f5',
|
||||
borderRadius: '6px',
|
||||
lineHeight: '1.8',
|
||||
maxHeight: '200px',
|
||||
overflowY: 'auto'
|
||||
}}>
|
||||
{viewingNovel.outline || '暂无大纲'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: '24px', paddingTop: '16px', borderTop: '1px solid #f0f0f0' }}>
|
||||
<div style={{ fontSize: '12px', color: '#8c8c8c' }}>
|
||||
创建时间:{new Date(viewingNovel.createdAt).toLocaleString()}
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: '#8c8c8c', marginTop: '4px' }}>
|
||||
更新时间:{new Date(viewingNovel.updatedAt).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NovelList;
|
||||
478
src/pages/SkillsPage.tsx
Normal file
478
src/pages/SkillsPage.tsx
Normal file
@@ -0,0 +1,478 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Card,
|
||||
Button,
|
||||
Space,
|
||||
Typography,
|
||||
Tag,
|
||||
Modal,
|
||||
Form,
|
||||
Input,
|
||||
Select,
|
||||
message,
|
||||
Row,
|
||||
Col,
|
||||
Popconfirm,
|
||||
Divider
|
||||
} from 'antd';
|
||||
import {
|
||||
PlusOutlined,
|
||||
EditOutlined,
|
||||
DeleteOutlined,
|
||||
ThunderboltOutlined,
|
||||
CodeOutlined,
|
||||
BulbOutlined,
|
||||
RobotOutlined,
|
||||
BookOutlined,
|
||||
EditOutlined as EditIcon
|
||||
} from '@ant-design/icons';
|
||||
|
||||
const { Title, Text, Paragraph } = Typography;
|
||||
const { TextArea } = Input;
|
||||
|
||||
interface Skill {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
category: string;
|
||||
prompt: string;
|
||||
examples: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
const SkillsPage: React.FC = () => {
|
||||
const [skills, setSkills] = useState<Skill[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
const [editingSkill, setEditingSkill] = useState<Skill | null>(null);
|
||||
const [form] = Form.useForm();
|
||||
|
||||
useEffect(() => {
|
||||
loadSkills();
|
||||
}, []);
|
||||
|
||||
const loadSkills = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// 这里将来可以连接到实际的数据库
|
||||
// 现在先使用模拟数据
|
||||
const mockSkills: Skill[] = [
|
||||
{
|
||||
id: '1',
|
||||
name: '小说设定生成',
|
||||
description: '根据用户的小说创意,自动生成完整的小说设定,包括角色、世界观、故事大纲等',
|
||||
category: '创作辅助',
|
||||
prompt: '你是一个专业的小说设定助手。根据用户提供的小说创意,生成详细的小说设定,包括:1. 故事大纲(150字内)2. 主要角色设定(至少3个角色)3. 世界观背景 4. 核心冲突 5. 情节发展建议',
|
||||
examples: '用户输入:写一个现代都市修仙小说\n\n生成结果:\n故事大纲:普通程序员意外获得修仙传承,在现代都市中一边工作一边修炼,逐渐发现都市中隐藏的修仙世界...',
|
||||
createdAt: '2024-01-15',
|
||||
updatedAt: '2024-01-15'
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: '章节内容创作',
|
||||
description: '根据章节标题和细纲,自动创作具体的章节内容,保持风格连贯和情节合理',
|
||||
category: '内容创作',
|
||||
prompt: '你是一个专业的小说作家。根据提供的章节标题、细纲和前文内容,创作符合要求的章节内容。要求:1. 严格遵守细纲要求 2. 保持人物性格一致 3. 语言生动流畅 4. 字数控制在900-1200字',
|
||||
examples: '章节标题:第一章 意外穿越\n细纲:主角意外穿越到修仙世界,获得神秘传承\n前文:无\n\n生成结果:林明醒来时,发现自己躺在一片陌生的森林中...',
|
||||
createdAt: '2024-01-16',
|
||||
updatedAt: '2024-01-16'
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: '角色对话优化',
|
||||
description: '优化小说中的人物对话,使其更符合角色性格和场景氛围',
|
||||
category: '内容优化',
|
||||
prompt: '你是一个对话优化专家。根据提供的对话内容和角色设定,优化对话表达,使其:1. 更符合角色性格 2. 更贴合场景氛围 3. 语言更自然流畅 4. 保持原有意思不变',
|
||||
examples: '原对话:"你是什么人?"林明问。\n优化后:"你是谁?"林明的声音里带着警惕,眼神紧盯着对方...',
|
||||
createdAt: '2024-01-17',
|
||||
updatedAt: '2024-01-17'
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
name: '情节建议生成',
|
||||
description: '为小说创作提供情节发展建议,帮助解决创作瓶颈',
|
||||
category: '创作辅助',
|
||||
prompt: '你是一个创意写作顾问。根据用户提供的当前情节和创作瓶颈,提供3-5个情节发展建议,每个建议都要:1. 符合故事逻辑 2. 具有戏剧冲突 3. 推动情节发展 4. 保持人物一致性',
|
||||
examples: '当前情节:主角刚刚获得修仙传承,但不知道如何修炼\n\n建议1:安排一位神秘导师,指点主角入门...\n建议2:主角在修炼过程中遇到困难,需要寻找资源...',
|
||||
createdAt: '2024-01-18',
|
||||
updatedAt: '2024-01-18'
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
name: '文笔风格调整',
|
||||
description: '调整小说的文笔风格,如简洁、华丽、幽默等不同风格',
|
||||
category: '内容优化',
|
||||
prompt: '你是一个文笔风格调整专家。根据用户指定的风格要求,调整文本的表达方式。可选风格:简洁明快、华丽优美、幽默风趣、严肃深沉等。要求保持原意不变,只改变表达方式。',
|
||||
examples: '原文:他走进了房间,看到了一个人坐在那里。\n简洁风格:他进屋,见一人独坐。\n华丽风格:他缓步入室,目光所及,见一人静坐其间...',
|
||||
createdAt: '2024-01-19',
|
||||
updatedAt: '2024-01-19'
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
name: '章节大纲生成',
|
||||
description: '为指定章节生成详细的章节大纲,包括主要情节、转折点、人物发展等',
|
||||
category: '创作辅助',
|
||||
prompt: '你是一个章节大纲专家。根据小说总体设定和章节要求,生成详细的章节大纲。大纲应包含:1. 章节主题 2. 主要情节发展 3. 重要转折点 4. 人物心理变化 5. 与前后章节的衔接',
|
||||
examples: '小说:都市修仙\n章节:第5章\n要求:主角首次展示修仙能力\n\n大纲:章节主题:初露锋芒\n主要情节:主角在公司遇到危机,情急之下使用修仙能力...',
|
||||
createdAt: '2024-01-20',
|
||||
updatedAt: '2024-01-20'
|
||||
}
|
||||
];
|
||||
|
||||
setSkills(mockSkills);
|
||||
} catch (error) {
|
||||
message.error('加载技能列表失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAdd = () => {
|
||||
setEditingSkill(null);
|
||||
form.resetFields();
|
||||
setModalVisible(true);
|
||||
};
|
||||
|
||||
const handleEdit = (skill: Skill) => {
|
||||
setEditingSkill(skill);
|
||||
form.setFieldsValue(skill);
|
||||
setModalVisible(true);
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
// 这里将来连接到实际的数据库删除操作
|
||||
setSkills(skills.filter(skill => skill.id !== id));
|
||||
message.success('技能删除成功');
|
||||
} catch (error) {
|
||||
message.error('删除技能失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
|
||||
if (editingSkill) {
|
||||
// 编辑模式
|
||||
const updatedSkill: Skill = {
|
||||
...editingSkill,
|
||||
...values,
|
||||
updatedAt: new Date().toISOString().split('T')[0]
|
||||
};
|
||||
setSkills(skills.map(skill =>
|
||||
skill.id === editingSkill.id ? updatedSkill : skill
|
||||
));
|
||||
message.success('技能更新成功');
|
||||
} else {
|
||||
// 新增模式
|
||||
const newSkill: Skill = {
|
||||
id: Date.now().toString(),
|
||||
...values,
|
||||
createdAt: new Date().toISOString().split('T')[0],
|
||||
updatedAt: new Date().toISOString().split('T')[0]
|
||||
};
|
||||
setSkills([...skills, newSkill]);
|
||||
message.success('技能添加成功');
|
||||
}
|
||||
|
||||
setModalVisible(false);
|
||||
form.resetFields();
|
||||
} catch (error) {
|
||||
message.error('表单验证失败');
|
||||
}
|
||||
};
|
||||
|
||||
const getCategoryIcon = (category: string) => {
|
||||
switch (category) {
|
||||
case '创作辅助':
|
||||
return <BulbOutlined style={{ color: '#faad14', fontSize: '20px' }} />;
|
||||
case '内容创作':
|
||||
return <EditIcon style={{ color: '#52c41a', fontSize: '20px' }} />;
|
||||
case '内容优化':
|
||||
return <CodeOutlined style={{ color: '#1890ff', fontSize: '20px' }} />;
|
||||
default:
|
||||
return <ThunderboltOutlined style={{ color: '#8c8c8c', fontSize: '20px' }} />;
|
||||
}
|
||||
};
|
||||
|
||||
const getCategoryColor = (category: string) => {
|
||||
switch (category) {
|
||||
case '创作辅助':
|
||||
return 'orange';
|
||||
case '内容创作':
|
||||
return 'green';
|
||||
case '内容优化':
|
||||
return 'blue';
|
||||
default:
|
||||
return 'default';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ marginBottom: '24px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div>
|
||||
<Title level={2} style={{ margin: 0, display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||
<ThunderboltOutlined style={{ color: '#1890ff' }} />
|
||||
技能管理
|
||||
</Title>
|
||||
<Text type="secondary" style={{ marginTop: '8px', display: 'block' }}>
|
||||
管理和配置 AI 智能体的各种创作技能
|
||||
</Text>
|
||||
</div>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={handleAdd}
|
||||
size="large"
|
||||
>
|
||||
新建技能
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<Row gutter={16}>
|
||||
<Col span={8}>
|
||||
<Card
|
||||
size="small"
|
||||
style={{ background: '#f0f9ff', border: '1px solid #91d5ff' }}
|
||||
>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div style={{ fontSize: '24px', fontWeight: 'bold', color: '#1890ff' }}>
|
||||
{skills.length}
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: '#666', marginTop: '4px' }}>
|
||||
总技能数
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Card
|
||||
size="small"
|
||||
style={{ background: '#f6ffed', border: '1px solid #b7eb8f' }}
|
||||
>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div style={{ fontSize: '24px', fontWeight: 'bold', color: '#52c41a' }}>
|
||||
{skills.filter(s => s.category === '创作辅助').length}
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: '#666', marginTop: '4px' }}>
|
||||
创作辅助
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Card
|
||||
size="small"
|
||||
style={{ background: '#fff9e6', border: '1px solid #ffe58f' }}
|
||||
>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div style={{ fontSize: '24px', fontWeight: 'bold', color: '#faad14' }}>
|
||||
{skills.filter(s => s.category === '内容创作' || s.category === '内容优化').length}
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: '#666', marginTop: '4px' }}>
|
||||
内容处理
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div style={{ textAlign: 'center', padding: '50px' }}>
|
||||
加载中...
|
||||
</div>
|
||||
) : (
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(350px, 1fr))',
|
||||
gap: '16px'
|
||||
}}>
|
||||
{skills.map((skill) => (
|
||||
<Card
|
||||
key={skill.id}
|
||||
hoverable
|
||||
style={{
|
||||
borderRadius: '12px',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column'
|
||||
}}
|
||||
actions={[
|
||||
<Button
|
||||
key="edit"
|
||||
type="text"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => handleEdit(skill)}
|
||||
>
|
||||
编辑
|
||||
</Button>,
|
||||
<Popconfirm
|
||||
key="delete"
|
||||
title="确认删除"
|
||||
description="确定要删除这个技能吗?"
|
||||
onConfirm={() => handleDelete(skill.id)}
|
||||
okText="确定"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Button
|
||||
type="text"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
]}
|
||||
>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', marginBottom: '16px' }}>
|
||||
<div style={{
|
||||
width: '48px',
|
||||
height: '48px',
|
||||
borderRadius: '8px',
|
||||
background: '#f0f9ff',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginRight: '12px'
|
||||
}}>
|
||||
{getCategoryIcon(skill.category)}
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<Title level={4} style={{ margin: 0, marginBottom: '8px' }}>
|
||||
{skill.name}
|
||||
</Title>
|
||||
<Tag color={getCategoryColor(skill.category)}>
|
||||
{skill.category}
|
||||
</Tag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Paragraph
|
||||
ellipsis={{ rows: 2 }}
|
||||
style={{
|
||||
color: '#666',
|
||||
marginBottom: '16px',
|
||||
minHeight: '44px'
|
||||
}}
|
||||
>
|
||||
{skill.description}
|
||||
</Paragraph>
|
||||
|
||||
<Divider style={{ margin: '12px 0' }} />
|
||||
|
||||
<div style={{ fontSize: '12px', color: '#999' }}>
|
||||
<div style={{ marginBottom: '4px' }}>
|
||||
<BookOutlined style={{ marginRight: '4px' }} />
|
||||
提示词模板:{skill.prompt.length} 字符
|
||||
</div>
|
||||
{skill.examples && (
|
||||
<div>
|
||||
<BulbOutlined style={{ marginRight: '4px' }} />
|
||||
包含示例
|
||||
</div>
|
||||
)}
|
||||
<div style={{ marginTop: '4px' }}>
|
||||
更新时间:{skill.updatedAt}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{skills.length === 0 && !loading && (
|
||||
<div style={{
|
||||
textAlign: 'center',
|
||||
padding: '100px 0',
|
||||
color: '#999'
|
||||
}}>
|
||||
<RobotOutlined style={{ fontSize: '64px', color: '#d9d9d9', marginBottom: '16px' }} />
|
||||
<div>还没有技能,点击上方按钮创建第一个技能吧!</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
title={
|
||||
<Space>
|
||||
<ThunderboltOutlined style={{ color: '#1890ff' }} />
|
||||
{editingSkill ? '编辑技能' : '新建技能'}
|
||||
</Space>
|
||||
}
|
||||
open={modalVisible}
|
||||
onOk={handleSubmit}
|
||||
onCancel={() => setModalVisible(false)}
|
||||
width={800}
|
||||
okText="确定"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
style={{ marginTop: '24px' }}
|
||||
>
|
||||
<Form.Item
|
||||
name="name"
|
||||
label="技能名称"
|
||||
rules={[{ required: true, message: '请输入技能名称' }]}
|
||||
>
|
||||
<Input placeholder="例如:小说设定生成" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="category"
|
||||
label="技能分类"
|
||||
rules={[{ required: true, message: '请选择技能分类' }]}
|
||||
>
|
||||
<Select placeholder="选择分类">
|
||||
<Select.Option value="创作辅助">创作辅助</Select.Option>
|
||||
<Select.Option value="内容创作">内容创作</Select.Option>
|
||||
<Select.Option value="内容优化">内容优化</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="description"
|
||||
label="技能描述"
|
||||
rules={[{ required: true, message: '请输入技能描述' }]}
|
||||
>
|
||||
<TextArea
|
||||
placeholder="简单描述这个技能的功能和用途"
|
||||
rows={2}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="prompt"
|
||||
label="提示词模板"
|
||||
rules={[{ required: true, message: '请输入提示词模板' }]}
|
||||
>
|
||||
<TextArea
|
||||
placeholder="输入 AI 使用的提示词模板,可以使用变量占位符"
|
||||
rows={6}
|
||||
style={{ fontFamily: 'monospace' }}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="examples"
|
||||
label="使用示例"
|
||||
>
|
||||
<TextArea
|
||||
placeholder="输入使用示例,帮助用户理解如何使用这个技能"
|
||||
rows={4}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SkillsPage;
|
||||
399
src/pages/SystemConfig.tsx
Normal file
399
src/pages/SystemConfig.tsx
Normal file
@@ -0,0 +1,399 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Card, Form, Input, InputNumber, Button, Space, message, Divider, Select, Spin, Tag } from 'antd';
|
||||
import { ApiOutlined, SaveOutlined, SyncOutlined, CheckCircleOutlined } from '@ant-design/icons';
|
||||
import { storage } from '../utils/indexedDB';
|
||||
import { OllamaService } from '../utils/ollama';
|
||||
|
||||
const { Option } = Select;
|
||||
|
||||
interface ModelInfo {
|
||||
name: string;
|
||||
size?: number;
|
||||
modified?: string;
|
||||
}
|
||||
|
||||
const SystemConfig: React.FC = () => {
|
||||
const [form] = Form.useForm();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [detectingModels, setDetectingModels] = useState(false);
|
||||
const [availableModels, setAvailableModels] = useState<ModelInfo[]>([]);
|
||||
const [connectionStatus, setConnectionStatus] = useState<'unknown' | 'success' | 'error'>('unknown');
|
||||
const [currentModel, setCurrentModel] = useState<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
const loadConfig = async () => {
|
||||
const config = await storage.getSystemConfig();
|
||||
form.setFieldsValue(config);
|
||||
setCurrentModel(config.model || '');
|
||||
};
|
||||
loadConfig();
|
||||
}, [form]);
|
||||
|
||||
const handleDetectModels = async () => {
|
||||
setDetectingModels(true);
|
||||
setConnectionStatus('unknown');
|
||||
try {
|
||||
const values = await form.validateFields(['ollamaUrl']);
|
||||
const ollamaService = new OllamaService(values);
|
||||
|
||||
// 测试连接
|
||||
const isConnected = await ollamaService.testConnection();
|
||||
if (!isConnected) {
|
||||
setConnectionStatus('error');
|
||||
message.error('无法连接到 Ollama 服务,请检查服务地址和状态');
|
||||
setAvailableModels([]);
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取模型列表
|
||||
const models = await ollamaService.getAvailableModelsWithInfo();
|
||||
setAvailableModels(models);
|
||||
setConnectionStatus('success');
|
||||
|
||||
if (models.length === 0) {
|
||||
message.warning('未检测到已安装的模型,请先使用 ollama pull 命令安装模型');
|
||||
} else {
|
||||
message.success(`成功检测到 ${models.length} 个已安装模型`);
|
||||
|
||||
// 如果当前模型不在列表中,清空选择
|
||||
if (currentModel && !models.find(m => m.name === currentModel)) {
|
||||
form.setFieldValue('model', undefined);
|
||||
setCurrentModel('');
|
||||
message.warning('当前选择的模型未在本地安装,请重新选择');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
setConnectionStatus('error');
|
||||
message.error('模型检测失败,请检查 Ollama 服务状态');
|
||||
setAvailableModels([]);
|
||||
} finally {
|
||||
setDetectingModels(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTestConnection = async () => {
|
||||
setTesting(true);
|
||||
setConnectionStatus('unknown');
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
const ollamaService = new OllamaService(values);
|
||||
const isConnected = await ollamaService.testConnection();
|
||||
|
||||
if (isConnected) {
|
||||
setConnectionStatus('success');
|
||||
message.success('Ollama 服务连接成功!');
|
||||
} else {
|
||||
setConnectionStatus('error');
|
||||
message.error('Ollama 服务连接失败,请检查服务地址');
|
||||
}
|
||||
} catch (error) {
|
||||
setConnectionStatus('error');
|
||||
message.error('连接测试失败,请检查配置');
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleModelChange = (value: string) => {
|
||||
setCurrentModel(value);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
|
||||
// 验证选择的模型是否可用
|
||||
if (currentModel && availableModels.length > 0) {
|
||||
const modelExists = availableModels.find(m => m.name === currentModel);
|
||||
if (!modelExists) {
|
||||
message.error('请选择本地已安装的模型,或点击"检测模型"刷新列表');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await storage.saveSystemConfig(values);
|
||||
message.success('配置保存成功!');
|
||||
} catch (error) {
|
||||
message.error('配置保存失败,请检查输入');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatModelSize = (bytes?: number) => {
|
||||
if (!bytes) return '未知';
|
||||
const gb = bytes / (1024 * 1024 * 1024);
|
||||
return `${gb.toFixed(2)} GB`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ marginBottom: '24px' }}>
|
||||
<h2 style={{ margin: 0, fontSize: '20px', fontWeight: 600, color: '#262626' }}>系统配置</h2>
|
||||
<p style={{ margin: '8px 0 0 0', color: '#8c8c8c', fontSize: '14px' }}>配置 Ollama 服务和 AI 模型参数</p>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
background: 'white',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #f0f0f0',
|
||||
marginBottom: '16px',
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
<div style={{
|
||||
padding: '16px 24px',
|
||||
background: '#fafafa',
|
||||
borderBottom: '1px solid #f0f0f0',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px'
|
||||
}}>
|
||||
<div style={{
|
||||
width: '8px',
|
||||
height: '8px',
|
||||
borderRadius: '50%',
|
||||
background: connectionStatus === 'success' ? '#52c41a' : connectionStatus === 'error' ? '#ff4d4f' : '#faad14'
|
||||
}} />
|
||||
<div>
|
||||
<div style={{ fontWeight: 500, color: '#262626' }}>
|
||||
{connectionStatus === 'success' ? '服务正常' : connectionStatus === 'error' ? '服务异常' : '未检测'}
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: '#8c8c8c' }}>
|
||||
{availableModels.length > 0
|
||||
? `已安装 ${availableModels.length} 个模型`
|
||||
: connectionStatus === 'success'
|
||||
? '未安装模型'
|
||||
: '请先连接服务'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card bordered={false} style={{ boxShadow: 'none' }}>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
initialValues={{
|
||||
ollamaUrl: 'http://localhost:11434',
|
||||
model: '',
|
||||
temperature: 0.7,
|
||||
topP: 0.9,
|
||||
maxTokens: 2000
|
||||
}}
|
||||
>
|
||||
<div style={{ marginBottom: '24px' }}>
|
||||
<div style={{
|
||||
fontSize: '14px',
|
||||
fontWeight: 500,
|
||||
color: '#262626',
|
||||
marginBottom: '16px'
|
||||
}}>
|
||||
服务配置
|
||||
</div>
|
||||
|
||||
<Form.Item
|
||||
label={<span style={{ color: '#595959' }}>Ollama 服务地址</span>}
|
||||
name="ollamaUrl"
|
||||
rules={[{ required: true, message: '请输入Ollama服务地址' }]}
|
||||
>
|
||||
<Input
|
||||
placeholder="http://localhost:11434"
|
||||
style={{ borderRadius: '6px' }}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Space style={{ marginBottom: '16px' }}>
|
||||
<Button
|
||||
icon={<ApiOutlined />}
|
||||
onClick={handleTestConnection}
|
||||
loading={testing}
|
||||
style={{ borderRadius: '6px' }}
|
||||
>
|
||||
测试连接
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<SyncOutlined />}
|
||||
onClick={handleDetectModels}
|
||||
loading={detectingModels}
|
||||
style={{ borderRadius: '6px' }}
|
||||
>
|
||||
检测模型
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '24px' }}>
|
||||
<div style={{
|
||||
fontSize: '14px',
|
||||
fontWeight: 500,
|
||||
color: '#262626',
|
||||
marginBottom: '16px'
|
||||
}}>
|
||||
模型选择
|
||||
</div>
|
||||
|
||||
<Form.Item
|
||||
label={
|
||||
<Space>
|
||||
<span style={{ color: '#595959' }}>AI 模型</span>
|
||||
{availableModels.length > 0 && (
|
||||
<Tag color="success">{availableModels.length} 个可用</Tag>
|
||||
)}
|
||||
</Space>
|
||||
}
|
||||
name="model"
|
||||
rules={[{
|
||||
required: true,
|
||||
message: '请选择AI模型'
|
||||
}]}
|
||||
tooltip="只能选择本地已安装的模型"
|
||||
>
|
||||
<Select
|
||||
placeholder={availableModels.length === 0 ? "请先点击检测模型" : "选择AI模型"}
|
||||
showSearch
|
||||
allowClear
|
||||
onChange={handleModelChange}
|
||||
notFoundContent={detectingModels ? <Spin size="small" /> : "未检测到可用模型"}
|
||||
disabled={availableModels.length === 0}
|
||||
style={{ borderRadius: '6px' }}
|
||||
>
|
||||
{availableModels.map(model => (
|
||||
<Option key={model.name} value={model.name}>
|
||||
<Space>
|
||||
<span>{model.name}</span>
|
||||
{model.size && (
|
||||
<span style={{ color: '#8c8c8c', fontSize: '12px' }}>
|
||||
({formatModelSize(model.size)})
|
||||
</span>
|
||||
)}
|
||||
</Space>
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
{currentModel && (
|
||||
<div style={{
|
||||
padding: '12px',
|
||||
background: '#f6ffed',
|
||||
border: '1px solid #b7eb8f',
|
||||
borderRadius: '6px',
|
||||
marginBottom: '16px'
|
||||
}}>
|
||||
<Space>
|
||||
<CheckCircleOutlined style={{ color: '#52c41a' }} />
|
||||
<span style={{ color: '#52c41a', fontSize: '14px' }}>
|
||||
当前使用模型: {currentModel}
|
||||
</span>
|
||||
</Space>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '24px' }}>
|
||||
<div style={{
|
||||
fontSize: '14px',
|
||||
fontWeight: 500,
|
||||
color: '#262626',
|
||||
marginBottom: '16px'
|
||||
}}>
|
||||
生成参数
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '16px' }}>
|
||||
<Form.Item
|
||||
label={<span style={{ color: '#595959' }}>温度</span>}
|
||||
name="temperature"
|
||||
rules={[{ required: true, message: '请输入温度值' }]}
|
||||
tooltip="控制生成文本的随机性"
|
||||
>
|
||||
<InputNumber
|
||||
min={0}
|
||||
max={2}
|
||||
step={0.1}
|
||||
precision={1}
|
||||
style={{ width: '100%', borderRadius: '6px' }}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label={<span style={{ color: '#595959' }}>Top P</span>}
|
||||
name="topP"
|
||||
rules={[{ required: true, message: '请输入Top P值' }]}
|
||||
tooltip="控制生成文本的多样性"
|
||||
>
|
||||
<InputNumber
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.1}
|
||||
precision={1}
|
||||
style={{ width: '100%', borderRadius: '6px' }}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label={<span style={{ color: '#595959' }}>最大 Tokens</span>}
|
||||
name="maxTokens"
|
||||
rules={[{ required: true, message: '请输入最大生成长度' }]}
|
||||
>
|
||||
<InputNumber
|
||||
min={100}
|
||||
max={8000}
|
||||
step={100}
|
||||
style={{ width: '100%', borderRadius: '6px' }}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Form.Item style={{ marginBottom: 0 }}>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<SaveOutlined />}
|
||||
onClick={handleSave}
|
||||
loading={loading}
|
||||
size="large"
|
||||
style={{ borderRadius: '6px', minWidth: '120px' }}
|
||||
>
|
||||
保存配置
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
<Divider style={{ margin: '32px 0' }} />
|
||||
|
||||
<div>
|
||||
<div style={{
|
||||
fontSize: '14px',
|
||||
fontWeight: 500,
|
||||
color: '#262626',
|
||||
marginBottom: '16px'
|
||||
}}>
|
||||
使用说明
|
||||
</div>
|
||||
<ul style={{
|
||||
margin: 0,
|
||||
paddingLeft: '20px',
|
||||
color: '#595959',
|
||||
fontSize: '14px',
|
||||
lineHeight: '1.8'
|
||||
}}>
|
||||
<li>确保 Ollama 服务正在运行(默认端口 11434)</li>
|
||||
<li>点击"测试连接"验证 Ollama 服务是否正常</li>
|
||||
<li>点击"检测模型"获取本地已安装的模型列表</li>
|
||||
<li>模型选择限制为只能使用本地已安装的模型</li>
|
||||
<li>如需新模型,请使用命令:ollama pull 模型名</li>
|
||||
<li>推荐:qwen3:8b起步</li>
|
||||
<li>温度参数控制生成文本的随机性,建议值为 0.7</li>
|
||||
</ul>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SystemConfig;
|
||||
810
src/pages/UserManual.tsx
Normal file
810
src/pages/UserManual.tsx
Normal file
@@ -0,0 +1,810 @@
|
||||
import React from 'react';
|
||||
import { Card, Typography, Divider, Space, Tag, List, Steps, Alert, Tabs } from 'antd';
|
||||
import { BookOutlined, SettingOutlined, RobotOutlined, EditOutlined, AppstoreOutlined, CheckCircleOutlined } from '@ant-design/icons';
|
||||
|
||||
const { Title, Paragraph, Text } = Typography;
|
||||
|
||||
const UserManual: React.FC = () => {
|
||||
const tabItems = [
|
||||
{
|
||||
key: 'overview',
|
||||
label: (
|
||||
<span>
|
||||
<RobotOutlined /> 系统概述
|
||||
</span>
|
||||
),
|
||||
children: (
|
||||
<div>
|
||||
<Card>
|
||||
<Title level={3}>AI 小说创作系统概述</Title>
|
||||
<Paragraph>
|
||||
AI 小说创作系统是一个基于人工智能的辅助创作平台,通过本地部署的 Ollama 模型帮助用户完成小说创作的各个环节。
|
||||
系统提供从小说创建、设定生成、章节创作到完整作品管理的全流程支持。
|
||||
</Paragraph>
|
||||
|
||||
<Title level={4}>主要功能</Title>
|
||||
<List
|
||||
dataSource={[
|
||||
'智能小说设定生成(角色、世界观、故事线)',
|
||||
'分章节内容创作与续写',
|
||||
'本地化部署,数据安全可控',
|
||||
'多项目管理,便于同时处理多个作品',
|
||||
'灵活的参数配置,适应不同创作风格'
|
||||
]}
|
||||
renderItem={(item: string) => (
|
||||
<List.Item>
|
||||
<Text>{item}</Text>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
key: 'model',
|
||||
label: (
|
||||
<span>
|
||||
<SettingOutlined /> 模型配置指南
|
||||
</span>
|
||||
),
|
||||
children: (
|
||||
<div>
|
||||
<Alert
|
||||
message="重要提示"
|
||||
description="配置模型是使用本系统的第一步,必须先完成模型配置才能使用 AI 创作功能。"
|
||||
type="warning"
|
||||
showIcon
|
||||
style={{ marginBottom: '16px' }}
|
||||
/>
|
||||
|
||||
<Card style={{ marginBottom: '16px' }}>
|
||||
<Title level={4}>1. Ollama 服务安装</Title>
|
||||
<Paragraph>
|
||||
首先需要在本地安装 Ollama 服务,这是系统运行的基础。
|
||||
</Paragraph>
|
||||
|
||||
<Title level={5}>安装步骤:</Title>
|
||||
<Steps
|
||||
direction="vertical"
|
||||
current={-1}
|
||||
items={[
|
||||
{
|
||||
title: '下载 Ollama',
|
||||
description: '访问 Ollama 官网(https://ollama.ai)下载适合您操作系统的版本'
|
||||
},
|
||||
{
|
||||
title: '安装 Ollama',
|
||||
description: '运行安装程序,按提示完成安装'
|
||||
},
|
||||
{
|
||||
title: '启动服务',
|
||||
description: '在终端中运行 ollama serve 命令启动服务'
|
||||
},
|
||||
{
|
||||
title: '验证安装',
|
||||
description: '确认服务运行在默认端口 11434'
|
||||
}
|
||||
]}
|
||||
/>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Title level={4}>2. 模型安装与选择</Title>
|
||||
<Paragraph>
|
||||
安装完 Ollama 后,需要安装适合的 AI 模型用于小说创作。
|
||||
</Paragraph>
|
||||
|
||||
<Title level={5}>推荐模型配置:</Title>
|
||||
<Space direction="vertical" style={{ width: '100%', marginBottom: '16px' }}>
|
||||
<div style={{ padding: '12px', background: '#f6ffed', borderRadius: '6px', border: '1px solid #b7eb8f' }}>
|
||||
<div>
|
||||
<Tag color="green">推荐配置</Tag>
|
||||
<Text strong>qwen3:8b</Text>
|
||||
<Text type="secondary"> - 8B 参数,质量和速度平衡,适合大多数创作需求</Text>
|
||||
</div>
|
||||
<div style={{ marginTop: '8px' }}>
|
||||
<Text code>ollama pull qwen3:8b</Text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ padding: '12px', background: '#e6f7ff', borderRadius: '6px', border: '1px solid #91d5ff' }}>
|
||||
<div>
|
||||
<Tag color="blue">进阶配置</Tag>
|
||||
<Text strong>qwen3:14b</Text>
|
||||
<Text type="secondary"> - 14B 参数,生成质量更高,需要更多内存</Text>
|
||||
</div>
|
||||
<div style={{ marginTop: '8px' }}>
|
||||
<Text code>ollama pull qwen3:14b</Text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ padding: '12px', background: '#fff9e6', borderRadius: '6px', border: '1px solid #ffe58f' }}>
|
||||
<div>
|
||||
<Tag color="purple">高级配置</Tag>
|
||||
<Text strong>qwen3:32b</Text>
|
||||
<Text type="secondary"> - 32B 参数,最佳创作效果,需要较好的硬件配置</Text>
|
||||
</div>
|
||||
<div style={{ marginTop: '8px' }}>
|
||||
<Text code>ollama pull qwen3:32b</Text>
|
||||
</div>
|
||||
</div>
|
||||
</Space>
|
||||
|
||||
<Title level={5}>其他可选模型:</Title>
|
||||
<List
|
||||
dataSource={[
|
||||
<div>
|
||||
<Text strong>llama3:8b</Text> - Meta 开源的 Llama 3 模型,英文表现优秀
|
||||
</div>,
|
||||
<div>
|
||||
<Text strong>mistral:7b</Text> - Mistral AI 开发的模型,推理能力强
|
||||
</div>,
|
||||
<div>
|
||||
<Text strong>gemma:7b</Text> - Google 开发的 Gemma 模型
|
||||
</div>
|
||||
]}
|
||||
renderItem={(item: React.ReactNode) => (
|
||||
<List.Item>
|
||||
{item}
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Title level={4}>3. 系统配置参数详解</Title>
|
||||
<Paragraph>
|
||||
在"设置 → 模型"页面中,您可以配置以下关键参数来优化 AI 创作效果:
|
||||
</Paragraph>
|
||||
|
||||
<Title level={5}>服务配置参数:</Title>
|
||||
<List
|
||||
dataSource={[
|
||||
<div>
|
||||
<Text strong>Ollama 服务地址:</Text>
|
||||
<div style={{ marginTop: '4px' }}>
|
||||
<Text type="secondary">默认地址:http://localhost:11434</Text>
|
||||
</div>
|
||||
<div style={{ marginTop: '4px', padding: '8px', background: '#f5f5f5', borderRadius: '4px' }}>
|
||||
<Text code>http://localhost:11434</Text>
|
||||
</div>
|
||||
<div style={{ marginTop: '4px' }}>
|
||||
<Text type="secondary">如果 Ollama 安装在其他机器或端口,请相应修改地址</Text>
|
||||
</div>
|
||||
</div>,
|
||||
<div>
|
||||
<Text strong>AI 模型选择:</Text>
|
||||
<div style={{ marginTop: '4px' }}>
|
||||
<Text type="secondary">只能选择本地已安装的模型,需要先点击"检测模型"按钮</Text>
|
||||
</div>
|
||||
<div style={{ marginTop: '4px', padding: '8px', background: '#fffbe6', borderRadius: '4px', border: '1px solid #ffe58f' }}>
|
||||
<Text strong>注意:</Text>如果模型列表为空,请先用命令行安装模型
|
||||
</div>
|
||||
</div>
|
||||
]}
|
||||
renderItem={(item: React.ReactNode) => (
|
||||
<List.Item>
|
||||
{item}
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Title level={5}>生成参数配置:</Title>
|
||||
<List
|
||||
dataSource={[
|
||||
<div>
|
||||
<Text strong>温度参数:</Text>
|
||||
<div style={{ marginTop: '4px' }}>
|
||||
<Text type="secondary">范围:0-2,推荐值:0.7</Text>
|
||||
</div>
|
||||
<div style={{ marginTop: '4px' }}>
|
||||
<Text type="secondary">• 低值(0.3-0.5):生成内容更加确定、保守</Text>
|
||||
</div>
|
||||
<div style={{ marginTop: '4px' }}>
|
||||
<Text type="secondary">• 中值(0.6-0.8):平衡创造性和连贯性(推荐)</Text>
|
||||
</div>
|
||||
<div style={{ marginTop: '4px' }}>
|
||||
<Text type="secondary">• 高值(0.9-1.2):内容更加随机、创新</Text>
|
||||
</div>
|
||||
</div>,
|
||||
<div>
|
||||
<Text strong>Top P 参数:</Text>
|
||||
<div style={{ marginTop: '4px' }}>
|
||||
<Text type="secondary">范围:0-1,推荐值:0.9</Text>
|
||||
</div>
|
||||
<div style={{ marginTop: '4px' }}>
|
||||
<Text type="secondary">控制生成文本的多样性,值越高内容越多样</Text>
|
||||
</div>
|
||||
</div>,
|
||||
<div>
|
||||
<Text strong>最大 Tokens:</Text>
|
||||
<div style={{ marginTop: '4px' }}>
|
||||
<Text type="secondary">范围:100-8000,推荐值:2000</Text>
|
||||
</div>
|
||||
<div style={{ marginTop: '4px' }}>
|
||||
<Text type="secondary">控制单次生成的最大长度,1 Token ≈ 0.75 个中文字符</Text>
|
||||
</div>
|
||||
</div>
|
||||
]}
|
||||
renderItem={(item: React.ReactNode) => (
|
||||
<List.Item>
|
||||
{item}
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Title level={4}>4. 配置验证完整流程</Title>
|
||||
<Alert
|
||||
message="配置验证步骤"
|
||||
description="请按顺序完成以下步骤,确保每个步骤都成功后再进行下一步"
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: '16px' }}
|
||||
/>
|
||||
|
||||
<Steps
|
||||
direction="vertical"
|
||||
current={-1}
|
||||
items={[
|
||||
{
|
||||
title: '步骤 1:测试 Ollama 服务连接',
|
||||
description: (
|
||||
<div>
|
||||
<div>点击"测试连接"按钮验证 Ollama 服务状态</div>
|
||||
<div style={{ marginTop: '8px', padding: '8px', background: '#f6ffed', borderRadius: '4px' }}>
|
||||
<CheckCircleOutlined style={{ color: '#52c41a', marginRight: '4px' }} />
|
||||
<Text>成功提示:Ollama 服务连接成功!</Text>
|
||||
</div>
|
||||
<div style={{ marginTop: '8px', padding: '8px', background: '#fff1f0', borderRadius: '4px' }}>
|
||||
<Text>失败提示:请检查 ollama serve 是否正在运行</Text>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '步骤 2:检测已安装模型',
|
||||
description: (
|
||||
<div>
|
||||
<div>点击"检测模型"按钮获取本地已安装的模型列表</div>
|
||||
<div style={{ marginTop: '8px', padding: '8px', background: '#f6ffed', borderRadius: '4px' }}>
|
||||
<CheckCircleOutlined style={{ color: '#52c41a', marginRight: '4px' }} />
|
||||
<Text>成功提示:成功检测到 N 个已安装模型</Text>
|
||||
</div>
|
||||
<div style={{ marginTop: '8px', padding: '8px', background: '#fffbe6', borderRadius: '4px' }}>
|
||||
<Text>警告提示:未检测到已安装的模型,请先使用 ollama pull 命令安装模型</Text>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '步骤 3:选择 AI 模型',
|
||||
description: (
|
||||
<div>
|
||||
<div>从下拉列表中选择要使用的模型</div>
|
||||
<div style={{ marginTop: '8px' }}>
|
||||
<Text type="secondary">• 模型列表显示:模型名称(模型大小)</Text>
|
||||
</div>
|
||||
<div style={{ marginTop: '4px' }}>
|
||||
<Text type="secondary">• 选择已安装的模型,系统会显示当前使用模型信息</Text>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '步骤 4:调整生成参数',
|
||||
description: (
|
||||
<div>
|
||||
<div>根据您的创作需求调整参数</div>
|
||||
<div style={{ marginTop: '8px' }}>
|
||||
<Text type="secondary">• 初次使用建议保持默认参数</Text>
|
||||
</div>
|
||||
<div style={{ marginTop: '4px' }}>
|
||||
<Text type="secondary">• 有经验后可以根据题材特点微调参数</Text>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '步骤 5:保存配置',
|
||||
description: (
|
||||
<div>
|
||||
<div>点击"保存配置"按钮完成设置</div>
|
||||
<div style={{ marginTop: '8px', padding: '8px', background: '#f6ffed', borderRadius: '4px' }}>
|
||||
<CheckCircleOutlined style={{ color: '#52c41a', marginRight: '4px' }} />
|
||||
<Text>成功提示:配置保存成功!</Text>
|
||||
</div>
|
||||
<div style={{ marginTop: '8px' }}>
|
||||
<Text type="secondary">保存后系统会自动刷新状态,页面底部会显示当前配置信息</Text>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
key: 'novel',
|
||||
label: (
|
||||
<span>
|
||||
<EditOutlined /> 小说管理功能
|
||||
</span>
|
||||
),
|
||||
children: (
|
||||
<div>
|
||||
<Alert
|
||||
message="核心功能"
|
||||
description="小说管理是本系统的核心功能,支持从创意到成稿的完整创作流程。"
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: '16px' }}
|
||||
/>
|
||||
|
||||
<Card style={{ marginBottom: '16px' }}>
|
||||
<Title level={4}>1. 创建新小说(第一步)</Title>
|
||||
<Paragraph>
|
||||
开始创作前,首先需要创建一个新的小说项目。这是整个创作流程的起点。
|
||||
</Paragraph>
|
||||
|
||||
<Title level={5}>详细步骤:</Title>
|
||||
<Steps
|
||||
direction="vertical"
|
||||
current={-1}
|
||||
items={[
|
||||
{
|
||||
title: '步骤 1:进入小说管理页面',
|
||||
description: (
|
||||
<div>
|
||||
<div>点击左侧菜单的"小说管理"进入小说列表页面</div>
|
||||
<div style={{ marginTop: '8px', padding: '8px', background: '#f0f9ff', borderRadius: '4px' }}>
|
||||
<Text type="secondary">📍 位置:左侧菜单 → 第一项"小说管理"</Text>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '步骤 2:点击"新建小说"按钮',
|
||||
description: (
|
||||
<div>
|
||||
<div>在页面右上角找到蓝色的"新建小说"按钮</div>
|
||||
<div style={{ marginTop: '8px', padding: '8px', background: '#f0f9ff', borderRadius: '4px' }}>
|
||||
<Text type="secondary">📍 位置:页面右上角,带有 ➕ 图标的蓝色按钮</Text>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '步骤 3:填写小说基本信息',
|
||||
description: (
|
||||
<div>
|
||||
<div>在弹出的对话框中填写以下信息:</div>
|
||||
<div style={{ marginTop: '8px' }}>
|
||||
<Text strong>书名:</Text>
|
||||
<Text type="secondary">为您的小说起一个吸引人的名字(必填)</Text>
|
||||
</div>
|
||||
<div style={{ marginTop: '4px' }}>
|
||||
<Text strong>题材类型:</Text>
|
||||
<Text type="secondary">从下拉列表中选择题材(必填)</Text>
|
||||
</div>
|
||||
<div style={{ marginTop: '4px' }}>
|
||||
<Text strong>初步想法:</Text>
|
||||
<Text type="secondary">简单描述您的创作概念,可留空让 AI 帮助完善(选填)</Text>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '步骤 4:确认创建',
|
||||
description: (
|
||||
<div>
|
||||
<div>点击"确定"按钮完成小说创建</div>
|
||||
<div style={{ marginTop: '8px', padding: '8px', background: '#f6ffed', borderRadius: '4px' }}>
|
||||
<CheckCircleOutlined style={{ color: '#52c41a', marginRight: '4px' }} />
|
||||
<Text>成功提示:小说创建成功,请前往AI生成页面完善设定</Text>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
]}
|
||||
/>
|
||||
|
||||
<Title level={5}>题材选项说明:</Title>
|
||||
<List
|
||||
grid={{ gutter: 16, column: 3 }}
|
||||
dataSource={[
|
||||
'穿越', '都市', '修仙', '武侠', '玄幻',
|
||||
'科幻', '言情', '历史', '游戏', '灵异', '军事', '悬疑', '其他'
|
||||
]}
|
||||
renderItem={(item: string) => (
|
||||
<List.Item>
|
||||
<Tag color="blue">{item}</Tag>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Card style={{ marginBottom: '16px' }}>
|
||||
<Title level={4}>2. 完善小说设定(第二步)</Title>
|
||||
<Paragraph>
|
||||
创建小说后,下一步是完善小说的详细设定。这是创作高质量小说的基础。
|
||||
</Paragraph>
|
||||
|
||||
<Title level={5}>详细步骤:</Title>
|
||||
<Steps
|
||||
direction="vertical"
|
||||
current={-1}
|
||||
items={[
|
||||
{
|
||||
title: '步骤 1:进入 AI 生成页面',
|
||||
description: (
|
||||
<div>
|
||||
<div>在小说列表中找到刚创建的小说,点击卡片进入详情页</div>
|
||||
<div style={{ marginTop: '8px' }}>
|
||||
<Text type="secondary">或者:点击卡片上的"编辑"按钮,然后选择"完善设定"</Text>
|
||||
</div>
|
||||
<div style={{ marginTop: '8px', padding: '8px', background: '#fffbe6', borderRadius: '4px', border: '1px solid #ffe58f' }}>
|
||||
<Text strong>⚠️ 重要:</Text>如果未完善设定,会显示黄色警告提示
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '步骤 2:配置小说参数',
|
||||
description: (
|
||||
<div>
|
||||
<div>在 AI 生成页面配置以下参数:</div>
|
||||
<div style={{ marginTop: '8px' }}>
|
||||
<Text strong>小说名称:</Text>
|
||||
<Text type="secondary">自动显示创建时的书名(不可修改)</Text>
|
||||
</div>
|
||||
<div style={{ marginTop: '4px' }}>
|
||||
<Text strong>题材类型:</Text>
|
||||
<Text type="secondary">自动显示创建时的题材(不可修改)</Text>
|
||||
</div>
|
||||
<div style={{ marginTop: '4px' }}>
|
||||
<Text strong>目标字数:</Text>
|
||||
<Text type="secondary">选择小说总字数:5万字、10万字、20万字、30万字、50万字、100万字</Text>
|
||||
</div>
|
||||
<div style={{ marginTop: '4px' }}>
|
||||
<Text strong>特殊要求:</Text>
|
||||
<Text type="secondary">描述您对这部小说的特殊要求或想法(选填)</Text>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '步骤 3:AI 生成基础设定',
|
||||
description: (
|
||||
<div>
|
||||
<div>点击"AI生成设定"按钮,AI 将为您生成:</div>
|
||||
<div style={{ marginTop: '8px' }}>
|
||||
<Text strong>📖 故事大纲:</Text>
|
||||
<Text type="secondary">整个故事的核心情节和主题(150字以内)</Text>
|
||||
</div>
|
||||
<div style={{ marginTop: '4px' }}>
|
||||
<Text strong>🏗️ 情节结构:</Text>
|
||||
<Text type="secondary">按模块划分整个故事的结构</Text>
|
||||
</div>
|
||||
<div style={{ marginTop: '4px' }}>
|
||||
<Text strong>👥 人物设定:</Text>
|
||||
<Text type="secondary">主要人物的详细设定,包括主角、配角、反派</Text>
|
||||
</div>
|
||||
<div style={{ marginTop: '8px', padding: '8px', background: '#f6ffed', borderRadius: '4px' }}>
|
||||
<CheckCircleOutlined style={{ color: '#52c41a', marginRight: '4px' }} />
|
||||
<Text>成功提示:基础设定生成成功!现在可以开始手动生成章节标题和细纲。</Text>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '步骤 4:生成章节规划',
|
||||
description: (
|
||||
<div>
|
||||
<div>基础设定生成后,可以开始生成章节标题和细纲:</div>
|
||||
<div style={{ marginTop: '8px' }}>
|
||||
<Text strong>单个生成:</Text>
|
||||
<Text type="secondary">点击"生成下一章标题+细纲"逐章生成</Text>
|
||||
</div>
|
||||
<div style={{ marginTop: '4px' }}>
|
||||
<Text strong>批量生成:</Text>
|
||||
<Text type="secondary">点击"批量生成5章标题+细纲"一次生成多个章节</Text>
|
||||
</div>
|
||||
<div style={{ marginTop: '4px' }}>
|
||||
<Text strong>单独生成:</Text>
|
||||
<Text type="secondary">直接点击章节卡片单独生成某章的标题和细纲</Text>
|
||||
</div>
|
||||
<div style={{ marginTop: '8px', padding: '8px', background: '#f0f9ff', borderRadius: '4px' }}>
|
||||
<Text type="secondary">💡 提示:章节规划包含章节标题、细纲、预计字数等信息</Text>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '步骤 5:编辑和确认设定',
|
||||
description: (
|
||||
<div>
|
||||
<div>生成的内容可以手动编辑:</div>
|
||||
<div style={{ marginTop: '8px' }}>
|
||||
<Text strong>编辑设定:</Text>
|
||||
<Text type="secondary">点击"编辑设定"按钮修改故事大纲、情节结构、人物设定</Text>
|
||||
</div>
|
||||
<div style={{ marginTop: '4px' }}>
|
||||
<Text strong>编辑章节:</Text>
|
||||
<Text type="secondary">点击章节卡片的"编辑"按钮修改章节标题和细纲</Text>
|
||||
</div>
|
||||
<div style={{ marginTop: '8px' }}>
|
||||
<Text strong>确认保存:</Text>
|
||||
<Text type="secondary">确认无误后点击"确认并保存"按钮</Text>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Card style={{ marginBottom: '16px' }}>
|
||||
<Title level={4}>3. 章节内容创作(第三步)</Title>
|
||||
<Paragraph>
|
||||
完成设定后,就可以开始具体的章节内容创作了。
|
||||
</Paragraph>
|
||||
|
||||
<Title level={5}>详细步骤:</Title>
|
||||
<Steps
|
||||
direction="vertical"
|
||||
current={-1}
|
||||
items={[
|
||||
{
|
||||
title: '步骤 1:进入小说详情页',
|
||||
description: (
|
||||
<div>
|
||||
<div>点击"前往创作"按钮或返回小说列表,点击小说卡片</div>
|
||||
<div style={{ marginTop: '8px', padding: '8px', background: '#f0f9ff', borderRadius: '4px' }}>
|
||||
<Text type="secondary">📍 位置:小说列表 → 点击小说卡片</Text>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '步骤 2:查看章节规划',
|
||||
description: (
|
||||
<div>
|
||||
<div>在"章节列表"中可以看到所有已规划的章节:</div>
|
||||
<div style={{ marginTop: '8px' }}>
|
||||
<Tag color="green">绿色边框</Tag>
|
||||
<Text type="secondary">:已生成标题和细纲的章节</Text>
|
||||
</div>
|
||||
<div style={{ marginTop: '4px' }}>
|
||||
<Tag color="orange">黄色边框</Tag>
|
||||
<Text type="secondary">:待生成标题和细纲的章节</Text>
|
||||
</div>
|
||||
<div style={{ marginTop: '4px' }}>
|
||||
<Tag color="blue">蓝色边框</Tag>
|
||||
<Text type="secondary">:已创作内容的章节</Text>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '步骤 3:生成章节内容',
|
||||
description: (
|
||||
<div>
|
||||
<div>选择一个已规划标题和细纲的章节,点击"AI生成"按钮:</div>
|
||||
<div style={{ marginTop: '8px' }}>
|
||||
<Text strong>生成过程:</Text>
|
||||
<Text type="secondary">AI 会基于小说设定和章节规划生成具体内容</Text>
|
||||
</div>
|
||||
<div style={{ marginTop: '4px' }}>
|
||||
<Text strong>生成时间:</Text>
|
||||
<Text type="secondary">大约需要10-30秒,请耐心等待</Text>
|
||||
</div>
|
||||
<div style={{ marginTop: '4px' }}>
|
||||
<Text strong>内容质量:</Text>
|
||||
<Text type="secondary">生成的内容约900-1200字,符合章节规划要求</Text>
|
||||
</div>
|
||||
<div style={{ marginTop: '8px', padding: '8px', background: '#f6ffed', borderRadius: '4px' }}>
|
||||
<CheckCircleOutlined style={{ color: '#52c41a', marginRight: '4px' }} />
|
||||
<Text>成功提示:第X章生成成功</Text>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '步骤 4:查看和编辑内容',
|
||||
description: (
|
||||
<div>
|
||||
<div>生成完成后,可以查看和编辑内容:</div>
|
||||
<div style={{ marginTop: '8px' }}>
|
||||
<Text strong>查看内容:</Text>
|
||||
<Text type="secondary">点击"查看内容"按钮查看生成的章节内容</Text>
|
||||
</div>
|
||||
<div style={{ marginTop: '4px' }}>
|
||||
<Text strong>手动编辑:</Text>
|
||||
<Text type="secondary">可以对AI生成的内容进行手动修改和润色</Text>
|
||||
</div>
|
||||
<div style={{ marginTop: '4px' }}>
|
||||
<Text strong>重新生成:</Text>
|
||||
<Text type="secondary">如果不满意,可以删除后重新生成</Text>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '步骤 5:继续创作其他章节',
|
||||
description: (
|
||||
<div>
|
||||
<div>重复步骤3-4,完成所有章节的创作:</div>
|
||||
<div style={{ marginTop: '8px' }}>
|
||||
<Text strong>建议顺序:</Text>
|
||||
<Text type="secondary">按章节顺序从第1章开始创作</Text>
|
||||
</div>
|
||||
<div style={{ marginTop: '4px' }}>
|
||||
<Text strong>批量创作:</Text>
|
||||
<Text type="secondary">可以连续创作多个章节,保持创作连贯性</Text>
|
||||
</div>
|
||||
<div style={{ marginTop: '4px' }}>
|
||||
<Text strong>定期保存:</Text>
|
||||
<Text type="secondary">系统会自动保存,但建议定期手动确认</Text>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
]}
|
||||
/>
|
||||
|
||||
<Title level={5}>章节管理功能:</Title>
|
||||
<List
|
||||
dataSource={[
|
||||
<div>
|
||||
<Text strong>查看章节:</Text>
|
||||
<Text type="secondary">点击章节卡片的"查看内容"按钮查看完整章节内容</Text>
|
||||
</div>,
|
||||
<div>
|
||||
<Text strong>编辑章节:</Text>
|
||||
<Text type="secondary">手动修改AI生成的内容,添加自己的创意和风格</Text>
|
||||
</div>,
|
||||
<div>
|
||||
<Text strong>删除章节:</Text>
|
||||
<Text type="secondary">删除不满意的章节,重新生成</Text>
|
||||
</div>,
|
||||
<div>
|
||||
<Text strong>下载章节:</Text>
|
||||
<Text type="secondary">将单个章节导出为 Markdown 文件</Text>
|
||||
</div>,
|
||||
<div>
|
||||
<Text strong>下载整书:</Text>
|
||||
<Text type="secondary">将所有章节合并导出为完整的 Markdown 文件</Text>
|
||||
</div>,
|
||||
<div>
|
||||
<Text strong>查看规划:</Text>
|
||||
<Text type="secondary">查看章节的标题、细纲、预计字数等规划信息</Text>
|
||||
</div>
|
||||
]}
|
||||
renderItem={(item: React.ReactNode) => (
|
||||
<List.Item>
|
||||
{item}
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
key: 'tips',
|
||||
label: (
|
||||
<span>
|
||||
<AppstoreOutlined /> 使用技巧
|
||||
</span>
|
||||
),
|
||||
children: (
|
||||
<div>
|
||||
<Card>
|
||||
<Title level={3}>提高创作质量的技巧</Title>
|
||||
<List
|
||||
dataSource={[
|
||||
'充分完善设定:详细的角色和世界观设定有助于生成更连贯的内容',
|
||||
'逐步生成章节:不要一次性生成太多内容,分章节逐步完善质量更高',
|
||||
'适当手动编辑:AI生成后进行手动修改,提升整体质量和风格统一性',
|
||||
'参数调优:根据不同题材调整温度和Top P参数,找到最佳配置',
|
||||
'保持风格一致:在续写时参考前文风格,保持整本小说的风格统一',
|
||||
'定期备份:虽然系统有自动保存,但重要节点建议手动导出备份'
|
||||
]}
|
||||
renderItem={(item: string) => (
|
||||
<List.Item>
|
||||
<Text>{item}</Text>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Title level={3}>常见问题解决</Title>
|
||||
<List
|
||||
dataSource={[
|
||||
<div>
|
||||
<Text strong>问题:生成内容不符合预期</Text>
|
||||
<div style={{ marginTop: '4px' }}>
|
||||
<Text type="secondary">解决:重新编辑章节细纲,提供更详细的指导,然后重新生成</Text>
|
||||
</div>
|
||||
</div>,
|
||||
<div>
|
||||
<Text strong>问题:章节之间连贯性差</Text>
|
||||
<div style={{ marginTop: '4px' }}>
|
||||
<Text type="secondary">解决:手动编辑前一章的结尾,为下一章做铺垫,然后重新生成</Text>
|
||||
</div>
|
||||
</div>,
|
||||
<div>
|
||||
<Text strong>问题:人物性格不一致</Text>
|
||||
<div style={{ marginTop: '4px' }}>
|
||||
<Text type="secondary">解决:完善人物设定,详细描述性格特点,并在章节细纲中强调</Text>
|
||||
</div>
|
||||
</div>,
|
||||
<div>
|
||||
<Text strong>问题:创作进度缓慢</Text>
|
||||
<div style={{ marginTop: '4px' }}>
|
||||
<Text type="secondary">解决:使用批量生成功能,但要注意定期检查质量</Text>
|
||||
</div>
|
||||
</div>
|
||||
]}
|
||||
renderItem={(item: React.ReactNode) => (
|
||||
<List.Item>
|
||||
{item}
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: '1200px', margin: '0 auto', padding: '24px' }}>
|
||||
<div style={{ marginBottom: '32px', textAlign: 'center' }}>
|
||||
<Title level={2} style={{ color: '#1890ff', marginBottom: '8px' }}>
|
||||
<BookOutlined /> AI 小说创作系统使用手册
|
||||
</Title>
|
||||
<Text type="secondary">完整的系统功能使用指南</Text>
|
||||
</div>
|
||||
|
||||
<Tabs
|
||||
defaultActiveKey="overview"
|
||||
items={tabItems}
|
||||
size="large"
|
||||
style={{ marginBottom: '24px' }}
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<Title level={3}>技术支持</Title>
|
||||
<Paragraph>
|
||||
如遇到问题或需要更多帮助,请参考:
|
||||
</Paragraph>
|
||||
<List
|
||||
dataSource={[
|
||||
'Ollama 官方文档:https://github.com/ollama/ollama',
|
||||
'Qwen 模型文档:https://huggingface.co/Qwen',
|
||||
'系统反馈:通过底部联系信息反馈问题'
|
||||
]}
|
||||
renderItem={(item: string) => (
|
||||
<List.Item>
|
||||
<Text>{item}</Text>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Divider />
|
||||
<Paragraph style={{ textAlign: 'center', color: '#8c8c8c' }}>
|
||||
AI 小说创作系统 v0.0.1 | 代码老中医出品
|
||||
</Paragraph>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserManual;
|
||||
1
src/react-app-env.d.ts
vendored
Normal file
1
src/react-app-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="react-scripts" />
|
||||
15
src/reportWebVitals.ts
Normal file
15
src/reportWebVitals.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { ReportHandler } from 'web-vitals';
|
||||
|
||||
const reportWebVitals = (onPerfEntry?: ReportHandler) => {
|
||||
if (onPerfEntry && onPerfEntry instanceof Function) {
|
||||
import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
|
||||
getCLS(onPerfEntry);
|
||||
getFID(onPerfEntry);
|
||||
getFCP(onPerfEntry);
|
||||
getLCP(onPerfEntry);
|
||||
getTTFB(onPerfEntry);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export default reportWebVitals;
|
||||
5
src/setupTests.ts
Normal file
5
src/setupTests.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
// jest-dom adds custom jest matchers for asserting on DOM nodes.
|
||||
// allows you to do things like:
|
||||
// expect(element).toHaveTextContent(/react/i)
|
||||
// learn more: https://github.com/testing-library/jest-dom
|
||||
import '@testing-library/jest-dom';
|
||||
61
src/types/index.ts
Normal file
61
src/types/index.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
export interface SystemConfig {
|
||||
ollamaUrl: string;
|
||||
model: string;
|
||||
temperature: number;
|
||||
topP: number;
|
||||
maxTokens: number;
|
||||
}
|
||||
|
||||
// 章节规划信息
|
||||
export interface ChapterOutline {
|
||||
chapterNumber: number;
|
||||
title: string;
|
||||
outline: string;
|
||||
moduleNumber?: number; // 所属模块
|
||||
estimatedWords?: number; // 预计字数
|
||||
status?: 'pending' | 'completed'; // 章节状态:待生成或已完成
|
||||
}
|
||||
|
||||
export interface Novel {
|
||||
id: string;
|
||||
title: string;
|
||||
genre: string;
|
||||
outline: string;
|
||||
targetWordCount?: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
// AI生成的详细设定
|
||||
generatedSettings?: {
|
||||
storyOutline: string;
|
||||
plotStructure: string;
|
||||
characters: string;
|
||||
targetWordCount: number;
|
||||
chapterCount: number;
|
||||
moduleCount: number; // 模块数量
|
||||
chapters: ChapterOutline[]; // 详细章节规划
|
||||
chapterOutline: string; // 原始章节大纲文本
|
||||
};
|
||||
// 本地文件路径
|
||||
settingFilePath?: string;
|
||||
}
|
||||
|
||||
export interface Chapter {
|
||||
id: string;
|
||||
novelId: string;
|
||||
chapterNumber: number;
|
||||
title: string;
|
||||
content: string;
|
||||
outline: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
estimatedWords?: number;
|
||||
moduleNumber?: number;
|
||||
}
|
||||
|
||||
export interface NovelGenerationParams {
|
||||
title: string;
|
||||
genre?: string;
|
||||
customRequirements?: string;
|
||||
targetWordCount?: number; // 目标字数
|
||||
preferredModuleCount?: number; // 偏好的模块数量
|
||||
}
|
||||
279
src/utils/indexedDB.ts
Normal file
279
src/utils/indexedDB.ts
Normal file
@@ -0,0 +1,279 @@
|
||||
import { SystemConfig, Novel, Chapter } from '../types';
|
||||
|
||||
const DB_NAME = 'AINovelDB';
|
||||
const DB_VERSION = 2;
|
||||
|
||||
class IndexedDBStorage {
|
||||
private db: IDBDatabase | null = null;
|
||||
|
||||
async init(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
|
||||
request.onerror = () => {
|
||||
reject(new Error('Failed to open IndexedDB'));
|
||||
};
|
||||
|
||||
request.onsuccess = () => {
|
||||
this.db = request.result;
|
||||
resolve();
|
||||
};
|
||||
|
||||
request.onupgradeneeded = (event) => {
|
||||
const db = (event.target as IDBOpenDBRequest).result;
|
||||
|
||||
// 创建小说存储
|
||||
if (!db.objectStoreNames.contains('novels')) {
|
||||
const novelStore = db.createObjectStore('novels', { keyPath: 'id' });
|
||||
novelStore.createIndex('createdAt', 'createdAt', { unique: false });
|
||||
}
|
||||
|
||||
// 创建章节存储
|
||||
if (!db.objectStoreNames.contains('chapters')) {
|
||||
const chapterStore = db.createObjectStore('chapters', { keyPath: 'id' });
|
||||
chapterStore.createIndex('novelId', 'novelId', { unique: false });
|
||||
chapterStore.createIndex('chapterNumber', 'chapterNumber', { unique: false });
|
||||
}
|
||||
|
||||
// 创建系统配置存储
|
||||
if (!db.objectStoreNames.contains('config')) {
|
||||
db.createObjectStore('config', { keyPath: 'id' });
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private async getStore(storeName: string, mode: IDBTransactionMode = 'readonly'): Promise<IDBObjectStore> {
|
||||
if (!this.db) {
|
||||
await this.init();
|
||||
}
|
||||
|
||||
// 如果数据库连接失败,尝试重新初始化
|
||||
if (!this.db) {
|
||||
throw new Error('Database initialization failed');
|
||||
}
|
||||
|
||||
// 检查请求的存储是否存在
|
||||
if (!this.db.objectStoreNames.contains(storeName)) {
|
||||
console.error(`Object store '${storeName}' not found in database`);
|
||||
// 尝试重新初始化数据库以创建缺失的存储
|
||||
await this.reinit();
|
||||
if (!this.db || !this.db.objectStoreNames.contains(storeName)) {
|
||||
throw new Error(`Object store '${storeName}' not found after reinitialization`);
|
||||
}
|
||||
}
|
||||
|
||||
const transaction = this.db.transaction(storeName, mode);
|
||||
return transaction.objectStore(storeName);
|
||||
}
|
||||
|
||||
private async reinit(): Promise<void> {
|
||||
// 关闭当前连接
|
||||
if (this.db) {
|
||||
this.db.close();
|
||||
this.db = null;
|
||||
}
|
||||
|
||||
// 删除旧数据库并重新创建
|
||||
return new Promise((resolve, reject) => {
|
||||
const deleteReq = indexedDB.deleteDatabase(DB_NAME);
|
||||
deleteReq.onsuccess = () => {
|
||||
console.log('Old database deleted successfully');
|
||||
this.init().then(resolve).catch(reject);
|
||||
};
|
||||
deleteReq.onerror = () => {
|
||||
console.error('Failed to delete old database');
|
||||
reject(new Error('Failed to delete old database'));
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// 小说操作
|
||||
async getNovels(): Promise<Novel[]> {
|
||||
const store = await this.getStore('novels');
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = store.getAll();
|
||||
request.onsuccess = () => resolve(request.result || []);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
async addNovel(novel: Novel): Promise<void> {
|
||||
const store = await this.getStore('novels', 'readwrite');
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = store.add(novel);
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
async updateNovel(id: string, updates: Partial<Novel>): Promise<void> {
|
||||
const novels = await this.getNovels();
|
||||
const index = novels.findIndex(n => n.id === id);
|
||||
if (index !== -1) {
|
||||
const updatedNovel = { ...novels[index], ...updates, updatedAt: new Date().toISOString() };
|
||||
const store = await this.getStore('novels', 'readwrite');
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = store.put(updatedNovel);
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async deleteNovel(id: string): Promise<void> {
|
||||
const store = await this.getStore('novels', 'readwrite');
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = store.delete(id);
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
// 章节操作
|
||||
async getChapters(novelId: string): Promise<Chapter[]> {
|
||||
const store = await this.getStore('chapters');
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = store.getAll();
|
||||
request.onsuccess = () => {
|
||||
const allChapters = request.result || [];
|
||||
resolve(allChapters.filter(c => c.novelId === novelId));
|
||||
};
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
async getAllChapters(): Promise<Chapter[]> {
|
||||
const store = await this.getStore('chapters');
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = store.getAll();
|
||||
request.onsuccess = () => resolve(request.result || []);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
async addChapter(chapter: Chapter): Promise<void> {
|
||||
const store = await this.getStore('chapters', 'readwrite');
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = store.add(chapter);
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
async updateChapter(id: string, updates: Partial<Chapter>): Promise<void> {
|
||||
const chapters = await this.getAllChapters();
|
||||
const index = chapters.findIndex(c => c.id === id);
|
||||
if (index !== -1) {
|
||||
const updatedChapter = { ...chapters[index], ...updates, updatedAt: new Date().toISOString() };
|
||||
const store = await this.getStore('chapters', 'readwrite');
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = store.put(updatedChapter);
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async deleteChapter(id: string): Promise<void> {
|
||||
const store = await this.getStore('chapters', 'readwrite');
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = store.delete(id);
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
// 系统配置操作
|
||||
async getSystemConfig(): Promise<SystemConfig> {
|
||||
const store = await this.getStore('config');
|
||||
return new Promise((resolve) => {
|
||||
const request = store.get('system');
|
||||
request.onsuccess = () => {
|
||||
const config = request.result;
|
||||
if (config) {
|
||||
delete config.id;
|
||||
resolve(config);
|
||||
} else {
|
||||
// 返回默认配置
|
||||
resolve({
|
||||
ollamaUrl: 'http://localhost:11434',
|
||||
model: '',
|
||||
temperature: 0.7,
|
||||
topP: 0.9,
|
||||
maxTokens: 2000
|
||||
});
|
||||
}
|
||||
};
|
||||
request.onerror = () => {
|
||||
resolve({
|
||||
ollamaUrl: 'http://localhost:11434',
|
||||
model: '',
|
||||
temperature: 0.7,
|
||||
topP: 0.9,
|
||||
maxTokens: 2000
|
||||
});
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async saveSystemConfig(config: SystemConfig): Promise<void> {
|
||||
const store = await this.getStore('config', 'readwrite');
|
||||
const data = { id: 'system', ...config };
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = store.put(data);
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 导出单例
|
||||
export const indexedDBStorage = new IndexedDBStorage();
|
||||
|
||||
// 调试工具:检查数据库状态
|
||||
export const debugDB = async () => {
|
||||
try {
|
||||
await indexedDBStorage.init();
|
||||
const novels = await indexedDBStorage.getNovels();
|
||||
const chapters = await indexedDBStorage.getAllChapters();
|
||||
const config = await indexedDBStorage.getSystemConfig();
|
||||
|
||||
console.log('=== IndexedDB 调试信息 ===');
|
||||
console.log('小说数量:', novels.length);
|
||||
console.log('章节数量:', chapters.length);
|
||||
console.log('系统配置:', config);
|
||||
console.log('========================');
|
||||
|
||||
return {
|
||||
novels,
|
||||
chapters,
|
||||
config,
|
||||
status: 'success'
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('IndexedDB 调试失败:', error);
|
||||
return {
|
||||
status: 'error',
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// 兼容旧代码的 storage 对象
|
||||
export const storage = {
|
||||
getNovels: () => indexedDBStorage.getNovels(),
|
||||
addNovel: (novel: Novel) => indexedDBStorage.addNovel(novel),
|
||||
updateNovel: (id: string, updates: Partial<Novel>) => indexedDBStorage.updateNovel(id, updates),
|
||||
deleteNovel: (id: string) => indexedDBStorage.deleteNovel(id),
|
||||
|
||||
getChapters: (novelId: string) => indexedDBStorage.getChapters(novelId),
|
||||
getAllChapters: () => indexedDBStorage.getAllChapters(),
|
||||
addChapter: (chapter: Chapter) => indexedDBStorage.addChapter(chapter),
|
||||
updateChapter: (id: string, updates: Partial<Chapter>) => indexedDBStorage.updateChapter(id, updates),
|
||||
deleteChapter: (id: string) => indexedDBStorage.deleteChapter(id),
|
||||
|
||||
getSystemConfig: () => indexedDBStorage.getSystemConfig(),
|
||||
saveSystemConfig: (config: SystemConfig) => indexedDBStorage.saveSystemConfig(config)
|
||||
};
|
||||
268
src/utils/novelSettingManager.ts
Normal file
268
src/utils/novelSettingManager.ts
Normal file
@@ -0,0 +1,268 @@
|
||||
import { Novel } from '../types';
|
||||
|
||||
export class NovelSettingManager {
|
||||
/**
|
||||
* 生成小说设置文件内容(优化版)
|
||||
*/
|
||||
static generateSettingContent(title: string, settings: any): string {
|
||||
let content = `# ${title} - 小说设定文件\n\n`;
|
||||
content += `生成时间:${new Date().toLocaleString()}\n\n`;
|
||||
content += `---\n\n`;
|
||||
|
||||
if (settings.storyOutline) {
|
||||
content += `## 📖 故事大纲\n\n${settings.storyOutline}\n\n`;
|
||||
}
|
||||
|
||||
if (settings.plotStructure) {
|
||||
content += `## 🎯 情节结构\n\n${settings.plotStructure}\n\n`;
|
||||
}
|
||||
|
||||
if (settings.characters) {
|
||||
content += `## 👥 人物设定\n\n${settings.characters}\n\n`;
|
||||
}
|
||||
|
||||
content += `## 📊 篇幅规划\n\n`;
|
||||
content += `- 目标字数:${settings.targetWordCount}字\n`;
|
||||
content += `- 章节数量:${settings.chapterCount}章\n`;
|
||||
content += `- 每章字数:${Math.round(settings.targetWordCount / settings.chapterCount)}字\n\n`;
|
||||
|
||||
// 增加详细的章节规划信息
|
||||
if (settings.chapters && settings.chapters.length > 0) {
|
||||
content += `## 📚 详细章节规划\n\n`;
|
||||
content += `以下是全书${settings.chapters.length}个章节的详细规划,每章包含标题、细纲、预计字数等信息。\n\n`;
|
||||
|
||||
// 按模块分组显示章节
|
||||
const moduleGroups: { [key: number]: any[] } = {};
|
||||
settings.chapters.forEach((chapter: any) => {
|
||||
const moduleNum = chapter.moduleNumber || 1;
|
||||
if (!moduleGroups[moduleNum]) {
|
||||
moduleGroups[moduleNum] = [];
|
||||
}
|
||||
moduleGroups[moduleNum].push(chapter);
|
||||
});
|
||||
|
||||
// 输出每个模块的章节
|
||||
Object.keys(moduleGroups).sort((a, b) => parseInt(a) - parseInt(b)).forEach(moduleNum => {
|
||||
content += `### 模块${moduleNum}\n\n`;
|
||||
moduleGroups[parseInt(moduleNum)].forEach((chapter: any) => {
|
||||
content += `#### 第${chapter.chapterNumber}章:${chapter.title}\n\n`;
|
||||
content += `**所属模块**:模块${chapter.moduleNumber || 1}\n\n`;
|
||||
content += `**章节细纲**:\n${chapter.outline}\n\n`;
|
||||
content += `**预计字数**:${chapter.estimatedWords || 1050}字\n\n`;
|
||||
content += `---\n\n`;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (settings.chapterOutline) {
|
||||
content += `## 📚 原始章节细纲\n\n${settings.chapterOutline}\n\n`;
|
||||
}
|
||||
|
||||
content += `---\n\n`;
|
||||
content += `*此文件由AI创作助手自动生成,包含小说的核心设定信息和详细章节规划。在创作各章节时请严格遵循本设定的要求。*\n`;
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成并保存设置文件
|
||||
*/
|
||||
static async generateAndSaveSettingFile(title: string, settings: any): Promise<string> {
|
||||
try {
|
||||
const content = this.generateSettingContent(title, settings);
|
||||
const filename = `${title}_novel_setting.md`;
|
||||
|
||||
// 创建下载链接
|
||||
const blob = new Blob([content], { type: 'text/markdown;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
return filename;
|
||||
} catch (error) {
|
||||
console.error('保存设置文件失败:', error);
|
||||
throw new Error('保存设置文件失败');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从文件内容解析设置
|
||||
*/
|
||||
static parseSettingFile(content: string): any {
|
||||
const settings: any = {
|
||||
storyOutline: '',
|
||||
plotStructure: '',
|
||||
characters: '',
|
||||
targetWordCount: 50000,
|
||||
chapterCount: 20,
|
||||
chapterOutline: ''
|
||||
};
|
||||
|
||||
const lines = content.split('\n');
|
||||
let currentSection = '';
|
||||
let sectionContent = '';
|
||||
|
||||
lines.forEach(line => {
|
||||
if (line.startsWith('## ')) {
|
||||
// 保存上一个section
|
||||
if (currentSection && sectionContent.trim()) {
|
||||
switch(currentSection) {
|
||||
case '📖 故事大纲':
|
||||
case '故事大纲':
|
||||
settings.storyOutline = sectionContent.trim();
|
||||
break;
|
||||
case '🎯 情节结构':
|
||||
case '情节结构':
|
||||
settings.plotStructure = sectionContent.trim();
|
||||
break;
|
||||
case '👥 人物设定':
|
||||
case '人物设定':
|
||||
settings.characters = sectionContent.trim();
|
||||
break;
|
||||
case '📚 章节细纲':
|
||||
case '章节细纲':
|
||||
settings.chapterOutline = sectionContent.trim();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
currentSection = line.replace('## ', '').replace(/[📖🎯👥📚]/g, '').trim();
|
||||
sectionContent = '';
|
||||
} else if (line.trim() && !line.startsWith('---') && !line.startsWith('# ') && !line.startsWith('*')) {
|
||||
sectionContent += line + '\n';
|
||||
}
|
||||
});
|
||||
|
||||
// 处理最后一个section
|
||||
if (currentSection && sectionContent.trim()) {
|
||||
switch(currentSection) {
|
||||
case '故事大纲':
|
||||
settings.storyOutline = sectionContent.trim();
|
||||
break;
|
||||
case '情节结构':
|
||||
settings.plotStructure = sectionContent.trim();
|
||||
break;
|
||||
case '人物设定':
|
||||
settings.characters = sectionContent.trim();
|
||||
break;
|
||||
case '章节细纲':
|
||||
settings.chapterOutline = sectionContent.trim();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 提取数字信息
|
||||
const wordCountMatch = content.match(/目标字数[::]\s*(\d+)/);
|
||||
if (wordCountMatch) {
|
||||
settings.targetWordCount = parseInt(wordCountMatch[1]);
|
||||
}
|
||||
|
||||
const chapterCountMatch = content.match(/章节数量[::]\s*(\d+)/);
|
||||
if (chapterCountMatch) {
|
||||
settings.chapterCount = parseInt(chapterCountMatch[1]);
|
||||
}
|
||||
|
||||
return settings;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成章节上下文信息(优化版)
|
||||
*/
|
||||
static generateChapterContext(
|
||||
novel: Novel,
|
||||
currentChapter: number,
|
||||
previousChapters: any[],
|
||||
settings: any,
|
||||
chapterTitle?: string,
|
||||
chapterOutline?: string
|
||||
): string {
|
||||
let context = `# 《${novel.title}》第${currentChapter}章创作指南\n\n`;
|
||||
|
||||
// 第一步:novel_setting.md的核心设定
|
||||
context += `## 📋 核心设定(必须严格遵守)\n\n`;
|
||||
|
||||
if (settings.storyOutline) {
|
||||
context += `### 📖 故事大纲\n${settings.storyOutline}\n\n`;
|
||||
}
|
||||
|
||||
if (settings.plotStructure) {
|
||||
context += `### 🏗️ 情节结构\n${settings.plotStructure}\n\n`;
|
||||
}
|
||||
|
||||
if (settings.characters) {
|
||||
context += `### 👥 人物设定\n${settings.characters}\n\n`;
|
||||
}
|
||||
|
||||
// 每10章重新强调核心设定
|
||||
if (currentChapter % 10 === 1) {
|
||||
context += `⚠️ **重要提醒**:这是第${currentChapter}章,属于新一轮章节的开始。请特别重视并严格遵守上述核心设定,确保人物性格、能力、背景等保持一致,不偏离主线。\n\n`;
|
||||
}
|
||||
|
||||
context += `---\n\n`;
|
||||
|
||||
// 第二步:当前章节的标题和细纲
|
||||
context += `## 📝 当前章节创作要求\n\n`;
|
||||
context += `**章节编号**:第${currentChapter}章\n\n`;
|
||||
|
||||
if (chapterTitle) {
|
||||
context += `**章节标题**:${chapterTitle}\n\n`;
|
||||
}
|
||||
|
||||
if (chapterOutline) {
|
||||
context += `**章节细纲**:\n${chapterOutline}\n\n`;
|
||||
} else if (settings.chapters && settings.chapters[currentChapter - 1]) {
|
||||
// 从预设章节数据中获取细纲
|
||||
const plannedChapter = settings.chapters[currentChapter - 1];
|
||||
context += `**章节标题**:${plannedChapter.title}\n\n`;
|
||||
context += `**章节细纲**:\n${plannedChapter.outline}\n\n`;
|
||||
context += `**预计字数**:${plannedChapter.estimatedWords || 1050}字\n\n`;
|
||||
if (plannedChapter.moduleNumber) {
|
||||
context += `**所属模块**:模块${plannedChapter.moduleNumber}\n\n`;
|
||||
}
|
||||
}
|
||||
|
||||
context += `---\n\n`;
|
||||
|
||||
// 第三步:最近一章的内容(保持上下文连贯性)
|
||||
if (previousChapters.length > 0) {
|
||||
const lastChapter = previousChapters[previousChapters.length - 1];
|
||||
context += `## 📖 上一章内容回顾(用于衔接)\n\n`;
|
||||
context += `**第${lastChapter.chapterNumber}章 ${lastChapter.title}**\n\n`;
|
||||
|
||||
// 截取上一章的完整内容,但限制长度避免token过多
|
||||
const lastContent = lastChapter.content.length > 2000
|
||||
? lastChapter.content.substring(0, 2000) + "..."
|
||||
: lastChapter.content;
|
||||
|
||||
context += `${lastContent}\n\n`;
|
||||
context += `*(以上是第${lastChapter.chapterNumber}章的完整内容,请确保第${currentChapter}章与其自然衔接)*\n\n`;
|
||||
context += `---\n\n`;
|
||||
} else {
|
||||
context += `## 📖 创作说明\n\n`;
|
||||
context += `这是小说的第一章,请开篇设定好场景,引入主要人物和核心冲突,为后续情节发展做好铺垫。\n\n`;
|
||||
context += `---\n\n`;
|
||||
}
|
||||
|
||||
// 第四步:创作要求
|
||||
const targetWords = Math.round(settings.targetWordCount / settings.chapterCount);
|
||||
context += `## ✍️ 创作要求\n\n`;
|
||||
context += `1. **字数要求**:${targetWords}字左右(可根据情节需要适当调整)\n`;
|
||||
context += `2. **内容要求**:严格按照章节细纲创作,不偏离主线\n`;
|
||||
context += `3. **人物一致性**:确保人物性格、能力、说话方式等与核心设定一致\n`;
|
||||
context += `4. **情节连贯**:与上一章自然衔接,确保情节发展合理\n`;
|
||||
context += `5. **文笔要求**:描述生动,对话自然,节奏紧凑\n`;
|
||||
|
||||
if (currentChapter % 10 === 1) {
|
||||
context += `6. **特殊提醒**:这是新一轮章节的开始,请特别注意巩固核心设定,防止情节偏离\n`;
|
||||
}
|
||||
|
||||
context += `\n请开始创作第${currentChapter}章的完整内容:\n\n`;
|
||||
|
||||
return context;
|
||||
}
|
||||
}
|
||||
120
src/utils/ollama.ts
Normal file
120
src/utils/ollama.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import { SystemConfig } from '../types';
|
||||
|
||||
export class OllamaService {
|
||||
private config: SystemConfig;
|
||||
|
||||
constructor(config: SystemConfig) {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
updateConfig(config: SystemConfig) {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
|
||||
|
||||
async testConnection(): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch(`${this.config.ollamaUrl}/api/tags`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
return response.ok;
|
||||
} catch (error) {
|
||||
console.error('Ollama连接测试失败:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async getAvailableModels(): Promise<string[]> {
|
||||
try {
|
||||
const response = await fetch(`${this.config.ollamaUrl}/api/tags`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return data.models?.map((model: any) => model.name) || [];
|
||||
} catch (error) {
|
||||
console.error('获取模型列表失败:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async getAvailableModelsWithInfo(): Promise<any[]> {
|
||||
try {
|
||||
const response = await fetch(`${this.config.ollamaUrl}/api/tags`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return data.models || [];
|
||||
} catch (error) {
|
||||
console.error('获取模型详细信息失败:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async chat(userMessage: string, context: string): Promise<string> {
|
||||
const systemPrompt = `你是一位专业的写作助手和创意顾问,擅长帮助作者进行小说创作。你可以:
|
||||
1. 讨论情节构思和发展
|
||||
2. 分析人物性格和关系
|
||||
3. 提供场景设置建议
|
||||
4. 解决创作瓶颈
|
||||
5. 提供写作技巧和指导
|
||||
|
||||
请根据用户的问题提供专业、有建设性的建议。`;
|
||||
|
||||
const prompt = `${context}
|
||||
|
||||
用户问题:${userMessage}
|
||||
|
||||
请提供专业建议:`;
|
||||
|
||||
try {
|
||||
const response = await fetch(`${this.config.ollamaUrl}/api/generate`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: this.config.model,
|
||||
prompt: prompt,
|
||||
system: systemPrompt,
|
||||
stream: false,
|
||||
options: {
|
||||
temperature: this.config.temperature,
|
||||
top_p: this.config.topP,
|
||||
num_predict: this.config.maxTokens
|
||||
}
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return data.response || '回复失败,请重试。';
|
||||
} catch (error) {
|
||||
console.error('Ollama聊天错误:', error);
|
||||
throw new Error('AI回复失败,请检查Ollama服务是否正常运行。');
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user