feat(ui): add moderated group conversations

Let users give installed models individual turns or ordered rounds in a shared conversation. Attribute completed responses and exclude interrupted output from subsequent prompts.

Add streaming and cancellation tests, browser coverage for CI, and a guide for the session-only page.

Assisted-by: Codex:GPT-6
This commit is contained in:
localai-org-maint-bot committed 2026-09-19 10:02:32 +00:00
1 parent 8b01583e70
commit bf36e69491
8 files changed
+573 -1

No files matched your search

+172
View File
@@ -0,0 +1,172 @@
// SPDX-License-Identifier: MIT
import { test, expect } from './coverage-fixtures.js'
async function setup(page, models = ['alpha', 'beta']) {
await page.route('**/api/**', route => route.fulfill({ json: {} }))
await page.route('**/api/auth/status', route => route.fulfill({ json: { authEnabled: false } }))
await page.route('**/api/models/capabilities', route => route.fulfill({ json: { data: models.map(id => ({ id, capabilities: ['FLAG_CHAT'] })) } }))
await page.goto('/app/group-chat')
}
async function participants(page) {
await page.getByLabel('Model to add').selectOption('alpha')
await page.getByRole('button', { name: 'Add participant', exact: true }).click()
await page.getByLabel('Model to add').selectOption('beta')
await page.getByRole('button', { name: 'Add participant', exact: true }).click()
}
function sse(content = 'Reply', extra = '') {
return `data: ${JSON.stringify({ choices: [{ delta: { reasoning_content: 'private reasoning', content } }] })}\n\n${extra}data: [DONE]\n\n`
}
test('manual turns share attributed history and lock setup', async ({ page }) => {
const requests = []
await page.route('**/v1/chat/completions', async route => {
requests.push(route.request().postDataJSON())
await route.fulfill({ contentType: 'text/event-stream', body: sse(`Reply ${requests.length}`) })
})
await setup(page)
await participants(page)
await page.getByLabel('Name for alpha').fill('Planner')
await page.getByLabel('Objective').fill('Design a garden')
await page.getByLabel('Moderator message').fill('Use native plants')
await page.getByRole('button', { name: 'Add message', exact: true }).click()
await page.getByRole('button', { name: 'Give Planner a turn', exact: true }).click()
await expect(page.getByText('Reply 1', { exact: true })).toBeVisible()
await expect(page.getByLabel('Objective')).toBeDisabled()
await page.getByRole('button', { name: 'Give beta a turn', exact: true }).click()
await expect(page.getByText('Reply 2', { exact: true })).toBeVisible()
expect(requests.map(r => r.model)).toEqual(['alpha', 'beta'])
expect(requests[0].messages[0].content).toContain('Planner')
expect(JSON.stringify(requests[1].messages)).toContain('Planner (alpha)')
expect(JSON.stringify(requests[1].messages)).toContain('Reply 1')
expect(JSON.stringify(requests[1].messages)).not.toContain('private reasoning')
expect(requests[1].messages.at(-1).role).toBe('user')
expect(requests[1].messages.filter(m => m.role === 'assistant')).toEqual([])
await page.getByRole('button', { name: 'New conversation', exact: true }).click()
await expect(page.getByText('Reply 1', { exact: true })).toHaveCount(0)
await expect(page.getByLabel('Objective')).toBeEnabled()
})
test('bounded rounds run in order and reject invalid names', async ({ page }) => {
const requests = []
await page.route('**/v1/chat/completions', route => {
requests.push(route.request().postDataJSON().model)
return route.fulfill({ contentType: 'text/event-stream', body: sse() })
})
await setup(page)
await participants(page)
await page.getByLabel('Name for beta').fill('alpha')
await expect(page.getByRole('button', { name: 'Run one round', exact: true })).toBeDisabled()
await page.getByLabel('Name for beta').fill(' ')
await expect(page.getByRole('button', { name: 'Run one round', exact: true })).toBeDisabled()
await page.getByLabel('Name for beta').fill('beta')
await page.getByLabel('Number of rounds').fill('11')
await expect(page.getByRole('button', { name: 'Run rounds', exact: true })).toBeDisabled()
await page.getByLabel('Number of rounds').fill('2')
await page.getByRole('button', { name: 'Run rounds', exact: true }).click()
await expect(page.getByRole('button', { name: 'Run rounds', exact: true })).toBeEnabled()
expect(requests).toEqual(['alpha', 'beta', 'alpha', 'beta'])
await page.getByRole('button', { name: 'Run one round', exact: true }).click()
await expect(page.getByRole('button', { name: 'Run one round', exact: true })).toBeEnabled()
expect(requests).toEqual(['alpha', 'beta', 'alpha', 'beta', 'alpha', 'beta'])
})
test('stop cancels the run and prevents overlapping turns', async ({ page }) => {
let count = 0
let release
await page.route('**/v1/chat/completions', async route => {
count++
await new Promise(resolve => { release = resolve })
await route.fulfill({ contentType: 'text/event-stream', body: sse('late reply') }).catch(() => {})
})
await setup(page)
await participants(page)
await page.getByRole('button', { name: 'Run one round', exact: true }).click()
await expect(page.getByRole('button', { name: 'Give beta a turn', exact: true })).toBeDisabled()
await expect.poll(() => count).toBe(1)
await page.getByRole('button', { name: 'Stop', exact: true }).click()
release()
await expect(page.getByText('Incomplete', { exact: true })).toBeVisible()
await expect(page.getByRole('button', { name: 'Run one round', exact: true })).toBeEnabled()
expect(count).toBe(1)
await expect(page.getByText('late reply', { exact: true })).toHaveCount(0)
})
for (const [name, response, message] of [
['HTTP failure', { status: 500, json: { error: { message: 'Backend failed' } } }, 'Backend failed'],
['SSE failure', { contentType: 'text/event-stream', body: 'data: {"error":{"message":"Stream failed"}}\n\n' }, 'Stream failed'],
['empty response', { contentType: 'text/event-stream', body: 'data: [DONE]\n\n' }, 'The model returned no text.'],
]) {
test(`${name} stops remaining turns`, async ({ page }) => {
let count = 0
await page.route('**/v1/chat/completions', route => { count++; return route.fulfill(response) })
await setup(page)
await participants(page)
await page.getByRole('button', { name: 'Run one round', exact: true }).click()
await expect(page.getByRole('alert')).toContainText(message)
expect(count).toBe(1)
})
}
test('empty model discovery and chat permission', async ({ page }) => {
await setup(page, [])
await expect(page.getByText('No installed chat models available.', { exact: true })).toBeVisible()
await expect(page.getByRole('button', { name: 'Add participant', exact: true })).toBeDisabled()
await page.route('**/api/auth/status', route => route.fulfill({ json: { authEnabled: true, user: { role: 'user', permissions: {} } } }))
await page.reload()
await expect(page).toHaveURL(/\/app\/?$/)
})
async function stagedStream(page) {
await page.addInitScript(() => {
const original = window.fetch
window.groupRequests = []
window.groupAborted = false
window.fetch = async (input, options) => {
if (!String(input).endsWith('/v1/chat/completions')) return original(input, options)
window.groupRequests.push(JSON.parse(options.body))
return new Response(new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode('data: {"choices":[{"delta":{"content":"unfinished text"}}]}\n\n'))
options.signal.addEventListener('abort', () => {
window.groupAborted = true
controller.error(new DOMException('Aborted', 'AbortError'))
})
},
}), { headers: { 'Content-Type': 'text/event-stream' } })
}
})
}
test('partial abort is excluded from later prompts and leaving cancels a stream', async ({ page }) => {
await stagedStream(page)
await setup(page)
await participants(page)
await page.getByRole('button', { name: 'Run one round', exact: true }).click()
await expect(page.getByText('unfinished text', { exact: true })).toBeVisible()
await page.getByRole('button', { name: 'Stop', exact: true }).click()
await expect(page.getByText('Incomplete', { exact: true })).toBeVisible()
await page.getByRole('button', { name: 'Give beta a turn', exact: true }).click()
const requests = await page.evaluate(() => window.groupRequests)
expect(requests).toHaveLength(2)
expect(JSON.stringify(requests[1])).not.toContain('unfinished text')
await page.evaluate(() => { window.groupAborted = false })
await page.getByRole('link', { name: 'Back to Chat', exact: true }).click()
await expect.poll(() => page.evaluate(() => window.groupAborted)).toBe(true)
await page.getByRole('link', { name: 'Group chat', exact: true }).click()
await expect(page.getByText('unfinished text', { exact: true })).toHaveCount(0)
})
for (const [name, body] of [
['truncated stream', 'data: {"choices":[{"delta":{"content":"partial"}}]}\n\n'],
['malformed stream', 'data: not-json\n\n'],
]) {
test(`${name} fails without scheduling the next participant`, async ({ page }) => {
let count = 0
await page.route('**/v1/chat/completions', route => { count++; return route.fulfill({ contentType: 'text/event-stream', body }) })
await setup(page)
await participants(page)
await page.getByRole('button', { name: 'Run one round', exact: true }).click()
await expect(page.getByRole('alert')).toBeVisible()
expect(count).toBe(1)
})
}
+8
View File
@@ -13791,3 +13791,11 @@ button.lane:hover {
touch-action: none;
cursor: grab;
}
/* Group conversation: shared controls wrap on narrow screens. */
.group-chat { max-width: 960px; margin: 0 auto; padding: var(--spacing-lg); }
.group-chat .card { padding: var(--spacing-lg); }
.group-chat-controls { display: flex; flex-wrap: wrap; align-items: end; gap: var(--spacing-md); }
.group-chat-controls label { flex: 1 1 180px; }
.group-chat textarea { width: 100%; resize: vertical; }
.group-chat-content { white-space: pre-wrap; overflow-wrap: anywhere; }
+2 -1
View File
@@ -1,5 +1,5 @@
import { useState, useEffect, useRef, useCallback, useMemo } from 'react'
import { useParams, useOutletContext, useNavigate, useLocation } from 'react-router-dom'
import { Link, useParams, useOutletContext, useNavigate, useLocation } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { fromState } from '../utils/editorNav'
import { useChat } from '../hooks/useChat'
@@ -1020,6 +1020,7 @@ export default function Chat() {
style={{ flex: '1 1 0', minWidth: 120 }}
/>
<div className="chat-header-actions">
<Link className="btn btn-secondary" to="/app/group-chat">{t('group.title', 'Group chat')}</Link>
{activeChat.model && isAdmin && (
<button
type="button"
+133
View File
@@ -0,0 +1,133 @@
// SPDX-License-Identifier: MIT
import { useEffect, useRef, useState } from 'react'
import { Link } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { useModels } from '../hooks/useModels'
import { CAP_CHAT } from '../utils/capabilities'
import { streamChat } from '../utils/api'
import { GroupRun, validParticipants } from '../utils/groupChat'
export default function GroupChat() {
const { t } = useTranslation('chat')
const { models, loading, error: modelError } = useModels(CAP_CHAT)
const [model, setModel] = useState('')
const [participants, setParticipants] = useState([])
const [objective, setObjective] = useState('')
const [message, setMessage] = useState('')
const [transcript, setTranscript] = useState([])
const [rounds, setRounds] = useState('1')
const [locked, setLocked] = useState(false)
const [running, setRunning] = useState(false)
const [error, setError] = useState('')
const runner = useRef(null)
if (!runner.current) runner.current = new GroupRun(streamChat)
const mounted = useRef(true)
useEffect(() => {
mounted.current = true
return () => { mounted.current = false; runner.current.stop() }
}, [])
const selected = models.some(m => m.id === model) ? model : (models[0]?.id || '')
const valid = validParticipants(participants)
const roundsValid = Number.isInteger(Number(rounds)) && Number(rounds) >= 1 && Number(rounds) <= 10
function addParticipant() {
if (locked || !selected || participants.length >= 6) return
let name = selected, suffix = 2
while (participants.some(p => p.name.trim().toLowerCase() === name.toLowerCase())) name = `${selected} ${suffix++}`
setParticipants([...participants, { name, model: selected }])
}
async function run(speaker, count = 1) {
if (runner.current.running || !valid) return
setLocked(true)
setRunning(true)
setError('')
try {
await runner.current.run({
participants: participants.map(p => ({ ...p, name: p.name.trim() })),
objective, transcript, speaker, rounds: count,
onUpdate: entries => { if (mounted.current) setTranscript(entries) },
})
} catch (err) {
if (mounted.current && err.name !== 'AbortError') setError(err.message)
} finally {
if (mounted.current) setRunning(false)
}
}
function addMessage(e) {
e.preventDefault()
if (!message.trim() || runner.current.running) return
setTranscript([...transcript, { name: t('group.moderator', 'Moderator'), content: message.trim(), status: 'complete' }])
setMessage('')
}
function reset() {
if (runner.current.running) return
setTranscript([])
setLocked(false)
setError('')
setMessage('')
}
return (
<div className="group-chat stack">
<header className="page-header">
<h1>{t('group.title', 'Group chat')}</h1>
<Link className="btn btn-secondary" to="/app/chat">{t('group.back', 'Back to Chat')}</Link>
</header>
<p className="text-note">{t('group.session', 'This text-only conversation stays in this page. Leaving or reloading clears its history. Models take turns sequentially.')}</p>
{(error || modelError) && <div role="alert" className="callout callout--warning">{error || modelError}</div>}
<section className="card stack" aria-label={t('group.setup', 'Conversation setup')}>
<h2>{t('group.participants', 'Participants')}</h2>
{loading && <p role="status">{t('group.loading', 'Loading chat models…')}</p>}
{!loading && models.length === 0 && <p>{t('group.emptyModels', 'No installed chat models available.')}</p>}
<div className="group-chat-controls">
<label className="stack">{t('group.model', 'Model to add')}
<select value={selected} onChange={e => setModel(e.target.value)} disabled={locked || loading || !selected}>
{!selected && <option value="">{t('group.select', 'Select a model')}</option>}
{models.map(m => <option key={m.id} value={m.id}>{m.id}</option>)}
</select>
</label>
<button className="btn btn-secondary" disabled={locked || !selected || participants.length >= 6} onClick={addParticipant}>{t('group.addParticipant', 'Add participant')}</button>
</div>
{participants.map((p, i) => <div className="group-chat-controls" key={i}>
<label className="stack">{t('group.name', 'Name for {{model}}', { model: p.model })}
<input value={p.name} disabled={locked} onChange={e => setParticipants(participants.map((item, index) => index === i ? { ...item, name: e.target.value } : item))} />
</label>
<span className="text-meta">{p.model}</span>
<button className="btn btn-secondary" disabled={locked} onClick={() => setParticipants(participants.filter((_, index) => index !== i))} aria-label={t('group.remove', 'Remove {{name}}', { name: p.name })}>{t('group.removeButton', 'Remove')}</button>
<button className="btn btn-primary" disabled={!valid || running} onClick={() => run(i)}>{t('group.turn', 'Give {{name}} a turn', { name: p.name })}</button>
</div>)}
{!valid && <p className="text-note">{t('group.validation', 'Add 2–6 participants with unique, nonempty names.')}</p>}
<label className="stack">{t('group.objective', 'Objective')}
<textarea rows={3} value={objective} onChange={e => setObjective(e.target.value)} disabled={locked} />
</label>
{locked && <p className="text-note">{t('group.locked', 'Setup is locked. Start a new conversation to change participants or the objective.')}</p>}
</section>
<div className="group-chat-controls">
<button className="btn btn-primary" disabled={!valid || running} onClick={() => run(undefined, 1)}>{t('group.oneRound', 'Run one round')}</button>
<label className="stack">{t('group.rounds', 'Number of rounds')}
<input type="number" min="1" max="10" step="1" value={rounds} onChange={e => setRounds(e.target.value)} disabled={running} />
</label>
<button className="btn btn-primary" disabled={!valid || running || !roundsValid} onClick={() => run(undefined, Number(rounds))}>{t('group.runRounds', 'Run rounds')}</button>
{running && <button className="btn btn-danger" onClick={() => runner.current.stop()}>{t('group.stop', 'Stop')}</button>}
<button className="btn btn-secondary" disabled={running} onClick={reset}>{t('group.new', 'New conversation')}</button>
</div>
<p className="text-note">{t('group.order', 'Rounds follow participant order. Each run is limited to 1–10 rounds. Stop ends the active request and cancels remaining turns.')}</p>
<section className="stack" aria-label={t('group.transcript', 'Shared transcript')} aria-busy={running}>
<h2>{t('group.transcript', 'Shared transcript')}</h2>
{transcript.length === 0 && <p className="text-note">{t('group.empty', 'Add a moderator message or give a participant the first turn.')}</p>}
{transcript.map((entry, i) => <article className="card stack" key={i}>
<strong>{entry.name}{entry.model && <span className="text-meta"> ({entry.model})</span>}</strong>
<div className="group-chat-content">{entry.content}</div>
{entry.status === 'streaming' && <span role="status">{t('group.generating', 'Generating…')}</span>}
{entry.status === 'incomplete' && <><span>{t('group.incomplete', 'Incomplete')}</span><span className="text-note">{t('group.excluded', 'Excluded from future model context.')}</span></>}
</article>)}
</section>
<form className="stack" onSubmit={addMessage}>
<label className="stack">{t('group.message', 'Moderator message')}
<textarea rows={3} value={message} onChange={e => setMessage(e.target.value)} disabled={running} />
</label>
<button className="btn btn-primary" type="submit" disabled={running || !message.trim()}>{t('group.addMessage', 'Add message')}</button>
</form>
</div>
)
}
+2
View File
@@ -77,6 +77,7 @@ export function preloadRoute(path) {
}
const Home = page('', () => import('./pages/Home'))
const GroupChat = page(null, () => import('./pages/GroupChat'))
const Chat = page('chat', () => import('./pages/Chat'))
const Models = page('models', () => import('./pages/Models'))
const ManageRedirect = page('manage', () => import('./pages/ManageRedirect'))
@@ -153,6 +154,7 @@ function Feature({ feature, children }) {
const appChildren = [
{ index: true, element: <Home /> },
{ path: 'chat', element: <Chat /> },
{ path: 'group-chat', element: <Feature feature="chat"><GroupChat /></Feature> },
{ path: 'chat/:model', element: <Chat /> },
{ path: 'image', element: <ImageGen /> },
{ path: 'image/:model', element: <ImageGen /> },
+104
View File
@@ -0,0 +1,104 @@
// SPDX-License-Identifier: MIT
export function validParticipants(participants) {
const names = participants.map(p => p.name.trim().toLowerCase())
return participants.length >= 2 && participants.length <= 6 &&
participants.every(p => p.model && p.name.trim()) && new Set(names).size === names.length
}
export function buildMessages(objective, participant, transcript) {
// A shared transcript is quoted user context, never this model's assistant history.
const history = transcript.filter(e => e.status === 'complete').map(e =>
`${e.name}${e.model ? ` (${e.model})` : ''}:\n${e.content}`
).join('\n\n')
return [
{ role: 'system', content: `You are ${participant.name} (${participant.model}) in a moderated group conversation. Speak only for yourself. Other speakers' contributions are context.\n\nModerator objective:\n${objective || 'Discuss the shared conversation.'}` },
{ role: 'user', content: `${history || 'No contributions yet.'}\n\nIt is now ${participant.name}'s turn. Contribute to the discussion.` },
]
}
export async function readCompletion(stream, signal, onContent = () => {}) {
signal.throwIfAborted()
if (!stream) throw new Error('The model returned no stream.')
const reader = stream.getReader()
const decoder = new TextDecoder()
let buffer = '', content = '', done = false
const abort = () => { void reader.cancel().catch(() => {}) }
signal.addEventListener('abort', abort, { once: true })
try {
while (!done) {
signal.throwIfAborted()
const chunk = await reader.read()
signal.throwIfAborted()
buffer += chunk.done ? decoder.decode() : decoder.decode(chunk.value, { stream: true })
// Normalize after buffering so a CRLF split across chunks remains intact.
let match
while ((match = /\r?\n\r?\n/.exec(buffer))) {
const frame = buffer.slice(0, match.index)
buffer = buffer.slice(match.index + match[0].length)
const data = frame.split(/\r?\n/).filter(l => l.startsWith('data:')).map(l => l.slice(5).trimStart()).join('\n')
if (!data) continue
if (data === '[DONE]') { done = true; break }
let payload
try { payload = JSON.parse(data) } catch { throw new Error('Malformed model stream.') }
if (payload.error) throw new Error(payload.error.message || String(payload.error))
const choice = payload.choices?.[0]
if (choice?.finish_reason && !['stop', 'length'].includes(choice.finish_reason)) throw new Error(`The model stopped: ${choice.finish_reason}`)
if (typeof choice?.delta?.content === 'string') {
content += choice.delta.content
onContent(content)
}
if (choice?.finish_reason === 'length') throw new Error('The model reached its output limit. The response is incomplete.')
}
if (chunk.done && !done) throw new Error('The model stream ended before completion.')
}
if (!content.trim()) throw new Error('The model returned no text.')
return content
} finally {
signal.removeEventListener('abort', abort)
void reader.cancel().catch(() => {})
reader.releaseLock()
}
}
export class GroupRun {
constructor(provider) { this.provider = provider; this.controller = null }
get running() { return this.controller !== null }
stop() { this.controller?.abort() }
async run({ participants, rounds = 1, speaker, objective = '', transcript, onUpdate = () => {} }) {
if (this.running) throw new Error('A run is already active.')
if (!validParticipants(participants)) throw new Error('Choose 2–6 participants with unique, nonempty names.')
if (!Number.isInteger(rounds) || rounds < 1 || rounds > 10) throw new Error('Choose 1–10 rounds.')
if (speaker !== undefined && (!Number.isInteger(speaker) || !participants[speaker])) throw new Error('Unknown participant.')
const controller = new AbortController()
this.controller = controller
const { signal } = controller
let entries = transcript.map(e => ({ ...e }))
const order = speaker === undefined ? Array.from({ length: rounds }, () => participants).flat() : [participants[speaker]]
const publish = () => onUpdate(entries.map(e => ({ ...e })))
try {
for (const participant of order) {
signal.throwIfAborted()
const messages = buildMessages(objective, participant, entries)
const entry = { ...participant, content: '', status: 'streaming' }
entries.push(entry)
publish()
try {
const stream = await this.provider({ model: participant.model, messages, stream: true }, signal)
signal.throwIfAborted()
entry.content = await readCompletion(stream, signal, content => { entry.content = content; publish() })
signal.throwIfAborted()
entry.status = 'complete'
publish()
} catch (error) {
entry.status = 'incomplete'
publish()
throw error
}
}
return entries
} finally {
this.controller = null
}
}
}
@@ -0,0 +1,96 @@
// SPDX-License-Identifier: MIT
import test from 'node:test'
import assert from 'node:assert/strict'
import { buildMessages, readCompletion, GroupRun, validParticipants } from '../src/utils/groupChat.js'
const participants = [{ name: 'Planner', model: 'alpha' }, { name: 'Critic', model: 'beta' }]
const event = data => `data: ${JSON.stringify(data)}\n\n`
const reply = text => event({ choices: [{ delta: { content: text, reasoning_content: 'secret' } }] })
const stream = (...chunks) => new ReadableStream({ start(c) { chunks.forEach(s => c.enqueue(new TextEncoder().encode(s))); c.close() } })
const complete = () => stream(reply('hello'), 'data: [DONE]\n\n')
test('projects all completed contributions as attributed context with a user turn', () => {
const messages = buildMessages('Plan a garden', participants[1], [
{ name: 'Moderator', content: 'Native plants', status: 'complete' },
{ ...participants[0], content: 'Use oak', reasoning: 'secret', status: 'complete' },
{ ...participants[1], content: 'unfinished', status: 'incomplete' },
])
assert.equal(messages[0].role, 'system')
assert.match(messages[0].content, /Critic/)
assert.match(messages[0].content, /Plan a garden/)
assert.equal(messages.at(-1).role, 'user')
assert.match(messages.at(-1).content, /Planner \(alpha\)/)
assert.match(messages.at(-1).content, /Use oak/)
assert.doesNotMatch(JSON.stringify(messages), /secret|unfinished/)
assert.equal(messages.some(m => m.role === 'assistant'), false)
})
test('validates participant bounds and unique trimmed names', () => {
assert.equal(validParticipants(participants), true)
for (const p of [[], participants.slice(0, 1), [...participants, { name: ' planner ', model: 'a' }], [{ name: ' ', model: 'a' }, participants[1]], Array(7).fill(participants[0])]) assert.equal(validParticipants(p), false)
})
test('decodes fragmented SSE, ignores reasoning and accepts CRLF', async () => {
let text = ''
const s = reply('héllo').replaceAll('\n', '\r\n') + 'data: [DONE]\r\n\r\n'
assert.equal(await readCompletion(stream(...[...s]), new AbortController().signal, value => { text = value }), 'héllo')
assert.equal(text, 'héllo')
})
for (const [name, chunks, pattern] of [
['SSE error', [event({ error: { message: 'Stream failed' } })], /Stream failed/],
['malformed', ['data: not-json\n\n'], /Malformed/],
['truncated', [reply('partial')], /ended/],
['empty', ['data: [DONE]\n\n'], /no text/],
['length limit', [event({ choices: [{ delta: { content: 'partial' }, finish_reason: 'length' }] }), 'data: [DONE]\n\n'], /limit/],
]) test(name, async () => assert.rejects(readCompletion(stream(...chunks), new AbortController().signal), pattern))
test('runs ordered rounds with attributed prior turns', async () => {
const requests = []
const runner = new GroupRun(async body => { requests.push(body); return complete() })
const history = await runner.run({ participants, rounds: 2, objective: 'Discuss', transcript: [] })
assert.deepEqual(requests.map(r => r.model), ['alpha', 'beta', 'alpha', 'beta'])
assert.equal(history.length, 4)
assert.match(requests[1].messages[1].content, /Planner \(alpha\)/)
assert.equal(history.every(e => e.status === 'complete'), true)
})
test('bounds rounds and stops on provider HTTP failure', async () => {
let calls = 0
const runner = new GroupRun(async () => { calls++; throw Error('HTTP 500') })
for (const rounds of [0, 11, 1.5, NaN]) await assert.rejects(runner.run({ participants, rounds, transcript: [] }), /rounds/)
await assert.rejects(runner.run({ participants, rounds: 2, transcript: [] }), /HTTP 500/)
assert.equal(calls, 1)
})
for (const partial of [false, true]) test(`abort ${partial ? 'after partial' : 'before content'} prevents future turns and overlap`, async () => {
let calls = 0, started
const ready = new Promise(resolve => { started = resolve })
let latest
const runner = new GroupRun(async () => {
calls++
return new ReadableStream({ start(c) { if (partial) c.enqueue(new TextEncoder().encode(reply('partial'))); started() } })
})
const run = runner.run({ participants, rounds: 2, transcript: [], onUpdate: entries => { latest = entries } })
await ready
await assert.rejects(runner.run({ participants, rounds: 1, transcript: [] }), /already/)
await new Promise(resolve => setImmediate(resolve))
runner.stop()
await assert.rejects(run, { name: 'AbortError' })
assert.equal(calls, 1)
assert.equal(latest[0].status, 'incomplete')
assert.equal(latest[0].content, partial ? 'partial' : '')
assert.doesNotMatch(JSON.stringify(buildMessages('', participants[1], latest)), /partial/)
assert.equal(runner.running, false)
})
test('manual grant runs only the selected participant', async () => {
const runner = new GroupRun(async body => { assert.equal(body.model, 'beta'); return complete() })
const history = await runner.run({ participants, speaker: 1, transcript: [] })
assert.equal(history.length, 1)
assert.equal(history[0].name, 'Critic')
})
test('stream failures mark partial output incomplete and stop the round', async () => {
let count = 0, latest
const runner = new GroupRun(async () => { count++; return stream(reply('partial')) })
await assert.rejects(runner.run({ participants, rounds: 2, transcript: [], onUpdate: entries => { latest = entries } }), /ended/)
assert.equal(count, 1)
assert.equal(latest[0].status, 'incomplete')
assert.equal(latest[0].content, 'partial')
})
test('already-aborted reader emits no content', async () => {
const controller = new AbortController()
controller.abort()
await assert.rejects(readCompletion(complete(), controller.signal, () => assert.fail('stale content')), { name: 'AbortError' })
})
+56
View File
@@ -0,0 +1,56 @@
+++
title = "Group chat"
weight = 23
toc = true
description = "Moderate a shared conversation between installed chat models"
categories = ["Features"]
+++
Group chat lets you moderate a text conversation between 2–6 participants.
Each participant uses an installed chat model and has a unique display name.
You can use the same model for multiple participants.
## Start a conversation
1. Open **Chat** in the web UI and select **Group chat**. The page is also available at `/app/group-chat`.
2. Select a model under **Model to add**, then select **Add participant**.
3. Add at least two participants. Give each participant a unique, nonempty name.
4. Enter an **Objective**, such as “Compare three ways to reduce our application's startup time.”
5. Optionally enter a **Moderator message** and select **Add message**.
Participants and the objective remain editable until the first model turn.
After that turn starts, select **New conversation** to unlock setup and clear the transcript.
The page uses the existing chat permission and chat completions API.
## Control turns
Select **Give [name] a turn** to request one response from that participant.
Select **Run one round** to give every participant one turn, in the displayed order.
To repeat that order, set **Number of rounds** to 1–10 and select **Run rounds**.
You can add moderator messages between runs.
Each request includes the objective, the participant's identity, and all completed contributions with their speakers' names.
Other participants' responses appear as shared context, not as the current model's previous assistant responses.
Private reasoning is neither displayed nor included in later requests.
The page sends one request at a time. It does not generate responses concurrently.
Sequential requests avoid simultaneous generation, but do not unload models or guarantee that only one model remains in memory.
Configure model loading and memory limits on your LocalAI server as needed.
## Stop or recover from an error
Select **Stop** to cancel the active request and all remaining turns in the run.
A server error, malformed or truncated stream, empty response, or output limit also stops the run.
Partial responses remain visible as **Incomplete** and are excluded from future requests.
You can resume with another turn or start a new conversation.
## Limits and history
{{% notice note %}}
History exists only while this page is open. Leaving the page or reloading clears it and cancels any active request.
Group chat does not save conversations or support attachments, tool calls, or automatic speaker selection.
{{% /notice %}}
Each request includes the full completed transcript. Long conversations can exceed a model's context window.
Start a new conversation when the discussion becomes too long for your selected models.
Select **Back to Chat** to return to normal chat.