+
{description}
- {args && Object.keys(args).length > 0 && (
-
- {Object.entries(args).map(([k, v]) => (
-
-
{k}:
-
- {typeof v === "string" ? v : JSON.stringify(v)}
-
+ {ingestPreview && ingestPreview.length > 0 ? (
+
+
+ 即将入库 {ingestPreview.length} 篇论文:
+
+ {ingestPreview.map((p, idx) => (
+
+
+ {idx + 1}.
+ {p.title || 未找到元信息(仅 ID)}
+
+
+ {p.arxiv_id}
+ {p.authors && p.authors.length > 0 && (
+
+ {p.authors.slice(0, 3).join(", ")}
+ {p.authors.length > 3 ? " 等" : ""}
+
+ )}
+
))}
+ {args?.query ? (
+
+ 来源查询:{String(args.query)}
+
+ ) : null}
+ ) : (
+ args &&
+ Object.keys(args).length > 0 && (
+
+ {Object.entries(args).map(([k, v]) => (
+
+ {k}:
+
+ {typeof v === "string" ? v : JSON.stringify(v)}
+
+
+ ))}
+
+ )
)}
diff --git a/frontend/src/pages/Collect.tsx b/frontend/src/pages/Collect.tsx
index dbbfcf5..e1de47b 100644
--- a/frontend/src/pages/Collect.tsx
+++ b/frontend/src/pages/Collect.tsx
@@ -21,7 +21,6 @@ import {
X,
Rss,
Loader2,
- RefreshCw,
FileText,
ExternalLink,
ChevronDown,
@@ -33,22 +32,22 @@ import {
Play,
Layers,
} from "lucide-react";
-import { ingestApi, topicApi } from "@/services/api";
+import { ingestApi, topicApi, paperApi } from "@/services/api";
import { useToast } from "@/contexts/ToastContext";
import ConfirmDialog from "@/components/ConfirmDialog";
import type {
Topic,
- TopicCreate,
- TopicUpdate,
ScheduleFrequency,
KeywordSuggestion,
IngestPaper,
TopicFetchResult,
+ MultiSourcePaper,
+ ChannelSuggestion,
} from "@/types";
import CSFeeds from "./CSFeeds";
type SortBy = "submittedDate" | "relevance" | "lastUpdatedDate";
-type ActiveTab = "search" | "subscriptions" | "csfeeds";
+type ActiveTab = "search" | "subscriptions" | "csfeeds" | "multi";
interface SearchResult {
ingested: number;
@@ -98,6 +97,15 @@ function relativeTime(iso: string): string {
return d.toLocaleDateString("zh-CN");
}
+const CHANNEL_MAP: Record
= {
+ arxiv: { id: "arxiv", name: "ArXiv", isFree: true },
+ openalex: { id: "openalex", name: "OpenAlex", isFree: true },
+ semantic_scholar: { id: "semantic_scholar", name: "Semantic Scholar", isFree: true },
+ dblp: { id: "dblp", name: "DBLP", isFree: true },
+ ieee: { id: "ieee", name: "IEEE", isFree: false },
+ biorxiv: { id: "biorxiv", name: "bioRxiv", isFree: true },
+};
+
export default function Collect() {
const { toast } = useToast();
const navigate = useNavigate();
@@ -113,6 +121,45 @@ export default function Collect() {
const [results, setResults] = useState([]);
const [error, setError] = useState("");
+ // ========== 多源搜索 ==========
+ const [multiQuery, setMultiQuery] = useState("");
+ const [multiChannels, setMultiChannels] = useState(["arxiv"]);
+ const [multiLoading, setMultiLoading] = useState(false);
+ const [multiResults, setMultiResults] = useState([]);
+ const [multiSuggestions, setMultiSuggestions] = useState(null);
+
+ const handleMultiSearch = useCallback(async (q: string, channels: string[]) => {
+ if (!q.trim()) return;
+ setMultiLoading(true);
+ try {
+ const res = await paperApi.multiSourceSearch(q.trim(), channels);
+ setMultiResults(res.results || []);
+ if (res.results && res.results.length > 0) {
+ toast("success", `找到 ${res.results.length} 篇相关论文`);
+ } else {
+ toast("info", "未找到相关论文");
+ }
+ } catch (err) {
+ toast("error", err instanceof Error ? err.message : "搜索失败");
+ } finally {
+ setMultiLoading(false);
+ }
+ }, [toast]);
+
+ const fetchMultiSuggestions = useCallback(async (q: string) => {
+ if (!q.trim()) { setMultiSuggestions(null); return; }
+ try {
+ const res = await paperApi.suggestChannels(q.trim());
+ setMultiSuggestions(res);
+ } catch { /* quiet */ }
+ }, []);
+
+ const applyMultiRecommendation = useCallback(() => {
+ if (multiSuggestions?.recommended) {
+ setMultiChannels(multiSuggestions.recommended);
+ }
+ }, [multiSuggestions]);
+
// ========== 订阅管理 ==========
const [topics, setTopics] = useState([]);
const [loading, setLoading] = useState(true);
@@ -414,6 +461,17 @@ export default function Collect() {
分类订阅
+
{/* 错误 */}
@@ -542,6 +600,164 @@ export default function Collect() {
+
+
+
+
+
+
多源搜索
+
+ 从 ArXiv、OpenAlex、Semantic Scholar 等多渠道并行搜索
+
+
+
+
+
+
+
+
+ {
+ setMultiQuery(e.target.value);
+ fetchMultiSuggestions(e.target.value);
+ }}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") handleMultiSearch(multiQuery, multiChannels);
+ }}
+ placeholder="输入关键词,如 machine learning transformer"
+ className="border-border bg-page text-ink placeholder:text-ink-placeholder focus:border-primary focus:ring-primary/20 h-11 w-full rounded-xl border pr-4 pl-10 text-sm focus:ring-2 focus:outline-none"
+ />
+
+
+
+
+ {multiSuggestions && multiSuggestions.recommended.length > 0 && (
+
+
+
推荐渠道:
+
+ {multiSuggestions.recommended.map((id) => {
+ const ch = CHANNEL_MAP[id];
+ return ch ? (
+
+ {ch.name}
+
+ ) : null;
+ })}
+
+ {JSON.stringify(multiSuggestions.recommended.sort()) !== JSON.stringify(multiChannels.sort()) && (
+
+ )}
+
+ )}
+
+
+ 渠道:
+ {Object.values(CHANNEL_MAP).map((channel) => {
+ const isSelected = multiChannels.includes(channel.id);
+ return (
+
+ );
+ })}
+
+
+
+ {multiLoading && (
+
+
+
+ )}
+
+ {!multiLoading && multiResults.length === 0 && multiQuery.trim() && (
+
+ )}
+
+ {!multiLoading && multiResults.length > 0 && (
+
+
+
+ 共找到 {multiResults.length} 篇论文
+
+
+ {multiResults.map((paper: MultiSourcePaper, idx: number) => {
+ const primarySource = paper.sources?.[0]?.channel;
+ return (
+
+
{paper.title}
+ {paper.authors && paper.authors.length > 0 && (
+
+ {paper.authors.slice(0, 3).join(", ")}
+ {paper.authors.length > 3 && " et al."}
+
+ )}
+
+ {primarySource && (
+
+ {primarySource}
+
+ )}
+ {paper.year && (
+ {paper.year}
+ )}
+ {paper.venue && (
+ {paper.venue}
+ )}
+
+
+ );
+ })}
+
+ )}
+
+ )}
+
{/* ================================================================
* 自动订阅管理
* ================================================================ */}
diff --git a/frontend/src/pages/PaperDetail.tsx b/frontend/src/pages/PaperDetail.tsx
index ad564c0..be18ca5 100644
--- a/frontend/src/pages/PaperDetail.tsx
+++ b/frontend/src/pages/PaperDetail.tsx
@@ -277,7 +277,7 @@ export default function PaperDetail() {
setReportTab("deep");
try {
const report = await pipelineApi.deep(id);
- setSavedDeep(report);
+ setDeepReport(report);
toast("success", "精读完成");
} catch {
toast("error", "精读失败");
@@ -491,9 +491,6 @@ export default function PaperDetail() {
? "done"
: "idle";
- const anyPipelineRunning =
- skimLoading || deepLoading || figuresAnalyzing || reasoningLoading || embedLoading;
-
return (
{/* 页面头 */}
@@ -1084,6 +1081,7 @@ export default function PaperDetail() {
paperId={id!}
paperTitle={paper.title}
paperArxivId={paper.arxiv_id}
+ paperPdfPath={paper.pdf_path}
onClose={() => setReaderOpen(false)}
/>
diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx
new file mode 100644
index 0000000..bf22094
--- /dev/null
+++ b/frontend/src/pages/Settings.tsx
@@ -0,0 +1,1060 @@
+/**
+ * Claude 风格的设置页面 - 左侧导航 + 右侧内容
+ */
+import { useState, useCallback, useEffect } from "react";
+import {
+ Cpu,
+ Mail,
+ GitBranch,
+ Settings,
+ ChevronRight,
+ Plus,
+ Trash2,
+ Pencil,
+ Power,
+ PowerOff,
+ Eye,
+ EyeOff,
+ Server,
+ RefreshCw,
+ Play,
+ Link2,
+ BookOpen,
+ Activity,
+ Zap,
+ Network,
+ Calendar,
+ CheckCircle2,
+ XCircle,
+ Clock,
+ AlertTriangle,
+ Send,
+} from "lucide-react";
+import { useToast } from "@/contexts/ToastContext";
+import { Button } from "@/components/ui/Button";
+import { Badge } from "@/components/ui/Badge";
+import { Spinner } from "@/components/ui/Spinner";
+import {
+ llmConfigApi,
+ pipelineApi,
+ citationApi,
+ jobApi,
+ systemApi,
+ emailConfigApi,
+ dailyReportApi,
+} from "@/services/api";
+import { getErrorMessage } from "@/lib/errorHandler";
+import { cn } from "@/lib/utils";
+import { formatDuration, timeAgo } from "@/lib/utils";
+
+type SettingsTab = "llm" | "email" | "pipeline" | "ops";
+
+const NAV_ITEMS: { key: SettingsTab; label: string; icon: typeof Cpu }[] = [
+ { key: "llm", label: "LLM 配置", icon: Cpu },
+ { key: "email", label: "邮箱与报告", icon: Mail },
+ { key: "pipeline", label: "Pipeline", icon: GitBranch },
+ { key: "ops", label: "运维", icon: Settings },
+];
+
+const PROVIDER_PRESETS: Record
}> = {
+ zhipu: {
+ label: "智谱 AI",
+ base_url: "https://open.bigmodel.cn/api/paas/v4/",
+ models: { model_skim: "glm-4.7", model_deep: "glm-4.7", model_vision: "glm-4.6v", model_embedding: "embedding-3", model_fallback: "glm-4.7" },
+ },
+ openai: {
+ label: "OpenAI",
+ base_url: "https://api.openai.com/v1",
+ models: { model_skim: "gpt-4o-mini", model_deep: "gpt-4.1", model_vision: "gpt-4o", model_embedding: "text-embedding-3-small", model_fallback: "gpt-4o-mini" },
+ },
+ anthropic: {
+ label: "Anthropic",
+ base_url: "",
+ models: { model_skim: "claude-3-haiku-20240307", model_deep: "claude-3-5-sonnet-20241022", model_embedding: "text-embedding-3-small", model_fallback: "claude-3-haiku-20240307" },
+ },
+};
+
+export default function SettingsPage() {
+ const [activeTab, setActiveTab] = useState("llm");
+
+ return (
+
+ {/* 左侧边栏 */}
+
+
+ {/* 右侧内容 */}
+
+
+ {activeTab === "llm" &&
}
+ {activeTab === "email" &&
}
+ {activeTab === "pipeline" &&
}
+ {activeTab === "ops" &&
}
+
+
+
+ );
+}
+
+/* ======== LLM 设置 ======== */
+function ProviderBadge({ provider }: { provider: string }) {
+ const colors: Record = {
+ zhipu: "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300",
+ openai: "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300",
+ anthropic: "bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-300",
+ };
+ const labels: Record = {
+ zhipu: "智谱",
+ openai: "OpenAI",
+ anthropic: "Anthropic",
+ };
+ return (
+
+
+ {labels[provider] || provider}
+
+ );
+}
+
+function LLMSettings() {
+ const { toast } = useToast();
+ const [configs, setConfigs] = useState([]);
+ const [activeInfo, setActiveInfo] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [showAdd, setShowAdd] = useState(false);
+ const [editCfg, setEditCfg] = useState(null);
+ const [submitting, setSubmitting] = useState(false);
+ const [actionId, setActionId] = useState(null);
+
+ const load = useCallback(async () => {
+ try {
+ const [listRes, activeRes] = await Promise.all([llmConfigApi.list(), llmConfigApi.active()]);
+ setConfigs(listRes.items || []);
+ setActiveInfo(activeRes);
+ } catch {
+ toast("error", "加载 LLM 配置失败");
+ } finally {
+ setLoading(false);
+ }
+ }, [toast]);
+
+ useEffect(() => { load(); }, [load]);
+
+ const handleDeactivate = async () => {
+ setSubmitting(true);
+ try {
+ await llmConfigApi.deactivate();
+ await load();
+ toast("success", "已切回默认配置");
+ } catch (err) {
+ toast("error", getErrorMessage(err));
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ const handleActivate = async (id: string) => {
+ setActionId(id);
+ try {
+ await llmConfigApi.activate(id);
+ await load();
+ toast("success", "配置已激活");
+ } catch (err) {
+ toast("error", getErrorMessage(err));
+ } finally {
+ setActionId(null);
+ }
+ };
+
+ const handleDelete = async (id: string) => {
+ if (!confirm("确定要删除此配置?")) return;
+ setActionId(id);
+ try {
+ await llmConfigApi.delete(id);
+ await load();
+ toast("success", "配置已删除");
+ } catch (err) {
+ toast("error", getErrorMessage(err));
+ } finally {
+ setActionId(null);
+ }
+ };
+
+ if (loading) return
;
+
+ return (
+
+
+
LLM 模型配置
+
配置 AI 模型,管理成本
+
+
+ {/* 当前激活 */}
+ {activeInfo && (
+
+
+
+
+
+
+
+
+
{activeInfo.config?.name || "当前配置"}
+
使用中
+
+
+ {activeInfo.source === "database" ? "用户配置" : ".env"}
+
+
+
+ 粗读: {activeInfo.config?.model_skim}
+ 精读: {activeInfo.config?.model_deep}
+ {activeInfo.config?.model_vision && 视觉: {activeInfo.config?.model_vision}}
+ 嵌入: {activeInfo.config?.model_embedding}
+
+
+
+
+
+ {activeInfo.source === "database" && (
+
+ )}
+
+
+
+ )}
+
+ {/* 配置列表 */}
+
+
+
所有配置
+
+
+
+ {configs.length === 0 ? (
+
+ ) : (
+
+ {configs.map((cfg) => (
+
+
+
+
+
+
+
+
{cfg.name}
+ {cfg.is_active &&
激活}
+
+
+
+ {cfg.api_key_masked}
+
+
+
+
+ {!cfg.is_active && (
+
+ )}
+
+
+
+
+ ))}
+
+ )}
+
+
+ {/* 添加/编辑弹窗 */}
+ {(showAdd || editCfg) && (
+
{ setShowAdd(false); setEditCfg(null); }}
+ onSaved={() => { setShowAdd(false); setEditCfg(null); load(); }}
+ />
+ )}
+
+ );
+}
+
+function ConfigModal({ config, onClose, onSaved }: { config?: any; onClose: () => void; onSaved: () => void }) {
+ const { toast } = useToast();
+ const [form, setForm] = useState({
+ name: config?.name || "",
+ provider: config?.provider || "zhipu",
+ api_key: "",
+ api_base_url: config?.api_base_url || PROVIDER_PRESETS.zhipu.base_url,
+ model_skim: config?.model_skim || "glm-4.7",
+ model_deep: config?.model_deep || "glm-4.7",
+ model_vision: config?.model_vision || "glm-4.6v",
+ model_embedding: config?.model_embedding || "embedding-3",
+ model_fallback: config?.model_fallback || "glm-4.7",
+ });
+ const [showKey, setShowKey] = useState(false);
+ const [submitting, setSubmitting] = useState(false);
+ const [error, setError] = useState("");
+
+ const setField = (k: string, v: string) => setForm((p) => ({ ...p, [k]: v }));
+
+ const handleProviderChange = (provider: string) => {
+ const preset = PROVIDER_PRESETS[provider];
+ if (preset) {
+ setForm((p) => ({ ...p, provider, api_base_url: preset.base_url, ...preset.models }));
+ }
+ };
+
+ const handleSubmit = async () => {
+ if (!form.name.trim()) { setError("请输入配置名称"); return; }
+ if (!form.api_key.trim() && !config) { setError("请输入 API Key"); return; }
+ setSubmitting(true);
+ setError("");
+ try {
+ if (config) {
+ const payload: any = { name: form.name, provider: form.provider, api_base_url: form.api_base_url, model_skim: form.model_skim, model_deep: form.model_deep, model_vision: form.model_vision, model_embedding: form.model_embedding, model_fallback: form.model_fallback };
+ if (form.api_key) payload.api_key = form.api_key;
+ await llmConfigApi.update(config.id, payload);
+ toast("success", "配置已保存");
+ } else {
+ await llmConfigApi.create(form);
+ toast("success", "配置已创建");
+ }
+ onSaved();
+ } catch (err: any) {
+ setError(getErrorMessage(err));
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ return (
+
+
+
{config ? "编辑配置" : "添加配置"}
+ {error &&
{error}
}
+
+
+
+
+ setField("name", e.target.value)} className="w-full rounded-lg border border-border bg-page px-3 py-2 text-sm text-ink outline-none focus:border-primary" placeholder="如:智谱 AI" />
+
+
+
+
+
+
+
+
+
+ setField("api_key", e.target.value)} className="w-full rounded-lg border border-border bg-page px-3 py-2 pr-10 text-sm text-ink outline-none focus:border-primary" placeholder={config ? "留空保持不变" : "输入 API Key"} />
+
+
+
+
+
+ setField("api_base_url", e.target.value)} className="w-full rounded-lg border border-border bg-page px-3 py-2 text-sm text-ink outline-none focus:border-primary" placeholder="留空使用默认" />
+
+
+
+
+
+
+
+
+
+ );
+}
+
+/* ======== 邮箱设置 ======== */
+function EmailSettings() {
+ const { toast } = useToast();
+ const [emailConfigs, setEmailConfigs] = useState([]);
+ const [dailyReport, setDailyReport] = useState(null);
+ const [localConfig, setLocalConfig] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [submitting, setSubmitting] = useState(false);
+ const [showAddEmail, setShowAddEmail] = useState(false);
+ const [editEmailConfig, setEditEmailConfig] = useState(null);
+ const [testEmailId, setTestEmailId] = useState(null);
+
+ const loadEmails = useCallback(async () => {
+ try { setEmailConfigs(await emailConfigApi.list() || []); } catch { toast("error", "加载邮箱配置失败"); }
+ }, [toast]);
+
+ const loadDaily = useCallback(async () => {
+ try {
+ const data = await dailyReportApi.getConfig();
+ setDailyReport(data);
+ setLocalConfig(data);
+ } catch { toast("error", "加载报告配置失败"); }
+ }, [toast]);
+
+ useEffect(() => { Promise.all([loadEmails(), loadDaily()]).finally(() => setLoading(false)); }, [loadEmails, loadDaily]);
+
+ const handleActivateEmail = async (id: string) => {
+ setSubmitting(true);
+ try {
+ await emailConfigApi.activate(id);
+ await loadEmails();
+ toast("success", "邮箱已激活");
+ } catch (err) {
+ toast("error", getErrorMessage(err));
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ const handleDeleteEmail = async (id: string) => {
+ if (!confirm("确定要删除此邮箱配置?")) return;
+ try {
+ await emailConfigApi.delete(id);
+ await loadEmails();
+ toast("success", "邮箱配置已删除");
+ } catch (err) {
+ toast("error", getErrorMessage(err));
+ }
+ };
+
+ const handleTestEmail = async (id: string) => {
+ setTestEmailId(id);
+ try {
+ await emailConfigApi.test(id);
+ toast("success", "测试邮件已发送,请检查邮箱");
+ } catch (err) {
+ toast("error", getErrorMessage(err));
+ } finally {
+ setTestEmailId(null);
+ }
+ };
+
+ const handleUpdateDailyReport = async (updates: any) => {
+ setSubmitting(true);
+ try {
+ const body: Record = { ...updates };
+ if (updates.recipient_emails !== undefined) {
+ body.recipient_emails = Array.isArray(updates.recipient_emails) ? updates.recipient_emails.join(",") : updates.recipient_emails;
+ }
+ const data = await dailyReportApi.updateConfig(body);
+ if (data.config) {
+ setDailyReport(data.config);
+ setLocalConfig(data.config);
+ toast("success", "每日报告配置已更新");
+ }
+ } catch (err) {
+ toast("error", getErrorMessage(err));
+ await loadDaily();
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ const handleInputChange = (field: string, value: any) => {
+ setLocalConfig((prev: any) => ({ ...prev, [field]: value }));
+ };
+
+ const handleInputBlur = (field: string) => {
+ if (localConfig && localConfig[field] !== dailyReport[field]) {
+ handleUpdateDailyReport({ [field]: localConfig[field] });
+ }
+ };
+
+ const handleRunDailyWorkflow = async () => {
+ if (!confirm("确定要立即执行每日工作流吗?这将使用AI推荐系统找出高价值论文进行精读,生成每日简报并发送邮件报告。\n\n注意:精读论文需要几分钟时间,任务将在后台执行,请稍后查看结果。")) return;
+ setSubmitting(true);
+ try {
+ await dailyReportApi.runOnce();
+ toast("success", "每日报告工作流已启动,正在后台执行");
+ } catch (err) {
+ toast("error", getErrorMessage(err));
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ if (loading) return
;
+
+ return (
+
+
+
+ {/* 邮箱配置 */}
+
+
+
邮箱配置
+
+
+ {emailConfigs.length === 0 ? (
+
+ ) : (
+ emailConfigs.map((cfg) => (
+
+
+
+
+
+
+
+ {cfg.name}
+ {cfg.is_active && 激活}
+
+
{cfg.sender_email}
+
+
+
+ {!cfg.is_active &&
}
+
+
+
+
+
+ ))
+ )}
+
+
+ {/* 每日报告 */}
+ {dailyReport && (
+
+
每日报告
+
+
+
+
+
+
每日报告
+
{dailyReport.enabled ? "已启用" : "已禁用"}
+
+
+
+
+
+ {dailyReport.enabled && (
+ <>
+
+
+
发送邮件报告
+
+
+ {dailyReport.send_email_report && (
+
+
handleInputChange("recipient_emails", e.target.value)}
+ onBlur={() => handleInputBlur("recipient_emails")}
+ disabled={submitting}
+ className="w-full rounded border border-border bg-surface px-2 py-1.5 text-xs text-ink placeholder:text-ink-placeholder"
+ />
+
+
+
handleInputChange("cron_expression", e.target.value)}
+ onBlur={() => handleInputBlur("cron_expression")}
+ disabled={submitting}
+ className="w-full rounded border border-border bg-surface px-2 py-1.5 text-xs font-mono text-ink placeholder:text-ink-placeholder"
+ />
+
+ 默认:0 4 * * *(UTC 4 点 = 北京时间 12 点)
+
+ 格式:分 时 日 月 周
+
+
+
+ )}
+
+
+
+
+
自动精读新论文
+
每日自动精选高价值论文进行深度阅读
+
+
+
+ {dailyReport.auto_deep_read && (
+
+ 每日精读上限
+ handleInputChange("deep_read_limit", parseInt(e.target.value) || 10)}
+ onBlur={() => handleInputBlur("deep_read_limit")}
+ disabled={submitting}
+ className="w-20 rounded border border-border bg-page px-2 py-1 text-xs text-ink outline-none focus:border-primary"
+ />
+ 篇
+
+ )}
+
+
+
+
+ >
+ )}
+
+
+ )}
+
+ {/* 添加邮箱弹窗 */}
+ {showAddEmail && (
+
{ setShowAddEmail(false); loadEmails(); }}
+ onCancel={() => setShowAddEmail(false)}
+ />
+ )}
+
+ {/* 编辑邮箱弹窗 */}
+ {editEmailConfig && (
+ { setEditEmailConfig(null); loadEmails(); }}
+ onCancel={() => setEditEmailConfig(null)}
+ />
+ )}
+
+ );
+}
+
+function AddEmailConfigModal({ onCreated, onCancel }: { onCreated: () => void; onCancel: () => void }) {
+ const { toast } = useToast();
+ const [form, setForm] = useState({
+ name: "",
+ smtp_server: "",
+ smtp_port: 587,
+ smtp_use_tls: true,
+ sender_email: "",
+ sender_name: "PaperMind",
+ username: "",
+ password: "",
+ });
+ const [submitting, setSubmitting] = useState(false);
+ const [error, setError] = useState("");
+
+ const setField = (key: string, value: any) => setForm((prev) => ({ ...prev, [key]: value }));
+
+ const handleSelectPreset = async (provider: string) => {
+ try {
+ const data = await emailConfigApi.smtpPresets();
+ const preset = data[provider];
+ if (!preset) {
+ toast("error", `未找到 ${provider} 邮箱的预设配置`);
+ return;
+ }
+ setForm((prev) => ({
+ ...prev,
+ smtp_server: preset.smtp_server || prev.smtp_server,
+ smtp_port: preset.smtp_port || 587,
+ smtp_use_tls: preset.smtp_use_tls !== false,
+ }));
+ } catch (err) {
+ toast("error", getErrorMessage(err));
+ }
+ };
+
+ const handleSubmit = async () => {
+ if (!form.name.trim() || !form.smtp_server || !form.sender_email || !form.username || !form.password) {
+ setError("请填写所有必填字段");
+ return;
+ }
+ setSubmitting(true);
+ setError("");
+ try {
+ await emailConfigApi.create(form);
+ toast("success", "邮箱配置已添加");
+ onCreated();
+ } catch (err) {
+ setError(getErrorMessage(err));
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ return (
+
+
+
添加邮箱配置
+ {error &&
{error}
}
+
+
+
+
+
+
+
+
+
+
+ setField("username", e.target.value)} className="w-full rounded-lg border border-border bg-page px-2.5 py-1.5 text-xs text-ink outline-none focus:border-primary" placeholder="同发件人邮箱" />
+
+
+
+ setField("password", e.target.value)} className="w-full rounded-lg border border-border bg-page px-2.5 py-1.5 text-xs text-ink outline-none focus:border-primary" placeholder="邮箱授权码" />
+
+
+
+
+
+
+
+
+
+ );
+}
+
+function EditEmailConfigModal({ config, onSaved, onCancel }: { config: any; onSaved: () => void; onCancel: () => void }) {
+ const [form, setForm] = useState({
+ name: config.name,
+ smtp_server: config.smtp_server,
+ smtp_port: config.smtp_port,
+ smtp_use_tls: config.smtp_use_tls,
+ sender_email: config.sender_email,
+ sender_name: config.sender_name || "PaperMind",
+ username: config.username,
+ password: "",
+ });
+ const [submitting, setSubmitting] = useState(false);
+ const [error, setError] = useState("");
+
+ const setField = (key: string, value: any) => setForm((prev) => ({ ...prev, [key]: value }));
+
+ const handleSubmit = async () => {
+ setSubmitting(true);
+ setError("");
+ try {
+ const payload = { ...form };
+ if (!form.password) delete (payload as any).password;
+ await emailConfigApi.update(config.id, payload);
+ onSaved();
+ } catch (err) {
+ setError(getErrorMessage(err));
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ return (
+
+
+
编辑邮箱配置
+ {error &&
{error}
}
+
+
+
+
+
+
+
+ );
+}
+
+/* ======== Pipeline 设置 ======== */
+function StatusDot({ status }: { status: string }) {
+ const colors: Record = {
+ succeeded: "bg-success",
+ failed: "bg-error",
+ running: "bg-info animate-pulse",
+ pending: "bg-warning",
+ };
+ return ;
+}
+
+function PipelineSettings() {
+ const [runs, setRuns] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [filter, setFilter] = useState<"all" | "succeeded" | "failed" | "running">("all");
+
+ const loadRuns = useCallback(async () => {
+ try { setRuns((await pipelineApi.runs(50)).items || []); } catch { /* quiet */ } finally { setLoading(false); }
+ }, []);
+
+ useEffect(() => { loadRuns(); }, [loadRuns]);
+
+ if (loading) return
;
+
+ const filtered = filter === "all" ? runs : runs.filter((r) => r.status === filter);
+ const counts = { all: runs.length, succeeded: runs.filter((r) => r.status === "succeeded").length, failed: runs.filter((r) => r.status === "failed").length, running: runs.filter((r) => r.status === "running" || r.status === "pending").length };
+
+ return (
+
+
+
Pipeline 运行记录
+
查看和管理 Pipeline 执行历史
+
+
+
+ {(["all", "succeeded", "failed", "running"] as const).map((f) => (
+
+ ))}
+
+
+
+ {filtered.length === 0 ? (
+
+ ) : (
+
+ {filtered.map((run) => (
+
+
+ {run.pipeline_name}
+ {run.paper_id && {run.paper_id.slice(0, 8)}}
+ {run.elapsed_ms != null ? formatDuration(run.elapsed_ms) : ""}
+ {timeAgo(run.created_at)}
+
+ ))}
+
+ )}
+
+ );
+}
+
+/* ======== 运维设置 ======== */
+interface OpResult { success: boolean; message: string; }
+
+function OpsSettings() {
+ const { toast } = useToast();
+ const [results, setResults] = useState>({});
+ const [loadings, setLoadings] = useState>({});
+
+ const setL = (k: string, v: boolean) => setLoadings((p) => ({ ...p, [k]: v }));
+ const setR = (k: string, r: OpResult) => setResults((p) => ({ ...p, [k]: r }));
+
+ const ops = [
+ { key: "batchProcess", label: "一键嵌入 & 粗读未读论文", desc: "对所有未读论文执行向量嵌入 + AI 粗读(并行处理)", icon: BookOpen, action: async () => { setL("batchProcess", true); try { const r = await jobApi.batchProcessUnread(50); setR("batchProcess", { success: r.failed === 0, message: r.message }); } catch (err) { setR("batchProcess", { success: false, message: err instanceof Error ? err.message : "失败" }); } finally { setL("batchProcess", false); } } },
+ { key: "syncIncremental", label: "增量引用同步", desc: "同步论文之间的引用关系", icon: Link2, action: async () => { setL("syncIncremental", true); try { const r = await citationApi.syncIncremental(); setR("syncIncremental", { success: true, message: `同步完成,处理 ${r.processed_papers ?? 0} 篇,新增 ${r.edges_inserted} 条边` }); } catch (err) { setR("syncIncremental", { success: false, message: err instanceof Error ? err.message : "失败" }); } finally { setL("syncIncremental", false); } } },
+ { key: "dailyJob", label: "执行每日任务", desc: "抓取论文 + 生成简报", icon: Calendar, action: async () => { setL("dailyJob", true); try { await jobApi.dailyRun(); setR("dailyJob", { success: true, message: "每日任务执行完成" }); } catch (err) { setR("dailyJob", { success: false, message: err instanceof Error ? err.message : "失败" }); } finally { setL("dailyJob", false); } } },
+ { key: "weeklyJob", label: "每周图维护", desc: "引用同步 + 图谱维护", icon: Network, action: async () => { setL("weeklyJob", true); try { await jobApi.weeklyGraphRun(); setR("weeklyJob", { success: true, message: "每周维护执行完成" }); } catch (err) { setR("weeklyJob", { success: false, message: err instanceof Error ? err.message : "失败" }); } finally { setL("weeklyJob", false); } } },
+ { key: "health", label: "系统健康检查", desc: "数据库 + 统计信息", icon: Zap, action: async () => { setL("health", true); try { const r = await systemApi.status(); setR("health", { success: r.health.status === "ok", message: `${r.health.status === "ok" ? "正常" : "异常"} | ${r.counts.topics} 主题 | ${r.counts.papers_latest_200} 论文` }); } catch (err) { setR("health", { success: false, message: err instanceof Error ? err.message : "失败" }); } finally { setL("health", false); } } },
+ ];
+
+ return (
+
+
+
+
+ {ops.map((op) => {
+ const Icon = op.icon;
+ const result = results[op.key];
+ const loading = loadings[op.key];
+ return (
+
+
+
+
+
+
+
+
{op.label}
+
{op.desc}
+
+
+
+
+ {result && (
+
+ {result.message}
+
+ )}
+
+ );
+ })}
+
+
+ );
+}
diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts
index 0c0f863..cbd3db3 100644
--- a/frontend/src/services/api.ts
+++ b/frontend/src/services/api.ts
@@ -53,6 +53,8 @@ import type {
ActiveTaskInfo,
LoginResponse,
AuthStatusResponse,
+ MultiSourceSearchResult,
+ ChannelSuggestion,
} from "@/types";
export type {
@@ -92,6 +94,56 @@ export function clearAuth(): void {
localStorage.removeItem("auth_token");
}
+/** 按 HTTP 状态码映射友好文案(HTML/超长响应降级用) */
+function friendlyStatusMessage(status: number, statusText: string): string {
+ if (status === 408 || status === 504 || status === 524) {
+ return "请求超时,服务端处理时间过长,请稍后重试";
+ }
+ if (status === 502 || status === 503) {
+ return "服务暂时不可用,请稍后重试";
+ }
+ if (status === 500) {
+ return "服务器内部错误,请稍后重试或联系管理员";
+ }
+ if (status === 429) {
+ return "请求过于频繁,请稍后再试";
+ }
+ if (status === 404) {
+ return "请求的资源不存在";
+ }
+ return `${status} ${statusText}`.trim() || "请求失败";
+}
+
+/** 从失败响应中安全提取错误消息:JSON 走字段,HTML/超长文本走状态码降级 */
+async function extractErrorMessage(resp: Response): Promise {
+ const fallback = friendlyStatusMessage(resp.status, resp.statusText);
+ const contentType = resp.headers.get("content-type") || "";
+ if (contentType.includes("application/json")) {
+ try {
+ const body = await resp.json();
+ const msg = body?.message || body?.detail || body?.error;
+ if (typeof msg === "string" && msg.trim()) return msg.trim();
+ } catch {
+ // JSON 解析失败,降级到 fallback
+ }
+ return fallback;
+ }
+ // 非 JSON(text/html / text/plain 等):可能是 Cloudflare 504 HTML 页,不能原样抛
+ try {
+ const text = (await resp.text()).trim();
+ if (!text) return fallback;
+ // HTML 直接丢弃
+ if (text.startsWith("<") || text.toLowerCase().includes("(path: string, options: RequestInit = {}): Promise {
const url = `${getApiBase().replace(/\/+$/, "")}${path}`;
let resp: Response;
@@ -108,19 +160,10 @@ async function request(path: string, options: RequestInit = {}): Promise {
throw new Error("网络连接失败,请检查后端服务是否启动");
}
if (!resp.ok) {
- let msg = `${resp.status} ${resp.statusText}`;
- try {
- const body = await resp.json();
- // 兼容后端 AppError 格式: {error, message, detail}
- msg = body.message || body.detail || body.error || msg;
- } catch {
- const text = await resp.text().catch(() => "");
- if (text) msg = text;
- }
+ const msg = await extractErrorMessage(resp);
// 401 未认证,清除 token 并刷新页面跳转登录
if (resp.status === 401) {
clearAuth();
- // 强制刷新页面触发 App 重新渲染登录页
window.location.reload();
}
throw new Error(msg);
@@ -275,13 +318,36 @@ export const paperApi = {
},
aiExplain: (id: string, text: string, action: "explain" | "translate" | "summarize") =>
post<{ action: string; result: string }>(`/papers/${id}/ai/explain`, { text, action }),
+ multiSourceSearch: (query: string, channels: string[]) => {
+ const params = new URLSearchParams({
+ query,
+ channels: channels.join(","),
+ });
+ return post(`/papers/search-multi?${params}`).then((res) => ({
+ results: res.papers || [],
+ channelStats: res.channel_stats,
+ }));
+ },
+ suggestChannels: (query: string) =>
+ get(`/papers/suggest-channels?query=${encodeURIComponent(query)}`),
};
/* ========== 摄入 ========== */
export const ingestApi = {
- arxiv: (query: string, maxResults = 20, topicId?: string, sortBy = "submittedDate") => {
- const params = new URLSearchParams({ query, max_results: String(maxResults), sort_by: sortBy });
+ arxiv: (
+ query: string,
+ maxResults = 20,
+ topicId?: string,
+ sortBy = "submittedDate",
+ daysBack = 0
+ ) => {
+ const params = new URLSearchParams({
+ query,
+ max_results: String(maxResults),
+ sort_by: sortBy,
+ days_back: String(daysBack),
+ });
if (topicId) params.append("topic_id", topicId);
return post(`/ingest/arxiv?${params}`);
},
@@ -475,13 +541,12 @@ async function fetchSSE(url: string, init?: RequestInit): Promise {
},
});
if (!resp.ok) {
- // 401 未认证,清除 token 并刷新页面跳转登录
if (resp.status === 401) {
clearAuth();
window.location.reload();
}
- const text = await resp.text().catch(() => "");
- throw new Error(`请求失败 (${resp.status}): ${text || resp.statusText}`);
+ const msg = await extractErrorMessage(resp);
+ throw new Error(`请求失败 (${resp.status}): ${msg}`);
}
return resp;
}
diff --git a/frontend/src/services/llmConfigApi.ts b/frontend/src/services/llmConfigApi.ts
new file mode 100644
index 0000000..61225ce
--- /dev/null
+++ b/frontend/src/services/llmConfigApi.ts
@@ -0,0 +1,115 @@
+/**
+ * LLM 配置管理 API 服务
+ */
+
+export interface LLMConfigItem {
+ id: string;
+ name: string;
+ provider: string;
+ api_base_url: string | null;
+ model_skim: string;
+ model_deep: string;
+ model_vision: string | null;
+ model_embedding: string;
+ model_fallback: string;
+ is_active: boolean;
+}
+
+export interface LLMConfigCreate {
+ name: string;
+ provider: string;
+ api_key: string;
+ api_base_url?: string | null;
+ model_skim: string;
+ model_deep: string;
+ model_vision?: string | null;
+ model_embedding: string;
+ model_fallback: string;
+}
+
+export interface LLMConfigUpdate {
+ name?: string;
+ provider?: string;
+ api_key?: string;
+ api_base_url?: string | null;
+ model_skim?: string;
+ model_deep?: string;
+ model_vision?: string | null;
+ model_embedding?: string;
+ model_fallback?: string;
+}
+
+export interface LLMConfigList {
+ configs: LLMConfigItem[];
+ active_id: string | null;
+}
+
+async function request(path: string, options: RequestInit = {}): Promise {
+ const token = localStorage.getItem("auth_token");
+ const headers: HeadersInit = {
+ "Content-Type": "application/json",
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
+ };
+
+ const url = `${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${path}`;
+ const resp = await fetch(url, {
+ ...options,
+ headers: {
+ ...headers,
+ ...(options.headers as HeadersInit),
+ },
+ });
+
+ if (!resp.ok) {
+ const error = await resp.json().catch(() => ({ detail: "请求失败" }));
+ throw new Error(error.detail || "请求失败");
+ }
+
+ return resp.json();
+}
+
+export const llmConfigApi = {
+ /** 获取所有配置 */
+ async list(): Promise {
+ return request("/llm-configs");
+ },
+
+ /** 获取单个配置 */
+ async get(configId: string): Promise<{ config: LLMConfigItem }> {
+ return request(`/llm-configs/${configId}`);
+ },
+
+ /** 创建配置 */
+ async create(data: LLMConfigCreate): Promise<{ config: LLMConfigItem }> {
+ return request("/llm-configs", {
+ method: "POST",
+ body: JSON.stringify(data),
+ });
+ },
+
+ /** 更新配置 */
+ async update(
+ configId: string,
+ data: LLMConfigUpdate
+ ): Promise<{ config: LLMConfigItem }> {
+ return request(`/llm-configs/${configId}`, {
+ method: "PATCH",
+ body: JSON.stringify(data),
+ });
+ },
+
+ /** 删除配置 */
+ async delete(configId: string): Promise<{ message: string }> {
+ return request(`/llm-configs/${configId}`, {
+ method: "DELETE",
+ });
+ },
+
+ /** 激活配置 */
+ async activate(configId: string): Promise<{ config: LLMConfigItem }> {
+ return request("/llm-configs/activate", {
+ method: "POST",
+ body: JSON.stringify({ config_id: configId }),
+ });
+ },
+};
diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts
index 6eea8be..f70015b 100644
--- a/frontend/src/types/index.ts
+++ b/frontend/src/types/index.ts
@@ -659,6 +659,28 @@ export interface IngestResult {
papers?: IngestPaper[];
}
+/* ========== 多源搜索 ========== */
+export interface MultiSourcePaper {
+ id: string;
+ title: string;
+ authors?: string[];
+ abstract?: string;
+ year?: number | null;
+ venue?: string | null;
+ sources: { channel: string; [key: string]: unknown }[];
+}
+
+export interface MultiSourceSearchResult {
+ papers: MultiSourcePaper[];
+ channel_stats?: Record;
+}
+
+export interface ChannelSuggestion {
+ recommended: string[];
+ alternatives: string[];
+ reasoning: string;
+}
+
/* ========== 聊天消息 ========== */
export interface ChatMessage {
id: string;
diff --git a/packages/ai/agent_service.py b/packages/ai/agent_service.py
index 66ea3ca..eeb03bf 100644
--- a/packages/ai/agent_service.py
+++ b/packages/ai/agent_service.py
@@ -7,6 +7,7 @@
import json
import logging
+import time
from typing import TYPE_CHECKING
from packages.agent_core.context_compaction import (
@@ -51,6 +52,9 @@
→ 调 search_arxiv 获取候选
→ **停下来**,等用户在前端界面勾选要入库的论文
→ 用户确认后调 ingest_arxiv(arxiv_ids=[用户选的])
+ → 调用 ingest_arxiv 前,**必须**先在文本消息中逐条列出每篇候选的
+ 「标题 + 第一作者 + 年份 + arXiv ID」,严禁只给出 arxiv_ids 列表让用户盲确认。
+ 用户对"看不见的 ID"没有判断依据,这是硬性规则。
4. **分析论文**("粗读"、"精读"、"分析图表")
→ 先确认目标论文 ID,再调对应工具
@@ -105,12 +109,40 @@
_ACTION_TTL = 1800 # 30 分钟
+# 已处理(确认/拒绝)过的 action_id → 时间戳;用于幂等保护,避免重复 confirm 报"已过期"
+_HANDLED_ACTION_CACHE: dict[str, float] = {}
+_HANDLED_ACTION_TTL = 3600.0 # 1 小时
+
def _make_sse(event: str, data: dict) -> str:
"""格式化 SSE 事件"""
return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n"
+def _mark_action_handled(action_id: str) -> None:
+ """标记 action 已被处理过(确认/拒绝),避免重复触发时报'已过期'"""
+ _HANDLED_ACTION_CACHE[action_id] = time.time()
+ # 控制缓存膨胀
+ if len(_HANDLED_ACTION_CACHE) > 256:
+ now = time.time()
+ expired = [
+ aid for aid, ts in _HANDLED_ACTION_CACHE.items() if now - ts > _HANDLED_ACTION_TTL
+ ]
+ for aid in expired:
+ _HANDLED_ACTION_CACHE.pop(aid, None)
+
+
+def _is_action_handled(action_id: str) -> bool:
+ """判断 action 是否已被处理过(幂等保护)"""
+ ts = _HANDLED_ACTION_CACHE.get(action_id)
+ if ts is None:
+ return False
+ if time.time() - ts > _HANDLED_ACTION_TTL:
+ _HANDLED_ACTION_CACHE.pop(action_id, None)
+ return False
+ return True
+
+
def _record_agent_usage(
provider: str,
model: str,
@@ -282,18 +314,21 @@ def stream_chat(
if confirmed_action_id:
action = _load_pending_action(confirmed_action_id)
if not action:
+ # 幂等保护:已处理过的 action 给中性提示,不再报"已过期"
+ already_handled = _is_action_handled(confirmed_action_id)
+ err_msg = (
+ "该操作已处理过,请继续后续对话。"
+ if already_handled
+ else "该操作已过期(可能因为服务重启或超时)。请重新描述您的需求,Agent 会重新发起操作。"
+ )
def _err_iter():
- yield _make_sse(
- "error",
- {
- "message": "该操作已过期(可能因为服务重启或超时)。请重新描述您的需求,Agent 会重新发起操作。"
- },
- )
+ yield _make_sse("error", {"message": err_msg})
yield _make_sse("done", {})
return _err_iter(), conversation
+ _mark_action_handled(confirmed_action_id)
loop = _create_loop(conversation)
def _confirm_iter():
@@ -318,18 +353,23 @@ def confirm_action(action_id: str) -> tuple[Iterator[str], list[dict]]:
action = _load_pending_action(action_id)
if not action:
+ # 幂等保护:之前确认/拒绝过就明确提示,不再和"真过期"混淆
+ already_handled = _is_action_handled(action_id)
+ err_msg = (
+ "该操作已处理过,请继续后续对话。"
+ if already_handled
+ else "该操作已过期(可能因为服务重启或超时)。请重新描述您的需求,Agent 会重新发起操作。"
+ )
def _err_iter():
- yield _make_sse(
- "error",
- {
- "message": "该操作已过期(可能因为服务重启或超时)。请重新描述您的需求,Agent 会重新发起操作。"
- },
- )
+ yield _make_sse("error", {"message": err_msg})
yield _make_sse("done", {})
return _err_iter(), []
+ # 立即标记已处理,防止用户双击 / 网络重试导致二次执行
+ _mark_action_handled(action_id)
+
# 删除 pending action
try:
with session_scope() as session:
@@ -354,8 +394,9 @@ def reject_action(action_id: str) -> tuple[Iterator[str], list[dict]]:
action = _load_pending_action(action_id)
- # 删除 pending action
+ # 标记已处理 + 删除 pending action
if action:
+ _mark_action_handled(action_id)
try:
with session_scope() as session:
repo = AgentPendingActionRepository(session)
diff --git a/packages/ai/agent_tools.py b/packages/ai/agent_tools.py
index b8f7783..d14bb0b 100644
--- a/packages/ai/agent_tools.py
+++ b/packages/ai/agent_tools.py
@@ -6,8 +6,8 @@
from __future__ import annotations
import logging
-from collections.abc import Iterator
from dataclasses import dataclass, field
+from typing import TYPE_CHECKING
from uuid import UUID, uuid4
from packages.ai.brief_service import DailyBriefService
@@ -21,6 +21,9 @@
TopicRepository,
)
+if TYPE_CHECKING:
+ from collections.abc import Iterator
+
logger = logging.getLogger(__name__)
@@ -194,6 +197,19 @@ class ToolDef:
"description": "最大搜索数量",
"default": 20,
},
+ "days_back": {
+ "type": "integer",
+ "description": (
+ "只检索最近 N 天提交的论文(默认 0 = 不限日期,可搜到经典/老论文)。"
+ "需要最新增量时传 7 或 30。"
+ ),
+ "default": 0,
+ },
+ "sort_by": {
+ "type": "string",
+ "description": "排序方式:relevance(相关性,默认)/ submittedDate(最新优先)",
+ "default": "relevance",
+ },
},
"required": ["query"],
},
@@ -648,7 +664,7 @@ def _get_timeline(keyword: str, limit: int = 100) -> ToolResult:
try:
result = GraphService().timeline(keyword=keyword, limit=limit)
tl = result.get("timeline", [])
- years = sorted(set(p.get("year") for p in tl if p.get("year")))
+ years = sorted({p.get("year") for p in tl if p.get("year")})
year_range = (
f"{years[0]}-{years[-1]}" if len(years) >= 2 else (str(years[0]) if years else "无")
)
@@ -741,12 +757,26 @@ def _get_system_status() -> ToolResult:
return ToolResult(success=False, summary=f"获取系统状态失败: {exc!s}")
-def _search_arxiv(query: str, max_results: int = 20) -> ToolResult:
- """搜索 arXiv,返回候选论文列表(不入库)"""
+def _search_arxiv(
+ query: str,
+ max_results: int = 20,
+ days_back: int = 0,
+ sort_by: str = "relevance",
+) -> ToolResult:
+ """搜索 arXiv,返回候选论文列表(不入库)
+
+ days_back=0(默认)不限日期,适合按关键词检索经典/全时间段论文。
+ 想要最新增量时传 days_back=7/30。
+ """
from packages.integrations.arxiv_client import ArxivClient
try:
- papers = ArxivClient().fetch_latest(query=query, max_results=max_results)
+ papers = ArxivClient().fetch_latest(
+ query=query,
+ max_results=max_results,
+ sort_by=sort_by,
+ days_back=days_back,
+ )
except Exception as exc:
logger.exception("ArXiv search failed: %s", exc)
return ToolResult(success=False, summary=f"ArXiv 搜索失败: {exc!s}")
diff --git a/packages/config.py b/packages/config.py
index 2e77d74..8e9c892 100644
--- a/packages/config.py
+++ b/packages/config.py
@@ -54,6 +54,7 @@ class Settings(BaseSettings):
zhipu_api_key: str | None = None
semantic_scholar_api_key: str | None = None
openalex_email: str | None = None
+ ieee_api_key: str | None = None
# Worker 调度
worker_retry_max: int = 2
diff --git a/packages/domain/model_tier.py b/packages/domain/model_tier.py
new file mode 100644
index 0000000..80cbc2c
--- /dev/null
+++ b/packages/domain/model_tier.py
@@ -0,0 +1,59 @@
+"""
+场景化模型配置 - 按使用场景分配不同成本的模型
+"""
+
+from enum import Enum
+
+
+class ModelTier(str, Enum):
+ """模型成本分层"""
+
+ ECONOMY = "economy" # 经济型:最便宜,适合简单任务(摘要、分类、关键词提取)
+ STANDARD = "standard" # 标准型:性价比,适合一般任务(RAG、对话、翻译)
+ PREMIUM = "premium" # 高级型:较贵,适合复杂任务(深度分析、写作、推理)
+ VISION = "vision" # 视觉型:图像/图表理解
+
+
+MODEL_TIER_SCENARIOS = {
+ # Economy 场景 - 快速 + 便宜
+ ModelTier.ECONOMY: [
+ "skim", # 论文粗读
+ "keyword", # 关键词提取
+ "classify", # 分类/标签
+ "embedding", # 向量化
+ "summarize_short", # 短摘要
+ ],
+ # Standard 场景 - 性价比
+ ModelTier.STANDARD: [
+ "translate", # 翻译
+ "rag", # RAG 问答
+ "chat", # Agent 对话
+ "explain", # 概念解释
+ "summarize_medium", # 中等摘要
+ ],
+ # Premium 场景 - 高质量
+ ModelTier.PREMIUM: [
+ "deep", # 论文精读
+ "writing", # 学术写作
+ "reasoning", # 逻辑推理
+ "figure_analysis", # 图表分析
+ "wiki", # Wiki 生成
+ "summarize_long", # 长文档摘要
+ ],
+ # Vision 场景
+ ModelTier.VISION: [
+ "vision", # 视觉理解
+ "ocr", # OCR 识别
+ ],
+}
+
+
+# 预设模型配置模板(常见服务商)
+PRESET_MODEL_CONFIGS = {
+ "zhipu": {
+ ModelTier.ECONOMY: "glm-4.7", # 统一使用 GLM-4.7
+ ModelTier.STANDARD: "glm-4.7", # 统一使用 GLM-4.7
+ ModelTier.PREMIUM: "glm-4.7", # 统一使用 GLM-4.7
+ ModelTier.VISION: "glm-4.6v", # 视觉专用
+ },
+}
diff --git a/packages/integrations/arxiv_channel.py b/packages/integrations/arxiv_channel.py
index 83e4519..4407f63 100644
--- a/packages/integrations/arxiv_channel.py
+++ b/packages/integrations/arxiv_channel.py
@@ -5,9 +5,9 @@
@author Color2333
"""
+from packages.domain.schemas import PaperCreate
from packages.integrations.arxiv_client import ArxivClient
from packages.integrations.channel_base import ChannelBase
-from packages.domain.schemas import PaperCreate
class ArxivChannel(ChannelBase):
@@ -44,9 +44,8 @@ def fetch(self, query: str, max_results: int = 20) -> list[PaperCreate]:
Returns:
list[PaperCreate]: 论文列表,source 字段统一设置为 "arxiv"
"""
- papers = self._client.fetch_latest(query, max_results)
+ papers = self._client.fetch_latest(query, max_results, days_back=0)
- # 统一设置 source 字段
for paper in papers:
paper.source = "arxiv"
paper.source_id = paper.arxiv_id
@@ -65,7 +64,7 @@ def download_pdf(self, arxiv_id: str) -> str | None:
"""
try:
return self._client.download_pdf(arxiv_id)
- except Exception as exc:
+ except Exception:
return None
def supports_incremental(self) -> bool:
diff --git a/packages/integrations/arxiv_client.py b/packages/integrations/arxiv_client.py
index 6cf3fa9..27e4dc5 100644
--- a/packages/integrations/arxiv_client.py
+++ b/packages/integrations/arxiv_client.py
@@ -2,6 +2,7 @@
import logging
import re
+import time
import xml.etree.ElementTree as ElementTree
from datetime import date, datetime, timedelta
@@ -15,32 +16,38 @@
logger = logging.getLogger(__name__)
-def _build_arxiv_query(raw: str, days_back: int = 7) -> str:
+def _build_arxiv_query(raw: str, days_back: int = 0) -> str:
"""将用户输入转换为 ArXiv API 查询语法
- 已是结构化查询(含 all:/ti: 等)直接返回
- - 否则按空格拆分,取前 3 个关键词用 AND 连接(避免 429)
- - 当 days_back > 0 时自动添加最近 N 天的日期范围过滤
+ - 带引号则识别为精确短语搜索
+ - 否则按空格拆分,取前 6 个关键词用 AND 连接
+ - 当 days_back > 0 时自动添加最近 N 天的日期范围过滤(默认 0 = 不过滤)
"""
raw = raw.strip()
if not raw:
return raw
- # 日期过滤(days_back <= 0 时不添加)
date_filter = ""
if days_back > 0:
from_date = datetime.now() - timedelta(days=days_back)
date_filter = f" AND submittedDate:[{from_date.strftime('%Y%m%d')}000000 TO *]"
if re.search(r"\b(all|ti|au|abs|cat|co|jr|rn|id):", raw):
- # 已经是结构化查询,检查是否已有日期过滤
if "submittedDate:" not in raw:
return raw + date_filter
return raw
- # 拆分词汇,跳过短词(<2 字符),最多取 3 个
+
+ # 整串带引号 → 当作精确短语搜索
+ quoted = re.match(r'^"(.+)"$', raw)
+ if quoted:
+ phrase = quoted.group(1).strip()
+ return f'all:"{phrase}"' + date_filter
+
+ # 拆词:跳过短词(<2 字符),最多取 6 个(原为 3,易把多关键词查询截断)
tokens = [t.strip() for t in raw.split() if len(t.strip()) >= 2]
if not tokens:
return f"all:{raw}"
- tokens = tokens[:3]
+ tokens = tokens[:6]
return " AND ".join(f"all:{t}" for t in tokens) + date_filter
@@ -61,9 +68,13 @@ def fetch_latest(
max_results: int = 20,
sort_by: str = "submittedDate",
start: int = 0,
- days_back: int = 7,
+ days_back: int = 0,
) -> list[PaperCreate]:
- """sort_by: submittedDate(最新) / relevance(相关性) / lastUpdatedDate"""
+ """sort_by: submittedDate(最新) / relevance(相关性) / lastUpdatedDate
+
+ days_back 默认 0 = 不加日期过滤(否则经典老论文如 OpenShape/Uni3D 都会被筛掉)。
+ 订阅/定时任务需要最新增量时,由调用方显式传 days_back。
+ """
# 获取速率限制许可(10 秒超时)
if not acquire_api("arxiv", timeout=10.0):
raise httpx.TimeoutException("ArXiv 速率限制等待超时,请稍后重试")
@@ -170,7 +181,6 @@ def fetch_categories(self) -> list[dict]:
{"code": "cs.CL", "name": "Computation and Language", "description": ""},
{"code": "cs.AI", "name": "Artificial Intelligence", "description": ""},
{"code": "cs.NE", "name": "Neural and Evolutionary Computing", "description": ""},
- {"code": "cs.CL", "name": "Computational Linguistics", "description": ""},
{"code": "cs.IR", "name": "Information Retrieval", "description": ""},
{"code": "cs.IT", "name": "Information Theory", "description": ""},
{"code": "cs.CR", "name": "Cryptography and Security", "description": ""},
diff --git a/packages/storage/models.py b/packages/storage/models.py
index 6036200..0ad0e58 100644
--- a/packages/storage/models.py
+++ b/packages/storage/models.py
@@ -512,3 +512,64 @@ class CSFeedSubscription(Base):
last_run_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
last_run_count: Mapped[int] = mapped_column(Integer, default=0)
created_at: Mapped[datetime] = mapped_column(DateTime, default=_utcnow)
+
+
+# ========== Sensemaking 认知重构相关 ==========
+
+
+class UserSchema(Base):
+ __tablename__ = "user_schemas"
+
+ id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4()))
+ user_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
+ name: Mapped[str] = mapped_column(String(256), nullable=False)
+
+ research_topics: Mapped[list[str]] = mapped_column(JSON, default=list)
+ academic_level: Mapped[str | None] = mapped_column(String(64), nullable=True)
+ current_challenges: Mapped[list[str]] = mapped_column(JSON, default=list)
+ beliefs: Mapped[list[str]] = mapped_column(JSON, default=list)
+ knowledge_gaps: Mapped[list[str]] = mapped_column(JSON, default=list)
+
+ version: Mapped[int] = mapped_column(Integer, default=1)
+ created_at: Mapped[datetime] = mapped_column(DateTime, default=_utcnow, nullable=False)
+ updated_at: Mapped[datetime] = mapped_column(
+ DateTime, default=_utcnow, onupdate=_utcnow, nullable=False
+ )
+
+
+class SensemakingSession(Base):
+ __tablename__ = "sensemaking_sessions"
+
+ id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4()))
+ paper_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
+ user_schema_id: Mapped[str] = mapped_column(
+ String(36), ForeignKey("user_schemas.id", ondelete="CASCADE"), nullable=False
+ )
+
+ act1_comprehension: Mapped[dict | None] = mapped_column(JSON, nullable=True)
+ act2_collision: Mapped[dict | None] = mapped_column(JSON, nullable=True)
+ act3_reconstruction: Mapped[dict | None] = mapped_column(JSON, nullable=True)
+
+ status: Mapped[str] = mapped_column(String(32), default="in_progress")
+ conversation_history: Mapped[list[dict]] = mapped_column(JSON, default=list)
+
+ created_at: Mapped[datetime] = mapped_column(DateTime, default=_utcnow, nullable=False)
+ updated_at: Mapped[datetime] = mapped_column(
+ DateTime, default=_utcnow, onupdate=_utcnow, nullable=False
+ )
+ completed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
+
+
+class SchemaPaperInteraction(Base):
+ __tablename__ = "schema_paper_interactions"
+
+ id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4()))
+ user_schema_id: Mapped[str] = mapped_column(
+ String(36), ForeignKey("user_schemas.id", ondelete="CASCADE"), nullable=False, index=True
+ )
+ paper_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
+
+ interaction_type: Mapped[str] = mapped_column(String(64), nullable=False)
+ cognitive_delta: Mapped[dict | None] = mapped_column(JSON, nullable=True)
+
+ created_at: Mapped[datetime] = mapped_column(DateTime, default=_utcnow, nullable=False)