feat(addons): GitHub settings page, registration and docs

Add the GitHub connection page (fine-grained token, repository list,
read-only note, Test that checks each listed repository), let the shared
connector page hide messaging settings for a kind that carries no
messages, register the add-on in the package matrix and the catalog, and
document the credential slot and the GitHub add-on.

Assisted-by: Claude:claude-sonnet-5-5 [go node]
This commit is contained in:
Ettore Di Giacinto committed 2026-11-03 14:20:22 +00:00
1 parent 3d687a209c
commit 3bbb40b6ff
19 files changed
+557 -24

No files matched your search

+2 -2
View File
@@ -107,7 +107,7 @@ jobs:
strategy:
fail-fast: true
matrix:
addon: [tables, voice, face, fine-tuning, quantization, studio, talk, telegram, slack, office, sandbox, web, memory]
addon: [tables, voice, face, fine-tuning, quantization, studio, talk, telegram, slack, github, office, sandbox, web, memory]
steps:
- uses: actions/checkout@v7
with:
@@ -226,7 +226,7 @@ jobs:
# event it fails the plan. A missing or malformed platform file, or
# any other error, fails the plan on every event.
include='[]'
for id in tables voice face fine-tuning quantization studio talk telegram slack office sandbox web memory; do
for id in tables voice face fine-tuning quantization studio talk telegram slack github office sandbox web memory; do
cfg="addons/$id/package/platforms.yaml"
err="matrix-$id.err"
if rows=$(go run ./scripts/addons/cmd/addonpkg matrix --config "$cfg" 2>"$err"); then
+2
View File
@@ -0,0 +1,2 @@
# Written by build.mjs; the package build makes it.
/dist/
+15
View File
@@ -0,0 +1,15 @@
import { fileURLToPath } from 'node:url'
import { dirname, resolve } from 'node:path'
import { buildAddonUI } from '../../internal/uibuild/uibuild.mjs'
const here = dirname(fileURLToPath(import.meta.url))
export async function build() {
// The page keeps the host module names it was written with; the compat
// directory answers them through the SDK (addons/internal/host-compat).
return buildAddonUI({ root: here, name: 'github', rgb: [36, 41, 47], compat: resolve(here, '../../internal/host-compat/src') })
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
build().then((dir) => console.log(`built ${dir}`), (e) => { console.error(e); process.exit(1) })
}
+8
View File
@@ -0,0 +1,8 @@
{
"name": "localai-github-ui",
"private": true,
"version": "0.1.0",
"description": "Native UI of the GitHub add-on: a standalone ES module that the host loads after its own build.",
"type": "module",
"scripts": { "build": "node build.mjs", "test": "node test/run.mjs" }
}
+92
View File
@@ -0,0 +1,92 @@
/* eslint-disable no-unused-vars -- components used only inside JSX look unused to this config, which has no eslint-plugin-react */
import { RouteProvider } from 'react-router-dom'
import { getSdk, } from '../../../internal/host-compat/src/runtime.js'
import { registerLocales } from '../../../internal/host-compat/src/i18n.js'
import ConnectorPage from '../../../internal/connector-ui/src/ConnectorPage'
import { Field, FieldError, Notice, TextInput } from '../../../internal/connector-ui/src/controls'
import en from './i18n/github.en.json'
import { checkLine, extraBody, extraForm, extraValid, githubSecret, parseRepos, reposProblem, tokenProblem } from './repos'
registerLocales({ github: { en } })
// The fine-grained token. It is typed only to replace it.
function GitHubToken({ form, setForm, t }) {
const problem = tokenProblem(form)
return (
<Field label={t('token')} hint={t('tokenHelp')}>
{(p) => (
<TextInput {...p} mono type="password" autoComplete="off" value={form.token || ''} required={!form.id}
placeholder={form.id ? t('tokenSaved') : ''} aria-invalid={problem ? 'true' : undefined}
onChange={(e) => setForm({ ...form, token: e.target.value })} />
)}
</Field>
)
}
// The repositories the agent may read. The host refuses any other one before
// the add-on is called.
function GitHubFields({ form, setForm, t }) {
const problem = reposProblem(form.repos_text)
const count = parseRepos(form.repos_text).length
return (
<>
{tokenProblem(form) && <FieldError role="status" data-testid="token-problem">{t(tokenProblem(form))}</FieldError>}
<Field label={t('repos')} hint={t('reposHelp', { count })}>
{(p) => (
<textarea {...p} className="cn-input cn-input--mono" rows={5} data-testid="github-repos" value={form.repos_text || ''}
placeholder="owner/name" aria-invalid={problem ? 'true' : undefined}
onChange={(e) => setForm({ ...form, repos_text: e.target.value })} />
)}
</Field>
{problem && <FieldError role="status" data-testid="repos-problem">{t(problem)}</FieldError>}
<Notice tone="info" role="note" testId="github-write-mode">{t('writeOff')}</Notice>
</>
)
}
function Help({ t }) {
return (
<details className="cn-help" data-testid="github-setup">
<summary>{t('setup.title')}</summary>
<ol>
<li>{t('setup.step1')}</li>
<li>{t('setup.step2')}</li>
<li>{t('setup.step3')}</li>
</ol>
</details>
)
}
function RowInfo({ c, t }) {
const repos = c.resources || []
return <span className="cn-muted" data-testid="github-row-repos">{repos.length === 0 ? t('noRepos') : t('reposOf', { count: repos.length, list: repos.join(', ') })}</span>
}
// The Test button: the host asks who the token is; this asks, for each listed
// repository, whether the token reaches it.
async function testExtra(c, t) {
const lines = []
for (const repo of c.resources || []) {
try {
const r = await getSdk().api.read({ arguments: { tool: 'check', repo } })
lines.push(checkLine(r?.data ? { ...r.data, repo } : { repo, reachable: false }, t))
} catch (_e) {
lines.push(checkLine({ repo, reachable: false }, t))
}
}
return lines.join(' ')
}
export function createGitHub() {
function Component({ page, path, search }) {
if (page !== 'apps') return null
return (
<RouteProvider value={{ page, path: path || '', search: search || '' }}>
<ConnectorPage kind="github" ns="github" testId="github-page" TokenFields={GitHubToken} tokenOf={githubSecret} messaging={false}
ExtraFields={GitHubFields} extraBody={extraBody} extraForm={extraForm} extraValid={extraValid} testExtra={testExtra}
Help={Help} RowInfo={RowInfo} />
</RouteProvider>
)
}
return { Component, components: {}, chatActions: [], dispose() {} }
}
+49
View File
@@ -0,0 +1,49 @@
{
"title": "GitHub",
"intro": "Let your agents read the code, issues and pull requests of the repositories you list. The agent can read only those, with the token you save here. Nothing is written to GitHub.",
"new": "New GitHub connection",
"edit": "Edit GitHub connection",
"name": "Name",
"token": "Fine-grained access token",
"tokenHelp": "Starts with github_pat_. Give it read access to the repositories below only: Contents, Issues and Pull requests. It is kept sealed and never shown again.",
"tokenSaved": "Saved. Type a token to replace it.",
"tokenShape": "Use a fine-grained token: it starts with github_pat_.",
"tokenSpace": "The token has no spaces.",
"repos": "Repositories the agent may read",
"reposHelp": "One per line, as owner/name. {{count}} listed. The agent is refused any other repository, even if the token could reach it.",
"reposShape": "Each repository is written owner/name.",
"reposMany": "At most 50 repositories.",
"writeOff": "Write access is off: this connection only reads.",
"agent": "Agent",
"agentNone": "Choose an agent",
"agentNoModel": "{{name}} (needs a model)",
"test": "Test",
"testOk": "Token of {{bot}}.",
"checkFailed": "Saved. The token check failed: {{reason}}",
"check": {
"unreachable": "{{repo}}: not reachable with this token.",
"readOnly": "{{repo}}: reachable, read only.",
"readWrite": "{{repo}}: reachable. The token could also write, so give it read access only."
},
"save": "Save",
"cancel": "Cancel",
"editButton": "Edit",
"delete": "Delete",
"empty": "No GitHub connections yet.",
"noRepos": "No repositories listed: the agent can read none.",
"reposOf": "{{count}} repositories: {{list}}",
"agentOf": "Agent: {{name}}",
"inactive": "Not running: {{reason}}.",
"confirmDelete": {
"title": "Delete {{name}}?",
"message": "The agent can no longer read these repositories, and the token saved with it is deleted. To set it up again you must enter the token again.",
"confirm": "Delete"
},
"setup": {
"title": "How to make the token",
"step1": "On GitHub, open Settings, Developer settings, Fine-grained personal access tokens, and make a new token.",
"step2": "Under Repository access, choose Only select repositories and pick the ones you list here.",
"step3": "Under Permissions, give Read access to Contents, Issues and Pull requests. Leave everything else off."
},
"back": "Back to connectors"
}
+14
View File
@@ -0,0 +1,14 @@
// The entry of the GitHub add-on. Its page edits the bindings of GitHub
// tokens through the core API, with the signed-in person's own session. It
// uses the host's React, router and i18n through the stand-ins of
// addons/internal/host-compat, like the Slack add-on.
import { requireHost, setSdk } from '../../../internal/host-compat/src/runtime.js'
const NEEDS = ['ui.ConfirmDialog']
export async function createAddon(sdk) {
requireHost(sdk, NEEDS)
setSdk(sdk)
const { createGitHub } = await import('./addon.jsx')
return createGitHub(sdk)
}
+57
View File
@@ -0,0 +1,57 @@
// Helpers of the GitHub page. No React, so a node test drives them.
// A fine-grained token. A classic token (ghp_) reaches every repository of
// the account, so the page does not take it.
export const TOKEN_PREFIX = 'github_pat_'
// What is wrong with the typed token, as a key of the page's words, or ''.
// Nothing typed is no problem: an update without a token keeps the stored one.
export function tokenProblem(form) {
const token = (form.token || '').trim()
if (!token) return ''
if (/\s/.test(token)) return 'tokenSpace'
if (!token.startsWith(TOKEN_PREFIX)) return 'tokenShape'
return ''
}
// The secret the field makes: the token, or '' when not typed or not right.
export function githubSecret(form) {
return tokenProblem(form) ? '' : (form.token || '').trim()
}
export const MAX_REPOS = 50
const REPO = /^[A-Za-z0-9_.-]{1,100}\/[A-Za-z0-9_.-]{1,100}$/
// The repositories of a text box: one per line, or separated by commas or
// spaces. Blank entries and repeats (any case) are dropped.
export function parseRepos(text) {
const seen = new Set()
const out = []
for (const part of String(text || '').split(/[\s,]+/)) {
const r = part.trim()
if (!r || seen.has(r.toLowerCase())) continue
seen.add(r.toLowerCase())
out.push(r)
}
return out
}
// What is wrong with the typed list, as a key of the page's words, or ''.
export function reposProblem(text) {
const list = parseRepos(text)
if (list.length > MAX_REPOS) return 'reposMany'
if (list.some((r) => !REPO.test(r) || r.includes('..'))) return 'reposShape'
return ''
}
// The fields this page adds to a binding: its form values, the request body
// they make, and whether they allow saving.
export const extraForm = (c) => ({ repos_text: (c.resources || []).join('\n') })
export const extraBody = (form) => ({ resources: parseRepos(form.repos_text) })
export const extraValid = (form) => !reposProblem(form.repos_text)
// The line the Test button adds for each repository, from the check tool.
export function checkLine(r, t) {
if (!r || r.reachable === false) return t('check.unreachable', { repo: r?.repo || '' })
return t(r.canWrite ? 'check.readWrite' : 'check.readOnly', { repo: r.fullName || r.repo })
}
+52
View File
@@ -0,0 +1,52 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { checkLine, extraBody, extraForm, extraValid, githubSecret, parseRepos, reposProblem, tokenProblem } from '../src/repos.js'
import { canSave, connectorPayload, emptyConnector } from '../../../internal/connector-ui/src/connector.js'
import en from '../src/i18n/github.en.json' with { type: 'json' }
test('github: only a fine-grained token is taken, and nothing typed keeps the saved one', () => {
assert.equal(tokenProblem({}), '')
assert.equal(tokenProblem({ token: 'github_pat_abc' }), '')
assert.equal(tokenProblem({ token: 'ghp_classic' }), 'tokenShape')
assert.equal(tokenProblem({ token: 'github_pat_a b' }), 'tokenSpace')
assert.equal(githubSecret({ token: ' github_pat_abc ' }), 'github_pat_abc')
assert.equal(githubSecret({ token: 'ghp_classic' }), '')
})
test('github: the repository list is read from lines, commas and spaces without repeats', () => {
assert.deepEqual(parseRepos('acme/widgets\n Acme/Tools, acme/widgets x/y'), ['acme/widgets', 'Acme/Tools', 'x/y'])
assert.deepEqual(parseRepos(''), [])
assert.equal(reposProblem('acme/widgets\nx/y'), '')
assert.equal(reposProblem('widgets'), 'reposShape')
assert.equal(reposProblem('a/b/c'), 'reposShape')
assert.equal(reposProblem('a/../b'), 'reposShape')
assert.equal(reposProblem(Array.from({ length: 51 }, (_, i) => `o/r${i}`).join('\n')), 'reposMany')
})
test('github: the list rides in the request, a binding is never switched on, and a saved one reads back', () => {
const form = { ...emptyConnector(), name: 'Work', agent_id: 'a', token: 'github_pat_x', ...extraForm({ resources: ['acme/widgets'] }) }
assert.equal(form.repos_text, 'acme/widgets')
const body = connectorPayload(form, 'github', githubSecret(form), { enabled: false, accept_any: false, ...extraBody({ ...form, repos_text: 'acme/widgets\nacme/tools' }) })
assert.equal(body.kind, 'github')
assert.equal(body.token, 'github_pat_x')
assert.equal(body.enabled, false)
assert.deepEqual(body.resources, ['acme/widgets', 'acme/tools'])
const update = connectorPayload({ ...emptyConnector(), id: 'b1', name: 'Work', agent_id: 'a' }, 'github', '', extraBody({ repos_text: '' }))
assert.equal('token' in update, false)
assert.deepEqual(update.resources, [], 'an empty list clears the list')
assert.equal(canSave(form, false, Boolean(githubSecret(form))), true)
assert.equal(extraValid({ repos_text: 'nope' }), false)
})
test('github: the Test lines say reachable, read only, or not reachable', () => {
const t = (key, o = {}) => en.check[key.replace('check.', '')].replace(/\{\{(\w+)\}\}/g, (_, k) => o[k])
assert.match(checkLine({ repo: 'a/b', reachable: false }, t), /not reachable/)
assert.match(checkLine({ repo: 'a/b', reachable: true, fullName: 'a/b', canWrite: false }, t), /read only/)
assert.match(checkLine({ repo: 'a/b', reachable: true, fullName: 'a/b', canWrite: true }, t), /read access only/)
})
test('github: every word the page asks for exists', () => {
for (const key of ['title', 'intro', 'new', 'token', 'tokenHelp', 'tokenShape', 'tokenSpace', 'repos', 'reposHelp', 'reposShape', 'reposMany', 'writeOff', 'test', 'testOk', 'save', 'cancel', 'empty', 'noRepos', 'reposOf', 'agentOf', 'confirmDelete', 'setup']) {
assert.ok(en[key], key)
}
})
+38
View File
@@ -0,0 +1,38 @@
// Runs the node unit tests and prints one JSON line per test:
// {"case":"unit: <name>","status":"passed"|"failed"|"skipped"}
// A test that is skipped or marked todo is reported as skipped, so the case
// inventory of scripts/addons/suites.json rejects it. No dependencies.
//
// Arguments name the test files to run (for example addons.test.mjs); without
// any, every *.test.mjs file runs. Each suite of suites.json names its own, so
// one suite's cases never appear in another's inventory.
import { run } from 'node:test'
import { readdirSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
const here = dirname(fileURLToPath(import.meta.url))
const only = process.argv.slice(2)
const names = readdirSync(here).filter(f => f.endsWith('.test.mjs')).sort()
for (const name of only) {
if (!names.includes(name)) {
console.log(JSON.stringify({ case: `unit: missing test file ${name}`, status: 'failed' }))
process.exit(1)
}
}
const files = names.filter(f => only.length === 0 || only.includes(f)).map(f => join(here, f))
let failed = false
const stream = run({ files, concurrency: 1 })
stream.on('test:pass', ({ name, skip, todo, nesting }) => {
if (nesting > 0) return
const status = skip || todo ? 'skipped' : 'passed'
console.log(JSON.stringify({ case: `unit: ${name}`, status }))
})
stream.on('test:fail', ({ name, nesting, details }) => {
if (nesting > 0) return
failed = true
console.log(JSON.stringify({ case: `unit: ${name}`, status: 'failed', error: String(details?.error?.message || '').slice(0, 300) }))
})
stream.on('test:stderr', ({ message }) => process.stderr.write(message))
stream.on('end', () => { process.exitCode = failed ? 1 : 0 })
stream.resume()
@@ -69,8 +69,17 @@ const ONE_LIST = [{ key: 'allow', label: 'allow', hint: 'allowHint', testId: 'co
* @param {Array} [props.lists] the allowlists: { key, label, hint, testId, match }
* @param {Function} [props.Help] a block under the intro ({ t })
* @param {string} [props.notifyMode] the inputMode of the notification field
* @param {boolean} [props.messaging] false for a kind that carries no messages: the page then hides the sender,
* reply, file and notification settings and the switch of the binding
* @param {Function} [props.ExtraFields] more fields of the add-on's own ({ form, setForm, t })
* @param {Function} [props.extraBody] the request fields they make: (form) => object
* @param {Function} [props.extraForm] their values from a saved binding: (binding) => object
* @param {Function} [props.extraValid] whether they allow saving: (form) => boolean
* @param {Function} [props.testExtra] more checks after the Test button: async (binding, t) => string
* @param {Function} [props.RowInfo] a line under a binding in the list ({ c, t })
*/
export default function ConnectorPage({ kind, ns, testId, TokenFields = SingleToken, tokenOf = (f) => f.token || '', lists = ONE_LIST, Help, notifyMode = 'text' }) {
export default function ConnectorPage({ kind, ns, testId, TokenFields = SingleToken, tokenOf = (f) => f.token || '', lists = ONE_LIST, Help, notifyMode = 'text',
messaging = true, ExtraFields, extraBody, extraForm, extraValid, testExtra, RowInfo }) {
const { t } = useTranslation(ns)
const [items, setItems] = useState([])
const [agents, setAgents] = useState([])
@@ -105,6 +114,13 @@ export default function ConnectorPage({ kind, ns, testId, TokenFields = SingleTo
const agentName = (id) => agents.find((a) => a.id === id)?.name || id
const open = (c) => { setForm(c); setConfirmed(Boolean(c.accept_any)); setNote('') }
const extra = (form) => ({ ...(messaging ? {} : { enabled: false, accept_any: false }), ...(extraBody ? extraBody(form) : {}) })
const check = async (c) => {
const r = await connectorsApi.test(c.id)
let text = t('testOk', { bot: r.bot_username })
if (testExtra) text += ` ${await testExtra(c, t)}`
return text
}
// 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.
@@ -115,14 +131,13 @@ export default function ConnectorPage({ kind, ns, testId, TokenFields = SingleTo
setNote('')
try {
const token = tokenOf(form).trim()
const body = connectorPayload(form, kind, token)
const body = connectorPayload(form, kind, token, extra(form))
const saved = form.id ? await connectorsApi.update(form.id, body) : await connectorsApi.create(body)
setForm(null)
await load()
if (token && saved?.id) {
try {
const r = await connectorsApi.test(saved.id)
setNote(t('testOk', { bot: r.bot_username }))
setNote(await check(saved))
} catch (err) { setError(t('checkFailed', { reason: err?.message || '' })) }
}
} catch (err) { fail(err) } finally { setBusy(false) }
@@ -146,7 +161,7 @@ export default function ConnectorPage({ kind, ns, testId, TokenFields = SingleTo
<div className="cn-page" data-testid={testId}>
<header className="cn-head">
<h1 className="cn-title">{t('title')}</h1>
<button type="button" className="dk-btn dk-btn--primary" onClick={() => open(emptyConnector())}>
<button type="button" className="dk-btn dk-btn--primary" onClick={() => open({ ...emptyConnector(), ...(extraForm ? extraForm({}) : {}) })}>
<Icon name="plus" /> {t('new')}
</button>
</header>
@@ -178,6 +193,8 @@ export default function ConnectorPage({ kind, ns, testId, TokenFields = SingleTo
</Select>
)}
</Field>
{ExtraFields && <ExtraFields form={form} setForm={setForm} t={t} />}
{messaging && (<>
<fieldset className="cn-group">
<legend>{t('who')}</legend>
<RadioCards name="who" legend={t('who')} value={form.accept_any} onChange={(any) => { setForm({ ...form, accept_any: any }); if (!any) setConfirmed(false) }}
@@ -198,12 +215,13 @@ export default function ConnectorPage({ kind, ns, testId, TokenFields = SingleTo
</Field>
<AnswerFields form={form} setForm={setForm} t={t} kindInfo={kindInfo} />
<FilesFields form={form} setForm={setForm} kind={kindFiles} saved={form.files} s={answerT(t)} />
</>)}
<div className="cn-actions">
<button type="submit" className="dk-btn dk-btn--primary" aria-busy={busy ? 'true' : undefined} disabled={busy || !canSave(form, confirmed, Boolean(tokenOf(form).trim()), kindFiles)}>{t('save')}</button>
<button type="submit" className="dk-btn dk-btn--primary" aria-busy={busy ? 'true' : undefined} disabled={busy || !canSave(form, confirmed, Boolean(tokenOf(form).trim()), kindFiles) || (extraValid ? !extraValid(form) : false)}>{t('save')}</button>
<button type="button" className="dk-btn dk-btn--secondary" onClick={() => setForm(null)}>{t('cancel')}</button>
</div>
</form>
<aside className="cn-aside" aria-label={answerT(t)('aside.title')}>
{messaging && <aside className="cn-aside" aria-label={answerT(t)('aside.title')}>
<h2 className="cn-aside__title">{answerT(t)('aside.title')}</h2>
<ol className="cn-aside__steps">
<li>{answerT(t)('aside.step1')}</li>
@@ -212,7 +230,7 @@ export default function ConnectorPage({ kind, ns, testId, TokenFields = SingleTo
</ol>
<h2 className="cn-aside__title">{answerT(t)('aside.testTitle')}</h2>
<p className="cn-muted">{answerT(t)('aside.testText')}</p>
</aside>
</aside>}
</div>
)}
@@ -221,27 +239,30 @@ export default function ConnectorPage({ kind, ns, testId, TokenFields = SingleTo
<li key={c.id} className="cn-row" data-testid="connector-row">
<div className="cn-row__text">
<span className="cn-row__name">{c.name}</span>
{messaging ? (<>
<span className="cn-muted">
{t('agentOf', { name: agentName(c.agent_id) })}
{' · '}
{t('accepts', { who: c.accept_any ? t('modes.anyone') : (c.allow || []).length === 0 ? t('modes.nobody') : t('modes.listed', { count: c.allow.length }) })}
</span>
<span className="cn-muted" data-testid="connector-answer-summary">{answerSummary(c, t)}</span>
</>) : <span className="cn-muted">{t('agentOf', { name: agentName(c.agent_id) })}</span>}
{RowInfo && <RowInfo c={c} t={t} />}
{c.active === false && <span className="cn-muted" data-testid="connector-inactive">{t('inactive', { reason: c.reason })}</span>}
{openSenders === c.id && <Senders id={c.id} onAllowed={load} t={t} />}
</div>
<span className="cn-row__actions">
<Switch checked={c.enabled} label={t('onFor', { name: c.name })}
{messaging && <Switch checked={c.enabled} label={t('onFor', { name: c.name })}
onChange={(on) => {
setItems((list) => list.map((x) => (x.id === c.id ? { ...x, enabled: on } : x)))
act(() => (on ? connectorsApi.enable(c.id) : connectorsApi.disable(c.id)))
}} />
}} />}
<button type="button" className="dk-btn dk-btn--secondary dk-btn--sm"
onClick={async () => { setError(''); try { const r = await connectorsApi.test(c.id); setNote(t('testOk', { bot: r.bot_username })) } catch (e) { fail(e) } }}>
onClick={async () => { setError(''); try { setNote(await check(c)) } catch (e) { fail(e) } }}>
{t('test')}
</button>
<button type="button" className="dk-btn dk-btn--secondary dk-btn--sm" onClick={() => setOpenSenders(openSenders === c.id ? '' : c.id)}>{t('senders')}</button>
<button type="button" className="dk-btn dk-btn--secondary dk-btn--sm" onClick={() => open(connectorForm(c))}>{t('editButton')}</button>
{messaging && <button type="button" className="dk-btn dk-btn--secondary dk-btn--sm" onClick={() => setOpenSenders(openSenders === c.id ? '' : c.id)}>{t('senders')}</button>}
<button type="button" className="dk-btn dk-btn--secondary dk-btn--sm" onClick={() => open({ ...connectorForm(c), ...(extraForm ? extraForm(c) : {}) })}>{t('editButton')}</button>
<button type="button" className="dk-btn dk-btn--secondary dk-btn--sm" onClick={() => setToDelete(c)}>{t('delete')}</button>
</span>
</li>
@@ -70,7 +70,7 @@ export function formatBytes(n) {
// The body of a create or replace request for a binding of kind. token is the
// secret the add-on's fields make; it goes only when it was typed: an update
// without one keeps the stored token.
export function connectorPayload(form, kind, token = form.token) {
export function connectorPayload(form, kind, token = form.token, extra = {}) {
const body = {
kind,
name: (form.name || '').trim(),
@@ -90,7 +90,9 @@ export function connectorPayload(form, kind, token = form.token) {
if (body.conversation === 'window') body.window_minutes = Number(form.window_minutes) || 0
const secret = (token || '').trim()
if (secret) body.token = secret
return body
// Fields of the add-on's own (a list of resources) and overrides, such as a
// binding that carries no messages and so is never switched on.
return { ...body, ...extra }
}
// Who the binding accepts, in a few words.
@@ -92,7 +92,7 @@ var _ = Describe("official library index", func() {
Expect(versionPattern.MatchString(r.Version)).To(BeTrue(), r.ID)
Expect(r.Artifact).To(HavePrefix("oci://"), r.ID)
}
Expect(slices.Compact(slices.Sorted(slices.Values(ids)))).To(ConsistOf("fine-tuning", "quantization", "voice", "face", "studio", "talk", "telegram", "slack", "tables", "office", "sandbox", "web", "memory"))
Expect(slices.Compact(slices.Sorted(slices.Values(ids)))).To(ConsistOf("fine-tuning", "quantization", "voice", "face", "studio", "talk", "telegram", "slack", "github", "tables", "office", "sandbox", "web", "memory"))
})
})
+39
View File
@@ -493,6 +493,45 @@ own record of the grants.
- An add-on that declares no `list` gets no listing tool, whatever else it
declares.
### Credentials and a resource list
An add-on whose tools call a service with a secret of the person, such as an
access token, can ask the host to keep the secret and to check which resources
a call may reach. The model never sees or sets either.
```json
{
"api": {"min": 2, "max": 2},
"capabilities": {
"credentials": [
{"id": "token", "title": "Access token", "kind": "token", "resourceArgument": "repo"}
],
"connector": {"kinds": [{"id": "example", "title": "Example", "delivery": "pull"}]}
}
}
```
- The secret and the list live in the person's **binding** of the add-on's
connector kind. The host seals the secret. The list is `Binding.Resources`: at
most 50 entries, each an external name (`owner/name` for a repository) with a
host ID made from its hash. The person edits it where they edit the binding.
- `resourceArgument` names a string argument that carries the external name. A
tool that declares it must require it. Before the add-on is called, the host
matches the argument against the lists of the caller's bindings of the add-on,
without regard to case. A name on no list is refused with the code
`resource_not_allowed` and the allowed names; the add-on is not called. When
two bindings list the same name, the first in list order is used.
- A call that passes the check carries `Request.Credential` (the secret),
`Request.ResourceKey` (the name as the person wrote it) and `Request.ResourceID`
(the host ID). The host drops these three fields from any request a client
sends. A person with no binding gets the code `no_credential`.
- A manifest with `credentials` must accept `api` 2, declare a connector kind and
declare read tools only: a change under a credential needs a write mode, which
this version does not have. Tools of such an add-on name no `resourceId`.
- An add-on that is rate limited returns `contract.ErrRateLimited{RetryAfter}`.
The runtime answers HTTP 429 with `Retry-After`; the model gets the code
`rate_limited`, `retryAfter` in seconds and a note not to retry before then.
## Singleton owner and its limits
Addon state lives in one place. Exactly one LocalAI instance is the **owner** of an
+15 -1
View File
@@ -7,7 +7,7 @@ url = "/features/chats-connectors/"
The Connectors page of the [chats](/features/chats/) (`/app/chats/connectors`, under **Agents** in the sidebar) lists the messaging bindings you made, and the MCP servers have a page of their own (**MCP connectors** in the sidebar, `/app/mcp-connectors`):
- A **messaging connector** binds a bot of a platform to one of your agents, so a person can talk to it from that platform. The platform comes from a **connector add-on**: the [Telegram add-on](#telegram) or the [Slack add-on](#slack). This is the first half of this page.
- A **messaging connector** binds a bot of a platform to one of your agents, so a person can talk to it from that platform. The platform comes from a **connector add-on**: the [Telegram add-on](#telegram) or the [Slack add-on](#slack) (the [GitHub add-on](#github) holds a token for your agents instead of carrying messages). This is the first half of this page.
- An **MCP server** gives your chats and agents more tools. See [MCP servers](#mcp-servers).
## Connector add-ons
@@ -134,6 +134,20 @@ speech-to-text model is set up") and no turn starts.
binding. `POST .../enable` and `.../disable` switch it. You can have 10 bindings.
A binding says `active: false` and gives a `reason` while its add-on does not run.
## GitHub
The GitHub add-on lets your [saved agents](/features/chats-agents/) read repositories: search code, read files and folders, and read issues, pull requests and their diffs. It reads only. It cannot write, merge or delete anything on GitHub.
1. Install the **GitHub** add-on from the add-on gallery.
2. On GitHub, make a **fine-grained personal access token** for only the repositories you want to share. Give it read access to Contents, Issues and Pull requests. Classic tokens are not accepted.
3. On the add-on page, choose **New GitHub connection**. Pick an agent, paste the token and list the repositories, one `owner/name` per line (at most 50). Press **Test** to see whose token it is and which listed repositories it reaches.
The list is checked by LocalAI before the add-on is called, so an agent is refused any repository that is not on it, even if the token could reach it. The token is kept sealed and is never shown again, and no tool result contains it. With several connections, a call uses the first one that lists the repository.
What GitHub returns (issue text, comments, file content) is written by other people. Every result is marked as untrusted data for the model. Long files are cut at 96 KiB and long patches at 64 KiB; the result says what was cut and how to ask for the rest. When GitHub limits the rate of calls, the agent is told how long to wait.
This version does not listen for mentions and has no write tools.
## Slack
The Slack add-on binds a Slack app to one of your [saved agents](/features/chats-agents/).
+1 -1
View File
@@ -3,7 +3,7 @@
This directory holds the template of the catalog entries of the first-party add-ons.
It holds no live URL, registry or namespace. The registry and repository are inputs of
the release workflow (`ADDON_OCI_REPOSITORY`). Each first-party add-on has its own
template (`tables`, `voice`, `face`, `fine-tuning`, `quantization`, `studio`, `talk`, `office`, `sandbox`, `web`, `memory`) and its own release
template (`tables`, `voice`, `face`, `fine-tuning`, `quantization`, `studio`, `talk`, `office`, `sandbox`, `web`, `memory`, `github`) and its own release
platform matrix, `addons/<id>/package/platforms.yaml`. The templates differ in `name:` only.
## Generate the index
+18
View File
@@ -0,0 +1,18 @@
id: "{{addon}}"
name: GitHub
description: "{{description}}"
version: "{{version}}"
channel: "{{channel}}"
artifact: "oci://{{repository}}:{{tag}}"
digest: "{{digest}}"
os: "{{os}}"
arch: "{{arch}}"
policy:
requireSignature: true
issuer: "{{issuer}}"
identity: "{{identity}}"
ui:
title: "{{title}}"
description: "{{description}}"
mainPage: "{{main_page}}"
icon: "{{icon}}"
+112
View File
@@ -194,6 +194,118 @@
- "fine-tune-checkpoints"
- "fine-tune-stop"
- "fine-tune-export"
- id: "github"
name: GitHub
description: "Let your agents read the code, issues and pull requests of the repositories you choose."
version: "0.0.0-development"
channel: "development"
artifact: "oci://quay.io/go-skynet/local-ai-addons:github-development"
mutable: true
os: "darwin"
arch: "amd64"
policy:
requireSignature: true
issuer: "https://token.actions.githubusercontent.com"
identity: "https://github.com/mudler/LocalAI/.github/workflows/addons-package.yml@refs/heads/master"
ui:
title: "GitHub"
description: "Let your agents read the code, issues and pull requests of the repositories you choose."
mainPage: "apps"
icon: "ui/icon.png"
toolCount: 8
tools:
- "search-code"
- "read-file"
- "list-tree"
- "list-issues"
- "get-issue"
- "get-pr"
- "get-pr-diff"
- "check"
- id: "github"
name: GitHub
description: "Let your agents read the code, issues and pull requests of the repositories you choose."
version: "0.0.0-development"
channel: "development"
artifact: "oci://quay.io/go-skynet/local-ai-addons:github-development"
mutable: true
os: "darwin"
arch: "arm64"
policy:
requireSignature: true
issuer: "https://token.actions.githubusercontent.com"
identity: "https://github.com/mudler/LocalAI/.github/workflows/addons-package.yml@refs/heads/master"
ui:
title: "GitHub"
description: "Let your agents read the code, issues and pull requests of the repositories you choose."
mainPage: "apps"
icon: "ui/icon.png"
toolCount: 8
tools:
- "search-code"
- "read-file"
- "list-tree"
- "list-issues"
- "get-issue"
- "get-pr"
- "get-pr-diff"
- "check"
- id: "github"
name: GitHub
description: "Let your agents read the code, issues and pull requests of the repositories you choose."
version: "0.0.0-development"
channel: "development"
artifact: "oci://quay.io/go-skynet/local-ai-addons:github-development"
mutable: true
os: "linux"
arch: "amd64"
policy:
requireSignature: true
issuer: "https://token.actions.githubusercontent.com"
identity: "https://github.com/mudler/LocalAI/.github/workflows/addons-package.yml@refs/heads/master"
ui:
title: "GitHub"
description: "Let your agents read the code, issues and pull requests of the repositories you choose."
mainPage: "apps"
icon: "ui/icon.png"
toolCount: 8
tools:
- "search-code"
- "read-file"
- "list-tree"
- "list-issues"
- "get-issue"
- "get-pr"
- "get-pr-diff"
- "check"
- id: "github"
name: GitHub
description: "Let your agents read the code, issues and pull requests of the repositories you choose."
version: "0.0.0-development"
channel: "development"
artifact: "oci://quay.io/go-skynet/local-ai-addons:github-development"
mutable: true
os: "linux"
arch: "arm64"
policy:
requireSignature: true
issuer: "https://token.actions.githubusercontent.com"
identity: "https://github.com/mudler/LocalAI/.github/workflows/addons-package.yml@refs/heads/master"
ui:
title: "GitHub"
description: "Let your agents read the code, issues and pull requests of the repositories you choose."
mainPage: "apps"
icon: "ui/icon.png"
toolCount: 8
tools:
- "search-code"
- "read-file"
- "list-tree"
- "list-issues"
- "get-issue"
- "get-pr"
- "get-pr-diff"
- "check"
- id: "memory"
name: Short memory
description: "Your chats keep short notes about you, read them before they answer, and save a new one only after you confirm it."
+4 -4
View File
@@ -916,7 +916,7 @@ var _ = Describe("catalog template", func() {
Expect(err).To(MatchError(ContainSubstring("development channel only")))
})
It("renders an entry for every first-party add-on from its own template", func() {
for _, id := range []string{"tables", "voice", "face", "fine-tuning", "quantization", "studio", "talk", "telegram", "slack", "office", "sandbox", "web", "memory"} {
for _, id := range []string{"tables", "voice", "face", "fine-tuning", "quantization", "studio", "talk", "telegram", "slack", "github", "office", "sandbox", "web", "memory"} {
p := newProjectFor(id, both)
m := p.merged("1.2.3", rev, nil)
repoName := newRegistry().repo("addons")
@@ -1013,7 +1013,7 @@ var _ = Describe("catalog template", func() {
for _, e := range entries {
got[e.ID] = e.ToolCount
}
Expect(got).To(Equal(map[string]int{"tables": 16, "fine-tuning": 8, "quantization": 6, "voice": 0, "face": 0, "studio": 0, "talk": 0, "telegram": 0, "slack": 0, "office": 11, "sandbox": 5, "web": 5, "memory": 0}))
Expect(got).To(Equal(map[string]int{"tables": 16, "fine-tuning": 8, "quantization": 6, "voice": 0, "face": 0, "studio": 0, "talk": 0, "telegram": 0, "slack": 0, "github": 8, "office": 11, "sandbox": 5, "web": 5, "memory": 0}))
})
It("refuses missing inputs, backend repositories, bad channels and unknown placeholders", func() {
reg := newRegistry()
@@ -1175,7 +1175,7 @@ var _ = Describe("workflow policy", func() {
})
It("rejects a workflow that names one add-on by path or skips an add-on in the matrix", func() {
expect(mutate("node \"addons/$ADDON/ui/build.mjs\"\n - name: Fill", "node addons/tables/ui/build.mjs\n - name: Fill"), "P_ADDON_PATH")
expect(mutate(" addon: [tables, voice, face, fine-tuning, quantization, studio, talk, telegram, slack, office, sandbox, web, memory]\n", " platform: [x]\n"), "P_MATRIX")
expect(mutate(" addon: [tables, voice, face, fine-tuning, quantization, studio, talk, telegram, slack, github, office, sandbox, web, memory]\n", " platform: [x]\n"), "P_MATRIX")
})
It("rejects a plan that does not refuse an add-on without an enabled platform", func() {
expect(mutate("has no enabled platform; refusing to publish", "has no platform"), "P_FAIL_CLOSED")
@@ -1225,7 +1225,7 @@ var _ = Describe("workflow policy", func() {
// firstPartyAddons are the add-ons the release workflow packages. The list is
// written out so a new add-on is a deliberate change here and in the workflow.
var firstPartyAddons = []string{"tables", "voice", "face", "fine-tuning", "quantization", "studio", "talk", "telegram", "slack", "office", "sandbox", "web", "memory"}
var firstPartyAddons = []string{"tables", "voice", "face", "fine-tuning", "quantization", "studio", "talk", "telegram", "slack", "github", "office", "sandbox", "web", "memory"}
func readWorkflow() string {
GinkgoHelper()