🎉 初始化项目

Signed-off-by: Echo <1711788888@qq.com>
This commit is contained in:
2026-02-27 21:52:00 +08:00
commit f4e166c5ee
482 changed files with 55079 additions and 0 deletions

1
web-app/.env.development Normal file
View File

@@ -0,0 +1 @@
VITE_API_BASE_URL=http://localhost:8888

1
web-app/.env.production Normal file
View File

@@ -0,0 +1 @@
VITE_API_BASE_URL=https://your-production-api.com

View File

@@ -0,0 +1,8 @@
{
"hash": "137c6446",
"configHash": "6b4f1a36",
"lockfileHash": "ed4ec364",
"browserHash": "4a9d650d",
"optimized": {},
"chunks": {}
}

View File

@@ -0,0 +1,3 @@
{
"type": "module"
}

View File

@@ -0,0 +1,3 @@
{
"type": "module"
}

55
web-app/README.md Normal file
View File

@@ -0,0 +1,55 @@
# 云酒馆前端
现代化的 SillyTavern UI采用 Glassmorphism 风格和深色主题。
## 技术栈
- React 18 + TypeScript
- Tailwind CSS
- Vite
- React Router
- Lucide Icons
## 开发
```bash
npm install
npm run dev
```
## 构建
```bash
npm run build
npm run preview
```
## 项目结构
```
src/
├── components/ # 可复用组件
│ ├── Navbar.tsx
│ ├── Sidebar.tsx
│ ├── ChatArea.tsx
│ └── ...
├── pages/ # 页面组件
│ ├── HomePage.tsx
│ ├── CharacterMarket.tsx
│ ├── CharacterManagePage.tsx
│ ├── PresetManagePage.tsx
│ └── ...
├── App.tsx # 路由配置
├── main.tsx # 入口文件
└── index.css # 全局样式
```
## 设计系统
- 主色调: #7C3AED (紫色)
- 次要色: #A78BFA (淡紫色)
- 强调色: #F97316 (橙色)
- 字体: Inter
- 风格: Glassmorphism + 深色主题
详见 [设计系统文档](../docs/design-system/)

15
web-app/index.html Normal file
View File

@@ -0,0 +1,15 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<title>SillyTavern - Modern UI</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

4652
web-app/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

32
web-app/package.json Normal file
View File

@@ -0,0 +1,32 @@
{
"name": "sillytavern-modern-ui",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"axios": "^1.13.5",
"lucide-react": "^0.344.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-markdown": "^10.1.0",
"react-router-dom": "^7.13.1",
"rehype-raw": "^7.0.0",
"rehype-sanitize": "^6.0.0",
"remark-gfm": "^4.0.1"
},
"devDependencies": {
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.4.19",
"postcss": "^8.4.38",
"tailwindcss": "^3.4.3",
"typescript": "^5.5.3",
"vite": "^5.3.1"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

36
web-app/src/App.tsx Normal file
View File

@@ -0,0 +1,36 @@
import {BrowserRouter, Route, Routes} from 'react-router-dom'
import HomePage from './pages/HomePage'
import CharacterMarket from './pages/CharacterMarket'
import CharacterDetail from './pages/CharacterDetail'
import CharacterDetailPage from './pages/CharacterDetailPage'
import ChatPage from './pages/ChatPage'
import LoginPage from './pages/LoginPage'
import RegisterPage from './pages/RegisterPage'
import ForgotPasswordPage from './pages/ForgotPasswordPage'
import ProfilePage from './pages/ProfilePage'
import CharacterManagePage from './pages/CharacterManagePage'
import PresetManagePage from './pages/PresetManagePage'
import AdminPage from './pages/AdminPage'
function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/market" element={<CharacterMarket />} />
<Route path="/character/:id" element={<CharacterDetail />} />
<Route path="/my-character/:id" element={<CharacterDetailPage />} />
<Route path="/chat" element={<ChatPage />} />
<Route path="/login" element={<LoginPage />} />
<Route path="/register" element={<RegisterPage />} />
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
<Route path="/profile" element={<ProfilePage />} />
<Route path="/characters" element={<CharacterManagePage />} />
<Route path="/presets" element={<PresetManagePage />} />
<Route path="/admin" element={<AdminPage />} />
</Routes>
</BrowserRouter>
)
}
export default App

114
web-app/src/api/aiConfig.ts Normal file
View File

@@ -0,0 +1,114 @@
import apiClient from './client'
export interface AIConfig {
id: number
name: string
provider: 'openai' | 'anthropic' | 'custom'
baseUrl: string // 注意:后端返回的是 baseUrl 而不是 baseURL
apiKey: string
models?: string[]
defaultModel: string
settings?: Record<string, any>
isActive: boolean
isDefault: boolean
createdAt: string
updatedAt: string
}
export interface CreateAIConfigRequest {
name: string
provider: 'openai' | 'anthropic' | 'custom'
baseUrl: string // 注意:后端使用 baseUrl 而不是 baseURL
apiKey: string
defaultModel?: string
settings?: Record<string, any>
isActive?: boolean
isDefault?: boolean
}
export interface UpdateAIConfigRequest {
name?: string
provider?: 'openai' | 'anthropic' | 'custom'
baseUrl?: string // 注意:后端使用 baseUrl 而不是 baseURL
apiKey?: string
defaultModel?: string
settings?: Record<string, any>
isActive?: boolean
isDefault?: boolean
}
export interface ModelInfo {
id: string
name: string
ownedBy: string
}
export interface GetModelsRequest {
baseUrl: string // 注意:后端使用 baseUrl 而不是 baseURL
apiKey: string
provider: 'openai' | 'anthropic' | 'custom'
}
export interface GetModelsResponse {
models: ModelInfo[]
}
export interface TestAIConfigRequest {
baseUrl: string // 注意:后端使用 baseUrl 而不是 baseURL
apiKey: string
provider: 'openai' | 'anthropic' | 'custom'
model: string
}
export interface TestAIConfigResponse {
success: boolean
message: string
latency: number
}
export interface AIConfigListResponse {
list: AIConfig[]
total: number
}
export const aiConfigApi = {
// 创建AI配置
createAIConfig: (data: CreateAIConfigRequest) => {
return apiClient.post<AIConfig>('/app/ai-config', data)
},
// 获取AI配置列表
getAIConfigList: () => {
return apiClient.get<AIConfigListResponse>('/app/ai-config')
},
// 更新AI配置
updateAIConfig: (id: number, data: UpdateAIConfigRequest) => {
return apiClient.put<AIConfig>(`/app/ai-config/${id}`, data)
},
// 删除AI配置
deleteAIConfig: (id: number) => {
return apiClient.delete<null>(`/app/ai-config/${id}`)
},
// 获取模型列表
getModels: (data: GetModelsRequest) => {
return apiClient.post<GetModelsResponse>('/app/ai-config/models', data)
},
// 测试AI配置用于新建时需要传递完整信息
testAIConfig: (data: TestAIConfigRequest) => {
return apiClient.post<TestAIConfigResponse>('/app/ai-config/test', data)
},
// 通过ID测试AI配置用于已保存的配置后端会从数据库获取完整API Key
testAIConfigById: (id: number) => {
return apiClient.post<TestAIConfigResponse>(`/app/ai-config/${id}/test`)
},
// 通过ID获取模型列表用于已保存的配置后端会从数据库获取完整API Key
getModelsByConfigId: (id: number) => {
return apiClient.get<GetModelsResponse>(`/app/ai-config/${id}/models`)
},
}

92
web-app/src/api/auth.ts Normal file
View File

@@ -0,0 +1,92 @@
import apiClient from './client'
// 类型定义
export interface RegisterRequest {
username: string
password: string
nickName?: string
email?: string
phone?: string
}
export interface LoginRequest {
username: string
password: string
}
export interface UpdateProfileRequest {
nickName?: string
email?: string
phone?: string
avatar?: string
preferences?: string
aiSettings?: string
}
export interface ChangePasswordRequest {
oldPassword: string
newPassword: string
}
export interface User {
id: number
uuid: string
username: string
nickName: string
email: string
phone: string
avatar: string
status: string
enable: boolean
isAdmin: boolean
lastLoginAt: string | null
lastLoginIp: string
chatCount: number
messageCount: number
createdAt: string
}
export interface LoginResponse {
user: User
token: string
refreshToken: string
expiresAt: number
}
// API 方法
export const authApi = {
// 用户注册
register: (data: RegisterRequest) => {
return apiClient.post('/app/auth/register', data)
},
// 用户登录
login: (data: LoginRequest): Promise<{ data: LoginResponse }> => {
return apiClient.post('/app/auth/login', data)
},
// 刷新 Token
refreshToken: (refreshToken: string): Promise<{ data: LoginResponse }> => {
return apiClient.post('/app/auth/refresh', { refreshToken })
},
// 用户登出
logout: () => {
return apiClient.post('/app/auth/logout')
},
// 获取用户信息
getUserInfo: (): Promise<{ data: User }> => {
return apiClient.get('/app/auth/userinfo')
},
// 更新用户资料
updateProfile: (data: UpdateProfileRequest) => {
return apiClient.put('/app/user/profile', data)
},
// 修改密码
changePassword: (data: ChangePasswordRequest) => {
return apiClient.post('/app/user/change-password', data)
},
}

View File

@@ -0,0 +1,130 @@
import apiClient from './client'
// 类型定义
export interface Character {
id: number
name: string
avatar: string
creator: string
version: string
description: string
personality: string
scenario: string
firstMes: string
mesExample: string
creatorNotes: string
systemPrompt: string
postHistoryInstructions: string
tags: string[]
alternateGreetings: string[]
characterBook: Record<string, any>
extensions: Record<string, any>
spec: string
specVersion: string
isPublic: boolean
useCount: number
favoriteCount: number
createdAt: string
updatedAt: string
}
export interface CreateCharacterRequest {
name: string
avatar?: string
creator?: string
version?: string
description?: string
personality?: string
scenario?: string
firstMes?: string
mesExample?: string
creatorNotes?: string
systemPrompt?: string
postHistoryInstructions?: string
tags?: string[]
alternateGreetings?: string[]
characterBook?: Record<string, any>
extensions?: Record<string, any>
isPublic?: boolean
}
export interface UpdateCharacterRequest {
name?: string
avatar?: string
creator?: string
version?: string
description?: string
personality?: string
scenario?: string
firstMes?: string
mesExample?: string
creatorNotes?: string
systemPrompt?: string
postHistoryInstructions?: string
tags?: string[]
alternateGreetings?: string[]
characterBook?: Record<string, any>
extensions?: Record<string, any>
isPublic?: boolean
}
export interface GetCharacterListRequest {
page?: number
pageSize?: number
keyword?: string
tag?: string
isPublic?: boolean
}
export interface CharacterListResponse {
list: Character[]
total: number
page: number
pageSize: number
}
// API 方法
export const characterApi = {
// 创建角色卡
createCharacter: (data: CreateCharacterRequest): Promise<{ data: Character }> => {
return apiClient.post('/app/character', data)
},
// 获取角色卡列表
getCharacterList: (params?: GetCharacterListRequest): Promise<{ data: CharacterListResponse }> => {
return apiClient.get('/app/character', { params })
},
// 获取角色卡详情
getCharacterById: (id: number): Promise<{ data: Character }> => {
return apiClient.get(`/app/character/${id}`)
},
// 更新角色卡
updateCharacter: (id: number, data: UpdateCharacterRequest) => {
return apiClient.put(`/app/character/${id}`, data)
},
// 删除角色卡
deleteCharacter: (id: number) => {
return apiClient.delete(`/app/character/${id}`)
},
// 上传角色卡文件PNG/JSON
uploadCharacter: (file: File): Promise<{ data: Character }> => {
const formData = new FormData()
formData.append('file', file)
return apiClient.post('/app/character/upload', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
})
},
// 导出角色卡为 JSON
exportCharacter: (id: number) => {
return apiClient.get(`/app/character/${id}/export`, {
responseType: 'blob',
})
},
}

76
web-app/src/api/client.ts Normal file
View File

