Spaces:
Runtime error
Runtime error
Commit ·
1e09b5f
1
Parent(s): c8b7149
launching
Browse files- .env.example +2 -0
- backend/app/config.py +2 -0
- backend/app/llm/provider.py +8 -0
- backend/app/llm/signatures.py +25 -1
- backend/app/pipeline/research.py +114 -8
- backend/app/routers/notebooks.py +9 -3
- frontend/src/pages/TopicMap.tsx +504 -107
.env.example
CHANGED
|
@@ -52,6 +52,8 @@ R2_PUBLIC_BASE_URL=
|
|
| 52 |
# Cheaper: LLM_MODEL=openai/glm-5.3-flash. Free for local dev: openai/glm-4.7-flash
|
| 53 |
LLM_MODEL=openai/glm-5.3
|
| 54 |
LLM_FAST_MODEL=openai/glm-5.3-flash
|
|
|
|
|
|
|
| 55 |
LLM_API_BASE=https://api.z.ai/api/paas/v4
|
| 56 |
LLM_API_KEY=
|
| 57 |
LLM_TEMPERATURE=1.0
|
|
|
|
| 52 |
# Cheaper: LLM_MODEL=openai/glm-5.3-flash. Free for local dev: openai/glm-4.7-flash
|
| 53 |
LLM_MODEL=openai/glm-5.3
|
| 54 |
LLM_FAST_MODEL=openai/glm-5.3-flash
|
| 55 |
+
# Chat triage classifier (greetings, off topic, follow ups). Empty = LLM_FAST_MODEL.
|
| 56 |
+
LLM_TRIAGE_MODEL=
|
| 57 |
LLM_API_BASE=https://api.z.ai/api/paas/v4
|
| 58 |
LLM_API_KEY=
|
| 59 |
LLM_TEMPERATURE=1.0
|
backend/app/config.py
CHANGED
|
@@ -63,6 +63,8 @@ class Settings(BaseSettings):
|
|
| 63 |
# LLM (LiteLLM model strings; Z.ai GLM by default)
|
| 64 |
llm_model: str = "openai/glm-5.3"
|
| 65 |
llm_fast_model: str = "openai/glm-5.3-flash"
|
|
|
|
|
|
|
| 66 |
llm_api_base: str = "https://api.z.ai/api/paas/v4"
|
| 67 |
llm_api_key: str = ""
|
| 68 |
llm_temperature: float = 1.0 # Z.ai recommends 1.0 for GLM-5.x
|
|
|
|
| 63 |
# LLM (LiteLLM model strings; Z.ai GLM by default)
|
| 64 |
llm_model: str = "openai/glm-5.3"
|
| 65 |
llm_fast_model: str = "openai/glm-5.3-flash"
|
| 66 |
+
# Chat triage (small talk, off topic, follow up rewriting). Empty = LLM_FAST_MODEL.
|
| 67 |
+
llm_triage_model: str = ""
|
| 68 |
llm_api_base: str = "https://api.z.ai/api/paas/v4"
|
| 69 |
llm_api_key: str = ""
|
| 70 |
llm_temperature: float = 1.0 # Z.ai recommends 1.0 for GLM-5.x
|
backend/app/llm/provider.py
CHANGED
|
@@ -52,6 +52,14 @@ def fast_lm() -> dspy.LM:
|
|
| 52 |
return _build(settings.llm_fast_model, effort="low", temperature=None, max_tokens=8000)
|
| 53 |
|
| 54 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
def configure_default() -> None:
|
| 56 |
dspy.configure(lm=main_lm(), adapter=dspy.JSONAdapter())
|
| 57 |
|
|
|
|
| 52 |
return _build(settings.llm_fast_model, effort="low", temperature=None, max_tokens=8000)
|
| 53 |
|
| 54 |
|
| 55 |
+
@lru_cache
|
| 56 |
+
def triage_lm() -> dspy.LM:
|
| 57 |
+
"""Cheap model that decides whether a chat message needs research at all."""
|
| 58 |
+
if not settings.llm_triage_model:
|
| 59 |
+
return fast_lm()
|
| 60 |
+
return _build(settings.llm_triage_model, effort="low", temperature=0.2, max_tokens=2000)
|
| 61 |
+
|
| 62 |
+
|
| 63 |
def configure_default() -> None:
|
| 64 |
dspy.configure(lm=main_lm(), adapter=dspy.JSONAdapter())
|
| 65 |
|
backend/app/llm/signatures.py
CHANGED
|
@@ -120,7 +120,9 @@ class ResearchArchive(dspy.Signature):
|
|
| 120 |
### heading only when the answer has distinct parts. Put [n] markers right after the sentence
|
| 121 |
or list item they support. Never use em dashes."""
|
| 122 |
|
| 123 |
-
question: str = dspy.InputField()
|
|
|
|
|
|
|
| 124 |
archive_guide: str = dspy.InputField(desc="How this notebook's files are laid out")
|
| 125 |
answer: str = dspy.OutputField(desc="Answer with [n] citation markers")
|
| 126 |
citations: list[FileCitation] = dspy.OutputField()
|
|
@@ -253,3 +255,25 @@ class EvergreenScore(dspy.Signature):
|
|
| 253 |
score: float = dspy.OutputField(desc="0 (dated) to 1 (timeless)")
|
| 254 |
reason: str = dspy.OutputField()
|
| 255 |
angle: str = dspy.OutputField(desc="One line hook for resharing now")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 120 |
### heading only when the answer has distinct parts. Put [n] markers right after the sentence
|
| 121 |
or list item they support. Never use em dashes."""
|
| 122 |
|
| 123 |
+
question: str = dspy.InputField(desc="A standalone question (follow ups already resolved)")
|
| 124 |
+
conversation: str = dspy.InputField(desc="Recent turns of this chat, oldest first. Use it for context "
|
| 125 |
+
"and to avoid repeating yourself; cite only what you read now")
|
| 126 |
archive_guide: str = dspy.InputField(desc="How this notebook's files are laid out")
|
| 127 |
answer: str = dspy.OutputField(desc="Answer with [n] citation markers")
|
| 128 |
citations: list[FileCitation] = dspy.OutputField()
|
|
|
|
| 255 |
score: float = dspy.OutputField(desc="0 (dated) to 1 (timeless)")
|
| 256 |
reason: str = dspy.OutputField()
|
| 257 |
angle: str = dspy.OutputField(desc="One line hook for resharing now")
|
| 258 |
+
|
| 259 |
+
|
| 260 |
+
class TriageMessage(dspy.Signature):
|
| 261 |
+
"""Triage a chat message sent to a research assistant that answers ONLY from one writer's archive of
|
| 262 |
+
posts. Decide what it needs before any expensive research runs.
|
| 263 |
+
- archive: needs facts, ideas, quotes or opinions from the writer's posts (including follow ups
|
| 264 |
+
like "say more", "what about 2023?", "compare that with pricing").
|
| 265 |
+
- chitchat: greetings, thanks, reactions, small talk.
|
| 266 |
+
- about_app: how to use Notestack itself (sources, notebooks, audio, launch kits).
|
| 267 |
+
- off_topic: general knowledge, coding, math, news, or anything the posts cannot answer.
|
| 268 |
+
For archive, rewrite the message into a standalone question using the conversation, and pick depth:
|
| 269 |
+
quick for a single fact or lookup, deep for synthesis across many posts. For the other kinds, write
|
| 270 |
+
a short, warm reply (1 to 3 sentences) that answers or gently steers back to what the archive can
|
| 271 |
+
do, suggesting one concrete question. Never use em dashes."""
|
| 272 |
+
|
| 273 |
+
conversation: str = dspy.InputField(desc="Recent turns, oldest first")
|
| 274 |
+
message: str = dspy.InputField()
|
| 275 |
+
notebook: str = dspy.InputField(desc="Notebook title and some of its post titles")
|
| 276 |
+
kind: Literal["archive", "chitchat", "about_app", "off_topic"] = dspy.OutputField()
|
| 277 |
+
standalone_question: str = dspy.OutputField(desc="For archive: the question with follow ups resolved")
|
| 278 |
+
depth: Literal["quick", "deep"] = dspy.OutputField()
|
| 279 |
+
reply: str = dspy.OutputField(desc="For non archive kinds: the reply to show. Empty for archive")
|
backend/app/pipeline/research.py
CHANGED
|
@@ -1,5 +1,11 @@
|
|
| 1 |
"""Agentic research over the file system corpus: the model explores files with list/search/read,
|
| 2 |
-
then every citation is checked against the real file before it reaches the writer.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
|
| 4 |
import re
|
| 5 |
from collections.abc import Callable
|
|
@@ -10,8 +16,8 @@ import dspy
|
|
| 10 |
from app.config import settings
|
| 11 |
from app.corpus import Corpus, ScopedTools
|
| 12 |
from app.llm.postprocess import strip_em_dashes
|
| 13 |
-
from app.llm.provider import main_lm, track_usage
|
| 14 |
-
from app.llm.signatures import ResearchArchive
|
| 15 |
|
| 16 |
_MARKER = re.compile(r"\[(\d+)\]")
|
| 17 |
|
|
@@ -33,6 +39,87 @@ class ResearchResult:
|
|
| 33 |
prompt_tokens: int = 0
|
| 34 |
completion_tokens: int = 0
|
| 35 |
steps: list[tuple[str, str]] = field(default_factory=list)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
|
| 37 |
|
| 38 |
def archive_guide(tools: ScopedTools) -> str:
|
|
@@ -73,9 +160,24 @@ def research(
|
|
| 73 |
allowed: dict[str, str],
|
| 74 |
question: str,
|
| 75 |
on_step: Callable[[str, str], None] | None = None,
|
|
|
|
|
|
|
| 76 |
) -> ResearchResult:
|
|
|
|
| 77 |
if not allowed:
|
| 78 |
-
return ResearchResult("This notebook has no posts yet. Add some sources first.", True, [])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
corpus.sync()
|
| 80 |
steps: list[tuple[str, str]] = []
|
| 81 |
|
|
@@ -85,14 +187,16 @@ def research(
|
|
| 85 |
on_step(kind, detail)
|
| 86 |
|
| 87 |
tools = ScopedTools(corpus, allowed, on_step=log)
|
|
|
|
| 88 |
agent = dspy.ReAct(
|
| 89 |
ResearchArchive,
|
| 90 |
tools=[tools.list_files, tools.search, tools.read],
|
| 91 |
-
max_iters=
|
| 92 |
)
|
| 93 |
lm = main_lm()
|
| 94 |
with track_usage(lm) as usage, dspy.context(lm=lm):
|
| 95 |
-
pred = agent(question=question,
|
|
|
|
| 96 |
|
| 97 |
text, cites = verify_citations(tools, pred.answer, pred.citations or [])
|
| 98 |
unsupported = bool(pred.unsupported) or not cites
|
|
@@ -103,7 +207,9 @@ def research(
|
|
| 103 |
text=strip_em_dashes(text),
|
| 104 |
unsupported=unsupported,
|
| 105 |
citations=cites,
|
| 106 |
-
prompt_tokens=usage.prompt_tokens,
|
| 107 |
-
completion_tokens=usage.completion_tokens,
|
| 108 |
steps=steps,
|
|
|
|
|
|
|
| 109 |
)
|
|
|
|
| 1 |
"""Agentic research over the file system corpus: the model explores files with list/search/read,
|
| 2 |
+
then every citation is checked against the real file before it reaches the writer.
|
| 3 |
+
|
| 4 |
+
Cost control: every message is triaged first. Greetings and thanks are answered from a phrase list
|
| 5 |
+
(no model call). Everything else gets one cheap call on the fast model that reads the recent
|
| 6 |
+
conversation, answers off topic and app questions directly, and turns follow ups into standalone
|
| 7 |
+
questions. Only real archive questions run the research agent, with a small step budget for simple
|
| 8 |
+
lookups and the full budget for synthesis."""
|
| 9 |
|
| 10 |
import re
|
| 11 |
from collections.abc import Callable
|
|
|
|
| 16 |
from app.config import settings
|
| 17 |
from app.corpus import Corpus, ScopedTools
|
| 18 |
from app.llm.postprocess import strip_em_dashes
|
| 19 |
+
from app.llm.provider import main_lm, track_usage, triage_lm
|
| 20 |
+
from app.llm.signatures import ResearchArchive, TriageMessage
|
| 21 |
|
| 22 |
_MARKER = re.compile(r"\[(\d+)\]")
|
| 23 |
|
|
|
|
| 39 |
prompt_tokens: int = 0
|
| 40 |
completion_tokens: int = 0
|
| 41 |
steps: list[tuple[str, str]] = field(default_factory=list)
|
| 42 |
+
kind: str = "archive" # archive | chitchat | about_app | off_topic
|
| 43 |
+
question: str = "" # the standalone question that was researched
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
@dataclass
|
| 47 |
+
class Turn:
|
| 48 |
+
role: str # user | assistant
|
| 49 |
+
text: str
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
HISTORY_TURNS = 8
|
| 53 |
+
QUICK_STEPS = 4
|
| 54 |
+
|
| 55 |
+
_CHITCHAT = re.compile(
|
| 56 |
+
r"^\s*(hi|hey|hello|yo|hiya|good (morning|afternoon|evening)|thanks|thank you|thx|ty|cheers|ok(ay)?|"
|
| 57 |
+
r"cool|great|nice|awesome|perfect|got it|sounds good|bye|goodbye|see you|lol|haha)[\s!.,:)]*$",
|
| 58 |
+
re.IGNORECASE,
|
| 59 |
+
)
|
| 60 |
+
_GREETING = re.compile(r"^\s*(hi|hey|hello|yo|hiya|good (morning|afternoon|evening))", re.IGNORECASE)
|
| 61 |
+
_BYE = re.compile(r"^\s*(bye|goodbye|see you)", re.IGNORECASE)
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def conversation_text(history: list[Turn], limit: int = HISTORY_TURNS) -> str:
|
| 65 |
+
"""Last few turns, citation markers stripped, each trimmed, so the prompt stays small."""
|
| 66 |
+
if not history:
|
| 67 |
+
return "(new conversation)"
|
| 68 |
+
lines = []
|
| 69 |
+
for t in history[-limit:]:
|
| 70 |
+
text = _MARKER.sub("", t.text).strip()
|
| 71 |
+
if len(text) > 700:
|
| 72 |
+
text = text[:700].rsplit(" ", 1)[0] + " ..."
|
| 73 |
+
lines.append(f"{'Writer' if t.role == 'user' else 'Assistant'}: {text}")
|
| 74 |
+
return "\n".join(lines)
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def instant_reply(message: str, history: list[Turn]) -> str | None:
|
| 78 |
+
"""Canned replies for pure small talk: no model call at all."""
|
| 79 |
+
if not _CHITCHAT.match(message):
|
| 80 |
+
return None
|
| 81 |
+
if _BYE.match(message):
|
| 82 |
+
return "Clear skies. Your archive will be here when you come back."
|
| 83 |
+
if _GREETING.match(message):
|
| 84 |
+
return ("Hi! Ask me anything about the posts in this notebook, for example what you have written "
|
| 85 |
+
"about pricing, or how your view on something changed over time.")
|
| 86 |
+
if history:
|
| 87 |
+
return "Glad that helped. Want to dig further into any of those posts?"
|
| 88 |
+
return "Ask me anything about the posts in this notebook and I will answer with citations."
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
@dataclass
|
| 92 |
+
class Triage:
|
| 93 |
+
kind: str
|
| 94 |
+
question: str
|
| 95 |
+
depth: str
|
| 96 |
+
reply: str
|
| 97 |
+
prompt_tokens: int = 0
|
| 98 |
+
completion_tokens: int = 0
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def triage(message: str, history: list[Turn], notebook: str) -> Triage:
|
| 102 |
+
"""One cheap call: is this worth researching, and what exactly should be researched?"""
|
| 103 |
+
lm = triage_lm()
|
| 104 |
+
try:
|
| 105 |
+
with track_usage(lm) as usage, dspy.context(lm=lm):
|
| 106 |
+
pred = dspy.Predict(TriageMessage)(conversation=conversation_text(history), message=message,
|
| 107 |
+
notebook=notebook)
|
| 108 |
+
except Exception:
|
| 109 |
+
# If triage fails, fall back to researching the raw message rather than failing the chat.
|
| 110 |
+
return Triage("archive", message, "deep", "")
|
| 111 |
+
kind = pred.kind if pred.kind in {"archive", "chitchat", "about_app", "off_topic"} else "archive"
|
| 112 |
+
question = (pred.standalone_question or "").strip() or message
|
| 113 |
+
reply = strip_em_dashes((pred.reply or "").strip())
|
| 114 |
+
if kind != "archive" and not reply:
|
| 115 |
+
kind = "archive" # nothing to say without research: research it
|
| 116 |
+
return Triage(kind, question, pred.depth if pred.depth in {"quick", "deep"} else "deep", reply,
|
| 117 |
+
usage.prompt_tokens, usage.completion_tokens)
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
def notebook_brief(title: str, allowed: dict[str, str]) -> str:
|
| 121 |
+
titles = list(allowed.values())[:25]
|
| 122 |
+
return f"Notebook: {title}. {len(allowed)} posts, including: " + "; ".join(titles)
|
| 123 |
|
| 124 |
|
| 125 |
def archive_guide(tools: ScopedTools) -> str:
|
|
|
|
| 160 |
allowed: dict[str, str],
|
| 161 |
question: str,
|
| 162 |
on_step: Callable[[str, str], None] | None = None,
|
| 163 |
+
history: list[Turn] | None = None,
|
| 164 |
+
notebook_title: str = "",
|
| 165 |
) -> ResearchResult:
|
| 166 |
+
history = history or []
|
| 167 |
if not allowed:
|
| 168 |
+
return ResearchResult("This notebook has no posts yet. Add some sources first.", True, [], kind="chitchat")
|
| 169 |
+
|
| 170 |
+
canned = instant_reply(question, history)
|
| 171 |
+
if canned:
|
| 172 |
+
return ResearchResult(canned, False, [], kind="chitchat", question=question)
|
| 173 |
+
|
| 174 |
+
if on_step:
|
| 175 |
+
on_step("think", "Reading the conversation")
|
| 176 |
+
route = triage(question, history, notebook_brief(notebook_title, allowed))
|
| 177 |
+
if route.kind != "archive":
|
| 178 |
+
return ResearchResult(route.reply, False, [], route.prompt_tokens, route.completion_tokens,
|
| 179 |
+
kind=route.kind, question=question)
|
| 180 |
+
|
| 181 |
corpus.sync()
|
| 182 |
steps: list[tuple[str, str]] = []
|
| 183 |
|
|
|
|
| 187 |
on_step(kind, detail)
|
| 188 |
|
| 189 |
tools = ScopedTools(corpus, allowed, on_step=log)
|
| 190 |
+
budget = min(QUICK_STEPS, settings.research_max_steps) if route.depth == "quick" else settings.research_max_steps
|
| 191 |
agent = dspy.ReAct(
|
| 192 |
ResearchArchive,
|
| 193 |
tools=[tools.list_files, tools.search, tools.read],
|
| 194 |
+
max_iters=budget,
|
| 195 |
)
|
| 196 |
lm = main_lm()
|
| 197 |
with track_usage(lm) as usage, dspy.context(lm=lm):
|
| 198 |
+
pred = agent(question=route.question, conversation=conversation_text(history),
|
| 199 |
+
archive_guide=archive_guide(tools))
|
| 200 |
|
| 201 |
text, cites = verify_citations(tools, pred.answer, pred.citations or [])
|
| 202 |
unsupported = bool(pred.unsupported) or not cites
|
|
|
|
| 207 |
text=strip_em_dashes(text),
|
| 208 |
unsupported=unsupported,
|
| 209 |
citations=cites,
|
| 210 |
+
prompt_tokens=usage.prompt_tokens + route.prompt_tokens,
|
| 211 |
+
completion_tokens=usage.completion_tokens + route.completion_tokens,
|
| 212 |
steps=steps,
|
| 213 |
+
kind="archive",
|
| 214 |
+
question=route.question,
|
| 215 |
)
|
backend/app/routers/notebooks.py
CHANGED
|
@@ -13,7 +13,7 @@ from app.config import settings
|
|
| 13 |
from app.corpus import Corpus
|
| 14 |
from app.llm.provider import provider_name
|
| 15 |
from app.models import Artifact, Chat, Citation, Document, DocumentTopic, Message, Notebook, NotebookDocument, Topic
|
| 16 |
-
from app.pipeline.research import research
|
| 17 |
from app.routers.sources import serialize_document
|
| 18 |
from app.services.artifacts import latest_jobs, serialize_artifact
|
| 19 |
from app.services.jobs import record_usage
|
|
@@ -219,7 +219,7 @@ def _sse(event: str, data: dict) -> str:
|
|
| 219 |
return f"event: {event}\ndata: {json.dumps(data)}\n\n"
|
| 220 |
|
| 221 |
|
| 222 |
-
_STEP_LABEL = {"list": "Scanning the archive", "search": "Searching for", "read": "Reading"}
|
| 223 |
|
| 224 |
|
| 225 |
@router.post("/{notebook_id}/chat")
|
|
@@ -232,6 +232,11 @@ async def chat(notebook_id: uuid.UUID, body: ChatIn, ctx: Ctx = Depends(get_ctx)
|
|
| 232 |
db.add(chat_row)
|
| 233 |
db.flush()
|
| 234 |
chat_row.updated_at = datetime.now(UTC) # keeps the history list newest first
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 235 |
db.add(Message(chat_id=chat_row.id, role="user", content=body.question))
|
| 236 |
db.commit()
|
| 237 |
|
|
@@ -252,7 +257,8 @@ async def chat(notebook_id: uuid.UUID, body: ChatIn, ctx: Ctx = Depends(get_ctx)
|
|
| 252 |
def on_step(kind: str, detail: str) -> None:
|
| 253 |
loop.call_soon_threadsafe(steps.put_nowait, (kind, detail))
|
| 254 |
|
| 255 |
-
task = asyncio.create_task(asyncio.to_thread(
|
|
|
|
| 256 |
while not task.done() or not steps.empty():
|
| 257 |
try:
|
| 258 |
kind, detail = await asyncio.wait_for(steps.get(), timeout=0.25)
|
|
|
|
| 13 |
from app.corpus import Corpus
|
| 14 |
from app.llm.provider import provider_name
|
| 15 |
from app.models import Artifact, Chat, Citation, Document, DocumentTopic, Message, Notebook, NotebookDocument, Topic
|
| 16 |
+
from app.pipeline.research import Turn, research
|
| 17 |
from app.routers.sources import serialize_document
|
| 18 |
from app.services.artifacts import latest_jobs, serialize_artifact
|
| 19 |
from app.services.jobs import record_usage
|
|
|
|
| 219 |
return f"event: {event}\ndata: {json.dumps(data)}\n\n"
|
| 220 |
|
| 221 |
|
| 222 |
+
_STEP_LABEL = {"list": "Scanning the archive", "search": "Searching for", "read": "Reading", "think": ""}
|
| 223 |
|
| 224 |
|
| 225 |
@router.post("/{notebook_id}/chat")
|
|
|
|
| 232 |
db.add(chat_row)
|
| 233 |
db.flush()
|
| 234 |
chat_row.updated_at = datetime.now(UTC) # keeps the history list newest first
|
| 235 |
+
# Memory: the chat so far (before this message), oldest first, capped to the last few turns.
|
| 236 |
+
prior = db.scalars(
|
| 237 |
+
select(Message).where(Message.chat_id == chat_row.id).order_by(Message.created_at.desc()).limit(8)
|
| 238 |
+
).all()
|
| 239 |
+
history = [Turn(m.role, m.content) for m in reversed(prior)]
|
| 240 |
db.add(Message(chat_id=chat_row.id, role="user", content=body.question))
|
| 241 |
db.commit()
|
| 242 |
|
|
|
|
| 257 |
def on_step(kind: str, detail: str) -> None:
|
| 258 |
loop.call_soon_threadsafe(steps.put_nowait, (kind, detail))
|
| 259 |
|
| 260 |
+
task = asyncio.create_task(asyncio.to_thread(
|
| 261 |
+
research, corpus, allowed, body.question, on_step, history, nb.title))
|
| 262 |
while not task.done() or not steps.empty():
|
| 263 |
try:
|
| 264 |
kind, detail = await asyncio.wait_for(steps.get(), timeout=0.25)
|
frontend/src/pages/TopicMap.tsx
CHANGED
|
@@ -1,22 +1,138 @@
|
|
| 1 |
-
import {
|
| 2 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
import { notebooksApi, topicsApi } from "../api/endpoints";
|
| 4 |
-
import type { Job, TopicDetail, TopicMap as TopicMapData } from "../api/types";
|
| 5 |
import { Reader } from "../components/Reader";
|
| 6 |
-
import { EmptyState, errorMessage, formatDate, JobProgress, Loading, PageHeader } from "../components/ui";
|
| 7 |
import { useJob } from "../hooks/useJob";
|
| 8 |
import { playClick } from "../lib/sound";
|
| 9 |
|
| 10 |
-
|
| 11 |
-
const
|
|
|
|
|
|
|
| 12 |
|
| 13 |
-
type P = { id: string; x: number; y: number; vx: number; vy: number; r: number };
|
| 14 |
type View = { x: number; y: number; w: number; h: number };
|
|
|
|
| 15 |
|
| 16 |
const HOME: View = { x: 0, y: 0, w: W, h: H };
|
| 17 |
const MIN_W = W / 8;
|
| 18 |
const MAX_W = W * 1.6;
|
| 19 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
/** Wheel to zoom around the cursor, drag to pan, buttons and keys for the rest. */
|
| 21 |
function useZoomPan(svgRef: RefObject<SVGSVGElement>) {
|
| 22 |
const [view, setView] = useState<View>(HOME);
|
|
@@ -33,7 +149,6 @@ function useZoomPan(svgRef: RefObject<SVGSVGElement>) {
|
|
| 33 |
const scaleView = (v: View, factor: number, fx: number, fy: number): View => {
|
| 34 |
const w = Math.max(MIN_W, Math.min(MAX_W, v.w * factor));
|
| 35 |
const h = (w / W) * H;
|
| 36 |
-
// Keep the focus point fixed on screen while scaling.
|
| 37 |
return { x: fx - ((fx - v.x) * w) / v.w, y: fy - ((fy - v.y) * h) / v.h, w, h };
|
| 38 |
};
|
| 39 |
|
|
@@ -56,7 +171,7 @@ function useZoomPan(svgRef: RefObject<SVGSVGElement>) {
|
|
| 56 |
});
|
| 57 |
|
| 58 |
const onPointerDown = (e: ReactPointerEvent<SVGSVGElement>) => {
|
| 59 |
-
if ((e.target as Element).closest(".map-node")) return;
|
| 60 |
drag.current = { px: e.clientX, py: e.clientY, view, moved: false };
|
| 61 |
svgRef.current?.setPointerCapture(e.pointerId);
|
| 62 |
};
|
|
@@ -79,12 +194,10 @@ function useZoomPan(svgRef: RefObject<SVGSVGElement>) {
|
|
| 79 |
zoomAt(0.6, p.x, p.y);
|
| 80 |
};
|
| 81 |
const pan = (dx: number, dy: number) => setView((v) => ({ ...v, x: v.x + dx * v.w, y: v.y + dy * v.h }));
|
| 82 |
-
const reset = () =>
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
setView((v) => {
|
| 87 |
-
const w = Math.min(v.w, W / 2);
|
| 88 |
const h = (w / W) * H;
|
| 89 |
return { x: x - w / 2, y: y - h / 2, w, h };
|
| 90 |
});
|
|
@@ -99,65 +212,66 @@ function useZoomPan(svgRef: RefObject<SVGSVGElement>) {
|
|
| 99 |
};
|
| 100 |
}
|
| 101 |
|
| 102 |
-
/
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
const
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
|
|
|
| 161 |
}
|
| 162 |
|
| 163 |
export default function TopicMap() {
|
|
@@ -165,6 +279,7 @@ export default function TopicMap() {
|
|
| 165 |
const [job, setJob] = useState<Job | null>(null);
|
| 166 |
const [selected, setSelected] = useState<TopicDetail | null>(null);
|
| 167 |
const [hover, setHover] = useState<string | null>(null);
|
|
|
|
| 168 |
const [reading, setReading] = useState<string | null>(null);
|
| 169 |
const [error, setError] = useState<string | null>(null);
|
| 170 |
const navigate = useNavigate();
|
|
@@ -179,7 +294,8 @@ export default function TopicMap() {
|
|
| 179 |
if (j.status === "done") load();
|
| 180 |
});
|
| 181 |
|
| 182 |
-
const positions = useMemo(() => (data ? layout(data) : new Map<string, P>()), [data]);
|
|
|
|
| 183 |
const neighbors = useMemo(() => {
|
| 184 |
const m = new Map<string, Set<string>>();
|
| 185 |
data?.edges.forEach((e) => {
|
|
@@ -190,9 +306,38 @@ export default function TopicMap() {
|
|
| 190 |
});
|
| 191 |
return m;
|
| 192 |
}, [data]);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 193 |
|
| 194 |
const focus = hover ?? selected?.id ?? null;
|
| 195 |
const running = live && (live.status === "queued" || live.status === "running");
|
|
|
|
|
|
|
|
|
|
| 196 |
|
| 197 |
const rebuild = async (full: boolean) => {
|
| 198 |
setError(null);
|
|
@@ -211,16 +356,38 @@ export default function TopicMap() {
|
|
| 211 |
}
|
| 212 |
setSelected(await topicsApi.get(id));
|
| 213 |
};
|
| 214 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 215 |
|
| 216 |
return (
|
| 217 |
<div className="page-wrap">
|
| 218 |
<PageHeader eyebrow="Topic map" title="The constellations in your archive">
|
| 219 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 220 |
{data?.has_untagged_posts ? "Map new posts" : "Refresh"}
|
| 221 |
</button>
|
| 222 |
-
<button className="btn" disabled={Boolean(running)} onClick={() => rebuild(true)}>
|
| 223 |
-
Rebuild
|
| 224 |
</button>
|
| 225 |
</PageHeader>
|
| 226 |
{live && (running || live.status === "failed") && <JobProgress job={live} />}
|
|
@@ -251,7 +418,46 @@ export default function TopicMap() {
|
|
| 251 |
1:1
|
| 252 |
</button>
|
| 253 |
</div>
|
| 254 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 255 |
<svg
|
| 256 |
ref={svgRef}
|
| 257 |
viewBox={`${view.x} ${view.y} ${view.w} ${view.h}`}
|
|
@@ -259,6 +465,7 @@ export default function TopicMap() {
|
|
| 259 |
aria-label="Topic constellation. Plus and minus zoom, arrow keys pan, 0 resets."
|
| 260 |
tabIndex={0}
|
| 261 |
className="map-svg"
|
|
|
|
| 262 |
{...handlers}
|
| 263 |
onKeyDown={(e) => {
|
| 264 |
const step = 0.12;
|
|
@@ -274,17 +481,45 @@ export default function TopicMap() {
|
|
| 274 |
}}
|
| 275 |
>
|
| 276 |
<defs>
|
| 277 |
-
<radialGradient id="star" cx="50%" cy="50%" r="50%">
|
| 278 |
-
<stop offset="0%" stopColor="#ffffff" />
|
| 279 |
-
<stop offset="
|
| 280 |
<stop offset="100%" stopColor="#217cff" stopOpacity="0" />
|
| 281 |
</radialGradient>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 282 |
</defs>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 283 |
{data.edges.map((e) => {
|
| 284 |
const a = positions.get(e.source);
|
| 285 |
const b = positions.get(e.target);
|
| 286 |
if (!a || !b) return null;
|
| 287 |
const lit = focus && (e.source === focus || e.target === focus);
|
|
|
|
| 288 |
return (
|
| 289 |
<line
|
| 290 |
key={`${e.source}-${e.target}`}
|
|
@@ -292,64 +527,226 @@ export default function TopicMap() {
|
|
| 292 |
y1={a.y}
|
| 293 |
x2={b.x}
|
| 294 |
y2={b.y}
|
|
|
|
| 295 |
stroke={lit ? "#217cff" : "#ffffff"}
|
| 296 |
-
strokeOpacity={lit ? 0.
|
| 297 |
-
strokeWidth={(lit ? 1.
|
|
|
|
| 298 |
/>
|
| 299 |
);
|
| 300 |
})}
|
|
|
|
| 301 |
{data.nodes.map((n) => {
|
| 302 |
const p = positions.get(n.id)!;
|
| 303 |
const dim = focus && focus !== n.id && !neighbors.get(focus)?.has(n.id);
|
| 304 |
const active = selected?.id === n.id;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 305 |
return (
|
| 306 |
<g
|
| 307 |
key={n.id}
|
| 308 |
className="map-node"
|
| 309 |
-
opacity={dim ? 0.
|
| 310 |
onMouseEnter={() => setHover(n.id)}
|
| 311 |
onMouseLeave={() => setHover(null)}
|
| 312 |
onClick={() => open(n.id)}
|
| 313 |
onDoubleClick={() => open(n.id, true)}
|
| 314 |
tabIndex={0}
|
| 315 |
role="button"
|
| 316 |
-
aria-label={`${n.name}, ${n.post_count} posts`}
|
| 317 |
onKeyDown={(e) => (e.key === "Enter" || e.key === " ") && open(n.id)}
|
| 318 |
>
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 324 |
</g>
|
| 325 |
);
|
| 326 |
})}
|
| 327 |
</svg>
|
| 328 |
</div>
|
|
|
|
| 329 |
<aside className="card map-side">
|
| 330 |
{!selected && (
|
| 331 |
<>
|
| 332 |
-
<p className="eyebrow">
|
| 333 |
-
<p className="muted">
|
| 334 |
-
|
| 335 |
-
{data.
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 344 |
</>
|
| 345 |
)}
|
|
|
|
| 346 |
{selected && (
|
| 347 |
<>
|
| 348 |
-
<button className="link-btn mono muted" onClick={() => setSelected(null)}>
|
| 349 |
-
|
| 350 |
</button>
|
| 351 |
-
<
|
|
|
|
|
|
|
|
|
|
| 352 |
{selected.summary && <p className="muted">{selected.summary}</p>}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 353 |
<button
|
| 354 |
className="btn btn-small btn-primary"
|
| 355 |
onClick={async () => {
|
|
|
|
| 1 |
+
import {
|
| 2 |
+
useCallback,
|
| 3 |
+
useEffect,
|
| 4 |
+
useMemo,
|
| 5 |
+
useRef,
|
| 6 |
+
useState,
|
| 7 |
+
type MouseEvent as ReactMouseEvent,
|
| 8 |
+
type PointerEvent as ReactPointerEvent,
|
| 9 |
+
type RefObject,
|
| 10 |
+
} from "react";
|
| 11 |
+
import { Link, useNavigate } from "react-router-dom";
|
| 12 |
import { notebooksApi, topicsApi } from "../api/endpoints";
|
| 13 |
+
import type { Job, TopicDetail, TopicMap as TopicMapData, TopicNode } from "../api/types";
|
| 14 |
import { Reader } from "../components/Reader";
|
| 15 |
+
import { EmptyState, errorMessage, formatDate, JobProgress, Loading, PageHeader, Tabs } from "../components/ui";
|
| 16 |
import { useJob } from "../hooks/useJob";
|
| 17 |
import { playClick } from "../lib/sound";
|
| 18 |
|
| 19 |
+
// Virtual canvas. The layout runs in open space and is then fitted into this box with a margin.
|
| 20 |
+
const W = 1200;
|
| 21 |
+
const H = 780;
|
| 22 |
+
const PAD = 90;
|
| 23 |
|
| 24 |
+
type P = { id: string; x: number; y: number; vx: number; vy: number; r: number; galaxy: number };
|
| 25 |
type View = { x: number; y: number; w: number; h: number };
|
| 26 |
+
type ColorMode = "recency" | "momentum";
|
| 27 |
|
| 28 |
const HOME: View = { x: 0, y: 0, w: W, h: H };
|
| 29 |
const MIN_W = W / 8;
|
| 30 |
const MAX_W = W * 1.6;
|
| 31 |
|
| 32 |
+
// Deterministic pseudo random, so the map looks the same on every visit.
|
| 33 |
+
function seeded(n: number) {
|
| 34 |
+
const x = Math.sin(n * 12.9898) * 43758.5453;
|
| 35 |
+
return x - Math.floor(x);
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
/** Force layout with generous spacing: strong repulsion (room for labels), long constellation lines,
|
| 39 |
+
* a pull toward each galaxy's centre so related topics cluster, then fitted to the canvas. */
|
| 40 |
+
function layout(data: TopicMapData): Map<string, P> {
|
| 41 |
+
const maxCount = Math.max(1, ...data.nodes.map((n) => n.post_count));
|
| 42 |
+
const nodes: P[] = data.nodes.map((n, i) => {
|
| 43 |
+
const a = i * 2.39996;
|
| 44 |
+
const rad = 80 + Math.sqrt(i) * 70;
|
| 45 |
+
return {
|
| 46 |
+
id: n.id,
|
| 47 |
+
x: Math.cos(a) * rad,
|
| 48 |
+
y: Math.sin(a) * rad,
|
| 49 |
+
vx: 0,
|
| 50 |
+
vy: 0,
|
| 51 |
+
r: 5 + 20 * Math.sqrt(n.post_count / maxCount),
|
| 52 |
+
galaxy: n.galaxy,
|
| 53 |
+
};
|
| 54 |
+
});
|
| 55 |
+
const byId = new Map(nodes.map((n) => [n.id, n]));
|
| 56 |
+
const maxW = Math.max(1, ...data.edges.map((e) => e.weight));
|
| 57 |
+
const STEPS = 420;
|
| 58 |
+
for (let step = 0; step < STEPS; step++) {
|
| 59 |
+
const cool = 1 - step / STEPS;
|
| 60 |
+
for (let i = 0; i < nodes.length; i++) {
|
| 61 |
+
for (let j = i + 1; j < nodes.length; j++) {
|
| 62 |
+
const a = nodes[i];
|
| 63 |
+
const b = nodes[j];
|
| 64 |
+
let dx = a.x - b.x;
|
| 65 |
+
let dy = a.y - b.y;
|
| 66 |
+
let d2 = dx * dx + dy * dy;
|
| 67 |
+
if (d2 < 0.01) {
|
| 68 |
+
dx = 0.1;
|
| 69 |
+
dy = 0.1;
|
| 70 |
+
d2 = 0.02;
|
| 71 |
+
}
|
| 72 |
+
const minD = a.r + b.r + 90; // room for two labels side by side
|
| 73 |
+
const k = a.galaxy === b.galaxy ? 7000 : 11000; // other galaxies push harder, leaving voids between them
|
| 74 |
+
const f = (k / d2) * (d2 < minD * minD ? 3 : 1);
|
| 75 |
+
const d = Math.sqrt(d2);
|
| 76 |
+
a.vx += (dx / d) * f;
|
| 77 |
+
a.vy += (dy / d) * f;
|
| 78 |
+
b.vx -= (dx / d) * f;
|
| 79 |
+
b.vy -= (dy / d) * f;
|
| 80 |
+
}
|
| 81 |
+
}
|
| 82 |
+
for (const e of data.edges) {
|
| 83 |
+
const a = byId.get(e.source);
|
| 84 |
+
const b = byId.get(e.target);
|
| 85 |
+
if (!a || !b) continue;
|
| 86 |
+
const dx = b.x - a.x;
|
| 87 |
+
const dy = b.y - a.y;
|
| 88 |
+
const d = Math.sqrt(dx * dx + dy * dy) || 1;
|
| 89 |
+
const target = 190 - 70 * (e.weight / maxW);
|
| 90 |
+
const f = (d - target) * 0.012 * (0.4 + e.weight / maxW);
|
| 91 |
+
a.vx += (dx / d) * f;
|
| 92 |
+
a.vy += (dy / d) * f;
|
| 93 |
+
b.vx -= (dx / d) * f;
|
| 94 |
+
b.vy -= (dy / d) * f;
|
| 95 |
+
}
|
| 96 |
+
// Galaxy cohesion.
|
| 97 |
+
const centers = new Map<number, { x: number; y: number; n: number }>();
|
| 98 |
+
for (const n of nodes) {
|
| 99 |
+
const c = centers.get(n.galaxy) ?? { x: 0, y: 0, n: 0 };
|
| 100 |
+
c.x += n.x;
|
| 101 |
+
c.y += n.y;
|
| 102 |
+
c.n += 1;
|
| 103 |
+
centers.set(n.galaxy, c);
|
| 104 |
+
}
|
| 105 |
+
for (const n of nodes) {
|
| 106 |
+
const c = centers.get(n.galaxy)!;
|
| 107 |
+
if (c.n > 1) {
|
| 108 |
+
n.vx += (c.x / c.n - n.x) * 0.006;
|
| 109 |
+
n.vy += (c.y / c.n - n.y) * 0.006;
|
| 110 |
+
}
|
| 111 |
+
n.vx += -n.x * 0.0012; // gentle gravity toward the origin
|
| 112 |
+
n.vy += -n.y * 0.0012;
|
| 113 |
+
n.x += Math.max(-16, Math.min(16, n.vx)) * cool;
|
| 114 |
+
n.y += Math.max(-16, Math.min(16, n.vy)) * cool;
|
| 115 |
+
n.vx *= 0.6;
|
| 116 |
+
n.vy *= 0.6;
|
| 117 |
+
}
|
| 118 |
+
}
|
| 119 |
+
// Fit into the canvas with a margin, keeping proportions; never enlarge a small map past 1.4x.
|
| 120 |
+
const xs = nodes.map((n) => n.x);
|
| 121 |
+
const ys = nodes.map((n) => n.y);
|
| 122 |
+
const minX = Math.min(...xs);
|
| 123 |
+
const maxX = Math.max(...xs);
|
| 124 |
+
const minY = Math.min(...ys);
|
| 125 |
+
const maxY = Math.max(...ys);
|
| 126 |
+
const s = Math.min((W - PAD * 2) / Math.max(1, maxX - minX), (H - PAD * 2) / Math.max(1, maxY - minY), 1.4);
|
| 127 |
+
const ox = (W - (maxX - minX) * s) / 2 - minX * s;
|
| 128 |
+
const oy = (H - (maxY - minY) * s) / 2 - minY * s;
|
| 129 |
+
for (const n of nodes) {
|
| 130 |
+
n.x = n.x * s + ox;
|
| 131 |
+
n.y = n.y * s + oy;
|
| 132 |
+
}
|
| 133 |
+
return byId;
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
/** Wheel to zoom around the cursor, drag to pan, buttons and keys for the rest. */
|
| 137 |
function useZoomPan(svgRef: RefObject<SVGSVGElement>) {
|
| 138 |
const [view, setView] = useState<View>(HOME);
|
|
|
|
| 149 |
const scaleView = (v: View, factor: number, fx: number, fy: number): View => {
|
| 150 |
const w = Math.max(MIN_W, Math.min(MAX_W, v.w * factor));
|
| 151 |
const h = (w / W) * H;
|
|
|
|
| 152 |
return { x: fx - ((fx - v.x) * w) / v.w, y: fy - ((fy - v.y) * h) / v.h, w, h };
|
| 153 |
};
|
| 154 |
|
|
|
|
| 171 |
});
|
| 172 |
|
| 173 |
const onPointerDown = (e: ReactPointerEvent<SVGSVGElement>) => {
|
| 174 |
+
if ((e.target as Element).closest(".map-node, .nebula")) return;
|
| 175 |
drag.current = { px: e.clientX, py: e.clientY, view, moved: false };
|
| 176 |
svgRef.current?.setPointerCapture(e.pointerId);
|
| 177 |
};
|
|
|
|
| 194 |
zoomAt(0.6, p.x, p.y);
|
| 195 |
};
|
| 196 |
const pan = (dx: number, dy: number) => setView((v) => ({ ...v, x: v.x + dx * v.w, y: v.y + dy * v.h }));
|
| 197 |
+
const reset = () => setView(HOME);
|
| 198 |
+
const focusOn = (x: number, y: number, width = W / 2) =>
|
| 199 |
+
setView(() => {
|
| 200 |
+
const w = Math.max(MIN_W, Math.min(W, width));
|
|
|
|
|
|
|
| 201 |
const h = (w / W) * H;
|
| 202 |
return { x: x - w / 2, y: y - h / 2, w, h };
|
| 203 |
});
|
|
|
|
| 212 |
};
|
| 213 |
}
|
| 214 |
|
| 215 |
+
// Small pieces
|
| 216 |
+
|
| 217 |
+
function Sparkline({ values, height = 36, highlightFrom }: { values: number[]; height?: number; highlightFrom?: number }) {
|
| 218 |
+
const max = Math.max(1, ...values);
|
| 219 |
+
const bw = 100 / values.length;
|
| 220 |
+
return (
|
| 221 |
+
<svg className="sparkline" viewBox={`0 0 100 ${height}`} preserveAspectRatio="none" aria-hidden="true">
|
| 222 |
+
{values.map((v, i) => {
|
| 223 |
+
const h = v ? Math.max(2, (v / max) * (height - 2)) : 1;
|
| 224 |
+
return (
|
| 225 |
+
<rect
|
| 226 |
+
key={i}
|
| 227 |
+
x={i * bw + bw * 0.15}
|
| 228 |
+
y={height - h}
|
| 229 |
+
width={bw * 0.7}
|
| 230 |
+
height={h}
|
| 231 |
+
rx={0.8}
|
| 232 |
+
className={highlightFrom !== undefined && i >= highlightFrom ? "recent" : ""}
|
| 233 |
+
/>
|
| 234 |
+
);
|
| 235 |
+
})}
|
| 236 |
+
</svg>
|
| 237 |
+
);
|
| 238 |
+
}
|
| 239 |
+
|
| 240 |
+
const STATUS_LABEL: Record<TopicNode["status"], string> = {
|
| 241 |
+
rising: "Rising",
|
| 242 |
+
steady: "Steady orbit",
|
| 243 |
+
dormant: "Dormant",
|
| 244 |
+
};
|
| 245 |
+
|
| 246 |
+
function momentumText(m: number) {
|
| 247 |
+
if (!Number.isFinite(m) || m === 0) return "no recent posts";
|
| 248 |
+
return m >= 1 ? `${m.toFixed(1)}x its usual share lately` : `${Math.round(m * 100)}% of its usual share lately`;
|
| 249 |
+
}
|
| 250 |
+
|
| 251 |
+
/** Deep field: faint background stars plus survey rings, drawn once. */
|
| 252 |
+
function DeepField() {
|
| 253 |
+
const stars = useMemo(
|
| 254 |
+
() =>
|
| 255 |
+
Array.from({ length: 260 }, (_, i) => ({
|
| 256 |
+
x: seeded(i + 1) * W,
|
| 257 |
+
y: seeded(i + 1000) * H,
|
| 258 |
+
r: 0.3 + seeded(i + 2000) * 1.1,
|
| 259 |
+
o: 0.15 + seeded(i + 3000) * 0.5,
|
| 260 |
+
})),
|
| 261 |
+
[],
|
| 262 |
+
);
|
| 263 |
+
return (
|
| 264 |
+
<g className="deep-field" aria-hidden="true">
|
| 265 |
+
{[140, 260, 390, 520].map((r) => (
|
| 266 |
+
<circle key={r} cx={W / 2} cy={H / 2} r={r} className="survey-ring" />
|
| 267 |
+
))}
|
| 268 |
+
<line x1={W / 2} y1={0} x2={W / 2} y2={H} className="survey-axis" />
|
| 269 |
+
<line x1={0} y1={H / 2} x2={W} y2={H / 2} className="survey-axis" />
|
| 270 |
+
{stars.map((s, i) => (
|
| 271 |
+
<circle key={i} cx={s.x} cy={s.y} r={s.r} fill="#ffffff" opacity={s.o} />
|
| 272 |
+
))}
|
| 273 |
+
</g>
|
| 274 |
+
);
|
| 275 |
}
|
| 276 |
|
| 277 |
export default function TopicMap() {
|
|
|
|
| 279 |
const [job, setJob] = useState<Job | null>(null);
|
| 280 |
const [selected, setSelected] = useState<TopicDetail | null>(null);
|
| 281 |
const [hover, setHover] = useState<string | null>(null);
|
| 282 |
+
const [mode, setMode] = useState<ColorMode>("recency");
|
| 283 |
const [reading, setReading] = useState<string | null>(null);
|
| 284 |
const [error, setError] = useState<string | null>(null);
|
| 285 |
const navigate = useNavigate();
|
|
|
|
| 294 |
if (j.status === "done") load();
|
| 295 |
});
|
| 296 |
|
| 297 |
+
const positions = useMemo(() => (data?.nodes.length ? layout(data) : new Map<string, P>()), [data]);
|
| 298 |
+
const byId = useMemo(() => new Map((data?.nodes ?? []).map((n) => [n.id, n])), [data]);
|
| 299 |
const neighbors = useMemo(() => {
|
| 300 |
const m = new Map<string, Set<string>>();
|
| 301 |
data?.edges.forEach((e) => {
|
|
|
|
| 306 |
});
|
| 307 |
return m;
|
| 308 |
}, [data]);
|
| 309 |
+
// Nebula for each galaxy with more than one star: centred on its members, sized to cover them.
|
| 310 |
+
const nebulae = useMemo(() => {
|
| 311 |
+
if (!data) return [];
|
| 312 |
+
return data.galaxies
|
| 313 |
+
.filter((g) => g.size > 1)
|
| 314 |
+
.map((g) => {
|
| 315 |
+
const pts = g.topic_ids.map((id) => positions.get(id)).filter(Boolean) as P[];
|
| 316 |
+
const cx = pts.reduce((s, p) => s + p.x, 0) / Math.max(pts.length, 1);
|
| 317 |
+
const cy = pts.reduce((s, p) => s + p.y, 0) / Math.max(pts.length, 1);
|
| 318 |
+
const rx = Math.max(90, ...pts.map((p) => Math.abs(p.x - cx) + p.r)) + 50;
|
| 319 |
+
const ry = Math.max(80, ...pts.map((p) => Math.abs(p.y - cy) + p.r)) + 45;
|
| 320 |
+
return { ...g, cx, cy, rx, ry };
|
| 321 |
+
});
|
| 322 |
+
}, [data, positions]);
|
| 323 |
+
|
| 324 |
+
const brightest = useMemo(
|
| 325 |
+
() => new Set((data?.nodes ?? []).slice().sort((a, b) => b.post_count - a.post_count).slice(0, 3).map((n) => n.id)),
|
| 326 |
+
[data],
|
| 327 |
+
);
|
| 328 |
+
const maxEdge = Math.max(1, ...(data?.edges ?? []).map((e) => e.weight));
|
| 329 |
+
const recentBucket = useMemo(() => {
|
| 330 |
+
const a = data?.archive;
|
| 331 |
+
if (!a?.start || !a.end || !a.recent_from) return undefined;
|
| 332 |
+
const span = +new Date(a.end) - +new Date(a.start) || 1;
|
| 333 |
+
return Math.floor(((+new Date(a.recent_from) - +new Date(a.start)) / span) * 12);
|
| 334 |
+
}, [data]);
|
| 335 |
|
| 336 |
const focus = hover ?? selected?.id ?? null;
|
| 337 |
const running = live && (live.status === "queued" || live.status === "running");
|
| 338 |
+
const scale = view.w / W; // keeps labels and strokes a constant size on screen
|
| 339 |
+
const zoomedIn = scale < 0.75;
|
| 340 |
+
const hovered = hover ? byId.get(hover) : null;
|
| 341 |
|
| 342 |
const rebuild = async (full: boolean) => {
|
| 343 |
setError(null);
|
|
|
|
| 356 |
}
|
| 357 |
setSelected(await topicsApi.get(id));
|
| 358 |
};
|
| 359 |
+
|
| 360 |
+
const flyToGalaxy = (gid: number) => {
|
| 361 |
+
const n = nebulae.find((g) => g.id === gid);
|
| 362 |
+
if (n) focusOn(n.cx, n.cy, Math.max(n.rx * 2.6, (n.ry * 2.6 * W) / H));
|
| 363 |
+
};
|
| 364 |
+
|
| 365 |
+
const starColor = (n: TopicNode) => {
|
| 366 |
+
if (mode === "momentum") return n.status === "rising" ? "#ffffff" : n.status === "dormant" ? "rgba(255,255,255,0.45)" : "#217cff";
|
| 367 |
+
// recency: fresh topics burn white, old ones cool toward dim blue
|
| 368 |
+
return n.recency > 0.75 ? "#ffffff" : n.recency > 0.4 ? "#9cc4ff" : "#217cff";
|
| 369 |
+
};
|
| 370 |
+
const starGlow = (n: TopicNode) =>
|
| 371 |
+
mode === "momentum" ? (n.status === "rising" ? 1 : n.status === "dormant" ? 0.25 : 0.55) : 0.25 + 0.75 * n.recency;
|
| 372 |
+
|
| 373 |
+
const galaxyCount = data ? data.galaxies.filter((g) => g.size > 1).length : 0;
|
| 374 |
|
| 375 |
return (
|
| 376 |
<div className="page-wrap">
|
| 377 |
<PageHeader eyebrow="Topic map" title="The constellations in your archive">
|
| 378 |
+
<Tabs<ColorMode>
|
| 379 |
+
tabs={[
|
| 380 |
+
{ id: "recency", label: "Recency" },
|
| 381 |
+
{ id: "momentum", label: "Momentum" },
|
| 382 |
+
]}
|
| 383 |
+
value={mode}
|
| 384 |
+
onChange={setMode}
|
| 385 |
+
/>
|
| 386 |
+
<button className="btn btn-small" disabled={Boolean(running)} onClick={() => rebuild(false)}>
|
| 387 |
{data?.has_untagged_posts ? "Map new posts" : "Refresh"}
|
| 388 |
</button>
|
| 389 |
+
<button className="btn btn-small" disabled={Boolean(running)} onClick={() => rebuild(true)}>
|
| 390 |
+
Rebuild
|
| 391 |
</button>
|
| 392 |
</PageHeader>
|
| 393 |
{live && (running || live.status === "failed") && <JobProgress job={live} />}
|
|
|
|
| 418 |
1:1
|
| 419 |
</button>
|
| 420 |
</div>
|
| 421 |
+
|
| 422 |
+
<div className="map-hud mono" aria-live="polite">
|
| 423 |
+
{hovered ? (
|
| 424 |
+
<>
|
| 425 |
+
<span className="hud-title">{hovered.name}</span>
|
| 426 |
+
<span>
|
| 427 |
+
{hovered.post_count} posts · last {formatDate(hovered.last_at)} · {STATUS_LABEL[hovered.status]}
|
| 428 |
+
</span>
|
| 429 |
+
<Sparkline values={hovered.timeline} height={18} highlightFrom={recentBucket} />
|
| 430 |
+
</>
|
| 431 |
+
) : (
|
| 432 |
+
<span className="muted">
|
| 433 |
+
{data.nodes.length} stars · {galaxyCount} galaxies · scroll to zoom, drag to pan
|
| 434 |
+
</span>
|
| 435 |
+
)}
|
| 436 |
+
</div>
|
| 437 |
+
|
| 438 |
+
<div className="map-legend mono" aria-label="Legend">
|
| 439 |
+
<span>
|
| 440 |
+
<i className="lg-size" /> size = posts
|
| 441 |
+
</span>
|
| 442 |
+
{mode === "recency" ? (
|
| 443 |
+
<span>
|
| 444 |
+
<i className="lg-bright" /> brighter = written about recently
|
| 445 |
+
</span>
|
| 446 |
+
) : (
|
| 447 |
+
<>
|
| 448 |
+
<span>
|
| 449 |
+
<i className="lg-tail" /> rising
|
| 450 |
+
</span>
|
| 451 |
+
<span>
|
| 452 |
+
<i className="lg-dormant" /> dormant
|
| 453 |
+
</span>
|
| 454 |
+
</>
|
| 455 |
+
)}
|
| 456 |
+
<span>
|
| 457 |
+
<i className="lg-line" /> lines = shared posts
|
| 458 |
+
</span>
|
| 459 |
+
</div>
|
| 460 |
+
|
| 461 |
<svg
|
| 462 |
ref={svgRef}
|
| 463 |
viewBox={`${view.x} ${view.y} ${view.w} ${view.h}`}
|
|
|
|
| 465 |
aria-label="Topic constellation. Plus and minus zoom, arrow keys pan, 0 resets."
|
| 466 |
tabIndex={0}
|
| 467 |
className="map-svg"
|
| 468 |
+
style={{ aspectRatio: `${W} / ${H}` }}
|
| 469 |
{...handlers}
|
| 470 |
onKeyDown={(e) => {
|
| 471 |
const step = 0.12;
|
|
|
|
| 481 |
}}
|
| 482 |
>
|
| 483 |
<defs>
|
| 484 |
+
<radialGradient id="star-glow" cx="50%" cy="50%" r="50%">
|
| 485 |
+
<stop offset="0%" stopColor="#ffffff" stopOpacity="0.9" />
|
| 486 |
+
<stop offset="30%" stopColor="#217cff" stopOpacity="0.55" />
|
| 487 |
<stop offset="100%" stopColor="#217cff" stopOpacity="0" />
|
| 488 |
</radialGradient>
|
| 489 |
+
<radialGradient id="nebula" cx="50%" cy="50%" r="50%">
|
| 490 |
+
<stop offset="0%" stopColor="#217cff" stopOpacity="0.16" />
|
| 491 |
+
<stop offset="60%" stopColor="#217cff" stopOpacity="0.06" />
|
| 492 |
+
<stop offset="100%" stopColor="#217cff" stopOpacity="0" />
|
| 493 |
+
</radialGradient>
|
| 494 |
+
<linearGradient id="comet" x1="0" y1="0" x2="1" y2="0">
|
| 495 |
+
<stop offset="0%" stopColor="#ffffff" stopOpacity="0.85" />
|
| 496 |
+
<stop offset="100%" stopColor="#217cff" stopOpacity="0" />
|
| 497 |
+
</linearGradient>
|
| 498 |
</defs>
|
| 499 |
+
|
| 500 |
+
<DeepField />
|
| 501 |
+
|
| 502 |
+
{nebulae.map((g) => (
|
| 503 |
+
<g key={g.id} className="nebula" onClick={() => flyToGalaxy(g.id)}>
|
| 504 |
+
<ellipse cx={g.cx} cy={g.cy} rx={g.rx} ry={g.ry} fill="url(#nebula)" />
|
| 505 |
+
<text
|
| 506 |
+
x={g.cx}
|
| 507 |
+
y={g.cy - g.ry + 18 * scale}
|
| 508 |
+
textAnchor="middle"
|
| 509 |
+
className="galaxy-label"
|
| 510 |
+
style={{ fontSize: 11 * scale, letterSpacing: 3 * scale }}
|
| 511 |
+
>
|
| 512 |
+
{g.name.toUpperCase()} GALAXY
|
| 513 |
+
</text>
|
| 514 |
+
</g>
|
| 515 |
+
))}
|
| 516 |
+
|
| 517 |
{data.edges.map((e) => {
|
| 518 |
const a = positions.get(e.source);
|
| 519 |
const b = positions.get(e.target);
|
| 520 |
if (!a || !b) return null;
|
| 521 |
const lit = focus && (e.source === focus || e.target === focus);
|
| 522 |
+
const strength = e.weight / maxEdge;
|
| 523 |
return (
|
| 524 |
<line
|
| 525 |
key={`${e.source}-${e.target}`}
|
|
|
|
| 527 |
y1={a.y}
|
| 528 |
x2={b.x}
|
| 529 |
y2={b.y}
|
| 530 |
+
className="constellation"
|
| 531 |
stroke={lit ? "#217cff" : "#ffffff"}
|
| 532 |
+
strokeOpacity={lit ? 0.95 : focus ? 0.04 : 0.08 + strength * 0.3}
|
| 533 |
+
strokeWidth={(lit ? 1.4 + strength * 2 : 0.6 + strength * 1.8) * scale}
|
| 534 |
+
strokeDasharray={strength < 0.25 && !lit ? `${3 * scale} ${4 * scale}` : undefined}
|
| 535 |
/>
|
| 536 |
);
|
| 537 |
})}
|
| 538 |
+
|
| 539 |
{data.nodes.map((n) => {
|
| 540 |
const p = positions.get(n.id)!;
|
| 541 |
const dim = focus && focus !== n.id && !neighbors.get(focus)?.has(n.id);
|
| 542 |
const active = selected?.id === n.id;
|
| 543 |
+
const color = starColor(n);
|
| 544 |
+
const glow = starGlow(n);
|
| 545 |
+
const showLabel =
|
| 546 |
+
zoomedIn || brightest.has(n.id) || n.post_count >= 3 || focus === n.id || active || Boolean(focus && neighbors.get(focus)?.has(n.id));
|
| 547 |
+
const tail = mode === "momentum" && n.status === "rising" ? Math.min(90, 30 + n.momentum * 18) : 0;
|
| 548 |
return (
|
| 549 |
<g
|
| 550 |
key={n.id}
|
| 551 |
className="map-node"
|
| 552 |
+
opacity={dim ? 0.2 : 1}
|
| 553 |
onMouseEnter={() => setHover(n.id)}
|
| 554 |
onMouseLeave={() => setHover(null)}
|
| 555 |
onClick={() => open(n.id)}
|
| 556 |
onDoubleClick={() => open(n.id, true)}
|
| 557 |
tabIndex={0}
|
| 558 |
role="button"
|
| 559 |
+
aria-label={`${n.name}, ${n.post_count} posts, ${STATUS_LABEL[n.status]}`}
|
| 560 |
onKeyDown={(e) => (e.key === "Enter" || e.key === " ") && open(n.id)}
|
| 561 |
>
|
| 562 |
+
{tail > 0 && (
|
| 563 |
+
<path
|
| 564 |
+
d={`M ${p.x} ${p.y - p.r * 0.35} L ${p.x + tail} ${p.y - tail * 0.45} L ${p.x + p.r * 0.35} ${p.y + p.r * 0.1} Z`}
|
| 565 |
+
fill="url(#comet)"
|
| 566 |
+
opacity={0.8}
|
| 567 |
+
/>
|
| 568 |
+
)}
|
| 569 |
+
<circle cx={p.x} cy={p.y} r={p.r * 2.1} fill="url(#star-glow)" opacity={active ? 1 : glow} />
|
| 570 |
+
{brightest.has(n.id) && (
|
| 571 |
+
<g className="spikes" stroke={color} strokeOpacity={0.55} strokeWidth={1 * scale}>
|
| 572 |
+
<line x1={p.x - p.r * 2.4} y1={p.y} x2={p.x + p.r * 2.4} y2={p.y} />
|
| 573 |
+
<line x1={p.x} y1={p.y - p.r * 2.4} x2={p.x} y2={p.y + p.r * 2.4} />
|
| 574 |
+
</g>
|
| 575 |
+
)}
|
| 576 |
+
{mode === "momentum" && n.status === "dormant" && (
|
| 577 |
+
<circle
|
| 578 |
+
cx={p.x}
|
| 579 |
+
cy={p.y}
|
| 580 |
+
r={p.r * 0.9 + 4}
|
| 581 |
+
fill="none"
|
| 582 |
+
stroke="#ffffff"
|
| 583 |
+
strokeOpacity={0.5}
|
| 584 |
+
strokeWidth={1 * scale}
|
| 585 |
+
strokeDasharray={`${2 * scale} ${3 * scale}`}
|
| 586 |
+
/>
|
| 587 |
+
)}
|
| 588 |
+
<circle cx={p.x} cy={p.y} r={Math.max(2.5, p.r * 0.42)} fill={color} />
|
| 589 |
+
{active && <circle cx={p.x} cy={p.y} r={p.r + 12} className="orbit-ring" strokeWidth={1.2 * scale} />}
|
| 590 |
+
{showLabel && (
|
| 591 |
+
<text
|
| 592 |
+
x={p.x}
|
| 593 |
+
y={p.y + p.r + 18 * scale}
|
| 594 |
+
textAnchor="middle"
|
| 595 |
+
className="map-label"
|
| 596 |
+
style={{ fontSize: 12.5 * scale, strokeWidth: 4 * scale }}
|
| 597 |
+
>
|
| 598 |
+
{n.name}
|
| 599 |
+
{(zoomedIn || active) && (
|
| 600 |
+
<tspan className="map-count" dx={6 * scale}>
|
| 601 |
+
{n.post_count}
|
| 602 |
+
</tspan>
|
| 603 |
+
)}
|
| 604 |
+
</text>
|
| 605 |
+
)}
|
| 606 |
</g>
|
| 607 |
);
|
| 608 |
})}
|
| 609 |
</svg>
|
| 610 |
</div>
|
| 611 |
+
|
| 612 |
<aside className="card map-side">
|
| 613 |
{!selected && (
|
| 614 |
<>
|
| 615 |
+
<p className="eyebrow">Mission briefing</p>
|
| 616 |
+
<p className="muted small">
|
| 617 |
+
{data.archive.posts} posts from {formatDate(data.archive.start)} to {formatDate(data.archive.end)}. Recent means since{" "}
|
| 618 |
+
{formatDate(data.archive.recent_from)}.
|
| 619 |
+
</p>
|
| 620 |
+
|
| 621 |
+
<section className="brief">
|
| 622 |
+
<h3>Galaxies</h3>
|
| 623 |
+
<ul className="brief-list">
|
| 624 |
+
{data.galaxies
|
| 625 |
+
.filter((g) => g.size > 1)
|
| 626 |
+
.slice(0, 6)
|
| 627 |
+
.map((g) => (
|
| 628 |
+
<li key={g.id}>
|
| 629 |
+
<button className="link-btn" onClick={() => flyToGalaxy(g.id)}>
|
| 630 |
+
{g.name}
|
| 631 |
+
</button>
|
| 632 |
+
<span className="mono muted">
|
| 633 |
+
{g.size} topics · {g.post_count} posts
|
| 634 |
+
</span>
|
| 635 |
+
</li>
|
| 636 |
+
))}
|
| 637 |
+
{galaxyCount === 0 && <li className="muted small">No clusters yet. Map more posts.</li>}
|
| 638 |
+
</ul>
|
| 639 |
+
</section>
|
| 640 |
+
|
| 641 |
+
<section className="brief">
|
| 642 |
+
<h3>Rising</h3>
|
| 643 |
+
<ul className="brief-list">
|
| 644 |
+
{data.insights.rising.map((r) => (
|
| 645 |
+
<li key={r.id}>
|
| 646 |
+
<button className="link-btn" onClick={() => open(r.id, true)}>
|
| 647 |
+
{r.name}
|
| 648 |
+
</button>
|
| 649 |
+
<span className="mono brief-up">{r.momentum.toFixed(1)}x</span>
|
| 650 |
+
</li>
|
| 651 |
+
))}
|
| 652 |
+
{data.insights.rising.length === 0 && <li className="muted small">Nothing is picking up speed right now.</li>}
|
| 653 |
+
</ul>
|
| 654 |
+
</section>
|
| 655 |
+
|
| 656 |
+
<section className="brief">
|
| 657 |
+
<h3>Dormant</h3>
|
| 658 |
+
<ul className="brief-list">
|
| 659 |
+
{data.insights.dormant.map((d) => (
|
| 660 |
+
<li key={d.id}>
|
| 661 |
+
<button className="link-btn" onClick={() => open(d.id, true)}>
|
| 662 |
+
{d.name}
|
| 663 |
+
</button>
|
| 664 |
+
<span className="mono muted">since {formatDate(d.last_at)}</span>
|
| 665 |
+
</li>
|
| 666 |
+
))}
|
| 667 |
+
{data.insights.dormant.length === 0 && <li className="muted small">Every topic has been visited lately.</li>}
|
| 668 |
+
</ul>
|
| 669 |
+
{data.insights.dormant.length > 0 && (
|
| 670 |
+
<Link to="/app/resurface" className="small-link mono">
|
| 671 |
+
Resurface old posts
|
| 672 |
+
</Link>
|
| 673 |
+
)}
|
| 674 |
+
</section>
|
| 675 |
+
|
| 676 |
+
<section className="brief">
|
| 677 |
+
<h3>Strongest pairings</h3>
|
| 678 |
+
<ul className="brief-list">
|
| 679 |
+
{data.insights.pairs.map((p) => (
|
| 680 |
+
<li key={`${p.a_id}-${p.b_id}`}>
|
| 681 |
+
<span>
|
| 682 |
+
<button className="link-btn" onClick={() => open(p.a_id, true)}>
|
| 683 |
+
{p.a}
|
| 684 |
+
</button>
|
| 685 |
+
<span className="muted"> + </span>
|
| 686 |
+
<button className="link-btn" onClick={() => open(p.b_id, true)}>
|
| 687 |
+
{p.b}
|
| 688 |
+
</button>
|
| 689 |
+
</span>
|
| 690 |
+
<span className="mono muted">{p.posts} posts</span>
|
| 691 |
+
</li>
|
| 692 |
+
))}
|
| 693 |
+
</ul>
|
| 694 |
+
</section>
|
| 695 |
</>
|
| 696 |
)}
|
| 697 |
+
|
| 698 |
{selected && (
|
| 699 |
<>
|
| 700 |
+
<button className="link-btn mono muted small" onClick={() => setSelected(null)}>
|
| 701 |
+
Back to briefing
|
| 702 |
</button>
|
| 703 |
+
<div className="row between">
|
| 704 |
+
<h2>{selected.name}</h2>
|
| 705 |
+
<span className={`pill mono status-${selected.status}`}>{STATUS_LABEL[selected.status]}</span>
|
| 706 |
+
</div>
|
| 707 |
{selected.summary && <p className="muted">{selected.summary}</p>}
|
| 708 |
+
|
| 709 |
+
<dl className="star-stats">
|
| 710 |
+
<div>
|
| 711 |
+
<dt>Posts</dt>
|
| 712 |
+
<dd>{selected.post_count}</dd>
|
| 713 |
+
</div>
|
| 714 |
+
<div>
|
| 715 |
+
<dt>Recent</dt>
|
| 716 |
+
<dd>{selected.recent_posts}</dd>
|
| 717 |
+
</div>
|
| 718 |
+
<div>
|
| 719 |
+
<dt>First</dt>
|
| 720 |
+
<dd>{formatDate(selected.first_at)}</dd>
|
| 721 |
+
</div>
|
| 722 |
+
<div>
|
| 723 |
+
<dt>Last</dt>
|
| 724 |
+
<dd>{formatDate(selected.last_at)}</dd>
|
| 725 |
+
</div>
|
| 726 |
+
</dl>
|
| 727 |
+
<p className="mono muted small">Momentum: {momentumText(selected.momentum)}</p>
|
| 728 |
+
|
| 729 |
+
<div className="timeline">
|
| 730 |
+
<Sparkline values={selected.timeline} height={44} highlightFrom={recentBucket} />
|
| 731 |
+
<div className="row between mono muted small">
|
| 732 |
+
<span>{formatDate(selected.timeline_start)}</span>
|
| 733 |
+
<span>{formatDate(selected.timeline_end)}</span>
|
| 734 |
+
</div>
|
| 735 |
+
</div>
|
| 736 |
+
|
| 737 |
+
{selected.related.length > 0 && (
|
| 738 |
+
<div className="stack">
|
| 739 |
+
<span className="mono muted small">Travels with</span>
|
| 740 |
+
<div className="chips">
|
| 741 |
+
{selected.related.map((r) => (
|
| 742 |
+
<button key={r.id} className="chip chip-btn" onClick={() => open(r.id, true)} title={`${r.shared_posts} shared posts`}>
|
| 743 |
+
{r.name} <span className="mono">{r.shared_posts}</span>
|
| 744 |
+
</button>
|
| 745 |
+
))}
|
| 746 |
+
</div>
|
| 747 |
+
</div>
|
| 748 |
+
)}
|
| 749 |
+
|
| 750 |
<button
|
| 751 |
className="btn btn-small btn-primary"
|
| 752 |
onClick={async () => {
|