mirror of
https://github.com/mudler/LocalAI.git
synced 2026-09-25 07:34:58 -04:00
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:
1 parent
8b01583e70
commit
bf36e69491
8 files changed
+573
-1
No files matched your search
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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; }
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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' })
|
||||
})
|
||||
@@ -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.
|
||||
Reference in new issue
Block a user