首页 (page.tsx): - 集成 useConversations 和 useSettings hooks - 实现快捷操作创建新会话 - 添加加载状态处理 聊天页 (chat/[id]/page.tsx): - 集成 useStreamChat 实现流式对话 - 支持 AI 思考内容展示 - 优化消息发送和模型切换 - 添加错误处理和重试机制 设置页 (settings/page.tsx): - 重构为完整的设置管理界面 - 支持 API 配置(URL、密钥) - 支持默认模型和工具选择 - 支持主题和语言设置 - 添加设置保存和同步功能
626 lines
23 KiB
TypeScript
626 lines
23 KiB
TypeScript
'use client';
|
||
|
||
import { useState, useEffect } from 'react';
|
||
import Link from 'next/link';
|
||
import { ArrowLeft, Download, Check, Loader2, Eye, EyeOff, RotateCcw } from 'lucide-react';
|
||
import { Sidebar, SidebarToggle } from '@/components/layout/Sidebar';
|
||
import { Toggle } from '@/components/ui/Toggle';
|
||
import { cn } from '@/lib/utils';
|
||
import { currentUser, chatHistories } from '@/data/mock';
|
||
import { useSettings, useModels, useTools } from '@/hooks/useSettings';
|
||
|
||
// 默认系统提示词
|
||
const DEFAULT_SYSTEM_PROMPT = `你是一个专业、友好的 AI 助手。请遵循以下规则来回复用户:
|
||
|
||
## 回复风格
|
||
- 使用中文回复,除非用户明确要求其他语言
|
||
- 回复要详细、有深度,不要过于简短
|
||
- 语气友好、专业,像一个耐心的老师
|
||
|
||
## 格式规范
|
||
- 使用 Markdown 格式化回复
|
||
- 对于代码,使用代码块并标明语言,例如 \`\`\`python
|
||
- 使用标题(##、###)来组织长回复
|
||
- 使用列表(有序或无序)来列举要点
|
||
- 使用粗体或斜体强调重要内容
|
||
|
||
## 回答结构
|
||
当回答技术问题时,请按以下结构:
|
||
1. **简要概述**:先给出简洁的答案
|
||
2. **详细解释**:深入解释原理或概念
|
||
3. **代码示例**:如果适用,提供完整、可运行的代码
|
||
4. **注意事项**:指出常见错误或最佳实践
|
||
5. **延伸阅读**:如果有相关主题,可以简要提及
|
||
|
||
## 代码规范
|
||
- 代码要完整、可运行,不要省略关键部分
|
||
- 添加适当的注释解释关键逻辑
|
||
- 使用有意义的变量名
|
||
- 如果代码较长,先展示完整代码,再逐段解释
|
||
|
||
## 特别注意
|
||
- 如果问题不明确,先确认理解是否正确
|
||
- 如果有多种方案,说明各自的优缺点
|
||
- 承认不确定的地方,不要编造信息`;
|
||
|
||
export default function SettingsPage() {
|
||
const [sidebarOpen, setSidebarOpen] = useState(true);
|
||
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 [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');
|
||
|
||
// 当设置加载完成后,更新本地状态
|
||
useEffect(() => {
|
||
if (settings) {
|
||
setCchUrl(settings.cchUrl || '');
|
||
setSystemPrompt(settings.systemPrompt || '');
|
||
setTemperature(settings.temperature || '0.7');
|
||
}
|
||
}, [settings]);
|
||
|
||
// 保存 CCH 配置
|
||
const handleSaveCchConfig = async () => {
|
||
setSaveStatus('saving');
|
||
try {
|
||
const updates: Record<string, string> = { cchUrl };
|
||
if (cchApiKey) {
|
||
updates.cchApiKey = cchApiKey;
|
||
}
|
||
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 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);
|
||
};
|
||
|
||
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>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="flex min-h-screen">
|
||
{/* 侧边栏 */}
|
||
<Sidebar
|
||
user={currentUser}
|
||
chatHistories={chatHistories}
|
||
isOpen={sidebarOpen}
|
||
onToggle={() => setSidebarOpen(!sidebarOpen)}
|
||
/>
|
||
|
||
{/* 主内容区 */}
|
||
<main
|
||
className={cn(
|
||
'flex-1 min-h-screen transition-all duration-300',
|
||
sidebarOpen ? 'ml-[var(--sidebar-width)]' : 'ml-0'
|
||
)}
|
||
>
|
||
{/* Header */}
|
||
<header className="h-[var(--header-height)] px-4 flex items-center">
|
||
<SidebarToggle onClick={() => setSidebarOpen(!sidebarOpen)} />
|
||
</header>
|
||
|
||
{/* Body */}
|
||
<div className="px-8 pb-8">
|
||
<div className="max-w-[800px] mx-auto">
|
||
{/* 返回链接 */}
|
||
<Link
|
||
href="/"
|
||
className="inline-flex items-center gap-2 text-[var(--color-text-secondary)] text-sm mb-4 hover:text-[var(--color-text-primary)] transition-colors"
|
||
>
|
||
<ArrowLeft size={16} />
|
||
<span>返回聊天</span>
|
||
</Link>
|
||
|
||
{/* 页面标题 */}
|
||
<div className="mb-8">
|
||
<h1 className="text-2xl font-semibold text-[var(--color-text-primary)] mb-2">
|
||
设置
|
||
</h1>
|
||
<p className="text-sm text-[var(--color-text-secondary)]">
|
||
管理您的账户设置和偏好
|
||
</p>
|
||
</div>
|
||
|
||
{/* CCH 配置 */}
|
||
<SettingsSection
|
||
title="CCH 服务配置"
|
||
description="配置 Claude Code Hub 服务连接"
|
||
>
|
||
<SettingsItem
|
||
label="CCH 服务地址"
|
||
description="Claude Code Hub 服务的 URL"
|
||
>
|
||
<input
|
||
type="text"
|
||
className="settings-input w-80"
|
||
value={cchUrl}
|
||
onChange={(e) => setCchUrl(e.target.value)}
|
||
placeholder="http://localhost:13500"
|
||
/>
|
||
</SettingsItem>
|
||
|
||
<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="配置默认模型和工具"
|
||
>
|
||
<SettingsItem
|
||
label="默认模型"
|
||
description="为新对话选择默认 AI 模型"
|
||
>
|
||
<select
|
||
className="settings-select"
|
||
value={settings?.defaultModel || ''}
|
||
onChange={(e) => handleModelChange(e.target.value)}
|
||
disabled={modelsLoading || saving}
|
||
>
|
||
{models.map((model) => (
|
||
<option key={model.modelId} value={model.modelId}>
|
||
{model.displayName}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</SettingsItem>
|
||
|
||
<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-lg 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="选择您喜欢的主题"
|
||
>
|
||
<select
|
||
className="settings-select"
|
||
value={settings?.theme || 'light'}
|
||
onChange={(e) => handleThemeChange(e.target.value)}
|
||
disabled={saving}
|
||
>
|
||
<option value="system">跟随系统</option>
|
||
<option value="light">浅色</option>
|
||
<option value="dark">深色</option>
|
||
</select>
|
||
</SettingsItem>
|
||
|
||
<SettingsItem
|
||
label="语言"
|
||
description="选择您的首选语言"
|
||
>
|
||
<select
|
||
className="settings-select"
|
||
value={settings?.language || 'zh-CN'}
|
||
onChange={(e) => handleLanguageChange(e.target.value)}
|
||
disabled={saving}
|
||
>
|
||
<option value="en">English</option>
|
||
<option value="zh-CN">简体中文</option>
|
||
<option value="zh-TW">繁體中文</option>
|
||
<option value="ja">日本語</option>
|
||
</select>
|
||
</SettingsItem>
|
||
</SettingsSection>
|
||
|
||
{/* 数据与隐私 */}
|
||
<SettingsSection
|
||
title="数据与隐私"
|
||
description="管理您的数据和隐私设置"
|
||
>
|
||
<SettingsItem
|
||
label="聊天历史"
|
||
description="保存您的对话记录"
|
||
>
|
||
<Toggle
|
||
checked={settings?.saveChatHistory || false}
|
||
onChange={handleChatHistoryToggle}
|
||
disabled={saving}
|
||
/>
|
||
</SettingsItem>
|
||
|
||
<SettingsItem
|
||
label="导出数据"
|
||
description="下载所有聊天历史"
|
||
>
|
||
<button className="btn-ghost inline-flex items-center gap-2">
|
||
<Download size={16} />
|
||
导出
|
||
</button>
|
||
</SettingsItem>
|
||
|
||
<SettingsItem
|
||
label="清除所有聊天"
|
||
description="删除所有对话历史"
|
||
>
|
||
<button className="btn-ghost text-red-600 hover:text-red-700">
|
||
清除
|
||
</button>
|
||
</SettingsItem>
|
||
</SettingsSection>
|
||
|
||
{/* 危险区域 */}
|
||
<SettingsSection
|
||
title="危险区域"
|
||
description="不可逆操作"
|
||
variant="danger"
|
||
>
|
||
<SettingsItem
|
||
label="删除账户"
|
||
description="永久删除您的账户和所有数据"
|
||
>
|
||
<button className="btn-danger">删除账户</button>
|
||
</SettingsItem>
|
||
</SettingsSection>
|
||
</div>
|
||
</div>
|
||
</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-white border rounded-xl mb-6 overflow-hidden',
|
||
variant === 'danger' ? 'border-red-200' : 'border-[var(--color-border)]'
|
||
)}
|
||
>
|
||
<div
|
||
className={cn(
|
||
'px-5 py-4 border-b',
|
||
variant === 'danger'
|
||
? 'bg-red-50 border-red-200'
|
||
: 'border-[var(--color-border-light)]'
|
||
)}
|
||
>
|
||
<h2
|
||
className={cn(
|
||
'text-base font-semibold',
|
||
variant === 'danger' ? 'text-red-600' : '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>
|
||
);
|
||
}
|