支持在 Claude 原生格式和 OpenAI 兼容格式之间切换: - 新增 api_format 数据库字段和迁移脚本 - 更新设置 Hook 类型定义 - 扩展设置 API 支持 apiFormat 读写 - 添加设置页面 API 格式选择 UI 组件
796 lines
30 KiB
TypeScript
796 lines
30 KiB
TypeScript
'use client';
|
||
|
||
import { useState, useEffect } from 'react';
|
||
import Link from 'next/link';
|
||
import { ArrowLeft, Download, Check, Loader2, Eye, EyeOff, RotateCcw, Moon, Sun, Sparkles, Trash2, AlertTriangle } from 'lucide-react';
|
||
import { Toggle } from '@/components/ui/Toggle';
|
||
import { ModelCardSelector } from '@/components/ui/ModelCardSelector';
|
||
import { FontSizePicker } from '@/components/ui/FontSizePicker';
|
||
import { ConfirmDialog } from '@/components/ui/ConfirmDialog';
|
||
import { cn } from '@/lib/utils';
|
||
import { useSettings, useModels, useTools } from '@/hooks/useSettings';
|
||
|
||
// 默认系统提示词
|
||
const DEFAULT_SYSTEM_PROMPT = `你是一个专业、友好的 AI 助手。请遵循以下规则来回复用户:
|
||
|
||
## 回复风格
|
||
- 使用中文回复,除非用户明确要求其他语言
|
||
- 回复要详细、有深度,不要过于简短
|
||
- 语气友好、专业,像一个耐心的老师
|
||
|
||
## 格式规范
|
||
- 使用 Markdown 格式化回复
|
||
- 对于代码,使用代码块并标明语言,例如 \`\`\`python
|
||
- 使用标题(##、###)来组织长回复
|
||
- 使用列表(有序或无序)来列举要点
|
||
- 使用粗体或斜体强调重要内容
|
||
|
||
## 回答结构
|
||
当回答技术问题时,请按以下结构:
|
||
1. **简要概述**:先给出简洁的答案
|
||
2. **详细解释**:深入解释原理或概念
|
||
3. **代码示例**:如果适用,提供完整、可运行的代码
|
||
4. **注意事项**:指出常见错误或最佳实践
|
||
5. **延伸阅读**:如果有相关主题,可以简要提及
|
||
|
||
## 代码规范
|
||
- 代码要完整、可运行,不要省略关键部分
|
||
- 添加适当的注释解释关键逻辑
|
||
- 使用有意义的变量名
|
||
- 如果代码较长,先展示完整代码,再逐段解释
|
||
|
||
## 特别注意
|
||
- 如果问题不明确,先确认理解是否正确
|
||
- 如果有多种方案,说明各自的优缺点
|
||
- 承认不确定的地方,不要编造信息`;
|
||
|
||
export default function SettingsPage() {
|
||
const { settings, loading, saving, updateSettings } = useSettings();
|
||
const { models, loading: modelsLoading } = useModels();
|
||
const { tools, loading: toolsLoading } = useTools();
|
||
|
||
// CCH 配置状态
|
||
const [cchUrl, setCchUrl] = useState('');
|
||
const [cchApiKey, setCchApiKey] = useState('');
|
||
const [apiFormat, setApiFormat] = useState<'claude' | 'openai'>('claude');
|
||
const [showApiKey, setShowApiKey] = useState(false);
|
||
const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle');
|
||
|
||
// AI 行为设置状态
|
||
const [systemPrompt, setSystemPrompt] = useState('');
|
||
const [temperature, setTemperature] = useState('0.7');
|
||
const [promptSaveStatus, setPromptSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle');
|
||
|
||
// 导出和清除状态
|
||
const [exportLoading, setExportLoading] = useState(false);
|
||
const [clearDialogOpen, setClearDialogOpen] = useState(false);
|
||
const [clearLoading, setClearLoading] = useState(false);
|
||
const [chatStats, setChatStats] = useState<{ conversationCount: number; messageCount: number } | null>(null);
|
||
|
||
// 当设置加载完成后,更新本地状态
|
||
useEffect(() => {
|
||
if (settings) {
|
||
setCchUrl(settings.cchUrl || '');
|
||
setApiFormat((settings.apiFormat as 'claude' | 'openai') || 'claude');
|
||
setSystemPrompt(settings.systemPrompt || '');
|
||
setTemperature(settings.temperature || '0.7');
|
||
}
|
||
}, [settings]);
|
||
|
||
// 保存 CCH 配置
|
||
const handleSaveCchConfig = async () => {
|
||
setSaveStatus('saving');
|
||
try {
|
||
const updates: Record<string, string> = {};
|
||
if (cchUrl) updates.cchUrl = cchUrl;
|
||
if (cchApiKey) updates.cchApiKey = cchApiKey;
|
||
updates.apiFormat = apiFormat;
|
||
|
||
if (Object.keys(updates).length > 0) {
|
||
await updateSettings(updates);
|
||
}
|
||
setSaveStatus('saved');
|
||
setCchApiKey(''); // 清除输入的 API Key
|
||
setTimeout(() => setSaveStatus('idle'), 2000);
|
||
} catch {
|
||
setSaveStatus('error');
|
||
setTimeout(() => setSaveStatus('idle'), 2000);
|
||
}
|
||
};
|
||
|
||
// 清除 API Key
|
||
const handleClearApiKey = async () => {
|
||
try {
|
||
await updateSettings({ cchApiKey: '' });
|
||
} catch (error) {
|
||
console.error('Failed to clear API key:', error);
|
||
}
|
||
};
|
||
|
||
// 更新默认模型
|
||
const handleModelChange = async (modelId: string) => {
|
||
try {
|
||
await updateSettings({ defaultModel: modelId });
|
||
} catch (error) {
|
||
console.error('Failed to update model:', error);
|
||
}
|
||
};
|
||
|
||
// 更新字体大小
|
||
const handleFontSizeChange = async (fontSize: number) => {
|
||
try {
|
||
await updateSettings({ fontSize });
|
||
// 实时应用到页面
|
||
document.documentElement.style.setProperty('--font-size-base', `${fontSize}px`);
|
||
} catch (error) {
|
||
console.error('Failed to update font size:', error);
|
||
}
|
||
};
|
||
|
||
// 切换工具
|
||
const handleToolToggle = async (toolId: string) => {
|
||
const currentTools = settings?.defaultTools || [];
|
||
const newTools = currentTools.includes(toolId)
|
||
? currentTools.filter((t) => t !== toolId)
|
||
: [...currentTools, toolId];
|
||
try {
|
||
await updateSettings({ defaultTools: newTools });
|
||
} catch (error) {
|
||
console.error('Failed to update tools:', error);
|
||
}
|
||
};
|
||
|
||
// 切换思考模式
|
||
const handleThinkingToggle = async () => {
|
||
try {
|
||
await updateSettings({ enableThinking: !settings?.enableThinking });
|
||
} catch (error) {
|
||
console.error('Failed to toggle thinking:', error);
|
||
}
|
||
};
|
||
|
||
// 切换聊天历史
|
||
const handleChatHistoryToggle = async () => {
|
||
try {
|
||
await updateSettings({ saveChatHistory: !settings?.saveChatHistory });
|
||
} catch (error) {
|
||
console.error('Failed to toggle chat history:', error);
|
||
}
|
||
};
|
||
|
||
// 更新主题
|
||
const handleThemeChange = async (theme: string) => {
|
||
try {
|
||
await updateSettings({ theme });
|
||
} catch (error) {
|
||
console.error('Failed to update theme:', error);
|
||
}
|
||
};
|
||
|
||
// 更新语言
|
||
const handleLanguageChange = async (language: string) => {
|
||
try {
|
||
await updateSettings({ language });
|
||
} catch (error) {
|
||
console.error('Failed to update language:', error);
|
||
}
|
||
};
|
||
|
||
// 保存 System Prompt 和温度设置
|
||
const handleSavePromptSettings = async () => {
|
||
setPromptSaveStatus('saving');
|
||
try {
|
||
await updateSettings({ systemPrompt, temperature });
|
||
setPromptSaveStatus('saved');
|
||
setTimeout(() => setPromptSaveStatus('idle'), 2000);
|
||
} catch {
|
||
setPromptSaveStatus('error');
|
||
setTimeout(() => setPromptSaveStatus('idle'), 2000);
|
||
}
|
||
};
|
||
|
||
// 重置为默认 System Prompt
|
||
const handleResetSystemPrompt = () => {
|
||
setSystemPrompt(DEFAULT_SYSTEM_PROMPT);
|
||
};
|
||
|
||
// 更新温度(实时显示但不立即保存)
|
||
const handleTemperatureChange = (value: string) => {
|
||
setTemperature(value);
|
||
};
|
||
|
||
// 切换主题
|
||
const handleToggleTheme = async () => {
|
||
const newTheme = settings?.theme === 'dark' ? 'light' : 'dark';
|
||
try {
|
||
await updateSettings({ theme: newTheme });
|
||
document.documentElement.setAttribute('data-theme', newTheme);
|
||
} catch (error) {
|
||
console.error('Failed to update theme:', error);
|
||
}
|
||
};
|
||
|
||
// 导出聊天数据
|
||
const handleExportData = async () => {
|
||
setExportLoading(true);
|
||
try {
|
||
const response = await fetch('/api/conversations/export');
|
||
if (!response.ok) {
|
||
throw new Error('Export failed');
|
||
}
|
||
const data = await response.json();
|
||
|
||
// 创建 Blob 并下载
|
||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
const date = new Date().toISOString().split('T')[0];
|
||
a.download = `lioncode-chat-export-${date}.json`;
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
document.body.removeChild(a);
|
||
URL.revokeObjectURL(url);
|
||
} catch (error) {
|
||
console.error('Failed to export data:', error);
|
||
alert('导出失败,请稍后重试');
|
||
} finally {
|
||
setExportLoading(false);
|
||
}
|
||
};
|
||
|
||
// 打开清除对话框(获取统计信息)
|
||
const handleOpenClearDialog = async () => {
|
||
try {
|
||
const response = await fetch('/api/conversations/all');
|
||
if (response.ok) {
|
||
const stats = await response.json();
|
||
setChatStats(stats);
|
||
}
|
||
} catch (error) {
|
||
console.error('Failed to get stats:', error);
|
||
}
|
||
setClearDialogOpen(true);
|
||
};
|
||
|
||
// 清除所有聊天
|
||
const handleClearAllChats = async () => {
|
||
setClearLoading(true);
|
||
try {
|
||
const response = await fetch('/api/conversations/all', {
|
||
method: 'DELETE',
|
||
});
|
||
if (!response.ok) {
|
||
throw new Error('Delete failed');
|
||
}
|
||
setClearDialogOpen(false);
|
||
// 跳转到首页
|
||
window.location.href = '/';
|
||
} catch (error) {
|
||
console.error('Failed to clear chats:', error);
|
||
alert('清除失败,请稍后重试');
|
||
} finally {
|
||
setClearLoading(false);
|
||
}
|
||
};
|
||
|
||
if (loading) {
|
||
return (
|
||
<div className="flex min-h-screen items-center justify-center">
|
||
<Loader2 className="h-8 w-8 animate-spin text-[var(--color-primary)]" />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const isDarkMode = settings?.theme === 'dark';
|
||
|
||
return (
|
||
<div className="min-h-screen bg-[var(--color-bg-secondary)]">
|
||
{/* 顶部导航栏 */}
|
||
<header className="sticky top-0 z-10 bg-[var(--color-bg-primary)] border-b border-[var(--color-border-light)]">
|
||
<div className="max-w-[900px] mx-auto px-6 h-16 flex items-center justify-between">
|
||
{/* 左侧:返回按钮和标题 */}
|
||
<div className="flex items-center gap-4">
|
||
<Link
|
||
href="/"
|
||
className="p-2 -ml-2 rounded-lg text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-bg-hover)] transition-colors"
|
||
title="返回聊天"
|
||
>
|
||
<ArrowLeft size={20} />
|
||
</Link>
|
||
<div className="flex items-center gap-2">
|
||
<Sparkles size={24} className="text-[var(--color-primary)]" />
|
||
<h1 className="text-xl font-semibold text-[var(--color-text-primary)]">Settings</h1>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 右侧:主题切换 */}
|
||
<button
|
||
onClick={handleToggleTheme}
|
||
className="p-2 rounded-lg text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-bg-hover)] transition-colors"
|
||
title={isDarkMode ? '切换到浅色模式' : '切换到深色模式'}
|
||
>
|
||
{isDarkMode ? <Sun size={20} /> : <Moon size={20} />}
|
||
</button>
|
||
</div>
|
||
</header>
|
||
|
||
{/* 主内容区 */}
|
||
<main className="max-w-[900px] mx-auto px-6 py-8">
|
||
{/* CCH 配置 */}
|
||
<SettingsSection
|
||
title="CCH 服务配置"
|
||
description="配置 Claude Code Hub 服务连接"
|
||
>
|
||
{/* 服务地址配置 */}
|
||
<SettingsItem
|
||
label="服务地址"
|
||
description="配置 CCH 服务的访问地址"
|
||
>
|
||
<input
|
||
type="text"
|
||
className="settings-input w-80"
|
||
value={cchUrl}
|
||
onChange={(e) => setCchUrl(e.target.value)}
|
||
placeholder="https://claude.leocoder.cn/"
|
||
/>
|
||
</SettingsItem>
|
||
|
||
{/* API 格式选择 */}
|
||
<div className="px-5 py-4 border-b border-[var(--color-border-light)]">
|
||
<div className="text-sm font-medium text-[var(--color-text-primary)] mb-1">
|
||
API 格式
|
||
</div>
|
||
<div className="text-xs text-[var(--color-text-tertiary)] mb-4">
|
||
选择中转站的 API 接口格式
|
||
</div>
|
||
<div className="flex gap-3">
|
||
{/* Claude 原生选项 */}
|
||
<button
|
||
type="button"
|
||
onClick={() => setApiFormat('claude')}
|
||
className={cn(
|
||
'flex-1 p-4 rounded-lg border-2 text-left transition-all',
|
||
apiFormat === 'claude'
|
||
? 'border-[var(--color-primary)] bg-[var(--color-primary)]/5'
|
||
: 'border-[var(--color-border)] hover:border-[var(--color-border-dark)] bg-[var(--color-bg-primary)]'
|
||
)}
|
||
>
|
||
<div className="flex items-center gap-2 mb-1">
|
||
<div className={cn(
|
||
'w-4 h-4 rounded-full border-2 flex items-center justify-center',
|
||
apiFormat === 'claude'
|
||
? 'border-[var(--color-primary)]'
|
||
: 'border-[var(--color-border)]'
|
||
)}>
|
||
{apiFormat === 'claude' && (
|
||
<div className="w-2 h-2 rounded-full bg-[var(--color-primary)]" />
|
||
)}
|
||
</div>
|
||
<span className={cn(
|
||
'font-medium',
|
||
apiFormat === 'claude'
|
||
? 'text-[var(--color-primary)]'
|
||
: 'text-[var(--color-text-primary)]'
|
||
)}>
|
||
Claude 原生
|
||
</span>
|
||
</div>
|
||
</button>
|
||
|
||
{/* OpenAI 兼容选项 */}
|
||
<button
|
||
type="button"
|
||
onClick={() => setApiFormat('openai')}
|
||
className={cn(
|
||
'flex-1 p-4 rounded-lg border-2 text-left transition-all',
|
||
apiFormat === 'openai'
|
||
? 'border-[var(--color-primary)] bg-[var(--color-primary)]/5'
|
||
: 'border-[var(--color-border)] hover:border-[var(--color-border-dark)] bg-[var(--color-bg-primary)]'
|
||
)}
|
||
>
|
||
<div className="flex items-center gap-2 mb-1">
|
||
<div className={cn(
|
||
'w-4 h-4 rounded-full border-2 flex items-center justify-center',
|
||
apiFormat === 'openai'
|
||
? 'border-[var(--color-primary)]'
|
||
: 'border-[var(--color-border)]'
|
||
)}>
|
||
{apiFormat === 'openai' && (
|
||
<div className="w-2 h-2 rounded-full bg-[var(--color-primary)]" />
|
||
)}
|
||
</div>
|
||
<span className={cn(
|
||
'font-medium',
|
||
apiFormat === 'openai'
|
||
? 'text-[var(--color-primary)]'
|
||
: 'text-[var(--color-text-primary)]'
|
||
)}>
|
||
OpenAI 兼容
|
||
</span>
|
||
</div>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<SettingsItem
|
||
label="API Key"
|
||
description={
|
||
settings?.cchApiKeyConfigured
|
||
? '已配置 API Key'
|
||
: '设置访问 CCH 服务的 API Key'
|
||
}
|
||
>
|
||
<div className="flex items-center gap-2">
|
||
{settings?.cchApiKeyConfigured ? (
|
||
<>
|
||
<span className="inline-flex items-center gap-1 text-sm text-green-600">
|
||
<Check size={14} />
|
||
已配置
|
||
</span>
|
||
<button
|
||
onClick={handleClearApiKey}
|
||
className="btn-ghost text-red-600 hover:text-red-700 text-sm"
|
||
disabled={saving}
|
||
>
|
||
清除
|
||
</button>
|
||
</>
|
||
) : (
|
||
<div className="relative">
|
||
<input
|
||
type={showApiKey ? 'text' : 'password'}
|
||
className="settings-input pr-10 w-80"
|
||
value={cchApiKey}
|
||
onChange={(e) => setCchApiKey(e.target.value)}
|
||
placeholder="输入 API Key"
|
||
/>
|
||
<button
|
||
type="button"
|
||
className="absolute right-3 top-1/2 -translate-y-1/2 text-[var(--color-text-tertiary)] hover:text-[var(--color-text-primary)]"
|
||
onClick={() => setShowApiKey(!showApiKey)}
|
||
>
|
||
{showApiKey ? <EyeOff size={16} /> : <Eye size={16} />}
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</SettingsItem>
|
||
|
||
<div className="px-5 py-4 border-t border-[var(--color-border-light)]">
|
||
<button
|
||
onClick={handleSaveCchConfig}
|
||
disabled={saveStatus === 'saving'}
|
||
className="btn-primary inline-flex items-center gap-2"
|
||
>
|
||
{saveStatus === 'saving' ? (
|
||
<Loader2 size={16} className="animate-spin" />
|
||
) : saveStatus === 'saved' ? (
|
||
<Check size={16} />
|
||
) : null}
|
||
{saveStatus === 'saving' ? '保存中...' : saveStatus === 'saved' ? '已保存' : '保存配置'}
|
||
</button>
|
||
{saveStatus === 'error' && (
|
||
<span className="ml-3 text-sm text-red-600">保存失败</span>
|
||
)}
|
||
</div>
|
||
</SettingsSection>
|
||
|
||
{/* 模型和工具设置 */}
|
||
<SettingsSection
|
||
title="AI 配置"
|
||
description="配置默认模型和工具"
|
||
>
|
||
{/* 模型卡片选择 */}
|
||
<div className="px-5 py-4 border-b border-[var(--color-border-light)]">
|
||
<div className="text-sm font-medium text-[var(--color-text-primary)] mb-2">
|
||
默认模型
|
||
</div>
|
||
<div className="text-xs text-[var(--color-text-tertiary)] mb-4">
|
||
为新对话选择默认 AI 模型
|
||
</div>
|
||
<ModelCardSelector
|
||
value={settings?.defaultModel || ''}
|
||
onChange={handleModelChange}
|
||
disabled={modelsLoading || saving}
|
||
models={models}
|
||
/>
|
||
</div>
|
||
|
||
{/* 字体大小设置 */}
|
||
<div className="px-5 py-4 border-b border-[var(--color-border-light)]">
|
||
<div className="text-sm font-medium text-[var(--color-text-primary)] mb-2">
|
||
字体大小
|
||
</div>
|
||
<div className="text-xs text-[var(--color-text-tertiary)] mb-4">
|
||
调整全局字体大小,实时预览效果
|
||
</div>
|
||
<FontSizePicker
|
||
value={settings?.fontSize || 15}
|
||
onChange={handleFontSizeChange}
|
||
disabled={saving}
|
||
/>
|
||
</div>
|
||
|
||
<SettingsItem
|
||
label="启用思考模式"
|
||
description="让 AI 在回答前展示思考过程"
|
||
>
|
||
<Toggle
|
||
checked={settings?.enableThinking || false}
|
||
onChange={handleThinkingToggle}
|
||
disabled={saving}
|
||
/>
|
||
</SettingsItem>
|
||
|
||
<div className="px-5 py-4 border-b border-[var(--color-border-light)]">
|
||
<div className="text-sm font-medium text-[var(--color-text-primary)] mb-3">
|
||
默认工具
|
||
</div>
|
||
<div className="text-xs text-[var(--color-text-tertiary)] mb-4">
|
||
选择新对话默认启用的工具
|
||
</div>
|
||
<div className="flex flex-wrap gap-2">
|
||
{toolsLoading ? (
|
||
<span className="text-sm text-[var(--color-text-tertiary)]">加载中...</span>
|
||
) : (
|
||
tools.map((tool) => (
|
||
<button
|
||
key={tool.toolId}
|
||
onClick={() => handleToolToggle(tool.toolId)}
|
||
disabled={saving}
|
||
className={cn(
|
||
'inline-flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-all',
|
||
settings?.defaultTools?.includes(tool.toolId)
|
||
? 'bg-[var(--color-primary)] text-white'
|
||
: 'bg-[var(--color-bg-tertiary)] text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-secondary)]'
|
||
)}
|
||
>
|
||
{settings?.defaultTools?.includes(tool.toolId) && (
|
||
<Check size={14} />
|
||
)}
|
||
{tool.displayName}
|
||
</button>
|
||
))
|
||
)}
|
||
</div>
|
||
</div>
|
||
</SettingsSection>
|
||
|
||
{/* AI 行为设置 */}
|
||
<SettingsSection
|
||
title="AI 行为设置"
|
||
description="自定义 AI 的回复风格和行为"
|
||
>
|
||
{/* 温度参数 */}
|
||
<div className="px-5 py-4 border-b border-[var(--color-border-light)]">
|
||
<div className="flex justify-between items-center mb-3">
|
||
<div>
|
||
<div className="text-sm font-medium text-[var(--color-text-primary)]">
|
||
温度参数 (Temperature)
|
||
</div>
|
||
<div className="text-xs text-[var(--color-text-tertiary)] mt-1">
|
||
控制回复的创造性和随机性。较低值更保守精确,较高值更创新多样。
|
||
</div>
|
||
</div>
|
||
<div className="text-lg font-mono font-semibold text-[var(--color-primary)]">
|
||
{temperature}
|
||
</div>
|
||
</div>
|
||
<div className="flex items-center gap-4">
|
||
<span className="text-xs text-[var(--color-text-tertiary)]">精确</span>
|
||
<input
|
||
type="range"
|
||
min="0"
|
||
max="1"
|
||
step="0.1"
|
||
value={temperature}
|
||
onChange={(e) => handleTemperatureChange(e.target.value)}
|
||
className="flex-1 h-2 bg-[var(--color-bg-tertiary)] rounded-lg appearance-none cursor-pointer accent-[var(--color-primary)]"
|
||
/>
|
||
<span className="text-xs text-[var(--color-text-tertiary)]">创意</span>
|
||
</div>
|
||
</div>
|
||
|
||
{/* System Prompt 编辑器 */}
|
||
<div className="px-5 py-4 border-b border-[var(--color-border-light)]">
|
||
<div className="flex justify-between items-start mb-3">
|
||
<div>
|
||
<div className="text-sm font-medium text-[var(--color-text-primary)]">
|
||
系统提示词 (System Prompt)
|
||
</div>
|
||
<div className="text-xs text-[var(--color-text-tertiary)] mt-1">
|
||
定义 AI 的角色、回复风格和行为规范。留空将使用默认提示词。
|
||
</div>
|
||
</div>
|
||
<button
|
||
onClick={handleResetSystemPrompt}
|
||
className="btn-ghost text-sm inline-flex items-center gap-1"
|
||
title="重置为默认提示词"
|
||
>
|
||
<RotateCcw size={14} />
|
||
重置默认
|
||
</button>
|
||
</div>
|
||
<textarea
|
||
value={systemPrompt}
|
||
onChange={(e) => setSystemPrompt(e.target.value)}
|
||
placeholder="留空使用默认提示词..."
|
||
rows={12}
|
||
className="w-full px-3 py-2 text-sm border border-[var(--color-border)] rounded-lg bg-[var(--color-bg-primary)] text-[var(--color-text-primary)] placeholder-[var(--color-text-tertiary)] focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] focus:border-transparent resize-y font-mono"
|
||
/>
|
||
<div className="mt-2 text-xs text-[var(--color-text-tertiary)]">
|
||
提示:使用 Markdown 格式可以让提示词更有结构。当前字符数:{systemPrompt.length}
|
||
</div>
|
||
</div>
|
||
|
||
{/* 保存按钮 */}
|
||
<div className="px-5 py-4">
|
||
<button
|
||
onClick={handleSavePromptSettings}
|
||
disabled={promptSaveStatus === 'saving'}
|
||
className="btn-primary inline-flex items-center gap-2"
|
||
>
|
||
{promptSaveStatus === 'saving' ? (
|
||
<Loader2 size={16} className="animate-spin" />
|
||
) : promptSaveStatus === 'saved' ? (
|
||
<Check size={16} />
|
||
) : null}
|
||
{promptSaveStatus === 'saving' ? '保存中...' : promptSaveStatus === 'saved' ? '已保存' : '保存 AI 设置'}
|
||
</button>
|
||
{promptSaveStatus === 'error' && (
|
||
<span className="ml-3 text-sm text-red-600">保存失败</span>
|
||
)}
|
||
</div>
|
||
</SettingsSection>
|
||
|
||
{/* 数据与隐私 */}
|
||
<SettingsSection
|
||
title="数据与隐私"
|
||
description="管理您的数据和隐私设置"
|
||
>
|
||
<SettingsItem
|
||
label="聊天历史"
|
||
description="保存您的对话记录"
|
||
>
|
||
<Toggle
|
||
checked={settings?.saveChatHistory || false}
|
||
onChange={handleChatHistoryToggle}
|
||
disabled={saving}
|
||
/>
|
||
</SettingsItem>
|
||
|
||
<SettingsItem
|
||
label="导出数据"
|
||
description="下载所有聊天历史"
|
||
>
|
||
<button
|
||
onClick={handleExportData}
|
||
disabled={exportLoading}
|
||
className="btn-ghost inline-flex items-center gap-2"
|
||
>
|
||
{exportLoading ? (
|
||
<Loader2 size={16} className="animate-spin" />
|
||
) : (
|
||
<Download size={16} />
|
||
)}
|
||
{exportLoading ? '导出中...' : '导出'}
|
||
</button>
|
||
</SettingsItem>
|
||
|
||
<SettingsItem
|
||
label="清除所有聊天"
|
||
description="删除所有对话历史"
|
||
>
|
||
<button
|
||
onClick={handleOpenClearDialog}
|
||
className="btn-ghost text-red-600 hover:text-red-700 inline-flex items-center gap-2"
|
||
>
|
||
<Trash2 size={16} />
|
||
清除
|
||
</button>
|
||
</SettingsItem>
|
||
</SettingsSection>
|
||
|
||
{/* 清除确认对话框 */}
|
||
<ConfirmDialog
|
||
isOpen={clearDialogOpen}
|
||
onClose={() => setClearDialogOpen(false)}
|
||
onConfirm={handleClearAllChats}
|
||
title="确认清除所有聊天记录?"
|
||
variant="danger"
|
||
confirmText="确认清除"
|
||
cancelText="取消"
|
||
loading={clearLoading}
|
||
>
|
||
<div className="space-y-3">
|
||
<p className="text-sm text-[var(--color-text-secondary)]">
|
||
此操作将永久删除:
|
||
</p>
|
||
<ul className="text-sm text-[var(--color-text-primary)] space-y-1 ml-4">
|
||
<li className="flex items-center gap-2">
|
||
<span className="w-1.5 h-1.5 rounded-full bg-red-500" />
|
||
<span><strong>{chatStats?.conversationCount || 0}</strong> 个对话</span>
|
||
</li>
|
||
<li className="flex items-center gap-2">
|
||
<span className="w-1.5 h-1.5 rounded-full bg-red-500" />
|
||
<span><strong>{chatStats?.messageCount || 0}</strong> 条消息</span>
|
||
</li>
|
||
</ul>
|
||
<div className="flex items-start gap-2 p-3 rounded-md bg-red-500/10 border border-red-500/20">
|
||
<AlertTriangle className="w-4 h-4 text-red-500 mt-0.5 flex-shrink-0" />
|
||
<p className="text-xs text-red-500">
|
||
此操作不可撤销!建议先导出数据备份。
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</ConfirmDialog>
|
||
</main>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// 设置区域组件
|
||
interface SettingsSectionProps {
|
||
title: string;
|
||
description: string;
|
||
children: React.ReactNode;
|
||
variant?: 'default' | 'danger';
|
||
}
|
||
|
||
function SettingsSection({ title, description, children, variant = 'default' }: SettingsSectionProps) {
|
||
return (
|
||
<section
|
||
className={cn(
|
||
'bg-[var(--color-bg-primary)] border rounded-md mb-6 overflow-hidden',
|
||
variant === 'danger' ? 'border-red-500/20' : 'border-[var(--color-border)]'
|
||
)}
|
||
>
|
||
<div
|
||
className={cn(
|
||
'px-5 py-4 border-b',
|
||
variant === 'danger'
|
||
? 'bg-red-500/10 border-red-500/20'
|
||
: 'border-[var(--color-border-light)]'
|
||
)}
|
||
>
|
||
<h2
|
||
className={cn(
|
||
'text-base font-semibold',
|
||
variant === 'danger' ? 'text-red-500' : 'text-[var(--color-text-primary)]'
|
||
)}
|
||
>
|
||
{title}
|
||
</h2>
|
||
<p className="text-sm text-[var(--color-text-secondary)] mt-1">
|
||
{description}
|
||
</p>
|
||
</div>
|
||
<div>{children}</div>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
// 设置项组件
|
||
interface SettingsItemProps {
|
||
label: string;
|
||
description: React.ReactNode;
|
||
children: React.ReactNode;
|
||
}
|
||
|
||
function SettingsItem({ label, description, children }: SettingsItemProps) {
|
||
return (
|
||
<div className="flex justify-between items-center px-5 py-4 border-b border-[var(--color-border-light)] last:border-b-0 hover:bg-[var(--color-bg-tertiary)] transition-colors">
|
||
<div className="flex-1">
|
||
<div className="text-sm font-medium text-[var(--color-text-primary)] mb-1">
|
||
{label}
|
||
</div>
|
||
<div className="text-xs text-[var(--color-text-tertiary)]">
|
||
{description}
|
||
</div>
|
||
</div>
|
||
<div className="ml-4">{children}</div>
|
||
</div>
|
||
);
|
||
}
|