@@ -0,0 +1,76 @@
import axios, {AxiosError, AxiosInstance} from 'axios'
// API 基础配置
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8888'
// 创建 axios 实例
const apiClient: AxiosInstance = axios.create({
baseURL: API_BASE_URL,
timeout: 30000,
headers: {
'Content-Type': 'application/json',
},
})
// 请求拦截器 - 添加 Token
apiClient.interceptors.request.use(
(config) => {
const token = localStorage.getItem('token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
},
(error) => {
return Promise.reject(error)
}
)
// 响应拦截器 - 统一错误处理
apiClient.interceptors.response.use(
(response) => {
return response.data
},
async (error: AxiosError<any>) => {
if (error.response) {
const { status, data } = error.response
// Token 过期,尝试刷新
if (status === 401 && data?.data?.reload) {
const refreshToken = localStorage.getItem('refreshToken')
if (refreshToken) {
try {
const response = await axios.post(`${API_BASE_URL}/app/auth/refresh`, {
refreshToken,
})
const { token, refreshToken: newRefreshToken } = response.data.data
localStorage.setItem('token', token)
localStorage.setItem('refreshToken', newRefreshToken)
// 重试原请求
if (error.config) {
error.config.headers.Authorization = `Bearer ${token}`
return apiClient.request(error.config)
}
} catch (refreshError) {
// 刷新失败,清除 Token 并跳转登录
localStorage.removeItem('token')
localStorage.removeItem('refreshToken')
window.location.href = '/login'
return Promise.reject(refreshError)
}
} else {
// 没有 refreshToken跳转登录
window.location.href = '/login'
}
}
// 返回错误信息
return Promise.reject(data?.msg || '请求失败')
}
return Promise.reject(error.message || '网络错误')
}
)
export default apiClient

View File

@@ -0,0 +1,109 @@
import apiClient from './client'
// 简化的角色信息(用于列表)
export interface CharacterSimple {
id: number
name: string
avatar: string
description: string
createdAt: string
updatedAt: string
}
// 对话列表项(轻量级)
export interface ConversationListItem {
id: number
characterId: number
title: string
messageCount: number
tokenCount: number
createdAt: string
updatedAt: string
character?: CharacterSimple
}
export interface Conversation {
id: number
userId?: number
characterId: number
title: string
presetId?: number
aiProvider: string
model: string
settings?: Record<string, any>
messageCount: number
tokenCount: number
createdAt: string
updatedAt: string
}
export interface Message {
id: number
conversationId: number
role: 'user' | 'assistant' | 'system'
content: string
tokenCount: number
createdAt: string
}
export interface CreateConversationRequest {
characterId: number
title?: string
presetId?: number
aiProvider?: string
model?: string
settings?: Record<string, any>
}
export interface SendMessageRequest {
content: string
}
export interface ConversationListResponse {
list: ConversationListItem[]
total: number
page: number
pageSize: number
}
export interface MessageListResponse {
list: Message[]
total: number
}
export const conversationApi = {
// 创建对话
createConversation: (data: CreateConversationRequest) => {
return apiClient.post<Conversation>('/app/conversation', data)
},
// 获取对话列表
getConversationList: (params: { page?: number; pageSize?: number }) => {
return apiClient.get<ConversationListResponse>('/app/conversation', { params })
},
// 获取对话详情
getConversationById: (id: number) => {
return apiClient.get<Conversation>(`/app/conversation/${id}`)
},
// 删除对话
deleteConversation: (id: number) => {
return apiClient.delete(`/app/conversation/${id}`)
},
// 获取消息列表
getMessageList: (conversationId: number, params: { page?: number; pageSize?: number }) => {
return apiClient.get<MessageListResponse>(`/app/conversation/${conversationId}/messages`, { params })
},
// 发送消息
sendMessage: (conversationId: number, data: SendMessageRequest) => {
return apiClient.post<Message>(`/app/conversation/${conversationId}/message`, data)
},
// 更新对话设置
updateConversationSettings: (conversationId: number, settings: Record<string, any>) => {
return apiClient.put(`/app/conversation/${conversationId}/settings`, { settings })
},
}

102
web-app/src/api/preset.ts Normal file
View File

@@ -0,0 +1,102 @@
import apiClient from './client'
// 预设接口定义
export interface Preset {
id: number
userId: number
name: string
description: string
isPublic: boolean
isDefault: boolean
temperature: number
topP: number
topK: number
frequencyPenalty: number
presencePenalty: number
maxTokens: number
repetitionPenalty: number
minP: number
topA: number
systemPrompt: string
stopSequences: string[]
extensions: Record<string, any>
useCount: number
createdAt: string
updatedAt: string
}
// 创建预设请求
export interface CreatePresetRequest {
name: string
description?: string
isPublic?: boolean
temperature?: number
topP?: number
topK?: number
frequencyPenalty?: number
presencePenalty?: number
maxTokens?: number
repetitionPenalty?: number
minP?: number
topA?: number
systemPrompt?: string
stopSequences?: string[]
extensions?: Record<string, any>
}
// 预设列表响应
export interface PresetListResponse {
list: Preset[]
total: number
page: number
pageSize: number
}
// 预设 API
export const presetApi = {
// 创建预设
createPreset: (data: CreatePresetRequest) => {
return apiClient.post<Preset>('/app/preset', data)
},
// 获取预设列表
getPresetList: (params: { page?: number; pageSize?: number; keyword?: string; isPublic?: boolean }) => {
return apiClient.get<PresetListResponse>('/app/preset', { params })
},
// 根据ID获取预设
getPresetById: (id: number) => {
return apiClient.get<Preset>(`/app/preset/${id}`)
},
// 更新预设
updatePreset: (id: number, data: Partial<CreatePresetRequest>) => {
return apiClient.put<Preset>(`/app/preset/${id}`, data)
},
// 删除预设
deletePreset: (id: number) => {
return apiClient.delete(`/app/preset/${id}`)
},
// 设置默认预设
setDefaultPreset: (id: number) => {
return apiClient.post(`/app/preset/${id}/default`)
},
// 导入预设
importPreset: (file: File) => {
const formData = new FormData()
formData.append('file', file)
return apiClient.post<Preset>('/app/preset/import', formData, {
headers: { 'Content-Type': 'multipart/form-data' }
})
},
// 导出预设
exportPreset: (id: number) => {
return apiClient.get(`/app/preset/${id}/export`, {
responseType: 'blob'
})
}
}

18
web-app/src/api/upload.ts Normal file
View File

@@ -0,0 +1,18 @@
import apiClient from './client'
// 上传图片接口
export interface UploadImageResponse {
url: string
}
// 上传 API
export const uploadApi = {
// 上传图片到 OSS
uploadImage: (file: File) => {
const formData = new FormData()
formData.append('file', file)
return apiClient.post<UploadImageResponse>('/app/upload/image', formData, {
headers: { 'Content-Type': 'multipart/form-data' }
})
}
}

View File

@@ -0,0 +1,131 @@
import {PanelRightClose, Settings, Sparkles} from 'lucide-react'
import {type Character} from '../api/character'
import {type Conversation} from '../api/conversation'
interface CharacterPanelProps {
character: Character
conversation: Conversation
onOpenSettings: () => void
onClose?: () => void
}
export default function CharacterPanel({ character, conversation, onOpenSettings, onClose }: CharacterPanelProps) {
const tags = Array.isArray(character.tags) ? character.tags : []
return (
<div className="w-80 glass border-l border-white/10 flex flex-col">
<div className="p-4 border-b border-white/10 flex items-center justify-between">
<h2 className="font-semibold"></h2>
<div className="flex items-center gap-2">
<button
onClick={onOpenSettings}
className="p-2 glass-hover rounded-lg cursor-pointer"
title="对话设置"
>
<Settings className="w-5 h-5" />
</button>
{onClose && (
<button
onClick={onClose}
className="p-2 glass-hover rounded-lg cursor-pointer"
title="隐藏角色面板"
>
<PanelRightClose className="w-5 h-5" />
</button>
)}
</div>
</div>
<div className="p-4">
<div className="glass-hover rounded-2xl p-6 text-center">
{character.avatar ? (
<img
src={character.avatar}
alt={character.name || '角色'}
className="w-24 h-24 mx-auto mb-4 rounded-full object-cover"
/>
) : (
<div className="w-24 h-24 mx-auto mb-4 rounded-full bg-gradient-to-br from-primary to-secondary flex items-center justify-center text-3xl">
{character.name ? character.name.charAt(0) : '?'}
</div>
)}
<h3 className="text-lg font-semibold mb-2">{character.name || '未命名角色'}</h3>
<p className="text-sm text-white/60 mb-4 line-clamp-2">{character.description || '暂无描述'}</p>
<div className="flex items-center justify-center gap-2 text-xs text-white/40">
<Sparkles className="w-4 h-4" />
<span>线</span>
</div>
</div>
</div>
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{tags.length > 0 && (
<div>
<h3 className="text-sm font-medium mb-2 text-white/80"></h3>
<div className="flex flex-wrap gap-2">
{tags.map((tag, index) => (
<span
key={index}
className="px-3 py-1 glass rounded-full text-xs"
>
{tag}
</span>
))}
</div>
</div>
)}
{character.personality && (
<div>
<h3 className="text-sm font-medium mb-2 text-white/80"></h3>
<p className="text-sm text-white/60 leading-relaxed">{character.personality}</p>
</div>
)}
{character.scenario && (
<div>
<h3 className="text-sm font-medium mb-2 text-white/80"></h3>
<p className="text-sm text-white/60 leading-relaxed">{character.scenario}</p>
</div>
)}
{character.creator && (
<div>
<h3 className="text-sm font-medium mb-2 text-white/80"></h3>
<p className="text-sm text-white/60">{character.creator}</p>
</div>
)}
<div>
<h3 className="text-sm font-medium mb-2 text-white/80"></h3>
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="text-white/60"></span>
<span className="font-medium">{conversation.messageCount}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-white/60">Token 使</span>
<span className="font-medium">{conversation.tokenCount}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-white/60">AI </span>
<span className="font-medium text-xs">{conversation.model || 'GPT-4'}</span>
</div>
</div>
</div>
{character.characterBook && (
<div>
<h3 className="text-sm font-medium mb-2 text-white/80"></h3>
<p className="text-xs text-white/40">
{typeof character.characterBook === 'object' && character.characterBook.entries
? `${character.characterBook.entries.length} 个条目`
: '已启用'}
</p>
</div>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,719 @@
import {
Check,
Copy,
Download,
Mic,
MoreVertical,
Paperclip,
RefreshCw,
Send,
Settings,
Trash2,
Waves,
Zap
} from 'lucide-react'
import {useEffect, useRef, useState} from 'react'
import {createPortal} from 'react-dom'
import {type Conversation, conversationApi, type Message} from '../api/conversation'
import {type Character} from '../api/character'
import {type AIConfig, aiConfigApi} from '../api/aiConfig'
import {type Preset, presetApi} from '../api/preset'
import MessageContent from './MessageContent'
interface ChatAreaProps {
conversation: Conversation
character: Character
onConversationUpdate: (conversation: Conversation) => void
}
export default function ChatArea({ conversation, character, onConversationUpdate }: ChatAreaProps) {
const [messages, setMessages] = useState<Message[]>([])
const [inputValue, setInputValue] = useState('')
const [sending, setSending] = useState(false)
const [loading, setLoading] = useState(true)
const [showMenu, setShowMenu] = useState(false)
const [copiedId, setCopiedId] = useState<number | null>(null)
const [aiConfigs, setAiConfigs] = useState<AIConfig[]>([])
const [selectedConfigId, setSelectedConfigId] = useState<number>()
const [showModelSelector, setShowModelSelector] = useState(false)
const [streamEnabled, setStreamEnabled] = useState(true) // 默认启用流式传输
const [presets, setPresets] = useState<Preset[]>([])
const [selectedPresetId, setSelectedPresetId] = useState<number>()
const [showPresetSelector, setShowPresetSelector] = useState(false)
const messagesEndRef = useRef<HTMLDivElement>(null)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const modelSelectorRef = useRef<HTMLDivElement>(null)
const presetSelectorRef = useRef<HTMLDivElement>(null)
const menuRef = useRef<HTMLDivElement>(null)
// 点击外部关闭下拉菜单
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as HTMLElement
// 检查是否点击在模型选择器外部
if (showModelSelector && modelSelectorRef.current && !modelSelectorRef.current.contains(target)) {
setShowModelSelector(false)
}
// 检查是否点击在预设选择器外部
if (showPresetSelector && presetSelectorRef.current && !presetSelectorRef.current.contains(target)) {
setShowPresetSelector(false)
}
// 检查是否点击在菜单外部
if (showMenu && menuRef.current && !menuRef.current.contains(target)) {
setShowMenu(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [showModelSelector, showPresetSelector, showMenu])
useEffect(() => {
loadMessages()
loadAIConfigs()
loadCurrentConfig()
loadPresets()
loadCurrentPreset()
}, [conversation.id])
useEffect(() => {
scrollToBottom()
}, [messages])
const loadMessages = async () => {
try {
setLoading(true)
const response = await conversationApi.getMessageList(conversation.id, {
page: 1,
pageSize: 100,
})
setMessages(response.data.list || [])
} catch (err) {
console.error('加载消息失败:', err)
} finally {
setLoading(false)
}
}
const loadAIConfigs = async () => {
try {
const response = await aiConfigApi.getAIConfigList()
const activeConfigs = response.data.list.filter(config => config.isActive)
setAiConfigs(activeConfigs)
} catch (err) {
console.error('加载 AI 配置失败:', err)
}
}
const loadCurrentConfig = () => {
if (conversation.settings) {
try {
const settings = typeof conversation.settings === 'string'
? JSON.parse(conversation.settings)
: conversation.settings
if (settings.aiConfigId) {
setSelectedConfigId(settings.aiConfigId)
}
} catch (e) {
console.error('解析设置失败:', e)
}
}
}
const loadPresets = async () => {
try {
const response = await presetApi.getPresetList({ page: 1, pageSize: 100 })
setPresets(response.data.list)
} catch (err) {
console.error('加载预设失败:', err)
}
}
const loadCurrentPreset = () => {
if (conversation.settings) {
try {
const settings = typeof conversation.settings === 'string'
? JSON.parse(conversation.settings)
: conversation.settings
if (settings.presetId) {
setSelectedPresetId(settings.presetId)
}
} catch (e) {
console.error('解析设置失败:', e)
}
}
}
const handlePresetChange = async (presetId: number) => {
try {
const settings = conversation.settings
? (typeof conversation.settings === 'string' ? JSON.parse(conversation.settings) : conversation.settings)
: {}
settings.presetId = presetId
await conversationApi.updateConversationSettings(conversation.id, settings)
setSelectedPresetId(presetId)
setShowPresetSelector(false)
const convResp = await conversationApi.getConversationById(conversation.id)
onConversationUpdate(convResp.data)
} catch (err) {
console.error('更新预设失败:', err)
alert('更新失败,请重试')
}
}
const handleModelChange = async (configId: number) => {
try {
const settings = conversation.settings
? (typeof conversation.settings === 'string' ? JSON.parse(conversation.settings) : conversation.settings)
: {}
settings.aiConfigId = configId
await conversationApi.updateConversationSettings(conversation.id, settings)
setSelectedConfigId(configId)
setShowModelSelector(false)
const convResp = await conversationApi.getConversationById(conversation.id)
onConversationUpdate(convResp.data)
} catch (err) {
console.error('更新模型配置失败:', err)
alert('更新失败,请重试')
}
}
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
}
const handleSend = async () => {
// 防止重复发送
if (!inputValue.trim() || sending) return
const userMessage = inputValue.trim()
// 立即清空输入框和设置发送状态,防止重复触发
setInputValue('')
setSending(true)
// 立即显示用户消息
const tempUserMessage: Message = {
id: Date.now(),
conversationId: conversation.id,
role: 'user',
content: userMessage,
tokenCount: 0,
createdAt: new Date().toISOString(),
}
setMessages((prev) => [...prev, tempUserMessage])
// 创建临时AI消息用于流式显示
const tempAIMessage: Message = {
id: Date.now() + 1,
conversationId: conversation.id,
role: 'assistant',
content: '',
tokenCount: 0,
createdAt: new Date().toISOString(),
}
try {
if (streamEnabled) {
// 流式传输
console.log('[Stream] 开始流式传输...')
setMessages((prev) => [...prev, tempAIMessage])
const response = await fetch(
`${import.meta.env.VITE_API_BASE_URL || '/api'}/app/conversation/${conversation.id}/message?stream=true`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('token')}`,
},
body: JSON.stringify({ content: userMessage }),
}
)
if (!response.ok) {
throw new Error('流式传输失败')
}
console.log('[Stream] 连接成功,开始接收数据...')
const reader = response.body?.getReader()
const decoder = new TextDecoder()
if (reader) {
let fullContent = ''
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) {
console.log('[Stream] 传输完成')
break
}
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
// 保留最后一行(可能不完整)
buffer = lines.pop() || ''
let currentEvent = ''
for (const line of lines) {
if (line.startsWith('event: ')) {
currentEvent = line.slice(7).trim()
console.log('[Stream] 事件类型:', currentEvent)
} else if (line.startsWith('data: ')) {
const data = line.slice(6).trim()
if (currentEvent === 'message') {
// 消息内容 - 后端现在发送的是纯文本不再是JSON
fullContent += data
console.log('[Stream] 接收内容片段:', data)
// 实时更新临时AI消息的内容
setMessages((prev) =>
prev.map((m) =>
m.id === tempAIMessage.id ? { ...m, content: fullContent } : m
)
)
} else if (currentEvent === 'done') {
// 流式传输完成
console.log('[Stream] 收到完成信号,重新加载消息')
await loadMessages()
break
} else if (currentEvent === 'error') {
// 错误处理
console.error('[Stream] 错误:', data)
throw new Error(data)
}
currentEvent = ''
}
}
}
}
// 更新对话信息
const convResp = await conversationApi.getConversationById(conversation.id)
onConversationUpdate(convResp.data)
} else {
// 普通传输
const response = await conversationApi.sendMessage(conversation.id, {
content: userMessage,
})
// 更新消息列表包含AI回复
await loadMessages()
// 更新对话信息
const convResp = await conversationApi.getConversationById(conversation.id)
onConversationUpdate(convResp.data)
}
} catch (err: any) {
console.error('发送消息失败:', err)
alert(err.response?.data?.msg || '发送消息失败,请重试')
// 移除临时消息
setMessages((prev) => prev.filter((m) => m.id !== tempUserMessage.id && m.id !== tempAIMessage.id))
} finally {
setSending(false)
}
}
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey && !sending && inputValue.trim()) {
e.preventDefault()
handleSend()
}
}
const handleCopyMessage = (content: string, id: number) => {
navigator.clipboard.writeText(content)
setCopiedId(id)
setTimeout(() => setCopiedId(null), 2000)
}
const handleRegenerateResponse = async () => {
if (messages.length === 0 || sending) return
// 找到最后一条用户消息
const lastUserMessage = [...messages].reverse().find(m => m.role === 'user')
if (!lastUserMessage) return
setSending(true)
try {
await conversationApi.sendMessage(conversation.id, {
content: lastUserMessage.content,
})
await loadMessages()
const convResp = await conversationApi.getConversationById(conversation.id)
onConversationUpdate(convResp.data)
} catch (err) {
console.error('重新生成失败:', err)
alert('重新生成失败,请重试')
} finally {
setSending(false)
}
}
const handleDeleteConversation = async () => {
if (!confirm('确定要删除这个对话吗?')) return
try {
await conversationApi.deleteConversation(conversation.id)
window.location.href = '/chat'
} catch (err) {
console.error('删除对话失败:', err)
alert('删除失败')
}
}
const handleExportConversation = () => {
const content = messages
.map((msg) => `[${msg.role}] ${msg.content}`)
.join('\n\n')
const blob = new Blob([content], { type: 'text/plain' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${conversation.title}.txt`
a.click()
URL.revokeObjectURL(url)
}
const formatTime = (dateString: string) => {
const date = new Date(dateString)
const now = new Date()
const diff = now.getTime() - date.getTime()
const minutes = Math.floor(diff / 60000)
const hours = Math.floor(diff / 3600000)
const days = Math.floor(diff / 86400000)
if (minutes < 1) return '刚刚'
if (minutes < 60) return `${minutes}分钟前`
if (hours < 24) return `${hours}小时前`
if (days < 7) return `${days}天前`
return date.toLocaleDateString('zh-CN')
}
return (
<div className="flex-1 flex flex-col">
<div className="p-4 glass border-b border-white/10">
<div className="flex items-center justify-between">
<div className="flex-1 min-w-0">
<h2 className="text-lg font-semibold truncate">{conversation.title}</h2>
<div className="flex items-center gap-2 text-sm text-white/60">
<span> {character.name} </span>
</div>
</div>
<div className="flex items-center gap-2">
<div className="relative" ref={modelSelectorRef}>
<button
onClick={(e) => {
e.stopPropagation()
setShowModelSelector(!showModelSelector)
setShowPresetSelector(false)
setShowMenu(false)
}}
className="flex items-center gap-2 px-3 py-2 glass-hover rounded-lg cursor-pointer text-sm"
title="切换模型"
>
<Zap className="w-4 h-4 text-secondary" />
<span className="text-xs">
{selectedConfigId
? aiConfigs.find(c => c.id === selectedConfigId)?.name || '默认模型'
: '默认模型'}
</span>
</button>
{showModelSelector && createPortal(
<div
className="fixed glass rounded-xl p-2 min-w-[200px] shadow-xl"
style={{
top: modelSelectorRef.current ? modelSelectorRef.current.getBoundingClientRect().bottom + 8 : 0,
left: modelSelectorRef.current ? modelSelectorRef.current.getBoundingClientRect().right - 200 : 0,
zIndex: 9999
}}
onClick={(e) => e.stopPropagation()}
>
{aiConfigs.length === 0 ? (
<div className="px-4 py-3 text-xs text-white/60 text-center">
</div>
) : (
aiConfigs.map((config) => (
<button
key={config.id}
onClick={(e) => {
e.stopPropagation()
handleModelChange(config.id)
}}
className={`w-full px-3 py-2 glass-hover rounded-lg text-sm text-left cursor-pointer ${
selectedConfigId === config.id ? 'bg-primary/20 ring-1 ring-primary' : ''
}`}
>
<div className="font-medium">{config.name}</div>
<div className="text-xs text-white/60 mt-0.5">
{config.provider} {config.defaultModel}
</div>
</button>
))
)}
</div>,
document.body
)}
</div>
{/* 预设选择器 */}
<div className="relative" ref={presetSelectorRef}>
<button
onClick={(e) => {
e.stopPropagation()
setShowPresetSelector(!showPresetSelector)
setShowModelSelector(false)
setShowMenu(false)
}}
className="flex items-center gap-2 px-3 py-2 glass-hover rounded-lg cursor-pointer text-sm"
title="选择预设"
>
<Settings className="w-4 h-4 text-primary" />
<span className="text-xs">
{selectedPresetId
? presets.find(p => p.id === selectedPresetId)?.name || '默认预设'
: '默认预设'}
</span>
</button>
{showPresetSelector && createPortal(
<div
className="fixed glass rounded-xl p-2 min-w-[200px] shadow-xl"
style={{
top: presetSelectorRef.current ? presetSelectorRef.current.getBoundingClientRect().bottom + 8 : 0,
left: presetSelectorRef.current ? presetSelectorRef.current.getBoundingClientRect().right - 200 : 0,
zIndex: 9999
}}
onClick={(e) => e.stopPropagation()}
>
{presets.length === 0 ? (
<div className="px-4 py-3 text-xs text-white/60 text-center">
</div>
) : (
presets.map((preset) => (
<button
key={preset.id}
onClick={(e) => {
e.stopPropagation()
handlePresetChange(preset.id)
}}
className={`w-full px-3 py-2 glass-hover rounded-lg text-sm text-left cursor-pointer ${
selectedPresetId === preset.id ? 'bg-primary/20 ring-1 ring-primary' : ''
}`}
>
<div className="font-medium">{preset.name}</div>
<div className="text-xs text-white/60 mt-0.5">
{preset.description || `温度: ${preset.temperature}`}
</div>
</button>
))
)}
</div>,
document.body
)}
</div>
<button
onClick={() => setStreamEnabled(!streamEnabled)}
className={`p-2 glass-hover rounded-lg cursor-pointer ${
streamEnabled ? 'text-green-400' : 'text-white/60'
}`}
title={streamEnabled ? '流式传输已启用' : '流式传输已禁用'}
>
<Waves className="w-5 h-5" />
</button>
<button
onClick={handleRegenerateResponse}
disabled={sending || messages.length === 0}
className="p-2 glass-hover rounded-lg cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
title="重新生成回复"
>
<RefreshCw className="w-5 h-5" />
</button>
<div className="relative" ref={menuRef}>
<button
onClick={(e) => {
e.stopPropagation()
setShowMenu(!showMenu)
setShowModelSelector(false)
setShowPresetSelector(false)
}}
className="p-2 glass-hover rounded-lg cursor-pointer"
>
<MoreVertical className="w-5 h-5" />
</button>
{showMenu && createPortal(
<div
className="fixed glass rounded-xl p-2 min-w-[160px] shadow-xl"
style={{
top: menuRef.current ? menuRef.current.getBoundingClientRect().bottom + 8 : 0,
left: menuRef.current ? menuRef.current.getBoundingClientRect().right - 160 : 0,
zIndex: 9999
}}
onClick={(e) => e.stopPropagation()}
>
<button
onClick={handleExportConversation}
className="w-full px-4 py-2 glass-hover rounded-lg text-sm text-left cursor-pointer flex items-center gap-2"
>
<Download className="w-4 h-4" />
</button>
<button
onClick={handleDeleteConversation}
className="w-full px-4 py-2 glass-hover rounded-lg text-sm text-left cursor-pointer flex items-center gap-2 text-red-400"
>
<Trash2 className="w-4 h-4" />
</button>
</div>,
document.body
)}
</div>
</div>
</div>
</div>
<div className="flex-1 overflow-y-auto p-6 space-y-6">
{loading ? (
<div className="text-center text-white/60">...</div>
) : messages.length === 0 ? (
<div className="text-center text-white/60 py-12">
<p className="mb-2"></p>
<p className="text-sm"></p>
</div>
) : (
messages.map((msg) => (
<div
key={msg.id}
className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'} group`}
>
<div
className={`max-w-2xl min-w-0 relative ${
msg.role === 'user'
? 'glass-hover rounded-2xl rounded-br-md p-4'
: 'glass-hover rounded-2xl rounded-bl-md p-4'
}`}
style={{ wordBreak: 'break-word', overflowWrap: 'anywhere' }}
>
{msg.role === 'assistant' && (
<div className="flex items-center gap-2 mb-2">
{character.avatar && (
<img
src={character.avatar}
alt={character.name}
className="w-6 h-6 rounded-full object-cover"
/>
)}
<span className="text-sm font-medium text-primary">{character.name}</span>
</div>
)}
<MessageContent
content={msg.content}
role={msg.role}
onChoiceSelect={(choice) => {
setInputValue(choice)
// 自动聚焦到输入框
textareaRef.current?.focus()
}}
/>
<div className="flex items-center justify-between mt-2">
<span className="text-xs text-white/40">{formatTime(msg.createdAt)}</span>
<button
onClick={() => handleCopyMessage(msg.content, msg.id)}
className="opacity-0 group-hover:opacity-100 p-1 glass-hover rounded transition-opacity cursor-pointer"
title="复制消息"
>
{copiedId === msg.id ? (
<Check className="w-3 h-3 text-green-400" />
) : (
<Copy className="w-3 h-3" />
)}
</button>
</div>
</div>
</div>
))
)}
{sending && (
<div className="flex justify-start">
<div className="glass-hover rounded-2xl rounded-bl-md p-4">
<div className="flex items-center gap-2">
<div className="w-2 h-2 bg-primary rounded-full animate-pulse"></div>
<div className="w-2 h-2 bg-primary rounded-full animate-pulse" style={{ animationDelay: '0.2s' }}></div>
<div className="w-2 h-2 bg-primary rounded-full animate-pulse" style={{ animationDelay: '0.4s' }}></div>
</div>
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
<div className="p-4 glass border-t border-white/10">
<div className="flex items-end gap-2">
<button
className="p-3 glass-hover rounded-lg cursor-pointer opacity-50"
title="附件(开发中)"
disabled
>
<Paperclip className="w-5 h-5" />
</button>
<div className="flex-1 glass rounded-2xl p-3 focus-within:ring-2 focus-within:ring-primary/50 transition-all">
<textarea
ref={textareaRef}
value={inputValue}
onChange={(e) => {
setInputValue(e.target.value)
// 自动调整高度
if (textareaRef.current) {
textareaRef.current.style.height = 'auto'
textareaRef.current.style.height = Math.min(textareaRef.current.scrollHeight, 120) + 'px'
}
}}
onKeyDown={handleKeyDown}
placeholder={sending ? '正在发送...' : '输入消息... (Enter发送Shift+Enter换行)'}
rows={1}
className="w-full bg-transparent resize-none focus:outline-none text-sm"
style={{ maxHeight: '120px', minHeight: '24px' }}
disabled={sending}
/>
</div>
<button
className="p-3 glass-hover rounded-lg cursor-pointer opacity-50"
title="语音输入(开发中)"
disabled
>
<Mic className="w-5 h-5" />
</button>
<button
onClick={handleSend}
disabled={!inputValue.trim() || sending}
className="p-3 bg-gradient-to-r from-primary to-secondary rounded-lg hover:opacity-90 transition-opacity cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
title="发送消息"
>
<Send className="w-5 h-5" />
</button>
</div>
<div className="mt-2 flex items-center justify-between text-xs text-white/40">
<span>: {conversation.messageCount} | Token: {conversation.tokenCount}</span>
{sending && <span className="text-primary animate-pulse">AI ...</span>}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,341 @@
import {useEffect, useRef, useState} from 'react'
import {Code, Eye, Play} from 'lucide-react'
interface MessageContentProps {
content: string
role: 'user' | 'assistant'
onChoiceSelect?: (choice: string) => void
}
interface Choice {
label: string
text: string
}
// 解析并高亮对白的组件
function DialogueText({ text }: { text: string }) {
// 匹配各种引号格式的对白
const dialogueRegex = /([""「『])(.*?)([""」』])|(")(.*?)(")|(')(.*?)(')/g
const parts: JSX.Element[] = []
let lastIndex = 0
let match
let key = 0
while ((match = dialogueRegex.exec(text)) !== null) {
// 添加对白之前的文本
if (match.index > lastIndex) {
parts.push(
<span key={`text-${key++}`} className="text-white/80">
{text.substring(lastIndex, match.index)}
</span>
)
}
// 提取对白内容(处理不同的引号组)
const dialogue = match[2] || match[5] || match[8]
const openQuote = match[1] || match[4] || match[7]
const closeQuote = match[3] || match[6] || match[9]
// 添加高亮的对白
parts.push(
<span key={`dialogue-${key++}`} className="inline-block">
<span className="text-primary/60">{openQuote}</span>
<span className="text-primary font-medium px-0.5">{dialogue}</span>
<span className="text-primary/60">{closeQuote}</span>
</span>
)
lastIndex = match.index + match[0].length
}
// 添加剩余的文本
if (lastIndex < text.length) {
parts.push(
<span key={`text-${key++}`} className="text-white/80">
{text.substring(lastIndex)}
</span>
)
}
return <>{parts.length > 0 ? parts : <span className="text-white/80">{text}</span>}</>
}
// 解析选择项的函数
function parseChoices(content: string): { choices: Choice[]; cleanContent: string } {
// 匹配 <choice>...</choice> 或 [choice]...[/choice] 格式
const choiceRegex = /(?:<choice>|\\[choice\\])([\s\S]*?)(?:<\/choice>|\\[\/choice\\])/i
const match = content.match(choiceRegex)
if (!match) {
// 如果没有标准格式尝试从HTML中提取选择项
// 匹配类似 "1.xxx" 或 "A.xxx" 的列表项
const htmlChoiceRegex = /<p>\s*(\d+|[A-Z])\s*[.、:]\s*([^<]+)/gi
const choices: Choice[] = []
let htmlMatch
while ((htmlMatch = htmlChoiceRegex.exec(content)) !== null) {
choices.push({
label: htmlMatch[1],
text: htmlMatch[2].trim()
})
}
if (choices.length > 0) {
return { choices, cleanContent: content }
}
return { choices: [], cleanContent: content }
}
const choiceBlock = match[1]
const choices: Choice[] = []
// 匹配 A. text, B. text 等格式
const optionRegex = /^([A-Z])[.、:]\s*(.+)$/gm
let optionMatch
while ((optionMatch = optionRegex.exec(choiceBlock)) !== null) {
choices.push({
label: optionMatch[1],
text: optionMatch[2].trim()
})
}
// 移除选择块,返回清理后的内容
const cleanContent = content.replace(choiceRegex, '').trim()
return { choices, cleanContent }
}
// 清理脚本输出内容
function cleanScriptOutput(content: string): string {
// 移除 <UpdateVariable>...</UpdateVariable> 块
let cleaned = content.replace(/<UpdateVariable>[\s\S]*?<\/UpdateVariable>/gi, '')
// 移除 <Analysis>...</Analysis> 块
cleaned = cleaned.replace(/<Analysis>[\s\S]*?<\/Analysis>/gi, '')
// 移除 _.set() 调用
cleaned = cleaned.replace(/^\s*_.set\([^)]+\);\s*$/gm, '')
return cleaned.trim()
}
// 解析状态面板数据
function parseStatusPanel(content: string): { status: any; cleanContent: string } {
const statusRegex = /<status_current_variable>([\s\S]*?)<\/status_current_variable>/i
const match = content.match(statusRegex)
if (!match) {
return { status: null, cleanContent: content }
}
try {
const statusData = JSON.parse(match[1].trim())
const cleanContent = content.replace(statusRegex, '').trim()
return { status: statusData, cleanContent }
} catch (e) {
console.error('解析状态面板失败:', e)
return { status: null, cleanContent: content }
}
}
export default function MessageContent({ content, role, onChoiceSelect }: MessageContentProps) {
const [showRaw, setShowRaw] = useState(false)
const [hasHtml, setHasHtml] = useState(false)
const [hasScript, setHasScript] = useState(false)
const [allowScript, setAllowScript] = useState(true) // 默认启用脚本
const [choices, setChoices] = useState<Choice[]>([])
const [displayContent, setDisplayContent] = useState(content)
const [statusPanel, setStatusPanel] = useState<any>(null)
const iframeRef = useRef<HTMLIFrameElement>(null)
useEffect(() => {
console.log('[MessageContent] 原始内容:', content)
// 解析状态面板
const { status, cleanContent: contentAfterStatus } = parseStatusPanel(content)
console.log('[MessageContent] 状态面板:', status)
setStatusPanel(status)
// 解析选择项
const { choices: parsedChoices, cleanContent } = parseChoices(contentAfterStatus)
console.log('[MessageContent] 选择项:', parsedChoices)
setChoices(parsedChoices)
// 清理脚本输出
const finalContent = cleanScriptOutput(cleanContent)
console.log('[MessageContent] 清理后内容:', finalContent)
setDisplayContent(finalContent)
// 检测内容类型
const htmlRegex = /<[^>]+>/g
const scriptRegex = /<script[\s\S]*?<\/script>/gi
const hasHtmlContent = htmlRegex.test(finalContent)
const hasScriptContent = scriptRegex.test(finalContent)
console.log('[MessageContent] hasHtml:', hasHtmlContent, 'hasScript:', hasScriptContent)
setHasHtml(hasHtmlContent)
setHasScript(hasScriptContent)
}, [content])
const renderInIframe = () => {
if (!iframeRef.current) return
const iframe = iframeRef.current
const doc = iframe.contentDocument || iframe.contentWindow?.document
if (doc) {
doc.open()
doc.write(`
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body {
margin: 0;
padding: 16px;
font-family: system-ui, -apple-system, sans-serif;
background: transparent;
color: #fff;
}
* {
box-sizing: border-box;
}
</style>
</head>
<body>
${displayContent}
</body>
</html>
`)
doc.close()
// 自动调整iframe高度
setTimeout(() => {
if (doc.body) {
const height = doc.body.scrollHeight
iframe.style.height = `${Math.max(height, 100)}px`
}
}, 100)
}
}
useEffect(() => {
if (allowScript && hasScript) {
renderInIframe()
}
}, [allowScript, displayContent])
// 如果是用户消息,直接显示纯文本
if (role === 'user') {
return <p className="text-sm leading-relaxed whitespace-pre-wrap break-words overflow-wrap-anywhere">{content}</p>
}
// AI消息 - 支持多种渲染模式
return (
<div className="space-y-2">
{/* 控制按钮 */}
{(hasHtml || hasScript) && (
<div className="flex items-center gap-2 mb-2">
<button
onClick={() => setShowRaw(!showRaw)}
className="flex items-center gap-1 px-2 py-1 text-xs glass-hover rounded cursor-pointer"
title={showRaw ? '显示渲染' : '显示源码'}
>
{showRaw ? <Eye className="w-3 h-3" /> : <Code className="w-3 h-3" />}
{showRaw ? '渲染' : '源码'}
</button>
{hasScript && (
<button
onClick={() => setAllowScript(!allowScript)}
className={`flex items-center gap-1 px-2 py-1 text-xs rounded cursor-pointer ${
allowScript ? 'bg-green-500/20 text-green-400' : 'bg-yellow-500/20 text-yellow-400'
}`}
title={allowScript ? '禁用脚本' : '允许脚本'}
>
<Play className="w-3 h-3" />
{allowScript ? '脚本已启用' : '启用脚本'}
</button>
)}
</div>
)}
{/* 内容渲染 */}
{showRaw ? (
<pre className="text-xs bg-black/30 p-3 rounded-lg overflow-x-auto">
<code>{content}</code>
</pre>
) : hasScript && allowScript ? (
<iframe
ref={iframeRef}
className="w-full border border-white/10 rounded-lg bg-black/20"
sandbox="allow-scripts allow-same-origin"
style={{ minHeight: '100px' }}
/>
) : hasHtml ? (
<div
className="prose prose-invert max-w-none text-sm leading-relaxed break-words overflow-wrap-anywhere"
dangerouslySetInnerHTML={{ __html: displayContent }}
/>
) : (
<div className="text-sm leading-relaxed whitespace-pre-wrap break-words overflow-wrap-anywhere">
<DialogueText text={displayContent} />
</div>
)}
{/* Sudachi 选择按钮 */}
{choices.length > 0 && onChoiceSelect && (
<div className="mt-4 p-4 glass rounded-lg border border-primary/20">
<div className="text-sm font-medium text-primary mb-3"></div>
<div className="space-y-2">
{choices.map((choice) => (
<button
key={choice.label}
onClick={() => onChoiceSelect(choice.text)}
className="w-full text-left px-4 py-3 glass-hover rounded-lg border border-white/10 hover:border-primary/50 transition-all group"
>
<span className="text-primary font-bold mr-2">{choice.label}.</span>
<span className="text-white/80 group-hover:text-white">{choice.text}</span>
</button>
))}
</div>
</div>
)}
{/* 状态面板 */}
{statusPanel && (
<div className="mt-4 p-4 glass rounded-lg border border-secondary/20">
<div className="text-sm font-medium text-secondary mb-3"></div>
<div className="space-y-3">
{Object.entries(statusPanel).map(([category, data]: [string, any]) => (
<div key={category} className="space-y-1">
<div className="text-xs font-semibold text-secondary/80">{category}</div>
<div className="pl-3 space-y-1">
{typeof data === 'object' && data !== null ? (
Object.entries(data).map(([key, value]: [string, any]) => (
<div key={key} className="flex justify-between text-xs">
<span className="text-white/60">{key}:</span>
<span className="text-white/90 font-medium">
{typeof value === 'object' ? JSON.stringify(value) : String(value)}
</span>
</div>
))
) : (
<div className="text-xs text-white/90">{String(data)}</div>
)}
</div>
</div>
))}
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,138 @@
import {useEffect, useState} from 'react'
import {Link} from 'react-router-dom'
import {LogOut, Menu, Shield, Sparkles, User} from 'lucide-react'
import {authApi, type User as UserType} from '../api/auth'
export default function Navbar() {
const [user, setUser] = useState<UserType | null>(null)
const [showUserMenu, setShowUserMenu] = useState(false)
useEffect(() => {
loadUser()
}, [])
const loadUser = async () => {
const token = localStorage.getItem('token')
if (!token) return
try {
const response = await authApi.getUserInfo()
setUser(response.data)
} catch (err) {
console.error('获取用户信息失败:', err)
localStorage.removeItem('token')
localStorage.removeItem('refreshToken')
}
}
const handleLogout = () => {
localStorage.removeItem('token')
localStorage.removeItem('refreshToken')
setUser(null)
window.location.href = '/'
}
return (
<nav className="fixed top-4 left-4 right-4 z-50">
<div className="max-w-7xl mx-auto glass rounded-2xl px-6 py-4">
<div className="flex items-center justify-between">
<Link to="/" className="flex items-center gap-2">
<div className="w-8 h-8 bg-gradient-to-br from-primary to-secondary rounded-lg flex items-center justify-center">
<Sparkles className="w-5 h-5" />
</div>
<span className="text-xl font-semibold bg-gradient-to-r from-primary to-secondary bg-clip-text text-transparent">
</span>
</Link>
<div className="hidden md:flex items-center gap-6">
<Link to="/" className="text-sm hover:text-primary transition-colors cursor-pointer">
</Link>
<Link to="/market" className="text-sm hover:text-primary transition-colors cursor-pointer">
广
</Link>
{user && (
<>
<Link to="/characters" className="text-sm hover:text-primary transition-colors cursor-pointer">
</Link>
<Link to="/presets" className="text-sm hover:text-primary transition-colors cursor-pointer">
</Link>
<Link to="/chat" className="text-sm hover:text-primary transition-colors cursor-pointer">
</Link>
{user.isAdmin && (
<Link to="/admin" className="text-sm hover:text-primary transition-colors cursor-pointer flex items-center gap-1">
<Shield className="w-4 h-4" />
</Link>
)}
</>
)}
</div>
<div className="flex items-center gap-3">
{user ? (
<div className="relative">
<button
onClick={() => setShowUserMenu(!showUserMenu)}
className="flex items-center gap-2 px-4 py-2 glass-hover rounded-lg text-sm cursor-pointer"
>
{user.avatar ? (
<img src={user.avatar} alt={user.nickName} className="w-6 h-6 rounded-full" />
) : (
<User className="w-5 h-5" />
)}
<span>{user.nickName || user.username}</span>
</button>
{showUserMenu && (
<div className="absolute right-0 mt-2 w-48 glass rounded-xl overflow-hidden">
<Link
to="/profile"
className="block px-4 py-3 text-sm hover:bg-white/5 cursor-pointer"
onClick={() => setShowUserMenu(false)}
>
</Link>
{user.isAdmin && (
<Link
to="/admin"
className="block px-4 py-3 text-sm hover:bg-white/5 cursor-pointer flex items-center gap-2"
onClick={() => setShowUserMenu(false)}
>
<Shield className="w-4 h-4" />
</Link>
)}
<button
onClick={handleLogout}
className="w-full text-left px-4 py-3 text-sm hover:bg-white/5 cursor-pointer flex items-center gap-2 text-red-400"
>
<LogOut className="w-4 h-4" />
退
</button>
</div>
)}
</div>
) : (
<>
<Link to="/login" className="hidden md:block px-4 py-2 glass-hover rounded-lg text-sm cursor-pointer">
</Link>
<Link to="/register" className="px-4 py-2 bg-gradient-to-r from-primary to-secondary rounded-lg text-sm hover:opacity-90 transition-opacity cursor-pointer">
使
</Link>
</>
)}
<button className="md:hidden p-2 glass-hover rounded-lg cursor-pointer">
<Menu className="w-5 h-5" />
</button>
</div>
</div>
</div>
</nav>
)
}

View File

@@ -0,0 +1,247 @@
import {Image, Palette, RotateCcw, Save, Upload, X} from 'lucide-react'
import {useEffect, useRef, useState} from 'react'
import {type Conversation, conversationApi} from '../api/conversation'
interface SettingsPanelProps {
conversation: Conversation
onClose: () => void
onUpdate?: (settings: ConversationSettings) => void
}
interface ConversationSettings {
themeColor: string
backgroundImage?: string
}
const THEME_COLORS = [
{ value: '#7C3AED', label: '紫色' },
{ value: '#F97316', label: '橙色' },
{ value: '#10B981', label: '绿色' },
{ value: '#3B82F6', label: '蓝色' },
{ value: '#EC4899', label: '粉色' },
{ value: '#F59E0B', label: '黄色' },
]
export default function SettingsPanel({ conversation, onClose, onUpdate }: SettingsPanelProps) {
const [settings, setSettings] = useState<ConversationSettings>({
themeColor: '#7C3AED',
})
const [hasChanges, setHasChanges] = useState(false)
const [backgroundPreview, setBackgroundPreview] = useState<string>()
const fileInputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
loadSettings()
}, [])
const loadSettings = () => {
if (conversation.settings) {
try {
const parsed = typeof conversation.settings === 'string'
? JSON.parse(conversation.settings)
: conversation.settings
// 合并默认设置和保存的设置
const mergedSettings = {
themeColor: '#7C3AED',
...parsed
}
setSettings(mergedSettings)
if (parsed.backgroundImage) {
setBackgroundPreview(parsed.backgroundImage)
}
// 应用主题色
if (parsed.themeColor) {
document.documentElement.style.setProperty('--color-primary', parsed.themeColor)
}
} catch (e) {
console.error('解析设置失败:', e)
}
}
}
const handleChange = (key: keyof ConversationSettings, value: any) => {
setSettings(prev => ({ ...prev, [key]: value }))
setHasChanges(true)
}
const handleImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
if (!file.type.startsWith('image/')) {
alert('请选择图片文件')
return
}
if (file.size > 5 * 1024 * 1024) {
alert('图片大小不能超过 5MB')
return
}
const reader = new FileReader()
reader.onload = (event) => {
const dataUrl = event.target?.result as string
setBackgroundPreview(dataUrl)
handleChange('backgroundImage', dataUrl)
}
reader.readAsDataURL(file)
}
const handleRemoveBackground = () => {
setBackgroundPreview(undefined)
handleChange('backgroundImage', undefined)
}
const handleSave = async () => {
try {
// 保存到后端
await conversationApi.updateConversationSettings(conversation.id, settings)
// 应用主题色到根元素
if (settings.themeColor) {
document.documentElement.style.setProperty('--color-primary', settings.themeColor)
}
// 通知父组件更新
onUpdate?.(settings)
setHasChanges(false)
alert('设置已保存')
} catch (err) {
console.error('保存设置失败:', err)
alert('保存失败,请重试')
}
}
const handleReset = () => {
setSettings({
themeColor: '#7C3AED',
})
setBackgroundPreview(undefined)
setHasChanges(false)
}
return (
<div className="flex-1 flex flex-col">
<div className="p-4 glass border-b border-white/10 flex items-center justify-between">
<h2 className="text-lg font-semibold"></h2>
<button
onClick={onClose}
className="p-2 glass-hover rounded-lg cursor-pointer"
>
<X className="w-5 h-5" />
</button>
</div>
<div className="flex-1 overflow-y-auto p-6">
<div className="max-w-2xl mx-auto space-y-6">
<div className="glass-hover rounded-2xl p-6">
<div className="flex items-center gap-3 mb-6">
<div className="p-2 bg-primary/20 rounded-lg">
<Palette className="w-5 h-5 text-primary" />
</div>
<h3 className="font-semibold"></h3>
</div>
<div className="space-y-5">
<div>
<label className="text-sm text-white/80 mb-3 block"></label>
<div className="grid grid-cols-3 gap-3">
{THEME_COLORS.map((color) => (
<button
key={color.value}
onClick={() => handleChange('themeColor', color.value)}
className={`p-4 rounded-xl cursor-pointer transition-all ${
settings.themeColor === color.value
? 'ring-2 ring-white/40 scale-105'
: 'ring-1 ring-white/20 hover:ring-white/30'
}`}
style={{ backgroundColor: color.value }}
>
<div className="text-xs font-medium text-white">{color.label}</div>
</button>
))}
</div>
</div>
<div>
<label className="text-sm text-white/80 mb-3 block"></label>
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleImageUpload}
className="hidden"
/>
{backgroundPreview ? (
<div className="relative glass rounded-xl p-4">
<img
src={backgroundPreview}
alt="背景预览"
className="w-full h-32 object-cover rounded-lg"
/>
<div className="flex gap-2 mt-3">
<button
onClick={() => fileInputRef.current?.click()}
className="flex-1 px-4 py-2 glass-hover rounded-lg text-sm cursor-pointer flex items-center justify-center gap-2"
>
<Upload className="w-4 h-4" />
</button>
<button
onClick={handleRemoveBackground}
className="flex-1 px-4 py-2 glass-hover rounded-lg text-sm cursor-pointer text-red-400"
>
</button>
</div>
</div>
) : (
<button
onClick={() => fileInputRef.current?.click()}
className="w-full p-8 glass-hover rounded-xl cursor-pointer border-2 border-dashed border-white/20 hover:border-primary/50 transition-all"
>
<Image className="w-8 h-8 mx-auto mb-2 text-white/40" />
<p className="text-sm text-white/60"></p>
<p className="text-xs text-white/40 mt-1"> JPGPNG 5MB</p>
</button>
)}
</div>
</div>
</div>
<div className="glass-hover rounded-2xl p-4">
<div className="text-xs text-white/60 space-y-2">
<p>💡 </p>
<p>💡 </p>
<p>💡 AI </p>
</div>
</div>
</div>
</div>
<div className="p-4 glass border-t border-white/10 flex gap-3">
<button
onClick={handleReset}
disabled={!hasChanges}
className="flex-1 px-4 py-3 glass-hover rounded-xl text-sm font-medium cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
<RotateCcw className="w-4 h-4" />
</button>
<button
onClick={handleSave}
disabled={!hasChanges}
className="flex-1 px-4 py-3 bg-gradient-to-r from-primary to-secondary rounded-xl text-sm font-medium hover:opacity-90 transition-opacity cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
<Save className="w-4 h-4" />
</button>
</div>
</div>
)
}

View File

@@ -0,0 +1,249 @@
import {ArrowLeft, MessageSquare, PanelLeftClose, Plus, Search, X} from 'lucide-react'
import {Link} from 'react-router-dom'
import {useEffect, useState} from 'react'
import {type Conversation, conversationApi, type ConversationListItem} from '../api/conversation'
import {type Character, characterApi} from '../api/character'
interface SidebarProps {
currentConversationId?: number
onConversationChange: (conversation: Conversation) => void
onClose?: () => void
}
export default function Sidebar({ currentConversationId, onConversationChange, onClose }: SidebarProps) {
const [conversations, setConversations] = useState<ConversationListItem[]>([])
const [characters, setCharacters] = useState<Character[]>([])
const [showCharacterModal, setShowCharacterModal] = useState(false)
const [searchQuery, setSearchQuery] = useState('')
const [loading, setLoading] = useState(true)
useEffect(() => {
loadConversations()
loadCharacters()
}, [])
const loadConversations = async () => {
try {
const response = await conversationApi.getConversationList({ page: 1, pageSize: 50 })
setConversations(response.data.list || [])
} catch (err) {
console.error('加载对话列表失败:', err)
} finally {
setLoading(false)
}
}
const loadCharacters = async () => {
try {
const response = await characterApi.getCharacterList({ page: 1, pageSize: 50 })
setCharacters(response.data.list || [])
} catch (err) {
console.error('加载角色列表失败:', err)
}
}
const filteredCharacters = characters.filter(char =>
char.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
char.description.toLowerCase().includes(searchQuery.toLowerCase())
)
const handleStartChat = async (character: Character) => {
try {
const response = await conversationApi.createConversation({
characterId: character.id,
title: `${character.name} 的对话`,
})
setShowCharacterModal(false)
onConversationChange(response.data)
await loadConversations()
} catch (err) {
console.error('创建对话失败:', err)
alert('创建对话失败,请重试')
}
}
const handleConversationClick = async (conv: ConversationListItem) => {
try {
// 获取完整的对话信息
const response = await conversationApi.getConversationById(conv.id)
onConversationChange(response.data)
} catch (err) {
console.error('加载对话失败:', err)
alert('加载对话失败,请重试')
}
}
const formatTime = (dateString: string) => {
const date = new Date(dateString)
const now = new Date()
const diff = now.getTime() - date.getTime()
const minutes = Math.floor(diff / 60000)
const hours = Math.floor(diff / 3600000)
const days = Math.floor(diff / 86400000)
if (minutes < 1) return '刚刚'
if (minutes < 60) return `${minutes}分钟前`
if (hours < 24) return `${hours}小时前`
if (days < 7) return `${days}天前`
return date.toLocaleDateString('zh-CN')
}
return (
<>
<div className="w-80 glass border-r border-white/10 flex flex-col">
<div className="p-4 border-b border-white/10">
<div className="flex items-center gap-3 mb-4">
<Link to="/" className="p-2 glass-hover rounded-lg cursor-pointer group" title="返回首页">
<ArrowLeft className="w-5 h-5 group-hover:text-primary transition-colors" />
</Link>
<h1 className="flex-1 text-xl font-semibold bg-gradient-to-r from-primary to-secondary bg-clip-text text-transparent">
</h1>
<button
onClick={() => setShowCharacterModal(true)}
className="p-2 glass-hover rounded-lg cursor-pointer"
title="新建对话"
>
<Plus className="w-5 h-5" />
</button>
{onClose && (
<button
onClick={onClose}
className="p-2 glass-hover rounded-lg cursor-pointer"
title="隐藏侧边栏"
>
<PanelLeftClose className="w-5 h-5" />
</button>
)}
</div>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-white/40" />
<input
type="text"
placeholder="搜索对话..."
className="w-full pl-10 pr-4 py-2 glass rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary/50 transition-all"
/>
</div>
</div>
<div className="flex-1 overflow-y-auto p-2">
{loading ? (
<div className="text-center py-8 text-white/60">...</div>
) : conversations.length === 0 ? (
<div className="text-center py-8 text-white/60">
<p className="mb-2"></p>
<button
onClick={() => setShowCharacterModal(true)}
className="text-sm text-primary hover:text-secondary transition-colors cursor-pointer"
>
</button>
</div>
) : (
conversations.map((conv) => (
<button
key={conv.id}
onClick={() => handleConversationClick(conv)}
className={`w-full p-3 glass-hover rounded-lg mb-2 text-left cursor-pointer group ${
currentConversationId === conv.id ? 'ring-2 ring-primary/50' : ''
}`}
>
<div className="flex items-start gap-3">
<div className="p-2 bg-primary/20 rounded-lg group-hover:bg-primary/30 transition-colors">
<MessageSquare className="w-4 h-4 text-primary" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between mb-1">
<h3 className="font-medium text-sm truncate">{conv.title}</h3>
<span className="text-xs text-white/40">{formatTime(conv.updatedAt)}</span>
</div>
<p className="text-xs text-white/60">: {conv.messageCount}</p>
</div>
</div>
</button>
))
)}
</div>
</div>
{showCharacterModal && (
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-4">
<div className="glass rounded-3xl p-6 max-w-2xl w-full max-h-[80vh] overflow-hidden flex flex-col">
<div className="flex items-center justify-between mb-6">
<h2 className="text-2xl font-bold"></h2>
<button
onClick={() => setShowCharacterModal(false)}
className="p-2 glass-hover rounded-lg cursor-pointer"
>
<X className="w-5 h-5" />
</button>
</div>
<div className="relative mb-4">
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-white/40" />
<input
type="text"
placeholder="搜索角色..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-12 pr-4 py-3 glass rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-primary/50"
/>
</div>
<div className="flex-1 overflow-y-auto pr-2">
{filteredCharacters.length === 0 ? (
<div className="text-center py-12 text-white/60">
<p className="mb-4"></p>
<Link
to="/characters"
onClick={() => setShowCharacterModal(false)}
className="text-primary hover:text-secondary transition-colors cursor-pointer"
>
</Link>
</div>
) : (
<div className="grid md:grid-cols-2 gap-4">
{filteredCharacters.map((char) => (
<button
key={char.id}
onClick={() => handleStartChat(char)}
className="glass-hover rounded-2xl p-4 text-left cursor-pointer group"
>
<div className="flex items-start gap-3">
{char.avatar && (
<img
src={char.avatar}
alt={char.name}
className="w-12 h-12 rounded-full object-cover"
/>
)}
<div className="flex-1 min-w-0">
<h3 className="font-semibold mb-1 group-hover:text-primary transition-colors">
{char.name}
</h3>
<p className="text-xs text-white/40 line-clamp-2">{char.description}</p>
</div>
</div>
</button>
))}
</div>
)}
</div>
<div className="mt-4 pt-4 border-t border-white/10">
<Link
to="/characters"
onClick={() => setShowCharacterModal(false)}
className="text-sm text-primary hover:text-secondary transition-colors cursor-pointer"
>
</Link>
</div>
</div>
</div>
)}
</>
)
}

27
web-app/src/index.css Normal file
View File

@@ -0,0 +1,27 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--color-primary: #7C3AED;
--color-secondary: #A78BFA;
}
body {
@apply bg-[#0A0A0F] text-white font-sans antialiased;
font-family: 'Inter', system-ui, -apple-system, sans-serif;
}
}
@layer utilities {
.glass {
@apply bg-white/5 backdrop-blur-xl border border-white/10;
}
.glass-hover {
@apply transition-all duration-200 hover:bg-white/10 hover:border-white/20;
}
}

10
web-app/src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.tsx'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,183 @@
import {useEffect, useState} from 'react'
import {Link, useNavigate, useParams} from 'react-router-dom'
import Navbar from '../components/Navbar'
import {ArrowLeft, Book, MessageSquare, Play, Star, Tag} from 'lucide-react'
import {type Character, characterApi} from '../api/character'
export default function CharacterDetail() {
const { id } = useParams<{ id: string }>()
const [character, setCharacter] = useState<Character | null>(null)
const [loading, setLoading] = useState(true)
const navigate = useNavigate()
useEffect(() => {
if (id) {
loadCharacter(parseInt(id))
}
}, [id])
const loadCharacter = async (characterId: number) => {
try {
setLoading(true)
const response = await characterApi.getCharacterById(characterId)
setCharacter(response.data)
} catch (err: any) {
console.error('获取角色卡详情失败:', err)
alert('角色卡不存在或无权访问')
navigate('/market')
} finally {
setLoading(false)
}
}
const getWorldBookStats = () => {
if (!character?.characterBook) return null
try {
const book = typeof character.characterBook === 'string'
? JSON.parse(character.characterBook)
: character.characterBook
return {
name: book.name || '未命名世界书',
entryCount: book.entries?.length || 0
}
} catch {
return null
}
}
const handleStartChat = () => {
if (!character) return
navigate(`/chat?character=${character.id}`)
}
if (loading) {
return (
<div className="min-h-screen bg-gradient-to-br from-[#0A0A0F] via-[#1A0B2E] to-[#0A0A0F] flex items-center justify-center">
<div className="text-white">...</div>
</div>
)
}
if (!character) {
return null
}
const worldBookStats = getWorldBookStats()
return (
<div className="min-h-screen bg-gradient-to-br from-[#0A0A0F] via-[#1A0B2E] to-[#0A0A0F]">
<Navbar />
<div className="pt-32 pb-20 px-4">
<div className="max-w-5xl mx-auto">
<Link
to="/market"
className="inline-flex items-center gap-2 text-white/60 hover:text-white transition-colors mb-6 cursor-pointer"
>
<ArrowLeft className="w-4 h-4" />
广
</Link>
<div className="glass rounded-3xl overflow-hidden mb-6">
<div className="relative h-64 bg-gradient-to-br from-primary/20 to-secondary/20">
{character.avatar ? (
<img src={character.avatar} alt={character.name} className="w-full h-full object-cover" />
) : (
<div className="w-full h-full flex items-center justify-center text-8xl">
👤
</div>
)}
<div className="absolute inset-0 bg-gradient-to-t from-[#0A0A0F] to-transparent" />
</div>
<div className="p-8">
<div className="flex flex-col md:flex-row gap-8">
<div className="flex-1">
<div className="flex items-start justify-between mb-4">
<div>
<h1 className="text-3xl font-bold mb-2">{character.name}</h1>
<p className="text-lg text-white/60">{character.creator || '未知作者'}</p>
</div>
<div className="flex items-center gap-4 text-sm text-white/60">
<div className="flex items-center gap-1">
<MessageSquare className="w-4 h-4" />
<span>{character.useCount} 使</span>
</div>
<div className="flex items-center gap-1">
<Star className="w-4 h-4" />
<span>{character.favoriteCount} </span>
</div>
</div>
</div>
{character.tags && character.tags.length > 0 && (
<div className="flex flex-wrap gap-2 mb-4">
{character.tags.map((tag, i) => (
<span key={i} className="px-3 py-1 glass rounded-lg text-sm flex items-center gap-1">
<Tag className="w-3 h-3" />
{tag}
</span>
))}
</div>
)}
<p className="text-white/80 leading-relaxed mb-6">
{character.description || '暂无描述'}
</p>
<div className="flex gap-3">
<button
onClick={handleStartChat}
className="flex-1 px-6 py-3 bg-gradient-to-r from-primary to-secondary rounded-xl font-medium hover:opacity-90 transition-opacity cursor-pointer flex items-center justify-center gap-2"
>
<Play className="w-5 h-5" />
</button>
</div>
</div>
</div>
</div>
</div>
<div className="grid md:grid-cols-2 gap-6">
{character.personality && (
<div className="glass rounded-2xl p-6">
<h2 className="text-xl font-semibold mb-4"></h2>
<p className="text-white/80 whitespace-pre-wrap">{character.personality}</p>
</div>
)}
{character.scenario && (
<div className="glass rounded-2xl p-6">
<h2 className="text-xl font-semibold mb-4"></h2>
<p className="text-white/80 whitespace-pre-wrap">{character.scenario}</p>
</div>
)}
{character.firstMes && (
<div className="glass rounded-2xl p-6">
<h2 className="text-xl font-semibold mb-4"></h2>
<div className="glass-hover rounded-xl p-4">
<p className="text-white/80 whitespace-pre-wrap">{character.firstMes}</p>
</div>
</div>
)}
{worldBookStats && worldBookStats.entryCount > 0 && (
<div className="glass rounded-2xl p-6">
<h2 className="text-xl font-semibold mb-4 flex items-center gap-2">
<Book className="w-5 h-5 text-secondary" />
</h2>
<div className="text-white/60">
<div>{worldBookStats.name}</div>
<div>{worldBookStats.entryCount} </div>
</div>
</div>
)}
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,244 @@
import {useEffect, useState} from 'react'
import {Link, useNavigate, useParams} from 'react-router-dom'
import Navbar from '../components/Navbar'
import {ArrowLeft, Book, Calendar, Download, Edit, MessageSquare, Tag, User} from 'lucide-react'
import {type Character, characterApi} from '../api/character'
export default function CharacterDetailPage() {
const { id } = useParams<{ id: string }>()
const [character, setCharacter] = useState<Character | null>(null)
const [loading, setLoading] = useState(true)
const navigate = useNavigate()
useEffect(() => {
if (id) {
loadCharacter(parseInt(id))
}
}, [id])
const loadCharacter = async (characterId: number) => {
try {
setLoading(true)
const response = await characterApi.getCharacterById(characterId)
setCharacter(response.data)
} catch (err: any) {
console.error('获取角色卡详情失败:', err)
if (err.response?.status === 401) {
navigate('/login')
} else {
alert('角色卡不存在或无权访问')
navigate('/characters')
}
} finally {
setLoading(false)
}
}
const handleExport = async () => {
if (!character) return
try {
const response = await characterApi.exportCharacter(character.id)
const blob = new Blob([response.data], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${character.name}.json`
a.click()
URL.revokeObjectURL(url)
} catch (err) {
console.error('导出角色卡失败:', err)
alert('导出失败,请重试')
}
}
const handleStartChat = () => {
if (!character) return
navigate(`/chat?character=${character.id}`)
}
const getWorldBookStats = () => {
if (!character?.characterBook) return null
try {
const book = typeof character.characterBook === 'string'
? JSON.parse(character.characterBook)
: character.characterBook
return {
name: book.name || '未命名世界书',
entryCount: book.entries?.length || 0
}
} catch {
return null
}
}
if (loading) {
return (
<div className="min-h-screen bg-gradient-to-br from-[#0A0A0F] via-[#1A0B2E] to-[#0A0A0F] flex items-center justify-center">
<div className="text-white">...</div>
</div>
)
}
if (!character) {
return null
}
const worldBookStats = getWorldBookStats()
return (
<div className="min-h-screen bg-gradient-to-br from-[#0A0A0F] via-[#1A0B2E] to-[#0A0A0F]">
<Navbar />
<div className="pt-32 pb-20 px-4">
<div className="max-w-5xl mx-auto">
<Link
to="/characters"
className="inline-flex items-center gap-2 text-white/60 hover:text-white transition-colors mb-6"
>
<ArrowLeft className="w-4 h-4" />
</Link>
<div className="glass rounded-3xl overflow-hidden">
<div className="relative h-64 bg-gradient-to-br from-primary/20 to-secondary/20">
{character.avatar && (
<img
src={character.avatar}
alt={character.name}
className="w-full h-full object-cover"
/>
)}
<div className="absolute inset-0 bg-gradient-to-t from-[#0A0A0F] to-transparent" />
<div className="absolute bottom-6 left-6 right-6">
<h1 className="text-4xl font-bold mb-2">{character.name}</h1>
{character.creator && (
<div className="flex items-center gap-2 text-white/60">
<User className="w-4 h-4" />
<span>{character.creator}</span>
</div>
)}
</div>
</div>
<div className="p-8">
<div className="flex gap-3 mb-8">
<button
onClick={handleStartChat}
className="flex-1 px-6 py-3 bg-gradient-to-r from-primary to-secondary rounded-xl font-medium hover:opacity-90 transition-opacity cursor-pointer flex items-center justify-center gap-2"
>
<MessageSquare className="w-5 h-5" />
</button>
<button
onClick={() => navigate(`/characters?edit=${character.id}`)}
className="px-6 py-3 glass-hover rounded-xl cursor-pointer flex items-center gap-2"
>
<Edit className="w-5 h-5" />
</button>
<button
onClick={handleExport}
className="px-6 py-3 glass-hover rounded-xl cursor-pointer flex items-center gap-2"
>
<Download className="w-5 h-5" />
</button>
</div>
{character.tags && character.tags.length > 0 && (
<div className="mb-6">
<div className="flex items-center gap-2 mb-3">
<Tag className="w-4 h-4 text-primary" />
<span className="text-sm font-medium"></span>
</div>
<div className="flex flex-wrap gap-2">
{character.tags.map((tag, i) => (
<span key={i} className="px-3 py-1 glass rounded-lg text-sm">
{tag}
</span>
))}
</div>
</div>
)}
{worldBookStats && (
<div className="mb-6 glass rounded-xl p-4">
<div className="flex items-center gap-2 mb-2">
<Book className="w-4 h-4 text-secondary" />
<span className="text-sm font-medium"></span>
</div>
<div className="text-sm text-white/60">
<div>{worldBookStats.name}</div>
<div>{worldBookStats.entryCount} </div>
</div>
</div>
)}
<div className="space-y-6">
{character.description && (
<div>
<h3 className="text-lg font-semibold mb-2"></h3>
<p className="text-white/80 whitespace-pre-wrap">{character.description}</p>
</div>
)}
{character.personality && (
<div>
<h3 className="text-lg font-semibold mb-2"></h3>
<p className="text-white/80 whitespace-pre-wrap">{character.personality}</p>
</div>
)}
{character.scenario && (
<div>
<h3 className="text-lg font-semibold mb-2"></h3>
<p className="text-white/80 whitespace-pre-wrap">{character.scenario}</p>
</div>
)}
{character.firstMes && (
<div>
<h3 className="text-lg font-semibold mb-2"></h3>
<div className="glass rounded-xl p-4">
<p className="text-white/80 whitespace-pre-wrap">{character.firstMes}</p>
</div>
</div>
)}
{character.mesExample && (
<div>
<h3 className="text-lg font-semibold mb-2"></h3>
<div className="glass rounded-xl p-4">
<p className="text-white/80 whitespace-pre-wrap font-mono text-sm">
{character.mesExample}
</p>
</div>
</div>
)}
{character.systemPrompt && (
<div>
<h3 className="text-lg font-semibold mb-2"></h3>
<div className="glass rounded-xl p-4">
<p className="text-white/80 whitespace-pre-wrap font-mono text-sm">
{character.systemPrompt}
</p>
</div>
</div>
)}
<div className="flex items-center gap-4 text-sm text-white/40 pt-4 border-t border-white/10">
<div className="flex items-center gap-2">
<Calendar className="w-4 h-4" />
{new Date(character.createdAt).toLocaleDateString()}
</div>
<div> {character.version || '1.0'}</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,677 @@
import {useEffect, useState} from 'react'
import {useNavigate, useSearchParams} from 'react-router-dom'
import Navbar from '../components/Navbar'
import {Book, Download, Edit, FileJson, Image as ImageIcon, Plus, Search, Trash2, X} from 'lucide-react'
import {type Character, characterApi} from '../api/character'
interface WorldBookEntry {
keys: string[]
content: string
enabled: boolean
insertion_order: number
case_sensitive?: boolean
name?: string
priority?: number
id?: number
comment?: string
selective?: boolean
secondary_keys?: string[]
constant?: boolean
position?: 'before_char' | 'after_char'
}
export default function CharacterManagePage() {
const [characters, setCharacters] = useState<Character[]>([])
const [searchQuery, setSearchQuery] = useState('')
const [showUploadModal, setShowUploadModal] = useState(false)
const [showEditModal, setShowEditModal] = useState(false)
const [selectedCharacter, setSelectedCharacter] = useState<Character | null>(null)
const [loading, setLoading] = useState(true)
const [uploading, setUploading] = useState(false)
const [page, setPage] = useState(1)
const [total, setTotal] = useState(0)
const [showWorldBookEditor, setShowWorldBookEditor] = useState(false)
const [worldBookEntries, setWorldBookEntries] = useState<WorldBookEntry[]>([])
const navigate = useNavigate()
const [searchParams] = useSearchParams()
useEffect(() => {
loadCharacters()
}, [page, searchQuery])
useEffect(() => {
const editId = searchParams.get('edit')
if (editId && characters.length > 0) {
const char = characters.find(c => c.id === parseInt(editId))
if (char) {
setSelectedCharacter(char)
setShowEditModal(true)
loadWorldBook(char)
}
}
}, [searchParams, characters])
const loadWorldBook = (character: Character) => {
if (!character.characterBook) {
setWorldBookEntries([])
return
}
try {
const book = typeof character.characterBook === 'string'
? JSON.parse(character.characterBook)
: character.characterBook
setWorldBookEntries(book.entries || [])
} catch {
setWorldBookEntries([])
}
}
const loadCharacters = async () => {
try {
setLoading(true)
const response = await characterApi.getCharacterList({
page,
pageSize: 20,
keyword: searchQuery || undefined,
})
setCharacters(response.data.list)
setTotal(response.data.total)
} catch (err: any) {
console.error('获取角色卡列表失败:', err)
if (err.response?.status === 401) {
navigate('/login')
}
} finally {
setLoading(false)
}
}
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
try {
setUploading(true)
await characterApi.uploadCharacter(file)
setShowUploadModal(false)
loadCharacters()
alert('上传成功!')
} catch (err: any) {
console.error('上传角色卡失败:', err)
alert(err.response?.data?.msg || '上传失败,请重试')
} finally {
setUploading(false)
}
}
const handleExport = async (character: Character) => {
try {
const response = await characterApi.exportCharacter(character.id)
const blob = new Blob([response.data], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${character.name}.json`
a.click()
URL.revokeObjectURL(url)
} catch (err) {
console.error('导出角色卡失败:', err)
alert('导出失败,请重试')
}
}
const handleDelete = async (id: number) => {
if (!confirm('确定要删除这个角色卡吗?')) return
try {
await characterApi.deleteCharacter(id)
loadCharacters()
alert('删除成功!')
} catch (err: any) {
console.error('删除角色卡失败:', err)
alert(err.response?.data?.msg || '删除失败,请重试')
}
}
const handleSaveEdit = async () => {
if (!selectedCharacter) return
const formData = new FormData(document.getElementById('edit-form') as HTMLFormElement)
// 构建世界书数据
const characterBook = worldBookEntries.length > 0 ? {
name: selectedCharacter.name + ' 的世界书',
entries: worldBookEntries
} : null
const updateData = {
name: formData.get('name') as string,
description: formData.get('description') as string,
personality: formData.get('personality') as string,
scenario: formData.get('scenario') as string,
firstMes: formData.get('firstMes') as string,
mesExample: formData.get('mesExample') as string,
systemPrompt: formData.get('systemPrompt') as string,
tags: (formData.get('tags') as string).split(',').map(t => t.trim()).filter(Boolean),
isPublic: formData.get('isPublic') === 'on',
characterBook: characterBook,
}
try {
await characterApi.updateCharacter(selectedCharacter.id, updateData)
setShowEditModal(false)
setShowWorldBookEditor(false)
loadCharacters()
alert('保存成功!')
} catch (err: any) {
console.error('更新角色卡失败:', err)
alert(err.response?.data?.msg || '保存失败,请重试')
}
}
const addWorldBookEntry = () => {
setWorldBookEntries([...worldBookEntries, {
keys: [],
content: '',
enabled: true,
insertion_order: worldBookEntries.length,
position: 'after_char'
}])
}
const updateWorldBookEntry = (index: number, field: keyof WorldBookEntry, value: any) => {
const newEntries = [...worldBookEntries]
newEntries[index] = { ...newEntries[index], [field]: value }
setWorldBookEntries(newEntries)
}
const deleteWorldBookEntry = (index: number) => {
setWorldBookEntries(worldBookEntries.filter((_, i) => i !== index))
}
const getWorldBookEntryCount = (character: Character) => {
if (!character.characterBook) return 0
try {
const book = typeof character.characterBook === 'string'
? JSON.parse(character.characterBook)
: character.characterBook
return book.entries?.length || 0
} catch {
return 0
}
}
if (loading && characters.length === 0) {
return (
<div className="min-h-screen bg-gradient-to-br from-[#0A0A0F] via-[#1A0B2E] to-[#0A0A0F] flex items-center justify-center">
<div className="text-white">...</div>
</div>
)
}
return (
<div className="min-h-screen bg-gradient-to-br from-[#0A0A0F] via-[#1A0B2E] to-[#0A0A0F]">
<Navbar />
<div className="pt-32 pb-20 px-4">
<div className="max-w-7xl mx-auto">
<div className="flex items-center justify-between mb-8">
<div>
<h1 className="text-4xl font-bold mb-2"></h1>
<p className="text-white/60"></p>
</div>
<button
onClick={() => setShowUploadModal(true)}
className="px-6 py-3 bg-gradient-to-r from-primary to-secondary rounded-xl font-medium hover:opacity-90 transition-opacity cursor-pointer flex items-center gap-2"
>
<Plus className="w-5 h-5" />
</button>
</div>
<div className="glass rounded-2xl p-6 mb-8">
<div className="flex gap-4">
<div className="flex-1 relative">
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-white/40" />
<input
type="text"
placeholder="搜索角色名称或描述..."
value={searchQuery}
onChange={(e) => {
setSearchQuery(e.target.value)
setPage(1)
}}
className="w-full pl-12 pr-4 py-3 glass rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-primary/50"
/>
</div>
</div>
</div>
{characters.length === 0 ? (
<div className="glass rounded-2xl p-12 text-center">
<p className="text-white/60 mb-4"></p>
<button
onClick={() => setShowUploadModal(true)}
className="px-6 py-3 bg-gradient-to-r from-primary to-secondary rounded-xl font-medium hover:opacity-90 transition-opacity cursor-pointer"
>
</button>
</div>
) : (
<>
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
{characters.map((char) => {
const worldBookCount = getWorldBookEntryCount(char)
return (
<div
key={char.id}
className="glass-hover rounded-2xl overflow-hidden cursor-pointer"
onClick={() => navigate(`/my-character/${char.id}`)}
>
<div className="relative h-48 bg-gradient-to-br from-primary/20 to-secondary/20">
{char.avatar ? (
<img src={char.avatar} alt={char.name} className="w-full h-full object-cover" />
) : (
<div className="w-full h-full flex items-center justify-center text-6xl">
👤
</div>
)}
<div className="absolute inset-0 bg-gradient-to-t from-[#0A0A0F] to-transparent" />
{worldBookCount > 0 && (
<div className="absolute top-3 right-3 px-2 py-1 glass rounded-lg flex items-center gap-1 text-xs">
<Book className="w-3 h-3" />
{worldBookCount}
</div>
)}
</div>
<div className="p-4">
<h3 className="text-lg font-semibold mb-1 truncate">{char.name}</h3>
<p className="text-sm text-white/60 mb-3">{char.creator || '未知作者'}</p>
{char.tags && char.tags.length > 0 && (
<div className="flex flex-wrap gap-1 mb-3">
{char.tags.slice(0, 3).map((tag, i) => (
<span key={i} className="text-xs px-2 py-1 bg-primary/20 text-primary rounded">
{tag}
</span>
))}
</div>
)}
<p className="text-sm text-white/60 mb-4 line-clamp-2">
{char.description || '暂无描述'}
</p>
<div className="flex gap-2" onClick={(e) => e.stopPropagation()}>
<button
onClick={() => {
setSelectedCharacter(char)
setShowEditModal(true)
}}
className="flex-1 px-4 py-2 glass-hover rounded-lg text-sm cursor-pointer flex items-center justify-center gap-2"
>
<Edit className="w-4 h-4" />
</button>
<button
onClick={() => handleExport(char)}
className="px-4 py-2 glass-hover rounded-lg text-sm cursor-pointer"
>
<Download className="w-4 h-4" />
</button>
<button
onClick={() => handleDelete(char.id)}
className="px-4 py-2 glass-hover rounded-lg text-sm cursor-pointer text-red-400"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
</div>
)
})}
</div>
{total > 20 && (
<div className="flex justify-center gap-2 mt-8">
<button
onClick={() => setPage(p => Math.max(1, p - 1))}
disabled={page === 1}
className="px-4 py-2 glass-hover rounded-lg cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
>
</button>
<span className="px-4 py-2 glass rounded-lg">
{page} / {Math.ceil(total / 20)}
</span>
<button
onClick={() => setPage(p => p + 1)}
disabled={page >= Math.ceil(total / 20)}
className="px-4 py-2 glass-hover rounded-lg cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
>
</button>
</div>
)}
</>
)}
</div>
</div>
{showUploadModal && (
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-4">
<div className="glass rounded-3xl p-8 max-w-md w-full">
<h2 className="text-2xl font-bold mb-6"></h2>
<div className="space-y-4">
<div>
<label className="block text-sm text-white/80 mb-2">PNG </label>
<label className="glass-hover rounded-xl p-6 border-2 border-dashed border-white/20 cursor-pointer block text-center">
<ImageIcon className="w-8 h-8 mx-auto mb-2 text-white/40" />
<span className="text-sm text-white/60">
{uploading ? '上传中...' : '点击上传 PNG 格式角色卡'}
</span>
<input
type="file"
accept=".png"
onChange={handleFileUpload}
disabled={uploading}
className="hidden"
/>
</label>
</div>
<div className="text-center text-white/40 text-sm"></div>
<div>
<label className="block text-sm text-white/80 mb-2">JSON </label>
<label className="glass-hover rounded-xl p-6 border-2 border-dashed border-white/20 cursor-pointer block text-center">
<FileJson className="w-8 h-8 mx-auto mb-2 text-white/40" />
<span className="text-sm text-white/60">
{uploading ? '上传中...' : '点击上传 JSON 格式角色卡'}
</span>
<input
type="file"
accept=".json"
onChange={handleFileUpload}
disabled={uploading}
className="hidden"
/>
</label>
</div>
</div>
<button
onClick={() => setShowUploadModal(false)}
disabled={uploading}
className="w-full mt-6 px-6 py-3 glass-hover rounded-xl cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
>
</button>
</div>
</div>
)}
{showEditModal && selectedCharacter && (
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-4 overflow-y-auto">
<div className="glass rounded-3xl p-8 max-w-2xl w-full my-8">
<h2 className="text-2xl font-bold mb-6"></h2>
<form id="edit-form" className="space-y-4 max-h-[60vh] overflow-y-auto pr-2">
<div>
<label className="text-sm text-white/80 mb-2 block"></label>
<input
type="text"
name="name"
defaultValue={selectedCharacter.name}
className="w-full px-4 py-3 glass rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-primary/50"
/>
</div>
<div>
<label className="text-sm text-white/80 mb-2 block"></label>
<textarea
name="description"
rows={3}
defaultValue={selectedCharacter.description}
className="w-full px-4 py-3 glass rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-primary/50 resize-none"
/>
</div>
<div>
<label className="text-sm text-white/80 mb-2 block"></label>
<textarea
name="personality"
rows={3}
defaultValue={selectedCharacter.personality}
className="w-full px-4 py-3 glass rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-primary/50 resize-none"
/>
</div>
<div>
<label className="text-sm text-white/80 mb-2 block"></label>
<textarea
name="scenario"
rows={3}
defaultValue={selectedCharacter.scenario}
className="w-full px-4 py-3 glass rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-primary/50 resize-none"
/>
</div>
<div>
<label className="text-sm text-white/80 mb-2 block"></label>
<textarea
name="firstMes"
rows={3}
defaultValue={selectedCharacter.firstMes}
className="w-full px-4 py-3 glass rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-primary/50 resize-none"
/>
</div>
<div>
<label className="text-sm text-white/80 mb-2 block"></label>
<textarea
name="mesExample"
rows={4}
defaultValue={selectedCharacter.mesExample}
placeholder="<START>&#10;{{user}}: 你好&#10;{{char}}: 你好!很高兴见到你。"
className="w-full px-4 py-3 glass rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-primary/50 resize-none"
/>
</div>
<div>
<label className="text-sm text-white/80 mb-2 block"></label>
<textarea
name="systemPrompt"
rows={3}
defaultValue={selectedCharacter.systemPrompt}
className="w-full px-4 py-3 glass rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-primary/50 resize-none"
/>
</div>
<div>
<label className="text-sm text-white/80 mb-2 block"></label>
<input
type="text"
name="tags"
defaultValue={selectedCharacter.tags?.join(', ')}
className="w-full px-4 py-3 glass rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-primary/50"
/>
</div>
<div>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
name="isPublic"
defaultChecked={selectedCharacter.isPublic}
className="w-4 h-4 cursor-pointer"
/>
<span className="text-sm text-white/80">广</span>
</label>
<p className="text-xs text-white/40 mt-1">广使</p>
</div>
<div>
<button
type="button"
onClick={() => setShowWorldBookEditor(true)}
className="w-full px-4 py-3 glass-hover rounded-xl text-sm cursor-pointer flex items-center justify-center gap-2"
>
<Book className="w-4 h-4" />
({getWorldBookEntryCount(selectedCharacter)} )
</button>
</div>
</form>
<div className="flex gap-3 mt-6">
<button
onClick={() => setShowEditModal(false)}
className="flex-1 px-6 py-3 glass-hover rounded-xl cursor-pointer"
>
</button>
<button
onClick={handleSaveEdit}
className="flex-1 px-6 py-3 bg-gradient-to-r from-primary to-secondary rounded-xl font-medium hover:opacity-90 transition-opacity cursor-pointer"
>
</button>
</div>
</div>
</div>
)}
{showWorldBookEditor && selectedCharacter && (
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-4">
<div className="glass rounded-3xl p-8 max-w-4xl w-full max-h-[80vh] overflow-hidden flex flex-col">
<div className="flex items-center justify-between mb-6">
<h2 className="text-2xl font-bold"></h2>
<button
onClick={() => setShowWorldBookEditor(false)}
className="p-2 glass-hover rounded-lg cursor-pointer"
>
<X className="w-5 h-5" />
</button>
</div>
<div className="flex-1 overflow-y-auto pr-2 space-y-4">
{worldBookEntries.length === 0 ? (
<div className="glass rounded-xl p-8 text-center">
<Book className="w-12 h-12 mx-auto mb-3 text-white/40" />
<p className="text-white/60 mb-4"></p>
<button
onClick={addWorldBookEntry}
className="px-4 py-2 bg-gradient-to-r from-primary to-secondary rounded-lg text-sm cursor-pointer"
>
</button>
</div>
) : (
worldBookEntries.map((entry, index) => (
<div key={index} className="glass rounded-xl p-4">
<div className="flex items-center justify-between mb-3">
<span className="text-sm font-medium"> #{index + 1}</span>
<div className="flex items-center gap-2">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={entry.enabled}
onChange={(e) => updateWorldBookEntry(index, 'enabled', e.target.checked)}
className="cursor-pointer"
/>
<span className="text-xs text-white/60"></span>
</label>
<button
onClick={() => deleteWorldBookEntry(index)}
className="p-1 glass-hover rounded text-red-400 cursor-pointer"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
<div className="space-y-3">
<div>
<label className="text-xs text-white/60 mb-1 block"></label>
<input
type="text"
value={entry.keys.join(', ')}
onChange={(e) => updateWorldBookEntry(index, 'keys', e.target.value.split(',').map(k => k.trim()).filter(Boolean))}
placeholder="例如: 魔法, 咒语, 法术"
className="w-full px-3 py-2 glass rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary/50"
/>
</div>
<div>
<label className="text-xs text-white/60 mb-1 block"></label>
<textarea
value={entry.content}
onChange={(e) => updateWorldBookEntry(index, 'content', e.target.value)}
placeholder="当触发关键词时,这段内容会被添加到对话上下文中..."
rows={3}
className="w-full px-3 py-2 glass rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary/50 resize-none"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs text-white/60 mb-1 block"></label>
<select
value={entry.position || 'after_char'}
onChange={(e) => updateWorldBookEntry(index, 'position', e.target.value)}
className="w-full px-3 py-2 glass rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary/50"
>
<option value="before_char"></option>
<option value="after_char"></option>
</select>
</div>
<div>
<label className="text-xs text-white/60 mb-1 block"></label>
<input
type="number"
value={entry.insertion_order}
onChange={(e) => updateWorldBookEntry(index, 'insertion_order', parseInt(e.target.value))}
className="w-full px-3 py-2 glass rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary/50"
/>
</div>
</div>
</div>
</div>
))
)}
</div>
<div className="flex gap-3 mt-6 pt-6 border-t border-white/10">
<button
onClick={addWorldBookEntry}
className="px-4 py-2 glass-hover rounded-lg text-sm cursor-pointer flex items-center gap-2"
>
<Plus className="w-4 h-4" />
</button>
<div className="flex-1" />
<button
onClick={() => setShowWorldBookEditor(false)}
className="px-4 py-2 glass-hover rounded-lg text-sm cursor-pointer"
>
</button>
<button
onClick={() => {
setShowWorldBookEditor(false)
// 世界书数据会在保存角色卡时一起保存
}}
className="px-4 py-2 bg-gradient-to-r from-primary to-secondary rounded-lg text-sm cursor-pointer"
>
</button>
</div>
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,206 @@
import {useEffect, useState} from 'react'
import {Link, useNavigate} from 'react-router-dom'
import Navbar from '../components/Navbar'
import {Book, MessageSquare, Search, Star} from 'lucide-react'
import {type Character, characterApi} from '../api/character'
const categories = ['全部', '冒险', '知识', '娱乐', '情感', '科幻', '奇幻', '历史']
export default function CharacterMarket() {
const [characters, setCharacters] = useState<Character[]>([])
const [selectedCategory, setSelectedCategory] = useState('全部')
const [searchQuery, setSearchQuery] = useState('')
const [loading, setLoading] = useState(true)
const [page, setPage] = useState(1)
const [total, setTotal] = useState(0)
const navigate = useNavigate()
useEffect(() => {
loadCharacters()
}, [page, searchQuery, selectedCategory])
const loadCharacters = async () => {
try {
setLoading(true)
const response = await characterApi.getCharacterList({
page,
pageSize: 20,
keyword: searchQuery || undefined,
tag: selectedCategory !== '全部' ? selectedCategory : undefined,
isPublic: true, // 只获取公开的角色卡
})
setCharacters(response.data.list)
setTotal(response.data.total)
} catch (err: any) {
console.error('获取角色卡列表失败:', err)
if (err.response?.status === 401) {
navigate('/login')
}
} finally {
setLoading(false)
}
}
const getWorldBookCount = (character: Character) => {
if (!character.characterBook) return 0
try {
const book = typeof character.characterBook === 'string'
? JSON.parse(character.characterBook)
: character.characterBook
return book.entries?.length || 0
} catch {
return 0
}
}
if (loading && characters.length === 0) {
return (
<div className="min-h-screen bg-gradient-to-br from-[#0A0A0F] via-[#1A0B2E] to-[#0A0A0F] flex items-center justify-center">
<div className="text-white">...</div>
</div>
)
}
return (
<div className="min-h-screen bg-gradient-to-br from-[#0A0A0F] via-[#1A0B2E] to-[#0A0A0F]">
<Navbar />
<div className="pt-32 pb-20 px-4">
<div className="max-w-7xl mx-auto">
<div className="mb-8">
<h1 className="text-4xl font-bold mb-2">广</h1>
<p className="text-white/60"> AI </p>
</div>
<div className="glass rounded-2xl p-6 mb-8">
<div className="flex flex-col md:flex-row gap-4">
<div className="flex-1 relative">
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-white/40" />
<input
type="text"
placeholder="搜索角色名称或描述..."
value={searchQuery}
onChange={(e) => {
setSearchQuery(e.target.value)
setPage(1)
}}
className="w-full pl-12 pr-4 py-3 glass rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-primary/50"
/>
</div>
</div>
<div className="flex gap-2 mt-4 overflow-x-auto pb-2">
{categories.map((cat) => (
<button
key={cat}
onClick={() => {
setSelectedCategory(cat)
setPage(1)
}}
className={`px-4 py-2 rounded-lg text-sm whitespace-nowrap cursor-pointer transition-all ${
selectedCategory === cat
? 'bg-gradient-to-r from-primary to-secondary'
: 'glass-hover'
}`}
>
{cat}
</button>
))}
</div>
</div>
{characters.length === 0 ? (
<div className="glass rounded-2xl p-12 text-center">
<p className="text-white/60"></p>
</div>
) : (
<>
<div className="grid md:grid-cols-3 lg:grid-cols-4 gap-6">
{characters.map((char) => {
const worldBookCount = getWorldBookCount(char)
return (
<Link
key={char.id}
to={`/character/${char.id}`}
className="glass-hover rounded-2xl overflow-hidden cursor-pointer group"
>
<div className="relative h-48 bg-gradient-to-br from-primary/20 to-secondary/20">
{char.avatar ? (
<img src={char.avatar} alt={char.name} className="w-full h-full object-cover" />
) : (
<div className="w-full h-full flex items-center justify-center text-6xl">
👤
</div>
)}
<div className="absolute inset-0 bg-gradient-to-t from-[#0A0A0F] to-transparent" />
{worldBookCount > 0 && (
<div className="absolute top-3 right-3 px-2 py-1 glass rounded-lg flex items-center gap-1 text-xs">
<Book className="w-3 h-3" />
{worldBookCount}
</div>
)}
</div>
<div className="p-4">
<h3 className="text-lg font-semibold mb-1 group-hover:text-primary transition-colors truncate">
{char.name}
</h3>
<p className="text-sm text-white/60 mb-3">{char.creator || '未知作者'}</p>
<p className="text-xs text-white/40 mb-4 line-clamp-2">
{char.description || '暂无描述'}
</p>
{char.tags && char.tags.length > 0 && (
<div className="flex flex-wrap gap-1 mb-3">
{char.tags.slice(0, 2).map((tag, i) => (
<span key={i} className="text-xs px-2 py-1 bg-primary/20 text-primary rounded">
{tag}
</span>
))}
</div>
)}
<div className="flex items-center justify-between text-xs text-white/40 pt-4 border-t border-white/10">
<div className="flex items-center gap-1">
<MessageSquare className="w-3 h-3" />
<span>{char.useCount}</span>
</div>
<div className="flex items-center gap-1">
<Star className="w-3 h-3" />
<span>{char.favoriteCount}</span>
</div>
</div>
</div>
</Link>
)
})}
</div>
{total > 20 && (
<div className="flex justify-center gap-2 mt-8">
<button
onClick={() => setPage(p => Math.max(1, p - 1))}
disabled={page === 1}
className="px-4 py-2 glass-hover rounded-lg cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
>
</button>
<span className="px-4 py-2 glass rounded-lg">
{page} / {Math.ceil(total / 20)}
</span>
<button
onClick={() => setPage(p => p + 1)}
disabled={page >= Math.ceil(total / 20)}
className="px-4 py-2 glass-hover rounded-lg cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
>
</button>
</div>
)}
</>
)}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,208 @@
import {useEffect, useState} from 'react'
import {useNavigate, useSearchParams} from 'react-router-dom'
import {PanelLeftOpen, PanelRightOpen} from 'lucide-react'
import Sidebar from '../components/Sidebar'
import ChatArea from '../components/ChatArea'
import CharacterPanel from '../components/CharacterPanel'
import SettingsPanel from '../components/SettingsPanel'
import {type Conversation, conversationApi} from '../api/conversation'
import {type Character, characterApi} from '../api/character'
export default function ChatPage() {
const [searchParams] = useSearchParams()
const navigate = useNavigate()
const [activePanel, setActivePanel] = useState<'chat' | 'settings'>('chat')
const [currentConversation, setCurrentConversation] = useState<Conversation | null>(null)
const [currentCharacter, setCurrentCharacter] = useState<Character | null>(null)
const [loading, setLoading] = useState(true)
const [backgroundImage, setBackgroundImage] = useState<string>()
const [showSidebar, setShowSidebar] = useState(true)
const [showCharacterPanel, setShowCharacterPanel] = useState(true)
// 从URL参数获取角色ID或对话ID
const characterId = searchParams.get('character')
const conversationId = searchParams.get('conversation')
useEffect(() => {
initializeChat()
}, [characterId, conversationId])
useEffect(() => {
if (currentConversation?.settings) {
try {
const settings = typeof currentConversation.settings === 'string'
? JSON.parse(currentConversation.settings)
: currentConversation.settings
// 设置背景图(如果有)
if (settings.backgroundImage) {
setBackgroundImage(settings.backgroundImage)
} else {
// 如果当前对话没有背景图,清除背景图
setBackgroundImage(undefined)
}
// 设置主题色
if (settings.themeColor) {
document.documentElement.style.setProperty('--color-primary', settings.themeColor)
}
} catch (e) {
console.error('解析设置失败:', e)
}
} else {
// 如果没有设置,清除背景图
setBackgroundImage(undefined)
}
}, [currentConversation?.settings, currentConversation?.id])
const initializeChat = async () => {
try {
setLoading(true)
// 如果有对话ID加载现有对话
if (conversationId) {
const convResp = await conversationApi.getConversationById(Number(conversationId))
setCurrentConversation(convResp.data)
// 加载对话关联的角色
const charResp = await characterApi.getCharacterById(convResp.data.characterId)
setCurrentCharacter(charResp.data)
}
// 如果有角色ID创建新对话
else if (characterId) {
const charResp = await characterApi.getCharacterById(Number(characterId))
setCurrentCharacter(charResp.data)
// 创建新对话
const convResp = await conversationApi.createConversation({
characterId: Number(characterId),
title: `${charResp.data.name} 的对话`,
})
setCurrentConversation(convResp.data)
// 更新URL为对话ID
navigate(`/chat?conversation=${convResp.data.id}`, { replace: true })
}
} catch (err) {
console.error('初始化对话失败:', err)
alert('加载对话失败,请重试')
} finally {
setLoading(false)
}
}
const handleConversationChange = (conversation: Conversation) => {
setCurrentConversation(conversation)
navigate(`/chat?conversation=${conversation.id}`)
}
const handleSettingsUpdate = (settings: any) => {
if (settings.backgroundImage !== undefined) {
setBackgroundImage(settings.backgroundImage)
}
if (settings.themeColor) {
document.documentElement.style.setProperty('--color-primary', settings.themeColor)
}
}
if (loading) {
return (
<div className="h-screen w-screen flex items-center justify-center bg-gradient-to-br from-[#0A0A0F] via-[#1A0B2E] to-[#0A0A0F]">
<div className="text-white">...</div>
</div>
)
}
// 如果没有对话和角色,显示带侧边栏的空状态
if (!currentConversation || !currentCharacter) {
return (
<div className="h-screen w-screen overflow-hidden flex bg-gradient-to-br from-[#0A0A0F] via-[#1A0B2E] to-[#0A0A0F]">
<Sidebar
currentConversationId={undefined}
onConversationChange={handleConversationChange}
/>
<div className="flex-1 flex items-center justify-center">
<div className="text-center">
<p className="text-white/60 mb-4"></p>
<button
onClick={() => navigate('/characters')}
className="px-6 py-3 bg-gradient-to-r from-primary to-secondary rounded-xl font-medium hover:opacity-90 transition-opacity cursor-pointer"
>
</button>
</div>
</div>
</div>
)
}
return (
<div className="h-screen w-screen overflow-hidden flex bg-gradient-to-br from-[#0A0A0F] via-[#1A0B2E] to-[#0A0A0F] relative">
{backgroundImage && (
<div
className="absolute inset-0 bg-cover bg-center bg-no-repeat opacity-20"
style={{ backgroundImage: `url(${backgroundImage})` }}
/>
)}
<div className="relative z-10 flex w-full">
{/* 左侧边栏 */}
{showSidebar && (
<Sidebar
currentConversationId={currentConversation.id}
onConversationChange={handleConversationChange}
onClose={() => setShowSidebar(false)}
/>
)}
{/* 左侧边栏切换按钮 */}
{!showSidebar && (
<button
onClick={() => setShowSidebar(true)}
className="fixed left-2 top-4 z-50 p-2 glass-hover rounded-lg cursor-pointer shadow-lg"
title="显示对话列表"
>
<PanelLeftOpen className="w-5 h-5" />
</button>
)}
<div className="flex-1 flex">
{activePanel === 'chat' ? (
<>
<ChatArea
conversation={currentConversation}
character={currentCharacter}
onConversationUpdate={setCurrentConversation}
/>
{showCharacterPanel && (
<CharacterPanel
character={currentCharacter}
conversation={currentConversation}
onOpenSettings={() => setActivePanel('settings')}
onClose={() => setShowCharacterPanel(false)}
/>
)}
{/* 右侧角色面板切换按钮 */}
{!showCharacterPanel && (
<button
onClick={() => setShowCharacterPanel(true)}
className="fixed right-2 top-4 z-50 p-2 glass-hover rounded-lg cursor-pointer shadow-lg"
title="显示角色信息"
>
<PanelRightOpen className="w-5 h-5" />
</button>
)}
</>
) : (
<SettingsPanel
conversation={currentConversation}
onClose={() => setActivePanel('chat')}
onUpdate={handleSettingsUpdate}
/>
)}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,87 @@
import {Link} from 'react-router-dom'
import {ArrowLeft, Mail, Sparkles} from 'lucide-react'
import {useState} from 'react'
export default function ForgotPasswordPage() {
const [email, setEmail] = useState('')
const [submitted, setSubmitted] = useState(false)
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
setSubmitted(true)
}
return (
<div className="min-h-screen bg-gradient-to-br from-[#0A0A0F] via-[#1A0B2E] to-[#0A0A0F] flex items-center justify-center px-4">
<div className="w-full max-w-md">
<Link to="/" className="flex items-center justify-center gap-2 mb-8">
<div className="w-10 h-10 bg-gradient-to-br from-primary to-secondary rounded-xl flex items-center justify-center">
<Sparkles className="w-6 h-6" />
</div>
<span className="text-2xl font-semibold bg-gradient-to-r from-primary to-secondary bg-clip-text text-transparent">
</span>
</Link>
<div className="glass rounded-3xl p-8">
{!submitted ? (
<>
<h1 className="text-2xl font-bold mb-2 text-center"></h1>
<p className="text-white/60 text-center mb-8">
</p>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="text-sm text-white/80 mb-2 block"></label>
<div className="relative">
<Mail className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-white/40" />
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="your@email.com"
className="w-full pl-12 pr-4 py-3 glass rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-primary/50"
required
/>
</div>
</div>
<button
type="submit"
className="w-full py-3 bg-gradient-to-r from-primary to-secondary rounded-xl font-medium hover:opacity-90 transition-opacity cursor-pointer"
>
</button>
</form>
<Link
to="/login"
className="mt-6 flex items-center justify-center gap-2 text-sm text-white/60 hover:text-white transition-colors cursor-pointer"
>
<ArrowLeft className="w-4 h-4" />
</Link>
</>
) : (
<div className="text-center">
<div className="w-16 h-16 bg-green-500/20 rounded-full flex items-center justify-center mx-auto mb-4">
<Mail className="w-8 h-8 text-green-400" />
</div>
<h1 className="text-2xl font-bold mb-2"></h1>
<p className="text-white/60 mb-8">
<span className="text-primary">{email}</span>
</p>
<Link
to="/login"
className="inline-flex items-center gap-2 px-6 py-3 bg-gradient-to-r from-primary to-secondary rounded-xl font-medium hover:opacity-90 transition-opacity cursor-pointer"
>
</Link>
</div>
)}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,132 @@
import {Link} from 'react-router-dom'
import Navbar from '../components/Navbar'
import {ArrowRight, MessageSquare, Sparkles, Star, Users, Zap} from 'lucide-react'
const featuredCharacters = [
{ id: 1, name: '艾莉娅', role: '冒险向导', avatar: '🧝‍♀️', chats: 12500, rating: 4.9 },
{ id: 2, name: '智者墨菲', role: '知识导师', avatar: '🧙‍♂️', chats: 8900, rating: 4.8 },
{ id: 3, name: '赛博朋克', role: '未来战士', avatar: '🤖', chats: 15200, rating: 4.9 },
{ id: 4, name: '月影', role: '神秘刺客', avatar: '🥷', chats: 10300, rating: 4.7 },
]
export default function HomePage() {
return (
<div className="min-h-screen bg-gradient-to-br from-[#0A0A0F] via-[#1A0B2E] to-[#0A0A0F]">
<Navbar />
<section className="pt-32 pb-20 px-4">
<div className="max-w-6xl mx-auto text-center">
<div className="inline-flex items-center gap-2 glass rounded-full px-4 py-2 mb-6">
<Sparkles className="w-4 h-4 text-primary" />
<span className="text-sm">AI </span>
</div>
<h1 className="text-5xl md:text-7xl font-bold mb-6 leading-tight">
AI
<br />
<span className="bg-gradient-to-r from-primary to-secondary bg-clip-text text-transparent">
</span>
</h1>
<p className="text-lg text-white/60 mb-8 max-w-2xl mx-auto">
AI
</p>
<div className="flex items-center justify-center gap-4">
<Link
to="/market"
className="px-8 py-4 bg-gradient-to-r from-primary to-secondary rounded-xl font-medium hover:opacity-90 transition-opacity cursor-pointer inline-flex items-center gap-2"
>
广
<ArrowRight className="w-5 h-5" />
</Link>
<button className="px-8 py-4 glass-hover rounded-xl font-medium cursor-pointer">
</button>
</div>
</div>
</section>
<section className="py-20 px-4">
<div className="max-w-6xl mx-auto">
<div className="grid md:grid-cols-3 gap-6">
<div className="glass-hover rounded-2xl p-8 text-center">
<div className="w-16 h-16 bg-primary/20 rounded-2xl flex items-center justify-center mx-auto mb-4">
<Users className="w-8 h-8 text-primary" />
</div>
<h3 className="text-xl font-semibold mb-2"></h3>
<p className="text-white/60"> AI </p>
</div>
<div className="glass-hover rounded-2xl p-8 text-center">
<div className="w-16 h-16 bg-secondary/20 rounded-2xl flex items-center justify-center mx-auto mb-4">
<MessageSquare className="w-8 h-8 text-secondary" />
</div>
<h3 className="text-xl font-semibold mb-2"></h3>
<p className="text-white/60"> AI </p>
</div>
<div className="glass-hover rounded-2xl p-8 text-center">
<div className="w-16 h-16 bg-cta/20 rounded-2xl flex items-center justify-center mx-auto mb-4">
<Zap className="w-8 h-8 text-cta" />
</div>
<h3 className="text-xl font-semibold mb-2"></h3>
<p className="text-white/60"></p>
</div>
</div>
</div>
</section>
<section className="py-20 px-4">
<div className="max-w-6xl mx-auto">
<div className="flex items-center justify-between mb-8">
<div>
<h2 className="text-3xl font-bold mb-2"></h2>
<p className="text-white/60"> AI </p>
</div>
<Link
to="/market"
className="text-primary hover:text-secondary transition-colors cursor-pointer inline-flex items-center gap-2"
>
<ArrowRight className="w-4 h-4" />
</Link>
</div>
<div className="grid md:grid-cols-4 gap-6">
{featuredCharacters.map((char) => (
<Link
key={char.id}
to={`/character/${char.id}`}
className="glass-hover rounded-2xl p-6 text-center cursor-pointer group"
>
<div className="text-6xl mb-4">{char.avatar}</div>
<h3 className="text-lg font-semibold mb-1 group-hover:text-primary transition-colors">
{char.name}
</h3>
<p className="text-sm text-white/60 mb-4">{char.role}</p>
<div className="flex items-center justify-center gap-4 text-xs text-white/40">
<div className="flex items-center gap-1">
<MessageSquare className="w-3 h-3" />
<span>{(char.chats / 1000).toFixed(1)}k</span>
</div>
<div className="flex items-center gap-1">
<Star className="w-3 h-3 fill-yellow-400 text-yellow-400" />
<span>{char.rating}</span>
</div>
</div>
</Link>
))}
</div>
</div>
</section>
<footer className="py-12 px-4 border-t border-white/10">
<div className="max-w-6xl mx-auto text-center text-white/40 text-sm">
<p>© 2024 . All rights reserved.</p>
</div>
</footer>
</div>
)
}

View File

@@ -0,0 +1,142 @@
import {useState} from 'react'
import {Link, useNavigate} from 'react-router-dom'
import {Eye, EyeOff, Lock, Mail, Sparkles} from 'lucide-react'
import {authApi} from '../api/auth'
export default function LoginPage() {
const [showPassword, setShowPassword] = useState(false)
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const navigate = useNavigate()
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
setLoading(true)
try {
const response = await authApi.login({ username, password })
const { token, refreshToken, user } = response.data
localStorage.setItem('token', token)
localStorage.setItem('refreshToken', refreshToken)
localStorage.setItem('user', JSON.stringify(user))
navigate('/profile')
} catch (err: any) {
setError(err || '登录失败,请检查用户名和密码')
} finally {
setLoading(false)
}
}
return (
<div className="min-h-screen bg-gradient-to-br from-[#0A0A0F] via-[#1A0B2E] to-[#0A0A0F] flex items-center justify-center px-4">
<div className="w-full max-w-md">
<Link to="/" className="flex items-center justify-center gap-2 mb-8">
<div className="w-10 h-10 bg-gradient-to-br from-primary to-secondary rounded-xl flex items-center justify-center">
<Sparkles className="w-6 h-6" />
</div>
<span className="text-2xl font-semibold bg-gradient-to-r from-primary to-secondary bg-clip-text text-transparent">
</span>
</Link>
<div className="glass rounded-3xl p-8">
<h1 className="text-2xl font-bold mb-2 text-center"></h1>
<p className="text-white/60 text-center mb-8"></p>
{error && (
<div className="glass rounded-xl p-4 border border-red-500/50 bg-red-500/10 mb-4">
<p className="text-sm text-red-400">{error}</p>
</div>
)}
<form onSubmit={handleLogin} className="space-y-4">
<div>
<label className="text-sm text-white/80 mb-2 block"></label>
<div className="relative">
<Mail className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-white/40" />
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="请输入用户名"
className="w-full pl-12 pr-4 py-3 glass rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-primary/50"
required
/>
</div>
</div>
<div>
<label className="text-sm text-white/80 mb-2 block"></label>
<div className="relative">
<Lock className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-white/40" />
<input
type={showPassword ? 'text' : 'password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
className="w-full pl-12 pr-12 py-3 glass rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-primary/50"
required
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-4 top-1/2 -translate-y-1/2 text-white/40 hover:text-white/60 cursor-pointer"
>
{showPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
</button>
</div>
</div>
<div className="flex items-center justify-between text-sm">
<label className="flex items-center gap-2 cursor-pointer">
<input type="checkbox" className="cursor-pointer" />
<span className="text-white/60"></span>
</label>
<Link to="/forgot-password" className="text-primary hover:text-secondary transition-colors cursor-pointer">
</Link>
</div>
<button
type="submit"
disabled={loading}
className="w-full py-3 bg-gradient-to-r from-primary to-secondary rounded-xl font-medium hover:opacity-90 transition-opacity cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? '登录中...' : '登录'}
</button>
</form>
<div className="mt-6 text-center text-sm text-white/60">
<Link to="/register" className="text-primary hover:text-secondary transition-colors ml-1 cursor-pointer">
</Link>
</div>
<div className="mt-6 relative">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-white/10"></div>
</div>
<div className="relative flex justify-center text-xs">
<span className="px-4 glass text-white/60">使</span>
</div>
</div>
<div className="mt-6 grid grid-cols-2 gap-3">
<button className="py-3 glass-hover rounded-xl text-sm cursor-pointer">
GitHub
</button>
<button className="py-3 glass-hover rounded-xl text-sm cursor-pointer">
Google
</button>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,836 @@
import {useEffect, useState} from 'react'
import Navbar from '../components/Navbar'
import {Check, Copy, Download, Edit, FileJson, Plus, Search, Star, Trash2, Upload} from 'lucide-react'
import {type Preset, presetApi} from '../api/preset'
export default function PresetManagePage() {
const [presets, setPresets] = useState<Preset[]>([])
const [searchQuery, setSearchQuery] = useState('')
const [showUploadModal, setShowUploadModal] = useState(false)
const [showEditModal, setShowEditModal] = useState(false)
const [showCreateModal, setShowCreateModal] = useState(false)
const [selectedPreset, setSelectedPreset] = useState<Preset | null>(null)
const [copiedId, setCopiedId] = useState<number | null>(null)
const [loading, setLoading] = useState(true)
const [page, setPage] = useState(1)
const [total, setTotal] = useState(0)
// 加载预设列表
useEffect(() => {
loadPresets()
}, [page, searchQuery])
const loadPresets = async () => {
try {
setLoading(true)
const response = await presetApi.getPresetList({
page,
pageSize: 20,
keyword: searchQuery
})
setPresets(response.data.list)
setTotal(response.data.total)
} catch (err) {
console.error('加载预设失败:', err)
alert('加载预设失败,请重试')
} finally {
setLoading(false)
}
}
// 文件上传导入
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
try {
await presetApi.importPreset(file)
await loadPresets()
setShowUploadModal(false)
alert('预设导入成功!')
} catch (err: any) {
console.error('导入预设失败:', err)
alert(err.response?.data?.msg || '导入失败,请检查文件格式')
}
}
// 导出预设
const handleExport = async (preset: Preset) => {
try {
const response = await presetApi.exportPreset(preset.id)
const blob = new Blob([response.data], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${preset.name}.json`
a.click()
URL.revokeObjectURL(url)
} catch (err) {
console.error('导出预设失败:', err)
alert('导出失败,请重试')
}
}
// 复制预设
const handleCopy = async (preset: Preset) => {
try {
await presetApi.createPreset({
name: `${preset.name} (副本)`,
description: preset.description,
temperature: preset.temperature,
topP: preset.topP,
topK: preset.topK,
frequencyPenalty: preset.frequencyPenalty,
presencePenalty: preset.presencePenalty,
maxTokens: preset.maxTokens,
repetitionPenalty: preset.repetitionPenalty,
minP: preset.minP,
topA: preset.topA,
systemPrompt: preset.systemPrompt,
stopSequences: preset.stopSequences,
extensions: preset.extensions
})
await loadPresets()
setCopiedId(preset.id)
setTimeout(() => setCopiedId(null), 2000)
} catch (err) {
console.error('复制预设失败:', err)
alert('复制失败,请重试')
}
}
// 删除预设
const handleDelete = async (id: number) => {
if (!confirm('确定要删除这个预设吗?')) return
try {
await presetApi.deletePreset(id)
await loadPresets()
} catch (err: any) {
console.error('删除预设失败:', err)
alert(err.response?.data?.msg || '删除失败,请重试')
}
}
// 设置默认预设
const handleSetDefault = async (id: number) => {
try {
await presetApi.setDefaultPreset(id)
await loadPresets()
alert('已设置为默认预设')
} catch (err) {
console.error('设置默认预设失败:', err)
alert('设置失败,请重试')
}
}
// 保存编辑
const handleSaveEdit = async (updatedPreset: Preset) => {
try {
await presetApi.updatePreset(updatedPreset.id, {
name: updatedPreset.name,
description: updatedPreset.description,
temperature: updatedPreset.temperature,
topP: updatedPreset.topP,
topK: updatedPreset.topK,
frequencyPenalty: updatedPreset.frequencyPenalty,
presencePenalty: updatedPreset.presencePenalty,
maxTokens: updatedPreset.maxTokens,
repetitionPenalty: updatedPreset.repetitionPenalty,
minP: updatedPreset.minP,
topA: updatedPreset.topA,
systemPrompt: updatedPreset.systemPrompt,
stopSequences: updatedPreset.stopSequences,
extensions: updatedPreset.extensions
})
await loadPresets()
setShowEditModal(false)
alert('保存成功')
} catch (err) {
console.error('保存预设失败:', err)
alert('保存失败,请重试')
}
}
// 创建新预设
const handleCreate = async (newPreset: Partial<Preset>) => {
try {
await presetApi.createPreset({
name: newPreset.name!,
description: newPreset.description,
temperature: newPreset.temperature ?? 0.7,
topP: newPreset.topP ?? 0.9,
topK: newPreset.topK ?? 40,
frequencyPenalty: newPreset.frequencyPenalty ?? 0,
presencePenalty: newPreset.presencePenalty ?? 0,
maxTokens: newPreset.maxTokens ?? 2048,
repetitionPenalty: newPreset.repetitionPenalty ?? 1.0,
minP: newPreset.minP ?? 0,
topA: newPreset.topA ?? 0,
systemPrompt: newPreset.systemPrompt ?? '',
stopSequences: newPreset.stopSequences ?? [],
extensions: newPreset.extensions ?? {}
})
await loadPresets()
setShowCreateModal(false)
alert('创建成功')
} catch (err) {
console.error('创建预设失败:', err)
alert('创建失败,请重试')
}
}
const filteredPresets = presets
return (
<div className="min-h-screen bg-gradient-to-br from-[#0A0A0F] via-[#1A0B2E] to-[#0A0A0F]">
<Navbar />
<div className="pt-32 pb-20 px-4">
<div className="max-w-7xl mx-auto">
<div className="flex items-center justify-between mb-8">
<div>
<h1 className="text-4xl font-bold mb-2"></h1>
<p className="text-white/60"></p>
</div>
<div className="flex gap-3">
<button
onClick={() => setShowCreateModal(true)}
className="px-6 py-3 bg-gradient-to-r from-primary to-secondary rounded-xl font-medium hover:opacity-90 transition-opacity cursor-pointer flex items-center gap-2"
>
<Plus className="w-5 h-5" />
</button>
<button
onClick={() => setShowUploadModal(true)}
className="px-6 py-3 glass-hover rounded-xl font-medium cursor-pointer flex items-center gap-2"
>
<Upload className="w-5 h-5" />
</button>
</div>
</div>
<div className="glass rounded-2xl p-6 mb-8">
<div className="relative">
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-white/40" />
<input
type="text"
placeholder="搜索预设名称或描述..."
value={searchQuery}
onChange={(e) => {
setSearchQuery(e.target.value)
setPage(1)
}}
className="w-full pl-12 pr-4 py-3 glass rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-primary/50"
/>
</div>
</div>
{loading ? (
<div className="text-center text-white/60 py-12">...</div>
) : filteredPresets.length === 0 ? (
<div className="text-center text-white/60 py-12">
<p className="mb-2"></p>
<p className="text-sm">使</p>
</div>
) : (
<div className="grid md:grid-cols-2 gap-6">
{filteredPresets.map((preset) => (
<div key={preset.id} className="glass-hover rounded-2xl p-6">
<div className="flex items-start justify-between mb-4">
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
<h3 className="text-lg font-semibold">{preset.name}</h3>
{preset.isDefault && (
<span className="text-xs px-2 py-1 bg-primary/20 text-primary rounded flex items-center gap-1">
<Star className="w-3 h-3" />
</span>
)}
{preset.isPublic && (
<span className="text-xs px-2 py-1 bg-secondary/20 text-secondary rounded">
</span>
)}
</div>
<p className="text-sm text-white/60">{preset.description}</p>
<p className="text-xs text-white/40 mt-2">使: {preset.useCount}</p>
</div>
</div>
<div className="grid grid-cols-2 gap-3 mb-4 text-sm">
<div className="glass rounded-lg p-3">
<div className="text-white/60 mb-1"></div>
<div className="font-medium">{preset.temperature}</div>
</div>
<div className="glass rounded-lg p-3">
<div className="text-white/60 mb-1"></div>
<div className="font-medium">{preset.maxTokens}</div>
</div>
<div className="glass rounded-lg p-3">
<div className="text-white/60 mb-1">Top P</div>
<div className="font-medium">{preset.topP}</div>
</div>
<div className="glass rounded-lg p-3">
<div className="text-white/60 mb-1">Top K</div>
<div className="font-medium">{preset.topK}</div>
</div>
</div>
<div className="flex gap-2">
<button
onClick={() => {
setSelectedPreset(preset)
setShowEditModal(true)
}}
className="flex-1 px-4 py-2 glass-hover rounded-lg text-sm cursor-pointer flex items-center justify-center gap-2"
>
<Edit className="w-4 h-4" />
</button>
{!preset.isDefault && (
<button
onClick={() => handleSetDefault(preset.id)}
className="px-4 py-2 glass-hover rounded-lg text-sm cursor-pointer"
title="设为默认"
>
<Star className="w-4 h-4" />
</button>
)}
<button
onClick={() => handleCopy(preset)}
className="px-4 py-2 glass-hover rounded-lg text-sm cursor-pointer"
title="复制预设"
>
{copiedId === preset.id ? (
<Check className="w-4 h-4 text-green-400" />
) : (
<Copy className="w-4 h-4" />
)}
</button>
<button
onClick={() => handleExport(preset)}
className="px-4 py-2 glass-hover rounded-lg text-sm cursor-pointer"
title="导出预设"
>
<Download className="w-4 h-4" />
</button>
<button
onClick={() => handleDelete(preset.id)}
className="px-4 py-2 glass-hover rounded-lg text-sm cursor-pointer text-red-400"
title="删除预设"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
))}
</div>
)}
{/* 分页 */}
{total > 20 && (
<div className="flex justify-center gap-2 mt-8">
<button
onClick={() => setPage(p => Math.max(1, p - 1))}
disabled={page === 1}
className="px-4 py-2 glass-hover rounded-lg text-sm cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
>
</button>
<span className="px-4 py-2 glass rounded-lg text-sm">
{page} / {Math.ceil(total / 20)}
</span>
<button
onClick={() => setPage(p => p + 1)}
disabled={page >= Math.ceil(total / 20)}
className="px-4 py-2 glass-hover rounded-lg text-sm cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
>
</button>
</div>
)}
</div>
</div>
{/* 导入模态框 */}
{showUploadModal && (
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-4">
<div className="glass rounded-3xl p-8 max-w-md w-full">
<h2 className="text-2xl font-bold mb-6"></h2>
<div>
<label className="block text-sm text-white/80 mb-2">JSON </label>
<label className="glass-hover rounded-xl p-6 border-2 border-dashed border-white/20 cursor-pointer block text-center">
<FileJson className="w-8 h-8 mx-auto mb-2 text-white/40" />
<span className="text-sm text-white/60"> JSON </span>
<input
type="file"
accept=".json"
onChange={handleFileUpload}
className="hidden"
/>
</label>
<p className="text-xs text-white/40 mt-2">
SillyTavernTavernAI
</p>
</div>
<button
onClick={() => setShowUploadModal(false)}
className="w-full mt-6 px-6 py-3 glass-hover rounded-xl cursor-pointer"
>
</button>
</div>
</div>
)}
{/* 编辑模态框 */}
{showEditModal && selectedPreset && (
<EditPresetModal
preset={selectedPreset}
onSave={handleSaveEdit}
onClose={() => setShowEditModal(false)}
/>
)}
{/* 创建模态框 */}
{showCreateModal && (
<CreatePresetModal
onCreate={handleCreate}
onClose={() => setShowCreateModal(false)}
/>
)}
</div>
)
}
// 编辑预设模态框
function EditPresetModal({ preset, onSave, onClose }: {
preset: Preset
onSave: (preset: Preset) => void
onClose: () => void
}) {
// 解析 extensions 中的 prompts
const getPrompts = () => {
const ext = preset.extensions as any
if (ext?.prompts && Array.isArray(ext.prompts)) {
return ext.prompts
}
// 默认提示词结构
return [
{ identifier: 'main', name: 'Main Prompt', content: '', role: 'system', system_prompt: true },
{ identifier: 'nsfw', name: 'Auxiliary Prompt', content: '', role: 'system', system_prompt: true },
{ identifier: 'jailbreak', name: 'Post-History Instructions', content: '', role: 'system', system_prompt: true },
]
}
const [formData, setFormData] = useState({
...preset,
description: preset.description || '',
temperature: preset.temperature ?? 0.7,
maxTokens: preset.maxTokens ?? 2048,
topP: preset.topP ?? 0.9,
topK: preset.topK ?? 40,
frequencyPenalty: preset.frequencyPenalty ?? 0,
presencePenalty: preset.presencePenalty ?? 0,
repetitionPenalty: preset.repetitionPenalty ?? 1.0,
minP: preset.minP ?? 0,
topA: preset.topA ?? 0,
systemPrompt: preset.systemPrompt || '',
stopSequences: preset.stopSequences || [],
extensions: preset.extensions || {}
})
const [prompts, setPrompts] = useState(getPrompts())
const [activeTab, setActiveTab] = useState<'params' | 'prompts'>('params')
const handlePromptChange = (index: number, field: string, value: any) => {
const newPrompts = [...prompts]
newPrompts[index] = { ...newPrompts[index], [field]: value }
setPrompts(newPrompts)
}
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
// 更新 extensions 中的 prompts
const updatedExtensions = {
...(formData.extensions as any),
prompts: prompts
}
onSave({
...formData,
extensions: updatedExtensions
})
}
return (
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-4 overflow-y-auto">
<div className="glass rounded-3xl p-8 max-w-4xl w-full my-8">
<h2 className="text-2xl font-bold mb-6"></h2>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="text-sm text-white/80 mb-2 block"></label>
<input
type="text"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
className="w-full px-4 py-3 glass rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-primary/50"
required
/>
</div>
<div>
<label className="text-sm text-white/80 mb-2 block"></label>
<input
type="text"
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
className="w-full px-4 py-3 glass rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-primary/50"
/>
</div>
{/* 标签页切换 */}
<div className="flex gap-2 border-b border-white/10">
<button
type="button"
onClick={() => setActiveTab('params')}
className={`px-4 py-2 text-sm font-medium transition-colors ${
activeTab === 'params'
? 'text-primary border-b-2 border-primary'
: 'text-white/60 hover:text-white/80'
}`}
>
</button>
<button
type="button"
onClick={() => setActiveTab('prompts')}
className={`px-4 py-2 text-sm font-medium transition-colors ${
activeTab === 'prompts'
? 'text-primary border-b-2 border-primary'
: 'text-white/60 hover:text-white/80'
}`}
>
</button>
</div>
<div className="max-h-[50vh] overflow-y-auto pr-2">
{activeTab === 'params' ? (
<div className="space-y-4">
<div className="grid md:grid-cols-2 gap-4">
<div>
<label className="text-sm text-white/80 mb-2 block">
(Temperature): {formData.temperature}
</label>
<input
type="range"
min="0"
max="2"
step="0.1"
value={formData.temperature}
onChange={(e) => setFormData({ ...formData, temperature: parseFloat(e.target.value) })}
className="w-full"
/>
<p className="text-xs text-white/40 mt-1"></p>
</div>
<div>
<label className="text-sm text-white/80 mb-2 block">
(Max Tokens): {formData.maxTokens}
</label>
<input
type="number"
value={formData.maxTokens}
onChange={(e) => setFormData({ ...formData, maxTokens: parseInt(e.target.value) })}
className="w-full px-4 py-3 glass rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-primary/50"
min="1"
max="32000"
/>
</div>
<div>
<label className="text-sm text-white/80 mb-2 block">
Top P: {formData.topP}
</label>
<input
type="range"
min="0"
max="1"
step="0.05"
value={formData.topP}
onChange={(e) => setFormData({ ...formData, topP: parseFloat(e.target.value) })}
className="w-full"
/>
</div>
<div>
<label className="text-sm text-white/80 mb-2 block">
Top K: {formData.topK}
</label>
<input
type="number"
value={formData.topK}
onChange={(e) => setFormData({ ...formData, topK: parseInt(e.target.value) })}
className="w-full px-4 py-3 glass rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-primary/50"
min="0"
max="100"
/>
</div>
<div>
<label className="text-sm text-white/80 mb-2 block">
: {formData.frequencyPenalty}
</label>
<input
type="range"
min="-2"
max="2"
step="0.1"
value={formData.frequencyPenalty}
onChange={(e) => setFormData({ ...formData, frequencyPenalty: parseFloat(e.target.value) })}
className="w-full"
/>
</div>
<div>
<label className="text-sm text-white/80 mb-2 block">
: {formData.presencePenalty}
</label>
<input
type="range"
min="-2"
max="2"
step="0.1"
value={formData.presencePenalty}
onChange={(e) => setFormData({ ...formData, presencePenalty: parseFloat(e.target.value) })}
className="w-full"
/>
</div>
</div>
<div>
<label className="text-sm text-white/80 mb-2 block"></label>
<textarea
rows={3}
value={(formData.stopSequences || []).join('\n')}
onChange={(e) => setFormData({ ...formData, stopSequences: e.target.value.split('\n').filter(s => s.trim()) })}
className="w-full px-4 py-3 glass rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-primary/50 resize-none"
placeholder="输入停止序列..."
/>
</div>
</div>
) : (
<div className="space-y-3">
<p className="text-sm text-white/60 mb-4">
AI
</p>
{prompts.map((prompt: any, index: number) => (
<div key={index} className="glass rounded-xl p-4">
<div className="flex items-center gap-3 mb-3">
<div className="flex items-center gap-2 flex-1">
<span className="text-sm font-medium">{prompt.name || prompt.identifier}</span>
{prompt.identifier && (
<span className="text-xs text-white/40 px-2 py-0.5 glass rounded">
{prompt.identifier}
</span>
)}
</div>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={!prompt.marker}
onChange={(e) => handlePromptChange(index, 'marker', !e.target.checked)}
className="rounded"
/>
<span className="text-white/60"></span>
</label>
</div>
{!prompt.marker && (
<textarea
rows={3}
value={prompt.content || ''}
onChange={(e) => handlePromptChange(index, 'content', e.target.value)}
className="w-full px-3 py-2 glass rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary/50 resize-none"
placeholder={`输入 ${prompt.name} 的内容...`}
/>
)}
</div>
))}
</div>
)}
</div>
<div className="flex gap-3 pt-4">
<button
type="button"
onClick={onClose}
className="flex-1 px-6 py-3 glass-hover rounded-xl cursor-pointer"
>
</button>
<button
type="submit"
className="flex-1 px-6 py-3 bg-gradient-to-r from-primary to-secondary rounded-xl font-medium hover:opacity-90 transition-opacity cursor-pointer"
>
</button>
</div>
</form>
</div>
</div>
)
}
// 创建预设模态框
function CreatePresetModal({ onCreate, onClose }: {
onCreate: (preset: Partial<Preset>) => void
onClose: () => void
}) {
const [formData, setFormData] = useState({
name: '',
description: '',
temperature: 0.7,
maxTokens: 2048,
topP: 0.9,
topK: 40,
frequencyPenalty: 0,
presencePenalty: 0,
repetitionPenalty: 1.0,
minP: 0,
topA: 0,
systemPrompt: '',
stopSequences: [] as string[],
extensions: {} as Record<string, any>
})
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
onCreate(formData)
}
return (
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-4 overflow-y-auto">
<div className="glass rounded-3xl p-8 max-w-2xl w-full my-8">
<h2 className="text-2xl font-bold mb-6"></h2>
<form onSubmit={handleSubmit} className="space-y-4 max-h-[60vh] overflow-y-auto pr-2">
<div>
<label className="text-sm text-white/80 mb-2 block"> *</label>
<input
type="text"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
className="w-full px-4 py-3 glass rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-primary/50"
required
/>
</div>
<div>
<label className="text-sm text-white/80 mb-2 block"></label>
<input
type="text"
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
className="w-full px-4 py-3 glass rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-primary/50"
/>
</div>
<div className="grid md:grid-cols-2 gap-4">
<div>
<label className="text-sm text-white/80 mb-2 block">
: {formData.temperature}
</label>
<input
type="range"
min="0"
max="2"
step="0.1"
value={formData.temperature}
onChange={(e) => setFormData({ ...formData, temperature: parseFloat(e.target.value) })}
className="w-full"
/>
</div>
<div>
<label className="text-sm text-white/80 mb-2 block">
: {formData.maxTokens}
</label>
<input
type="number"
value={formData.maxTokens}
onChange={(e) => setFormData({ ...formData, maxTokens: parseInt(e.target.value) })}
className="w-full px-4 py-3 glass rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-primary/50"
min="1"
max="32000"
/>
</div>
</div>
<div>
<label className="text-sm text-white/80 mb-2 block"></label>
<textarea
rows={4}
value={formData.systemPrompt}
onChange={(e) => setFormData({ ...formData, systemPrompt: e.target.value })}
className="w-full px-4 py-3 glass rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-primary/50 resize-none"
placeholder="设置 AI 的行为和角色..."
/>
</div>
<div>
<label className="text-sm text-white/80 mb-2 block"></label>
<textarea
rows={3}
value={formData.stopSequences.join('\n')}
onChange={(e) => setFormData({ ...formData, stopSequences: e.target.value.split('\n').filter(s => s.trim()) })}
className="w-full px-4 py-3 glass rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-primary/50 resize-none"
placeholder="输入停止序列..."
/>
</div>
<div>
<label className="text-sm text-white/80 mb-2 block">JSON </label>
<textarea
rows={4}
value={JSON.stringify(formData.extensions, null, 2)}
onChange={(e) => {
try {
const parsed = JSON.parse(e.target.value)
setFormData({ ...formData, extensions: parsed })
} catch (err) {
// 保持原值,等待用户输入完整的 JSON
}
}}
className="w-full px-4 py-3 glass rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-primary/50 resize-none font-mono"
placeholder='{"key": "value"}'
/>
<p className="text-xs text-white/40 mt-1"> SillyTavern </p>
</div>
<div className="flex gap-3 pt-4">
<button
type="button"
onClick={onClose}
className="flex-1 px-6 py-3 glass-hover rounded-xl cursor-pointer"
>
</button>
<button
type="submit"
className="flex-1 px-6 py-3 bg-gradient-to-r from-primary to-secondary rounded-xl font-medium hover:opacity-90 transition-opacity cursor-pointer"
>
</button>
</div>
</form>
</div>
</div>
)
}

View File

@@ -0,0 +1,295 @@
import {useEffect, useState} from 'react'
import {Link, useNavigate} from 'react-router-dom'
import Navbar from '../components/Navbar'
import {Bell, Heart, LogOut, MessageSquare, Settings, Shield, Star, User} from 'lucide-react'
import {authApi, type User as UserType} from '../api/auth'
export default function ProfilePage() {
const [activeTab, setActiveTab] = useState<'overview' | 'settings' | 'favorites'>('overview')
const [user, setUser] = useState<UserType | null>(null)
const [loading, setLoading] = useState(true)
const navigate = useNavigate()
useEffect(() => {
loadUserInfo()
}, [])
const loadUserInfo = async () => {
try {
const response = await authApi.getUserInfo()
setUser(response.data)
} catch (err) {
console.error('获取用户信息失败:', err)
navigate('/login')
} finally {
setLoading(false)
}
}
const handleLogout = async () => {
try {
await authApi.logout()
} catch (err) {
console.error('登出失败:', err)
} finally {
localStorage.removeItem('token')
localStorage.removeItem('refreshToken')
localStorage.removeItem('user')
navigate('/login')
}
}
if (loading) {
return (
<div className="min-h-screen bg-gradient-to-br from-[#0A0A0F] via-[#1A0B2E] to-[#0A0A0F] flex items-center justify-center">
<div className="text-white">...</div>
</div>
)
}
if (!user) {
return null
}
const recentChats = [
{ id: 1, character: '艾莉娅', lastMessage: '那么,准备好下一次冒险了吗?', time: '2分钟前' },
{ id: 2, character: '智者墨菲', lastMessage: '这个问题很有深度...', time: '1小时前' },
{ id: 3, character: '赛博朋克', lastMessage: '未来已来,你准备好了吗?', time: '3小时前' },
]
const favoriteCharacters = [
{ id: 1, name: '艾莉娅', avatar: '🧝‍♀️', role: '冒险向导' },
{ id: 2, name: '智者墨菲', avatar: '🧙‍♂️', role: '知识导师' },
{ id: 3, name: '月影', avatar: '🥷', role: '神秘刺客' },
{ id: 4, name: '心理咨询师', avatar: '💝', role: '情感导师' },
]
return (
<div className="min-h-screen bg-gradient-to-br from-[#0A0A0F] via-[#1A0B2E] to-[#0A0A0F]">
<Navbar />
<div className="pt-32 pb-20 px-4">
<div className="max-w-6xl mx-auto">
<div className="glass rounded-3xl p-8 mb-6">
<div className="flex flex-col md:flex-row items-center gap-6">
<div className="w-24 h-24 bg-gradient-to-br from-primary to-secondary rounded-full flex items-center justify-center text-4xl">
{user.avatar ? (
<img src={user.avatar} alt={user.nickName} className="w-full h-full rounded-full object-cover" />
) : (
'👤'
)}
</div>
<div className="flex-1 text-center md:text-left">
<h1 className="text-3xl font-bold mb-2">{user.nickName || user.username}</h1>
<p className="text-white/60 mb-4">{user.email || '未设置邮箱'}</p>
<div className="flex flex-wrap gap-6 justify-center md:justify-start">
<div>
<div className="text-2xl font-bold text-primary">{user.chatCount}</div>
<div className="text-sm text-white/60"></div>
</div>
<div>
<div className="text-2xl font-bold text-secondary">{user.messageCount}</div>
<div className="text-sm text-white/60"></div>
</div>
<div>
<div className="text-2xl font-bold text-cta">
{user.lastLoginAt ? new Date(user.lastLoginAt).toLocaleDateString() : '从未'}
</div>
<div className="text-sm text-white/60"></div>
</div>
</div>
</div>
<button className="px-6 py-3 glass-hover rounded-xl cursor-pointer flex items-center gap-2">
<Settings className="w-5 h-5" />
</button>
</div>
</div>
<div className="glass rounded-2xl p-2 mb-6 inline-flex gap-2">
<button
onClick={() => setActiveTab('overview')}
className={`px-6 py-2 rounded-lg text-sm cursor-pointer transition-all ${
activeTab === 'overview' ? 'bg-gradient-to-r from-primary to-secondary' : 'hover:bg-white/5'
}`}
>
</button>
<button
onClick={() => setActiveTab('favorites')}
className={`px-6 py-2 rounded-lg text-sm cursor-pointer transition-all ${
activeTab === 'favorites' ? 'bg-gradient-to-r from-primary to-secondary' : 'hover:bg-white/5'
}`}
>
</button>
<button
onClick={() => setActiveTab('settings')}
className={`px-6 py-2 rounded-lg text-sm cursor-pointer transition-all ${
activeTab === 'settings' ? 'bg-gradient-to-r from-primary to-secondary' : 'hover:bg-white/5'
}`}
>
</button>
</div>
{activeTab === 'overview' && (
<div className="grid md:grid-cols-2 gap-6">
<div className="glass rounded-2xl p-6">
<h2 className="text-xl font-semibold mb-4 flex items-center gap-2">
<MessageSquare className="w-5 h-5 text-primary" />
</h2>
<div className="space-y-3">
{recentChats.map((chat) => (
<Link
key={chat.id}
to="/chat"
className="glass-hover rounded-xl p-4 block cursor-pointer"
>
<div className="flex items-center justify-between mb-2">
<span className="font-medium">{chat.character}</span>
<span className="text-xs text-white/40">{chat.time}</span>
</div>
<p className="text-sm text-white/60 truncate">{chat.lastMessage}</p>
</Link>
))}
</div>
</div>
<div className="glass rounded-2xl p-6">
<h2 className="text-xl font-semibold mb-4 flex items-center gap-2">
<Star className="w-5 h-5 text-yellow-400" />
</h2>
<div className="grid grid-cols-3 gap-4">
{['🏆', '⭐', '🎯', '💎', '🔥', '👑'].map((badge, i) => (
<div key={i} className="glass-hover rounded-xl p-4 text-center cursor-pointer">
<div className="text-3xl mb-2">{badge}</div>
<div className="text-xs text-white/60"> {i + 1}</div>
</div>
))}
</div>
</div>
</div>
)}
{activeTab === 'favorites' && (
<div className="glass rounded-2xl p-6">
<h2 className="text-xl font-semibold mb-4 flex items-center gap-2">
<Heart className="w-5 h-5 text-red-400" />
</h2>
<div className="grid md:grid-cols-4 gap-4">
{favoriteCharacters.map((char) => (
<Link
key={char.id}
to={`/character/${char.id}`}
className="glass-hover rounded-xl p-6 text-center cursor-pointer"
>
<div className="text-5xl mb-3">{char.avatar}</div>
<h3 className="font-semibold mb-1">{char.name}</h3>
<p className="text-sm text-white/60">{char.role}</p>
</Link>
))}
</div>
</div>
)}
{activeTab === 'settings' && (
<div className="space-y-6">
<div className="glass rounded-2xl p-6">
<h2 className="text-xl font-semibold mb-4 flex items-center gap-2">
<User className="w-5 h-5 text-primary" />
</h2>
<div className="space-y-4">
<div>
<label className="text-sm text-white/80 mb-2 block"></label>
<input
type="text"
defaultValue={user.username}
disabled
className="w-full px-4 py-3 glass rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-primary/50 opacity-50"
/>
</div>
<div>
<label className="text-sm text-white/80 mb-2 block"></label>
<input
type="email"
defaultValue={user.email}
className="w-full px-4 py-3 glass rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-primary/50"
/>
</div>
<div>
<label className="text-sm text-white/80 mb-2 block"></label>
<input
type="text"
defaultValue={user.nickName}
className="w-full px-4 py-3 glass rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-primary/50"
/>
</div>
</div>
</div>
<div className="glass rounded-2xl p-6">
<h2 className="text-xl font-semibold mb-4 flex items-center gap-2">
<Shield className="w-5 h-5 text-secondary" />
</h2>
<div className="space-y-3">
<label className="flex items-center justify-between cursor-pointer">
<span className="text-sm"></span>
<input type="checkbox" defaultChecked className="cursor-pointer" />
</label>
<label className="flex items-center justify-between cursor-pointer">
<span className="text-sm"></span>
<input type="checkbox" defaultChecked className="cursor-pointer" />
</label>
<label className="flex items-center justify-between cursor-pointer">
<span className="text-sm">线</span>
<input type="checkbox" className="cursor-pointer" />
</label>
</div>
</div>
<div className="glass rounded-2xl p-6">
<h2 className="text-xl font-semibold mb-4 flex items-center gap-2">
<Bell className="w-5 h-5 text-cta" />
</h2>
<div className="space-y-3">
<label className="flex items-center justify-between cursor-pointer">
<span className="text-sm"></span>
<input type="checkbox" defaultChecked className="cursor-pointer" />
</label>
<label className="flex items-center justify-between cursor-pointer">
<span className="text-sm"></span>
<input type="checkbox" defaultChecked className="cursor-pointer" />
</label>
<label className="flex items-center justify-between cursor-pointer">
<span className="text-sm"></span>
<input type="checkbox" className="cursor-pointer" />
</label>
</div>
</div>
<div className="flex gap-3">
<button className="px-6 py-3 bg-gradient-to-r from-primary to-secondary rounded-xl font-medium hover:opacity-90 transition-opacity cursor-pointer">
</button>
<button
onClick={handleLogout}
className="px-6 py-3 glass-hover rounded-xl font-medium cursor-pointer flex items-center gap-2 text-red-400"
>
<LogOut className="w-5 h-5" />
退
</button>
</div>
</div>
)}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,184 @@
import {useState} from 'react'
import {Link, useNavigate} from 'react-router-dom'
import {Eye, EyeOff, Lock, Mail, Sparkles, User} from 'lucide-react'
import {authApi} from '../api/auth'
export default function RegisterPage() {
const [showPassword, setShowPassword] = useState(false)
const [showConfirmPassword, setShowConfirmPassword] = useState(false)
const [formData, setFormData] = useState({
username: '',
email: '',
password: '',
confirmPassword: '',
nickName: ''
})
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const navigate = useNavigate()
const handleRegister = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
if (formData.password !== formData.confirmPassword) {
setError('两次输入的密码不一致')
return
}
if (formData.password.length < 6) {
setError('密码长度至少为 6 位')
return
}
setLoading(true)
try {
await authApi.register({
username: formData.username,
password: formData.password,
email: formData.email,
nickName: formData.nickName || formData.username,
})
alert('注册成功!请登录')
navigate('/login')
} catch (err: any) {
setError(err || '注册失败,请重试')
} finally {
setLoading(false)
}
}
return (
<div className="min-h-screen bg-gradient-to-br from-[#0A0A0F] via-[#1A0B2E] to-[#0A0A0F] flex items-center justify-center px-4 py-12">
<div className="w-full max-w-md">
<Link to="/" className="flex items-center justify-center gap-2 mb-8">
<div className="w-10 h-10 bg-gradient-to-br from-primary to-secondary rounded-xl flex items-center justify-center">
<Sparkles className="w-6 h-6" />
</div>
<span className="text-2xl font-semibold bg-gradient-to-r from-primary to-secondary bg-clip-text text-transparent">
</span>
</Link>
<div className="glass rounded-3xl p-8">
<h1 className="text-2xl font-bold mb-2 text-center"></h1>
<p className="text-white/60 text-center mb-8"> AI </p>
{error && (
<div className="glass rounded-xl p-4 border border-red-500/50 bg-red-500/10 mb-4">
<p className="text-sm text-red-400">{error}</p>
</div>
)}
<form onSubmit={handleRegister} className="space-y-4">
<div>
<label className="text-sm text-white/80 mb-2 block"></label>
<div className="relative">
<User className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-white/40" />
<input
type="text"
value={formData.username}
onChange={(e) => setFormData({ ...formData, username: e.target.value })}
placeholder="选择一个用户名"
className="w-full pl-12 pr-4 py-3 glass rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-primary/50"
required
minLength={3}
/>
</div>
</div>
<div>
<label className="text-sm text-white/80 mb-2 block"></label>
<div className="relative">
<Mail className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-white/40" />
<input
type="email"
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
placeholder="your@email.com"
className="w-full pl-12 pr-4 py-3 glass rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-primary/50"
/>
</div>
</div>
<div>
<label className="text-sm text-white/80 mb-2 block"></label>
<div className="relative">
<Lock className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-white/40" />
<input
type={showPassword ? 'text' : 'password'}
value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
placeholder="至少 6 个字符"
className="w-full pl-12 pr-12 py-3 glass rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-primary/50"
required
minLength={6}
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-4 top-1/2 -translate-y-1/2 text-white/40 hover:text-white/60 cursor-pointer"
>
{showPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
</button>
</div>
</div>
<div>
<label className="text-sm text-white/80 mb-2 block"></label>
<div className="relative">
<Lock className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-white/40" />
<input
type={showConfirmPassword ? 'text' : 'password'}
value={formData.confirmPassword}
onChange={(e) => setFormData({ ...formData, confirmPassword: e.target.value })}
placeholder="再次输入密码"
className="w-full pl-12 pr-12 py-3 glass rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-primary/50"
required
/>
<button
type="button"
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
className="absolute right-4 top-1/2 -translate-y-1/2 text-white/40 hover:text-white/60 cursor-pointer"
>
{showConfirmPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
</button>
</div>
</div>
<label className="flex items-start gap-2 text-sm text-white/60 cursor-pointer">
<input type="checkbox" className="mt-1 cursor-pointer" required />
<span>
<Link to="/terms" className="text-primary hover:text-secondary transition-colors mx-1">
</Link>
<Link to="/privacy" className="text-primary hover:text-secondary transition-colors ml-1">
</Link>
</span>
</label>
<button
type="submit"
disabled={loading}
className="w-full py-3 bg-gradient-to-r from-primary to-secondary rounded-xl font-medium hover:opacity-90 transition-opacity cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? '注册中...' : '注册'}
</button>
</form>
<div className="mt-6 text-center text-sm text-white/60">
<Link to="/login" className="text-primary hover:text-secondary transition-colors ml-1 cursor-pointer">
</Link>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,20 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {
colors: {
primary: 'var(--color-primary)',
secondary: 'var(--color-secondary)',
cta: '#F97316',
},
fontFamily: {
sans: ['Inter', 'system-ui', 'sans-serif'],
},
},
},
plugins: [],
}

21
web-app/tsconfig.json Normal file
View File

@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

6
web-app/vite.config.ts Normal file
View File

@@ -0,0 +1,6 @@
import {defineConfig} from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
})