claude-code-cchui/src/app/settings/page.tsx
gaoziman 99ca472dd2 feat(设置): 添加 API 格式选择功能
支持在 Claude 原生格式和 OpenAI 兼容格式之间切换:
- 新增 api_format 数据库字段和迁移脚本
- 更新设置 Hook 类型定义
- 扩展设置 API 支持 apiFormat 读写
- 添加设置页面 API 格式选择 UI 组件
2025-12-21 21:14:41 +08:00

796 lines
30 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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>
);
}