mirror of
https://github.com/mudler/LocalAI.git
synced 2026-11-05 20:13:23 -05:00
feat(ui): warn when two connections are the same bot
The connection pages of the messaging add-ons and the connections list show "This bot is also connected as <name>. Both connections will answer the same message." on both connections, with a link to the other and a "Turn this one off" button. Saving a connection that would be a twin of another enabled one asks first: the server answers 409 with the type duplicate_connection, and the page offers "Save anyway", which sends confirm_duplicate. The new texts are in all nine locales of the web UI and of the shared connector pages. Assisted-by: Claude:claude-sonnet-5-5
This commit is contained in:
28 files changed
+371
-27
No files matched your search
@@ -19,7 +19,7 @@ import { Check, Field, HintLabels, Notice, RadioCards, Select, Switch, TextInput
|
||||
import NameList from './NameList'
|
||||
import PermissionFields, { NoCapsNotice } from './PermissionFields'
|
||||
import { agentsApi, connectorsApi, setApiLanguage } from './api'
|
||||
import { canSave, channelOnly, connectorForm, hasNoCaps, connectorPayload, emptyConnector, listEntries, replaceList, senderLine, usualCaps } from './connector'
|
||||
import { DUPLICATE_CONFIRMATION, canSave, channelOnly, duplicateOther, duplicateRefusalName, isDuplicateRefusal, connectorForm, hasNoCaps, connectorPayload, emptyConnector, listEntries, replaceList, senderLine, usualCaps } from './connector'
|
||||
import { registerConnectorLocales } from './i18n'
|
||||
import './connector.css'
|
||||
|
||||
@@ -67,6 +67,23 @@ function SingleToken({ form, setForm, t }) {
|
||||
)
|
||||
}
|
||||
|
||||
// Two enabled connections that are the same bot: both answer every message.
|
||||
// The warning names the other one, offers to switch this one off and opens the
|
||||
// other. The host decides what is a duplicate (c.duplicate); this only shows it.
|
||||
function SameBotNotice({ c, s, busy, onOff, onOpen }) {
|
||||
const other = duplicateOther(c)
|
||||
if (!other) return null
|
||||
return (
|
||||
<Notice tone="warn" role="status" testId="duplicate-warning">
|
||||
<p className="cn-notice__text">{s('dup.text', { name: other.name })}</p>
|
||||
<span className="cn-row__actions">
|
||||
<button type="button" className="dk-btn dk-btn--secondary dk-btn--sm" data-testid="duplicate-off" disabled={busy} onClick={onOff}>{s('dup.turnOff')}</button>
|
||||
<button type="button" className="cn-link cn-link--text" data-testid="duplicate-open" onClick={() => onOpen(other.id)}>{s('dup.openOther', { name: other.name })}</button>
|
||||
</span>
|
||||
</Notice>
|
||||
)
|
||||
}
|
||||
|
||||
const ONE_LIST = [{ key: 'allow', label: 'allow', hint: 'allowHint', testId: 'connector-allow' }]
|
||||
|
||||
/**
|
||||
@@ -102,6 +119,8 @@ export default function ConnectorPage({ kind, ns, testId, TokenFields = SingleTo
|
||||
const [confirmed, setConfirmed] = useState(false)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [toDelete, setToDelete] = useState(null)
|
||||
// The question after a save that would make this connection the same bot as another: { other }.
|
||||
const [dupAsk, setDupAsk] = useState(null)
|
||||
const [openSenders, setOpenSenders] = useState('')
|
||||
const [kindInfo, setKindInfo] = useState(null)
|
||||
const [capabilities, setCapabilities] = useState([])
|
||||
@@ -162,7 +181,15 @@ export default function ConnectorPage({ kind, ns, testId, TokenFields = SingleTo
|
||||
}, [items])
|
||||
|
||||
const agentName = (id) => agents.find((a) => a.id === id)?.name || id
|
||||
const open = (c) => { setForm(c); setConfirmed(Boolean(c.accept_any)); setNote(''); setPairedList(null) }
|
||||
const open = (c) => { setForm(c); setConfirmed(Boolean(c.accept_any)); setNote(''); setPairedList(null); setDupAsk(null) }
|
||||
// The question sits by the Save button, at the end of a long form: bring it into view.
|
||||
useEffect(() => {
|
||||
if (dupAsk) document.querySelector('[data-testid="duplicate-confirm"]')?.scrollIntoView?.({ block: 'nearest' })
|
||||
}, [dupAsk])
|
||||
const openById = (id) => {
|
||||
const c = items.find((x) => x.id === id)
|
||||
if (c) open({ ...connectorForm(c), ...(extraForm ? extraForm(c) : {}) })
|
||||
}
|
||||
const extra = (form) => ({ ...(messaging ? {} : { enabled: false, accept_any: false }), ...(extraBody ? extraBody(form) : {}) })
|
||||
const check = async (c) => {
|
||||
try {
|
||||
@@ -179,14 +206,16 @@ export default function ConnectorPage({ kind, ns, testId, TokenFields = SingleTo
|
||||
|
||||
// The server refuses a token that the platform refuses. After a save with
|
||||
// a new token, the page names the bot the token belongs to.
|
||||
const save = async (e) => {
|
||||
e.preventDefault()
|
||||
const save = (e) => { e.preventDefault(); return doSave(false) }
|
||||
// confirmDuplicate answers the question of a save that makes the connection a twin of another.
|
||||
const doSave = async (confirmDuplicate) => {
|
||||
setBusy(true)
|
||||
setError('')
|
||||
setNote('')
|
||||
setDupAsk(null)
|
||||
try {
|
||||
const token = tokenOf(form).trim()
|
||||
const body = connectorPayload(form, kind, token, extra(form))
|
||||
const body = connectorPayload(form, kind, token, { ...extra(form), ...(confirmDuplicate ? { confirm_duplicate: DUPLICATE_CONFIRMATION } : {}) })
|
||||
const saved = form.id ? await connectorsApi.update(form.id, body) : await connectorsApi.create(body)
|
||||
setForm(null)
|
||||
await load()
|
||||
@@ -195,7 +224,10 @@ export default function ConnectorPage({ kind, ns, testId, TokenFields = SingleTo
|
||||
setNote(await check(saved))
|
||||
} catch (err) { setError(t('checkFailed', { reason: err?.message || '' })) }
|
||||
}
|
||||
} catch (err) { fail(err) } finally { setBusy(false) }
|
||||
} catch (err) {
|
||||
if (isDuplicateRefusal(err)) setDupAsk({ other: duplicateRefusalName(err) })
|
||||
else fail(err)
|
||||
} finally { setBusy(false) }
|
||||
}
|
||||
|
||||
// The preset for a saved binding that has no permission on: the usual set,
|
||||
@@ -260,6 +292,9 @@ export default function ConnectorPage({ kind, ns, testId, TokenFields = SingleTo
|
||||
{form && (
|
||||
<div className="cn-formgrid">
|
||||
<form className="cn-form" onSubmit={save} aria-label={form.id ? t('edit') : t('new')} data-testid="connector-form">
|
||||
{messaging && form.id && (
|
||||
<SameBotNotice c={savedItem} s={s} busy={busy} onOff={() => { setForm(null); act(() => connectorsApi.disable(form.id)) }} onOpen={openById} />
|
||||
)}
|
||||
{messaging && capabilities.length > 0 && (
|
||||
<ReadinessList s={s} ctx={fixCtx} rowsInput={readyInput} />
|
||||
)}
|
||||
@@ -307,6 +342,16 @@ export default function ConnectorPage({ kind, ns, testId, TokenFields = SingleTo
|
||||
<AccessView binding={form} capabilities={capabilities} s={s} version={accessVersion} />
|
||||
</>)}
|
||||
</>)}
|
||||
{dupAsk && (
|
||||
<Notice tone="warn" role="alert" testId="duplicate-confirm">
|
||||
<p className="cn-notice__text">{s('dup.text', { name: dupAsk.other })}</p>
|
||||
<p className="cn-notice__text">{s('dup.confirmHelp')}</p>
|
||||
<span className="cn-row__actions">
|
||||
<button type="button" className="dk-btn dk-btn--primary dk-btn--sm" data-testid="duplicate-save-anyway" disabled={busy} onClick={() => doSave(true)}>{s('dup.saveAnyway')}</button>
|
||||
<button type="button" className="dk-btn dk-btn--secondary dk-btn--sm" onClick={() => setDupAsk(null)}>{s('dup.goBack')}</button>
|
||||
</span>
|
||||
</Notice>
|
||||
)}
|
||||
<div className="cn-actions">
|
||||
<button type="submit" className="dk-btn dk-btn--primary" data-testid="connector-save" aria-busy={busy ? 'true' : undefined} disabled={busy || !canSave(form, confirmed, Boolean(tokenOf(form).trim()), kindFiles, agentOptional) || (extraValid ? !extraValid(form) : false)}>{t('save')}</button>
|
||||
<button type="button" className="dk-btn dk-btn--secondary" onClick={() => setForm(null)}>{t('cancel')}</button>
|
||||
@@ -341,6 +386,7 @@ export default function ConnectorPage({ kind, ns, testId, TokenFields = SingleTo
|
||||
{c.credentials_only ? answerT(t)('credentialsOnly') : t('agentOf', { name: agentName(c.agent_id) })}
|
||||
</span>}
|
||||
{RowInfo && <RowInfo c={c} t={t} />}
|
||||
{messaging && form?.id !== c.id && <SameBotNotice c={c} s={s} busy={busy} onOff={() => act(() => connectorsApi.disable(c.id))} onOpen={openById} />}
|
||||
{messaging && capabilities.length > 0 && hasNoCaps(c) && (
|
||||
<NoCapsNotice capabilities={capabilities} s={s} busy={busy} name={c.name} onUsual={() => allowUsual(c)} />
|
||||
)}
|
||||
|
||||
@@ -76,6 +76,8 @@ fieldset.cn-group {
|
||||
|
||||
.cn-link { background: none; border: 0; padding: 0 var(--dk-space-1); color: inherit; cursor: pointer; font: inherit; }
|
||||
.ui-callout__text .cn-link { margin-left: var(--dk-space-2); }
|
||||
/* The link to the other connection of the same bot: it reads as a link, not as a dismiss mark. */
|
||||
.ui-callout__text .cn-link--text { margin-left: 0; text-align: left; text-decoration: underline; overflow-wrap: anywhere; }
|
||||
.ui-callout__text .cn-notice__text { margin: 0 0 var(--dk-space-2); }
|
||||
|
||||
@media (max-width: 900px) {
|
||||
|
||||
@@ -25,6 +25,15 @@ export function localizeAccess(view) {
|
||||
}
|
||||
}
|
||||
|
||||
// Two enabled connections that are the same bot answer every message twice.
|
||||
// The host marks each of them with `duplicate` ({ code, message, others }); the
|
||||
// page only shows it. Saving one such connection first gets a 409 of type
|
||||
// duplicate_connection, and the same request with the confirmation goes through.
|
||||
export const DUPLICATE_CONFIRMATION = 'answer twice'
|
||||
export const duplicateOther = (c) => c?.duplicate?.others?.[0] || null
|
||||
export const isDuplicateRefusal = (err) => err?.status === 409 && err.body?.error?.type === 'duplicate_connection'
|
||||
export const duplicateRefusalName = (err) => (typeof err?.body?.error?.param === 'string' ? err.body.error.param : '')
|
||||
|
||||
export const MAX_WINDOW_MINUTES = 7 * 24 * 60
|
||||
|
||||
// How the triggers that the agent proposes in a chat are approved, and the
|
||||
|
||||
@@ -297,5 +297,11 @@
|
||||
"approvals.line.runs": "For the owner's own chats and for senders paired with the bot. Everyone else is still asked.",
|
||||
"approvals.line.idle": "The write mode of this connection is Read only, so no write tool runs.",
|
||||
"approvals.line.other": "Only the owner and a paired sender skip the card. This kind of sender is asked.",
|
||||
"access.viaNoConfirm": "ran without asking"
|
||||
"access.viaNoConfirm": "ran without asking",
|
||||
"dup.text": "Dieser Bot ist auch als {{name}} verbunden. Beide Verbindungen beantworten dieselbe Nachricht.",
|
||||
"dup.turnOff": "Diese ausschalten",
|
||||
"dup.openOther": "{{name}} öffnen",
|
||||
"dup.saveAnyway": "Trotzdem speichern",
|
||||
"dup.goBack": "Zurück",
|
||||
"dup.confirmHelp": "Speichere nur dann trotzdem, wenn du auf jede Nachricht zwei Antworten haben willst."
|
||||
}
|
||||
@@ -297,5 +297,11 @@
|
||||
"approvals.line.runs": "For the owner's own chats and for senders paired with the bot. Everyone else is still asked.",
|
||||
"approvals.line.idle": "The write mode of this connection is Read only, so no write tool runs.",
|
||||
"approvals.line.other": "Only the owner and a paired sender skip the card. This kind of sender is asked.",
|
||||
"access.viaNoConfirm": "ran without asking"
|
||||
"access.viaNoConfirm": "ran without asking",
|
||||
"dup.text": "This bot is also connected as {{name}}. Both connections will answer the same message.",
|
||||
"dup.turnOff": "Turn this one off",
|
||||
"dup.openOther": "Open {{name}}",
|
||||
"dup.saveAnyway": "Save anyway",
|
||||
"dup.goBack": "Go back",
|
||||
"dup.confirmHelp": "Save it anyway only if you want to get two answers to each message."
|
||||
}
|
||||
@@ -297,5 +297,11 @@
|
||||
"approvals.line.runs": "For the owner's own chats and for senders paired with the bot. Everyone else is still asked.",
|
||||
"approvals.line.idle": "The write mode of this connection is Read only, so no write tool runs.",
|
||||
"approvals.line.other": "Only the owner and a paired sender skip the card. This kind of sender is asked.",
|
||||
"access.viaNoConfirm": "ran without asking"
|
||||
"access.viaNoConfirm": "ran without asking",
|
||||
"dup.text": "Este bot también está conectado como {{name}}. Ambas conexiones responderán al mismo mensaje.",
|
||||
"dup.turnOff": "Desactivar esta",
|
||||
"dup.openOther": "Abrir {{name}}",
|
||||
"dup.saveAnyway": "Guardar de todos modos",
|
||||
"dup.goBack": "Volver",
|
||||
"dup.confirmHelp": "Guarda de todos modos solo si quieres recibir dos respuestas a cada mensaje."
|
||||
}
|
||||
@@ -297,5 +297,11 @@
|
||||
"approvals.line.runs": "For the owner's own chats and for senders paired with the bot. Everyone else is still asked.",
|
||||
"approvals.line.idle": "The write mode of this connection is Read only, so no write tool runs.",
|
||||
"approvals.line.other": "Only the owner and a paired sender skip the card. This kind of sender is asked.",
|
||||
"access.viaNoConfirm": "ran without asking"
|
||||
"access.viaNoConfirm": "ran without asking",
|
||||
"dup.text": "Ce bot est aussi connecté sous le nom {{name}}. Les deux connexions répondront au même message.",
|
||||
"dup.turnOff": "Désactiver celle-ci",
|
||||
"dup.openOther": "Ouvrir {{name}}",
|
||||
"dup.saveAnyway": "Enregistrer quand même",
|
||||
"dup.goBack": "Retour",
|
||||
"dup.confirmHelp": "N'enregistrez quand même que si vous voulez recevoir deux réponses à chaque message."
|
||||
}
|
||||
@@ -297,5 +297,11 @@
|
||||
"approvals.line.runs": "For the owner's own chats and for senders paired with the bot. Everyone else is still asked.",
|
||||
"approvals.line.idle": "The write mode of this connection is Read only, so no write tool runs.",
|
||||
"approvals.line.other": "Only the owner and a paired sender skip the card. This kind of sender is asked.",
|
||||
"access.viaNoConfirm": "ran without asking"
|
||||
"access.viaNoConfirm": "ran without asking",
|
||||
"dup.text": "Bot ini juga terhubung sebagai {{name}}. Kedua koneksi akan menjawab pesan yang sama.",
|
||||
"dup.turnOff": "Matikan yang ini",
|
||||
"dup.openOther": "Buka {{name}}",
|
||||
"dup.saveAnyway": "Tetap simpan",
|
||||
"dup.goBack": "Kembali",
|
||||
"dup.confirmHelp": "Tetap simpan hanya jika Anda ingin menerima dua jawaban untuk setiap pesan."
|
||||
}
|
||||
@@ -297,5 +297,11 @@
|
||||
"approvals.line.runs": "Per le chat del proprietario e per i mittenti abbinati al bot. Tutti gli altri vengono comunque interpellati.",
|
||||
"approvals.line.idle": "La modalità di scrittura di questa connessione è Sola lettura, quindi nessuno strumento di scrittura parte.",
|
||||
"approvals.line.other": "Solo il proprietario e un mittente abbinato saltano la scheda. Questo tipo di mittente viene interpellato.",
|
||||
"access.viaNoConfirm": "eseguito senza chiedere"
|
||||
"access.viaNoConfirm": "eseguito senza chiedere",
|
||||
"dup.text": "Questo bot è collegato anche come {{name}}. Entrambe le connessioni risponderanno allo stesso messaggio.",
|
||||
"dup.turnOff": "Disattiva questa",
|
||||
"dup.openOther": "Apri {{name}}",
|
||||
"dup.saveAnyway": "Salva comunque",
|
||||
"dup.goBack": "Indietro",
|
||||
"dup.confirmHelp": "Salva comunque solo se vuoi ricevere due risposte a ogni messaggio."
|
||||
}
|
||||
@@ -297,5 +297,11 @@
|
||||
"approvals.line.runs": "For the owner's own chats and for senders paired with the bot. Everyone else is still asked.",
|
||||
"approvals.line.idle": "The write mode of this connection is Read only, so no write tool runs.",
|
||||
"approvals.line.other": "Only the owner and a paired sender skip the card. This kind of sender is asked.",
|
||||
"access.viaNoConfirm": "ran without asking"
|
||||
"access.viaNoConfirm": "ran without asking",
|
||||
"dup.text": "이 봇은 {{name}}(으)로도 연결되어 있습니다. 두 연결이 같은 메시지에 모두 답합니다.",
|
||||
"dup.turnOff": "이 연결 끄기",
|
||||
"dup.openOther": "{{name}} 열기",
|
||||
"dup.saveAnyway": "그래도 저장",
|
||||
"dup.goBack": "돌아가기",
|
||||
"dup.confirmHelp": "메시지마다 답을 두 번 받고 싶을 때만 그래도 저장하세요."
|
||||
}
|
||||
@@ -297,5 +297,11 @@
|
||||
"approvals.line.runs": "For the owner's own chats and for senders paired with the bot. Everyone else is still asked.",
|
||||
"approvals.line.idle": "The write mode of this connection is Read only, so no write tool runs.",
|
||||
"approvals.line.other": "Only the owner and a paired sender skip the card. This kind of sender is asked.",
|
||||
"access.viaNoConfirm": "ran without asking"
|
||||
"access.viaNoConfirm": "ran without asking",
|
||||
"dup.text": "Este bot também está conectado como {{name}}. As duas conexões vão responder à mesma mensagem.",
|
||||
"dup.turnOff": "Desativar esta",
|
||||
"dup.openOther": "Abrir {{name}}",
|
||||
"dup.saveAnyway": "Salvar mesmo assim",
|
||||
"dup.goBack": "Voltar",
|
||||
"dup.confirmHelp": "Salve mesmo assim só se você quiser receber duas respostas para cada mensagem."
|
||||
}
|
||||
@@ -297,5 +297,11 @@
|
||||
"approvals.line.runs": "For the owner's own chats and for senders paired with the bot. Everyone else is still asked.",
|
||||
"approvals.line.idle": "The write mode of this connection is Read only, so no write tool runs.",
|
||||
"approvals.line.other": "Only the owner and a paired sender skip the card. This kind of sender is asked.",
|
||||
"access.viaNoConfirm": "ran without asking"
|
||||
"access.viaNoConfirm": "ran without asking",
|
||||
"dup.text": "这个机器人也以 {{name}} 的名称连接。两个连接都会回复同一条消息。",
|
||||
"dup.turnOff": "关闭这个连接",
|
||||
"dup.openOther": "打开 {{name}}",
|
||||
"dup.saveAnyway": "仍然保存",
|
||||
"dup.goBack": "返回",
|
||||
"dup.confirmHelp": "只有在你想让每条消息收到两个回复时,才仍然保存。"
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { localizeAccess, localizeCapabilities, approvalForm, approvalPayload, approvalProblem, bytesToMib, canSave, connectorForm, connectorPayload, emptyConnector, REPLY_LANGUAGES, fileDirection, fileLimitProblem, formatBytes, mibToBytes, modeText, previewLimit, senderLine } from '../../../internal/connector-ui/src/connector.js'
|
||||
import { DUPLICATE_CONFIRMATION, duplicateOther, duplicateRefusalName, isDuplicateRefusal, localizeAccess, localizeCapabilities, approvalForm, approvalPayload, approvalProblem, bytesToMib, canSave, connectorForm, connectorPayload, emptyConnector, REPLY_LANGUAGES, fileDirection, fileLimitProblem, formatBytes, mibToBytes, modeText, previewLimit, senderLine } from '../../../internal/connector-ui/src/connector.js'
|
||||
|
||||
test('chat connectors: the token goes only when it was typed', () => {
|
||||
const create = connectorPayload({ ...emptyConnector(), name: ' Bot ', token: ' 1:abc ', agent_id: 'a1', allow: ['11', ' 11', '@ada_l', ''] }, 'telegram')
|
||||
@@ -231,3 +231,26 @@ test('chat connectors: the host text in the page language replaces the English o
|
||||
assert.equal(localizeAccess(null), null)
|
||||
assert.deepEqual(localizeCapabilities(undefined), [])
|
||||
})
|
||||
|
||||
test('chat connectors: the host names the other connection of a bot that is connected twice', () => {
|
||||
assert.equal(duplicateOther({ id: 'a' }), null)
|
||||
assert.equal(duplicateOther({ id: 'a', duplicate: { others: [] } }), null)
|
||||
assert.deepEqual(duplicateOther({ id: 'a', duplicate: { code: 'duplicate_connection', others: [{ id: 'b', name: 'Team app' }] } }), { id: 'b', name: 'Team app' })
|
||||
})
|
||||
|
||||
test('chat connectors: only a 409 of the type duplicate_connection is the question about a twin', () => {
|
||||
const refusal = (status, type, param) => Object.assign(new Error('x'), { status, body: { error: { type, param } } })
|
||||
assert.equal(isDuplicateRefusal(refusal(409, 'duplicate_connection', 'Team app')), true)
|
||||
assert.equal(isDuplicateRefusal(refusal(409, 'connectors_off')), false)
|
||||
assert.equal(isDuplicateRefusal(refusal(400, 'duplicate_connection')), false)
|
||||
assert.equal(isDuplicateRefusal(new Error('network')), false)
|
||||
assert.equal(duplicateRefusalName(refusal(409, 'duplicate_connection', 'Team app')), 'Team app')
|
||||
assert.equal(duplicateRefusalName(refusal(409, 'duplicate_connection')), '')
|
||||
})
|
||||
|
||||
test('chat connectors: the confirmation of a twin is the text the host expects, and goes with the save only when given', () => {
|
||||
assert.equal(DUPLICATE_CONFIRMATION, 'answer twice')
|
||||
const body = connectorPayload({ ...emptyConnector(), name: 'B', agent_id: 'a', token: '1:x' }, 'telegram', '1:x', { confirm_duplicate: DUPLICATE_CONFIRMATION })
|
||||
assert.equal(body.confirm_duplicate, 'answer twice')
|
||||
assert.equal('confirm_duplicate' in connectorPayload({ ...emptyConnector(), name: 'B', agent_id: 'a', token: '1:x' }, 'telegram', '1:x', {}), false)
|
||||
})
|
||||
@@ -73,6 +73,34 @@ test.describe('Chat connectors', () => {
|
||||
await expect.poll(() => s.calls.includes('disable')).toBe(true)
|
||||
})
|
||||
|
||||
test('warns on a binding that is the same bot as another, links to it and turns it off', async ({ page }) => {
|
||||
const twin = { ...BINDING, id: 'b2', name: 'Support bot copy' }
|
||||
const pair = (x, y) => ({ ...x, duplicate: { code: 'duplicate_connection', message: '', others: [{ id: y.id, name: y.name }] } })
|
||||
const s = await api(page, { items: [pair(BINDING, twin), pair(twin, BINDING)] })
|
||||
await page.route(/\/api\/chats\/connectors\/[^/?]+\/disable$/, r => {
|
||||
s.calls.push('disable')
|
||||
s.items = s.items.map(x => ({ ...x, enabled: false, duplicate: undefined }))
|
||||
return r.fulfill({ json: s.items[0] })
|
||||
})
|
||||
await page.goto('/app/chats/connectors')
|
||||
const rows = page.getByTestId('connector-row')
|
||||
await expect(rows).toHaveCount(2)
|
||||
await expect(rows.first().getByTestId('connector-duplicate')).toContainText('This bot is also connected as Support bot copy. Both connections will answer the same message.')
|
||||
await expect(rows.last().getByTestId('connector-duplicate')).toContainText('This bot is also connected as Support bot. Both connections will answer the same message.')
|
||||
await expect(rows.first().getByTestId('connector-duplicate-open')).toHaveAttribute('href', '/app/addons/telegram/bots?binding=b2')
|
||||
await expect(rows.last().getByTestId('connector-duplicate-open')).toHaveAttribute('href', '/app/addons/telegram/bots?binding=b1')
|
||||
|
||||
await page.setViewportSize({ width: 360, height: 800 })
|
||||
await page.reload()
|
||||
await expect(rows).toHaveCount(2)
|
||||
await expect(page.getByTestId('connector-duplicate')).toHaveCount(2)
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth)).toBeLessThanOrEqual(0)
|
||||
|
||||
await rows.last().getByRole('button', { name: 'Turn this one off' }).click()
|
||||
await expect.poll(() => s.calls.includes('disable')).toBe(true)
|
||||
await expect(page.getByTestId('connector-duplicate')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('an old link to the MCP kind opens the MCP connectors page', async ({ page }) => {
|
||||
await api(page)
|
||||
await page.route('**/api/chats/mcp-connectors*', r => r.fulfill({ json: { connectors: [] } }))
|
||||
|
||||
@@ -85,6 +85,97 @@ test.describe('Slack add-on', () => {
|
||||
expect(put.body.allow).toEqual(['U001'])
|
||||
})
|
||||
|
||||
// The host marks two enabled bindings of one bot with `duplicate`; the page shows it on both.
|
||||
const TWIN = { ...BINDING, id: 'b2', name: 'Team app copy' }
|
||||
const duplicate = (other) => ({ code: 'duplicate_connection', message: `This bot is also connected as ${other.name}. Both connections will answer the same message.`, others: [other] })
|
||||
async function twins(page, a) {
|
||||
const state = { items: [{ ...BINDING }, { ...TWIN }], disabled: [] }
|
||||
const view = () => state.items.map(c => {
|
||||
const other = state.items.find(o => o.id !== c.id)
|
||||
return c.enabled && other?.enabled ? { ...c, duplicate: duplicate({ id: other.id, name: other.name }) } : c
|
||||
})
|
||||
await a.install()
|
||||
await page.route(/\/api\/chats\/connectors\/[^/?]+\/disable$/, r => {
|
||||
const id = new URL(r.request().url()).pathname.split('/').at(-2)
|
||||
state.disabled.push(id)
|
||||
state.items = state.items.map(c => (c.id === id ? { ...c, enabled: false } : c))
|
||||
return r.fulfill({ json: state.items.find(c => c.id === id) })
|
||||
})
|
||||
await page.route(/\/api\/chats\/connectors(\?.*)?$/, r => {
|
||||
if (r.request().method() !== 'GET') return r.fallback()
|
||||
return r.fulfill({ json: { connectors: view() } })
|
||||
})
|
||||
return state
|
||||
}
|
||||
|
||||
test('warns on both connections of the same bot, opens the other and turns this one off', async ({ page }) => {
|
||||
const a = api(page)
|
||||
const state = await twins(page, a)
|
||||
await page.goto(PAGE)
|
||||
|
||||
const rows = page.getByTestId('connector-row')
|
||||
await expect(rows).toHaveCount(2)
|
||||
const warnings = page.getByTestId('duplicate-warning')
|
||||
await expect(warnings).toHaveCount(2)
|
||||
await expect(warnings.first()).toContainText('This bot is also connected as Team app copy. Both connections will answer the same message.')
|
||||
await expect(warnings.last()).toContainText('This bot is also connected as Team app. Both connections will answer the same message.')
|
||||
|
||||
// The link opens the other connection, and its own form shows the warning too.
|
||||
await rows.first().getByTestId('duplicate-open').click()
|
||||
const form = page.getByTestId('connector-form')
|
||||
await expect(form.getByLabel('Name', { exact: true })).toHaveValue('Team app copy')
|
||||
await expect(form.getByTestId('duplicate-warning')).toContainText('This bot is also connected as Team app.')
|
||||
await form.getByRole('button', { name: 'Cancel' }).click()
|
||||
|
||||
// The page does not scroll sideways on a phone.
|
||||
await page.setViewportSize({ width: 360, height: 800 })
|
||||
await page.reload()
|
||||
await expect(warnings).toHaveCount(2)
|
||||
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth)
|
||||
expect(overflow).toBeLessThanOrEqual(0)
|
||||
|
||||
await rows.last().getByRole('button', { name: 'Turn this one off' }).click()
|
||||
await expect(page.getByTestId('duplicate-warning')).toHaveCount(0)
|
||||
expect(state.disabled).toEqual(['b2'])
|
||||
await expect(rows.last().getByRole('switch', { name: 'Team app copy on' })).toHaveAttribute('aria-checked', 'false')
|
||||
})
|
||||
|
||||
test('asks before it saves a connection that is the same bot as another, and saves it when confirmed', async ({ page }) => {
|
||||
const a = api(page, [BINDING])
|
||||
await a.install()
|
||||
const bodies = []
|
||||
await page.route(/\/api\/chats\/connectors(\?.*)?$/, r => {
|
||||
const req = r.request()
|
||||
if (req.method() !== 'POST') return r.fallback()
|
||||
const body = req.postDataJSON()
|
||||
bodies.push(body)
|
||||
if (body.confirm_duplicate !== 'answer twice') {
|
||||
return r.fulfill({ status: 409, json: { error: { type: 'duplicate_connection', code: 409, param: 'Team app', message: 'This bot is also connected as Team app. Both connections will answer the same message.' } } })
|
||||
}
|
||||
return r.fulfill({ status: 201, json: { ...TWIN, token: undefined, has_token: true } })
|
||||
})
|
||||
await page.goto(PAGE)
|
||||
await page.getByRole('button', { name: 'New Slack app' }).click()
|
||||
await page.getByLabel('Name', { exact: true }).fill('Team app copy')
|
||||
await page.getByLabel('Bot token').fill('xoxb-second')
|
||||
await page.getByLabel('App-level token').fill('xapp-second')
|
||||
await page.getByLabel('Agent', { exact: true }).selectOption('a1')
|
||||
await page.getByRole('button', { name: 'Save' }).click()
|
||||
|
||||
const ask = page.getByTestId('duplicate-confirm')
|
||||
await expect(ask).toContainText('This bot is also connected as Team app. Both connections will answer the same message.')
|
||||
await expect(page.getByTestId('connector-form')).toBeVisible()
|
||||
expect(bodies).toHaveLength(1)
|
||||
expect(bodies[0]).not.toHaveProperty('confirm_duplicate')
|
||||
|
||||
await ask.getByRole('button', { name: 'Go back' }).click()
|
||||
await expect(ask).toHaveCount(0)
|
||||
await page.getByRole('button', { name: 'Save' }).click()
|
||||
await page.getByTestId('duplicate-save-anyway').click()
|
||||
await expect(page.getByTestId('connector-form')).toHaveCount(0)
|
||||
expect(bodies.at(-1)).toMatchObject({ name: 'Team app copy', confirm_duplicate: 'answer twice' })
|
||||
})
|
||||
|
||||
test('shows how to make the Slack app, with its manifest', async ({ page }) => {
|
||||
await api(page).install()
|
||||
await page.goto(PAGE)
|
||||
|
||||
@@ -266,7 +266,12 @@
|
||||
"connectorsPage": {
|
||||
"loadFailed": "Ihre Konnektoren konnten nicht geladen werden.",
|
||||
"waitingTitle": "Warten auf ihr Add-on",
|
||||
"waitingText": "Diese Verknüpfungen bleiben erhalten, laufen aber erst, wenn ihr Add-on installiert und eingeschaltet ist."
|
||||
"waitingText": "Diese Verknüpfungen bleiben erhalten, laufen aber erst, wenn ihr Add-on installiert und eingeschaltet ist.",
|
||||
"duplicate": {
|
||||
"text": "Dieser Bot ist auch als {{name}} verbunden. Beide Verbindungen beantworten dieselbe Nachricht.",
|
||||
"turnOff": "Diese ausschalten",
|
||||
"open": "{{name}} öffnen"
|
||||
}
|
||||
},
|
||||
"tools": {
|
||||
"commands": {
|
||||
|
||||
@@ -332,7 +332,12 @@
|
||||
"bindingsOf": "Bindings of {{name}}",
|
||||
"loadFailed": "Could not load your connectors.",
|
||||
"waitingTitle": "Waiting for their add-on",
|
||||
"waitingText": "These bindings are kept, but they do not run until their add-on is installed and turned on."
|
||||
"waitingText": "These bindings are kept, but they do not run until their add-on is installed and turned on.",
|
||||
"duplicate": {
|
||||
"text": "This bot is also connected as {{name}}. Both connections will answer the same message.",
|
||||
"turnOff": "Turn this one off",
|
||||
"open": "Open {{name}}"
|
||||
}
|
||||
},
|
||||
"conversionPage": {
|
||||
"title": "Converted from the old chat",
|
||||
|
||||
@@ -266,7 +266,12 @@
|
||||
"connectorsPage": {
|
||||
"loadFailed": "No se pudieron cargar tus conectores.",
|
||||
"waitingTitle": "Esperando su complemento",
|
||||
"waitingText": "Estos enlaces se conservan, pero no funcionan hasta que su complemento esté instalado y activado."
|
||||
"waitingText": "Estos enlaces se conservan, pero no funcionan hasta que su complemento esté instalado y activado.",
|
||||
"duplicate": {
|
||||
"text": "Este bot también está conectado como {{name}}. Ambas conexiones responderán al mismo mensaje.",
|
||||
"turnOff": "Desactivar esta",
|
||||
"open": "Abrir {{name}}"
|
||||
}
|
||||
},
|
||||
"tools": {
|
||||
"commands": {
|
||||
|
||||
@@ -266,7 +266,12 @@
|
||||
"connectorsPage": {
|
||||
"loadFailed": "Impossible de charger vos connecteurs.",
|
||||
"waitingTitle": "En attente de leur module",
|
||||
"waitingText": "Ces liaisons sont conservées, mais elles ne fonctionnent qu'une fois leur module installé et activé."
|
||||
"waitingText": "Ces liaisons sont conservées, mais elles ne fonctionnent qu'une fois leur module installé et activé.",
|
||||
"duplicate": {
|
||||
"text": "Ce bot est aussi connecté sous le nom {{name}}. Les deux connexions répondront au même message.",
|
||||
"turnOff": "Désactiver celle-ci",
|
||||
"open": "Ouvrir {{name}}"
|
||||
}
|
||||
},
|
||||
"tools": {
|
||||
"commands": {
|
||||
|
||||
@@ -266,7 +266,12 @@
|
||||
"connectorsPage": {
|
||||
"loadFailed": "Konektor Anda tidak dapat dimuat.",
|
||||
"waitingTitle": "Menunggu add-on-nya",
|
||||
"waitingText": "Tautan ini tetap disimpan, tetapi tidak berjalan sampai add-on-nya dipasang dan diaktifkan."
|
||||
"waitingText": "Tautan ini tetap disimpan, tetapi tidak berjalan sampai add-on-nya dipasang dan diaktifkan.",
|
||||
"duplicate": {
|
||||
"text": "Bot ini juga terhubung sebagai {{name}}. Kedua koneksi akan menjawab pesan yang sama.",
|
||||
"turnOff": "Matikan yang ini",
|
||||
"open": "Buka {{name}}"
|
||||
}
|
||||
},
|
||||
"tools": {
|
||||
"commands": {
|
||||
|
||||
@@ -266,7 +266,12 @@
|
||||
"connectorsPage": {
|
||||
"loadFailed": "Impossibile caricare i tuoi connettori.",
|
||||
"waitingTitle": "In attesa del loro add-on",
|
||||
"waitingText": "Questi collegamenti restano salvati, ma non funzionano finché il loro add-on non è installato e attivo."
|
||||
"waitingText": "Questi collegamenti restano salvati, ma non funzionano finché il loro add-on non è installato e attivo.",
|
||||
"duplicate": {
|
||||
"text": "Questo bot è collegato anche come {{name}}. Entrambe le connessioni risponderanno allo stesso messaggio.",
|
||||
"turnOff": "Disattiva questa",
|
||||
"open": "Apri {{name}}"
|
||||
}
|
||||
},
|
||||
"tools": {
|
||||
"commands": {
|
||||
|
||||
@@ -266,7 +266,12 @@
|
||||
"connectorsPage": {
|
||||
"loadFailed": "커넥터를 불러올 수 없습니다.",
|
||||
"waitingTitle": "애드온 대기 중",
|
||||
"waitingText": "이 연결은 유지되지만, 해당 애드온을 설치하고 켜야 실행됩니다."
|
||||
"waitingText": "이 연결은 유지되지만, 해당 애드온을 설치하고 켜야 실행됩니다.",
|
||||
"duplicate": {
|
||||
"text": "이 봇은 {{name}}(으)로도 연결되어 있습니다. 두 연결이 같은 메시지에 모두 답합니다.",
|
||||
"turnOff": "이 연결 끄기",
|
||||
"open": "{{name}} 열기"
|
||||
}
|
||||
},
|
||||
"tools": {
|
||||
"commands": {
|
||||
|
||||
@@ -266,7 +266,12 @@
|
||||
"connectorsPage": {
|
||||
"loadFailed": "Não foi possível carregar seus conectores.",
|
||||
"waitingTitle": "Aguardando o complemento",
|
||||
"waitingText": "Estes vínculos são mantidos, mas só funcionam quando o complemento deles estiver instalado e ligado."
|
||||
"waitingText": "Estes vínculos são mantidos, mas só funcionam quando o complemento deles estiver instalado e ligado.",
|
||||
"duplicate": {
|
||||
"text": "Este bot também está conectado como {{name}}. As duas conexões vão responder à mesma mensagem.",
|
||||
"turnOff": "Desativar esta",
|
||||
"open": "Abrir {{name}}"
|
||||
}
|
||||
},
|
||||
"tools": {
|
||||
"commands": {
|
||||
|
||||
@@ -266,7 +266,12 @@
|
||||
"connectorsPage": {
|
||||
"loadFailed": "无法加载你的连接器。",
|
||||
"waitingTitle": "等待对应的插件",
|
||||
"waitingText": "这些绑定会被保留,但要等对应插件安装并开启后才会运行。"
|
||||
"waitingText": "这些绑定会被保留,但要等对应插件安装并开启后才会运行。",
|
||||
"duplicate": {
|
||||
"text": "这个机器人也以 {{name}} 的名称连接。两个连接都会回复同一条消息。",
|
||||
"turnOff": "关闭这个连接",
|
||||
"open": "打开 {{name}}"
|
||||
}
|
||||
},
|
||||
"tools": {
|
||||
"commands": {
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Link, Navigate, useSearchParams } from 'react-router-dom'
|
||||
import Icon from '../components/Icon'
|
||||
import { Button, Callout, EmptyState, LoadError, Page, PageHeader, SkeletonRows } from '../components/ui'
|
||||
import { chatAgentsApi, chatConnectorsApi } from '../utils/api'
|
||||
import { acceptsOf, groupConnectors } from '../utils/chatConnectors'
|
||||
import { acceptsOf, bindingAddress, duplicateOf, groupConnectors } from '../utils/chatConnectors'
|
||||
import './connectors.css'
|
||||
|
||||
// The messaging connectors of the user. A platform comes from a connector
|
||||
@@ -59,6 +59,8 @@ export default function ChatConnectors() {
|
||||
|
||||
const row = (c) => {
|
||||
const a = acceptsOf(c)
|
||||
const other = duplicateOf(c)
|
||||
const otherHref = other ? bindingAddress(groups, c, other.id) : ''
|
||||
return (
|
||||
<li key={c.id} className="cn-row" data-testid="connector-row" data-on={c.enabled ? 'true' : 'false'}>
|
||||
<span className="cn-row__lead" aria-hidden="true"><Icon name="chat" /></span>
|
||||
@@ -68,6 +70,20 @@ export default function ChatConnectors() {
|
||||
<span>{t('connectorsPage.agentOf', { name: agentName(c.agent_id) })}</span>
|
||||
<span>{t('connectorsPage.accepts', { who: a.key === 'listed' ? t('connectorsPage.modes.listed', { count: a.count }) : t(`connectorsPage.modes.${a.key}`) })}</span>
|
||||
</span>
|
||||
{other && (
|
||||
<span className="cn-result cn-result--warn" data-testid="connector-duplicate">
|
||||
<Icon name="warning" />
|
||||
<span>
|
||||
{t('connectorsPage.duplicate.text', { name: other.name })}
|
||||
{otherHref && <> <Link to={otherHref} data-testid="connector-duplicate-open">{t('connectorsPage.duplicate.open', { name: other.name })}</Link></>}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
{other && (
|
||||
<span className="cn-row__fix">
|
||||
<Button size="sm" data-testid="connector-duplicate-off" onClick={() => act(() => chatConnectorsApi.disable(c.id))}>{t('connectorsPage.duplicate.turnOff')}</Button>
|
||||
</span>
|
||||
)}
|
||||
{c.active === false && (
|
||||
<span className="cn-result cn-result--warn">
|
||||
<Icon name="warning" />
|
||||
|
||||
@@ -84,6 +84,8 @@
|
||||
}
|
||||
@starting-style { .cn-result { opacity: 0; transform: translateY(-2px); } }
|
||||
.cn-result .lai-icon { flex: none; width: 14px; height: 14px; margin-top: 2px; }
|
||||
.cn-row__fix { margin-top: var(--sp-1); }
|
||||
.cn-result a { color: inherit; text-decoration: underline; }
|
||||
.cn-result--ok { color: var(--dk-ok-text); }
|
||||
.cn-result--error { color: var(--dk-error-text); }
|
||||
.cn-result--warn { color: var(--dk-warn-text); }
|
||||
|
||||
+13
@@ -27,3 +27,16 @@ export function acceptsOf(c) {
|
||||
const n = (c.allow || []).length
|
||||
return n === 0 ? { key: 'nobody' } : { key: 'listed', count: n }
|
||||
}
|
||||
|
||||
// The other enabled binding of the same bot that the host names on a binding
|
||||
// (`duplicate`), or null. Both then answer the same message.
|
||||
export function duplicateOf(c) {
|
||||
return c?.duplicate?.others?.[0] || null
|
||||
}
|
||||
|
||||
// The page of the add-on that serves a binding, opened on one binding of it.
|
||||
// groups come from groupConnectors; '' when no running add-on serves the kind.
|
||||
export function bindingAddress(groups, c, id) {
|
||||
const href = (groups || []).find((g) => g.kind === c.kind && g.addon === c.addon)?.href
|
||||
return href ? `${href}${href.includes('?') ? '&' : '?'}binding=${encodeURIComponent(id)}` : ''
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { acceptsOf, groupConnectors } from '../../src/utils/chatConnectors.js'
|
||||
import { acceptsOf, bindingAddress, duplicateOf, groupConnectors } from '../../src/utils/chatConnectors.js'
|
||||
|
||||
const kinds = [{ addon: 'telegram', kind: 'telegram', title: 'Telegram', page: 'bot' }]
|
||||
|
||||
@@ -34,3 +34,18 @@ test('chat connectors: who a binding accepts', () => {
|
||||
assert.deepEqual(acceptsOf({ allow: [] }), { key: 'nobody' })
|
||||
assert.deepEqual(acceptsOf({ allow: ['1', '2'] }), { key: 'listed', count: 2 })
|
||||
})
|
||||
|
||||
test('chat connectors: the other binding of a bot that is connected twice', () => {
|
||||
assert.equal(duplicateOf({ id: 'a' }), null)
|
||||
assert.equal(duplicateOf({ id: 'a', duplicate: { code: 'duplicate_connection', others: [] } }), null)
|
||||
assert.deepEqual(duplicateOf({ id: 'a', duplicate: { others: [{ id: 'b', name: 'Team app' }, { id: 'c', name: 'Third' }] } }), { id: 'b', name: 'Team app' })
|
||||
})
|
||||
|
||||
test('chat connectors: a link to a binding opens the page of its add-on on that binding', () => {
|
||||
const { groups } = groupConnectors(kinds, [])
|
||||
const c = { kind: 'telegram', addon: 'telegram' }
|
||||
assert.equal(bindingAddress(groups, c, 'b 2'), '/app/addons/telegram/bot?binding=b%202')
|
||||
const noPage = groupConnectors([{ addon: 'slack', kind: 'slack', title: 'Slack' }], []).groups
|
||||
assert.equal(bindingAddress(noPage, { kind: 'slack', addon: 'slack' }, 'b2'), '/app/addons?detail=slack&binding=b2')
|
||||
assert.equal(bindingAddress(groups, { kind: 'telegram', addon: 'other' }, 'b2'), '', 'no running add-on, no link')
|
||||
})
|
||||
Reference in new issue
Block a user