Skip to content

Commit 43eaf76

Browse files
author
Alexey Panfilov
committed
feat(adminapi): session dividers + longer display history
The Chat tab now loads up to 200 rows and renders horizontal divider lines between sessions (from /clear or the 4-hour idle break). Old image attachments from previous turns are rendered inline as <img> tags in the bubble so multimodal conversations are visible on page reload. - store: DisplayableStore interface with DisplayHistory(chatID, limit) returning HistoryItem rows. is_reset rows surface as role="break"; image_url parts are extracted into HistoryItem.ImageURLs. - agent: GetDisplayHistory wrapper with fallback to plain GetHistory for the in-memory store. - template: chat-break divider (horizontal line + date pill) rendered between message groups. Break reason humanised (CONTEXT_RESET → "Cleared", IDLE_4H → "4h idle"). Bubble meta now includes the message timestamp.
1 parent 8c3f1ce commit 43eaf76

7 files changed

Lines changed: 238 additions & 11 deletions

File tree

.claude/scheduled_tasks.lock

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{"sessionId":"6cecbd7a-9522-431a-98ae-17f6056c37eb","pid":13602,"acquiredAt":1776707251721}

internal/adminapi/chat.go

Lines changed: 43 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"time"
1212

1313
"telegram-agent/internal/llm"
14+
"telegram-agent/internal/store"
1415
)
1516

