Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 73 additions & 38 deletions web/frontend/src/app/settings/page.tsx
Original file line number Diff line number Diff line change
@@ -1,80 +1,116 @@
'use client';

import { useState } from 'react';
import { useEffect, useState } from 'react';
import { SiteLayout } from '@/components/site/SiteLayout';
import { useAuth } from '@/lib/auth';
import { useLocalLLMKeys } from '@/hooks/useLocalLLMKeys';

const PROVIDERS = [
{ key: 'openai', label: 'OpenAI', url: 'https://api.openai.com/v1', models: 'gpt-4o / gpt-4o-mini' },
{ key: 'deepseek', label: 'DeepSeek', url: 'https://api.deepseek.com/v1', models: 'deepseek-chat / deepseek-reasoner' },
{ key: 'openrouter', label: 'OpenRouter', url: 'https://openrouter.ai/api/v1', models: '多模型聚合' },
{ key: 'custom', label: '自定义兼容端点', url: '', models: '兼容 OpenAI 协议' },
];
import { CUSTOM_MODEL_TYPES, customModelConfig, CustomModelType } from '@/lib/customModelConfig';

export default function SettingsPage() {
const { user } = useAuth();
const { hasLocalKey, saveLocalKey, clearLocalKey } = useLocalLLMKeys();
const [drafts, setDrafts] = useState<Record<string, string>>({});
const [saved, setSaved] = useState<string | null>(null);
const [keyDrafts, setKeyDrafts] = useState<Record<string, string>>({});
const [urlDrafts, setUrlDrafts] = useState<Record<string, string>>({});
const [savedType, setSavedType] = useState<string | null>(null);

// 登录用户变化 / 首挂载时,用已保存的 Base URL(或默认值)填充表单
useEffect(() => {
if (!user) return;
setUrlDrafts((prev) => {
const next = { ...prev };
for (const t of CUSTOM_MODEL_TYPES) {
if (next[t.key] == null) {
next[t.key] = customModelConfig.getBaseUrl(user.id, t.key) || t.defaultBaseUrl;
}
}
return next;
});
}, [user]);
Comment on lines +16 to +28

const setDraft = (k: string, v: string) => setDrafts((p) => ({ ...p, [k]: v }));
const setKeyDraft = (k: string, v: string) => setKeyDrafts((p) => ({ ...p, [k]: v }));
const setUrlDraft = (k: string, v: string) => setUrlDrafts((p) => ({ ...p, [k]: v }));

const save = (type: CustomModelType) => {
if (!user) return;
const keyVal = keyDrafts[type]?.trim();
if (keyVal) saveLocalKey(type, keyVal);
customModelConfig.saveBaseUrl(user.id, type, urlDrafts[type] ?? '');
setKeyDrafts((p) => ({ ...p, [type]: '' }));
setSavedType(type);
setTimeout(() => setSavedType(null), 1800);
};

const save = (k: string) => {
const val = drafts[k]?.trim();
if (!val) return;
saveLocalKey(k, val);
setDrafts((p) => ({ ...p, [k]: '' }));
setSaved(k);
setTimeout(() => setSaved(null), 1800);
const clear = (type: CustomModelType) => {
if (!user) return;
clearLocalKey(type);
customModelConfig.clearBaseUrl(user.id, type);
setUrlDrafts((p) => ({ ...p, [type]: CUSTOM_MODEL_TYPES.find((t) => t.key === type)!.defaultBaseUrl }));
};

return (
<SiteLayout maxWidth="max-w-3xl">
<h1 className="h-serif text-2xl">自定义模型设置</h1>
<p className="mt-1 text-sm text-text-secondary">
配置自定义 LLM 接口与密钥。这些信息仅保存在你的浏览器本地,服务端不存储、不传输持久化。
配置自定义 LLM 接口与密钥,只按接口类型选择,不绑定具体提供商。这些信息仅保存在你的浏览器本地,服务端不存储、不传输持久化。
</p>

<div className="mt-4 flex items-start gap-2 rounded-lg border border-verdict-hold/30 bg-verdict-hold/5 p-3 text-xs text-text-secondary">
<i className="fa-solid fa-lock mt-0.5 text-verdict-hold" />
<span>
本地 Key 仅写入浏览器 localStorage(按账户隔离),不会随分析请求持久化到服务端,也不会出现在公开报告中。
配置仅写入浏览器 localStorage(按账户隔离),不会随分析请求持久化到服务端,也不会出现在公开报告中。
清除浏览器数据将一并清除。配置后,分析将优先使用你的自定义模型。
</span>
</div>

{!user && (
<p className="mt-4 text-xs text-verdict-hold">
<i className="fa-solid fa-circle-info mr-1" />本地 Key 按账户隔离保存,登录后才会启用。
<i className="fa-solid fa-circle-info mr-1" />配置按账户隔离保存,登录后才会启用。
</p>
)}

<div className="mt-6 space-y-3">
{PROVIDERS.map((p) => {
const has = user ? hasLocalKey(p.key) : false;
{CUSTOM_MODEL_TYPES.map((t) => {
const has = user ? hasLocalKey(t.key) : false;
const keyDraft = keyDrafts[t.key] ?? '';
const urlDraft = urlDrafts[t.key] ?? '';
const urlDirty = user ? urlDraft.trim() !== (customModelConfig.getBaseUrl(user.id, t.key) || t.defaultBaseUrl) : false;
return (
<div key={p.key} className="surface-panel p-4">
<div className="flex items-center justify-between">
<div>
<h3 className="text-sm font-semibold text-text-primary">{p.label}</h3>
<p className="num text-[11px] text-text-tertiary">{p.url || '需填写 Base URL'} · {p.models}</p>
<div key={t.key} className="surface-panel p-4">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<h3 className="text-sm font-semibold text-text-primary">{t.label}</h3>
<p className="mt-0.5 text-[11px] leading-relaxed text-text-tertiary">{t.desc}</p>
</div>
{has && <span className="verdict-pill verdict-bull"><i className="fa-solid fa-check text-[10px]" />已配置</span>}
{has && <span className="verdict-pill verdict-bull shrink-0"><i className="fa-solid fa-check text-[10px]" />已配置</span>}
</div>
<div className="mt-3 flex gap-2">
<div className="mt-3 space-y-2">
<label className="block text-xs text-text-tertiary">API Key</label>
<input
type="password"
value={drafts[p.key] ?? ''}
onChange={(e) => setDraft(p.key, e.target.value)}
placeholder={has ? '••••••••(输入新值可替换)' : '粘贴 API Key'}
className="h-10 flex-1 rounded-lg border border-dark-border bg-dark-input px-3 text-sm text-text-primary placeholder:text-text-tertiary focus:border-accent-primary focus:outline-none"
value={keyDraft}
onChange={(e) => setKeyDraft(t.key, e.target.value)}
placeholder={has ? '••••••••(输入新值可替换)' : '粘贴该服务的 API Key'}
className="h-10 w-full rounded-lg border border-dark-border bg-dark-input px-3 text-sm text-text-primary placeholder:text-text-tertiary focus:border-accent-primary focus:outline-none"
/>
<button onClick={() => save(p.key)} disabled={!drafts[p.key]?.trim()} className="btn-primary px-4 py-2 text-xs">
{saved === p.key ? '已保存' : '保存'}
<label className="block pt-1 text-xs text-text-tertiary">Base URL</label>
<input
type="text"
value={urlDraft}
onChange={(e) => setUrlDraft(t.key, e.target.value)}
placeholder={t.placeholder}
className="num h-10 w-full rounded-lg border border-dark-border bg-dark-input px-3 text-sm text-text-primary placeholder:text-text-tertiary focus:border-accent-primary focus:outline-none"
/>
</div>
<div className="mt-3 flex items-center gap-2">
<button
onClick={() => save(t.key)}
disabled={!user || (!keyDraft.trim() && !urlDirty)}
className="btn-primary px-4 py-2 text-xs"
>
{savedType === t.key ? '已保存' : '保存'}
</button>
{has && (
<button onClick={() => clearLocalKey(p.key)} className="btn-ghost px-3 py-2 text-xs">
<button onClick={() => clear(t.key)} className="btn-ghost px-3 py-2 text-xs">
清除
</button>
)}
Expand All @@ -86,4 +122,3 @@ export default function SettingsPage() {
</SiteLayout>
);
}

2 changes: 1 addition & 1 deletion web/frontend/src/components/auth/AuthLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ export function AuthLayout({
toggleLinkText
}: AuthLayoutProps) {
return (
<div className="min-h-screen bg-gradient-hero flex flex-col justify-center py-4 px-4 sm:py-12 sm:px-6 lg:px-8 relative overflow-hidden">
<div className="min-h-screen bg-dark-primary flex flex-col justify-center py-4 px-4 sm:py-12 sm:px-6 lg:px-8 relative overflow-hidden">
{/* Background decoration */}
<div className="absolute inset-0 overflow-hidden">
<div className="absolute top-1/4 left-1/4 w-64 h-64 sm:w-96 sm:h-96 bg-accent-primary/5 rounded-full blur-3xl animate-float" />
Expand Down
2 changes: 1 addition & 1 deletion web/frontend/src/components/auth/Turnstile.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ export const Turnstile = forwardRef<TurnstileRef, TurnstileProps>(function Turns
}, []);

return (
<div className={className}>
<div className={`flex flex-col items-center ${className ?? ''}`}>
<div ref={containerRef} />
{loadError && (
<p className="mt-1 text-xs text-verdict-bear">
Expand Down
2 changes: 1 addition & 1 deletion web/frontend/src/components/site/SearchBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ export function SearchBar({ size = 'lg' }: { size?: 'lg' | 'md' }) {
type="text"
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder="输入股票代码、公司名称,或一句研究指令,如「把 minimax 加入港股通对后市的影响」"
placeholder="输入股票代码、公司名称,或一句研究指令,如「宁德时代海外扩张对未来 12 个月利润的影响」"
className={`w-full rounded-xl border border-dark-border bg-dark-input ${height} ${textSize} pl-11 pr-28 text-text-primary placeholder:text-text-tertiary focus:border-accent-primary focus:outline-none focus:ring-1 focus:ring-accent-primary/40`}
autoFocus={size === 'lg'}
/>
Expand Down
10 changes: 8 additions & 2 deletions web/frontend/src/components/site/SiteFooter.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
'use client';

import Link from 'next/link';
import { useAuth } from '@/lib/auth';

export function SiteFooter() {
const { user } = useAuth();

return (
<footer className="border-t border-dark-border bg-dark-primary">
<div className="mx-auto max-w-6xl px-4 py-8 text-xs text-text-tertiary">
Expand All @@ -14,7 +19,9 @@ export function SiteFooter() {
<Link href="/" className="hover:text-text-secondary">研究</Link>
<Link href="/leaderboard" className="hover:text-text-secondary">公开榜单</Link>
<Link href="/subscription" className="hover:text-text-secondary">订阅</Link>
<Link href="/settings" className="hover:text-text-secondary">自定义模型</Link>
{/* 未登录时设置入口在顶部导航(登录按钮左侧的齿轮图标);
登录后顶部不再显示,只保留这里 —— 任何状态全站只有一个入口 */}
{user && <Link href="/settings" className="hover:text-text-secondary">自定义模型</Link>}
Comment on lines +22 to +24
</nav>
</div>
<p className="disclaimer-strip mt-6 leading-relaxed">
Expand All @@ -25,4 +32,3 @@ export function SiteFooter() {
</footer>
);
}

17 changes: 13 additions & 4 deletions web/frontend/src/components/site/SiteHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ const NAV = [
{ href: '/', label: '研究' },
{ href: '/leaderboard', label: '公开榜单' },
{ href: '/subscription', label: '订阅' },
{ href: '/settings', label: '自定义模型' },
];

export function SiteHeader() {
Expand Down Expand Up @@ -67,9 +66,19 @@ export function SiteHeader() {
</button>
</div>
) : (
<Link href="/auth" className="btn-primary px-3 py-1.5 text-xs">
登录
</Link>
<>
<Link
href="/settings"
className="rounded-md p-1.5 text-text-secondary transition-colors hover:text-text-primary"
aria-label="自定义模型设置"
title="自定义模型设置"
>
<i className="fa-solid fa-gear" />
</Link>
<Link href="/auth" className="btn-primary px-3 py-1.5 text-xs">
登录
</Link>
</>
)}
<button
className="rounded-md p-1.5 text-text-secondary md:hidden"
Expand Down
78 changes: 78 additions & 0 deletions web/frontend/src/lib/customModelConfig.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
/**
* 自定义模型配置(类型 + Base URL)— 纯前端,localStorage
*
* 自定义模型只按「接口类型」区分(openai / anthropic 兼容),不指定具体
* 提供商。API Key 走 keyVault(taw:llmkey 命名空间);Base URL 属于非敏感
* 配置,存于同一命名空间的 `<type>::baseurl` 键下,按 userId 隔离。
*/

import { LOCAL_KEY_NS } from './keyVault';

export type CustomModelType = 'openai-compatible' | 'anthropic-compatible';

export const CUSTOM_MODEL_TYPES: {
key: CustomModelType;
label: string;
desc: string;
defaultBaseUrl: string;
placeholder: string;
}[] = [
{
key: 'openai-compatible',
label: 'OpenAI 接口兼容',
desc: '适用于 OpenAI 及各类兼容 Chat Completions 协议的服务(DeepSeek、OpenRouter、vLLM 等)',
defaultBaseUrl: 'https://api.openai.com/v1',
placeholder: 'https://api.openai.com/v1',
},
{
key: 'anthropic-compatible',
label: 'Anthropic 接口兼容',
desc: '适用于 Anthropic 及各类兼容 Messages 协议的服务(Claude 系列等)',
defaultBaseUrl: 'https://api.anthropic.com',
placeholder: 'https://api.anthropic.com',
},
];

function baseUrlId(userId: string | number, type: CustomModelType): string {
return `${LOCAL_KEY_NS}:${userId}:${type}::baseurl`;
}

function safeLocalStorage(): Storage | null {
if (typeof window === 'undefined' || !window.localStorage) return null;
return window.localStorage;
}

export const customModelConfig = {
/** 读取某类型的 Base URL;未配置时返回空串(由调用方决定是否用默认值填充表单) */
getBaseUrl(userId: string | number, type: CustomModelType): string {
const ls = safeLocalStorage();
if (!ls) return '';
try {
return ls.getItem(baseUrlId(userId, type)) || '';
} catch {
return '';
}
},

saveBaseUrl(userId: string | number, type: CustomModelType, url: string): void {
const ls = safeLocalStorage();
if (!ls) return;
try {
ls.setItem(baseUrlId(userId, type), url.trim());
} catch {
/* storage 不可用时静默失败 */
}
},

clearBaseUrl(userId: string | number, type: CustomModelType): void {
const ls = safeLocalStorage();
if (!ls) return;
try {
ls.removeItem(baseUrlId(userId, type));
} catch {
/* noop */
}
},
};

export default customModelConfig;