feat(dashboard): live Telegram thread mirror on homepage
- /api/chat/telegram-thread proxy (token stays server-side) - TelegramThreadCard rewrite: scrollable full history, 'Load older' cursor pagination, 15s polling with referential-equality short-circuit, scroll-anchored prepends, auto-stick to bottom only when already there
This commit is contained in:
parent
438488c8c8
commit
572418f0ea
2 changed files with 224 additions and 92 deletions
34
dashboard/src/app/api/chat/telegram-thread/route.ts
Normal file
34
dashboard/src/app/api/chat/telegram-thread/route.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
/**
|
||||
* /api/chat/telegram-thread — proxy for the bridge's Telegram mirror.
|
||||
*
|
||||
* GET ?limit=50&before=<seq>
|
||||
* Same proxy pattern as /api/chat/history: the bridge bearer token stays on
|
||||
* the server, the browser only ever talks to this route.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
const BRIDGE_URL = process.env.TIGER_BRIDGE_URL || "http://localhost:3456";
|
||||
const BRIDGE_TOKEN = process.env.TIGER_BRIDGE_TOKEN || "";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const limit = request.nextUrl.searchParams.get("limit") || "50";
|
||||
const before = request.nextUrl.searchParams.get("before") || "";
|
||||
const qs = new URLSearchParams({ limit });
|
||||
if (before) qs.set("before", before);
|
||||
|
||||
try {
|
||||
const r = await fetch(`${BRIDGE_URL}/tiger/chat/telegram?${qs.toString()}`, {
|
||||
headers: { Authorization: `Bearer ${BRIDGE_TOKEN}` },
|
||||
cache: "no-store",
|
||||
});
|
||||
const data = await r.json();
|
||||
return NextResponse.json(data, { status: r.status });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return NextResponse.json(
|
||||
{ ok: false, error: "Bridge unreachable", details: message },
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,120 +1,218 @@
|
|||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { Card } from "@/components/ui/card"
|
||||
import { Send } from "lucide-react"
|
||||
/**
|
||||
* TelegramThreadCard — live mirror of the Telegram conversation with Tiger.
|
||||
*
|
||||
* Data source: /api/chat/telegram-thread → bridge /tiger/chat/telegram, which
|
||||
* reads OpenClaw's native session transcript (the same file Tiger's context
|
||||
* comes from). Both directions, full history, in sync by construction.
|
||||
*
|
||||
* Behaviour:
|
||||
* - loads the newest page, scrolled to the bottom (like Telegram itself)
|
||||
* - "Load older" at the top pages backwards through the entire history
|
||||
* - polls for new messages every 15s; only repaints when something changed
|
||||
* - preserves scroll position when older messages are prepended
|
||||
*/
|
||||
|
||||
interface TelegramMessage {
|
||||
role: string
|
||||
content: string
|
||||
timestamp: number
|
||||
meta?: Record<string, unknown>
|
||||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
import { Card } from "@/components/ui/card"
|
||||
import { Send, ChevronUp, RefreshCw } from "lucide-react"
|
||||
|
||||
interface ThreadMessage {
|
||||
seq: number
|
||||
role: "user" | "agent"
|
||||
text: string
|
||||
timestamp: string
|
||||
}
|
||||
|
||||
interface ThreadResponse {
|
||||
ok: boolean
|
||||
messages?: ThreadMessage[]
|
||||
hasMore?: boolean
|
||||
error?: string
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 40
|
||||
const POLL_MS = 15_000
|
||||
|
||||
export function TelegramThreadCard() {
|
||||
const [messages, setMessages] = useState<TelegramMessage[]>([])
|
||||
const [messages, setMessages] = useState<ThreadMessage[]>([])
|
||||
const [hasMore, setHasMore] = useState(false)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [loadingOlder, setLoadingOlder] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
// Tracks whether the user is parked at the bottom — only then do we
|
||||
// auto-scroll on new messages, so reading history is never interrupted.
|
||||
const stickToBottom = useRef(true)
|
||||
|
||||
const fetchPage = useCallback(
|
||||
async (before?: number): Promise<ThreadResponse> => {
|
||||
const qs = new URLSearchParams({ limit: String(PAGE_SIZE) })
|
||||
if (before) qs.set("before", String(before))
|
||||
const r = await fetch(`/api/chat/telegram-thread?${qs.toString()}`)
|
||||
return r.json()
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
// Initial load
|
||||
useEffect(() => {
|
||||
fetch("/api/chat/history?limit=5")
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data?.messages) {
|
||||
setMessages(data.messages.slice(-5).reverse())
|
||||
fetchPage()
|
||||
.then((data) => {
|
||||
if (data.ok && data.messages) {
|
||||
setMessages(data.messages)
|
||||
setHasMore(Boolean(data.hasMore))
|
||||
} else {
|
||||
setError(data.error || "No data")
|
||||
}
|
||||
setLoading(false)
|
||||
})
|
||||
.catch(e => {
|
||||
console.error("Failed to load:", e)
|
||||
setError(e.message)
|
||||
setLoading(false)
|
||||
.catch((e: Error) => setError(e.message))
|
||||
.finally(() => setLoading(false))
|
||||
}, [fetchPage])
|
||||
|
||||
// Poll for new messages
|
||||
useEffect(() => {
|
||||
const t = setInterval(() => {
|
||||
fetchPage()
|
||||
.then((data) => {
|
||||
if (!data.ok || !data.messages) return
|
||||
setMessages((prev) => {
|
||||
const newest = data.messages!
|
||||
if (
|
||||
prev.length > 0 &&
|
||||
newest.length > 0 &&
|
||||
prev[prev.length - 1].seq === newest[newest.length - 1].seq
|
||||
) {
|
||||
return prev // nothing new — keep referential equality, no repaint
|
||||
}
|
||||
// Merge: keep any older pages we already loaded, append the fresh tail.
|
||||
const known = new Set(prev.map((m) => m.seq))
|
||||
const fresh = newest.filter((m) => !known.has(m.seq))
|
||||
return fresh.length > 0 ? [...prev, ...fresh] : prev
|
||||
})
|
||||
}, [])
|
||||
})
|
||||
.catch(() => { /* transient poll errors are fine — next tick retries */ })
|
||||
}, POLL_MS)
|
||||
return () => clearInterval(t)
|
||||
}, [fetchPage])
|
||||
|
||||
const hasData = messages.length > 0
|
||||
// Auto-scroll to bottom on new tail messages (only if user was at bottom)
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current
|
||||
if (el && stickToBottom.current) {
|
||||
el.scrollTop = el.scrollHeight
|
||||
}
|
||||
}, [messages])
|
||||
|
||||
// Simple timestamp formatter
|
||||
const formatTime = (ts: number) => {
|
||||
if (!ts) return ""
|
||||
const diff = Date.now() - ts
|
||||
const mins = Math.floor(diff / 60000)
|
||||
if (mins < 1) return "just now"
|
||||
if (mins < 60) return `${mins}m ago`
|
||||
const hours = Math.floor(mins / 60)
|
||||
if (hours < 24) return `${hours}h ago`
|
||||
return new Date(ts).toLocaleDateString()
|
||||
const onScroll = () => {
|
||||
const el = scrollRef.current
|
||||
if (!el) return
|
||||
stickToBottom.current =
|
||||
el.scrollHeight - el.scrollTop - el.clientHeight < 40
|
||||
}
|
||||
|
||||
// Simple truncate
|
||||
const truncate = (text: string, max = 40) => {
|
||||
if (!text) return ""
|
||||
return text.length > max ? text.slice(0, max) + "..." : text
|
||||
const loadOlder = async () => {
|
||||
if (loadingOlder || messages.length === 0) return
|
||||
setLoadingOlder(true)
|
||||
const el = scrollRef.current
|
||||
const prevHeight = el?.scrollHeight ?? 0
|
||||
try {
|
||||
const data = await fetchPage(messages[0].seq)
|
||||
if (data.ok && data.messages && data.messages.length > 0) {
|
||||
setMessages((prev) => [...data.messages!, ...prev])
|
||||
setHasMore(Boolean(data.hasMore))
|
||||
// Keep the viewport anchored on the message the user was reading.
|
||||
requestAnimationFrame(() => {
|
||||
if (el) el.scrollTop = el.scrollHeight - prevHeight
|
||||
})
|
||||
} else {
|
||||
setHasMore(false)
|
||||
}
|
||||
} finally {
|
||||
setLoadingOlder(false)
|
||||
}
|
||||
}
|
||||
|
||||
const formatTime = (iso: string) => {
|
||||
if (!iso) return ""
|
||||
const d = new Date(iso)
|
||||
const now = new Date()
|
||||
const sameDay = d.toDateString() === now.toDateString()
|
||||
return sameDay
|
||||
? d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
|
||||
: d.toLocaleDateString([], { day: "numeric", month: "short" }) +
|
||||
" " +
|
||||
d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card className="bg-card/40 p-4 flex flex-col">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Send className="h-4 w-4 text-primary" />
|
||||
<span className="text-[11px] uppercase tracking-wider text-muted-foreground/80">Telegram thread</span>
|
||||
<span className="text-[11px] uppercase tracking-wider text-muted-foreground/80">
|
||||
Telegram thread
|
||||
</span>
|
||||
{!loading && !error && (
|
||||
<span className="ml-auto text-[10px] text-muted-foreground/60 flex items-center gap-1">
|
||||
<RefreshCw className="h-3 w-3" /> live
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
|
||||
{loading && (
|
||||
<div className="flex-1 flex items-center justify-center h-80">
|
||||
<span className="text-sm text-muted-foreground">Loading...</span>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
)}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Card className="bg-card/40 p-4 flex flex-col">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Send className="h-4 w-4 text-primary" />
|
||||
<span className="text-[11px] uppercase tracking-wider text-muted-foreground/80">Telegram thread</span>
|
||||
</div>
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
{error && (
|
||||
<div className="flex-1 flex items-center justify-center h-80">
|
||||
<span className="text-sm text-red-500">Error: {error}</span>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
)}
|
||||
|
||||
return (
|
||||
<Card className="bg-card/40 p-4 flex flex-col">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Send className="h-4 w-4 text-primary" />
|
||||
<span className="text-[11px] uppercase tracking-wider text-muted-foreground/80">Chat history</span>
|
||||
</div>
|
||||
<a href="/chat?session=telegram" className="text-xs text-primary hover:underline">Open chat →</a>
|
||||
</div>
|
||||
{!loading && !error && (
|
||||
<div
|
||||
ref={scrollRef}
|
||||
onScroll={onScroll}
|
||||
className="h-80 overflow-y-auto pr-1 flex flex-col gap-2"
|
||||
>
|
||||
{hasMore && (
|
||||
<button
|
||||
onClick={loadOlder}
|
||||
disabled={loadingOlder}
|
||||
className="self-center text-[11px] text-muted-foreground hover:text-foreground flex items-center gap-1 py-1 px-2 rounded hover:bg-muted/40 transition-colors"
|
||||
>
|
||||
<ChevronUp className="h-3 w-3" />
|
||||
{loadingOlder ? "Loading..." : "Load older"}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{hasData ? (
|
||||
<ul className="space-y-2 flex-1">
|
||||
{messages.map((msg, i) => (
|
||||
<li key={i} className="text-sm">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="font-medium text-xs">
|
||||
{msg.role === "user" ? "You" : "Tiger"}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{formatTime(msg.timestamp)}
|
||||
{messages.length === 0 && (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
No messages yet
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground/80 truncate">
|
||||
{truncate(msg.content)}
|
||||
)}
|
||||
|
||||
{messages.map((m) => (
|
||||
<div
|
||||
key={m.seq}
|
||||
className={`max-w-[85%] rounded-lg px-3 py-2 text-sm whitespace-pre-wrap break-words ${
|
||||
m.role === "user"
|
||||
? "self-end bg-primary/15 text-foreground"
|
||||
: "self-start bg-muted/50 text-foreground"
|
||||
}`}
|
||||
>
|
||||
<div>{m.text}</div>
|
||||
<div className="mt-1 text-[10px] text-muted-foreground/70 text-right">
|
||||
{m.role === "user" ? "you" : "tiger"} · {formatTime(m.timestamp)}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<div className="flex-1 flex flex-col items-center justify-center text-center py-6 px-2">
|
||||
<Send className="h-8 w-8 text-muted-foreground/30 mb-2" />
|
||||
<p className="text-sm text-muted-foreground">No messages yet.</p>
|
||||
<p className="text-[11px] text-muted-foreground/60 mt-1 max-w-[260px]">
|
||||
Start a conversation to see messages here.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue