Compare commits

..

No commits in common. "3b0683faf9b763a7e2b54af2e04446eb94beb66f" and "269fc798aa853f4489cd486535ae395edb1d5d84" have entirely different histories.

6 changed files with 79 additions and 241 deletions

View File

@ -122,19 +122,9 @@ export async function POST(request: NextRequest) {
}) })
.returning(); .returning();
// 创建默认用户设置API Key 为空,需要用户自行配置) // 创建默认用户设置
await db.insert(userSettings).values({ await db.insert(userSettings).values({
userId, userId,
cchUrl: process.env.CCH_DEFAULT_URL || 'https://claude.leocoder.cn/',
cchApiKey: null,
cchApiKeyConfigured: false,
defaultModel: 'claude-sonnet-4-20250514',
defaultTools: ['web_search', 'code_execution', 'web_fetch'],
theme: 'light',
language: 'zh-CN',
fontSize: 15,
enableThinking: false,
saveChatHistory: true,
}); });
// 生成 JWT Token // 生成 JWT Token

View File

@ -4,8 +4,6 @@ import { conversations, messages, userSettings } from '@/drizzle/schema';
import { eq } from 'drizzle-orm'; import { eq } from 'drizzle-orm';
import { nanoid } from 'nanoid'; import { nanoid } from 'nanoid';
import { executeTool } from '@/services/tools'; import { executeTool } from '@/services/tools';
import { getCurrentUser } from '@/lib/auth';
import { decryptApiKey } from '@/lib/crypto';
interface ChatRequest { interface ChatRequest {
conversationId: string; conversationId: string;
@ -214,30 +212,18 @@ export async function POST(request: Request) {
})) : undefined, })) : undefined,
}); });
// 获取当前登录用户 // 获取用户设置
const user = await getCurrentUser();
if (!user) {
return NextResponse.json(
{ error: '请先登录后再使用聊天功能' },
{ status: 401 }
);
}
// 获取该用户的设置
const settings = await db.query.userSettings.findFirst({ const settings = await db.query.userSettings.findFirst({
where: eq(userSettings.userId, user.userId), where: eq(userSettings.id, 1),
}); });
if (!settings?.cchApiKey) { if (!settings?.cchApiKey) {
return NextResponse.json( return NextResponse.json(
{ error: '请先在设置中配置您的 API Key 才能使用聊天功能', code: 'API_KEY_NOT_CONFIGURED' }, { error: 'Please configure your CCH API key in settings' },
{ status: 400 } { status: 400 }
); );
} }
// 解密 API Key
const decryptedApiKey = decryptApiKey(settings.cchApiKey);
// 获取对话信息 // 获取对话信息
const conversation = await db.query.conversations.findFirst({ const conversation = await db.query.conversations.findFirst({
where: eq(conversations.conversationId, conversationId), where: eq(conversations.conversationId, conversationId),
@ -281,7 +267,7 @@ export async function POST(request: Request) {
const stream = new ReadableStream({ const stream = new ReadableStream({
async start(controller) { async start(controller) {
try { try {
const cchUrl = settings.cchUrl || process.env.CCH_DEFAULT_URL || 'https://claude.leocoder.cn/'; const cchUrl = settings.cchUrl || 'http://localhost:13500';
// 获取系统提示词(叠加模式) // 获取系统提示词(叠加模式)
// 1. 始终使用 DEFAULT_SYSTEM_PROMPT 作为基础 // 1. 始终使用 DEFAULT_SYSTEM_PROMPT 作为基础
@ -316,7 +302,7 @@ export async function POST(request: Request) {
// ==================== Codex 模型处理OpenAI 格式) ==================== // ==================== Codex 模型处理OpenAI 格式) ====================
const result = await handleCodexChat({ const result = await handleCodexChat({
cchUrl, cchUrl,
apiKey: decryptedApiKey, apiKey: settings.cchApiKey!,
model: useModel, model: useModel,
systemPrompt, systemPrompt,
temperature, temperature,
@ -335,7 +321,7 @@ export async function POST(request: Request) {
// ==================== Claude 模型处理(原有逻辑) ==================== // ==================== Claude 模型处理(原有逻辑) ====================
const result = await handleClaudeChat({ const result = await handleClaudeChat({
cchUrl, cchUrl,
apiKey: decryptedApiKey, apiKey: settings.cchApiKey!,
model: useModel, model: useModel,
systemPrompt, systemPrompt,
temperature, temperature,

View File

@ -2,12 +2,18 @@ import { NextResponse } from 'next/server';
import { db } from '@/drizzle/db'; import { db } from '@/drizzle/db';
import { userSettings } from '@/drizzle/schema'; import { userSettings } from '@/drizzle/schema';
import { eq } from 'drizzle-orm'; import { eq } from 'drizzle-orm';
import { getCurrentUser } from '@/lib/auth';
import { encryptApiKey } from '@/lib/crypto';
// 默认设置值 // GET /api/settings - 获取用户设置
const DEFAULT_SETTINGS = { export async function GET() {
cchUrl: process.env.CCH_DEFAULT_URL || 'https://claude.leocoder.cn/', try {
const settings = await db.query.userSettings.findFirst({
where: eq(userSettings.id, 1),
});
if (!settings) {
// 如果没有设置,返回默认值
return NextResponse.json({
cchUrl: process.env.CCH_DEFAULT_URL || 'http://localhost:13500',
cchApiKeyConfigured: false, cchApiKeyConfigured: false,
defaultModel: 'claude-sonnet-4-20250514', defaultModel: 'claude-sonnet-4-20250514',
defaultTools: ['web_search', 'code_execution', 'web_fetch'], defaultTools: ['web_search', 'code_execution', 'web_fetch'],
@ -18,65 +24,23 @@ const DEFAULT_SETTINGS = {
fontSize: 15, fontSize: 15,
enableThinking: false, enableThinking: false,
saveChatHistory: true, saveChatHistory: true,
}; });
// 格式化设置响应(不返回 API Key 本身)
function formatSettingsResponse(settings: typeof userSettings.$inferSelect | null) {
if (!settings) {
return DEFAULT_SETTINGS;
} }
return { // 不返回 API Key 本身,只返回是否已配置
cchUrl: settings.cchUrl || DEFAULT_SETTINGS.cchUrl, return NextResponse.json({
cchApiKeyConfigured: settings.cchApiKeyConfigured || false, cchUrl: settings.cchUrl,
defaultModel: settings.defaultModel || DEFAULT_SETTINGS.defaultModel, cchApiKeyConfigured: settings.cchApiKeyConfigured,
defaultTools: settings.defaultTools || DEFAULT_SETTINGS.defaultTools, defaultModel: settings.defaultModel,
defaultTools: settings.defaultTools,
systemPrompt: settings.systemPrompt || '', systemPrompt: settings.systemPrompt || '',
temperature: settings.temperature || '0.7', temperature: settings.temperature || '0.7',
theme: settings.theme || DEFAULT_SETTINGS.theme, theme: settings.theme,
language: settings.language || DEFAULT_SETTINGS.language, language: settings.language,
fontSize: settings.fontSize || 15, fontSize: settings.fontSize || 15,
enableThinking: settings.enableThinking || false, enableThinking: settings.enableThinking,
saveChatHistory: settings.saveChatHistory ?? true, saveChatHistory: settings.saveChatHistory,
};
}
// GET /api/settings - 获取当前用户的设置
export async function GET() {
try {
// 1. 获取当前登录用户
const user = await getCurrentUser();
if (!user) {
// 未登录用户返回默认设置(允许查看但无法保存)
return NextResponse.json(DEFAULT_SETTINGS);
}
// 2. 查询该用户的设置
const settings = await db.query.userSettings.findFirst({
where: eq(userSettings.userId, user.userId),
}); });
// 3. 如果没有设置记录,创建默认设置
if (!settings) {
const newSettings = await db
.insert(userSettings)
.values({
userId: user.userId,
cchUrl: DEFAULT_SETTINGS.cchUrl,
defaultModel: DEFAULT_SETTINGS.defaultModel,
defaultTools: DEFAULT_SETTINGS.defaultTools,
theme: DEFAULT_SETTINGS.theme,
language: DEFAULT_SETTINGS.language,
fontSize: DEFAULT_SETTINGS.fontSize,
enableThinking: DEFAULT_SETTINGS.enableThinking,
saveChatHistory: DEFAULT_SETTINGS.saveChatHistory,
})
.returning();
return NextResponse.json(formatSettingsResponse(newSettings[0]));
}
return NextResponse.json(formatSettingsResponse(settings));
} catch (error) { } catch (error) {
console.error('Failed to get settings:', error); console.error('Failed to get settings:', error);
return NextResponse.json( return NextResponse.json(
@ -86,18 +50,9 @@ export async function GET() {
} }
} }
// PUT /api/settings - 更新当前用户设置 // PUT /api/settings - 更新用户设置
export async function PUT(request: Request) { export async function PUT(request: Request) {
try { try {
// 1. 获取当前登录用户
const user = await getCurrentUser();
if (!user) {
return NextResponse.json(
{ error: '请先登录后再修改设置' },
{ status: 401 }
);
}
const body = await request.json(); const body = await request.json();
const { const {
cchUrl, cchUrl,
@ -113,7 +68,7 @@ export async function PUT(request: Request) {
saveChatHistory, saveChatHistory,
} = body; } = body;
// 2. 构建更新对象 // 构建更新对象
const updateData: Record<string, unknown> = { const updateData: Record<string, unknown> = {
updatedAt: new Date(), updatedAt: new Date(),
}; };
@ -122,15 +77,14 @@ export async function PUT(request: Request) {
updateData.cchUrl = cchUrl; updateData.cchUrl = cchUrl;
} }
// 如果提供了 API Key加密后存储 // 如果提供了 API Key更新它
if (cchApiKey !== undefined) { if (cchApiKey !== undefined) {
if (cchApiKey === '') { if (cchApiKey === '') {
// 清除 API Key // 清除 API Key
updateData.cchApiKey = null; updateData.cchApiKey = null;
updateData.cchApiKeyConfigured = false; updateData.cchApiKeyConfigured = false;
} else { } else {
// 加密存储 API Key updateData.cchApiKey = cchApiKey;
updateData.cchApiKey = encryptApiKey(cchApiKey);
updateData.cchApiKeyConfigured = true; updateData.cchApiKeyConfigured = true;
} }
} }
@ -172,23 +126,15 @@ export async function PUT(request: Request) {
updateData.saveChatHistory = saveChatHistory; updateData.saveChatHistory = saveChatHistory;
} }
// 3. 检查是否存在该用户的设置记录 // 检查是否存在设置记录
const existing = await db.query.userSettings.findFirst({ const existing = await db.query.userSettings.findFirst({
where: eq(userSettings.userId, user.userId), where: eq(userSettings.id, 1),
}); });
if (!existing) { if (!existing) {
// 创建新的设置记录 // 创建新的设置记录
await db.insert(userSettings).values({ await db.insert(userSettings).values({
userId: user.userId, id: 1,
cchUrl: DEFAULT_SETTINGS.cchUrl,
defaultModel: DEFAULT_SETTINGS.defaultModel,
defaultTools: DEFAULT_SETTINGS.defaultTools,
theme: DEFAULT_SETTINGS.theme,
language: DEFAULT_SETTINGS.language,
fontSize: DEFAULT_SETTINGS.fontSize,
enableThinking: DEFAULT_SETTINGS.enableThinking,
saveChatHistory: DEFAULT_SETTINGS.saveChatHistory,
...updateData, ...updateData,
}); });
} else { } else {
@ -196,15 +142,27 @@ export async function PUT(request: Request) {
await db await db
.update(userSettings) .update(userSettings)
.set(updateData) .set(updateData)
.where(eq(userSettings.userId, user.userId)); .where(eq(userSettings.id, 1));
} }
// 4. 返回更新后的设置(不包含 API Key // 返回更新后的设置(不包含 API Key
const updatedSettings = await db.query.userSettings.findFirst({ const updatedSettings = await db.query.userSettings.findFirst({
where: eq(userSettings.userId, user.userId), where: eq(userSettings.id, 1),
}); });
return NextResponse.json(formatSettingsResponse(updatedSettings ?? null)); return NextResponse.json({
cchUrl: updatedSettings?.cchUrl,
cchApiKeyConfigured: updatedSettings?.cchApiKeyConfigured,
defaultModel: updatedSettings?.defaultModel,
defaultTools: updatedSettings?.defaultTools,
systemPrompt: updatedSettings?.systemPrompt || '',
temperature: updatedSettings?.temperature || '0.7',
theme: updatedSettings?.theme,
language: updatedSettings?.language,
fontSize: updatedSettings?.fontSize || 15,
enableThinking: updatedSettings?.enableThinking,
saveChatHistory: updatedSettings?.saveChatHistory,
});
} catch (error) { } catch (error) {
console.error('Failed to update settings:', error); console.error('Failed to update settings:', error);
return NextResponse.json( return NextResponse.json(

View File

@ -2,8 +2,7 @@
import { useState, useRef, useEffect, use } from 'react'; import { useState, useRef, useEffect, use } from 'react';
import { useRouter, useSearchParams } from 'next/navigation'; import { useRouter, useSearchParams } from 'next/navigation';
import Link from 'next/link'; import { Share2, MoreHorizontal, Loader2, Square, Clock, ChevronDown, Pencil, Trash2, Check, X } from 'lucide-react';
import { Share2, MoreHorizontal, Loader2, Square, Clock, ChevronDown, Pencil, Trash2, Check, X, AlertTriangle, Settings } from 'lucide-react';
import { Sidebar, SidebarToggle } from '@/components/layout/Sidebar'; import { Sidebar, SidebarToggle } from '@/components/layout/Sidebar';
import { ChatInput } from '@/components/features/ChatInput'; import { ChatInput } from '@/components/features/ChatInput';
import { MessageBubble } from '@/components/features/MessageBubble'; import { MessageBubble } from '@/components/features/MessageBubble';
@ -449,30 +448,6 @@ export default function ChatPage({ params }: PageProps) {
{/* 消息列表区域 */} {/* 消息列表区域 */}
<div className="flex-1 overflow-y-auto"> <div className="flex-1 overflow-y-auto">
<div className="max-w-[900px] mx-auto px-4 py-6"> <div className="max-w-[900px] mx-auto px-4 py-6">
{/* API Key 未配置提示 */}
{!settings?.cchApiKeyConfigured && (
<div className="mb-6 p-4 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg">
<div className="flex items-start gap-3">
<AlertTriangle className="w-5 h-5 text-amber-500 mt-0.5 flex-shrink-0" />
<div className="flex-1">
<h3 className="text-sm font-medium text-amber-800 dark:text-amber-200 mb-1">
API Key
</h3>
<p className="text-sm text-amber-700 dark:text-amber-300 mb-3">
API Key 使 AI
</p>
<Link
href="/settings"
className="inline-flex items-center gap-2 px-4 py-2 bg-amber-500 hover:bg-amber-600 text-white text-sm font-medium rounded-lg transition-colors"
>
<Settings size={16} />
</Link>
</div>
</div>
</div>
)}
{messages.length === 0 ? ( {messages.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20 text-center"> <div className="flex flex-col items-center justify-center py-20 text-center">
<h2 className="text-xl font-medium text-[var(--color-text-primary)] mb-2"> <h2 className="text-xl font-medium text-[var(--color-text-primary)] mb-2">

View File

@ -50,6 +50,7 @@ export default function SettingsPage() {
const { tools, loading: toolsLoading } = useTools(); const { tools, loading: toolsLoading } = useTools();
// CCH 配置状态 // CCH 配置状态
const [cchUrl, setCchUrl] = useState('');
const [cchApiKey, setCchApiKey] = useState(''); const [cchApiKey, setCchApiKey] = useState('');
const [showApiKey, setShowApiKey] = useState(false); const [showApiKey, setShowApiKey] = useState(false);
const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle'); const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle');
@ -68,6 +69,7 @@ export default function SettingsPage() {
// 当设置加载完成后,更新本地状态 // 当设置加载完成后,更新本地状态
useEffect(() => { useEffect(() => {
if (settings) { if (settings) {
setCchUrl(settings.cchUrl || '');
setSystemPrompt(settings.systemPrompt || ''); setSystemPrompt(settings.systemPrompt || '');
setTemperature(settings.temperature || '0.7'); setTemperature(settings.temperature || '0.7');
} }
@ -77,9 +79,11 @@ export default function SettingsPage() {
const handleSaveCchConfig = async () => { const handleSaveCchConfig = async () => {
setSaveStatus('saving'); setSaveStatus('saving');
try { try {
const updates: Record<string, string> = { cchUrl };
if (cchApiKey) { if (cchApiKey) {
await updateSettings({ cchApiKey }); updates.cchApiKey = cchApiKey;
} }
await updateSettings(updates);
setSaveStatus('saved'); setSaveStatus('saved');
setCchApiKey(''); // 清除输入的 API Key setCchApiKey(''); // 清除输入的 API Key
setTimeout(() => setSaveStatus('idle'), 2000); setTimeout(() => setSaveStatus('idle'), 2000);
@ -313,6 +317,19 @@ export default function SettingsPage() {
title="CCH 服务配置" title="CCH 服务配置"
description="配置 Claude Code Hub 服务连接" 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 <SettingsItem
label="API Key" label="API Key"
description={ description={

View File

@ -1,88 +0,0 @@
import crypto from 'crypto';
// 加密密钥32字节 = 256位用于 AES-256
// 生产环境应使用环境变量
const ENCRYPTION_KEY = process.env.ENCRYPTION_KEY || 'lioncode-encryption-key-2024-sec';
// 确保密钥长度为32字节
const getKey = (): Buffer => {
const key = ENCRYPTION_KEY;
// 使用 SHA-256 哈希确保密钥长度为32字节
return crypto.createHash('sha256').update(key).digest();
};
// IV 长度AES-256-GCM 推荐使用12字节
const IV_LENGTH = 12;
// Auth Tag 长度
const AUTH_TAG_LENGTH = 16;
/**
* API Key
* 使 AES-256-GCM
* @param plainText API Key
* @returns iv:authTag:encryptedDataBase64编码
*/
export function encryptApiKey(plainText: string): string {
if (!plainText) return '';
const key = getKey();
const iv = crypto.randomBytes(IV_LENGTH);
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
let encrypted = cipher.update(plainText, 'utf8', 'base64');
encrypted += cipher.final('base64');
const authTag = cipher.getAuthTag();
// 组合 IV + AuthTag + 加密数据,用冒号分隔
return `${iv.toString('base64')}:${authTag.toString('base64')}:${encrypted}`;
}
/**
* API Key
* @param encryptedText
* @returns API Key
*/
export function decryptApiKey(encryptedText: string): string {
if (!encryptedText) return '';
try {
const parts = encryptedText.split(':');
if (parts.length !== 3) {
// 兼容旧的明文存储(迁移期间)
console.warn('检测到未加密的 API Key返回原值');
return encryptedText;
}
const [ivBase64, authTagBase64, encrypted] = parts;
const key = getKey();
const iv = Buffer.from(ivBase64, 'base64');
const authTag = Buffer.from(authTagBase64, 'base64');
const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
decipher.setAuthTag(authTag);
let decrypted = decipher.update(encrypted, 'base64', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
} catch (error) {
console.error('API Key 解密失败:', error);
// 如果解密失败,可能是旧的明文数据,返回原值
return encryptedText;
}
}
/**
*
* @param text
* @returns
*/
export function isEncrypted(text: string): boolean {
if (!text) return false;
const parts = text.split(':');
return parts.length === 3;
}