This repository has no description

Files
README.md

fray #

Web-based visualizer for Bluesky threads with AI-assisted faction analysis. Paste a thread link plus a description of the debate; fray fetches every reply, has an LLM derive the factions and classify each post, and renders the thread as a navigable graph colored by faction.

A flame war rendered as a force-directed graph: avatar nodes tinted by faction, quote posts as squares

Selecting a post highlights its chain back to the root and shows the whole ancestor conversation as a thread view:

Selected post with its ancestor chain highlighted and shown as a thread

Running it #

Everything runs through the Nix dev shell:

# terminal 1 — backend (FastAPI on :8811)
cd backend
nix develop .. -c uv run uvicorn app.main:app --port 8811

# terminal 2 — frontend (Vite on :5173, proxies /api to :8811)
cd frontend
nix develop .. -c npm run dev

Then open http://localhost:5173.

Bring your own key: the server stores no LLM credentials. Enter any OpenAI-compatible endpoint URL and API key in the form (your browser remembers them in localStorage); the model picker is populated from that endpoint's /v1/models, with free-text entry as a fallback. The "Parallel calls" field controls concurrent classification requests (default 8) — raise it for fast paid endpoints, lower it for local or strictly rate-limited ones; rate-limit responses are retried with backoff either way. Leave the endpoint blank to fetch and explore a thread with no AI analysis — every post lands in a single "Unanalyzed" faction.

If you host this publicly, note that the backend forwards requests to whatever endpoint URL users supply (and proxies avatars from cdn.bsky.app); put it behind auth or network isolation you're comfortable with.

How it works #

  • Fetch — the public Bluesky AppView (getPostThread, no auth). The reply tree is flattened breadth-first so a max_posts cutoff keeps top-of-thread coverage; nodes the AppView truncates get re-fetched individually. Quote posts are fetched too (getQuotes) and enter the graph as children of the post they quote — drawn as squares instead of circles — and then the fetch recurses into them: quote posts that drew replies get their own reply trees walked (highest engagement first) and their quotes fetched, up to three quote-hop levels, under API-call budgets (MAX_QUOTE_FETCHES, MAX_QUOTE_SUBTREES in bluesky.py). Viral quote-dunk branches — often where the real shitstorm lives — are pulled in; drive-by quotes stay leaves.
  • Phase 1: factions — one LLM call with the user context, the root post, and a spread sample of replies derives the faction taxonomy (factions named in your context are used verbatim; "Unclear" is always appended).
  • Phase 2: classification — every post is classified in parallel batches of 30 (4 concurrent), each batch seeing the context, faction definitions, and each post's parent snippet.
  • Streaming — /api/analyze streams NDJSON progress events (fetch counts, faction list, classification progress) and ends with the full result.
  • Cache — threads and analyses are cached as JSON under backend/.cache/, keyed by URL+limits and thread+context+model respectively. "Ignore cache" in the UI bypasses it.
  • Graph — Sigma.js + graphology. Two layouts, toggleable: radial tree (root centered, distance = reply depth) and ForceAtlas2 (seeded from the radial positions, computed in a Web Worker so the graph renders immediately and animates into place). Nodes show author avatars washed with their faction color — the blend happens in the fragment shader, so the texture atlas holds one small thumbnail per author (served resized by the avatar proxy) and theme switches re-tint for free. Node size scales with likes. Click a node for the full post and its ancestor thread, with the chain highlighted in the graph; the author's other posts stay half-dimmed in their faction color so their trail through the argument is visible. Hover legend rows to isolate a faction, click to hide it. Threads with thousands of posts render smoothly — the pipeline was profiled and tuned on a 2,700-post thread.

Sharing #

Export HTML (topbar, after an analysis) downloads a fully self-contained interactive file — the graph viewer plus the analysis data plus embedded avatars (~3-4MB for a big thread). Recipients just open it in a browser: no server, no key, works offline, and it survives the thread being deleted. Load file imports an exported .html (or its bare JSON) back into the live app. Either way the expensive computation is done exactly once.

Faction colors #

Colors come from the validated dataviz categorical palette (light + dark variants). Slot order here is blue, yellow, magenta, green, then orange, aqua, violet, red: the first four were brute-forced against the palette validator as the largest subset passing all-pairs CVD/normal-vision gates in both modes (a node graph compares every color with every other, unlike adjacent-only bar charts). Factions 5+ fall back to the remaining adjacent-validated hues, with the legend and click-to-highlight carrying separation; "Unclear" is a muted gray that never competes with a real faction.