Skip to content

Commit 2aa61f2

Browse files
Polish offline desktop GUI and README showcase
1 parent e63e38f commit 2aa61f2

18 files changed

Lines changed: 2344 additions & 156 deletions

README.md

Lines changed: 79 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ and adapt tomorrow's recommendations.
2020
![Interest Drift](https://img.shields.io/badge/interest-drift-00897B.svg)
2121
![Feishu/Lark](https://img.shields.io/badge/Feishu%2FLark-bot-00A1E9.svg)
2222

23-
[Quick Start](#quick-start) | [Local GUI](#local-gui) |
23+
[Quick Start](#quick-start) | [Desktop Preview](#desktop-preview) | [Local GUI](#local-gui) |
2424
[GUI Preview](https://openraiser.github.io/PaperFlow/deployments/desktop/static/index.html?demo=1) |
2525
[CLI Usage](#cli-usage) |
2626
[Feedback Loop](docs/feedback-loop.md) |
@@ -59,6 +59,67 @@ webhook server alive for scheduled chat pushes.
5959
</tr>
6060
</table>
6161

62+
## Desktop Preview
63+
64+
PaperFlow now ships with an offline-first desktop browser GUI. It uses the same
65+
local SQLite state and backend workflows as the CLI, so the UI is not a static
66+
mock: paper pulls, feedback, reading reports, Wiki graph updates, and settings
67+
all route through the local backend.
68+
69+
<div align="center">
70+
<a href="docs/assets/readme/paperflow-desktop-demo.mp4">
71+
<img src="docs/assets/readme/paperflow-desktop-demo.gif" alt="PaperFlow offline desktop workflow demo" width="92%">
72+
</a>
73+
<br>
74+
<sub>
75+
Desktop demo: daily paper stream -> Wiki graph -> cited Q&A -> reading reports -> local settings.
76+
<br>
77+
<a href="docs/assets/readme/paperflow-desktop-demo.mp4">Watch / download MP4</a>
78+
</sub>
79+
</div>
80+
81+
<br>
82+
83+
<details>
84+
<summary>View desktop screenshots</summary>
85+
86+
<table>
87+
<tr>
88+
<td width="50%">
89+
<img src="docs/assets/readme/paperflow-desktop-papers.png" alt="PaperFlow daily paper recommendation stream">
90+
<br><b>Daily paper stream</b><br>
91+
Date-aware pulls, source filters, candidate metrics, paper actions, and backend task state.
92+
</td>
93+
<td width="50%">
94+
<img src="docs/assets/readme/paperflow-desktop-wiki.png" alt="PaperFlow knowledge Wiki graph">
95+
<br><b>Knowledge Wiki graph</b><br>
96+
Backend-derived paper, topic, method, profile, and citation relationships.
97+
</td>
98+
</tr>
99+
<tr>
100+
<td width="50%">
101+
<img src="docs/assets/readme/paperflow-desktop-chat.png" alt="PaperFlow cited local Wiki question answering">
102+
<br><b>Cited Wiki Q&A</b><br>
103+
Streamed answers with clickable reference markers and source cards.
104+
</td>
105+
<td width="50%">
106+
<img src="docs/assets/readme/paperflow-desktop-settings.png" alt="PaperFlow local settings and source configuration">
107+
<br><b>Local settings</b><br>
108+
Provider keys, storage paths, paper-source modes, conference access, and export controls.
109+
</td>
110+
</tr>
111+
</table>
112+
113+
</details>
114+
115+
### Product Framework
116+
117+
<img src="docs/assets/readme/paperflow-product-framework.png" alt="PaperFlow product framework diagram" width="100%">
118+
119+
The desktop loop is intentionally local: profile state, paper pushes, feedback,
120+
reading reports, and Wiki nodes stay on disk unless you explicitly enable
121+
external providers or Feishu/Lark export.
122+
62123
## Why PaperFlow
63124

64125
Scientific-paper recommendation is not a one-shot ranking problem. Real
@@ -81,6 +142,9 @@ the system adapt tomorrow?**
81142
| Daily recommendation | Fetches arXiv, OpenReview, and journal papers, then ranks a personalized daily digest |
82143
| Reading reports | Generates personalized paper reports from metadata and PDF content |
83144
| Feedback learning | Updates the same profile from CLI, GUI, Feishu/Lark, selected, skipped, read, and natural-language feedback |
145+
| Local research Wiki | Ingests paper pushes, reports, citations, feedback, and profile signals into a queryable local graph |
146+
| Cited Wiki Q&A | Answers with local evidence when retrieval is needed, supports clickable citations and explicit `@`-style references |
147+
| Offline desktop GUI | Provides a local UI for daily pulls, feedback, report reading, Wiki graph inspection, Q&A, and settings |
84148
| Drift adaptation | Tracks short-window vs long-window interest movement across days |
85149
| Feishu/Lark bot | Sends daily pushes and weekly reports; routes chat feedback and PDF requests |
86150
| Benchmark tooling | Packages, downloads, predicts, and evaluates PaperFlow-Bench submissions |
@@ -258,12 +322,20 @@ mock-data preview:
258322
[PaperFlow GUI Preview](https://openraiser.github.io/PaperFlow/deployments/desktop/static/index.html?demo=1).
259323

260324
The GUI uses the same local SQLite database as the CLI. It is designed for the
261-
real daily workflow: select a user profile, run or load the latest daily push,
262-
mark papers for reading, mark explicit negative feedback, generate local
263-
Markdown reading reports, manage must-read anchors, read an arXiv ID or local
264-
PDF directly, manage local research roles, filter feedback history, and search
265-
the PaperFlow Wiki. It does not run background schedules; scheduled
266-
Feishu/Lark delivery still uses `deployments/feishu/`.
325+
real daily workflow, not a standalone mock:
326+
327+
- select a user profile and inspect the profile-derived direction summary
328+
- pull papers for today's date, or intentionally fetch a previous date window
329+
- keep long-running daily pulls in backend task state while the UI polls status
330+
- mark papers as precision-read, not interested, or later
331+
- submit feedback and update the local profile/Wiki signal path
332+
- generate or reopen reading reports from paper cards
333+
- inspect the backend-derived Wiki graph and search local knowledge nodes
334+
- ask questions over the local Wiki with clickable references
335+
- configure providers, source modes, storage paths, and export behavior
336+
337+
The desktop GUI does not run background schedules. Scheduled Feishu/Lark
338+
delivery still uses `deployments/feishu/`.
267339

268340
Useful options:
269341

agents/wiki-agent/retrieve/answer.py

Lines changed: 156 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
import importlib
66
import time
7-
from typing import Any, Dict, List
7+
from typing import Any, Dict, Iterable, Iterator, List, Optional
88

99
from paperflow.providers import build_llm_provider
1010

@@ -15,9 +15,12 @@
1515
SYSTEM_PROMPT = """You are PaperFlow's private research wiki assistant.
1616
1717
Answer only from the provided wiki snippets. Cite every concrete claim with
18-
[N], where N is the snippet number. If the snippets do not contain enough
19-
evidence, say that the local wiki does not have enough material yet.
20-
Use concise Chinese by default unless the user asks otherwise."""
18+
[N], where N is the snippet number shown in the context. Keep citation markers
19+
inline immediately after the supported claim, and do not invent references. If
20+
the snippets do not contain enough evidence, say that the local wiki does not
21+
have enough material yet. Do not expose internal section labels such as Q1/Q2
22+
unless the user explicitly asks for report-section structure. Use concise
23+
Chinese by default unless the user asks otherwise."""
2124

2225

2326
def _snippet(node: Dict[str, Any], max_chars: int = 700) -> str:
@@ -51,7 +54,7 @@ def _build_prompt(question: str, hits: List[Dict[str, Any]]) -> str:
5154

5255
def _extractive_fallback(hits: List[Dict[str, Any]], error: str) -> str:
5356
lines = [
54-
"The configured LLM was not available, so PaperFlow returned local wiki snippets instead.",
57+
"当前 LLM 不可用,PaperFlow 先返回本地 Wiki 证据片段(local wiki snippets)。",
5558
f"Provider error: {error}",
5659
"",
5760
]
@@ -62,65 +65,175 @@ def _extractive_fallback(hits: List[Dict[str, Any]], error: str) -> str:
6265
return "\n".join(lines).strip()
6366

6467

65-
def answer_question(user_id: str, question: str, *, limit: int = 8) -> Dict[str, Any]:
66-
"""Return an LLM answer with local wiki citations."""
67-
started = time.time()
68+
def _merge_hits(
69+
pinned_nodes: Iterable[Dict[str, Any]],
70+
retrieved_nodes: Iterable[Dict[str, Any]],
71+
limit: int,
72+
) -> List[Dict[str, Any]]:
73+
merged: List[Dict[str, Any]] = []
74+
seen = set()
75+
for node in [*list(pinned_nodes or []), *list(retrieved_nodes or [])]:
76+
node_id = str(node.get("node_id") or "").strip()
77+
if not node_id or node_id in seen:
78+
continue
79+
seen.add(node_id)
80+
merged.append(node)
81+
if len(merged) >= max(1, int(limit)):
82+
break
83+
return merged
84+
85+
86+
def _prepare_hits(
87+
user_id: str,
88+
question: str,
89+
limit: int,
90+
pinned_nodes: Optional[List[Dict[str, Any]]],
91+
) -> tuple[List[Dict[str, Any]], Optional[str]]:
6892
embedding_error = None
6993
try:
7094
wiki_db.embed_nodes_for_user(user_id, limit=500)
7195
except Exception as exc:
7296
embedding_error = str(exc)
73-
hits = wiki_db.search_nodes(user_id, question, limit=limit)
74-
if not hits:
75-
return {
76-
"text": (
77-
"The local wiki does not have enough relevant material yet. "
78-
"Run `paperflow read` for related papers, or run "
79-
"`paperflow wiki backfill` to import existing runtime history."
80-
),
81-
"citations": [],
82-
"elapsed_ms": int((time.time() - started) * 1000),
83-
"token_usage": {},
84-
}
97+
retrieved = wiki_db.search_nodes(user_id, question, limit=max(limit, limit + len(pinned_nodes or [])))
98+
return _merge_hits(pinned_nodes or [], retrieved, limit), embedding_error
8599

86-
prompt = _build_prompt(question, hits)
87-
llm = build_llm_provider()
88-
llm_error = None
89-
response = None
90-
try:
91-
response = llm.generate(prompt, system=SYSTEM_PROMPT, temperature=0.0, max_tokens=900)
92-
answer_text = response.text
93-
except Exception as exc:
94-
llm_error = str(exc)
95-
answer_text = _extractive_fallback(hits, llm_error)
96-
citations_by_node = wiki_db.get_citations_for_nodes(user_id, [hit["node_id"] for hit in hits])
100+
101+
def _build_citations(user_id: str, hits: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
102+
citations_by_node = wiki_db.get_citations_for_nodes(user_id, [hit["node_id"] for hit in hits if hit.get("node_id")])
97103
citations = []
98104
for index, hit in enumerate(hits, start=1):
105+
metadata = hit.get("metadata") or {}
106+
display_node = hit
107+
section_title = ""
108+
if str(hit.get("node_type") or "") == "section" and metadata.get("parent_paper_id"):
109+
parent = wiki_db.get_node(user_id, str(metadata.get("parent_paper_id")))
110+
if parent:
111+
display_node = parent
112+
section_title = str(hit.get("title") or "")
99113
node_citations = citations_by_node.get(hit["node_id"], [])
100114
first = node_citations[0] if node_citations else {}
115+
display_metadata = dict(display_node.get("metadata") or {})
116+
if metadata.get("parent_paper_id"):
117+
display_metadata.setdefault("parent_paper_id", metadata.get("parent_paper_id"))
118+
if section_title:
119+
display_metadata["section_title"] = section_title
120+
display_metadata["evidence_node_id"] = hit["node_id"]
101121
citations.append(
102122
{
103123
"index": index,
104-
"node_id": hit["node_id"],
105-
"title": hit["title"],
106-
"node_type": hit["node_type"],
124+
"node_id": display_node["node_id"],
125+
"title": display_node["title"],
126+
"node_type": display_node["node_type"],
107127
"excerpt": first.get("excerpt") or _snippet(hit, max_chars=260),
108128
"source_type": first.get("source"),
109129
"source_id": first.get("source_id"),
110130
"anchor": first.get("anchor"),
111-
"metadata": hit.get("metadata") or {},
131+
"metadata": display_metadata,
112132
}
113133
)
134+
return citations
135+
136+
137+
def _empty_answer(started: float) -> Dict[str, Any]:
138+
return {
139+
"text": (
140+
"本地 Wiki 还没有足够相关的材料。可以先对相关论文生成精读报告,"
141+
"或运行 `paperflow wiki backfill` 导入已有运行历史。"
142+
),
143+
"citations": [],
144+
"elapsed_ms": int((time.time() - started) * 1000),
145+
"token_usage": {},
146+
}
147+
148+
149+
def _token_usage(llm: Any, response: Any, embedding_error: Optional[str], llm_error: Optional[str]) -> Dict[str, Any]:
150+
return {
151+
"provider": getattr(llm, "name", "unknown"),
152+
"model": getattr(llm, "model", "unknown"),
153+
"prompt_tokens": response.prompt_tokens if response else 0,
154+
"completion_tokens": response.completion_tokens if response else 0,
155+
"embedding_error": embedding_error,
156+
"llm_error": llm_error,
157+
}
158+
159+
160+
def answer_question(
161+
user_id: str,
162+
question: str,
163+
*,
164+
limit: int = 8,
165+
pinned_nodes: Optional[List[Dict[str, Any]]] = None,
166+
) -> Dict[str, Any]:
167+
"""Return an LLM answer with local wiki citations."""
168+
started = time.time()
169+
hits, embedding_error = _prepare_hits(user_id, question, limit, pinned_nodes)
170+
if not hits:
171+
return _empty_answer(started)
172+
173+
prompt = _build_prompt(question, hits)
174+
llm = build_llm_provider()
175+
llm_error = None
176+
response = None
177+
try:
178+
response = llm.generate(prompt, system=SYSTEM_PROMPT, temperature=0.0, max_tokens=900)
179+
answer_text = response.text
180+
except Exception as exc:
181+
llm_error = str(exc)
182+
answer_text = _extractive_fallback(hits, llm_error)
183+
citations = _build_citations(user_id, hits)
114184
return {
115185
"text": answer_text,
116186
"citations": citations,
117187
"elapsed_ms": int((time.time() - started) * 1000),
118-
"token_usage": {
119-
"provider": getattr(llm, "name", "unknown"),
120-
"model": getattr(llm, "model", "unknown"),
121-
"prompt_tokens": response.prompt_tokens if response else 0,
122-
"completion_tokens": response.completion_tokens if response else 0,
123-
"embedding_error": embedding_error,
124-
"llm_error": llm_error,
125-
},
188+
"token_usage": _token_usage(llm, response, embedding_error, llm_error),
189+
}
190+
191+
192+
def answer_question_stream(
193+
user_id: str,
194+
question: str,
195+
*,
196+
limit: int = 8,
197+
pinned_nodes: Optional[List[Dict[str, Any]]] = None,
198+
) -> Iterator[Dict[str, Any]]:
199+
"""Yield answer events with local citations and provider-native chunks."""
200+
started = time.time()
201+
hits, embedding_error = _prepare_hits(user_id, question, limit, pinned_nodes)
202+
if not hits:
203+
result = _empty_answer(started)
204+
yield {"event": "meta", "data": {key: value for key, value in result.items() if key != "text"}}
205+
yield {"event": "chunk", "data": {"text": result["text"]}}
206+
yield {"event": "done", "data": result}
207+
return
208+
209+
citations = _build_citations(user_id, hits)
210+
llm = build_llm_provider()
211+
prompt = _build_prompt(question, hits)
212+
llm_error = None
213+
text_parts: List[str] = []
214+
meta = {
215+
"citations": citations,
216+
"elapsed_ms": 0,
217+
"token_usage": _token_usage(llm, None, embedding_error, None),
218+
"streaming": {"provider": True, "transport": "sse"},
219+
}
220+
yield {"event": "meta", "data": meta}
221+
try:
222+
for chunk in llm.stream_generate(prompt, system=SYSTEM_PROMPT, temperature=0.0, max_tokens=900):
223+
if not chunk:
224+
continue
225+
text_parts.append(chunk)
226+
yield {"event": "chunk", "data": {"text": chunk}}
227+
except Exception as exc:
228+
llm_error = str(exc)
229+
fallback = _extractive_fallback(hits, llm_error)
230+
text_parts = [fallback]
231+
yield {"event": "chunk", "data": {"text": fallback}}
232+
233+
result = {
234+
"text": "".join(text_parts),
235+
"citations": citations,
236+
"elapsed_ms": int((time.time() - started) * 1000),
237+
"token_usage": _token_usage(llm, None, embedding_error, llm_error),
126238
}
239+
yield {"event": "done", "data": result}

0 commit comments

Comments
 (0)