+ )
+}
diff --git a/core/http/react-ui/src/components/ChatsList.jsx b/core/http/react-ui/src/components/ChatsList.jsx
index 5c46093249..28a25dba8f 100644
--- a/core/http/react-ui/src/components/ChatsList.jsx
+++ b/core/http/react-ui/src/components/ChatsList.jsx
@@ -2,6 +2,7 @@ import { useState, useEffect, useRef, useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { groupConversations } from '../utils/homeConversations'
import Icon from './Icon'
+import { knownKind } from '../utils/originChats'
// The chats come from the server list (see menuChats in utils/chatThread.js):
// a name, the model and the times. The names are matched here at once; with
@@ -14,7 +15,9 @@ function matches(chat, query) {
const SEARCH_DELAY_MS = 250
// The chat history: a search box, one block per day, the model that answered
-// and the time, with rename, copy, export and delete on each row. It reads
+// and the time, with rename, copy, export and delete on each row. A chat that
+// no person started (a trigger, a connector, a job or a page) carries a badge
+// with the name of what started it (originTitle) or the name of its kind. It reads
// like the Home "Jump back in" list. Two frames show it: the Ctrl or Cmd K
// dialog of a transcript (ChatsMenu) and the All chats page.
//
@@ -32,6 +35,7 @@ export default function ChatsList({
onExport,
onCopyChat,
onSearch,
+ originTitle,
onEscape,
autoFocus = false,
idPrefix = 'chats',
@@ -188,7 +192,8 @@ export default function ChatsList({
const idx = n++
const selected = idx === activeIdx
const editing = editingId === chat.id
- const preview = snippets[chat.id] || (chat.origin ? t(`menu.origin.${chat.origin}`, chat.origin) : '')
+ const preview = snippets[chat.id] || ''
+ const originKind = chat.origin ? (knownKind(chat.origin)) : ''
return (
menuChats(chat.chats).filter(c => c.id !== pendingDelete?.id),
[chat.chats, pendingDelete],
)
- // The sidebar's recent chats follow this list.
- useEffect(() => { announceChats(visibleChats) }, [visibleChats])
+ // The sidebar's recent chats follow this list, without the chats that no
+ // person started: they have their own area (All chats, second tab).
+ useEffect(() => { announceChats(visibleChats.filter(c => !c.origin)) }, [visibleChats])
+ // What started a chat is named by the connector and trigger lists. They are
+ // read only when this page shows a chat that something else started.
+ const hasAutomated = visibleChats.some(c => c.origin) || Boolean(activeChat?.origin)
+ const originCtx = useOriginContext(hasAutomated)
+ const originTitle = (c) => resolveOrigin({ origin: c.origin, originId: c.originId }, originCtx).title
const promptDeleteAll = () => setConfirmDialog({
title: t('deleteAllDialog.title'),
@@ -709,7 +717,8 @@ function ChatPage() {
danger: true,
onConfirm: async () => {
setConfirmDialog(null)
- for (const c of visibleChats) {
+ // Clear all removes the person's own chats. The ones that something else started stay.
+ for (const c of visibleChats.filter(x => !x.origin)) {
try { await chat.remove(c.id); drafts.clear(c.id); pane.forget(c.id) } catch (e) { addToast(e.message || tc('errors.delete'), 'error'); break }
}
navigate('/app')
@@ -1028,6 +1037,7 @@ function ChatPage() {
onExport={(c) => exportChat(c.id)}
onCopyChat={(c) => copyChat(c.id)}
onSearch={(text) => chatsApi.search(text, 50)}
+ originTitle={originTitle}
/>
)}
manageMode={manage}
@@ -1056,6 +1066,18 @@ function ChatPage() {
/>
)}
+ {activeId && isAutomated(activeChat) && (() => {
+ const o = resolveOrigin({ origin: activeChat.origin, originId: activeChat.origin_id }, originCtx)
+ const kind = knownKind(o.kind)
+ const name = o.title || t(`origin.kindFallback.${kind}`)
+ return (
+
+ {t('origin.banner', { title: name })}
+ {o.href && {t('origin.open', { title: name })}}
+
+ )
+ })()}
+
{isEmpty && }
diff --git a/core/http/react-ui/src/pages/Home.jsx b/core/http/react-ui/src/pages/Home.jsx
index 9b20c561aa..55159f21ff 100644
--- a/core/http/react-ui/src/pages/Home.jsx
+++ b/core/http/react-ui/src/pages/Home.jsx
@@ -328,7 +328,7 @@ export default function Home() {
// chats leaves the strip empty.
useEffect(() => {
let live = true
- chatsApi.list(1, 'chat').then(r => {
+ chatsApi.list(1, 'chat', 'person').then(r => {
if (!live) return
setConversations(resumeItems(r.conversations))
setChatServer({ files: r.files !== false, filesReason: r.files_reason || '', persistent: r.persistent !== false, imageModel: r.image_model || '' })
diff --git a/core/http/react-ui/src/pages/chat.css b/core/http/react-ui/src/pages/chat.css
index 1fad00e4e5..f15f193066 100644
--- a/core/http/react-ui/src/pages/chat.css
+++ b/core/http/react-ui/src/pages/chat.css
@@ -1843,3 +1843,105 @@ html[data-handoff='fade']::view-transition-new(root) {
animation: cxFade var(--dur-slow) var(--ease-out) both;
}
}
+
+/* Chats that a person did not start: the badge in a list row, the banner over
+ a transcript, the tabs and the groups of All chats. */
+.cx-row-item__origin {
+ flex: none;
+ max-width: 14em;
+ overflow: hidden;
+ padding: 1px 7px;
+ border-radius: var(--radius-full);
+ background: var(--dk-accent-wash);
+ color: var(--dk-accent-text);
+ font-size: var(--fs-meta);
+ font-weight: 600;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+@media (max-width: 639px) { .cx-row-item__origin { max-width: 6.5em; } }
+.cx-origin-banner { margin: 8px 16px 0; justify-content: flex-start; flex-wrap: wrap; }
+.cx-origin-banner .cx-link { margin-left: auto; }
+
+.ac-tabs { margin-bottom: 16px; }
+.ac-tabs .dk-tab { display: inline-flex; align-items: center; gap: 8px; }
+.ac-tab__count {
+ color: var(--dk-muted);
+ font-size: var(--fs-meta);
+ font-variant-numeric: tabular-nums;
+}
+.ac-tab__wait {
+ min-width: 18px;
+ height: 18px;
+ padding: 0 5px;
+ border-radius: var(--radius-full);
+ background: var(--dk-warn-wash);
+ color: var(--dk-warn-text);
+ font-size: var(--fs-meta);
+ font-weight: 700;
+ line-height: 18px;
+ text-align: center;
+}
+.ac-intro, .ac-empty { margin: 0 0 16px; color: var(--dk-muted); font-size: var(--fs-small); }
+.ac-empty { padding: 28px 16px; text-align: center; }
+.ac-kind { margin-bottom: 28px; }
+.ac-kind__title {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin: 0 0 10px;
+ color: var(--dk-muted);
+ font-family: var(--font-sans);
+ font-size: var(--fs-meta);
+ font-weight: 600;
+ letter-spacing: .09em;
+ text-transform: uppercase;
+}
+.ac-kind__count { font-variant-numeric: tabular-nums; }
+.ac-group {
+ margin-bottom: 12px;
+ padding: 12px 14px;
+ border-radius: var(--dk-radius-2);
+ box-shadow: inset 0 0 0 1px var(--dk-edge);
+}
+.ac-group__head { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 6px 16px; }
+.ac-group__main { display: flex; align-items: center; gap: 8px; min-width: 0; }
+.ac-group__title { margin: 0; min-width: 0; overflow: hidden; font-size: var(--fs-body); font-weight: 600; text-overflow: ellipsis; white-space: nowrap; }
+.ac-group__meta { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 12px; color: var(--dk-muted); font-size: var(--fs-small); }
+.ac-badge {
+ flex: none;
+ padding: 1px 7px;
+ border-radius: var(--radius-full);
+ background: var(--dk-accent-wash);
+ color: var(--dk-accent-text);
+ font-size: var(--fs-meta);
+ font-weight: 600;
+}
+.ac-link { color: var(--dk-accent-text); font-weight: 600; }
+.ac-list { margin: 8px 0 0; padding: 0; list-style: none; }
+.ac-row { display: flex; align-items: center; gap: 8px; border-radius: var(--dk-radius-2); }
+.ac-row:hover { background: var(--dk-hover); }
+.ac-row__open {
+ display: flex;
+ flex: 1;
+ align-items: baseline;
+ justify-content: space-between;
+ gap: 12px;
+ min-width: 0;
+ padding: 8px 6px;
+ border: 0;
+ background: none;
+ color: inherit;
+ font: inherit;
+ text-align: left;
+ cursor: pointer;
+}
+.ac-row__name { min-width: 0; overflow: hidden; font-size: var(--fs-small); text-overflow: ellipsis; white-space: nowrap; }
+.ac-row__time { flex: none; color: var(--dk-muted); font-size: var(--fs-meta); font-variant-numeric: tabular-nums; }
+.ac-row__busy { color: var(--dk-muted); }
+.ac-row__wait { color: var(--dk-warn-text); }
+.ac-more { margin-top: 6px; }
+@media (max-width: 639px) {
+ .ac-group { padding: 10px; }
+ .ac-row__open { flex-direction: column; align-items: flex-start; gap: 2px; }
+}
diff --git a/core/http/react-ui/src/utils/api.js b/core/http/react-ui/src/utils/api.js
index 09bd87e5bb..4b477d07b2 100644
--- a/core/http/react-ui/src/utils/api.js
+++ b/core/http/react-ui/src/utils/api.js
@@ -134,7 +134,7 @@ export const backendsApi = {
// Chats API: conversations the server keeps. A message sent while a turn runs
// waits and reaches the model, with any others, at its next request.
export const chatsApi = {
- list: (limit, kind) => fetchJSON(chatsListPath(API_CONFIG.endpoints.chats, limit, kind)),
+ list: (limit, kind, origin) => fetchJSON(chatsListPath(API_CONFIG.endpoints.chats, limit, kind, origin)),
// With an agent, its model (when it has one) replaces model. With assistant,
// the chat gets the LocalAI management tools (admin only, setting on).
// switches are the drafted switches of a new chat (useChatSwitches): the
diff --git a/core/http/react-ui/src/utils/chatThread.js b/core/http/react-ui/src/utils/chatThread.js
index 615c5d6020..6bfca154b7 100644
--- a/core/http/react-ui/src/utils/chatThread.js
+++ b/core/http/react-ui/src/utils/chatThread.js
@@ -163,6 +163,8 @@ export function menuChats(conversations) {
model: c.model || '',
running: Boolean(c.running),
origin: c.origin || '',
+ originId: c.origin_id || '',
+ lastTurn: c.last_turn || '',
agentId: c.agent_id || '',
assistant: Boolean(c.assistant),
createdAt: Date.parse(c.created) || 0,
diff --git a/core/http/react-ui/src/utils/chatsQuery.js b/core/http/react-ui/src/utils/chatsQuery.js
index 06dc30733e..5e2861fe3a 100644
--- a/core/http/react-ui/src/utils/chatsQuery.js
+++ b/core/http/react-ui/src/utils/chatsQuery.js
@@ -1,10 +1,15 @@
// The address of the chat list. With kind "chat" the server leaves sub-agent
// runs out and counts only chats against the limit, so a burst of newer runs
// cannot push a person's chats off the page.
-export function chatsListPath(base, limit, kind) {
+//
+// With an origin the server returns only the chats of that origin and counts
+// the limit against them: person (a person started it), automated (anything
+// else) or one origin kind such as trigger or connector.
+export function chatsListPath(base, limit, kind, origin) {
const sp = new URLSearchParams()
if (limit) sp.set('limit', String(limit))
if (kind) sp.set('kind', kind)
+ if (origin) sp.set('origin', origin)
const s = sp.toString()
return s ? `${base}?${s}` : base
}
diff --git a/core/http/react-ui/src/utils/homeConversations.js b/core/http/react-ui/src/utils/homeConversations.js
index b758bb9a2f..36d5757cc7 100644
--- a/core/http/react-ui/src/utils/homeConversations.js
+++ b/core/http/react-ui/src/utils/homeConversations.js
@@ -7,7 +7,7 @@ const DAY_MS = 24 * 60 * 60 * 1000
// newest first. A sub-agent run belongs to its parent and is left out.
export function resumeItems(conversations) {
return (conversations || [])
- .filter(c => c && c.id && c.kind !== 'subagent')
+ .filter(c => c && c.id && c.kind !== 'subagent' && !c.origin)
.map(c => ({
id: c.id,
title: (c.title || '').trim() || 'New chat',
diff --git a/core/http/react-ui/src/utils/originChats.js b/core/http/react-ui/src/utils/originChats.js
new file mode 100644
index 0000000000..b6b7d2e8fd
--- /dev/null
+++ b/core/http/react-ui/src/utils/originChats.js
@@ -0,0 +1,159 @@
+import { addonPageAddress } from '../addons/addresses.js'
+
+// Chats that a person did not start: a trigger, a connector binding, an agent
+// job or a page sent the first message. They stay out of the lists of the
+// person's own chats and have an area of their own. No React, so a node test
+// drives these helpers.
+//
+// Nothing here knows a platform. A chat has an origin kind (trigger,
+// connector, job, page, or a kind a later release adds) and the id of the
+// thing that started it. The title of that thing comes from the list the
+// kind belongs to; a kind that has no list falls back to the id, and a kind
+// this file never heard of falls under a generic group.
+
+// The order of the groups. A kind that is not here comes after, by name.
+const KIND_ORDER = ['trigger', 'connector', 'job', 'page']
+
+// The origin kinds that have words of their own; any other falls under "other".
+const KNOWN = ['trigger', 'connector', 'job', 'page']
+export const knownKind = (kind) => (KNOWN.includes(kind) ? kind : 'other')
+
+export const isAutomated = (chat) => Boolean(chat?.origin)
+
+// The chat summary, split: the chats a person started, then the others.
+export function splitByOrigin(chats) {
+ const person = []
+ const automated = []
+ for (const c of chats || []) (isAutomated(c) ? automated : person).push(c)
+ return { person, automated }
+}
+
+// Where the thing that started a chat has its page, or '' when it has none.
+function originHref(kind, id) {
+ if (kind === 'trigger') return id ? `/app/triggers/${encodeURIComponent(id)}` : '/app/triggers'
+ if (kind === 'connector') return '/app/chats/connectors'
+ if (kind === 'job') return '/app/triggers'
+ if (kind === 'page') return id ? `/app/pages/${encodeURIComponent(id)}` : '/app/pages'
+ return ''
+}
+
+// The name a list gives the origin, or '' when the list does not know it.
+// ctx holds the lists the page read: connectors (bindings), connectorKinds
+// and triggers. A connector without a name of its own takes the title of its
+// kind.
+function originName(kind, id, ctx) {
+ if (kind === 'connector') {
+ const b = (ctx?.connectors || []).find((x) => x.id === id)
+ if (!b) return ''
+ if (b.name) return b.name
+ return (ctx?.connectorKinds || []).find((k) => k.kind === b.kind)?.title || b.kind || ''
+ }
+ if (kind === 'trigger') return (ctx?.triggers || []).find((x) => x.id === id)?.name || ''
+ // A job or a page has no list of its own here: its id is its name.
+ if (kind === 'job' || kind === 'page') return id || ''
+ return ''
+}
+
+// What started a chat: { kind, id, key, title, href }. title is '' when
+// nothing names it; the view then shows the label of the kind.
+export function resolveOrigin(chat, ctx) {
+ const kind = chat?.origin || ''
+ const id = chat?.originId || ''
+ let href = originHref(kind, id)
+ if (kind === 'connector') {
+ // The add-on that serves the binding's kind has a page of its own for it.
+ const b = (ctx?.connectors || []).find((x) => x.id === id)
+ const k = b && (ctx?.connectorKinds || []).find((x) => x.kind === b.kind && (!b.addon || x.addon === b.addon))
+ if (k?.page) href = addonPageAddress({ addon: k.addon, page: k.page })
+ }
+ return { kind, id, key: `${kind}/${id}`, title: originName(kind, id, ctx), href }
+}
+
+// The status of a chat of this kind: running while a turn runs, waiting when
+// it needs the person (its turn paused at the step limit, or an unread
+// notification asks for them; needsYou is the Set of chat ids with one), idle
+// otherwise.
+export function chatStatus(chat, needsYou) {
+ if (chat?.lastTurn === 'paused' || needsYou?.has(chat?.id)) return 'waiting'
+ if (chat?.running) return 'running'
+ return 'idle'
+}
+
+// The ids of the chats that unread inbox items ask the person to look at: a
+// run that failed, or something that needs them. An item links to its chat.
+const CHAT_LINK = /^\/app\/chats\/([^/?#]+)/
+export function needsYouIds(notifications) {
+ const ids = new Set()
+ for (const n of notifications || []) {
+ if (n.read || (n.kind !== 'needs_attention' && n.kind !== 'run_failed')) continue
+ const m = CHAT_LINK.exec(n.link || '')
+ if (m) ids.add(decodeURIComponent(m[1]))
+ }
+ return ids
+}
+
+// How many of these chats need the person, and whether any turn runs.
+export function attention(chats, needsYou) {
+ let waiting = 0
+ let running = 0
+ for (const c of chats || []) {
+ if (!isAutomated(c)) continue
+ const s = chatStatus(c, needsYou)
+ if (s === 'waiting') waiting++
+ else if (s === 'running') running++
+ }
+ return { waiting, running }
+}
+
+// The chats that were not started by a person, in groups by origin kind and,
+// inside a kind, by origin (one trigger, one connector binding...). Kinds
+// keep a fixed order, origins and chats go newest first. Every group has the
+// count, the time of its latest chat and how many chats run or wait.
+export function groupByOrigin(chats, ctx, needsYou) {
+ const kinds = new Map()
+ for (const c of chats || []) {
+ if (!isAutomated(c)) continue
+ const o = resolveOrigin(c, ctx)
+ if (!kinds.has(o.kind)) kinds.set(o.kind, new Map())
+ const origins = kinds.get(o.kind)
+ if (!origins.has(o.key)) origins.set(o.key, { ...o, chats: [], count: 0, lastActive: 0, running: 0, waiting: 0 })
+ const g = origins.get(o.key)
+ g.chats.push(c)
+ g.count++
+ g.lastActive = Math.max(g.lastActive, c.updatedAt || 0)
+ const s = chatStatus(c, needsYou)
+ if (s === 'running') g.running++
+ if (s === 'waiting') g.waiting++
+ }
+ const rank = (k) => { const i = KIND_ORDER.indexOf(k); return i < 0 ? KIND_ORDER.length : i }
+ return [...kinds.entries()]
+ .sort(([a], [b]) => rank(a) - rank(b) || a.localeCompare(b))
+ .map(([kind, origins]) => {
+ const groups = [...origins.values()].sort((a, b) => b.lastActive - a.lastActive)
+ for (const g of groups) g.chats.sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0))
+ return {
+ kind,
+ count: groups.reduce((n, g) => n + g.count, 0),
+ waiting: groups.reduce((n, g) => n + g.waiting, 0),
+ running: groups.reduce((n, g) => n + g.running, 0),
+ groups,
+ }
+ })
+}
+
+// The tab of All chats the browser remembers.
+export const TAB_KEY = 'localai_all_chats_tab'
+export const TABS = ['mine', 'automated']
+
+export function readTab(storage) {
+ try {
+ const v = (storage || window.localStorage).getItem(TAB_KEY)
+ return TABS.includes(v) ? v : 'mine'
+ } catch {
+ return 'mine'
+ }
+}
+
+export function writeTab(tab, storage) {
+ try { (storage || window.localStorage).setItem(TAB_KEY, tab) } catch { /* private mode: the tab is not remembered */ }
+}
diff --git a/core/http/react-ui/tests/unit/chatslist.test.mjs b/core/http/react-ui/tests/unit/chatslist.test.mjs
index 32d07b71b2..514e9fce19 100644
--- a/core/http/react-ui/tests/unit/chatslist.test.mjs
+++ b/core/http/react-ui/tests/unit/chatslist.test.mjs
@@ -8,6 +8,16 @@ test('chats list: a request for chats asks the server to leave sub-agent runs ou
assert.equal(chatsListPath('/api/chats', 200, 'chat'), '/api/chats?limit=200&kind=chat')
assert.equal(chatsListPath('/api/chats'), '/api/chats')
assert.equal(chatsListPath('/api/chats', 5), '/api/chats?limit=5')
+ assert.equal(chatsListPath('/api/chats', 8, 'chat', 'person'), '/api/chats?limit=8&kind=chat&origin=person')
+ assert.equal(chatsListPath('/api/chats', 200, 'chat', 'automated'), '/api/chats?limit=200&kind=chat&origin=automated')
+})
+
+test('chats list: resume rows leave out chats that a trigger or a connector started', () => {
+ const rows = resumeItems([
+ { id: 'bot', origin: 'connector', origin_id: 'b1', updated: '2026-10-27T10:00:00Z' },
+ { id: 'me', title: 'Mine', origin: '', updated: '2026-10-27T09:00:00Z' },
+ ])
+ assert.deepEqual(rows.map(r => r.id), ['me'])
})
test('chats list: resume rows skip a sub-agent run that is the newest record', () => {
diff --git a/core/http/react-ui/tests/unit/chatthread.test.mjs b/core/http/react-ui/tests/unit/chatthread.test.mjs
index 3480859c9f..180a5d130c 100644
--- a/core/http/react-ui/tests/unit/chatthread.test.mjs
+++ b/core/http/react-ui/tests/unit/chatthread.test.mjs
@@ -80,4 +80,7 @@ test('chat thread: server chats in the menu shape', () => {
assert.equal(c.name, 'T')
assert.equal(c.running, true)
assert.equal(c.updatedAt, Date.parse('2026-01-02T00:00:00Z'))
+ assert.deepEqual([c.origin, c.originId, c.lastTurn], ['', '', ''])
+ const [b] = menuChats([{ id: 'b', origin: 'connector', origin_id: 'b1', last_turn: 'paused' }])
+ assert.deepEqual([b.origin, b.originId, b.lastTurn], ['connector', 'b1', 'paused'])
})
diff --git a/core/http/react-ui/tests/unit/originchats.test.mjs b/core/http/react-ui/tests/unit/originchats.test.mjs
new file mode 100644
index 0000000000..741909b7e7
--- /dev/null
+++ b/core/http/react-ui/tests/unit/originchats.test.mjs
@@ -0,0 +1,99 @@
+import test from 'node:test'
+import assert from 'node:assert/strict'
+import {
+ attention, chatStatus, groupByOrigin, needsYouIds, readTab, resolveOrigin, splitByOrigin, writeTab,
+} from '../../src/utils/originChats.js'
+
+const chat = (id, origin, originId, updatedAt, extra = {}) => ({ id, origin, originId, updatedAt, running: false, lastTurn: 'done', ...extra })
+const ctx = {
+ connectors: [{ id: 'b1', name: 'Team bot', kind: 'chatapp' }, { id: 'b2', name: '', kind: 'chatapp' }],
+ connectorKinds: [{ kind: 'chatapp', title: 'Chat App' }],
+ triggers: [{ id: 't1', name: 'Morning digest' }],
+}
+
+test('origin chats: the person and the others are split by origin', () => {
+ const { person, automated } = splitByOrigin([chat('a', '', '', 1), chat('b', 'connector', 'b1', 2), chat('c', 'someday', 'x', 3)])
+ assert.deepEqual(person.map(c => c.id), ['a'])
+ assert.deepEqual(automated.map(c => c.id), ['b', 'c'])
+})
+
+test('origin chats: a connector takes the name of its binding, then the title of its kind', () => {
+ assert.equal(resolveOrigin(chat('a', 'connector', 'b1', 1), ctx).title, 'Team bot')
+ assert.equal(resolveOrigin(chat('a', 'connector', 'b2', 1), ctx).title, 'Chat App')
+ assert.equal(resolveOrigin(chat('a', 'connector', 'gone', 1), ctx).title, '')
+ assert.equal(resolveOrigin(chat('a', 'connector', 'b1', 1), ctx).href, '/app/chats/connectors')
+})
+
+test('origin chats: a connector links to the page of the add-on that serves its kind', () => {
+ const withPage = { ...ctx, connectors: [{ id: 'b1', name: 'Team bot', kind: 'chatapp', addon: 'chatapp' }], connectorKinds: [{ addon: 'chatapp', kind: 'chatapp', title: 'Chat App', page: 'bot' }] }
+ assert.equal(resolveOrigin(chat('a', 'connector', 'b1', 1), withPage).href, '/app/addons/chatapp/bot')
+})
+
+test('origin chats: a trigger, a job and a page are named by their list or their id', () => {
+ assert.equal(resolveOrigin(chat('a', 'trigger', 't1', 1), ctx).title, 'Morning digest')
+ assert.equal(resolveOrigin(chat('a', 'trigger', 't1', 1), ctx).href, '/app/triggers/t1')
+ assert.equal(resolveOrigin(chat('a', 'job', 'nightly', 1), ctx).title, 'nightly')
+ assert.equal(resolveOrigin(chat('a', 'page', 'notes', 1), ctx).href, '/app/pages/notes')
+})
+
+test('origin chats: an origin this release does not know gets no title and no link', () => {
+ const o = resolveOrigin(chat('a', 'future', 'f1', 1), ctx)
+ assert.deepEqual([o.kind, o.title, o.href], ['future', '', ''])
+})
+
+test('origin chats: groups go by kind, then by origin, newest first, with counts', () => {
+ const chats = [
+ chat('a', 'connector', 'b1', 10), chat('b', 'connector', 'b1', 30), chat('c', 'connector', 'b2', 20),
+ chat('d', 'trigger', 't1', 5), chat('e', 'future', 'f1', 99), chat('p', '', '', 100),
+ ]
+ const kinds = groupByOrigin(chats, ctx, new Set())
+ assert.deepEqual(kinds.map(k => k.kind), ['trigger', 'connector', 'future'])
+ const conn = kinds[1]
+ assert.equal(conn.count, 3)
+ assert.deepEqual(conn.groups.map(g => g.id), ['b1', 'b2'])
+ assert.equal(conn.groups[0].count, 2)
+ assert.equal(conn.groups[0].lastActive, 30)
+ assert.deepEqual(conn.groups[0].chats.map(c => c.id), ['b', 'a'])
+ assert.ok(!JSON.stringify(kinds).includes('"p"'), 'the chat of a person is not grouped')
+})
+
+test('origin chats: a running turn and a chat that needs the person are counted apart', () => {
+ const chats = [
+ chat('r', 'connector', 'b1', 3, { running: true }),
+ chat('w', 'connector', 'b1', 2, { lastTurn: 'paused' }),
+ chat('n', 'trigger', 't1', 1),
+ chat('i', 'trigger', 't1', 0),
+ ]
+ const needs = new Set(['n'])
+ assert.equal(chatStatus(chats[0], needs), 'running')
+ assert.equal(chatStatus(chats[1], needs), 'waiting')
+ assert.equal(chatStatus(chats[2], needs), 'waiting')
+ assert.equal(chatStatus(chats[3], needs), 'idle')
+ assert.deepEqual(attention(chats, needs), { waiting: 2, running: 1 })
+ const conn = groupByOrigin(chats, ctx, needs).find(k => k.kind === 'connector')
+ assert.deepEqual([conn.running, conn.waiting], [1, 1])
+})
+
+test('origin chats: only unread items that ask for the person count, and they link to a chat', () => {
+ const ids = needsYouIds([
+ { kind: 'needs_attention', read: false, link: '/app/chats/c1' },
+ { kind: 'run_failed', read: false, link: '/app/chats/c%202?x=1' },
+ { kind: 'run_ended', read: false, link: '/app/chats/c3' },
+ { kind: 'needs_attention', read: true, link: '/app/chats/c4' },
+ { kind: 'needs_attention', read: false, link: '/app/triggers' },
+ ])
+ assert.deepEqual([...ids].sort(), ['c 2', 'c1'])
+})
+
+test('origin chats: the tab is remembered, and a storage that throws does not break it', () => {
+ const mem = new Map()
+ const storage = { getItem: (k) => mem.get(k) ?? null, setItem: (k, v) => mem.set(k, v) }
+ assert.equal(readTab(storage), 'mine')
+ writeTab('automated', storage)
+ assert.equal(readTab(storage), 'automated')
+ mem.set('localai_all_chats_tab', 'nonsense')
+ assert.equal(readTab(storage), 'mine')
+ const broken = { getItem() { throw new Error('denied') }, setItem() { throw new Error('denied') } }
+ assert.equal(readTab(broken), 'mine')
+ assert.doesNotThrow(() => writeTab('automated', broken))
+})
diff --git a/docs/content/features/chats.md b/docs/content/features/chats.md
index 7acc9d9e89..380fe3a8e4 100644
--- a/docs/content/features/chats.md
+++ b/docs/content/features/chats.md
@@ -174,6 +174,22 @@ The answers have the shapes of `GET /api/chats/{id}/tools` and `GET
/api/chats/{id}/addons`. A model you may not use is `403`, a model the server
does not have is `400`, and another user's agent is `404`.
+### List the chats of one origin
+
+`GET /api/chats` returns every chat unless you send `origin`. With
+`origin=person` it returns only the chats a person started, with
+`origin=automated` only the chats that something else started, and with an
+origin kind (`trigger`, `connector`, `job`, `page`) only the chats of that kind.
+Each chat carries `origin` (empty for a person) and `origin_id` (the trigger,
+binding, job or page that started it). With `origin` set, `limit` counts the
+chats kept and sub-agent runs are left out, so a burst of chats from a bot
+cannot push your own chats out of the first page.
+
+```bash
+curl -s 'localhost:8080/api/chats?kind=chat&origin=person&limit=8'
+curl -s 'localhost:8080/api/chats?kind=chat&origin=automated&limit=200'
+```
+
## Keep typing while the model works
Send another message while a turn runs and it waits. The response says so:
@@ -1804,6 +1820,22 @@ and opens again from any browser.
Markdown or delete one, clear them all, or import a JSON export. A deleted
chat can be brought back for a few seconds with **Undo**; then the server
deletes it.
+
+ The page lists the chats you started. Chats that something else started (a
+ connector, a trigger, an agent job or a page) have a second tab, **From
+ add-ons and automations**, which appears once there is one. It groups them by
+ what started them: first the kind (triggers, connectors, jobs, pages, any
+ other), then the origin itself, such as one connector binding or one
+ trigger, with the origin's name, how many chats it has, when it was last
+ active, a **Running** mark while a turn runs and **Waiting for you** when a
+ chat needs you (its turn paused at the step limit, or an unread inbox item
+ asks for you). A link opens the origin's page. These chats do not appear in
+ the sidebar, in **Jump back in** or in the **My chats** tab; the **All
+ chats** entry in the sidebar shows a number when some of them wait for you.
+ `Ctrl+K` in a transcript still lists and finds every chat, with a badge on
+ the ones you did not start. Opening one shows "This conversation was started
+ by ..." over the transcript, with a link to the origin. The browser
+ remembers which tab you used last.
- **Old addresses.** `/app/chats` with no chat id opens Home. A `?model=` in
the address selects that model on Home, so `/app/chats?model=` and the
older `/app/chat/` still start a chat on that model.