feat: add chat group daily summarizer
Deploy Dashboard / deploy (push) Failing after 38s

- Telegram collector (Telethon MTProto) with checkpoint-based message fetching
- Claude API summarization via proxy (haiku model)
- Dashboard page with date picker, markdown summary, raw message drill-down
- Separate container lifecycle from dashboard for stable Telethon sessions
- Shared SQLite DB mounted read-only into dashboard
This commit is contained in:
Gan, Jimmy
2026-02-27 02:51:52 +08:00
parent 7f94013bf9
commit c676c84bc1
14 changed files with 441 additions and 1 deletions
+3
View File
@@ -41,5 +41,8 @@ WEBAUTHN_ORIGINS = os.environ.get("WEBAUTHN_ORIGIN", "https://nas.jimmygan.com,h
# CORS
CORS_ORIGINS = os.environ.get("CORS_ORIGINS", "https://nas.jimmygan.com,https://nas.jimmygan.com:8443").split(",")
# Chat Summary
CHAT_SUMMARY_DB = os.environ.get("CHAT_SUMMARY_DB", "/app/data/chat-summarizer/chat_summary.db")
# OpenClaw
OPENCLAW_GATEWAY_TOKEN = os.environ.get("OPENCLAW_GATEWAY_TOKEN", "")
+2 -1
View File
@@ -5,7 +5,7 @@ from fastapi.responses import JSONResponse
from slowapi import Limiter
from slowapi.util import get_remote_address
from slowapi.errors import RateLimitExceeded
from routers import docker_router, gitea, files, terminal, system, auth
from routers import docker_router, gitea, files, terminal, system, auth, chat_summary
import auth as auth_module
import asyncio
@@ -139,6 +139,7 @@ app.include_router(docker_router.router, prefix="/api/docker", dependencies=[Dep
app.include_router(gitea.router, prefix="/api/gitea", dependencies=[Depends(auth_module.get_current_user)])
app.include_router(files.router, prefix="/api/files", dependencies=[Depends(auth_module.get_current_user)])
app.include_router(system.router, prefix="/api/system", dependencies=[Depends(auth_module.get_current_user)])
app.include_router(chat_summary.router, prefix="/api/chat-summary", dependencies=[Depends(auth_module.get_current_user)])
# WebSocket endpoint (auth handled inside handler via Query param)
app.add_api_websocket_route("/ws/terminal", terminal.ws_endpoint)
+41
View File
@@ -0,0 +1,41 @@
from fastapi import APIRouter, HTTPException
import aiosqlite
from config import CHAT_SUMMARY_DB
router = APIRouter()
async def _db():
return aiosqlite.connect(CHAT_SUMMARY_DB)
@router.get("/dates")
async def list_dates():
async with await _db() as db:
cursor = await db.execute("SELECT date FROM summaries ORDER BY date DESC")
return [r[0] for r in await cursor.fetchall()]
@router.get("/summary/{date}")
async def get_summary(date: str):
async with await _db() as db:
cursor = await db.execute("SELECT content, created_at FROM summaries WHERE date=?", (date,))
row = await cursor.fetchone()
if not row:
raise HTTPException(404, "No summary for this date")
return {"date": date, "content": row[0], "created_at": row[1]}
@router.get("/messages/{date}")
async def get_messages(date: str):
async with await _db() as db:
cursor = await db.execute(
"SELECT group_name, sender_name, text, timestamp FROM messages WHERE date(timestamp)=? ORDER BY timestamp",
(date,)
)
return [{"group": r[0], "sender": r[1], "text": r[2], "time": r[3]} for r in await cursor.fetchall()]
@router.post("/trigger")
async def trigger_summary():
import os
trigger_path = os.environ.get("CHAT_SUMMARY_TRIGGER", "/app/data/chat-summarizer/trigger")
os.makedirs(os.path.dirname(trigger_path), exist_ok=True)
with open(trigger_path, "w") as f:
f.write("1")
return {"status": "triggered"}
+1
View File
@@ -45,6 +45,7 @@ services:
- /volume1/docker/nas-dashboard/ssh/dashboard_terminal:/app/ssh/id_ed25519:ro
- /volume1/docker/nas-dashboard/ssh/vps_terminal:/app/ssh/vps_id_ed25519:ro
- /volume1/docker/nas-dashboard/ssh/known_hosts:/app/ssh/known_hosts:ro
- /volume1/docker/chat-summarizer/data:/app/data/chat-summarizer:ro
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:4000/api/health')"]
interval: 30s
+3
View File
@@ -7,6 +7,7 @@
import Terminal from "./routes/Terminal.svelte";
import OpenClaw from "./routes/OpenClaw.svelte";
import Settings from "./routes/Settings.svelte";
import ChatSummary from "./routes/ChatSummary.svelte";
import Login from "./routes/Login.svelte";
import { onMount } from "svelte";
import { getToken, checkAuth, setToken, setRefreshToken } from "./lib/api.js";
@@ -92,6 +93,8 @@
<Terminal />
{:else if page === "openclaw"}
<OpenClaw />
{:else if page === "chat-digest"}
<ChatSummary />
{:else if page === "settings"}
<Settings />
{/if}
@@ -27,6 +27,7 @@
const tools = [
{ id: "openclaw", label: "OpenClaw", icon: "openclaw" },
{ id: "chat-digest", label: "Chat Digest", icon: "chat" },
{ id: "gitea", label: "Repos", icon: "git" },
{ label: "Gitea Web", port: 3300, icon: "git", external: true },
{ label: "Stirling PDF", port: 8030, icon: "pdf", external: true },
@@ -133,6 +134,8 @@
<span class="w-5 h-5 flex items-center justify-center">
{#if item.icon === "openclaw"}
<svg class="w-[16px] h-[16px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z" /></svg>
{:else if item.icon === "chat"}
<svg class="w-[16px] h-[16px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M17 8h2a2 2 0 012 2v6a2 2 0 01-2 2h-2v4l-4-4H9a2 2 0 01-2-2v-1m0-3V6a2 2 0 012-2h8a2 2 0 012 2v3a2 2 0 01-2 2H9l-4 4V9z" /></svg>
{:else if item.icon === "git"}
<svg class="w-[16px] h-[16px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" /></svg>
{:else if item.icon === "pdf"}
@@ -160,6 +163,8 @@
<span class="w-5 h-5 flex items-center justify-center">
{#if item.icon === "openclaw"}
<svg class="w-[18px] h-[18px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z" /></svg>
{:else if item.icon === "chat"}
<svg class="w-[18px] h-[18px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M17 8h2a2 2 0 012 2v6a2 2 0 01-2 2h-2v4l-4-4H9a2 2 0 01-2-2v-1m0-3V6a2 2 0 012-2h8a2 2 0 012 2v3a2 2 0 01-2 2H9l-4 4V9z" /></svg>
{:else if item.icon === "git"}
<svg class="w-[18px] h-[18px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" /></svg>
{/if}
@@ -0,0 +1,125 @@
<script>
import { onMount } from "svelte";
import { get, post } from "../lib/api.js";
let dates = $state([]);
let selectedDate = $state("");
let summary = $state(null);
let messages = $state([]);
let showMessages = $state(false);
let loading = $state(false);
let triggering = $state(false);
let error = $state("");
onMount(async () => {
try {
dates = await get("/api/chat-summary/dates");
if (dates.length) selectDate(dates[0]);
} catch (e) {
error = e.body?.detail || "Failed to load dates";
}
});
async function selectDate(d) {
selectedDate = d;
showMessages = false;
loading = true;
error = "";
try {
summary = await get(`/api/chat-summary/summary/${d}`);
} catch (e) {
summary = null;
if (e.status !== 404) error = e.body?.detail || "Failed to load summary";
} finally {
loading = false;
}
}
async function loadMessages() {
if (showMessages) { showMessages = false; return; }
try {
messages = await get(`/api/chat-summary/messages/${selectedDate}`);
showMessages = true;
} catch (e) {
error = "Failed to load messages";
}
}
async function trigger() {
triggering = true;
try {
await post("/api/chat-summary/trigger");
} catch (e) {
error = "Trigger failed";
} finally {
triggering = false;
}
}
</script>
<div>
<div class="flex items-center justify-between mb-6">
<h2 class="text-xl font-bold text-surface-800 dark:text-surface-100">Chat Digest</h2>
<button onclick={trigger} disabled={triggering}
class="px-3 py-1.5 text-sm font-medium rounded-lg bg-primary-600 text-white hover:bg-primary-700 disabled:opacity-50">
{triggering ? "Generating..." : "Generate Now"}
</button>
</div>
{#if error}
<div class="mb-4 p-3 rounded-lg bg-red-50 text-red-700 text-sm dark:bg-red-900/20 dark:text-red-400">{error}</div>
{/if}
<div class="flex gap-2 mb-6 flex-wrap">
{#each dates as d}
<button onclick={() => selectDate(d)}
class="px-3 py-1.5 text-sm rounded-lg transition-colors {selectedDate === d ? 'bg-primary-600 text-white' : 'bg-surface-100 text-surface-600 hover:bg-surface-200 dark:bg-surface-700 dark:text-surface-300 dark:hover:bg-surface-600'}">
{d}
</button>
{/each}
{#if !dates.length && !error}
<p class="text-sm text-surface-400">No summaries yet.</p>
{/if}
</div>
{#if loading}
<div class="flex justify-center py-12"><div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div></div>
{:else if summary}
<div class="prose dark:prose-invert max-w-none bg-white dark:bg-surface-800 rounded-xl p-6 shadow-sm border border-surface-200 dark:border-surface-700">
{@html markdownToHtml(summary.content)}
</div>
<button onclick={loadMessages}
class="mt-4 text-sm text-primary-600 hover:text-primary-700 dark:text-primary-400">
{showMessages ? "Hide raw messages" : "Show raw messages"}
</button>
{#if showMessages}
<div class="mt-3 bg-white dark:bg-surface-800 rounded-xl p-4 shadow-sm border border-surface-200 dark:border-surface-700 max-h-96 overflow-y-auto">
{#each messages as m}
<div class="py-1.5 border-b border-surface-100 dark:border-surface-700 last:border-0 text-sm">
<span class="text-surface-400 text-xs">{m.time?.slice(11,16)}</span>
<span class="font-medium text-primary-600 dark:text-primary-400 ml-1">[{m.group}] {m.sender}:</span>
<span class="text-surface-700 dark:text-surface-300 ml-1">{m.text}</span>
</div>
{/each}
</div>
{/if}
{/if}
</div>
<script>
function markdownToHtml(md) {
if (!md) return "";
return md
.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
.replace(/^### (.+)$/gm, '<h3 class="text-lg font-semibold mt-4 mb-2">$1</h3>')
.replace(/^## (.+)$/gm, '<h2 class="text-xl font-bold mt-5 mb-2">$1</h2>')
.replace(/^# (.+)$/gm, '<h1 class="text-2xl font-bold mt-6 mb-3">$1</h1>')
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.+?)\*/g, '<em>$1</em>')
.replace(/^- (.+)$/gm, '<li class="ml-4">$1</li>')
.replace(/\n{2,}/g, '<br><br>')
.replace(/\n/g, '<br>');
}
</script>