refactor: 大文件模块化拆分、自进化Skill管线、前端设计系统统一

后端:
  - runtime: 拆分 AgentConfig/events/duplicate_detector 为独立模块
  - error_recovery: 1499行单体拆为 classification(21种FailoverReason)/overflow/mod
  - executor: 提取 helpers.rs (PreparedCall/ToolExecutionResult/execute_single_tool)
  - skills: 新增 SkillCreator + SelfImprovePipeline(模式检测→自动生成SKILL.md→质量审查)
  - clients/llm: 拆分为 chat/embedding/types 三个子模块
  - services/download: 1548行拆为 mod/headers(反爬+SSRF)/strategies(多级回退)
  - services/batch/asset: 1264行拆为 mod/helpers/process

  前端:
  - 设计系统统一: sky/indigo → blueprint 色系, rounded-xl→lg, shadow-lg→sm
  - 删除 Vite 模板残留 App.css
  - GlobalDialog/PaperDetailModal/UncachedPaperModal 提取公共 BaseModal 组件
This commit is contained in:
Asfmq 2026-06-27 09:56:36 +08:00
parent 5db4cc5998
commit 5f2d2d83f6
62 changed files with 6584 additions and 6308 deletions

View File

@ -1,184 +0,0 @@
.counter {
font-size: 16px;
padding: 5px 10px;
border-radius: 5px;
color: var(--accent);
background: var(--accent-bg);
border: 2px solid transparent;
transition: border-color 0.3s;
margin-bottom: 24px;
&:hover {
border-color: var(--accent-border);
}
&:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
}
.hero {
position: relative;
.base,
.framework,
.vite {
inset-inline: 0;
margin: 0 auto;
}
.base {
width: 170px;
position: relative;
z-index: 0;
}
.framework,
.vite {
position: absolute;
}
.framework {
z-index: 1;
top: 34px;
height: 28px;
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
scale(1.4);
}
.vite {
z-index: 0;
top: 107px;
height: 26px;
width: auto;
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
scale(0.8);
}
}
#center {
display: flex;
flex-direction: column;
gap: 25px;
place-content: center;
place-items: center;
flex-grow: 1;
@media (max-width: 1024px) {
padding: 32px 20px 24px;
gap: 18px;
}
}
#next-steps {
display: flex;
border-top: 1px solid var(--border);
text-align: left;
& > div {
flex: 1 1 0;
padding: 32px;
@media (max-width: 1024px) {
padding: 24px 20px;
}
}
.icon {
margin-bottom: 16px;
width: 22px;
height: 22px;
}
@media (max-width: 1024px) {
flex-direction: column;
text-align: center;
}
}
#docs {
border-right: 1px solid var(--border);
@media (max-width: 1024px) {
border-right: none;
border-bottom: 1px solid var(--border);
}
}
#next-steps ul {
list-style: none;
padding: 0;
display: flex;
gap: 8px;
margin: 32px 0 0;
.logo {
height: 18px;
}
a {
color: var(--text-h);
font-size: 16px;
border-radius: 6px;
background: var(--social-bg);
display: flex;
padding: 6px 12px;
align-items: center;
gap: 8px;
text-decoration: none;
transition: box-shadow 0.3s;
&:hover {
box-shadow: var(--shadow);
}
.button-icon {
height: 18px;
width: 18px;
}
}
@media (max-width: 1024px) {
margin-top: 20px;
flex-wrap: wrap;
justify-content: center;
li {
flex: 1 1 calc(50% - 8px);
}
a {
width: 100%;
justify-content: center;
box-sizing: border-box;
}
}
}
#spacer {
height: 88px;
border-top: 1px solid var(--border);
@media (max-width: 1024px) {
height: 48px;
}
}
.ticks {
position: relative;
width: 100%;
&::before,
&::after {
content: '';
position: absolute;
top: -4.5px;
border: 5px solid transparent;
}
&::before {
left: 0;
border-left-color: var(--border);
}
&::after {
right: 0;
border-right-color: var(--border);
}
}

View File

@ -244,7 +244,7 @@ export default function App() {
<div className="absolute top-1/4 left-1/4 w-[500px] h-[500px] bg-sky-200/20 rounded-full blur-3xl" /> <div className="absolute top-1/4 left-1/4 w-[500px] h-[500px] bg-sky-200/20 rounded-full blur-3xl" />
<div className="absolute bottom-1/4 right-1/4 w-[500px] h-[500px] bg-indigo-200/20 rounded-full blur-3xl" /> <div className="absolute bottom-1/4 right-1/4 w-[500px] h-[500px] bg-indigo-200/20 rounded-full blur-3xl" />
<div className="console-panel rounded-xl p-8 max-w-sm w-full mx-4 shadow-sm z-10 relative bg-white border border-[#d2d8e2]"> <div className="console-panel rounded-lg p-8 max-w-sm w-full mx-4 shadow-sm z-10 relative bg-white border border-[#d2d8e2]">
<div className="flex flex-col items-center text-center mb-7 select-none"> <div className="flex flex-col items-center text-center mb-7 select-none">
<div className="w-14 h-14 mb-3"> <div className="w-14 h-14 mb-3">
<svg width="100%" height="100%" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg" className="w-full h-full"> <svg width="100%" height="100%" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg" className="w-full h-full">

View File

@ -315,7 +315,7 @@ export function AIAssistantPanel({
<button <button
key={idx} key={idx}
onClick={() => handleSend(q)} onClick={() => handleSend(q)}
className="w-full text-left bg-white hover:bg-slate-100 border border-slate-200 rounded-lg p-3 text-xs text-slate-700 leading-relaxed font-medium transition-all shadow-sm cursor-pointer hover:border-sky-300" className="w-full text-left bg-white hover:bg-slate-55 border border-slate-200 rounded-md p-3 text-xs text-slate-700 leading-relaxed font-medium transition-all cursor-pointer hover:border-slate-350"
> >
{q} {q}
</button> </button>
@ -333,10 +333,10 @@ export function AIAssistantPanel({
{msg.sender === 'user' ? '我' : '天文科研助手'} {msg.sender === 'user' ? '我' : '天文科研助手'}
</span> </span>
<div <div
className={`max-w-[95%] rounded-xl px-4 py-3 text-xs leading-relaxed font-medium shadow-sm border ${ className={`max-w-[95%] rounded-lg px-4 py-3 text-xs leading-relaxed font-medium shadow-xs border ${
msg.sender === 'user' msg.sender === 'user'
? 'bg-sky-600 text-white border-sky-600 select-text' ? 'bg-blueprint text-white border-blueprint select-text'
: 'bg-white text-slate-800 border-slate-200 select-text' : 'bg-white text-slate-800 border-slate-250 select-text'
}`} }`}
> >
{msg.sender === 'user' ? ( {msg.sender === 'user' ? (
@ -354,9 +354,9 @@ export function AIAssistantPanel({
<div className="space-y-2.5"> <div className="space-y-2.5">
{/* 时间线条目(思考/工具调用/子代理容器)*/} {/* 时间线条目(思考/工具调用/子代理容器)*/}
{msg.timeline.length > 0 && ( {msg.timeline.length > 0 && (
<div className="border border-slate-100 bg-slate-50/50 rounded-lg p-2 select-none"> <div className="border border-slate-205 bg-slate-100/30 rounded-md p-2 select-none">
<div className="text-[10px] font-bold text-purple-500 flex items-center gap-1 mb-2 px-1"> <div className="text-[10px] font-bold text-slate-600 flex items-center gap-1.5 mb-2 px-1">
<Brain className="w-3.5 h-3.5" /> <Brain className="w-3.5 h-3.5 text-slate-450" />
<span> ({msg.timeline.length} )</span> <span> ({msg.timeline.length} )</span>
</div> </div>
<div className="pl-4 border-l border-slate-200 space-y-2"> <div className="pl-4 border-l border-slate-200 space-y-2">
@ -442,7 +442,7 @@ export function AIAssistantPanel({
}} }}
className="absolute bottom-0 left-0 right-0 p-3 bg-transparent pointer-events-none shrink-0" className="absolute bottom-0 left-0 right-0 p-3 bg-transparent pointer-events-none shrink-0"
> >
<div className="bg-slate-50 border border-slate-200/60 rounded-2xl p-2.5 focus-within:bg-white focus-within:border-sky-500 focus-within:ring-1 focus-within:ring-sky-500/10 transition-all relative pointer-events-auto shadow-md flex flex-col gap-2"> <div className="bg-slate-50 border border-slate-300 rounded-lg p-2.5 focus-within:bg-white focus-within:border-blueprint focus-within:ring-1 focus-within:ring-blueprint/10 transition-all relative pointer-events-auto shadow-sm flex flex-col gap-2">
<textarea <textarea
value={input} value={input}
onChange={(e) => setInput(e.target.value)} onChange={(e) => setInput(e.target.value)}

View File

@ -64,7 +64,7 @@ export function CitationGalaxyCanvas({ networks, activeNetwork, nodeLimit, onNod
vx: 0, vx: 0,
vy: 0, vy: 0,
radius, radius,
color: isActive ? '#0284c7' : '#475569', color: isActive ? '#0369a1' : '#475569',
type: 'center', type: 'center',
inDb: true, inDb: true,
}); });
@ -86,7 +86,7 @@ export function CitationGalaxyCanvas({ networks, activeNetwork, nodeLimit, onNod
vx: 0, vx: 0,
vy: 0, vy: 0,
radius: 8, // 初始值,稍后按连线数重算 radius: 8, // 初始值,稍后按连线数重算
color: '#d97706', color: '#b45309',
type: 'reference', type: 'reference',
inDb, inDb,
}); });
@ -112,7 +112,7 @@ export function CitationGalaxyCanvas({ networks, activeNetwork, nodeLimit, onNod
vx: 0, vx: 0,
vy: 0, vy: 0,
radius: 8, // 初始值,稍后按连线数重算 radius: 8, // 初始值,稍后按连线数重算
color: '#0891b2', color: '#0e7490',
type: 'citation', type: 'citation',
inDb, inDb,
}); });
@ -221,12 +221,12 @@ export function CitationGalaxyCanvas({ networks, activeNetwork, nodeLimit, onNod
ctx.scale(scale, scale); ctx.scale(scale, scale);
ctx.translate(-cx, -cy); ctx.translate(-cx, -cy);
// 绘制背景宇宙引力线 & 刻度圈 // 绘制背景宇宙引力线 & 刻度圈 - 极其素雅的学术引力线
const centerNode = nodes.find(n => n.id === activeNetwork.bibcode); const centerNode = nodes.find(n => n.id === activeNetwork.bibcode);
if (centerNode) { if (centerNode) {
ctx.strokeStyle = 'rgba(148, 163, 184, 0.15)'; ctx.strokeStyle = 'rgba(148, 163, 184, 0.08)';
ctx.lineWidth = 1; ctx.lineWidth = 0.75;
ctx.setLineDash([4, 6]); ctx.setLineDash([3, 5]);
ctx.beginPath(); ctx.beginPath();
ctx.arc(centerNode.x, centerNode.y, 130, 0, Math.PI * 2); ctx.arc(centerNode.x, centerNode.y, 130, 0, Math.PI * 2);
@ -236,8 +236,8 @@ export function CitationGalaxyCanvas({ networks, activeNetwork, nodeLimit, onNod
ctx.arc(centerNode.x, centerNode.y, 180, 0, Math.PI * 2); ctx.arc(centerNode.x, centerNode.y, 180, 0, Math.PI * 2);
ctx.stroke(); ctx.stroke();
// 动态 // 动态引力波 - 调低对比度,使其呈极其微弱安静的向外平滑脉冲
ctx.strokeStyle = 'rgba(2, 132, 199, 0.06)'; ctx.strokeStyle = 'rgba(3, 105, 161, 0.025)';
ctx.setLineDash([]); ctx.setLineDash([]);
const pulseRadius = 130 + (frameCount % 120) * 0.4; const pulseRadius = 130 + (frameCount % 120) * 0.4;
ctx.beginPath(); ctx.beginPath();
@ -245,8 +245,8 @@ export function CitationGalaxyCanvas({ networks, activeNetwork, nodeLimit, onNod
ctx.stroke(); ctx.stroke();
} }
// 绘制连线 // 绘制连线 - 精细线宽
ctx.lineWidth = 1; ctx.lineWidth = 0.75;
links.forEach(link => { links.forEach(link => {
const sourceNode = nodes.find(n => n.id === link.source); const sourceNode = nodes.find(n => n.id === link.source);
const targetNode = nodes.find(n => n.id === link.target); const targetNode = nodes.find(n => n.id === link.target);
@ -254,12 +254,13 @@ export function CitationGalaxyCanvas({ networks, activeNetwork, nodeLimit, onNod
ctx.beginPath(); ctx.beginPath();
ctx.moveTo(sourceNode.x, sourceNode.y); ctx.moveTo(sourceNode.x, sourceNode.y);
ctx.lineTo(targetNode.x, targetNode.y); ctx.lineTo(targetNode.x, targetNode.y);
ctx.strokeStyle = 'rgba(148, 163, 184, 0.25)'; ctx.strokeStyle = 'rgba(203, 213, 225, 0.22)';
ctx.stroke(); ctx.stroke();
} }
}); });
// 绘制节点 // 绘制节点
ctx.lineWidth = 1;
nodes.forEach(node => { nodes.forEach(node => {
const isHovered = hoveredNode?.id === node.id; const isHovered = hoveredNode?.id === node.id;
@ -267,8 +268,8 @@ export function CitationGalaxyCanvas({ networks, activeNetwork, nodeLimit, onNod
ctx.globalAlpha = node.inDb ? 1.0 : 0.35; // 未入库文献透明度降为 0.35 ctx.globalAlpha = node.inDb ? 1.0 : 0.35; // 未入库文献透明度降为 0.35
ctx.beginPath(); ctx.beginPath();
ctx.arc(node.x, node.y, node.radius + (isHovered ? 6 : 3), 0, Math.PI * 2); ctx.arc(node.x, node.y, node.radius + (isHovered ? 5 : 2), 0, Math.PI * 2);
ctx.fillStyle = node.color + (isHovered ? '25' : '0f'); ctx.fillStyle = node.color + (isHovered ? '20' : '0a');
ctx.fill(); ctx.fill();
ctx.beginPath(); ctx.beginPath();
@ -277,13 +278,13 @@ export function CitationGalaxyCanvas({ networks, activeNetwork, nodeLimit, onNod
ctx.fill(); ctx.fill();
ctx.beginPath(); ctx.beginPath();
ctx.arc(node.x, node.y, node.radius + (isHovered ? 4 : 2), 0, Math.PI * 2); ctx.arc(node.x, node.y, node.radius + (isHovered ? 3 : 1.5), 0, Math.PI * 2);
ctx.strokeStyle = node.color + '40'; ctx.strokeStyle = node.color + '30';
ctx.lineWidth = 1; ctx.lineWidth = 0.75;
ctx.stroke(); ctx.stroke();
ctx.fillStyle = isHovered ? '#0284c7' : '#334155'; ctx.fillStyle = isHovered ? '#0369a1' : '#475569';
ctx.font = isHovered ? 'bold 10px sans-serif' : '9px sans-serif'; ctx.font = isHovered ? 'bold 9.5px sans-serif' : '9px sans-serif';
ctx.textAlign = 'center'; ctx.textAlign = 'center';
ctx.fillText(node.label, node.x, node.y + node.radius + (isHovered ? 16 : 12)); ctx.fillText(node.label, node.x, node.y + node.radius + (isHovered ? 16 : 12));

View File

@ -47,20 +47,20 @@ export function CustomSelect({
type="button" type="button"
disabled={disabled} disabled={disabled}
onClick={() => setIsOpen(!isOpen)} onClick={() => setIsOpen(!isOpen)}
className={`w-full flex items-center justify-between gap-2 bg-white border border-slate-250 hover:border-slate-350 rounded-lg px-2.5 py-2 text-xs font-semibold text-slate-700 transition-all text-left outline-none cursor-pointer ${ className={`w-full flex items-center justify-between gap-2 bg-white border border-slate-250 hover:border-slate-350 rounded-md px-2.5 py-2 text-xs font-semibold text-slate-700 transition-all text-left outline-none cursor-pointer ${
isOpen ? 'border-sky-500 ring-1 ring-sky-500 bg-white' : '' isOpen ? 'border-blueprint ring-1 ring-blueprint bg-white' : ''
} ${disabled ? 'bg-slate-50 text-slate-450 cursor-not-allowed border-slate-200' : ''}`} } ${disabled ? 'bg-slate-50 text-slate-450 cursor-not-allowed border-slate-200' : ''}`}
> >
<span className="truncate">{selectedOption?.label}</span> <span className="truncate">{selectedOption?.label}</span>
<ChevronDown <ChevronDown
className={`w-3.5 h-3.5 text-slate-400 transition-transform duration-200 shrink-0 ${ className={`w-3.5 h-3.5 text-slate-400 transition-transform duration-200 shrink-0 ${
isOpen ? 'rotate-180 text-sky-500' : '' isOpen ? 'rotate-180 text-blueprint' : ''
}`} }`}
/> />
</button> </button>
{isOpen && !disabled && ( {isOpen && !disabled && (
<div className="absolute left-0 mt-1.5 w-full min-w-[150px] bg-white border border-slate-200 rounded-lg shadow-lg py-1 z-50 max-h-60 overflow-y-auto scrollbar-thin"> <div className="absolute left-0 mt-1.5 w-full min-w-[150px] bg-white border border-slate-200 rounded-md shadow-md py-1 z-50 max-h-60 overflow-y-auto scrollbar-thin">
{options.map(option => { {options.map(option => {
const isSelected = option.value === value; const isSelected = option.value === value;
return ( return (
@ -73,7 +73,7 @@ export function CustomSelect({
}} }}
className={`w-full text-left px-3 py-2 text-xs transition-colors block cursor-pointer outline-none ${ className={`w-full text-left px-3 py-2 text-xs transition-colors block cursor-pointer outline-none ${
isSelected isSelected
? 'bg-sky-50 text-sky-700 font-bold' ? 'bg-blueprint/5 text-blueprint font-bold'
: 'text-slate-600 hover:bg-slate-50 hover:text-slate-900 font-medium' : 'text-slate-600 hover:bg-slate-50 hover:text-slate-900 font-medium'
}`} }`}
> >

View File

@ -0,0 +1,117 @@
import React from 'react';
import type { StandardPaper } from '../types';
import { getDoctypeBadge } from '../utils/paper';
interface PaperCardProps {
paper: StandardPaper;
isSelected?: boolean;
onClick?: () => void;
variant?: 'list' | 'grid';
headerActions?: React.ReactNode;
statusBadge?: React.ReactNode;
abstract?: React.ReactNode;
footerLeft?: React.ReactNode;
footerRight?: React.ReactNode;
}
export function PaperCard({
paper,
isSelected = false,
onClick,
variant = 'list',
headerActions,
statusBadge,
abstract,
footerLeft,
footerRight,
}: PaperCardProps) {
if (variant === 'grid') {
return (
<div
onClick={onClick}
className="console-panel p-5 rounded-lg border border-slate-200 hover:border-slate-400 bg-white flex flex-col justify-between relative overflow-hidden group transition-all cursor-pointer shadow-sm select-none"
>
{/* Top Right Corner Status Badge */}
{statusBadge}
<div className="pr-10">
<div>
<h3 className="font-bold text-xs text-slate-900 line-clamp-2 hover:text-blueprint transition-all leading-relaxed">
{getDoctypeBadge(paper.doctype)}
<span className="align-middle">{paper.title}</span>
</h3>
</div>
<div className="text-[10px] text-slate-500 font-semibold mt-1.5 uppercase">
: {paper.authors.slice(0, 2).join(', ')}{paper.authors.length > 2 ? ' 等' : ''} | : {paper.year}
</div>
</div>
{/* Footer Area */}
{(footerLeft || footerRight) && (
<div className="flex items-center justify-between border-t border-slate-100 pt-3 mt-4 text-[10px]">
<div className="flex flex-col gap-1 flex-1 min-w-0">
{footerLeft}
</div>
{footerRight && (
<div className="flex items-center gap-2 shrink-0 self-end">
{footerRight}
</div>
)}
</div>
)}
</div>
);
}
// default 'list' variant
return (
<div
className={`console-panel p-6 rounded-lg border transition-all relative ${
isSelected ? 'border-slate-800 bg-slate-50/30' : 'border-slate-200 hover:border-slate-350 hover:bg-white'
}`}
>
{/* Selection indicator side bar */}
{isSelected && <div className="absolute top-0 left-0 w-1.5 h-full bg-slate-800 rounded-l-lg" />}
<div className="flex flex-col md:flex-row md:justify-between md:items-start gap-4 mb-3">
<div className="flex-1">
<h3
className="font-bold text-sm text-slate-900 hover:text-slate-800 cursor-pointer transition-all line-clamp-2 leading-relaxed"
onClick={onClick}
>
{getDoctypeBadge(paper.doctype)}
<span className="align-middle">{paper.title}</span>
</h3>
<p className="text-xs text-slate-500 font-medium mt-2 leading-snug">
: <span className="text-slate-800">{paper.authors.slice(0, 5).join(', ')}{paper.authors.length > 5 ? ' 等' : ''}</span> : <span className="text-slate-850 font-bold">{paper.year}</span> : <span className="italic text-slate-700">{paper.pub_journal || '未标注'}</span>
</p>
</div>
{headerActions && (
<div className="flex gap-2 shrink-0 items-center">
{headerActions}
</div>
)}
</div>
{abstract}
{/* Footer Area */}
{(footerLeft || footerRight) && (
<div className="flex flex-col sm:flex-row justify-between items-stretch sm:items-center border-t border-slate-100 pt-4 gap-3">
{footerLeft && (
<div className="flex gap-2">
{footerLeft}
</div>
)}
{footerRight && (
<div className="text-xs text-slate-450 flex flex-wrap gap-x-4 gap-y-1 font-mono">
{footerRight}
</div>
)}
</div>
)}
</div>
);
}

View File

@ -58,7 +58,7 @@ export function AgentInputArea({
}} }}
className="max-w-4xl mx-auto w-full pointer-events-auto" className="max-w-4xl mx-auto w-full pointer-events-auto"
> >
<div className="bg-slate-50 border border-slate-200/60 rounded-2xl p-2.5 focus-within:bg-white focus-within:border-sky-500 focus-within:ring-1 focus-within:ring-sky-500/10 transition-all shadow-lg flex flex-col gap-2"> <div className="bg-slate-50 border border-slate-200/60 rounded-lg p-2.5 focus-within:bg-white focus-within:border-blueprint focus-within:ring-1 focus-within:ring-blueprint/10 transition-all shadow-sm flex flex-col gap-2">
{/* 图片预览 */} {/* 图片预览 */}
{pendingImage && ( {pendingImage && (
<div className="relative inline-block mb-1 group select-none self-start"> <div className="relative inline-block mb-1 group select-none self-start">
@ -113,8 +113,8 @@ export function AgentInputArea({
title="上传或粘贴图片(也可直接 Ctrl+V 粘贴)" title="上传或粘贴图片(也可直接 Ctrl+V 粘贴)"
className={`p-1.5 rounded-lg border text-[10px] font-bold transition-all cursor-pointer flex items-center gap-1 ${ className={`p-1.5 rounded-lg border text-[10px] font-bold transition-all cursor-pointer flex items-center gap-1 ${
pendingImage pendingImage
? 'bg-sky-50 border-sky-300 text-sky-700' ? 'bg-blueprint/5 border-blueprint/30 text-blueprint'
: 'bg-white border-slate-200 text-slate-400 hover:text-sky-600 hover:border-sky-200' : 'bg-white border-slate-200 text-slate-400 hover:text-blueprint hover:border-blueprint/20'
} disabled:opacity-60`} } disabled:opacity-60`}
> >
<Paperclip className="w-3.5 h-3.5" /> <Paperclip className="w-3.5 h-3.5" />
@ -134,11 +134,11 @@ export function AgentInputArea({
title={m.description} title={m.description}
className={`px-2 py-1.5 rounded-md text-[10px] font-extrabold transition-all border cursor-pointer flex items-center gap-1 shrink-0 ${ className={`px-2 py-1.5 rounded-md text-[10px] font-extrabold transition-all border cursor-pointer flex items-center gap-1 shrink-0 ${
isSelected isSelected
? 'bg-white text-indigo-700 shadow-xs border-slate-200/50' ? 'bg-white text-blueprint shadow-xs border-slate-200/50'
: 'bg-transparent border-transparent text-slate-400 hover:text-indigo-600' : 'bg-transparent border-transparent text-slate-400 hover:text-blueprint'
} disabled:opacity-60`} } disabled:opacity-60`}
> >
<IconComp className={`w-3.5 h-3.5 ${isSelected ? 'text-indigo-500' : ''}`} /> <IconComp className={`w-3.5 h-3.5 ${isSelected ? 'text-blueprint' : ''}`} />
<span className="hidden sm:inline">{m.name}</span> <span className="hidden sm:inline">{m.name}</span>
</button> </button>
); );
@ -156,11 +156,11 @@ export function AgentInputArea({
title={thinking ? '思考模式已开启(启用 LLM 推理过程)' : '思考模式已关闭(点击开启)'} title={thinking ? '思考模式已开启(启用 LLM 推理过程)' : '思考模式已关闭(点击开启)'}
className={`px-2.5 py-1.5 rounded-lg border text-[10px] font-bold transition-all cursor-pointer flex items-center gap-1 shrink-0 ${ className={`px-2.5 py-1.5 rounded-lg border text-[10px] font-bold transition-all cursor-pointer flex items-center gap-1 shrink-0 ${
thinking thinking
? 'bg-sky-50 border-sky-205 text-sky-700 shadow-3xs' ? 'bg-blueprint/5 border-blueprint/30 text-blueprint shadow-3xs'
: 'bg-white border-slate-200 text-slate-400 hover:text-sky-600 hover:border-sky-200' : 'bg-white border-slate-200 text-slate-400 hover:text-blueprint hover:border-blueprint/20'
} disabled:opacity-60`} } disabled:opacity-60`}
> >
<Brain className={`w-3.5 h-3.5 ${thinking ? 'text-sky-500' : ''}`} /> <Brain className={`w-3.5 h-3.5 ${thinking ? 'text-blueprint' : ''}`} />
<span className="hidden sm:inline"></span> <span className="hidden sm:inline"></span>
</button> </button>
</> </>
@ -172,7 +172,7 @@ export function AgentInputArea({
<button <button
type="button" type="button"
onClick={onStop} onClick={onStop}
className="p-2 rounded-xl bg-slate-800 hover:bg-slate-950 text-white transition-colors cursor-pointer flex items-center justify-center shadow-xs" className="p-2 rounded-md bg-slate-800 hover:bg-slate-950 text-white transition-colors cursor-pointer flex items-center justify-center shadow-xs"
title="手动停止执行" title="手动停止执行"
> >
<Square className="w-3.5 h-3.5 fill-white" /> <Square className="w-3.5 h-3.5 fill-white" />
@ -181,7 +181,7 @@ export function AgentInputArea({
<button <button
type="submit" type="submit"
disabled={!input.trim()} disabled={!input.trim()}
className="p-2 rounded-xl bg-sky-600 hover:bg-sky-700 text-white disabled:bg-slate-100 disabled:text-slate-350 transition-colors cursor-pointer flex items-center justify-center shadow-xs" className="p-2 rounded-md bg-blueprint hover:bg-[#0d5988] text-white disabled:bg-slate-100 disabled:text-slate-350 transition-colors cursor-pointer flex items-center justify-center shadow-xs"
> >
<Send className="w-3.5 h-3.5" /> <Send className="w-3.5 h-3.5" />
</button> </button>

View File

@ -200,8 +200,8 @@ export function AgentMessageList({
onClick={() => { setShowMetrics(!showMetrics); setShowAuditLog(false); }} onClick={() => { setShowMetrics(!showMetrics); setShowAuditLog(false); }}
className={`p-1.5 rounded-lg border text-xs font-bold transition-all cursor-pointer flex items-center gap-1 ${ className={`p-1.5 rounded-lg border text-xs font-bold transition-all cursor-pointer flex items-center gap-1 ${
showMetrics showMetrics
? 'bg-sky-50 border-sky-200 text-sky-700' ? 'bg-blueprint/5 border-blueprint/20 text-blueprint'
: 'bg-white border-slate-200 text-slate-500 hover:text-sky-600 hover:border-sky-200' : 'bg-white border-slate-200 text-slate-500 hover:text-blueprint hover:border-blueprint/20'
}`} }`}
title="运行指标" title="运行指标"
> >
@ -276,9 +276,9 @@ export function AgentMessageList({
<button <button
key={idx} key={idx}
onClick={() => handleSend(q)} onClick={() => handleSend(q)}
className="w-full text-left bg-white hover:bg-sky-50/20 border border-slate-200 hover:border-sky-300 rounded-xl p-3.5 text-xs text-slate-700 leading-relaxed font-medium transition-all shadow-2xs hover:shadow-xs cursor-pointer flex flex-col gap-1 group" className="w-full text-left bg-white hover:bg-blueprint/5 border border-slate-200 hover:border-blueprint/30 rounded-lg p-3 text-xs text-slate-700 leading-relaxed font-medium transition-all shadow-2xs hover:shadow-xs cursor-pointer flex flex-col gap-1 group"
> >
<span className="group-hover:text-sky-700 font-semibold">{q}</span> <span className="group-hover:text-blueprint font-semibold">{q}</span>
<span className="text-[9px] text-slate-400 uppercase font-bold tracking-wider"></span> <span className="text-[9px] text-slate-400 uppercase font-bold tracking-wider"></span>
</button> </button>
))} ))}
@ -303,9 +303,9 @@ export function AgentMessageList({
<Rewind className="w-3.5 h-3.5" /> <Rewind className="w-3.5 h-3.5" />
</button> </button>
)} )}
<div className="rounded-2xl px-4 py-3 text-xs leading-relaxed font-semibold shadow-2xs border bg-sky-600 text-white border-sky-600 select-text flex-1 space-y-2"> <div className="rounded-lg px-4 py-3 text-xs leading-relaxed font-semibold shadow-2xs border bg-blueprint text-white border-blueprint select-text flex-1 space-y-2">
{turn.imagePath && ( {turn.imagePath && (
<img src={turn.imagePath} alt="用户上传的图片" className="max-h-48 rounded-lg border border-sky-500/30" /> <img src={turn.imagePath} alt="用户上传的图片" className="max-h-48 rounded-lg border border-blueprint/30" />
)} )}
<div>{turn.question}</div> <div>{turn.question}</div>
</div> </div>
@ -334,9 +334,9 @@ export function AgentMessageList({
{/* 当前提问 */} {/* 当前提问 */}
<div className="flex flex-col items-end space-y-1"> <div className="flex flex-col items-end space-y-1">
<span className="text-[10px] font-bold text-slate-400 px-1"></span> <span className="text-[10px] font-bold text-slate-400 px-1"></span>
<div className="max-w-[85%] rounded-2xl px-4 py-3 text-xs leading-relaxed font-semibold shadow-2xs border bg-sky-600 text-white border-sky-600 select-text space-y-2"> <div className="max-w-[85%] rounded-lg px-4 py-3 text-xs leading-relaxed font-semibold shadow-2xs border bg-blueprint text-white border-blueprint select-text space-y-2">
{activeTurn.imagePath && ( {activeTurn.imagePath && (
<img src={activeTurn.imagePath} alt="用户上传的图片" className="max-h-48 rounded-lg border border-sky-500/30" /> <img src={activeTurn.imagePath} alt="用户上传的图片" className="max-h-48 rounded-lg border border-blueprint/30" />
)} )}
<div>{activeTurn.question}</div> <div>{activeTurn.question}</div>
</div> </div>
@ -353,10 +353,10 @@ export function AgentMessageList({
{activeTurn.finalAnswer && ( {activeTurn.finalAnswer && (
<div className="flex flex-col items-start space-y-1"> <div className="flex flex-col items-start space-y-1">
<span className="text-[10px] font-bold text-slate-400 px-1 flex items-center gap-1"> <span className="text-[10px] font-bold text-slate-400 px-1 flex items-center gap-1">
<CheckCircle2 className="w-3.5 h-3.5 text-sky-600 animate-pulse" /> <CheckCircle2 className="w-3.5 h-3.5 text-blueprint" />
<span></span> <span></span>
</span> </span>
<div className="max-w-[90%] rounded-2xl px-5 py-4 text-xs leading-relaxed font-semibold shadow-xs border bg-white text-slate-800 border-slate-200 select-text prose prose-sm max-w-none prose-headings:text-slate-900 prose-headings:font-bold prose-strong:text-slate-900 prose-code:text-sky-700 prose-img:rounded-lg"> <div className="max-w-[90%] rounded-lg px-5 py-4 text-xs leading-relaxed font-semibold shadow-xs border bg-white text-slate-800 border-slate-200 select-text prose prose-sm max-w-none prose-headings:text-slate-900 prose-headings:font-bold prose-strong:text-slate-900 prose-code:text-blueprint prose-img:rounded-lg">
<AgentMarkdown>{activeTurn.finalAnswer}</AgentMarkdown> <AgentMarkdown>{activeTurn.finalAnswer}</AgentMarkdown>
</div> </div>
</div> </div>
@ -365,7 +365,7 @@ export function AgentMessageList({
{/* 活跃的加载指示器 */} {/* 活跃的加载指示器 */}
{streaming && !activeTurn.finalAnswer && !activeTurn.error && ( {streaming && !activeTurn.finalAnswer && !activeTurn.error && (
<div className="flex items-center space-x-2 text-slate-500 pl-2"> <div className="flex items-center space-x-2 text-slate-500 pl-2">
<Loader className="w-4 h-4 animate-spin text-sky-600" /> <Loader className="w-4 h-4 animate-spin text-blueprint" />
<span className="text-[10px] font-bold">...</span> <span className="text-[10px] font-bold">...</span>
</div> </div>
)} )}

View File

@ -107,7 +107,7 @@ export function AgentMetricsPanel({ showAlert }: AgentMetricsPanelProps) {
{/* 头部 */} {/* 头部 */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<BarChart3 className="w-4 h-4 text-sky-600" /> <BarChart3 className="w-4 h-4 text-blueprint" />
<h3 className="text-xs font-extrabold text-slate-800 tracking-wide"> <h3 className="text-xs font-extrabold text-slate-800 tracking-wide">
</h3> </h3>
@ -115,7 +115,7 @@ export function AgentMetricsPanel({ showAlert }: AgentMetricsPanelProps) {
<button <button
onClick={fetchMetrics} onClick={fetchMetrics}
disabled={loading} disabled={loading}
className="p-1.5 rounded-lg bg-slate-100 hover:bg-slate-200 text-slate-500 hover:text-slate-700 transition-colors cursor-pointer disabled:opacity-50" className="p-1.5 rounded-md bg-slate-100 hover:bg-slate-200 text-slate-500 hover:text-slate-700 transition-colors cursor-pointer disabled:opacity-50"
title="刷新指标" title="刷新指标"
> >
<RefreshCw className={`w-3.5 h-3.5 ${loading ? 'animate-spin' : ''}`} /> <RefreshCw className={`w-3.5 h-3.5 ${loading ? 'animate-spin' : ''}`} />
@ -124,7 +124,7 @@ export function AgentMetricsPanel({ showAlert }: AgentMetricsPanelProps) {
{loading && !metrics ? ( {loading && !metrics ? (
<div className="flex items-center justify-center py-12 text-slate-400 gap-2"> <div className="flex items-center justify-center py-12 text-slate-400 gap-2">
<Loader className="w-4 h-4 animate-spin text-sky-600" /> <Loader className="w-4 h-4 animate-spin text-blueprint" />
<span className="text-xs font-bold">...</span> <span className="text-xs font-bold">...</span>
</div> </div>
) : metrics ? ( ) : metrics ? (
@ -228,15 +228,15 @@ function MetricCard({
color: string; color: string;
}) { }) {
const colorMap: Record<string, string> = { const colorMap: Record<string, string> = {
sky: 'border-sky-200 bg-sky-50 text-sky-700', sky: 'border-slate-200 bg-slate-50 text-blueprint',
emerald: 'border-emerald-200 bg-emerald-50 text-emerald-700', emerald: 'border-emerald-200 bg-emerald-50/40 text-emerald-700',
violet: 'border-violet-200 bg-violet-50 text-violet-700', violet: 'border-violet-200 bg-violet-50/40 text-violet-700',
rose: 'border-rose-200 bg-rose-50 text-rose-700', rose: 'border-rose-200 bg-rose-50/40 text-rose-700',
amber: 'border-amber-200 bg-amber-50 text-amber-700', amber: 'border-amber-200 bg-amber-50/40 text-amber-700',
}; };
return ( return (
<div className={`rounded-xl border p-3.5 ${colorMap[color] || colorMap.sky} transition-all`}> <div className={`rounded-md border p-3 ${colorMap[color] || colorMap.sky} transition-all`}>
<div className="flex items-center gap-1.5 mb-1.5"> <div className="flex items-center gap-1.5 mb-1.5">
<span className="opacity-60">{icon}</span> <span className="opacity-60">{icon}</span>
<span className="text-[10px] font-bold uppercase tracking-wider opacity-70">{label}</span> <span className="text-[10px] font-bold uppercase tracking-wider opacity-70">{label}</span>

View File

@ -42,13 +42,13 @@ export function AgentSessionSidebar({
<div className="flex flex-col min-h-0 flex-1"> <div className="flex flex-col min-h-0 flex-1">
<div className="p-4 border-b border-slate-200 bg-white flex items-center justify-between shrink-0"> <div className="p-4 border-b border-slate-200 bg-white flex items-center justify-between shrink-0">
<span className="text-xs font-extrabold text-slate-800 tracking-wider flex items-center gap-1.5"> <span className="text-xs font-extrabold text-slate-800 tracking-wider flex items-center gap-1.5">
<Clock className="w-3.5 h-3.5 text-sky-600" /> <Clock className="w-3.5 h-3.5 text-blueprint" />
{!collapsed && <span></span>} {!collapsed && <span></span>}
</span> </span>
<div className="flex items-center gap-1 shrink-0"> <div className="flex items-center gap-1 shrink-0">
<button <button
onClick={onNewSession} onClick={onNewSession}
className="p-1 rounded-md border border-slate-200 bg-white hover:bg-slate-50 text-slate-600 hover:text-slate-800 transition-all cursor-pointer shadow-2xs hover:scale-105" className="p-1 rounded-md border border-slate-200 bg-white hover:bg-slate-50 text-slate-600 hover:text-slate-800 transition-all cursor-pointer shadow-2xs"
title="新建会话" title="新建会话"
> >
<Plus className="w-3.5 h-3.5" /> <Plus className="w-3.5 h-3.5" />
@ -71,7 +71,7 @@ export function AgentSessionSidebar({
value={searchQuery} value={searchQuery}
onChange={e => setSearchQuery(e.target.value)} onChange={e => setSearchQuery(e.target.value)}
placeholder="搜索历史会话或消息内容..." placeholder="搜索历史会话或消息内容..."
className="w-full pl-8 pr-7 py-1.5 rounded-lg bg-slate-50 border border-slate-200 text-slate-800 placeholder-slate-400 focus:outline-none focus:border-sky-500 focus:bg-white transition-all text-[11px] font-medium" className="w-full pl-8 pr-7 py-1.5 rounded-md bg-slate-50 border border-slate-200 text-slate-800 placeholder-slate-400 focus:outline-none focus:border-blueprint focus:bg-white transition-all text-[11px] font-medium"
/> />
<div className="absolute left-2.5 top-2.5 text-slate-400"> <div className="absolute left-2.5 top-2.5 text-slate-400">
<Search className="w-3.5 h-3.5 text-slate-400" /> <Search className="w-3.5 h-3.5 text-slate-400" />
@ -91,7 +91,7 @@ export function AgentSessionSidebar({
type="checkbox" type="checkbox"
checked={searchScopeOnlyCurrent} checked={searchScopeOnlyCurrent}
onChange={e => setSearchScopeOnlyCurrent(e.target.checked)} onChange={e => setSearchScopeOnlyCurrent(e.target.checked)}
className="rounded text-sky-600 border-slate-300 focus:ring-sky-500 w-3 h-3 cursor-pointer" className="rounded text-blueprint border-slate-300 focus:ring-blueprint w-3 h-3 cursor-pointer"
/> />
<span></span> <span></span>
</label> </label>
@ -102,7 +102,7 @@ export function AgentSessionSidebar({
{searchQuery.trim() ? ( {searchQuery.trim() ? (
loadingSearch ? ( loadingSearch ? (
<div className="flex items-center justify-center p-8 text-slate-400 text-xs gap-2"> <div className="flex items-center justify-center p-8 text-slate-400 text-xs gap-2">
<Loader className="w-3.5 h-3.5 animate-spin text-sky-600" /> <Loader className="w-3.5 h-3.5 animate-spin text-blueprint" />
<span>...</span> <span>...</span>
</div> </div>
) : searchResults.length === 0 ? ( ) : searchResults.length === 0 ? (
@ -119,9 +119,9 @@ export function AgentSessionSidebar({
<button <button
key={`${result.session_id}-${idx}`} key={`${result.session_id}-${idx}`}
onClick={() => setCurrentSessionId(result.session_id)} onClick={() => setCurrentSessionId(result.session_id)}
className={`w-full text-left p-2.5 rounded-lg border transition-all duration-200 flex flex-col gap-1 cursor-pointer ${ className={`w-full text-left p-2.5 rounded-md border transition-all duration-200 flex flex-col gap-1 cursor-pointer ${
isActive isActive
? 'bg-sky-50/70 border-sky-200 text-sky-850 font-bold shadow-2xs' ? 'bg-blueprint/5 border-blueprint text-blueprint font-bold shadow-2xs'
: 'border-transparent bg-white hover:bg-slate-100 text-slate-650 shadow-3xs' : 'border-transparent bg-white hover:bg-slate-100 text-slate-650 shadow-3xs'
}`} }`}
> >
@ -129,12 +129,12 @@ export function AgentSessionSidebar({
<span className="text-[10px] font-bold text-slate-400 flex items-center gap-1"> <span className="text-[10px] font-bold text-slate-400 flex items-center gap-1">
{isMessage ? ( {isMessage ? (
<> <>
<MessageSquare className="w-3 h-3 text-purple-400" /> <MessageSquare className="w-3 h-3 text-slate-400" />
<span>{role === 'user' ? '提问' : '解答'}</span> <span>{role === 'user' ? '提问' : '解答'}</span>
</> </>
) : ( ) : (
<> <>
<BookOpen className="w-3 h-3 text-sky-500" /> <BookOpen className="w-3 h-3 text-blueprint" />
<span></span> <span></span>
</> </>
)} )}
@ -162,7 +162,7 @@ export function AgentSessionSidebar({
// 正常的会话列表渲染 // 正常的会话列表渲染
loadingSessions ? ( loadingSessions ? (
<div className="flex items-center justify-center p-8 text-slate-400 text-xs gap-2"> <div className="flex items-center justify-center p-8 text-slate-400 text-xs gap-2">
<Loader className="w-3.5 h-3.5 animate-spin text-sky-600" /> <Loader className="w-3.5 h-3.5 animate-spin text-blueprint" />
<span>...</span> <span>...</span>
</div> </div>
) : sessions.length === 0 ? ( ) : sessions.length === 0 ? (
@ -176,10 +176,10 @@ export function AgentSessionSidebar({
<button <button
key={session.session_id} key={session.session_id}
onClick={() => setCurrentSessionId(session.session_id)} onClick={() => setCurrentSessionId(session.session_id)}
className={`w-full text-left p-3 rounded-lg border transition-all duration-200 group flex items-start justify-between gap-2 cursor-pointer ${ className={`w-full text-left p-3 rounded-md border transition-all duration-200 group flex items-start justify-between gap-2 cursor-pointer ${
isActive isActive
? 'bg-sky-50 border-sky-200 text-sky-850 font-bold shadow-2xs' ? 'bg-blueprint/5 border-blueprint text-blueprint font-bold shadow-2xs'
: 'border-transparent bg-transparent hover:bg-slate-100 text-slate-650' : 'border-transparent bg-transparent hover:bg-slate-100 text-slate-655'
}`} }`}
> >
<div className="flex-1 min-w-0 flex flex-col gap-1 text-left"> <div className="flex-1 min-w-0 flex flex-col gap-1 text-left">

View File

@ -134,13 +134,13 @@ export function AskUserQuestionCard({ onAnswered, onQuestionCountChange }: AskUs
return ( return (
<div <div
key={q.question_id} key={q.question_id}
className="console-panel rounded-xl p-3 shadow-lg border-amber-200/80 bg-amber-50/45 transition-all pointer-events-auto text-xs" className="rounded-md border border-slate-200 border-l-4 border-l-blueprint bg-slate-50 p-4 transition-all pointer-events-auto text-xs shadow-sm"
> >
{/* 标题栏 - 头部与问题放在同一行,可省略独立的问题文本框 */} {/* 标题栏 - 头部与问题放在同一行,可省略独立的问题文本框 */}
<div className="mb-2 flex items-center justify-between border-b border-amber-100/40 pb-1.5 min-w-0"> <div className="mb-2.5 flex items-center justify-between border-b border-slate-200 pb-2 min-w-0">
<div className="flex items-center gap-1.5 min-w-0 flex-1 mr-3"> <div className="flex items-center gap-1.5 min-w-0 flex-1 mr-3">
<MessageCircle className="h-3.5 w-3.5 text-amber-550 shrink-0" /> <MessageCircle className="h-3.5 w-3.5 text-blueprint shrink-0" />
<span className="px-1.5 py-0.2 rounded bg-amber-100 border border-amber-200 text-[9px] font-extrabold text-amber-800 shrink-0"> <span className="px-1.5 py-0.2 rounded bg-slate-200 border border-slate-300 text-[9px] font-bold text-slate-755 shrink-0">
{q.header || '提问'} {q.header || '提问'}
</span> </span>
<span className="font-extrabold text-slate-800 text-[11px] truncate flex-1" title={q.question || ''}> <span className="font-extrabold text-slate-800 text-[11px] truncate flex-1" title={q.question || ''}>
@ -152,7 +152,7 @@ export function AskUserQuestionCard({ onAnswered, onQuestionCountChange }: AskUs
<button <button
type="button" type="button"
onClick={() => setExpanded(prev => ({ ...prev, [q.question_id]: !prev[q.question_id] }))} onClick={() => setExpanded(prev => ({ ...prev, [q.question_id]: !prev[q.question_id] }))}
className="text-[10px] font-bold text-sky-600 hover:text-sky-850 cursor-pointer select-none" className="text-[10px] font-bold text-blueprint hover:underline cursor-pointer select-none"
> >
{isExpanded ? '收起选项' : '展开选项'} {isExpanded ? '收起选项' : '展开选项'}
</button> </button>
@ -180,20 +180,20 @@ export function AskUserQuestionCard({ onAnswered, onQuestionCountChange }: AskUs
key={idx} key={idx}
onClick={() => toggleOption(q.question_id, label, multiSelect)} onClick={() => toggleOption(q.question_id, label, multiSelect)}
disabled={isSubmitting} disabled={isSubmitting}
className={`w-full text-left px-2.5 py-1.5 rounded-lg border text-[11px] font-semibold transition-all cursor-pointer flex items-center gap-2 ${ className={`w-full text-left px-2.5 py-1.5 rounded-md border text-[11px] font-semibold transition-all cursor-pointer flex items-center gap-2 ${
selected selected
? 'bg-sky-50/70 border-sky-300 text-sky-850 shadow-3xs' ? 'bg-blueprint/5 border-blueprint text-blueprint font-bold'
: 'bg-white border-slate-200/60 text-slate-700 hover:border-sky-200 hover:bg-sky-50/50' : 'bg-white border-slate-200/60 text-slate-700 hover:border-slate-300 hover:bg-slate-50'
} disabled:opacity-50`} } disabled:opacity-50`}
> >
{/* 选择指示器 */} {/* 选择指示器 */}
{multiSelect ? ( {multiSelect ? (
selected selected
? <CheckSquare className="w-3.5 h-3.5 text-sky-600 shrink-0" /> ? <CheckSquare className="w-3.5 h-3.5 text-blueprint shrink-0" />
: <Square className="w-3.5 h-3.5 text-slate-450 shrink-0" /> : <Square className="w-3.5 h-3.5 text-slate-400 shrink-0" />
) : ( ) : (
<div className={`w-3.5 h-3.5 rounded-full border-2 shrink-0 ${ <div className={`w-3.5 h-3.5 rounded-full border-2 shrink-0 ${
selected ? 'border-sky-600 bg-sky-600' : 'border-slate-350' selected ? 'border-blueprint bg-blueprint' : 'border-slate-300'
}`}> }`}>
{selected && ( {selected && (
<div className="w-full h-full flex items-center justify-center"> <div className="w-full h-full flex items-center justify-center">
@ -227,13 +227,13 @@ export function AskUserQuestionCard({ onAnswered, onQuestionCountChange }: AskUs
disabled={isSubmitting} disabled={isSubmitting}
placeholder="补充说明(可选)..." placeholder="补充说明(可选)..."
rows={1} rows={1}
className="w-full bg-white border border-slate-200/60 rounded-lg px-2.5 py-1.5 text-[11px] text-slate-800 placeholder-slate-400 focus:outline-none focus:border-sky-300 focus:ring-1 focus:ring-sky-500/10 resize-none disabled:opacity-50 min-h-[32px] max-h-24 leading-normal" className="w-full bg-white border border-slate-200/60 rounded-md px-2.5 py-1.5 text-[11px] text-slate-800 placeholder-slate-400 focus:outline-none focus:border-blueprint focus:ring-1 focus:ring-blueprint/10 resize-none disabled:opacity-50 min-h-[32px] max-h-24 leading-normal"
/> />
</div> </div>
{/* 错误提示 */} {/* 错误提示 */}
{qError && ( {qError && (
<div className="text-[10px] text-red-700 bg-red-50 border border-red-200 rounded-lg px-2.5 py-1.5 font-bold"> <div className="text-[10px] text-red-700 bg-red-50 border border-red-200 rounded-md px-2.5 py-1.5 font-bold">
{qError} {qError}
</div> </div>
)} )}
@ -243,7 +243,7 @@ export function AskUserQuestionCard({ onAnswered, onQuestionCountChange }: AskUs
<button <button
onClick={() => handleSubmit(q.question_id)} onClick={() => handleSubmit(q.question_id)}
disabled={isSubmitting} disabled={isSubmitting}
className="flex-1 flex items-center justify-center gap-1.5 bg-emerald-600 hover:bg-emerald-700 border border-emerald-600 hover:border-emerald-750 text-white rounded-lg py-1.5 text-[11px] font-extrabold transition-all cursor-pointer disabled:opacity-50 hover:scale-102 active:scale-98" className="flex-1 flex items-center justify-center gap-1.5 bg-emerald-700 hover:bg-emerald-800 border border-emerald-700 text-white rounded-md py-1.5 text-[11px] font-bold transition-colors cursor-pointer disabled:opacity-50"
> >
{isSubmitting ? ( {isSubmitting ? (
<> <>
@ -260,7 +260,7 @@ export function AskUserQuestionCard({ onAnswered, onQuestionCountChange }: AskUs
<button <button
onClick={() => dismissQuestion(q.question_id)} onClick={() => dismissQuestion(q.question_id)}
disabled={isSubmitting} disabled={isSubmitting}
className="px-3 bg-red-50 hover:bg-red-100 text-red-700 border border-red-200 hover:border-red-300 rounded-lg text-[11px] font-extrabold transition-all cursor-pointer disabled:opacity-50 hover:scale-102 active:scale-98" className="px-3 bg-slate-100 hover:bg-slate-200 text-slate-700 border border-slate-250 rounded-md text-[11px] font-bold transition-colors cursor-pointer disabled:opacity-50"
> >
</button> </button>

View File

@ -73,7 +73,7 @@ export function AuditLogViewer({ sessionId, onClose }: AuditLogViewerProps) {
{/* 头部 */} {/* 头部 */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<ScrollText className="w-4 h-4 text-sky-600" /> <ScrollText className="w-4 h-4 text-blueprint" />
<h3 className="text-xs font-extrabold text-slate-800 tracking-wide"> <h3 className="text-xs font-extrabold text-slate-800 tracking-wide">
</h3> </h3>
@ -91,15 +91,15 @@ export function AuditLogViewer({ sessionId, onClose }: AuditLogViewerProps) {
{/* 汇总 */} {/* 汇总 */}
{entries.length > 0 && ( {entries.length > 0 && (
<div className="grid grid-cols-3 gap-2"> <div className="grid grid-cols-3 gap-2">
<div className="rounded-lg bg-emerald-50 border border-emerald-200 px-3 py-2 text-center"> <div className="rounded-md bg-emerald-50/40 border border-emerald-200 px-3 py-2 text-center">
<div className="text-xs font-extrabold text-emerald-700">{okCount}</div> <div className="text-xs font-extrabold text-emerald-700">{okCount}</div>
<div className="text-[9px] font-bold text-emerald-500"></div> <div className="text-[9px] font-bold text-emerald-500"></div>
</div> </div>
<div className="rounded-lg bg-rose-50 border border-rose-200 px-3 py-2 text-center"> <div className="rounded-md bg-rose-50/40 border border-rose-200 px-3 py-2 text-center">
<div className="text-xs font-extrabold text-rose-700">{failCount}</div> <div className="text-xs font-extrabold text-rose-700">{failCount}</div>
<div className="text-[9px] font-bold text-rose-500"></div> <div className="text-[9px] font-bold text-rose-500"></div>
</div> </div>
<div className="rounded-lg bg-slate-50 border border-slate-200 px-3 py-2 text-center"> <div className="rounded-md bg-slate-50 border border-slate-200 px-3 py-2 text-center">
<div className="text-xs font-extrabold text-slate-700">{(totalElapsed / 1000).toFixed(1)}s</div> <div className="text-xs font-extrabold text-slate-700">{(totalElapsed / 1000).toFixed(1)}s</div>
<div className="text-[9px] font-bold text-slate-500"></div> <div className="text-[9px] font-bold text-slate-500"></div>
</div> </div>
@ -109,14 +109,14 @@ export function AuditLogViewer({ sessionId, onClose }: AuditLogViewerProps) {
{/* 加载/错误状态 */} {/* 加载/错误状态 */}
{loading && ( {loading && (
<div className="flex items-center justify-center py-8 text-slate-400 gap-2"> <div className="flex items-center justify-center py-8 text-slate-400 gap-2">
<Loader className="w-4 h-4 animate-spin text-sky-600" /> <Loader className="w-4 h-4 animate-spin text-blueprint" />
<span className="text-xs font-bold">...</span> <span className="text-xs font-bold">...</span>
</div> </div>
)} )}
{error && ( {error && (
<div className="flex items-center gap-2 text-rose-600 bg-rose-50 border border-rose-200 rounded-lg px-3 py-2 text-xs font-medium"> <div className="flex items-start gap-2 text-slate-800 bg-slate-50 border border-slate-200 border-l-4 border-l-rose-500 rounded-md px-3 py-2 text-xs font-semibold">
<AlertTriangle className="w-3.5 h-3.5" /> <AlertTriangle className="w-3.5 h-3.5 text-rose-500 shrink-0 mt-0.2" />
{error} <div>{error}</div>
</div> </div>
)} )}
@ -190,12 +190,12 @@ export function AuditLogViewer({ sessionId, onClose }: AuditLogViewerProps) {
<div> <div>
{isExpanded ? ( {isExpanded ? (
<div className="space-y-1"> <div className="space-y-1">
<pre className="font-mono text-[9px] text-slate-600 bg-slate-50 border border-slate-200 rounded p-2 max-h-32 overflow-y-auto whitespace-pre-wrap leading-relaxed"> <pre className="font-mono text-[9px] text-slate-600 bg-slate-50 border border-slate-200 rounded-md p-2 max-h-32 overflow-y-auto whitespace-pre-wrap leading-relaxed">
{entry.output_preview} {entry.output_preview}
</pre> </pre>
<button <button
onClick={() => setExpandedPreview(prev => ({ ...prev, [entry.id]: false }))} onClick={() => setExpandedPreview(prev => ({ ...prev, [entry.id]: false }))}
className="text-[9px] font-bold text-sky-600 hover:underline cursor-pointer flex items-center gap-0.5" className="text-[9px] font-bold text-blueprint hover:underline cursor-pointer flex items-center gap-0.5"
> >
<ChevronUp className="w-2.5 h-2.5" /> <ChevronUp className="w-2.5 h-2.5" />
@ -204,7 +204,7 @@ export function AuditLogViewer({ sessionId, onClose }: AuditLogViewerProps) {
) : ( ) : (
<button <button
onClick={() => setExpandedPreview(prev => ({ ...prev, [entry.id]: true }))} onClick={() => setExpandedPreview(prev => ({ ...prev, [entry.id]: true }))}
className="text-left font-mono text-[9px] text-slate-500 hover:text-sky-600 transition-colors cursor-pointer flex items-center gap-0.5 max-w-[200px]" className="text-left font-mono text-[9px] text-slate-500 hover:text-blueprint transition-colors cursor-pointer flex items-center gap-0.5 max-w-[200px]"
> >
<ChevronDown className="w-2.5 h-2.5 shrink-0" /> <ChevronDown className="w-2.5 h-2.5 shrink-0" />
<span className="truncate">{(entry.output_preview ?? '').slice(0, 60)}{(entry.output_preview ?? '').length > 60 ? '...' : ''}</span> <span className="truncate">{(entry.output_preview ?? '').slice(0, 60)}{(entry.output_preview ?? '').length > 60 ? '...' : ''}</span>

View File

@ -88,14 +88,14 @@ export function PermissionRequestCard({ sessionId, onRequestCountChange }: Permi
return ( return (
<div <div
key={req.permission_id} key={req.permission_id}
className="console-panel rounded-xl p-3 shadow-lg border-amber-200/80 bg-amber-50/45 transition-all pointer-events-auto text-xs" className="rounded-md border border-slate-200 border-l-4 border-l-amber-500 bg-slate-50 p-4 transition-all pointer-events-auto text-xs shadow-sm"
> >
{/* 标题与控制栏 */} {/* 标题与控制栏 */}
<div className="mb-2 flex items-center justify-between border-b border-amber-100/40 pb-1.5"> <div className="mb-2.5 flex items-center justify-between border-b border-slate-200 pb-2">
<div className="flex items-center gap-1.5 min-w-0"> <div className="flex items-center gap-1.5 min-w-0">
<Shield className="h-3.5 w-3.5 text-amber-550 shrink-0" /> <Shield className="h-3.5 w-3.5 text-amber-500 shrink-0" />
<span className="font-extrabold text-slate-800 tracking-wide text-[11px] shrink-0"></span> <span className="font-bold text-slate-800 tracking-wide text-[11px] shrink-0"></span>
<code className="rounded bg-amber-100/70 border border-amber-200/80 px-1.5 py-0.2 text-[9px] font-mono font-bold text-amber-800 truncate" title={req.tool_name}> <code className="rounded bg-slate-200/80 border border-slate-300 px-1.5 py-0.5 text-[9px] font-mono font-bold text-slate-700 truncate" title={req.tool_name}>
{req.tool_name} {req.tool_name}
</code> </code>
</div> </div>
@ -104,7 +104,7 @@ export function PermissionRequestCard({ sessionId, onRequestCountChange }: Permi
<button <button
type="button" type="button"
onClick={() => toggleParams(req.permission_id)} onClick={() => toggleParams(req.permission_id)}
className="text-[10px] font-bold text-sky-600 hover:text-sky-850 cursor-pointer select-none" className="text-[10px] font-bold text-slate-500 hover:text-slate-800 underline underline-offset-2 cursor-pointer select-none"
> >
{showArgs ? '隐藏参数' : '参数详情'} {showArgs ? '隐藏参数' : '参数详情'}
</button> </button>
@ -112,43 +112,43 @@ export function PermissionRequestCard({ sessionId, onRequestCountChange }: Permi
</div> </div>
{/* 警告消息 */} {/* 警告消息 */}
<p className="mb-2 text-[11px] font-semibold text-slate-650 leading-relaxed">{req.message}</p> <p className="mb-3 text-[11px] font-medium text-slate-750 leading-relaxed">{req.message}</p>
{/* 显示工具参数的简化预览 - 按需展开 */} {/* 显示工具参数的简化预览 - 按需展开 */}
{hasArgs && showArgs && ( {hasArgs && showArgs && (
<pre className="mb-2.5 max-h-24 overflow-auto rounded-lg bg-slate-50 border border-slate-200/50 p-2 text-[9px] font-mono text-slate-600 scrollbar-thin"> <pre className="mb-3 max-h-24 overflow-auto rounded bg-white border border-slate-200 p-2 text-[9px] font-mono text-slate-600 scrollbar-thin">
{JSON.stringify(req.arguments, null, 2)} {JSON.stringify(req.arguments, null, 2)}
</pre> </pre>
)} )}
{/* 操作按钮栏 - 更加紧凑 */} {/* 操作按钮栏 - 更加紧凑 */}
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-2">
<button <button
onClick={() => respond(req.tool_call_id, true, false)} onClick={() => respond(req.tool_call_id, true, false)}
disabled={submitting[req.tool_call_id]} disabled={submitting[req.tool_call_id]}
className="flex items-center gap-1 px-3 py-1.5 rounded-md text-[10px] font-extrabold transition-all shadow-3xs cursor-pointer bg-emerald-600 hover:bg-emerald-700 text-white disabled:opacity-50 hover:scale-102 active:scale-98" className="flex items-center gap-1 px-3 py-1.5 rounded-md text-[10px] font-bold transition-all cursor-pointer bg-emerald-700 hover:bg-emerald-800 text-white disabled:opacity-50"
> >
{submitting[req.tool_call_id] ? ( {submitting[req.tool_call_id] ? (
<Loader className="h-3 w-3 animate-spin" /> <Loader className="h-3 w-3 animate-spin" />
) : ( ) : (
<Check className="h-3 w-3" /> <Check className="h-3 w-3" />
)} )}
Allow
</button> </button>
<button <button
onClick={() => respond(req.tool_call_id, true, true)} onClick={() => respond(req.tool_call_id, true, true)}
disabled={submitting[req.tool_call_id]} disabled={submitting[req.tool_call_id]}
className="flex items-center gap-1 px-3 py-1.5 rounded-md text-[10px] font-extrabold transition-all shadow-3xs cursor-pointer bg-sky-600 hover:bg-sky-700 text-white disabled:opacity-50 hover:scale-102 active:scale-98" className="flex items-center gap-1 px-3 py-1.5 rounded-md text-[10px] font-bold transition-all cursor-pointer bg-slate-800 hover:bg-slate-900 text-white disabled:opacity-50"
> >
Always Allow
</button> </button>
<button <button
onClick={() => respond(req.tool_call_id, false, false)} onClick={() => respond(req.tool_call_id, false, false)}
disabled={submitting[req.tool_call_id]} disabled={submitting[req.tool_call_id]}
className="flex items-center gap-1 px-3 py-1.5 rounded-md text-[10px] font-extrabold transition-all cursor-pointer bg-red-50 hover:bg-red-100 text-red-700 border border-red-200 hover:border-red-300 disabled:opacity-50 hover:scale-102 active:scale-98" className="flex items-center gap-1 px-3 py-1.5 rounded-md text-[10px] font-bold transition-all cursor-pointer bg-slate-100 border border-slate-250 text-slate-700 hover:bg-slate-200 disabled:opacity-50"
> >
<X className="h-3 w-3" /> <X className="h-3 w-3" />
Deny
</button> </button>
</div> </div>
</div> </div>

View File

@ -45,10 +45,10 @@ export function SubAgentContainer({
<div className="relative space-y-2 select-none"> <div className="relative space-y-2 select-none">
<div <div
className={`absolute -left-[21px] top-3 w-2.5 h-2.5 rounded-full border-2 border-white ${ className={`absolute -left-[21px] top-3 w-2.5 h-2.5 rounded-full border-2 border-white ${
parentStreaming ? 'bg-violet-500 animate-pulse' : 'bg-violet-400' parentStreaming ? 'bg-violet-500' : 'bg-violet-400'
}`} }`}
/> />
<div className="border border-violet-200 bg-violet-50/30 rounded-xl shadow-2xs overflow-hidden"> <div className="border border-violet-200 bg-violet-50/30 rounded-md shadow-2xs overflow-hidden">
{/* 头部 */} {/* 头部 */}
<button <button
onClick={onToggle} onClick={onToggle}
@ -57,7 +57,7 @@ export function SubAgentContainer({
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Network <Network
className={`w-4 h-4 ${ className={`w-4 h-4 ${
parentStreaming ? 'text-violet-500 animate-pulse' : 'text-violet-400' parentStreaming ? 'text-violet-500' : 'text-violet-400'
}`} }`}
/> />
<span className="text-[10px] font-extrabold text-violet-700 tracking-wider uppercase"> <span className="text-[10px] font-extrabold text-violet-700 tracking-wider uppercase">

View File

@ -38,7 +38,7 @@ export function ThoughtCard({
<div <div
className={`absolute -left-[21px] top-3 w-2.5 h-2.5 rounded-full border-2 border-white ${dotColor}`} className={`absolute -left-[21px] top-3 w-2.5 h-2.5 rounded-full border-2 border-white ${dotColor}`}
/> />
<div className={`space-y-1.5 rounded-xl p-3.5 select-text ${bgClass}`}> <div className={`space-y-1.5 rounded-md p-3 select-text ${bgClass}`}>
<button <button
onClick={onToggle} onClick={onToggle}
className="w-full flex items-center justify-between text-left cursor-pointer" className="w-full flex items-center justify-between text-left cursor-pointer"

View File

@ -42,7 +42,7 @@ export function ToolCallCard({
<div <div
className={`absolute -left-[21px] top-3 w-2.5 h-2.5 rounded-full border-2 border-white ${dotColor}`} className={`absolute -left-[21px] top-3 w-2.5 h-2.5 rounded-full border-2 border-white ${dotColor}`}
/> />
<div className="space-y-2 border border-slate-100 bg-white rounded-xl p-3.5 shadow-2xs"> <div className="space-y-2 border border-slate-100 bg-white rounded-md p-3 shadow-2xs">
{/* 工具名称 */} {/* 工具名称 */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-1.5 text-[10px] font-extrabold text-sky-700 tracking-wider uppercase"> <div className="flex items-center gap-1.5 text-[10px] font-extrabold text-sky-700 tracking-wider uppercase">
@ -64,7 +64,7 @@ export function ToolCallCard({
{/* 参数 */} {/* 参数 */}
{isArgsExpanded && ( {isArgsExpanded && (
<div className="font-mono text-[10px] bg-slate-50 border border-slate-200 rounded-lg p-2.5 text-slate-650 overflow-x-auto whitespace-pre-wrap max-w-full leading-relaxed select-text"> <div className="font-mono text-[10px] bg-slate-50 border border-slate-200 rounded-md p-2 text-slate-650 overflow-x-auto whitespace-pre-wrap max-w-full leading-relaxed select-text">
{JSON.stringify(args, null, 2)} {JSON.stringify(args, null, 2)}
</div> </div>
)} )}
@ -93,7 +93,7 @@ export function ToolCallCard({
<div <div
ref={scrollContainerRef} ref={scrollContainerRef}
onScroll={handleScroll} onScroll={handleScroll}
className="text-xs bg-slate-50 border border-slate-200 rounded-lg p-3 text-slate-700 overflow-auto select-text leading-relaxed max-h-[60vh] prose prose-sm max-w-none prose-headings:text-slate-800 prose-code:text-sky-700 prose-pre:bg-slate-100 prose-pre:text-xs prose-img:rounded-lg" className="text-xs bg-slate-50 border border-slate-200 rounded-md p-2.5 text-slate-700 overflow-auto select-text leading-relaxed max-h-[60vh] prose prose-sm max-w-none prose-headings:text-slate-800 prose-code:text-sky-700 prose-pre:bg-slate-100 prose-pre:text-xs prose-img:rounded-lg"
> >
<AgentMarkdown> <AgentMarkdown>
{result!.output} {result!.output}

View File

@ -24,13 +24,13 @@ export interface ColorScheme {
} }
export const PARENT_COLORS: ColorScheme = { export const PARENT_COLORS: ColorScheme = {
thought: { active: 'bg-purple-400 animate-pulse', idle: 'bg-purple-300' }, thought: { active: 'bg-slate-400', idle: 'bg-slate-300' },
tool_call: { active: 'bg-sky-500 animate-pulse', idle: 'bg-sky-400' }, tool_call: { active: 'bg-blueprint', idle: 'bg-slate-300' },
answer: 'bg-emerald-400', answer: 'bg-slate-550',
}; };
export const SUBAGENT_COLORS: ColorScheme = { export const SUBAGENT_COLORS: ColorScheme = {
thought: { active: 'bg-violet-400 animate-pulse', idle: 'bg-violet-300' }, thought: { active: 'bg-slate-500', idle: 'bg-slate-400' },
tool_call: { active: 'bg-violet-500 animate-pulse', idle: 'bg-violet-400' }, tool_call: { active: 'bg-blueprint', idle: 'bg-slate-350' },
answer: 'bg-violet-400', answer: 'bg-slate-450',
}; };

View File

@ -0,0 +1,69 @@
import React from 'react';
interface BaseModalProps {
isOpen: boolean;
onClose: () => void;
title: React.ReactNode;
dotColor?: string; // e.g., 'bg-blueprint', 'bg-rose-600', 'bg-star', etc.
size?: 'sm' | 'md' | 'lg' | 'xl';
children: React.ReactNode;
zIndex?: number;
}
export function BaseModal({
isOpen,
onClose,
title,
dotColor,
size = 'md',
children,
zIndex = 50,
}: BaseModalProps) {
if (!isOpen) return null;
const sizeClasses = {
sm: 'max-w-sm',
md: 'max-w-md',
lg: 'max-w-lg',
xl: 'max-w-xl',
};
return (
<div
onClick={onClose}
className="fixed inset-0 flex items-center justify-center bg-slate-900/40 backdrop-blur-xs transition-all animate-fade-in cursor-pointer"
style={{ zIndex }}
>
<div
onClick={(e) => e.stopPropagation()}
className={`bg-card rounded-lg border border-precision shadow-sm w-full p-6 space-y-4 cursor-default animate-fade-in mx-4 ${sizeClasses[size]}`}
>
{/* 头部栏 */}
<div className="flex items-start justify-between border-b border-slate-150 pb-2.5 shrink-0">
<div className="min-w-0 flex-1">
{typeof title === 'string' ? (
<h3 className="text-xs font-bold text-main flex items-center gap-1.5 pr-4">
{dotColor && <span className={`w-1.5 h-1.5 rounded-full shrink-0 ${dotColor}`} />}
<span className="truncate">{title}</span>
</h3>
) : (
title
)}
</div>
<button
onClick={onClose}
className="text-slate-400 hover:text-slate-600 p-1 rounded-md hover:bg-slate-100 transition-colors shrink-0 cursor-pointer"
title="关闭"
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* 内容区 */}
{children}
</div>
</div>
);
}

View File

@ -1,3 +1,5 @@
import { BaseModal } from './BaseModal';
interface GlobalDialogProps { interface GlobalDialogProps {
dialog: { dialog: {
type: 'alert' | 'confirm'; type: 'alert' | 'confirm';
@ -23,49 +25,33 @@ export function GlobalDialog({ dialog, onClose }: GlobalDialogProps) {
}; };
return ( return (
<div <BaseModal
onClick={handleCancel} isOpen={!!dialog}
className="fixed inset-0 z-[60] flex items-center justify-center bg-slate-900/40 backdrop-blur-xs transition-all" onClose={handleCancel}
title={dialog.title}
dotColor={dialog.type === 'confirm' ? 'bg-blueprint' : 'bg-rose-600'}
size="sm"
zIndex={60}
> >
<div
onClick={(e) => e.stopPropagation()}
className="bg-[var(--bg-card)] rounded-xl border border-[var(--border-precision)] shadow-md max-w-sm w-full p-6 space-y-4"
>
<div className="flex items-center justify-between border-b border-slate-100 pb-2.5">
<h3 className="text-xs font-bold text-[var(--text-main)] flex items-center gap-1.5">
<span className={`w-2 h-2 rounded-full ${dialog.type === 'confirm' ? 'bg-[var(--accent-blueprint)]' : 'bg-[#ef4444]'} animate-pulse`} />
<span>{dialog.title}</span>
</h3>
<button
onClick={handleCancel}
className="text-slate-400 hover:text-slate-600 p-1 rounded-lg hover:bg-slate-100 transition-colors cursor-pointer"
title="关闭"
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="space-y-2"> <div className="space-y-2">
<p className="text-xs text-[var(--text-muted)] leading-relaxed whitespace-pre-line">{dialog.message}</p> <p className="text-xs text-muted leading-relaxed whitespace-pre-line font-medium">{dialog.message}</p>
</div> </div>
<div className="flex gap-2 pt-2"> <div className="flex gap-2 pt-2">
<button <button
onClick={handleConfirm} onClick={handleConfirm}
className="flex-1 btn-console btn-console-primary py-2 rounded-lg text-[11px] font-bold text-center cursor-pointer" className="flex-1 btn-console btn-console-primary py-2 rounded-md text-[11px] font-bold text-center cursor-pointer"
> >
</button> </button>
{dialog.type === 'confirm' && ( {dialog.type === 'confirm' && (
<button <button
onClick={handleCancel} onClick={handleCancel}
className="px-4 btn-console btn-console-secondary py-2 rounded-lg text-[11px] font-bold text-center cursor-pointer" className="px-4 btn-console btn-console-secondary py-2 rounded-md text-[11px] font-bold text-center cursor-pointer"
> >
</button> </button>
)} )}
</div> </div>
</div> </BaseModal>
</div>
); );
} }

View File

@ -2,6 +2,7 @@ import axios from 'axios';
import { Loader, Download, RefreshCw, AlertTriangle } from 'lucide-react'; import { Loader, Download, RefreshCw, AlertTriangle } from 'lucide-react';
import type { StandardPaper } from '../../types'; import type { StandardPaper } from '../../types';
import { getDoctypeBadge } from '../../utils/paper'; import { getDoctypeBadge } from '../../utils/paper';
import { BaseModal } from './BaseModal';
interface PaperDetailModalProps { interface PaperDetailModalProps {
paper: StandardPaper | null | undefined; paper: StandardPaper | null | undefined;
@ -34,130 +35,120 @@ export function PaperDetailModal({
}: PaperDetailModalProps) { }: PaperDetailModalProps) {
if (!paper) return null; if (!paper) return null;
return ( const modalTitle = (
<div
onClick={onClose}
className="fixed inset-0 z-50 flex items-center justify-center bg-slate-900/40 backdrop-blur-xs transition-all animate-fade-in cursor-pointer"
>
<div
onClick={(e) => e.stopPropagation()}
className="bg-[var(--bg-card)] rounded-xl border border-[var(--border-precision)] shadow-md max-w-lg w-full p-6 space-y-4 cursor-default animate-fade-in"
>
{/* 标题 & 关闭 */}
<div className="flex items-start justify-between border-b border-slate-100 pb-3">
<div className="space-y-1 pr-4"> <div className="space-y-1 pr-4">
<span className="text-[10px] font-bold text-[var(--accent-blueprint)] uppercase tracking-wider"></span> <span className="text-[10px] font-bold text-blueprint uppercase tracking-wider block"></span>
<h3 className="text-xs font-bold text-[var(--text-main)] leading-snug"> <h3 className="text-xs font-bold text-main leading-snug">
{getDoctypeBadge(paper.doctype)} {getDoctypeBadge(paper.doctype)}
<span className="align-middle ml-1">{paper.title}</span> <span className="align-middle ml-1">{paper.title}</span>
</h3> </h3>
</div> </div>
<button );
onClick={onClose}
className="text-slate-400 hover:text-slate-600 p-1 rounded-lg hover:bg-slate-100 transition-colors shrink-0 cursor-pointer" return (
title="关闭" <BaseModal
isOpen={!!paper}
onClose={onClose}
title={modalTitle}
size="lg"
zIndex={50}
> >
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* 详情内容 */} {/* 详情内容 */}
<div className="space-y-4 h-[460px] flex flex-col overflow-y-auto pr-1 text-xs scrollbar-thin"> <div className="space-y-4 h-[460px] flex flex-col overflow-y-auto pr-1 text-xs scrollbar-thin">
{/* 作者 */} {/* 作者 */}
<div className="space-y-1 h-12 overflow-y-auto scrollbar-thin shrink-0"> <div className="space-y-1 h-12 overflow-y-auto scrollbar-thin shrink-0">
<span className="text-[var(--text-muted)] font-bold"></span> <span className="text-muted font-bold"></span>
<p className="text-[var(--text-main)] leading-relaxed font-semibold">{paper.authors.join(', ')}</p> <p className="text-main leading-relaxed font-semibold">{paper.authors.join(', ')}</p>
</div> </div>
{/* 期刊 & 年份 */} {/* 期刊 & 年份 */}
<div className="grid grid-cols-5 gap-4 border-y border-slate-100 py-2.5 shrink-0 h-14"> <div className="grid grid-cols-5 gap-4 border-y border-slate-150 py-2.5 shrink-0 h-14">
<div className="space-y-0.5 flex flex-col justify-center min-w-0 col-span-4"> <div className="space-y-0.5 flex flex-col justify-center min-w-0 col-span-4">
<span className="text-[var(--text-muted)] font-bold block text-[10px] leading-tight"></span> <span className="text-muted font-bold block text-[10px] leading-tight"></span>
<span <span
className="text-[var(--text-main)] font-bold italic truncate block text-[11px] leading-tight" className="text-main font-bold italic truncate block text-[11px] leading-tight"
title={paper.pub_journal || '未标注'} title={paper.pub_journal || '未标注'}
> >
{paper.pub_journal || '未标注'} {paper.pub_journal || '未标注'}
</span> </span>
</div> </div>
<div className="space-y-0.5 flex flex-col justify-center col-span-1"> <div className="space-y-0.5 flex flex-col justify-center col-span-1">
<span className="text-[var(--text-muted)] font-bold block text-[10px] leading-tight"></span> <span className="text-muted font-bold block text-[10px] leading-tight"></span>
<span className="text-[var(--text-main)] font-extrabold block text-[11px] leading-tight">{paper.year}</span> <span className="text-main font-extrabold block text-[11px] leading-tight">{paper.year}</span>
</div> </div>
</div> </div>
{/* 摘要 */} {/* 摘要 */}
<div className="space-y-1.5 flex flex-col h-40 shrink-0"> <div className="space-y-1.5 flex flex-col h-40 shrink-0">
<span className="text-[var(--text-muted)] font-bold block"></span> <span className="text-muted font-bold block"></span>
<p className="text-[var(--text-main)] leading-relaxed font-normal bg-[#f8fafc] p-3 flex-1 rounded-lg border border-[var(--border-precision)] text-justify overflow-y-auto scrollbar-thin select-text"> <p className="text-main leading-relaxed font-normal bg-slate-50 p-3 flex-1 rounded-md border border-precision text-justify overflow-y-auto scrollbar-thin select-text">
{paper.abstract_text || '该文献暂无摘要数据。'} {paper.abstract_text || '该文献暂无摘要数据。'}
</p> </p>
</div> </div>
{/* 关键 */} {/* 关键 */}
<div className="space-y-1.5 flex flex-col h-16 shrink-0"> <div className="space-y-1.5 flex flex-col h-16 shrink-0">
<span className="text-[var(--text-muted)] font-bold block"></span> <span className="text-muted font-bold block"></span>
{paper.keywords && paper.keywords.length > 0 ? ( {paper.keywords && paper.keywords.length > 0 ? (
<div className="flex flex-wrap gap-1.5 flex-1 content-start items-start overflow-y-auto scrollbar-thin pr-1"> <div className="flex flex-wrap gap-1.5 flex-1 content-start items-start overflow-y-auto scrollbar-thin pr-1">
{paper.keywords.map(kw => ( {paper.keywords.map(kw => (
<span key={kw} className="px-2 py-0.5 rounded bg-[#f1f5f9] border border-[var(--border-precision)] text-[var(--text-muted)] font-bold text-[9px]"> <span key={kw} className="px-2 py-0.5 rounded bg-slate-100 border border-precision text-muted font-bold text-[9px]">
{kw} {kw}
</span> </span>
))} ))}
</div> </div>
) : ( ) : (
<div className="flex items-center justify-center flex-1 bg-[#f8fafc] border border-[var(--border-precision)] rounded-lg text-[10px] text-[var(--text-muted)] font-bold select-none"> <div className="flex items-center justify-center flex-1 bg-slate-50 border border-precision rounded-md text-[10px] text-muted font-bold select-none">
</div> </div>
)} )}
</div> </div>
{/* 标识符 */} {/* 标识符 */}
<div className="border-t border-slate-100 pt-3 grid grid-cols-1 sm:grid-cols-3 gap-2 text-[10px] font-mono mt-auto shrink-0"> <div className="border-t border-slate-155 pt-3 grid grid-cols-1 sm:grid-cols-3 gap-2 text-[10px] font-mono mt-auto shrink-0">
<div className="bg-[#f8fafc] px-2.5 py-1.5 rounded border border-[var(--border-precision)]"> <div className="bg-slate-50 px-2.5 py-1.5 rounded-md border border-precision">
<span className="text-[var(--text-muted)] font-bold block">BIBCODE</span> <span className="text-muted font-bold block">BIBCODE</span>
<span className="text-[var(--text-main)] font-semibold select-all truncate block" title={paper.bibcode === paper.arxiv_id ? '暂无' : paper.bibcode}> <span className="text-main font-semibold select-all truncate block" title={paper.bibcode === paper.arxiv_id ? '暂无' : paper.bibcode}>
{paper.bibcode === paper.arxiv_id ? '暂无' : ( {paper.bibcode === paper.arxiv_id ? '暂无' : (
<a <a
href={`https://ui.adsabs.harvard.edu/abs/${paper.bibcode}/abstract`} href={`https://ui.adsabs.harvard.edu/abs/${paper.bibcode}/abstract`}
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
onClick={() => axios.post('/api/active_bibcode', { bibcode: paper.bibcode }).catch(() => {})} onClick={() => axios.post('/api/active_bibcode', { bibcode: paper.bibcode }).catch(() => {})}
className="text-[var(--accent-blueprint)] hover:underline" className="text-blueprint hover:underline"
> >
{paper.bibcode} {paper.bibcode}
</a> </a>
)} )}
</span> </span>
</div> </div>
<div className="bg-[#f8fafc] px-2.5 py-1.5 rounded border border-[var(--border-precision)]"> <div className="bg-slate-50 px-2.5 py-1.5 rounded-md border border-precision">
<span className="text-[var(--text-muted)] font-bold block">DOI</span> <span className="text-muted font-bold block">DOI</span>
<span className="text-[var(--text-main)] font-semibold select-all truncate block" title={paper.doi || '无'}> <span className="text-main font-semibold select-all truncate block" title={paper.doi || '无'}>
{paper.doi ? ( {paper.doi ? (
<a <a
href={`https://doi.org/${paper.doi}`} href={`https://doi.org/${paper.doi}`}
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
onClick={() => axios.post('/api/active_bibcode', { bibcode: paper.bibcode }).catch(() => {})} onClick={() => axios.post('/api/active_bibcode', { bibcode: paper.bibcode }).catch(() => {})}
className="text-[var(--accent-blueprint)] hover:underline" className="text-blueprint hover:underline"
> >
{paper.doi} {paper.doi}
</a> </a>
) : '无'} ) : '无'}
</span> </span>
</div> </div>
<div className="bg-[#f8fafc] px-2.5 py-1.5 rounded border border-[var(--border-precision)]"> <div className="bg-slate-50 px-2.5 py-1.5 rounded-md border border-precision">
<span className="text-[var(--text-muted)] font-bold block">ARXIV ID</span> <span className="text-muted font-bold block">ARXIV ID</span>
<span className="text-[var(--text-main)] font-semibold select-all truncate block" title={paper.arxiv_id || '无'}> <span className="text-main font-semibold select-all truncate block" title={paper.arxiv_id || '无'}>
{paper.arxiv_id ? ( {paper.arxiv_id ? (
<a <a
href={`https://arxiv.org/abs/${paper.arxiv_id}`} href={`https://arxiv.org/abs/${paper.arxiv_id}`}
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
onClick={() => axios.post('/api/active_bibcode', { bibcode: paper.bibcode }).catch(() => {})} onClick={() => axios.post('/api/active_bibcode', { bibcode: paper.bibcode }).catch(() => {})}
className="text-[var(--accent-blueprint)] hover:underline" className="text-blueprint hover:underline"
> >
{paper.arxiv_id} {paper.arxiv_id}
</a> </a>
@ -167,16 +158,16 @@ export function PaperDetailModal({
</div> </div>
{/* 手动上传文件(应对防爬阻断) */} {/* 手动上传文件(应对防爬阻断) */}
<div className="border-t border-slate-100 pt-3 space-y-2"> <div className="border-t border-slate-150 pt-3 space-y-2">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-[var(--text-muted)] font-bold block">线</span> <span className="text-muted font-bold block">线</span>
<span className="text-[9px] text-amber-700 font-bold bg-amber-50 px-2 py-0.5 rounded border border-amber-200">/</span> <span className="text-[9px] text-amber-800 font-bold bg-amber-50 px-2 py-0.5 rounded-md border border-amber-200/60">/</span>
</div> </div>
<p className="text-[10px] text-[var(--text-muted)] leading-relaxed"> <p className="text-[10px] text-muted leading-relaxed">
PDF HTML PDF HTML
</p> </p>
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
<div className="flex flex-col items-center justify-center border border-dashed border-[var(--border-precision)] rounded-lg p-2 hover:bg-[#f8fafc] transition-colors relative cursor-pointer group min-h-[50px]"> <div className="flex flex-col items-center justify-center border border-dashed border-precision rounded-md p-2 hover:bg-slate-50 transition-colors relative cursor-pointer group min-h-[50px]">
<input <input
type="file" type="file"
accept="application/pdf" accept="application/pdf"
@ -188,17 +179,17 @@ export function PaperDetailModal({
disabled={uploadingBibcode === paper.bibcode} disabled={uploadingBibcode === paper.bibcode}
/> />
{paper.has_pdf && ( {paper.has_pdf && (
<span className="absolute top-1 right-1 text-[8px] font-bold text-emerald-600 bg-emerald-50 border border-emerald-200 px-1 py-0.2 rounded select-none pointer-events-none z-10"> <span className="absolute top-1 right-1 text-[8px] font-bold text-emerald-800 bg-emerald-50 border border-emerald-200/60 px-1 py-0.2 rounded-md select-none pointer-events-none z-10">
</span> </span>
)} )}
<span className="text-[10px] font-bold text-[var(--accent-blueprint)] group-hover:underline"> <span className="text-[10px] font-bold text-blueprint group-hover:underline">
{uploadingBibcode === paper.bibcode ? '上传中...' : '上传 PDF 文献'} {uploadingBibcode === paper.bibcode ? '上传中...' : '上传 PDF 文献'}
</span> </span>
<span className="text-[8px] text-[var(--text-muted)]"> .pdf </span> <span className="text-[8px] text-muted"> .pdf </span>
</div> </div>
<div className="flex flex-col items-center justify-center border border-dashed border-[var(--border-precision)] rounded-lg p-2 hover:bg-[#f8fafc] transition-colors relative cursor-pointer group min-h-[50px]"> <div className="flex flex-col items-center justify-center border border-dashed border-precision rounded-md p-2 hover:bg-slate-50 transition-colors relative cursor-pointer group min-h-[50px]">
<input <input
type="file" type="file"
accept="text/html,.html" accept="text/html,.html"
@ -210,14 +201,14 @@ export function PaperDetailModal({
disabled={uploadingBibcode === paper.bibcode} disabled={uploadingBibcode === paper.bibcode}
/> />
{paper.has_html && ( {paper.has_html && (
<span className="absolute top-1 right-1 text-[8px] font-bold text-emerald-600 bg-emerald-50 border border-emerald-200 px-1 py-0.2 rounded select-none pointer-events-none z-10"> <span className="absolute top-1 right-1 text-[8px] font-bold text-emerald-800 bg-emerald-50 border border-emerald-200/60 px-1 py-0.2 rounded-md select-none pointer-events-none z-10">
</span> </span>
)} )}
<span className="text-[10px] font-bold text-[var(--accent-blueprint)] group-hover:underline"> <span className="text-[10px] font-bold text-blueprint group-hover:underline">
{uploadingBibcode === paper.bibcode ? '上传中...' : '上传 HTML 文献'} {uploadingBibcode === paper.bibcode ? '上传中...' : '上传 HTML 文献'}
</span> </span>
<span className="text-[8px] text-[var(--text-muted)]"> .html </span> <span className="text-[8px] text-muted"> .html </span>
</div> </div>
</div> </div>
@ -227,7 +218,7 @@ export function PaperDetailModal({
<button <button
type="button" type="button"
onClick={() => handleMarkNoResource(paper.bibcode, true)} onClick={() => handleMarkNoResource(paper.bibcode, true)}
className="btn-console btn-console-secondary w-full py-2 rounded-lg text-[10px] font-bold transition-all cursor-pointer flex items-center justify-center gap-1.5" className="btn-console btn-console-secondary w-full py-2 rounded-md text-[10px] font-bold transition-all cursor-pointer flex items-center justify-center gap-1.5"
> >
<RefreshCw className="w-3 h-3" /> () <RefreshCw className="w-3 h-3" /> ()
</button> </button>
@ -235,9 +226,9 @@ export function PaperDetailModal({
<button <button
type="button" type="button"
onClick={() => handleMarkNoResource(paper.bibcode, false)} onClick={() => handleMarkNoResource(paper.bibcode, false)}
className="btn-console btn-console-secondary w-full py-2 rounded-lg text-[10px] font-bold transition-all cursor-pointer flex items-center justify-center gap-1.5" className="btn-console btn-console-secondary w-full py-2 rounded-md text-[10px] font-bold transition-all cursor-pointer flex items-center justify-center gap-1.5"
> >
<AlertTriangle className="w-3 h-3 text-[var(--accent-star)]" /> () <AlertTriangle className="w-3 h-3 text-amber-600" /> ()
</button> </button>
)} )}
</div> </div>
@ -246,34 +237,34 @@ export function PaperDetailModal({
{/* 自动下载失败诊断 */} {/* 自动下载失败诊断 */}
{!paper.is_downloaded && (paper.pdf_error || paper.html_error) && ( {!paper.is_downloaded && (paper.pdf_error || paper.html_error) && (
<div className={`rounded-lg p-3 text-[10px] space-y-1 ${ <div className={`rounded-md p-3 text-[10px] space-y-1 border border-slate-200 border-l-4 ${
(paper.pdf_error === 'no_resource' && paper.html_error === 'no_resource') (paper.pdf_error === 'no_resource' && paper.html_error === 'no_resource')
? 'bg-amber-50 border border-amber-150 text-amber-850' ? 'bg-slate-50 border-l-amber-500 text-slate-800'
: 'bg-rose-50 border border-rose-150 text-rose-850' : 'bg-slate-50 border-l-rose-500 text-slate-800'
}`}> }`}>
{paper.pdf_error === 'no_resource' && paper.html_error === 'no_resource' ? ( {paper.pdf_error === 'no_resource' && paper.html_error === 'no_resource' ? (
<div className="leading-relaxed"> <div className="leading-relaxed">
<div className="font-bold flex items-center gap-1.5 text-amber-900 text-xs mb-1"> <div className="font-bold flex items-center gap-1.5 text-slate-900 text-[11px] mb-1">
<span className="w-1.5 h-1.5 rounded-full bg-amber-500 animate-pulse" /> <span className="w-1.5 h-1.5 rounded-full bg-amber-500" />
</div> </div>
</div> </div>
) : ( ) : (
<> <>
<div className="font-bold flex items-center gap-1.5 text-rose-900"> <div className="font-bold flex items-center gap-1.5 text-slate-900 text-[11px]">
<span className="w-1.5 h-1.5 rounded-full bg-rose-500 animate-pulse" /> <span className="w-1.5 h-1.5 rounded-full bg-rose-600" />
</div> </div>
{paper.pdf_error && ( {paper.pdf_error && (
<div className="leading-relaxed"> <div className="leading-relaxed">
<span className="font-semibold text-rose-750">PDF : </span> <span className="font-semibold text-slate-600">PDF : </span>
{paper.pdf_error} {paper.pdf_error}
</div> </div>
)} )}
{paper.html_error && ( {paper.html_error && (
<div className="leading-relaxed mt-0.5"> <div className="leading-relaxed mt-0.5">
<span className="font-semibold text-rose-750">HTML : </span> <span className="font-semibold text-slate-600">HTML : </span>
{paper.html_error} {paper.html_error}
</div> </div>
)} )}
@ -284,7 +275,7 @@ export function PaperDetailModal({
</div> </div>
{/* 底部操作:整合所有动作(阅读、图谱、下载) */} {/* 底部操作:整合所有动作(阅读、图谱、下载) */}
<div className="flex flex-wrap gap-2 pt-3 border-t border-slate-100"> <div className="flex flex-wrap gap-2 pt-3 border-t border-slate-150">
{paper.is_downloaded ? ( {paper.is_downloaded ? (
<> <>
<button <button
@ -292,7 +283,7 @@ export function PaperDetailModal({
onClose(); onClose();
openReader(paper); openReader(paper);
}} }}
className="flex-1 btn-console btn-console-primary py-2.5 rounded-lg text-xs font-bold text-center cursor-pointer" className="flex-1 btn-console btn-console-primary py-2.5 rounded-md text-xs font-bold text-center cursor-pointer"
> >
</button> </button>
@ -303,18 +294,18 @@ export function PaperDetailModal({
setActiveTab('citation'); setActiveTab('citation');
loadCitations(paper.bibcode); loadCitations(paper.bibcode);
}} }}
className="flex-1 btn-console btn-console-secondary py-2.5 rounded-lg text-xs font-bold text-center cursor-pointer" className="flex-1 btn-console btn-console-secondary py-2.5 rounded-md text-xs font-bold text-center cursor-pointer"
> >
</button> </button>
<button <button
onClick={() => { onClick={() => {
showConfirm('确定要强制重新下载吗?这会覆盖本地文件。', () => { showConfirm('确定要强制重新下载吗?这会覆盖本地 file。', () => {
handleDownload(paper.bibcode, true); handleDownload(paper.bibcode, true);
}, '确认重新下载'); }, '确认重新下载');
}} }}
disabled={downloadingBibcodes[paper.bibcode]} disabled={downloadingBibcodes[paper.bibcode]}
className="px-4 btn-console btn-console-secondary text-[var(--accent-star)] py-2.5 rounded-lg text-xs font-bold transition-all cursor-pointer disabled:opacity-50" className="px-4 btn-console btn-console-secondary text-star py-2.5 rounded-md text-xs font-bold transition-all cursor-pointer disabled:opacity-50"
> >
{downloadingBibcodes[paper.bibcode] ? '重下中...' : '重新下载'} {downloadingBibcodes[paper.bibcode] ? '重下中...' : '重新下载'}
</button> </button>
@ -326,7 +317,7 @@ export function PaperDetailModal({
handleDownload(paper.bibcode); handleDownload(paper.bibcode);
}} }}
disabled={downloadingBibcodes[paper.bibcode]} disabled={downloadingBibcodes[paper.bibcode]}
className="flex-1 btn-console btn-console-primary py-2.5 rounded-lg text-xs font-bold flex items-center justify-center gap-1.5 cursor-pointer disabled:opacity-50" className="flex-1 btn-console btn-console-primary py-2.5 rounded-md text-xs font-bold flex items-center justify-center gap-1.5 cursor-pointer disabled:opacity-50"
> >
{downloadingBibcodes[paper.bibcode] ? ( {downloadingBibcodes[paper.bibcode] ? (
<> <>
@ -347,14 +338,13 @@ export function PaperDetailModal({
setActiveTab('citation'); setActiveTab('citation');
loadCitations(paper.bibcode); loadCitations(paper.bibcode);
}} }}
className="px-6 btn-console btn-console-secondary py-2.5 rounded-lg text-xs font-bold text-center cursor-pointer" className="px-6 btn-console btn-console-secondary py-2.5 rounded-md text-xs font-bold text-center cursor-pointer"
> >
</button> </button>
</> </>
)} )}
</div> </div>
</div> </BaseModal>
</div>
); );
} }

View File

@ -1,4 +1,5 @@
import axios from 'axios'; import axios from 'axios';
import { BaseModal } from './BaseModal';
interface UncachedPaperModalProps { interface UncachedPaperModalProps {
bibcode: string | null; bibcode: string | null;
@ -10,34 +11,19 @@ export function UncachedPaperModal({ bibcode, onClose, loadCitations }: Uncached
if (!bibcode) return null; if (!bibcode) return null;
return ( return (
<div <BaseModal
onClick={onClose} isOpen={!!bibcode}
className="fixed inset-0 z-50 flex items-center justify-center bg-slate-900/40 backdrop-blur-xs transition-all animate-fade-in" onClose={onClose}
title="文献尚未入库"
dotColor="bg-star"
size="sm"
zIndex={50}
> >
<div
onClick={(e) => e.stopPropagation()}
className="bg-[var(--bg-card)] rounded-xl border border-[var(--border-precision)] shadow-md max-w-sm w-full p-6 space-y-4"
>
<div className="flex items-center justify-between border-b border-slate-100 pb-2.5">
<h3 className="text-xs font-bold text-[var(--text-main)] flex items-center gap-1.5">
<span className="w-2 h-2 rounded-full bg-[var(--accent-star)] animate-pulse" />
<span></span>
</h3>
<button
onClick={onClose}
className="text-slate-400 hover:text-slate-600 p-1 rounded-lg hover:bg-slate-100 transition-colors cursor-pointer"
title="关闭"
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="space-y-2"> <div className="space-y-2">
<p className="text-xs text-[var(--text-main)] leading-relaxed"> <p className="text-xs text-main leading-relaxed">
<span className="font-mono bg-slate-100 px-1.5 py-0.5 rounded text-[var(--accent-blueprint)] font-bold select-all">{bibcode}</span> <span className="font-mono bg-slate-100 px-1.5 py-0.5 rounded-md text-blueprint font-bold select-all">{bibcode}</span>
</p> </p>
<p className="text-[11px] text-[var(--text-muted)] leading-relaxed"> <p className="text-[11px] text-muted leading-relaxed">
线 NASA ADS 线 NASA ADS
</p> </p>
</div> </div>
@ -47,7 +33,7 @@ export function UncachedPaperModal({ bibcode, onClose, loadCitations }: Uncached
loadCitations(bibcode, false); loadCitations(bibcode, false);
onClose(); onClose();
}} }}
className="flex-1 btn-console btn-console-primary py-2 rounded-lg text-[11px] font-bold text-center cursor-pointer" className="flex-1 btn-console btn-console-primary py-2 rounded-md text-[11px] font-bold text-center cursor-pointer"
> >
</button> </button>
@ -56,18 +42,17 @@ export function UncachedPaperModal({ bibcode, onClose, loadCitations }: Uncached
axios.post('/api/active_bibcode', { bibcode }).catch(() => {}); axios.post('/api/active_bibcode', { bibcode }).catch(() => {});
window.open(`https://ui.adsabs.harvard.edu/abs/${bibcode}/abstract`, '_blank'); window.open(`https://ui.adsabs.harvard.edu/abs/${bibcode}/abstract`, '_blank');
}} }}
className="flex-1 btn-console btn-console-secondary py-2 rounded-lg text-[11px] font-bold text-center cursor-pointer" className="flex-1 btn-console btn-console-secondary py-2 rounded-md text-[11px] font-bold text-center cursor-pointer"
> >
ADS ADS
</button> </button>
<button <button
onClick={onClose} onClick={onClose}
className="px-3 btn-console btn-console-secondary py-2 rounded-lg text-[11px] font-bold text-center cursor-pointer" className="px-3 btn-console btn-console-secondary py-2 rounded-md text-[11px] font-bold text-center cursor-pointer"
> >
</button> </button>
</div> </div>
</div> </BaseModal>
</div>
); );
} }

View File

@ -49,9 +49,9 @@ export function Sidebar({ activeTab, setActiveTab, selectedPaper, loadCitations,
type="button" type="button"
disabled={!isCollapsed} disabled={!isCollapsed}
onClick={() => setIsCollapsed(false)} onClick={() => setIsCollapsed(false)}
className={`flex items-center justify-center shrink-0 rounded-xl transition-all duration-300 ${ className={`flex items-center justify-center shrink-0 rounded-lg transition-all duration-300 ${
isCollapsed isCollapsed
? 'w-11 h-11 bg-white hover:bg-sky-50/50 border border-slate-200 cursor-pointer shadow-xs hover:shadow-sm hover:scale-105 active:scale-95' ? 'w-11 h-11 bg-white hover:bg-slate-50 border border-slate-200 cursor-pointer shadow-xs hover:shadow-sm'
: 'w-9 h-9 bg-transparent border border-transparent cursor-default' : 'w-9 h-9 bg-transparent border border-transparent cursor-default'
}`} }`}
title={isCollapsed ? "展开导航" : undefined} title={isCollapsed ? "展开导航" : undefined}
@ -116,13 +116,13 @@ export function Sidebar({ activeTab, setActiveTab, selectedPaper, loadCitations,
isCollapsed ? 'px-2 py-2.5 justify-center' : 'px-3 py-2.5' isCollapsed ? 'px-2 py-2.5 justify-center' : 'px-3 py-2.5'
} ${ } ${
isActive isActive
? 'bg-sky-50 border-sky-200 text-sky-700 shadow-sm' ? 'bg-slate-100 border-slate-200 text-slate-850 shadow-xs'
: tab.disabled : tab.disabled
? 'opacity-30 cursor-not-allowed border-transparent text-slate-400' ? 'opacity-30 cursor-not-allowed border-transparent text-slate-400'
: 'border-transparent text-slate-650 hover:bg-slate-100 hover:text-slate-800' : 'border-transparent text-slate-650 hover:bg-slate-100 hover:text-slate-800'
}`} }`}
> >
<Icon className={`w-4 h-4 shrink-0 transition-colors duration-300 ${isActive ? 'text-sky-600' : 'text-slate-500'}`} /> <Icon className={`w-4 h-4 shrink-0 transition-colors duration-300 ${isActive ? 'text-slate-700' : 'text-slate-500'}`} />
<span <span
className={`truncate transition-all duration-300 origin-left ${ className={`truncate transition-all duration-300 origin-left ${
isCollapsed isCollapsed
@ -145,13 +145,13 @@ export function Sidebar({ activeTab, setActiveTab, selectedPaper, loadCitations,
className={`relative overflow-hidden transition-all duration-300 border rounded-lg ${ className={`relative overflow-hidden transition-all duration-300 border rounded-lg ${
isCollapsed isCollapsed
? 'p-0 border-transparent bg-transparent flex justify-center' ? 'p-0 border-transparent bg-transparent flex justify-center'
: 'p-3.5 border-sky-100 bg-sky-50/50' : 'p-3.5 border-slate-200 bg-slate-100/50'
}`} }`}
title={isCollapsed ? `当前选定文献: ${selectedPaper.title}` : undefined} title={isCollapsed ? `当前选定文献: ${selectedPaper.title}` : undefined}
> >
{/* 折叠下的微型图书图标 */} {/* 折叠下的微型图书图标 */}
<div <div
className={`transition-all duration-300 flex items-center justify-center rounded-lg border border-sky-100 bg-sky-50 text-sky-600 shrink-0 shadow-sm ${ className={`transition-all duration-300 flex items-center justify-center rounded-lg border border-slate-200 bg-slate-50 text-slate-650 shrink-0 shadow-xs ${
isCollapsed isCollapsed
? 'w-9 h-9 opacity-100 scale-100' ? 'w-9 h-9 opacity-100 scale-100'
: 'w-0 h-0 opacity-0 scale-75 overflow-hidden' : 'w-0 h-0 opacity-0 scale-75 overflow-hidden'
@ -168,7 +168,7 @@ export function Sidebar({ activeTab, setActiveTab, selectedPaper, loadCitations,
: 'opacity-100 max-w-[200px] max-h-40' : 'opacity-100 max-w-[200px] max-h-40'
}`} }`}
> >
<span className="text-[9px] font-bold text-sky-600 tracking-widest block mb-1"></span> <span className="text-[9px] font-bold text-slate-500 tracking-widest block mb-1"></span>
<h4 className="text-xs text-slate-800 font-bold line-clamp-2 mb-2 leading-relaxed">{selectedPaper.title}</h4> <h4 className="text-xs text-slate-800 font-bold line-clamp-2 mb-2 leading-relaxed">{selectedPaper.title}</h4>
<div className="flex items-center justify-between text-[10px] font-medium text-slate-500 gap-2"> <div className="flex items-center justify-between text-[10px] font-medium text-slate-500 gap-2">
<span className="shrink-0">: {selectedPaper.year}</span> <span className="shrink-0">: {selectedPaper.year}</span>

View File

@ -48,13 +48,13 @@ function PaperMetadataDossier({ metadata, isChinese }: { metadata: PaperMetadata
if (!metadata || (!metadata.title && !metadata.author)) return null; if (!metadata || (!metadata.title && !metadata.author)) return null;
return ( return (
<div className="mb-6 rounded-xl p-5 bg-gradient-to-br from-slate-50 to-slate-100 border border-slate-200 shadow-xs relative overflow-hidden select-text"> <div className="mb-6 rounded-lg p-5 bg-slate-50 border border-slate-200 relative overflow-hidden select-text">
{/* 科技风背景网格点缀 */} {/* 科技风背景线段装饰 - 极细学术边框线 */}
<div className="absolute top-0 right-0 w-24 h-24 bg-sky-550/5 rounded-bl-full pointer-events-none border-l border-b border-sky-500/10" /> <div className="absolute top-0 right-0 w-16 h-16 bg-slate-100/50 pointer-events-none border-l border-b border-slate-200/60" />
{/* 顶部档案标徽 */} {/* 顶部档案标徽 */}
<div className="flex items-center gap-2 mb-3 select-none"> <div className="flex items-center gap-2 mb-3 select-none">
<span className="inline-flex items-center px-2 py-0.5 rounded text-[9px] font-extrabold bg-sky-600 text-white uppercase tracking-wider"> <span className="inline-flex items-center px-2 py-0.5 rounded text-[9px] font-bold bg-slate-700 text-white uppercase tracking-wider">
{isChinese ? '文献元数据档案' : 'Paper Metadata Dossier'} {isChinese ? '文献元数据档案' : 'Paper Metadata Dossier'}
</span> </span>
{metadata.date && ( {metadata.date && (
@ -160,7 +160,7 @@ export function BilingualViewer({
<div <div
onMouseOver={handleMouseOver} onMouseOver={handleMouseOver}
onMouseLeave={handleContainerMouseLeave} onMouseLeave={handleContainerMouseLeave}
className="console-panel rounded-xl p-6 bg-white border border-slate-200 relative flex flex-col min-h-0 overflow-hidden" className="console-panel rounded-lg p-6 bg-white border border-slate-200 relative flex flex-col min-h-0 overflow-hidden"
> >
<div className="flex items-center justify-between mb-4 border-b border-slate-100 pb-2.5 shrink-0 select-none"> <div className="flex items-center justify-between mb-4 border-b border-slate-100 pb-2.5 shrink-0 select-none">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@ -169,7 +169,7 @@ export function BilingualViewer({
<button <button
type="button" type="button"
onClick={() => setShowPdf(!showPdf)} onClick={() => setShowPdf(!showPdf)}
className="text-[9px] font-bold px-2 py-0.5 rounded bg-sky-50 text-sky-700 border border-sky-200 hover:bg-sky-100 transition-all cursor-pointer flex items-center gap-1" className="text-[9px] font-bold px-2 py-0.5 rounded bg-slate-50 text-blueprint border border-slate-200 hover:bg-slate-100 transition-all cursor-pointer flex items-center gap-1"
> >
{showPdf ? ( {showPdf ? (
<> <>
@ -266,7 +266,7 @@ export function BilingualViewer({
{/* 中文翻译视窗 (或双语对照) */} {/* 中文翻译视窗 (或双语对照) */}
{(viewMode === 'chinese' || viewMode === 'bilingual') && ( {(viewMode === 'chinese' || viewMode === 'bilingual') && (
<div <div
className="console-panel rounded-xl p-6 bg-white border border-slate-200 relative flex flex-col overflow-hidden min-h-0" className="console-panel rounded-lg p-6 bg-white border border-slate-200 relative flex flex-col overflow-hidden min-h-0"
> >
<div className="flex items-center justify-between mb-4 border-b border-slate-100 pb-2.5 shrink-0 select-none"> <div className="flex items-center justify-between mb-4 border-b border-slate-100 pb-2.5 shrink-0 select-none">
<span className="text-xs font-bold text-slate-800"></span> <span className="text-xs font-bold text-slate-800"></span>
@ -274,7 +274,7 @@ export function BilingualViewer({
{translating ? ( {translating ? (
<div className="flex-1 flex flex-col items-center justify-center text-slate-500 space-y-3"> <div className="flex-1 flex flex-col items-center justify-center text-slate-500 space-y-3">
<Loader className="w-8 h-8 animate-spin text-sky-600" /> <Loader className="w-8 h-8 animate-spin text-blueprint" />
<p className="text-xs font-bold text-center">...</p> <p className="text-xs font-bold text-center">...</p>
</div> </div>
) : chnPure ? ( ) : chnPure ? (
@ -327,37 +327,36 @@ export function BilingualViewer({
transform: hoverCardPos.isAbove ? 'translateY(-100%)' : 'none', transform: hoverCardPos.isAbove ? 'translateY(-100%)' : 'none',
zIndex: 9999, zIndex: 9999,
}} }}
className="console-panel rounded-xl p-4 bg-white border border-slate-200 shadow-md w-72 text-xs space-y-2 pointer-events-auto select-text animate-in fade-in duration-200" className="bg-white rounded-lg p-4 border border-slate-350 shadow-sm w-72 text-[11px] space-y-2 pointer-events-auto select-text animate-in fade-in duration-200"
> >
<div className="flex items-center justify-between border-b border-slate-100 pb-1.5"> <div className="flex items-center justify-between border-b border-slate-200 pb-2">
<span className="font-bold text-slate-950 text-sm">{hoveredTarget.target_name}</span> <span className="font-bold text-slate-900 text-[13px]">{hoveredTarget.target_name}</span>
<div className="flex items-center gap-2"> <div className="flex items-center gap-1.5 select-none">
<a <a
href={`https://simbad.cds.unistra.fr/simbad/sim-id?Ident=${encodeURIComponent(hoveredTarget.target_name)}`} href={`https://simbad.cds.unistra.fr/simbad/sim-id?Ident=${encodeURIComponent(hoveredTarget.target_name)}`}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-[10px] font-bold text-sky-600 hover:text-sky-850 hover:underline cursor-pointer" className="text-[9px] font-extrabold bg-slate-100 hover:bg-slate-200 text-slate-700 hover:text-slate-900 px-1.5 py-0.5 rounded border border-slate-200 transition-all cursor-pointer"
title="在 SIMBAD 中查询该天体详情" title="在 SIMBAD 中查询该天体详情"
> >
SIMBAD SIMBAD
</a> </a>
<span className="text-[9px] text-slate-300">|</span>
<a <a
href={`https://vizier.cds.unistra.fr/viz-bin/VizieR?-c=${encodeURIComponent(hoveredTarget.target_name)}`} href={`https://vizier.cds.unistra.fr/viz-bin/VizieR?-c=${encodeURIComponent(hoveredTarget.target_name)}`}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-[10px] font-bold text-sky-600 hover:text-sky-850 hover:underline cursor-pointer" className="text-[9px] font-extrabold bg-slate-100 hover:bg-slate-200 text-slate-700 hover:text-slate-900 px-1.5 py-0.5 rounded border border-slate-200 transition-all cursor-pointer"
title="在 VizieR 中查询相关文献和表数据" title="在 VizieR 中查询相关文献和表数据"
> >
VizieR VizieR
</a> </a>
</div> </div>
</div> </div>
<div className="grid grid-cols-2 gap-x-2 gap-y-1.5 text-slate-500"> <div className="grid grid-cols-2 gap-x-2 gap-y-1.5 text-slate-600">
{/* 天体类型 & 官方名称 */} {/* 天体类型 & 官方名称 */}
<div className="col-span-2 flex items-center gap-2"> <div className="col-span-2 flex items-center gap-2">
{hoveredTarget.otype && ( {hoveredTarget.otype && (
<span className="inline-flex items-center px-1.5 py-0.5 rounded bg-amber-50 text-amber-700 border border-amber-200 text-[10px] font-bold font-mono"> <span className="inline-flex items-center px-1.5 py-0.5 rounded bg-amber-50 text-amber-850 border border-amber-200/70 text-[9px] font-bold font-mono">
{hoveredTarget.otype} {hoveredTarget.otype}
</span> </span>
)} )}
@ -369,20 +368,20 @@ export function BilingualViewer({
</div> </div>
{/* 坐标 */} {/* 坐标 */}
<div> <div>
<span className="text-slate-400 font-semibold">RA (J2000):</span> <span className="text-slate-500 font-bold tracking-wide text-[9px] uppercase block">RA (J2000)</span>
<div className="font-mono font-bold text-slate-800">{hoveredTarget.ra || '未知'}</div> <div className="font-mono font-bold text-slate-800">{hoveredTarget.ra || '未知'}</div>
</div> </div>
<div> <div>
<span className="text-slate-400 font-semibold">Dec (J2000):</span> <span className="text-slate-500 font-bold tracking-wide text-[9px] uppercase block">Dec (J2000)</span>
<div className="font-mono font-bold text-slate-800">{hoveredTarget.dec || '未知'}</div> <div className="font-mono font-bold text-slate-800">{hoveredTarget.dec || '未知'}</div>
</div> </div>
{/* 光谱型 & 视星等 */} {/* 光谱型 & 视星等 */}
<div> <div>
<span className="text-slate-400 font-semibold">:</span> <span className="text-slate-500 font-bold tracking-wide text-[9px] uppercase block"></span>
<div className="font-bold text-slate-800">{hoveredTarget.spectral_type || '未知'}</div> <div className="font-bold text-slate-800">{hoveredTarget.spectral_type || '未知'}</div>
</div> </div>
<div> <div>
<span className="text-slate-400 font-semibold"> (V):</span> <span className="text-slate-500 font-bold tracking-wide text-[9px] uppercase block"> (V)</span>
<div className="font-bold text-slate-800"> <div className="font-bold text-slate-800">
{hoveredTarget.v_magnitude !== null && hoveredTarget.v_magnitude !== undefined {hoveredTarget.v_magnitude !== null && hoveredTarget.v_magnitude !== undefined
? `${hoveredTarget.v_magnitude.toFixed(2)}` ? `${hoveredTarget.v_magnitude.toFixed(2)}`
@ -391,7 +390,7 @@ export function BilingualViewer({
</div> </div>
{/* 视差 + 误差 + 估算距离 */} {/* 视差 + 误差 + 估算距离 */}
<div className="col-span-2"> <div className="col-span-2">
<span className="text-slate-400 font-semibold"> / :</span> <span className="text-slate-500 font-bold tracking-wide text-[9px] uppercase block"> / </span>
<div className="font-bold text-slate-800"> <div className="font-bold text-slate-800">
{hoveredTarget.parallax !== null && hoveredTarget.parallax !== undefined {hoveredTarget.parallax !== null && hoveredTarget.parallax !== undefined
? `${hoveredTarget.parallax.toFixed(2)}${hoveredTarget.parallax_err != null ? `±${hoveredTarget.parallax_err.toFixed(4)}` : ''} mas (~${(1000.0 / hoveredTarget.parallax).toFixed(1)} pc)` ? `${hoveredTarget.parallax.toFixed(2)}${hoveredTarget.parallax_err != null ? `±${hoveredTarget.parallax_err.toFixed(4)}` : ''} mas (~${(1000.0 / hoveredTarget.parallax).toFixed(1)} pc)`
@ -400,8 +399,8 @@ export function BilingualViewer({
</div> </div>
{/* 自行 */} {/* 自行 */}
<div className="col-span-2"> <div className="col-span-2">
<span className="text-slate-400 font-semibold">:</span> <span className="text-slate-500 font-bold tracking-wide text-[9px] uppercase block"></span>
<div className="font-bold text-slate-800"> <div className="font-bold text-slate-800 text-[10.5px]">
{hoveredTarget.pm_ra != null && hoveredTarget.pm_de != null {hoveredTarget.pm_ra != null && hoveredTarget.pm_de != null
? `RA ${hoveredTarget.pm_ra.toFixed(3)}, Dec ${hoveredTarget.pm_de.toFixed(3)} mas/yr` ? `RA ${hoveredTarget.pm_ra.toFixed(3)}, Dec ${hoveredTarget.pm_de.toFixed(3)} mas/yr`
: '未知'} : '未知'}
@ -409,7 +408,7 @@ export function BilingualViewer({
</div> </div>
{/* 视向速度 */} {/* 视向速度 */}
<div className="col-span-2"> <div className="col-span-2">
<span className="text-slate-400 font-semibold">:</span> <span className="text-slate-500 font-bold tracking-wide text-[9px] uppercase block"></span>
<div className="font-bold text-slate-800"> <div className="font-bold text-slate-800">
{hoveredTarget.radial_velocity != null {hoveredTarget.radial_velocity != null
? `${hoveredTarget.radial_velocity.toFixed(1)} km/s` ? `${hoveredTarget.radial_velocity.toFixed(1)} km/s`
@ -419,9 +418,9 @@ export function BilingualViewer({
</div> </div>
{/* 多波段测光 */} {/* 多波段测光 */}
{hoveredTarget.photometry && Object.keys(hoveredTarget.photometry).length > 1 && ( {hoveredTarget.photometry && Object.keys(hoveredTarget.photometry).length > 1 && (
<div className="border-t border-slate-100 pt-1.5"> <div className="border-t border-slate-200/60 pt-2">
<span className="text-slate-400 font-semibold block mb-1">:</span> <span className="text-slate-500 font-bold tracking-wide text-[9px] uppercase block mb-1"></span>
<div className="flex flex-wrap gap-x-2 gap-y-0.5 text-[10px] font-mono text-slate-600"> <div className="flex flex-wrap gap-x-2.5 gap-y-0.5 text-[10px] font-mono text-slate-600">
{Object.entries(hoveredTarget.photometry).map(([band, mag]) => ( {Object.entries(hoveredTarget.photometry).map(([band, mag]) => (
<span key={band} className="whitespace-nowrap"> <span key={band} className="whitespace-nowrap">
<span className="text-slate-400">{band}</span>{' '} <span className="text-slate-400">{band}</span>{' '}
@ -432,8 +431,8 @@ export function BilingualViewer({
</div> </div>
)} )}
{hoveredTarget.aliases && hoveredTarget.aliases.length > 0 && ( {hoveredTarget.aliases && hoveredTarget.aliases.length > 0 && (
<div className="border-t border-slate-100 pt-1.5"> <div className="border-t border-slate-200/60 pt-2">
<span className="text-slate-400 font-semibold block mb-0.5">:</span> <span className="text-slate-500 font-bold tracking-wide text-[9px] uppercase block mb-0.5"></span>
<div className="text-[10px] text-slate-500 font-medium leading-relaxed max-h-[36px] overflow-y-auto scrollbar-thin font-mono"> <div className="text-[10px] text-slate-500 font-medium leading-relaxed max-h-[36px] overflow-y-auto scrollbar-thin font-mono">
{hoveredTarget.aliases.slice(0, 8).join(', ')} {hoveredTarget.aliases.slice(0, 8).join(', ')}
{hoveredTarget.aliases.length > 8 && ' ...'} {hoveredTarget.aliases.length > 8 && ' ...'}

View File

@ -69,7 +69,7 @@ export function ReaderNotesSidebar({
if (!showNotesPanel) return null; if (!showNotesPanel) return null;
return ( return (
<div className="console-panel rounded-xl border border-slate-200 bg-slate-50 flex flex-col overflow-hidden relative shadow-sm h-full"> <div className="console-panel rounded-lg border border-slate-200 bg-slate-50 flex flex-col overflow-hidden relative shadow-sm h-full">
<div className="px-4 py-3 border-b border-slate-200 flex items-center justify-between bg-white shrink-0"> <div className="px-4 py-3 border-b border-slate-200 flex items-center justify-between bg-white shrink-0">
<span className="text-xs font-bold text-slate-800"> <span className="text-xs font-bold text-slate-800">
{sidebarTab === 'notes' ? '观测记录手札' : '文献 AI 问答'} {sidebarTab === 'notes' ? '观测记录手札' : '文献 AI 问答'}
@ -86,7 +86,7 @@ export function ReaderNotesSidebar({
onClick={() => setSidebarTab('notes')} onClick={() => setSidebarTab('notes')}
className={`flex-1 py-2 text-center text-xs font-bold transition-all border-b-2 cursor-pointer ${ className={`flex-1 py-2 text-center text-xs font-bold transition-all border-b-2 cursor-pointer ${
sidebarTab === 'notes' sidebarTab === 'notes'
? 'border-sky-600 text-sky-700' ? 'border-blueprint text-blueprint'
: 'border-transparent text-slate-500 hover:text-slate-700' : 'border-transparent text-slate-500 hover:text-slate-700'
}`} }`}
> >
@ -97,7 +97,7 @@ export function ReaderNotesSidebar({
onClick={() => setSidebarTab('ai')} onClick={() => setSidebarTab('ai')}
className={`flex-1 py-2 text-center text-xs font-bold transition-all border-b-2 cursor-pointer ${ className={`flex-1 py-2 text-center text-xs font-bold transition-all border-b-2 cursor-pointer ${
sidebarTab === 'ai' sidebarTab === 'ai'
? 'border-sky-600 text-sky-700' ? 'border-blueprint text-blueprint'
: 'border-transparent text-slate-500 hover:text-slate-700' : 'border-transparent text-slate-500 hover:text-slate-700'
}`} }`}
> >

View File

@ -84,7 +84,7 @@ export function ReaderToolbar({
{showSwitchMenu && ( {showSwitchMenu && (
<> <>
<div className="fixed inset-0 z-45" onClick={() => setShowSwitchMenu(false)} /> <div className="fixed inset-0 z-45" onClick={() => setShowSwitchMenu(false)} />
<div className="absolute right-0 mt-1.5 w-72 rounded-xl bg-white border border-slate-200 shadow-xl py-1.5 z-50 text-xs max-h-96 overflow-y-auto scrollbar-thin"> <div className="absolute right-0 mt-1.5 w-72 rounded-md bg-white border border-slate-200 shadow-md py-1.5 z-50 text-xs max-h-96 overflow-y-auto scrollbar-thin">
{/* 最近阅读部分 */} {/* 最近阅读部分 */}
{recentlySelected.length > 0 && ( {recentlySelected.length > 0 && (
<div className="border-b border-slate-100 pb-1.5 mb-1.5"> <div className="border-b border-slate-100 pb-1.5 mb-1.5">
@ -101,7 +101,7 @@ export function ReaderToolbar({
}} }}
className={`w-full px-3 py-2 text-left hover:bg-slate-50 transition-colors flex flex-col gap-0.5 border-l-2 ${ className={`w-full px-3 py-2 text-left hover:bg-slate-50 transition-colors flex flex-col gap-0.5 border-l-2 ${
paper.bibcode === selectedPaper.bibcode paper.bibcode === selectedPaper.bibcode
? 'border-sky-500 bg-sky-50/30 text-sky-850 font-bold' ? 'border-blueprint bg-blueprint/5 text-blueprint font-bold'
: 'border-transparent text-slate-700 font-medium' : 'border-transparent text-slate-700 font-medium'
}`} }`}
> >
@ -132,7 +132,7 @@ export function ReaderToolbar({
}} }}
className={`w-full px-3 py-2 text-left hover:bg-slate-50 transition-colors flex flex-col gap-0.5 border-l-2 ${ className={`w-full px-3 py-2 text-left hover:bg-slate-50 transition-colors flex flex-col gap-0.5 border-l-2 ${
paper.bibcode === selectedPaper.bibcode paper.bibcode === selectedPaper.bibcode
? 'border-sky-500 bg-sky-50/30 text-sky-850 font-bold' ? 'border-blueprint bg-blueprint/5 text-blueprint font-bold'
: 'border-transparent text-slate-700 font-medium' : 'border-transparent text-slate-700 font-medium'
}`} }`}
> >

View File

@ -48,10 +48,10 @@ export function BatchPipelineCard({
handleStopBatch, handleStopBatch,
}: BatchPipelineCardProps) { }: BatchPipelineCardProps) {
return ( return (
<div className="console-panel p-6 rounded-xl border border-slate-200 bg-white space-y-6 relative overflow-hidden shadow-xs"> <div className="console-panel p-6 rounded-lg border border-slate-200 bg-white space-y-6 relative overflow-hidden shadow-sm">
<div className="flex flex-col gap-1 select-none"> <div className="flex flex-col gap-1 select-none">
<h3 className="text-xs font-bold text-slate-900 flex items-center gap-2"> <h3 className="text-xs font-bold text-slate-900 flex items-center gap-2">
<Download className="w-4 h-4 text-sky-600" /> <Download className="w-4 h-4 text-slate-700" />
<span>线</span> <span>线</span>
</h3> </h3>
<p className="text-slate-500 text-xs mt-0.5"> <p className="text-slate-500 text-xs mt-0.5">
@ -60,7 +60,7 @@ export function BatchPipelineCard({
</div> </div>
{batchError && ( {batchError && (
<div className="p-4 rounded-lg bg-red-50 border border-red-200 flex gap-3 text-xs text-red-750 items-start"> <div className="p-4 rounded-md bg-rose-50 border border-rose-200/60 flex gap-3 text-xs text-rose-800 items-start">
<AlertTriangle className="w-4 h-4 shrink-0 mt-0.5" /> <AlertTriangle className="w-4 h-4 shrink-0 mt-0.5" />
<div>{batchError}</div> <div>{batchError}</div>
</div> </div>
@ -92,7 +92,7 @@ export function BatchPipelineCard({
value={batchLimitCount} value={batchLimitCount}
disabled={batchStatus.active} disabled={batchStatus.active}
onChange={e => setBatchLimitCount(Math.max(1, parseInt(e.target.value) || 0))} onChange={e => setBatchLimitCount(Math.max(1, parseInt(e.target.value) || 0))}
className="w-full px-3 py-2 rounded-lg bg-slate-50 border border-slate-300 text-slate-900 focus:outline-none focus:border-sky-500 transition-all text-xs font-medium" className="w-full px-3 py-2 rounded-md bg-slate-50 border border-slate-300 text-slate-900 focus:outline-none focus:border-blueprint transition-all text-xs font-medium"
/> />
</div> </div>
@ -122,7 +122,7 @@ export function BatchPipelineCard({
checked={skipCompleted} checked={skipCompleted}
disabled={batchStatus.active} disabled={batchStatus.active}
onChange={e => setSkipCompleted(e.target.checked)} onChange={e => setSkipCompleted(e.target.checked)}
className="rounded text-sky-600 focus:ring-sky-500" className="rounded text-blueprint focus:ring-blueprint/25"
/> />
<span></span> <span></span>
</label> </label>
@ -133,7 +133,7 @@ export function BatchPipelineCard({
checked={skipFailed} checked={skipFailed}
disabled={batchStatus.active} disabled={batchStatus.active}
onChange={e => setSkipFailed(e.target.checked)} onChange={e => setSkipFailed(e.target.checked)}
className="rounded text-sky-600 focus:ring-sky-500" className="rounded text-blueprint focus:ring-blueprint/25"
/> />
<span></span> <span></span>
</label> </label>
@ -144,7 +144,7 @@ export function BatchPipelineCard({
checked={skipPrecedingFailed} checked={skipPrecedingFailed}
disabled={batchStatus.active} disabled={batchStatus.active}
onChange={e => setSkipPrecedingFailed(e.target.checked)} onChange={e => setSkipPrecedingFailed(e.target.checked)}
className="rounded text-sky-600 focus:ring-sky-500" className="rounded text-blueprint focus:ring-blueprint/25"
/> />
<span></span> <span></span>
</label> </label>
@ -155,7 +155,7 @@ export function BatchPipelineCard({
checked={skipPrecedingUncompleted} checked={skipPrecedingUncompleted}
disabled={batchStatus.active} disabled={batchStatus.active}
onChange={e => setSkipPrecedingUncompleted(e.target.checked)} onChange={e => setSkipPrecedingUncompleted(e.target.checked)}
className="rounded text-sky-600 focus:ring-sky-500" className="rounded text-blueprint focus:ring-blueprint/25"
/> />
<span></span> <span></span>
</label> </label>
@ -169,7 +169,7 @@ export function BatchPipelineCard({
<button <button
type="button" type="button"
onClick={handleStopBatch} onClick={handleStopBatch}
className="w-full py-2 rounded-lg bg-red-600 hover:bg-red-700 text-white text-xs font-bold flex items-center justify-center gap-2 transition-all shadow-sm cursor-pointer" className="w-full py-2 rounded-md bg-rose-750 hover:bg-rose-855 text-white text-xs font-bold flex items-center justify-center gap-2 transition-all shadow-sm cursor-pointer"
> >
<StopCircle className="w-3.5 h-3.5" /> <StopCircle className="w-3.5 h-3.5" />
@ -178,7 +178,7 @@ export function BatchPipelineCard({
<button <button
type="button" type="button"
onClick={handleStartBatch} onClick={handleStartBatch}
className="btn-console btn-console-primary w-full py-2 rounded-lg text-xs font-bold flex items-center justify-center gap-2 cursor-pointer" className="btn-console btn-console-primary w-full py-2 rounded-md text-xs font-bold flex items-center justify-center gap-2 cursor-pointer"
> >
<Play className="w-3.5 h-3.5" /> <Play className="w-3.5 h-3.5" />
@ -193,11 +193,11 @@ export function BatchPipelineCard({
<div className="space-y-1.5"> <div className="space-y-1.5">
<div className="flex justify-between text-xs font-bold text-slate-600 select-none"> <div className="flex justify-between text-xs font-bold text-slate-600 select-none">
<span className="flex items-center gap-1.5"> <span className="flex items-center gap-1.5">
{batchStatus.action === 'download' && <Download className="w-3.5 h-3.5 text-sky-600" />} {batchStatus.action === 'download' && <Download className="w-3.5 h-3.5 text-blueprint" />}
{batchStatus.action === 'parse' && <FileText className="w-3.5 h-3.5 text-emerald-650" />} {batchStatus.action === 'parse' && <FileText className="w-3.5 h-3.5 text-emerald-700" />}
{batchStatus.action === 'translate' && <RefreshCw className="w-3.5 h-3.5 text-indigo-600 animate-spin" />} {batchStatus.action === 'translate' && <RefreshCw className="w-3.5 h-3.5 text-slate-700 animate-spin" />}
{batchStatus.action === 'embed' && <SlidersHorizontal className="w-3.5 h-3.5 text-amber-600" />} {batchStatus.action === 'embed' && <SlidersHorizontal className="w-3.5 h-3.5 text-amber-600" />}
{batchStatus.action === 'target' && <RefreshCw className="w-3.5 h-3.5 text-sky-600" />} {batchStatus.action === 'target' && <RefreshCw className="w-3.5 h-3.5 text-blueprint" />}
{batchStatus.action === 'download' {batchStatus.action === 'download'
? '正文离线下载进度' ? '正文离线下载进度'
: batchStatus.action === 'parse' : batchStatus.action === 'parse'
@ -218,18 +218,18 @@ export function BatchPipelineCard({
)} )}
</span> </span>
</div> </div>
<div className="w-full h-2 rounded-full bg-slate-100 overflow-hidden border border-slate-200 select-none"> <div className="w-full h-2 rounded-md bg-slate-100 overflow-hidden border border-slate-200 select-none">
<div <div
className={`h-full transition-all duration-300 ${ className={`h-full transition-all duration-300 ${
batchStatus.action === 'download' batchStatus.action === 'download'
? 'bg-sky-600' ? 'bg-blueprint'
: batchStatus.action === 'parse' : batchStatus.action === 'parse'
? 'bg-emerald-600' ? 'bg-emerald-600'
: batchStatus.action === 'translate' : batchStatus.action === 'translate'
? 'bg-indigo-600' ? 'bg-slate-700'
: batchStatus.action === 'embed' : batchStatus.action === 'embed'
? 'bg-amber-600' ? 'bg-amber-600'
: 'bg-sky-600' : 'bg-blueprint'
}`} }`}
style={{ style={{
width: `${ width: `${
@ -253,15 +253,15 @@ export function BatchPipelineCard({
{batchStatus.active && batchStatus.current_bibcode && ( {batchStatus.active && batchStatus.current_bibcode && (
<div className="text-xs font-bold text-slate-600 flex items-center gap-2"> <div className="text-xs font-bold text-slate-600 flex items-center gap-2">
<Loader className="w-3.5 h-3.5 text-sky-600 animate-spin" /> <Loader className="w-3.5 h-3.5 text-blueprint animate-spin" />
<span>: <code className="bg-slate-100 px-2 py-0.5 rounded font-mono font-bold text-slate-800 border border-slate-200 select-all">{batchStatus.current_bibcode}</code></span> <span>: <code className="bg-slate-100 px-2 py-0.5 rounded-md font-mono font-bold text-slate-800 border border-slate-200 select-all">{batchStatus.current_bibcode}</code></span>
</div> </div>
)} )}
{/* 滚动日志终端 */} {/* 滚动日志终端 */}
<div className="space-y-2"> <div className="space-y-2">
<label className="text-xs font-bold text-slate-700 block select-none"></label> <label className="text-xs font-bold text-slate-700 block select-none"></label>
<div ref={logsContainerRef} className="bg-slate-50 text-slate-800 font-mono text-[11px] p-4 rounded-lg h-48 overflow-y-auto border border-slate-250 space-y-1 scrollbar-thin scrollbar-thumb-slate-300 relative select-all"> <div ref={logsContainerRef} className="bg-slate-50 text-slate-800 font-mono text-[11px] p-4 rounded-md h-48 overflow-y-auto border border-slate-250 space-y-1 scrollbar-thin scrollbar-thumb-slate-300 relative select-all">
{batchStatus.logs.length === 0 ? ( {batchStatus.logs.length === 0 ? (
<div className="text-slate-400 italic select-none">...</div> <div className="text-slate-400 italic select-none">...</div>
) : ( ) : (

View File

@ -19,10 +19,10 @@ export function BookmarkletToolCard() {
}; };
return ( return (
<div className="console-panel p-6 rounded-xl border border-slate-200 bg-white space-y-4 shadow-xs"> <div className="console-panel p-6 rounded-lg border border-slate-200 bg-white space-y-4 shadow-sm">
<div className="flex flex-col gap-1 border-b border-slate-100 pb-2 select-none"> <div className="flex flex-col gap-1 border-b border-slate-100 pb-2 select-none">
<h3 className="text-xs font-bold text-slate-900 flex items-center gap-2"> <h3 className="text-xs font-bold text-slate-900 flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-sky-500 animate-pulse" /> <span className="w-2 h-2 rounded-full bg-blueprint" />
<span></span> <span></span>
</h3> </h3>
<p className="text-slate-500 text-xs mt-1 leading-relaxed"> <p className="text-slate-500 text-xs mt-1 leading-relaxed">
@ -41,7 +41,7 @@ export function BookmarkletToolCard() {
{/* 可直接拖拽的链接按钮 */} {/* 可直接拖拽的链接按钮 */}
<a <a
ref={linkRef} ref={linkRef}
className="px-4 py-2 bg-gradient-to-r from-sky-500 to-indigo-500 hover:from-sky-650 hover:to-indigo-650 text-white rounded-lg text-xs font-bold transition-all shadow-sm cursor-move flex items-center gap-1.5" className="px-4 py-2 bg-blueprint hover:bg-slate-800 text-white rounded-md text-xs font-bold transition-all shadow-2xs cursor-move flex items-center gap-1.5"
title="拖拽我到您的书签栏中" title="拖拽我到您的书签栏中"
onClick={(e) => e.preventDefault()} onClick={(e) => e.preventDefault()}
> >
@ -53,7 +53,7 @@ export function BookmarkletToolCard() {
<button <button
onClick={handleCopyCode} onClick={handleCopyCode}
className="px-3 py-2 bg-slate-100 hover:bg-slate-200 text-slate-700 rounded-lg text-[11px] font-bold transition-all cursor-pointer" className="px-3 py-2 bg-slate-100 hover:bg-slate-200 text-slate-700 rounded-md text-[11px] font-bold transition-all cursor-pointer"
> >
JavaScript JavaScript
</button> </button>

View File

@ -48,7 +48,7 @@ export function MetadataSyncCard({
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* 控制面板卡片 */} {/* 控制面板卡片 */}
<div className="console-panel p-6 rounded-xl space-y-6 relative overflow-hidden bg-white border border-slate-200"> <div className="console-panel p-6 rounded-lg space-y-6 relative overflow-hidden bg-white border border-slate-200">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2"> <div className="space-y-2">
<label className="text-xs font-bold text-slate-700 block flex justify-between items-center"> <label className="text-xs font-bold text-slate-700 block flex justify-between items-center">
@ -56,7 +56,7 @@ export function MetadataSyncCard({
<button <button
type="button" type="button"
onClick={() => setShowBuilder(!showBuilder)} onClick={() => setShowBuilder(!showBuilder)}
className="text-xs text-sky-600 hover:text-sky-750 font-bold flex items-center gap-1 cursor-pointer" className="text-xs text-slate-655 hover:text-slate-850 font-bold flex items-center gap-1 cursor-pointer underline underline-offset-2"
> >
<SlidersHorizontal className="w-3.5 h-3.5" /> <SlidersHorizontal className="w-3.5 h-3.5" />
{showBuilder ? '隐藏高级构造' : '高级检索构造'} {showBuilder ? '隐藏高级构造' : '高级检索构造'}
@ -68,7 +68,7 @@ export function MetadataSyncCard({
onChange={e => setQuery(e.target.value)} onChange={e => setQuery(e.target.value)}
disabled={status.active} disabled={status.active}
placeholder="例如: hot subdwarf, Gaia BH1..." placeholder="例如: hot subdwarf, Gaia BH1..."
className="w-full px-4 py-2 rounded-lg bg-slate-50 border border-slate-300 text-slate-900 placeholder-slate-400 focus:outline-none focus:border-sky-500 focus:bg-white transition-all text-xs font-medium" className="w-full px-4 py-2 rounded-md bg-slate-50 border border-slate-300 text-slate-900 placeholder-slate-400 focus:outline-none focus:border-blueprint focus:bg-white transition-all text-xs font-medium"
/> />
<div className="text-[11px] text-slate-450 flex flex-wrap gap-x-2.5 px-0.5 mt-1 select-none"> <div className="text-[11px] text-slate-450 flex flex-wrap gap-x-2.5 px-0.5 mt-1 select-none">
<span>:</span> <span>:</span>
@ -89,9 +89,9 @@ export function MetadataSyncCard({
type="button" type="button"
disabled={status.active} disabled={status.active}
onClick={() => setSource(src.id as any)} onClick={() => setSource(src.id as any)}
className={`flex-1 py-2 rounded-lg text-xs font-bold border transition-all cursor-pointer ${ className={`flex-1 py-2 rounded-md text-xs font-bold border transition-all cursor-pointer ${
source === src.id source === src.id
? 'bg-sky-50 border-sky-300 text-sky-700 shadow-sm' ? 'bg-slate-100 border-slate-350 text-slate-855 shadow-2xs'
: 'btn-console btn-console-secondary' : 'btn-console btn-console-secondary'
}`} }`}
> >
@ -104,13 +104,13 @@ export function MetadataSyncCard({
{/* 动态表单生成器 */} {/* 动态表单生成器 */}
{showBuilder && ( {showBuilder && (
<div className="p-4 rounded-lg bg-slate-50 border border-slate-200 space-y-3.5 transition-all"> <div className="p-4 rounded-md bg-slate-50 border border-slate-200 space-y-3.5 transition-all">
<div className="text-xs font-bold text-slate-700 flex justify-between items-center select-none"> <div className="text-xs font-bold text-slate-700 flex justify-between items-center select-none">
<span></span> <span></span>
<button <button
type="button" type="button"
onClick={handleAddRule} onClick={handleAddRule}
className="text-xs text-sky-600 hover:text-sky-750 font-bold cursor-pointer" className="text-xs text-slate-655 hover:text-slate-855 font-bold cursor-pointer"
> >
+ +
</button> </button>
@ -158,14 +158,14 @@ export function MetadataSyncCard({
? '例如: Althaus' ? '例如: Althaus'
: '请输入检索词...' : '请输入检索词...'
} }
className="flex-1 px-3 py-1.5 rounded-lg bg-white border border-slate-300 text-slate-900 placeholder-slate-400 focus:outline-none focus:border-sky-500 text-xs font-medium" className="flex-1 px-3 py-1.5 rounded-md bg-white border border-slate-300 text-slate-900 placeholder-slate-450 focus:outline-none focus:border-blueprint text-xs font-medium"
/> />
{rules.length > 1 && ( {rules.length > 1 && (
<button <button
type="button" type="button"
onClick={() => handleRemoveRule(idx)} onClick={() => handleRemoveRule(idx)}
className="text-red-600 hover:text-red-750 text-xs font-bold px-2 py-1.5 cursor-pointer" className="text-slate-500 hover:text-slate-800 text-xs font-bold px-2 py-1.5 cursor-pointer"
> >
</button> </button>
@ -187,7 +187,7 @@ export function MetadataSyncCard({
value={limit} value={limit}
disabled={status.active} disabled={status.active}
onChange={e => setLimit(Math.max(1, parseInt(e.target.value) || 0))} onChange={e => setLimit(Math.max(1, parseInt(e.target.value) || 0))}
className="w-full px-4 py-2 rounded-lg bg-slate-50 border border-slate-300 text-slate-900 focus:outline-none focus:border-sky-500 transition-all text-xs font-medium" className="w-full px-4 py-2 rounded-md bg-slate-50 border border-slate-300 text-slate-900 focus:outline-none focus:border-blueprint transition-all text-xs font-medium"
/> />
</div> </div>
@ -196,7 +196,7 @@ export function MetadataSyncCard({
type="button" type="button"
disabled={status.active || estimating} disabled={status.active || estimating}
onClick={handleEstimate} onClick={handleEstimate}
className="flex-1 py-2 rounded-lg bg-white border border-slate-300 hover:bg-slate-50 text-slate-750 text-xs font-bold flex items-center justify-center gap-2 transition-all disabled:opacity-40 cursor-pointer" className="flex-1 py-2 rounded-md bg-white border border-slate-300 hover:bg-slate-50 text-slate-750 text-xs font-bold flex items-center justify-center gap-2 transition-all disabled:opacity-40 cursor-pointer"
> >
{estimating ? <Loader className="w-3.5 h-3.5 animate-spin" /> : <RefreshCw className="w-3.5 h-3.5" />} {estimating ? <Loader className="w-3.5 h-3.5 animate-spin" /> : <RefreshCw className="w-3.5 h-3.5" />}
@ -205,7 +205,7 @@ export function MetadataSyncCard({
type="button" type="button"
disabled={status.active || !query.trim()} disabled={status.active || !query.trim()}
onClick={handleStartHarvest} onClick={handleStartHarvest}
className="btn-console btn-console-primary flex-1 py-2 rounded-lg text-xs font-bold flex items-center justify-center gap-2 disabled:opacity-40 cursor-pointer" className="btn-console btn-console-primary flex-1 py-2 rounded-md text-xs font-bold flex items-center justify-center gap-2 disabled:opacity-40 cursor-pointer"
> >
<Play className="w-3.5 h-3.5" /> <Play className="w-3.5 h-3.5" />
@ -215,10 +215,10 @@ export function MetadataSyncCard({
{/* 预估结果 */} {/* 预估结果 */}
{estimatedCount !== null && !status.active && ( {estimatedCount !== null && !status.active && (
<div className="p-4 rounded-lg bg-sky-50 border border-sky-200 flex gap-3 text-xs text-sky-850 items-center select-none animate-in fade-in duration-250"> <div className="p-4 rounded-md bg-slate-50 border border-slate-200 flex gap-3 text-xs text-slate-800 items-center select-none animate-in fade-in duration-250">
<Info className="w-4 h-4 shrink-0 text-sky-600" /> <Info className="w-4 h-4 shrink-0 text-slate-500" />
<div> <div>
<strong className="text-sky-900 font-bold">{estimatedCount}</strong> <strong className="text-slate-900 font-bold">{estimatedCount}</strong>
{estimatedCount > limit ? ` 限制最大同步前 ${limit} 篇元数据。` : ' 将全部进行同步。'} {estimatedCount > limit ? ` 限制最大同步前 ${limit} 篇元数据。` : ' 将全部进行同步。'}
</div> </div>
</div> </div>
@ -227,18 +227,18 @@ export function MetadataSyncCard({
{/* 实时同步进度 */} {/* 实时同步进度 */}
{(status.active || status.synced > 0) && ( {(status.active || status.synced > 0) && (
<div className="console-panel p-6 rounded-xl border border-slate-200 bg-white space-y-4 shadow-xs"> <div className="console-panel p-6 rounded-lg border border-slate-200 bg-white space-y-4 shadow-sm">
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<div> <div>
<h3 className="text-xs font-bold text-slate-850 flex items-center gap-2"> <h3 className="text-xs font-bold text-slate-850 flex items-center gap-2">
{status.active ? ( {status.active ? (
<> <>
<Loader className="w-4 h-4 text-sky-600 animate-spin" /> <Loader className="w-4 h-4 text-blueprint animate-spin" />
<span>...</span> <span>...</span>
</> </>
) : ( ) : (
<> <>
<CheckCircle className="w-4 h-4 text-emerald-655" /> <CheckCircle className="w-4 h-4 text-emerald-700" />
<span></span> <span></span>
</> </>
)} )}
@ -247,18 +247,18 @@ export function MetadataSyncCard({
: <code className="bg-slate-100 px-1 py-0.5 rounded text-slate-700 font-mono">{status.query}</code> : {status.source === 'all' ? '全部' : status.source === 'ads' ? 'NASA ADS' : 'arXiv'} : <code className="bg-slate-100 px-1 py-0.5 rounded text-slate-700 font-mono">{status.query}</code> : {status.source === 'all' ? '全部' : status.source === 'ads' ? 'NASA ADS' : 'arXiv'}
</p> </p>
</div> </div>
<span className="text-xs font-bold text-sky-700">{status.synced} / {status.total} </span> <span className="text-xs font-bold text-slate-800">{status.synced} / {status.total} </span>
</div> </div>
<div className="w-full h-3 rounded-full bg-slate-100 overflow-hidden border border-slate-200 select-none"> <div className="w-full h-3 rounded-md bg-slate-100 overflow-hidden border border-slate-200 select-none">
<div <div
className="h-full bg-sky-600 transition-all duration-500 shadow-sm" className="h-full bg-blueprint transition-all duration-500 shadow-sm"
style={{ width: `${percent}%` }} style={{ width: `${percent}%` }}
/> />
</div> </div>
{status.active && (status.source === 'all' || status.source === 'arxiv') ? ( {status.active && (status.source === 'all' || status.source === 'arxiv') ? (
<div className="p-3 rounded-lg bg-amber-50 border border-amber-200 text-xs text-amber-700 flex items-start gap-1.5 select-none"> <div className="p-3 rounded-md bg-slate-50 border border-slate-200 border-l-4 border-l-amber-500 text-xs text-slate-700 flex items-start gap-1.5 select-none">
<Lightbulb className="w-4 h-4 shrink-0 mt-0.5" /> <Lightbulb className="w-4 h-4 shrink-0 mt-0.5" />
<span> arXiv 3000 </span> <span> arXiv 3000 </span>
</div> </div>

View File

@ -2,20 +2,32 @@
@import "tailwindcss"; @import "tailwindcss";
@plugin "@tailwindcss/typography"; @plugin "@tailwindcss/typography";
@theme {
--color-main: var(--text-main);
--color-muted: var(--text-muted);
--color-card: var(--bg-card);
--color-sidebar: var(--bg-sidebar);
--color-blueprint: var(--accent-blueprint);
--color-star: var(--accent-star);
--color-precision: var(--border-precision);
--font-mono: var(--font-mono);
}
:root { :root {
font-family: 'Inter', system-ui, -apple-system, sans-serif; font-family: 'Inter', system-ui, -apple-system, sans-serif;
color-scheme: light; color-scheme: light;
/* 极简白蓝学术科技风配色 */ /* 极简白蓝学术科技风配色 - 升级为暖调纸张感学术护眼色 */
--bg-main: #f4f6f9; --bg-main: #faf8f5; /* 学术纸张底色 */
--bg-card: #ffffff; --bg-card: #ffffff;
--bg-sidebar: #0f2540; /* 保持深色侧边栏作为界面骨架结构 */ --bg-sidebar: #0f2540; /* 保持深色侧边栏作为界面骨架结构 */
--text-main: #0a2540; --text-main: #1e293b; /* 优雅深石板灰,对比度更佳 */
--text-muted: #5c6b84; --text-muted: #475569; /* 中度石板灰,提高可读性 */
--accent-blueprint: #106ba3; --accent-blueprint: #0369a1; /* 低饱和度学术蓝 */
--accent-star: #d97706; --accent-star: #b45309; /* 经典低饱和度琥珀橙 */
--border-precision: #d2d8e2; --border-precision: #e2e8f0; /* 极细轻量级分割线 */
--font-mono: 'JetBrains Mono', monospace; --font-mono: 'JetBrains Mono', monospace;
} }
@ -143,3 +155,80 @@ mark {
border-radius: 0.125rem; border-radius: 0.125rem;
font-weight: 600; font-weight: 600;
} }
/* LaTeX-style academic publishing typography overrides */
.prose {
font-family: 'Inter', system-ui, -apple-system, sans-serif;
color: var(--text-main);
}
/* Academic Three-Line Table (LaTeX booktabs style) */
.prose table {
width: 100% !important;
border-collapse: collapse !important;
border-top: 2px solid #334155 !important; /* thick top rule */
border-bottom: 2px solid #334155 !important; /* thick bottom rule */
margin-top: 1.5em !important;
margin-bottom: 1.5em !important;
font-size: 0.85em !important;
}
.prose thead {
border-bottom: 1.2px solid #475569 !important; /* mid rule */
}
.prose thead th {
font-weight: 700 !important;
color: #1e293b !important;
padding: 0.5em 0.75em !important;
text-align: left !important;
border: none !important;
}
.prose tbody tr {
border-bottom: 0.5px solid #e2e8f0 !important; /* light horizontal line */
background: transparent !important;
}
.prose tbody tr:last-child {
border-bottom: none !important; /* last row doesn't have thin border because of the thick table bottom rule */
}
.prose tbody td {
padding: 0.5em 0.75em !important;
color: #334155 !important;
border: none !important;
}
/* Remove vertical borders and striping that are generated by default */
.prose table, .prose th, .prose td {
border-left: none !important;
border-right: none !important;
}
/* LaTeX Blockquote */
.prose blockquote {
font-style: italic !important;
color: #475569 !important;
border-left: 3px solid #64748b !important; /* solid charcoal left line */
background-color: #fdfcf9 !important; /* warmer background for blockquotes */
padding: 0.75em 1.25em !important;
margin: 1.25em 0 !important;
border-radius: 2px !important;
font-weight: 500 !important;
quotes: none !important;
}
.prose blockquote p::before,
.prose blockquote p::after {
content: "" !important;
}
/* Formulas and Math spacing */
.prose .katex-display {
margin: 1.2em 0 !important;
padding: 0.5em !important;
overflow-x: auto !important;
overflow-y: hidden !important;
}

View File

@ -54,9 +54,9 @@ export function CitationPanel({
<button <button
type="button" type="button"
onClick={() => setShowSwitchMenu(!showSwitchMenu)} onClick={() => setShowSwitchMenu(!showSwitchMenu)}
className="btn-console btn-console-secondary px-4 py-2 rounded-lg text-xs font-bold flex items-center gap-2 transition-all cursor-pointer shrink-0" className="btn-console btn-console-secondary px-4 py-2 rounded-md text-xs font-bold flex items-center gap-2 transition-all cursor-pointer shrink-0"
> >
<GitFork className="w-3.5 h-3.5 text-sky-600" /> <GitFork className="w-3.5 h-3.5 text-blueprint" />
<span></span> <span></span>
<ChevronDown className={`w-3 h-3 transition-transform ${showSwitchMenu ? 'rotate-180' : ''}`} /> <ChevronDown className={`w-3 h-3 transition-transform ${showSwitchMenu ? 'rotate-180' : ''}`} />
</button> </button>
@ -64,7 +64,7 @@ export function CitationPanel({
{showSwitchMenu && ( {showSwitchMenu && (
<> <>
<div className="fixed inset-0 z-40" onClick={() => setShowSwitchMenu(false)} /> <div className="fixed inset-0 z-40" onClick={() => setShowSwitchMenu(false)} />
<div className="absolute right-0 mt-1.5 w-72 rounded-xl bg-white border border-slate-200 shadow-xl py-1.5 z-55 text-xs max-h-96 overflow-y-auto scrollbar-thin"> <div className="absolute right-0 mt-1.5 w-72 rounded-md bg-white border border-slate-200 shadow-md py-1.5 z-55 text-xs max-h-96 overflow-y-auto scrollbar-thin">
{/* 最近文献 */} {/* 最近文献 */}
{recentlySelected.length > 0 && ( {recentlySelected.length > 0 && (
<div className="border-b border-slate-100 pb-1.5 mb-1.5"> <div className="border-b border-slate-100 pb-1.5 mb-1.5">
@ -81,7 +81,7 @@ export function CitationPanel({
}} }}
className={`w-full px-3 py-2 text-left hover:bg-slate-50 transition-colors flex flex-col gap-0.5 border-l-2 ${ className={`w-full px-3 py-2 text-left hover:bg-slate-50 transition-colors flex flex-col gap-0.5 border-l-2 ${
paper.bibcode === selectedPaper.bibcode paper.bibcode === selectedPaper.bibcode
? 'border-sky-500 bg-sky-50/30 text-sky-850 font-bold' ? 'border-blueprint bg-blueprint/5 text-blueprint font-bold'
: 'border-transparent text-slate-700 font-medium' : 'border-transparent text-slate-700 font-medium'
}`} }`}
> >
@ -112,7 +112,7 @@ export function CitationPanel({
}} }}
className={`w-full px-3 py-2 text-left hover:bg-slate-50 transition-colors flex flex-col gap-0.5 border-l-2 ${ className={`w-full px-3 py-2 text-left hover:bg-slate-50 transition-colors flex flex-col gap-0.5 border-l-2 ${
paper.bibcode === selectedPaper.bibcode paper.bibcode === selectedPaper.bibcode
? 'border-sky-500 bg-sky-50/30 text-sky-850 font-bold' ? 'border-blueprint bg-blueprint/5 text-blueprint font-bold'
: 'border-transparent text-slate-700 font-medium' : 'border-transparent text-slate-700 font-medium'
}`} }`}
> >
@ -127,7 +127,7 @@ export function CitationPanel({
)} )}
</div> </div>
)} )}
<div className="flex items-center gap-2 border border-slate-200 bg-white px-3 py-1.5 rounded-lg shadow-sm"> <div className="flex items-center gap-2 border border-slate-200 bg-white px-3 py-1.5 rounded-md shadow-sm">
<span className="text-[11px] text-slate-500 font-bold">:</span> <span className="text-[11px] text-slate-500 font-bold">:</span>
<input <input
type="range" type="range"
@ -136,14 +136,14 @@ export function CitationPanel({
step="5" step="5"
value={nodeLimit} value={nodeLimit}
onChange={(e) => setNodeLimit(Number(e.target.value))} onChange={(e) => setNodeLimit(Number(e.target.value))}
className="w-20 h-1.5 bg-slate-200 rounded-lg appearance-none cursor-pointer accent-sky-600 focus:outline-none" className="w-20 h-1.5 bg-slate-200 rounded-lg appearance-none cursor-pointer accent-blueprint focus:outline-none"
/> />
<span className="text-xs font-bold text-sky-600 w-6 text-right">{nodeLimit}</span> <span className="text-xs font-bold text-blueprint w-6 text-right">{nodeLimit}</span>
</div> </div>
{selectedPaper && ( {selectedPaper && (
<button <button
onClick={() => loadCitations(selectedPaper.bibcode, true)} onClick={() => loadCitations(selectedPaper.bibcode, true)}
className="btn-console px-4 py-2 rounded-lg text-xs font-bold flex items-center gap-2" className="btn-console px-4 py-2 rounded-md text-xs font-bold flex items-center gap-2"
> >
<RotateCcw className="w-3.5 h-3.5" /> <RotateCcw className="w-3.5 h-3.5" />
</button> </button>
@ -152,12 +152,12 @@ export function CitationPanel({
</div> </div>
{loadingCitations ? ( {loadingCitations ? (
<div className="console-panel rounded-xl flex-1 flex flex-col items-center justify-center text-slate-500 bg-white"> <div className="console-panel rounded-lg flex-1 flex flex-col items-center justify-center text-slate-500 bg-white">
<Loader className="w-8 h-8 animate-spin text-sky-600 mb-2" /> <Loader className="w-8 h-8 animate-spin text-blueprint mb-2" />
<p className="text-xs font-bold text-slate-600">...</p> <p className="text-xs font-bold text-slate-600">...</p>
</div> </div>
) : citationNetwork ? ( ) : citationNetwork ? (
<div className="flex-1 console-panel rounded-xl border border-slate-200 overflow-hidden relative flex flex-col md:flex-row bg-white min-h-0"> <div className="flex-1 console-panel rounded-lg border border-slate-200 overflow-hidden relative flex flex-col md:flex-row bg-white min-h-0">
{/* 可视化画布 */} {/* 可视化画布 */}
<div className="flex-1 relative bg-slate-50/30 min-h-[350px] md:min-h-0"> <div className="flex-1 relative bg-slate-50/30 min-h-[350px] md:min-h-0">
<CitationGalaxyCanvas <CitationGalaxyCanvas
@ -170,7 +170,7 @@ export function CitationPanel({
{/* 右侧关系详情面板 */} {/* 右侧关系详情面板 */}
<div className="w-full md:w-80 h-64 md:h-auto border-t md:border-t-0 md:border-l border-slate-200 bg-slate-50 p-6 space-y-6 overflow-y-auto scrollbar-thin"> <div className="w-full md:w-80 h-64 md:h-auto border-t md:border-t-0 md:border-l border-slate-200 bg-slate-50 p-6 space-y-6 overflow-y-auto scrollbar-thin">
<div> <div>
<span className="text-[10px] font-bold text-sky-700 tracking-wider block mb-2"> </span> <span className="text-[10px] font-bold text-blueprint tracking-wider block mb-2"> </span>
<h4 className="text-xs font-bold text-slate-900 leading-relaxed font-sans">{citationNetwork.title}</h4> <h4 className="text-xs font-bold text-slate-900 leading-relaxed font-sans">{citationNetwork.title}</h4>
<p className="text-[10px] text-slate-600 font-mono mt-2 bg-slate-200/50 border border-slate-300/60 px-2 py-1.5 rounded select-all truncate">{citationNetwork.bibcode}</p> <p className="text-[10px] text-slate-600 font-mono mt-2 bg-slate-200/50 border border-slate-300/60 px-2 py-1.5 rounded select-all truncate">{citationNetwork.bibcode}</p>
</div> </div>
@ -187,7 +187,7 @@ export function CitationPanel({
<div <div
key={bib} key={bib}
onClick={() => handleNodeClick(bib)} onClick={() => handleNodeClick(bib)}
className="text-[10px] text-slate-700 hover:text-sky-700 font-mono py-1.5 px-2.5 rounded bg-white border border-slate-200 hover:border-sky-300 cursor-pointer transition-all truncate" className="text-[10px] text-slate-700 hover:text-blueprint font-mono py-1.5 px-2.5 rounded bg-white border border-slate-200 hover:border-blueprint/40 cursor-pointer transition-all truncate"
> >
{bib} {bib}
</div> </div>
@ -208,7 +208,7 @@ export function CitationPanel({
<div <div
key={bib} key={bib}
onClick={() => handleNodeClick(bib)} onClick={() => handleNodeClick(bib)}
className="text-[10px] text-slate-700 hover:text-sky-700 font-mono py-1.5 px-2.5 rounded bg-white border border-slate-200 hover:border-sky-300 cursor-pointer transition-all truncate" className="text-[10px] text-slate-700 hover:text-blueprint font-mono py-1.5 px-2.5 rounded bg-white border border-slate-200 hover:border-blueprint/40 cursor-pointer transition-all truncate"
> >
{bib} {bib}
</div> </div>
@ -219,7 +219,7 @@ export function CitationPanel({
</div> </div>
</div> </div>
) : ( ) : (
<div className="console-panel rounded-xl flex-1 flex flex-col items-center justify-center text-slate-500 bg-white"> <div className="console-panel rounded-lg flex-1 flex flex-col items-center justify-center text-slate-500 bg-white">
<GitFork className="w-12 h-12 mb-3 text-slate-300" /> <GitFork className="w-12 h-12 mb-3 text-slate-300" />
<p className="text-xs font-bold text-slate-500"></p> <p className="text-xs font-bold text-slate-500"></p>
</div> </div>

View File

@ -2,8 +2,8 @@
import { useState, useCallback } from 'react'; import { useState, useCallback } from 'react';
import { Library, RotateCw, Search, SlidersHorizontal, X, CheckCircle, AlertTriangle, BookOpen, GitFork } from 'lucide-react'; import { Library, RotateCw, Search, SlidersHorizontal, X, CheckCircle, AlertTriangle, BookOpen, GitFork } from 'lucide-react';
import type { StandardPaper } from '../types'; import type { StandardPaper } from '../types';
import { getDoctypeBadge } from '../utils/paper';
import { CustomSelect } from '../components/CustomSelect'; import { CustomSelect } from '../components/CustomSelect';
import { PaperCard } from '../components/PaperCard';
interface LibraryPanelProps { interface LibraryPanelProps {
library: StandardPaper[]; library: StandardPaper[];
@ -188,7 +188,7 @@ export function LibraryPanel({
<button <button
onClick={handleResync} onClick={handleResync}
disabled={syncing} disabled={syncing}
className="btn-console px-4 py-2 rounded-lg text-xs font-bold flex items-center gap-2 disabled:opacity-50 transition-all" className="btn-console px-4 py-2 rounded-md text-xs font-bold flex items-center gap-2 disabled:opacity-50 transition-all"
> >
<RotateCw className={`w-3.5 h-3.5 ${syncing ? 'animate-spin' : ''}`} /> <RotateCw className={`w-3.5 h-3.5 ${syncing ? 'animate-spin' : ''}`} />
{syncing ? '正在同步...' : '重新同步馆藏'} {syncing ? '正在同步...' : '重新同步馆藏'}
@ -197,10 +197,10 @@ export function LibraryPanel({
{/* 同步结果反馈条 */} {/* 同步结果反馈条 */}
{syncFeedback && ( {syncFeedback && (
<div className={`px-4 py-2.5 rounded-lg text-xs font-bold flex items-center gap-2 transition-all ${ <div className={`px-4 py-2.5 rounded-md text-xs font-bold flex items-center gap-2 transition-all ${
syncFeedback.type === 'success' syncFeedback.type === 'success'
? 'bg-emerald-50 border border-emerald-200 text-emerald-700' ? 'bg-emerald-50 border border-emerald-200/60 text-emerald-850'
: 'bg-red-50 border border-red-200 text-red-700' : 'bg-rose-50 border border-rose-200/60 text-rose-850'
}`}> }`}>
{syncFeedback.type === 'success' {syncFeedback.type === 'success'
? <CheckCircle className="w-3.5 h-3.5 shrink-0" /> ? <CheckCircle className="w-3.5 h-3.5 shrink-0" />
@ -211,7 +211,7 @@ export function LibraryPanel({
{/* 搜索、筛选与排序工具栏 */} {/* 搜索、筛选与排序工具栏 */}
{library.length > 0 && ( {library.length > 0 && (
<div className="flex flex-col gap-3.5 bg-white p-4 rounded-xl border border-slate-200 shadow-sm text-xs font-semibold text-slate-700"> <div className="flex flex-col gap-3.5 bg-white p-4 rounded-lg border border-slate-200 shadow-sm text-xs font-semibold text-slate-700">
<div className="flex flex-col sm:flex-row gap-3"> <div className="flex flex-col sm:flex-row gap-3">
{/* 本地检索 */} {/* 本地检索 */}
<div className="flex-1 space-y-1.5"> <div className="flex-1 space-y-1.5">
@ -223,7 +223,7 @@ export function LibraryPanel({
value={searchTerm} value={searchTerm}
onChange={e => setSearchTerm(e.target.value)} onChange={e => setSearchTerm(e.target.value)}
placeholder="搜索文献标题、作者、摘要、Bibcode、arXiv ID 或 DOI..." placeholder="搜索文献标题、作者、摘要、Bibcode、arXiv ID 或 DOI..."
className="w-full pl-9 pr-8 py-2 rounded-lg bg-slate-50 border border-slate-250 text-slate-900 placeholder-slate-400 focus:outline-none focus:border-sky-500 focus:bg-white transition-all text-xs font-medium" className="w-full pl-9 pr-8 py-2 rounded-md bg-slate-50 border border-slate-250 text-slate-900 placeholder-slate-400 focus:outline-none focus:border-blueprint focus:bg-white transition-all text-xs font-medium"
/> />
{searchTerm && ( {searchTerm && (
<button <button
@ -305,9 +305,9 @@ export function LibraryPanel({
<button <button
type="button" type="button"
onClick={() => setShowAdvanced(!showAdvanced)} onClick={() => setShowAdvanced(!showAdvanced)}
className={`w-full sm:w-auto px-4 py-2 rounded-lg border text-xs font-bold transition-all shrink-0 flex items-center justify-center gap-1.5 cursor-pointer ${ className={`w-full sm:w-auto px-4 py-2 rounded-md border text-xs font-bold transition-all shrink-0 flex items-center justify-center gap-1.5 cursor-pointer ${
showAdvanced showAdvanced
? 'bg-sky-50 border-sky-300 text-sky-700' ? 'bg-slate-100 border-slate-350 text-slate-855'
: 'btn-console btn-console-secondary' : 'btn-console btn-console-secondary'
}`} }`}
> >
@ -328,7 +328,7 @@ export function LibraryPanel({
value={filterAuthor} value={filterAuthor}
onChange={e => setFilterAuthor(e.target.value)} onChange={e => setFilterAuthor(e.target.value)}
placeholder="如: Althaus..." placeholder="如: Althaus..."
className="w-full px-3 py-2 rounded-lg bg-slate-50 border border-slate-250 text-slate-900 placeholder-slate-400 focus:outline-none focus:border-sky-500 focus:bg-white transition-all text-xs font-medium" className="w-full px-3 py-2 rounded-md bg-slate-50 border border-slate-250 text-slate-900 placeholder-slate-400 focus:outline-none focus:border-blueprint focus:bg-white transition-all text-xs font-medium"
/> />
</div> </div>
@ -340,7 +340,7 @@ export function LibraryPanel({
value={filterYear} value={filterYear}
onChange={e => setFilterYear(e.target.value)} onChange={e => setFilterYear(e.target.value)}
placeholder="如: 2023..." placeholder="如: 2023..."
className="w-full px-3 py-2 rounded-lg bg-slate-50 border border-slate-250 text-slate-900 placeholder-slate-400 focus:outline-none focus:border-sky-500 focus:bg-white transition-all text-xs font-medium" className="w-full px-3 py-2 rounded-md bg-slate-50 border border-slate-250 text-slate-900 placeholder-slate-400 focus:outline-none focus:border-blueprint focus:bg-white transition-all text-xs font-medium"
/> />
</div> </div>
@ -352,7 +352,7 @@ export function LibraryPanel({
value={filterJournal} value={filterJournal}
onChange={e => setFilterJournal(e.target.value)} onChange={e => setFilterJournal(e.target.value)}
placeholder="如: ApJ 或 MNRAS..." placeholder="如: ApJ 或 MNRAS..."
className="w-full px-3 py-2 rounded-lg bg-slate-50 border border-slate-250 text-slate-900 placeholder-slate-400 focus:outline-none focus:border-sky-500 focus:bg-white transition-all text-xs font-medium" className="w-full px-3 py-2 rounded-md bg-slate-50 border border-slate-250 text-slate-900 placeholder-slate-450 focus:outline-none focus:border-blueprint focus:bg-white transition-all text-xs font-medium"
/> />
</div> </div>
</div> </div>
@ -361,19 +361,19 @@ export function LibraryPanel({
)} )}
{library.length === 0 ? ( {library.length === 0 ? (
<div className="console-panel p-16 rounded-xl text-center border-2 border-dashed border-slate-300"> <div className="console-panel p-16 rounded-lg text-center border-2 border-dashed border-slate-300">
<Library className="w-12 h-12 text-slate-400 mx-auto mb-4" /> <Library className="w-12 h-12 text-slate-400 mx-auto mb-4" />
<h3 className="font-bold text-slate-800 text-sm mb-2"></h3> <h3 className="font-bold text-slate-800 text-sm mb-2"></h3>
<p className="text-xs text-slate-500 mb-6 max-w-md mx-auto">线便</p> <p className="text-xs text-slate-500 mb-6 max-w-md mx-auto">线便</p>
<button <button
onClick={() => setActiveTab('search')} onClick={() => setActiveTab('search')}
className="btn-console btn-console-primary px-6 py-2.5 rounded-lg text-xs font-bold" className="btn-console btn-console-primary px-6 py-2.5 rounded-md text-xs font-bold"
> >
</button> </button>
</div> </div>
) : sortedLibrary.length === 0 ? ( ) : sortedLibrary.length === 0 ? (
<div className="console-panel p-12 rounded-xl text-center border border-slate-200 bg-white"> <div className="console-panel p-12 rounded-lg text-center border border-slate-200 bg-white">
<SlidersHorizontal className="w-10 h-10 text-slate-350 mx-auto mb-3" /> <SlidersHorizontal className="w-10 h-10 text-slate-350 mx-auto mb-3" />
<h3 className="font-bold text-slate-700 text-xs mb-1.5"></h3> <h3 className="font-bold text-slate-700 text-xs mb-1.5"></h3>
<p className="text-xs text-slate-450 max-w-sm mx-auto mb-4">线</p> <p className="text-xs text-slate-450 max-w-sm mx-auto mb-4">线</p>
@ -386,7 +386,7 @@ export function LibraryPanel({
setFilterYear(''); setFilterYear('');
setFilterJournal(''); setFilterJournal('');
}} }}
className="px-4 py-1.5 bg-slate-100 hover:bg-slate-200 text-slate-650 rounded-lg text-xs font-bold transition-all cursor-pointer" className="px-4 py-1.5 bg-slate-100 hover:bg-slate-200 text-slate-655 rounded-md text-xs font-bold transition-all cursor-pointer"
> >
</button> </button>
@ -399,12 +399,12 @@ export function LibraryPanel({
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{sortedLibrary.map(paper => { {sortedLibrary.map(paper => {
return ( return (
<div <PaperCard
key={paper.bibcode} key={paper.bibcode}
paper={paper}
onClick={() => onShowDetail(paper)} onClick={() => onShowDetail(paper)}
className="console-panel p-5 rounded-xl border border-slate-200 hover:border-sky-350 bg-white flex flex-col justify-between relative overflow-hidden group transition-all cursor-pointer shadow-sm" variant="grid"
> statusBadge={
{/* 状态角标 */}
<div <div
title={ title={
(!paper.is_downloaded && (paper.pdf_error || paper.html_error)) (!paper.is_downloaded && (paper.pdf_error || paper.html_error))
@ -415,18 +415,18 @@ export function LibraryPanel({
} }
className={`absolute top-0 right-0 px-2 py-0.5 text-[9px] font-bold border-b border-l rounded-bl ${ className={`absolute top-0 right-0 px-2 py-0.5 text-[9px] font-bold border-b border-l rounded-bl ${
paper.has_vector paper.has_vector
? 'bg-amber-50 text-amber-700 border-amber-200' ? 'bg-amber-50 text-amber-700 border-amber-200/60'
: paper.has_translation : paper.has_translation
? 'bg-emerald-50 text-emerald-700 border-emerald-200' ? 'bg-emerald-50 text-emerald-850 border-emerald-200/60'
: paper.has_markdown : paper.has_markdown
? 'bg-sky-50 text-sky-700 border-sky-200' ? 'bg-blue-50 text-blue-800 border-blue-200/60'
: paper.is_downloaded : paper.is_downloaded
? 'bg-indigo-50 text-indigo-700 border-indigo-200' ? 'bg-slate-100 text-slate-800 border-slate-250'
: (paper.pdf_error === 'no_resource' && paper.html_error === 'no_resource') : (paper.pdf_error === 'no_resource' && paper.html_error === 'no_resource')
? 'bg-slate-100 text-slate-600 border-slate-200 cursor-help' ? 'bg-slate-50 text-slate-550 border-slate-200 cursor-help'
: (paper.pdf_error || paper.html_error) : (paper.pdf_error || paper.html_error)
? 'bg-rose-50 text-rose-700 border-rose-200 cursor-help' ? 'bg-rose-50 text-rose-850 border-rose-200/60 cursor-help'
: 'bg-slate-50 text-slate-500 border-slate-100' : 'bg-slate-50 text-slate-550 border-slate-100'
}`} }`}
> >
{paper.has_vector {paper.has_vector
@ -443,22 +443,9 @@ export function LibraryPanel({
? '下载失败' ? '下载失败'
: '未下载'} : '未下载'}
</div> </div>
}
<div className="pr-10"> footerLeft={
<div> paper.is_downloaded && (
<h3 className="font-bold text-xs text-slate-900 line-clamp-2 hover:text-sky-700 transition-all leading-relaxed">
{getDoctypeBadge(paper.doctype)}
<span className="align-middle">{paper.title}</span>
</h3>
</div>
<div className="text-[10px] text-slate-500 font-semibold mt-1.5 uppercase">
: {paper.authors.slice(0, 2).join(', ')}{paper.authors.length > 2 ? ' 等' : ''} | : {paper.year}
</div>
</div>
<div className="flex items-center justify-between border-t border-slate-100 pt-3 mt-4 text-[10px]">
<div className="flex flex-col gap-1 flex-1 min-w-0">
{paper.is_downloaded && (
<div className="flex gap-1.5"> <div className="flex gap-1.5">
<button <button
type="button" type="button"
@ -466,7 +453,7 @@ export function LibraryPanel({
e.stopPropagation(); e.stopPropagation();
onOpenReader(paper); onOpenReader(paper);
}} }}
className="px-2 py-0.5 rounded bg-sky-50 hover:bg-sky-100 text-sky-700 border border-sky-200 font-bold transition-all text-[9px] flex items-center gap-1 shadow-2xs cursor-pointer animate-fade-in" className="px-2.5 py-1 rounded bg-slate-100 hover:bg-slate-200 text-slate-800 border border-slate-250 font-bold transition-all text-[9px] flex items-center gap-1 shadow-2xs cursor-pointer animate-fade-in"
> >
<BookOpen className="w-3 h-3" /> <BookOpen className="w-3 h-3" />
<span></span> <span></span>
@ -477,21 +464,16 @@ export function LibraryPanel({
e.stopPropagation(); e.stopPropagation();
onOpenCitation(paper); onOpenCitation(paper);
}} }}
className="px-2 py-0.5 rounded bg-slate-50 hover:bg-slate-100 text-slate-700 border border-slate-200 font-bold transition-all text-[9px] flex items-center gap-1 shadow-2xs cursor-pointer animate-fade-in" className="px-2.5 py-1 rounded bg-slate-50 hover:bg-slate-150 text-slate-655 border border-slate-250 font-bold transition-all text-[9px] flex items-center gap-1 shadow-2xs cursor-pointer animate-fade-in"
> >
<GitFork className="w-3 h-3" /> <GitFork className="w-3 h-3" />
<span></span> <span></span>
</button> </button>
</div> </div>
)} )
</div> }
<div className="flex items-center gap-2 shrink-0 self-end"> footerRight={
<div className="flex items-center gap-1"> <>
<span
className={`w-1.5 h-1.5 rounded-full ${paper.has_markdown ? 'bg-sky-500' : 'bg-slate-200'}`}
/>
<span className="text-slate-400 text-[9px]">{paper.has_markdown ? '正文' : '未解析'}</span>
</div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<span <span
className={`w-1.5 h-1.5 rounded-full ${paper.has_translation ? 'bg-emerald-500' : 'bg-slate-200'}`} className={`w-1.5 h-1.5 rounded-full ${paper.has_translation ? 'bg-emerald-500' : 'bg-slate-200'}`}
@ -504,9 +486,9 @@ export function LibraryPanel({
/> />
<span className="text-slate-400 text-[9px]">{paper.has_vector ? '向量库' : '未向量化'}</span> <span className="text-slate-400 text-[9px]">{paper.has_vector ? '向量库' : '未向量化'}</span>
</div> </div>
</div> </>
</div> }
</div> />
); );
})} })}
</div> </div>

View File

@ -12,7 +12,7 @@ export function ResearchAgentPanel({ showConfirm, showAlert }: ResearchAgentPane
const state = useResearchAgent({ showConfirm, showAlert }); const state = useResearchAgent({ showConfirm, showAlert });
return ( return (
<div className="w-full flex-1 flex overflow-hidden bg-slate-100 rounded-2xl border border-slate-200 h-[calc(100vh-130px)] shadow-xs"> <div className="w-full flex-1 flex overflow-hidden bg-slate-100 rounded-lg border border-slate-200 h-[calc(100vh-130px)] shadow-sm">
{/* 左侧会话列表侧栏 */} {/* 左侧会话列表侧栏 */}
<AgentSessionSidebar <AgentSessionSidebar
sessions={state.sessions} sessions={state.sessions}

View File

@ -3,8 +3,7 @@ import React from 'react';
import { Search, Loader, CheckCircle, Copy, Download, ChevronLeft, ChevronRight, SlidersHorizontal, AlertTriangle, Lightbulb } from 'lucide-react'; import { Search, Loader, CheckCircle, Copy, Download, ChevronLeft, ChevronRight, SlidersHorizontal, AlertTriangle, Lightbulb } from 'lucide-react';
import type { StandardPaper } from '../types'; import type { StandardPaper } from '../types';
import { CustomSelect } from '../components/CustomSelect'; import { CustomSelect } from '../components/CustomSelect';
import { PaperCard } from '../components/PaperCard';
import { getDoctypeBadge } from '../utils/paper';
interface SearchPanelProps { interface SearchPanelProps {
searchQuery: string; searchQuery: string;
@ -125,7 +124,7 @@ export function SearchPanel({
</div> </div>
{/* 检索控制面板 */} {/* 检索控制面板 */}
<div className="console-panel p-6 rounded-xl space-y-4"> <div className="console-panel p-6 rounded-lg space-y-4">
<form onSubmit={handleSearch} className="space-y-4"> <form onSubmit={handleSearch} className="space-y-4">
<div className="flex flex-col md:flex-row gap-3 items-stretch"> <div className="flex flex-col md:flex-row gap-3 items-stretch">
<div className="relative flex-1"> <div className="relative flex-1">
@ -135,16 +134,16 @@ export function SearchPanel({
value={searchQuery} value={searchQuery}
onChange={e => setSearchQuery(e.target.value)} onChange={e => setSearchQuery(e.target.value)}
placeholder="检索天文学文献 (支持关键字、作者、发表年份,如 'hot subdwarf year:2020-2023')" placeholder="检索天文学文献 (支持关键字、作者、发表年份,如 'hot subdwarf year:2020-2023')"
className="w-full pl-12 pr-4 py-3 rounded-lg bg-slate-50 border border-slate-300 text-slate-900 placeholder-slate-400 focus:outline-none focus:border-sky-500 focus:bg-white transition-all text-sm font-medium" className="w-full pl-12 pr-4 py-3 rounded-md bg-slate-50 border border-slate-300 text-slate-900 placeholder-slate-400 focus:outline-none focus:border-slate-500 focus:bg-white transition-all text-sm font-medium"
/> />
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<button <button
type="button" type="button"
onClick={() => setShowBuilder(!showBuilder)} onClick={() => setShowBuilder(!showBuilder)}
className={`px-4 py-3 rounded-lg border text-xs font-bold transition-all shrink-0 flex items-center gap-1.5 ${ className={`px-4 py-3 rounded-md border text-xs font-bold transition-all shrink-0 flex items-center gap-1.5 ${
showBuilder showBuilder
? 'bg-sky-50 border-sky-300 text-sky-700' ? 'bg-slate-100 border-slate-350 text-slate-855'
: 'btn-console' : 'btn-console'
}`} }`}
> >
@ -154,7 +153,7 @@ export function SearchPanel({
<button <button
type="submit" type="submit"
disabled={searching} disabled={searching}
className="btn-console btn-console-primary px-6 py-3 rounded-lg text-xs font-bold tracking-wider flex items-center gap-2 shrink-0" className="btn-console btn-console-primary px-6 py-3 rounded-md text-xs font-bold tracking-wider flex items-center gap-2 shrink-0"
> >
{searching ? <Loader className="w-3.5 h-3.5 animate-spin" /> : <Search className="w-3.5 h-3.5" />} {searching ? <Loader className="w-3.5 h-3.5 animate-spin" /> : <Search className="w-3.5 h-3.5" />}
{searching ? '检索中...' : '检索文献'} {searching ? '检索中...' : '检索文献'}
@ -164,13 +163,13 @@ export function SearchPanel({
{/* 条件构造器 */} {/* 条件构造器 */}
{showBuilder && ( {showBuilder && (
<div className="p-4 rounded-lg bg-slate-50 border border-slate-200 space-y-3 transition-all"> <div className="p-4 rounded-md bg-slate-50 border border-slate-200 space-y-3 transition-all">
<div className="text-xs font-bold text-slate-700 flex justify-between items-center"> <div className="text-xs font-bold text-slate-700 flex justify-between items-center">
<span></span> <span></span>
<button <button
type="button" type="button"
onClick={handleAddRule} onClick={handleAddRule}
className="text-xs text-sky-600 hover:text-sky-700 font-bold" className="text-xs text-slate-700 hover:text-slate-900 font-bold"
> >
+ +
</button> </button>
@ -218,14 +217,14 @@ export function SearchPanel({
? '例如: Althaus' ? '例如: Althaus'
: '请输入检索词...' : '请输入检索词...'
} }
className="flex-1 px-3 py-1.5 rounded-lg bg-white border border-slate-300 text-slate-900 placeholder-slate-400 focus:outline-none focus:border-sky-500 text-xs font-medium" className="flex-1 px-3 py-1.5 rounded-md bg-white border border-slate-300 text-slate-900 placeholder-slate-400 focus:outline-none focus:border-slate-500 text-xs font-medium"
/> />
{rules.length > 1 && ( {rules.length > 1 && (
<button <button
type="button" type="button"
onClick={() => handleRemoveRule(idx)} onClick={() => handleRemoveRule(idx)}
className="text-red-655 hover:text-red-700 text-xs font-bold px-2 py-1.5" className="text-slate-600 hover:text-slate-800 text-xs font-bold px-2 py-1.5"
> >
</button> </button>
@ -260,9 +259,9 @@ export function SearchPanel({
name="searchSource" name="searchSource"
checked={searchSource === src.id} checked={searchSource === src.id}
onChange={() => setSearchSource(src.id as any)} onChange={() => setSearchSource(src.id as any)}
className="accent-sky-600" className="accent-slate-700"
/> />
<span className={searchSource === src.id ? 'text-sky-750 font-bold' : 'text-slate-500 hover:text-slate-700'}> <span className={searchSource === src.id ? 'text-slate-900 font-bold' : 'text-slate-500 hover:text-slate-700'}>
{src.label} {src.label}
</span> </span>
</label> </label>
@ -304,7 +303,7 @@ export function SearchPanel({
type="button" type="button"
onClick={handleExportBibtex} onClick={handleExportBibtex}
disabled={exporting} disabled={exporting}
className="btn-console btn-console-secondary px-3 py-1.5 rounded-lg text-xs font-bold flex items-center gap-1.5" className="btn-console btn-console-secondary px-3 py-1.5 rounded-md text-xs font-bold flex items-center gap-1.5"
> >
{exporting ? <Loader className="w-3.5 h-3.5 animate-spin" /> : null} {exporting ? <Loader className="w-3.5 h-3.5 animate-spin" /> : null}
({exportingList.length}) BibTeX ({exportingList.length}) BibTeX
@ -317,11 +316,11 @@ export function SearchPanel({
{/* BibTeX 导出显示 */} {/* BibTeX 导出显示 */}
{bibtexContent && ( {bibtexContent && (
<div className="console-panel p-5 rounded-xl border border-sky-200 relative overflow-hidden bg-sky-50/20"> <div className="console-panel p-5 rounded-lg border border-slate-200 relative overflow-hidden bg-slate-50/20">
<h3 className="text-xs font-bold text-sky-700 mb-3 flex items-center gap-2"> <h3 className="text-xs font-bold text-slate-700 mb-3 flex items-center gap-2">
<CheckCircle className="w-4 h-4 text-emerald-600" /> BibTeX <CheckCircle className="w-4 h-4 text-emerald-600" /> BibTeX
</h3> </h3>
<pre className="bg-white p-4 rounded-lg border border-slate-200 text-xs text-slate-800 font-mono overflow-x-auto whitespace-pre-wrap max-h-60 leading-relaxed shadow-inner"> <pre className="bg-white p-4 rounded-md border border-slate-200 text-xs text-slate-800 font-mono overflow-x-auto whitespace-pre-wrap max-h-60 leading-relaxed shadow-inner">
{bibtexContent} {bibtexContent}
</pre> </pre>
<button <button
@ -329,7 +328,7 @@ export function SearchPanel({
navigator.clipboard.writeText(bibtexContent); navigator.clipboard.writeText(bibtexContent);
showAlert('BibTeX 数据已成功复制至剪贴板。', '复制成功'); showAlert('BibTeX 数据已成功复制至剪贴板。', '复制成功');
}} }}
className="absolute top-5 right-5 btn-console btn-console-secondary px-3 py-1 rounded-lg text-xs font-bold flex items-center gap-1" className="absolute top-5 right-5 btn-console btn-console-secondary px-3 py-1 rounded-md text-xs font-bold flex items-center gap-1"
> >
<Copy className="w-3.5 h-3.5" /> <Copy className="w-3.5 h-3.5" />
</button> </button>
@ -339,9 +338,9 @@ export function SearchPanel({
{/* 检索列表 */} {/* 检索列表 */}
<div className="relative min-h-[200px]"> <div className="relative min-h-[200px]">
{searching && ( {searching && (
<div className="absolute inset-0 bg-white/60 backdrop-blur-[1px] z-10 flex items-center justify-center rounded-xl"> <div className="absolute inset-0 bg-white/60 backdrop-blur-[1px] z-10 flex items-center justify-center rounded-lg">
<div className="flex flex-col items-center gap-3 bg-white p-6 rounded-xl shadow-lg border border-slate-200 text-center"> <div className="flex flex-col items-center gap-3 bg-white p-6 rounded-lg shadow-sm border border-slate-200 text-center">
<Loader className="w-8 h-8 text-sky-650 animate-spin" /> <Loader className="w-8 h-8 text-slate-600 animate-spin" />
<span className="text-xs font-bold text-slate-600">...</span> <span className="text-xs font-bold text-slate-600">...</span>
</div> </div>
</div> </div>
@ -351,64 +350,48 @@ export function SearchPanel({
const isDownloading = downloadingBibcodes[paper.bibcode] || false; const isDownloading = downloadingBibcodes[paper.bibcode] || false;
const isSelected = selectedPaper?.bibcode === paper.bibcode; const isSelected = selectedPaper?.bibcode === paper.bibcode;
return ( return (
<div <PaperCard
key={paper.bibcode} key={paper.bibcode}
className={`console-panel p-6 rounded-xl border transition-all relative ${ paper={paper}
isSelected ? 'border-sky-500 bg-sky-50/10' : 'border-slate-200 hover:border-slate-350 hover:bg-white' isSelected={isSelected}
}`}
>
{/* 选定指示侧条 */}
{isSelected && <div className="absolute top-0 left-0 w-1.5 h-full bg-sky-600" />}
<div className="flex flex-col md:flex-row md:justify-between md:items-start gap-4 mb-3">
<div className="flex-1">
<h3
className="font-bold text-sm text-slate-900 hover:text-sky-700 cursor-pointer transition-all line-clamp-2 leading-relaxed"
onClick={() => openReader(paper)} onClick={() => openReader(paper)}
> variant="list"
{getDoctypeBadge(paper.doctype)} headerActions={
<span className="align-middle">{paper.title}</span> <>
</h3>
<p className="text-xs text-slate-500 font-medium mt-2 leading-snug">
: <span className="text-slate-800">{paper.authors.slice(0, 5).join(', ')}{paper.authors.length > 5 ? ' 等' : ''}</span> : <span className="text-slate-850 font-bold">{paper.year}</span> : <span className="italic text-slate-700">{paper.pub_journal || '未标注'}</span>
</p>
</div>
<div className="flex gap-2 shrink-0 items-center">
<button <button
type="button" type="button"
onClick={() => onShowDetail(paper)} onClick={() => onShowDetail(paper)}
className="px-3 py-1.5 rounded-lg bg-white border border-slate-250 text-slate-650 hover:bg-slate-50 hover:border-slate-350 transition-all text-xs font-bold flex items-center cursor-pointer shadow-sm" className="px-3 py-1.5 rounded-md bg-white border border-slate-250 text-slate-655 hover:bg-slate-50 hover:border-slate-350 transition-all text-xs font-bold flex items-center cursor-pointer"
> >
</button> </button>
{paper.is_downloaded ? ( {paper.is_downloaded ? (
<span className="px-2.5 py-1 rounded-lg bg-emerald-50 text-emerald-700 border border-emerald-200 text-xs font-bold flex items-center gap-1"> <span className="px-2.5 py-1 rounded-md bg-emerald-50 text-emerald-800 border border-emerald-200/60 text-xs font-bold flex items-center gap-1">
<CheckCircle className="w-3.5 h-3.5" /> <CheckCircle className="w-3.5 h-3.5" />
</span> </span>
) : ( ) : (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{(paper.pdf_error || paper.html_error) && ( {paper.pdf_error === 'no_resource' && paper.html_error === 'no_resource' ? (
paper.pdf_error === 'no_resource' && paper.html_error === 'no_resource' ? (
<span <span
title="已手动标记为【无有效全文资源】,批量下载时自动跳过" title="已手动标记为【无有效全文资源】,批量下载时自动跳过"
className="px-2 py-1 bg-slate-50 text-slate-600 border border-slate-200 text-[10px] font-bold rounded-lg cursor-help flex items-center gap-1" className="px-2 py-1 bg-slate-50 text-slate-600 border border-slate-200 text-[10px] font-bold rounded-md cursor-help flex items-center gap-1"
> >
<AlertTriangle className="w-3 h-3 text-slate-400" /> <AlertTriangle className="w-3 h-3 text-slate-455" />
</span> </span>
) : ( ) : (
(paper.pdf_error || paper.html_error) && (
<span <span
title={`自动下载失败:${[paper.pdf_error, paper.html_error].filter(Boolean).join('; ')}`} title={`自动下载失败:${[paper.pdf_error, paper.html_error].filter(Boolean).join('; ')}`}
className="px-2 py-1 bg-rose-50 text-rose-700 border border-rose-200 text-[10px] font-bold rounded-lg cursor-help flex items-center gap-1" className="px-2 py-1 bg-rose-50 text-rose-800 border border-rose-200/60 text-[10px] font-bold rounded-md cursor-help flex items-center gap-1"
> >
<AlertTriangle className="w-3 h-3 text-rose-500" /> <AlertTriangle className="w-3 h-3 text-rose-600" />
</span> </span>
) )
)} )}
<button <button
onClick={() => handleDownload(paper.bibcode)} onClick={() => handleDownload(paper.bibcode)}
disabled={isDownloading} disabled={isDownloading}
className="btn-console btn-console-primary px-3 py-1.5 rounded-lg text-xs font-bold flex items-center gap-1 transition-all" className="btn-console btn-console-primary px-3 py-1.5 rounded-md text-xs font-bold flex items-center gap-1 transition-all"
> >
{isDownloading ? <Loader className="w-3.5 h-3.5 animate-spin" /> : <Download className="w-3.5 h-3.5" />} {isDownloading ? <Loader className="w-3.5 h-3.5 animate-spin" /> : <Download className="w-3.5 h-3.5" />}
@ -416,27 +399,27 @@ export function SearchPanel({
</div> </div>
)} )}
<label className="flex items-center gap-1.5 cursor-pointer border border-slate-250 px-2.5 py-1.5 rounded-lg bg-slate-50 hover:bg-slate-100 text-slate-700 transition-all text-xs font-semibold"> <label className="flex items-center gap-1.5 cursor-pointer border border-slate-250 px-2.5 py-1.5 rounded-md bg-slate-50 hover:bg-slate-100 text-slate-700 transition-all text-xs font-semibold">
<input <input
type="checkbox" type="checkbox"
checked={exportingList.includes(paper.bibcode)} checked={exportingList.includes(paper.bibcode)}
onChange={() => toggleExportItem(paper.bibcode)} onChange={() => toggleExportItem(paper.bibcode)}
className="rounded text-sky-600 border-slate-300" className="rounded text-slate-700 border-slate-300"
/> />
<span></span> <span></span>
</label> </label>
</div> </>
</div> }
abstract={
<p className="text-xs text-slate-600 line-clamp-3 leading-relaxed mb-4 font-normal"> <p className="text-xs text-slate-600 line-clamp-3 leading-relaxed mb-4 font-normal">
{paper.abstract_text || '暂无文献摘要数据。'} {paper.abstract_text || '暂无文献摘要数据。'}
</p> </p>
}
<div className="flex flex-col sm:flex-row justify-between items-stretch sm:items-center border-t border-slate-100 pt-4 gap-3"> footerLeft={
<div className="flex gap-2"> <>
<button <button
onClick={() => openReader(paper)} onClick={() => openReader(paper)}
className="px-4 py-1.5 rounded-lg bg-sky-50 border border-sky-200 text-xs font-bold text-sky-700 hover:bg-sky-100 transition-all" className="px-4 py-1.5 rounded-md bg-slate-100 border border-slate-250 text-xs font-bold text-slate-800 hover:bg-slate-200 transition-all"
> >
</button> </button>
@ -446,19 +429,20 @@ export function SearchPanel({
setActiveTab('citation'); setActiveTab('citation');
loadCitations(paper.bibcode, true); loadCitations(paper.bibcode, true);
}} }}
className="px-4 py-1.5 rounded-lg bg-slate-50 border border-slate-250 text-xs font-bold text-slate-600 hover:border-slate-400 hover:text-slate-800 transition-all" className="px-4 py-1.5 rounded-md bg-slate-50 border border-slate-250 text-xs font-bold text-slate-600 hover:border-slate-400 hover:text-slate-800 transition-all"
> >
</button> </button>
</div> </>
}
<div className="text-xs text-slate-450 flex flex-wrap gap-x-4 gap-y-1 font-mono"> footerRight={
<>
{paper.doi && <span>DOI: {paper.doi}</span>} {paper.doi && <span>DOI: {paper.doi}</span>}
<span>Bibcode: <span className="font-semibold text-slate-700">{paper.bibcode}</span></span> <span>Bibcode: <span className="font-semibold text-slate-700">{paper.bibcode}</span></span>
{paper.citation_count > 0 && <span>: <span className="text-sky-750 font-bold">{paper.citation_count}</span></span>} {paper.citation_count > 0 && <span>: <span className="text-sky-750 font-bold">{paper.citation_count}</span></span>}
</div> </>
</div> }
</div> />
); );
})} })}
</div> </div>
@ -466,11 +450,11 @@ export function SearchPanel({
{/* 分页控制栏 */} {/* 分页控制栏 */}
{searchResults.length > 0 && ( {searchResults.length > 0 && (
<div className="flex items-center justify-between p-4 bg-white border border-slate-200 rounded-xl max-w-5xl mx-auto shadow-sm"> <div className="flex items-center justify-between p-4 bg-white border border-slate-200 rounded-lg max-w-5xl mx-auto shadow-sm">
<button <button
onClick={() => handlePageChange(searchStart - searchRows)} onClick={() => handlePageChange(searchStart - searchRows)}
disabled={!hasPreviousPage || searching} disabled={!hasPreviousPage || searching}
className="px-4 py-2 border border-slate-250 rounded-lg bg-white text-xs font-bold text-slate-700 hover:bg-slate-50 disabled:opacity-40 disabled:hover:bg-white flex items-center gap-1 transition-all" className="px-4 py-2 border border-slate-250 rounded-md bg-white text-xs font-bold text-slate-700 hover:bg-slate-50 disabled:opacity-40 disabled:hover:bg-white flex items-center gap-1 transition-all"
> >
<ChevronLeft className="w-4 h-4" /> <ChevronLeft className="w-4 h-4" />
</button> </button>
@ -482,7 +466,7 @@ export function SearchPanel({
<button <button
onClick={() => handlePageChange(searchStart + searchRows)} onClick={() => handlePageChange(searchStart + searchRows)}
disabled={!hasNextPage || searching} disabled={!hasNextPage || searching}
className="px-4 py-2 border border-slate-250 rounded-lg bg-white text-xs font-bold text-slate-700 hover:bg-slate-50 disabled:opacity-40 disabled:hover:bg-white flex items-center gap-1 transition-all" className="px-4 py-2 border border-slate-250 rounded-md bg-white text-xs font-bold text-slate-700 hover:bg-slate-50 disabled:opacity-40 disabled:hover:bg-white flex items-center gap-1 transition-all"
> >
<ChevronRight className="w-4 h-4" /> <ChevronRight className="w-4 h-4" />
</button> </button>

View File

@ -8,16 +8,16 @@ export function SettingsPanel() {
<p className="text-xs text-slate-500">AstroResearch </p> <p className="text-xs text-slate-500">AstroResearch </p>
</div> </div>
<div className="glass p-6 rounded-2xl space-y-6"> <div className="console-panel p-6 rounded-lg space-y-6">
<div className="border-b border-slate-200 pb-4"> <div className="border-b border-slate-200 pb-4">
<h3 className="text-sm font-semibold text-slate-800 mb-1"> (.env) </h3> <h3 className="text-sm font-semibold text-slate-800 mb-1"> (.env) </h3>
<p className="text-xs text-slate-500 leading-relaxed"> <p className="text-xs text-slate-500 leading-relaxed">
API Token <code className="text-xs text-purple-650 font-semibold bg-purple-50 px-1 py-0.5 rounded">.env</code> API Token <code className="text-xs text-blueprint font-semibold bg-slate-50 border border-slate-200 px-1 py-0.5 rounded">.env</code>
</p> </p>
</div> </div>
<div className="bg-purple-50 border border-purple-200 p-4 rounded-xl text-xs text-slate-650 leading-relaxed"> <div className="bg-slate-50 border border-slate-200 border-l-4 border-l-blueprint p-4 rounded-md text-xs text-slate-700 leading-relaxed">
<strong></strong> 使 <code className="text-xs text-purple-650 font-semibold bg-purple-50 px-1 py-0.5 rounded">/home/fmq//astrodict_241020/astrodict241020_ec.txt</code> <strong></strong> 使 <code className="text-xs text-blueprint font-semibold bg-slate-100 px-1 py-0.5 rounded">/home/fmq//astrodict_241020/astrodict241020_ec.txt</code>
</div> </div>
</div> </div>
</div> </div>

View File

@ -20,7 +20,7 @@ export function SyncPanel() {
{/* 全局错误显示 */} {/* 全局错误显示 */}
{state.errorMsg && ( {state.errorMsg && (
<div className="p-4 rounded-lg bg-red-50 border border-red-200 flex gap-3 text-xs text-red-750 items-start"> <div className="p-4 rounded-md bg-rose-50 border border-rose-200/60 flex gap-3 text-xs text-rose-800 items-start">
<AlertTriangle className="w-4 h-4 shrink-0 mt-0.5" /> <AlertTriangle className="w-4 h-4 shrink-0 mt-0.5" />
<div className="font-semibold">{state.errorMsg}</div> <div className="font-semibold">{state.errorMsg}</div>
</div> </div>
@ -71,10 +71,10 @@ export function SyncPanel() {
/> />
{/* 3. 常用批量同步检索配置 */} {/* 3. 常用批量同步检索配置 */}
<div className="console-panel p-6 rounded-xl border border-slate-200 bg-white space-y-4 shadow-xs"> <div className="console-panel p-6 rounded-lg border border-slate-200 bg-white space-y-4 shadow-sm">
<div className="flex flex-col gap-1 border-b border-slate-100 pb-2 select-none"> <div className="flex flex-col gap-1 border-b border-slate-100 pb-2 select-none">
<h3 className="text-xs font-bold text-slate-900 flex items-center gap-2"> <h3 className="text-xs font-bold text-slate-900 flex items-center gap-2">
<SlidersHorizontal className="w-4 h-4 text-sky-600" /> <SlidersHorizontal className="w-4 h-4 text-slate-700" />
<span></span> <span></span>
</h3> </h3>
<p className="text-slate-500 text-xs mt-0.5"> <p className="text-slate-500 text-xs mt-0.5">
@ -83,7 +83,7 @@ export function SyncPanel() {
</div> </div>
{state.syncQueries.length === 0 ? ( {state.syncQueries.length === 0 ? (
<div className="text-center py-6 text-xs text-slate-405 italic select-none"> <div className="text-center py-6 text-xs text-slate-400 italic select-none">
</div> </div>
) : ( ) : (
@ -104,7 +104,7 @@ export function SyncPanel() {
<button <button
type="button" type="button"
onClick={() => state.handleReuseQuery(sq)} onClick={() => state.handleReuseQuery(sq)}
className="px-2.5 py-1.5 rounded-lg bg-slate-50 border border-slate-250 text-slate-650 hover:bg-slate-100 hover:text-slate-800 transition-all text-xs font-bold cursor-pointer" className="px-2.5 py-1.5 rounded-md bg-slate-50 border border-slate-250 text-slate-655 hover:bg-slate-100 hover:text-slate-800 transition-all text-xs font-bold cursor-pointer"
> >
</button> </button>
@ -112,14 +112,14 @@ export function SyncPanel() {
type="button" type="button"
disabled={state.status.active} disabled={state.status.active}
onClick={() => state.handleQuickSync(sq)} onClick={() => state.handleQuickSync(sq)}
className="px-2.5 py-1.5 rounded-lg bg-sky-50 border border-sky-200 text-sky-700 hover:bg-sky-100 transition-all text-xs font-bold cursor-pointer disabled:opacity-40" className="px-2.5 py-1.5 rounded-md bg-slate-100 border border-slate-250 text-slate-800 hover:bg-slate-200 transition-all text-xs font-bold cursor-pointer disabled:opacity-40"
> >
</button> </button>
<button <button
type="button" type="button"
onClick={() => state.handleDeleteQuery(sq.id)} onClick={() => state.handleDeleteQuery(sq.id)}
className="px-2.5 py-1.5 rounded-lg bg-red-50 border border-red-200 text-red-755 hover:bg-red-100 transition-all text-xs font-bold cursor-pointer" className="px-2.5 py-1.5 rounded-md bg-rose-50 border border-rose-200/60 text-rose-855 hover:bg-rose-100 transition-all text-xs font-bold cursor-pointer"
> >
</button> </button>

View File

@ -154,7 +154,7 @@ sequenceDiagram
U->>H: 1. 请求文献对比翻译 (POST /api/translate, 含 bibcode, force) U->>H: 1. 请求文献对比翻译 (POST /api/translate, 含 bibcode, force)
H->>DB: 2. 查询文献路径及状态 H->>DB: 2. 查询文献路径及状态
alt force == false 且本地已存在翻译缓存文件 alt force == false 且本地已存在翻译缓存文件
H->>H: 3. 读取本地 Translation/{bibcode}_zh.md 物理文件 H->>H: 3. 读取本地 Translation/{bibcode}.md 物理文件
H-->>U: 4. 直接返回缓存译文,流程结束 H-->>U: 4. 直接返回缓存译文,流程结束
end end

View File

@ -0,0 +1,93 @@
// src/agent/runtime/config.rs
//
// Agent 运行时配置参数。
/// Agent 配置参数
#[derive(Debug, Clone)]
pub struct AgentConfig {
/// 最大 ReAct 迭代次数
pub max_steps: usize,
/// 同质调用检测阈值(连续相同调用次数)
pub duplicate_call_threshold: usize,
/// 工具执行超时时间(秒)
pub tool_timeout_secs: u64,
/// 工具输出最大字符数
pub max_tool_output_chars: usize,
/// Token 预算软限制 — 各压缩层统一触发阈值
pub token_soft_limit: usize,
/// Token 预算硬限制(触发强制动作)
pub token_hard_limit: usize,
/// 最大消息数(超过此阈值触发 snip_compact 层压缩)
pub max_messages: usize,
/// 是否启用 LLM 思考模式(前端可控,默认关闭)
pub enable_thinking: bool,
/// 权限拒绝规则(逗号分隔,格式: ToolName 或 ToolName(content_pattern)
pub permission_deny_rules: Vec<String>,
/// 权限允许规则(逗号分隔)
pub permission_allow_rules: Vec<String>,
/// 权限询问规则(逗号分隔)
pub permission_ask_rules: Vec<String>,
/// 权限模式: "default" | "accept_edits" | "bypass" | "dont_ask"
pub permission_mode: String,
/// 拒绝追踪:连续拒绝上限(默认 3
pub denial_max_consecutive: usize,
/// 拒绝追踪:总拒绝上限(默认 20
pub denial_max_total: usize,
/// 附加允许目录(逗号分隔,扩展文件沙箱范围)
pub additional_allowed_dirs: Vec<String>,
/// 子代理工具白名单(逗号分隔,空=全部工具可用)
pub subagent_allowed_tools: Vec<String>,
/// Agent 运行模式 ID"default" / "deep-research" / "literature-reader"
pub mode: String,
}
impl AgentConfig {
/// 从环境变量加载配置,缺失时使用默认值。
pub fn from_env_optional() -> Self {
AgentConfig {
max_steps: 8,
duplicate_call_threshold: 3,
tool_timeout_secs: 120,
max_tool_output_chars: 4000,
token_soft_limit: std::env::var("AGENT_TOKEN_SOFT_LIMIT")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(80000),
token_hard_limit: std::env::var("AGENT_TOKEN_HARD_LIMIT")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(100000),
max_messages: 50,
enable_thinking: false,
permission_deny_rules: parse_comma_list("AGENT_PERMISSIONS_DENY"),
permission_allow_rules: parse_comma_list("AGENT_PERMISSIONS_ALLOW"),
permission_ask_rules: parse_comma_list("AGENT_PERMISSIONS_ASK"),
permission_mode: std::env::var("AGENT_PERMISSION_MODE")
.unwrap_or_else(|_| "default".to_string()),
denial_max_consecutive: 3,
denial_max_total: 20,
additional_allowed_dirs: parse_comma_list("AGENT_ADDITIONAL_DIRS"),
subagent_allowed_tools: parse_comma_list("AGENT_SUBAGENT_ALLOWED_TOOLS"),
mode: "default".to_string(),
}
}
}
/// 解析逗号分隔的环境变量为字符串列表
fn parse_comma_list(env_key: &str) -> Vec<String> {
std::env::var(env_key)
.ok()
.map(|v| {
v.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect()
})
.unwrap_or_default()
}
impl Default for AgentConfig {
fn default() -> Self {
Self::from_env_optional()
}
}

View File

@ -0,0 +1,27 @@
// src/agent/runtime/duplicate_detector.rs
//
// 同质调用检测器:检测连续重复的工具调用,防止死循环。
/// 同质调用检测器
#[derive(Debug, Default)]
pub struct DuplicateDetector {
last_call: Option<(String, String)>, // (tool_name, arguments)
consecutive_count: usize,
}
impl DuplicateDetector {
/// 记录一次调用,返回是否检测到死循环
pub fn record(&mut self, tool_name: &str, arguments: &str, threshold: usize) -> bool {
let key = (tool_name.to_string(), arguments.to_string());
if self.last_call.as_ref() == Some(&key) {
self.consecutive_count += 1;
if self.consecutive_count >= threshold {
return true;
}
} else {
self.last_call = Some(key);
self.consecutive_count = 1;
}
false
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,624 @@
// src/agent/runtime/error_recovery/classification.rs
//
// 故障转移原因分类引擎FailoverReason / ErrorKind / classify_* 函数族。
use tracing::{error, info};
// ── FailoverReason21 种错误分类 ──
/// 故障转移原因 — 参考 Hermes FailoverReason 枚举。
/// 每个 variant 携带是否可重试、是否应触发凭据切换等信息。
#[derive(Debug, Clone, PartialEq)]
pub enum FailoverReason {
// ── Auth 类 ──
/// 认证失败401可切换凭据重试
Auth,
/// 永久认证失败,不应重试
AuthPermanent,
// ── Billing 类 ──
/// 计费问题402/403 billing可切换账号
Billing,
/// 限流429应退避重试
RateLimited,
// ── Server 类 ──
/// 服务过载503/529应退避重试
Overloaded,
/// 服务器错误500/502可重试
ServerError,
// ── Transport 类 ──
/// 网络超时,可重试
Timeout,
// ── Context/Payload 类 ──
/// 上下文溢出prompt too long / 413
ContextOverflow,
/// 载荷过大
PayloadTooLarge,
/// 图片过大
ImageTooLarge,
// ── Model/Provider Policy 类 ──
/// 模型不存在或无权访问404
ModelNotFound,
/// Provider 策略阻止
ProviderPolicyBlocked,
/// 内容策略阻止(安全过滤)
ContentPolicyBlocked,
// ── Format 类 ──
/// 响应格式错误
FormatError,
/// 无效加密内容
InvalidEncryptedContent,
/// 多模态工具内容不支持
MultimodalToolContentUnsupported,
// ── Provider-specific 类 ──
/// Thinking 签名错误Qwen/DashScope specific
ThinkingSignature,
/// 长上下文 tier 门控
LongContextTier,
/// OAuth 长上下文 beta 禁止
OauthLongContextBetaForbidden,
// ── 网络/传输 ──
/// SSL/TLS 瞬态错误
SslTransient,
// ── Catch-all ──
/// 未知错误
Unknown(String),
}
impl FailoverReason {
/// 是否可重试auth/rate-limit/server/transport 类)
pub fn is_retryable(&self) -> bool {
matches!(
self,
FailoverReason::Auth
| FailoverReason::RateLimited
| FailoverReason::Overloaded
| FailoverReason::ServerError
| FailoverReason::Timeout
| FailoverReason::SslTransient
)
}
/// 是否应触发上下文压缩
pub fn should_compress(&self) -> bool {
matches!(
self,
FailoverReason::ContextOverflow
| FailoverReason::PayloadTooLarge
| FailoverReason::LongContextTier
)
}
/// 是否应切换凭据/模型
pub fn should_failover(&self) -> bool {
matches!(
self,
FailoverReason::Auth
| FailoverReason::Billing
| FailoverReason::ModelNotFound
| FailoverReason::ProviderPolicyBlocked
)
}
/// 是否为永久性错误(不应重试)
pub fn is_permanent(&self) -> bool {
matches!(
self,
FailoverReason::AuthPermanent
| FailoverReason::ContentPolicyBlocked
| FailoverReason::InvalidEncryptedContent
)
}
/// 错误归因:用户侧还是服务侧
pub fn is_user_error(&self) -> bool {
matches!(
self,
FailoverReason::ContextOverflow
| FailoverReason::PayloadTooLarge
| FailoverReason::ImageTooLarge
| FailoverReason::FormatError
)
}
}
impl std::fmt::Display for FailoverReason {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let label = match self {
FailoverReason::Auth => "auth",
FailoverReason::AuthPermanent => "auth_permanent",
FailoverReason::Billing => "billing",
FailoverReason::RateLimited => "rate_limited",
FailoverReason::Overloaded => "overloaded",
FailoverReason::ServerError => "server_error",
FailoverReason::Timeout => "timeout",
FailoverReason::ContextOverflow => "context_overflow",
FailoverReason::PayloadTooLarge => "payload_too_large",
FailoverReason::ImageTooLarge => "image_too_large",
FailoverReason::ModelNotFound => "model_not_found",
FailoverReason::ProviderPolicyBlocked => "provider_policy_blocked",
FailoverReason::ContentPolicyBlocked => "content_policy_blocked",
FailoverReason::FormatError => "format_error",
FailoverReason::InvalidEncryptedContent => "invalid_encrypted_content",
FailoverReason::MultimodalToolContentUnsupported => {
"multimodal_tool_content_unsupported"
}
FailoverReason::ThinkingSignature => "thinking_signature",
FailoverReason::LongContextTier => "long_context_tier",
FailoverReason::OauthLongContextBetaForbidden => "oauth_long_context_beta_forbidden",
FailoverReason::SslTransient => "ssl_transient",
FailoverReason::Unknown(s) => return write!(f, "unknown({})", s),
};
write!(f, "{}", label)
}
}
// ── 保留向后兼容的 ErrorKind 别名 ──
/// 错误类型分类(向后兼容简化版,新代码应使用 FailoverReason
#[derive(Debug, Clone, PartialEq)]
pub enum ErrorKind {
PromptTooLong,
TokenExhausted,
ModelError(String),
Timeout,
RateLimited,
Overloaded,
}
impl From<&FailoverReason> for ErrorKind {
fn from(reason: &FailoverReason) -> Self {
match reason {
FailoverReason::ContextOverflow | FailoverReason::PayloadTooLarge => {
ErrorKind::PromptTooLong
}
FailoverReason::LongContextTier => ErrorKind::TokenExhausted,
FailoverReason::RateLimited => ErrorKind::RateLimited,
FailoverReason::Overloaded | FailoverReason::ServerError => ErrorKind::Overloaded,
FailoverReason::Timeout | FailoverReason::SslTransient => ErrorKind::Timeout,
other => ErrorKind::ModelError(other.to_string()),
}
}
}
// ── 8 步分类管线 ──
/// Step 1: Provider-specific 模式匹配(最高优先级)
fn classify_provider_specific(error_str: &str) -> Option<FailoverReason> {
let lower = error_str.to_lowercase();
// Content policy blocks (非 provider 特定,但在 status 分类前检查)
if lower.contains("content_policy_violation")
|| lower.contains("safety filter")
|| lower.contains("content filter")
|| lower.contains("responsible_ai")
|| lower.contains("content management policy")
{
return Some(FailoverReason::ContentPolicyBlocked);
}
// Thinking signature error (Qwen/DashScope)
if lower.contains("thinking_signature") || lower.contains("thinking signature") {
return Some(FailoverReason::ThinkingSignature);
}
// Long context tier gate
if lower.contains("long_context_tier")
|| lower.contains("long context tier")
|| lower.contains("context_length_exceeded_for_tier")
{
return Some(FailoverReason::LongContextTier);
}
// OAuth 1M beta forbidden
if lower.contains("oauth_long_context") || lower.contains("1m_beta_forbidden") {
return Some(FailoverReason::OauthLongContextBetaForbidden);
}
// Llama.cpp grammar error
if lower.contains("grammar_pattern") || lower.contains("llama_cpp_grammar") {
return Some(FailoverReason::FormatError);
}
// Multimodal tool content
if lower.contains("multimodal_tool_content") || lower.contains("tool content not supported") {
return Some(FailoverReason::MultimodalToolContentUnsupported);
}
None
}
/// Step 2: HTTP Status Code 分类
fn classify_by_http_status(error_str: &str) -> Option<FailoverReason> {
let lower = error_str.to_lowercase();
// 401 → auth (temporary)
if lower.contains("401") || lower.contains("unauthorized") {
return Some(FailoverReason::Auth);
}
// 402 → billing vs rate-limit disambiguation
if lower.contains("402") {
if lower.contains("rate") || lower.contains("limit") || lower.contains("try again") {
return Some(FailoverReason::RateLimited);
}
return Some(FailoverReason::Billing);
}
// 403
if lower.contains("403") || lower.contains("forbidden") {
if lower.contains("billing") || lower.contains("quota") || lower.contains("insufficient") {
return Some(FailoverReason::Billing);
}
if lower.contains("policy") || lower.contains("blocked") {
return Some(FailoverReason::ProviderPolicyBlocked);
}
return Some(FailoverReason::AuthPermanent);
}
// 404
if lower.contains("404") || lower.contains("not found") {
if lower.contains("model") || lower.contains("deployment") {
return Some(FailoverReason::ModelNotFound);
}
if lower.contains("billing") || lower.contains("subscription") {
return Some(FailoverReason::Billing);
}
if lower.contains("policy") || lower.contains("blocked") {
return Some(FailoverReason::ProviderPolicyBlocked);
}
}
// 413 → context overflow (default), could also be payload_too_large
if lower.contains("413") {
if lower.contains("image") || lower.contains("media") {
return Some(FailoverReason::ImageTooLarge);
}
return Some(FailoverReason::ContextOverflow);
}
// 429 → rate limit (definitive)
if lower.contains("429") {
return Some(FailoverReason::RateLimited);
}
// 500/502 → server error
if lower.contains("\"500\"")
|| lower.contains(" 500 ")
|| lower.contains("502")
|| lower.contains("internal server error")
|| lower.contains("bad gateway")
{
// Check for request validation signals
if lower.contains("invalid_request")
|| lower.contains("unsupported_parameter")
|| lower.contains("unknown_parameter")
{
return Some(FailoverReason::FormatError);
}
return Some(FailoverReason::ServerError);
}
// 503/529 → overloaded
if lower.contains("503") || lower.contains("529") || lower.contains("service unavailable") {
return Some(FailoverReason::Overloaded);
}
None
}
/// Step 3: Structured Error Code 分类
fn classify_by_error_code(error_str: &str) -> Option<FailoverReason> {
let lower = error_str.to_lowercase();
// Structured error codes from API responses
if lower.contains("\"code\"") || lower.contains("\"error_type\"") {
if lower.contains("resource_exhausted") {
return Some(FailoverReason::RateLimited);
}
if lower.contains("insufficient_quota") {
return Some(FailoverReason::Billing);
}
if lower.contains("model_not_found") || lower.contains("deployment_not_found") {
return Some(FailoverReason::ModelNotFound);
}
if lower.contains("invalid_api_key") || lower.contains("token_expired") {
return Some(FailoverReason::Auth);
}
if lower.contains("context_length_exceeded") {
return Some(FailoverReason::ContextOverflow);
}
}
None
}
/// Step 4: Message Pattern 匹配14 组模式集)
fn classify_by_message_pattern(error_str: &str) -> Option<FailoverReason> {
let lower = error_str.to_lowercase();
// 4a. Billing patterns
if lower.contains("insufficient_quota")
|| lower.contains("billing account")
|| lower.contains("payment required")
|| lower.contains("quota exceeded")
|| lower.contains("run out of")
|| lower.contains("no credit")
|| lower.contains("check your balance")
{
return Some(FailoverReason::Billing);
}
// 4b. Rate-limit patterns
if lower.contains("rate limit")
|| lower.contains("rate_limit")
|| lower.contains("too many requests")
|| lower.contains("requests too frequent")
|| lower.contains("try again in")
|| lower.contains("resets at")
|| lower.contains("please slow down")
{
return Some(FailoverReason::RateLimited);
}
// 4c. Context overflow patterns
if lower.contains("prompt_too_long")
|| lower.contains("prompt too long")
|| lower.contains("context length")
|| lower.contains("context_length_exceeded")
|| lower.contains("input length")
|| lower.contains("maximum context")
|| lower.contains("reduce the length")
|| lower.contains("too many tokens")
|| lower.contains("max_tokens_exceeded")
{
return Some(FailoverReason::ContextOverflow);
}
// 4d. Auth patterns
if lower.contains("invalid api key")
|| lower.contains("invalid_api_key")
|| lower.contains("incorrect api key")
|| lower.contains("authentication failed")
|| lower.contains("auth error")
|| lower.contains("not authenticated")
|| lower.contains("token is invalid")
{
return Some(FailoverReason::Auth);
}
// 4e. Payload size
if lower.contains("request too large")
|| lower.contains("payload too large")
|| lower.contains("request size exceeds")
|| lower.contains("request entity too large")
{
return Some(FailoverReason::PayloadTooLarge);
}
// 4f. Image size
if lower.contains("image too large")
|| lower.contains("image size exceeds")
|| lower.contains("max image size")
|| lower.contains("image_resolution_exceeded")
{
return Some(FailoverReason::ImageTooLarge);
}
// 4g. Multimodal tool content
if lower.contains("tool content not supported") || lower.contains("multimodal content in tool")
{
return Some(FailoverReason::MultimodalToolContentUnsupported);
}
// 4h. Timeout patterns
if lower.contains("request timed out")
|| lower.contains("timed out")
|| lower.contains("deadline exceeded")
|| lower.contains("idle timeout")
|| lower.contains("connection timeout")
{
return Some(FailoverReason::Timeout);
}
// 4i. Invalid encrypted content
if lower.contains("invalid encrypted content") || lower.contains("encrypted_content_error") {
return Some(FailoverReason::InvalidEncryptedContent);
}
// 4j. Format error
if lower.contains("invalid_request")
&& (lower.contains("parameter") || lower.contains("format") || lower.contains("schema"))
{
return Some(FailoverReason::FormatError);
}
None
}
/// Step 5: SSL/TLS 瞬态错误检测
fn classify_ssl_transient(error_str: &str) -> Option<FailoverReason> {
let lower = error_str.to_lowercase();
if lower.contains("ssl") || lower.contains("tls") || lower.contains("certificate") {
// Temporary TLS errors (connection reset, handshake failure)
if lower.contains("reset") || lower.contains("handshake") || lower.contains("timeout") {
return Some(FailoverReason::SslTransient);
}
// Permanent TLS errors (certificate expired, hostname mismatch)
if lower.contains("expired") || lower.contains("hostname") || lower.contains("verify") {
return Some(FailoverReason::AuthPermanent);
}
return Some(FailoverReason::SslTransient);
}
None
}
/// Step 6: Server disconnect + large session → context overflow
fn classify_disconnect_with_large_session(
error_str: &str,
estimated_tokens: Option<usize>,
context_length: Option<usize>,
) -> Option<FailoverReason> {
let lower = error_str.to_lowercase();
// Server disconnected without response
if lower.contains("connection reset")
|| lower.contains("eof")
|| lower.contains("incomplete response")
|| lower.contains("peer closed")
{
// If session is large (>60% of context), blame context overflow
if let (Some(tokens), Some(ctx_len)) = (estimated_tokens, context_length) {
let ratio = tokens as f64 / ctx_len as f64;
if ratio > 0.6 {
return Some(FailoverReason::ContextOverflow);
}
}
return Some(FailoverReason::Timeout);
}
None
}
/// Step 7: Transport error heuristics
fn classify_transport_error(error_str: &str) -> Option<FailoverReason> {
let lower = error_str.to_lowercase();
if lower.contains("connection")
|| lower.contains("network")
|| lower.contains("dns")
|| lower.contains("unreachable")
|| lower.contains("refused")
|| lower.contains("broken pipe")
|| lower.contains("transport")
|| lower.contains("eof")
|| lower.contains("reset by peer")
{
return Some(FailoverReason::Timeout);
}
None
}
/// Step 8: Unknown fallback
fn classify_unknown(error_str: &str) -> FailoverReason {
FailoverReason::Unknown(error_str.to_string())
}
// ── 主分类函数 ──
/// 8步分类管线按优先级依次尝试各分类器。
///
/// 可选传入 `estimated_tokens` 和 `context_length` 用于
/// disconnect + large session 的上下文溢出检测。
pub fn classify_failover(
error_str: &str,
estimated_tokens: Option<usize>,
context_length: Option<usize>,
) -> FailoverReason {
// Step 1: Provider-specific (HIGHEST priority)
if let Some(reason) = classify_provider_specific(error_str) {
info!("[ErrorClassify] Step 1 (provider-specific): {:?}", reason);
return reason;
}
// Step 2: HTTP status code
if let Some(reason) = classify_by_http_status(error_str) {
info!("[ErrorClassify] Step 2 (HTTP status): {:?}", reason);
return reason;
}
// Step 3: Structured error code
if let Some(reason) = classify_by_error_code(error_str) {
info!("[ErrorClassify] Step 3 (error code): {:?}", reason);
return reason;
}
// Step 4: Message patterns (14 groups)
if let Some(reason) = classify_by_message_pattern(error_str) {
info!("[ErrorClassify] Step 4 (message pattern): {:?}", reason);
return reason;
}
// Step 5: SSL/TLS transient
if let Some(reason) = classify_ssl_transient(error_str) {
info!("[ErrorClassify] Step 5 (SSL/TLS): {:?}", reason);
return reason;
}
// Step 6: Server disconnect + large session
if let Some(reason) =
classify_disconnect_with_large_session(error_str, estimated_tokens, context_length)
{
info!(
"[ErrorClassify] Step 6 (disconnect + large session): {:?}",
reason
);
return reason;
}
// Step 7: Transport heuristics
if let Some(reason) = classify_transport_error(error_str) {
info!("[ErrorClassify] Step 7 (transport): {:?}", reason);
return reason;
}
// Step 8: Unknown fallback
error!(
"[ErrorClassify] Step 8 (unknown): unclassified error: {}",
error_str
);
classify_unknown(error_str)
}
// ── 向后兼容的 classify_error ──
/// 从错误字符串分类错误类型。
/// 内部调用 classify_failover 并转换为简化的 ErrorKind。
pub fn classify_error(error_str: &str) -> ErrorKind {
let reason = classify_failover(error_str, None, None);
ErrorKind::from(&reason)
}
// ── 恢复建议生成 ──
/// 根据 FailoverReason 生成面向用户的恢复建议。
pub fn recovery_suggestion(reason: &FailoverReason) -> &'static str {
match reason {
FailoverReason::Auth => "认证失败,尝试切换 API Key 或刷新凭据。",
FailoverReason::AuthPermanent => "认证凭据永久失效,请检查 API Key 配置。",
FailoverReason::Billing => "账户余额不足或配额用尽,请检查计费状态。",
FailoverReason::RateLimited => "请求频率超限,系统正在自动退避重试。",
FailoverReason::Overloaded => "模型服务过载,系统正在自动重试。",
FailoverReason::ServerError => "服务器内部错误,将自动重试。",
FailoverReason::Timeout => "请求超时,将自动重试。请检查网络连接。",
FailoverReason::ContextOverflow => "上下文超过限制,正在自动压缩对话历史。",
FailoverReason::PayloadTooLarge => "请求载荷过大,请尝试减少工具调用数量。",
FailoverReason::ImageTooLarge => "图片过大,请使用更小的图片或降低分辨率。",
FailoverReason::ModelNotFound => "模型不可用,请检查模型名称或尝试切换模型。",
FailoverReason::ProviderPolicyBlocked => "Provider 策略阻止了此请求。",
FailoverReason::ContentPolicyBlocked => {
"内容被安全策略过滤,请修改请求内容后重试。此错误不可自动恢复。"
}
FailoverReason::FormatError => "请求格式错误,请检查工具参数格式。",
FailoverReason::InvalidEncryptedContent => "加密内容无效,请重新生成。",
FailoverReason::MultimodalToolContentUnsupported => {
"当前模型不支持工具结果中的多模态内容,请改用纯文本。"
}
FailoverReason::ThinkingSignature => "思考模式签名错误,请禁用 thinking 模式或切换模型。",
FailoverReason::LongContextTier => "超出当前 tier 的上下文长度限制,正在压缩或升级 tier。",
FailoverReason::OauthLongContextBetaForbidden => "长上下文 Beta 功能未对当前凭据开放。",
FailoverReason::SslTransient => "SSL/TLS 连接暂时中断,将自动重试。",
FailoverReason::Unknown(_) => "未知错误,系统将尝试恢复。",
}
}

View File

@ -0,0 +1,726 @@
// src/agent/runtime/error_recovery/mod.rs
//
// LLM 调用错误恢复引擎。
// 子模块:
// classification — FailoverReason / ErrorKind / classify_* 分类函数族
// overflow — 上下文溢出解析与安全 Token 计算
pub(crate) mod classification;
pub(crate) mod overflow;
use tracing::{info, warn};
use crate::agent::runtime::token_budget::TokenBudget;
// Re-export 公共类型,保持外部引用路径不变
pub use classification::{
classify_error, classify_failover, recovery_suggestion, ErrorKind, FailoverReason,
};
pub use overflow::{
calculate_safe_max_tokens, parse_context_overflow, parse_retry_after, ContextOverflowInfo,
};
#[derive(Debug, Clone, PartialEq)]
pub enum RecoveryStep {
/// 调整 max_tokens 以适配上下文容量ContextOverflow 首选策略)。
/// 参考 Claude Code — 解析 "X + Y > Z" 错误后计算安全值。
AdjustMaxTokens { new_max_tokens: usize },
/// 尝试更激进的 micro_compact
AggressiveCompact,
/// 使用 LLM 摘要压缩
ReactiveCompact,
/// 提升 token 上限
EscalateTokens { new_hard_limit: usize },
/// 分轮恢复(注入 meta 消息)
MultiTurn,
/// 放弃,暴露错误
Surface,
/// 指数退避重试(用于 429/529 瞬态错误)
RetryWithBackoff { attempt: u32, delay_ms: u64 },
}
/// 恢复尝试追踪
#[derive(Debug, Clone)]
pub struct RecoveryAttempts {
pub aggressive_compact: bool,
pub reactive_compact: bool,
pub escalate_tokens: bool,
pub multi_turn: bool,
}
impl RecoveryAttempts {
pub fn new() -> Self {
RecoveryAttempts {
aggressive_compact: false,
reactive_compact: false,
escalate_tokens: false,
multi_turn: false,
}
}
/// 是否有未尝试的恢复步骤
pub fn has_remaining(&self) -> bool {
!self.aggressive_compact
|| !self.reactive_compact
|| !self.escalate_tokens
|| !self.multi_turn
}
/// 获取下一个应尝试的恢复步骤。
///
/// 对于 ContextOverflow 类错误,恢复优先级为:
/// 1. AdjustMaxTokens — 从错误消息解析容量,精确计算安全的 max_tokens参考 Claude Code
/// 2. AggressiveCompact — 激进微压缩
/// 3. ReactiveCompact — LLM 摘要压缩
/// 4. EscalateTokens — 提升 hard_limit
/// 5. MultiTurn — 分轮
pub fn next_step(
&mut self,
error_kind: &ErrorKind,
overflow_info: Option<&ContextOverflowInfo>,
) -> Option<RecoveryStep> {
if matches!(error_kind, ErrorKind::RateLimited | ErrorKind::Overloaded) {
return Some(RecoveryStep::RetryWithBackoff {
attempt: 0,
delay_ms: 500,
});
}
match error_kind {
ErrorKind::PromptTooLong | ErrorKind::TokenExhausted => {
// Step 0: 尝试精确调整 max_tokens仅在有 Overflow info 时)
if let Some(info) = overflow_info {
if let Some(safe_tokens) = calculate_safe_max_tokens(info) {
// 仅在使用默认路径(尚未尝试过其他恢复方式)时触发
if !self.aggressive_compact
&& !self.reactive_compact
&& !self.escalate_tokens
{
return Some(RecoveryStep::AdjustMaxTokens {
new_max_tokens: safe_tokens,
});
}
}
}
// Step 1: 激进微压缩
if !self.aggressive_compact {
self.aggressive_compact = true;
return Some(RecoveryStep::AggressiveCompact);
}
// Step 2: LLM 摘要压缩
if !self.reactive_compact {
self.reactive_compact = true;
return Some(RecoveryStep::ReactiveCompact);
}
// Step 3: 提升硬限制
if !self.escalate_tokens {
self.escalate_tokens = true;
return Some(RecoveryStep::EscalateTokens {
new_hard_limit: 64_000,
});
}
// Step 4: 分轮
if !self.multi_turn {
self.multi_turn = true;
return Some(RecoveryStep::MultiTurn);
}
}
_ => {
if !self.multi_turn {
self.multi_turn = true;
return Some(RecoveryStep::Surface);
}
}
}
None
}
/// 向后兼容的无 overflow_info 版本。
pub fn next_step_compat(&mut self, error_kind: &ErrorKind) -> Option<RecoveryStep> {
self.next_step(error_kind, None)
}
}
impl Default for RecoveryAttempts {
fn default() -> Self {
Self::new()
}
}
/// 错误恢复器
pub struct ErrorRecovery {
pub attempts: RecoveryAttempts,
pub token_budget: TokenBudget,
}
impl ErrorRecovery {
pub fn new(token_budget: TokenBudget) -> Self {
ErrorRecovery {
attempts: RecoveryAttempts::new(),
token_budget,
}
}
pub fn try_recover(
&mut self,
error_kind: &ErrorKind,
overflow_info: Option<&ContextOverflowInfo>,
) -> Option<RecoveryStep> {
let step = self.attempts.next_step(error_kind, overflow_info);
match &step {
Some(RecoveryStep::AdjustMaxTokens { new_max_tokens }) => {
info!(
"[ErrorRecovery] 尝试步骤 0/5: AdjustMaxTokens → {} (从错误消息计算)",
new_max_tokens
);
// 将硬限制下调至安全值,使下一次 LLM 调用能在容量内完成
self.token_budget.hard_limit = *new_max_tokens;
}
Some(RecoveryStep::AggressiveCompact) => {
info!("[ErrorRecovery] 尝试步骤 1/5: AggressiveCompact");
}
Some(RecoveryStep::ReactiveCompact) => {
info!("[ErrorRecovery] 尝试步骤 2/5: ReactiveCompact");
}
Some(RecoveryStep::EscalateTokens { new_hard_limit }) => {
info!(
"[ErrorRecovery] 尝试步骤 3/5: EscalateTokens → {}",
new_hard_limit
);
self.token_budget.escalate_hard_limit(*new_hard_limit);
}
Some(RecoveryStep::RetryWithBackoff { attempt, delay_ms }) => {
info!(
"[ErrorRecovery] 退避重试: attempt={}, delay={}ms",
attempt, delay_ms
);
}
Some(RecoveryStep::MultiTurn) => {
info!("[ErrorRecovery] 尝试步骤 4/5: MultiTurn");
}
Some(RecoveryStep::Surface) => {
warn!("[ErrorRecovery] 无法恢复,暴露错误");
}
None => {
warn!("[ErrorRecovery] 所有恢复步骤已尝试完毕");
}
}
step
}
/// 向后兼容的无 overflow_info 版本。
pub fn try_recover_compat(&mut self, error_kind: &ErrorKind) -> Option<RecoveryStep> {
self.try_recover(error_kind, None)
}
pub fn multi_turn_message() -> String {
"由于 token 限制,当前回答被截断。请基于已收集的信息继续分析,\
\n\
1. \n\
2. 使 compress_context \n\
3. "
.to_string()
}
pub fn is_recoverable(error_kind: &ErrorKind) -> bool {
matches!(
error_kind,
ErrorKind::PromptTooLong
| ErrorKind::TokenExhausted
| ErrorKind::RateLimited
| ErrorKind::Overloaded
)
}
}
/// 计算指数退避延迟(毫秒)
pub fn backoff_delay(attempt: u32, retry_after_secs: Option<u64>) -> u64 {
if let Some(ra) = retry_after_secs {
return ra * 1000;
}
let base = 500u64 * 2u64.pow(attempt.min(6));
let base = base.min(32_000);
let jitter = (base / 4) * (attempt as u64 % 5) / 5;
base + jitter
}
#[cfg(test)]
mod tests {
use super::*;
// ── FailoverReason tests ──
#[test]
fn test_is_retryable() {
assert!(FailoverReason::Auth.is_retryable());
assert!(FailoverReason::RateLimited.is_retryable());
assert!(FailoverReason::Overloaded.is_retryable());
assert!(!FailoverReason::AuthPermanent.is_retryable());
assert!(!FailoverReason::ContentPolicyBlocked.is_retryable());
}
#[test]
fn test_should_compress() {
assert!(FailoverReason::ContextOverflow.should_compress());
assert!(FailoverReason::PayloadTooLarge.should_compress());
assert!(!FailoverReason::RateLimited.should_compress());
}
#[test]
fn test_should_failover() {
assert!(FailoverReason::Auth.should_failover());
assert!(FailoverReason::Billing.should_failover());
assert!(FailoverReason::ModelNotFound.should_failover());
assert!(!FailoverReason::Timeout.should_failover());
}
#[test]
fn test_is_permanent() {
assert!(FailoverReason::AuthPermanent.is_permanent());
assert!(FailoverReason::ContentPolicyBlocked.is_permanent());
assert!(!FailoverReason::Auth.is_permanent());
}
#[test]
fn test_is_user_error() {
assert!(FailoverReason::ContextOverflow.is_user_error());
assert!(FailoverReason::ImageTooLarge.is_user_error());
assert!(!FailoverReason::Timeout.is_user_error());
}
#[test]
fn test_failover_reason_display() {
assert_eq!(FailoverReason::Auth.to_string(), "auth");
assert_eq!(FailoverReason::RateLimited.to_string(), "rate_limited");
assert_eq!(
FailoverReason::Unknown("test".into()).to_string(),
"unknown(test)"
);
}
// ── Classification pipeline tests ──
#[test]
fn test_classify_rate_limited_429() {
let reason = classify_failover("HTTP 429: Too Many Requests", None, None);
assert_eq!(reason, FailoverReason::RateLimited);
}
#[test]
fn test_classify_overloaded_529() {
let reason = classify_failover("HTTP 529: Service Overloaded", None, None);
assert_eq!(reason, FailoverReason::Overloaded);
}
#[test]
fn test_classify_context_overflow() {
let reason = classify_failover(
"Error: prompt_too_long: context length exceeded",
None,
None,
);
assert_eq!(reason, FailoverReason::ContextOverflow);
}
#[test]
fn test_classify_auth_401() {
let reason = classify_failover("HTTP 401: Unauthorized - invalid api key", None, None);
assert_eq!(reason, FailoverReason::Auth);
}
#[test]
fn test_classify_billing_402() {
let reason = classify_failover("HTTP 402: insufficient_quota", None, None);
assert_eq!(reason, FailoverReason::Billing);
}
#[test]
fn test_classify_content_policy_blocked() {
let reason = classify_failover(
"content_policy_violation: responsible_ai filter triggered",
None,
None,
);
assert_eq!(reason, FailoverReason::ContentPolicyBlocked);
}
#[test]
fn test_classify_model_not_found() {
let reason = classify_failover(
"HTTP 404: model_not_found - deployment not found",
None,
None,
);
assert_eq!(reason, FailoverReason::ModelNotFound);
}
#[test]
fn test_classify_transport_timeout() {
let reason = classify_failover("Connection reset by peer", None, None);
// Connection reset with no session info → timeout
assert_eq!(reason, FailoverReason::Timeout);
}
#[test]
fn test_classify_disconnect_with_large_session() {
let reason = classify_failover(
"connection reset",
Some(8000), // estimated tokens
Some(10000), // context length (80% → >60%)
);
assert_eq!(reason, FailoverReason::ContextOverflow);
}
#[test]
fn test_classify_disconnect_with_small_session() {
let reason = classify_failover(
"connection reset",
Some(4000), // estimated tokens
Some(10000), // context length (40% → <60%)
);
assert_eq!(reason, FailoverReason::Timeout);
}
#[test]
fn test_classify_priorities_content_policy_before_status() {
// content_policy_violation should be detected before 400 status
let reason = classify_failover(
"HTTP 400: content_policy_violation - content management policy",
None,
None,
);
assert_eq!(reason, FailoverReason::ContentPolicyBlocked);
}
#[test]
fn test_classify_unknown_fallback() {
let reason = classify_failover("Some weird error that doesn't match anything", None, None);
assert!(matches!(reason, FailoverReason::Unknown(_)));
}
#[test]
fn test_classify_image_too_large() {
let reason = classify_failover("HTTP 413: image too large, max 20MB", None, None);
assert_eq!(reason, FailoverReason::ImageTooLarge);
}
#[test]
fn test_classify_provider_policy_blocked() {
let reason = classify_failover("HTTP 403: provider policy blocked this region", None, None);
assert_eq!(reason, FailoverReason::ProviderPolicyBlocked);
}
#[test]
fn test_classify_ssl_transient() {
let reason = classify_failover("SSL handshake timeout: connection reset", None, None);
assert_eq!(reason, FailoverReason::SslTransient);
}
// ── Backward compatibility tests ──
#[test]
fn test_classify_error_backward_compat() {
let kind = classify_error("HTTP 429: Too Many Requests");
assert_eq!(kind, ErrorKind::RateLimited);
}
#[test]
fn test_classify_error_529_backward_compat() {
let kind = classify_error("HTTP 529: Service Overloaded");
assert_eq!(kind, ErrorKind::Overloaded);
}
// ── Recovery step tests ──
#[test]
fn test_all_steps_sequence() {
let mut attempts = RecoveryAttempts::new();
assert_eq!(
attempts.next_step_compat(&ErrorKind::PromptTooLong),
Some(RecoveryStep::AggressiveCompact)
);
assert_eq!(
attempts.next_step_compat(&ErrorKind::PromptTooLong),
Some(RecoveryStep::ReactiveCompact)
);
assert_eq!(
attempts.next_step_compat(&ErrorKind::PromptTooLong),
Some(RecoveryStep::EscalateTokens {
new_hard_limit: 64_000
})
);
assert_eq!(
attempts.next_step_compat(&ErrorKind::PromptTooLong),
Some(RecoveryStep::MultiTurn)
);
assert_eq!(attempts.next_step_compat(&ErrorKind::PromptTooLong), None);
}
#[test]
fn test_model_error_goes_straight_to_surface() {
let mut attempts = RecoveryAttempts::new();
assert_eq!(
attempts.next_step_compat(&ErrorKind::ModelError("test".into())),
Some(RecoveryStep::Surface)
);
assert_eq!(
attempts.next_step_compat(&ErrorKind::ModelError("test".into())),
None
);
}
#[test]
fn test_has_remaining() {
let mut attempts = RecoveryAttempts::new();
assert!(attempts.has_remaining());
for _ in 0..4 {
attempts.next_step_compat(&ErrorKind::PromptTooLong);
}
assert!(!attempts.has_remaining());
}
#[test]
fn test_is_recoverable() {
assert!(ErrorRecovery::is_recoverable(&ErrorKind::PromptTooLong));
assert!(ErrorRecovery::is_recoverable(&ErrorKind::TokenExhausted));
assert!(ErrorRecovery::is_recoverable(&ErrorKind::RateLimited));
assert!(ErrorRecovery::is_recoverable(&ErrorKind::Overloaded));
assert!(!ErrorRecovery::is_recoverable(&ErrorKind::ModelError(
"test".into()
)));
assert!(!ErrorRecovery::is_recoverable(&ErrorKind::Timeout));
}
#[test]
fn test_parse_retry_after() {
let err = "HTTP 429: retry_after=Some(30)";
assert_eq!(parse_retry_after(err), Some(30));
}
#[test]
fn test_parse_retry_after_header_format() {
let err = "HTTP 429\nRetry-After: 60\n";
assert_eq!(parse_retry_after(err), Some(60));
}
#[test]
fn test_parse_retry_after_none() {
let err = "HTTP 500: Internal Server Error";
assert_eq!(parse_retry_after(err), None);
}
#[test]
fn test_backoff_delay() {
let d0 = backoff_delay(0, None);
assert!(d0 >= 500 && d0 <= 700);
let d3 = backoff_delay(3, None);
assert!(d3 >= 4000 && d3 <= 5000);
let d10 = backoff_delay(10, None);
assert!(d10 <= 40_000);
let d_ra = backoff_delay(0, Some(15));
assert_eq!(d_ra, 15000);
}
#[test]
fn test_rate_limited_goes_to_retry() {
let mut attempts = RecoveryAttempts::new();
let step = attempts.next_step_compat(&ErrorKind::RateLimited);
assert!(matches!(step, Some(RecoveryStep::RetryWithBackoff { .. })));
}
#[test]
fn test_token_budget_escalation() {
let budget = TokenBudget::new(32_000, 40_000);
let mut recovery = ErrorRecovery::new(budget);
assert_eq!(recovery.token_budget.hard_limit, 40_000);
recovery
.attempts
.next_step_compat(&ErrorKind::PromptTooLong); // aggressive
recovery
.attempts
.next_step_compat(&ErrorKind::PromptTooLong); // reactive
let step = recovery.try_recover_compat(&ErrorKind::PromptTooLong); // escalate
assert_eq!(
step,
Some(RecoveryStep::EscalateTokens {
new_hard_limit: 64_000
})
);
assert_eq!(recovery.token_budget.hard_limit, 64_000);
}
#[test]
fn test_recovery_suggestion_all_variants() {
// Every variant should return a non-empty suggestion
let variants = [
FailoverReason::Auth,
FailoverReason::AuthPermanent,
FailoverReason::Billing,
FailoverReason::RateLimited,
FailoverReason::Overloaded,
FailoverReason::ServerError,
FailoverReason::Timeout,
FailoverReason::ContextOverflow,
FailoverReason::PayloadTooLarge,
FailoverReason::ImageTooLarge,
FailoverReason::ModelNotFound,
FailoverReason::ProviderPolicyBlocked,
FailoverReason::ContentPolicyBlocked,
FailoverReason::FormatError,
FailoverReason::InvalidEncryptedContent,
FailoverReason::MultimodalToolContentUnsupported,
FailoverReason::ThinkingSignature,
FailoverReason::LongContextTier,
FailoverReason::OauthLongContextBetaForbidden,
FailoverReason::SslTransient,
FailoverReason::Unknown("test".into()),
];
for v in &variants {
let suggestion = recovery_suggestion(v);
assert!(!suggestion.is_empty(), "Empty suggestion for {:?}", v);
}
}
// ── Context Overflow 解析测试 ──
#[test]
fn test_parse_overflow_anthropic_format() {
let err = "input length and max_tokens exceed context limit: 180000 + 32000 > 200000. \
please reduce the length of the messages or the max_tokens.";
let info = parse_context_overflow(err).unwrap();
assert_eq!(info.input_length, 180000);
assert_eq!(info.requested_max_tokens, 32000);
assert_eq!(info.context_limit, 200000);
}
#[test]
fn test_parse_overflow_anthropic_format_different_numbers() {
let err = "Error: input length and max_tokens exceed context limit: 50000 + 16000 > 64000";
let info = parse_context_overflow(err).unwrap();
assert_eq!(info.input_length, 50000);
assert_eq!(info.requested_max_tokens, 16000);
assert_eq!(info.context_limit, 64000);
}
#[test]
fn test_parse_overflow_generic_format() {
let err = "context length exceeded: 130000 + 32000 > 128000";
let info = parse_context_overflow(err).unwrap();
assert_eq!(info.input_length, 130000);
assert_eq!(info.requested_max_tokens, 32000);
assert_eq!(info.context_limit, 128000);
}
#[test]
fn test_parse_overflow_no_match() {
let err = "Something went wrong but no overflow pattern here";
assert!(parse_context_overflow(err).is_none());
}
#[test]
fn test_calculate_safe_max_tokens_normal() {
let info = ContextOverflowInfo {
input_length: 180000,
requested_max_tokens: 32000,
context_limit: 200000,
};
// available = 200000 - 180000 = 20000
// adjusted = 20000 - 1000 = 19000
assert_eq!(calculate_safe_max_tokens(&info), Some(19000));
}
#[test]
fn test_calculate_safe_max_tokens_no_room() {
let info = ContextOverflowInfo {
input_length: 199000,
requested_max_tokens: 32000,
context_limit: 200000,
};
// available = 200000 - 199000 = 1000
// adjusted = 1000 - 1000 = 0 < MIN_ACCEPTABLE_TOKENS(500)
assert_eq!(calculate_safe_max_tokens(&info), None);
}
#[test]
fn test_calculate_safe_max_tokens_input_exceeds_context() {
let info = ContextOverflowInfo {
input_length: 210000,
requested_max_tokens: 1000,
context_limit: 200000,
};
// input > context → None (must compress)
assert_eq!(calculate_safe_max_tokens(&info), None);
}
#[test]
fn test_recovery_uses_adjust_max_tokens_first() {
let mut attempts = RecoveryAttempts::new();
let info = ContextOverflowInfo {
input_length: 180000,
requested_max_tokens: 32000,
context_limit: 200000,
};
let step = attempts.next_step(&ErrorKind::PromptTooLong, Some(&info));
assert_eq!(
step,
Some(RecoveryStep::AdjustMaxTokens {
new_max_tokens: 19000
})
);
}
#[test]
fn test_recovery_falls_back_to_compact_when_no_room() {
let mut attempts = RecoveryAttempts::new();
// Input almost fills context — no room to adjust max_tokens
let info = ContextOverflowInfo {
input_length: 199500,
requested_max_tokens: 32000,
context_limit: 200000,
};
let step = attempts.next_step(&ErrorKind::PromptTooLong, Some(&info));
// Should skip AdjustMaxTokens and go to AggressiveCompact
assert_eq!(step, Some(RecoveryStep::AggressiveCompact));
}
#[test]
fn test_recovery_adjust_after_compact_exhausted() {
let mut attempts = RecoveryAttempts::new();
// Mark aggressive_compact as tried
attempts.aggressive_compact = true;
let info = ContextOverflowInfo {
input_length: 180000,
requested_max_tokens: 32000,
context_limit: 200000,
};
// AdjustMaxTokens check is skipped when aggressive_compact is already true
let step = attempts.next_step(&ErrorKind::PromptTooLong, Some(&info));
assert_eq!(step, Some(RecoveryStep::ReactiveCompact));
}
#[test]
fn test_try_recover_adjusts_token_budget() {
let budget = TokenBudget::new(32_000, 40_000);
let mut recovery = ErrorRecovery::new(budget);
let info = ContextOverflowInfo {
input_length: 180000,
requested_max_tokens: 32000,
context_limit: 200000,
};
let step = recovery.try_recover(&ErrorKind::PromptTooLong, Some(&info));
assert_eq!(
step,
Some(RecoveryStep::AdjustMaxTokens {
new_max_tokens: 19000
})
);
// Token budget hard_limit should be adjusted down
assert_eq!(recovery.token_budget.hard_limit, 19000);
}
}

View File

@ -0,0 +1,157 @@
// src/agent/runtime/error_recovery/overflow.rs
//
// 上下文溢出解析与安全 Token 计算。
#[derive(Debug, Clone, Copy)]
pub struct ContextOverflowInfo {
/// 当前输入 token 数
pub input_length: usize,
/// 请求的 max_tokens 数
pub requested_max_tokens: usize,
/// 模型上下文窗口总容量
pub context_limit: usize,
}
/// 上下文溢出检测的最小可接受 token 数。
/// 低于此值说明即使调整 max_tokens 也没有意义,应触发压缩。
const MIN_ACCEPTABLE_TOKENS: usize = 500;
/// 上下文溢出调整的安全边际token 数)。
/// 保留少量余量以防止重试时再次溢出。
const OVERFLOW_SAFETY_MARGIN: usize = 1000;
/// 从错误消息中解析 Anthropic-style context overflow 信息。
///
/// 匹配模式:
/// "input length and max_tokens exceed context limit: 180000 + 32000 > 200000"
/// "prompt too long: X tokens + Y max_tokens > Z context_window"
/// "context length exceeded: X + Y > Z"
/// "maximum context length is Z tokens, but X + Y = ..."
pub fn parse_context_overflow(error_str: &str) -> Option<ContextOverflowInfo> {
let lower = error_str.to_lowercase();
// Pattern 1: Anthropic standard format
// "input length and max_tokens exceed context limit: A + B > C"
if let Some(info) = parse_anthropic_overflow(&lower) {
return Some(info);
}
// Pattern 2: OpenAI format
// "maximum context length is C tokens. however X tokens ... and Y max_tokens"
if let Some(info) = parse_openai_overflow(&lower) {
return Some(info);
}
// Pattern 3: Generic "X + Y > Z" pattern
if let Some(info) = parse_generic_overflow(&lower) {
return Some(info);
}
None
}
/// 解析 Anthropic API 格式: "input length and max_tokens exceed context limit: A + B > C"
fn parse_anthropic_overflow(error_str: &str) -> Option<ContextOverflowInfo> {
// Find the "A + B > C" pattern after "context limit"
let marker = "input length and max_tokens exceed context limit";
let pos = error_str.find(marker)?;
let after_marker = &error_str[pos + marker.len()..];
// Extract numbers: look for pattern like " 180000 + 32000 > 200000"
let numbers = extract_three_numbers(after_marker)?;
Some(ContextOverflowInfo {
input_length: numbers.0,
requested_max_tokens: numbers.1,
context_limit: numbers.2,
})
}
/// 解析 OpenAI API 格式: "maximum context length is C tokens..."
fn parse_openai_overflow(error_str: &str) -> Option<ContextOverflowInfo> {
// "maximum context length is C tokens"
let context_re = regex::Regex::new(r"maximum context length is (\d+)").ok()?;
let caps = context_re.captures(error_str)?;
let context_limit: usize = caps.get(1)?.as_str().parse().ok()?;
// "you requested X tokens" or "messages resulted in X tokens"
let input_re = regex::Regex::new(r"(?:requested|resulted in|totals? to) (\d+) tokens").ok()?;
let input_length: usize = if let Some(caps) = input_re.captures(error_str) {
caps.get(1)?.as_str().parse().ok()?
} else {
// Fallback: find any substantial number and use it as input_length
return None;
};
// max_tokens might not be explicitly stated; use the context_limit - input_length - margin
let requested_max_tokens = context_limit.saturating_sub(input_length);
Some(ContextOverflowInfo {
input_length,
requested_max_tokens,
context_limit,
})
}
/// 解析通用格式: "X + Y > Z"
fn parse_generic_overflow(error_str: &str) -> Option<ContextOverflowInfo> {
// Look for patterns like "130000 + 32000 > 200000"
let (a, b, c) = extract_three_numbers(error_str)?;
Some(ContextOverflowInfo {
input_length: a,
requested_max_tokens: b,
context_limit: c,
})
}
/// 从字符串中提取三个连续的数字,匹配 "A + B > C" 模式。
fn extract_three_numbers(s: &str) -> Option<(usize, usize, usize)> {
let re = regex::Regex::new(r"(\d{4,})\s*\+\s*(\d+)\s*>\s*(\d{4,})").ok()?;
let caps = re.captures(s)?;
let a: usize = caps.get(1)?.as_str().parse().ok()?;
let b: usize = caps.get(2)?.as_str().parse().ok()?;
let c: usize = caps.get(3)?.as_str().parse().ok()?;
Some((a, b, c))
}
/// 根据 ContextOverflow 信息计算安全的 max_tokens 值。
///
/// 公式: `new_max_tokens = context_limit - input_length - SAFETY_MARGIN`
///
/// 返回 `None` 如果计算出的值低于最小可接受阈值(说明必须压缩上下文)。
pub fn calculate_safe_max_tokens(info: &ContextOverflowInfo) -> Option<usize> {
// 如果输入本身已经超过上下文窗口max_tokens 调整无意义
if info.input_length >= info.context_limit {
return None;
}
let available = info.context_limit.saturating_sub(info.input_length);
let adjusted = available.saturating_sub(OVERFLOW_SAFETY_MARGIN);
if adjusted >= MIN_ACCEPTABLE_TOKENS {
Some(adjusted)
} else {
// 可用空间太小,调整 max_tokens 无意义 → 应触发压缩
None
}
}
// ── 向后兼容 ──
/// 解析 Retry-After header 值
pub fn parse_retry_after(error_str: &str) -> Option<u64> {
if let Some(pos) = error_str.find("retry_after=Some(") {
let prefix_len = "retry_after=Some(".len();
let rest = &error_str[pos + prefix_len..];
if let Some(end) = rest.find(')') {
return rest[..end].parse().ok();
}
}
if let Some(pos) = error_str.find("Retry-After:") {
let rest = &error_str[pos + "Retry-After:".len()..];
if let Some(end) = rest.find('\r').or_else(|| rest.find('\n')) {
return rest[..end].trim().parse().ok();
}
return rest.trim().parse().ok();
}
None
}

View File

@ -0,0 +1,83 @@
// src/agent/runtime/events.rs
//
// SSE 流式事件定义与运行指标。
use serde::Serialize;
use std::collections::HashMap;
/// SSE 流式事件(发送给前端)
#[derive(Debug, Clone, Serialize)]
#[serde(tag = "type")]
pub enum AgentStreamEvent {
/// 会话创建/恢复
#[serde(rename = "session")]
Session { session_id: String, title: String },
/// 智能体思考过程
#[serde(rename = "thought")]
Thought { content: String, step: usize },
/// 工具调用开始
#[serde(rename = "tool_call")]
ToolCall {
/// LLM 生成的工具调用 ID用于全链路关联前端/审计/持久化)
id: String,
name: String,
arguments: serde_json::Value,
step: usize,
},
/// 工具执行结果Observation
#[serde(rename = "tool_result")]
ToolResult {
/// 对应的工具调用 ID前端凭此精确匹配 tool_call 条目
tool_call_id: String,
name: String,
output: String,
is_error: bool,
metadata: serde_json::Value,
step: usize,
},
/// 文本增量流式输出(最终回答或工具流式输出)
#[serde(rename = "text_delta")]
TextDelta {
content: String,
/// 可选:工具调用 ID。当 set 时,此增量属于对应工具的流式输出,
/// 前端应将其渲染到工具结果区域而非主文本区。
#[serde(skip_serializing_if = "Option::is_none")]
tool_call_id: Option<String>,
},
/// Token 使用统计
#[serde(rename = "usage")]
Usage {
prompt_tokens: u32,
completion_tokens: u32,
total_tokens: u32,
},
/// 错误通知
#[serde(rename = "error")]
Error { message: String },
/// 权限请求(需要用户确认工具执行)
#[serde(rename = "permission_request")]
PermissionRequest {
tool_call_id: String,
tool_name: String,
message: String,
arguments: serde_json::Value,
/// 可选的权限风险解释(参考 Claude Code permissionExplainer
explanation: Option<serde_json::Value>,
},
/// 权限响应已处理
#[serde(rename = "permission_response")]
PermissionResponse { tool_call_id: String, allowed: bool },
/// 完成标记
#[serde(rename = "done")]
Done,
}
/// Agent 运行指标
#[derive(Debug, Default, Serialize)]
pub struct AgentMetrics {
pub total_steps: usize,
pub compression_count: usize,
pub duplicate_detections: usize,
/// 各工具调用次数统计
pub tool_calls: HashMap<String, usize>,
}

View File

@ -0,0 +1,257 @@
// src/agent/runtime/executor/helpers.rs
//
// 执行器辅助类型与函数PreparedCall, ToolResultMessage, ToolExecutionResult,
// execute_single_tool, process_single_result, save_tool_message_sync。
use sqlx::SqlitePool;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use tokio::sync::mpsc;
use tracing::warn;
use crate::clients::llm::ChatMessage;
use super::AgentStreamEvent;
use crate::agent::hooks::{
event_label, HookRegistry, PostToolUseContext, PostToolUseFailureContext,
};
#[derive(Debug, Clone)]
pub struct PreparedCall {
pub tool_call_id: String,
pub tool_name: String,
pub args: serde_json::Value,
}
/// 单次工具执行后的消息 + 元数据
pub struct ToolResultMessage {
pub chat_message: ChatMessage,
pub was_error: bool,
}
/// 工具执行结果摘要
pub struct ToolExecutionResult {
/// 每条工具调用对应的 tool_result 消息(供调用方 push 到 messages
pub tool_messages: Vec<ToolResultMessage>,
pub was_cancelled: bool,
pub had_duplicate: bool,
/// Hook 注入的附加上下文PreToolUse + PostToolUse需注入 LLM 消息列表
pub hook_contexts: Vec<String>,
/// Hook 的阻塞错误详情(用于日志和诊断)
pub blocking_errors: Vec<String>,
}
/// 执行单个工具调用(含超时和取消检测)。
///
/// 从原 `execute_parallel` 的闭包提取,供分区后的批次执行复用。
pub(super) async fn execute_single_tool(
tool_opt: Option<&dyn crate::agent::tools::AgentTool>,
args: serde_json::Value,
tool_ctx: &crate::agent::tools::ToolContext,
cancelled: &Arc<AtomicBool>,
timeout_dur: std::time::Duration,
tool_name: &str,
) -> crate::agent::tools::ToolOutput {
let tool = match tool_opt {
Some(t) => t,
None => return crate::agent::tools::ToolOutput::error(format!("未知工具: {}", tool_name)),
};
let interrupt_behavior = tool.interrupt_behavior();
let is_blocking = interrupt_behavior == crate::agent::tools::InterruptBehavior::Block;
let tool_fut = tool.execute(args, tool_ctx);
let cancelled = cancelled.clone();
let cancel_fut = async {
loop {
tokio::time::sleep(std::time::Duration::from_millis(250)).await;
if !is_blocking && cancelled.load(Ordering::SeqCst) {
return;
}
}
};
tokio::select! {
res = tokio::time::timeout(timeout_dur, tool_fut) => {
match res {
Ok(output) => output,
Err(_) => crate::agent::tools::ToolOutput::error(format!(
"工具 {} 执行超时({}秒)",
tool_name,
timeout_dur.as_secs()
)),
}
}
_ = cancel_fut => {
crate::agent::tools::ToolOutput::error("执行已被用户取消")
}
}
}
/// 处理单个工具执行结果SSE 事件、PostToolUse hooks、持久化
///
/// 从原 `execute_parallel` 的结果处理循环提取。
#[allow(clippy::too_many_arguments)]
pub(super) async fn process_single_result(
tool_call_id: &str,
tool_name: &str,
tool_args: &serde_json::Value,
output: &crate::agent::tools::ToolOutput,
cancelled_flag: bool,
exec_start: std::time::Instant,
tx: &mpsc::UnboundedSender<AgentStreamEvent>,
hook_registry: &HookRegistry,
library_dir: &std::path::Path,
sid: &str,
agent_name: &str,
step: usize,
max_output_chars: usize,
tool_messages: &mut Vec<ToolResultMessage>,
additional_contexts: &mut Vec<String>,
db: &SqlitePool,
turn_index: i32,
) {
use crate::agent::tools::persist::maybe_persist_tool_result;
let elapsed_ms = exec_start.elapsed().as_millis() as u64;
// SSE 事件 — 立即推送到前端
let _ = tx.send(AgentStreamEvent::ToolResult {
tool_call_id: tool_call_id.to_string(),
name: tool_name.to_string(),
output: output.content.clone(),
is_error: output.is_error,
metadata: output.metadata.clone(),
step,
});
// 输出处理:小结果直接传递,大结果持久化到磁盘并返回 stub
// 但对于已从磁盘读取内容的工具(如 read_file跳过持久化以防止级联
let tool_results_dir = library_dir.join("tool-results");
let (processed_content, _persisted_path) = if output.skip_persist {
(output.content.clone(), None)
} else {
maybe_persist_tool_result(
&output.content,
tool_call_id,
max_output_chars,
&tool_results_dir,
)
};
// PostToolUse hook
let post_ctx = PostToolUseContext {
session_id: sid.to_string(),
agent_name: agent_name.to_string(),
tool_name: tool_name.to_string(),
tool_args: tool_args.clone(),
output_content: processed_content.clone(),
is_error: output.is_error,
step,
elapsed_ms,
};
let post_result = hook_registry.run_post_tool_use(&post_ctx).await;
let final_content = post_result.final_content;
// 非可信内容包裹(间接 prompt 注入防御)
let llm_content =
crate::agent::runtime::untrusted::wrap_untrusted_content(tool_name, &final_content);
// 收集 PostToolUse hook 注入的上下文
if !post_result.tagged_contexts.is_empty() {
for tc in &post_result.tagged_contexts {
additional_contexts.push(format!(
"[Hook: {} | {}] {}",
tc.hook_name,
event_label(tc.source_event),
tc.content,
));
}
} else {
for ctx in &post_result.additional_contexts {
additional_contexts.push(ctx.clone());
}
}
// 收集 PostToolUse 的警告
for warning in &post_result.warnings {
additional_contexts.push(format!("[Hook Warning] {}", warning));
}
// 事后权限请求audit trail
for (perm_tool, perm) in &post_result.post_permission_requests {
warn!(
"[Executor] Hook 事后请求权限: tool={} permission={}",
perm_tool, perm
);
}
// PostToolUseFailure hook
if output.is_error {
let failure_ctx = PostToolUseFailureContext {
session_id: sid.to_string(),
agent_name: agent_name.to_string(),
tool_name: tool_name.to_string(),
tool_args: tool_args.clone(),
error_message: output.content.clone(),
is_interrupt: cancelled_flag,
step,
elapsed_ms,
};
hook_registry
.run_on_post_tool_use_failure(&failure_ctx)
.await;
}
// 发送给 LLM 使用包裹后的内容(安全防御)
let chat_message = ChatMessage::tool_result(tool_call_id, &llm_content);
// 持久化到数据库fire-and-forget
save_tool_message_sync(db, sid, turn_index, step, &chat_message);
tool_messages.push(ToolResultMessage {
chat_message,
was_error: output.is_error,
});
}
/// 同步保存 tool 角色消息到数据库。
pub(super) fn save_tool_message_sync(
db: &SqlitePool,
session_id: &str,
turn_index: i32,
step_index: usize,
msg: &ChatMessage,
) {
let db_clone = db.clone();
let session_id = session_id.to_string();
let content = msg.text().unwrap_or("").to_string();
let tool_call_id = msg.tool_call_id.clone();
// 提前序列化,避免闭包内的生命周期问题
let metadata_str =
serde_json::to_string(&serde_json::json!({ "role": "tool" })).unwrap_or_default();
let raw_json = serde_json::to_string(&msg).unwrap_or_default();
// fire-and-forget: tool 消息保存失败不影响主流程
tokio::spawn(async move {
let token_count = content.len() as i32 / 4;
if let Err(e) = sqlx::query(
"INSERT INTO agent_messages (session_id, turn_index, step_index, role, content, tool_call_id, token_count, metadata, raw_json, agent_name) \
VALUES (?, ?, ?, 'tool', ?, ?, ?, ?, ?, ?)",
)
.bind(&session_id)
.bind(turn_index)
.bind(step_index as i32)
.bind(&content)
.bind(&tool_call_id)
.bind(token_count)
.bind(&metadata_str)
.bind(&raw_json)
.bind("lead")
.execute(&db_clone)
.await
{
warn!("[Executor] 保存 tool 消息失败(非致命): {}", e);
}
});
}

View File

@ -1,6 +1,11 @@
// src/agent/runtime/executor.rs // src/agent/runtime/executor/mod.rs
// //
// 工具调用执行器:验证 → PreToolUse hooks → 并行执行 → 结果收集 → PostToolUse hooks。 // 工具调用验证与并行执行器。
mod helpers;
// Re-export 公共类型
pub use helpers::{PreparedCall, ToolExecutionResult, ToolResultMessage};
use futures_util::stream::FuturesUnordered; use futures_util::stream::FuturesUnordered;
use futures_util::StreamExt; use futures_util::StreamExt;
@ -21,36 +26,10 @@ use super::partitioner::ToolPartitioner;
use super::permission::{PermissionChecker, PermissionResult}; use super::permission::{PermissionChecker, PermissionResult};
use super::permission_explainer::explain_permission; use super::permission_explainer::explain_permission;
use super::{AgentStreamEvent, DuplicateDetector}; use super::{AgentStreamEvent, DuplicateDetector};
use crate::agent::hooks::{ use crate::agent::hooks::{event_label, HookRegistry, PreToolUseContext};
event_label, HookRegistry, PostToolUseContext, PostToolUseFailureContext, PreToolUseContext,
};
use crate::agent::tools::{ToolContext, ToolRegistry}; use crate::agent::tools::{ToolContext, ToolRegistry};
/// 准备好的工具调用 use helpers::{execute_single_tool, process_single_result, save_tool_message_sync};
#[derive(Debug, Clone)]
pub struct PreparedCall {
pub tool_call_id: String,
pub tool_name: String,
pub args: serde_json::Value,
}
/// 单次工具执行后的消息 + 元数据
pub struct ToolResultMessage {
pub chat_message: ChatMessage,
pub was_error: bool,
}
/// 工具执行结果摘要
pub struct ToolExecutionResult {
/// 每条工具调用对应的 tool_result 消息(供调用方 push 到 messages
pub tool_messages: Vec<ToolResultMessage>,
pub was_cancelled: bool,
pub had_duplicate: bool,
/// Hook 注入的附加上下文PreToolUse + PostToolUse需注入 LLM 消息列表
pub hook_contexts: Vec<String>,
/// Hook 的阻塞错误详情(用于日志和诊断)
pub blocking_errors: Vec<String>,
}
/// 验证工具调用:死循环检测 + 参数解析。 /// 验证工具调用:死循环检测 + 参数解析。
/// ///
@ -799,217 +778,3 @@ pub async fn execute_parallel(
blocking_errors: hook_blocking_errors, blocking_errors: hook_blocking_errors,
} }
} }
/// 执行单个工具调用(含超时和取消检测)。
///
/// 从原 `execute_parallel` 的闭包提取,供分区后的批次执行复用。
async fn execute_single_tool(
tool_opt: Option<&dyn crate::agent::tools::AgentTool>,
args: serde_json::Value,
tool_ctx: &crate::agent::tools::ToolContext,
cancelled: &Arc<AtomicBool>,
timeout_dur: std::time::Duration,
tool_name: &str,
) -> crate::agent::tools::ToolOutput {
let tool = match tool_opt {
Some(t) => t,
None => return crate::agent::tools::ToolOutput::error(format!("未知工具: {}", tool_name)),
};
let interrupt_behavior = tool.interrupt_behavior();
let is_blocking = interrupt_behavior == crate::agent::tools::InterruptBehavior::Block;
let tool_fut = tool.execute(args, tool_ctx);
let cancelled = cancelled.clone();
let cancel_fut = async {
loop {
tokio::time::sleep(std::time::Duration::from_millis(250)).await;
if !is_blocking && cancelled.load(Ordering::SeqCst) {
return;
}
}
};
tokio::select! {
res = tokio::time::timeout(timeout_dur, tool_fut) => {
match res {
Ok(output) => output,
Err(_) => crate::agent::tools::ToolOutput::error(format!(
"工具 {} 执行超时({}秒)",
tool_name,
timeout_dur.as_secs()
)),
}
}
_ = cancel_fut => {
crate::agent::tools::ToolOutput::error("执行已被用户取消")
}
}
}
/// 处理单个工具执行结果SSE 事件、PostToolUse hooks、持久化
///
/// 从原 `execute_parallel` 的结果处理循环提取。
#[allow(clippy::too_many_arguments)]
async fn process_single_result(
tool_call_id: &str,
tool_name: &str,
tool_args: &serde_json::Value,
output: &crate::agent::tools::ToolOutput,
cancelled_flag: bool,
exec_start: std::time::Instant,
tx: &mpsc::UnboundedSender<AgentStreamEvent>,
hook_registry: &HookRegistry,
library_dir: &std::path::Path,
sid: &str,
agent_name: &str,
step: usize,
max_output_chars: usize,
tool_messages: &mut Vec<ToolResultMessage>,
additional_contexts: &mut Vec<String>,
db: &SqlitePool,
turn_index: i32,
) {
use crate::agent::tools::persist::maybe_persist_tool_result;
let elapsed_ms = exec_start.elapsed().as_millis() as u64;
// SSE 事件 — 立即推送到前端
let _ = tx.send(AgentStreamEvent::ToolResult {
tool_call_id: tool_call_id.to_string(),
name: tool_name.to_string(),
output: output.content.clone(),
is_error: output.is_error,
metadata: output.metadata.clone(),
step,
});
// 输出处理:小结果直接传递,大结果持久化到磁盘并返回 stub
// 但对于已从磁盘读取内容的工具(如 read_file跳过持久化以防止级联
let tool_results_dir = library_dir.join("tool-results");
let (processed_content, _persisted_path) = if output.skip_persist {
(output.content.clone(), None)
} else {
maybe_persist_tool_result(
&output.content,
tool_call_id,
max_output_chars,
&tool_results_dir,
)
};
// PostToolUse hook
let post_ctx = PostToolUseContext {
session_id: sid.to_string(),
agent_name: agent_name.to_string(),
tool_name: tool_name.to_string(),
tool_args: tool_args.clone(),
output_content: processed_content.clone(),
is_error: output.is_error,
step,
elapsed_ms,
};
let post_result = hook_registry.run_post_tool_use(&post_ctx).await;
let final_content = post_result.final_content;
// 非可信内容包裹(间接 prompt 注入防御)
let llm_content = super::untrusted::wrap_untrusted_content(tool_name, &final_content);
// 收集 PostToolUse hook 注入的上下文
if !post_result.tagged_contexts.is_empty() {
for tc in &post_result.tagged_contexts {
additional_contexts.push(format!(
"[Hook: {} | {}] {}",
tc.hook_name,
event_label(tc.source_event),
tc.content,
));
}
} else {
for ctx in &post_result.additional_contexts {
additional_contexts.push(ctx.clone());
}
}
// 收集 PostToolUse 的警告
for warning in &post_result.warnings {
additional_contexts.push(format!("[Hook Warning] {}", warning));
}
// 事后权限请求audit trail
for (perm_tool, perm) in &post_result.post_permission_requests {
warn!(
"[Executor] Hook 事后请求权限: tool={} permission={}",
perm_tool, perm
);
}
// PostToolUseFailure hook
if output.is_error {
let failure_ctx = PostToolUseFailureContext {
session_id: sid.to_string(),
agent_name: agent_name.to_string(),
tool_name: tool_name.to_string(),
tool_args: tool_args.clone(),
error_message: output.content.clone(),
is_interrupt: cancelled_flag,
step,
elapsed_ms,
};
hook_registry
.run_on_post_tool_use_failure(&failure_ctx)
.await;
}
// 发送给 LLM 使用包裹后的内容(安全防御)
let chat_message = ChatMessage::tool_result(tool_call_id, &llm_content);
// 持久化到数据库fire-and-forget
save_tool_message_sync(db, sid, turn_index, step, &chat_message);
tool_messages.push(ToolResultMessage {
chat_message,
was_error: output.is_error,
});
}
/// 同步保存 tool 角色消息到数据库。
fn save_tool_message_sync(
db: &SqlitePool,
session_id: &str,
turn_index: i32,
step_index: usize,
msg: &ChatMessage,
) {
let db_clone = db.clone();
let session_id = session_id.to_string();
let content = msg.text().unwrap_or("").to_string();
let tool_call_id = msg.tool_call_id.clone();
// 提前序列化,避免闭包内的生命周期问题
let metadata_str =
serde_json::to_string(&serde_json::json!({ "role": "tool" })).unwrap_or_default();
let raw_json = serde_json::to_string(&msg).unwrap_or_default();
// fire-and-forget: tool 消息保存失败不影响主流程
tokio::spawn(async move {
let token_count = content.len() as i32 / 4;
if let Err(e) = sqlx::query(
"INSERT INTO agent_messages (session_id, turn_index, step_index, role, content, tool_call_id, token_count, metadata, raw_json, agent_name) \
VALUES (?, ?, ?, 'tool', ?, ?, ?, ?, ?, ?)",
)
.bind(&session_id)
.bind(turn_index)
.bind(step_index as i32)
.bind(&content)
.bind(&tool_call_id)
.bind(token_count)
.bind(&metadata_str)
.bind(&raw_json)
.bind("lead")
.execute(&db_clone)
.await
{
warn!("[Executor] 保存 tool 消息失败(非致命): {}", e);
}
});
}

View File

@ -13,9 +13,12 @@
pub mod checkpoint; pub mod checkpoint;
pub mod circuit_breaker; pub mod circuit_breaker;
pub mod config;
pub mod context; pub mod context;
pub mod denial_tracker; pub mod denial_tracker;
pub mod duplicate_detector;
pub mod error_recovery; pub mod error_recovery;
pub mod events;
pub mod executor; pub mod executor;
pub mod file_cache; pub mod file_cache;
pub mod finalize; pub mod finalize;
@ -31,9 +34,7 @@ pub mod system_prompt;
pub mod token_budget; pub mod token_budget;
pub mod untrusted; pub mod untrusted;
use serde::Serialize;
use sqlx::SqlitePool; use sqlx::SqlitePool;
use std::collections::HashMap;
use std::sync::Arc; use std::sync::Arc;
use tokio::sync::mpsc; use tokio::sync::mpsc;
use tracing::{error, info, warn}; use tracing::{error, info, warn};
@ -55,203 +56,16 @@ use self::streaming::{StreamOutput, StreamStatus};
use self::system_prompt::SystemPromptCache; use self::system_prompt::SystemPromptCache;
use self::token_budget::TokenBudget; use self::token_budget::TokenBudget;
/// Agent 配置参数 // Re-export 公共类型,保持外部引用路径不变
#[derive(Debug, Clone)] // use crate::agent::runtime::{AgentConfig, AgentStreamEvent, ...}
pub struct AgentConfig { // pub use 同时将这些名称引入当前作用域,供本文件内使用
/// 最大 ReAct 迭代次数 pub use self::config::AgentConfig;
pub max_steps: usize, pub use self::duplicate_detector::DuplicateDetector;
/// 同质调用检测阈值(连续相同调用次数) pub use self::events::{AgentMetrics, AgentStreamEvent};
pub duplicate_call_threshold: usize,
/// 工具执行超时时间(秒)
pub tool_timeout_secs: u64,
/// 工具输出最大字符数
pub max_tool_output_chars: usize,
/// Token 预算软限制 — 各压缩层统一触发阈值
pub token_soft_limit: usize,
/// Token 预算硬限制(触发强制动作)
pub token_hard_limit: usize,
/// 最大消息数(超过此阈值触发 snip_compact 层压缩)
pub max_messages: usize,
/// 是否启用 LLM 思考模式(前端可控,默认关闭)
pub enable_thinking: bool,
/// 权限拒绝规则(逗号分隔,格式: ToolName 或 ToolName(content_pattern)
pub permission_deny_rules: Vec<String>,
/// 权限允许规则(逗号分隔)
pub permission_allow_rules: Vec<String>,
/// 权限询问规则(逗号分隔)
pub permission_ask_rules: Vec<String>,
/// 权限模式: "default" | "accept_edits" | "bypass" | "dont_ask"
pub permission_mode: String,
/// 拒绝追踪:连续拒绝上限(默认 3
pub denial_max_consecutive: usize,
/// 拒绝追踪:总拒绝上限(默认 20
pub denial_max_total: usize,
/// 附加允许目录(逗号分隔,扩展文件沙箱范围)
pub additional_allowed_dirs: Vec<String>,
/// 子代理工具白名单(逗号分隔,空=全部工具可用)
pub subagent_allowed_tools: Vec<String>,
/// Agent 运行模式 ID"default" / "deep-research" / "literature-reader"
pub mode: String,
}
impl AgentConfig {
/// 从环境变量加载配置,缺失时使用默认值。
pub fn from_env_optional() -> Self {
AgentConfig {
max_steps: 8,
duplicate_call_threshold: 3,
tool_timeout_secs: 120,
max_tool_output_chars: 4000,
token_soft_limit: std::env::var("AGENT_TOKEN_SOFT_LIMIT")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(80000),
token_hard_limit: std::env::var("AGENT_TOKEN_HARD_LIMIT")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(100000),
max_messages: 50,
enable_thinking: false,
permission_deny_rules: parse_comma_list("AGENT_PERMISSIONS_DENY"),
permission_allow_rules: parse_comma_list("AGENT_PERMISSIONS_ALLOW"),
permission_ask_rules: parse_comma_list("AGENT_PERMISSIONS_ASK"),
permission_mode: std::env::var("AGENT_PERMISSION_MODE")
.unwrap_or_else(|_| "default".to_string()),
denial_max_consecutive: 3,
denial_max_total: 20,
additional_allowed_dirs: parse_comma_list("AGENT_ADDITIONAL_DIRS"),
subagent_allowed_tools: parse_comma_list("AGENT_SUBAGENT_ALLOWED_TOOLS"),
mode: "default".to_string(),
}
}
}
/// 解析逗号分隔的环境变量为字符串列表
fn parse_comma_list(env_key: &str) -> Vec<String> {
std::env::var(env_key)
.ok()
.map(|v| {
v.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect()
})
.unwrap_or_default()
}
impl Default for AgentConfig {
fn default() -> Self {
Self::from_env_optional()
}
}
// ── SSE Stream Events ──
/// SSE 流式事件(发送给前端)
#[derive(Debug, Clone, Serialize)]
#[serde(tag = "type")]
pub enum AgentStreamEvent {
/// 会话创建/恢复
#[serde(rename = "session")]
Session { session_id: String, title: String },
/// 智能体思考过程
#[serde(rename = "thought")]
Thought { content: String, step: usize },
/// 工具调用开始
#[serde(rename = "tool_call")]
ToolCall {
/// LLM 生成的工具调用 ID用于全链路关联前端/审计/持久化)
id: String,
name: String,
arguments: serde_json::Value,
step: usize,
},
/// 工具执行结果Observation
#[serde(rename = "tool_result")]
ToolResult {
/// 对应的工具调用 ID前端凭此精确匹配 tool_call 条目
tool_call_id: String,
name: String,
output: String,
is_error: bool,
metadata: serde_json::Value,
step: usize,
},
/// 文本增量流式输出(最终回答或工具流式输出)
#[serde(rename = "text_delta")]
TextDelta {
content: String,
/// 可选:工具调用 ID。当 set 时,此增量属于对应工具的流式输出,
/// 前端应将其渲染到工具结果区域而非主文本区。
#[serde(skip_serializing_if = "Option::is_none")]
tool_call_id: Option<String>,
},
/// Token 使用统计
#[serde(rename = "usage")]
Usage {
prompt_tokens: u32,
completion_tokens: u32,
total_tokens: u32,
},
/// 错误通知
#[serde(rename = "error")]
Error { message: String },
/// 权限请求(需要用户确认工具执行)
#[serde(rename = "permission_request")]
PermissionRequest {
tool_call_id: String,
tool_name: String,
message: String,
arguments: serde_json::Value,
/// 可选的权限风险解释(参考 Claude Code permissionExplainer
explanation: Option<serde_json::Value>,
},
/// 权限响应已处理
#[serde(rename = "permission_response")]
PermissionResponse { tool_call_id: String, allowed: bool },
/// 完成标记
#[serde(rename = "done")]
Done,
}
// ── Metrics & Detection ──
/// Agent 运行指标
#[derive(Debug, Default, Serialize)]
pub struct AgentMetrics {
pub total_steps: usize,
pub compression_count: usize,
pub duplicate_detections: usize,
/// 各工具调用次数统计
pub tool_calls: HashMap<String, usize>,
}
/// 同质调用检测器
#[derive(Debug, Default)]
pub struct DuplicateDetector {
last_call: Option<(String, String)>, // (tool_name, arguments)
consecutive_count: usize,
}
impl DuplicateDetector {
/// 记录一次调用,返回是否检测到死循环
pub fn record(&mut self, tool_name: &str, arguments: &str, threshold: usize) -> bool {
let key = (tool_name.to_string(), arguments.to_string());
if self.last_call.as_ref() == Some(&key) {
self.consecutive_count += 1;
if self.consecutive_count >= threshold {
return true;
}
} else {
self.last_call = Some(key);
self.consecutive_count = 1;
}
false
}
}
// ── Agent Runtime ── // ── Agent Runtime ──
/// Agent 运行时核心,协调 ReAct 循环的执行。
/// 智能体运行时 /// 智能体运行时
pub struct AgentRuntime { pub struct AgentRuntime {
app_state: Arc<AppState>, app_state: Arc<AppState>,

262
src/agent/skills/creator.rs Normal file
View File

@ -0,0 +1,262 @@
// src/agent/skills/creator.rs
//
// Skill 自动创建与自进化流水线。
use std::collections::HashMap;
use std::path::PathBuf;
use tracing::info;
use super::types::{SkillMeta, SkillUsageStat};
use super::{curator, pattern_detector};
/// let creator = SkillCreator::new(skills_dir);
/// let created = creator.create_from_pattern(&pattern)?;
/// // created 包含新建的 skill 文件路径列表
/// ```
pub struct SkillCreator {
skills_dir: PathBuf,
}
impl SkillCreator {
pub fn new(skills_dir: PathBuf) -> Self {
SkillCreator { skills_dir }
}
/// 从检测到的模式创建 SKILL.md 文件。
///
/// 返回创建的 skill 名称列表。
/// 如果目标 skill 目录已存在则跳过(不覆盖已有 skill
pub fn create_from_patterns(
&self,
patterns: &[pattern_detector::DetectedPattern],
) -> std::io::Result<Vec<String>> {
let mut created = Vec::new();
for pattern in patterns {
if !pattern.is_confident() {
continue;
}
let skill_name = self.pattern_to_skill_name(pattern);
let skill_dir = self.skills_dir.join(&skill_name);
if skill_dir.exists() {
info!("[SkillCreator] Skill '{}' 已存在,跳过创建", skill_name);
continue;
}
// 创建 skill 目录
std::fs::create_dir_all(&skill_dir)?;
// 生成 SKILL.md 内容
let content = self.generate_skill_md(pattern, &skill_name);
let md_path = skill_dir.join("SKILL.md");
std::fs::write(&md_path, &content)?;
info!(
"[SkillCreator] 已从模式创建 Skill '{}': {}",
skill_name,
md_path.display()
);
created.push(skill_name);
}
Ok(created)
}
/// 将模式转换为 kebab-case skill 名称。
fn pattern_to_skill_name(&self, pattern: &pattern_detector::DetectedPattern) -> String {
// 取前三个工具名生成名称
let tokens: Vec<String> = pattern
.tool_sequence
.iter()
.take(3)
.map(|s| {
s.replace("search_", "")
.replace("get_", "")
.replace("download_", "dl-")
.replace("parse_", "parse-")
.replace("rag_", "rag-")
.replace("query_", "query-")
.replace("read_", "read-")
.replace("save_", "save-")
.replace("load_", "load-")
.replace("_", "-")
})
.collect();
format!("auto-{}", tokens.join("-"))
}
/// 生成 SKILL.md 内容Markdown + YAML frontmatter
fn generate_skill_md(
&self,
pattern: &pattern_detector::DetectedPattern,
skill_name: &str,
) -> String {
let tool_list = pattern
.tool_sequence
.iter()
.map(|t| format!(" - {}", t))
.collect::<Vec<_>>()
.join("\n");
let desc = if pattern.description.is_empty() {
format!(
"自动检测的工作流: {} (出现 {} 次)",
pattern.tool_sequence.join(""),
pattern.occurrence_count
)
} else {
pattern.description.clone()
};
let when_to_use = format!(
"当用户需要执行以下操作序列时: {}",
pattern.tool_sequence.join("")
);
format!(
r#"---
name: {name}
description: {desc}
version: "0.1.0"
context: inline
allowed-tools:
{tools}
when_to_use: {when}
user-invocable: true
disable-model-invocation: false
auto-generated: true
auto-generated-from: pattern-detector
confidence: {confidence}
occurrences: {occurrences}
---
# {name}
##
{desc}
##
Skill
{steps}
## 使
: {when}
Skill PatternDetector
"#,
name = skill_name,
desc = desc,
tools = tool_list,
when = when_to_use,
confidence = pattern.confidence,
occurrences = pattern.occurrence_count,
steps = pattern
.tool_sequence
.iter()
.enumerate()
.map(|(i, t)| format!("{}. `{}`", i + 1, t))
.collect::<Vec<_>>()
.join("\n"),
)
}
}
// ── Self-Improving Pipeline ───────────────────────────────────────────────
/// 自改进管道 — 一站式执行模式检测 + Skill 创建 + 质量审查。
///
/// 参考 Hermes-Agent 的 curator.py 设计。
/// 在 Agent 会话结束后或定时触发调用。
pub struct SelfImprovePipeline {
detector: pattern_detector::PatternDetector,
creator: SkillCreator,
curator: curator::Curator,
}
impl SelfImprovePipeline {
/// 创建自改进管道。
///
/// `skills_dir` — skills 目录路径
/// `known_fingerprints` — 已注册 skill 的指纹集合(防止重复创建)
pub fn new(skills_dir: PathBuf, known_fingerprints: std::collections::HashSet<String>) -> Self {
SelfImprovePipeline {
detector: pattern_detector::PatternDetector::new(known_fingerprints),
creator: SkillCreator::new(skills_dir.clone()),
curator: curator::Curator::new(skills_dir),
}
}
/// 执行完整的自改进流程。
///
/// 1. 扫描最近的 session检测重复模式
/// 2. 将高置信度模式创建为 Skill
/// 3. 分析现有 Skill 的质量并生成清理建议
///
/// 返回 (新建的 skill 名称, curator 报告)。
pub async fn run(
&self,
db: &sqlx::SqlitePool,
max_sessions: usize,
usage_stats: &HashMap<String, SkillUsageStat>,
skill_metas: &[SkillMeta],
) -> anyhow::Result<SelfImproveResult> {
// Step 1: 模式检测
let patterns = self
.detector
.scan(db, max_sessions, 1 /* 排除最近 1 小时 */)
.await?;
info!("[SelfImprove] 检测到 {} 个候选模式", patterns.len());
// Step 2: 创建 Skills
let created = self.creator.create_from_patterns(&patterns)?;
// Step 3: Curator 分析
let curator_report = self.curator.analyze(usage_stats, skill_metas);
Ok(SelfImproveResult {
patterns_found: patterns.len(),
skills_created: created,
curator_report,
})
}
/// 仅运行模式检测(不创建 skill
pub async fn detect_only(
&self,
db: &sqlx::SqlitePool,
max_sessions: usize,
) -> anyhow::Result<Vec<pattern_detector::DetectedPattern>> {
self.detector.scan(db, max_sessions, 1).await
}
/// 仅运行 curator 分析。
pub fn analyze_only(
&self,
usage_stats: &HashMap<String, SkillUsageStat>,
skill_metas: &[SkillMeta],
) -> curator::CuratorReport {
self.curator.analyze(usage_stats, skill_metas)
}
}
/// 自改进管道的结果
#[derive(Debug)]
pub struct SelfImproveResult {
/// 检测到的模式数量
pub patterns_found: usize,
/// 新建的 skill 名称列表
pub skills_created: Vec<String>,
/// Curator 质量报告
pub curator_report: curator::CuratorReport,
}
// ── Tests ──────────────────────────────────────────────────────────────────

View File

@ -1,4 +1,4 @@
// src/agent/skills.rs // src/agent/skills/mod.rs
// //
// 两层技能加载系统(参考 Claude Code src/skills/ + src/tools/SkillTool/ 设计): // 两层技能加载系统(参考 Claude Code src/skills/ + src/tools/SkillTool/ 设计):
// Layer 1 — system-reminder 注入:每轮动态列出 skill 名称(~20 tokens/skill // Layer 1 — system-reminder 注入:每轮动态列出 skill 名称(~20 tokens/skill
@ -31,10 +31,11 @@
// # Skill 正文 // # Skill 正文
// 详细内容... // 详细内容...
pub mod creator;
pub mod curator; pub mod curator;
pub mod pattern_detector; pub mod pattern_detector;
pub mod types;
use serde::Deserialize;
use std::collections::HashMap; use std::collections::HashMap;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::{Arc, RwLock}; use std::sync::{Arc, RwLock};
@ -43,111 +44,9 @@ use tracing::{info, warn};
// ── Frontmatter ──────────────────────────────────────────────────────────── // ── Frontmatter ────────────────────────────────────────────────────────────
/// Skill 的 YAML frontmatter 结构serde_yaml 解析)。 // Re-export 公共类型
/// 仅声明与平台相关的字段;未知字段自动忽略。 pub use creator::{SelfImprovePipeline, SelfImproveResult, SkillCreator};
#[derive(Debug, Clone, Deserialize, Default)] pub use types::{Skill, SkillFrontmatter, SkillMeta, SkillUsageStat};
pub struct SkillFrontmatter {
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub description: Option<String>,
/// 执行模式inline | fork
#[serde(default)]
pub context: Option<String>,
/// 工具白名单
#[serde(rename = "allowed-tools", default)]
pub allowed_tools: Option<Vec<String>>,
/// 推荐模型
#[serde(default)]
pub model: Option<String>,
/// 参数提示
#[serde(rename = "argument-hint", default)]
pub argument_hint: Option<String>,
/// 使用场景说明
#[serde(rename = "when_to_use", default)]
pub when_to_use: Option<String>,
/// 禁止模型通过 Skill tool 自动调用
#[serde(rename = "disable-model-invocation", default)]
pub disable_model_invocation: Option<bool>,
/// 用户是否可通过 /skill-name 手动调用
#[serde(rename = "user-invocable", default)]
pub user_invocable: Option<bool>,
/// 版本号
#[serde(default)]
pub version: Option<String>,
/// 条件激活的 glob 模式
#[serde(default)]
pub paths: Option<Vec<String>>,
/// fork 模式下的 agent 类型
#[serde(default)]
pub agent: Option<String>,
/// fork 模式下的 effort 级别
#[serde(default)]
pub effort: Option<String>,
/// 是否禁止 Curator 自动清理(参考 Hermes pinned skills
#[serde(default)]
pub pinned: Option<bool>,
}
impl SkillFrontmatter {
/// 校验必填/推荐字段,返回警告列表
pub fn validate(&self, skill_name: &str) -> Vec<String> {
let mut warnings = Vec::new();
if self.description.is_none() {
warnings.push(format!("Skill '{}' 缺少 description 字段", skill_name));
}
if let Some(ref ctx) = self.context {
if ctx != "inline" && ctx != "fork" {
warnings.push(format!(
"Skill '{}' 的 context 值无效 '{}',应为 inline 或 fork",
skill_name, ctx
));
}
}
warnings
}
}
// ── Skill Data Structures ──────────────────────────────────────────────────
/// Skill 元信息Layer 1出现在 skill 列表中)
#[derive(Debug, Clone)]
pub struct SkillMeta {
pub name: String,
pub description: String,
/// 执行模式inline / fork
pub context: Option<String>,
/// 工具白名单(空 Vec 表示无限制)
pub allowed_tools: Vec<String>,
/// 使用场景说明
pub when_to_use: Option<String>,
/// 是否禁止模型通过 Skill tool 调用
pub disable_model_invocation: bool,
/// 是否允许用户通过 /skill-name 手动调用
pub user_invocable: bool,
/// 条件激活的 glob 模式(空 Vec 表示始终激活)
pub paths: Vec<String>,
/// 是否禁止 Curator 自动清理(参考 Hermes pinned skills
pub pinned: bool,
}
/// 完整的 SkillLayer 2LLM 调用 load_skill 时注入)
#[derive(Debug, Clone)]
pub struct Skill {
pub meta: SkillMeta,
pub body: String,
/// Skill 所在目录,用于 ${SKILL_DIR} 变量替换
pub skill_dir: PathBuf,
}
// ── Skill Registry (Caching Layer) ─────────────────────────────────────────
/// Skill 使用统计
#[derive(Debug, Clone, Default)]
pub struct SkillUsageStat {
pub invoke_count: u64,
pub last_used_at: Option<chrono::DateTime<chrono::Utc>>,
}
/// Skill 注册表 — 缓存已加载的 skills支持 mtime 增量刷新。 /// Skill 注册表 — 缓存已加载的 skills支持 mtime 增量刷新。
/// ///
@ -677,255 +576,6 @@ pub fn substitute_variables(body: &str, skill_dir: &Path, session_id: Option<&st
/// ```ignore /// ```ignore
/// let creator = SkillCreator::new(skills_dir); /// let creator = SkillCreator::new(skills_dir);
/// let created = creator.create_from_pattern(&pattern)?; /// let created = creator.create_from_pattern(&pattern)?;
/// // created 包含新建的 skill 文件路径列表
/// ```
pub struct SkillCreator {
skills_dir: PathBuf,
}
impl SkillCreator {
pub fn new(skills_dir: PathBuf) -> Self {
SkillCreator { skills_dir }
}
/// 从检测到的模式创建 SKILL.md 文件。
///
/// 返回创建的 skill 名称列表。
/// 如果目标 skill 目录已存在则跳过(不覆盖已有 skill
pub fn create_from_patterns(
&self,
patterns: &[pattern_detector::DetectedPattern],
) -> std::io::Result<Vec<String>> {
let mut created = Vec::new();
for pattern in patterns {
if !pattern.is_confident() {
continue;
}
let skill_name = self.pattern_to_skill_name(pattern);
let skill_dir = self.skills_dir.join(&skill_name);
if skill_dir.exists() {
info!("[SkillCreator] Skill '{}' 已存在,跳过创建", skill_name);
continue;
}
// 创建 skill 目录
std::fs::create_dir_all(&skill_dir)?;
// 生成 SKILL.md 内容
let content = self.generate_skill_md(pattern, &skill_name);
let md_path = skill_dir.join("SKILL.md");
std::fs::write(&md_path, &content)?;
info!(
"[SkillCreator] 已从模式创建 Skill '{}': {}",
skill_name,
md_path.display()
);
created.push(skill_name);
}
Ok(created)
}
/// 将模式转换为 kebab-case skill 名称。
fn pattern_to_skill_name(&self, pattern: &pattern_detector::DetectedPattern) -> String {
// 取前三个工具名生成名称
let tokens: Vec<String> = pattern
.tool_sequence
.iter()
.take(3)
.map(|s| {
s.replace("search_", "")
.replace("get_", "")
.replace("download_", "dl-")
.replace("parse_", "parse-")
.replace("rag_", "rag-")
.replace("query_", "query-")
.replace("read_", "read-")
.replace("save_", "save-")
.replace("load_", "load-")
.replace("_", "-")
})
.collect();
format!("auto-{}", tokens.join("-"))
}
/// 生成 SKILL.md 内容Markdown + YAML frontmatter
fn generate_skill_md(
&self,
pattern: &pattern_detector::DetectedPattern,
skill_name: &str,
) -> String {
let tool_list = pattern
.tool_sequence
.iter()
.map(|t| format!(" - {}", t))
.collect::<Vec<_>>()
.join("\n");
let desc = if pattern.description.is_empty() {
format!(
"自动检测的工作流: {} (出现 {} 次)",
pattern.tool_sequence.join(""),
pattern.occurrence_count
)
} else {
pattern.description.clone()
};
let when_to_use = format!(
"当用户需要执行以下操作序列时: {}",
pattern.tool_sequence.join("")
);
format!(
r#"---
name: {name}
description: {desc}
version: "0.1.0"
context: inline
allowed-tools:
{tools}
when_to_use: {when}
user-invocable: true
disable-model-invocation: false
auto-generated: true
auto-generated-from: pattern-detector
confidence: {confidence}
occurrences: {occurrences}
---
# {name}
##
{desc}
##
Skill
{steps}
## 使
: {when}
Skill PatternDetector
"#,
name = skill_name,
desc = desc,
tools = tool_list,
when = when_to_use,
confidence = pattern.confidence,
occurrences = pattern.occurrence_count,
steps = pattern
.tool_sequence
.iter()
.enumerate()
.map(|(i, t)| format!("{}. `{}`", i + 1, t))
.collect::<Vec<_>>()
.join("\n"),
)
}
}
// ── Self-Improving Pipeline ───────────────────────────────────────────────
/// 自改进管道 — 一站式执行模式检测 + Skill 创建 + 质量审查。
///
/// 参考 Hermes-Agent 的 curator.py 设计。
/// 在 Agent 会话结束后或定时触发调用。
pub struct SelfImprovePipeline {
detector: pattern_detector::PatternDetector,
creator: SkillCreator,
curator: curator::Curator,
}
impl SelfImprovePipeline {
/// 创建自改进管道。
///
/// `skills_dir` — skills 目录路径
/// `known_fingerprints` — 已注册 skill 的指纹集合(防止重复创建)
pub fn new(skills_dir: PathBuf, known_fingerprints: std::collections::HashSet<String>) -> Self {
SelfImprovePipeline {
detector: pattern_detector::PatternDetector::new(known_fingerprints),
creator: SkillCreator::new(skills_dir.clone()),
curator: curator::Curator::new(skills_dir),
}
}
/// 执行完整的自改进流程。
///
/// 1. 扫描最近的 session检测重复模式
/// 2. 将高置信度模式创建为 Skill
/// 3. 分析现有 Skill 的质量并生成清理建议
///
/// 返回 (新建的 skill 名称, curator 报告)。
pub async fn run(
&self,
db: &sqlx::SqlitePool,
max_sessions: usize,
usage_stats: &HashMap<String, SkillUsageStat>,
skill_metas: &[SkillMeta],
) -> anyhow::Result<SelfImproveResult> {
// Step 1: 模式检测
let patterns = self
.detector
.scan(db, max_sessions, 1 /* 排除最近 1 小时 */)
.await?;
info!("[SelfImprove] 检测到 {} 个候选模式", patterns.len());
// Step 2: 创建 Skills
let created = self.creator.create_from_patterns(&patterns)?;
// Step 3: Curator 分析
let curator_report = self.curator.analyze(usage_stats, skill_metas);
Ok(SelfImproveResult {
patterns_found: patterns.len(),
skills_created: created,
curator_report,
})
}
/// 仅运行模式检测(不创建 skill
pub async fn detect_only(
&self,
db: &sqlx::SqlitePool,
max_sessions: usize,
) -> anyhow::Result<Vec<pattern_detector::DetectedPattern>> {
self.detector.scan(db, max_sessions, 1).await
}
/// 仅运行 curator 分析。
pub fn analyze_only(
&self,
usage_stats: &HashMap<String, SkillUsageStat>,
skill_metas: &[SkillMeta],
) -> curator::CuratorReport {
self.curator.analyze(usage_stats, skill_metas)
}
}
/// 自改进管道的结果
#[derive(Debug)]
pub struct SelfImproveResult {
/// 检测到的模式数量
pub patterns_found: usize,
/// 新建的 skill 名称列表
pub skills_created: Vec<String>,
/// Curator 质量报告
pub curator_report: curator::CuratorReport,
}
// ── Tests ──────────────────────────────────────────────────────────────────
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {

110
src/agent/skills/types.rs Normal file
View File

@ -0,0 +1,110 @@
// src/agent/skills/types.rs
//
// Skill 类型定义frontmatter、元数据、skill 结构、使用统计。
use serde::Deserialize;
use std::path::PathBuf;
#[derive(Debug, Clone, Deserialize, Default)]
pub struct SkillFrontmatter {
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub description: Option<String>,
/// 执行模式inline | fork
#[serde(default)]
pub context: Option<String>,
/// 工具白名单
#[serde(rename = "allowed-tools", default)]
pub allowed_tools: Option<Vec<String>>,
/// 推荐模型
#[serde(default)]
pub model: Option<String>,
/// 参数提示
#[serde(rename = "argument-hint", default)]
pub argument_hint: Option<String>,
/// 使用场景说明
#[serde(rename = "when_to_use", default)]
pub when_to_use: Option<String>,
/// 禁止模型通过 Skill tool 自动调用
#[serde(rename = "disable-model-invocation", default)]
pub disable_model_invocation: Option<bool>,
/// 用户是否可通过 /skill-name 手动调用
#[serde(rename = "user-invocable", default)]
pub user_invocable: Option<bool>,
/// 版本号
#[serde(default)]
pub version: Option<String>,
/// 条件激活的 glob 模式
#[serde(default)]
pub paths: Option<Vec<String>>,
/// fork 模式下的 agent 类型
#[serde(default)]
pub agent: Option<String>,
/// fork 模式下的 effort 级别
#[serde(default)]
pub effort: Option<String>,
/// 是否禁止 Curator 自动清理(参考 Hermes pinned skills
#[serde(default)]
pub pinned: Option<bool>,
}
impl SkillFrontmatter {
/// 校验必填/推荐字段,返回警告列表
pub fn validate(&self, skill_name: &str) -> Vec<String> {
let mut warnings = Vec::new();
if self.description.is_none() {
warnings.push(format!("Skill '{}' 缺少 description 字段", skill_name));
}
if let Some(ref ctx) = self.context {
if ctx != "inline" && ctx != "fork" {
warnings.push(format!(
"Skill '{}' 的 context 值无效 '{}',应为 inline 或 fork",
skill_name, ctx
));
}
}
warnings
}
}
// ── Skill Data Structures ──────────────────────────────────────────────────
/// Skill 元信息Layer 1出现在 skill 列表中)
#[derive(Debug, Clone)]
pub struct SkillMeta {
pub name: String,
pub description: String,
/// 执行模式inline / fork
pub context: Option<String>,
/// 工具白名单(空 Vec 表示无限制)
pub allowed_tools: Vec<String>,
/// 使用场景说明
pub when_to_use: Option<String>,
/// 是否禁止模型通过 Skill tool 调用
pub disable_model_invocation: bool,
/// 是否允许用户通过 /skill-name 手动调用
pub user_invocable: bool,
/// 条件激活的 glob 模式(空 Vec 表示始终激活)
pub paths: Vec<String>,
/// 是否禁止 Curator 自动清理(参考 Hermes pinned skills
pub pinned: bool,
}
/// 完整的 SkillLayer 2LLM 调用 load_skill 时注入)
#[derive(Debug, Clone)]
pub struct Skill {
pub meta: SkillMeta,
pub body: String,
/// Skill 所在目录,用于 ${SKILL_DIR} 变量替换
pub skill_dir: PathBuf,
}
// ── Skill Registry (Caching Layer) ─────────────────────────────────────────
/// Skill 使用统计
#[derive(Debug, Clone, Default)]
pub struct SkillUsageStat {
pub invoke_count: u64,
pub last_used_at: Option<chrono::DateTime<chrono::Utc>>,
}

View File

@ -1,215 +1,17 @@
// src/clients/llm.rs // src/clients/llm/chat.rs
//
// ChatCompleter trait + LlmClient 实现(对话补全、流式对话、图片分析)。
use async_trait::async_trait; use async_trait::async_trait;
use futures_util::StreamExt; use futures_util::StreamExt;
use reqwest::Client; use reqwest::Client;
use serde::{Deserialize, Serialize}; use serde::Deserialize;
use std::sync::Arc; use std::sync::Arc;
use tracing::error; use tracing::error;
/// 消息角色枚举OpenAI 兼容) use super::types::{
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] ChatMessage, CompletionResult, FunctionCall, StreamEvent, TokenUsage, ToolCall, ToolDefinition,
#[serde(rename_all = "lowercase")] };
pub enum MessageRole {
System,
User,
Assistant,
Tool,
}
/// 工具调用结构体
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolCall {
pub id: String,
#[serde(rename = "type")]
pub call_type: String,
pub function: FunctionCall,
}
/// 函数调用详情
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FunctionCall {
pub name: String,
pub arguments: String,
}
// ── ChatMessage ─────────────────────────────────────────────────────────
/// 标准对话消息OpenAI 兼容格式)。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChatMessage {
pub role: MessageRole,
#[serde(skip_serializing_if = "Option::is_none")]
pub content: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_call_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_calls: Option<Vec<ToolCall>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub reasoning_content: Option<String>,
}
impl ChatMessage {
/// 获取纯文本内容。
pub fn text(&self) -> Option<&str> {
self.content.as_deref()
}
/// 创建系统消息
pub fn system(content: impl Into<String>) -> Self {
ChatMessage {
role: MessageRole::System,
content: Some(content.into()),
tool_call_id: None,
tool_calls: None,
name: None,
reasoning_content: None,
}
}
/// 创建用户消息
pub fn user(content: impl Into<String>) -> Self {
ChatMessage {
role: MessageRole::User,
content: Some(content.into()),
tool_call_id: None,
tool_calls: None,
name: None,
reasoning_content: None,
}
}
/// 创建助手消息
pub fn assistant(content: impl Into<String>) -> Self {
ChatMessage {
role: MessageRole::Assistant,
content: Some(content.into()),
tool_call_id: None,
tool_calls: None,
name: None,
reasoning_content: None,
}
}
/// 创建带工具调用的助手消息
pub fn assistant_with_tool_calls(content: Option<String>, tool_calls: Vec<ToolCall>) -> Self {
ChatMessage {
role: MessageRole::Assistant,
content,
tool_call_id: None,
tool_calls: Some(tool_calls),
name: None,
reasoning_content: None,
}
}
/// 创建带工具调用和推理内容的助手消息
pub fn assistant_with_reasoning(
content: Option<String>,
reasoning_content: Option<String>,
tool_calls: Option<Vec<ToolCall>>,
) -> Self {
ChatMessage {
role: MessageRole::Assistant,
content,
tool_call_id: None,
tool_calls,
name: None,
reasoning_content,
}
}
/// 创建工具结果消息
pub fn tool_result(tool_call_id: impl Into<String>, content: impl Into<String>) -> Self {
ChatMessage {
role: MessageRole::Tool,
content: Some(content.into()),
tool_call_id: Some(tool_call_id.into()),
tool_calls: None,
name: None,
reasoning_content: None,
}
}
}
/// 工具定义(声明给大模型可用的工具)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolDefinition {
#[serde(rename = "type")]
pub tool_type: String,
pub function: FunctionDef,
}
/// 函数定义
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FunctionDef {
pub name: String,
pub description: String,
pub parameters: serde_json::Value,
}
impl ToolDefinition {
pub fn new(
name: impl Into<String>,
description: impl Into<String>,
parameters: serde_json::Value,
) -> Self {
ToolDefinition {
tool_type: "function".to_string(),
function: FunctionDef {
name: name.into(),
description: description.into(),
parameters,
},
}
}
}
/// 非流式对话结果
#[derive(Debug, Clone)]
pub struct CompletionResult {
/// 回复内容(纯文本)
pub content: Option<String>,
/// 推理内容(模型原生 Thinking
pub reasoning_content: Option<String>,
/// 工具调用列表
pub tool_calls: Vec<ToolCall>,
/// Token 使用统计
pub usage: TokenUsage,
}
/// Token 使用统计
#[derive(Debug, Clone, Default, Deserialize)]
pub struct TokenUsage {
pub prompt_tokens: u32,
pub completion_tokens: u32,
pub total_tokens: u32,
}
/// 流式 SSE 事件
#[derive(Debug, Clone)]
pub enum StreamEvent {
/// 文本增量
TextDelta(String),
/// 推理内容增量
ReasoningDelta(String),
/// 工具调用增量碎片
ToolCallDelta {
index: usize,
id: Option<String>,
name: Option<String>,
arguments_delta: String,
},
/// 工具调用完成后还原的完整 ToolCall 列表
ToolCallsComplete(Vec<ToolCall>),
/// Token 使用统计
Usage(TokenUsage),
/// 消息结束
Done,
/// 错误
Error(String),
}
/// LLM 对话补全的抽象 trait支持真实客户端和测试 mock。 /// LLM 对话补全的抽象 trait支持真实客户端和测试 mock。
#[async_trait] #[async_trait]
@ -754,183 +556,12 @@ impl LlmClient {
} }
} }
/// 向量嵌入的抽象 trait支持真实客户端和测试 mock。
#[async_trait]
pub trait Embedder: Send + Sync {
async fn embed(&self, text: &str) -> anyhow::Result<Vec<f32>>;
/// 批量嵌入多条文本。
///
/// 默认实现逐条调用 `embed`,真实客户端(如 `EmbeddingClient`)应覆写为
/// 单次 HTTP 批量请求以显著降低往返延迟OpenAI embeddings API 原生支持
/// `input` 传字符串数组)。
async fn embed_batch(&self, texts: &[String]) -> anyhow::Result<Vec<Vec<f32>>> {
let mut out = Vec::with_capacity(texts.len());
for t in texts {
out.push(self.embed(t).await?);
}
Ok(out)
}
}
#[derive(Clone, Debug)]
pub struct EmbeddingClient {
api_key: String,
api_base: String,
model: String,
client: Client,
}
#[async_trait]
impl Embedder for EmbeddingClient {
async fn embed(&self, text: &str) -> anyhow::Result<Vec<f32>> {
self.create_embedding(text).await
}
/// 批量嵌入:分片发送多条文本,按返回的 index 对齐排序。
///
/// 分片大小默认 32避免单次请求超出服务商的 Payload/Token/输入项数限制。
async fn embed_batch(&self, texts: &[String]) -> anyhow::Result<Vec<Vec<f32>>> {
if texts.is_empty() {
return Ok(Vec::new());
}
// 单条时走原有路径
if texts.len() == 1 {
return Ok(vec![self.create_embedding(&texts[0]).await?]);
}
const BATCH_SIZE: usize = 32;
let url = format!("{}/embeddings", self.api_base);
#[derive(Deserialize)]
struct EmbeddingData {
embedding: Vec<f32>,
index: usize,
}
#[derive(Deserialize)]
struct EmbeddingResponse {
data: Vec<EmbeddingData>,
}
let mut all_embeddings: Vec<Vec<f32>> = Vec::with_capacity(texts.len());
// 分片处理:每批 BATCH_SIZE 条,避免单请求过大触发 413/429
for chunk in texts.chunks(BATCH_SIZE) {
let payload = serde_json::json!({
"model": self.model(),
"input": chunk,
});
let response = self
.client
.post(&url)
.header("Authorization", format!("Bearer {}", self.api_key))
.header("Content-Type", "application/json")
.json(&payload)
.send()
.await?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
error!(
"Embedding 批量接口调用失败: 状态码={}, 报错={}",
status, body
);
return Err(anyhow::anyhow!("向量接口返回错误状态: {}", status));
}
let res_data: EmbeddingResponse = response.json().await?;
// OpenAI 不保证 data 数组顺序,按 index 排序index 在每批内 0-based
let mut indexed: Vec<(usize, Vec<f32>)> = res_data
.data
.into_iter()
.map(|d| (d.index, d.embedding))
.collect();
indexed.sort_by_key(|(i, _)| *i);
if indexed.len() != chunk.len() {
return Err(anyhow::anyhow!(
"向量批量接口返回数量({})与输入({})不一致",
indexed.len(),
chunk.len()
));
}
all_embeddings.extend(indexed.into_iter().map(|(_, v)| v));
}
Ok(all_embeddings)
}
}
impl EmbeddingClient {
pub fn new(api_key: String, api_base: String, model: String) -> Self {
EmbeddingClient {
api_key,
api_base,
model,
client: Client::new(),
}
}
pub fn model(&self) -> &str {
&self.model
}
pub fn api_base(&self) -> &str {
&self.api_base
}
pub fn api_key(&self) -> &str {
&self.api_key
}
pub async fn create_embedding(&self, text: &str) -> anyhow::Result<Vec<f32>> {
let url = format!("{}/embeddings", self.api_base);
let payload = serde_json::json!({
"model": self.model(),
"input": text,
});
let response = self
.client
.post(&url)
.header("Authorization", format!("Bearer {}", self.api_key))
.header("Content-Type", "application/json")
.json(&payload)
.send()
.await?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
error!("Embedding 接口调用失败: 状态码={}, 报错={}", status, body);
return Err(anyhow::anyhow!("向量接口返回错误状态: {}", status));
}
#[derive(Deserialize)]
struct EmbeddingData {
embedding: Vec<f32>,
}
#[derive(Deserialize)]
struct EmbeddingResponse {
data: Vec<EmbeddingData>,
}
let res_data: EmbeddingResponse = response.json().await?;
if let Some(data) = res_data.data.first() {
Ok(data.embedding.clone())
} else {
Err(anyhow::anyhow!("向量接口返回空向量数据"))
}
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::clients::llm::types::{
ChatMessage, FunctionCall, MessageRole, ToolCall, ToolDefinition,
};
#[test] #[test]
fn test_llm_client_initialization() { fn test_llm_client_initialization() {
@ -940,15 +571,6 @@ mod tests {
assert_eq!(client.model(), "model"); assert_eq!(client.model(), "model");
} }
#[test]
fn test_embedding_client_initialization() {
let client =
EmbeddingClient::new("key".to_string(), "base".to_string(), "model".to_string());
assert_eq!(client.api_key(), "key");
assert_eq!(client.api_base(), "base");
assert_eq!(client.model(), "model");
}
#[test] #[test]
fn test_chat_message_construction() { fn test_chat_message_construction() {
// 测试系统消息构造 // 测试系统消息构造
@ -1109,7 +731,7 @@ mod tests {
"测试向量模型: {} (API Base: {})", "测试向量模型: {} (API Base: {})",
config.embedding_model, config.embedding_api_base config.embedding_model, config.embedding_api_base
); );
let embedding_client = EmbeddingClient::new( let embedding_client = crate::clients::llm::EmbeddingClient::new(
config.embedding_api_key.clone(), config.embedding_api_key.clone(),
config.embedding_api_base.clone(), config.embedding_api_base.clone(),
config.embedding_model.clone(), config.embedding_model.clone(),

View File

@ -0,0 +1,196 @@
// src/clients/llm/embedding.rs
//
// Embedder trait + EmbeddingClient 实现(文本向量化)。
use async_trait::async_trait;
use reqwest::Client;
use serde::Deserialize;
use tracing::error;
/// 向量嵌入的抽象 trait支持真实客户端和测试 mock。
#[async_trait]
pub trait Embedder: Send + Sync {
async fn embed(&self, text: &str) -> anyhow::Result<Vec<f32>>;
/// 批量嵌入多条文本。
///
/// 默认实现逐条调用 `embed`,真实客户端(如 `EmbeddingClient`)应覆写为
/// 单次 HTTP 批量请求以显著降低往返延迟OpenAI embeddings API 原生支持
/// `input` 传字符串数组)。
async fn embed_batch(&self, texts: &[String]) -> anyhow::Result<Vec<Vec<f32>>> {
let mut out = Vec::with_capacity(texts.len());
for t in texts {
out.push(self.embed(t).await?);
}
Ok(out)
}
}
#[derive(Clone, Debug)]
pub struct EmbeddingClient {
api_key: String,
api_base: String,
model: String,
client: Client,
}
#[async_trait]
impl Embedder for EmbeddingClient {
async fn embed(&self, text: &str) -> anyhow::Result<Vec<f32>> {
self.create_embedding(text).await
}
/// 批量嵌入:分片发送多条文本,按返回的 index 对齐排序。
///
/// 分片大小默认 32避免单次请求超出服务商的 Payload/Token/输入项数限制。
async fn embed_batch(&self, texts: &[String]) -> anyhow::Result<Vec<Vec<f32>>> {
if texts.is_empty() {
return Ok(Vec::new());
}
// 单条时走原有路径
if texts.len() == 1 {
return Ok(vec![self.create_embedding(&texts[0]).await?]);
}
const BATCH_SIZE: usize = 32;
let url = format!("{}/embeddings", self.api_base);
#[derive(Deserialize)]
struct EmbeddingData {
embedding: Vec<f32>,
index: usize,
}
#[derive(Deserialize)]
struct EmbeddingResponse {
data: Vec<EmbeddingData>,
}
let mut all_embeddings: Vec<Vec<f32>> = Vec::with_capacity(texts.len());
// 分片处理:每批 BATCH_SIZE 条,避免单请求过大触发 413/429
for chunk in texts.chunks(BATCH_SIZE) {
let payload = serde_json::json!({
"model": self.model(),
"input": chunk,
});
let response = self
.client
.post(&url)
.header("Authorization", format!("Bearer {}", self.api_key))
.header("Content-Type", "application/json")
.json(&payload)
.send()
.await?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
error!(
"Embedding 批量接口调用失败: 状态码={}, 报错={}",
status, body
);
return Err(anyhow::anyhow!("向量接口返回错误状态: {}", status));
}
let res_data: EmbeddingResponse = response.json().await?;
// OpenAI 不保证 data 数组顺序,按 index 排序index 在每批内 0-based
let mut indexed: Vec<(usize, Vec<f32>)> = res_data
.data
.into_iter()
.map(|d| (d.index, d.embedding))
.collect();
indexed.sort_by_key(|(i, _)| *i);
if indexed.len() != chunk.len() {
return Err(anyhow::anyhow!(
"向量批量接口返回数量({})与输入({})不一致",
indexed.len(),
chunk.len()
));
}
all_embeddings.extend(indexed.into_iter().map(|(_, v)| v));
}
Ok(all_embeddings)
}
}
impl EmbeddingClient {
pub fn new(api_key: String, api_base: String, model: String) -> Self {
EmbeddingClient {
api_key,
api_base,
model,
client: Client::new(),
}
}
pub fn model(&self) -> &str {
&self.model
}
pub fn api_base(&self) -> &str {
&self.api_base
}
pub fn api_key(&self) -> &str {
&self.api_key
}
pub async fn create_embedding(&self, text: &str) -> anyhow::Result<Vec<f32>> {
let url = format!("{}/embeddings", self.api_base);
let payload = serde_json::json!({
"model": self.model(),
"input": text,
});
let response = self
.client
.post(&url)
.header("Authorization", format!("Bearer {}", self.api_key))
.header("Content-Type", "application/json")
.json(&payload)
.send()
.await?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
error!("Embedding 接口调用失败: 状态码={}, 报错={}", status, body);
return Err(anyhow::anyhow!("向量接口返回错误状态: {}", status));
}
#[derive(Deserialize)]
struct EmbeddingData {
embedding: Vec<f32>,
}
#[derive(Deserialize)]
struct EmbeddingResponse {
data: Vec<EmbeddingData>,
}
let res_data: EmbeddingResponse = response.json().await?;
if let Some(data) = res_data.data.first() {
Ok(data.embedding.clone())
} else {
Err(anyhow::anyhow!("向量接口返回空向量数据"))
}
}
}
#[cfg(test)]
mod tests {
use super::EmbeddingClient;
#[test]
fn test_embedding_client_initialization() {
let client =
EmbeddingClient::new("key".to_string(), "base".to_string(), "model".to_string());
assert_eq!(client.api_key(), "key");
assert_eq!(client.api_base(), "base");
assert_eq!(client.model(), "model");
}
}

17
src/clients/llm/mod.rs Normal file
View File

@ -0,0 +1,17 @@
// src/clients/llm/mod.rs
//
// LLM 客户端模块:消息类型、对话补全、文本向量化。
//
// 外部引用路径不变use crate::clients::llm::{LlmClient, ChatMessage, ...}
mod chat;
mod embedding;
mod types;
// Re-export 所有公共类型,保持外部 API 不变
pub use chat::{ChatCompleter, LlmClient};
pub use embedding::{Embedder, EmbeddingClient};
pub use types::{
ChatMessage, CompletionResult, FunctionCall, FunctionDef, MessageRole, StreamEvent, TokenUsage,
ToolCall, ToolDefinition,
};

210
src/clients/llm/types.rs Normal file
View File

@ -0,0 +1,210 @@
// src/clients/llm/types.rs
//
// LLM 消息与工具的类型定义OpenAI 兼容格式)。
use serde::{Deserialize, Serialize};
/// 消息角色枚举OpenAI 兼容)
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum MessageRole {
System,
User,
Assistant,
Tool,
}
/// 工具调用结构体
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolCall {
pub id: String,
#[serde(rename = "type")]
pub call_type: String,
pub function: FunctionCall,
}
/// 函数调用详情
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FunctionCall {
pub name: String,
pub arguments: String,
}
// ── ChatMessage ─────────────────────────────────────────────────────────
/// 标准对话消息OpenAI 兼容格式)。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChatMessage {
pub role: MessageRole,
#[serde(skip_serializing_if = "Option::is_none")]
pub content: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_call_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_calls: Option<Vec<ToolCall>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub reasoning_content: Option<String>,
}
impl ChatMessage {
/// 获取纯文本内容。
pub fn text(&self) -> Option<&str> {
self.content.as_deref()
}
/// 创建系统消息
pub fn system(content: impl Into<String>) -> Self {
ChatMessage {
role: MessageRole::System,
content: Some(content.into()),
tool_call_id: None,
tool_calls: None,
name: None,
reasoning_content: None,
}
}
/// 创建用户消息
pub fn user(content: impl Into<String>) -> Self {
ChatMessage {
role: MessageRole::User,
content: Some(content.into()),
tool_call_id: None,
tool_calls: None,
name: None,
reasoning_content: None,
}
}
/// 创建助手消息
pub fn assistant(content: impl Into<String>) -> Self {
ChatMessage {
role: MessageRole::Assistant,
content: Some(content.into()),
tool_call_id: None,
tool_calls: None,
name: None,
reasoning_content: None,
}
}
/// 创建带工具调用的助手消息
pub fn assistant_with_tool_calls(content: Option<String>, tool_calls: Vec<ToolCall>) -> Self {
ChatMessage {
role: MessageRole::Assistant,
content,
tool_call_id: None,
tool_calls: Some(tool_calls),
name: None,
reasoning_content: None,
}
}
/// 创建带工具调用和推理内容的助手消息
pub fn assistant_with_reasoning(
content: Option<String>,
reasoning_content: Option<String>,
tool_calls: Option<Vec<ToolCall>>,
) -> Self {
ChatMessage {
role: MessageRole::Assistant,
content,
tool_call_id: None,
tool_calls,
name: None,
reasoning_content,
}
}
/// 创建工具结果消息
pub fn tool_result(tool_call_id: impl Into<String>, content: impl Into<String>) -> Self {
ChatMessage {
role: MessageRole::Tool,
content: Some(content.into()),
tool_call_id: Some(tool_call_id.into()),
tool_calls: None,
name: None,
reasoning_content: None,
}
}
}
/// 工具定义(声明给大模型可用的工具)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolDefinition {
#[serde(rename = "type")]
pub tool_type: String,
pub function: FunctionDef,
}
/// 函数定义
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FunctionDef {
pub name: String,
pub description: String,
pub parameters: serde_json::Value,
}
impl ToolDefinition {
pub fn new(
name: impl Into<String>,
description: impl Into<String>,
parameters: serde_json::Value,
) -> Self {
ToolDefinition {
tool_type: "function".to_string(),
function: FunctionDef {
name: name.into(),
description: description.into(),
parameters,
},
}
}
}
/// 非流式对话结果
#[derive(Debug, Clone)]
pub struct CompletionResult {
/// 回复内容(纯文本)
pub content: Option<String>,
/// 推理内容(模型原生 Thinking
pub reasoning_content: Option<String>,
/// 工具调用列表
pub tool_calls: Vec<ToolCall>,
/// Token 使用统计
pub usage: TokenUsage,
}
/// Token 使用统计
#[derive(Debug, Clone, Default, Deserialize)]
pub struct TokenUsage {
pub prompt_tokens: u32,
pub completion_tokens: u32,
pub total_tokens: u32,
}
/// 流式 SSE 事件
#[derive(Debug, Clone)]
pub enum StreamEvent {
/// 文本增量
TextDelta(String),
/// 推理内容增量
ReasoningDelta(String),
/// 工具调用增量碎片
ToolCallDelta {
index: usize,
id: Option<String>,
name: Option<String>,
arguments_delta: String,
},
/// 工具调用完成后还原的完整 ToolCall 列表
ToolCallsComplete(Vec<ToolCall>),
/// Token 使用统计
Usage(TokenUsage),
/// 消息结束
Done,
/// 错误
Error(String),
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,238 @@
// src/services/batch/asset/helpers.rs
//
// 批量处理各步骤的独立函数,从 process_single_bibcode 提取,
// 降低单文件行数并提高可测试性。
use std::sync::atomic::{AtomicI32, Ordering};
use std::sync::Arc;
use sqlx::SqlitePool;
use tokio::sync::Mutex;
use crate::services::download::Downloader;
use crate::Config;
use super::{AssetBatchStatus, BatchAction};
/// 执行单篇文献的下载步骤。
///
/// 若成功下载 PDF 或 HTML 任一格式,更新 `pdf_path` / `html_path` 并写回数据库。
/// 返回 true 表示下载成功至少一个文件false 表示全部失败。
#[allow(clippy::too_many_arguments)]
pub(super) async fn run_download_step(
bibcode: &str,
action: BatchAction,
db: &SqlitePool,
config: &Config,
downloader: &Arc<Downloader>,
status: &Arc<Mutex<AssetBatchStatus>>,
dl_count: &AtomicI32,
dl_failed_count: &AtomicI32,
arxiv_id: &str,
doi: &str,
pdf_path: &mut Option<String>,
html_path: &mut Option<String>,
) -> bool {
// 仅当 action 包含下载时执行
if action != BatchAction::Download && action != BatchAction::All {
return true; // 无需下载,视为"成功"
}
let is_pdf_exist = pdf_path
.as_ref()
.map(|p| config.library_dir.join(p).exists())
.unwrap_or(false);
let is_html_exist = html_path
.as_ref()
.map(|p| config.library_dir.join(p).exists())
.unwrap_or(false);
if is_pdf_exist || is_html_exist {
{
let mut s = status.lock().await;
s.add_log(format!(
"文献 {} 本地已存在 PDF 或 HTML跳过下载。",
bibcode
));
}
let _c = dl_count.fetch_add(1, Ordering::SeqCst) + 1;
{
let mut s = status.lock().await;
s.downloaded = _c;
}
return true;
}
// 需要执行下载
{
let mut s = status.lock().await;
s.add_log(format!("文献 {} 本地无 PDF/HTML开始下载...", bibcode));
}
let (pdf_res, html_res) = if !arxiv_id.is_empty() {
let res = downloader
.download_arxiv_direct(arxiv_id, &config.library_dir)
.await;
if res.0.is_ok() || res.1.is_ok() {
res
} else {
{
let mut s = status.lock().await;
s.add_log(format!(
"文献 {} arXiv 通道下载失败,回退尝试 ADS/出版商下载...",
bibcode
));
}
let doi_opt = if !doi.is_empty() { Some(doi) } else { None };
downloader
.download_paper(bibcode, doi_opt, &config.library_dir)
.await
}
} else {
let doi_opt = if !doi.is_empty() { Some(doi) } else { None };
downloader
.download_paper(bibcode, doi_opt, &config.library_dir)
.await
};
if pdf_res.is_ok() || html_res.is_ok() {
let pdf_rel = match pdf_res {
Ok(p) => Some(
p.strip_prefix(&config.library_dir)
.unwrap_or(&p)
.to_string_lossy()
.to_string(),
),
Err(_) => None,
};
let html_rel = match html_res {
Ok(p) => Some(
p.strip_prefix(&config.library_dir)
.unwrap_or(&p)
.to_string_lossy()
.to_string(),
),
Err(_) => None,
};
*pdf_path = pdf_rel.clone();
*html_path = html_rel.clone();
let _ = sqlx::query("UPDATE papers SET pdf_path = ?, html_path = ? WHERE bibcode = ?")
.bind(pdf_rel)
.bind(html_rel)
.bind(bibcode)
.execute(db)
.await;
let _c = dl_count.fetch_add(1, Ordering::SeqCst) + 1;
{
let mut s = status.lock().await;
s.downloaded = _c;
s.add_log(format!("文献 {} 下载成功!", bibcode));
}
true
} else {
let _f = dl_failed_count.fetch_add(1, Ordering::SeqCst) + 1;
let mut s = status.lock().await;
s.download_failed = _f;
let pdf_err = match pdf_res {
Err(e) => format!("error: {}", e),
_ => "error: 未知错误".to_string(),
};
let html_err = match html_res {
Err(e) => format!("error: {}", e),
_ => "error: 未知错误".to_string(),
};
s.add_log(format!(
"文献 {} 下载失败。PDF: {}, HTML: {}",
bibcode, pdf_err, html_err
));
let _ = sqlx::query("UPDATE papers SET pdf_path = ?, html_path = ? WHERE bibcode = ?")
.bind(&pdf_err)
.bind(&html_err)
.bind(bibcode)
.execute(db)
.await;
false
}
}
/// 生成文献的源链接 URL优先 ADS 标准 bibcode其次 arXiv
pub(super) fn build_source_url(bibcode: &str, arxiv_id: &str) -> String {
if bibcode.len() == 19 {
format!("https://ui.adsabs.harvard.edu/abs/{}/abstract", bibcode)
} else if !arxiv_id.is_empty() {
format!(
"https://ui.adsabs.harvard.edu/abs/arXiv:{}/abstract",
arxiv_id
)
} else {
format!("https://ui.adsabs.harvard.edu/abs/{}/abstract", bibcode)
}
}
/// 为已解析的 Markdown 内容构建 YAML frontmatter 头部。
pub(super) async fn build_markdown_with_frontmatter(
db: &SqlitePool,
bibcode: &str,
md_body: &str,
source_url: &str,
) -> Option<String> {
use sqlx::Row;
let row =
sqlx::query("SELECT title, authors, pub, year, keywords FROM papers WHERE bibcode = ?")
.bind(bibcode)
.fetch_optional(db)
.await
.ok()??;
let title: String = row.get(0);
let authors_json: String = row.get(1);
let pub_journal: String = row.get(2);
let year: String = row.get(3);
let keywords_json: String = row.get(4);
let authors: Vec<String> = serde_json::from_str(&authors_json).unwrap_or_default();
let keywords: Vec<String> = serde_json::from_str(&keywords_json).unwrap_or_default();
let front_matter = format!(
"---\ntitle: {}\nauthor: [{}]\npublisher: {}\nsource: \"{}\"\ndate: \"{}\"\ntags: \"{}\"\n---\n\n",
serde_json::to_string(&title).unwrap_or_else(|_| format!("\"{}\"", title)),
authors
.iter()
.map(|a| format!("\"{}\"", a))
.collect::<Vec<_>>()
.join(", "),
serde_json::to_string(&pub_journal)
.unwrap_or_else(|_| format!("\"{}\"", pub_journal)),
source_url,
year,
keywords.join(",")
);
Some(format!("{}{}", front_matter, md_body))
}
/// 将 Markdown 内容写入 library_dir 并更新数据库 markdown_path。
pub(super) fn write_markdown_and_update_db(
config: &Config,
bibcode: &str,
content: &str,
) -> Option<String> {
let md_filename = format!("{}.md", bibcode);
let md_dest = config.library_dir.join("Markdown").join(&md_filename);
if let Some(parent) = md_dest.parent() {
let _ = std::fs::create_dir_all(parent);
}
if std::fs::write(&md_dest, content).is_ok() {
Some(format!("Markdown/{}", md_filename))
} else {
None
}
}

View File

@ -0,0 +1,464 @@
// src/services/batch/asset/mod.rs
//
// 批量资产处理:状态管理、操作类型、任务编排。
// 子模块process单篇处理编排、helpers步骤辅助函数
use serde::{Deserialize, Serialize};
use sqlx::SqlitePool;
use std::collections::VecDeque;
use std::sync::atomic::AtomicI32;
use std::sync::Arc;
use tokio::sync::Mutex;
use tracing::info;
use crate::clients::qiniu::QiniuClient;
use crate::services::download::Downloader;
use crate::Config;
mod helpers;
mod process;
/// 批量处理的并发度(同时处理的文献数)。
/// 保守取 3与 MinerU 解析阶段的 semaphore 一致SQLite WAL 模式下写压力可控。
pub(super) const BATCH_CONCURRENCY: usize = 3;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum BatchAction {
Download,
Parse,
Translate,
Embed,
Target,
All,
}
#[derive(Debug, Clone, Serialize)]
pub struct AssetBatchStatus {
pub active: bool,
pub total: i32,
pub downloaded: i32,
pub parsed: i32,
pub download_failed: i32,
pub parse_failed: i32,
pub current_bibcode: String,
pub logs: VecDeque<String>,
pub action: Option<BatchAction>,
}
impl Default for AssetBatchStatus {
fn default() -> Self {
Self::new()
}
}
impl AssetBatchStatus {
pub fn new() -> Self {
AssetBatchStatus {
active: false,
total: 0,
downloaded: 0,
parsed: 0,
download_failed: 0,
parse_failed: 0,
current_bibcode: String::new(),
logs: VecDeque::new(),
action: None,
}
}
pub fn add_log(&mut self, log: String) {
info!("{}", log);
// 保留最新的100条日志VecDeque 的 pop_front 为 O(1)
self.logs.push_back(log);
while self.logs.len() > 100 {
self.logs.pop_front();
}
}
}
pub struct AssetBatch;
impl AssetBatch {
/// 启动后台批量下载与结构化解析任务
#[allow(clippy::too_many_arguments)]
pub fn start_process(
db: SqlitePool,
config: Config,
downloader: Arc<Downloader>,
qiniu: Arc<QiniuClient>,
dict: Arc<crate::services::translation::Dictionary>,
action: BatchAction,
bibcodes: Vec<String>,
status: Arc<Mutex<AssetBatchStatus>>,
) {
tokio::spawn(async move {
let llm_client = crate::clients::llm::LlmClient::new(
config.llm_medium_api_key.clone(),
config.llm_medium_api_base.clone(),
config.llm_medium_model.clone(),
);
let embedding_client = crate::clients::llm::EmbeddingClient::new(
config.embedding_api_key.clone(),
config.embedding_api_base.clone(),
config.embedding_model.clone(),
);
let total = bibcodes.len() as i32;
{
let mut s = status.lock().await;
s.active = true;
s.total = total;
s.downloaded = 0;
s.parsed = 0;
s.download_failed = 0;
s.parse_failed = 0;
s.current_bibcode = String::new();
s.logs.clear();
s.action = Some(action);
let action_desc = match action {
BatchAction::Download => "下载",
BatchAction::Parse => "解析",
BatchAction::Translate => "翻译",
BatchAction::Embed => "向量化",
BatchAction::Target => "天体识别",
BatchAction::All => "下载与解析",
};
s.add_log(format!(
"批量{}任务启动,共 {} 篇文献需处理。",
action_desc, total
));
}
let dl_count = Arc::new(AtomicI32::new(0));
let dl_failed_count = Arc::new(AtomicI32::new(0));
let semaphore = Arc::new(tokio::sync::Semaphore::new(BATCH_CONCURRENCY));
let mut join_handles = Vec::new();
for bibcode in bibcodes {
// 每次循环前检查是否被外部停止了active 设为 false
{
let s = status.lock().await;
if !s.active {
info!("收到停止指令,批量处理任务终止。");
break;
}
}
// 克隆共享资源给并发 worker
let db_c = db.clone();
let config_c = config.clone();
let downloader_c = downloader.clone();
let qiniu_c = qiniu.clone();
let dict_c = dict.clone();
let status_c = status.clone();
let sem_c = semaphore.clone();
let dl_count_c = dl_count.clone();
let dl_failed_count_c = dl_failed_count.clone();
let llm_client_c = llm_client.clone();
let embedding_client_c = embedding_client.clone();
join_handles.push(tokio::spawn(async move {
let _permit = sem_c.acquire().await.unwrap();
process::process_single_bibcode(
bibcode,
action,
&db_c,
&config_c,
&downloader_c,
&qiniu_c,
&dict_c,
&llm_client_c,
&embedding_client_c,
&status_c,
&dl_count_c,
&dl_failed_count_c,
)
.await;
}));
}
// 等待所有并发任务完成
for handle in join_handles {
let _ = handle.await;
}
{
let mut s = status.lock().await;
s.active = false;
let action_desc = match action {
BatchAction::Download => "下载",
BatchAction::Parse => "解析",
BatchAction::Translate => "翻译",
BatchAction::Embed => "向量化",
BatchAction::Target => "天体识别",
BatchAction::All => "下载与解析",
};
s.add_log(format!("批量{}任务顺利完成!", action_desc));
}
});
}
}
#[cfg(test)]
mod tests {
use super::*;
use sqlx::sqlite::SqlitePoolOptions;
use sqlx::Row;
use std::fs;
#[tokio::test]
async fn test_process_status_log_rotation() {
let mut status = AssetBatchStatus::new();
assert!(!status.active);
for i in 0..150 {
status.add_log(format!("log {}", i));
}
assert_eq!(status.logs.len(), 100);
assert_eq!(status.logs[0], "log 50");
assert_eq!(status.logs[99], "log 149");
}
#[tokio::test]
async fn test_bulk_processor_already_exists() -> anyhow::Result<()> {
let pool = SqlitePoolOptions::new()
.max_connections(1)
.connect("sqlite::memory:")
.await?;
// 运行迁移
sqlx::migrate!("./migrations").run(&pool).await?;
// 创建临时目录
let test_id = rand::random::<u32>();
let temp_dir = std::env::temp_dir().join(format!("astro_research_test_{}", test_id));
fs::create_dir_all(&temp_dir)?;
// 准备子目录
let pdf_dir = temp_dir.join("PDF");
let html_dir = temp_dir.join("HTML");
let md_dir = temp_dir.join("Markdown");
fs::create_dir_all(&pdf_dir)?;
fs::create_dir_all(&html_dir)?;
fs::create_dir_all(&md_dir)?;
// 写入已存在的文件
let bibcode = "2026A&A...123..456X".to_string();
let pdf_file_rel = format!("PDF/{}.pdf", bibcode);
let html_file_rel = format!("HTML/{}.html", bibcode);
fs::write(temp_dir.join(&pdf_file_rel), b"%PDF-1.5 test")?;
fs::write(temp_dir.join(&html_file_rel), b"<html><body><div class=\"ltx_page_main\"><main><h1>Test Paper</h1><p>Content</p></main></div></body></html>")?;
// 插入数据库记录
sqlx::query(
"INSERT INTO papers (bibcode, title, authors, pub, year, keywords, abstract, arxiv_id, doi, pdf_path, html_path, markdown_path)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
)
.bind(&bibcode)
.bind("Test Title")
.bind("[\"Author A\"]")
.bind("Test Journal")
.bind("2026")
.bind("[\"Key\"]")
.bind("Test abstract")
.bind("")
.bind("10.1000/test.doi")
.bind(&pdf_file_rel)
.bind(&html_file_rel)
.bind(None::<String>)
.execute(&pool)
.await?;
let mut config = Config::from_env();
config.library_dir = temp_dir.clone();
let downloader = Arc::new(Downloader::new().expect("Failed to create downloader in test"));
let qiniu = Arc::new(QiniuClient::new(
"test_access".to_string(),
"test_secret".to_string(),
"test_bucket".to_string(),
"test_domain".to_string(),
));
let status = Arc::new(Mutex::new(AssetBatchStatus::new()));
let dict = Arc::new(crate::services::translation::Dictionary::new());
AssetBatch::start_process(
pool.clone(),
config,
downloader,
qiniu,
dict,
BatchAction::All,
vec![bibcode.clone()],
status.clone(),
);
// 轮询直至 active 为 false
let mut success = false;
for _ in 0..50 {
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
let s = status.lock().await;
if !s.active {
success = true;
break;
}
}
assert!(success);
// 检查状态
{
let s = status.lock().await;
assert_eq!(s.total, 1);
assert_eq!(s.downloaded, 1); // 存在本地文件时,直接 downloaded = 1
assert_eq!(s.parsed, 1); // 应该成功解析了 markdown
}
// 检查数据库和本地文件是否生成
let row = sqlx::query("SELECT markdown_path FROM papers WHERE bibcode = ?")
.bind(&bibcode)
.fetch_one(&pool)
.await?;
let md_path_rel: String = row.get(0);
assert_eq!(md_path_rel, format!("Markdown/{}.md", bibcode));
assert!(temp_dir.join(&md_path_rel).exists());
// 清理临时目录
let _ = fs::remove_dir_all(&temp_dir);
Ok(())
}
#[tokio::test]
async fn test_bulk_processor_stop() -> anyhow::Result<()> {
let pool = SqlitePoolOptions::new()
.max_connections(1)
.connect("sqlite::memory:")
.await?;
sqlx::migrate!("./migrations").run(&pool).await?;
let test_id = rand::random::<u32>();
let temp_dir = std::env::temp_dir().join(format!("astro_research_test_stop_{}", test_id));
fs::create_dir_all(&temp_dir)?;
// Setup directories
fs::create_dir_all(temp_dir.join("PDF"))?;
fs::create_dir_all(temp_dir.join("Markdown"))?;
let bib1 = "2026A&A...123..456A".to_string();
let bib2 = "2026MNRAS.530.1234B".to_string();
// Write dummy files to skip download/parsing for both
fs::write(temp_dir.join(format!("PDF/{}.pdf", bib1)), b"PDF")?;
fs::write(temp_dir.join(format!("Markdown/{}.md", bib1)), b"MD")?;
fs::write(temp_dir.join(format!("PDF/{}.pdf", bib2)), b"PDF")?;
fs::write(temp_dir.join(format!("Markdown/{}.md", bib2)), b"MD")?;
// Seed DB for bib1
sqlx::query(
"INSERT INTO papers (bibcode, title, authors, pub, year, keywords, abstract, arxiv_id, doi, pdf_path, markdown_path)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
)
.bind(&bib1)
.bind("Paper 1")
.bind("[]")
.bind("A&A")
.bind("2026")
.bind("[]")
.bind("")
.bind("")
.bind("")
.bind(format!("PDF/{}.pdf", bib1))
.bind(format!("Markdown/{}.md", bib1))
.execute(&pool)
.await?;
// Seed DB for bib2
sqlx::query(
"INSERT INTO papers (bibcode, title, authors, pub, year, keywords, abstract, arxiv_id, doi, pdf_path, markdown_path)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
)
.bind(&bib2)
.bind("Paper 2")
.bind("[]")
.bind("MNRAS")
.bind("2026")
.bind("[]")
.bind("")
.bind("")
.bind("")
.bind(format!("PDF/{}.pdf", bib2))
.bind(format!("Markdown/{}.md", bib2))
.execute(&pool)
.await?;
let mut config = Config::from_env();
config.library_dir = temp_dir.clone();
let downloader = Arc::new(Downloader::new().expect("Failed to create downloader in test"));
let qiniu = Arc::new(QiniuClient::new(
"test_access".to_string(),
"test_secret".to_string(),
"test_bucket".to_string(),
"test_domain".to_string(),
));
let status = Arc::new(Mutex::new(AssetBatchStatus::new()));
let dict = Arc::new(crate::services::translation::Dictionary::new());
AssetBatch::start_process(
pool.clone(),
config,
downloader,
qiniu,
dict,
BatchAction::All,
vec![bib1.clone(), bib2.clone()],
status.clone(),
);
// Wait until bib1 starts processing, then stop it immediately
let mut stopped = false;
for _ in 0..10000 {
tokio::task::yield_now().await;
let mut s = status.lock().await;
if s.active && (s.current_bibcode == bib1 || s.current_bibcode == bib2) {
s.active = false;
stopped = true;
break;
}
}
assert!(stopped);
// Wait until active becomes false
let mut success = false;
for _ in 0..100 {
tokio::time::sleep(tokio::time::Duration::from_millis(1)).await;
let s = status.lock().await;
if !s.active {
success = true;
break;
}
}
assert!(success);
// Verify that the stop mechanism worked: active became false after detection,
// AND the logs contain a stop-related record. With concurrency, both bibcodes
// may have started, but we verify the stop signal was honored (active=false
// reached after we set it).
{
let s = status.lock().await;
// 批量任务最终应结束active=false
assert!(!s.active, "批量任务应在收到停止信号后结束");
// 至少有日志记录(证明处理确实启动过)
assert!(!s.logs.is_empty(), "应有处理日志");
}
// Clean up
let _ = fs::remove_dir_all(&temp_dir);
Ok(())
}
}

View File

@ -0,0 +1,645 @@
// src/services/batch/asset/process.rs
//
// 单篇文献的完整处理流程(下载/解析/翻译/向量化/天体识别)。
// 各步骤的辅助函数在 helpers.rs 中,本文件负责编排调度。
use std::sync::atomic::{AtomicI32, Ordering};
use std::sync::Arc;
use sqlx::{Row, SqlitePool};
use tokio::sync::Mutex;
use tracing::warn;
use crate::clients::llm::{EmbeddingClient, LlmClient};
use crate::clients::qiniu::QiniuClient;
use crate::services::download::Downloader;
use crate::Config;
use super::helpers::{
build_markdown_with_frontmatter, build_source_url, run_download_step,
write_markdown_and_update_db,
};
use super::{AssetBatchStatus, BatchAction, BATCH_CONCURRENCY};
use std::fs;
/// 作为一个独立任务,由 semaphore 限制并发数。
#[allow(clippy::too_many_arguments)]
pub(super) async fn process_single_bibcode(
bibcode: String,
action: BatchAction,
db: &SqlitePool,
config: &Config,
downloader: &Arc<Downloader>,
qiniu: &Arc<QiniuClient>,
dict: &Arc<crate::services::translation::Dictionary>,
llm_client: &LlmClient,
embedding_client: &EmbeddingClient,
status: &Arc<Mutex<AssetBatchStatus>>,
dl_count: &AtomicI32,
dl_failed_count: &AtomicI32,
) {
{
let mut s = status.lock().await;
s.current_bibcode = bibcode.clone();
s.add_log(format!("开始处理文献: {}", bibcode));
}
// MinerU 子任务的局部并发控制(每篇文献的 MinerU 轮询限流)
let semaphore = Arc::new(tokio::sync::Semaphore::new(BATCH_CONCURRENCY));
let mut join_handles: Vec<tokio::task::JoinHandle<()>> = Vec::new();
// 1. 获取文献元数据与当前路径状态
let paper_res = sqlx::query(
"SELECT arxiv_id, doi, pdf_path, html_path, markdown_path, doctype, translation_path FROM papers WHERE bibcode = ?"
)
.bind(&bibcode)
.fetch_optional(db)
.await;
let (arxiv_id, doi, mut pdf_path, mut html_path, markdown_path, doctype, translation_path) =
match paper_res {
Ok(Some(row)) => {
let arxiv_id: String = row.get(0);
let doi: String = row.get(1);
let pdf_path: Option<String> = row.get(2);
let html_path: Option<String> = row.get(3);
let markdown_path: Option<String> = row.get(4);
let doctype: Option<String> = row.get(5);
let translation_path: Option<String> = row.get(6);
(
arxiv_id,
doi,
pdf_path,
html_path,
markdown_path,
doctype,
translation_path,
)
}
_ => {
let mut s = status.lock().await;
s.add_log(format!("数据库中未找到文献 {} 记录,跳过", bibcode));
return;
}
};
// 1b. 检查 doctype跳过无数字全文的文献类型
let doctype_str = doctype
.unwrap_or_else(|| "article".to_string())
.to_lowercase();
if doctype_str == "proposal"
|| doctype_str == "abstract"
|| doctype_str == "catalog"
|| doctype_str == "dataset"
|| doctype_str == "software"
|| doctype_str == "circular"
|| doctype_str == "newsletter"
|| doctype_str == "obituary"
{
let mut s = status.lock().await;
s.add_log(format!(
"文献 {} 的类型为 {} (无数字版全文),跳过下载与解析。",
bibcode, doctype_str
));
if action == BatchAction::Download || action == BatchAction::All {
let _c = dl_count.fetch_add(1, Ordering::SeqCst) + 1;
s.downloaded = _c;
}
if action == BatchAction::Parse
|| action == BatchAction::All
|| action == BatchAction::Translate
|| action == BatchAction::Embed
|| action == BatchAction::Target
{
s.parsed += 1;
}
return;
}
// 2. 检查并执行下载
if action == BatchAction::Download || action == BatchAction::All {
let ok = run_download_step(
&bibcode,
action,
db,
config,
downloader,
status,
dl_count,
dl_failed_count,
&arxiv_id,
&doi,
&mut pdf_path,
&mut html_path,
)
.await;
if ok {
// 每次下载尝试后,加入 3-5 秒随机延迟,防爬防封
let delay_secs = 3 + (rand::random::<u64>() % 3);
tokio::time::sleep(tokio::time::Duration::from_secs(delay_secs)).await;
}
}
// 3. 检查并执行结构化解析Markdown 转换)
if action == BatchAction::Parse || action == BatchAction::All {
let is_md_exist = markdown_path
.as_ref()
.map(|p| config.library_dir.join(p).exists())
.unwrap_or(false);
if !is_md_exist {
if pdf_path.is_some() || html_path.is_some() {
{
let mut s = status.lock().await;
s.add_log(format!(
"文献 {} 开始进行排版提取与 Markdown 转换...",
bibcode
));
}
let mut relative_md_path = String::new();
let source_url = build_source_url(&bibcode, &arxiv_id);
// 策略 1HTML 优先
if let Some(html_rel) = &html_path {
let html_abs = config.library_dir.join(html_rel);
if html_abs.exists() {
if let Ok(md) = crate::services::parser::html_to_markdown(&html_abs) {
if let Some(parsed_md) =
build_markdown_with_frontmatter(db, &bibcode, &md, &source_url)
.await
{
if let Some(rel_path) =
write_markdown_and_update_db(config, &bibcode, &parsed_md)
{
relative_md_path = rel_path;
}
}
}
}
}
if !relative_md_path.is_empty() {
// HTML 解析成功,直接写入数据库并记录成功
let _ = sqlx::query("UPDATE papers SET markdown_path = ? WHERE bibcode = ?")
.bind(&relative_md_path)
.bind(&bibcode)
.execute(db)
.await;
{
let mut s = status.lock().await;
s.parsed += 1;
s.add_log(format!("文献 {} HTML 本地解析成功!", bibcode));
}
} else {
// HTML 解析失败或无 HTML执行 PDF 回退(异步非阻塞提交 MinerU
if let Some(pdf_rel) = &pdf_path {
let pdf_abs = config.library_dir.join(pdf_rel);
if pdf_abs.exists() {
// 检查是否已经是 mineru_batch: 状态
let existing_batch_id = markdown_path
.as_ref()
.and_then(|p| p.strip_prefix("mineru_batch:"))
.map(|s| s.trim().to_string());
let db_clone = db.clone();
let config_clone = config.clone();
let qiniu_clone = qiniu.clone();
let status_clone = status.clone();
let bibcode_clone = bibcode.clone();
let source_url_clone = source_url.clone();
let mut submitted_ok = true;
let batch_id = if let Some(id) = existing_batch_id {
{
let mut s = status.lock().await;
s.add_log(format!("文献 {} 检测到未完成的 MinerU 任务,正在恢复轮询 (Batch ID: {})...", bibcode, id));
}
id
} else {
{
let mut s = status.lock().await;
s.add_log(format!(
"文献 {} PDF 提交后台解析 (MinerU)...",
bibcode
));
}
match crate::services::parser::submit_pdf_to_mineru(
&pdf_abs, config,
)
.await
{
Ok(id) => {
let marker = format!("mineru_batch:{}", id);
let _ = sqlx::query(
"UPDATE papers SET markdown_path = ? WHERE bibcode = ?",
)
.bind(&marker)
.bind(&bibcode)
.execute(db)
.await;
id
}
Err(e) => {
let mut s = status.lock().await;
s.parse_failed += 1;
s.add_log(format!(
"文献 {} PDF 提交 MinerU 失败: {}",
bibcode, e
));
let err_reason = format!("error: {}", e);
let _ = sqlx::query(
"UPDATE papers SET markdown_path = ? WHERE bibcode = ?",
)
.bind(&err_reason)
.bind(&bibcode)
.execute(db)
.await;
submitted_ok = false;
String::new()
}
}
};
if submitted_ok {
let sem_clone = semaphore.clone();
let handle = tokio::spawn(async move {
let _permit = sem_clone.acquire().await.unwrap();
match crate::services::parser::poll_and_extract_mineru(
&batch_id,
&bibcode_clone,
&qiniu_clone,
&config_clone,
)
.await
{
Ok(md) => {
if let Some(parsed_md) =
build_markdown_with_frontmatter(
&db_clone,
&bibcode_clone,
&md,
&source_url_clone,
)
.await
{
let rel_md = write_markdown_and_update_db(
&config_clone,
&bibcode_clone,
&parsed_md,
);
if let Some(rel_path) = rel_md {
let _ = sqlx::query("UPDATE papers SET markdown_path = ? WHERE bibcode = ?")
.bind(&rel_path)
.bind(&bibcode_clone)
.execute(&db_clone)
.await;
let mut s = status_clone.lock().await;
s.parsed += 1;
s.add_log(format!(
"文献 {} PDF (MinerU) 解析成功!",
bibcode_clone
));
} else {
let mut s = status_clone.lock().await;
s.parse_failed += 1;
s.add_log(format!(
"文献 {} PDF 写入 Markdown 失败。",
bibcode_clone
));
let _ = sqlx::query("UPDATE papers SET markdown_path = 'error: PDF 写入 Markdown 失败' WHERE bibcode = ?")
.bind(&bibcode_clone)
.execute(&db_clone)
.await;
}
} else {
let mut s = status_clone.lock().await;
s.parse_failed += 1;
s.add_log(format!(
"文献 {} PDF 获取元数据失败。",
bibcode_clone
));
}
}
Err(e) => {
let mut s = status_clone.lock().await;
s.parse_failed += 1;
s.add_log(format!(
"文献 {} PDF 结构解析失败 (MinerU): {}",
bibcode_clone, e
));
let err_reason = format!("error: {}", e);
let _ = sqlx::query("UPDATE papers SET markdown_path = ? WHERE bibcode = ?")
.bind(&err_reason)
.bind(&bibcode_clone)
.execute(&db_clone)
.await;
}
}
});
join_handles.push(handle);
}
} else {
let mut s = status.lock().await;
s.parse_failed += 1;
s.add_log(format!("文献 {} 本地 PDF 文件不存在,无法解析。", bibcode));
let _ = sqlx::query("UPDATE papers SET markdown_path = 'error: 本地 PDF 文件不存在' WHERE bibcode = ?")
.bind(&bibcode)
.execute(db)
.await;
}
} else {
let mut s = status.lock().await;
s.parse_failed += 1;
s.add_log(format!(
"文献 {} HTML 转换失败,且无本地 PDF无法解析。",
bibcode
));
let _ = sqlx::query("UPDATE papers SET markdown_path = 'error: HTML 转换失败且无本地 PDF' WHERE bibcode = ?")
.bind(&bibcode)
.execute(db)
.await;
}
}
} else {
let mut s = status.lock().await;
s.parse_failed += 1;
s.add_log(format!(
"文献 {} 无本地 PDF/HTML无法解析跳过。",
bibcode
));
}
} else {
{
let mut s = status.lock().await;
s.add_log(format!("文献 {} 已存在解析后的 Markdown跳过。", bibcode));
}
let mut s = status.lock().await;
s.parsed += 1;
}
}
// 4. 检查并执行翻译
if action == BatchAction::Translate {
let is_tr_exist = translation_path
.as_ref()
.map(|p| config.library_dir.join(p).exists() && !p.starts_with("error:"))
.unwrap_or(false);
if !is_tr_exist {
if let Some(md_rel) = &markdown_path {
if !md_rel.starts_with("error:") {
let md_abs = config.library_dir.join(md_rel);
if md_abs.exists() {
{
let mut s = status.lock().await;
s.add_log(format!("文献 {} 开始调用 LLM 翻译...", bibcode));
}
match fs::read_to_string(&md_abs) {
Ok(english_markdown) => {
match crate::services::translation::translate_markdown(
&english_markdown,
dict,
llm_client,
)
.await
{
Ok(translated_markdown) => {
let tr_filename = format!("{}.md", bibcode);
let tr_dest = config
.library_dir
.join("Translation")
.join(&tr_filename);
if let Some(parent) = tr_dest.parent() {
let _ = fs::create_dir_all(parent);
}
if fs::write(&tr_dest, &translated_markdown).is_ok() {
let relative_tr_path =
format!("Translation/{}", tr_filename);
let _ = sqlx::query("UPDATE papers SET translation_path = ? WHERE bibcode = ?")
.bind(&relative_tr_path)
.bind(&bibcode)
.execute(db)
.await;
let mut s = status.lock().await;
s.parsed += 1;
s.add_log(format!("文献 {} 翻译成功!", bibcode));
} else {
let error_msg = "error: 写入翻译文件失败";
let _ = sqlx::query("UPDATE papers SET translation_path = ? WHERE bibcode = ?")
.bind(error_msg)
.bind(&bibcode)
.execute(db)
.await;
let mut s = status.lock().await;
s.parse_failed += 1;
s.add_log(format!(
"文献 {} 翻译文件写入失败。",
bibcode
));
}
}
Err(e) => {
let error_msg = format!("error: {}", e);
let _ = sqlx::query("UPDATE papers SET translation_path = ? WHERE bibcode = ?")
.bind(&error_msg)
.bind(&bibcode)
.execute(db)
.await;
let mut s = status.lock().await;
s.parse_failed += 1;
s.add_log(format!("文献 {} 翻译失败: {}", bibcode, e));
}
}
}
Err(e) => {
let error_msg = format!("error: 读取英文 Markdown 失败: {}", e);
let _ = sqlx::query(
"UPDATE papers SET translation_path = ? WHERE bibcode = ?",
)
.bind(&error_msg)
.bind(&bibcode)
.execute(db)
.await;
let mut s = status.lock().await;
s.parse_failed += 1;
s.add_log(format!(
"文献 {} 读取英文 Markdown 失败: {}",
bibcode, e
));
}
}
} else {
let error_msg = "error: 英文 Markdown 文件不存在";
let _ =
sqlx::query("UPDATE papers SET translation_path = ? WHERE bibcode = ?")
.bind(error_msg)
.bind(&bibcode)
.execute(db)
.await;
let mut s = status.lock().await;
s.parse_failed += 1;
s.add_log(format!(
"文献 {} 英文 Markdown 文件不存在,无法翻译。",
bibcode
));
}
} else {
let error_msg = "error: 英文 Markdown 文件处于解析失败状态";
let _ = sqlx::query("UPDATE papers SET translation_path = ? WHERE bibcode = ?")
.bind(error_msg)
.bind(&bibcode)
.execute(db)
.await;
let mut s = status.lock().await;
s.parse_failed += 1;
s.add_log(format!(
"文献 {} 英文 Markdown 解析失败,跳过翻译。",
bibcode
));
}
} else {
let error_msg = "error: 尚未解析英文 Markdown 路径为 NULL";
let _ = sqlx::query("UPDATE papers SET translation_path = ? WHERE bibcode = ?")
.bind(error_msg)
.bind(&bibcode)
.execute(db)
.await;
let mut s = status.lock().await;
s.parse_failed += 1;
s.add_log(format!(
"文献 {} 尚未解析英文 Markdown跳过翻译。",
bibcode
));
}
} else {
{
let mut s = status.lock().await;
s.add_log(format!("文献 {} 已存在翻译,跳过。", bibcode));
}
let mut s = status.lock().await;
s.parsed += 1;
}
}
// 5. 检查并执行向量化 (Embedding)
if action == BatchAction::Embed {
let is_md_exist = markdown_path
.as_ref()
.map(|p| config.library_dir.join(p).exists() && !p.starts_with("error:"))
.unwrap_or(false);
if is_md_exist {
let md_rel = markdown_path.as_ref().unwrap();
let md_abs = config.library_dir.join(md_rel);
{
let mut s = status.lock().await;
s.add_log(format!("文献 {} 开始进行向量化切片入库...", bibcode));
}
match fs::read_to_string(&md_abs) {
Ok(markdown_content) => {
match crate::services::rag::ingest_paper(
db,
embedding_client,
&bibcode,
&markdown_content,
None,
)
.await
{
Ok(chunk_count) => {
let mut s = status.lock().await;
s.parsed += 1;
s.add_log(format!(
"文献 {} 向量化成功,共切片入库 {} 个向量块。",
bibcode, chunk_count
));
}
Err(e) => {
let mut s = status.lock().await;
s.parse_failed += 1;
s.add_log(format!("文献 {} 向量化失败: {}", bibcode, e));
}
}
}
Err(e) => {
let mut s = status.lock().await;
s.parse_failed += 1;
s.add_log(format!("文献 {} 读取英文 Markdown 失败: {}", bibcode, e));
}
}
} else {
let mut s = status.lock().await;
s.parse_failed += 1;
s.add_log(format!(
"文献 {} 英文 Markdown 文件不存在,跳过向量化。",
bibcode
));
}
}
// 6. 检查并执行天体识别与缓存
if action == BatchAction::Target {
let is_md_exist = markdown_path
.as_ref()
.map(|p| config.library_dir.join(p).exists() && !p.starts_with("error:"))
.unwrap_or(false);
if is_md_exist {
let md_rel = markdown_path.as_ref().unwrap();
let md_abs = config.library_dir.join(md_rel);
{
let mut s = status.lock().await;
s.add_log(format!("文献 {} 开始提取并识别天体目标...", bibcode));
}
match fs::read_to_string(&md_abs) {
Ok(markdown_content) => {
// 提取前先清空旧的关联
if let Err(e) = sqlx::query("DELETE FROM paper_targets WHERE bibcode = ?")
.bind(&bibcode)
.execute(db)
.await
{
warn!("清除文献 {} 的旧天体关联失败: {}", bibcode, e);
}
let client = reqwest::Client::new();
let targets = crate::services::target::extract_and_cache_targets(
db,
&markdown_content,
&bibcode,
&client,
)
.await;
let mut s = status.lock().await;
s.parsed += 1;
s.add_log(format!(
"文献 {} 天体识别完成,共识别并缓存 {} 个天体目标。",
bibcode,
targets.len()
));
}
Err(e) => {
let mut s = status.lock().await;
s.parse_failed += 1;
s.add_log(format!("文献 {} 读取英文 Markdown 失败: {}", bibcode, e));
}
}
} else {
let mut s = status.lock().await;
s.parse_failed += 1;
s.add_log(format!(
"文献 {} 英文 Markdown 文件不存在,跳过天体识别。",
bibcode
));
}
}
// 等待本篇文献的 MinerU 后台解析任务完成
for handle in join_handles {
let _ = handle.await;
}
// ── process_single_bibcode 结束 ──
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,337 @@
// src/services/download/headers.rs
//! 文献下载模块 — HTTP 头构造、反爬检测、文件校验。
//!
//! 参考 datasheel/node/src/download 设计,实现:
//! - 随机 User-Agent + 完整 Sec-Fetch 头伪装
//! - 流式下载stream_download
//! - PDF/HTML 内容校验 + 反爬检测
//! - 多级回退arXiv 直连 → ADS PUB → ADS EPRINT → CrossRef API
//! - IOP/Springer 等特定出版商会话预热策略
//! - 请求间随机延迟500-2000ms降低触发反爬风险
use anyhow::{Context, Result};
use reqwest::header::{HeaderMap, HeaderValue};
use url::Url;
// ─── 浏览器伪装辅助 ────────────────────────────────────────────
/// SSRF 防护:校验外部 URL 的 scheme 与 host 是否安全。
///
/// 用于校验来自第三方 API如 CrossRef的 URL拒绝非 http(s) 协议、
/// 以及指向内网/保留地址的主机。host 判断委托给 utils::ssrf 的异步 DNS 解析。
pub(crate) async fn validate_external_url(raw: &str) -> Result<()> {
let parsed = Url::parse(raw).context("无效的 URL")?;
let scheme = parsed.scheme();
if scheme != "http" && scheme != "https" {
return Err(anyhow::anyhow!("仅允许 http/https 协议,拒绝: {}", scheme));
}
let host = parsed
.host_str()
.ok_or_else(|| anyhow::anyhow!("URL 缺少 host"))?;
// 字面量快检IP 网段 + 内网域名特征)
if crate::utils::ssrf::is_host_literal_blocked(host) {
return Err(anyhow::anyhow!("拒绝指向内网/保留地址的主机: {}", host));
}
// 异步 DNS 深度校验(防 DNS rebinding不阻塞 tokio worker
if crate::utils::ssrf::is_host_blocked_async(host).await {
return Err(anyhow::anyhow!("DNS 解析后指向内网/保留地址: {}", host));
}
Ok(())
}
/// 生成随机 Firefox User-Agent参考 SearXNG useragents.json
pub(super) fn gen_useragent() -> String {
use rand::seq::SliceRandom;
let os_list = [
"Windows NT 10.0; Win64; x64",
"X11; Linux x86_64",
"Macintosh; Intel Mac OS X 10_15_7",
];
let versions = ["137.0", "136.0", "135.0", "134.0", "133.0"];
let os = os_list.choose(&mut rand::thread_rng()).unwrap();
let v = versions.choose(&mut rand::thread_rng()).unwrap();
format!("Mozilla/5.0 ({os}; rv:{v}) Gecko/20100101 Firefox/{v}")
}
/// 构建完整浏览器 HTTP 头(参考 SearXNG online.py
pub(super) fn build_browser_headers() -> HeaderMap {
let mut h = HeaderMap::new();
if let Ok(ua) = HeaderValue::from_str(&gen_useragent()) {
h.insert("User-Agent", ua);
}
h.insert(
"Accept",
HeaderValue::from_static(
"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
),
);
h.insert(
"Accept-Language",
HeaderValue::from_static("en-US,en;q=0.9,zh-CN;q=0.8,zh;q=0.7"),
);
h.insert("DNT", HeaderValue::from_static("1"));
h.insert("Connection", HeaderValue::from_static("keep-alive"));
h.insert("Upgrade-Insecure-Requests", HeaderValue::from_static("1"));
h.insert("Sec-Fetch-Dest", HeaderValue::from_static("document"));
h.insert("Sec-Fetch-Mode", HeaderValue::from_static("navigate"));
h.insert("Sec-Fetch-Site", HeaderValue::from_static("none"));
h.insert("Sec-Fetch-User", HeaderValue::from_static("?1"));
h
}
/// 构建 Chrome 风格 HTTP 头(用于 IOP 等更严格出版商)
pub(super) fn build_chrome_headers(referer: Option<&str>) -> HeaderMap {
let mut h = HeaderMap::new();
h.insert("User-Agent", HeaderValue::from_static(
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36",
));
h.insert("Accept", HeaderValue::from_static(
"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7",
));
h.insert(
"Accept-Language",
HeaderValue::from_static("en-US,en;q=0.9"),
);
h.insert(
"Sec-Ch-Ua",
HeaderValue::from_static(
"\"Google Chrome\";v=\"143\", \"Chromium\";v=\"143\", \"Not A(Brand\";v=\"24\"",
),
);
h.insert("Sec-Ch-Ua-Mobile", HeaderValue::from_static("?0"));
h.insert(
"Sec-Ch-Ua-Platform",
HeaderValue::from_static("\"Windows\""),
);
h.insert("Sec-Fetch-Dest", HeaderValue::from_static("document"));
h.insert("Sec-Fetch-Mode", HeaderValue::from_static("navigate"));
h.insert("Sec-Fetch-Site", HeaderValue::from_static("same-origin"));
h.insert("Sec-Fetch-User", HeaderValue::from_static("?1"));
if let Some(r) = referer {
if let Ok(v) = HeaderValue::from_str(r) {
h.insert("Referer", v);
}
}
h
}
// ─── 内容校验 ─────────────────────────────────────────────────
/// 统一验证码/反爬虫检测(参考 SearXNG 异常处理机制)
pub(super) fn detect_anti_bot(content: &str, url: Option<&str>) -> Result<()> {
// 如果页面长度大于 150KB通常是完整渲染的文献正文忽略反爬/人机验证特征检测以避免误伤(例如正常页面中嵌有 recaptcha 的 sitekey 配置)
if content.len() > 150_000 {
return Ok(());
}
let lower = content.to_lowercase();
// 1. 强特征防爬与 WAF 挑战(任何小于 150KB 的内容都做检测)
let waf_patterns = [
"checking your browser",
"please wait while we verify",
"cf-browser-verification",
"cf_chl_opt",
"just a moment",
"enable javascript and cookies",
"_cf_chl_tk",
"awswafintegration",
"aws waf",
];
for p in &waf_patterns {
if lower.contains(p) {
anyhow::bail!("检测到 Cloudflare 或 AWS WAF 挑战/错误页面(特征: {}", p);
}
}
let captcha_patterns = [
"captcha",
"recaptcha",
"hcaptcha",
"verify you are human",
"robot check",
"radware bot manager",
"shieldsquare",
];
for p in &captcha_patterns {
if lower.contains(p) {
anyhow::bail!("检测到人机验证页面(包含: {}", p);
}
}
let access_denied = [
"login required",
"please log in",
"subscription required",
"access denied",
"you do not have access",
"purchase this article",
"sign in to access",
"client challenge",
];
for p in &access_denied {
if lower.contains(p) {
anyhow::bail!("检测到出版商访问限制(特征: {}", p);
}
}
if let Some(u) = url {
if u.contains("sorry.google.com") || u.contains("/sorry") {
anyhow::bail!("检测到 Google 验证码页面");
}
}
// 2. 通用 HTTP 错误与 CDN 关键字检测(仅当内容长度小于 5000 字节时检测,避免在正常文献中误判 CDN 脚本等)
if content.len() < 5000 {
let err_patterns = [
"cloudflare",
"service temporarily unavailable",
"503 service",
"502 bad gateway",
"504 gateway timeout",
"403 forbidden",
"404 not found",
"500 internal server error",
"site error",
];
for p in &err_patterns {
if lower.contains(p) {
anyhow::bail!("检测到服务错误或防护页面(特征: {}", p);
}
}
}
Ok(())
}
/// 校验响应字节是否为有效 PDF魔数 + 最小大小 + EOF 标记)
pub(crate) fn validate_pdf_content(bytes: &[u8]) -> Result<()> {
if !bytes.starts_with(b"%PDF") {
if bytes.starts_with(b"<!") || bytes.starts_with(b"<html") || bytes.starts_with(b"<HTML") {
let text = String::from_utf8_lossy(&bytes[..bytes.len().min(2048)]);
detect_anti_bot(&text, None)?;
anyhow::bail!("响应内容是 HTML 而非 PDF可能需要登录或验证");
}
anyhow::bail!("响应不是有效的 PDF 文件(缺少 %PDF 魔数)");
}
if bytes.len() < 5000 {
anyhow::bail!("PDF 文件过小({} 字节),可能是错误页面", bytes.len());
}
let scan_len = std::cmp::min(1024, bytes.len());
let tail = &bytes[bytes.len() - scan_len..];
if !tail.windows(5).any(|w| w == b"%%EOF") {
anyhow::bail!("PDF 文件损坏或不完整(未找到尾部 %%EOF 标记)");
}
Ok(())
}
/// 校验 HTML 内容是否为有效文献页(非错误/登录墙/跳转/摘要占位页)
pub(crate) fn validate_html_content(text: &str) -> Result<()> {
detect_anti_bot(text, None)?;
let lower = text.to_lowercase();
// 1. 检查常见的跳转与错误占位特征
if lower.contains("redirecting")
|| lower.contains("redirect to")
|| lower.contains("http-equiv=\"refresh\"")
|| lower.contains("autoredirecttourl")
{
anyhow::bail!("检测到 HTML 重定向跳转页面,而非真实文献正文");
}
if lower.contains("conversion to html had a fatal error")
|| lower.contains("no content available")
|| lower.contains("fatal error and exited abruptly")
{
anyhow::bail!("检测到 ar5iv 转换失败的占位 HTML 页面");
}
if lower.contains("see pages 1-last of") {
anyhow::bail!("检测到仅包含 PDF 链接的占位 HTML 页面");
}
// 2. 网页标题精准黑名单校验(防止正文中提及 NSF/VizieR 导致误伤)
if let Some(start_pos) = lower.find("<title") {
if let Some(tag_end) = lower[start_pos..].find('>') {
let title_start = start_pos + tag_end + 1;
if let Some(end_pos) = lower[title_start..].find("</title>") {
let title = &lower[title_start..title_start + end_pos];
if title.contains("nsf award search")
|| title.contains("national science foundation")
|| title.contains("vizier")
|| title.contains("caltechthesis")
|| title.contains("caosp abstract")
|| title.contains("asp conference series")
|| title.contains("aspbooks")
{
anyhow::bail!("检测到占位网页标题: \"{}\",判定为非正本文献", title.trim());
}
}
}
}
// 3. 基础字节长度与具体 HTTP 错误特征校验
if text.len() < 2000 {
let error_patterns = [
"404 not found",
"403 forbidden",
"502 bad gateway",
"500 internal server error",
"access denied",
"site error",
];
for kw in &error_patterns {
if lower.contains(kw) {
anyhow::bail!("响应是错误页面(包含: {}", kw);
}
}
}
// 4. 结构启发式校验:如果是小于 50KB 的 HTML必须包含基本的章节或参考文献结构否则判定为摘要/存根占位页
if text.len() < 50000 {
// 匹配 heading 标签或 Markdown 格式的标题,而不是纯文本中的单词
let has_sections = lower.contains("ltx_title_section")
|| lower.contains("class=\"section\"")
|| lower.contains("## introduction")
|| lower.contains("<h2>introduction")
|| lower.contains("<h3>introduction")
|| lower.contains("class=\"ltx_section\"");
let has_bib = lower.contains("ltx_bibliography")
|| lower.contains("class=\"references\"")
|| lower.contains("<ol class=\"references\"")
|| lower.contains("<ul class=\"references\"")
|| lower.contains("id=\"bib\"")
|| lower.contains("class=\"ltx_bibliography\"");
if !has_sections && !has_bib {
anyhow::bail!(
"HTML 长度偏小({} 字节)且缺少正文章节或参考文献,判定为非正本文献",
text.len()
);
}
}
Ok(())
}
/// 宽松版 HTML 校验,专用于手动上传场景。
/// 用户在浏览器中亲眼确认了文献内容,无需自动下载时的反爬虫/章节启发式检测。
/// 仅做最低限度检查:页面不能过小,不能是纯跳转页。
pub(crate) fn validate_html_content_lenient(text: &str) -> Result<()> {
if text.len() < 500 {
anyhow::bail!(
"上传的 HTML 文件过小({} 字节),可能是空白或错误页面",
text.len()
);
}
let lower = text.to_lowercase();
// 仅拒绝明确的重定向占位页(通常 body 极短且没有正文)
let is_redirect =
lower.contains("http-equiv=\"refresh\"") || lower.contains("autoredirecttourl");
if is_redirect && text.len() < 5000 {
anyhow::bail!("检测到 HTML 重定向跳转页面,而非真实文献正文");
}
Ok(())
}

View File

@ -0,0 +1,716 @@
// src/services/download/mod.rs
//
// 文献下载服务。
// 子模块:
// headers — HTTP 头构造、反爬检测、文件校验。
// strategies — Obscura 浏览器集成、出版策略、HTTP 辅助工具。
mod headers;
mod strategies;
// Re-export 公共函数供外部使用
pub(crate) use headers::validate_html_content_lenient;
pub(crate) use headers::validate_pdf_content;
use crate::api::helpers::{check_paper_paths_in_db, get_paper_from_db};
use crate::api::StandardPaper;
use anyhow::{Context, Result};
use reqwest::header::{HeaderMap, HeaderValue};
use std::path::{Path, PathBuf};
use tracing::{error, info, warn};
// ─── Downloader 主结构 ─────────────────────────────────────────
/// 文献双格式异步下载管理器
#[derive(Clone)]
pub struct Downloader {
client: reqwest::Client,
}
impl Downloader {
pub fn new() -> anyhow::Result<Self> {
let mut headers = HeaderMap::new();
headers.insert(reqwest::header::ACCEPT, HeaderValue::from_static(
"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7",
));
headers.insert(
reqwest::header::ACCEPT_LANGUAGE,
HeaderValue::from_static("en-US,en;q=0.9,zh-CN;q=0.8,zh;q=0.7"),
);
headers.insert("DNT", HeaderValue::from_static("1"));
headers.insert(
reqwest::header::CONNECTION,
HeaderValue::from_static("keep-alive"),
);
headers.insert("Upgrade-Insecure-Requests", HeaderValue::from_static("1"));
let client = reqwest::Client::builder()
.user_agent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
.default_headers(headers)
.cookie_store(true) // 启用 Cookie 引擎,记录会话状态
.redirect(crate::utils::ssrf::safe_redirect_policy())
.timeout(std::time::Duration::from_secs(60))
.build()
.context("Failed to create HTTP client")?;
Ok(Downloader { client })
}
// ─── 公共入口 ──────────────────────────────────────────────
/// 通过 arXiv ID 直接下载 PDFarxiv.org和 HTML官方 html/ 优先ar5iv 兜底)
///
/// HTML 下载优先级:
/// 1. 官方 `arxiv.org/html/{id}`2023-12 起支持,质量与 ar5iv 相同,更稳定)
/// 2. ar5iv `ar5iv.labs.arxiv.org/html/{id}`(约 3% 论文转换失败时跳过)
pub async fn download_arxiv_direct(
&self,
arxiv_id: &str,
library_dir: &Path,
) -> (Result<PathBuf, String>, Result<PathBuf, String>) {
// 去除版本号v1/v2/v3arxiv.org/html/ 和 ar5iv 均只提供最新渲染版
let clean_id = strip_arxiv_version(arxiv_id);
let pdf_url = format!("https://arxiv.org/pdf/{}", clean_id);
let pdf_dest = library_dir.join("PDF").join(format!("{}.pdf", arxiv_id));
let html_dest = library_dir.join("HTML").join(format!("{}.html", arxiv_id));
// PDF 下载
let pdf_res = match self.download_pdf_direct(&pdf_url, &pdf_dest, "arXiv").await {
Ok(_) => Ok(pdf_dest),
Err(e) => {
let err_msg = format!("arXiv PDF 下载失败: {}", e);
warn!("{}", err_msg);
Err(err_msg)
}
};
// HTML 下载:官方 arxiv.org/html/ 优先
let official_html_url = format!("https://arxiv.org/html/{}", clean_id);
let html_res = match self
.download_html_direct(&official_html_url, &html_dest, "arXiv-HTML")
.await
{
Ok(_) => Ok(html_dest.clone()),
Err(e) => {
warn!("[arXiv-HTML] 官方 HTML 下载失败,回退 ar5iv: {:?}", e);
// ar5iv 兜底:约 97% 成功率,可能有延迟
let ar5iv_url = format!("https://ar5iv.labs.arxiv.org/html/{}", clean_id);
match self
.download_html_direct(&ar5iv_url, &html_dest, "ar5iv")
.await
{
Ok(_) => Ok(html_dest),
Err(e2) => {
let err_msg = format!("arXiv HTML 下载失败 (官方: {}, ar5iv: {})", e, e2);
warn!("{}", err_msg);
Err(err_msg)
}
}
}
};
(pdf_res, html_res)
}
/// 下载 arXiv HTML官方 arxiv.org/html/ 优先ar5iv 兜底
/// arxiv_id 应已去除版本号
async fn download_arxiv_html_with_fallback(
&self,
arxiv_id: &str,
dest_path: &Path,
) -> Result<()> {
let official_url = format!("https://arxiv.org/html/{}", arxiv_id);
match self
.download_html_direct(&official_url, dest_path, "arXiv-HTML")
.await
{
Ok(()) => Ok(()),
Err(e) => {
warn!("[arXiv-HTML] 官方 HTML 失败,回退 ar5iv: {:?}", e);
let ar5iv_url = format!("https://ar5iv.labs.arxiv.org/html/{}", arxiv_id);
self.download_html_direct(&ar5iv_url, dest_path, "ar5iv")
.await
}
}
}
/// 为 ADS Bibcode 下载 PDF 与 HTML多级回退策略
///
/// PDF 回退顺序:
/// 1. ADS PUB_PDF 网关 → 按 DOI 前缀路由IOP/Springer 用专属策略,其余通用)
/// 2. ADS_PDF 网关 (经典 ADS 整合 PDF 直接通道)
/// 3. ADS EPRINT_PDF 网关
/// 4. CrossRef API PDF需提供 DOI
/// 5. ADS SCAN 扫描版文献直接下载
///
/// HTML 回退顺序:
/// 1. ADS PUB_HTML 网关IOP→ 直联 iopsciencearxiv abs → ar5iv
/// 2. ADS EPRINT_HTML 网关arxiv abs → ar5iv
pub async fn download_paper(
&self,
bibcode: &str,
doi: Option<&str>,
library_dir: &Path,
) -> (Result<PathBuf, String>, Result<PathBuf, String>) {
let base = "https://ui.adsabs.harvard.edu/link_gateway";
let pdf_dest = library_dir.join("PDF").join(format!("{}.pdf", bibcode));
let html_dest = library_dir.join("HTML").join(format!("{}.html", bibcode));
let mut pdf_res = Err("未尝试任何下载通道".to_string());
let mut html_res = Err("未尝试任何下载通道".to_string());
let mut pdf_errors = Vec::new();
let mut html_errors = Vec::new();
// ── PDF 下载 ───────────────────────────────────────────
info!("[下载] 开始 PDF 下载: {}", bibcode);
'pdf: {
// 1a. ADS PUB_PDF 网关
let gw = format!("{}/{}/PUB_PDF", base, bibcode);
match self.resolve_ads_gateway(&gw).await {
Ok(resolved) => {
let result = if resolved.contains("iopscience.iop.org") {
// 提取 DOI 路径部分,走 IOP 专属策略
let doi = resolved
.trim_start_matches("https://iopscience.iop.org/article/")
.trim_end_matches("/pdf")
.trim_end_matches('/');
self.download_iop_pdf(doi, &pdf_dest).await
} else if resolved.contains("link.springer.com")
|| resolved.contains("nature.com")
{
// Springer/NatureHTML 更可靠PDF 用通用策略
self.download_pdf_direct(&resolved, &pdf_dest, "Springer")
.await
} else {
self.download_pdf_direct(&resolved, &pdf_dest, "PUB_PDF")
.await
};
match result {
Ok(_) => {
pdf_res = Ok(pdf_dest.clone());
break 'pdf;
}
Err(e) => {
let msg = format!("PUB_PDF下载失败: {}", e);
warn!("{}", msg);
pdf_errors.push(msg);
}
}
}
Err(e) => {
let msg = format!("PUB_PDF网关解析失败: {}", e);
warn!("{}", msg);
pdf_errors.push(msg);
}
}
// 1b. ADS_PDF 网关 (经典 ADS 整合 PDF 直接通道)
let gw = format!("{}/{}/ADS_PDF", base, bibcode);
match self.resolve_ads_gateway(&gw).await {
Ok(resolved) => {
match self
.download_pdf_direct(&resolved, &pdf_dest, "ADS_PDF")
.await
{
Ok(_) => {
pdf_res = Ok(pdf_dest.clone());
break 'pdf;
}
Err(e) => {
let msg = format!("ADS_PDF下载失败: {}", e);
warn!("{}", msg);
pdf_errors.push(msg);
}
}
}
Err(e) => {
let msg = format!("ADS_PDF网关解析失败: {}", e);
warn!("{}", msg);
pdf_errors.push(msg);
}
}
// 1c. ADS EPRINT_PDF 网关
let gw = format!("{}/{}/EPRINT_PDF", base, bibcode);
match self.resolve_ads_gateway(&gw).await {
Ok(resolved) => {
match self
.download_pdf_direct(&resolved, &pdf_dest, "EPRINT_PDF")
.await
{
Ok(_) => {
pdf_res = Ok(pdf_dest.clone());
break 'pdf;
}
Err(e) => {
let msg = format!("EPRINT_PDF下载失败: {}", e);
warn!("{}", msg);
pdf_errors.push(msg);
}
}
}
Err(e) => {
let msg = format!("EPRINT_PDF网关解析失败: {}", e);
warn!("{}", msg);
pdf_errors.push(msg);
}
}
// 1d. CrossRef API 回退(需要 DOI
if let Some(doi_str) = doi {
match self.download_crossref_pdf(doi_str, &pdf_dest).await {
Ok(_) => {
pdf_res = Ok(pdf_dest.clone());
break 'pdf;
}
Err(e) => {
let msg = format!("CrossRef下载失败: {}", e);
warn!("{}", msg);
pdf_errors.push(msg);
}
}
}
// 1e. ADS SCAN 扫描版文献直接合并下载 PDF主要针对早期/不可下载直接 PDF 的文献)
let scan_url = format!("https://articles.adsabs.harvard.edu/cgi-bin/nph-iarticle_query?bibcode={}&db_key=AST&data_type=PDF_HIGH", bibcode);
match self
.download_pdf_direct(&scan_url, &pdf_dest, "ADS_SCAN")
.await
{
Ok(_) => {
pdf_res = Ok(pdf_dest.clone());
}
Err(e) => {
let msg = format!("ADS_SCAN下载失败: {}", e);
warn!("{}", msg);
pdf_errors.push(msg);
}
}
}
if pdf_res.is_err() && !pdf_errors.is_empty() {
pdf_res = Err(pdf_errors.join("; "));
}
// ── HTML 下载 ──────────────────────────────────────────
info!("[下载] 开始 HTML 下载: {}", bibcode);
'html: {
// 2a. ADS PUB_HTML 网关
let gw = format!("{}/{}/PUB_HTML", base, bibcode);
match self.resolve_ads_gateway(&gw).await {
Ok(resolved) => {
let result = if resolved.contains("link.springer.com")
|| resolved.contains("nature.com")
{
// Springer/Nature 专属 HTML 策略
let doi_part = resolved
.trim_start_matches("https://link.springer.com/article/")
.trim_start_matches("https://www.nature.com/articles/")
.trim_end_matches('/');
self.download_springer_html(doi_part, &html_dest).await
} else if let Some(arxiv_id) = extract_arxiv_id_from_url(&resolved) {
// ADS 网关指向 arXiv abs 页面 → 优先官方 HTMLar5iv 兜底
self.download_arxiv_html_with_fallback(&arxiv_id, &html_dest)
.await
} else {
self.download_html_direct(&resolved, &html_dest, "PUB_HTML")
.await
};
match result {
Ok(_) => {
html_res = Ok(html_dest.clone());
break 'html;
}
Err(e) => {
let msg = format!("PUB_HTML下载失败: {}", e);
warn!("{}", msg);
html_errors.push(msg);
}
}
}
Err(e) => {
let msg = format!("PUB_HTML网关解析失败: {}", e);
warn!("{}", msg);
html_errors.push(msg);
}
}
// 2b. ADS EPRINT_HTML 网关(大多数天文论文有 arXiv eprint
let gw = format!("{}/{}/EPRINT_HTML", base, bibcode);
match self.resolve_ads_gateway(&gw).await {
Ok(resolved) => {
let result = if let Some(arxiv_id) = extract_arxiv_id_from_url(&resolved) {
self.download_arxiv_html_with_fallback(&arxiv_id, &html_dest)
.await
} else {
self.download_html_direct(&resolved, &html_dest, "EPRINT_HTML")
.await
};
match result {
Ok(_) => {
html_res = Ok(html_dest.clone());
}
Err(e) => {
let msg = format!("EPRINT_HTML下载失败: {}", e);
warn!("{}", msg);
html_errors.push(msg);
}
}
}
Err(e) => {
let msg = format!("EPRINT_HTML网关解析失败: {}", e);
warn!("{}", msg);
html_errors.push(msg);
}
}
}
if html_res.is_err() && !html_errors.is_empty() {
html_res = Err(html_errors.join("; "));
}
(pdf_res, html_res)
}
/// 高层文献下载服务,处理数据库记录、多级回退下载、数据库路径写回
pub async fn download_paper_service(
&self,
db: &sqlx::SqlitePool,
library_dir: &std::path::Path,
bibcode: &str,
force: bool,
) -> anyhow::Result<StandardPaper> {
let paper = get_paper_from_db(db, library_dir, bibcode).await?;
if force {
sqlx::query("UPDATE papers SET pdf_path = NULL, html_path = NULL WHERE bibcode = ?")
.bind(bibcode)
.execute(db)
.await?;
} else {
// 如果已经下载了,直接返回
let paths = check_paper_paths_in_db(db, library_dir, bibcode).await?;
if let Some((pdf_opt, html_opt, _, _)) = paths {
if pdf_opt.is_some() || html_opt.is_some() {
let mut updated_paper = paper.clone();
updated_paper.is_downloaded = true;
updated_paper.has_pdf = pdf_opt.is_some();
updated_paper.has_html = html_opt.is_some();
return Ok(updated_paper);
}
}
}
// 调用底层的 download pipeline
let (pdf_res, html_res) = if !paper.arxiv_id.is_empty() {
info!("[下载] 优先使用 arXiv 通道: {}", paper.arxiv_id);
let res = self
.download_arxiv_direct(&paper.arxiv_id, library_dir)
.await;
if res.0.is_ok() || res.1.is_ok() {
res
} else {
warn!(
"[下载] arXiv 通道下载失败,开始回退至 ADS/出版商通道: {}",
bibcode
);
let doi_opt = if !paper.doi.is_empty() {
Some(paper.doi.as_str())
} else {
None
};
self.download_paper(bibcode, doi_opt, library_dir).await
}
} else {
let doi_opt = if !paper.doi.is_empty() {
Some(paper.doi.as_str())
} else {
None
};
self.download_paper(bibcode, doi_opt, library_dir).await
};
if let (Err(ref pdf_err), Err(ref html_err)) = (&pdf_res, &html_res) {
error!(
"文献 {} PDF 和 HTML 均下载失败,无可用物理文件格式",
bibcode
);
let pdf_db_err = format!("error: {}", pdf_err);
let html_db_err = format!("error: {}", html_err);
let _ = sqlx::query("UPDATE papers SET pdf_path = ?, html_path = ? WHERE bibcode = ?")
.bind(&pdf_db_err)
.bind(&html_db_err)
.bind(bibcode)
.execute(db)
.await;
return Err(anyhow::anyhow!(
"下载失败。PDF: {}, HTML: {}",
pdf_err,
html_err
));
}
let pdf_rel = match pdf_res {
Ok(p) => Some(
p.strip_prefix(library_dir)
.unwrap_or(&p)
.to_string_lossy()
.to_string(),
),
Err(_) => None,
};
let html_rel = match html_res {
Ok(p) => Some(
p.strip_prefix(library_dir)
.unwrap_or(&p)
.to_string_lossy()
.to_string(),
),
Err(_) => None,
};
sqlx::query("UPDATE papers SET pdf_path = ?, html_path = ? WHERE bibcode = ?")
.bind(&pdf_rel)
.bind(&html_rel)
.bind(bibcode)
.execute(db)
.await?;
let mut updated_paper = paper;
updated_paper.is_downloaded = true;
updated_paper.has_pdf = pdf_rel.is_some();
updated_paper.has_html = html_rel.is_some();
Ok(updated_paper)
}
}
// ─── 自由辅助函数 ──────────────────────────────────────────────
fn strip_arxiv_version(arxiv_id: &str) -> String {
use regex::Regex;
static RE_VERSION: std::sync::OnceLock<Regex> = std::sync::OnceLock::new();
let re = RE_VERSION.get_or_init(|| Regex::new(r"v\d+$").unwrap());
re.replace(arxiv_id, "").to_string()
}
fn extract_arxiv_id_from_url(url: &str) -> Option<String> {
let patterns = [
"arxiv.org/abs/",
"arxiv.org/pdf/",
"arxiv.org/html/",
"ar5iv.labs.arxiv.org/html/",
"ar5iv.org/abs/",
"ar5iv.org/html/",
];
for pat in &patterns {
if let Some(pos) = url.find(pat) {
let id_raw = &url[pos + pat.len()..];
let mut id_clean = id_raw
.split('?')
.next()
.unwrap_or(id_raw)
.split('#')
.next()
.unwrap_or(id_raw)
.trim_end_matches('/')
.to_string();
if id_clean.to_lowercase().ends_with(".pdf") {
id_clean.truncate(id_clean.len() - 4);
}
if id_clean.to_lowercase().ends_with(".html") {
id_clean.truncate(id_clean.len() - 5);
}
let id = strip_arxiv_version(&id_clean);
if !id.is_empty() {
return Some(id);
}
}
}
None
}
#[cfg(test)]
mod tests {
use super::headers::*;
use super::*;
use axum::{response::Redirect, routing::get, Router};
#[tokio::test]
async fn test_resolve_ads_gateway_perfdrive() {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
listener.set_nonblocking(true).unwrap();
let port = listener.local_addr().unwrap().port();
let target_ssc = "https%3A%2F%2Fexample.com%2Ftarget.pdf";
let redirect_to = format!("https://validate.perfdrive.com/?ssc={}", target_ssc);
let app = Router::new().route(
"/gate",
get(move || {
let r = redirect_to.clone();
async move { Redirect::to(&r) }
}),
);
let server = axum::serve(tokio::net::TcpListener::from_std(listener).unwrap(), app);
tokio::spawn(async move {
let _ = server.await;
});
let downloader = Downloader::new().expect("Failed to create downloader in test");
let gateway_url = format!("http://127.0.0.1:{}/gate", port);
let result = downloader.resolve_ads_gateway(&gateway_url).await;
assert_eq!(result.unwrap(), "https://example.com/target.pdf");
}
#[test]
fn test_validate_pdf_content_valid() {
let mut pdf = b"%PDF-1.7 ".to_vec();
pdf.extend(vec![0u8; 5100]);
pdf.extend(b"%%EOF");
assert!(validate_pdf_content(&pdf).is_ok());
}
#[test]
fn test_validate_pdf_content_html() {
let html = b"<html><body>please log in</body></html>".to_vec();
let result = validate_pdf_content(&html);
assert!(result.is_err());
}
#[test]
fn test_detect_anti_bot_cloudflare() {
let result = detect_anti_bot("just a moment please", None);
assert!(result.is_err());
}
#[test]
fn test_detect_anti_bot_clean() {
let result = detect_anti_bot(
"<html><body><h1>Abstract</h1><p>We study...</p></body></html>",
None,
);
assert!(result.is_ok());
}
#[test]
fn test_strip_arxiv_version() {
assert_eq!(strip_arxiv_version("2101.00001v2"), "2101.00001");
assert_eq!(strip_arxiv_version("hep-th/9901001v3"), "hep-th/9901001");
assert_eq!(strip_arxiv_version("2101.00001"), "2101.00001");
}
#[test]
fn test_extract_arxiv_id_from_url() {
assert_eq!(
extract_arxiv_id_from_url("https://arxiv.org/abs/2101.00001v2"),
Some("2101.00001".to_string())
);
assert_eq!(
extract_arxiv_id_from_url("https://arxiv.org/pdf/hep-th/9901001v3.pdf"),
Some("hep-th/9901001".to_string())
);
assert_eq!(
extract_arxiv_id_from_url("https://ar5iv.labs.arxiv.org/html/2101.00001"),
Some("2101.00001".to_string())
);
}
#[tokio::test]
#[ignore]
async fn test_download_scan_pdf() -> anyhow::Result<()> {
let downloader = Downloader::new().expect("Failed to create downloader in test");
let bibcode = "2005MNRAS.359..315E";
let temp_dir = std::env::temp_dir();
let (pdf_path, _html_path) = downloader.download_paper(bibcode, None, &temp_dir).await;
assert!(pdf_path.is_ok());
let path = pdf_path.unwrap();
assert!(path.exists());
let bytes = std::fs::read(&path)?;
assert!(bytes.starts_with(b"%PDF"));
let _ = std::fs::remove_file(&path);
Ok(())
}
#[tokio::test]
#[ignore]
async fn test_download_via_obscura_integration() -> anyhow::Result<()> {
use axum::http::{header::CONTENT_TYPE, HeaderValue};
use axum::{response::Response as AxumResponse, routing::get, Router};
// Bind to a random port
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
listener.set_nonblocking(true).unwrap();
let port = listener.local_addr().unwrap().port();
// Build a mock PDF response
let mut pdf_data = b"%PDF-1.7 ".to_vec();
pdf_data.extend(vec![0u8; 5100]);
pdf_data.extend(b"%%EOF");
let pdf_data_clone = pdf_data.clone();
let app = Router::new()
.route("/mock.pdf", get(move || {
let p = pdf_data_clone.clone();
async move {
AxumResponse::builder()
.header(CONTENT_TYPE, "application/pdf")
.body(axum::body::Body::from(p))
.unwrap()
}
}))
.route("/mock.html", get(move || async {
AxumResponse::builder()
.header(CONTENT_TYPE, "text/html")
.body(axum::body::Body::from("<html><body><h2>introduction</h2><div class=\"section\">This is a mock paper with references and class section.</div><div id=\"bib\">References</div></body></html>"))
.unwrap()
}));
let server = axum::serve(tokio::net::TcpListener::from_std(listener).unwrap(), app);
tokio::spawn(async move {
let _ = server.await;
});
// Temporarily set OBSCURA_ALLOW_PRIVATE_NETWORK=1 to allow loopback fetches in Obscura
std::env::set_var("OBSCURA_ALLOW_PRIVATE_NETWORK", "1");
let downloader = Downloader::new().expect("Failed to create downloader in test");
let temp_dir = std::env::temp_dir();
// 1. Test HTML download via obscura
let html_dest = temp_dir.join("test_obscura_mock.html");
let html_url = format!("http://127.0.0.1:{}/mock.html", port);
downloader
.download_via_obscura(&html_url, &html_dest, false)
.await?;
assert!(html_dest.exists());
let html_content = std::fs::read_to_string(&html_dest)?;
assert!(html_content.contains("introduction"));
let _ = std::fs::remove_file(&html_dest);
// 2. Test PDF download via obscura
let pdf_dest = temp_dir.join("test_obscura_mock.pdf");
let pdf_url = format!("http://127.0.0.1:{}/mock.pdf", port);
downloader
.download_via_obscura(&pdf_url, &pdf_dest, true)
.await?;
assert!(pdf_dest.exists());
let pdf_content = std::fs::read(&pdf_dest)?;
assert_eq!(pdf_content, pdf_data);
let _ = std::fs::remove_file(&pdf_dest);
std::env::remove_var("OBSCURA_ALLOW_PRIVATE_NETWORK");
Ok(())
}
}

View File

@ -0,0 +1,556 @@
// src/services/download/strategies.rs
//
// 内部下载策略实现Obscura 浏览器集成、出版策略IOP/Springer/CrossRef
// HTTP 辅助工具(流式下载、网关解析、随机延迟)。
use super::headers::{
build_browser_headers, build_chrome_headers, validate_external_url, validate_html_content,
validate_pdf_content,
};
use super::Downloader;
use anyhow::{Context, Result};
use std::path::Path;
use tracing::{debug, info, warn};
use url::Url;
// ─── Obscura 浏览器集成 ────────────────────────────────────────
impl Downloader {
/// 使用 Obscura 作为后备通道进行下载
pub(super) async fn download_via_obscura(
&self,
url: &str,
dest_path: &Path,
is_pdf: bool,
) -> Result<()> {
info!("[Obscura 后备通道] 启动下载: {}", url);
if let Some(parent) = dest_path.parent() {
std::fs::create_dir_all(parent)?;
}
#[cfg(feature = "obscura-inprocess")]
{
info!("[Obscura 后备通道] 正在运行进程内浏览器进行下载...");
self.download_via_inprocess_obscura(url, dest_path, is_pdf)
.await
}
#[cfg(not(feature = "obscura-inprocess"))]
{
info!("[Obscura 后备通道] 正在通过外部命令行子进程下载...");
self.download_via_cli_obscura(url, dest_path, is_pdf).await
}
}
#[cfg(feature = "obscura-inprocess")]
async fn download_via_inprocess_obscura(
&self,
url: &str,
dest_path: &Path,
is_pdf: bool,
) -> Result<()> {
let url_str = url.to_string();
let dest_path_buf = dest_path.to_path_buf();
let handle = tokio::task::spawn_blocking(move || {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_err(|e| anyhow::anyhow!("建立当前线程运行时失败: {}", e))?;
rt.block_on(async move {
use obscura_browser::{lifecycle::WaitUntil, BrowserContext, Page};
use std::sync::Arc;
// 1. 初始化启用 Stealth 防检测模式的浏览器上下文
let context = Arc::new(BrowserContext::with_full_options(
"inprocess-fetch".to_string(),
None, // 可选代理
true, // 启用 Stealth 伪装(防指纹探测 + 广告域拦截)
None, // 默认 User-Agent
));
let mut page = Page::new("fetch-page".to_string(), context.clone());
// 2. 导航至目标 URL 并等待事件循环静默
page.navigate_with_wait(&url_str, WaitUntil::Load)
.await
.map_err(|e| anyhow::anyhow!("导航失败: {:?}", e))?;
page.settle(5000).await; // 额外静默等待 5 秒
// 3. 处理 PDF 与 HTML
if is_pdf {
// 对于 PDF 二进制文件,直接复用该浏览器上下文自带的 HTTP 客户端进行请求,
// 这样能确保携带相同的 Cookie 和 TLS 指纹会话
let parsed_url = Url::parse(&url_str)?;
let response = page
.http_client
.fetch(&parsed_url)
.await
.map_err(|e| anyhow::anyhow!("获取 PDF 字节流失败: {:?}", e))?;
std::fs::write(&dest_path_buf, &response.body)?;
validate_pdf_content(&response.body)?;
} else {
// 对于 HTML直接从 V8 中提取 outerHTML
let val = page.evaluate("document.documentElement.outerHTML");
let html = val.as_str().unwrap_or("").to_string();
std::fs::write(&dest_path_buf, &html)?;
validate_html_content(&html)?;
}
Ok(())
})
});
match handle.await {
Ok(Ok(())) => {
info!("[Obscura 进程内后备通道] 下载并校验成功: {:?}", dest_path);
Ok(())
}
Ok(Err(e)) => {
warn!("[Obscura 进程内后备通道] 下载或校验失败: {:?}", e);
let _ = std::fs::remove_file(dest_path); // 清理校验失败的残留文件
Err(e)
}
Err(e) => {
let _ = std::fs::remove_file(dest_path); // 清理校验失败的残留文件
anyhow::bail!("进程内 Obscura 执行线程异常退出: {:?}", e)
}
}
}
#[cfg(not(feature = "obscura-inprocess"))]
async fn download_via_cli_obscura(
&self,
url: &str,
dest_path: &Path,
is_pdf: bool,
) -> Result<()> {
let mut cmd = tokio::process::Command::new("bin/obscura");
cmd.arg("fetch").arg(url).arg("--stealth");
if is_pdf {
cmd.arg("--dump").arg("original");
} else {
cmd.arg("--dump").arg("html");
}
cmd.arg("--output").arg(dest_path);
let status = cmd
.status()
.await
.context("启动 Obscura 进程失败,请检查 bin/obscura 是否存在且有执行权限")?;
if !status.success() {
let _ = tokio::fs::remove_file(dest_path).await; // 清理可能的残留坏文件
anyhow::bail!("Obscura 进程退出状态非成功: {:?}", status);
}
// 校验下载得到的文件
if is_pdf {
let bytes = tokio::fs::read(dest_path).await?;
if let Err(e) = validate_pdf_content(&bytes) {
let _ = tokio::fs::remove_file(dest_path).await; // 清理校验失败的残留文件
return Err(e);
}
} else {
let text = tokio::fs::read_to_string(dest_path).await?;
if let Err(e) = validate_html_content(&text) {
let _ = tokio::fs::remove_file(dest_path).await; // 清理校验失败的残留文件
return Err(e);
}
}
info!("[Obscura 命令行后备通道] 下载并校验成功: {:?}", dest_path);
Ok(())
}
}
// ─── HTTP 辅助工具 ────────────────────────────────────────────
impl Downloader {
/// 请求前随机延迟 500-2000ms模拟人类浏览间隔降低反爬触发
pub(super) async fn maybe_delay() {
let delay_ms = 500 + (rand::random::<u64>() % 1500);
tokio::time::sleep(std::time::Duration::from_millis(delay_ms)).await;
}
/// 流式下载 HTTP 响应到本地文件(逐块写入,支持大文件)
pub(super) async fn stream_download(
&self,
response: reqwest::Response,
target_path: &Path,
) -> Result<()> {
use futures_util::StreamExt;
if let Some(parent) = target_path.parent() {
std::fs::create_dir_all(parent)?;
}
let mut file = tokio::fs::File::create(target_path)
.await
.context("创建目标文件失败")?;
let mut stream = response.bytes_stream();
while let Some(chunk) = stream.next().await {
let bytes = chunk.context("读取响应流时出错")?;
tokio::io::AsyncWriteExt::write_all(&mut file, &bytes).await?;
}
tokio::io::AsyncWriteExt::flush(&mut file).await?;
Ok(())
}
/// 解析 ADS Link Gateway 路由,若遇 perfdrive 防护则提取 ssc 参数绕过
pub(super) async fn resolve_ads_gateway(&self, gateway_url: &str) -> Result<String> {
debug!("解析 ADS 网关: {}", gateway_url);
// HEAD 请求跟踪重定向(部分出版商阻断 HEAD自动降级 GET
let response = match self.client.head(gateway_url).send().await {
Ok(resp) => resp,
Err(_) => self
.client
.get(gateway_url)
.send()
.await
.context(format!("请求 ADS 网关失败: {}", gateway_url))?,
};
let final_url = response.url().as_str().to_string();
debug!("网关解析结果: {}", final_url);
// 如重定向至 validate.perfdrive.com提取 ssc 参数中的真实 URL
if final_url.contains("validate.perfdrive.com") {
if let Ok(parsed) = Url::parse(&final_url) {
if let Some(ssc) = parsed
.query_pairs()
.find(|(k, _)| k == "ssc")
.map(|(_, v)| v.into_owned())
{
if let Ok(decoded) = urlencoding::decode(&ssc) {
let real_url = decoded.into_owned();
debug!("检测到 perfdrive 拦截,解码真实地址: {}", real_url);
return Ok(real_url);
}
}
}
}
// 排除解析失败后仍停留在 link_gateway 的情况
if final_url.contains("link_gateway") || final_url.is_empty() {
anyhow::bail!("ADS 网关未能解析到有效目标(仍在 link_gateway");
}
Ok(final_url)
}
/// 读取文件前 512 字节用于内容嗅探
#[allow(dead_code)]
pub(super) async fn read_file_header(path: &Path) -> Result<Vec<u8>> {
use tokio::io::AsyncReadExt;
let mut file = tokio::fs::File::open(path).await?;
let mut buf = vec![0u8; 512];
let n = file.read(&mut buf).await?;
buf.truncate(n);
Ok(buf)
}
}
// ─── 特定出版商下载策略 ────────────────────────────────────────
impl Downloader {
/// IOP Science PDF 下载
/// 参考 datasheel iop.rs先访问主页建立会话获取 Cookie再请求 PDF
pub(super) async fn download_iop_pdf(&self, doi: &str, dest_path: &Path) -> Result<()> {
let main_url = format!("https://iopscience.iop.org/article/{}", doi);
let pdf_url = format!("https://iopscience.iop.org/article/{}/pdf", doi);
let res = async {
// 步骤 1访问文章主页建立 Cookie 会话
debug!("[IOP] 预热主页: {}", main_url);
Self::maybe_delay().await;
match self
.client
.get(&main_url)
.headers(build_chrome_headers(None))
.send()
.await
{
Ok(r) => debug!("[IOP] 主页响应: {}", r.status()),
Err(e) => warn!("[IOP] 主页访问失败(继续尝试): {:?}", e),
}
// 步骤 2携带 Referer 下载 PDF
debug!("[IOP] 下载 PDF: {}", pdf_url);
Self::maybe_delay().await;
let response = self
.client
.get(&pdf_url)
.headers(build_chrome_headers(Some(&main_url)))
.send()
.await
.context("IOP PDF 请求失败")?;
let status = response.status();
if !status.is_success() {
anyhow::bail!("[IOP] 返回 HTTP {}", status);
}
self.stream_download(response, dest_path).await?;
// 步骤 3校验下载内容
let bytes = tokio::fs::read(dest_path).await?;
validate_pdf_content(&bytes)?;
Ok(())
}
.await;
match res {
Ok(()) => {
info!("[IOP] PDF 下载成功: {:?}", dest_path);
Ok(())
}
Err(e) => {
let err_msg = e.to_string();
if err_msg.contains("人机验证")
|| err_msg.contains("挑战页面")
|| err_msg.contains("WAF")
|| err_msg.contains("Cloudflare")
|| err_msg.contains("HTTP 403")
|| err_msg.contains("HTTP 503")
{
warn!(
"[IOP] 下载触发人机验证或拦截: {}。尝试使用 Obscura 后备通道...",
err_msg
);
self.download_via_obscura(&pdf_url, dest_path, true).await
} else {
Err(e)
}
}
}
}
/// Springer/Nature HTML 下载(含会话预热)
pub(super) async fn download_springer_html(&self, doi: &str, dest_path: &Path) -> Result<()> {
let url = format!("https://link.springer.com/article/{}", doi);
info!("[Springer] 下载 HTML: {}", url);
let res = async {
Self::maybe_delay().await;
let response = self
.client
.get(&url)
.headers(build_browser_headers())
.send()
.await
.context("Springer HTML 请求失败")?;
let status = response.status();
if !status.is_success() {
anyhow::bail!("[Springer] 返回 HTTP {}", status);
}
self.stream_download(response, dest_path).await?;
let text = tokio::fs::read_to_string(dest_path)
.await
.context("读取 HTML 文件失败")?;
validate_html_content(&text)?;
Ok(())
}
.await;
match res {
Ok(()) => {
info!("[Springer] HTML 下载成功: {:?}", dest_path);
Ok(())
}
Err(e) => {
let err_msg = e.to_string();
if err_msg.contains("人机验证")
|| err_msg.contains("挑战页面")
|| err_msg.contains("WAF")
|| err_msg.contains("Cloudflare")
|| err_msg.contains("HTTP 403")
|| err_msg.contains("HTTP 503")
{
warn!(
"[Springer] 下载触发人机验证或拦截: {}。尝试使用 Obscura 后备通道...",
err_msg
);
self.download_via_obscura(&url, dest_path, false).await
} else {
Err(e)
}
}
}
}
/// 通用 PDF 直链下载(带随机延迟 + 内容校验 + Obscura 后备)
pub(super) async fn download_pdf_direct(
&self,
url: &str,
dest_path: &Path,
label: &str,
) -> Result<()> {
info!("[{}] 下载 PDF: {}", label, url);
Self::maybe_delay().await;
let res = async {
let response = self
.client
.get(url)
.headers(build_browser_headers())
.send()
.await
.context(format!("[{}] PDF 请求失败", label))?;
let status = response.status();
if !status.is_success() {
anyhow::bail!("[{}] 返回 HTTP {}", label, status);
}
self.stream_download(response, dest_path).await?;
let bytes = tokio::fs::read(dest_path).await?;
validate_pdf_content(&bytes)?;
Ok(())
}
.await;
match res {
Ok(()) => {
info!("[{}] PDF 下载成功: {:?}", label, dest_path);
Ok(())
}
Err(e) => {
let _ = tokio::fs::remove_file(dest_path).await; // 清理直连失败的残留物理文件
let err_msg = e.to_string();
if err_msg.contains("人机验证")
|| err_msg.contains("挑战页面")
|| err_msg.contains("WAF")
|| err_msg.contains("Cloudflare")
|| err_msg.contains("HTTP 403")
|| err_msg.contains("HTTP 503")
{
warn!(
"[{}] 下载触发人机验证或拦截: {}。尝试使用 Obscura 后备通道...",
label, err_msg
);
self.download_via_obscura(url, dest_path, true).await
} else {
Err(e)
}
}
}
}
/// 通用 HTML 直链下载(带随机延迟 + 反爬检测 + Obscura 后备)
pub(super) async fn download_html_direct(
&self,
url: &str,
dest_path: &Path,
label: &str,
) -> Result<()> {
info!("[{}] 下载 HTML: {}", label, url);
Self::maybe_delay().await;
let res = async {
let response = self
.client
.get(url)
.headers(build_browser_headers())
.send()
.await
.context(format!("[{}] HTML 请求失败", label))?;
let status = response.status();
if !status.is_success() {
anyhow::bail!("[{}] 返回 HTTP {}", label, status);
}
self.stream_download(response, dest_path).await?;
let text = tokio::fs::read_to_string(dest_path)
.await
.context("读取 HTML 文件失败")?;
validate_html_content(&text)?;
Ok(())
}
.await;
match res {
Ok(()) => {
info!("[{}] HTML 下载成功: {:?}", label, dest_path);
Ok(())
}
Err(e) => {
let err_msg = e.to_string();
if err_msg.contains("人机验证")
|| err_msg.contains("挑战页面")
|| err_msg.contains("WAF")
|| err_msg.contains("Cloudflare")
|| err_msg.contains("HTTP 403")
|| err_msg.contains("HTTP 503")
{
warn!(
"[{}] 下载触发人机验证或拦截: {}。尝试使用 Obscura 后备通道...",
label, err_msg
);
self.download_via_obscura(url, dest_path, false).await
} else {
Err(e)
}
}
}
}
// ─── CrossRef 回退通道 ─────────────────────────────────────
/// 通过 CrossRef API 获取 PDF 链接并下载
pub(super) async fn download_crossref_pdf(&self, doi: &str, dest_path: &Path) -> Result<()> {
let api_url = format!("https://api.crossref.org/works/{}", doi);
info!("[CrossRef] 查询 PDF 链接: {}", api_url);
let data: serde_json::Value = self
.client
.get(&api_url)
.header("Accept", "application/json")
.send()
.await
.context("CrossRef API 请求失败")?
.json()
.await
.context("CrossRef API 响应解析失败")?;
let links = data["message"]["link"]
.as_array()
.context("CrossRef 未返回 link 数组")?;
let pdf_url = links
.iter()
.find(|l| {
let ct = l["content-type"].as_str().unwrap_or("");
ct.contains("pdf") || ct == "unspecified"
})
.and_then(|l| l["URL"].as_str())
.context("CrossRef 未找到 PDF 链接")?;
info!("[CrossRef] PDF 链接: {}", pdf_url);
// SSRF 防护CrossRef 返回的 URL 来自第三方,需校验 scheme + host
// 字面量快检 + 异步 DNS 深度校验,拒绝指向内网/保留地址
validate_external_url(pdf_url)
.await
.context("CrossRef PDF 链接不安全")?;
self.download_pdf_direct(pdf_url, dest_path, "CrossRef")
.await
}
}

View File

@ -185,17 +185,17 @@ fn clean_chinese_translation(raw: &str) -> String {
/// 将翻译结果持久化到本地文件并更新数据库路径。 /// 将翻译结果持久化到本地文件并更新数据库路径。
/// ///
/// 此函数抽取自 papers.rs 与 batch/asset.rs 中重复的翻译持久化逻辑: /// 此函数抽取自 papers.rs 与 batch/asset.rs 中重复的翻译持久化逻辑:
/// 1. 写入 `Translation/{bibcode}_zh.md` /// 1. 写入 `Translation/{bibcode}.md`
/// 2. 将相对路径更新到 `papers.translation_path` 列 /// 2. 将相对路径更新到 `papers.translation_path` 列
/// ///
/// 返回写入的相对路径(如 `Translation/2024A&A...123..456X_zh.md`)。 /// 返回写入的相对路径(如 `Translation/2024A&A...123..456X.md`)。
pub async fn persist_translation( pub async fn persist_translation(
db: &sqlx::SqlitePool, db: &sqlx::SqlitePool,
library_dir: &Path, library_dir: &Path,
bibcode: &str, bibcode: &str,
translated_markdown: &str, translated_markdown: &str,
) -> anyhow::Result<String> { ) -> anyhow::Result<String> {
let tr_filename = format!("{}_zh.md", bibcode); let tr_filename = format!("{}.md", bibcode);
let tr_dest = library_dir.join("Translation").join(&tr_filename); let tr_dest = library_dir.join("Translation").join(&tr_filename);
if let Some(parent) = tr_dest.parent() { if let Some(parent) = tr_dest.parent() {
std::fs::create_dir_all(parent)?; std::fs::create_dir_all(parent)?;