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:
parent
5db4cc5998
commit
5f2d2d83f6
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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 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="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">
|
||||
|
||||
@ -315,7 +315,7 @@ export function AIAssistantPanel({
|
||||
<button
|
||||
key={idx}
|
||||
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}
|
||||
</button>
|
||||
@ -333,10 +333,10 @@ export function AIAssistantPanel({
|
||||
{msg.sender === 'user' ? '我' : '天文科研助手'}
|
||||
</span>
|
||||
<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'
|
||||
? 'bg-sky-600 text-white border-sky-600 select-text'
|
||||
: 'bg-white text-slate-800 border-slate-200 select-text'
|
||||
? 'bg-blueprint text-white border-blueprint select-text'
|
||||
: 'bg-white text-slate-800 border-slate-250 select-text'
|
||||
}`}
|
||||
>
|
||||
{msg.sender === 'user' ? (
|
||||
@ -354,9 +354,9 @@ export function AIAssistantPanel({
|
||||
<div className="space-y-2.5">
|
||||
{/* 时间线条目(思考/工具调用/子代理容器)*/}
|
||||
{msg.timeline.length > 0 && (
|
||||
<div className="border border-slate-100 bg-slate-50/50 rounded-lg p-2 select-none">
|
||||
<div className="text-[10px] font-bold text-purple-500 flex items-center gap-1 mb-2 px-1">
|
||||
<Brain className="w-3.5 h-3.5" />
|
||||
<div className="border border-slate-205 bg-slate-100/30 rounded-md p-2 select-none">
|
||||
<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 text-slate-450" />
|
||||
<span>推理与工具链 ({msg.timeline.length} 项)</span>
|
||||
</div>
|
||||
<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"
|
||||
>
|
||||
<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
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
|
||||
@ -64,7 +64,7 @@ export function CitationGalaxyCanvas({ networks, activeNetwork, nodeLimit, onNod
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
radius,
|
||||
color: isActive ? '#0284c7' : '#475569',
|
||||
color: isActive ? '#0369a1' : '#475569',
|
||||
type: 'center',
|
||||
inDb: true,
|
||||
});
|
||||
@ -86,7 +86,7 @@ export function CitationGalaxyCanvas({ networks, activeNetwork, nodeLimit, onNod
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
radius: 8, // 初始值,稍后按连线数重算
|
||||
color: '#d97706',
|
||||
color: '#b45309',
|
||||
type: 'reference',
|
||||
inDb,
|
||||
});
|
||||
@ -112,7 +112,7 @@ export function CitationGalaxyCanvas({ networks, activeNetwork, nodeLimit, onNod
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
radius: 8, // 初始值,稍后按连线数重算
|
||||
color: '#0891b2',
|
||||
color: '#0e7490',
|
||||
type: 'citation',
|
||||
inDb,
|
||||
});
|
||||
@ -221,12 +221,12 @@ export function CitationGalaxyCanvas({ networks, activeNetwork, nodeLimit, onNod
|
||||
ctx.scale(scale, scale);
|
||||
ctx.translate(-cx, -cy);
|
||||
|
||||
// 绘制背景宇宙引力线 & 刻度圈
|
||||
// 绘制背景宇宙引力线 & 刻度圈 - 极其素雅的学术引力线
|
||||
const centerNode = nodes.find(n => n.id === activeNetwork.bibcode);
|
||||
if (centerNode) {
|
||||
ctx.strokeStyle = 'rgba(148, 163, 184, 0.15)';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.setLineDash([4, 6]);
|
||||
ctx.strokeStyle = 'rgba(148, 163, 184, 0.08)';
|
||||
ctx.lineWidth = 0.75;
|
||||
ctx.setLineDash([3, 5]);
|
||||
|
||||
ctx.beginPath();
|
||||
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.stroke();
|
||||
|
||||
// 动态圈
|
||||
ctx.strokeStyle = 'rgba(2, 132, 199, 0.06)';
|
||||
// 动态引力波圈 - 调低对比度,使其呈极其微弱安静的向外平滑脉冲
|
||||
ctx.strokeStyle = 'rgba(3, 105, 161, 0.025)';
|
||||
ctx.setLineDash([]);
|
||||
const pulseRadius = 130 + (frameCount % 120) * 0.4;
|
||||
ctx.beginPath();
|
||||
@ -245,8 +245,8 @@ export function CitationGalaxyCanvas({ networks, activeNetwork, nodeLimit, onNod
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// 绘制连线
|
||||
ctx.lineWidth = 1;
|
||||
// 绘制连线 - 精细线宽
|
||||
ctx.lineWidth = 0.75;
|
||||
links.forEach(link => {
|
||||
const sourceNode = nodes.find(n => n.id === link.source);
|
||||
const targetNode = nodes.find(n => n.id === link.target);
|
||||
@ -254,12 +254,13 @@ export function CitationGalaxyCanvas({ networks, activeNetwork, nodeLimit, onNod
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(sourceNode.x, sourceNode.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.lineWidth = 1;
|
||||
nodes.forEach(node => {
|
||||
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.beginPath();
|
||||
ctx.arc(node.x, node.y, node.radius + (isHovered ? 6 : 3), 0, Math.PI * 2);
|
||||
ctx.fillStyle = node.color + (isHovered ? '25' : '0f');
|
||||
ctx.arc(node.x, node.y, node.radius + (isHovered ? 5 : 2), 0, Math.PI * 2);
|
||||
ctx.fillStyle = node.color + (isHovered ? '20' : '0a');
|
||||
ctx.fill();
|
||||
|
||||
ctx.beginPath();
|
||||
@ -277,13 +278,13 @@ export function CitationGalaxyCanvas({ networks, activeNetwork, nodeLimit, onNod
|
||||
ctx.fill();
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.arc(node.x, node.y, node.radius + (isHovered ? 4 : 2), 0, Math.PI * 2);
|
||||
ctx.strokeStyle = node.color + '40';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.arc(node.x, node.y, node.radius + (isHovered ? 3 : 1.5), 0, Math.PI * 2);
|
||||
ctx.strokeStyle = node.color + '30';
|
||||
ctx.lineWidth = 0.75;
|
||||
ctx.stroke();
|
||||
|
||||
ctx.fillStyle = isHovered ? '#0284c7' : '#334155';
|
||||
ctx.font = isHovered ? 'bold 10px sans-serif' : '9px sans-serif';
|
||||
ctx.fillStyle = isHovered ? '#0369a1' : '#475569';
|
||||
ctx.font = isHovered ? 'bold 9.5px sans-serif' : '9px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(node.label, node.x, node.y + node.radius + (isHovered ? 16 : 12));
|
||||
|
||||
|
||||
@ -47,20 +47,20 @@ export function CustomSelect({
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
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 ${
|
||||
isOpen ? 'border-sky-500 ring-1 ring-sky-500 bg-white' : ''
|
||||
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-blueprint ring-1 ring-blueprint bg-white' : ''
|
||||
} ${disabled ? 'bg-slate-50 text-slate-450 cursor-not-allowed border-slate-200' : ''}`}
|
||||
>
|
||||
<span className="truncate">{selectedOption?.label}</span>
|
||||
<ChevronDown
|
||||
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>
|
||||
|
||||
{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 => {
|
||||
const isSelected = option.value === value;
|
||||
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 ${
|
||||
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'
|
||||
}`}
|
||||
>
|
||||
|
||||
117
dashboard/src/components/PaperCard.tsx
Normal file
117
dashboard/src/components/PaperCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -58,7 +58,7 @@ export function AgentInputArea({
|
||||
}}
|
||||
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 && (
|
||||
<div className="relative inline-block mb-1 group select-none self-start">
|
||||
@ -113,8 +113,8 @@ export function AgentInputArea({
|
||||
title="上传或粘贴图片(也可直接 Ctrl+V 粘贴)"
|
||||
className={`p-1.5 rounded-lg border text-[10px] font-bold transition-all cursor-pointer flex items-center gap-1 ${
|
||||
pendingImage
|
||||
? 'bg-sky-50 border-sky-300 text-sky-700'
|
||||
: 'bg-white border-slate-200 text-slate-400 hover:text-sky-600 hover:border-sky-200'
|
||||
? 'bg-blueprint/5 border-blueprint/30 text-blueprint'
|
||||
: 'bg-white border-slate-200 text-slate-400 hover:text-blueprint hover:border-blueprint/20'
|
||||
} disabled:opacity-60`}
|
||||
>
|
||||
<Paperclip className="w-3.5 h-3.5" />
|
||||
@ -134,11 +134,11 @@ export function AgentInputArea({
|
||||
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 ${
|
||||
isSelected
|
||||
? 'bg-white text-indigo-700 shadow-xs border-slate-200/50'
|
||||
: 'bg-transparent border-transparent text-slate-400 hover:text-indigo-600'
|
||||
? 'bg-white text-blueprint shadow-xs border-slate-200/50'
|
||||
: 'bg-transparent border-transparent text-slate-400 hover:text-blueprint'
|
||||
} 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>
|
||||
</button>
|
||||
);
|
||||
@ -156,11 +156,11 @@ export function AgentInputArea({
|
||||
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 ${
|
||||
thinking
|
||||
? 'bg-sky-50 border-sky-205 text-sky-700 shadow-3xs'
|
||||
: 'bg-white border-slate-200 text-slate-400 hover:text-sky-600 hover:border-sky-200'
|
||||
? 'bg-blueprint/5 border-blueprint/30 text-blueprint shadow-3xs'
|
||||
: 'bg-white border-slate-200 text-slate-400 hover:text-blueprint hover:border-blueprint/20'
|
||||
} 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>
|
||||
</button>
|
||||
</>
|
||||
@ -172,7 +172,7 @@ export function AgentInputArea({
|
||||
<button
|
||||
type="button"
|
||||
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="手动停止执行"
|
||||
>
|
||||
<Square className="w-3.5 h-3.5 fill-white" />
|
||||
@ -181,7 +181,7 @@ export function AgentInputArea({
|
||||
<button
|
||||
type="submit"
|
||||
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" />
|
||||
</button>
|
||||
|
||||
@ -200,8 +200,8 @@ export function AgentMessageList({
|
||||
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 ${
|
||||
showMetrics
|
||||
? 'bg-sky-50 border-sky-200 text-sky-700'
|
||||
: 'bg-white border-slate-200 text-slate-500 hover:text-sky-600 hover:border-sky-200'
|
||||
? 'bg-blueprint/5 border-blueprint/20 text-blueprint'
|
||||
: 'bg-white border-slate-200 text-slate-500 hover:text-blueprint hover:border-blueprint/20'
|
||||
}`}
|
||||
title="运行指标"
|
||||
>
|
||||
@ -276,9 +276,9 @@ export function AgentMessageList({
|
||||
<button
|
||||
key={idx}
|
||||
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>
|
||||
</button>
|
||||
))}
|
||||
@ -303,9 +303,9 @@ export function AgentMessageList({
|
||||
<Rewind className="w-3.5 h-3.5" />
|
||||
</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 && (
|
||||
<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>
|
||||
@ -334,9 +334,9 @@ export function AgentMessageList({
|
||||
{/* 当前提问 */}
|
||||
<div className="flex flex-col items-end space-y-1">
|
||||
<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 && (
|
||||
<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>
|
||||
@ -353,10 +353,10 @@ export function AgentMessageList({
|
||||
{activeTurn.finalAnswer && (
|
||||
<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">
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@ -365,7 +365,7 @@ export function AgentMessageList({
|
||||
{/* 活跃的加载指示器 */}
|
||||
{streaming && !activeTurn.finalAnswer && !activeTurn.error && (
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -107,7 +107,7 @@ export function AgentMetricsPanel({ showAlert }: AgentMetricsPanelProps) {
|
||||
{/* 头部 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<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>
|
||||
@ -115,7 +115,7 @@ export function AgentMetricsPanel({ showAlert }: AgentMetricsPanelProps) {
|
||||
<button
|
||||
onClick={fetchMetrics}
|
||||
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="刷新指标"
|
||||
>
|
||||
<RefreshCw className={`w-3.5 h-3.5 ${loading ? 'animate-spin' : ''}`} />
|
||||
@ -124,7 +124,7 @@ export function AgentMetricsPanel({ showAlert }: AgentMetricsPanelProps) {
|
||||
|
||||
{loading && !metrics ? (
|
||||
<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>
|
||||
</div>
|
||||
) : metrics ? (
|
||||
@ -228,15 +228,15 @@ function MetricCard({
|
||||
color: string;
|
||||
}) {
|
||||
const colorMap: Record<string, string> = {
|
||||
sky: 'border-sky-200 bg-sky-50 text-sky-700',
|
||||
emerald: 'border-emerald-200 bg-emerald-50 text-emerald-700',
|
||||
violet: 'border-violet-200 bg-violet-50 text-violet-700',
|
||||
rose: 'border-rose-200 bg-rose-50 text-rose-700',
|
||||
amber: 'border-amber-200 bg-amber-50 text-amber-700',
|
||||
sky: 'border-slate-200 bg-slate-50 text-blueprint',
|
||||
emerald: 'border-emerald-200 bg-emerald-50/40 text-emerald-700',
|
||||
violet: 'border-violet-200 bg-violet-50/40 text-violet-700',
|
||||
rose: 'border-rose-200 bg-rose-50/40 text-rose-700',
|
||||
amber: 'border-amber-200 bg-amber-50/40 text-amber-700',
|
||||
};
|
||||
|
||||
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">
|
||||
<span className="opacity-60">{icon}</span>
|
||||
<span className="text-[10px] font-bold uppercase tracking-wider opacity-70">{label}</span>
|
||||
|
||||
@ -42,13 +42,13 @@ export function AgentSessionSidebar({
|
||||
<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">
|
||||
<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>}
|
||||
</span>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<button
|
||||
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="新建会话"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
@ -71,7 +71,7 @@ export function AgentSessionSidebar({
|
||||
value={searchQuery}
|
||||
onChange={e => setSearchQuery(e.target.value)}
|
||||
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">
|
||||
<Search className="w-3.5 h-3.5 text-slate-400" />
|
||||
@ -91,7 +91,7 @@ export function AgentSessionSidebar({
|
||||
type="checkbox"
|
||||
checked={searchScopeOnlyCurrent}
|
||||
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>
|
||||
</label>
|
||||
@ -102,7 +102,7 @@ export function AgentSessionSidebar({
|
||||
{searchQuery.trim() ? (
|
||||
loadingSearch ? (
|
||||
<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>
|
||||
</div>
|
||||
) : searchResults.length === 0 ? (
|
||||
@ -119,9 +119,9 @@ export function AgentSessionSidebar({
|
||||
<button
|
||||
key={`${result.session_id}-${idx}`}
|
||||
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
|
||||
? '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'
|
||||
}`}
|
||||
>
|
||||
@ -129,12 +129,12 @@ export function AgentSessionSidebar({
|
||||
<span className="text-[10px] font-bold text-slate-400 flex items-center gap-1">
|
||||
{isMessage ? (
|
||||
<>
|
||||
<MessageSquare className="w-3 h-3 text-purple-400" />
|
||||
<MessageSquare className="w-3 h-3 text-slate-400" />
|
||||
<span>{role === 'user' ? '提问' : '解答'}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<BookOpen className="w-3 h-3 text-sky-500" />
|
||||
<BookOpen className="w-3 h-3 text-blueprint" />
|
||||
<span>会话</span>
|
||||
</>
|
||||
)}
|
||||
@ -162,7 +162,7 @@ export function AgentSessionSidebar({
|
||||
// 正常的会话列表渲染
|
||||
loadingSessions ? (
|
||||
<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>
|
||||
</div>
|
||||
) : sessions.length === 0 ? (
|
||||
@ -176,10 +176,10 @@ export function AgentSessionSidebar({
|
||||
<button
|
||||
key={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
|
||||
? 'bg-sky-50 border-sky-200 text-sky-850 font-bold shadow-2xs'
|
||||
: 'border-transparent bg-transparent hover:bg-slate-100 text-slate-650'
|
||||
? 'bg-blueprint/5 border-blueprint text-blueprint font-bold shadow-2xs'
|
||||
: '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">
|
||||
|
||||
@ -134,13 +134,13 @@ export function AskUserQuestionCard({ onAnswered, onQuestionCountChange }: AskUs
|
||||
return (
|
||||
<div
|
||||
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">
|
||||
<MessageCircle className="h-3.5 w-3.5 text-amber-550 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">
|
||||
<MessageCircle className="h-3.5 w-3.5 text-blueprint 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 || '提问'}
|
||||
</span>
|
||||
<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
|
||||
type="button"
|
||||
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 ? '收起选项' : '展开选项'}
|
||||
</button>
|
||||
@ -180,20 +180,20 @@ export function AskUserQuestionCard({ onAnswered, onQuestionCountChange }: AskUs
|
||||
key={idx}
|
||||
onClick={() => toggleOption(q.question_id, label, multiSelect)}
|
||||
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
|
||||
? 'bg-sky-50/70 border-sky-300 text-sky-850 shadow-3xs'
|
||||
: 'bg-white border-slate-200/60 text-slate-700 hover:border-sky-200 hover:bg-sky-50/50'
|
||||
? 'bg-blueprint/5 border-blueprint text-blueprint font-bold'
|
||||
: 'bg-white border-slate-200/60 text-slate-700 hover:border-slate-300 hover:bg-slate-50'
|
||||
} disabled:opacity-50`}
|
||||
>
|
||||
{/* 选择指示器 */}
|
||||
{multiSelect ? (
|
||||
selected
|
||||
? <CheckSquare className="w-3.5 h-3.5 text-sky-600 shrink-0" />
|
||||
: <Square className="w-3.5 h-3.5 text-slate-450 shrink-0" />
|
||||
? <CheckSquare className="w-3.5 h-3.5 text-blueprint 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 ${
|
||||
selected ? 'border-sky-600 bg-sky-600' : 'border-slate-350'
|
||||
selected ? 'border-blueprint bg-blueprint' : 'border-slate-300'
|
||||
}`}>
|
||||
{selected && (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
@ -227,13 +227,13 @@ export function AskUserQuestionCard({ onAnswered, onQuestionCountChange }: AskUs
|
||||
disabled={isSubmitting}
|
||||
placeholder="补充说明(可选)..."
|
||||
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>
|
||||
|
||||
{/* 错误提示 */}
|
||||
{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}
|
||||
</div>
|
||||
)}
|
||||
@ -243,7 +243,7 @@ export function AskUserQuestionCard({ onAnswered, onQuestionCountChange }: AskUs
|
||||
<button
|
||||
onClick={() => handleSubmit(q.question_id)}
|
||||
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 ? (
|
||||
<>
|
||||
@ -260,7 +260,7 @@ export function AskUserQuestionCard({ onAnswered, onQuestionCountChange }: AskUs
|
||||
<button
|
||||
onClick={() => dismissQuestion(q.question_id)}
|
||||
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>
|
||||
|
||||
@ -73,7 +73,7 @@ export function AuditLogViewer({ sessionId, onClose }: AuditLogViewerProps) {
|
||||
{/* 头部 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<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>
|
||||
@ -91,15 +91,15 @@ export function AuditLogViewer({ sessionId, onClose }: AuditLogViewerProps) {
|
||||
{/* 汇总 */}
|
||||
{entries.length > 0 && (
|
||||
<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-[9px] font-bold text-emerald-500">成功</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-[9px] font-bold text-rose-500">失败</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-[9px] font-bold text-slate-500">总耗时</div>
|
||||
</div>
|
||||
@ -109,14 +109,14 @@ export function AuditLogViewer({ sessionId, onClose }: AuditLogViewerProps) {
|
||||
{/* 加载/错误状态 */}
|
||||
{loading && (
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
{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">
|
||||
<AlertTriangle className="w-3.5 h-3.5" />
|
||||
{error}
|
||||
<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 text-rose-500 shrink-0 mt-0.2" />
|
||||
<div>{error}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -190,12 +190,12 @@ export function AuditLogViewer({ sessionId, onClose }: AuditLogViewerProps) {
|
||||
<div>
|
||||
{isExpanded ? (
|
||||
<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}
|
||||
</pre>
|
||||
<button
|
||||
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" />
|
||||
收起
|
||||
@ -204,7 +204,7 @@ export function AuditLogViewer({ sessionId, onClose }: AuditLogViewerProps) {
|
||||
) : (
|
||||
<button
|
||||
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" />
|
||||
<span className="truncate">{(entry.output_preview ?? '').slice(0, 60)}{(entry.output_preview ?? '').length > 60 ? '...' : ''}</span>
|
||||
|
||||
@ -88,14 +88,14 @@ export function PermissionRequestCard({ sessionId, onRequestCountChange }: Permi
|
||||
return (
|
||||
<div
|
||||
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">
|
||||
<Shield className="h-3.5 w-3.5 text-amber-550 shrink-0" />
|
||||
<span className="font-extrabold 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}>
|
||||
<Shield className="h-3.5 w-3.5 text-amber-500 shrink-0" />
|
||||
<span className="font-bold text-slate-800 tracking-wide text-[11px] shrink-0">敏感操作授权</span>
|
||||
<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}
|
||||
</code>
|
||||
</div>
|
||||
@ -104,7 +104,7 @@ export function PermissionRequestCard({ sessionId, onRequestCountChange }: Permi
|
||||
<button
|
||||
type="button"
|
||||
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 ? '隐藏参数' : '参数详情'}
|
||||
</button>
|
||||
@ -112,43 +112,43 @@ export function PermissionRequestCard({ sessionId, onRequestCountChange }: Permi
|
||||
</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 && (
|
||||
<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)}
|
||||
</pre>
|
||||
)}
|
||||
|
||||
{/* 操作按钮栏 - 更加紧凑 */}
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
onClick={() => respond(req.tool_call_id, true, false)}
|
||||
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] ? (
|
||||
<Loader className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<Check className="h-3 w-3" />
|
||||
)}
|
||||
Allow
|
||||
允许一次
|
||||
</button>
|
||||
<button
|
||||
onClick={() => respond(req.tool_call_id, true, true)}
|
||||
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
|
||||
onClick={() => respond(req.tool_call_id, false, false)}
|
||||
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" />
|
||||
Deny
|
||||
拒绝执行
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -45,10 +45,10 @@ export function SubAgentContainer({
|
||||
<div className="relative space-y-2 select-none">
|
||||
<div
|
||||
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
|
||||
onClick={onToggle}
|
||||
@ -57,7 +57,7 @@ export function SubAgentContainer({
|
||||
<div className="flex items-center gap-2">
|
||||
<Network
|
||||
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">
|
||||
|
||||
@ -38,7 +38,7 @@ export function ThoughtCard({
|
||||
<div
|
||||
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
|
||||
onClick={onToggle}
|
||||
className="w-full flex items-center justify-between text-left cursor-pointer"
|
||||
|
||||
@ -42,7 +42,7 @@ export function ToolCallCard({
|
||||
<div
|
||||
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 gap-1.5 text-[10px] font-extrabold text-sky-700 tracking-wider uppercase">
|
||||
@ -64,7 +64,7 @@ export function ToolCallCard({
|
||||
|
||||
{/* 参数 */}
|
||||
{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)}
|
||||
</div>
|
||||
)}
|
||||
@ -93,7 +93,7 @@ export function ToolCallCard({
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
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>
|
||||
{result!.output}
|
||||
|
||||
@ -24,13 +24,13 @@ export interface ColorScheme {
|
||||
}
|
||||
|
||||
export const PARENT_COLORS: ColorScheme = {
|
||||
thought: { active: 'bg-purple-400 animate-pulse', idle: 'bg-purple-300' },
|
||||
tool_call: { active: 'bg-sky-500 animate-pulse', idle: 'bg-sky-400' },
|
||||
answer: 'bg-emerald-400',
|
||||
thought: { active: 'bg-slate-400', idle: 'bg-slate-300' },
|
||||
tool_call: { active: 'bg-blueprint', idle: 'bg-slate-300' },
|
||||
answer: 'bg-slate-550',
|
||||
};
|
||||
|
||||
export const SUBAGENT_COLORS: ColorScheme = {
|
||||
thought: { active: 'bg-violet-400 animate-pulse', idle: 'bg-violet-300' },
|
||||
tool_call: { active: 'bg-violet-500 animate-pulse', idle: 'bg-violet-400' },
|
||||
answer: 'bg-violet-400',
|
||||
thought: { active: 'bg-slate-500', idle: 'bg-slate-400' },
|
||||
tool_call: { active: 'bg-blueprint', idle: 'bg-slate-350' },
|
||||
answer: 'bg-slate-450',
|
||||
};
|
||||
|
||||
69
dashboard/src/components/dialogs/BaseModal.tsx
Normal file
69
dashboard/src/components/dialogs/BaseModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -1,3 +1,5 @@
|
||||
import { BaseModal } from './BaseModal';
|
||||
|
||||
interface GlobalDialogProps {
|
||||
dialog: {
|
||||
type: 'alert' | 'confirm';
|
||||
@ -23,49 +25,33 @@ export function GlobalDialog({ dialog, onClose }: GlobalDialogProps) {
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={handleCancel}
|
||||
className="fixed inset-0 z-[60] flex items-center justify-center bg-slate-900/40 backdrop-blur-xs transition-all"
|
||||
<BaseModal
|
||||
isOpen={!!dialog}
|
||||
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">
|
||||
<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 className="flex gap-2 pt-2">
|
||||
<button
|
||||
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>
|
||||
{dialog.type === 'confirm' && (
|
||||
<button
|
||||
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>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</BaseModal>
|
||||
);
|
||||
}
|
||||
|
||||
@ -2,6 +2,7 @@ import axios from 'axios';
|
||||
import { Loader, Download, RefreshCw, AlertTriangle } from 'lucide-react';
|
||||
import type { StandardPaper } from '../../types';
|
||||
import { getDoctypeBadge } from '../../utils/paper';
|
||||
import { BaseModal } from './BaseModal';
|
||||
|
||||
interface PaperDetailModalProps {
|
||||
paper: StandardPaper | null | undefined;
|
||||
@ -34,130 +35,120 @@ export function PaperDetailModal({
|
||||
}: PaperDetailModalProps) {
|
||||
if (!paper) return null;
|
||||
|
||||
return (
|
||||
<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">
|
||||
const modalTitle = (
|
||||
<div className="space-y-1 pr-4">
|
||||
<span className="text-[10px] font-bold text-[var(--accent-blueprint)] uppercase tracking-wider">文献详情元数据</span>
|
||||
<h3 className="text-xs font-bold text-[var(--text-main)] leading-snug">
|
||||
<span className="text-[10px] font-bold text-blueprint uppercase tracking-wider block">文献详情元数据</span>
|
||||
<h3 className="text-xs font-bold text-main leading-snug">
|
||||
{getDoctypeBadge(paper.doctype)}
|
||||
<span className="align-middle ml-1">{paper.title}</span>
|
||||
</h3>
|
||||
</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"
|
||||
title="关闭"
|
||||
);
|
||||
|
||||
return (
|
||||
<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-1 h-12 overflow-y-auto scrollbar-thin shrink-0">
|
||||
<span className="text-[var(--text-muted)] font-bold">作者列表</span>
|
||||
<p className="text-[var(--text-main)] leading-relaxed font-semibold">{paper.authors.join(', ')}</p>
|
||||
<span className="text-muted font-bold">作者列表</span>
|
||||
<p className="text-main leading-relaxed font-semibold">{paper.authors.join(', ')}</p>
|
||||
</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">
|
||||
<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-bold italic truncate block text-[11px] leading-tight"
|
||||
className="text-main font-bold italic truncate block text-[11px] leading-tight"
|
||||
title={paper.pub_journal || '未标注'}
|
||||
>
|
||||
{paper.pub_journal || '未标注'}
|
||||
</span>
|
||||
</div>
|
||||
<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-[var(--text-main)] font-extrabold block text-[11px] leading-tight">{paper.year}</span>
|
||||
<span className="text-muted font-bold block text-[10px] leading-tight">发表年份</span>
|
||||
<span className="text-main font-extrabold block text-[11px] leading-tight">{paper.year}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 摘要 */}
|
||||
<div className="space-y-1.5 flex flex-col h-40 shrink-0">
|
||||
<span className="text-[var(--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">
|
||||
<span className="text-muted font-bold block">摘要</span>
|
||||
<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 || '该文献暂无摘要数据。'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 关键字 */}
|
||||
{/* 关键词 */}
|
||||
<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 ? (
|
||||
<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 => (
|
||||
<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}
|
||||
</span>
|
||||
))}
|
||||
</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 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="bg-[#f8fafc] px-2.5 py-1.5 rounded border border-[var(--border-precision)]">
|
||||
<span className="text-[var(--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}>
|
||||
<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-slate-50 px-2.5 py-1.5 rounded-md border border-precision">
|
||||
<span className="text-muted font-bold block">BIBCODE</span>
|
||||
<span className="text-main font-semibold select-all truncate block" title={paper.bibcode === paper.arxiv_id ? '暂无' : paper.bibcode}>
|
||||
{paper.bibcode === paper.arxiv_id ? '暂无' : (
|
||||
<a
|
||||
href={`https://ui.adsabs.harvard.edu/abs/${paper.bibcode}/abstract`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
onClick={() => axios.post('/api/active_bibcode', { bibcode: paper.bibcode }).catch(() => {})}
|
||||
className="text-[var(--accent-blueprint)] hover:underline"
|
||||
className="text-blueprint hover:underline"
|
||||
>
|
||||
{paper.bibcode}
|
||||
</a>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="bg-[#f8fafc] px-2.5 py-1.5 rounded border border-[var(--border-precision)]">
|
||||
<span className="text-[var(--text-muted)] font-bold block">DOI</span>
|
||||
<span className="text-[var(--text-main)] font-semibold select-all truncate block" title={paper.doi || '无'}>
|
||||
<div className="bg-slate-50 px-2.5 py-1.5 rounded-md border border-precision">
|
||||
<span className="text-muted font-bold block">DOI</span>
|
||||
<span className="text-main font-semibold select-all truncate block" title={paper.doi || '无'}>
|
||||
{paper.doi ? (
|
||||
<a
|
||||
href={`https://doi.org/${paper.doi}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
onClick={() => axios.post('/api/active_bibcode', { bibcode: paper.bibcode }).catch(() => {})}
|
||||
className="text-[var(--accent-blueprint)] hover:underline"
|
||||
className="text-blueprint hover:underline"
|
||||
>
|
||||
{paper.doi}
|
||||
</a>
|
||||
) : '无'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="bg-[#f8fafc] px-2.5 py-1.5 rounded border border-[var(--border-precision)]">
|
||||
<span className="text-[var(--text-muted)] font-bold block">ARXIV ID</span>
|
||||
<span className="text-[var(--text-main)] font-semibold select-all truncate block" title={paper.arxiv_id || '无'}>
|
||||
<div className="bg-slate-50 px-2.5 py-1.5 rounded-md border border-precision">
|
||||
<span className="text-muted font-bold block">ARXIV ID</span>
|
||||
<span className="text-main font-semibold select-all truncate block" title={paper.arxiv_id || '无'}>
|
||||
{paper.arxiv_id ? (
|
||||
<a
|
||||
href={`https://arxiv.org/abs/${paper.arxiv_id}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
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}
|
||||
</a>
|
||||
@ -167,16 +158,16 @@ export function PaperDetailModal({
|
||||
</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">
|
||||
<span className="text-[var(--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-muted font-bold block">手动离线上传文献</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>
|
||||
<p className="text-[10px] text-[var(--text-muted)] leading-relaxed">
|
||||
<p className="text-[10px] text-muted leading-relaxed">
|
||||
若自动下载受阻,可在浏览器中打开上方链接,手动保存 PDF 或 HTML 后在此处上传覆盖。
|
||||
</p>
|
||||
<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
|
||||
type="file"
|
||||
accept="application/pdf"
|
||||
@ -188,17 +179,17 @@ export function PaperDetailModal({
|
||||
disabled={uploadingBibcode === paper.bibcode}
|
||||
/>
|
||||
{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 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 文献'}
|
||||
</span>
|
||||
<span className="text-[8px] text-[var(--text-muted)]">支持 .pdf 格式</span>
|
||||
<span className="text-[8px] text-muted">支持 .pdf 格式</span>
|
||||
</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
|
||||
type="file"
|
||||
accept="text/html,.html"
|
||||
@ -210,14 +201,14 @@ export function PaperDetailModal({
|
||||
disabled={uploadingBibcode === paper.bibcode}
|
||||
/>
|
||||
{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 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 文献'}
|
||||
</span>
|
||||
<span className="text-[8px] text-[var(--text-muted)]">支持 .html 格式</span>
|
||||
<span className="text-[8px] text-muted">支持 .html 格式</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -227,7 +218,7 @@ export function PaperDetailModal({
|
||||
<button
|
||||
type="button"
|
||||
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" /> 恢复自动下载状态 (允许后续重新尝试)
|
||||
</button>
|
||||
@ -235,9 +226,9 @@ export function PaperDetailModal({
|
||||
<button
|
||||
type="button"
|
||||
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>
|
||||
)}
|
||||
</div>
|
||||
@ -246,34 +237,34 @@ export function PaperDetailModal({
|
||||
|
||||
{/* 自动下载失败诊断 */}
|
||||
{!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')
|
||||
? 'bg-amber-50 border border-amber-150 text-amber-850'
|
||||
: 'bg-rose-50 border border-rose-150 text-rose-850'
|
||||
? 'bg-slate-50 border-l-amber-500 text-slate-800'
|
||||
: 'bg-slate-50 border-l-rose-500 text-slate-800'
|
||||
}`}>
|
||||
{paper.pdf_error === 'no_resource' && paper.html_error === 'no_resource' ? (
|
||||
<div className="leading-relaxed">
|
||||
<div className="font-bold flex items-center gap-1.5 text-amber-900 text-xs mb-1">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-amber-500 animate-pulse" />
|
||||
<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" />
|
||||
已手动标记为【无有效全文资源】
|
||||
</div>
|
||||
该文献已从物理文献下载重试队列中排除,后续批量下载时系统将自动跳过此文献。
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="font-bold flex items-center gap-1.5 text-rose-900">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-rose-500 animate-pulse" />
|
||||
<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-600" />
|
||||
自动下载失败诊断信息:
|
||||
</div>
|
||||
{paper.pdf_error && (
|
||||
<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}
|
||||
</div>
|
||||
)}
|
||||
{paper.html_error && (
|
||||
<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}
|
||||
</div>
|
||||
)}
|
||||
@ -284,7 +275,7 @@ export function PaperDetailModal({
|
||||
</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 ? (
|
||||
<>
|
||||
<button
|
||||
@ -292,7 +283,7 @@ export function PaperDetailModal({
|
||||
onClose();
|
||||
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>
|
||||
@ -303,18 +294,18 @@ export function PaperDetailModal({
|
||||
setActiveTab('citation');
|
||||
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
|
||||
onClick={() => {
|
||||
showConfirm('确定要强制重新下载吗?这会覆盖本地文件。', () => {
|
||||
showConfirm('确定要强制重新下载吗?这会覆盖本地 file。', () => {
|
||||
handleDownload(paper.bibcode, true);
|
||||
}, '确认重新下载');
|
||||
}}
|
||||
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] ? '重下中...' : '重新下载'}
|
||||
</button>
|
||||
@ -326,7 +317,7 @@ export function PaperDetailModal({
|
||||
handleDownload(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] ? (
|
||||
<>
|
||||
@ -347,14 +338,13 @@ export function PaperDetailModal({
|
||||
setActiveTab('citation');
|
||||
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>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</BaseModal>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import axios from 'axios';
|
||||
import { BaseModal } from './BaseModal';
|
||||
|
||||
interface UncachedPaperModalProps {
|
||||
bibcode: string | null;
|
||||
@ -10,34 +11,19 @@ export function UncachedPaperModal({ bibcode, onClose, loadCitations }: Uncached
|
||||
if (!bibcode) return null;
|
||||
|
||||
return (
|
||||
<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"
|
||||
<BaseModal
|
||||
isOpen={!!bibcode}
|
||||
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">
|
||||
<p className="text-xs text-[var(--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> 尚未收录在本地数据库中。
|
||||
<p className="text-xs text-main leading-relaxed">
|
||||
文献 <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 className="text-[11px] text-[var(--text-muted)] leading-relaxed">
|
||||
<p className="text-[11px] text-muted leading-relaxed">
|
||||
您可以选择在线拉取该文献元数据并入库,或是直接跳转至 NASA ADS 平台查看其原始页面。
|
||||
</p>
|
||||
</div>
|
||||
@ -47,7 +33,7 @@ export function UncachedPaperModal({ bibcode, onClose, loadCitations }: Uncached
|
||||
loadCitations(bibcode, false);
|
||||
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>
|
||||
@ -56,18 +42,17 @@ export function UncachedPaperModal({ bibcode, onClose, loadCitations }: Uncached
|
||||
axios.post('/api/active_bibcode', { bibcode }).catch(() => {});
|
||||
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
|
||||
</button>
|
||||
<button
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</BaseModal>
|
||||
);
|
||||
}
|
||||
|
||||
@ -49,9 +49,9 @@ export function Sidebar({ activeTab, setActiveTab, selectedPaper, loadCitations,
|
||||
type="button"
|
||||
disabled={!isCollapsed}
|
||||
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
|
||||
? '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'
|
||||
}`}
|
||||
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'
|
||||
} ${
|
||||
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
|
||||
? 'opacity-30 cursor-not-allowed border-transparent text-slate-400'
|
||||
: '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
|
||||
className={`truncate transition-all duration-300 origin-left ${
|
||||
isCollapsed
|
||||
@ -145,13 +145,13 @@ export function Sidebar({ activeTab, setActiveTab, selectedPaper, loadCitations,
|
||||
className={`relative overflow-hidden transition-all duration-300 border rounded-lg ${
|
||||
isCollapsed
|
||||
? '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}
|
||||
>
|
||||
{/* 折叠下的微型图书图标 */}
|
||||
<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
|
||||
? 'w-9 h-9 opacity-100 scale-100'
|
||||
: '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'
|
||||
}`}
|
||||
>
|
||||
<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>
|
||||
<div className="flex items-center justify-between text-[10px] font-medium text-slate-500 gap-2">
|
||||
<span className="shrink-0">发表年份: {selectedPaper.year}</span>
|
||||
|
||||
@ -48,13 +48,13 @@ function PaperMetadataDossier({ metadata, isChinese }: { metadata: PaperMetadata
|
||||
if (!metadata || (!metadata.title && !metadata.author)) return null;
|
||||
|
||||
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="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="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-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">
|
||||
<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'}
|
||||
</span>
|
||||
{metadata.date && (
|
||||
@ -160,7 +160,7 @@ export function BilingualViewer({
|
||||
<div
|
||||
onMouseOver={handleMouseOver}
|
||||
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 gap-2">
|
||||
@ -169,7 +169,7 @@ export function BilingualViewer({
|
||||
<button
|
||||
type="button"
|
||||
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 ? (
|
||||
<>
|
||||
@ -266,7 +266,7 @@ export function BilingualViewer({
|
||||
{/* 中文翻译视窗 (或双语对照) */}
|
||||
{(viewMode === 'chinese' || viewMode === 'bilingual') && (
|
||||
<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">
|
||||
<span className="text-xs font-bold text-slate-800">中文学术对比翻译</span>
|
||||
@ -274,7 +274,7 @@ export function BilingualViewer({
|
||||
|
||||
{translating ? (
|
||||
<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>
|
||||
</div>
|
||||
) : chnPure ? (
|
||||
@ -327,37 +327,36 @@ export function BilingualViewer({
|
||||
transform: hoverCardPos.isAbove ? 'translateY(-100%)' : 'none',
|
||||
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">
|
||||
<span className="font-bold text-slate-950 text-sm">{hoveredTarget.target_name}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center justify-between border-b border-slate-200 pb-2">
|
||||
<span className="font-bold text-slate-900 text-[13px]">{hoveredTarget.target_name}</span>
|
||||
<div className="flex items-center gap-1.5 select-none">
|
||||
<a
|
||||
href={`https://simbad.cds.unistra.fr/simbad/sim-id?Ident=${encodeURIComponent(hoveredTarget.target_name)}`}
|
||||
target="_blank"
|
||||
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 中查询该天体详情"
|
||||
>
|
||||
SIMBAD
|
||||
</a>
|
||||
<span className="text-[9px] text-slate-300">|</span>
|
||||
<a
|
||||
href={`https://vizier.cds.unistra.fr/viz-bin/VizieR?-c=${encodeURIComponent(hoveredTarget.target_name)}`}
|
||||
target="_blank"
|
||||
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 中查询相关文献和表数据"
|
||||
>
|
||||
VizieR
|
||||
</a>
|
||||
</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">
|
||||
{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}
|
||||
</span>
|
||||
)}
|
||||
@ -369,20 +368,20 @@ export function BilingualViewer({
|
||||
</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>
|
||||
<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>
|
||||
{/* 光谱型 & 视星等 */}
|
||||
<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>
|
||||
<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">
|
||||
{hoveredTarget.v_magnitude !== null && hoveredTarget.v_magnitude !== undefined
|
||||
? `${hoveredTarget.v_magnitude.toFixed(2)}`
|
||||
@ -391,7 +390,7 @@ export function BilingualViewer({
|
||||
</div>
|
||||
{/* 视差 + 误差 + 估算距离 */}
|
||||
<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">
|
||||
{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)`
|
||||
@ -400,8 +399,8 @@ export function BilingualViewer({
|
||||
</div>
|
||||
{/* 自行 */}
|
||||
<div className="col-span-2">
|
||||
<span className="text-slate-400 font-semibold">自行:</span>
|
||||
<div className="font-bold text-slate-800">
|
||||
<span className="text-slate-500 font-bold tracking-wide text-[9px] uppercase block">自行</span>
|
||||
<div className="font-bold text-slate-800 text-[10.5px]">
|
||||
{hoveredTarget.pm_ra != null && hoveredTarget.pm_de != null
|
||||
? `RA ${hoveredTarget.pm_ra.toFixed(3)}, Dec ${hoveredTarget.pm_de.toFixed(3)} mas/yr`
|
||||
: '未知'}
|
||||
@ -409,7 +408,7 @@ export function BilingualViewer({
|
||||
</div>
|
||||
{/* 视向速度 */}
|
||||
<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">
|
||||
{hoveredTarget.radial_velocity != null
|
||||
? `${hoveredTarget.radial_velocity.toFixed(1)} km/s`
|
||||
@ -419,9 +418,9 @@ export function BilingualViewer({
|
||||
</div>
|
||||
{/* 多波段测光 */}
|
||||
{hoveredTarget.photometry && Object.keys(hoveredTarget.photometry).length > 1 && (
|
||||
<div className="border-t border-slate-100 pt-1.5">
|
||||
<span className="text-slate-400 font-semibold 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="border-t border-slate-200/60 pt-2">
|
||||
<span className="text-slate-500 font-bold tracking-wide text-[9px] uppercase block mb-1">多波段星等</span>
|
||||
<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]) => (
|
||||
<span key={band} className="whitespace-nowrap">
|
||||
<span className="text-slate-400">{band}</span>{' '}
|
||||
@ -432,8 +431,8 @@ export function BilingualViewer({
|
||||
</div>
|
||||
)}
|
||||
{hoveredTarget.aliases && hoveredTarget.aliases.length > 0 && (
|
||||
<div className="border-t border-slate-100 pt-1.5">
|
||||
<span className="text-slate-400 font-semibold block mb-0.5">常用别名:</span>
|
||||
<div className="border-t border-slate-200/60 pt-2">
|
||||
<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">
|
||||
{hoveredTarget.aliases.slice(0, 8).join(', ')}
|
||||
{hoveredTarget.aliases.length > 8 && ' ...'}
|
||||
|
||||
@ -69,7 +69,7 @@ export function ReaderNotesSidebar({
|
||||
if (!showNotesPanel) return null;
|
||||
|
||||
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">
|
||||
<span className="text-xs font-bold text-slate-800">
|
||||
{sidebarTab === 'notes' ? '观测记录手札' : '文献 AI 问答'}
|
||||
@ -86,7 +86,7 @@ export function ReaderNotesSidebar({
|
||||
onClick={() => setSidebarTab('notes')}
|
||||
className={`flex-1 py-2 text-center text-xs font-bold transition-all border-b-2 cursor-pointer ${
|
||||
sidebarTab === 'notes'
|
||||
? 'border-sky-600 text-sky-700'
|
||||
? 'border-blueprint text-blueprint'
|
||||
: 'border-transparent text-slate-500 hover:text-slate-700'
|
||||
}`}
|
||||
>
|
||||
@ -97,7 +97,7 @@ export function ReaderNotesSidebar({
|
||||
onClick={() => setSidebarTab('ai')}
|
||||
className={`flex-1 py-2 text-center text-xs font-bold transition-all border-b-2 cursor-pointer ${
|
||||
sidebarTab === 'ai'
|
||||
? 'border-sky-600 text-sky-700'
|
||||
? 'border-blueprint text-blueprint'
|
||||
: 'border-transparent text-slate-500 hover:text-slate-700'
|
||||
}`}
|
||||
>
|
||||
|
||||
@ -84,7 +84,7 @@ export function ReaderToolbar({
|
||||
{showSwitchMenu && (
|
||||
<>
|
||||
<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 && (
|
||||
<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 ${
|
||||
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'
|
||||
}`}
|
||||
>
|
||||
@ -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 ${
|
||||
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'
|
||||
}`}
|
||||
>
|
||||
|
||||
@ -48,10 +48,10 @@ export function BatchPipelineCard({
|
||||
handleStopBatch,
|
||||
}: BatchPipelineCardProps) {
|
||||
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">
|
||||
<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>
|
||||
</h3>
|
||||
<p className="text-slate-500 text-xs mt-0.5">
|
||||
@ -60,7 +60,7 @@ export function BatchPipelineCard({
|
||||
</div>
|
||||
|
||||
{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" />
|
||||
<div>{batchError}</div>
|
||||
</div>
|
||||
@ -92,7 +92,7 @@ export function BatchPipelineCard({
|
||||
value={batchLimitCount}
|
||||
disabled={batchStatus.active}
|
||||
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>
|
||||
|
||||
@ -122,7 +122,7 @@ export function BatchPipelineCard({
|
||||
checked={skipCompleted}
|
||||
disabled={batchStatus.active}
|
||||
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>
|
||||
</label>
|
||||
@ -133,7 +133,7 @@ export function BatchPipelineCard({
|
||||
checked={skipFailed}
|
||||
disabled={batchStatus.active}
|
||||
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>
|
||||
</label>
|
||||
@ -144,7 +144,7 @@ export function BatchPipelineCard({
|
||||
checked={skipPrecedingFailed}
|
||||
disabled={batchStatus.active}
|
||||
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>
|
||||
</label>
|
||||
@ -155,7 +155,7 @@ export function BatchPipelineCard({
|
||||
checked={skipPrecedingUncompleted}
|
||||
disabled={batchStatus.active}
|
||||
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>
|
||||
</label>
|
||||
@ -169,7 +169,7 @@ export function BatchPipelineCard({
|
||||
<button
|
||||
type="button"
|
||||
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" />
|
||||
停止批量任务
|
||||
@ -178,7 +178,7 @@ export function BatchPipelineCard({
|
||||
<button
|
||||
type="button"
|
||||
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" />
|
||||
启动批量任务
|
||||
@ -193,11 +193,11 @@ export function BatchPipelineCard({
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex justify-between text-xs font-bold text-slate-600 select-none">
|
||||
<span className="flex items-center gap-1.5">
|
||||
{batchStatus.action === 'download' && <Download className="w-3.5 h-3.5 text-sky-600" />}
|
||||
{batchStatus.action === 'parse' && <FileText className="w-3.5 h-3.5 text-emerald-650" />}
|
||||
{batchStatus.action === 'translate' && <RefreshCw className="w-3.5 h-3.5 text-indigo-600 animate-spin" />}
|
||||
{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-700" />}
|
||||
{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 === '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 === 'parse'
|
||||
@ -218,18 +218,18 @@ export function BatchPipelineCard({
|
||||
)}
|
||||
</span>
|
||||
</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
|
||||
className={`h-full transition-all duration-300 ${
|
||||
batchStatus.action === 'download'
|
||||
? 'bg-sky-600'
|
||||
? 'bg-blueprint'
|
||||
: batchStatus.action === 'parse'
|
||||
? 'bg-emerald-600'
|
||||
: batchStatus.action === 'translate'
|
||||
? 'bg-indigo-600'
|
||||
? 'bg-slate-700'
|
||||
: batchStatus.action === 'embed'
|
||||
? 'bg-amber-600'
|
||||
: 'bg-sky-600'
|
||||
: 'bg-blueprint'
|
||||
}`}
|
||||
style={{
|
||||
width: `${
|
||||
@ -253,15 +253,15 @@ export function BatchPipelineCard({
|
||||
|
||||
{batchStatus.active && batchStatus.current_bibcode && (
|
||||
<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" />
|
||||
<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>
|
||||
<Loader className="w-3.5 h-3.5 text-blueprint animate-spin" />
|
||||
<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 className="space-y-2">
|
||||
<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 ? (
|
||||
<div className="text-slate-400 italic select-none">等待数据流任务启动,暂无日志输出...</div>
|
||||
) : (
|
||||
|
||||
@ -19,10 +19,10 @@ export function BookmarkletToolCard() {
|
||||
};
|
||||
|
||||
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">
|
||||
<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>
|
||||
</h3>
|
||||
<p className="text-slate-500 text-xs mt-1 leading-relaxed">
|
||||
@ -41,7 +41,7 @@ export function BookmarkletToolCard() {
|
||||
{/* 可直接拖拽的链接按钮 */}
|
||||
<a
|
||||
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="拖拽我到您的书签栏中"
|
||||
onClick={(e) => e.preventDefault()}
|
||||
>
|
||||
@ -53,7 +53,7 @@ export function BookmarkletToolCard() {
|
||||
|
||||
<button
|
||||
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 书签代码
|
||||
</button>
|
||||
|
||||
@ -48,7 +48,7 @@ export function MetadataSyncCard({
|
||||
return (
|
||||
<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="space-y-2">
|
||||
<label className="text-xs font-bold text-slate-700 block flex justify-between items-center">
|
||||
@ -56,7 +56,7 @@ export function MetadataSyncCard({
|
||||
<button
|
||||
type="button"
|
||||
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" />
|
||||
{showBuilder ? '隐藏高级构造' : '高级检索构造'}
|
||||
@ -68,7 +68,7 @@ export function MetadataSyncCard({
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
disabled={status.active}
|
||||
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">
|
||||
<span>高级格式:</span>
|
||||
@ -89,9 +89,9 @@ export function MetadataSyncCard({
|
||||
type="button"
|
||||
disabled={status.active}
|
||||
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
|
||||
? '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'
|
||||
}`}
|
||||
>
|
||||
@ -104,13 +104,13 @@ export function MetadataSyncCard({
|
||||
|
||||
{/* 动态表单生成器 */}
|
||||
{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">
|
||||
<span>高级条件生成器</span>
|
||||
<button
|
||||
type="button"
|
||||
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>
|
||||
@ -158,14 +158,14 @@ export function MetadataSyncCard({
|
||||
? '例如: 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 && (
|
||||
<button
|
||||
type="button"
|
||||
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>
|
||||
@ -187,7 +187,7 @@ export function MetadataSyncCard({
|
||||
value={limit}
|
||||
disabled={status.active}
|
||||
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>
|
||||
|
||||
@ -196,7 +196,7 @@ export function MetadataSyncCard({
|
||||
type="button"
|
||||
disabled={status.active || estimating}
|
||||
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" />}
|
||||
估计目录总量
|
||||
@ -205,7 +205,7 @@ export function MetadataSyncCard({
|
||||
type="button"
|
||||
disabled={status.active || !query.trim()}
|
||||
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" />
|
||||
启动元数据同步
|
||||
@ -215,10 +215,10 @@ export function MetadataSyncCard({
|
||||
|
||||
{/* 预估结果 */}
|
||||
{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">
|
||||
<Info className="w-4 h-4 shrink-0 text-sky-600" />
|
||||
<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-slate-500" />
|
||||
<div>
|
||||
检索库中估计有约 <strong className="text-sky-900 font-bold">{estimatedCount}</strong> 篇文献记录。
|
||||
检索库中估计有约 <strong className="text-slate-900 font-bold">{estimatedCount}</strong> 篇文献记录。
|
||||
{estimatedCount > limit ? ` 限制最大同步前 ${limit} 篇元数据。` : ' 将全部进行同步。'}
|
||||
</div>
|
||||
</div>
|
||||
@ -227,18 +227,18 @@ export function MetadataSyncCard({
|
||||
|
||||
{/* 实时同步进度 */}
|
||||
{(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>
|
||||
<h3 className="text-xs font-bold text-slate-850 flex items-center gap-2">
|
||||
{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>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CheckCircle className="w-4 h-4 text-emerald-655" />
|
||||
<CheckCircle className="w-4 h-4 text-emerald-700" />
|
||||
<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'}
|
||||
</p>
|
||||
</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 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
|
||||
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}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{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" />
|
||||
<span>同步 arXiv 文献时系统会自动加设 3000 毫秒的安全限流延迟以规避服务器封锁。</span>
|
||||
</div>
|
||||
|
||||
@ -2,20 +2,32 @@
|
||||
@import "tailwindcss";
|
||||
@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 {
|
||||
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
||||
color-scheme: light;
|
||||
|
||||
/* 极简白蓝学术科技风配色 */
|
||||
--bg-main: #f4f6f9;
|
||||
/* 极极简白蓝学术科技风配色 - 升级为暖调纸张感学术护眼色 */
|
||||
--bg-main: #faf8f5; /* 学术纸张底色 */
|
||||
--bg-card: #ffffff;
|
||||
--bg-sidebar: #0f2540; /* 保持深色侧边栏作为界面骨架结构 */
|
||||
--text-main: #0a2540;
|
||||
--text-muted: #5c6b84;
|
||||
--text-main: #1e293b; /* 优雅深石板灰,对比度更佳 */
|
||||
--text-muted: #475569; /* 中度石板灰,提高可读性 */
|
||||
|
||||
--accent-blueprint: #106ba3;
|
||||
--accent-star: #d97706;
|
||||
--border-precision: #d2d8e2;
|
||||
--accent-blueprint: #0369a1; /* 低饱和度学术蓝 */
|
||||
--accent-star: #b45309; /* 经典低饱和度琥珀橙 */
|
||||
--border-precision: #e2e8f0; /* 极细轻量级分割线 */
|
||||
|
||||
--font-mono: 'JetBrains Mono', monospace;
|
||||
}
|
||||
@ -143,3 +155,80 @@ mark {
|
||||
border-radius: 0.125rem;
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@ -54,9 +54,9 @@ export function CitationPanel({
|
||||
<button
|
||||
type="button"
|
||||
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>
|
||||
<ChevronDown className={`w-3 h-3 transition-transform ${showSwitchMenu ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
@ -64,7 +64,7 @@ export function CitationPanel({
|
||||
{showSwitchMenu && (
|
||||
<>
|
||||
<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 && (
|
||||
<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 ${
|
||||
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'
|
||||
}`}
|
||||
>
|
||||
@ -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 ${
|
||||
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'
|
||||
}`}
|
||||
>
|
||||
@ -127,7 +127,7 @@ export function CitationPanel({
|
||||
)}
|
||||
</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>
|
||||
<input
|
||||
type="range"
|
||||
@ -136,14 +136,14 @@ export function CitationPanel({
|
||||
step="5"
|
||||
value={nodeLimit}
|
||||
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>
|
||||
{selectedPaper && (
|
||||
<button
|
||||
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" /> 重置引用星系图
|
||||
</button>
|
||||
@ -152,12 +152,12 @@ export function CitationPanel({
|
||||
</div>
|
||||
|
||||
{loadingCitations ? (
|
||||
<div className="console-panel rounded-xl 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" />
|
||||
<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-blueprint mb-2" />
|
||||
<p className="text-xs font-bold text-slate-600">正在从馆藏数据库中提取引用拓扑数据...</p>
|
||||
</div>
|
||||
) : 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">
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
@ -187,7 +187,7 @@ export function CitationPanel({
|
||||
<div
|
||||
key={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}
|
||||
</div>
|
||||
@ -208,7 +208,7 @@ export function CitationPanel({
|
||||
<div
|
||||
key={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}
|
||||
</div>
|
||||
@ -219,7 +219,7 @@ export function CitationPanel({
|
||||
</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" />
|
||||
<p className="text-xs font-bold text-slate-500">无法拉取当前选定目标的星系引用关系</p>
|
||||
</div>
|
||||
|
||||
@ -2,8 +2,8 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { Library, RotateCw, Search, SlidersHorizontal, X, CheckCircle, AlertTriangle, BookOpen, GitFork } from 'lucide-react';
|
||||
import type { StandardPaper } from '../types';
|
||||
import { getDoctypeBadge } from '../utils/paper';
|
||||
import { CustomSelect } from '../components/CustomSelect';
|
||||
import { PaperCard } from '../components/PaperCard';
|
||||
|
||||
interface LibraryPanelProps {
|
||||
library: StandardPaper[];
|
||||
@ -188,7 +188,7 @@ export function LibraryPanel({
|
||||
<button
|
||||
onClick={handleResync}
|
||||
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' : ''}`} />
|
||||
{syncing ? '正在同步...' : '重新同步馆藏'}
|
||||
@ -197,10 +197,10 @@ export function LibraryPanel({
|
||||
|
||||
{/* 同步结果反馈条 */}
|
||||
{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'
|
||||
? 'bg-emerald-50 border border-emerald-200 text-emerald-700'
|
||||
: 'bg-red-50 border border-red-200 text-red-700'
|
||||
? 'bg-emerald-50 border border-emerald-200/60 text-emerald-850'
|
||||
: 'bg-rose-50 border border-rose-200/60 text-rose-850'
|
||||
}`}>
|
||||
{syncFeedback.type === 'success'
|
||||
? <CheckCircle className="w-3.5 h-3.5 shrink-0" />
|
||||
@ -211,7 +211,7 @@ export function LibraryPanel({
|
||||
|
||||
{/* 搜索、筛选与排序工具栏 */}
|
||||
{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-1 space-y-1.5">
|
||||
@ -223,7 +223,7 @@ export function LibraryPanel({
|
||||
value={searchTerm}
|
||||
onChange={e => setSearchTerm(e.target.value)}
|
||||
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 && (
|
||||
<button
|
||||
@ -305,9 +305,9 @@ export function LibraryPanel({
|
||||
<button
|
||||
type="button"
|
||||
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
|
||||
? 'bg-sky-50 border-sky-300 text-sky-700'
|
||||
? 'bg-slate-100 border-slate-350 text-slate-855'
|
||||
: 'btn-console btn-console-secondary'
|
||||
}`}
|
||||
>
|
||||
@ -328,7 +328,7 @@ export function LibraryPanel({
|
||||
value={filterAuthor}
|
||||
onChange={e => setFilterAuthor(e.target.value)}
|
||||
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>
|
||||
|
||||
@ -340,7 +340,7 @@ export function LibraryPanel({
|
||||
value={filterYear}
|
||||
onChange={e => setFilterYear(e.target.value)}
|
||||
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>
|
||||
|
||||
@ -352,7 +352,7 @@ export function LibraryPanel({
|
||||
value={filterJournal}
|
||||
onChange={e => setFilterJournal(e.target.value)}
|
||||
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>
|
||||
@ -361,19 +361,19 @@ export function LibraryPanel({
|
||||
)}
|
||||
|
||||
{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" />
|
||||
<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>
|
||||
<button
|
||||
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>
|
||||
</div>
|
||||
) : 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" />
|
||||
<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>
|
||||
@ -386,7 +386,7 @@ export function LibraryPanel({
|
||||
setFilterYear('');
|
||||
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>
|
||||
@ -399,12 +399,12 @@ export function LibraryPanel({
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{sortedLibrary.map(paper => {
|
||||
return (
|
||||
<div
|
||||
<PaperCard
|
||||
key={paper.bibcode}
|
||||
paper={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
|
||||
title={
|
||||
(!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 ${
|
||||
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
|
||||
? 'bg-emerald-50 text-emerald-700 border-emerald-200'
|
||||
? 'bg-emerald-50 text-emerald-850 border-emerald-200/60'
|
||||
: 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
|
||||
? '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')
|
||||
? '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)
|
||||
? 'bg-rose-50 text-rose-700 border-rose-200 cursor-help'
|
||||
: 'bg-slate-50 text-slate-500 border-slate-100'
|
||||
? 'bg-rose-50 text-rose-850 border-rose-200/60 cursor-help'
|
||||
: 'bg-slate-50 text-slate-550 border-slate-100'
|
||||
}`}
|
||||
>
|
||||
{paper.has_vector
|
||||
@ -443,22 +443,9 @@ export function LibraryPanel({
|
||||
? '下载失败'
|
||||
: '未下载'}
|
||||
</div>
|
||||
|
||||
<div className="pr-10">
|
||||
<div>
|
||||
<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 && (
|
||||
}
|
||||
footerLeft={
|
||||
paper.is_downloaded && (
|
||||
<div className="flex gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
@ -466,7 +453,7 @@ export function LibraryPanel({
|
||||
e.stopPropagation();
|
||||
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" />
|
||||
<span>阅读</span>
|
||||
@ -477,21 +464,16 @@ export function LibraryPanel({
|
||||
e.stopPropagation();
|
||||
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" />
|
||||
<span>图谱</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0 self-end">
|
||||
<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>
|
||||
)
|
||||
}
|
||||
footerRight={
|
||||
<>
|
||||
<div className="flex items-center gap-1">
|
||||
<span
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@ -12,7 +12,7 @@ export function ResearchAgentPanel({ showConfirm, showAlert }: ResearchAgentPane
|
||||
const state = useResearchAgent({ showConfirm, showAlert });
|
||||
|
||||
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
|
||||
sessions={state.sessions}
|
||||
|
||||
@ -3,8 +3,7 @@ import React from 'react';
|
||||
import { Search, Loader, CheckCircle, Copy, Download, ChevronLeft, ChevronRight, SlidersHorizontal, AlertTriangle, Lightbulb } from 'lucide-react';
|
||||
import type { StandardPaper } from '../types';
|
||||
import { CustomSelect } from '../components/CustomSelect';
|
||||
|
||||
import { getDoctypeBadge } from '../utils/paper';
|
||||
import { PaperCard } from '../components/PaperCard';
|
||||
|
||||
interface SearchPanelProps {
|
||||
searchQuery: string;
|
||||
@ -125,7 +124,7 @@ export function SearchPanel({
|
||||
</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">
|
||||
<div className="flex flex-col md:flex-row gap-3 items-stretch">
|
||||
<div className="relative flex-1">
|
||||
@ -135,16 +134,16 @@ export function SearchPanel({
|
||||
value={searchQuery}
|
||||
onChange={e => setSearchQuery(e.target.value)}
|
||||
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 className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
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
|
||||
? 'bg-sky-50 border-sky-300 text-sky-700'
|
||||
? 'bg-slate-100 border-slate-350 text-slate-855'
|
||||
: 'btn-console'
|
||||
}`}
|
||||
>
|
||||
@ -154,7 +153,7 @@ export function SearchPanel({
|
||||
<button
|
||||
type="submit"
|
||||
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 ? '检索中...' : '检索文献'}
|
||||
@ -164,13 +163,13 @@ export function SearchPanel({
|
||||
|
||||
{/* 条件构造器 */}
|
||||
{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">
|
||||
<span>高级检索式条件构造器</span>
|
||||
<button
|
||||
type="button"
|
||||
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>
|
||||
@ -218,14 +217,14 @@ export function SearchPanel({
|
||||
? '例如: 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 && (
|
||||
<button
|
||||
type="button"
|
||||
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>
|
||||
@ -260,9 +259,9 @@ export function SearchPanel({
|
||||
name="searchSource"
|
||||
checked={searchSource === src.id}
|
||||
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}
|
||||
</span>
|
||||
</label>
|
||||
@ -304,7 +303,7 @@ export function SearchPanel({
|
||||
type="button"
|
||||
onClick={handleExportBibtex}
|
||||
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}
|
||||
导出已选 ({exportingList.length}) 篇 BibTeX
|
||||
@ -317,11 +316,11 @@ export function SearchPanel({
|
||||
|
||||
{/* BibTeX 导出显示 */}
|
||||
{bibtexContent && (
|
||||
<div className="console-panel p-5 rounded-xl border border-sky-200 relative overflow-hidden bg-sky-50/20">
|
||||
<h3 className="text-xs font-bold text-sky-700 mb-3 flex items-center gap-2">
|
||||
<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-slate-700 mb-3 flex items-center gap-2">
|
||||
<CheckCircle className="w-4 h-4 text-emerald-600" /> BibTeX 导出成功
|
||||
</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}
|
||||
</pre>
|
||||
<button
|
||||
@ -329,7 +328,7 @@ export function SearchPanel({
|
||||
navigator.clipboard.writeText(bibtexContent);
|
||||
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" /> 复制数据
|
||||
</button>
|
||||
@ -339,9 +338,9 @@ export function SearchPanel({
|
||||
{/* 检索列表 */}
|
||||
<div className="relative min-h-[200px]">
|
||||
{searching && (
|
||||
<div className="absolute inset-0 bg-white/60 backdrop-blur-[1px] z-10 flex items-center justify-center rounded-xl">
|
||||
<div className="flex flex-col items-center gap-3 bg-white p-6 rounded-xl shadow-lg border border-slate-200 text-center">
|
||||
<Loader className="w-8 h-8 text-sky-650 animate-spin" />
|
||||
<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-lg shadow-sm border border-slate-200 text-center">
|
||||
<Loader className="w-8 h-8 text-slate-600 animate-spin" />
|
||||
<span className="text-xs font-bold text-slate-600">正在与学术服务器通讯,检索文献数据中...</span>
|
||||
</div>
|
||||
</div>
|
||||
@ -351,64 +350,48 @@ export function SearchPanel({
|
||||
const isDownloading = downloadingBibcodes[paper.bibcode] || false;
|
||||
const isSelected = selectedPaper?.bibcode === paper.bibcode;
|
||||
return (
|
||||
<div
|
||||
<PaperCard
|
||||
key={paper.bibcode}
|
||||
className={`console-panel p-6 rounded-xl border transition-all relative ${
|
||||
isSelected ? 'border-sky-500 bg-sky-50/10' : 'border-slate-200 hover:border-slate-350 hover:bg-white'
|
||||
}`}
|
||||
>
|
||||
{/* 选定指示侧条 */}
|
||||
{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"
|
||||
paper={paper}
|
||||
isSelected={isSelected}
|
||||
onClick={() => openReader(paper)}
|
||||
>
|
||||
{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>
|
||||
|
||||
<div className="flex gap-2 shrink-0 items-center">
|
||||
variant="list"
|
||||
headerActions={
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
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>
|
||||
{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" /> 已下载
|
||||
</span>
|
||||
) : (
|
||||
<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
|
||||
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>
|
||||
) : (
|
||||
(paper.pdf_error || paper.html_error) && (
|
||||
<span
|
||||
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>
|
||||
)
|
||||
)}
|
||||
<button
|
||||
onClick={() => handleDownload(paper.bibcode)}
|
||||
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" />}
|
||||
下载文献
|
||||
@ -416,27 +399,27 @@ export function SearchPanel({
|
||||
</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
|
||||
type="checkbox"
|
||||
checked={exportingList.includes(paper.bibcode)}
|
||||
onChange={() => toggleExportItem(paper.bibcode)}
|
||||
className="rounded text-sky-600 border-slate-300"
|
||||
className="rounded text-slate-700 border-slate-300"
|
||||
/>
|
||||
<span>选择</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</>
|
||||
}
|
||||
abstract={
|
||||
<p className="text-xs text-slate-600 line-clamp-3 leading-relaxed mb-4 font-normal">
|
||||
{paper.abstract_text || '暂无文献摘要数据。'}
|
||||
</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">
|
||||
<div className="flex gap-2">
|
||||
}
|
||||
footerLeft={
|
||||
<>
|
||||
<button
|
||||
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>
|
||||
@ -446,19 +429,20 @@ export function SearchPanel({
|
||||
setActiveTab('citation');
|
||||
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>
|
||||
</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>}
|
||||
<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>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
@ -466,11 +450,11 @@ export function SearchPanel({
|
||||
|
||||
{/* 分页控制栏 */}
|
||||
{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
|
||||
onClick={() => handlePageChange(searchStart - searchRows)}
|
||||
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" /> 上一页
|
||||
</button>
|
||||
@ -482,7 +466,7 @@ export function SearchPanel({
|
||||
<button
|
||||
onClick={() => handlePageChange(searchStart + searchRows)}
|
||||
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" />
|
||||
</button>
|
||||
|
||||
@ -8,16 +8,16 @@ export function SettingsPanel() {
|
||||
<p className="text-xs text-slate-500">AstroResearch 核心系统运行参数配置</p>
|
||||
</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">
|
||||
<h3 className="text-sm font-semibold text-slate-800 mb-1">配置文件 (.env) 运作模式</h3>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div className="bg-purple-50 border border-purple-200 p-4 rounded-xl text-xs text-slate-650 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> 路径文件内容完整可用。
|
||||
<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-blueprint font-semibold bg-slate-100 px-1 py-0.5 rounded">/home/fmq/下载/astrodict_241020/astrodict241020_ec.txt</code> 路径文件内容完整可用。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -20,7 +20,7 @@ export function SyncPanel() {
|
||||
|
||||
{/* 全局错误显示 */}
|
||||
{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" />
|
||||
<div className="font-semibold">{state.errorMsg}</div>
|
||||
</div>
|
||||
@ -71,10 +71,10 @@ export function SyncPanel() {
|
||||
/>
|
||||
|
||||
{/* 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">
|
||||
<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>
|
||||
</h3>
|
||||
<p className="text-slate-500 text-xs mt-0.5">
|
||||
@ -83,7 +83,7 @@ export function SyncPanel() {
|
||||
</div>
|
||||
|
||||
{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>
|
||||
) : (
|
||||
@ -104,7 +104,7 @@ export function SyncPanel() {
|
||||
<button
|
||||
type="button"
|
||||
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>
|
||||
@ -112,14 +112,14 @@ export function SyncPanel() {
|
||||
type="button"
|
||||
disabled={state.status.active}
|
||||
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
|
||||
type="button"
|
||||
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>
|
||||
|
||||
@ -154,7 +154,7 @@ sequenceDiagram
|
||||
U->>H: 1. 请求文献对比翻译 (POST /api/translate, 含 bibcode, force)
|
||||
H->>DB: 2. 查询文献路径及状态
|
||||
alt force == false 且本地已存在翻译缓存文件
|
||||
H->>H: 3. 读取本地 Translation/{bibcode}_zh.md 物理文件
|
||||
H->>H: 3. 读取本地 Translation/{bibcode}.md 物理文件
|
||||
H-->>U: 4. 直接返回缓存译文,流程结束
|
||||
end
|
||||
|
||||
|
||||
93
src/agent/runtime/config.rs
Normal file
93
src/agent/runtime/config.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
27
src/agent/runtime/duplicate_detector.rs
Normal file
27
src/agent/runtime/duplicate_detector.rs
Normal 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
624
src/agent/runtime/error_recovery/classification.rs
Normal file
624
src/agent/runtime/error_recovery/classification.rs
Normal file
@ -0,0 +1,624 @@
|
||||
// src/agent/runtime/error_recovery/classification.rs
|
||||
//
|
||||
// 故障转移原因分类引擎:FailoverReason / ErrorKind / classify_* 函数族。
|
||||
|
||||
use tracing::{error, info};
|
||||
|
||||
// ── FailoverReason:21 种错误分类 ──
|
||||
|
||||
/// 故障转移原因 — 参考 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(_) => "未知错误,系统将尝试恢复。",
|
||||
}
|
||||
}
|
||||
726
src/agent/runtime/error_recovery/mod.rs
Normal file
726
src/agent/runtime/error_recovery/mod.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
157
src/agent/runtime/error_recovery/overflow.rs
Normal file
157
src/agent/runtime/error_recovery/overflow.rs
Normal 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
|
||||
}
|
||||
83
src/agent/runtime/events.rs
Normal file
83
src/agent/runtime/events.rs
Normal 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>,
|
||||
}
|
||||
257
src/agent/runtime/executor/helpers.rs
Normal file
257
src/agent/runtime/executor/helpers.rs
Normal 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
@ -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::StreamExt;
|
||||
@ -21,36 +26,10 @@ use super::partitioner::ToolPartitioner;
|
||||
use super::permission::{PermissionChecker, PermissionResult};
|
||||
use super::permission_explainer::explain_permission;
|
||||
use super::{AgentStreamEvent, DuplicateDetector};
|
||||
use crate::agent::hooks::{
|
||||
event_label, HookRegistry, PostToolUseContext, PostToolUseFailureContext, PreToolUseContext,
|
||||
};
|
||||
use crate::agent::hooks::{event_label, HookRegistry, PreToolUseContext};
|
||||
use crate::agent::tools::{ToolContext, ToolRegistry};
|
||||
|
||||
/// 准备好的工具调用
|
||||
#[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>,
|
||||
}
|
||||
use helpers::{execute_single_tool, process_single_result, save_tool_message_sync};
|
||||
|
||||
/// 验证工具调用:死循环检测 + 参数解析。
|
||||
///
|
||||
@ -799,217 +778,3 @@ pub async fn execute_parallel(
|
||||
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);
|
||||
}
|
||||
});
|
||||
}
|
||||
@ -13,9 +13,12 @@
|
||||
|
||||
pub mod checkpoint;
|
||||
pub mod circuit_breaker;
|
||||
pub mod config;
|
||||
pub mod context;
|
||||
pub mod denial_tracker;
|
||||
pub mod duplicate_detector;
|
||||
pub mod error_recovery;
|
||||
pub mod events;
|
||||
pub mod executor;
|
||||
pub mod file_cache;
|
||||
pub mod finalize;
|
||||
@ -31,9 +34,7 @@ pub mod system_prompt;
|
||||
pub mod token_budget;
|
||||
pub mod untrusted;
|
||||
|
||||
use serde::Serialize;
|
||||
use sqlx::SqlitePool;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::mpsc;
|
||||
use tracing::{error, info, warn};
|
||||
@ -55,203 +56,16 @@ use self::streaming::{StreamOutput, StreamStatus};
|
||||
use self::system_prompt::SystemPromptCache;
|
||||
use self::token_budget::TokenBudget;
|
||||
|
||||
/// 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()
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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
|
||||
}
|
||||
}
|
||||
// Re-export 公共类型,保持外部引用路径不变
|
||||
// (use crate::agent::runtime::{AgentConfig, AgentStreamEvent, ...})
|
||||
// pub use 同时将这些名称引入当前作用域,供本文件内使用
|
||||
pub use self::config::AgentConfig;
|
||||
pub use self::duplicate_detector::DuplicateDetector;
|
||||
pub use self::events::{AgentMetrics, AgentStreamEvent};
|
||||
|
||||
// ── Agent Runtime ──
|
||||
|
||||
/// Agent 运行时核心,协调 ReAct 循环的执行。
|
||||
/// 智能体运行时
|
||||
pub struct AgentRuntime {
|
||||
app_state: Arc<AppState>,
|
||||
|
||||
262
src/agent/skills/creator.rs
Normal file
262
src/agent/skills/creator.rs
Normal 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 ──────────────────────────────────────────────────────────────────
|
||||
@ -1,4 +1,4 @@
|
||||
// src/agent/skills.rs
|
||||
// src/agent/skills/mod.rs
|
||||
//
|
||||
// 两层技能加载系统(参考 Claude Code src/skills/ + src/tools/SkillTool/ 设计):
|
||||
// Layer 1 — system-reminder 注入:每轮动态列出 skill 名称(~20 tokens/skill)
|
||||
@ -31,10 +31,11 @@
|
||||
// # Skill 正文
|
||||
// 详细内容...
|
||||
|
||||
pub mod creator;
|
||||
pub mod curator;
|
||||
pub mod pattern_detector;
|
||||
pub mod types;
|
||||
|
||||
use serde::Deserialize;
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, RwLock};
|
||||
@ -43,111 +44,9 @@ use tracing::{info, warn};
|
||||
|
||||
// ── Frontmatter ────────────────────────────────────────────────────────────
|
||||
|
||||
/// Skill 的 YAML frontmatter 结构(serde_yaml 解析)。
|
||||
/// 仅声明与平台相关的字段;未知字段自动忽略。
|
||||
#[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,
|
||||
}
|
||||
|
||||
/// 完整的 Skill(Layer 2:LLM 调用 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>>,
|
||||
}
|
||||
// Re-export 公共类型
|
||||
pub use creator::{SelfImprovePipeline, SelfImproveResult, SkillCreator};
|
||||
pub use types::{Skill, SkillFrontmatter, SkillMeta, SkillUsageStat};
|
||||
|
||||
/// Skill 注册表 — 缓存已加载的 skills,支持 mtime 增量刷新。
|
||||
///
|
||||
@ -677,255 +576,6 @@ pub fn substitute_variables(body: &str, skill_dir: &Path, session_id: Option<&st
|
||||
/// ```ignore
|
||||
/// 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 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
110
src/agent/skills/types.rs
Normal file
110
src/agent/skills/types.rs
Normal 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,
|
||||
}
|
||||
|
||||
/// 完整的 Skill(Layer 2:LLM 调用 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>>,
|
||||
}
|
||||
@ -1,215 +1,17 @@
|
||||
// src/clients/llm.rs
|
||||
// src/clients/llm/chat.rs
|
||||
//
|
||||
// ChatCompleter trait + LlmClient 实现(对话补全、流式对话、图片分析)。
|
||||
|
||||
use async_trait::async_trait;
|
||||
use futures_util::StreamExt;
|
||||
use reqwest::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde::Deserialize;
|
||||
use std::sync::Arc;
|
||||
use tracing::error;
|
||||
|
||||
/// 消息角色枚举(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),
|
||||
}
|
||||
use super::types::{
|
||||
ChatMessage, CompletionResult, FunctionCall, StreamEvent, TokenUsage, ToolCall, ToolDefinition,
|
||||
};
|
||||
|
||||
/// LLM 对话补全的抽象 trait,支持真实客户端和测试 mock。
|
||||
#[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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::clients::llm::types::{
|
||||
ChatMessage, FunctionCall, MessageRole, ToolCall, ToolDefinition,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn test_llm_client_initialization() {
|
||||
@ -940,15 +571,6 @@ mod tests {
|
||||
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]
|
||||
fn test_chat_message_construction() {
|
||||
// 测试系统消息构造
|
||||
@ -1109,7 +731,7 @@ mod tests {
|
||||
"测试向量模型: {} (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_base.clone(),
|
||||
config.embedding_model.clone(),
|
||||
196
src/clients/llm/embedding.rs
Normal file
196
src/clients/llm/embedding.rs
Normal 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
17
src/clients/llm/mod.rs
Normal 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
210
src/clients/llm/types.rs
Normal 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
238
src/services/batch/asset/helpers.rs
Normal file
238
src/services/batch/asset/helpers.rs
Normal 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
|
||||
}
|
||||
}
|
||||
464
src/services/batch/asset/mod.rs
Normal file
464
src/services/batch/asset/mod.rs
Normal 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(())
|
||||
}
|
||||
}
|
||||
645
src/services/batch/asset/process.rs
Normal file
645
src/services/batch/asset/process.rs
Normal 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);
|
||||
|
||||
// 策略 1:HTML 优先
|
||||
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
337
src/services/download/headers.rs
Normal file
337
src/services/download/headers.rs
Normal 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(())
|
||||
}
|
||||
716
src/services/download/mod.rs
Normal file
716
src/services/download/mod.rs
Normal 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 直接下载 PDF(arxiv.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/v3),arxiv.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→ 直联 iopscience,arxiv 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/Nature:HTML 更可靠,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 页面 → 优先官方 HTML,ar5iv 兜底
|
||||
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(())
|
||||
}
|
||||
}
|
||||
556
src/services/download/strategies.rs
Normal file
556
src/services/download/strategies.rs
Normal 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
|
||||
}
|
||||
}
|
||||
@ -185,17 +185,17 @@ fn clean_chinese_translation(raw: &str) -> String {
|
||||
/// 将翻译结果持久化到本地文件并更新数据库路径。
|
||||
///
|
||||
/// 此函数抽取自 papers.rs 与 batch/asset.rs 中重复的翻译持久化逻辑:
|
||||
/// 1. 写入 `Translation/{bibcode}_zh.md`
|
||||
/// 1. 写入 `Translation/{bibcode}.md`
|
||||
/// 2. 将相对路径更新到 `papers.translation_path` 列
|
||||
///
|
||||
/// 返回写入的相对路径(如 `Translation/2024A&A...123..456X_zh.md`)。
|
||||
/// 返回写入的相对路径(如 `Translation/2024A&A...123..456X.md`)。
|
||||
pub async fn persist_translation(
|
||||
db: &sqlx::SqlitePool,
|
||||
library_dir: &Path,
|
||||
bibcode: &str,
|
||||
translated_markdown: &str,
|
||||
) -> 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);
|
||||
if let Some(parent) = tr_dest.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user