Add paper browser web app
This commit is contained in:
@@ -0,0 +1,367 @@
|
||||
const state = {
|
||||
papers: [],
|
||||
total: 0,
|
||||
offset: 0,
|
||||
limit: 50,
|
||||
selectedId: null,
|
||||
selectedPaper: null,
|
||||
selectedMarkdown: "",
|
||||
topic: "",
|
||||
loading: false,
|
||||
health: null,
|
||||
};
|
||||
|
||||
const $ = (selector) => document.querySelector(selector);
|
||||
|
||||
const elements = {
|
||||
healthLine: $("#healthLine"),
|
||||
searchInput: $("#searchInput"),
|
||||
yearSelect: $("#yearSelect"),
|
||||
statusSelect: $("#statusSelect"),
|
||||
sortSelect: $("#sortSelect"),
|
||||
clearTopicBtn: $("#clearTopicBtn"),
|
||||
topicChips: $("#topicChips"),
|
||||
resultTitle: $("#resultTitle"),
|
||||
resultCount: $("#resultCount"),
|
||||
resultsList: $("#resultsList"),
|
||||
loadMoreBtn: $("#loadMoreBtn"),
|
||||
detailMetaLine: $("#detailMetaLine"),
|
||||
detailTitle: $("#detailTitle"),
|
||||
detailTags: $("#detailTags"),
|
||||
arxivLink: $("#arxivLink"),
|
||||
paperPreview: $("#paperPreview"),
|
||||
abstractBtn: $("#abstractBtn"),
|
||||
summaryBtn: $("#summaryBtn"),
|
||||
translateBtn: $("#translateBtn"),
|
||||
deepBtn: $("#deepBtn"),
|
||||
modelStatus: $("#modelStatus"),
|
||||
aiStatus: $("#aiStatus"),
|
||||
abstractView: $("#abstractView"),
|
||||
aiView: $("#aiView"),
|
||||
};
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? "")
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
function debounce(fn, delay = 250) {
|
||||
let timer = null;
|
||||
return (...args) => {
|
||||
window.clearTimeout(timer);
|
||||
timer = window.setTimeout(() => fn(...args), delay);
|
||||
};
|
||||
}
|
||||
|
||||
async function api(path, options = {}) {
|
||||
const response = await fetch(path, {
|
||||
...options,
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
...(options.headers || {}),
|
||||
},
|
||||
});
|
||||
const payload = await response.json();
|
||||
if (!response.ok) {
|
||||
throw new Error(payload.error || `HTTP ${response.status}`);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
function queryParams(extra = {}) {
|
||||
const params = new URLSearchParams();
|
||||
const q = elements.searchInput.value.trim();
|
||||
if (q) params.set("q", q);
|
||||
if (state.topic) params.set("topic", state.topic);
|
||||
if (elements.yearSelect.value) params.set("year", elements.yearSelect.value);
|
||||
if (elements.statusSelect.value) params.set("status", elements.statusSelect.value);
|
||||
if (elements.sortSelect.value) params.set("sort", elements.sortSelect.value);
|
||||
params.set("limit", String(state.limit));
|
||||
params.set("offset", String(state.offset));
|
||||
Object.entries(extra).forEach(([key, value]) => params.set(key, String(value)));
|
||||
return params;
|
||||
}
|
||||
|
||||
async function loadHealth() {
|
||||
try {
|
||||
const health = await api("/api/health");
|
||||
state.health = health;
|
||||
elements.healthLine.textContent = `${health.paper_count} papers`;
|
||||
elements.modelStatus.textContent = health.ollama_ok
|
||||
? `Ollama 已连接 · summary=${health.default_models.summary}`
|
||||
: "Ollama 未连接";
|
||||
} catch (error) {
|
||||
elements.healthLine.textContent = "服务状态未知";
|
||||
elements.modelStatus.textContent = error.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTopics() {
|
||||
const payload = await api("/api/topics");
|
||||
elements.topicChips.innerHTML = payload.topics
|
||||
.slice(0, 24)
|
||||
.map((item) => {
|
||||
const active = item.topic === state.topic ? " active" : "";
|
||||
return `<button class="topic-chip${active}" type="button" data-topic="${escapeHtml(item.topic)}">${escapeHtml(item.topic)} ${item.count}</button>`;
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
async function loadPapers({ reset = false } = {}) {
|
||||
if (state.loading) return;
|
||||
state.loading = true;
|
||||
if (reset) {
|
||||
state.offset = 0;
|
||||
state.papers = [];
|
||||
}
|
||||
elements.resultTitle.textContent = state.topic || "论文库";
|
||||
elements.resultCount.textContent = "...";
|
||||
try {
|
||||
const payload = await api(`/api/papers?${queryParams()}`);
|
||||
state.total = payload.total;
|
||||
state.papers = reset ? payload.items : [...state.papers, ...payload.items];
|
||||
state.offset = state.papers.length;
|
||||
renderResults();
|
||||
if (!state.selectedId && state.papers.length) {
|
||||
await selectPaper(state.papers[0].id);
|
||||
}
|
||||
} catch (error) {
|
||||
elements.resultsList.innerHTML = `<div class="empty-state">${escapeHtml(error.message)}</div>`;
|
||||
} finally {
|
||||
state.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function renderResults() {
|
||||
elements.resultCount.textContent = String(state.total);
|
||||
elements.loadMoreBtn.style.display = state.papers.length < state.total ? "block" : "none";
|
||||
if (!state.papers.length) {
|
||||
elements.resultsList.innerHTML = `<div class="empty-state">没有匹配结果</div>`;
|
||||
return;
|
||||
}
|
||||
elements.resultsList.innerHTML = state.papers
|
||||
.map((paper) => {
|
||||
const active = paper.id === state.selectedId ? " active" : "";
|
||||
const topics = paper.topics
|
||||
.slice(0, 4)
|
||||
.map((topic) => `<span class="mini-badge">${escapeHtml(topic)}</span>`)
|
||||
.join("");
|
||||
return `
|
||||
<button class="paper-row${active}" type="button" data-id="${paper.id}">
|
||||
<div class="paper-title">${escapeHtml(paper.title)}</div>
|
||||
<div class="paper-meta">
|
||||
<span>${escapeHtml(paper.year || "unknown")}</span>
|
||||
<span>${escapeHtml(paper.venue || paper.source || "paper")}</span>
|
||||
<span>score ${escapeHtml(paper.collection_score)}</span>
|
||||
<span>${escapeHtml(paper.status)}</span>
|
||||
</div>
|
||||
<div class="paper-topics">${topics}</div>
|
||||
</button>
|
||||
`;
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
function stripFrontmatter(markdown) {
|
||||
const text = String(markdown || "");
|
||||
const match = text.match(/^# .+?\n\n---[\s\S]*?\n---\n\n?/);
|
||||
return match ? text.slice(match[0].length) : text;
|
||||
}
|
||||
|
||||
function inlineMarkdown(text) {
|
||||
let value = escapeHtml(text);
|
||||
value = value.replace(/\[([^\]]+)\]\((https?:\/\/[^)]+)\)/g, '<a href="$2" target="_blank" rel="noreferrer">$1</a>');
|
||||
value = value.replace(/`([^`]+)`/g, "<code>$1</code>");
|
||||
return value;
|
||||
}
|
||||
|
||||
function renderMarkdown(markdown) {
|
||||
const lines = stripFrontmatter(markdown).split(/\r?\n/);
|
||||
const output = [];
|
||||
let inList = false;
|
||||
const closeList = () => {
|
||||
if (inList) {
|
||||
output.push("</ul>");
|
||||
inList = false;
|
||||
}
|
||||
};
|
||||
|
||||
for (const raw of lines) {
|
||||
const line = raw.trimEnd();
|
||||
if (!line.trim()) {
|
||||
closeList();
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith("### ")) {
|
||||
closeList();
|
||||
output.push(`<h3>${inlineMarkdown(line.slice(4))}</h3>`);
|
||||
} else if (line.startsWith("## ")) {
|
||||
closeList();
|
||||
output.push(`<h2>${inlineMarkdown(line.slice(3))}</h2>`);
|
||||
} else if (line.startsWith("# ")) {
|
||||
closeList();
|
||||
output.push(`<h1>${inlineMarkdown(line.slice(2))}</h1>`);
|
||||
} else if (line.startsWith("- ")) {
|
||||
if (!inList) {
|
||||
output.push("<ul>");
|
||||
inList = true;
|
||||
}
|
||||
output.push(`<li>${inlineMarkdown(line.slice(2))}</li>`);
|
||||
} else {
|
||||
closeList();
|
||||
output.push(`<p>${inlineMarkdown(line)}</p>`);
|
||||
}
|
||||
}
|
||||
closeList();
|
||||
return output.join("");
|
||||
}
|
||||
|
||||
function renderDetail(paper, markdown) {
|
||||
state.selectedPaper = paper;
|
||||
state.selectedMarkdown = markdown;
|
||||
elements.detailMetaLine.textContent = `${paper.year || "unknown"} · ${paper.venue || paper.source || "paper"} · ${paper.status}`;
|
||||
elements.detailTitle.textContent = paper.title;
|
||||
elements.arxivLink.href = paper.url || "#";
|
||||
elements.arxivLink.style.visibility = paper.url ? "visible" : "hidden";
|
||||
elements.detailTags.innerHTML = [
|
||||
`<span class="tag score">score ${escapeHtml(paper.collection_score)}</span>`,
|
||||
`<span class="tag">${escapeHtml(paper.relevance || "relevance")}</span>`,
|
||||
...paper.topics.map((topic) => `<span class="tag">${escapeHtml(topic)}</span>`),
|
||||
].join("");
|
||||
elements.paperPreview.innerHTML = renderMarkdown(markdown);
|
||||
elements.abstractView.innerHTML = `<p class="empty-state">未加载摘要</p>`;
|
||||
elements.aiView.innerHTML = `<p class="empty-state">未生成 AI 结果</p>`;
|
||||
elements.aiStatus.textContent = "";
|
||||
renderResults();
|
||||
}
|
||||
|
||||
async function selectPaper(id) {
|
||||
state.selectedId = id;
|
||||
renderResults();
|
||||
try {
|
||||
const payload = await api(`/api/paper?id=${encodeURIComponent(id)}`);
|
||||
renderDetail(payload.paper, payload.markdown);
|
||||
} catch (error) {
|
||||
elements.detailTitle.textContent = error.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAbstract() {
|
||||
if (!state.selectedId) return;
|
||||
setBusy(true, "获取 arXiv 摘要");
|
||||
try {
|
||||
const payload = await api(`/api/paper/abstract?id=${encodeURIComponent(state.selectedId)}`);
|
||||
const abstract = payload.abstract;
|
||||
if (!abstract) {
|
||||
elements.abstractView.innerHTML = `<p class="empty-state">${escapeHtml(payload.message || "没有摘要")}</p>`;
|
||||
} else {
|
||||
elements.abstractView.innerHTML = `
|
||||
<h3>${escapeHtml(abstract.title)}</h3>
|
||||
<p>${escapeHtml(abstract.abstract)}</p>
|
||||
<div class="paper-meta">
|
||||
<span>${escapeHtml(abstract.published)}</span>
|
||||
<span>${escapeHtml((abstract.categories || []).join(", "))}</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
activateTab("abstract");
|
||||
} catch (error) {
|
||||
elements.abstractView.innerHTML = `<p class="empty-state">${escapeHtml(error.message)}</p>`;
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
function setBusy(isBusy, label = "") {
|
||||
elements.abstractBtn.disabled = isBusy;
|
||||
elements.summaryBtn.disabled = isBusy;
|
||||
elements.translateBtn.disabled = isBusy;
|
||||
elements.deepBtn.disabled = isBusy;
|
||||
elements.aiStatus.textContent = isBusy ? label : "";
|
||||
}
|
||||
|
||||
async function runAi(mode) {
|
||||
if (!state.selectedId) return;
|
||||
if (mode === "deep") {
|
||||
const ok = window.confirm("深度研读会调用 large 模型,占用更多 GPU。继续?");
|
||||
if (!ok) return;
|
||||
}
|
||||
const model = state.health?.default_models?.[mode] || (mode === "translate" ? "ChatGPT-5.6:light" : "ChatGPT-5.6:fast");
|
||||
const label = mode === "translate" ? "翻译中" : mode === "deep" ? "深度研读中" : "摘要生成中";
|
||||
setBusy(true, `${label} · ${model}`);
|
||||
activateTab("ai");
|
||||
elements.aiView.innerHTML = `<p class="empty-state">${escapeHtml(label)}</p>`;
|
||||
try {
|
||||
const payload = await api("/api/ai", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ id: state.selectedId, mode, model }),
|
||||
});
|
||||
const cache = payload.cached ? "cached" : `${payload.duration_seconds}s`;
|
||||
elements.aiView.innerHTML = `
|
||||
<div class="paper-meta">
|
||||
<span>${escapeHtml(payload.model)}</span>
|
||||
<span>${escapeHtml(payload.mode)}</span>
|
||||
<span>${escapeHtml(cache)}</span>
|
||||
</div>
|
||||
<div class="ai-output">${escapeHtml(payload.response)}</div>
|
||||
`;
|
||||
} catch (error) {
|
||||
elements.aiView.innerHTML = `<p class="empty-state">${escapeHtml(error.message)}</p>`;
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
function activateTab(name) {
|
||||
document.querySelectorAll(".tab").forEach((tab) => {
|
||||
tab.classList.toggle("active", tab.dataset.tab === name);
|
||||
});
|
||||
document.querySelectorAll(".tab-view").forEach((view) => {
|
||||
view.classList.toggle("active", view.id === `${name}View`);
|
||||
});
|
||||
}
|
||||
|
||||
function bindEvents() {
|
||||
const reload = debounce(() => loadPapers({ reset: true }), 260);
|
||||
elements.searchInput.addEventListener("input", reload);
|
||||
elements.yearSelect.addEventListener("change", reload);
|
||||
elements.statusSelect.addEventListener("change", reload);
|
||||
elements.sortSelect.addEventListener("change", reload);
|
||||
elements.clearTopicBtn.addEventListener("click", async () => {
|
||||
state.topic = "";
|
||||
await loadTopics();
|
||||
await loadPapers({ reset: true });
|
||||
});
|
||||
elements.topicChips.addEventListener("click", async (event) => {
|
||||
const button = event.target.closest("[data-topic]");
|
||||
if (!button) return;
|
||||
state.topic = button.dataset.topic === state.topic ? "" : button.dataset.topic;
|
||||
await loadTopics();
|
||||
await loadPapers({ reset: true });
|
||||
});
|
||||
elements.resultsList.addEventListener("click", (event) => {
|
||||
const button = event.target.closest("[data-id]");
|
||||
if (button) selectPaper(button.dataset.id);
|
||||
});
|
||||
elements.loadMoreBtn.addEventListener("click", () => loadPapers());
|
||||
elements.abstractBtn.addEventListener("click", loadAbstract);
|
||||
elements.summaryBtn.addEventListener("click", () => runAi("summary"));
|
||||
elements.translateBtn.addEventListener("click", () => runAi("translate"));
|
||||
elements.deepBtn.addEventListener("click", () => runAi("deep"));
|
||||
document.querySelectorAll(".tab").forEach((tab) => {
|
||||
tab.addEventListener("click", () => activateTab(tab.dataset.tab));
|
||||
});
|
||||
}
|
||||
|
||||
async function boot() {
|
||||
bindEvents();
|
||||
await loadHealth();
|
||||
await loadTopics();
|
||||
await loadPapers({ reset: true });
|
||||
}
|
||||
|
||||
boot();
|
||||
@@ -0,0 +1,119 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Agent Paper Browser</title>
|
||||
<link rel="stylesheet" href="/static/styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<main class="app-shell">
|
||||
<aside class="sidebar">
|
||||
<div class="brand">
|
||||
<div class="brand-mark">AP</div>
|
||||
<div>
|
||||
<h1>Agent Papers</h1>
|
||||
<p id="healthLine">连接中</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label class="field">
|
||||
<span>检索</span>
|
||||
<input id="searchInput" type="search" placeholder="memory / safety / GUI / benchmark" autocomplete="off" />
|
||||
</label>
|
||||
|
||||
<div class="filter-grid">
|
||||
<label class="field">
|
||||
<span>年份</span>
|
||||
<select id="yearSelect">
|
||||
<option value="">全部</option>
|
||||
<option value="2026">2026</option>
|
||||
<option value="2025">2025</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span>状态</span>
|
||||
<select id="statusSelect">
|
||||
<option value="">全部</option>
|
||||
<option value="queued">queued</option>
|
||||
<option value="skimmed">skimmed</option>
|
||||
<option value="summarized">summarized</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label class="field">
|
||||
<span>排序</span>
|
||||
<select id="sortSelect">
|
||||
<option value="score">相关度</option>
|
||||
<option value="date">发布日期</option>
|
||||
<option value="title">标题</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<section class="topic-panel">
|
||||
<div class="panel-title">
|
||||
<span>主题</span>
|
||||
<button id="clearTopicBtn" class="ghost-button" type="button">清空</button>
|
||||
</div>
|
||||
<div id="topicChips" class="topic-chips"></div>
|
||||
</section>
|
||||
</aside>
|
||||
|
||||
<section class="results-pane">
|
||||
<header class="results-header">
|
||||
<div>
|
||||
<div class="eyebrow">Corpus</div>
|
||||
<h2 id="resultTitle">论文库</h2>
|
||||
</div>
|
||||
<div id="resultCount" class="count-pill">0</div>
|
||||
</header>
|
||||
<div id="resultsList" class="results-list"></div>
|
||||
<button id="loadMoreBtn" class="load-more" type="button">加载更多</button>
|
||||
</section>
|
||||
|
||||
<section class="detail-pane">
|
||||
<header class="detail-header">
|
||||
<div>
|
||||
<div id="detailMetaLine" class="eyebrow">选择论文</div>
|
||||
<h2 id="detailTitle">从左侧选择一篇论文</h2>
|
||||
</div>
|
||||
<a id="arxivLink" class="link-button" href="#" target="_blank" rel="noreferrer">arXiv</a>
|
||||
</header>
|
||||
|
||||
<div id="detailTags" class="detail-tags"></div>
|
||||
|
||||
<div class="action-bar">
|
||||
<button id="abstractBtn" class="tool-button" type="button">获取摘要</button>
|
||||
<button id="summaryBtn" class="tool-button primary" type="button">中文摘要</button>
|
||||
<button id="translateBtn" class="tool-button" type="button">翻译摘要</button>
|
||||
<button id="deepBtn" class="tool-button caution" type="button">深度研读</button>
|
||||
</div>
|
||||
|
||||
<div class="model-strip">
|
||||
<span id="modelStatus">Ollama 未检测</span>
|
||||
<span id="aiStatus"></span>
|
||||
</div>
|
||||
|
||||
<section class="split-content">
|
||||
<article class="preview-panel">
|
||||
<h3>预览</h3>
|
||||
<div id="paperPreview" class="markdown-preview"></div>
|
||||
</article>
|
||||
|
||||
<article class="ai-panel">
|
||||
<div class="tabs">
|
||||
<button class="tab active" data-tab="abstract" type="button">摘要</button>
|
||||
<button class="tab" data-tab="ai" type="button">AI</button>
|
||||
</div>
|
||||
<div id="abstractView" class="tab-view active"></div>
|
||||
<div id="aiView" class="tab-view"></div>
|
||||
</article>
|
||||
</section>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script src="/static/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,515 @@
|
||||
:root {
|
||||
--bg: #f7f8f5;
|
||||
--paper: #ffffff;
|
||||
--paper-2: #f0f4f1;
|
||||
--ink: #18211f;
|
||||
--muted: #68736f;
|
||||
--line: #dce2df;
|
||||
--teal: #0f766e;
|
||||
--teal-weak: #d8f0eb;
|
||||
--coral: #b94b35;
|
||||
--gold: #a66a00;
|
||||
--green: #3f7b43;
|
||||
--shadow: 0 16px 38px rgba(24, 33, 31, 0.08);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
font-family:
|
||||
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
|
||||
sans-serif;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select {
|
||||
font: inherit;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
display: grid;
|
||||
grid-template-columns: 260px minmax(340px, 430px) minmax(0, 1fr);
|
||||
height: 100vh;
|
||||
min-height: 680px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sidebar,
|
||||
.results-pane,
|
||||
.detail-pane {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
border-right: 1px solid var(--line);
|
||||
padding: 20px 18px;
|
||||
background: #fbfcfa;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.brand-mark {
|
||||
display: grid;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
place-items: center;
|
||||
border: 1px solid #a4c7bf;
|
||||
border-radius: 8px;
|
||||
background: var(--teal-weak);
|
||||
color: #07564f;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.brand h1,
|
||||
.results-header h2,
|
||||
.detail-header h2 {
|
||||
margin: 0;
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.brand h1 {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.brand p,
|
||||
.eyebrow {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.brand p {
|
||||
margin: 4px 0 0;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.field span,
|
||||
.panel-title {
|
||||
color: #44504c;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
input,
|
||||
select {
|
||||
width: 100%;
|
||||
height: 38px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
background: var(--paper);
|
||||
color: var(--ink);
|
||||
padding: 0 10px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
input:focus,
|
||||
select:focus {
|
||||
border-color: var(--teal);
|
||||
box-shadow: 0 0 0 3px rgba(15, 118, 110, 0.12);
|
||||
}
|
||||
|
||||
.filter-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.topic-panel {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.ghost-button {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--teal);
|
||||
padding: 4px 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.topic-chips,
|
||||
.detail-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.topic-chip,
|
||||
.tag {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
background: var(--paper);
|
||||
color: #34413d;
|
||||
padding: 5px 9px;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.topic-chip.active {
|
||||
border-color: var(--teal);
|
||||
background: var(--teal-weak);
|
||||
color: #064f49;
|
||||
}
|
||||
|
||||
.results-pane {
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr auto;
|
||||
border-right: 1px solid var(--line);
|
||||
background: #fdfefd;
|
||||
}
|
||||
|
||||
.results-header,
|
||||
.detail-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 18px 20px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.results-header h2,
|
||||
.detail-header h2 {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.count-pill {
|
||||
min-width: 46px;
|
||||
height: 30px;
|
||||
border-radius: 999px;
|
||||
background: #e9efe9;
|
||||
color: #33423d;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.results-list {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.paper-row {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
border: 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: transparent;
|
||||
padding: 15px 18px;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.paper-row:hover,
|
||||
.paper-row.active {
|
||||
background: #f0f6f3;
|
||||
}
|
||||
|
||||
.paper-row.active {
|
||||
box-shadow: inset 3px 0 0 var(--teal);
|
||||
}
|
||||
|
||||
.paper-title {
|
||||
font-size: 15px;
|
||||
font-weight: 750;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.paper-meta,
|
||||
.paper-topics {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.mini-badge {
|
||||
border-radius: 4px;
|
||||
background: #edf0ec;
|
||||
padding: 3px 6px;
|
||||
}
|
||||
|
||||
.load-more {
|
||||
height: 44px;
|
||||
border: 0;
|
||||
border-top: 1px solid var(--line);
|
||||
background: #f7faf7;
|
||||
color: var(--teal);
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.detail-pane {
|
||||
display: grid;
|
||||
grid-template-rows: auto auto auto auto 1fr;
|
||||
background: var(--bg);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.detail-header {
|
||||
background: var(--paper);
|
||||
}
|
||||
|
||||
.detail-header h2 {
|
||||
max-width: 920px;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.link-button {
|
||||
display: grid;
|
||||
min-width: 72px;
|
||||
height: 34px;
|
||||
place-items: center;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
color: var(--teal);
|
||||
text-decoration: none;
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.detail-tags {
|
||||
padding: 12px 20px;
|
||||
background: var(--paper);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.tag.score {
|
||||
border-color: #e7c98d;
|
||||
background: #fff6df;
|
||||
color: #704a00;
|
||||
}
|
||||
|
||||
.action-bar,
|
||||
.model-strip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
padding: 12px 20px;
|
||||
background: var(--paper);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.tool-button {
|
||||
min-height: 36px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
background: #fbfcfa;
|
||||
color: #22302d;
|
||||
padding: 0 12px;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.tool-button.primary {
|
||||
border-color: #0f766e;
|
||||
background: #0f766e;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.tool-button.caution {
|
||||
border-color: #d9b169;
|
||||
background: #fff8e8;
|
||||
color: #6b4500;
|
||||
}
|
||||
|
||||
.tool-button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.model-strip {
|
||||
justify-content: space-between;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.split-content {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(360px, 0.95fr) minmax(360px, 1fr);
|
||||
gap: 14px;
|
||||
padding: 14px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.preview-panel,
|
||||
.ai-panel {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: var(--paper);
|
||||
box-shadow: var(--shadow);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.preview-panel {
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr;
|
||||
}
|
||||
|
||||
.preview-panel h3 {
|
||||
margin: 0;
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.markdown-preview,
|
||||
.tab-view {
|
||||
overflow: auto;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.markdown-preview {
|
||||
line-height: 1.55;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.markdown-preview h1,
|
||||
.markdown-preview h2,
|
||||
.markdown-preview h3 {
|
||||
margin: 16px 0 8px;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.markdown-preview h1 {
|
||||
font-size: 21px;
|
||||
}
|
||||
|
||||
.markdown-preview h2 {
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
.markdown-preview h3 {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.markdown-preview p,
|
||||
.tab-view p {
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
|
||||
.markdown-preview ul {
|
||||
margin: 8px 0 14px;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.markdown-preview code,
|
||||
.tab-view code {
|
||||
border-radius: 4px;
|
||||
background: #eef2ef;
|
||||
padding: 1px 4px;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.tab {
|
||||
height: 44px;
|
||||
border: 0;
|
||||
border-right: 1px solid var(--line);
|
||||
background: #fbfcfa;
|
||||
color: #4d5c57;
|
||||
padding: 0 18px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
background: var(--paper);
|
||||
color: var(--teal);
|
||||
box-shadow: inset 0 -3px 0 var(--teal);
|
||||
}
|
||||
|
||||
.tab-view {
|
||||
display: none;
|
||||
max-height: calc(100vh - 266px);
|
||||
line-height: 1.6;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.tab-view.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.ai-output {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
color: var(--muted);
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.app-shell {
|
||||
grid-template-columns: 240px minmax(320px, 390px) minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.split-content {
|
||||
grid-template-columns: 1fr;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.tab-view {
|
||||
max-height: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.app-shell {
|
||||
display: block;
|
||||
height: auto;
|
||||
min-height: 100vh;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.sidebar,
|
||||
.results-pane,
|
||||
.detail-pane {
|
||||
border-right: 0;
|
||||
}
|
||||
|
||||
.results-list,
|
||||
.split-content {
|
||||
max-height: none;
|
||||
overflow: visible;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user