From 9e45780ba0bd13e280c0251613fa7afdb244fcc5 Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Thu, 7 May 2026 21:00:06 -0500 Subject: [PATCH] feat(web): community site for deepseek-tui.com (mobile + color refresh) (#1108) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First commit of the Next.js community site that powers deepseek-tui.com, deployed via Cloudflare Workers / OpenNext. This commit lands the scaffold and applies the visual + correctness pass requested by community feedback: - Palette: drop the cream/Anthropic-feel paper (#F4F1E8) for a DeepSeek-aligned cool white + soft gray (#FFFFFF / #F4F6FB), with indigo accents kept. Soften default hairlines so a pure-white background reads clean instead of harsh. - Mobile: add a hamburger menu (mobile-menu.tsx) so phones can reach Install / Docs / Activity / Roadmap / Contribute — previously the link list was hidden on phones with no replacement. Tighter hero, flexible button row, viewport-safe code blocks, columnar grids collapse cleanly under 768px, and the printed-almanac center rule is desktop-only now (it sliced through narrow viewports). - "How it works" diagram: replace the hand-rolled ASCII art (which misaligned under CJK monospace because Han characters take 2 columns vs Latin's 1, per dhh's note in WeChat) with a real mermaid diagram rendered client-side via dynamic import. Uses the mermaid.live standard syntax 庄表伟 recommended. - Issue #1104: the docs listed a `deepseek-cn` provider that the v0.8.16 binary doesn't accept (`ProviderArg` in crates/cli only has 9 variants; the 10th lives only in the legacy tui/config.rs). derive-facts.mjs now omits `deepseek-cn` until that variant is wired through the shared ProviderKind, and the install page's China-network recipe uses `base_url` / `DEEPSEEK_BASE_URL` (which actually works on v0.8.16) instead of the unsupported provider. Auto-deploys via .github/workflows/deploy-web.yml on push to main. Co-authored-by: Claude Opus 4.7 (1M context) --- .github/workflows/deploy-web.yml | 76 + web/.env.example | 27 + web/.gitignore | 12 + web/AGENT.md | 121 + web/README.md | 101 + web/app/[locale]/admin/admin-client.tsx | 171 + web/app/[locale]/admin/page.tsx | 136 + web/app/[locale]/contribute/page.tsx | 300 + web/app/[locale]/docs/page.tsx | 498 + web/app/[locale]/feed/page.tsx | 177 + web/app/[locale]/install/page.tsx | 295 + web/app/[locale]/layout.tsx | 54 + web/app/[locale]/page.tsx | 436 + web/app/[locale]/roadmap/page.tsx | 309 + web/app/api/admin/login/route.ts | 57 + web/app/api/admin/logout/route.ts | 42 + web/app/api/admin/post/route.ts | 96 + web/app/api/cron/route.ts | 104 + web/app/api/github/feed/route.ts | 11 + web/app/globals.css | 343 + web/app/icon.svg | 6 + web/app/layout.tsx | 56 + web/components/feed-card.tsx | 59 + web/components/footer.tsx | 143 + web/components/install-tabs.tsx | 331 + web/components/locale-switcher.tsx | 37 + web/components/mermaid-diagram.tsx | 84 + web/components/mobile-menu.tsx | 92 + web/components/nav.tsx | 100 + web/components/seal.tsx | 17 + web/components/stat-grid.tsx | 33 + web/components/ticker.tsx | 31 + web/components/whale.tsx | 25 + web/eslint.config.mjs | 35 + web/lib/community-agent-tasks.ts | 403 + web/lib/community-agent.ts | 320 + web/lib/content-watch.ts | 229 + web/lib/deepseek.ts | 100 + web/lib/facts-drift.ts | 222 + web/lib/facts.generated.ts | 96 + web/lib/facts.ts | 41 + web/lib/github.ts | 153 + web/lib/i18n/config.ts | 10 + web/lib/i18n/dictionaries/en.ts | 58 + web/lib/i18n/dictionaries/zh.ts | 61 + web/lib/i18n/get-dictionary.ts | 10 + web/lib/kv.ts | 58 + web/lib/roadmap-feed.ts | 134 + web/lib/types.ts | 34 + web/middleware.ts | 50 + web/next.config.ts | 21 + web/open-next.config.ts | 6 + web/package-lock.json | 13258 ++++++++++++++++++++++ web/package.json | 36 + web/postcss.config.mjs | 8 + web/scripts/check-kv-id.mjs | 46 + web/scripts/derive-facts.mjs | 172 + web/tailwind.config.ts | 39 + web/tsconfig.json | 23 + web/worker.ts | 37 + web/wrangler.jsonc | 37 + 61 files changed, 20077 insertions(+) create mode 100644 .github/workflows/deploy-web.yml create mode 100644 web/.env.example create mode 100644 web/.gitignore create mode 100644 web/AGENT.md create mode 100644 web/README.md create mode 100644 web/app/[locale]/admin/admin-client.tsx create mode 100644 web/app/[locale]/admin/page.tsx create mode 100644 web/app/[locale]/contribute/page.tsx create mode 100644 web/app/[locale]/docs/page.tsx create mode 100644 web/app/[locale]/feed/page.tsx create mode 100644 web/app/[locale]/install/page.tsx create mode 100644 web/app/[locale]/layout.tsx create mode 100644 web/app/[locale]/page.tsx create mode 100644 web/app/[locale]/roadmap/page.tsx create mode 100644 web/app/api/admin/login/route.ts create mode 100644 web/app/api/admin/logout/route.ts create mode 100644 web/app/api/admin/post/route.ts create mode 100644 web/app/api/cron/route.ts create mode 100644 web/app/api/github/feed/route.ts create mode 100644 web/app/globals.css create mode 100644 web/app/icon.svg create mode 100644 web/app/layout.tsx create mode 100644 web/components/feed-card.tsx create mode 100644 web/components/footer.tsx create mode 100644 web/components/install-tabs.tsx create mode 100644 web/components/locale-switcher.tsx create mode 100644 web/components/mermaid-diagram.tsx create mode 100644 web/components/mobile-menu.tsx create mode 100644 web/components/nav.tsx create mode 100644 web/components/seal.tsx create mode 100644 web/components/stat-grid.tsx create mode 100644 web/components/ticker.tsx create mode 100644 web/components/whale.tsx create mode 100644 web/eslint.config.mjs create mode 100644 web/lib/community-agent-tasks.ts create mode 100644 web/lib/community-agent.ts create mode 100644 web/lib/content-watch.ts create mode 100644 web/lib/deepseek.ts create mode 100644 web/lib/facts-drift.ts create mode 100644 web/lib/facts.generated.ts create mode 100644 web/lib/facts.ts create mode 100644 web/lib/github.ts create mode 100644 web/lib/i18n/config.ts create mode 100644 web/lib/i18n/dictionaries/en.ts create mode 100644 web/lib/i18n/dictionaries/zh.ts create mode 100644 web/lib/i18n/get-dictionary.ts create mode 100644 web/lib/kv.ts create mode 100644 web/lib/roadmap-feed.ts create mode 100644 web/lib/types.ts create mode 100644 web/middleware.ts create mode 100644 web/next.config.ts create mode 100644 web/open-next.config.ts create mode 100644 web/package-lock.json create mode 100644 web/package.json create mode 100644 web/postcss.config.mjs create mode 100644 web/scripts/check-kv-id.mjs create mode 100644 web/scripts/derive-facts.mjs create mode 100644 web/tailwind.config.ts create mode 100644 web/tsconfig.json create mode 100644 web/worker.ts create mode 100644 web/wrangler.jsonc diff --git a/.github/workflows/deploy-web.yml b/.github/workflows/deploy-web.yml new file mode 100644 index 00000000..7d7cdd27 --- /dev/null +++ b/.github/workflows/deploy-web.yml @@ -0,0 +1,76 @@ +name: deploy-web + +# Auto-deploys deepseek-tui.com (Cloudflare Worker) on every push to main that +# touches the web app or any source the site auto-derives facts from. +# +# Required repo secret: +# CLOUDFLARE_API_TOKEN — token with Workers Scripts:Edit + Workers KV:Edit +# + Workers Custom Domain:Edit on this account. +# +# Manual trigger via the Actions tab is also supported. + +on: + push: + branches: [main] + paths: + - "web/**" + - "Cargo.toml" + - "crates/tui/src/config.rs" + - "crates/tui/src/sandbox/**" + - "crates/tui/src/main.rs" + - "npm/deepseek-tui/package.json" + - "CHANGELOG.md" + - ".github/workflows/deploy-web.yml" + workflow_dispatch: + +concurrency: + group: deploy-web + cancel-in-progress: false + +jobs: + deploy: + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + defaults: + run: + working-directory: web + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + cache-dependency-path: web/package-lock.json + + - name: Install dependencies + run: npm ci + + - name: Lint + run: npm run lint + + - name: Build (Next.js) + run: npm run build + + - name: Build (OpenNext for Cloudflare) + run: npx opennextjs-cloudflare build + + - name: Verify KV ids configured + run: npm run predeploy + + - name: Deploy to Cloudflare + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + run: npx wrangler deploy + + - name: Refresh fact drift after deploy + if: success() + env: + CRON_SECRET: ${{ secrets.CRON_SECRET }} + run: | + if [ -n "$CRON_SECRET" ]; then + curl -fsS -H "x-cron-secret: $CRON_SECRET" \ + "https://deepseek-tui.com/api/cron?task=facts-drift" || true + fi diff --git a/web/.env.example b/web/.env.example new file mode 100644 index 00000000..ae4e6a77 --- /dev/null +++ b/web/.env.example @@ -0,0 +1,27 @@ +# Required for the /api/cron routes (DeepSeek summarization + community agent). +DEEPSEEK_API_KEY=sk-your-deepseek-key + +# Optional — raises GitHub API rate limit from 60 to 5000 req/h. +# Use a fine-grained PAT scoped to public repos only. +GITHUB_TOKEN= + +# Override which repo to mirror. Defaults to Hmbown/deepseek-tui. +GITHUB_REPO=Hmbown/deepseek-tui + +# Optional — required to manually invoke /api/cron +# (cloudflare cron triggers don't need this; they set cf-cron). +CRON_SECRET= + +# Optional — defaults to deepseek-v4-flash. +DEEPSEEK_MODEL=deepseek-v4-flash +DEEPSEEK_BASE_URL=https://api.deepseek.com + +# Admin panel auth. Set to a random secret; access /admin?token=. +MAINTAINER_TOKEN= + +# GitHub PAT for posting comments via /admin. Needs issues:write scope. +MAINTAINER_GITHUB_PAT= + +# Set to 1 once the Gitee mirror at gitee.com/Hmbown/... +# exists. Until then leave blank to hide Gitee links. +NEXT_PUBLIC_GITEE_ENABLED= diff --git a/web/.gitignore b/web/.gitignore new file mode 100644 index 00000000..0a91c140 --- /dev/null +++ b/web/.gitignore @@ -0,0 +1,12 @@ +node_modules +.next +.open-next +.wrangler +.env +.env.local +.env.*.local +*.log +.DS_Store +next-env.d.ts +.vercel +tsconfig.tsbuildinfo diff --git a/web/AGENT.md b/web/AGENT.md new file mode 100644 index 00000000..f3bf9afa --- /dev/null +++ b/web/AGENT.md @@ -0,0 +1,121 @@ +# Community Assistant Agent + +The community assistant is a set of Cloudflare Cron Triggers that call `deepseek-v4-flash` to draft triage comments, PR reviews, stale-issue nudges, duplicate suggestions, and weekly digests. **It never posts to GitHub directly.** Every output is a draft staged in Workers KV for maintainer review. + +## Architecture + +``` +Cloudflare Cron Triggers + └─ worker.ts scheduled() handler + ├─ */30 min → triage (new issues) + pr-review (new PRs) + ├─ daily → stale (30d inactive) + dupes (embed-similarity scan) + ├─ weekly → digest (Mon 09:00 UTC) + └─ 6h → curate (Today's Dispatch — pre-existing) + +Drafts stored in Workers KV: + draft:triage: + draft:pr-review: + draft:stale: + draft:dupes: + draft:digest:-W + +Usage logged to: + usage: +``` + +## Cron schedule + +| Expression | Frequency | Tasks | +|---|---|---| +| `0 */6 * * *` | Every 6 hours | Today's Dispatch (curate) | +| `*/30 * * * *` | Every 30 min | Issue triage + PR review | +| `0 0 * * *` | Daily 00:00 UTC | Stale issue nudges + duplicate detection | +| `0 9 * * 1` | Monday 09:00 UTC | Weekly digest | + +## Voice constraints + +All drafts follow these rules: + +- Calm, factual, never breathless. +- Never uses first person plural ("we"/"我们") — the maintainer is one person. +- Never commits to timing, prioritisation, or merge intent. +- Never apologises on the maintainer's behalf. +- Cites specific files / line numbers / linked issues when discussing code. +- Ends with: "— drafted by community assistant, pending maintainer review" +- Chinese drafts end with: "— 由社区助理草拟,待维护者审阅" +- Chinese output is rewritten in zh-CN, not machine-translated. + +## Cost guardrails + +- Each cron invocation caps at ~30k input tokens and ~2k output tokens. +- Issue/PR bodies are truncated to 1000–4000 chars before sending to the model. +- Deduplication: `hasFreshDraft` checks if a draft already exists that's newer than the item's `updated_at`. Skips if so. +- Token usage is logged to `usage:` KV keys (retained 90 days). +- If `DEEPSEEK_API_KEY` is missing or the API errors, the cron returns 200 with `{ skipped: true, reason }` — never crashes, never retry-loops. + +## Maintainer review surface + +Access at `/admin?token=`. + +- Lists all pending drafts with source link, draft body, and three actions: + - **Post as comment** — calls GitHub REST API using `MAINTAINER_GITHUB_PAT` + - **Edit & post** — opens a textarea for editing before posting + - **Discard** — removes the draft from KV +- The auth token is set via `MAINTAINER_TOKEN` env var. Access sets an `mt` cookie for the session. +- **Nothing posts to GitHub without an explicit maintainer click.** + +## Environment variables + +| Variable | Required | Purpose | +|---|---|---| +| `DEEPSEEK_API_KEY` | Yes | DeepSeek API key for the community agent | +| `GITHUB_TOKEN` | Optional | Fine-grained PAT for GitHub API (raises rate limit) | +| `CRON_SECRET` | Optional | Shared secret for manual cron invocation | +| `MAINTAINER_TOKEN` | Optional | Auth token for /admin panel | +| `MAINTAINER_GITHUB_PAT` | Optional | GitHub PAT with `issues:write` scope for posting comments | + +## Initial deployment + +One-time setup before the first `npm run deploy`: + +1. **Create the KV namespaces:** + ```bash + npx wrangler kv namespace create CURATED_KV + npx wrangler kv namespace create NEXT_INC_CACHE_KV + ``` + Copy the returned `id` values and paste them into the matching + `wrangler.jsonc` bindings, replacing each `"REPLACE_WITH_KV_ID"`. + +2. **Set secrets:** + ```bash + npx wrangler secret put DEEPSEEK_API_KEY + npx wrangler secret put MAINTAINER_TOKEN + npx wrangler secret put MAINTAINER_GITHUB_PAT + npx wrangler secret put CRON_SECRET + ``` + +3. **(Optional) Raise GitHub rate limit:** + ```bash + npx wrangler secret put GITHUB_TOKEN + ``` + +4. **Verify:** + ```bash + npm run predeploy # checks KV ID is set + npm run deploy # builds + deploys + ``` + +## Kill switch + +To disable the community agent entirely: + +1. Remove all cron triggers from `wrangler.jsonc` except the original `0 */6 * * *` (curate). +2. Redeploy: `npm run deploy`. + +The curate cron (Today's Dispatch) continues working independently. Individual tasks remain callable manually for testing through `/api/cron?task=triage`, `/api/cron?task=pr-review`, etc. + +To disable a specific cron task, remove its cron expression from `wrangler.jsonc` and redeploy. + +## Bilingual output + +Every draft contains both `bodyEn` (English) and `bodyZh` (Chinese zh-CN). The admin panel shows the version matching the current locale. The zh version is rewritten natively by the model, not translated from English. diff --git a/web/README.md b/web/README.md new file mode 100644 index 00000000..64ca3d93 --- /dev/null +++ b/web/README.md @@ -0,0 +1,101 @@ +# deepseek-tui-web + +Community site for [deepseek-tui](https://github.com/Hmbown/deepseek-tui) — lives at **deepseek-tui.com**. + +Next.js 15 (App Router) + Tailwind, deployed to Cloudflare Workers via [`@opennextjs/cloudflare`](https://opennext.js.org/cloudflare). Curated "Today's Dispatch" content is regenerated every 6 hours by a Cloudflare Cron Trigger that calls `deepseek-v4-flash` to summarise recent repo activity, and stored in Workers KV. + +## Local dev + +```bash +cd web +npm install +cp .env.example .env.local # fill in the keys you have +npm run dev # http://localhost:3000 +``` + +Required env (only for the curator + private-repo rate limits): + +| Variable | What | Required? | +| ------------------- | ------------------------------------------------- | -------------------- | +| `DEEPSEEK_API_KEY` | DeepSeek platform key (`sk-...`) | only for `/api/cron?task=curate` | +| `GITHUB_TOKEN` | Fine-grained PAT, public-repo read scope | optional (raises rate limit) | +| `GITHUB_REPO` | Defaults to `Hmbown/deepseek-tui` | optional | +| `CRON_SECRET` | Shared secret for manual cron invocation | optional | + +The site renders fine without any of them — `Today's Dispatch` falls back to a static editorial; the GitHub feed shows "feed not yet loaded". + +## Deploy to Cloudflare + +You already own `deepseek-tui.com` on Cloudflare and have a Workers Paid plan. The deploy is two steps: + +1. **Provision KV namespaces once:** + + ```bash + npx wrangler kv namespace create CURATED_KV + npx wrangler kv namespace create NEXT_INC_CACHE_KV + ``` + + Copy the printed `id` values into the matching `wrangler.jsonc` bindings + (replace each `REPLACE_WITH_KV_ID`). + +2. **Set secrets and deploy:** + + ```bash + npx wrangler secret put DEEPSEEK_API_KEY + npx wrangler secret put GITHUB_TOKEN # optional + npx wrangler secret put CRON_SECRET # optional, for manual /api/cron?task=curate hits + + npm run deploy # builds with OpenNext + uploads + ``` + +3. **Point the domain:** in the Cloudflare dashboard, add a Worker route for `deepseek-tui.com/*` → `deepseek-tui-web` (the deploy command will offer this if the zone is already on your account). + +The first cron run happens within 6 hours; you can also kick it manually: + +```bash +curl -H "x-cron-secret: $CRON_SECRET" "https://deepseek-tui.com/api/cron?task=curate" +``` + +## What's where + +``` +web/ +├── app/ +│ ├── layout.tsx root layout, font loading +│ ├── page.tsx home — hero, dispatch, stats, how-it-works, join +│ ├── globals.css design system: paper grain, hairlines, type, seal +│ ├── install/page.tsx per-OS install with auto-detection +│ ├── docs/page.tsx modes / tools / approval / config / mcp / providers +│ ├── feed/page.tsx live mirror of issues + PRs +│ ├── roadmap/page.tsx shipped / underway / considered / ruled out +│ ├── contribute/page.tsx how to PR + house rules + dev loop +│ └── api/ +│ ├── cron/route.ts manual cron trigger: GitHub → DeepSeek → KV +│ └── github/feed/route.ts cached JSON endpoint +├── components/ +│ ├── nav.tsx sticky header w/ date strip + CJK accents +│ ├── footer.tsx dense 5-column footer +│ ├── seal.tsx red Chinese-seal mark used as section anchor +│ ├── ticker.tsx animated live activity strip +│ ├── stat-grid.tsx tabular repo stats row +│ ├── feed-card.tsx one issue/PR card +│ └── install-tabs.tsx client component, OS auto-detect + copy +├── lib/ +│ ├── types.ts shared types +│ ├── github.ts REST client + relative-time formatter +│ ├── deepseek.ts v4-flash chat client + curate() prompt +│ └── kv.ts Cloudflare KV access via OpenNext bindings +├── wrangler.jsonc CF Worker config + cron + KV binding +├── open-next.config.ts OpenNext adapter config +└── tailwind.config.ts design tokens +``` + +## Aesthetic + +"Yamen tech": Qing memorial document × WeChat news feed × Bloomberg terminal. + +- **Palette**: cream paper `#FAF6EE`, ink `#0A2540`, cinnabar red `#C8102E`, aged gold, jade green, cobalt blue. +- **Type**: Fraunces (display), IBM Plex Sans (body), JetBrains Mono (UI/code), Noto Serif SC (decorative CJK anchors). +- **Structure**: hairline 1px dividers, multi-column grids, big tabular numbers, surgical use of red for "hot" markers, decorative Chinese-seal squares as section anchors. + +If you want to retune the palette, edit `:root` in `app/globals.css` and the `colors` block in `tailwind.config.ts`. diff --git a/web/app/[locale]/admin/admin-client.tsx b/web/app/[locale]/admin/admin-client.tsx new file mode 100644 index 00000000..2c47f263 --- /dev/null +++ b/web/app/[locale]/admin/admin-client.tsx @@ -0,0 +1,171 @@ +"use client"; + +import { useState } from "react"; +import type { AgentDraft } from "@/lib/community-agent"; + +interface Props { + drafts: AgentDraft[]; + posted: AgentDraft[]; + isZh: boolean; + typeLabels: Record; +} + +export function AdminClient({ drafts, posted, isZh, typeLabels }: Props) { + const [items, setItems] = useState(drafts); + const [postedItems, setPostedItems] = useState(posted); + const [editing, setEditing] = useState(null); + const [editBody, setEditBody] = useState(""); + const [loading, setLoading] = useState(null); + + const handleAction = async (draftKey: string, action: "post" | "discard", editedBody?: string) => { + setLoading(draftKey); + try { + const res = await fetch("/api/admin/post", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ action, draftKey, editedBody, lang: isZh ? "zh" : "en" }), + }); + const data = await res.json(); + if (data.ok) { + if (action === "discard") { + setItems((prev) => prev.filter((d) => `draft:${d.type}:${d.id}` !== draftKey)); + } else if (action === "post") { + const posted = items.find((d) => `draft:${d.type}:${d.id}` === draftKey); + if (posted) { + setItems((prev) => prev.filter((d) => `draft:${d.type}:${d.id}` !== draftKey)); + setPostedItems((prev) => [{ ...posted, posted: true }, ...prev]); + } + } + setEditing(null); + } else { + alert(`Error: ${data.error}`); + } + } catch (e) { + alert(`Network error: ${e}`); + } finally { + setLoading(null); + } + }; + + const startEdit = (draft: AgentDraft) => { + const key = `draft:${draft.type}:${draft.id}`; + setEditing(key); + setEditBody(draft.bodyEn); + }; + + return ( + <> + {/* Pending drafts */} + {items.length > 0 && ( +
+

+ {isZh ? "待审阅" : "Pending"} ({items.length}) +

+ {items.map((draft) => { + const key = `draft:${draft.type}:${draft.id}`; + const label = typeLabels[draft.type] ?? { en: draft.type, zh: draft.type }; + return ( +
+
+
+ + {isZh ? label.zh : label.en} + + {draft.targetNumber && ( + #{draft.targetNumber} + )} +
+ + {new Date(draft.generatedAt).toISOString().slice(0, 16)} + +
+ +
+ {editing === key ? ( +
+