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/1299-workflows.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Settings has a **Workflows** page: the factory presets, your own definitions as `workflows/<id>.json` in the workspace, and which one this project uses. A definition is edited as a node table — id, name, role, inputs, outputs, guards, refusal — with the flowchart drawn above it from the same table and each guard showing the sentence it would print right now, or saying the fact could not be read. *Customize* copies a preset into a new file and never edits the shipped one, and saving an unedited definition writes identical bytes (#1272, step 3).
1,781 changes: 1,053 additions & 728 deletions core/client.js

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion core/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import * as dshUpdate from './src/host/dsh-update.js'
import * as hooks from './src/host/hooks.js'
import * as remoteChat from './src/host/remote-chat.js'
import * as deliveryBoard from './src/host/delivery-board.js'
import * as workflowStore from './src/host/workflow-store.js'
import * as channel from './src/host/channel.js'

export { COLORS } from './src/host/constants.js'
Expand All @@ -53,7 +54,7 @@ export const inject = ['llm', 'tools', 'systemPrompt', 'sessionController', 'ses
// the functions other modules call; they call them through `rt` at run time, never during
// install. Listeners run and tools are listed in registration order, so this order holds:
// usage meters model requests after every listener that rewrites them.
const MODULES = [diagnostics, store, harness, memberAuth, authz, models, permissions, turns, delivery, roster, folders, agents, prompt, timeContext, tools, decisions, chatIndex, groups, roomMembership, reach, tasks, taskDrive, approvals, resume, memory, parts, images, secrets, connectors, connectorAccess, capabilities, schedules, automationDefault, browser, usage, update, dshUpdate, hooks, remoteChat, deliveryBoard, channel]
const MODULES = [diagnostics, store, harness, memberAuth, authz, models, permissions, turns, delivery, roster, folders, agents, prompt, timeContext, tools, decisions, chatIndex, groups, roomMembership, reach, tasks, taskDrive, approvals, resume, memory, parts, images, secrets, connectors, connectorAccess, capabilities, schedules, automationDefault, browser, usage, update, dshUpdate, hooks, remoteChat, deliveryBoard, workflowStore, channel]

export function apply(ctx, config = {}) {
const rt = createRuntime(ctx, config)
Expand Down
48 changes: 42 additions & 6 deletions core/src/client/delivery-view.js
Original file line number Diff line number Diff line change
Expand Up @@ -359,16 +359,47 @@ export function DeliveryHeader({ view, snapshot, repo = null, onRetry = null, on

// One repository's columns (#1265 step 4). The card key carries the repository, so the same issue
// number in two repositories is two cards and neither is mistaken for the other.
function DeliveryColumns({ view, repo = null }) {
function DeliveryColumns({ view, repo = null, placed = null }) {
const columns = view?.columns ?? {}
// A card a workflow section already draws is not drawn again here (#1272 step 3): the section owns
// it, and the five legacy columns keep every card that names no workflow.
const drawn = placed === null || placed === undefined ? null : new Set(placed)
const cards = (id) => (columns[id] ?? []).filter((card) => drawn === null || !drawn.has(`${repo ?? card.repo ?? ''}#${card.kind}${card.number}`))
return h('div', { className: 'bt-del-columns' },
stagesOf(view).map(column => h('section', { key: column.id, className: 'bt-del-column', 'data-column': column.id },
h('h3', { className: 'bt-board-column-head' },
h('span', null, t(column.label)),
h('span', { className: 'bt-board-count' }, String((columns[column.id] ?? []).length))),
(columns[column.id] ?? []).length === 0
h('span', { className: 'bt-board-count' }, String(cards(column.id).length))),
cards(column.id).length === 0
? h('p', { className: 'bt-board-empty' }, t('Nothing here.'))
: (columns[column.id] ?? []).map(card => h(DeliveryCard, { key: `${repo ?? ''}-${card.kind}-${card.number}`, card, repo })))))
: cards(column.id).map(card => h(DeliveryCard, { key: `${repo ?? ''}-${card.kind}-${card.number}`, card, repo })))))
}

// The columns a workflow definition draws (#1272 step 3, acceptance 1 and 2): one block per workflow,
// the columns in the definition's own order, and every header naming the workflow it belongs to — so
// two workflows never share a column and a reader can always tell whose column it is.
function DeliveryWorkflowSections({ sections, repo = null }) {
const list = Array.isArray(sections) ? sections : []
if (list.length === 0) return null
return h('div', { className: 'bt-del-workflows' }, list.map(section => h('section', {
key: section.id, className: 'bt-del-workflow', 'data-workflow': section.id,
},
h('h3', { className: 'bt-del-workflow-name' },
h('span', null, section.name),
h('span', { className: 'bt-wf-path' }, `workflows/${section.id}.json`)),
h('div', { className: 'bt-del-columns' }, section.columns.map(column => h('section', {
key: column.id, className: 'bt-del-column', 'data-column': column.id, 'data-workflow': section.id,
},
h('h4', { className: 'bt-board-column-head' },
h('span', null, column.label),
h('span', { className: 'bt-del-workflow-tag' }, section.name),
h('span', { className: 'bt-board-count' }, String(column.cards.length))),
column.cards.length === 0
? h('p', { className: 'bt-board-empty' }, t('Nothing here.'))
: column.cards.map(card => h(DeliveryCard, { key: `${section.id}-${repo ?? card.repo ?? ''}-${card.kind}-${card.number}`, card, repo: repo ?? card.repo ?? null }))))),
(section.unplaced ?? []).length > 0
? h('p', { className: 'bt-del-hint' }, t('{count} card(s) name a node this workflow draws no column for.', { count: section.unplaced.length }))
: null)))
}

// A repository that cannot be read keeps its block and says so; the readable ones are drawn beside
Expand All @@ -383,7 +414,7 @@ function DeliveryRepoBlock({ entry }) {
? h('p', { className: 'bt-del-block-state', role: 'status' },
state === 'loading' ? loadingText(entry?.elapsedMs) : String(entry?.error?.message ?? t('The delivery board is not ready yet.')),
entry?.error ? ` · ${hintFor(entry.error, { repo: entry.repo })}` : '')
: h(DeliveryColumns, { view: entry, repo: entry.repo }))
: h(DeliveryColumns, { view: entry, repo: entry.repo, placed: entry.placed }))
}

