Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/2564-plugin-switches.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Settings → Plugins can turn the Bot Social, Tree, Timeline and Workflow plugins off and back on ([#2564](https://github.com/ThinkFlowLab/FlyBot/issues/2564)). The switch is kept in FlyBot's own `state.json` and applies at the next start: a plugin that is off registers no tools, panels or endpoints, and the packages stay installed so nothing has to be reinstalled.
79 changes: 76 additions & 3 deletions core/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -1300,6 +1300,12 @@ var ZH = {
"On by default for each of your Bots, on every turn: in its own chat, in groups, on schedules and when a friend uses it. A friend's own Bot never gets it.": "默认对你的每个 Bot 开启,每一轮都可用:它自己的聊天、群聊、定时任务,以及好友使用它时。好友自己的 Bot 永远拿不到。",
"No Bots": "没有 Bot",
"Scheduled tasks": "定时任务",
"A switch takes effect the next time FlyBot starts.": "开关在下次启动 FlyBot 时生效。",
"Restart FlyBot to apply the switches you changed.": "重启 FlyBot 后,你改动的开关才会生效。",
"Chat with a friend's Bots on a Bot Social server.": "在 Bot Social 服务器上与朋友的 Bot 聊天。",
"Run Bots as a tree under a coordinator.": "把 Bot 组织成树,由协调者带队。",
"Read the team activity as a timeline.": "用时间线查看团队活动。",
"Run workflow graphs across several Bots.": "跨多个 Bot 运行工作流图。",
"Scheduled tasks · {count}": "定时任务 · {count}",
"Every day": "每天",
"Every {count} days": "每 {count} 天",
Expand Down Expand Up @@ -1959,10 +1965,18 @@ function faceAliases(participant, name) {

// src/client/flybot-registry.js
var SLOTS = ["flybot.sidebar.section", "flybot.sidebar.structure", "flybot.composer.mention", "flybot.bot.details", "flybot.approvals", "flybot.room.overview", "flybot.room.members", "flybot.session.dock", "flybot.new-chat.action", "flybot.account.header", "flybot.settings.page"];
var SLOT_OWNERS = [
["social", /^social(?:$|-)/],
["tree", /^flybot-tree(?:$|-)/],
["timeline", /^timeline$/],
["workflow", /^workflow$/]
];
var ownerOf = (id) => SLOT_OWNERS.find(([, pattern]) => pattern.test(String(id ?? "")))?.[0];
function createRegistry() {
const entries2 = new Map(SLOTS.map((name) => [name, []]));
const listeners2 = /* @__PURE__ */ new Set();
let version = 0;
let disabled = /* @__PURE__ */ new Set();
const changed2 = () => {
version += 1;
for (const fn of [...listeners2]) fn();
Expand All @@ -1982,7 +1996,11 @@ function createRegistry() {
changed2();
};
},
entries: (name) => entries2.get(name) ?? [],
entries: (name) => (entries2.get(name) ?? []).filter((entry) => !disabled.has(ownerOf(entry.id))),
setDisabledPlugins(ids = []) {
disabled = /* @__PURE__ */ new Set([...ids]);
changed2();
},
subscribe(fn) {
listeners2.add(fn);
return () => listeners2.delete(fn);
Expand All @@ -1995,6 +2013,10 @@ function registry() {
root.__flybot__ ??= { slots: createRegistry() };
return root.__flybot__.slots;
}
function setDisabledPlugins(ids = []) {
registry().setDisabledPlugins(ids);
}
var pluginOfSlot = ownerOf;
function newChatActionRows(context = {}) {
const rows = [];
for (const entry of registry().entries("flybot.new-chat.action")) {
Expand Down Expand Up @@ -19285,8 +19307,8 @@ var ABOUT_CSS = `
// src/client/settings.js
var import_react42 = require("react");
var capitalize2 = (word) => word[0].toUpperCase() + word.slice(1);
var SETTINGS_PAGES = [["general", "General"], ["bots", "Bots"], ["groups", "Group chats"], ["projects", "Projects"], ["connectors", "Connectors"], ["capabilities", "Capabilities"], ["schedules", "Scheduled tasks"], ["workflows", "Workflows"], ["usage", "Usage"], ["secrets", "Secrets"], ["feedback", "Feedback"], ["about", "About FlyBot"]];
var PAGE_ICONS = { ...SETTINGS_ICONS, projects: FolderIcon, connectors: ConnectorIcon, workflows: WorkflowIcon, capabilities: PlugIcon, schedules: ClockIcon, secrets: KeyIcon, feedback: FeedbackIcon, about: InfoIcon };
var SETTINGS_PAGES = [["general", "General"], ["bots", "Bots"], ["groups", "Group chats"], ["projects", "Projects"], ["connectors", "Connectors"], ["capabilities", "Capabilities"], ["plugins", "Plugins"], ["schedules", "Scheduled tasks"], ["workflows", "Workflows"], ["usage", "Usage"], ["secrets", "Secrets"], ["feedback", "Feedback"], ["about", "About FlyBot"]];
var PAGE_ICONS = { ...SETTINGS_ICONS, projects: FolderIcon, connectors: ConnectorIcon, workflows: WorkflowIcon, capabilities: PlugIcon, plugins: PlugIcon, schedules: ClockIcon, secrets: KeyIcon, feedback: FeedbackIcon, about: InfoIcon };
function SetSection({ title, children }) {
return (0, import_react42.createElement)("section", { className: "bt-set-section" }, (0, import_react42.createElement)("h3", null, title), (0, import_react42.createElement)("div", { className: "bt-set-card" }, children));
}
Expand All @@ -19303,6 +19325,46 @@ var NOTIFY_KINDS = [
["finished", "Finished"],
["failed", "Failed"]
];
function PluginsPage({ call: call4 }) {
const [answer, setAnswer] = (0, import_react42.useState)(null);
const [error, setError] = (0, import_react42.useState)("");
(0, import_react42.useEffect)(() => {
let live = true;
call4("plugins/list").then((value) => {
if (live) setAnswer(value ?? { plugins: [] });
}).catch((failure) => {
if (live) setError(failure?.message ?? String(failure));
});
return () => {
live = false;
};
}, []);
const flip = (id, enabled) => {
call4("plugins/set", { id, enabled }).then((value) => {
setAnswer(value ?? null);
setError("");
}).catch((failure) => setError(failure?.message ?? String(failure)));
};
const plugins = answer?.plugins ?? [];
return (0, import_react42.createElement)(
"div",
null,
(0, import_react42.createElement)(
SetSection,
{ title: t("Plugins") },
error ? (0, import_react42.createElement)("p", { className: "bt-set-hint", role: "alert" }, error) : null,
answer === null && !error ? (0, import_react42.createElement)("p", { className: "bt-set-hint" }, t("Loading…")) : null,
plugins.map((plugin) => (0, import_react42.createElement)(SwitchRow, {
key: plugin.id,
label: plugin.name,
hint: `${plugin.package} — ${t(plugin.purpose)}`,
on: plugin.enabled !== false,
onChange: (enabled) => flip(plugin.id, enabled)
})),
(0, import_react42.createElement)("p", { className: "bt-set-hint" }, answer?.pendingRestart === true ? t("Restart FlyBot to apply the switches you changed.") : t("A switch takes effect the next time FlyBot starts."))
)
);
}
function GeneralPage({ roster, actions, run }) {
const prefs = roster.prefs ?? {};
const themes = allThemes();
Expand Down Expand Up @@ -19618,6 +19680,7 @@ function SettingsDialog({ page, id, roster, actions, useUpdate, leaving = false
shown === "workflows" ? (0, import_react42.createElement)(WorkflowsPage, { call: call2, roster }) : null,
pluginPage ? (0, import_react42.createElement)(SettingsPageSlot, { entry: pluginPage, props: { roster, actions, call: call2, run, t, Section: SetSection, Row: SetRow } }) : null,
shown === "capabilities" ? (0, import_react42.createElement)(CapabilitiesPage, { roster, actions, run }) : null,
shown === "plugins" ? (0, import_react42.createElement)(PluginsPage, { call: call2 }) : null,
shown === "schedules" ? (0, import_react42.createElement)(SchedulesPage, { roster, actions, start: id ?? void 0 }) : null,
shown === "secrets" ? (0, import_react42.createElement)(SecretsPage, { roster, actions, run, Section: SetSection }) : null,
shown === "feedback" ? (0, import_react42.createElement)(FeedbackPage, { roster, actions, run, Section: SetSection, Row: SetRow }) : null,
Expand Down Expand Up @@ -22533,6 +22596,16 @@ function apply(ctx) {
}
ctx.effect(() => installLocale(ctx.get("locale")), "ds-bot: interface copy");
ctx.effect(() => installBrandChrome(), "ds-bot: brand chrome");
ctx.effect(() => {
let live2 = true;
call2("plugins/list").then((answer) => {
if (live2) setDisabledPlugins((answer?.plugins ?? []).filter((plugin) => plugin.enabled === false).map((plugin) => plugin.id));
}).catch(() => {
});
return () => {
live2 = false;
};
}, "ds-bot: plugin switches");
const roster = createSource(EMPTY_ROSTER);
ctx.effect(() => installTabTitle(ctx.sessions.list, roster, typeof document === "undefined" ? void 0 : document), "ds-bot: tab title");
migrateStorage();
Expand Down
28 changes: 27 additions & 1 deletion core/src/client/flybot-registry.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,25 @@
import { remoteMentionLabel } from '../shared/remote-label.js'
export const SLOTS = ['flybot.sidebar.section', 'flybot.sidebar.structure', 'flybot.composer.mention', 'flybot.bot.details', 'flybot.approvals', 'flybot.room.overview', 'flybot.room.members', 'flybot.session.dock', 'flybot.new-chat.action', 'flybot.account.header', 'flybot.settings.page']

// Which feature plugin a slot entry belongs to (#2564), read from the ids the four
// plugin bundles register under. It lives here because a disabled plugin's bundle still
// loads — the switch only stops its apply() on the host — so core is what hides the
// panels it would have drawn, and one list is easier to keep true than five call sites.
const SLOT_OWNERS = [
['social', /^social(?:$|-)/],
['tree', /^flybot-tree(?:$|-)/],
['timeline', /^timeline$/],
['workflow', /^workflow$/],
]
const ownerOf = id => SLOT_OWNERS.find(([, pattern]) => pattern.test(String(id ?? '')))?.[0]

export function createRegistry() {
const entries = new Map(SLOTS.map(name => [name, []]))
const listeners = new Set()
let version = 0
// Feature plugins the owner switched off (#2564): their entries stay registered but
// are never handed out, so every surface that reads a slot drops them together.
let disabled = new Set()
const changed = () => {
version += 1
for (const fn of [...listeners]) fn()
Expand All @@ -26,7 +41,11 @@ export function createRegistry() {
changed()
}
},
entries: name => entries.get(name) ?? [],
entries: name => (entries.get(name) ?? []).filter(entry => !disabled.has(ownerOf(entry.id))),
setDisabledPlugins(ids = []) {
disabled = new Set([...ids])
changed()
},
subscribe(fn) {
listeners.add(fn)
return () => listeners.delete(fn)
Expand All @@ -41,6 +60,13 @@ export function registry() {
return root.__flybot__.slots
}

// The live registry's switches, set from the host's answer at start and from Settings.
export function setDisabledPlugins(ids = []) {
registry().setDisabledPlugins(ids)
}

export const pluginOfSlot = ownerOf

// Extra "@" rows from plugins (remote Bots and the like), after the team's own Bots.
// Extra rows in the sidebar '+' new-chat menu (after New Bot / New group chat).
export function newChatActionRows(context = {}) {
Expand Down
6 changes: 6 additions & 0 deletions core/src/client/i18n.js
Original file line number Diff line number Diff line change
Expand Up @@ -1254,6 +1254,12 @@ const ZH = {
'On by default for each of your Bots, on every turn: in its own chat, in groups, on schedules and when a friend uses it. A friend\'s own Bot never gets it.': '默认对你的每个 Bot 开启,每一轮都可用:它自己的聊天、群聊、定时任务,以及好友使用它时。好友自己的 Bot 永远拿不到。',
'No Bots': '没有 Bot',
'Scheduled tasks': '定时任务',
'A switch takes effect the next time FlyBot starts.': '开关在下次启动 FlyBot 时生效。',
'Restart FlyBot to apply the switches you changed.': '重启 FlyBot 后,你改动的开关才会生效。',
'Chat with a friend\'s Bots on a Bot Social server.': '在 Bot Social 服务器上与朋友的 Bot 聊天。',
'Run Bots as a tree under a coordinator.': '把 Bot 组织成树,由协调者带队。',
'Read the team activity as a timeline.': '用时间线查看团队活动。',
'Run workflow graphs across several Bots.': '跨多个 Bot 运行工作流图。',
'Scheduled tasks · {count}': '定时任务 · {count}',
'Every day': '每天',
'Every {count} days': '每 {count} 天',
Expand Down
13 changes: 11 additions & 2 deletions core/src/client/index.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { FLYBOT_CSS, FlyBotApprovals, FlyBotSessionDock, createClientApi as createFlyBotClient } from './flybot-slots.js'
import { FLYBOT_CSS, FlyBotApprovals, FlyBotSessionDock, call as flybotCall, createClientApi as createFlyBotClient } from './flybot-slots.js'
import { createElement as h } from 'react'
import { registry as flybotSlots } from './flybot-registry.js'
import { registry as flybotSlots, setDisabledPlugins } from './flybot-registry.js'
import { TURN_FAILURES_CSS, TurnFailures } from './turn-failures.js'
import { call } from './api.js'
import { isHex } from './inks.js'
Expand Down Expand Up @@ -65,6 +65,15 @@ export function apply(ctx) {
} catch { /* every provide form refused: __flybot__.api above still serves plugins */ }
ctx.effect(() => installLocale(ctx.get('locale')), 'ds-bot: interface copy')
ctx.effect(() => installBrandChrome(), 'ds-bot: brand chrome')
// A plugin the owner switched off (#2564) still loads its browser bundle, so the
// switch is read once and its slot entries are dropped before anything draws them.
ctx.effect(() => {
let live = true
flybotCall('plugins/list')
.then(answer => { if (live) setDisabledPlugins((answer?.plugins ?? []).filter(plugin => plugin.enabled === false).map(plugin => plugin.id)) })
.catch(() => { /* an older host has no switch: every plugin stays visible */ })
return () => { live = false }
}, 'ds-bot: plugin switches')
const roster = createSource(EMPTY_ROSTER)
ctx.effect(() => installTabTitle(ctx.sessions.list, roster, typeof document === 'undefined' ? undefined : document), 'ds-bot: tab title')
migrateStorage()
Expand Down
40 changes: 38 additions & 2 deletions core/src/client/settings.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,8 @@ const capitalize = word => word[0].toUpperCase() + word.slice(1)

// Settings dialog: a 198px page list beside the page. Bots and group chats each list
// their members, and one opens on its own settings form. Usage widens the dialog.
const SETTINGS_PAGES = [['general', 'General'], ['bots', 'Bots'], ['groups', 'Group chats'], ['projects', 'Projects'], ['connectors', 'Connectors'], ['capabilities', 'Capabilities'], ['schedules', 'Scheduled tasks'], ['workflows', 'Workflows'], ['usage', 'Usage'], ['secrets', 'Secrets'], ['feedback', 'Feedback'], ['about', 'About FlyBot']]
const PAGE_ICONS = { ...SETTINGS_ICONS, projects: FolderIcon, connectors: ConnectorIcon, workflows: WorkflowIcon, capabilities: PlugIcon, schedules: ClockIcon, secrets: KeyIcon, feedback: FeedbackIcon, about: InfoIcon }
const SETTINGS_PAGES = [['general', 'General'], ['bots', 'Bots'], ['groups', 'Group chats'], ['projects', 'Projects'], ['connectors', 'Connectors'], ['capabilities', 'Capabilities'], ['plugins', 'Plugins'], ['schedules', 'Scheduled tasks'], ['workflows', 'Workflows'], ['usage', 'Usage'], ['secrets', 'Secrets'], ['feedback', 'Feedback'], ['about', 'About FlyBot']]
const PAGE_ICONS = { ...SETTINGS_ICONS, projects: FolderIcon, connectors: ConnectorIcon, workflows: WorkflowIcon, capabilities: PlugIcon, plugins: PlugIcon, schedules: ClockIcon, secrets: KeyIcon, feedback: FeedbackIcon, about: InfoIcon }

function SetSection({ title, children }) {
return h('section', { className: 'bt-set-section' }, h('h3', null, title), h('div', { className: 'bt-set-card' }, children))
Expand All @@ -46,6 +46,41 @@ const NOTIFY_KINDS = [
['finished', 'Finished'],
['failed', 'Failed'],
]
// Settings → Plugins (#2564): the four feature plugins, each with one switch. The
// switch writes FlyBot's state.json; the packages stay installed and the next start
// reads the answer, so nothing unloads from the session that flipped it.
function PluginsPage({ call }) {
const [answer, setAnswer] = useState(null)
const [error, setError] = useState('')
useEffect(() => {
let live = true
call('plugins/list')
.then(value => { if (live) setAnswer(value ?? { plugins: [] }) })
.catch(failure => { if (live) setError(failure?.message ?? String(failure)) })
return () => { live = false }
}, [])
const flip = (id, enabled) => {
call('plugins/set', { id, enabled })
.then(value => { setAnswer(value ?? null); setError('') })
.catch(failure => setError(failure?.message ?? String(failure)))
}
const plugins = answer?.plugins ?? []
return h('div', null,
h(SetSection, { title: t('Plugins') },
error ? h('p', { className: 'bt-set-hint', role: 'alert' }, error) : null,
answer === null && !error ? h('p', { className: 'bt-set-hint' }, t('Loading…')) : null,
plugins.map(plugin => h(SwitchRow, {
key: plugin.id,
label: plugin.name,
hint: `${plugin.package} — ${t(plugin.purpose)}`,
on: plugin.enabled !== false,
onChange: enabled => flip(plugin.id, enabled),
})),
h('p', { className: 'bt-set-hint' }, answer?.pendingRestart === true
? t('Restart FlyBot to apply the switches you changed.')
: t('A switch takes effect the next time FlyBot starts.'))))
}

// General is Appearance and Notifications; the switch to classic Agent mode is the account menu's (#393).
function GeneralPage({ roster, actions, run }) {
const prefs = roster.prefs ?? {}
Expand Down Expand Up @@ -272,6 +307,7 @@ export function SettingsDialog({ page, id, roster, actions, useUpdate, leaving =
shown === 'workflows' ? h(WorkflowsPage, { call, roster }) : null,
pluginPage ? h(SettingsPageSlot, { entry: pluginPage, props: { roster, actions, call, run, t, Section: SetSection, Row: SetRow } }) : null,
shown === 'capabilities' ? h(CapabilitiesPage, { roster, actions, run }) : null,
shown === 'plugins' ? h(PluginsPage, { call }) : null,
shown === 'schedules' ? h(SchedulesPage, { roster, actions, start: id ?? undefined }) : null,
shown === 'secrets' ? h(SecretsPage, { roster, actions, run, Section: SetSection }) : null,
shown === 'feedback' ? h(FeedbackPage, { roster, actions, run, Section: SetSection, Row: SetRow }) : null,
Expand Down
16 changes: 16 additions & 0 deletions core/src/host/hooks.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { FEED_EVENTS, installFeed } from './flybot-feed.js'
import { createPluginModel } from './plugin-model.js'
import { defineTool } from './tool-schema.js'
import { retiredBot } from './store.js'
import { pluginEnabledAtStart, pluginSwitches, setPluginSwitch } from './plugins.js'
import { codePoints } from './text.js'
import { cut } from '../shared/text-boundary.js'
import { EPHEMERAL_DENIED_TOOLS } from './tools.js'
Expand Down Expand Up @@ -1102,6 +1103,17 @@ export function install(rt) {
},
}
const isReadEndpoint = name => READ_ENDPOINTS.has(name) || readHandlers.has(name)

// The owner's plugin switches (#2564). A flip only writes state.json: the plugins
// already loaded keep running until the next start, and `pendingRestart` says so.
api.handle('plugins/list', async () => ({ plugins: pluginSwitches(state), pendingRestart: rt.pluginSwitchPending === true }), { read: true })
api.handle('plugins/set', async (body = {}) => {
const before = pluginSwitches(state).find(plugin => plugin.id === body.id)
const plugins = setPluginSwitch(state, body.id, body.enabled)
if (before !== undefined && before.enabled !== body.enabled) rt.pluginSwitchPending = true
await rt.save()
return { plugins, pendingRestart: rt.pluginSwitchPending === true }
})
async function endpoint(name, payload, { actor } = {}) {
await rt.load()
const body = payload ?? {}
Expand Down Expand Up @@ -1143,6 +1155,10 @@ export function install(rt) {
const fields = fieldsFor(plugin)
return {
version: FLYBOT_API_VERSION,
// The owner's switch for a feature plugin (#2564): a plugin asks once, in its own
// apply(), and registers nothing when this answers false. Read from the state file
// the last run wrote, because plugins load before the team is loaded.
pluginEnabled: id => pluginEnabledAtStart(rt.statePath, id),
plugin,
scope: build,
bot: {
Expand Down
Loading
Loading