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"
|
"use client"
|
||||||
|
|
||||||
import { useEffect, useState } from "react"
|
/**
|
||||||
import { Card } from "@/components/ui/card"
|
* TelegramThreadCard — live mirror of the Telegram conversation with Tiger.
|
||||||
import { Send } from "lucide-react"
|
*
|
||||||
|
* 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 {
|
import { useCallback, useEffect, useRef, useState } from "react"
|
||||||
role: string
|
import { Card } from "@/components/ui/card"
|
||||||
content: string
|
import { Send, ChevronUp, RefreshCw } from "lucide-react"
|
||||||
timestamp: number
|
|
||||||
meta?: Record<string, unknown>
|
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() {
|
export function TelegramThreadCard() {
|
||||||
const [messages, setMessages] = useState<TelegramMessage[]>([])
|
const [messages, setMessages] = useState<ThreadMessage[]>([])
|
||||||
|
const [hasMore, setHasMore] = useState(false)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [loadingOlder, setLoadingOlder] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
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(() => {
|
useEffect(() => {
|
||||||
fetch("/api/chat/history?limit=5")
|
fetchPage()
|
||||||
.then(r => r.json())
|
.then((data) => {
|
||||||
.then(data => {
|
if (data.ok && data.messages) {
|
||||||
if (data?.messages) {
|
setMessages(data.messages)
|
||||||
setMessages(data.messages.slice(-5).reverse())
|
setHasMore(Boolean(data.hasMore))
|
||||||
|
} else {
|
||||||
|
setError(data.error || "No data")
|
||||||
}
|
}
|
||||||
setLoading(false)
|
|
||||||
})
|
})
|
||||||
.catch(e => {
|
.catch((e: Error) => setError(e.message))
|
||||||
console.error("Failed to load:", e)
|
.finally(() => setLoading(false))
|
||||||
setError(e.message)
|
}, [fetchPage])
|
||||||
setLoading(false)
|
|
||||||
|
// 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 onScroll = () => {
|
||||||
const formatTime = (ts: number) => {
|
const el = scrollRef.current
|
||||||
if (!ts) return ""
|
if (!el) return
|
||||||
const diff = Date.now() - ts
|
stickToBottom.current =
|
||||||
const mins = Math.floor(diff / 60000)
|
el.scrollHeight - el.scrollTop - el.clientHeight < 40
|
||||||
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()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Simple truncate
|
const loadOlder = async () => {
|
||||||
const truncate = (text: string, max = 40) => {
|
if (loadingOlder || messages.length === 0) return
|
||||||
if (!text) return ""
|
setLoadingOlder(true)
|
||||||
return text.length > max ? text.slice(0, max) + "..." : text
|
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 (
|
return (
|
||||||
<Card className="bg-card/40 p-4 flex flex-col">
|
<Card className="bg-card/40 p-4 flex flex-col">
|
||||||
<div className="flex items-center gap-2 mb-3">
|
<div className="flex items-center gap-2 mb-3">
|
||||||
<Send className="h-4 w-4 text-primary" />
|
<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>
|
||||||
<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>
|
<span className="text-sm text-muted-foreground">Loading...</span>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
)}
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error) {
|
{error && (
|
||||||
return (
|
<div className="flex-1 flex items-center justify-center h-80">
|
||||||
<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">
|
|
||||||
<span className="text-sm text-red-500">Error: {error}</span>
|
<span className="text-sm text-red-500">Error: {error}</span>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
)}
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
{!loading && !error && (
|
||||||
<Card className="bg-card/40 p-4 flex flex-col">
|
<div
|
||||||
<div className="flex items-center justify-between mb-3">
|
ref={scrollRef}
|
||||||
<div className="flex items-center gap-2">
|
onScroll={onScroll}
|
||||||
<Send className="h-4 w-4 text-primary" />
|
className="h-80 overflow-y-auto pr-1 flex flex-col gap-2"
|
||||||
<span className="text-[11px] uppercase tracking-wider text-muted-foreground/80">Chat history</span>
|
>
|
||||||
</div>
|
{hasMore && (
|
||||||
<a href="/chat?session=telegram" className="text-xs text-primary hover:underline">Open chat →</a>
|
<button
|
||||||
</div>
|
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 ? (
|
{messages.length === 0 && (
|
||||||
<ul className="space-y-2 flex-1">
|
<div className="flex-1 flex items-center justify-center">
|
||||||
{messages.map((msg, i) => (
|
<span className="text-sm text-muted-foreground">
|
||||||
<li key={i} className="text-sm">
|
No messages yet
|
||||||
<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)}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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>
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue