- 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:
@@ -0,0 +1,6 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
WORKDIR /app
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
COPY . .
|
||||||
|
CMD ["python", "-u", "main.py"]
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import os
|
||||||
|
import logging
|
||||||
|
import aiosqlite
|
||||||
|
from telethon import TelegramClient
|
||||||
|
from db import DB_PATH, init_db
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
API_ID = int(os.environ.get("TG_API_ID", 0))
|
||||||
|
API_HASH = os.environ.get("TG_API_HASH", "")
|
||||||
|
SESSION_NAME = os.environ.get("TG_SESSION_NAME", "chat_summarizer")
|
||||||
|
GROUP_IDS = [int(g) for g in os.environ.get("TG_GROUP_IDS", "").split(",") if g.strip()]
|
||||||
|
|
||||||
|
def get_client():
|
||||||
|
return TelegramClient(f"/app/data/{SESSION_NAME}", API_ID, API_HASH)
|
||||||
|
|
||||||
|
async def auth():
|
||||||
|
"""Interactive one-time auth. Run: docker exec -it chat-summarizer python3 -c 'import asyncio; from collector import auth; asyncio.run(auth())'"""
|
||||||
|
client = get_client()
|
||||||
|
await client.start()
|
||||||
|
me = await client.get_me()
|
||||||
|
print(f"Logged in as {me.first_name} (id={me.id})")
|
||||||
|
await client.disconnect()
|
||||||
|
|
||||||
|
async def collect_messages():
|
||||||
|
if not GROUP_IDS:
|
||||||
|
log.warning("No TG_GROUP_IDS configured, skipping collection")
|
||||||
|
return
|
||||||
|
|
||||||
|
client = get_client()
|
||||||
|
await client.connect()
|
||||||
|
if not await client.is_user_authorized():
|
||||||
|
log.error("Not authorized — run interactive auth first")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with aiosqlite.connect(DB_PATH) as db:
|
||||||
|
for gid in GROUP_IDS:
|
||||||
|
try:
|
||||||
|
# Get checkpoint
|
||||||
|
cursor = await db.execute("SELECT last_msg_id FROM checkpoints WHERE group_id=?", (gid,))
|
||||||
|
row = await cursor.fetchone()
|
||||||
|
min_id = row[0] if row else 0
|
||||||
|
|
||||||
|
entity = await client.get_entity(gid)
|
||||||
|
group_name = getattr(entity, 'title', str(gid))
|
||||||
|
count = 0
|
||||||
|
|
||||||
|
async for msg in client.iter_messages(entity, min_id=min_id, limit=1000):
|
||||||
|
if not msg.text:
|
||||||
|
continue
|
||||||
|
sender = ""
|
||||||
|
if msg.sender:
|
||||||
|
sender = getattr(msg.sender, 'first_name', '') or getattr(msg.sender, 'title', '') or ''
|
||||||
|
last = getattr(msg.sender, 'last_name', '')
|
||||||
|
if last:
|
||||||
|
sender += f" {last}"
|
||||||
|
|
||||||
|
await db.execute(
|
||||||
|
"INSERT INTO messages (group_id, group_name, sender_name, text, msg_id, timestamp) VALUES (?,?,?,?,?,?)",
|
||||||
|
(gid, group_name, sender, msg.text, msg.id, msg.date.isoformat())
|
||||||
|
)
|
||||||
|
count += 1
|
||||||
|
|
||||||
|
if count > 0:
|
||||||
|
# Update checkpoint to max msg_id
|
||||||
|
cursor = await db.execute("SELECT MAX(msg_id) FROM messages WHERE group_id=?", (gid,))
|
||||||
|
max_row = await cursor.fetchone()
|
||||||
|
if max_row and max_row[0]:
|
||||||
|
await db.execute(
|
||||||
|
"INSERT OR REPLACE INTO checkpoints (group_id, last_msg_id) VALUES (?,?)",
|
||||||
|
(gid, max_row[0])
|
||||||
|
)
|
||||||
|
log.info("Collected %d messages from %s", count, group_name)
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
except Exception as e:
|
||||||
|
log.error("Error collecting from group %s: %s", gid, e)
|
||||||
|
finally:
|
||||||
|
await client.disconnect()
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import aiosqlite
|
||||||
|
import os
|
||||||
|
|
||||||
|
DB_PATH = os.environ.get("DB_PATH", "/app/data/chat_summary.db")
|
||||||
|
|
||||||
|
async def init_db():
|
||||||
|
async with aiosqlite.connect(DB_PATH) as db:
|
||||||
|
await db.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS messages (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
group_id INTEGER NOT NULL,
|
||||||
|
group_name TEXT,
|
||||||
|
sender_name TEXT,
|
||||||
|
text TEXT,
|
||||||
|
msg_id INTEGER,
|
||||||
|
timestamp DATETIME NOT NULL
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
await db.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS summaries (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
date TEXT UNIQUE NOT NULL,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
await db.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS checkpoints (
|
||||||
|
group_id INTEGER PRIMARY KEY,
|
||||||
|
last_msg_id INTEGER NOT NULL
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
await db.commit()
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
services:
|
||||||
|
chat-summarizer:
|
||||||
|
build: .
|
||||||
|
container_name: chat-summarizer
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- /volume1/docker/chat-summarizer/data:/app/data
|
||||||
|
environment:
|
||||||
|
- TG_API_ID=${TG_API_ID}
|
||||||
|
- TG_API_HASH=${TG_API_HASH}
|
||||||
|
- TG_SESSION_NAME=${TG_SESSION_NAME:-chat_summarizer}
|
||||||
|
- TG_GROUP_IDS=${TG_GROUP_IDS}
|
||||||
|
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
|
||||||
|
- ANTHROPIC_BASE_URL=${ANTHROPIC_BASE_URL:-https://www.bytecatcode.org}
|
||||||
|
- SUMMARY_HOUR=${SUMMARY_HOUR:-23}
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from db import init_db
|
||||||
|
from collector import collect_messages
|
||||||
|
from summarizer import summarize
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(name)s %(levelname)s %(message)s")
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
CST = timezone(timedelta(hours=8))
|
||||||
|
SUMMARY_HOUR = int(os.environ.get("SUMMARY_HOUR", 23))
|
||||||
|
COLLECT_INTERVAL = 300 # 5 minutes
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
await init_db()
|
||||||
|
log.info("Chat summarizer started (summary hour=%d CST)", SUMMARY_HOUR)
|
||||||
|
|
||||||
|
last_summary_date = None
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
await collect_messages()
|
||||||
|
except Exception as e:
|
||||||
|
log.error("Collection error: %s", e)
|
||||||
|
|
||||||
|
now = datetime.now(CST)
|
||||||
|
today = now.strftime("%Y-%m-%d")
|
||||||
|
if now.hour >= SUMMARY_HOUR and last_summary_date != today:
|
||||||
|
try:
|
||||||
|
await summarize(today)
|
||||||
|
last_summary_date = today
|
||||||
|
except Exception as e:
|
||||||
|
log.error("Summary error: %s", e)
|
||||||
|
|
||||||
|
await asyncio.sleep(COLLECT_INTERVAL)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
telethon
|
||||||
|
httpx
|
||||||
|
aiosqlite
|
||||||
|
cryptg
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import os
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
import aiosqlite
|
||||||
|
import httpx
|
||||||
|
from db import DB_PATH
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
API_KEY = os.environ.get("ANTHROPIC_API_KEY", "")
|
||||||
|
BASE_URL = os.environ.get("ANTHROPIC_BASE_URL", "https://www.bytecatcode.org")
|
||||||
|
MODEL = "claude-haiku-4-5-20251001"
|
||||||
|
CST = timezone(timedelta(hours=8))
|
||||||
|
|
||||||
|
PROMPT = """You are a chat group digest assistant. Summarize the following chat messages into a concise daily digest.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- Group by topic/theme, not by chat group
|
||||||
|
- Highlight key decisions, shared links, and action items
|
||||||
|
- Skip greetings, small talk, and low-value chatter
|
||||||
|
- Use markdown formatting
|
||||||
|
- Write in the same language as the messages (likely Chinese)
|
||||||
|
- Be concise
|
||||||
|
|
||||||
|
Messages:
|
||||||
|
{messages}"""
|
||||||
|
|
||||||
|
async def summarize(date_str: str = None):
|
||||||
|
now = datetime.now(CST)
|
||||||
|
if not date_str:
|
||||||
|
date_str = now.strftime("%Y-%m-%d")
|
||||||
|
|
||||||
|
start = datetime.strptime(date_str, "%Y-%m-%d").replace(tzinfo=CST)
|
||||||
|
end = start + timedelta(days=1)
|
||||||
|
|
||||||
|
async with aiosqlite.connect(DB_PATH) as db:
|
||||||
|
cursor = await db.execute(
|
||||||
|
"SELECT group_name, sender_name, text, timestamp FROM messages WHERE timestamp >= ? AND timestamp < ? ORDER BY timestamp",
|
||||||
|
(start.isoformat(), end.isoformat())
|
||||||
|
)
|
||||||
|
rows = await cursor.fetchall()
|
||||||
|
|
||||||
|
if not rows:
|
||||||
|
log.info("No messages for %s, skipping summary", date_str)
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Format messages for the prompt
|
||||||
|
formatted = []
|
||||||
|
for group_name, sender, text, ts in rows:
|
||||||
|
formatted.append(f"[{group_name}] {sender}: {text}")
|
||||||
|
messages_text = "\n".join(formatted)
|
||||||
|
|
||||||
|
# Call Claude API
|
||||||
|
async with httpx.AsyncClient(timeout=120) as client:
|
||||||
|
resp = await client.post(
|
||||||
|
f"{BASE_URL}/v1/messages",
|
||||||
|
headers={"x-api-key": API_KEY, "anthropic-version": "2023-06-01", "content-type": "application/json"},
|
||||||
|
json={
|
||||||
|
"model": MODEL,
|
||||||
|
"max_tokens": 4096,
|
||||||
|
"messages": [{"role": "user", "content": PROMPT.format(messages=messages_text)}]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
summary = data["content"][0]["text"]
|
||||||
|
|
||||||
|
# Store summary
|
||||||
|
async with aiosqlite.connect(DB_PATH) as db:
|
||||||
|
await db.execute(
|
||||||
|
"INSERT OR REPLACE INTO summaries (date, content, created_at) VALUES (?,?,?)",
|
||||||
|
(date_str, summary, now.isoformat())
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
log.info("Generated summary for %s (%d messages)", date_str, len(rows))
|
||||||
|
return summary
|
||||||
@@ -41,5 +41,8 @@ WEBAUTHN_ORIGINS = os.environ.get("WEBAUTHN_ORIGIN", "https://nas.jimmygan.com,h
|
|||||||
# CORS
|
# CORS
|
||||||
CORS_ORIGINS = os.environ.get("CORS_ORIGINS", "https://nas.jimmygan.com,https://nas.jimmygan.com:8443").split(",")
|
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
|
||||||
OPENCLAW_GATEWAY_TOKEN = os.environ.get("OPENCLAW_GATEWAY_TOKEN", "")
|
OPENCLAW_GATEWAY_TOKEN = os.environ.get("OPENCLAW_GATEWAY_TOKEN", "")
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from fastapi.responses import JSONResponse
|
|||||||
from slowapi import Limiter
|
from slowapi import Limiter
|
||||||
from slowapi.util import get_remote_address
|
from slowapi.util import get_remote_address
|
||||||
from slowapi.errors import RateLimitExceeded
|
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 auth as auth_module
|
||||||
|
|
||||||
import asyncio
|
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(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(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(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)
|
# WebSocket endpoint (auth handled inside handler via Query param)
|
||||||
app.add_api_websocket_route("/ws/terminal", terminal.ws_endpoint)
|
app.add_api_websocket_route("/ws/terminal", terminal.ws_endpoint)
|
||||||
|
|||||||
@@ -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"}
|
||||||
@@ -45,6 +45,7 @@ services:
|
|||||||
- /volume1/docker/nas-dashboard/ssh/dashboard_terminal:/app/ssh/id_ed25519:ro
|
- /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/vps_terminal:/app/ssh/vps_id_ed25519:ro
|
||||||
- /volume1/docker/nas-dashboard/ssh/known_hosts:/app/ssh/known_hosts:ro
|
- /volume1/docker/nas-dashboard/ssh/known_hosts:/app/ssh/known_hosts:ro
|
||||||
|
- /volume1/docker/chat-summarizer/data:/app/data/chat-summarizer:ro
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:4000/api/health')"]
|
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:4000/api/health')"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
import Terminal from "./routes/Terminal.svelte";
|
import Terminal from "./routes/Terminal.svelte";
|
||||||
import OpenClaw from "./routes/OpenClaw.svelte";
|
import OpenClaw from "./routes/OpenClaw.svelte";
|
||||||
import Settings from "./routes/Settings.svelte";
|
import Settings from "./routes/Settings.svelte";
|
||||||
|
import ChatSummary from "./routes/ChatSummary.svelte";
|
||||||
import Login from "./routes/Login.svelte";
|
import Login from "./routes/Login.svelte";
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
import { getToken, checkAuth, setToken, setRefreshToken } from "./lib/api.js";
|
import { getToken, checkAuth, setToken, setRefreshToken } from "./lib/api.js";
|
||||||
@@ -92,6 +93,8 @@
|
|||||||
<Terminal />
|
<Terminal />
|
||||||
{:else if page === "openclaw"}
|
{:else if page === "openclaw"}
|
||||||
<OpenClaw />
|
<OpenClaw />
|
||||||
|
{:else if page === "chat-digest"}
|
||||||
|
<ChatSummary />
|
||||||
{:else if page === "settings"}
|
{:else if page === "settings"}
|
||||||
<Settings />
|
<Settings />
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@
|
|||||||
|
|
||||||
const tools = [
|
const tools = [
|
||||||
{ id: "openclaw", label: "OpenClaw", icon: "openclaw" },
|
{ id: "openclaw", label: "OpenClaw", icon: "openclaw" },
|
||||||
|
{ id: "chat-digest", label: "Chat Digest", icon: "chat" },
|
||||||
{ id: "gitea", label: "Repos", icon: "git" },
|
{ id: "gitea", label: "Repos", icon: "git" },
|
||||||
{ label: "Gitea Web", port: 3300, icon: "git", external: true },
|
{ label: "Gitea Web", port: 3300, icon: "git", external: true },
|
||||||
{ label: "Stirling PDF", port: 8030, icon: "pdf", 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">
|
<span class="w-5 h-5 flex items-center justify-center">
|
||||||
{#if item.icon === "openclaw"}
|
{#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>
|
<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"}
|
{: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>
|
<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"}
|
{:else if item.icon === "pdf"}
|
||||||
@@ -160,6 +163,8 @@
|
|||||||
<span class="w-5 h-5 flex items-center justify-center">
|
<span class="w-5 h-5 flex items-center justify-center">
|
||||||
{#if item.icon === "openclaw"}
|
{#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>
|
<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"}
|
{: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>
|
<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}
|
{/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, "&").replace(/</g, "<").replace(/>/g, ">")
|
||||||
|
.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>
|
||||||
Reference in New Issue
Block a user