From 894c914e2669165d1e9beac8f2f09d03e344575b Mon Sep 17 00:00:00 2001 From: wuyang <5700876+banisherwy@user.noreply.gitee.com> Date: Wed, 8 Jul 2026 14:02:03 +0800 Subject: [PATCH] Add agentic paper search and chat --- web/README.md | 5 + web/app.py | 675 ++++++++++++++++++++++++++++++++++++++++++ web/static/app.js | 348 ++++++++++++++++++++-- web/static/index.html | 4 +- web/static/styles.css | 126 ++++++++ 5 files changed, 1129 insertions(+), 29 deletions(-) diff --git a/web/README.md b/web/README.md index 77a83e7..d89b9a4 100644 --- a/web/README.md +++ b/web/README.md @@ -35,6 +35,8 @@ https://lab.k1412.top/ - 深度分析模型:`ChatGPT-5.6:large`,只在前端手动选择时使用 - Atlas 解释:`ChatGPT-5.6:fast` - 主题导读、阅读路径、主题比较:`ChatGPT-5.6:large`,只在前端手动确认后调用 +- Search Agent 查询理解:`ChatGPT-5.6:light` +- Search Agent 结果简报、搜索追问、论文对话:`ChatGPT-5.6:fast` - Ollama 请求单并发执行,并缓存结果到 `web/cache/ai/` - arXiv 摘要缓存到 `web/cache/arxiv/` @@ -56,3 +58,6 @@ https://lab.k1412.top/ - 生成阅读路径 - 比较技术路线 - 单篇论文摘要、翻译、深度研读 +- 搜索意图理解、关键词扩展、结果简报 +- 基于搜索结果继续追问 +- 基于单篇论文继续对话式研读 diff --git a/web/app.py b/web/app.py index 6efca6d..bf8c98e 100644 --- a/web/app.py +++ b/web/app.py @@ -39,6 +39,10 @@ DEFAULT_MODELS = { "topic": "ChatGPT-5.6:large", "path": "ChatGPT-5.6:large", "compare": "ChatGPT-5.6:large", + "search_plan": "ChatGPT-5.6:light", + "search_summary": "ChatGPT-5.6:fast", + "search_followup": "ChatGPT-5.6:fast", + "paper_chat": "ChatGPT-5.6:fast", } NS = { @@ -48,8 +52,11 @@ NS = { INDEX_LOCK = threading.Lock() OLLAMA_LOCK = threading.Lock() +SEARCH_LOCK = threading.Lock() INDEX_CACHE: dict[str, Any] = {"mtime": 0.0, "papers": [], "by_id": {}} ATLAS_CACHE: dict[str, Any] = {"mtime": 0.0, "atlas": None} +NOTE_CACHE: dict[str, dict[str, Any]] = {} +SEARCH_JOBS: dict[str, dict[str, Any]] = {} TOPIC_PROFILES = { "agent-evaluation": { @@ -305,6 +312,556 @@ def paper_card(paper: dict[str, Any]) -> dict[str, Any]: } +def strip_note_frontmatter(markdown: str) -> str: + text = str(markdown or "") + match = re.match(r"^# .+?\n\n---[\s\S]*?\n---\n\n?", text) + return text[match.end() :] if match else text + + +def paper_note_text(paper: dict[str, Any]) -> str: + path = ROOT / paper["path"] + try: + mtime = path.stat().st_mtime + except FileNotFoundError: + return "" + cache_key = str(path) + cached = NOTE_CACHE.get(cache_key) + if cached and cached.get("mtime") == mtime: + return str(cached.get("text") or "") + text = strip_note_frontmatter(path.read_text(encoding="utf-8", errors="replace")) + text = normalize_space(text) + NOTE_CACHE[cache_key] = {"mtime": mtime, "text": text} + return text + + +def topic_label_map() -> dict[str, str]: + return {topic: profile_for(topic)["label"] for topic in TOPIC_PROFILES} + + +def tokenize_query(value: str) -> list[str]: + text = str(value or "").lower() + tokens = re.findall(r"[a-z0-9][a-z0-9.+#/-]{1,}|[\u4e00-\u9fff]{2,}", text) + return [token.strip(".,;:()[]{}") for token in tokens if token.strip(".,;:()[]{}")] + + +def unique_strings(items: list[Any], limit: int = 20) -> list[str]: + seen: set[str] = set() + output: list[str] = [] + for item in items: + text = str(item or "").strip() + key = text.lower() + if not text or key in seen: + continue + seen.add(key) + output.append(text) + if len(output) >= limit: + break + return output + + +def extract_json_object(text: str) -> dict[str, Any]: + raw = clean_model_response(text) + start = raw.find("{") + end = raw.rfind("}") + if start < 0 or end <= start: + raise ValueError("model did not return a JSON object") + return json.loads(raw[start : end + 1]) + + +QUERY_EXPANSIONS = { + "强化学习": { + "keywords_en": [ + "reinforcement learning", + "RL", + "policy optimization", + "reward", + "rollout", + "self-feedback", + "agent reinforcement learning", + "credit assignment", + "trajectory optimization", + ], + "topics": ["planning", "reasoning", "agent-evaluation", "multi-agent"], + }, + "评估": { + "keywords_en": ["evaluation", "benchmark", "trajectory", "judge", "rubric", "agent benchmark"], + "topics": ["agent-evaluation"], + }, + "记忆": { + "keywords_en": ["memory", "long-horizon", "state", "retention", "retrieval memory"], + "topics": ["memory", "rag"], + }, + "工具": { + "keywords_en": ["tool use", "tool calling", "function calling", "tool failure", "tool selection"], + "topics": ["tool-use"], + }, + "安全": { + "keywords_en": ["safety", "security", "privacy", "prompt injection", "guardrail", "permission"], + "topics": ["agent-safety"], + }, +} + +SEARCH_STOP_TERMS = { + "agent", + "agents", + "ai", + "llm", + "model", + "models", + "system", + "systems", + "method", + "methods", + "approach", + "approaches", + "algorithm", + "algorithms", + "application", + "applications", + "research", + "study", + "paper", + "learning", +} + + +def filter_search_keywords(items: list[Any], limit: int = 24) -> list[str]: + output: list[str] = [] + for item in unique_strings(items, limit * 3): + key = item.lower().strip() + if key in SEARCH_STOP_TERMS: + continue + if len(key) < 2: + continue + output.append(item) + if len(output) >= limit: + break + return output + + +def heuristic_search_plan(query: str) -> dict[str, Any]: + tokens = tokenize_query(query) + keywords_en: list[str] = [] + topics: list[str] = [] + for key, expansion in QUERY_EXPANSIONS.items(): + if key in query: + keywords_en.extend(expansion["keywords_en"]) + topics.extend(expansion["topics"]) + for topic, label in topic_label_map().items(): + haystack = f"{topic} {label}".lower() + if any(token.lower() in haystack for token in tokens): + topics.append(topic) + keywords_en.append(label) + keywords_en.append(topic.replace("-", " ")) + return { + "intent": "topic_search", + "interpreted_query": query, + "keywords_zh": [token for token in tokens if re.search(r"[\u4e00-\u9fff]", token)], + "keywords_en": filter_search_keywords(keywords_en + [token for token in tokens if re.search(r"[a-z0-9]", token)], 18), + "topics": unique_strings(topics, 8), + "filters": {}, + "search_rationale": "基于本地规则扩展中文/英文关键词,并映射到已有 Agent 主题。", + } + + +def search_plan_prompt(query: str, scope_topic: str = "") -> str: + topics = [ + {"id": topic, "label": profile_for(topic)["label"], "question": profile_for(topic)["question"]} + for topic in TOPIC_PROFILES + ] + no_think = "不要输出思考过程,不要输出 标签。" + return ( + "你是 Agent 论文库的搜索规划器。请理解用户输入,生成用于本地检索的 JSON。\n" + "只返回 JSON 对象,不要 Markdown。字段:\n" + "intent: topic_search|paper_lookup|question_answer|trend_scan|method_search|benchmark_search\n" + "interpreted_query: 中文解释\nkeywords_zh: 中文关键词数组\nkeywords_en: 英文关键词数组\n" + "topics: 从给定 topic id 中选择相关项数组\nfilters: 可为空对象,支持 recent_months 整数\n" + "search_rationale: 一句话说明为什么这样搜。\n" + f"当前 topic scope: {scope_topic or 'none'}\n" + f"可选 topics:\n{json.dumps(topics, ensure_ascii=False)}\n" + f"{no_think}\n\n用户输入:{query}" + ) + + +def plan_search_query(query: str, scope_topic: str = "") -> tuple[dict[str, Any], dict[str, Any] | None]: + fallback = heuristic_search_plan(query) + model = DEFAULT_MODELS["search_plan"] + result: dict[str, Any] | None = None + try: + with OLLAMA_LOCK: + result = call_ollama(model, search_plan_prompt(query, scope_topic), "search_plan") + plan = extract_json_object(result["response"]) + merged = { + **fallback, + **{key: value for key, value in plan.items() if value not in (None, "", [])}, + } + merged["keywords_zh"] = unique_strings(as_list(merged.get("keywords_zh")) + fallback["keywords_zh"], 16) + merged["keywords_en"] = filter_search_keywords(as_list(merged.get("keywords_en")) + fallback["keywords_en"], 24) + merged["topics"] = [topic for topic in unique_strings(as_list(merged.get("topics")) + fallback["topics"], 10) if topic in TOPIC_PROFILES] + if not isinstance(merged.get("filters"), dict): + merged["filters"] = {} + return merged, result + except Exception as exc: # noqa: BLE001 + fallback["planner_error"] = str(exc) + return fallback, result + + +def term_score(term: str, title: str, meta: str, note: str) -> float: + if not term: + return 0.0 + term_lower = term.lower() + score = 0.0 + if term_lower in title: + score += 50 + if term_lower in meta: + score += 18 + if term_lower in note: + score += min(22, 5 + note.count(term_lower) * 2) + for token in tokenize_query(term_lower): + if len(token) < 2: + continue + if token in title: + score += 14 + elif token in meta: + score += 8 + elif token in note: + score += 3 + return score + + +def score_search_matches( + papers: list[dict[str, Any]], + query: str, + plan: dict[str, Any], + scope_topic: str = "", + limit: int = 80, +) -> list[dict[str, Any]]: + raw_terms = [query, *as_list(plan.get("keywords_zh")), *as_list(plan.get("keywords_en"))] + terms = unique_strings(raw_terms, 32) + plan_topics = [topic for topic in as_list(plan.get("topics")) if topic in TOPIC_PROFILES] + scored: list[tuple[float, dict[str, Any], list[str]]] = [] + + for paper in papers: + if scope_topic and scope_topic not in paper["topics"]: + continue + title = paper["title"].lower() + meta = " ".join( + [ + paper["_search"], + " ".join(profile_for(topic)["label"] for topic in paper["topics"]), + " ".join(topic.replace("-", " ") for topic in paper["topics"]), + ] + ).lower() + note = paper_note_text(paper).lower() + evidence_score = 0.0 + matched_terms: list[str] = [] + for term in terms: + value = term_score(term, title, meta, note) + if value > 0: + evidence_score += value + matched_terms.append(term) + topic_overlap = sorted(set(plan_topics) & set(paper["topics"])) + if topic_overlap: + evidence_score += 34 + 9 * len(topic_overlap) + matched_terms.extend(profile_for(topic)["label"] for topic in topic_overlap) + if query.lower() in title: + evidence_score += 70 + if evidence_score <= 0: + continue + score = evidence_score + float(paper["collection_score"]) * 0.35 + item = paper_card(paper) + item["match_score"] = round(score, 2) + item["matched_terms"] = unique_strings(matched_terms, 8) + scored.append((score, item, matched_terms)) + + scored.sort(key=lambda row: (row[0], row[1].get("published_at") or "", row[1]["collection_score"]), reverse=True) + return [item for _, item, _ in scored[:limit]] + + +def aggregate_search_results(items: list[dict[str, Any]]) -> dict[str, Any]: + topic_counts: Counter = Counter() + month_counts: Counter = Counter() + year_counts: Counter = Counter() + for item in items: + month = month_key(str(item.get("published_at") or "")) + if month != "unknown": + month_counts[month] += 1 + year_counts[month[:4]] += 1 + for topic in item.get("topics") or []: + topic_counts[topic] += 1 + + topic_rows = [ + {"topic": topic, "label": profile_for(topic)["label"], "count": count} + for topic, count in topic_counts.most_common(10) + ] + months = [{"month": month, "count": count} for month, count in sorted(month_counts.items())] + recent_months = months[-6:] + date_values = [str(item.get("published_at") or "") for item in items if re.match(r"^\d{4}-\d{2}-\d{2}", str(item.get("published_at") or ""))] + return { + "total": len(items), + "topics": topic_rows, + "months": months, + "recent_months": recent_months, + "years": [{"year": year, "count": count} for year, count in sorted(year_counts.items())], + "date_range": { + "start": min(date_values) if date_values else "", + "end": max(date_values) if date_values else "", + }, + } + + +def search_summary_prompt(query: str, plan: dict[str, Any], items: list[dict[str, Any]], aggregation: dict[str, Any]) -> str: + compact_items = [ + { + "title": item["title"], + "published_at": item["published_at"], + "topics": item["topics"], + "score": item["collection_score"], + "matched_terms": item.get("matched_terms", []), + } + for item in items[:18] + ] + context = { + "query": query, + "plan": plan, + "aggregation": aggregation, + "top_papers": compact_items, + } + no_think = "不要输出思考过程,不要输出 标签。" + return ( + "你是 Agent 研究助理。请基于本地论文检索结果生成中文搜索简报。\n" + "只基于给定结果,不要编造论文细节。输出要短,包含:\n" + "1. 搜索理解\n2. 主要发现\n3. 时间/趋势\n4. 建议先读\n5. 可以继续追问的方向。\n" + f"{no_think}\n\n{json.dumps(context, ensure_ascii=False, indent=2)[:14000]}" + ) + + +def fallback_search_summary(query: str, plan: dict[str, Any], aggregation: dict[str, Any], items: list[dict[str, Any]]) -> str: + topics = "、".join(item["label"] for item in aggregation["topics"][:5]) or "暂无明显主题" + papers = ";".join(item["title"] for item in items[:3]) or "暂无命中论文" + keywords = ", ".join(as_list(plan.get("keywords_en"))[:8] + as_list(plan.get("keywords_zh"))[:4]) + return ( + f"搜索理解:你在查「{query}」,系统扩展了 {keywords or '原始关键词'}。\n\n" + f"主要发现:命中 {aggregation['total']} 篇候选,集中在 {topics}。\n\n" + f"时间/趋势:日期范围 {aggregation['date_range']['start'] or 'unknown'} 到 {aggregation['date_range']['end'] or 'unknown'}。\n\n" + f"建议先读:{papers}。\n\n" + "可以继续追问:只看最近论文、按主题拆分、生成阅读路径、比较工程/理论方向。" + ) + + +def generate_search_summary(query: str, plan: dict[str, Any], items: list[dict[str, Any]], aggregation: dict[str, Any]) -> dict[str, Any]: + model = DEFAULT_MODELS["search_summary"] + try: + with OLLAMA_LOCK: + result = call_ollama(model, search_summary_prompt(query, plan, items, aggregation), "search_summary") + return { + "text": result["response"], + "model": result["model"], + "duration_seconds": result["duration_seconds"], + "cached": False, + } + except Exception as exc: # noqa: BLE001 + return { + "text": fallback_search_summary(query, plan, aggregation, items), + "model": "fallback", + "error": str(exc), + "cached": False, + } + + +def new_search_job(query: str, scope_topic: str = "") -> dict[str, Any]: + job_id = hashlib.sha1(f"{query}:{scope_topic}:{time.time()}".encode("utf-8")).hexdigest()[:14] + now = time.strftime("%Y-%m-%dT%H:%M:%S%z") + job = { + "id": job_id, + "query": query, + "scope_topic": scope_topic, + "status": "queued", + "created_at": now, + "updated_at": now, + "steps": [ + {"id": "understand", "label": "理解问题", "status": "pending", "detail": ""}, + {"id": "expand", "label": "扩展关键词", "status": "pending", "detail": ""}, + {"id": "retrieve", "label": "检索论文库", "status": "pending", "detail": ""}, + {"id": "aggregate", "label": "统计主题/趋势", "status": "pending", "detail": ""}, + {"id": "summarize", "label": "生成搜索结论", "status": "pending", "detail": ""}, + ], + "result": None, + "error": "", + } + with SEARCH_LOCK: + SEARCH_JOBS[job_id] = job + prune_search_jobs_locked() + thread = threading.Thread(target=run_search_job, args=(job_id,), daemon=True) + thread.start() + return public_search_job(job) + + +def public_search_job(job: dict[str, Any]) -> dict[str, Any]: + return { + "id": job["id"], + "query": job["query"], + "scope_topic": job.get("scope_topic", ""), + "status": job["status"], + "created_at": job["created_at"], + "updated_at": job["updated_at"], + "steps": job["steps"], + "result": job.get("result"), + "error": job.get("error", ""), + } + + +def get_search_job(job_id: str) -> dict[str, Any] | None: + with SEARCH_LOCK: + job = SEARCH_JOBS.get(job_id) + return public_search_job(job) if job else None + + +def update_search_job(job_id: str, *, status: str | None = None, result: Any = None, error: str = "") -> None: + with SEARCH_LOCK: + job = SEARCH_JOBS.get(job_id) + if not job: + return + if status: + job["status"] = status + if result is not None: + job["result"] = result + if error: + job["error"] = error + job["updated_at"] = time.strftime("%Y-%m-%dT%H:%M:%S%z") + + +def update_search_step(job_id: str, step_id: str, status: str, detail: str = "") -> None: + with SEARCH_LOCK: + job = SEARCH_JOBS.get(job_id) + if not job: + return + for step in job["steps"]: + if step["id"] == step_id: + step["status"] = status + if detail: + step["detail"] = detail + step["updated_at"] = time.strftime("%H:%M:%S") + break + job["updated_at"] = time.strftime("%Y-%m-%dT%H:%M:%S%z") + + +def prune_search_jobs_locked() -> None: + if len(SEARCH_JOBS) <= 24: + return + ordered = sorted(SEARCH_JOBS.values(), key=lambda item: item.get("created_at", "")) + for job in ordered[: len(SEARCH_JOBS) - 24]: + SEARCH_JOBS.pop(job["id"], None) + + +def run_search_job(job_id: str) -> None: + job = get_search_job(job_id) + if not job: + return + query = job["query"] + scope_topic = job.get("scope_topic", "") + try: + update_search_job(job_id, status="running") + update_search_step(job_id, "understand", "running", "调用轻量模型理解搜索意图") + plan, planner_result = plan_search_query(query, scope_topic) + update_search_step(job_id, "understand", "done", str(plan.get("interpreted_query") or query)) + + keywords = unique_strings(as_list(plan.get("keywords_en")) + as_list(plan.get("keywords_zh")), 12) + update_search_step(job_id, "expand", "done", " / ".join(keywords[:8]) or "使用原始输入") + + update_search_step(job_id, "retrieve", "running", "扫描标题、主题、collection query 和本地卡片") + papers, _ = load_papers() + items = score_search_matches(papers, query, plan, scope_topic, limit=80) + update_search_step(job_id, "retrieve", "done", f"找到 {len(items)} 篇候选论文") + + update_search_step(job_id, "aggregate", "running", "统计日期、主题和最近趋势") + aggregation = aggregate_search_results(items) + update_search_step( + job_id, + "aggregate", + "done", + f"{len(aggregation['topics'])} 个相关主题,日期到 {aggregation['date_range']['end'] or 'unknown'}", + ) + + update_search_step(job_id, "summarize", "running", "调用 fast 模型生成搜索简报") + summary = generate_search_summary(query, plan, items, aggregation) + update_search_step(job_id, "summarize", "done", summary.get("model", "fallback")) + + result = { + "query": query, + "scope_topic": scope_topic, + "plan": plan, + "planner": { + "model": planner_result.get("model") if planner_result else "fallback", + "duration_seconds": planner_result.get("duration_seconds") if planner_result else None, + }, + "summary": summary, + "aggregation": aggregation, + "items": items[:60], + "total": len(items), + } + update_search_job(job_id, status="done", result=result) + except Exception as exc: # noqa: BLE001 + update_search_step(job_id, "summarize", "error", str(exc)) + update_search_job(job_id, status="error", error=str(exc)) + + +def search_followup_prompt(job: dict[str, Any], question: str) -> str: + result = job.get("result") or {} + compact = { + "query": result.get("query"), + "plan": result.get("plan"), + "summary": (result.get("summary") or {}).get("text"), + "aggregation": result.get("aggregation"), + "top_papers": [ + { + "title": item["title"], + "published_at": item["published_at"], + "topics": item["topics"], + "score": item["collection_score"], + "matched_terms": item.get("matched_terms", []), + } + for item in (result.get("items") or [])[:18] + ], + } + return ( + "你是 Agent 论文库搜索助手。用户正在基于一次搜索结果继续追问。\n" + "请只基于给定搜索上下文回答,中文,简洁但有判断。不要编造论文细节。\n" + "不要输出思考过程,不要输出 标签。\n\n" + f"搜索上下文:\n{json.dumps(compact, ensure_ascii=False, indent=2)[:14000]}\n\n" + f"用户追问:{question}" + ) + + +def paper_chat_prompt(paper: dict[str, Any], abstract: dict[str, Any] | None, question: str, history: list[dict[str, str]]) -> str: + recent_history = [ + {"role": str(item.get("role") or ""), "content": str(item.get("content") or "")[:1200]} + for item in history[-6:] + if item.get("content") + ] + context = { + "paper": { + "title": paper["title"], + "authors": paper["authors"], + "published_at": paper["published_at"], + "venue": paper["venue"], + "topics": paper["topics"], + "collection_score": paper["collection_score"], + "url": paper["url"], + }, + "abstract": abstract, + "local_note": paper_note_text(paper)[:5000], + "history": recent_history, + "question": question, + } + return ( + "你是 Agent 论文研读助手。请基于论文上下文和对话历史回答用户问题。\n" + "中文回答,优先给出判断和证据;信息不足就明确说不足。不要编造论文细节。\n" + "不要输出思考过程,不要输出 标签。\n\n" + f"{json.dumps(context, ensure_ascii=False, indent=2)[:15000]}" + ) + + def topic_counts(papers: list[dict[str, Any]]) -> list[dict[str, Any]]: counts: dict[str, int] = {} for paper in papers: @@ -671,6 +1228,10 @@ def call_ollama(model: str, prompt: str, mode: str) -> dict[str, Any]: options.update({"num_ctx": 8192, "num_predict": 900, "temperature": 0.25}) elif mode in {"atlas", "topic", "path", "compare"}: options.update({"num_ctx": 8192, "num_predict": 950, "temperature": 0.22}) + elif mode == "search_plan": + options.update({"num_ctx": 4096, "num_predict": 420, "temperature": 0.1}) + elif mode in {"search_summary", "search_followup", "paper_chat"}: + options.update({"num_ctx": 8192, "num_predict": 780, "temperature": 0.18}) payload = { "model": model, @@ -746,6 +1307,9 @@ class PaperBrowserHandler(SimpleHTTPRequestHandler): if path == "/api/papers": self.handle_paper_search(query) return + if path == "/api/search/agent": + self.handle_search_job(query) + return if path == "/api/paper": self.handle_paper_detail(query) return @@ -763,6 +1327,15 @@ class PaperBrowserHandler(SimpleHTTPRequestHandler): if parsed.path == "/api/atlas/ai": self.handle_atlas_ai() return + if parsed.path == "/api/search/agent": + self.handle_search_agent_start() + return + if parsed.path == "/api/search/followup": + self.handle_search_followup() + return + if parsed.path == "/api/paper/chat": + self.handle_paper_chat() + return write_error(self, HTTPStatus.NOT_FOUND, "not found") def serve_file(self, target: Path, content_type: str) -> None: @@ -862,6 +1435,64 @@ class PaperBrowserHandler(SimpleHTTPRequestHandler): }, ) + def handle_search_agent_start(self) -> None: + try: + payload = read_json_body(self) + query = str(payload.get("query") or "").strip() + scope_topic = str(payload.get("topic") or "").strip() + if not query: + write_error(self, HTTPStatus.BAD_REQUEST, "query is required") + return + if scope_topic and scope_topic not in TOPIC_PROFILES: + scope_topic = "" + write_json(self, {"job": new_search_job(query, scope_topic)}) + except Exception as exc: # noqa: BLE001 + write_error(self, HTTPStatus.INTERNAL_SERVER_ERROR, str(exc)) + + def handle_search_job(self, query: dict[str, list[str]]) -> None: + job_id = (query.get("id") or [""])[0].strip() + if not job_id: + write_error(self, HTTPStatus.BAD_REQUEST, "id is required") + return + job = get_search_job(job_id) + if not job: + write_error(self, HTTPStatus.NOT_FOUND, "search job not found") + return + write_json(self, {"job": job}) + + def handle_search_followup(self) -> None: + try: + payload = read_json_body(self) + job_id = str(payload.get("job_id") or "").strip() + question = str(payload.get("question") or "").strip() + model = str(payload.get("model") or DEFAULT_MODELS["search_followup"]) + if not job_id or not question: + write_error(self, HTTPStatus.BAD_REQUEST, "job_id and question are required") + return + job = get_search_job(job_id) + if not job or job.get("status") != "done": + write_error(self, HTTPStatus.NOT_FOUND, "completed search job not found") + return + if not OLLAMA_LOCK.acquire(blocking=False): + write_error(self, HTTPStatus.TOO_MANY_REQUESTS, "ollama is busy; try again later") + return + try: + result = call_ollama(model, search_followup_prompt(job, question), "search_followup") + write_json( + self, + { + "job_id": job_id, + "question": question, + "answer": result["response"], + "model": result["model"], + "duration_seconds": result["duration_seconds"], + }, + ) + finally: + OLLAMA_LOCK.release() + except Exception as exc: # noqa: BLE001 + write_error(self, HTTPStatus.INTERNAL_SERVER_ERROR, str(exc)) + def handle_paper_detail(self, query: dict[str, list[str]]) -> None: paper_id = (query.get("id") or [""])[0] _, by_id = load_papers() @@ -898,6 +1529,50 @@ class PaperBrowserHandler(SimpleHTTPRequestHandler): except Exception as exc: # noqa: BLE001 write_error(self, HTTPStatus.BAD_GATEWAY, f"arXiv fetch failed: {exc}") + def handle_paper_chat(self) -> None: + try: + payload = read_json_body(self) + paper_id = str(payload.get("id") or "").strip() + question = str(payload.get("question") or "").strip() + history = payload.get("history") or [] + model = str(payload.get("model") or DEFAULT_MODELS["paper_chat"]) + if not paper_id or not question: + write_error(self, HTTPStatus.BAD_REQUEST, "id and question are required") + return + if not isinstance(history, list): + history = [] + _, by_id = load_papers() + paper = by_id.get(paper_id) + if not paper: + write_error(self, HTTPStatus.NOT_FOUND, "paper not found") + return + if not OLLAMA_LOCK.acquire(blocking=False): + write_error(self, HTTPStatus.TOO_MANY_REQUESTS, "ollama is busy; try again later") + return + try: + abstract = None + arxiv_id = arxiv_id_from_paper(paper) + if arxiv_id: + try: + abstract = fetch_arxiv(arxiv_id) + except Exception: + abstract = None + result = call_ollama(model, paper_chat_prompt(paper, abstract, question, history), "paper_chat") + write_json( + self, + { + "id": paper_id, + "question": question, + "answer": result["response"], + "model": result["model"], + "duration_seconds": result["duration_seconds"], + }, + ) + finally: + OLLAMA_LOCK.release() + except Exception as exc: # noqa: BLE001 + write_error(self, HTTPStatus.INTERNAL_SERVER_ERROR, str(exc)) + def handle_ai(self) -> None: try: payload = read_json_body(self) diff --git a/web/static/app.js b/web/static/app.js index 4e298ce..c0cda82 100644 --- a/web/static/app.js +++ b/web/static/app.js @@ -8,8 +8,13 @@ const state = { selectedMarkdown: "", searchItems: [], searchTotal: 0, + searchJobId: "", + searchResult: null, + searchPollTimer: null, + searchFollowups: [], aiBusy: false, atlasChart: null, + paperChatHistory: {}, }; const $ = (selector) => document.querySelector(selector); @@ -498,6 +503,10 @@ function paperList(items, limit = items.length) { `; } +function compactPaperList(items, limit = items.length) { + return paperList(items, limit); +} + function routeButtons(routes) { return `
@@ -512,6 +521,169 @@ function routeButtons(routes) { `; } +function searchSteps(steps = []) { + return ` +
+ ${steps + .map( + (step) => ` +
+ +
+ ${escapeHtml(step.label)} + ${escapeHtml(step.detail || step.status)} +
+
+ `, + ) + .join("")} +
+ `; +} + +function searchAggregationView(aggregation) { + if (!aggregation) return ""; + const topTopics = aggregation.topics || []; + const recentMonths = aggregation.recent_months || []; + return ` +
+ ${statGrid([ + { value: aggregation.total || 0, label: "matches" }, + { value: topTopics.length, label: "topics" }, + { value: aggregation.date_range?.end || "unknown", label: "latest" }, + ])} +
+ ${recentMonths + .map((row) => `${escapeHtml(row.month)} · ${escapeHtml(row.count)}`) + .join("")} +
+
+ ${topTopics + .slice(0, 8) + .map( + (topic) => ` + + `, + ) + .join("")} +
+
+ `; +} + +function searchPlanView(plan) { + if (!plan) return ""; + const keywords = [...(plan.keywords_en || []), ...(plan.keywords_zh || [])].slice(0, 14); + return ` +
+
Search Plan
+

${escapeHtml(plan.interpreted_query || "")}

+

${escapeHtml(plan.search_rationale || "")}

+
+ ${keywords.map((keyword) => `${escapeHtml(keyword)}`).join("")} +
+
+ `; +} + +function renderSearchRunning(job) { + setReaderMode(false); + state.view = "search"; + setModeButtons("search"); + elements.lensEyebrow.textContent = "Search Agent"; + elements.lensTitle.textContent = job?.query || elements.searchInput.value.trim() || "搜索"; + elements.lensBody.innerHTML = ` +
+

Search Agent 正在理解问题、扩展关键词并检索本地论文库。

+
+
+ ${searchSteps(job?.steps || [])} +
+ `; +} + +function renderSearchHome() { + setReaderMode(false); + state.view = "search"; + setModeButtons("search"); + elements.lensEyebrow.textContent = "Search Agent"; + elements.lensTitle.textContent = "智能搜索"; + elements.lensBody.innerHTML = ` +
+

输入主题、论文线索或研究问题。Search Agent 会理解意图、扩展中英文关键词、检索本地论文库,并生成一份简短结论。

+
+
+

可以这样搜

+
+ + + +
+
+ `; +} + +function renderSearchError(jobOrError) { + const message = typeof jobOrError === "string" ? jobOrError : jobOrError?.error || "search failed"; + elements.lensBody.innerHTML = ` +
+
${escapeHtml(message)}
+
+ `; +} + +function followupList() { + const rows = state.searchFollowups || []; + if (!rows.length) return ""; + return ` +
+ ${rows + .map( + (row) => ` +
+ ${row.role === "user" ? "你" : "Search Agent"} +
${escapeHtml(row.content)}
+
+ `, + ) + .join("")} +
+ `; +} + +function renderSearchResult(result) { + setReaderMode(false); + state.view = "search"; + setModeButtons("search"); + elements.lensEyebrow.textContent = "Search Agent"; + elements.lensTitle.textContent = result.query; + const summary = result.summary || {}; + elements.lensBody.innerHTML = ` +
+
+

${escapeHtml(summary.model || "Search Agent")}

+
${escapeHtml(summary.text || "")}
+
+
+ ${searchAggregationView(result.aggregation)} + ${searchPlanView(result.plan)} +
+

继续追问

+ ${followupList()} +
+ + +
+
+
+

相关论文

+ ${compactPaperList(result.items || [], 60)} +
+ `; +} + function renderOverview(aiHtml = "") { setReaderMode(false); state.view = "atlas"; @@ -681,32 +853,85 @@ function renderRoute(routeId) { async function runSearch() { const q = elements.searchInput.value.trim(); if (!q) { - renderOverview(); + renderSearchHome(); return; } + const scopeTopic = state.topicFocus && state.view === "atlas" ? state.activeTopic : ""; setReaderMode(false); state.view = "search"; setModeButtons("search"); - elements.lensEyebrow.textContent = "Search"; - elements.lensTitle.textContent = q; - elements.lensBody.innerHTML = `
searching
`; - const params = new URLSearchParams({ q, limit: "60", sort: "score" }); - if (state.activeTopic) params.set("topic", state.activeTopic); - const payload = await api(`/api/papers?${params.toString()}`); - state.searchItems = payload.items; - state.searchTotal = payload.total; - elements.lensBody.innerHTML = ` -
- ${statGrid([ - { value: payload.total, label: "matches" }, - { value: state.activeTopic ? topicLabel(state.activeTopic) : "all", label: "scope" }, - { value: "score", label: "sort" }, - ])} -
-
- ${paperList(payload.items, payload.items.length)} -
- `; + state.searchFollowups = []; + state.searchResult = null; + const starter = { + query: q, + steps: [ + { label: "理解问题", status: "running", detail: "准备启动 Search Agent" }, + { label: "扩展关键词", status: "pending", detail: "" }, + { label: "检索论文库", status: "pending", detail: "" }, + { label: "统计主题/趋势", status: "pending", detail: "" }, + { label: "生成搜索结论", status: "pending", detail: "" }, + ], + }; + renderSearchRunning(starter); + try { + const payload = await api("/api/search/agent", { + method: "POST", + body: JSON.stringify({ query: q, topic: scopeTopic }), + }); + state.searchJobId = payload.job.id; + renderSearchRunning(payload.job); + pollSearchJob(payload.job.id); + } catch (error) { + renderSearchError(error.message); + } +} + +async function pollSearchJob(jobId) { + window.clearTimeout(state.searchPollTimer); + try { + const payload = await api(`/api/search/agent?id=${encodeURIComponent(jobId)}`); + const job = payload.job; + if (job.status === "done") { + state.searchResult = job.result; + state.searchItems = job.result.items || []; + state.searchTotal = job.result.total || 0; + renderSearchResult(job.result); + return; + } + if (job.status === "error") { + renderSearchError(job); + return; + } + renderSearchRunning(job); + state.searchPollTimer = window.setTimeout(() => pollSearchJob(jobId), 900); + } catch (error) { + renderSearchError(error.message); + } +} + +async function runSearchFollowup() { + const input = $("#searchFollowupInput"); + const question = input?.value.trim(); + if (!question || !state.searchJobId) return; + state.searchFollowups.push({ role: "user", content: question }); + state.searchFollowups.push({ role: "assistant", content: "thinking..." }); + renderSearchResult(state.searchResult); + try { + const payload = await api("/api/search/followup", { + method: "POST", + body: JSON.stringify({ job_id: state.searchJobId, question }), + }); + state.searchFollowups[state.searchFollowups.length - 1] = { + role: "assistant", + content: `${payload.answer}\n\n${payload.model} · ${payload.duration_seconds}s`, + }; + } catch (error) { + state.searchFollowups[state.searchFollowups.length - 1] = { + role: "assistant", + content: error.message, + }; + } + renderSearchResult(state.searchResult); } function renderReading() { @@ -755,6 +980,7 @@ function renderPaperReader(aiHtml = "", abstractHtml = "") { const paper = state.selectedPaper; if (!paper) return; setReaderMode(true); + const chatRows = state.paperChatHistory[paper.id] || []; elements.lensEyebrow.textContent = "Paper"; elements.lensTitle.textContent = "研读"; elements.lensBody.innerHTML = ` @@ -784,6 +1010,26 @@ function renderPaperReader(aiHtml = "", abstractHtml = "") {
${abstractHtml}
${aiHtml}
+
+

继续研读

+
+ ${chatRows + .map( + (row) => ` +
+ ${row.role === "user" ? "你" : "Paper Agent"} +
${escapeHtml(row.content)}
+
+ `, + ) + .join("")} +
+
+ + +
+
+

本地卡片

${renderMarkdown(state.selectedMarkdown)}
@@ -808,6 +1054,39 @@ async function loadAbstract() { } } +async function runPaperChat() { + if (!state.selectedPaper) return; + const input = $("#paperChatInput"); + const question = input?.value.trim(); + if (!question) return; + const paperId = state.selectedPaper.id; + const history = state.paperChatHistory[paperId] || []; + history.push({ role: "user", content: question }); + history.push({ role: "assistant", content: "thinking..." }); + state.paperChatHistory[paperId] = history; + const aiHtml = $("#paperAi")?.innerHTML || ""; + const abstractHtml = $("#paperAbstract")?.innerHTML || ""; + renderPaperReader(aiHtml, abstractHtml); + try { + const payload = await api("/api/paper/chat", { + method: "POST", + body: JSON.stringify({ + id: paperId, + question, + history: history.slice(0, -1), + }), + }); + history[history.length - 1] = { + role: "assistant", + content: `${payload.answer}\n\n${payload.model} · ${payload.duration_seconds}s`, + }; + } catch (error) { + history[history.length - 1] = { role: "assistant", content: error.message }; + } + state.paperChatHistory[paperId] = history; + renderPaperReader($("#paperAi")?.innerHTML || aiHtml, $("#paperAbstract")?.innerHTML || abstractHtml); +} + function setAiBusy(isBusy, label = "") { state.aiBusy = isBusy; document.querySelectorAll(".tool-button, .quiet-button").forEach((button) => { @@ -904,6 +1183,9 @@ function bindEvents() { const compareButton = event.target.closest("[data-compare-topic]"); const paperAction = event.target.closest("[data-paper-action]"); const readerAction = event.target.closest("[data-reader-action]"); + const searchFollowup = event.target.closest("[data-search-followup]"); + const paperChat = event.target.closest("[data-paper-chat]"); + const searchExample = event.target.closest("[data-search-example]"); if (topicButton) { await selectTopic(topicButton.dataset.topic); @@ -926,16 +1208,28 @@ function bindEvents() { } else if (readerAction) { if (state.topicFocus) renderTopic(state.topicFocus); else renderOverview(); + } else if (searchFollowup) { + await runSearchFollowup(); + } else if (paperChat) { + await runPaperChat(); + } else if (searchExample) { + elements.searchInput.value = searchExample.dataset.searchExample || ""; + await runSearch(); + } + }); + elements.lensBody.addEventListener("keydown", async (event) => { + if (event.key === "Enter" && event.target.closest("#searchFollowupInput")) { + await runSearchFollowup(); + } + if (event.key === "Enter" && event.target.closest("#paperChatInput")) { + await runPaperChat(); } }); elements.searchBtn.addEventListener("click", runSearch); - elements.searchInput.addEventListener( - "input", - debounce(() => { - if (elements.searchInput.value.trim()) runSearch(); - }), - ); + elements.searchInput.addEventListener("keydown", (event) => { + if (event.key === "Enter") runSearch(); + }); elements.explainAtlasBtn.addEventListener("click", () => runAtlasAi("atlas", state.activeTopic)); elements.readingPathBtn.addEventListener("click", () => runAtlasAi("path", state.activeTopic)); diff --git a/web/static/index.html b/web/static/index.html index 9a0a214..ca86d7f 100644 --- a/web/static/index.html +++ b/web/static/index.html @@ -5,7 +5,7 @@ Agent Knowledge Atlas - +
@@ -79,6 +79,6 @@
- + diff --git a/web/static/styles.css b/web/static/styles.css index 525e2f7..0e5e934 100644 --- a/web/static/styles.css +++ b/web/static/styles.css @@ -521,6 +521,132 @@ button { padding: 3px 6px; } +.agent-steps { + display: grid; + gap: 10px; +} + +.agent-step { + display: grid; + grid-template-columns: 13px 1fr; + gap: 10px; + align-items: start; + color: var(--muted); +} + +.step-dot { + width: 9px; + height: 9px; + margin-top: 6px; + border: 1px solid #aab3ad; + border-radius: 50%; + background: #ffffff; +} + +.agent-step strong { + display: block; + color: #17201d; + font-size: 13px; +} + +.agent-step span:last-child { + display: block; + margin-top: 2px; + font-size: 12px; + line-height: 1.42; +} + +.agent-step.running .step-dot { + border-color: var(--teal); + background: var(--teal); + box-shadow: 0 0 0 5px rgba(13, 118, 111, 0.12); +} + +.agent-step.done .step-dot { + border-color: var(--green); + background: var(--green); +} + +.agent-step.error .step-dot { + border-color: var(--coral); + background: var(--coral); +} + +.search-brief h3 { + margin: 0 0 8px; + font-size: 14px; +} + +.compact-section { + display: grid; + gap: 8px; +} + +.mini-chart-row, +.search-topic-row { + margin-top: 10px; +} + +.month-chip { + display: inline-flex; + align-items: center; + min-height: 24px; + margin: 0 6px 6px 0; + border: 1px solid var(--line); + border-radius: 5px; + background: #ffffff; + padding: 0 7px; + color: #4c5550; + font-size: 11px; + font-weight: 720; +} + +.followup-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 8px; + margin-top: 10px; +} + +.followup-row input { + min-width: 0; + min-height: 36px; + border: 1px solid var(--line); + border-radius: 7px; + background: #ffffff; + padding: 0 10px; + outline: 0; +} + +.dialogue-list { + display: grid; + gap: 8px; +} + +.dialogue-row { + border: 1px solid var(--line); + border-radius: 8px; + background: #ffffff; + padding: 10px; +} + +.dialogue-row.user { + background: #f4f8f6; +} + +.dialogue-row strong { + display: block; + margin-bottom: 4px; + font-size: 12px; +} + +.dialogue-row div { + white-space: pre-wrap; + color: #27312d; + font-size: 13px; + line-height: 1.58; +} + .ai-box, .reader-box { border: 1px solid var(--line);