1617
// defaultAdminChatID is used when no forward-auth user is present (local dev
@@ -22,6 +23,7 @@ type ChatAgent interface {
2223
Process(ctx context.Context, chatID int64, userMsg llm.Message, onToolCall func(string)) (string, error)
2324
ProcessStream(ctx context.Context, chatID int64, userMsg llm.Message, onToolCall func(string), onChunk func(string)) (string, error)
2425
GetChatHistory(chatID int64) []llm.Message
26+
GetDisplayHistory(chatID int64, limit int) []store.HistoryItem
2527
ClearChatHistory(chatID int64)
2628
PopLastUserTurn(chatID int64) (string, bool)
2729
}
@@ -67,24 +69,44 @@ func routedModel(r interface {
6769
}
6870

6971
type chatMsgView struct {
70-
Role string // "user" or "assistant"
71-
Body string
72+
Role string // "user" | "assistant" | "break"
73+
Body string // message text or break reason
74+
ImageURLs []string // image_url parts rendered as <img> in the bubble
75+
Time string // HH:MM for bubble, date for break markers
76+
BreakDate string // full "2006-01-02 15:04" for dividers
7277
}
7378

7479
type chatData struct {
7580
ActiveTab string
7681
History []chatMsgView
7782
}
7883

84+
// displayHistoryLimit is how many rows the Chat page loads on open. Larger
85+
// than the LLM context window — this is purely for UI scrollback, not
86+
// context. Capped to keep renders quick on slow connections.
87+
const displayHistoryLimit = 200
88+
7989
func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) {
8090
data := chatData{ActiveTab: "chat"}
8191
if s.agent != nil {
8292
chatID := s.chatIDFor(r)
83-
for _, m := range s.agent.GetChatHistory(chatID) {
84-
if m.Role != "user" && m.Role != "assistant" {
93+
for _, it := range s.agent.GetDisplayHistory(chatID, displayHistoryLimit) {
94+
v := chatMsgView{Role: it.Role, Body: it.Content, ImageURLs: it.ImageURLs}
95+
if it.Role == "break" {
96+
v.BreakDate = it.CreatedAt.Local().Format("2006-01-02 15:04")
97+
v.Body = humanizeBreakReason(it.Content)
98+
} else if !it.CreatedAt.IsZero() {
99+
v.Time = it.CreatedAt.Local().Format("15:04")
100+
}
101+
// Skip tool-role rows and empty assistant tool-call placeholders —
102+
// they'd just be visual noise in a human-facing view.
103+
if it.Role == "tool" {
104+
continue
105+
}
106+
if it.Role == "assistant" && v.Body == "" && len(v.ImageURLs) == 0 {
85107
continue
86108
}
87-
data.History = append(data.History, chatMsgView{Role: m.Role, Body: m.Content})
109+
data.History = append(data.History, v)
88110
}
89111
}
90112
w.Header().Set("Content-Type", "text/html; charset=utf-8")
@@ -264,6 +286,22 @@ func (s *Server) handleChatClear(w http.ResponseWriter, r *http.Request) {
264286
_, _ = w.Write([]byte(`<div id="chat-messages" class="chat-messages"></div>`))
265287
}
266288

289+
// humanizeBreakReason turns the internal session-break reason string into a
290+
// short label for the UI divider. Unknown reasons fall through verbatim so
291+
// future break types remain visible without a code change.
292+
func humanizeBreakReason(raw string) string {
293+
switch raw {
294+
case "CONTEXT_RESET":
295+
return "Cleared"
296+
case "IDLE_4H":
297+
return "4h idle"
298+
case "":
299+
return ""
300+
default:
301+
return raw
302+
}
303+
}
304+
267305
// buildUserMessage packs text + any attached images into an llm.Message.
268306
// When images are present we build a multimodal Parts payload; otherwise
269307
// we stick with plain Content so non-vision providers see a simple string.

internal/adminapi/templates/chat.html

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,13 @@
6666

6767
/* Drag-over visual cue on the whole chat wrap. */
6868
.chat-wrap.drop-active { outline: 2px dashed var(--accent, #3b82f6); outline-offset: -4px; }
69+
70+
/* Session break divider — rendered between groups of messages separated
71+
by /clear or the 4-hour idle reset. Flat horizontal line with a small
72+
date pill centred on it. */
73+
.chat-break { display: flex; align-items: center; gap: 0.6rem; margin: 0.75rem 0.25rem; opacity: 0.65; align-self: stretch; }
74+
.chat-break::before, .chat-break::after { content: ''; flex: 1; height: 1px; background: rgba(0,0,0,0.15); }
75+
.chat-break__label { font-size: 0.72em; padding: 0.1em 0.6em; border-radius: 999px; background: rgba(0,0,0,0.06); white-space: nowrap; }
6976
</style>
7077
</head>
7178
<body>
@@ -80,14 +87,26 @@ <h1>Admin</h1>
8087

8188
<div id="chat-messages" class="chat-messages">
8289
{{range .History}}
83-
<div class="chat-msg chat-msg--{{.Role}}">
84-
<div class="chat-msg__meta">{{if eq .Role "user"}}You{{else}}Assistant{{end}}</div>
85-
{{if eq .Role "assistant"}}
86-
<div class="chat-msg__body md" data-md="{{.Body}}"></div>
90+
{{if eq .Role "break"}}
91+
<div class="chat-break"><span class="chat-break__label">{{.BreakDate}}{{if .Body}} &middot; {{.Body}}{{end}}</span></div>
8792
{{else}}
88-
<div class="chat-msg__body">{{.Body}}</div>
93+
<div class="chat-msg chat-msg--{{.Role}}">
94+
<div class="chat-msg__meta">
95+
{{if eq .Role "user"}}You{{else}}Assistant{{end}}
96+
{{if .Time}} &middot; {{.Time}}{{end}}
97+
</div>
98+
{{if .ImageURLs}}
99+
<div class="chat-msg__body">
100+
{{range .ImageURLs}}<img src="{{.}}" alt="attachment">{{end}}
101+
{{if .Body}}<div>{{.Body}}</div>{{end}}
102+
</div>
103+
{{else if eq .Role "assistant"}}
104+
<div class="chat-msg__body md" data-md="{{.Body}}"></div>
105+
{{else}}
106+
<div class="chat-msg__body">{{.Body}}</div>
107+
{{end}}
108+
</div>
89109
{{end}}
90-
</div>
91110
{{end}}
92111
</div>
93112

internal/agent/agent.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,24 @@ func (a *Agent) GetChatHistory(chatID int64) []llm.Message {
120120
return a.store.GetHistory(chatID)
121121
}
122122

123+
// GetDisplayHistory returns UI-oriented history including session-break
124+
// markers and image attachment URLs. Falls back to plain GetHistory when
125+
// the backing store doesn't implement DisplayableStore. `limit` caps the
126+
// number of rows fetched; pass 0 for the store default.
127+
func (a *Agent) GetDisplayHistory(chatID int64, limit int) []store.HistoryItem {
128+
if ds, ok := a.store.(store.DisplayableStore); ok {
129+
return ds.DisplayHistory(chatID, limit)
130+
}
131+
out := make([]store.HistoryItem, 0)
132+
for _, m := range a.store.GetHistory(chatID) {
133+
if m.Role != "user" && m.Role != "assistant" {
134+
continue
135+
}
136+
out = append(out, store.HistoryItem{Role: m.Role, Content: m.Content})
137+
}
138+
return out
139+
}
140+
123141
// ClearChatHistory resets the conversation for the given chat ID.
124142
func (a *Agent) ClearChatHistory(chatID int64) {
125143
a.store.ClearHistory(chatID)

internal/store/postgres.go

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -398,6 +398,71 @@ func (p *Postgres) ClearHistory(chatID int64) {
398398
p.pool.Exec(ctx, `INSERT INTO messages (chat_id, role, content, is_reset) VALUES ($1, 'system', 'CONTEXT_RESET', TRUE)`, chatID) //nolint:errcheck
399399
}
400400

401+
// DisplayHistory returns the last `limit` non-compacted rows including
402+
// is_reset session-break markers. Used by the admin web UI to show the
403+
// full recent conversation with dividers between sessions.
404+
func (p *Postgres) DisplayHistory(chatID int64, limit int) []HistoryItem {
405+
if limit <= 0 {
406+
limit = 200
407+
}
408+
ctx := context.Background()
409+
rows, err := p.pool.Query(ctx, `
410+
SELECT role, content, parts, is_reset, created_at
411+
FROM messages
412+
WHERE chat_id = $1 AND is_compacted = FALSE
413+
ORDER BY id DESC LIMIT $2`,
414+
chatID, limit)
415+
if err != nil {
416+
return nil
417+
}
418+
defer rows.Close()
419+
420+
var out []HistoryItem
421+
for rows.Next() {
422+
var role, content string
423+
var partsJSON *string
424+
var isReset bool
425+
var createdAt time.Time
426+
if err := rows.Scan(&role, &content, &partsJSON, &isReset, &createdAt); err != nil {
427+
continue
428+
}
429+
item := HistoryItem{Content: content, CreatedAt: createdAt}
430+
if isReset {
431+
item.Role = "break"
432+
out = append(out, item)
433+
continue
434+
}
435+
item.Role = role
436+
if partsJSON != nil && *partsJSON != "" {
437+
var parts []llm.ContentPart
438+
if err := json.Unmarshal([]byte(*partsJSON), &parts); err == nil {
439+
var textBuf strings.Builder
440+
for _, pt := range parts {
441+
switch pt.Type {
442+
case "text":
443+
if textBuf.Len() > 0 {
444+
textBuf.WriteByte('\n')
445+
}
446+
textBuf.WriteString(pt.Text)
447+
case "image_url":
448+
if pt.ImageURL != nil && pt.ImageURL.URL != "" {
449+
item.ImageURLs = append(item.ImageURLs, pt.ImageURL.URL)
450+
}
451+
}
452+
}
453+
if content == "" {
454+
item.Content = textBuf.String()
455+
}
456+
}
457+
}
458+
out = append(out, item)
459+
}
460+
for i, j := 0, len(out)-1; i < j; i, j = i+1, j-1 {
461+
out[i], out[j] = out[j], out[i]
462+
}
463+
return out
464+
}
465+
401466
// LastUserMessage returns the most recent user-role row within the current
402467
// session (id > last reset). Used by admin chat for Regenerate / Edit.
403468
func (p *Postgres) LastUserMessage(chatID int64) (int64, string, bool) {

internal/store/sqlite.go

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -304,6 +304,73 @@ func (s *SQLite) ClearHistory(chatID int64) {
304304
s.db.Exec(`INSERT INTO messages (chat_id, role, content, is_reset) VALUES (?, 'system', 'CONTEXT_RESET', 1)`, chatID) //nolint:errcheck
305305
}
306306

307+
// DisplayHistory returns the last `limit` non-compacted rows including
308+
// is_reset session-break markers. Used by the admin web UI to show the
309+
// full recent conversation with dividers between sessions.
310+
func (s *SQLite) DisplayHistory(chatID int64, limit int) []HistoryItem {
311+
if limit <= 0 {
312+
limit = 200
313+
}
314+
rows, err := s.db.Query(`
315+
SELECT role, content, parts, is_reset, created_at
316+
FROM messages
317+
WHERE chat_id = ? AND is_compacted = 0
318+
ORDER BY id DESC LIMIT ?`,
319+
chatID, limit)
320+
if err != nil {
321+
return nil
322+
}
323+
defer rows.Close()
324+
325+
var out []HistoryItem
326+
for rows.Next() {
327+
var role, content string
328+
var partsJSON sql.NullString
329+
var isReset int
330+
var createdAt time.Time
331+
if err := rows.Scan(&role, &content, &partsJSON, &isReset, &createdAt); err != nil {
332+
continue
333+
}
334+
item := HistoryItem{Content: content, CreatedAt: createdAt}
335+
if isReset == 1 {
336+
item.Role = "break"
337+
out = append(out, item)
338+
continue
339+
}
340+
item.Role = role
341+
if partsJSON.Valid && partsJSON.String != "" {
342+
var parts []llm.ContentPart
343+
if err := json.Unmarshal([]byte(partsJSON.String), &parts); err == nil {
344+
// Collapse parts into display content: text parts concatenated,
345+
// image URLs surfaced separately so the template can render them.
346+
var textBuf strings.Builder
347+
for _, p := range parts {
348+
switch p.Type {
349+
case "text":
350+
if textBuf.Len() > 0 {
351+
textBuf.WriteByte('\n')
352+
}
353+
textBuf.WriteString(p.Text)
354+
case "image_url":
355+
if p.ImageURL != nil && p.ImageURL.URL != "" {
356+
item.ImageURLs = append(item.ImageURLs, p.ImageURL.URL)
357+
}
358+
}
359+
}
360+
if content == "" {
361+
item.Content = textBuf.String()
362+
}
363+
}
364+
}
365+
out = append(out, item)
366+
}
367+
// DESC → chronological.
368+
for i, j := 0, len(out)-1; i < j; i, j = i+1, j-1 {
369+
out[i], out[j] = out[j], out[i]
370+
}
371+
return out
372+
}
373+
307374
// LastUserMessage returns the most recent user-role row within the current
308375
// session (id > last reset). Used by admin chat for Regenerate / Edit.
309376
func (s *SQLite) LastUserMessage(chatID int64) (int64, string, bool) {

internal/store/store.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,25 @@ type HistorySnippet struct {
4747
BotText string
4848
}
4949

50+
// HistoryItem is one row returned by DisplayableStore.DisplayHistory — a
51+
// message, an image attachment descriptor, or a session-break marker. The
52+
// admin web UI uses this to render the conversation with <hr/>-style
53+
// dividers between sessions (from /clear or the 4-hour idle break).
54+
type HistoryItem struct {
55+
Role string // "user" | "assistant" | "tool" | "break"
56+
Content string // message text OR break reason
57+
ImageURLs []string // image_url parts (data URIs or http links)
58+
CreatedAt time.Time // for display timestamps + divider labels
59+
}
60+
61+
// DisplayableStore extends Store with a UI-oriented history fetch that
62+
// includes session-break markers and surfaces image attachments.
63+
// Implemented by SQLite and Postgres; the in-memory store skips it.
64+
type DisplayableStore interface {
65+
Store
66+
DisplayHistory(chatID int64, limit int) []HistoryItem
67+
}
68+
5069
// TruncatableStore supports dropping the tail of a conversation. Used by the
5170
// admin web chat to implement Regenerate / Edit. LastUserMessage returns the
5271
// most recent user-role row within the current session (id > last reset).

0 commit comments

Comments
 (0)