export function DeliveryBoard({ view, snapshot, message = '', repo = null, onRetry = null, onProbe = null }) {
Expand Down Expand Up @@ -443,8 +474,13 @@ export function DeliveryBoard({ view, snapshot, message = '', repo = null, onRet
? h('p', { className: 'bt-del-partial', role: 'status' }, missing.map(entry => missingText(entry)).join(' · '))
: null,
h(DeliveryHeader, { view: payload, repo, onRetry, onProbe }),
// A workflow's own columns come first: a card that names a workflow is drawn there and not in
// the five columns below, which keep every card that names none (#1272 step 3).
entries === null
? h(DeliveryWorkflowSections, { sections: payload.workflows, repo: repo ?? payload.repo ?? null })
: null,
entries === null
? h(DeliveryColumns, { view: payload, repo: repo ?? payload.repo ?? null })
? h(DeliveryColumns, { view: payload, repo: repo ?? payload.repo ?? null, placed: payload.placed })
: h('div', { className: 'bt-del-repos' },
entries.map(entry => h(DeliveryRepoBlock, { key: entry.repo ?? 'unknown', entry }))))
}
Expand Down
27 changes: 26 additions & 1 deletion core/src/client/i18n.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,32 @@
export const NAMESPACE = 'ds-bot'

const ZH = {
// Workflows (#1272 step 3)
Workflows: '工作流',
'A workflow is a file: workflows/<id>.json. Columns, the flowchart and the refusals are all drawn from it.':
'工作流就是一个文件:workflows/<id>.json。列、流程图和拒绝语都从它画出来。',
'Factory presets': '出厂预设',
Yours: '你自己的',
'project default': '项目默认',
'Reading workflows…': '正在读取工作流…',
Customize: '定制一份',
Save: '保存',
'Use for this project': '本项目使用',
'This node has no guards, so nothing refuses it but its inputs.': '这个节点没有 guard,除了输入之外没有别的东西会拒绝它。',
Guards: 'Guard',
Edges: '边',
'Guards of {node}': '{node} 的 Guard',
holds: '成立',
'cannot read': '读不到',
refuses: '拒绝',
'The flowchart, drawn from the node table': '流程图,按节点表绘制',
'This host could not read the facts, so these guards say what they could not read — none of them is a pass.':
'这台主机读不到事实,所以这些 guard 只说读不到什么——没有一条算通过。',
'Saved workflows/{id}.json': '已保存 workflows/{id}.json',
'Could not open this workflow': '打不开这个工作流',
'This definition cannot be saved': '这个定义不能保存',
'This definition cannot be copied': '这个定义不能复制',
'{count} card(s) name a node this workflow draws no column for.': '有 {count} 张卡指向这个工作流不画列的节点。',
// Shared
'Main Bot': '主 Bot',
Working: '工作中',
Expand Down Expand Up @@ -848,7 +874,6 @@ const ZH = {
'At least {count} characters.': '至少 {count} 个字符。',
'Replace value': '替换值',
'Saving…': '保存中…',
Save: '保存',
'Allowing…': '允许中…',
Allow: '允许',
'{bot} asked for {name}': '{bot} 请求了 {name}',
Expand Down
2 changes: 2 additions & 0 deletions core/src/client/icons.js
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ export const DownloadIcon = lineIcon(['M10 3.5v9', 'M6.5 9.5L10 13l3.5-3.5', 'M4
export const GaugeIcon = lineIcon(['M3.5 13.5a6.5 6.5 0 1 1 13 0', 'M10 13.5l3-4', 'M10 13.5h.01'])
export const PlugIcon = lineIcon(['M7 3v4M13 3v4', 'M5 7h10v2.5a5 5 0 0 1-10 0z', 'M10 14.5V17'])
export const CalendarIcon = lineIcon(['M5.5 5h9A1.5 1.5 0 0 1 16 6.5v8a1.5 1.5 0 0 1-1.5 1.5h-9A1.5 1.5 0 0 1 4 14.5v-8A1.5 1.5 0 0 1 5.5 5z', 'M4 8.75h12', 'M7.25 3.5v3M12.75 3.5v3'])
export const WorkflowIcon = lineIcon(['M4.5 4h4v3.5h-4z', 'M11.5 12.5h4V16h-4z', 'M6.5 7.5v3.5a2 2 0 0 0 2 2h3'])

export const ConnectorIcon = lineIcon(['M7 10a2.5 2.5 0 1 1-5 0 2.5 2.5 0 0 1 5 0z', 'M18 10a2.5 2.5 0 1 1-5 0 2.5 2.5 0 0 1 5 0z', 'M7 10h6'])
export const SwitchIcon = lineIcon(['M4 7h9.5M10.5 3.5L14 7l-3.5 3.5', 'M16 13H6.5M9.5 9.5L6 13l3.5 3.5'])
export const PaperclipIcon = lineIcon(['M15.5 9.5l-5.6 5.6a3.4 3.4 0 0 1-4.8-4.8l6.1-6.1a2.25 2.25 0 0 1 3.2 3.2l-6 6a1.1 1.1 0 0 1-1.6-1.6l5.5-5.5'])
Expand Down
4 changes: 4 additions & 0 deletions core/src/client/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -727,6 +727,10 @@ export function apply(ctx) {
wakeUpdate()
}
actions.repoStars = () => call('repo-stars')
// A workflow definition is read and written through the host (#1272 step 3): the page never writes
// a file or GitHub itself, and workflow-save is the endpoint authz allows.
actions.workflows = (payload) => call('workflows', payload)
actions.workflowSave = (payload) => call('workflow-save', payload)
actions.noteTurnTime = (sessionId, turn, key, time) => {
if (sessionId === undefined || !Number.isFinite(turn) || !Number.isFinite(time)) return
turnClock.set((state) => {
Expand Down
7 changes: 5 additions & 2 deletions core/src/client/settings.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { createElement as h, useCallback, useEffect, useRef, useState } from 're
import { ACCENT_INKS, INKS } from './inks.js'
import { isMainOf } from './sources.js'
import { allThemes } from './themes.js'
import { WorkflowIcon } from './icons.js'
import { WorkflowsPage } from './workflows.js'
import { CloseIcon, ChevronLeftIcon, ChevronRightIcon, ClockIcon, ConnectorIcon, InfoIcon, PlugIcon, PlusIcon, SlidersIcon, SETTINGS_ICONS } from './icons.js'
import { AdminStar, BotAvatar, RoomAvatar } from './mark.js'
import { openHarnessSettings } from './sidebar.js'
Expand All @@ -24,8 +26,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'], ['connectors', 'Connectors'], ['capabilities', 'Capabilities'], ['schedules', 'Scheduled tasks'], ['usage', 'Usage'], ['secrets', 'Secrets'], ['feedback', 'Feedback'], ['about', 'About FlyBot']]
const PAGE_ICONS = { ...SETTINGS_ICONS, connectors: ConnectorIcon, capabilities: PlugIcon, schedules: ClockIcon, secrets: KeyIcon, feedback: FeedbackIcon, about: InfoIcon }
const SETTINGS_PAGES = [['general', 'General'], ['bots', 'Bots'], ['groups', 'Group chats'], ['connectors', 'Connectors'], ['capabilities', 'Capabilities'], ['schedules', 'Scheduled tasks'], ['workflows', 'Workflows'], ['usage', 'Usage'], ['secrets', 'Secrets'], ['feedback', 'Feedback'], ['about', 'About FlyBot']]
const PAGE_ICONS = { ...SETTINGS_ICONS, connectors: ConnectorIcon, workflows: WorkflowIcon, capabilities: 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 Down Expand Up @@ -228,6 +230,7 @@ export function SettingsDialog({ page, id, roster, actions, useUpdate, leaving =
room ? h('div', { className: 'bt-settings-form' }, h(GroupSettingsForm, { key: room.id, room, roster, actions, run })) : null,
shown === 'groups' && !room ? h(GroupsPage, { roster, actions, run }) : null,
shown === 'connectors' ? h(ConnectorsPage, { roster, actions }) : null,
shown === 'workflows' ? h(WorkflowsPage, { actions }) : 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 === 'schedules' ? h(SchedulesPage, { roster, actions, start: id ?? undefined }) : null,
Expand Down
Loading
Loading