From 51040d46c2fe287320e520b860213bc6f9486f91 Mon Sep 17 00:00:00 2001 From: FlyBot Dev-4 Date: Tue, 3 Nov 2026 05:20:29 +0000 Subject: [PATCH] core: a read-only window's create entries no longer open the scheduled-task form the create form stayed: a Bot's Scheduled tasks card kept its New task row, the details line kept its empty state's Add one, and the composer's "+" menu kept Scheduled task. Each asked the settings page for a Bot's id, which opens the create form, and a locked page draws no form, so the click landed on an empty page. A locked window now draws no New task row, no empty-state Add one, and no Scheduled task menu row. With tasks, the details line keeps its read-only rows (they open the plain settings list). In a writable window every one of them stays. P-457 (bench, local only; the issue and pull request follow the core/client.js lane) Owner: Dev-4 Written-by: Dev-4 Reviewer: Birfy Base: 50081167e81ac5ee182e6cc5d1a25ab248329e32 --- core/client.js | 3375 +++--- core/src/client/composer.js | 10 +- core/src/client/details-drawer.js | 2 +- core/src/client/schedules.js | 16 +- core/test/composer-locked.test.js | 82 + core/test/schedules-client.test.js | 32 + docs/subsystems/core-client.md | 2 +- patches/ds-bot/0001-core-fixes.patch | 19 +- patches/ds-bot/0008-client-bundle.patch | 9347 +++++++++-------- patches/ds-bot/0012-room-membership.patch | 40 +- .../ds-bot/files/test/composer-locked.test.js | 82 + .../files/test/schedules-client.test.js | 32 + 12 files changed, 6975 insertions(+), 6064 deletions(-) create mode 100644 core/test/composer-locked.test.js create mode 100644 patches/ds-bot/files/test/composer-locked.test.js diff --git a/core/client.js b/core/client.js index 8563b9a3a..0156c32d2 100644 --- a/core/client.js +++ b/core/client.js @@ -11970,1821 +11970,1822 @@ function installHeaderEffects({ sessions, roster, actions, doc = globalThis.docu }; } -// src/client/composer.js +// src/client/schedules.js var import_react21 = require("react"); -var import_react_dom7 = require("react-dom"); -var speechApi = () => window.SpeechRecognition ?? window.webkitSpeechRecognition; -var keepComposerFocus = (event) => event.preventDefault(); -function useDictation(onText) { - const [listening, setListening] = (0, import_react21.useState)(false); - const [note, setNote] = (0, import_react21.useState)(""); - const recognition = (0, import_react21.useRef)(null); - const latest = (0, import_react21.useRef)(onText); - latest.current = onText; - (0, import_react21.useEffect)(() => () => recognition.current?.abort(), []); +var POLL_MS4 = 3e4; +var shared = null; +var listeners = /* @__PURE__ */ new Set(); +function publish(view) { + shared = view; + for (const listener of listeners) listener(view); +} +function publishFailure(error) { + publish({ available: false, tasks: [], readFailure: hostText(error?.message ?? String(error)) }); +} +function useSchedules(actions) { + const [view, setView] = (0, import_react21.useState)(shared); (0, import_react21.useEffect)(() => { - if (!note) return void 0; - const timer = setTimeout(() => setNote(""), 2600); - return () => clearTimeout(timer); - }, [note]); - const start = ({ continuous = true, onEnd } = {}) => { - const Api = speechApi(); - if (!Api) { - setNote(t("Dictation is not available in this browser")); - return false; - } - const engine = new Api(); - engine.lang = navigator.language || "zh-CN"; - engine.interimResults = true; - engine.continuous = continuous; - engine.onresult = (event) => { - let interim = ""; - for (let index = event.resultIndex; index < event.results.length; index += 1) { - const result = event.results[index]; - const text2 = result[0]?.transcript ?? ""; - if (result.isFinal) { - if (text2.trim()) latest.current(text2, true); - } else interim += text2; - } - if (interim) latest.current(interim, false); - }; - engine.onerror = (event) => { - if (event.error === "aborted" || event.error === "no-speech") return; - setNote(event.error === "not-allowed" || event.error === "service-not-allowed" ? t("Microphone access is blocked") : t("Couldn't hear that")); - }; - engine.onend = () => { - setListening(false); - recognition.current = null; - onEnd?.(); + listeners.add(setView); + let stopped = false; + let timer; + const load = () => actions.schedules?.().then((next) => { + if (!stopped) publish(next); + }).catch((error) => { + if (!stopped) publishFailure(error); + }).finally(() => { + if (!stopped) timer = setTimeout(load, POLL_MS4); + }); + load(); + return () => { + stopped = true; + clearTimeout(timer); + listeners.delete(setView); }; - recognition.current = engine; - try { - engine.start(); - setListening(true); - return true; - } catch { - setNote(t("Couldn't start dictation")); - return false; + }, []); + return view; +} +var schedulesReadFailure = (failure) => typeof failure === "string" && failure !== "" ? t("The scheduled tasks could not be read: {reason}", { reason: failure }) : null; +var localZone = () => Intl.DateTimeFormat().resolvedOptions().timeZone; +var clock2 = (time) => String(time ?? "").slice(0, 5); +var when2 = (ms, options) => new Intl.DateTimeFormat(dateLocale(), options).format(ms); +function dayAndTime(ms) { + const sameYear = new Date(ms).getFullYear() === (/* @__PURE__ */ new Date()).getFullYear(); + return when2(ms, { ...sameYear ? {} : { year: "numeric" }, month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }); +} +function instant(value) { + const at = Date.parse(value); + return Number.isFinite(at) ? dayAndTime(at) : ""; +} +function weekdayNames(days) { + const names = days.map((day) => when2(Date.UTC(2024, 0, day), { weekday: "short", timeZone: "UTC" })); + return names.join(dateLocale().startsWith("zh") ? "、" : ", "); +} +function everyText(seconds) { + if (seconds % 86400 === 0) return seconds === 86400 ? t("Every day") : t("Every {count} days", { count: seconds / 86400 }); + if (seconds % 3600 === 0) return seconds === 3600 ? t("Every hour") : t("Every {count} hours", { count: seconds / 3600 }); + if (seconds % 60 === 0) return t("Every {count} minutes", { count: seconds / 60 }); + return t("Every {count} seconds", { count: seconds }); +} +function timingText(task) { + const zone = task.timeZone && task.timeZone !== localZone() ? ` (${task.timeZone})` : ""; + switch (task.kind) { + case "after": + case "at": { + const time = instant(task.scheduledAt); + return time === "" ? "" : t("Once, {time}", { time }); } - }; - const stop = () => recognition.current?.stop(); - return { listening, note, start, stop }; + case "every": + return everyText(task.everySeconds); + case "daily": + return `${t("Every day at {time}", { time: clock2(task.time) })}${zone}`; + case "weekly": { + const days = (task.weekdays ?? []).join(); + const text2 = days === "1,2,3,4,5" ? t("Every weekday at {time}", { time: clock2(task.time) }) : days === "6,7" ? t("Every weekend at {time}", { time: clock2(task.time) }) : days === "1,2,3,4,5,6,7" ? t("Every day at {time}", { time: clock2(task.time) }) : t("Every {days} at {time}", { days: weekdayNames(task.weekdays ?? []), time: clock2(task.time) }); + return `${text2}${zone}`; + } + case "cron": + return `${t("On the schedule {expression}", { expression: task.expression })}${zone}`; + default: + return ""; + } } -function ComposerIdentity({ sessionId, useRoster }) { - const roster = useRoster((value) => value); - const room2 = roster.roomsById?.[sessionId]; - const self = roster.self; - const humans = (room2?.participants ?? []).filter((p) => p.kind === "human" && p.leftSeq === void 0); - if (room2?.viewerRole === "invited-owner") { - return (0, import_react21.createElement)( - "span", - { className: "bt-composer-as", title: t("You can remove your Bot from this room") }, - t("Shared room from {name}", { name: room2.sharedFrom?.inviterName || t("a friend") }) - ); +var nextText = (task) => { + if (task.status === "paused") return t("Paused"); + if (task.status !== "active") return t("Finished"); + const at = instant(task.scheduledAt); + return at === "" ? timingText(task) : t("Next: {time}", { time: at }); +}; +function lastRunText(run) { + if (run === void 0 || !Number.isFinite(run.at)) return ""; + const time = dayAndTime(run.at); + switch (run.result) { + case "running": + return t("Running since {time}", { time }); + case "stopped": + return t("Last run {time} · Stopped", { time }); + case "failed": + return t("Last run {time} · Failed: {reason}", { time, reason: hostText(run.reason || "Unknown error") }); + default: + return t("Last run {time}", { time }); } - if (!self || !room2 || humans.length < 2) return null; - return (0, import_react21.createElement)( - "span", - { className: "bt-composer-as", title: t("Messages in this room are attributed to you") }, - t("Sending as {name}", { name: self.displayName || self.memberId }) - ); } -function ComposerVoice({ sessionId, inputActions, actions, useUi }) { - const dictation = useDictation((text2, final) => { - if (final && inputActions) inputActions.insertText(text2, inputActions.captureInsertion()); - }); - const quote = useUi((state) => state.quote?.sessionId === sessionId ? state.quote : null); +var lockedWindow = (roster) => typeof roster?.readOnly === "string" && roster.readOnly.trim() !== ""; +function TaskRow({ task, actions, onView, onEdit, onCopy, showBot, roster, locked }) { + const [confirming, setConfirming] = (0, import_react21.useState)(false); + const [busy, setBusy] = (0, import_react21.useState)(false); + const [rowError, setRowError] = (0, import_react21.useState)(""); + const refuse = (error) => setRowError(hostText(error?.message ?? String(error))); (0, import_react21.useEffect)(() => { - if (!quote || !inputActions) return; - actions.clearQuote(quote.token); - const excerpt = quote.text.replace(/\s+/g, " ").trim(); - const line2 = `> ${excerpt.length > 200 ? `${excerpt.slice(0, 200)}…` : excerpt} - -`; - inputActions.insertText(line2, inputActions.captureInsertion()); - const box = document.querySelector('[class*="_centerCol"] [role=textbox][contenteditable=true]'); - if (box instanceof HTMLElement) { - box.focus(); - const selection = window.getSelection(); - selection?.selectAllChildren(box); - selection?.collapseToEnd(); + if (!confirming) return void 0; + const timer = setTimeout(() => setConfirming(false), 3e3); + return () => clearTimeout(timer); + }, [confirming]); + const remove = () => { + if (!confirming) { + setConfirming(true); + return; } - }, [quote?.token]); - if (sessionId === void 0) return null; - const pluginMic = actions.speech() !== void 0; + setBusy(true); + setRowError(""); + void actions.scheduleDelete(task.sessionId, task.id).then((next) => { + setBusy(false); + setRowError(""); + onView(next); + }, (error) => { + setBusy(false); + refuse(error); + }); + }; + const toggle = () => { + setBusy(true); + setRowError(""); + const step = task.status === "paused" ? actions.scheduleResume : actions.schedulePause; + void step(task.sessionId, task.id).then((next) => { + setBusy(false); + setRowError(""); + onView(next); + }, (error) => { + setBusy(false); + refuse(error); + }); + }; + const retry = () => { + setBusy(true); + setRowError(""); + void actions.scheduleRetry(task.sessionId, task.id).then((next) => { + setBusy(false); + onView(next); + }, (error) => { + setBusy(false); + refuse(error); + }); + }; + const room2 = task.roomId ? roster.roomsById[task.roomId] : void 0; + const stoppable = task.status === "active" || task.status === "paused"; + const failed = task.lastRun?.result === "failed"; + const last = lastRunText(task.lastRun); return (0, import_react21.createElement)( - import_react21.Fragment, - null, - pluginMic ? null : (0, import_react21.createElement)("button", { - type: "button", - className: "bt-mic", - "aria-label": dictation.listening ? t("Stop dictation") : t("Dictate"), - "aria-pressed": dictation.listening, - title: dictation.listening ? t("Stop dictation") : t("Dictate"), - onMouseDown: keepComposerFocus, - onClick: () => dictation.listening ? dictation.stop() : dictation.start() - }, (0, import_react21.createElement)(MicIcon)), - (0, import_react21.createElement)("button", { + "div", + { className: "bt-set-row bt-task", "data-done": stoppable ? void 0 : "", "data-paused": task.status === "paused" || void 0, "data-busy": busy || void 0 }, + showBot ? (0, import_react21.createElement)(BotAvatar, { bot: roster.byId[task.botId], size: 28, badge: false, live: false }) : null, + (0, import_react21.createElement)( + "span", + { className: "bt-set-copy" }, + (0, import_react21.createElement)("span", { className: "bt-task-title" }, task.title), + (0, import_react21.createElement)("span", { className: "bt-set-hint" }, [timingText(task), room2 ? t("in {name}", { name: room2.name }) : ""].filter(Boolean).join(" · ")), + task.prompt && task.prompt !== task.title ? (0, import_react21.createElement)("span", { className: "bt-task-prompt" }, task.prompt) : null, + last ? (0, import_react21.createElement)("span", { className: "bt-task-last", "data-failed": failed || void 0 }, last) : null, + rowError ? (0, import_react21.createElement)("span", { className: "bt-task-last", "data-failed": "", role: "alert" }, rowError) : null + ), + (0, import_react21.createElement)("span", { className: "bt-task-next" }, nextText(task)), + // A locked window draws none of the writes the host would refuse (#2232). Hiding, not + // disabling: a disabled button still says this window can do it. + !locked && failed && task.status !== "paused" && actions.scheduleRetry ? (0, import_react21.createElement)("button", { type: "button", className: "bt-mini bt-task-pause", disabled: busy, title: t("Run the task once now"), onClick: retry }, t("Retry")) : null, + !locked && onEdit ? (0, import_react21.createElement)("button", { type: "button", className: "bt-mini bt-task-tool", disabled: busy, "aria-label": t("Edit"), title: t("Edit"), onClick: () => onEdit(task) }, (0, import_react21.createElement)(PencilIcon)) : null, + !locked && onCopy ? (0, import_react21.createElement)("button", { type: "button", className: "bt-mini bt-task-tool", disabled: busy, "aria-label": t("Copy to another Bot or group chat"), title: t("Copy to…"), onClick: () => onCopy(task) }, (0, import_react21.createElement)(DuplicateIcon)) : null, + !locked && stoppable ? (0, import_react21.createElement)("button", { type: "button", className: "bt-mini bt-task-pause", disabled: busy, onClick: toggle }, task.status === "paused" ? t("Resume") : t("Pause")) : null, + locked ? null : (0, import_react21.createElement)("button", { type: "button", - className: "bt-voice", - "aria-label": t("Start voice chat"), - title: t("Voice chat"), - onMouseDown: keepComposerFocus, - onClick: () => actions.openVoice(sessionId) - }, (0, import_react21.createElement)(WaveIcon, { size: 18 })), - dictation.note ? (0, import_react21.createElement)("span", { className: "bt-mic-note", role: "status" }, dictation.note) : null + className: "bt-mini bt-task-remove", + "data-confirm": confirming || void 0, + disabled: busy, + "aria-label": confirming ? t("Click again to delete") : t("Delete task"), + title: confirming ? void 0 : t("Delete task"), + onClick: remove + }, confirming ? t("Delete") : (0, import_react21.createElement)(TrashIcon)) ); } -function menuIcons(roster) { - const marks = /* @__PURE__ */ new Map(); - return (row) => { - if (!row.bot) return PeopleIcon; - const id = row.bot.id; - if (!marks.has(id)) marks.set(id, ({ size = 14 }) => (0, import_react21.createElement)(BotMark, { bot: markBotForMention(row.bot, roster.getSnapshot()), size })); - return marks.get(id); +var pad = (number) => String(number).padStart(2, "0"); +function inAnHour() { + const date = new Date(Date.now() + 36e5); + date.setMinutes(0, 0, 0); + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:00`; +} +var KINDS = [["at", "Once"], ["daily", "Every day"], ["weekly", "Every week"], ["every", "Repeat"]]; +var UNITS = [["seconds", 1, "seconds"], ["minutes", 60, "minutes"], ["hours", 3600, "hours"], ["days", 86400, "days"]]; +function localInput(ms) { + const date = new Date(ms); + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`; +} +function formStart(task) { + const seconds = task?.kind === "every" ? Number(task.everySeconds) : 3600; + const [unit, size] = seconds % 86400 === 0 ? ["days", 86400] : seconds % 3600 === 0 ? ["hours", 3600] : seconds % 60 === 0 ? ["minutes", 60] : ["seconds", 1]; + const once = task?.kind === "at" || task?.kind === "after"; + return { + title: task?.title ?? "", + prompt: task?.prompt ?? "", + kind: once ? "at" : task?.kind ?? "daily", + at: once && !Number.isNaN(Date.parse(task.scheduledAt)) ? localInput(Date.parse(task.scheduledAt)) : inAnHour(), + time: task?.time ? clock2(task.time) : "09:00", + weekdays: task?.kind === "weekly" && Array.isArray(task.weekdays) ? task.weekdays : [1, 2, 3, 4, 5], + count: Math.max(1, Math.round(seconds / size)), + unit, + timeZone: task?.timeZone || localZone() }; } -function ComposerPlus({ sessionId, inputActions, useRoster, actions }) { - const roster = useRoster((value) => value); - const [open, setOpen] = (0, import_react21.useState)(null); - const [shown, leaving] = useLinger(open, 120); - const button = (0, import_react21.useRef)(null); - const menu = (0, import_react21.useRef)(null); - (0, import_react21.useEffect)(() => { - if (!open) return void 0; - const onDown = (event) => { - if (![button.current, menu.current].some((node) => node?.contains(event.target))) setOpen(null); - }; - const onKey = (event) => { - if (event.key === "Escape") { - event.stopPropagation(); - setOpen(null); - } - }; - window.addEventListener("mousedown", onDown, true); - window.addEventListener("keydown", onKey, true); - return () => { - window.removeEventListener("mousedown", onDown, true); - window.removeEventListener("keydown", onKey, true); - }; - }, [open]); - const target = roster.byId?.[sessionId]?.id ?? roster.roomsById?.[sessionId]?.id; - if (sessionId === void 0 || target === void 0) return null; - const toggle = () => { - if (open) { - setOpen(null); +function TaskForm({ roster, actions, start, task, onDone, onCancel }) { + const first = formStart(task); + const [target, setTarget] = (0, import_react21.useState)(task ? task.botId ?? task.roomId : start ?? ""); + const [title, setTitle] = (0, import_react21.useState)(first.title); + const [prompt, setPrompt] = (0, import_react21.useState)(first.prompt); + const [kind, setKind] = (0, import_react21.useState)(first.kind); + const [at, setAt] = (0, import_react21.useState)(first.at); + const [time, setTime] = (0, import_react21.useState)(first.time); + const [weekdays, setWeekdays] = (0, import_react21.useState)(first.weekdays); + const [count, setCount] = (0, import_react21.useState)(first.count); + const [unit, setUnit] = (0, import_react21.useState)(first.unit); + const [busy, setBusy] = (0, import_react21.useState)(false); + const [error, setError] = (0, import_react21.useState)(""); + const timing = () => { + if (kind === "cron") return { kind: "keep" }; + if (kind === "at") return { kind, at: new Date(at).toISOString(), timeZone: first.timeZone }; + if (kind === "every") return { kind, seconds: Math.round(Number(count) * UNITS.find(([id]) => id === unit)[1]) }; + return { kind, time, timeZone: first.timeZone, ...kind === "weekly" ? { weekdays } : {} }; + }; + const submit = (event) => { + event.preventDefault(); + if (busy || target === "") return; + setBusy(true); + setError(""); + let when3; + try { + when3 = timing(); + } catch { + setError(t("Pick a date and time")); + setBusy(false); return; } - const rect = button.current.getBoundingClientRect(); - setOpen({ left: rect.left, bottom: window.innerHeight - rect.top + 8 }); + const saved = task ? actions.scheduleUpdate({ sessionId: task.sessionId, id: task.id, target, title, prompt, when: when3 }) : actions.scheduleCreate({ target, title, prompt, when: when3 }); + void saved.then(onDone, (failure) => { + setError(hostText(failure?.message ?? String(failure))); + setBusy(false); + }); }; - const attach = () => { - setOpen(null); - button.current?.closest("[data-composer-card]")?.querySelector("input[type=file]")?.click(); + const toggleDay = (day) => setWeekdays((days) => days.includes(day) ? days.filter((item) => item !== day) : [...days, day].sort()); + return (0, import_react21.createElement)( + "form", + { className: "bt-set-card bt-task-form", onSubmit: submit }, + (0, import_react21.createElement)( + "label", + { className: "bt-set-row bt-bs-row" }, + (0, import_react21.createElement)("span", { className: "bt-set-copy" }, (0, import_react21.createElement)("span", { className: "bt-bs-label" }, t("Send to"))), + (0, import_react21.createElement)( + "select", + { className: "bt-select bt-task-bot", value: target, onChange: (event) => setTarget(event.target.value) }, + (0, import_react21.createElement)("option", { value: "" }, t("Pick a Bot or group chat")), + (0, import_react21.createElement)("optgroup", { label: t("Bots") }, roster.bots.map((bot) => (0, import_react21.createElement)("option", { key: bot.id, value: bot.id }, bot.name))), + roster.rooms.length > 0 ? (0, import_react21.createElement)("optgroup", { label: t("Group chats") }, roster.rooms.map((room2) => (0, import_react21.createElement)("option", { key: room2.id, value: room2.id }, room2.name))) : null + ) + ), + (0, import_react21.createElement)( + "label", + { className: "bt-set-row bt-bs-row" }, + (0, import_react21.createElement)("span", { className: "bt-set-copy" }, (0, import_react21.createElement)("span", { className: "bt-bs-label" }, t("Name"))), + (0, import_react21.createElement)("input", { className: "bt-bs-input", value: title, maxLength: 120, placeholder: t("Morning briefing"), autoFocus: true, onChange: (event) => setTitle(event.target.value) }) + ), + (0, import_react21.createElement)( + "label", + { className: "bt-set-row bt-bs-row bt-bs-stack" }, + (0, import_react21.createElement)("span", { className: "bt-set-copy" }, (0, import_react21.createElement)("span", { className: "bt-bs-label" }, t("What to do"))), + (0, import_react21.createElement)("textarea", { className: "bt-bs-input bt-bs-area", value: prompt, rows: 3, placeholder: t("Sum up what changed since yesterday and what needs me today."), onChange: (event) => setPrompt(event.target.value) }) + ), + (0, import_react21.createElement)( + "div", + { className: "bt-set-row bt-bs-row bt-bs-stack" }, + (0, import_react21.createElement)("span", { className: "bt-set-copy" }, (0, import_react21.createElement)("span", { className: "bt-bs-label" }, t("When"))), + (0, import_react21.createElement)(Segmented, { label: t("When"), value: kind }, KINDS.map(([id, label]) => (0, import_react21.createElement)("button", { key: id, type: "button", "aria-pressed": kind === id, onClick: () => setKind(id) }, t(label)))), + (0, import_react21.createElement)( + "div", + { key: kind, className: "bt-task-when bt-veil" }, + kind === "cron" ? (0, import_react21.createElement)("span", { className: "bt-task-every" }, timingText(task)) : null, + kind === "at" ? (0, import_react21.createElement)("input", { className: "bt-input", type: "datetime-local", value: at, "aria-label": t("Date and time"), onChange: (event) => setAt(event.target.value) }) : null, + kind === "weekly" ? (0, import_react21.createElement)("div", { className: "bt-task-days", role: "group", "aria-label": t("Days") }, [1, 2, 3, 4, 5, 6, 7].map((day) => (0, import_react21.createElement)("button", { + key: day, + type: "button", + className: "bt-task-day", + "aria-pressed": weekdays.includes(day), + onClick: () => toggleDay(day) + }, when2(Date.UTC(2024, 0, day), { weekday: "narrow", timeZone: "UTC" })))) : null, + kind === "daily" || kind === "weekly" ? (0, import_react21.createElement)("input", { className: "bt-input bt-task-time", type: "time", value: time, "aria-label": t("Time"), onChange: (event) => setTime(event.target.value) }) : null, + kind === "every" ? (0, import_react21.createElement)( + import_react21.Fragment, + null, + (0, import_react21.createElement)("span", { className: "bt-task-every" }, t("Every")), + (0, import_react21.createElement)("input", { className: "bt-input bt-task-count", type: "number", min: 1, value: count, "aria-label": t("How many"), onChange: (event) => setCount(event.target.value) }), + (0, import_react21.createElement)( + "select", + { className: "bt-select bt-task-unit", value: unit, "aria-label": t("Unit"), onChange: (event) => setUnit(event.target.value) }, + UNITS.map(([id, , label]) => (0, import_react21.createElement)("option", { key: id, value: id }, t(label))) + ) + ) : null + ) + ), + error ? (0, import_react21.createElement)("div", { className: "bt-set-row" }, (0, import_react21.createElement)("span", { className: "bt-danger bt-set-hint", role: "alert" }, error)) : null, + (0, import_react21.createElement)( + "div", + { className: "bt-set-row bt-task-foot" }, + (0, import_react21.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: onCancel }, t("Cancel")), + (0, import_react21.createElement)( + "button", + { type: "submit", className: "bt-send bt-soft-sm", disabled: busy || !target || title.trim() === "" || prompt.trim() === "" }, + task ? busy ? t("Saving…") : t("Save changes") : busy ? t("Creating…") : t("Create task") + ) + ) + ); +} +function openPlugins(actions) { + actions.closeSettings(); + actions.selectPanel("plugins"); +} +function AutomationOff({ actions }) { + return (0, import_react21.createElement)( + "div", + { className: "bt-set-card bt-task-off" }, + (0, import_react21.createElement)("span", { className: "bt-task-off-mark" }, (0, import_react21.createElement)(ClockIcon)), + (0, import_react21.createElement)("span", { className: "bt-task-off-title" }, t("Turn on Automation tasks in DSH")), + (0, import_react21.createElement)("span", { className: "bt-set-hint" }, t("Switch on Automation tasks on the Plugins page.")), + (0, import_react21.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => openPlugins(actions) }, t("Open Plugins")) + ); +} +function QuietHoursCard({ view, actions, locked }) { + const quiet = view.quietHours ?? { on: false }; + const [busy, setBusy] = (0, import_react21.useState)(false); + const [error, setError] = (0, import_react21.useState)(""); + const [from, setFrom] = (0, import_react21.useState)(quiet.from ?? "22:00"); + const [to, setTo] = (0, import_react21.useState)(quiet.to ?? "07:00"); + const unchecked = view.quietUnchecked === true; + const refresh = () => { + void Promise.resolve(actions.schedules?.()).then((next) => { + if (next) publish(next); + }).catch(() => { + }); }; - const schedule = () => { - setOpen(null); - actions.openSettings("schedules", target); + const save = (next) => { + if (busy) return; + setBusy(true); + setError(""); + const shown = next.on ? { on: true, from: next.from ?? from, to: next.to ?? to } : { on: false }; + void Promise.resolve(actions.setPrefs({ quietHours: next })).then(() => { + publish({ ...view, quietHours: shown }); + refresh(); + }, (failure) => setError(hostText(failure?.message ?? String(failure)))).finally(() => setBusy(false)); }; - const reference = () => { - setOpen(null); - actions.referenceChat(sessionId, inputActions?.captureInsertion?.()); + return (0, import_react21.createElement)( + "section", + { className: "bt-set-section" }, + (0, import_react21.createElement)("h3", null, t("Quiet hours")), + (0, import_react21.createElement)( + "div", + { className: "bt-set-card" }, + (0, import_react21.createElement)( + "div", + { className: "bt-set-row bt-bs-row" }, + (0, import_react21.createElement)( + "span", + { className: "bt-set-copy" }, + (0, import_react21.createElement)("span", { className: "bt-bs-label" }, quiet.on ? t("On, {from}–{to}", { from: quiet.from, to: quiet.to }) : t("Off")), + (0, import_react21.createElement)("span", { className: "bt-set-hint" }, t("A new or edited task cannot be due inside the window. It never mutes a task that already exists.")) + ), + locked ? null : (0, import_react21.createElement)("button", { + type: "button", + className: "bt-soft bt-soft-sm", + disabled: busy, + "aria-pressed": quiet.on, + onClick: () => save(quiet.on ? { on: false } : { on: true, from, to }) + }, quiet.on ? t("Turn off") : t("Turn on")) + ), + quiet.on && !locked ? (0, import_react21.createElement)( + "div", + { className: "bt-set-row bt-bs-row" }, + (0, import_react21.createElement)("span", { className: "bt-set-copy" }, (0, import_react21.createElement)("span", { className: "bt-bs-label" }, t("Window"))), + (0, import_react21.createElement)( + "div", + { className: "bt-task-when" }, + (0, import_react21.createElement)("input", { className: "bt-input bt-task-time", type: "time", value: from, "aria-label": t("Quiet hours start"), disabled: busy, onChange: (event) => setFrom(event.target.value) }), + (0, import_react21.createElement)("span", { className: "bt-task-every" }, "–"), + (0, import_react21.createElement)("input", { className: "bt-input bt-task-time", type: "time", value: to, "aria-label": t("Quiet hours end"), disabled: busy, onChange: (event) => setTo(event.target.value) }), + (0, import_react21.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy, onClick: () => save({ on: true, from, to }) }, t("Save window")) + ) + ) : null, + error ? (0, import_react21.createElement)("div", { className: "bt-set-row" }, (0, import_react21.createElement)("span", { className: "bt-danger bt-set-hint", role: "alert" }, error)) : null, + quiet.on && unchecked ? (0, import_react21.createElement)("div", { className: "bt-set-row" }, (0, import_react21.createElement)("span", { className: "bt-set-hint" }, t("A repeat or cron task can still run inside quiet hours, because DSH decides its next run. FlyBot says so here rather than claiming a silence it cannot keep."))) : null + ) + ); +} +function SchedulesPage({ roster, actions, start }) { + const view = useSchedules(actions); + const [adding, setAdding] = (0, import_react21.useState)(Boolean(start)); + const [editing, setEditing] = (0, import_react21.useState)(null); + const locked = lockedWindow(roster); + const refused = schedulesReadFailure(view?.readFailure); + if (refused !== null) return (0, import_react21.createElement)("div", { className: "bt-set-hint bt-danger", role: "status" }, refused); + if (view === null) return (0, import_react21.createElement)("div", { className: "bt-set-hint" }, t("Loading…")); + if (!view.available) return (0, import_react21.createElement)(AutomationOff, { actions }); + const shown = (next) => { + publish(next); + setAdding(false); + setEditing(null); }; - const canReference = typeof inputActions?.captureInsertion === "function" && actions.canReferenceChat(); + const edit = (task) => { + setAdding(false); + setEditing(task); + }; + const byChat = [ + ...roster.bots.map((bot) => [bot.id, (0, import_react21.createElement)(BotAvatar, { bot, size: 18, badge: false, live: false }), bot.name, view.tasks.filter((task) => task.botId === bot.id)]), + ...roster.rooms.map((room2) => [room2.id, (0, import_react21.createElement)(RoomAvatar, { room: room2, roster, size: 18 }), room2.name, view.tasks.filter((task) => task.botId === void 0 && task.roomId === room2.id)]) + ].filter(([, , , tasks]) => tasks.length > 0); + return [ + (0, import_react21.createElement)(QuietHoursCard, { key: "quiet", view, actions, locked }), + // A locked window draws no create control and opens no form (#2232): the header already says + // why, and a button the host refuses is the defect this page had. + locked ? null : adding ? (0, import_react21.createElement)(TaskForm, { key: "form", roster, actions, start, onDone: shown, onCancel: () => setAdding(false) }) : (0, import_react21.createElement)("div", { key: "add", className: "bt-actions" }, (0, import_react21.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm bt-with-icon", onClick: () => { + setEditing(null); + setAdding(true); + } }, (0, import_react21.createElement)(PlusIcon), t("New task"))), + byChat.length === 0 && !adding ? (0, import_react21.createElement)("div", { key: "none", className: "bt-set-card" }, (0, import_react21.createElement)("div", { className: "bt-set-row" }, (0, import_react21.createElement)("span", { className: "bt-set-hint" }, view.someHidden ? t("No scheduled tasks you can see. Another member's tasks stay hidden.") : t("No scheduled tasks yet.")))) : byChat.map(([id, avatar, name, tasks]) => (0, import_react21.createElement)( + "section", + { key: id, className: "bt-set-section" }, + (0, import_react21.createElement)("h3", { className: "bt-task-head" }, avatar, name), + (0, import_react21.createElement)(TaskList, { tasks, editing, roster, actions, locked, onEdit: edit, onDone: shown, onCancel: () => setEditing(null) }) + )) + ]; +} +function TaskList({ tasks, editing, roster, actions, locked, onEdit, onDone, onCancel, extra = [] }) { + const open = !locked && editing ? tasks.find((task) => task.id === editing.id && task.sessionId === editing.sessionId) : void 0; + const [copying, setCopying] = (0, import_react21.useState)(null); + const source = copying ? tasks.find((task) => task.id === copying.id && task.sessionId === copying.sessionId) : void 0; + const rows = tasks.filter((task) => task !== open && task !== source).map((task) => (0, import_react21.createElement)(TaskRow, { key: task.id, task, actions, roster, locked, onView: publish, onEdit, onCopy: actions.scheduleCreate ? setCopying : void 0 })); return (0, import_react21.createElement)( import_react21.Fragment, null, - (0, import_react21.createElement)("button", { - ref: button, - type: "button", - className: "bt-plus", - "aria-label": t("Add"), - title: t("Add"), - "aria-haspopup": "menu", - "aria-expanded": Boolean(open), - onMouseDown: keepComposerFocus, - onClick: toggle - }, (0, import_react21.createElement)(PlusIcon)), - shown ? (0, import_react_dom7.createPortal)((0, import_react21.createElement)( - "div", - { - ref: menu, - className: "bt-menu bt-menu-up bt-plus-menu", - role: "menu", - "aria-label": t("Add"), - style: { left: shown.left, bottom: shown.bottom }, - "data-leaving": leaving || void 0 - }, - (0, import_react21.createElement)("button", { type: "button", role: "menuitem", onClick: attach }, (0, import_react21.createElement)(PaperclipIcon), (0, import_react21.createElement)("span", { className: "bt-menu-label" }, t("Add files"))), - canReference ? (0, import_react21.createElement)("button", { type: "button", role: "menuitem", onMouseDown: keepComposerFocus, onClick: reference }, (0, import_react21.createElement)(ChatsIcon), (0, import_react21.createElement)("span", { className: "bt-menu-label" }, t("Reference a chat"))) : null, - (0, import_react21.createElement)("button", { type: "button", role: "menuitem", onClick: schedule }, (0, import_react21.createElement)(ClockIcon), (0, import_react21.createElement)("span", { className: "bt-menu-label" }, t("Scheduled task"))) - ), document.body) : null + open ? (0, import_react21.createElement)(TaskForm, { key: `edit-${open.id}`, roster, actions, task: open, onDone, onCancel }) : null, + source ? (0, import_react21.createElement)(CopyRow, { key: `copy-${source.id}`, task: source, roster, actions, onView: (next) => { + setCopying(null); + onDone(next); + }, onCancel: () => setCopying(null) }) : null, + rows.length + extra.length > 0 ? (0, import_react21.createElement)("div", { className: "bt-set-card" }, ...rows, ...extra) : null ); } - -// src/client/at-menu.js -var BOTS_SOURCE = "ds-bot"; -var CHATS_SOURCE = "ds-bot-chats"; -var ROOM_ALL = ["all", "everyone", "所有人", "全体"]; -var OTHER_CHATS_MAX = 8; -var conversationOf = (roster, sessionId) => { - if (!roster.ready) return null; - const room2 = roster.roomsById[sessionId]; - if (room2) return { room: room2 }; - const self = roster.byId[sessionId]; - return self ? { self } : null; -}; -function mentionRows(roster, sessionId, query = "") { - const here = conversationOf(roster, sessionId); - if (here === null) return null; - const face = isFaceRoom(here.room); - const live = (here.room?.participants ?? []).filter((p) => p.leftSeq === void 0); - const bots = here.room ? [ - ...here.room.members.map((id) => roster.byId[id]).filter(Boolean).map((bot) => face ? { ...bot, label: faceLabel(myOwnerName(roster, live, bot), bot.name) } : bot), - ...live.filter((p) => p.kind === "human").map((p) => ({ id: p.id, name: p.displayName || p.id, kind: "human", role: p.role })), - // A remote seat already listed under its handle (a duplicate participant record) is offered once. - ...uniqueBy(live.filter((p) => p.remote), (p) => p.handle || p.id).map((p) => ({ - id: p.handle || p.id, - // A face room inserts the disambiguated title. An ordinary group inserts the name the - // room routes on (#1141): never "@@handle.home (owner)" and never "(Friend)". - name: face ? remoteBotTitle(p) : remoteCallName(p), - kind: "bot", - remote: p, - handle: p.handle, - label: face ? faceLabel(ownerNameOf(p), cleanPeerName(p.displayName || p.name, "Bot")) : remoteBotTitle(p) - })) - ] : listedChatBots(roster).filter((bot) => bot.id !== here.self.id); - const needle = query.trim().toLowerCase(); - const rows = bots.filter((bot) => bot.name.toLowerCase().includes(needle) || String(bot.label ?? "").toLowerCase().includes(needle) || bot.remote && normHandle(remoteRecord(bot)?.handle || bot.handle).includes(normHandle(needle))).map((bot) => ({ bot, text: `@${bot.name} ` })); - const everyone = t("Everyone"); - if (here.room && bots.length > 1 && [everyone, ...ROOM_ALL].some((word) => word.toLowerCase().startsWith(needle))) { - rows.push({ room: here.room, count: bots.filter((bot) => bot.kind !== "human").length, text: `@${everyone} ` }); - } - if (face) return rows; - const seatHandles = new Set(bots.filter((bot) => bot.remote).map((bot) => normHandle(remoteRecord(bot)?.handle)).filter(Boolean)); - if (here.room) return [...rows, ...remoteMentionRows(roster, query).filter((row) => !seatHandles.has(normHandle(row.remote.handle)))]; - const remoteHandles = new Set(remoteMentionRows(roster, "").map((row) => normHandle(row.remote.handle))); - return [ - ...rows.filter((row) => !(row.bot && remoteHandles.has(normHandle(remoteRecord(row.bot)?.handle)))), - ...remoteMentionRows(roster, query) - ]; -} -var isFaceRoom = (room2) => room2?.origin === "face-to-face"; -var memberCount = (count) => t(count === 1 ? "{count} member" : "{count} members", { count }); -var botCount = (count) => count === 1 ? t("1 Bot") : t("{count} Bots", { count }); -var normHandle = (handle) => String(handle ?? "").trim().replace(/^@+/, "").toLowerCase(); -var remoteRecord = (bot) => bot?.remote ? bot.remote === true ? bot : bot.remote : null; -var myOwnerName = (roster, live, bot) => { - const seat = live.find((p) => !p.remote && p.kind !== "human" && (p.id === bot.id || bot.handle && p.handle === bot.handle)); - return seatOwnerName(seat?.ownerName, seat?.handle) || cleanPeerName(roster.self?.displayName, ""); -}; -var uniqueBy = (list, key) => { - const seen = /* @__PURE__ */ new Set(); - return list.filter((item) => { - const k = key(item); - if (!k || seen.has(k)) return !k; - seen.add(k); - return true; - }); -}; -var faceLabel = (person, bot) => person ? `${bot} (${person})` : bot; -function chatRows(roster, list, sessionId) { - const here = conversationOf(roster, sessionId); - if (here === null) return []; - const byId = list?.byId ?? {}; - const bots = roster.bots.filter((bot) => bot.id !== here.self?.id).map((bot) => { - const id = currentPart(bot); - return { sessionId: id, label: t("Chat with {name}", { name: bot.name }), bot, updatedAt: byId[id]?.updatedAt }; - }); - const team = /* @__PURE__ */ new Set([ - ...roster.bots.flatMap((bot) => [bot.id, currentPart(bot), ...bot.parts ?? []]), - ...roster.rooms.flatMap((room2) => [room2.id, ...room2.parts ?? []]) - ]); - const others = (list?.ids ?? []).map((id) => byId[id]).filter((row) => row !== void 0 && row.id !== sessionId && !team.has(row.id) && row.origin !== "subagent" && row.blank !== true).sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0)).slice(0, OTHER_CHATS_MAX).map((row) => ({ sessionId: row.id, label: row.displayTitle || row.title || row.id, updatedAt: row.updatedAt })); - return [...bots, ...others]; -} -function sessionMention(sessionId, label) { - let binary = ""; - for (const byte of new TextEncoder().encode(JSON.stringify(sessionId))) binary += String.fromCharCode(byte); - const payload = btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); - return `@[${label.replace(/[\\\]]/g, (mark) => `\\${mark}`)}](dsh-session:${payload})`; -} -function atSources({ roster, sessions, launched, iconFor }) { - const bots = { - trigger: "@", - name: BOTS_SOURCE, - order: -20, - showGroupTitle: false, - async candidates(session, { query }) { - const snapshot = roster.getSnapshot(); - const face = isFaceRoom(snapshot.roomsById?.[session.sessionId]); - const team = (mentionRows(snapshot, session.sessionId, query) ?? []).map((row) => ({ - // A friend's Bot by the one name the row inserts, "Chief (Bob)" (#967); its handle is the - // second line (#384). A seat carries its own name; a friend's chat is named as its sidebar - // row and header (#498). - name: row.bot?.remote && !face ? row.bot.kind === "bot" ? remoteBotTitle(remoteRecord(row.bot)) : friendBotTitle(row.bot) : row.bot ? row.bot.label ?? row.bot.name : row.remote ? row.text.replace(/^@/, "").trimEnd() : t("Everyone"), - ...row.bot?.role && !face ? { description: row.bot.role } : {}, - ...row.remote ? { description: row.remote.handle } : {}, - ...row.bot?.remote && !face && remoteRecord(row.bot).handle ? { description: remoteRecord(row.bot).handle } : {}, - // A face room counts the Bots in it, mine and everyone else's (#305, #457). - ...row.room ? { description: face ? botCount(row.count) : memberCount(row.count) } : {}, - icon: iconFor(row), - section: t("Bots"), - value: row.text - })); - if (mentionRows(snapshot, session.sessionId, "") === null || face) return team; - const extra = (await mentionCandidates(query, { sessionId: session.sessionId, roster: snapshot })).map((row) => ({ - name: row.name, - ...row.description ? { description: row.description } : {}, - icon: row.icon ?? "session", - section: row.section ?? "FlyBot", - value: row.value - })); - return [...team, ...extra]; - }, - onPick: ({ candidate }) => candidate.value ? { text: candidate.value } : void 0 - }; - const chats = { - trigger: "@", - name: CHATS_SOURCE, - order: -10, - showGroupTitle: false, - async candidates(session) { - if (!launched(session.sessionId)) return []; - return chatRows(roster.getSnapshot(), sessions.list.getSnapshot(), session.sessionId).map((row) => ({ - name: row.bot ? row.bot.name : row.label, - ...row.updatedAt ? { description: relativeShort(row.updatedAt) } : {}, - icon: row.bot ? iconFor(row) : "session", - section: row.bot ? t("Bots") : t("Conversations"), - value: JSON.stringify({ sessionId: row.sessionId, label: row.label }) - })); - }, - onPick({ candidate }) { - const { sessionId, label } = JSON.parse(candidate.value); - const mention = sessionMention(sessionId, label); - return { insert: { source: CHATS_SOURCE, ref: mention, label, appearance: "session", clipboardText: mention } }; - }, - codec: { clipboardText: (ref) => ref, serialize: (ref) => Promise.resolve(ref) } - }; - return [bots, chats]; -} - -// src/client/face-at-filter.js -var REFERENCE_SOURCE = "reference"; -var optionId = (source, index) => `dsh-slash-option-${source}-${index}`; -var isSessionRow = (group, item) => group.source === REFERENCE_SOURCE && item?.icon === "session"; -var same = (a, b) => a != null && b != null && a.source === b.source && a.index === b.index; -function planFaceMenu(state, previous = null) { - const hidden = []; - const ready = []; - let shown = 0; - let loading = false; - for (const group of state?.groups ?? []) { - const items = group.items ?? []; - if (group.status !== "ready" && items.length === 0) loading = true; - items.forEach((item, index) => { - const hide = isSessionRow(group, item); - if (hide) hidden.push({ source: group.source, index, title: item.section !== void 0 && item.section !== items[index - 1]?.section }); - else shown += 1; - if (group.status === "ready") ready.push({ source: group.source, index, hide }); - }); - } - const open = state?.open === true; - const blank3 = open && hidden.length > 0 && shown === 0 && !loading; - const dismiss = blank3 && (state.groups ?? []).every((group) => group.status === "ready"); - return { hidden, blank: blank3, dismiss, target: dismiss ? null : steer(ready, state?.highlight ?? null, previous) }; -} -function steer(ready, highlight, previous) { - const at = ready.findIndex((row) => same(row, highlight)); - if (at < 0 || !ready[at].hide) return null; - const from = ready.findIndex((row) => same(row, previous)); - const n = ready.length; - const dir = from >= 0 && from !== at && (from - at + n) % n === 1 ? -1 : 1; - for (let step = 1; step < n; step += 1) { - const row = ready[((at + dir * step) % n + n) % n]; - if (!row.hide) return { source: row.source, index: row.index }; - } - return null; -} -function faceMenuCss({ hidden, blank: blank3 }) { - if (hidden.length === 0) return ""; - const rows = hidden.map((row) => `[data-trigger-menu] [id="${optionId(row.source, row.index)}"]`); - const titles = hidden.filter((row) => row.title).map((row) => `[data-trigger-menu] [role="presentation"]:has(+ [id="${optionId(row.source, row.index)}"])`); - const css = [`${[...rows, ...titles].join(",")}{display:none!important}`]; - if (blank3) css.push("[data-trigger-menu]{display:none!important}"); - return css.join("\n"); -} -function watchFaceMenu(controller, doc = globalThis.document) { - if (typeof controller?.menu?.subscribe !== "function" || !doc?.head) return () => { - }; - const style = doc.createElement("style"); - style.setAttribute("data-bt-face-at", ""); - doc.head.appendChild(style); - let previous = null; - let stopped = false; - const later = typeof queueMicrotask === "function" ? queueMicrotask : (fn) => Promise.resolve().then(fn); - const apply2 = () => { - if (stopped) return; - const state = controller.menu.getSnapshot(); - const plan = planFaceMenu(state, previous); - const css = faceMenuCss(plan); - if (style.textContent !== css) style.textContent = css; - if (plan.dismiss) later(() => { - if (!stopped && planFaceMenu(controller.menu.getSnapshot(), previous).dismiss) controller.dismiss(); - }); - else if (plan.target) later(() => { - if (!stopped) controller.hover(plan.target.source, plan.target.index); - }); - else previous = state.open ? state.highlight : null; - }; - const unsubscribe = controller.menu.subscribe(apply2); - apply2(); - return () => { - stopped = true; - unsubscribe?.(); - style.remove(); - }; -} - -// src/client/speech.js -var VOICE_BUNDLE = "@deepseek-ai/dsh-experimental-voice-input-bundle"; -var USABLE = /* @__PURE__ */ new Set(["ready", "standby", "waking"]); -async function speechCatalog(speech) { - const result = await speech.catalog(); - if (!result?.ok) throw new Error(result?.error?.message ?? "Speech catalog failed"); - const catalog = result.value; - const provider = catalog.providers.find((item) => item.id === catalog.selection.providerId); - return { catalog, usable: USABLE.has(provider?.preparation?.phase) }; -} -function waveBytes(samples, rate = 16e3) { - const bytes = new Uint8Array(44 + samples.length * 2); - const view = new DataView(bytes.buffer); - const ascii = (at, text2) => { - for (let index = 0; index < text2.length; index += 1) bytes[at + index] = text2.charCodeAt(index); - }; - ascii(0, "RIFF"); - view.setUint32(4, 36 + samples.length * 2, true); - ascii(8, "WAVEfmt "); - view.setUint32(16, 16, true); - view.setUint16(20, 1, true); - view.setUint16(22, 1, true); - view.setUint32(24, rate, true); - view.setUint32(28, rate * 2, true); - view.setUint16(32, 2, true); - view.setUint16(34, 16, true); - ascii(36, "data"); - view.setUint32(40, samples.length * 2, true); - samples.forEach((sample, index) => { - const value = Math.max(-1, Math.min(1, sample)); - view.setInt16(44 + index * 2, value < 0 ? Math.round(value * 32768) : Math.round(value * 32767), true); - }); - return bytes; -} -function base64Of(bytes) { - let text2 = ""; - for (let index = 0; index < bytes.length; index += 32768) text2 += String.fromCharCode(...bytes.subarray(index, index + 32768)); - return btoa(text2); -} -var captureError = (kind, cause) => Object.assign(new Error(kind), { kind, cause }); -async function openCapture() { - const devices = navigator.mediaDevices; - if (!devices?.getUserMedia || typeof MediaRecorder === "undefined") throw captureError("unavailable"); - let stream; - try { - stream = await devices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true }, video: false }); - } catch (error) { - throw captureError(error?.name === "NotAllowedError" ? "blocked" : "failed", error); - } - const audio = new AudioContext(); - const analyser = audio.createAnalyser(); - analyser.fftSize = 512; - audio.createMediaStreamSource(stream).connect(analyser); - const frame = new Float32Array(analyser.fftSize); - const recorder = new MediaRecorder(stream); - const chunks = []; - recorder.ondataavailable = (event) => { - if (event.data.size > 0) chunks.push(event.data); - }; - recorder.start(); - let closed = false; - const close = () => { - if (closed) return; - closed = true; - if (recorder.state !== "inactive") recorder.stop(); - stream.getTracks().forEach((track) => track.stop()); - void audio.close().catch(() => { - }); - }; - return { - level() { - if (closed) return 0; - analyser.getFloatTimeDomainData(frame); - let sum = 0; - for (const sample of frame) sum += sample * sample; - return Math.min(1, Math.sqrt(sum / frame.length) * 4); - }, - cancel: close, - async finish(maxSeconds) { - if (closed || recorder.state === "inactive") { - close(); - return null; - } - try { - await new Promise((resolve) => { - recorder.onstop = resolve; - recorder.stop(); - }); - stream.getTracks().forEach((track) => track.stop()); - const blob = new Blob(chunks, { type: recorder.mimeType }); - if (blob.size === 0) return null; - const decoded = await audio.decodeAudioData(await blob.arrayBuffer()); - const length = Math.max(1, Math.floor(Math.min(decoded.duration, maxSeconds) * 16e3)); - const offline = new OfflineAudioContext(1, length, 16e3); - const source = offline.createBufferSource(); - source.buffer = decoded; - source.connect(offline.destination); - source.start(); - return waveBytes((await offline.startRendering()).getChannelData(0)); - } finally { - close(); - } - } +function CopyRow({ task, roster, actions, onView, onCancel }) { + const others = [ + ...roster.bots.filter((bot) => bot.id !== task.botId).map((bot) => [bot.id, bot.name]), + ...roster.rooms.filter((room2) => room2.id !== task.roomId).map((room2) => [room2.id, room2.name]) + ]; + const [target, setTarget] = (0, import_react21.useState)(""); + const [busy, setBusy] = (0, import_react21.useState)(false); + const [error, setError] = (0, import_react21.useState)(""); + const submit = (event) => { + event.preventDefault(); + if (busy || target === "") return; + setBusy(true); + setError(""); + void actions.scheduleCreate({ target, copyFrom: { sessionId: task.sessionId, id: task.id } }).then(onView, (failure) => { + setBusy(false); + setError(hostText(failure?.message ?? String(failure))); + }); }; + return (0, import_react21.createElement)( + "form", + { className: "bt-set-row bt-task-copy", onSubmit: submit }, + (0, import_react21.createElement)("span", { className: "bt-set-copy" }, (0, import_react21.createElement)("span", { className: "bt-bs-label" }, t("Copy “{name}” to", { name: task.title }))), + (0, import_react21.createElement)( + "select", + { className: "bt-select bt-task-bot", value: target, "aria-label": t("Copy to"), onChange: (event) => setTarget(event.target.value) }, + (0, import_react21.createElement)("option", { value: "" }, t("Pick a Bot or group chat")), + others.map(([id, name]) => (0, import_react21.createElement)("option", { key: id, value: id }, name)) + ), + (0, import_react21.createElement)("button", { type: "submit", className: "bt-soft bt-soft-sm", disabled: busy || target === "" }, busy ? t("Copying…") : t("Copy")), + (0, import_react21.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: onCancel }, t("Cancel")), + error ? (0, import_react21.createElement)("span", { className: "bt-danger bt-set-hint", role: "alert" }, error) : null + ); } -async function transcribeWave(speech, catalog, wave, signal) { - if (wave.byteLength > catalog.maxAudioBytes) throw captureError("too-long"); - const result = await speech.transcribe({ audioBase64: base64Of(wave), ...catalog.selection }, signal); - if (!result?.ok) throw Object.assign(new Error(result?.error?.message ?? "Transcription failed"), { kind: "service" }); - return String(result.value?.text ?? "").trim(); +function BotSchedules({ bot, roster, actions }) { + const view = useSchedules(actions); + const [editing, setEditing] = (0, import_react21.useState)(null); + const locked = lockedWindow(roster); + const refused = schedulesReadFailure(view?.readFailure); + if (refused !== null) { + return (0, import_react21.createElement)( + "section", + { className: "bt-set-section" }, + (0, import_react21.createElement)("h3", null, t("Scheduled tasks")), + (0, import_react21.createElement)("div", { className: "bt-set-card" }, (0, import_react21.createElement)( + "div", + { className: "bt-set-row bt-bs-row" }, + (0, import_react21.createElement)("span", { className: "bt-set-copy" }, (0, import_react21.createElement)("span", { className: "bt-set-hint bt-danger", role: "status" }, refused)) + )) + ); + } + if (view === null) return null; + const tasks = view.available ? view.tasks.filter((task) => task.botId === bot.id) : []; + return (0, import_react21.createElement)( + "section", + { className: "bt-set-section" }, + (0, import_react21.createElement)("h3", null, t("Scheduled tasks")), + !view.available ? (0, import_react21.createElement)( + "div", + { className: "bt-set-card" }, + (0, import_react21.createElement)( + "div", + { className: "bt-set-row bt-bs-row" }, + (0, import_react21.createElement)("span", { className: "bt-set-copy" }, (0, import_react21.createElement)("span", { className: "bt-set-hint" }, t("Switch on Automation tasks on the Plugins page."))), + (0, import_react21.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => openPlugins(actions) }, t("Open Plugins")) + ) + ) : (0, import_react21.createElement)(TaskList, { + tasks, + editing, + roster, + actions, + locked, + onEdit: locked ? void 0 : setEditing, + onDone: (next) => { + publish(next); + setEditing(null); + }, + onCancel: () => setEditing(null), + extra: locked ? [] : [(0, import_react21.createElement)("button", { key: "new", type: "button", className: "bt-set-row bt-bs-row bt-gs-add", onClick: () => actions.openSettings("schedules", bot.id) }, (0, import_react21.createElement)(PlusIcon), t("New task"))] + }) + ); } -function turnDetector({ loud = 0.12, quiet = 0.06, quietMs = 1200 } = {}) { - let spokeAt = 0; - let quietSince = 0; - return (level, now) => { - if (level >= loud) { - spokeAt ||= now; - quietSince = 0; - return "speech"; - } - if (!spokeAt) return "waiting"; - if (level < quiet) quietSince ||= now; - return quietSince && now - quietSince >= quietMs ? "done" : "speech"; - }; +function SummaryLines({ tasks, actions }) { + const shown = tasks.slice(0, 3); + const hidden = tasks.length - shown.length; + return (0, import_react21.createElement)( + import_react21.Fragment, + null, + (0, import_react21.createElement)("div", { className: "bt-section-title" }, hidden > 0 ? t("Scheduled tasks · {count}", { count: tasks.length }) : t("Scheduled tasks")), + (0, import_react21.createElement)( + "div", + { className: "bt-task-list" }, + shown.map((task) => (0, import_react21.createElement)( + "button", + { key: task.id, type: "button", className: "bt-task-line", onClick: () => actions.openSettings("schedules") }, + (0, import_react21.createElement)(ClockIcon), + (0, import_react21.createElement)("span", { className: "bt-task-line-title" }, task.title), + (0, import_react21.createElement)("span", { className: "bt-task-line-when" }, nextText(task)) + )), + hidden > 0 ? (0, import_react21.createElement)("button", { key: "more", type: "button", className: "bt-more", onClick: () => actions.openSettings("schedules") }, t("{count} more not shown", { count: hidden })) : null + ) + ); +} +function ScheduleSummary({ bot, roster, actions }) { + const view = useSchedules(actions); + if (view === null || view.readFailure !== void 0) return null; + const tasks = view.available ? view.tasks.filter((task) => task.botId === bot.id) : []; + if (lockedWindow(roster) && tasks.length === 0) return null; + return (0, import_react21.createElement)( + "div", + null, + tasks.length === 0 ? (0, import_react21.createElement)("div", { className: "bt-section-title" }, t("Scheduled tasks")) : (0, import_react21.createElement)(SummaryLines, { tasks, actions }), + tasks.length === 0 ? (0, import_react21.createElement)("button", { type: "button", className: "bt-more", onClick: () => actions.openSettings("schedules", bot.id) }, view.available === false ? t("Switch on Automation tasks on the Plugins page.") : view.someHidden ? t("No scheduled tasks you can see. Add one") : t("No scheduled tasks yet. Add one")) : null + ); +} +function RoomSchedules({ room: room2, actions }) { + const view = useSchedules(actions); + if (view === null || view.readFailure !== void 0 || room2.origin === "face-to-face") return null; + const tasks = view.available ? view.tasks.filter((task) => task.botId === void 0 && task.roomId === room2.id) : []; + if (tasks.length === 0) return null; + return (0, import_react21.createElement)("div", null, (0, import_react21.createElement)(SummaryLines, { tasks, actions })); } +var SCHEDULE_CSS = ` +.bt-task{gap:10px} +.bt-task-copy{flex-wrap:wrap;gap:8px} +.bt-task-copy .bt-task-bot{flex:1;min-width:140px;max-width:100%} +.bt-task[data-done]{opacity:.6} +.bt-task[data-busy]{opacity:.4} +.bt-task-title{font-size:13px;line-height:18px;font-weight:500;overflow-wrap:anywhere} +.bt-task-prompt{font-size:12px;line-height:16px;color:var(--bt-ink-2);display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden} +.bt-task-next{flex:none;font-size:12px;line-height:16px;color:var(--bt-ink-3);font-variant-numeric:tabular-nums;white-space:nowrap} +.bt-task-remove{display:inline-flex;align-items:center;justify-content:center;min-width:26px;height:26px;color:var(--bt-ink-3)} +.bt-task-remove svg{width:14px;height:14px} +.bt-task-tool{display:inline-flex;align-items:center;justify-content:center;min-width:26px;height:26px;color:var(--bt-ink-3)} +.bt-task-tool svg{width:14px;height:14px} +.bt-task-pause{height:26px;color:var(--bt-ink-2)} +.bt-task-last{font-size:12px;line-height:16px;color:var(--bt-ink-3);overflow-wrap:anywhere} +.bt-task-last[data-failed]{color:#c21d2e} +.bt-task[data-paused] .bt-task-title,.bt-task[data-paused] .bt-task-prompt{color:var(--bt-ink-3)} +.bt-task-remove[data-confirm]{background:rgba(224,33,53,.1);color:#e02135} +.bt-task-head{display:flex;align-items:center;gap:6px} +.bt-task-form{animation:bt-q-in .4s cubic-bezier(.22,1,.36,1),bt-veil-in .2s ease-out} +.bt-task-bot{width:auto;min-width:160px;max-width:58%} +.bt-task-when{display:flex;align-items:center;flex-wrap:wrap;gap:8px} +.bt-task-when .bt-input{width:auto;height:32px} +.bt-task-time{min-width:110px} +.bt-task-count{width:72px!important} +.bt-task-unit{width:auto;height:32px} +.bt-task-every{font-size:13px;color:var(--bt-ink-2)} +.bt-task-days{display:flex;gap:4px} +.bt-task-day{width:30px;height:30px;border-radius:50%;border:1px solid var(--bt-line);background:none;color:var(--bt-ink-2);font:inherit;font-size:12px;cursor:pointer;transition:background-color .12s ease,color .12s ease,border-color .12s ease} +.bt-task-day[aria-pressed=true]{background:var(--bt-user);border-color:var(--bt-user);color:var(--bt-user-ink)} +.bt-task-foot{justify-content:flex-end;gap:8px} +.bt-task-foot .bt-send{height:28px;font-size:12px} +.bt-task-off{align-items:center;gap:8px;padding:28px 20px;text-align:center} +.bt-task-off>*+*{border-top:0} +.bt-task-off-mark{width:44px;height:44px;border-radius:50%;background:var(--bt-hover);display:flex;align-items:center;justify-content:center;color:var(--bt-ink-2)} +.bt-task-off-mark svg{width:20px;height:20px} +.bt-task-off-title{font-size:14px;line-height:20px;font-weight:600} +.bt-task-off .bt-set-hint{max-width:380px} +.bt-task-list{display:flex;flex-direction:column;gap:2px} +.bt-task-line{display:flex;align-items:center;gap:8px;width:100%;min-width:0;padding:6px 8px;border:0;border-radius:8px;background:none;color:var(--bt-ink);font:inherit;font-size:13px;text-align:left;cursor:pointer;transition:background-color .12s ease} +.bt-task-line:hover{background:var(--bt-hover)} +.bt-task-line svg{flex:none;color:var(--bt-ink-3)} +.bt-task-line-title{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.bt-task-line-when{flex:none;font-size:12px;color:var(--bt-ink-3)} +@media (prefers-reduced-motion:reduce){.bt-task-form{animation:none}} +`; -// src/client/model-menu.js +// src/client/composer.js var import_react22 = require("react"); -var import_react_dom8 = require("react-dom"); -var MENU_EXIT_MS = 120; -function groupModels(models) { - const groups = /* @__PURE__ */ new Map(); - for (const model of models ?? []) { - const provider = model.provider ?? model.providerName ?? model.ref.split("/")[0]; - if (!groups.has(provider)) groups.set(provider, []); - groups.get(provider).push(model); - } - const names = /* @__PURE__ */ new Map(); - for (const [provider, list] of groups) { - const name = list[0].providerName ?? provider; - names.set(name, (names.get(name) ?? 0) + 1); - } - return [...groups].map(([provider, list]) => { - const name = list[0].providerName ?? provider; - return [names.get(name) > 1 ? `${name} (${provider})` : name, list]; - }); -} -var servedOf = (roster, ref) => (roster.models ?? []).find((model) => model.ref === ref); -var effortCache = /* @__PURE__ */ new Map(); -function useEfforts(served, ref, actions) { - const [fetched, setFetched] = (0, import_react22.useState)(void 0); +var import_react_dom7 = require("react-dom"); +var speechApi = () => window.SpeechRecognition ?? window.webkitSpeechRecognition; +var keepComposerFocus = (event) => event.preventDefault(); +function useDictation(onText) { + const [listening, setListening] = (0, import_react22.useState)(false); + const [note, setNote] = (0, import_react22.useState)(""); + const recognition = (0, import_react22.useRef)(null); + const latest = (0, import_react22.useRef)(onText); + latest.current = onText; + (0, import_react22.useEffect)(() => () => recognition.current?.abort(), []); (0, import_react22.useEffect)(() => { - setFetched(void 0); - if (ref === void 0 || served?.reasoning !== void 0 || actions?.efforts === void 0) return void 0; - let live = true; - if (effortCache.has(ref)) setFetched(effortCache.get(ref)); - else void Promise.resolve(actions.efforts(ref)).then((value) => { - const reasoning = value?.efforts ? value : null; - effortCache.set(ref, reasoning); - if (live) setFetched(reasoning); - }).catch(() => { - }); - return () => { - live = false; + if (!note) return void 0; + const timer = setTimeout(() => setNote(""), 2600); + return () => clearTimeout(timer); + }, [note]); + const start = ({ continuous = true, onEnd } = {}) => { + const Api = speechApi(); + if (!Api) { + setNote(t("Dictation is not available in this browser")); + return false; + } + const engine = new Api(); + engine.lang = navigator.language || "zh-CN"; + engine.interimResults = true; + engine.continuous = continuous; + engine.onresult = (event) => { + let interim = ""; + for (let index = event.resultIndex; index < event.results.length; index += 1) { + const result = event.results[index]; + const text2 = result[0]?.transcript ?? ""; + if (result.isFinal) { + if (text2.trim()) latest.current(text2, true); + } else interim += text2; + } + if (interim) latest.current(interim, false); }; - }, [ref, served?.reasoning !== void 0, actions]); - return served?.reasoning ?? fetched; + engine.onerror = (event) => { + if (event.error === "aborted" || event.error === "no-speech") return; + setNote(event.error === "not-allowed" || event.error === "service-not-allowed" ? t("Microphone access is blocked") : t("Couldn't hear that")); + }; + engine.onend = () => { + setListening(false); + recognition.current = null; + onEnd?.(); + }; + recognition.current = engine; + try { + engine.start(); + setListening(true); + return true; + } catch { + setNote(t("Couldn't start dictation")); + return false; + } + }; + const stop = () => recognition.current?.stop(); + return { listening, note, start, stop }; } -function effortRows(reasoning) { - if (reasoning?.efforts === void 0 || reasoning.efforts.length === 0) return []; - return [ - { effort: void 0, label: t("Default") }, - ...reasoning.efforts.map((effort) => ({ effort: effort.id, label: effort.name })) - ]; +function ComposerIdentity({ sessionId, useRoster }) { + const roster = useRoster((value) => value); + const room2 = roster.roomsById?.[sessionId]; + const self = roster.self; + const humans = (room2?.participants ?? []).filter((p) => p.kind === "human" && p.leftSeq === void 0); + if (room2?.viewerRole === "invited-owner") { + return (0, import_react22.createElement)( + "span", + { className: "bt-composer-as", title: t("You can remove your Bot from this room") }, + t("Shared room from {name}", { name: room2.sharedFrom?.inviterName || t("a friend") }) + ); + } + if (!self || !room2 || humans.length < 2) return null; + return (0, import_react22.createElement)( + "span", + { className: "bt-composer-as", title: t("Messages in this room are attributed to you") }, + t("Sending as {name}", { name: self.displayName || self.memberId }) + ); } -function EffortRows({ reasoning, current, onPick }) { +function ComposerVoice({ sessionId, inputActions, actions, useUi }) { + const dictation = useDictation((text2, final) => { + if (final && inputActions) inputActions.insertText(text2, inputActions.captureInsertion()); + }); + const quote = useUi((state) => state.quote?.sessionId === sessionId ? state.quote : null); + (0, import_react22.useEffect)(() => { + if (!quote || !inputActions) return; + actions.clearQuote(quote.token); + const excerpt = quote.text.replace(/\s+/g, " ").trim(); + const line2 = `> ${excerpt.length > 200 ? `${excerpt.slice(0, 200)}…` : excerpt} + +`; + inputActions.insertText(line2, inputActions.captureInsertion()); + const box = document.querySelector('[class*="_centerCol"] [role=textbox][contenteditable=true]'); + if (box instanceof HTMLElement) { + box.focus(); + const selection = window.getSelection(); + selection?.selectAllChildren(box); + selection?.collapseToEnd(); + } + }, [quote?.token]); + if (sessionId === void 0) return null; + const pluginMic = actions.speech() !== void 0; return (0, import_react22.createElement)( import_react22.Fragment, null, - (0, import_react22.createElement)("div", { className: "bt-menu-sep", role: "separator" }), - (0, import_react22.createElement)("div", { className: "bt-model-group", role: "presentation" }, t("Thinking level")), - effortRows(reasoning).map((row) => (0, import_react22.createElement)( - "button", - { - key: row.effort ?? "default", - type: "button", - role: "menuitemradio", - "aria-checked": row.effort === current, - onClick: () => onPick(row.effort) - }, - (0, import_react22.createElement)("span", { className: "bt-menu-label" }, row.label), - (0, import_react22.createElement)("span", { className: "bt-model-tick", "aria-hidden": true }, row.effort === current ? (0, import_react22.createElement)(CheckIcon) : null) - )) + pluginMic ? null : (0, import_react22.createElement)("button", { + type: "button", + className: "bt-mic", + "aria-label": dictation.listening ? t("Stop dictation") : t("Dictate"), + "aria-pressed": dictation.listening, + title: dictation.listening ? t("Stop dictation") : t("Dictate"), + onMouseDown: keepComposerFocus, + onClick: () => dictation.listening ? dictation.stop() : dictation.start() + }, (0, import_react22.createElement)(MicIcon)), + (0, import_react22.createElement)("button", { + type: "button", + className: "bt-voice", + "aria-label": t("Start voice chat"), + title: t("Voice chat"), + onMouseDown: keepComposerFocus, + onClick: () => actions.openVoice(sessionId) + }, (0, import_react22.createElement)(WaveIcon, { size: 18 })), + dictation.note ? (0, import_react22.createElement)("span", { className: "bt-mic-note", role: "status" }, dictation.note) : null ); } -function ModelRows({ roster, current, onPick }) { - return groupModels(roster.models).map(([provider, list]) => (0, import_react22.createElement)( - import_react22.Fragment, - { key: list[0].provider ?? provider }, - (0, import_react22.createElement)("div", { className: "bt-model-group", role: "presentation" }, provider), - list.map((model) => (0, import_react22.createElement)( - "button", - { - key: model.ref, - type: "button", - role: "menuitemradio", - "aria-checked": model.ref === current, - title: model.ref, - onClick: () => onPick(model.ref) - }, - (0, import_react22.createElement)("span", { className: "bt-menu-label" }, model.name), - model.ref === roster.defaultModel ? (0, import_react22.createElement)("span", { className: "bt-menu-hint" }, t("Default")) : null, - (0, import_react22.createElement)("span", { className: "bt-model-tick", "aria-hidden": true }, model.ref === current ? (0, import_react22.createElement)(CheckIcon) : null) - )) - )); +function menuIcons(roster) { + const marks = /* @__PURE__ */ new Map(); + return (row) => { + if (!row.bot) return PeopleIcon; + const id = row.bot.id; + if (!marks.has(id)) marks.set(id, ({ size = 14 }) => (0, import_react22.createElement)(BotMark, { bot: markBotForMention(row.bot, roster.getSnapshot()), size })); + return marks.get(id); + }; } -var Caret = () => (0, import_react22.createElement)("svg", { className: "bt-model-caret", width: 12, height: 12, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.8, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, (0, import_react22.createElement)("path", { d: "M5.5 8l4.5 4.5L14.5 8" })); -function useAnchoredMenu() { - const [place, setPlace] = (0, import_react22.useState)(null); - const [shownPlace, leaving] = useLinger(place, MENU_EXIT_MS); +function ComposerPlus({ sessionId, inputActions, useRoster, actions }) { + const roster = useRoster((value) => value); + const [open, setOpen] = (0, import_react22.useState)(null); + const [shown, leaving] = useLinger(open, 120); const button = (0, import_react22.useRef)(null); const menu = (0, import_react22.useRef)(null); - const close = () => setPlace(null); (0, import_react22.useEffect)(() => { - if (place === null) return void 0; + if (!open) return void 0; const onDown = (event) => { - if (menu.current?.contains(event.target) || button.current?.contains(event.target)) return; - close(); + if (![button.current, menu.current].some((node) => node?.contains(event.target))) setOpen(null); }; const onKey = (event) => { if (event.key === "Escape") { event.stopPropagation(); - close(); - button.current?.focus(); + setOpen(null); } }; - const onScroll = (event) => { - if (!menu.current?.contains(event.target)) close(); - }; - window.addEventListener("mousedown", onDown); + window.addEventListener("mousedown", onDown, true); window.addEventListener("keydown", onKey, true); - window.addEventListener("scroll", onScroll, true); return () => { - window.removeEventListener("mousedown", onDown); + window.removeEventListener("mousedown", onDown, true); window.removeEventListener("keydown", onKey, true); - window.removeEventListener("scroll", onScroll, true); }; - }, [place]); - const open = () => { - const rect = button.current.getBoundingClientRect(); - const width = Math.max(240, rect.width); - const left = Math.max(8, Math.min(rect.right - width, window.innerWidth - width - 8)); - const below = window.innerHeight - rect.bottom; - setPlace(below >= 260 || below >= rect.top ? { left, width, top: rect.bottom + 4, maxHeight: below - 16, "--bt-origin": "top right" } : { left, width, bottom: window.innerHeight - rect.top + 4, maxHeight: rect.top - 16, "--bt-origin": "bottom right" }); - }; - return { isOpen: place !== null, shownPlace, leaving, button, menu, open, close }; -} -function ModelMenu({ bot, roster, actions, run, wide = false, onPick, onPickEffort }) { - if (bot?.remote !== void 0) return null; - const { isOpen, shownPlace, leaving, button, menu, open, close } = useAnchoredMenu(); - const shown = modelOf(bot, roster); - const current = bot.model ?? bot.appliedModel; - const served = servedOf(roster, current); - const reasoning = useEfforts(served, current, actions); - const picked = reasoning?.efforts?.some((effort) => effort.id === bot.effort) ? bot.effort : void 0; - const currentEffort = picked ?? reasoning?.defaultEffort; - const effortLabel = currentEffort === void 0 ? reasoning?.efforts?.length > 0 ? t("Default") : void 0 : reasoning.efforts.find((effort) => effort.id === currentEffort)?.name ?? currentEffort; + }, [open]); + const target = roster.byId?.[sessionId]?.id ?? roster.roomsById?.[sessionId]?.id; + if (sessionId === void 0 || target === void 0) return null; + const locked = lockedWindow(roster); const toggle = () => { - if (isOpen) { - close(); + if (open) { + setOpen(null); return; } - void actions?.refreshModels?.(); - open(); - }; - const pick2 = (ref) => { - close(); - if (onPick) onPick(ref); - else if (ref !== current || !bot.model) void run(actions.updateBot(bot.id, { model: ref })); - }; - const pickEffort = (effort) => { - close(); - if (onPickEffort) onPickEffort(effort); - else if (effort !== bot.effort) void run(actions.updateBot(bot.id, { effort: effort ?? null })); + const rect = button.current.getBoundingClientRect(); + setOpen({ left: rect.left, bottom: window.innerHeight - rect.top + 8 }); }; - const models = roster.models ?? []; - return (0, import_react22.createElement)( - import_react22.Fragment, - null, - (0, import_react22.createElement)( - "button", - { - ref: button, - type: "button", - className: "bt-model-trigger", - "data-wide": wide || void 0, - "aria-haspopup": "menu", - "aria-expanded": isOpen, - "aria-label": t("Model: {model}", { model: shown?.name ?? t("none") }), - "data-missing": shown !== void 0 && !shown.served && models.length > 0 ? "" : void 0, - onClick: toggle - }, - (0, import_react22.createElement)( - "span", - { className: "bt-model-copy" }, - (0, import_react22.createElement)("span", { className: "bt-model-name" }, shown?.name ?? t("Choose a model")), - shown ? (0, import_react22.createElement)("span", { className: "bt-model-provider" }, shown.served || models.length === 0 ? effortLabel === void 0 ? shown.provider : `${shown.provider} · ${effortLabel}` : t("{provider} · not available", { provider: shown.provider })) : null - ), - (0, import_react22.createElement)(Caret) - ), - shownPlace ? (0, import_react_dom8.createPortal)((0, import_react22.createElement)( - "div", - { ref: menu, className: `bt-menu bt-model-menu${shownPlace.bottom !== void 0 ? " bt-menu-up" : ""}`, role: "menu", "aria-label": t("Model for {name}", { name: bot.name }), style: shownPlace, "data-leaving": leaving || void 0 }, - models.length > 0 ? (0, import_react22.createElement)(ModelRows, { roster, current, onPick: pick2 }) : (0, import_react22.createElement)("div", { className: "bt-model-none" }, t("No models are set up in DeepSeek Harness yet.")), - effortRows(reasoning).length > 0 ? (0, import_react22.createElement)(EffortRows, { reasoning, current: picked, onPick: pickEffort }) : null, - (0, import_react22.createElement)("div", { className: "bt-menu-sep", role: "separator" }), - (0, import_react22.createElement)("button", { type: "button", role: "menuitem", onClick: () => { - close(); - openHarnessModels(); - } }, (0, import_react22.createElement)(SlidersIcon), (0, import_react22.createElement)("span", { className: "bt-menu-label" }, t("Models and providers…"))) - ), document.body) : null - ); -} -function ModelChoiceDock({ sessionId, useRoster, actions }) { - const roster = useRoster((value) => value); - const bot = roster.byId[sessionId]; - const [busy, setBusy] = (0, import_react22.useState)(""); - const [error, setError] = (0, import_react22.useState)(""); - const shown = Boolean(bot) && currentPart(bot) === sessionId && roster.ready; - (0, import_react22.useEffect)(() => { - if (shown) void actions.refreshModels?.(); - }, [shown, sessionId]); - if (!bot || currentPart(bot) !== sessionId || !roster.ready) return null; - if (bot.remote) return bot.remote.gone ? (0, import_react22.createElement)(RemoteGoneDock, { bot }) : null; - const models = roster.models ?? []; - const lost = bot.model ? !models.some((model) => model.ref === bot.model) : false; - if (bot.model && (!lost || models.length === 0)) return null; - const pick2 = (ref) => { - setBusy(ref); - setError(""); - void Promise.resolve(actions.updateBot(bot.id, { model: ref })).catch((failure) => { - setError(failure?.message ?? String(failure)); - void actions.refreshModels?.(); - }).finally(() => setBusy("")); + const attach = () => { + setOpen(null); + button.current?.closest("[data-composer-card]")?.querySelector("input[type=file]")?.click(); }; - const text2 = lost ? t("{model} is not available anymore. Pick another model for me.", { model: bot.model.split("/").slice(1).join("/") }) : models.length === 0 ? t("Hi, I'm {name}. I don't have a model yet. How do you want to set one up?", { name: bot.name }) : t("Hi, I'm {name}. Pick a model for me first. You can change it later in my details.", { name: bot.name }); - const choices = models.length === 0 ? [...setupChoices(actions).map(([label, run]) => (0, import_react22.createElement)("button", { key: label, type: "button", className: "bt-model-chip", onClick: run }, label)), (0, import_react22.createElement)(KeyHelpLink, { key: "key-help" })] : [ - ...groupModels(models).map(([provider, list]) => (0, import_react22.createElement)( - "div", - { key: list[0].provider ?? provider, className: "bt-model-choice-group", role: "group", "aria-label": provider }, - (0, import_react22.createElement)("div", { className: "bt-model-choice-provider", "aria-hidden": true }, provider), - (0, import_react22.createElement)("div", { className: "bt-model-choice-chips" }, list.map((model) => (0, import_react22.createElement)("button", { - key: model.ref, - type: "button", - className: "bt-model-chip", - disabled: busy !== "", - "aria-busy": busy === model.ref || void 0, - "aria-label": `${model.name} · ${provider}`, - title: model.ref === roster.defaultModel ? `${model.ref} · ${t("Harness default")}` : model.ref, - onClick: () => pick2(model.ref) - }, model.name, busy === model.ref ? (0, import_react22.createElement)("span", { className: "bt-model-spin", "aria-label": t("Saving…") }) : null))) - )), - (0, import_react22.createElement)("button", { key: "manage", type: "button", className: "bt-model-chip bt-model-chip-quiet", onClick: openHarnessModels }, (0, import_react22.createElement)(SlidersIcon), t("Manage models")) - ]; - return (0, import_react22.createElement)( - "div", - { className: "bt-model-dock" }, - (0, import_react22.createElement)( - "div", - { className: "bt-astack", "data-lead": "" }, - (0, import_react22.createElement)( - "div", - { className: "bt-msg" }, - (0, import_react22.createElement)( - "div", - { className: "bt-msg-line" }, - (0, import_react22.createElement)(BotMark, { bot, size: 22 }), - (0, import_react22.createElement)("div", { className: "bt-bubble" }, text2) - ) - ) - ), - (0, import_react22.createElement)("div", { className: "bt-model-chips", role: "group", "aria-label": t("Choose a model") }, choices), - error ? (0, import_react22.createElement)("div", { className: "bt-secret-error bt-model-error", role: "alert" }, error) : null - ); -} -function RemoteGoneDock({ bot }) { - return (0, import_react22.createElement)( - "div", - { className: "bt-model-dock", "data-flybot": "remote-gone" }, - (0, import_react22.createElement)( - "div", - { className: "bt-astack", "data-lead": "" }, - (0, import_react22.createElement)( - "div", - { className: "bt-msg" }, - (0, import_react22.createElement)( - "div", - { className: "bt-msg-line" }, - (0, import_react22.createElement)(BotMark, { bot, size: 22 }), - (0, import_react22.createElement)("div", { className: "bt-bubble" }, t("{name} ({handle}) is no longer shared with you, so messages to it cannot be delivered. It runs on its owner's machine, so another model cannot stand in. Ask its owner for a new invite.", { name: bot.name, handle: bot.remote.handle })) - ) - ) - ) - ); -} -function setupChoices(actions) { - return [ - actions.canSignIn?.() ? [t("Sign in to DeepSeek"), () => actions.signIn()] : null, - [t("Enter a DeepSeek API key"), () => openHarnessOnboarding("deepseek-official")], - [t("Open settings to set up a custom model"), openHarnessModels] - ].filter(Boolean); -} -var KEY_HELP_URL = "https://github.com/ThinkFlowLab/FlyBot/blob/main/docs/deepseek-api-key.md"; -function KeyHelpLink() { - return (0, import_react22.createElement)("a", { className: "bt-model-help", href: KEY_HELP_URL, target: "_blank", rel: "noopener noreferrer" }, t("How to set a DeepSeek API key")); -} -function missingModelKey(message) { - const text2 = String(message ?? ""); - if (!/\bno api key\b|\bmissing (?:an? )?api key\b|\bapi key (?:is )?(?:missing|not set|not configured)\b/i.test(text2)) return void 0; - return { route: /provider route "([^"]+)"/i.exec(text2)?.[1] ?? "" }; -} -function modelKeyChoices(actions, route) { - if (/^deepseek/i.test(route)) return setupChoices(actions); - return [[t("Manage models"), openHarnessModels]]; -} -function TurnErrorCell({ node, t: chatT, actions }) { - const data = node?.data ?? {}; - const code = typeof data.code === "string" && data.code ? data.code : void 0; - const raw = String(data.message ?? ""); - const shell = (key, fallback) => { - const text2 = typeof chatT === "function" ? chatT(key) : void 0; - return typeof text2 === "string" && text2 !== "" && text2 !== key ? text2 : fallback; + const reference = () => { + setOpen(null); + actions.referenceChat(sessionId, inputActions?.captureInsertion?.()); }; - const missing = missingModelKey(raw); - const title = code === "ACCOUNT_SIGNED_OUT" ? shell("message.accountStopped", t("This turn failed")) : shell("message.turnError", t("This turn failed")); - const message = missing ? t("This Bot's model has no API key yet. Set one up, then send your message again.") : code === "ACCOUNT_SIGNED_OUT" ? shell("message.failure.accountSignedOut", raw) : code === "ACCOUNT_SIGN_IN_REQUIRED" ? shell("message.failure.accountSignInRequired", raw) : code === "QUOTA" || code === "ACCOUNT_QUOTA" ? shell("message.failure.quota", raw) : code === "AUTH" ? shell("message.failure.auth", raw) : raw; - const choices = missing ? modelKeyChoices(actions ?? {}, missing.route) : []; - const deepseek = missing ? /^deepseek/i.test(missing.route) : false; + const canReference = typeof inputActions?.captureInsertion === "function" && actions.canReferenceChat(); return (0, import_react22.createElement)( - "div", - { className: "bt-turn-error", role: "status", "data-missing-key": missing ? "" : void 0 }, - (0, import_react22.createElement)("span", { className: "bt-turn-error-dot", "aria-hidden": true }), - (0, import_react22.createElement)( - "div", - { className: "bt-turn-error-copy" }, - (0, import_react22.createElement)("span", { className: "bt-turn-error-title" }, title), - (0, import_react22.createElement)("span", { className: "bt-turn-error-message" }, message), - code ? (0, import_react22.createElement)("code", { className: "bt-turn-error-code" }, code) : null - ), - choices.length > 0 ? (0, import_react22.createElement)( + import_react22.Fragment, + null, + (0, import_react22.createElement)("button", { + ref: button, + type: "button", + className: "bt-plus", + "aria-label": t("Add"), + title: t("Add"), + "aria-haspopup": "menu", + "aria-expanded": Boolean(open), + onMouseDown: keepComposerFocus, + onClick: toggle + }, (0, import_react22.createElement)(PlusIcon)), + shown ? (0, import_react_dom7.createPortal)((0, import_react22.createElement)( "div", - { className: "bt-turn-error-actions" }, - choices.map(([label, run]) => (0, import_react22.createElement)("button", { key: label, type: "button", className: "bt-model-chip", onClick: run }, label)), - deepseek ? (0, import_react22.createElement)(KeyHelpLink, { key: "key-help" }) : null - ) : null + { + ref: menu, + className: "bt-menu bt-menu-up bt-plus-menu", + role: "menu", + "aria-label": t("Add"), + style: { left: shown.left, bottom: shown.bottom }, + "data-leaving": leaving || void 0 + }, + (0, import_react22.createElement)("button", { type: "button", role: "menuitem", onClick: attach }, (0, import_react22.createElement)(PaperclipIcon), (0, import_react22.createElement)("span", { className: "bt-menu-label" }, t("Add files"))), + canReference ? (0, import_react22.createElement)("button", { type: "button", role: "menuitem", onMouseDown: keepComposerFocus, onClick: reference }, (0, import_react22.createElement)(ChatsIcon), (0, import_react22.createElement)("span", { className: "bt-menu-label" }, t("Reference a chat"))) : null, + locked ? null : (0, import_react22.createElement)("button", { type: "button", role: "menuitem", onClick: () => { + setOpen(null); + actions.openSettings("schedules", target); + } }, (0, import_react22.createElement)(ClockIcon), (0, import_react22.createElement)("span", { className: "bt-menu-label" }, t("Scheduled task"))) + ), document.body) : null ); } -function DeepSeekSignIn({ useUi, actions }) { - const open = useUi((state) => state.signIn); - const entry = open ? actions.signInEntry?.() : null; - if (!entry) return null; - return (0, import_react22.createElement)(SignInHost, { key: open, entry, actions }); -} -function SignInHost({ entry, actions }) { - const [ops] = (0, import_react22.useState)(() => entry.inject?.() ?? {}); - const close = () => actions.closeSignIn(); - const useHook = (source) => (selector) => (0, import_react22.useSyncExternalStore)(source.subscribe, () => selector(source.getSnapshot())); - const [hooks] = (0, import_react22.useState)(() => ({ useAccount: useHook(ops.hooks.account), useTheme: useHook(ops.hooks.theme) })); - return (0, import_react22.createElement)(entry.component, { - ...ops, - ...hooks, - t: actions.accountT(), - complete: close, - useApiKey: () => { - close(); - openHarnessOnboarding("deepseek-official"); - } - }); -} -var MODEL_CSS = ` -.bt-turn-error{display:grid;grid-template-columns:10px minmax(0,1fr);column-gap:8px;row-gap:8px;padding:2px 0;font-size:13px;line-height:20px} -.bt-turn-error-dot{width:6px;height:6px;margin-top:7px;border-radius:50%;background:#e02135} -.bt-turn-error-copy{min-width:0;overflow-wrap:anywhere} -.bt-turn-error-title{margin-right:6px;font-weight:600;color:#e02135} -.bt-turn-error-message{color:var(--bt-ink-2)} -.bt-turn-error-code{margin-left:6px;font-size:11px;color:var(--bt-ink-3)} -.bt-turn-error-actions{grid-column:2;display:flex;flex-wrap:wrap;align-items:center;gap:6px} -.bt-model-help{display:inline-flex;align-items:center;min-height:28px;padding:0 4px;font-size:12px;line-height:16px;color:var(--bt-ink-2);text-decoration:underline;text-underline-offset:2px} -.bt-model-help:hover{color:var(--bt-ink)} -.bt-model-help:focus-visible{outline:2px solid var(--bt-accent);outline-offset:1px;border-radius:4px} -.bt-model-trigger{display:inline-flex;align-items:center;gap:8px;max-width:100%;min-width:0;height:auto;min-height:32px;padding:4px 8px 4px 10px;border:0;border-radius:8px;background:var(--bt-hover);color:var(--bt-ink);font:inherit;text-align:left;cursor:pointer;transition:background-color .12s ease} -.bt-model-trigger:hover,.bt-model-trigger[aria-expanded=true]{background:var(--bt-active)} -.bt-model-trigger:focus-visible{outline:2px solid var(--bt-accent);outline-offset:1px} -.bt-model-trigger[data-missing] .bt-model-provider{color:#c21d2e} -.bt-model-trigger[data-wide]{display:flex;width:100%;min-height:48px;padding:7px 12px;justify-content:space-between;border-radius:12px;background:var(--bt-card);box-shadow:inset 0 0 0 1px var(--bt-line)} -.bt-model-trigger[data-wide]:hover,.bt-model-trigger[data-wide][aria-expanded=true]{background:var(--bt-hover)} -.bt-model-copy{display:flex;flex-direction:column;min-width:0} -.bt-model-name{font-size:13px;line-height:18px;font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} -.bt-model-provider{font-size:11px;line-height:14px;color:var(--bt-ink-3);overflow:hidden;text-overflow:ellipsis;white-space:nowrap} -.bt-model-caret{flex:none;color:var(--bt-ink-3);transition:${spring(SPRING_SHAPE, "transform")}} -.bt-model-trigger[aria-expanded=true] .bt-model-caret{transform:rotate(180deg)} -.bt-model-menu{overflow-y:auto;scrollbar-width:thin} -.bt-model-group{flex:none;padding:8px 8px 2px;font-size:11px;line-height:14px;font-weight:500;color:var(--bt-ink-3);text-transform:none} -.bt-model-group:first-child{padding-top:2px} -.bt-model-tick{flex:none;width:16px;height:16px;display:inline-flex;color:var(--bt-ink-2)} -.bt-model-tick svg{animation:bt-check-pop ${SPRING_POP.duration}ms ${SPRING_POP.easing} 60ms backwards} -.bt-model-none{padding:8px;font-size:13px;line-height:18px;color:var(--bt-ink-2)} -.bt-model-dock{width:100%;max-width:var(--dsh-chat-content-width,748px);margin:0 auto;display:flex;flex-direction:column;align-items:flex-start;gap:8px;padding:0 0 10px;box-sizing:border-box;animation:bt-model-in ${SPRING_POP.duration}ms ${SPRING_POP.easing} backwards} -.bt-model-chips{display:flex;flex-wrap:wrap;gap:6px;margin-left:30px;max-width:min(560px,calc(100% - 30px));max-height:min(240px,36vh);overflow-y:auto;scrollbar-width:thin} -.bt-model-choice-group{display:flex;flex-direction:column;gap:6px;width:100%;min-width:0} -.bt-model-choice-provider{padding-top:4px;font-size:12px;line-height:16px;font-weight:500;color:var(--bt-ink-2);overflow-wrap:anywhere} -.bt-model-choice-chips{display:flex;flex-wrap:wrap;gap:6px} -.bt-model-chip{display:inline-flex;align-items:center;gap:6px;min-height:30px;padding:5px 12px;border:.5px solid var(--bt-line-2);border-radius:999px;background:var(--bt-surface,transparent);color:var(--bt-ink);font:inherit;font-size:13px;line-height:18px;cursor:pointer;transition:background-color .12s ease,border-color .12s ease} -.bt-model-chip:hover:not(:disabled){background:var(--bt-hover)} -.bt-model-chip:focus-visible{outline:2px solid var(--bt-accent);outline-offset:1px} -.bt-model-chip-quiet{color:var(--bt-ink-2)} -.bt-model-chip svg{width:14px;height:14px} -.bt-model-chip:disabled{cursor:default} -.bt-model-chip:disabled:not([aria-busy]){opacity:.5} -.bt-model-error{margin-left:30px} -@keyframes bt-model-in{from{opacity:0;transform:translateY(6px)}} -.bt-model-spin{flex:none;width:14px;height:14px;border-radius:50%;border:1.5px solid var(--bt-line-2);border-top-color:var(--bt-ink-2);animation:bt-model-spin .7s linear infinite} -@keyframes bt-model-spin{to{transform:rotate(360deg)}} -@media (prefers-reduced-motion:reduce){.bt-model-dock{animation:none}.bt-model-spin{animation-duration:2s}.bt-model-caret{transition:none}.bt-model-tick svg{animation:none}} -`; -// src/client/model-test.js -var import_react23 = require("react"); -var FAILURE_COPY = { - NO_MODEL: "Pick a model first.", - NO_KEY: "This model has no API key yet. Set one up in the model settings.", - AUTH: "The API key was rejected. Check it in the model settings.", - QUOTA: "The account is out of quota.", - RATE_LIMIT: "Too many requests right now. Try again in a moment.", - TIMEOUT: "The model did not answer within 30 seconds.", - NOT_SERVED: "This model is not available now. Check its provider in the model settings.", - MODEL_NOT_FOUND: "The provider does not know this model. Check the model name.", - TRANSPORT: "Could not reach the model service. Check the endpoint address and your network.", - SERVER: "The model service failed. Try again later.", - INVALID_REQUEST: "The model service refused the request. Check the endpoint and the model.", - UNKNOWN: "The test failed." +// src/client/at-menu.js +var BOTS_SOURCE = "ds-bot"; +var CHATS_SOURCE = "ds-bot-chats"; +var ROOM_ALL = ["all", "everyone", "所有人", "全体"]; +var OTHER_CHATS_MAX = 8; +var conversationOf = (roster, sessionId) => { + if (!roster.ready) return null; + const room2 = roster.roomsById[sessionId]; + if (room2) return { room: room2 }; + const self = roster.byId[sessionId]; + return self ? { self } : null; }; -function testOutcome(result, modelName) { - if (result?.ok === true) { - const seconds = typeof result.ms === "number" ? Math.max(0.1, Math.round(result.ms / 100) / 10) : void 0; - return { ok: true, text: seconds === void 0 ? t("Works. {model} answered.", { model: modelName }) : t("Works. {model} answered in {seconds} s.", { model: modelName, seconds }) }; +function mentionRows(roster, sessionId, query = "") { + const here = conversationOf(roster, sessionId); + if (here === null) return null; + const face = isFaceRoom(here.room); + const live = (here.room?.participants ?? []).filter((p) => p.leftSeq === void 0); + const bots = here.room ? [ + ...here.room.members.map((id) => roster.byId[id]).filter(Boolean).map((bot) => face ? { ...bot, label: faceLabel(myOwnerName(roster, live, bot), bot.name) } : bot), + ...live.filter((p) => p.kind === "human").map((p) => ({ id: p.id, name: p.displayName || p.id, kind: "human", role: p.role })), + // A remote seat already listed under its handle (a duplicate participant record) is offered once. + ...uniqueBy(live.filter((p) => p.remote), (p) => p.handle || p.id).map((p) => ({ + id: p.handle || p.id, + // A face room inserts the disambiguated title. An ordinary group inserts the name the + // room routes on (#1141): never "@@handle.home (owner)" and never "(Friend)". + name: face ? remoteBotTitle(p) : remoteCallName(p), + kind: "bot", + remote: p, + handle: p.handle, + label: face ? faceLabel(ownerNameOf(p), cleanPeerName(p.displayName || p.name, "Bot")) : remoteBotTitle(p) + })) + ] : listedChatBots(roster).filter((bot) => bot.id !== here.self.id); + const needle = query.trim().toLowerCase(); + const rows = bots.filter((bot) => bot.name.toLowerCase().includes(needle) || String(bot.label ?? "").toLowerCase().includes(needle) || bot.remote && normHandle(remoteRecord(bot)?.handle || bot.handle).includes(normHandle(needle))).map((bot) => ({ bot, text: `@${bot.name} ` })); + const everyone = t("Everyone"); + if (here.room && bots.length > 1 && [everyone, ...ROOM_ALL].some((word) => word.toLowerCase().startsWith(needle))) { + rows.push({ room: here.room, count: bots.filter((bot) => bot.kind !== "human").length, text: `@${everyone} ` }); } - const code = Object.hasOwn(FAILURE_COPY, result?.code) ? result.code : "UNKNOWN"; - return { ok: false, code, text: t(FAILURE_COPY[code]) }; + if (face) return rows; + const seatHandles = new Set(bots.filter((bot) => bot.remote).map((bot) => normHandle(remoteRecord(bot)?.handle)).filter(Boolean)); + if (here.room) return [...rows, ...remoteMentionRows(roster, query).filter((row) => !seatHandles.has(normHandle(row.remote.handle)))]; + const remoteHandles = new Set(remoteMentionRows(roster, "").map((row) => normHandle(row.remote.handle))); + return [ + ...rows.filter((row) => !(row.bot && remoteHandles.has(normHandle(remoteRecord(row.bot)?.handle)))), + ...remoteMentionRows(roster, query) + ]; +} +var isFaceRoom = (room2) => room2?.origin === "face-to-face"; +var memberCount = (count) => t(count === 1 ? "{count} member" : "{count} members", { count }); +var botCount = (count) => count === 1 ? t("1 Bot") : t("{count} Bots", { count }); +var normHandle = (handle) => String(handle ?? "").trim().replace(/^@+/, "").toLowerCase(); +var remoteRecord = (bot) => bot?.remote ? bot.remote === true ? bot : bot.remote : null; +var myOwnerName = (roster, live, bot) => { + const seat = live.find((p) => !p.remote && p.kind !== "human" && (p.id === bot.id || bot.handle && p.handle === bot.handle)); + return seatOwnerName(seat?.ownerName, seat?.handle) || cleanPeerName(roster.self?.displayName, ""); +}; +var uniqueBy = (list, key) => { + const seen = /* @__PURE__ */ new Set(); + return list.filter((item) => { + const k = key(item); + if (!k || seen.has(k)) return !k; + seen.add(k); + return true; + }); +}; +var faceLabel = (person, bot) => person ? `${bot} (${person})` : bot; +function chatRows(roster, list, sessionId) { + const here = conversationOf(roster, sessionId); + if (here === null) return []; + const byId = list?.byId ?? {}; + const bots = roster.bots.filter((bot) => bot.id !== here.self?.id).map((bot) => { + const id = currentPart(bot); + return { sessionId: id, label: t("Chat with {name}", { name: bot.name }), bot, updatedAt: byId[id]?.updatedAt }; + }); + const team = /* @__PURE__ */ new Set([ + ...roster.bots.flatMap((bot) => [bot.id, currentPart(bot), ...bot.parts ?? []]), + ...roster.rooms.flatMap((room2) => [room2.id, ...room2.parts ?? []]) + ]); + const others = (list?.ids ?? []).map((id) => byId[id]).filter((row) => row !== void 0 && row.id !== sessionId && !team.has(row.id) && row.origin !== "subagent" && row.blank !== true).sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0)).slice(0, OTHER_CHATS_MAX).map((row) => ({ sessionId: row.id, label: row.displayTitle || row.title || row.id, updatedAt: row.updatedAt })); + return [...bots, ...others]; +} +function sessionMention(sessionId, label) { + let binary = ""; + for (const byte of new TextEncoder().encode(JSON.stringify(sessionId))) binary += String.fromCharCode(byte); + const payload = btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); + return `@[${label.replace(/[\\\]]/g, (mark) => `\\${mark}`)}](dsh-session:${payload})`; +} +function atSources({ roster, sessions, launched, iconFor }) { + const bots = { + trigger: "@", + name: BOTS_SOURCE, + order: -20, + showGroupTitle: false, + async candidates(session, { query }) { + const snapshot = roster.getSnapshot(); + const face = isFaceRoom(snapshot.roomsById?.[session.sessionId]); + const team = (mentionRows(snapshot, session.sessionId, query) ?? []).map((row) => ({ + // A friend's Bot by the one name the row inserts, "Chief (Bob)" (#967); its handle is the + // second line (#384). A seat carries its own name; a friend's chat is named as its sidebar + // row and header (#498). + name: row.bot?.remote && !face ? row.bot.kind === "bot" ? remoteBotTitle(remoteRecord(row.bot)) : friendBotTitle(row.bot) : row.bot ? row.bot.label ?? row.bot.name : row.remote ? row.text.replace(/^@/, "").trimEnd() : t("Everyone"), + ...row.bot?.role && !face ? { description: row.bot.role } : {}, + ...row.remote ? { description: row.remote.handle } : {}, + ...row.bot?.remote && !face && remoteRecord(row.bot).handle ? { description: remoteRecord(row.bot).handle } : {}, + // A face room counts the Bots in it, mine and everyone else's (#305, #457). + ...row.room ? { description: face ? botCount(row.count) : memberCount(row.count) } : {}, + icon: iconFor(row), + section: t("Bots"), + value: row.text + })); + if (mentionRows(snapshot, session.sessionId, "") === null || face) return team; + const extra = (await mentionCandidates(query, { sessionId: session.sessionId, roster: snapshot })).map((row) => ({ + name: row.name, + ...row.description ? { description: row.description } : {}, + icon: row.icon ?? "session", + section: row.section ?? "FlyBot", + value: row.value + })); + return [...team, ...extra]; + }, + onPick: ({ candidate }) => candidate.value ? { text: candidate.value } : void 0 + }; + const chats = { + trigger: "@", + name: CHATS_SOURCE, + order: -10, + showGroupTitle: false, + async candidates(session) { + if (!launched(session.sessionId)) return []; + return chatRows(roster.getSnapshot(), sessions.list.getSnapshot(), session.sessionId).map((row) => ({ + name: row.bot ? row.bot.name : row.label, + ...row.updatedAt ? { description: relativeShort(row.updatedAt) } : {}, + icon: row.bot ? iconFor(row) : "session", + section: row.bot ? t("Bots") : t("Conversations"), + value: JSON.stringify({ sessionId: row.sessionId, label: row.label }) + })); + }, + onPick({ candidate }) { + const { sessionId, label } = JSON.parse(candidate.value); + const mention = sessionMention(sessionId, label); + return { insert: { source: CHATS_SOURCE, ref: mention, label, appearance: "session", clipboardText: mention } }; + }, + codec: { clipboardText: (ref) => ref, serialize: (ref) => Promise.resolve(ref) } + }; + return [bots, chats]; } -var testModel = (id) => call("test-model", { id }); -function ModelTestRow({ bot, modelName, test = testModel }) { - const [state, setState] = (0, import_react23.useState)({ busy: false, outcome: void 0 }); - const live = (0, import_react23.useRef)(true); - (0, import_react23.useEffect)(() => () => { - live.current = false; - }, []); - (0, import_react23.useEffect)(() => { - setState({ busy: false, outcome: void 0 }); - }, [bot.id, bot.model]); - const start = () => { - if (state.busy) return; - setState({ busy: true, outcome: void 0 }); - Promise.resolve().then(() => test(bot.id)).then( - (result) => testOutcome(result, modelName ?? result?.ref ?? ""), - (error) => ({ ok: false, text: String(error?.message ?? t("The test failed.")) }) - ).then((outcome2) => { - if (live.current) setState({ busy: false, outcome: outcome2 }); + +// src/client/face-at-filter.js +var REFERENCE_SOURCE = "reference"; +var optionId = (source, index) => `dsh-slash-option-${source}-${index}`; +var isSessionRow = (group, item) => group.source === REFERENCE_SOURCE && item?.icon === "session"; +var same = (a, b) => a != null && b != null && a.source === b.source && a.index === b.index; +function planFaceMenu(state, previous = null) { + const hidden = []; + const ready = []; + let shown = 0; + let loading = false; + for (const group of state?.groups ?? []) { + const items = group.items ?? []; + if (group.status !== "ready" && items.length === 0) loading = true; + items.forEach((item, index) => { + const hide = isSessionRow(group, item); + if (hide) hidden.push({ source: group.source, index, title: item.section !== void 0 && item.section !== items[index - 1]?.section }); + else shown += 1; + if (group.status === "ready") ready.push({ source: group.source, index, hide }); }); - }; - const { busy, outcome } = state; - return (0, import_react23.createElement)( - "div", - { className: "bt-set-row bt-bs-row bt-model-test" }, - (0, import_react23.createElement)( - "span", - { className: "bt-set-copy" }, - (0, import_react23.createElement)("span", { className: "bt-set-hint" }, t("Sends one short request to this model. It may be billed.")), - (0, import_react23.createElement)( - "span", - { className: "bt-model-test-result", role: "status", "aria-live": "polite", "data-ok": outcome === void 0 ? void 0 : String(outcome.ok) }, - busy ? t("Testing…") : outcome === void 0 ? null : outcome.text, - outcome?.code ? (0, import_react23.createElement)("code", { className: "bt-model-test-code" }, outcome.code) : null - ) - ), - (0, import_react23.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy, "aria-busy": busy || void 0, onClick: start }, t("Test connection")) - ); + } + const open = state?.open === true; + const blank3 = open && hidden.length > 0 && shown === 0 && !loading; + const dismiss = blank3 && (state.groups ?? []).every((group) => group.status === "ready"); + return { hidden, blank: blank3, dismiss, target: dismiss ? null : steer(ready, state?.highlight ?? null, previous) }; } -var MODEL_TEST_CSS = ` -.bt-model-test-result{font-size:13px;line-height:18px;color:var(--bt-ink-2)} -.bt-model-test-result:empty{display:none} -.bt-model-test-result[data-ok=true]{color:#1a7f37} -.bt-model-test-result[data-ok=false]{color:#c21d2e} -.bt-model-test-code{margin-left:6px;font-size:11px;color:var(--bt-ink-3)} -/* #1347: the hint beside "Test connection" is prose and wraps; the button next to it keeps its own - words ("Test connection" on one line) and takes the room it needs. */ -.bt-model-test>.bt-set-copy{flex:1 1 12ch;min-width:12ch} -.bt-model-test>.bt-soft{flex:none;white-space:nowrap} -@container bt-panel (max-width:300px){ - .bt-model-test{flex-wrap:wrap;row-gap:6px} - .bt-model-test>.bt-set-copy{flex:1 1 100%} +function steer(ready, highlight, previous) { + const at = ready.findIndex((row) => same(row, highlight)); + if (at < 0 || !ready[at].hide) return null; + const from = ready.findIndex((row) => same(row, previous)); + const n = ready.length; + const dir = from >= 0 && from !== at && (from - at + n) % n === 1 ? -1 : 1; + for (let step = 1; step < n; step += 1) { + const row = ready[((at + dir * step) % n + n) % n]; + if (!row.hide) return { source: row.source, index: row.index }; + } + return null; } -`; - -// src/client/folder-menu.js -var import_react24 = require("react"); -var import_react_dom9 = require("react-dom"); -var folderName = (path) => String(path).replace(/[\\/]+$/, "").split(/[\\/]/).pop() || String(path); -var Tick = ({ on }) => (0, import_react24.createElement)("span", { className: "bt-model-tick", "aria-hidden": true }, on ? (0, import_react24.createElement)(CheckIcon) : null); -function FolderMenu({ value, group = false, actions, run, onPick, wide = false }) { - const { isOpen, shownPlace, leaving, button, menu, open, close } = useAnchoredMenu(); - const [info, setInfo] = (0, import_react24.useState)(null); - const [view, setView] = (0, import_react24.useState)("list"); - const [draft, setDraft] = (0, import_react24.useState)(""); - const [listing, setListing] = (0, import_react24.useState)(null); - const [problem, setProblem] = (0, import_react24.useState)(""); - const fallback = group ? t("Each Bot's own folder") : t("Team workspace"); - const toggle = () => { - if (isOpen) { - close(); - return; - } - setView("list"); - setProblem(""); - void Promise.resolve(actions.folders()).then(setInfo).catch(() => setInfo((last) => last ?? { team: null, picker: null, folders: [] })); - open(); +function faceMenuCss({ hidden, blank: blank3 }) { + if (hidden.length === 0) return ""; + const rows = hidden.map((row) => `[data-trigger-menu] [id="${optionId(row.source, row.index)}"]`); + const titles = hidden.filter((row) => row.title).map((row) => `[data-trigger-menu] [role="presentation"]:has(+ [id="${optionId(row.source, row.index)}"])`); + const css = [`${[...rows, ...titles].join(",")}{display:none!important}`]; + if (blank3) css.push("[data-trigger-menu]{display:none!important}"); + return css.join("\n"); +} +function watchFaceMenu(controller, doc = globalThis.document) { + if (typeof controller?.menu?.subscribe !== "function" || !doc?.head) return () => { }; - const choose = (path) => { - close(); - if (path !== (value ?? "")) void onPick(path); + const style = doc.createElement("style"); + style.setAttribute("data-bt-face-at", ""); + doc.head.appendChild(style); + let previous = null; + let stopped = false; + const later = typeof queueMicrotask === "function" ? queueMicrotask : (fn) => Promise.resolve().then(fn); + const apply2 = () => { + if (stopped) return; + const state = controller.menu.getSnapshot(); + const plan = planFaceMenu(state, previous); + const css = faceMenuCss(plan); + if (style.textContent !== css) style.textContent = css; + if (plan.dismiss) later(() => { + if (!stopped && planFaceMenu(controller.menu.getSnapshot(), previous).dismiss) controller.dismiss(); + }); + else if (plan.target) later(() => { + if (!stopped) controller.hover(plan.target.source, plan.target.index); + }); + else previous = state.open ? state.highlight : null; }; - const browse = (path) => { - setProblem(""); - void Promise.resolve(actions.folderList(path)).then((next) => { - setListing(next); - setView("browse"); - }).catch((failure) => setProblem(failure?.message ?? String(failure))); + const unsubscribe = controller.menu.subscribe(apply2); + apply2(); + return () => { + stopped = true; + unsubscribe?.(); + style.remove(); }; - const pickNative = () => { - close(); - void run(Promise.resolve(actions.folderPick()).then((path) => path ? onPick(path) : void 0)); +} + +// src/client/speech.js +var VOICE_BUNDLE = "@deepseek-ai/dsh-experimental-voice-input-bundle"; +var USABLE = /* @__PURE__ */ new Set(["ready", "standby", "waking"]); +async function speechCatalog(speech) { + const result = await speech.catalog(); + if (!result?.ok) throw new Error(result?.error?.message ?? "Speech catalog failed"); + const catalog = result.value; + const provider = catalog.providers.find((item) => item.id === catalog.selection.providerId); + return { catalog, usable: USABLE.has(provider?.preparation?.phase) }; +} +function waveBytes(samples, rate = 16e3) { + const bytes = new Uint8Array(44 + samples.length * 2); + const view = new DataView(bytes.buffer); + const ascii = (at, text2) => { + for (let index = 0; index < text2.length; index += 1) bytes[at + index] = text2.charCodeAt(index); }; - const typed = () => { - const path = draft.trim(); - if (path !== "") choose(path); + ascii(0, "RIFF"); + view.setUint32(4, 36 + samples.length * 2, true); + ascii(8, "WAVEfmt "); + view.setUint32(16, 16, true); + view.setUint16(20, 1, true); + view.setUint16(22, 1, true); + view.setUint32(24, rate, true); + view.setUint32(28, rate * 2, true); + view.setUint16(32, 2, true); + view.setUint16(34, 16, true); + ascii(36, "data"); + view.setUint32(40, samples.length * 2, true); + samples.forEach((sample, index) => { + const value = Math.max(-1, Math.min(1, sample)); + view.setInt16(44 + index * 2, value < 0 ? Math.round(value * 32768) : Math.round(value * 32767), true); + }); + return bytes; +} +function base64Of(bytes) { + let text2 = ""; + for (let index = 0; index < bytes.length; index += 32768) text2 += String.fromCharCode(...bytes.subarray(index, index + 32768)); + return btoa(text2); +} +var captureError = (kind, cause) => Object.assign(new Error(kind), { kind, cause }); +async function openCapture() { + const devices = navigator.mediaDevices; + if (!devices?.getUserMedia || typeof MediaRecorder === "undefined") throw captureError("unavailable"); + let stream; + try { + stream = await devices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true }, video: false }); + } catch (error) { + throw captureError(error?.name === "NotAllowedError" ? "blocked" : "failed", error); + } + const audio = new AudioContext(); + const analyser = audio.createAnalyser(); + analyser.fftSize = 512; + audio.createMediaStreamSource(stream).connect(analyser); + const frame = new Float32Array(analyser.fftSize); + const recorder = new MediaRecorder(stream); + const chunks = []; + recorder.ondataavailable = (event) => { + if (event.data.size > 0) chunks.push(event.data); }; - const known = info?.folders ?? []; - const folders = value && !known.some((folder) => folder.path === value) ? [{ path: value, title: folderName(value) }, ...known] : known; - const listView = (0, import_react24.createElement)( - "div", - { key: "list", className: "bt-folder-view" }, - (0, import_react24.createElement)( - "button", - { type: "button", role: "menuitemradio", "aria-checked": !value, title: group ? void 0 : info?.team ?? void 0, onClick: () => choose("") }, - (0, import_react24.createElement)("span", { className: "bt-menu-label" }, fallback), - (0, import_react24.createElement)(Tick, { on: !value }) - ), - folders.length > 0 ? (0, import_react24.createElement)("div", { className: "bt-model-group", role: "presentation" }, t("Folders")) : null, - folders.map((folder) => (0, import_react24.createElement)( - "button", - { key: folder.path, type: "button", role: "menuitemradio", "aria-checked": folder.path === value, title: folder.path, onClick: () => choose(folder.path) }, - (0, import_react24.createElement)("span", { className: "bt-folder-copy" }, (0, import_react24.createElement)("span", { className: "bt-menu-label" }, folder.title), (0, import_react24.createElement)("span", { className: "bt-folder-path" }, folder.path)), - (0, import_react24.createElement)(Tick, { on: folder.path === value }) - )), - (0, import_react24.createElement)("div", { className: "bt-menu-sep", role: "separator" }), - info?.picker === "native" ? (0, import_react24.createElement)("button", { type: "button", role: "menuitem", onClick: pickNative }, (0, import_react24.createElement)(FolderIcon), (0, import_react24.createElement)("span", { className: "bt-menu-label" }, t("Choose a folder…"))) : null, - info?.picker === "browse" ? (0, import_react24.createElement)("button", { type: "button", role: "menuitem", onClick: () => browse(value) }, (0, import_react24.createElement)(FolderIcon), (0, import_react24.createElement)("span", { className: "bt-menu-label" }, t("Browse folders…"))) : null, - (0, import_react24.createElement)("button", { type: "button", role: "menuitem", onClick: () => { - setDraft(value ?? ""); - setView("type"); - } }, (0, import_react24.createElement)(PencilIcon), (0, import_react24.createElement)("span", { className: "bt-menu-label" }, t("Enter a path…"))) - ); - const typeView = (0, import_react24.createElement)( - "div", - { key: "type", className: "bt-folder-view" }, - (0, import_react24.createElement)("button", { type: "button", className: "bt-folder-back", onClick: () => setView("list") }, (0, import_react24.createElement)(ChevronLeftIcon), (0, import_react24.createElement)("span", { className: "bt-menu-label" }, t("Back"))), - (0, import_react24.createElement)("input", { - className: "bt-folder-input", - autoFocus: true, - value: draft, - spellCheck: false, - "aria-label": t("Folder path"), - placeholder: t("Full path of a folder"), - onChange: (event) => setDraft(event.target.value), - onKeyDown: (event) => { - if (event.key === "Enter" && !event.nativeEvent.isComposing) typed(); + recorder.start(); + let closed = false; + const close = () => { + if (closed) return; + closed = true; + if (recorder.state !== "inactive") recorder.stop(); + stream.getTracks().forEach((track) => track.stop()); + void audio.close().catch(() => { + }); + }; + return { + level() { + if (closed) return 0; + analyser.getFloatTimeDomainData(frame); + let sum = 0; + for (const sample of frame) sum += sample * sample; + return Math.min(1, Math.sqrt(sum / frame.length) * 4); + }, + cancel: close, + async finish(maxSeconds) { + if (closed || recorder.state === "inactive") { + close(); + return null; } - }), - (0, import_react24.createElement)("button", { type: "button", className: "bt-folder-use", disabled: draft.trim() === "", onClick: typed }, t("Use this folder")) - ); - const parent = listing?.crumbs?.length > 1 ? listing.crumbs.at(-2) : void 0; - const inside = listing?.entries?.filter((entry) => !entry.hidden) ?? []; - const browseView = listing ? (0, import_react24.createElement)( - "div", - { key: `browse:${listing.path}`, className: "bt-folder-view" }, - (0, import_react24.createElement)("button", { type: "button", className: "bt-folder-back", onClick: () => setView("list") }, (0, import_react24.createElement)(ChevronLeftIcon), (0, import_react24.createElement)("span", { className: "bt-menu-label" }, t("Back"))), - (0, import_react24.createElement)("div", { className: "bt-folder-path bt-folder-here" }, listing.path), - parent ? (0, import_react24.createElement)("button", { type: "button", role: "menuitem", title: parent.path, onClick: () => browse(parent.path) }, (0, import_react24.createElement)(FolderIcon), (0, import_react24.createElement)("span", { className: "bt-menu-label" }, t("Up one level"))) : null, - inside.map((entry) => (0, import_react24.createElement)( - "button", - { key: entry.path, type: "button", role: "menuitem", onClick: () => browse(entry.path) }, - (0, import_react24.createElement)(FolderIcon), - (0, import_react24.createElement)("span", { className: "bt-menu-label" }, entry.name), - (0, import_react24.createElement)(ChevronRightIcon) - )), - inside.length === 0 ? (0, import_react24.createElement)("div", { className: "bt-model-none" }, t("No folders inside.")) : null, - listing.truncated ? (0, import_react24.createElement)("div", { className: "bt-folder-path bt-folder-here" }, t("Some folders are not shown.")) : null, - (0, import_react24.createElement)("div", { className: "bt-menu-sep", role: "separator" }), - (0, import_react24.createElement)("button", { type: "button", className: "bt-folder-use", onClick: () => choose(listing.path) }, t("Use this folder")) - ) : null; - const shownView = view === "type" ? typeView : view === "browse" && browseView ? browseView : listView; - return (0, import_react24.createElement)( - import_react24.Fragment, - null, - (0, import_react24.createElement)( - "button", - { - ref: button, - type: "button", - className: "bt-model-trigger", - "data-wide": wide || void 0, - "aria-haspopup": "menu", - "aria-expanded": isOpen, - "aria-label": t("Working folder: {folder}", { folder: value ?? fallback }), - title: value, - onClick: toggle - }, - (0, import_react24.createElement)( - "span", - { className: "bt-model-copy" }, - (0, import_react24.createElement)("span", { className: "bt-model-name" }, value ? folderName(value) : fallback), - value ? (0, import_react24.createElement)("span", { className: "bt-model-provider" }, value) : null - ), - (0, import_react24.createElement)(Caret) - ), - shownPlace ? (0, import_react_dom9.createPortal)((0, import_react24.createElement)( - "div", - { ref: menu, className: `bt-menu bt-model-menu bt-folder-menu${shownPlace.bottom !== void 0 ? " bt-menu-up" : ""}`, role: "menu", "aria-label": t("Working folder"), style: shownPlace, "data-leaving": leaving || void 0 }, - shownView, - problem ? (0, import_react24.createElement)("div", { className: "bt-folder-error", role: "alert" }, problem) : null - ), document.body) : null - ); + try { + await new Promise((resolve) => { + recorder.onstop = resolve; + recorder.stop(); + }); + stream.getTracks().forEach((track) => track.stop()); + const blob = new Blob(chunks, { type: recorder.mimeType }); + if (blob.size === 0) return null; + const decoded = await audio.decodeAudioData(await blob.arrayBuffer()); + const length = Math.max(1, Math.floor(Math.min(decoded.duration, maxSeconds) * 16e3)); + const offline = new OfflineAudioContext(1, length, 16e3); + const source = offline.createBufferSource(); + source.buffer = decoded; + source.connect(offline.destination); + source.start(); + return waveBytes((await offline.startRendering()).getChannelData(0)); + } finally { + close(); + } + } + }; } -var FOLDER_CSS = ` -.bt-folder-view{display:flex;flex-direction:column;gap:2px;animation:bt-veil-in .14s ease-out} -.bt-folder-copy{flex:1;display:flex;flex-direction:column;min-width:0} -.bt-folder-copy .bt-menu-label{flex:none} -.bt-folder-path{min-width:0;font-size:11px;line-height:14px;color:var(--bt-ink-3);overflow:hidden;text-overflow:ellipsis;white-space:nowrap} -.bt-folder-here{padding:0 8px 4px;white-space:normal;overflow-wrap:anywhere} -.bt-menu .bt-folder-back{color:var(--bt-ink-2);font-weight:500} -.bt-folder-input{height:30px;margin:2px 4px;padding:0 8px;border:1px solid var(--bt-line-2);border-radius:8px;background:var(--bt-main);color:var(--bt-ink);font:inherit;font-size:13px;outline:none;box-sizing:border-box} -.bt-folder-input:focus{border-color:var(--bt-accent)} -.bt-menu .bt-folder-use{justify-content:center;font-weight:500;color:var(--bt-accent)} -.bt-menu .bt-folder-use:disabled{color:var(--bt-ink-3);cursor:default;background:none} -.bt-folder-error{padding:4px 8px;font-size:12px;line-height:16px;color:#c21d2e;overflow-wrap:anywhere} -@media (prefers-reduced-motion:reduce){.bt-folder-view{animation:none}} -`; - -// src/client/schedules.js -var import_react25 = require("react"); -var POLL_MS4 = 3e4; -var shared = null; -var listeners = /* @__PURE__ */ new Set(); -function publish(view) { - shared = view; - for (const listener of listeners) listener(view); +async function transcribeWave(speech, catalog, wave, signal) { + if (wave.byteLength > catalog.maxAudioBytes) throw captureError("too-long"); + const result = await speech.transcribe({ audioBase64: base64Of(wave), ...catalog.selection }, signal); + if (!result?.ok) throw Object.assign(new Error(result?.error?.message ?? "Transcription failed"), { kind: "service" }); + return String(result.value?.text ?? "").trim(); } -function publishFailure(error) { - publish({ available: false, tasks: [], readFailure: hostText(error?.message ?? String(error)) }); +function turnDetector({ loud = 0.12, quiet = 0.06, quietMs = 1200 } = {}) { + let spokeAt = 0; + let quietSince = 0; + return (level, now) => { + if (level >= loud) { + spokeAt ||= now; + quietSince = 0; + return "speech"; + } + if (!spokeAt) return "waiting"; + if (level < quiet) quietSince ||= now; + return quietSince && now - quietSince >= quietMs ? "done" : "speech"; + }; } -function useSchedules(actions) { - const [view, setView] = (0, import_react25.useState)(shared); - (0, import_react25.useEffect)(() => { - listeners.add(setView); - let stopped = false; - let timer; - const load = () => actions.schedules?.().then((next) => { - if (!stopped) publish(next); - }).catch((error) => { - if (!stopped) publishFailure(error); - }).finally(() => { - if (!stopped) timer = setTimeout(load, POLL_MS4); + +// src/client/model-menu.js +var import_react23 = require("react"); +var import_react_dom8 = require("react-dom"); +var MENU_EXIT_MS = 120; +function groupModels(models) { + const groups = /* @__PURE__ */ new Map(); + for (const model of models ?? []) { + const provider = model.provider ?? model.providerName ?? model.ref.split("/")[0]; + if (!groups.has(provider)) groups.set(provider, []); + groups.get(provider).push(model); + } + const names = /* @__PURE__ */ new Map(); + for (const [provider, list] of groups) { + const name = list[0].providerName ?? provider; + names.set(name, (names.get(name) ?? 0) + 1); + } + return [...groups].map(([provider, list]) => { + const name = list[0].providerName ?? provider; + return [names.get(name) > 1 ? `${name} (${provider})` : name, list]; + }); +} +var servedOf = (roster, ref) => (roster.models ?? []).find((model) => model.ref === ref); +var effortCache = /* @__PURE__ */ new Map(); +function useEfforts(served, ref, actions) { + const [fetched, setFetched] = (0, import_react23.useState)(void 0); + (0, import_react23.useEffect)(() => { + setFetched(void 0); + if (ref === void 0 || served?.reasoning !== void 0 || actions?.efforts === void 0) return void 0; + let live = true; + if (effortCache.has(ref)) setFetched(effortCache.get(ref)); + else void Promise.resolve(actions.efforts(ref)).then((value) => { + const reasoning = value?.efforts ? value : null; + effortCache.set(ref, reasoning); + if (live) setFetched(reasoning); + }).catch(() => { }); - load(); return () => { - stopped = true; - clearTimeout(timer); - listeners.delete(setView); + live = false; }; - }, []); - return view; -} -var schedulesReadFailure = (failure) => typeof failure === "string" && failure !== "" ? t("The scheduled tasks could not be read: {reason}", { reason: failure }) : null; -var localZone = () => Intl.DateTimeFormat().resolvedOptions().timeZone; -var clock2 = (time) => String(time ?? "").slice(0, 5); -var when2 = (ms, options) => new Intl.DateTimeFormat(dateLocale(), options).format(ms); -function dayAndTime(ms) { - const sameYear = new Date(ms).getFullYear() === (/* @__PURE__ */ new Date()).getFullYear(); - return when2(ms, { ...sameYear ? {} : { year: "numeric" }, month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }); -} -function instant(value) { - const at = Date.parse(value); - return Number.isFinite(at) ? dayAndTime(at) : ""; + }, [ref, served?.reasoning !== void 0, actions]); + return served?.reasoning ?? fetched; } -function weekdayNames(days) { - const names = days.map((day) => when2(Date.UTC(2024, 0, day), { weekday: "short", timeZone: "UTC" })); - return names.join(dateLocale().startsWith("zh") ? "、" : ", "); +function effortRows(reasoning) { + if (reasoning?.efforts === void 0 || reasoning.efforts.length === 0) return []; + return [ + { effort: void 0, label: t("Default") }, + ...reasoning.efforts.map((effort) => ({ effort: effort.id, label: effort.name })) + ]; } -function everyText(seconds) { - if (seconds % 86400 === 0) return seconds === 86400 ? t("Every day") : t("Every {count} days", { count: seconds / 86400 }); - if (seconds % 3600 === 0) return seconds === 3600 ? t("Every hour") : t("Every {count} hours", { count: seconds / 3600 }); - if (seconds % 60 === 0) return t("Every {count} minutes", { count: seconds / 60 }); - return t("Every {count} seconds", { count: seconds }); +function EffortRows({ reasoning, current, onPick }) { + return (0, import_react23.createElement)( + import_react23.Fragment, + null, + (0, import_react23.createElement)("div", { className: "bt-menu-sep", role: "separator" }), + (0, import_react23.createElement)("div", { className: "bt-model-group", role: "presentation" }, t("Thinking level")), + effortRows(reasoning).map((row) => (0, import_react23.createElement)( + "button", + { + key: row.effort ?? "default", + type: "button", + role: "menuitemradio", + "aria-checked": row.effort === current, + onClick: () => onPick(row.effort) + }, + (0, import_react23.createElement)("span", { className: "bt-menu-label" }, row.label), + (0, import_react23.createElement)("span", { className: "bt-model-tick", "aria-hidden": true }, row.effort === current ? (0, import_react23.createElement)(CheckIcon) : null) + )) + ); } -function timingText(task) { - const zone = task.timeZone && task.timeZone !== localZone() ? ` (${task.timeZone})` : ""; - switch (task.kind) { - case "after": - case "at": { - const time = instant(task.scheduledAt); - return time === "" ? "" : t("Once, {time}", { time }); - } - case "every": - return everyText(task.everySeconds); - case "daily": - return `${t("Every day at {time}", { time: clock2(task.time) })}${zone}`; - case "weekly": { - const days = (task.weekdays ?? []).join(); - const text2 = days === "1,2,3,4,5" ? t("Every weekday at {time}", { time: clock2(task.time) }) : days === "6,7" ? t("Every weekend at {time}", { time: clock2(task.time) }) : days === "1,2,3,4,5,6,7" ? t("Every day at {time}", { time: clock2(task.time) }) : t("Every {days} at {time}", { days: weekdayNames(task.weekdays ?? []), time: clock2(task.time) }); - return `${text2}${zone}`; - } - case "cron": - return `${t("On the schedule {expression}", { expression: task.expression })}${zone}`; - default: - return ""; - } +function ModelRows({ roster, current, onPick }) { + return groupModels(roster.models).map(([provider, list]) => (0, import_react23.createElement)( + import_react23.Fragment, + { key: list[0].provider ?? provider }, + (0, import_react23.createElement)("div", { className: "bt-model-group", role: "presentation" }, provider), + list.map((model) => (0, import_react23.createElement)( + "button", + { + key: model.ref, + type: "button", + role: "menuitemradio", + "aria-checked": model.ref === current, + title: model.ref, + onClick: () => onPick(model.ref) + }, + (0, import_react23.createElement)("span", { className: "bt-menu-label" }, model.name), + model.ref === roster.defaultModel ? (0, import_react23.createElement)("span", { className: "bt-menu-hint" }, t("Default")) : null, + (0, import_react23.createElement)("span", { className: "bt-model-tick", "aria-hidden": true }, model.ref === current ? (0, import_react23.createElement)(CheckIcon) : null) + )) + )); } -var nextText = (task) => { - if (task.status === "paused") return t("Paused"); - if (task.status !== "active") return t("Finished"); - const at = instant(task.scheduledAt); - return at === "" ? timingText(task) : t("Next: {time}", { time: at }); -}; -function lastRunText(run) { - if (run === void 0 || !Number.isFinite(run.at)) return ""; - const time = dayAndTime(run.at); - switch (run.result) { - case "running": - return t("Running since {time}", { time }); - case "stopped": - return t("Last run {time} · Stopped", { time }); - case "failed": - return t("Last run {time} · Failed: {reason}", { time, reason: hostText(run.reason || "Unknown error") }); - default: - return t("Last run {time}", { time }); - } +var Caret = () => (0, import_react23.createElement)("svg", { className: "bt-model-caret", width: 12, height: 12, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.8, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, (0, import_react23.createElement)("path", { d: "M5.5 8l4.5 4.5L14.5 8" })); +function useAnchoredMenu() { + const [place, setPlace] = (0, import_react23.useState)(null); + const [shownPlace, leaving] = useLinger(place, MENU_EXIT_MS); + const button = (0, import_react23.useRef)(null); + const menu = (0, import_react23.useRef)(null); + const close = () => setPlace(null); + (0, import_react23.useEffect)(() => { + if (place === null) return void 0; + const onDown = (event) => { + if (menu.current?.contains(event.target) || button.current?.contains(event.target)) return; + close(); + }; + const onKey = (event) => { + if (event.key === "Escape") { + event.stopPropagation(); + close(); + button.current?.focus(); + } + }; + const onScroll = (event) => { + if (!menu.current?.contains(event.target)) close(); + }; + window.addEventListener("mousedown", onDown); + window.addEventListener("keydown", onKey, true); + window.addEventListener("scroll", onScroll, true); + return () => { + window.removeEventListener("mousedown", onDown); + window.removeEventListener("keydown", onKey, true); + window.removeEventListener("scroll", onScroll, true); + }; + }, [place]); + const open = () => { + const rect = button.current.getBoundingClientRect(); + const width = Math.max(240, rect.width); + const left = Math.max(8, Math.min(rect.right - width, window.innerWidth - width - 8)); + const below = window.innerHeight - rect.bottom; + setPlace(below >= 260 || below >= rect.top ? { left, width, top: rect.bottom + 4, maxHeight: below - 16, "--bt-origin": "top right" } : { left, width, bottom: window.innerHeight - rect.top + 4, maxHeight: rect.top - 16, "--bt-origin": "bottom right" }); + }; + return { isOpen: place !== null, shownPlace, leaving, button, menu, open, close }; } -var lockedWindow = (roster) => typeof roster?.readOnly === "string" && roster.readOnly.trim() !== ""; -function TaskRow({ task, actions, onView, onEdit, onCopy, showBot, roster, locked }) { - const [confirming, setConfirming] = (0, import_react25.useState)(false); - const [busy, setBusy] = (0, import_react25.useState)(false); - const [rowError, setRowError] = (0, import_react25.useState)(""); - const refuse = (error) => setRowError(hostText(error?.message ?? String(error))); - (0, import_react25.useEffect)(() => { - if (!confirming) return void 0; - const timer = setTimeout(() => setConfirming(false), 3e3); - return () => clearTimeout(timer); - }, [confirming]); - const remove = () => { - if (!confirming) { - setConfirming(true); +function ModelMenu({ bot, roster, actions, run, wide = false, onPick, onPickEffort }) { + if (bot?.remote !== void 0) return null; + const { isOpen, shownPlace, leaving, button, menu, open, close } = useAnchoredMenu(); + const shown = modelOf(bot, roster); + const current = bot.model ?? bot.appliedModel; + const served = servedOf(roster, current); + const reasoning = useEfforts(served, current, actions); + const picked = reasoning?.efforts?.some((effort) => effort.id === bot.effort) ? bot.effort : void 0; + const currentEffort = picked ?? reasoning?.defaultEffort; + const effortLabel = currentEffort === void 0 ? reasoning?.efforts?.length > 0 ? t("Default") : void 0 : reasoning.efforts.find((effort) => effort.id === currentEffort)?.name ?? currentEffort; + const toggle = () => { + if (isOpen) { + close(); return; } - setBusy(true); - setRowError(""); - void actions.scheduleDelete(task.sessionId, task.id).then((next) => { - setBusy(false); - setRowError(""); - onView(next); - }, (error) => { - setBusy(false); - refuse(error); - }); + void actions?.refreshModels?.(); + open(); }; - const toggle = () => { - setBusy(true); - setRowError(""); - const step = task.status === "paused" ? actions.scheduleResume : actions.schedulePause; - void step(task.sessionId, task.id).then((next) => { - setBusy(false); - setRowError(""); - onView(next); - }, (error) => { - setBusy(false); - refuse(error); - }); + const pick2 = (ref) => { + close(); + if (onPick) onPick(ref); + else if (ref !== current || !bot.model) void run(actions.updateBot(bot.id, { model: ref })); }; - const retry = () => { - setBusy(true); - setRowError(""); - void actions.scheduleRetry(task.sessionId, task.id).then((next) => { - setBusy(false); - onView(next); - }, (error) => { - setBusy(false); - refuse(error); - }); + const pickEffort = (effort) => { + close(); + if (onPickEffort) onPickEffort(effort); + else if (effort !== bot.effort) void run(actions.updateBot(bot.id, { effort: effort ?? null })); }; - const room2 = task.roomId ? roster.roomsById[task.roomId] : void 0; - const stoppable = task.status === "active" || task.status === "paused"; - const failed = task.lastRun?.result === "failed"; - const last = lastRunText(task.lastRun); - return (0, import_react25.createElement)( - "div", - { className: "bt-set-row bt-task", "data-done": stoppable ? void 0 : "", "data-paused": task.status === "paused" || void 0, "data-busy": busy || void 0 }, - showBot ? (0, import_react25.createElement)(BotAvatar, { bot: roster.byId[task.botId], size: 28, badge: false, live: false }) : null, - (0, import_react25.createElement)( - "span", - { className: "bt-set-copy" }, - (0, import_react25.createElement)("span", { className: "bt-task-title" }, task.title), - (0, import_react25.createElement)("span", { className: "bt-set-hint" }, [timingText(task), room2 ? t("in {name}", { name: room2.name }) : ""].filter(Boolean).join(" · ")), - task.prompt && task.prompt !== task.title ? (0, import_react25.createElement)("span", { className: "bt-task-prompt" }, task.prompt) : null, - last ? (0, import_react25.createElement)("span", { className: "bt-task-last", "data-failed": failed || void 0 }, last) : null, - rowError ? (0, import_react25.createElement)("span", { className: "bt-task-last", "data-failed": "", role: "alert" }, rowError) : null + const models = roster.models ?? []; + return (0, import_react23.createElement)( + import_react23.Fragment, + null, + (0, import_react23.createElement)( + "button", + { + ref: button, + type: "button", + className: "bt-model-trigger", + "data-wide": wide || void 0, + "aria-haspopup": "menu", + "aria-expanded": isOpen, + "aria-label": t("Model: {model}", { model: shown?.name ?? t("none") }), + "data-missing": shown !== void 0 && !shown.served && models.length > 0 ? "" : void 0, + onClick: toggle + }, + (0, import_react23.createElement)( + "span", + { className: "bt-model-copy" }, + (0, import_react23.createElement)("span", { className: "bt-model-name" }, shown?.name ?? t("Choose a model")), + shown ? (0, import_react23.createElement)("span", { className: "bt-model-provider" }, shown.served || models.length === 0 ? effortLabel === void 0 ? shown.provider : `${shown.provider} · ${effortLabel}` : t("{provider} · not available", { provider: shown.provider })) : null + ), + (0, import_react23.createElement)(Caret) ), - (0, import_react25.createElement)("span", { className: "bt-task-next" }, nextText(task)), - // A locked window draws none of the writes the host would refuse (#2232). Hiding, not - // disabling: a disabled button still says this window can do it. - !locked && failed && task.status !== "paused" && actions.scheduleRetry ? (0, import_react25.createElement)("button", { type: "button", className: "bt-mini bt-task-pause", disabled: busy, title: t("Run the task once now"), onClick: retry }, t("Retry")) : null, - !locked && onEdit ? (0, import_react25.createElement)("button", { type: "button", className: "bt-mini bt-task-tool", disabled: busy, "aria-label": t("Edit"), title: t("Edit"), onClick: () => onEdit(task) }, (0, import_react25.createElement)(PencilIcon)) : null, - !locked && onCopy ? (0, import_react25.createElement)("button", { type: "button", className: "bt-mini bt-task-tool", disabled: busy, "aria-label": t("Copy to another Bot or group chat"), title: t("Copy to…"), onClick: () => onCopy(task) }, (0, import_react25.createElement)(DuplicateIcon)) : null, - !locked && stoppable ? (0, import_react25.createElement)("button", { type: "button", className: "bt-mini bt-task-pause", disabled: busy, onClick: toggle }, task.status === "paused" ? t("Resume") : t("Pause")) : null, - locked ? null : (0, import_react25.createElement)("button", { - type: "button", - className: "bt-mini bt-task-remove", - "data-confirm": confirming || void 0, - disabled: busy, - "aria-label": confirming ? t("Click again to delete") : t("Delete task"), - title: confirming ? void 0 : t("Delete task"), - onClick: remove - }, confirming ? t("Delete") : (0, import_react25.createElement)(TrashIcon)) + shownPlace ? (0, import_react_dom8.createPortal)((0, import_react23.createElement)( + "div", + { ref: menu, className: `bt-menu bt-model-menu${shownPlace.bottom !== void 0 ? " bt-menu-up" : ""}`, role: "menu", "aria-label": t("Model for {name}", { name: bot.name }), style: shownPlace, "data-leaving": leaving || void 0 }, + models.length > 0 ? (0, import_react23.createElement)(ModelRows, { roster, current, onPick: pick2 }) : (0, import_react23.createElement)("div", { className: "bt-model-none" }, t("No models are set up in DeepSeek Harness yet.")), + effortRows(reasoning).length > 0 ? (0, import_react23.createElement)(EffortRows, { reasoning, current: picked, onPick: pickEffort }) : null, + (0, import_react23.createElement)("div", { className: "bt-menu-sep", role: "separator" }), + (0, import_react23.createElement)("button", { type: "button", role: "menuitem", onClick: () => { + close(); + openHarnessModels(); + } }, (0, import_react23.createElement)(SlidersIcon), (0, import_react23.createElement)("span", { className: "bt-menu-label" }, t("Models and providers…"))) + ), document.body) : null ); } -var pad = (number) => String(number).padStart(2, "0"); -function inAnHour() { - const date = new Date(Date.now() + 36e5); - date.setMinutes(0, 0, 0); - return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:00`; -} -var KINDS = [["at", "Once"], ["daily", "Every day"], ["weekly", "Every week"], ["every", "Repeat"]]; -var UNITS = [["seconds", 1, "seconds"], ["minutes", 60, "minutes"], ["hours", 3600, "hours"], ["days", 86400, "days"]]; -function localInput(ms) { - const date = new Date(ms); - return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`; -} -function formStart(task) { - const seconds = task?.kind === "every" ? Number(task.everySeconds) : 3600; - const [unit, size] = seconds % 86400 === 0 ? ["days", 86400] : seconds % 3600 === 0 ? ["hours", 3600] : seconds % 60 === 0 ? ["minutes", 60] : ["seconds", 1]; - const once = task?.kind === "at" || task?.kind === "after"; - return { - title: task?.title ?? "", - prompt: task?.prompt ?? "", - kind: once ? "at" : task?.kind ?? "daily", - at: once && !Number.isNaN(Date.parse(task.scheduledAt)) ? localInput(Date.parse(task.scheduledAt)) : inAnHour(), - time: task?.time ? clock2(task.time) : "09:00", - weekdays: task?.kind === "weekly" && Array.isArray(task.weekdays) ? task.weekdays : [1, 2, 3, 4, 5], - count: Math.max(1, Math.round(seconds / size)), - unit, - timeZone: task?.timeZone || localZone() - }; -} -function TaskForm({ roster, actions, start, task, onDone, onCancel }) { - const first = formStart(task); - const [target, setTarget] = (0, import_react25.useState)(task ? task.botId ?? task.roomId : start ?? ""); - const [title, setTitle] = (0, import_react25.useState)(first.title); - const [prompt, setPrompt] = (0, import_react25.useState)(first.prompt); - const [kind, setKind] = (0, import_react25.useState)(first.kind); - const [at, setAt] = (0, import_react25.useState)(first.at); - const [time, setTime] = (0, import_react25.useState)(first.time); - const [weekdays, setWeekdays] = (0, import_react25.useState)(first.weekdays); - const [count, setCount] = (0, import_react25.useState)(first.count); - const [unit, setUnit] = (0, import_react25.useState)(first.unit); - const [busy, setBusy] = (0, import_react25.useState)(false); - const [error, setError] = (0, import_react25.useState)(""); - const timing = () => { - if (kind === "cron") return { kind: "keep" }; - if (kind === "at") return { kind, at: new Date(at).toISOString(), timeZone: first.timeZone }; - if (kind === "every") return { kind, seconds: Math.round(Number(count) * UNITS.find(([id]) => id === unit)[1]) }; - return { kind, time, timeZone: first.timeZone, ...kind === "weekly" ? { weekdays } : {} }; - }; - const submit = (event) => { - event.preventDefault(); - if (busy || target === "") return; - setBusy(true); +function ModelChoiceDock({ sessionId, useRoster, actions }) { + const roster = useRoster((value) => value); + const bot = roster.byId[sessionId]; + const [busy, setBusy] = (0, import_react23.useState)(""); + const [error, setError] = (0, import_react23.useState)(""); + const shown = Boolean(bot) && currentPart(bot) === sessionId && roster.ready; + (0, import_react23.useEffect)(() => { + if (shown) void actions.refreshModels?.(); + }, [shown, sessionId]); + if (!bot || currentPart(bot) !== sessionId || !roster.ready) return null; + if (bot.remote) return bot.remote.gone ? (0, import_react23.createElement)(RemoteGoneDock, { bot }) : null; + const models = roster.models ?? []; + const lost = bot.model ? !models.some((model) => model.ref === bot.model) : false; + if (bot.model && (!lost || models.length === 0)) return null; + const pick2 = (ref) => { + setBusy(ref); setError(""); - let when3; - try { - when3 = timing(); - } catch { - setError(t("Pick a date and time")); - setBusy(false); - return; - } - const saved = task ? actions.scheduleUpdate({ sessionId: task.sessionId, id: task.id, target, title, prompt, when: when3 }) : actions.scheduleCreate({ target, title, prompt, when: when3 }); - void saved.then(onDone, (failure) => { - setError(hostText(failure?.message ?? String(failure))); - setBusy(false); - }); + void Promise.resolve(actions.updateBot(bot.id, { model: ref })).catch((failure) => { + setError(failure?.message ?? String(failure)); + void actions.refreshModels?.(); + }).finally(() => setBusy("")); }; - const toggleDay = (day) => setWeekdays((days) => days.includes(day) ? days.filter((item) => item !== day) : [...days, day].sort()); - return (0, import_react25.createElement)( - "form", - { className: "bt-set-card bt-task-form", onSubmit: submit }, - (0, import_react25.createElement)( - "label", - { className: "bt-set-row bt-bs-row" }, - (0, import_react25.createElement)("span", { className: "bt-set-copy" }, (0, import_react25.createElement)("span", { className: "bt-bs-label" }, t("Send to"))), - (0, import_react25.createElement)( - "select", - { className: "bt-select bt-task-bot", value: target, onChange: (event) => setTarget(event.target.value) }, - (0, import_react25.createElement)("option", { value: "" }, t("Pick a Bot or group chat")), - (0, import_react25.createElement)("optgroup", { label: t("Bots") }, roster.bots.map((bot) => (0, import_react25.createElement)("option", { key: bot.id, value: bot.id }, bot.name))), - roster.rooms.length > 0 ? (0, import_react25.createElement)("optgroup", { label: t("Group chats") }, roster.rooms.map((room2) => (0, import_react25.createElement)("option", { key: room2.id, value: room2.id }, room2.name))) : null - ) - ), - (0, import_react25.createElement)( - "label", - { className: "bt-set-row bt-bs-row" }, - (0, import_react25.createElement)("span", { className: "bt-set-copy" }, (0, import_react25.createElement)("span", { className: "bt-bs-label" }, t("Name"))), - (0, import_react25.createElement)("input", { className: "bt-bs-input", value: title, maxLength: 120, placeholder: t("Morning briefing"), autoFocus: true, onChange: (event) => setTitle(event.target.value) }) - ), - (0, import_react25.createElement)( - "label", - { className: "bt-set-row bt-bs-row bt-bs-stack" }, - (0, import_react25.createElement)("span", { className: "bt-set-copy" }, (0, import_react25.createElement)("span", { className: "bt-bs-label" }, t("What to do"))), - (0, import_react25.createElement)("textarea", { className: "bt-bs-input bt-bs-area", value: prompt, rows: 3, placeholder: t("Sum up what changed since yesterday and what needs me today."), onChange: (event) => setPrompt(event.target.value) }) - ), - (0, import_react25.createElement)( + const text2 = lost ? t("{model} is not available anymore. Pick another model for me.", { model: bot.model.split("/").slice(1).join("/") }) : models.length === 0 ? t("Hi, I'm {name}. I don't have a model yet. How do you want to set one up?", { name: bot.name }) : t("Hi, I'm {name}. Pick a model for me first. You can change it later in my details.", { name: bot.name }); + const choices = models.length === 0 ? [...setupChoices(actions).map(([label, run]) => (0, import_react23.createElement)("button", { key: label, type: "button", className: "bt-model-chip", onClick: run }, label)), (0, import_react23.createElement)(KeyHelpLink, { key: "key-help" })] : [ + ...groupModels(models).map(([provider, list]) => (0, import_react23.createElement)( "div", - { className: "bt-set-row bt-bs-row bt-bs-stack" }, - (0, import_react25.createElement)("span", { className: "bt-set-copy" }, (0, import_react25.createElement)("span", { className: "bt-bs-label" }, t("When"))), - (0, import_react25.createElement)(Segmented, { label: t("When"), value: kind }, KINDS.map(([id, label]) => (0, import_react25.createElement)("button", { key: id, type: "button", "aria-pressed": kind === id, onClick: () => setKind(id) }, t(label)))), - (0, import_react25.createElement)( - "div", - { key: kind, className: "bt-task-when bt-veil" }, - kind === "cron" ? (0, import_react25.createElement)("span", { className: "bt-task-every" }, timingText(task)) : null, - kind === "at" ? (0, import_react25.createElement)("input", { className: "bt-input", type: "datetime-local", value: at, "aria-label": t("Date and time"), onChange: (event) => setAt(event.target.value) }) : null, - kind === "weekly" ? (0, import_react25.createElement)("div", { className: "bt-task-days", role: "group", "aria-label": t("Days") }, [1, 2, 3, 4, 5, 6, 7].map((day) => (0, import_react25.createElement)("button", { - key: day, - type: "button", - className: "bt-task-day", - "aria-pressed": weekdays.includes(day), - onClick: () => toggleDay(day) - }, when2(Date.UTC(2024, 0, day), { weekday: "narrow", timeZone: "UTC" })))) : null, - kind === "daily" || kind === "weekly" ? (0, import_react25.createElement)("input", { className: "bt-input bt-task-time", type: "time", value: time, "aria-label": t("Time"), onChange: (event) => setTime(event.target.value) }) : null, - kind === "every" ? (0, import_react25.createElement)( - import_react25.Fragment, - null, - (0, import_react25.createElement)("span", { className: "bt-task-every" }, t("Every")), - (0, import_react25.createElement)("input", { className: "bt-input bt-task-count", type: "number", min: 1, value: count, "aria-label": t("How many"), onChange: (event) => setCount(event.target.value) }), - (0, import_react25.createElement)( - "select", - { className: "bt-select bt-task-unit", value: unit, "aria-label": t("Unit"), onChange: (event) => setUnit(event.target.value) }, - UNITS.map(([id, , label]) => (0, import_react25.createElement)("option", { key: id, value: id }, t(label))) - ) - ) : null - ) - ), - error ? (0, import_react25.createElement)("div", { className: "bt-set-row" }, (0, import_react25.createElement)("span", { className: "bt-danger bt-set-hint", role: "alert" }, error)) : null, - (0, import_react25.createElement)( + { key: list[0].provider ?? provider, className: "bt-model-choice-group", role: "group", "aria-label": provider }, + (0, import_react23.createElement)("div", { className: "bt-model-choice-provider", "aria-hidden": true }, provider), + (0, import_react23.createElement)("div", { className: "bt-model-choice-chips" }, list.map((model) => (0, import_react23.createElement)("button", { + key: model.ref, + type: "button", + className: "bt-model-chip", + disabled: busy !== "", + "aria-busy": busy === model.ref || void 0, + "aria-label": `${model.name} · ${provider}`, + title: model.ref === roster.defaultModel ? `${model.ref} · ${t("Harness default")}` : model.ref, + onClick: () => pick2(model.ref) + }, model.name, busy === model.ref ? (0, import_react23.createElement)("span", { className: "bt-model-spin", "aria-label": t("Saving…") }) : null))) + )), + (0, import_react23.createElement)("button", { key: "manage", type: "button", className: "bt-model-chip bt-model-chip-quiet", onClick: openHarnessModels }, (0, import_react23.createElement)(SlidersIcon), t("Manage models")) + ]; + return (0, import_react23.createElement)( + "div", + { className: "bt-model-dock" }, + (0, import_react23.createElement)( "div", - { className: "bt-set-row bt-task-foot" }, - (0, import_react25.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: onCancel }, t("Cancel")), - (0, import_react25.createElement)( - "button", - { type: "submit", className: "bt-send bt-soft-sm", disabled: busy || !target || title.trim() === "" || prompt.trim() === "" }, - task ? busy ? t("Saving…") : t("Save changes") : busy ? t("Creating…") : t("Create task") + { className: "bt-astack", "data-lead": "" }, + (0, import_react23.createElement)( + "div", + { className: "bt-msg" }, + (0, import_react23.createElement)( + "div", + { className: "bt-msg-line" }, + (0, import_react23.createElement)(BotMark, { bot, size: 22 }), + (0, import_react23.createElement)("div", { className: "bt-bubble" }, text2) + ) ) - ) + ), + (0, import_react23.createElement)("div", { className: "bt-model-chips", role: "group", "aria-label": t("Choose a model") }, choices), + error ? (0, import_react23.createElement)("div", { className: "bt-secret-error bt-model-error", role: "alert" }, error) : null ); } -function openPlugins(actions) { - actions.closeSettings(); - actions.selectPanel("plugins"); -} -function AutomationOff({ actions }) { - return (0, import_react25.createElement)( +function RemoteGoneDock({ bot }) { + return (0, import_react23.createElement)( "div", - { className: "bt-set-card bt-task-off" }, - (0, import_react25.createElement)("span", { className: "bt-task-off-mark" }, (0, import_react25.createElement)(ClockIcon)), - (0, import_react25.createElement)("span", { className: "bt-task-off-title" }, t("Turn on Automation tasks in DSH")), - (0, import_react25.createElement)("span", { className: "bt-set-hint" }, t("Switch on Automation tasks on the Plugins page.")), - (0, import_react25.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => openPlugins(actions) }, t("Open Plugins")) - ); -} -function QuietHoursCard({ view, actions, locked }) { - const quiet = view.quietHours ?? { on: false }; - const [busy, setBusy] = (0, import_react25.useState)(false); - const [error, setError] = (0, import_react25.useState)(""); - const [from, setFrom] = (0, import_react25.useState)(quiet.from ?? "22:00"); - const [to, setTo] = (0, import_react25.useState)(quiet.to ?? "07:00"); - const unchecked = view.quietUnchecked === true; - const refresh = () => { - void Promise.resolve(actions.schedules?.()).then((next) => { - if (next) publish(next); - }).catch(() => { - }); - }; - const save = (next) => { - if (busy) return; - setBusy(true); - setError(""); - const shown = next.on ? { on: true, from: next.from ?? from, to: next.to ?? to } : { on: false }; - void Promise.resolve(actions.setPrefs({ quietHours: next })).then(() => { - publish({ ...view, quietHours: shown }); - refresh(); - }, (failure) => setError(hostText(failure?.message ?? String(failure)))).finally(() => setBusy(false)); - }; - return (0, import_react25.createElement)( - "section", - { className: "bt-set-section" }, - (0, import_react25.createElement)("h3", null, t("Quiet hours")), - (0, import_react25.createElement)( + { className: "bt-model-dock", "data-flybot": "remote-gone" }, + (0, import_react23.createElement)( "div", - { className: "bt-set-card" }, - (0, import_react25.createElement)( - "div", - { className: "bt-set-row bt-bs-row" }, - (0, import_react25.createElement)( - "span", - { className: "bt-set-copy" }, - (0, import_react25.createElement)("span", { className: "bt-bs-label" }, quiet.on ? t("On, {from}–{to}", { from: quiet.from, to: quiet.to }) : t("Off")), - (0, import_react25.createElement)("span", { className: "bt-set-hint" }, t("A new or edited task cannot be due inside the window. It never mutes a task that already exists.")) - ), - locked ? null : (0, import_react25.createElement)("button", { - type: "button", - className: "bt-soft bt-soft-sm", - disabled: busy, - "aria-pressed": quiet.on, - onClick: () => save(quiet.on ? { on: false } : { on: true, from, to }) - }, quiet.on ? t("Turn off") : t("Turn on")) - ), - quiet.on && !locked ? (0, import_react25.createElement)( + { className: "bt-astack", "data-lead": "" }, + (0, import_react23.createElement)( "div", - { className: "bt-set-row bt-bs-row" }, - (0, import_react25.createElement)("span", { className: "bt-set-copy" }, (0, import_react25.createElement)("span", { className: "bt-bs-label" }, t("Window"))), - (0, import_react25.createElement)( + { className: "bt-msg" }, + (0, import_react23.createElement)( "div", - { className: "bt-task-when" }, - (0, import_react25.createElement)("input", { className: "bt-input bt-task-time", type: "time", value: from, "aria-label": t("Quiet hours start"), disabled: busy, onChange: (event) => setFrom(event.target.value) }), - (0, import_react25.createElement)("span", { className: "bt-task-every" }, "–"), - (0, import_react25.createElement)("input", { className: "bt-input bt-task-time", type: "time", value: to, "aria-label": t("Quiet hours end"), disabled: busy, onChange: (event) => setTo(event.target.value) }), - (0, import_react25.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy, onClick: () => save({ on: true, from, to }) }, t("Save window")) + { className: "bt-msg-line" }, + (0, import_react23.createElement)(BotMark, { bot, size: 22 }), + (0, import_react23.createElement)("div", { className: "bt-bubble" }, t("{name} ({handle}) is no longer shared with you, so messages to it cannot be delivered. It runs on its owner's machine, so another model cannot stand in. Ask its owner for a new invite.", { name: bot.name, handle: bot.remote.handle })) ) - ) : null, - error ? (0, import_react25.createElement)("div", { className: "bt-set-row" }, (0, import_react25.createElement)("span", { className: "bt-danger bt-set-hint", role: "alert" }, error)) : null, - quiet.on && unchecked ? (0, import_react25.createElement)("div", { className: "bt-set-row" }, (0, import_react25.createElement)("span", { className: "bt-set-hint" }, t("A repeat or cron task can still run inside quiet hours, because DSH decides its next run. FlyBot says so here rather than claiming a silence it cannot keep."))) : null + ) ) ); } -function SchedulesPage({ roster, actions, start }) { - const view = useSchedules(actions); - const [adding, setAdding] = (0, import_react25.useState)(Boolean(start)); - const [editing, setEditing] = (0, import_react25.useState)(null); - const locked = lockedWindow(roster); - const refused = schedulesReadFailure(view?.readFailure); - if (refused !== null) return (0, import_react25.createElement)("div", { className: "bt-set-hint bt-danger", role: "status" }, refused); - if (view === null) return (0, import_react25.createElement)("div", { className: "bt-set-hint" }, t("Loading…")); - if (!view.available) return (0, import_react25.createElement)(AutomationOff, { actions }); - const shown = (next) => { - publish(next); - setAdding(false); - setEditing(null); - }; - const edit = (task) => { - setAdding(false); - setEditing(task); - }; - const byChat = [ - ...roster.bots.map((bot) => [bot.id, (0, import_react25.createElement)(BotAvatar, { bot, size: 18, badge: false, live: false }), bot.name, view.tasks.filter((task) => task.botId === bot.id)]), - ...roster.rooms.map((room2) => [room2.id, (0, import_react25.createElement)(RoomAvatar, { room: room2, roster, size: 18 }), room2.name, view.tasks.filter((task) => task.botId === void 0 && task.roomId === room2.id)]) - ].filter(([, , , tasks]) => tasks.length > 0); +function setupChoices(actions) { return [ - (0, import_react25.createElement)(QuietHoursCard, { key: "quiet", view, actions, locked }), - // A locked window draws no create control and opens no form (#2232): the header already says - // why, and a button the host refuses is the defect this page had. - locked ? null : adding ? (0, import_react25.createElement)(TaskForm, { key: "form", roster, actions, start, onDone: shown, onCancel: () => setAdding(false) }) : (0, import_react25.createElement)("div", { key: "add", className: "bt-actions" }, (0, import_react25.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm bt-with-icon", onClick: () => { - setEditing(null); - setAdding(true); - } }, (0, import_react25.createElement)(PlusIcon), t("New task"))), - byChat.length === 0 && !adding ? (0, import_react25.createElement)("div", { key: "none", className: "bt-set-card" }, (0, import_react25.createElement)("div", { className: "bt-set-row" }, (0, import_react25.createElement)("span", { className: "bt-set-hint" }, view.someHidden ? t("No scheduled tasks you can see. Another member's tasks stay hidden.") : t("No scheduled tasks yet.")))) : byChat.map(([id, avatar, name, tasks]) => (0, import_react25.createElement)( - "section", - { key: id, className: "bt-set-section" }, - (0, import_react25.createElement)("h3", { className: "bt-task-head" }, avatar, name), - (0, import_react25.createElement)(TaskList, { tasks, editing, roster, actions, locked, onEdit: edit, onDone: shown, onCancel: () => setEditing(null) }) - )) - ]; + actions.canSignIn?.() ? [t("Sign in to DeepSeek"), () => actions.signIn()] : null, + [t("Enter a DeepSeek API key"), () => openHarnessOnboarding("deepseek-official")], + [t("Open settings to set up a custom model"), openHarnessModels] + ].filter(Boolean); } -function TaskList({ tasks, editing, roster, actions, locked, onEdit, onDone, onCancel, extra = [] }) { - const open = !locked && editing ? tasks.find((task) => task.id === editing.id && task.sessionId === editing.sessionId) : void 0; - const [copying, setCopying] = (0, import_react25.useState)(null); - const source = copying ? tasks.find((task) => task.id === copying.id && task.sessionId === copying.sessionId) : void 0; - const rows = tasks.filter((task) => task !== open && task !== source).map((task) => (0, import_react25.createElement)(TaskRow, { key: task.id, task, actions, roster, locked, onView: publish, onEdit, onCopy: actions.scheduleCreate ? setCopying : void 0 })); - return (0, import_react25.createElement)( - import_react25.Fragment, - null, - open ? (0, import_react25.createElement)(TaskForm, { key: `edit-${open.id}`, roster, actions, task: open, onDone, onCancel }) : null, - source ? (0, import_react25.createElement)(CopyRow, { key: `copy-${source.id}`, task: source, roster, actions, onView: (next) => { - setCopying(null); - onDone(next); - }, onCancel: () => setCopying(null) }) : null, - rows.length + extra.length > 0 ? (0, import_react25.createElement)("div", { className: "bt-set-card" }, ...rows, ...extra) : null +var KEY_HELP_URL = "https://github.com/ThinkFlowLab/FlyBot/blob/main/docs/deepseek-api-key.md"; +function KeyHelpLink() { + return (0, import_react23.createElement)("a", { className: "bt-model-help", href: KEY_HELP_URL, target: "_blank", rel: "noopener noreferrer" }, t("How to set a DeepSeek API key")); +} +function missingModelKey(message) { + const text2 = String(message ?? ""); + if (!/\bno api key\b|\bmissing (?:an? )?api key\b|\bapi key (?:is )?(?:missing|not set|not configured)\b/i.test(text2)) return void 0; + return { route: /provider route "([^"]+)"/i.exec(text2)?.[1] ?? "" }; +} +function modelKeyChoices(actions, route) { + if (/^deepseek/i.test(route)) return setupChoices(actions); + return [[t("Manage models"), openHarnessModels]]; +} +function TurnErrorCell({ node, t: chatT, actions }) { + const data = node?.data ?? {}; + const code = typeof data.code === "string" && data.code ? data.code : void 0; + const raw = String(data.message ?? ""); + const shell = (key, fallback) => { + const text2 = typeof chatT === "function" ? chatT(key) : void 0; + return typeof text2 === "string" && text2 !== "" && text2 !== key ? text2 : fallback; + }; + const missing = missingModelKey(raw); + const title = code === "ACCOUNT_SIGNED_OUT" ? shell("message.accountStopped", t("This turn failed")) : shell("message.turnError", t("This turn failed")); + const message = missing ? t("This Bot's model has no API key yet. Set one up, then send your message again.") : code === "ACCOUNT_SIGNED_OUT" ? shell("message.failure.accountSignedOut", raw) : code === "ACCOUNT_SIGN_IN_REQUIRED" ? shell("message.failure.accountSignInRequired", raw) : code === "QUOTA" || code === "ACCOUNT_QUOTA" ? shell("message.failure.quota", raw) : code === "AUTH" ? shell("message.failure.auth", raw) : raw; + const choices = missing ? modelKeyChoices(actions ?? {}, missing.route) : []; + const deepseek = missing ? /^deepseek/i.test(missing.route) : false; + return (0, import_react23.createElement)( + "div", + { className: "bt-turn-error", role: "status", "data-missing-key": missing ? "" : void 0 }, + (0, import_react23.createElement)("span", { className: "bt-turn-error-dot", "aria-hidden": true }), + (0, import_react23.createElement)( + "div", + { className: "bt-turn-error-copy" }, + (0, import_react23.createElement)("span", { className: "bt-turn-error-title" }, title), + (0, import_react23.createElement)("span", { className: "bt-turn-error-message" }, message), + code ? (0, import_react23.createElement)("code", { className: "bt-turn-error-code" }, code) : null + ), + choices.length > 0 ? (0, import_react23.createElement)( + "div", + { className: "bt-turn-error-actions" }, + choices.map(([label, run]) => (0, import_react23.createElement)("button", { key: label, type: "button", className: "bt-model-chip", onClick: run }, label)), + deepseek ? (0, import_react23.createElement)(KeyHelpLink, { key: "key-help" }) : null + ) : null + ); +} +function DeepSeekSignIn({ useUi, actions }) { + const open = useUi((state) => state.signIn); + const entry = open ? actions.signInEntry?.() : null; + if (!entry) return null; + return (0, import_react23.createElement)(SignInHost, { key: open, entry, actions }); +} +function SignInHost({ entry, actions }) { + const [ops] = (0, import_react23.useState)(() => entry.inject?.() ?? {}); + const close = () => actions.closeSignIn(); + const useHook = (source) => (selector) => (0, import_react23.useSyncExternalStore)(source.subscribe, () => selector(source.getSnapshot())); + const [hooks] = (0, import_react23.useState)(() => ({ useAccount: useHook(ops.hooks.account), useTheme: useHook(ops.hooks.theme) })); + return (0, import_react23.createElement)(entry.component, { + ...ops, + ...hooks, + t: actions.accountT(), + complete: close, + useApiKey: () => { + close(); + openHarnessOnboarding("deepseek-official"); + } + }); +} +var MODEL_CSS = ` +.bt-turn-error{display:grid;grid-template-columns:10px minmax(0,1fr);column-gap:8px;row-gap:8px;padding:2px 0;font-size:13px;line-height:20px} +.bt-turn-error-dot{width:6px;height:6px;margin-top:7px;border-radius:50%;background:#e02135} +.bt-turn-error-copy{min-width:0;overflow-wrap:anywhere} +.bt-turn-error-title{margin-right:6px;font-weight:600;color:#e02135} +.bt-turn-error-message{color:var(--bt-ink-2)} +.bt-turn-error-code{margin-left:6px;font-size:11px;color:var(--bt-ink-3)} +.bt-turn-error-actions{grid-column:2;display:flex;flex-wrap:wrap;align-items:center;gap:6px} +.bt-model-help{display:inline-flex;align-items:center;min-height:28px;padding:0 4px;font-size:12px;line-height:16px;color:var(--bt-ink-2);text-decoration:underline;text-underline-offset:2px} +.bt-model-help:hover{color:var(--bt-ink)} +.bt-model-help:focus-visible{outline:2px solid var(--bt-accent);outline-offset:1px;border-radius:4px} +.bt-model-trigger{display:inline-flex;align-items:center;gap:8px;max-width:100%;min-width:0;height:auto;min-height:32px;padding:4px 8px 4px 10px;border:0;border-radius:8px;background:var(--bt-hover);color:var(--bt-ink);font:inherit;text-align:left;cursor:pointer;transition:background-color .12s ease} +.bt-model-trigger:hover,.bt-model-trigger[aria-expanded=true]{background:var(--bt-active)} +.bt-model-trigger:focus-visible{outline:2px solid var(--bt-accent);outline-offset:1px} +.bt-model-trigger[data-missing] .bt-model-provider{color:#c21d2e} +.bt-model-trigger[data-wide]{display:flex;width:100%;min-height:48px;padding:7px 12px;justify-content:space-between;border-radius:12px;background:var(--bt-card);box-shadow:inset 0 0 0 1px var(--bt-line)} +.bt-model-trigger[data-wide]:hover,.bt-model-trigger[data-wide][aria-expanded=true]{background:var(--bt-hover)} +.bt-model-copy{display:flex;flex-direction:column;min-width:0} +.bt-model-name{font-size:13px;line-height:18px;font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.bt-model-provider{font-size:11px;line-height:14px;color:var(--bt-ink-3);overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.bt-model-caret{flex:none;color:var(--bt-ink-3);transition:${spring(SPRING_SHAPE, "transform")}} +.bt-model-trigger[aria-expanded=true] .bt-model-caret{transform:rotate(180deg)} +.bt-model-menu{overflow-y:auto;scrollbar-width:thin} +.bt-model-group{flex:none;padding:8px 8px 2px;font-size:11px;line-height:14px;font-weight:500;color:var(--bt-ink-3);text-transform:none} +.bt-model-group:first-child{padding-top:2px} +.bt-model-tick{flex:none;width:16px;height:16px;display:inline-flex;color:var(--bt-ink-2)} +.bt-model-tick svg{animation:bt-check-pop ${SPRING_POP.duration}ms ${SPRING_POP.easing} 60ms backwards} +.bt-model-none{padding:8px;font-size:13px;line-height:18px;color:var(--bt-ink-2)} +.bt-model-dock{width:100%;max-width:var(--dsh-chat-content-width,748px);margin:0 auto;display:flex;flex-direction:column;align-items:flex-start;gap:8px;padding:0 0 10px;box-sizing:border-box;animation:bt-model-in ${SPRING_POP.duration}ms ${SPRING_POP.easing} backwards} +.bt-model-chips{display:flex;flex-wrap:wrap;gap:6px;margin-left:30px;max-width:min(560px,calc(100% - 30px));max-height:min(240px,36vh);overflow-y:auto;scrollbar-width:thin} +.bt-model-choice-group{display:flex;flex-direction:column;gap:6px;width:100%;min-width:0} +.bt-model-choice-provider{padding-top:4px;font-size:12px;line-height:16px;font-weight:500;color:var(--bt-ink-2);overflow-wrap:anywhere} +.bt-model-choice-chips{display:flex;flex-wrap:wrap;gap:6px} +.bt-model-chip{display:inline-flex;align-items:center;gap:6px;min-height:30px;padding:5px 12px;border:.5px solid var(--bt-line-2);border-radius:999px;background:var(--bt-surface,transparent);color:var(--bt-ink);font:inherit;font-size:13px;line-height:18px;cursor:pointer;transition:background-color .12s ease,border-color .12s ease} +.bt-model-chip:hover:not(:disabled){background:var(--bt-hover)} +.bt-model-chip:focus-visible{outline:2px solid var(--bt-accent);outline-offset:1px} +.bt-model-chip-quiet{color:var(--bt-ink-2)} +.bt-model-chip svg{width:14px;height:14px} +.bt-model-chip:disabled{cursor:default} +.bt-model-chip:disabled:not([aria-busy]){opacity:.5} +.bt-model-error{margin-left:30px} +@keyframes bt-model-in{from{opacity:0;transform:translateY(6px)}} +.bt-model-spin{flex:none;width:14px;height:14px;border-radius:50%;border:1.5px solid var(--bt-line-2);border-top-color:var(--bt-ink-2);animation:bt-model-spin .7s linear infinite} +@keyframes bt-model-spin{to{transform:rotate(360deg)}} +@media (prefers-reduced-motion:reduce){.bt-model-dock{animation:none}.bt-model-spin{animation-duration:2s}.bt-model-caret{transition:none}.bt-model-tick svg{animation:none}} +`; + +// src/client/model-test.js +var import_react24 = require("react"); +var FAILURE_COPY = { + NO_MODEL: "Pick a model first.", + NO_KEY: "This model has no API key yet. Set one up in the model settings.", + AUTH: "The API key was rejected. Check it in the model settings.", + QUOTA: "The account is out of quota.", + RATE_LIMIT: "Too many requests right now. Try again in a moment.", + TIMEOUT: "The model did not answer within 30 seconds.", + NOT_SERVED: "This model is not available now. Check its provider in the model settings.", + MODEL_NOT_FOUND: "The provider does not know this model. Check the model name.", + TRANSPORT: "Could not reach the model service. Check the endpoint address and your network.", + SERVER: "The model service failed. Try again later.", + INVALID_REQUEST: "The model service refused the request. Check the endpoint and the model.", + UNKNOWN: "The test failed." +}; +function testOutcome(result, modelName) { + if (result?.ok === true) { + const seconds = typeof result.ms === "number" ? Math.max(0.1, Math.round(result.ms / 100) / 10) : void 0; + return { ok: true, text: seconds === void 0 ? t("Works. {model} answered.", { model: modelName }) : t("Works. {model} answered in {seconds} s.", { model: modelName, seconds }) }; + } + const code = Object.hasOwn(FAILURE_COPY, result?.code) ? result.code : "UNKNOWN"; + return { ok: false, code, text: t(FAILURE_COPY[code]) }; +} +var testModel = (id) => call("test-model", { id }); +function ModelTestRow({ bot, modelName, test = testModel }) { + const [state, setState] = (0, import_react24.useState)({ busy: false, outcome: void 0 }); + const live = (0, import_react24.useRef)(true); + (0, import_react24.useEffect)(() => () => { + live.current = false; + }, []); + (0, import_react24.useEffect)(() => { + setState({ busy: false, outcome: void 0 }); + }, [bot.id, bot.model]); + const start = () => { + if (state.busy) return; + setState({ busy: true, outcome: void 0 }); + Promise.resolve().then(() => test(bot.id)).then( + (result) => testOutcome(result, modelName ?? result?.ref ?? ""), + (error) => ({ ok: false, text: String(error?.message ?? t("The test failed.")) }) + ).then((outcome2) => { + if (live.current) setState({ busy: false, outcome: outcome2 }); + }); + }; + const { busy, outcome } = state; + return (0, import_react24.createElement)( + "div", + { className: "bt-set-row bt-bs-row bt-model-test" }, + (0, import_react24.createElement)( + "span", + { className: "bt-set-copy" }, + (0, import_react24.createElement)("span", { className: "bt-set-hint" }, t("Sends one short request to this model. It may be billed.")), + (0, import_react24.createElement)( + "span", + { className: "bt-model-test-result", role: "status", "aria-live": "polite", "data-ok": outcome === void 0 ? void 0 : String(outcome.ok) }, + busy ? t("Testing…") : outcome === void 0 ? null : outcome.text, + outcome?.code ? (0, import_react24.createElement)("code", { className: "bt-model-test-code" }, outcome.code) : null + ) + ), + (0, import_react24.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy, "aria-busy": busy || void 0, onClick: start }, t("Test connection")) ); } -function CopyRow({ task, roster, actions, onView, onCancel }) { - const others = [ - ...roster.bots.filter((bot) => bot.id !== task.botId).map((bot) => [bot.id, bot.name]), - ...roster.rooms.filter((room2) => room2.id !== task.roomId).map((room2) => [room2.id, room2.name]) - ]; - const [target, setTarget] = (0, import_react25.useState)(""); - const [busy, setBusy] = (0, import_react25.useState)(false); - const [error, setError] = (0, import_react25.useState)(""); - const submit = (event) => { - event.preventDefault(); - if (busy || target === "") return; - setBusy(true); - setError(""); - void actions.scheduleCreate({ target, copyFrom: { sessionId: task.sessionId, id: task.id } }).then(onView, (failure) => { - setBusy(false); - setError(hostText(failure?.message ?? String(failure))); - }); +var MODEL_TEST_CSS = ` +.bt-model-test-result{font-size:13px;line-height:18px;color:var(--bt-ink-2)} +.bt-model-test-result:empty{display:none} +.bt-model-test-result[data-ok=true]{color:#1a7f37} +.bt-model-test-result[data-ok=false]{color:#c21d2e} +.bt-model-test-code{margin-left:6px;font-size:11px;color:var(--bt-ink-3)} +/* #1347: the hint beside "Test connection" is prose and wraps; the button next to it keeps its own + words ("Test connection" on one line) and takes the room it needs. */ +.bt-model-test>.bt-set-copy{flex:1 1 12ch;min-width:12ch} +.bt-model-test>.bt-soft{flex:none;white-space:nowrap} +@container bt-panel (max-width:300px){ + .bt-model-test{flex-wrap:wrap;row-gap:6px} + .bt-model-test>.bt-set-copy{flex:1 1 100%} +} +`; + +// src/client/folder-menu.js +var import_react25 = require("react"); +var import_react_dom9 = require("react-dom"); +var folderName = (path) => String(path).replace(/[\\/]+$/, "").split(/[\\/]/).pop() || String(path); +var Tick = ({ on }) => (0, import_react25.createElement)("span", { className: "bt-model-tick", "aria-hidden": true }, on ? (0, import_react25.createElement)(CheckIcon) : null); +function FolderMenu({ value, group = false, actions, run, onPick, wide = false }) { + const { isOpen, shownPlace, leaving, button, menu, open, close } = useAnchoredMenu(); + const [info, setInfo] = (0, import_react25.useState)(null); + const [view, setView] = (0, import_react25.useState)("list"); + const [draft, setDraft] = (0, import_react25.useState)(""); + const [listing, setListing] = (0, import_react25.useState)(null); + const [problem, setProblem] = (0, import_react25.useState)(""); + const fallback = group ? t("Each Bot's own folder") : t("Team workspace"); + const toggle = () => { + if (isOpen) { + close(); + return; + } + setView("list"); + setProblem(""); + void Promise.resolve(actions.folders()).then(setInfo).catch(() => setInfo((last) => last ?? { team: null, picker: null, folders: [] })); + open(); }; - return (0, import_react25.createElement)( - "form", - { className: "bt-set-row bt-task-copy", onSubmit: submit }, - (0, import_react25.createElement)("span", { className: "bt-set-copy" }, (0, import_react25.createElement)("span", { className: "bt-bs-label" }, t("Copy “{name}” to", { name: task.title }))), + const choose = (path) => { + close(); + if (path !== (value ?? "")) void onPick(path); + }; + const browse = (path) => { + setProblem(""); + void Promise.resolve(actions.folderList(path)).then((next) => { + setListing(next); + setView("browse"); + }).catch((failure) => setProblem(failure?.message ?? String(failure))); + }; + const pickNative = () => { + close(); + void run(Promise.resolve(actions.folderPick()).then((path) => path ? onPick(path) : void 0)); + }; + const typed = () => { + const path = draft.trim(); + if (path !== "") choose(path); + }; + const known = info?.folders ?? []; + const folders = value && !known.some((folder) => folder.path === value) ? [{ path: value, title: folderName(value) }, ...known] : known; + const listView = (0, import_react25.createElement)( + "div", + { key: "list", className: "bt-folder-view" }, (0, import_react25.createElement)( - "select", - { className: "bt-select bt-task-bot", value: target, "aria-label": t("Copy to"), onChange: (event) => setTarget(event.target.value) }, - (0, import_react25.createElement)("option", { value: "" }, t("Pick a Bot or group chat")), - others.map(([id, name]) => (0, import_react25.createElement)("option", { key: id, value: id }, name)) + "button", + { type: "button", role: "menuitemradio", "aria-checked": !value, title: group ? void 0 : info?.team ?? void 0, onClick: () => choose("") }, + (0, import_react25.createElement)("span", { className: "bt-menu-label" }, fallback), + (0, import_react25.createElement)(Tick, { on: !value }) ), - (0, import_react25.createElement)("button", { type: "submit", className: "bt-soft bt-soft-sm", disabled: busy || target === "" }, busy ? t("Copying…") : t("Copy")), - (0, import_react25.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: onCancel }, t("Cancel")), - error ? (0, import_react25.createElement)("span", { className: "bt-danger bt-set-hint", role: "alert" }, error) : null + folders.length > 0 ? (0, import_react25.createElement)("div", { className: "bt-model-group", role: "presentation" }, t("Folders")) : null, + folders.map((folder) => (0, import_react25.createElement)( + "button", + { key: folder.path, type: "button", role: "menuitemradio", "aria-checked": folder.path === value, title: folder.path, onClick: () => choose(folder.path) }, + (0, import_react25.createElement)("span", { className: "bt-folder-copy" }, (0, import_react25.createElement)("span", { className: "bt-menu-label" }, folder.title), (0, import_react25.createElement)("span", { className: "bt-folder-path" }, folder.path)), + (0, import_react25.createElement)(Tick, { on: folder.path === value }) + )), + (0, import_react25.createElement)("div", { className: "bt-menu-sep", role: "separator" }), + info?.picker === "native" ? (0, import_react25.createElement)("button", { type: "button", role: "menuitem", onClick: pickNative }, (0, import_react25.createElement)(FolderIcon), (0, import_react25.createElement)("span", { className: "bt-menu-label" }, t("Choose a folder…"))) : null, + info?.picker === "browse" ? (0, import_react25.createElement)("button", { type: "button", role: "menuitem", onClick: () => browse(value) }, (0, import_react25.createElement)(FolderIcon), (0, import_react25.createElement)("span", { className: "bt-menu-label" }, t("Browse folders…"))) : null, + (0, import_react25.createElement)("button", { type: "button", role: "menuitem", onClick: () => { + setDraft(value ?? ""); + setView("type"); + } }, (0, import_react25.createElement)(PencilIcon), (0, import_react25.createElement)("span", { className: "bt-menu-label" }, t("Enter a path…"))) ); -} -function BotSchedules({ bot, roster, actions }) { - const view = useSchedules(actions); - const [editing, setEditing] = (0, import_react25.useState)(null); - const locked = lockedWindow(roster); - const refused = schedulesReadFailure(view?.readFailure); - if (refused !== null) { - return (0, import_react25.createElement)( - "section", - { className: "bt-set-section" }, - (0, import_react25.createElement)("h3", null, t("Scheduled tasks")), - (0, import_react25.createElement)("div", { className: "bt-set-card" }, (0, import_react25.createElement)( - "div", - { className: "bt-set-row bt-bs-row" }, - (0, import_react25.createElement)("span", { className: "bt-set-copy" }, (0, import_react25.createElement)("span", { className: "bt-set-hint bt-danger", role: "status" }, refused)) - )) - ); - } - if (view === null) return null; - const tasks = view.available ? view.tasks.filter((task) => task.botId === bot.id) : []; - return (0, import_react25.createElement)( - "section", - { className: "bt-set-section" }, - (0, import_react25.createElement)("h3", null, t("Scheduled tasks")), - !view.available ? (0, import_react25.createElement)( - "div", - { className: "bt-set-card" }, - (0, import_react25.createElement)( - "div", - { className: "bt-set-row bt-bs-row" }, - (0, import_react25.createElement)("span", { className: "bt-set-copy" }, (0, import_react25.createElement)("span", { className: "bt-set-hint" }, t("Switch on Automation tasks on the Plugins page."))), - (0, import_react25.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => openPlugins(actions) }, t("Open Plugins")) - ) - ) : (0, import_react25.createElement)(TaskList, { - tasks, - editing, - roster, - actions, - locked, - onEdit: locked ? void 0 : setEditing, - onDone: (next) => { - publish(next); - setEditing(null); - }, - onCancel: () => setEditing(null), - extra: [(0, import_react25.createElement)("button", { key: "new", type: "button", className: "bt-set-row bt-bs-row bt-gs-add", onClick: () => actions.openSettings("schedules", bot.id) }, (0, import_react25.createElement)(PlusIcon), t("New task"))] - }) + const typeView = (0, import_react25.createElement)( + "div", + { key: "type", className: "bt-folder-view" }, + (0, import_react25.createElement)("button", { type: "button", className: "bt-folder-back", onClick: () => setView("list") }, (0, import_react25.createElement)(ChevronLeftIcon), (0, import_react25.createElement)("span", { className: "bt-menu-label" }, t("Back"))), + (0, import_react25.createElement)("input", { + className: "bt-folder-input", + autoFocus: true, + value: draft, + spellCheck: false, + "aria-label": t("Folder path"), + placeholder: t("Full path of a folder"), + onChange: (event) => setDraft(event.target.value), + onKeyDown: (event) => { + if (event.key === "Enter" && !event.nativeEvent.isComposing) typed(); + } + }), + (0, import_react25.createElement)("button", { type: "button", className: "bt-folder-use", disabled: draft.trim() === "", onClick: typed }, t("Use this folder")) ); -} -function SummaryLines({ tasks, actions }) { - const shown = tasks.slice(0, 3); - const hidden = tasks.length - shown.length; + const parent = listing?.crumbs?.length > 1 ? listing.crumbs.at(-2) : void 0; + const inside = listing?.entries?.filter((entry) => !entry.hidden) ?? []; + const browseView = listing ? (0, import_react25.createElement)( + "div", + { key: `browse:${listing.path}`, className: "bt-folder-view" }, + (0, import_react25.createElement)("button", { type: "button", className: "bt-folder-back", onClick: () => setView("list") }, (0, import_react25.createElement)(ChevronLeftIcon), (0, import_react25.createElement)("span", { className: "bt-menu-label" }, t("Back"))), + (0, import_react25.createElement)("div", { className: "bt-folder-path bt-folder-here" }, listing.path), + parent ? (0, import_react25.createElement)("button", { type: "button", role: "menuitem", title: parent.path, onClick: () => browse(parent.path) }, (0, import_react25.createElement)(FolderIcon), (0, import_react25.createElement)("span", { className: "bt-menu-label" }, t("Up one level"))) : null, + inside.map((entry) => (0, import_react25.createElement)( + "button", + { key: entry.path, type: "button", role: "menuitem", onClick: () => browse(entry.path) }, + (0, import_react25.createElement)(FolderIcon), + (0, import_react25.createElement)("span", { className: "bt-menu-label" }, entry.name), + (0, import_react25.createElement)(ChevronRightIcon) + )), + inside.length === 0 ? (0, import_react25.createElement)("div", { className: "bt-model-none" }, t("No folders inside.")) : null, + listing.truncated ? (0, import_react25.createElement)("div", { className: "bt-folder-path bt-folder-here" }, t("Some folders are not shown.")) : null, + (0, import_react25.createElement)("div", { className: "bt-menu-sep", role: "separator" }), + (0, import_react25.createElement)("button", { type: "button", className: "bt-folder-use", onClick: () => choose(listing.path) }, t("Use this folder")) + ) : null; + const shownView = view === "type" ? typeView : view === "browse" && browseView ? browseView : listView; return (0, import_react25.createElement)( import_react25.Fragment, null, - (0, import_react25.createElement)("div", { className: "bt-section-title" }, hidden > 0 ? t("Scheduled tasks · {count}", { count: tasks.length }) : t("Scheduled tasks")), (0, import_react25.createElement)( + "button", + { + ref: button, + type: "button", + className: "bt-model-trigger", + "data-wide": wide || void 0, + "aria-haspopup": "menu", + "aria-expanded": isOpen, + "aria-label": t("Working folder: {folder}", { folder: value ?? fallback }), + title: value, + onClick: toggle + }, + (0, import_react25.createElement)( + "span", + { className: "bt-model-copy" }, + (0, import_react25.createElement)("span", { className: "bt-model-name" }, value ? folderName(value) : fallback), + value ? (0, import_react25.createElement)("span", { className: "bt-model-provider" }, value) : null + ), + (0, import_react25.createElement)(Caret) + ), + shownPlace ? (0, import_react_dom9.createPortal)((0, import_react25.createElement)( "div", - { className: "bt-task-list" }, - shown.map((task) => (0, import_react25.createElement)( - "button", - { key: task.id, type: "button", className: "bt-task-line", onClick: () => actions.openSettings("schedules") }, - (0, import_react25.createElement)(ClockIcon), - (0, import_react25.createElement)("span", { className: "bt-task-line-title" }, task.title), - (0, import_react25.createElement)("span", { className: "bt-task-line-when" }, nextText(task)) - )), - hidden > 0 ? (0, import_react25.createElement)("button", { key: "more", type: "button", className: "bt-more", onClick: () => actions.openSettings("schedules") }, t("{count} more not shown", { count: hidden })) : null - ) - ); -} -function ScheduleSummary({ bot, actions }) { - const view = useSchedules(actions); - if (view === null || view.readFailure !== void 0) return null; - const tasks = view.available ? view.tasks.filter((task) => task.botId === bot.id) : []; - return (0, import_react25.createElement)( - "div", - null, - tasks.length === 0 ? (0, import_react25.createElement)("div", { className: "bt-section-title" }, t("Scheduled tasks")) : (0, import_react25.createElement)(SummaryLines, { tasks, actions }), - tasks.length === 0 ? (0, import_react25.createElement)("button", { type: "button", className: "bt-more", onClick: () => actions.openSettings("schedules", bot.id) }, view.available === false ? t("Switch on Automation tasks on the Plugins page.") : view.someHidden ? t("No scheduled tasks you can see. Add one") : t("No scheduled tasks yet. Add one")) : null + { ref: menu, className: `bt-menu bt-model-menu bt-folder-menu${shownPlace.bottom !== void 0 ? " bt-menu-up" : ""}`, role: "menu", "aria-label": t("Working folder"), style: shownPlace, "data-leaving": leaving || void 0 }, + shownView, + problem ? (0, import_react25.createElement)("div", { className: "bt-folder-error", role: "alert" }, problem) : null + ), document.body) : null ); } -function RoomSchedules({ room: room2, actions }) { - const view = useSchedules(actions); - if (view === null || view.readFailure !== void 0 || room2.origin === "face-to-face") return null; - const tasks = view.available ? view.tasks.filter((task) => task.botId === void 0 && task.roomId === room2.id) : []; - if (tasks.length === 0) return null; - return (0, import_react25.createElement)("div", null, (0, import_react25.createElement)(SummaryLines, { tasks, actions })); -} -var SCHEDULE_CSS = ` -.bt-task{gap:10px} -.bt-task-copy{flex-wrap:wrap;gap:8px} -.bt-task-copy .bt-task-bot{flex:1;min-width:140px;max-width:100%} -.bt-task[data-done]{opacity:.6} -.bt-task[data-busy]{opacity:.4} -.bt-task-title{font-size:13px;line-height:18px;font-weight:500;overflow-wrap:anywhere} -.bt-task-prompt{font-size:12px;line-height:16px;color:var(--bt-ink-2);display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden} -.bt-task-next{flex:none;font-size:12px;line-height:16px;color:var(--bt-ink-3);font-variant-numeric:tabular-nums;white-space:nowrap} -.bt-task-remove{display:inline-flex;align-items:center;justify-content:center;min-width:26px;height:26px;color:var(--bt-ink-3)} -.bt-task-remove svg{width:14px;height:14px} -.bt-task-tool{display:inline-flex;align-items:center;justify-content:center;min-width:26px;height:26px;color:var(--bt-ink-3)} -.bt-task-tool svg{width:14px;height:14px} -.bt-task-pause{height:26px;color:var(--bt-ink-2)} -.bt-task-last{font-size:12px;line-height:16px;color:var(--bt-ink-3);overflow-wrap:anywhere} -.bt-task-last[data-failed]{color:#c21d2e} -.bt-task[data-paused] .bt-task-title,.bt-task[data-paused] .bt-task-prompt{color:var(--bt-ink-3)} -.bt-task-remove[data-confirm]{background:rgba(224,33,53,.1);color:#e02135} -.bt-task-head{display:flex;align-items:center;gap:6px} -.bt-task-form{animation:bt-q-in .4s cubic-bezier(.22,1,.36,1),bt-veil-in .2s ease-out} -.bt-task-bot{width:auto;min-width:160px;max-width:58%} -.bt-task-when{display:flex;align-items:center;flex-wrap:wrap;gap:8px} -.bt-task-when .bt-input{width:auto;height:32px} -.bt-task-time{min-width:110px} -.bt-task-count{width:72px!important} -.bt-task-unit{width:auto;height:32px} -.bt-task-every{font-size:13px;color:var(--bt-ink-2)} -.bt-task-days{display:flex;gap:4px} -.bt-task-day{width:30px;height:30px;border-radius:50%;border:1px solid var(--bt-line);background:none;color:var(--bt-ink-2);font:inherit;font-size:12px;cursor:pointer;transition:background-color .12s ease,color .12s ease,border-color .12s ease} -.bt-task-day[aria-pressed=true]{background:var(--bt-user);border-color:var(--bt-user);color:var(--bt-user-ink)} -.bt-task-foot{justify-content:flex-end;gap:8px} -.bt-task-foot .bt-send{height:28px;font-size:12px} -.bt-task-off{align-items:center;gap:8px;padding:28px 20px;text-align:center} -.bt-task-off>*+*{border-top:0} -.bt-task-off-mark{width:44px;height:44px;border-radius:50%;background:var(--bt-hover);display:flex;align-items:center;justify-content:center;color:var(--bt-ink-2)} -.bt-task-off-mark svg{width:20px;height:20px} -.bt-task-off-title{font-size:14px;line-height:20px;font-weight:600} -.bt-task-off .bt-set-hint{max-width:380px} -.bt-task-list{display:flex;flex-direction:column;gap:2px} -.bt-task-line{display:flex;align-items:center;gap:8px;width:100%;min-width:0;padding:6px 8px;border:0;border-radius:8px;background:none;color:var(--bt-ink);font:inherit;font-size:13px;text-align:left;cursor:pointer;transition:background-color .12s ease} -.bt-task-line:hover{background:var(--bt-hover)} -.bt-task-line svg{flex:none;color:var(--bt-ink-3)} -.bt-task-line-title{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} -.bt-task-line-when{flex:none;font-size:12px;color:var(--bt-ink-3)} -@media (prefers-reduced-motion:reduce){.bt-task-form{animation:none}} +var FOLDER_CSS = ` +.bt-folder-view{display:flex;flex-direction:column;gap:2px;animation:bt-veil-in .14s ease-out} +.bt-folder-copy{flex:1;display:flex;flex-direction:column;min-width:0} +.bt-folder-copy .bt-menu-label{flex:none} +.bt-folder-path{min-width:0;font-size:11px;line-height:14px;color:var(--bt-ink-3);overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.bt-folder-here{padding:0 8px 4px;white-space:normal;overflow-wrap:anywhere} +.bt-menu .bt-folder-back{color:var(--bt-ink-2);font-weight:500} +.bt-folder-input{height:30px;margin:2px 4px;padding:0 8px;border:1px solid var(--bt-line-2);border-radius:8px;background:var(--bt-main);color:var(--bt-ink);font:inherit;font-size:13px;outline:none;box-sizing:border-box} +.bt-folder-input:focus{border-color:var(--bt-accent)} +.bt-menu .bt-folder-use{justify-content:center;font-weight:500;color:var(--bt-accent)} +.bt-menu .bt-folder-use:disabled{color:var(--bt-ink-3);cursor:default;background:none} +.bt-folder-error{padding:4px 8px;font-size:12px;line-height:16px;color:#c21d2e;overflow-wrap:anywhere} +@media (prefers-reduced-motion:reduce){.bt-folder-view{animation:none}} `; // src/client/capability-settings.js @@ -20042,7 +20043,7 @@ function DetailsDrawer({ sessionId, startRenaming, startTab, hasStrip, roster, a (0, import_react44.createElement)("div", { className: "bt-section-title" }, t("Usage")), (0, import_react44.createElement)("div", { className: "bt-kv" }, (0, import_react44.createElement)("span", null, bot.name), (0, import_react44.createElement)("span", null, usage)) ) : null, - (0, import_react44.createElement)(ScheduleSummary, { key: "routines", bot, actions }) + (0, import_react44.createElement)(ScheduleSummary, { key: "routines", bot, roster, actions }) ] : null, bot && !bot.remote && tab === "browser" ? (0, import_react44.createElement)(BrowserCards, { bot, sessionId, roster, actions, useBrowserTabs, useBrowserPages }) : null ) diff --git a/core/src/client/composer.js b/core/src/client/composer.js index a78739699..2275dc623 100644 --- a/core/src/client/composer.js +++ b/core/src/client/composer.js @@ -4,6 +4,7 @@ import { ChatsIcon, ClockIcon, MicIcon, PaperclipIcon, PeopleIcon, PlusIcon, Wav import { BotMark, markBotForMention } from './mark.js' import { useLinger } from './motion.js' import { t } from './i18n.js' +import { lockedWindow } from './schedules.js' // ------------------------------------------------------------------------- // Composer: dictation mic and the voice-chat button. The shell's send @@ -136,6 +137,9 @@ export function ComposerPlus({ sessionId, inputActions, useRoster, actions }) { }, [open]) const target = roster.byId?.[sessionId]?.id ?? roster.roomsById?.[sessionId]?.id if (sessionId === undefined || target === undefined) return null + // A locked window draws no create entry: "Scheduled task" opened the settings list with this + // chat's id, which asks for the create form, and a locked page draws no form (#2232, P-457). + const locked = lockedWindow(roster) const toggle = () => { if (open) { setOpen(null); return } const rect = button.current.getBoundingClientRect() @@ -145,10 +149,6 @@ export function ComposerPlus({ sessionId, inputActions, useRoster, actions }) { setOpen(null) button.current?.closest('[data-composer-card]')?.querySelector('input[type=file]')?.click() } - const schedule = () => { - setOpen(null) - actions.openSettings('schedules', target) - } // The chats open in the shell's "@" menu at the caret, so the pick lands where the // user was typing. const reference = () => { @@ -167,5 +167,5 @@ export function ComposerPlus({ sessionId, inputActions, useRoster, actions }) { }, h('button', { type: 'button', role: 'menuitem', onClick: attach }, h(PaperclipIcon), h('span', { className: 'bt-menu-label' }, t('Add files'))), canReference ? h('button', { type: 'button', role: 'menuitem', onMouseDown: keepComposerFocus, onClick: reference }, h(ChatsIcon), h('span', { className: 'bt-menu-label' }, t('Reference a chat'))) : null, - h('button', { type: 'button', role: 'menuitem', onClick: schedule }, h(ClockIcon), h('span', { className: 'bt-menu-label' }, t('Scheduled task')))), document.body) : null) + locked ? null : h('button', { type: 'button', role: 'menuitem', onClick: () => { setOpen(null); actions.openSettings('schedules', target) } }, h(ClockIcon), h('span', { className: 'bt-menu-label' }, t('Scheduled task')))), document.body) : null) } diff --git a/core/src/client/details-drawer.js b/core/src/client/details-drawer.js index cc513c708..3225304af 100644 --- a/core/src/client/details-drawer.js +++ b/core/src/client/details-drawer.js @@ -347,7 +347,7 @@ export function DetailsDrawer({ sessionId, startRenaming, startTab, hasStrip, ro roster.memory === false ? null : h(MemoryRow, { key: 'memory', bot, actions }), usage ? h('div', { key: 'usage' }, h('div', { className: 'bt-section-title' }, t('Usage')), h('div', { className: 'bt-kv' }, h('span', null, bot.name), h('span', null, usage))) : null, - h(ScheduleSummary, { key: 'routines', bot, actions }), + h(ScheduleSummary, { key: 'routines', bot, roster, actions }), ] : null, bot && !bot.remote && tab === 'browser' ? h(BrowserCards, { bot, sessionId, roster, actions, useBrowserTabs, useBrowserPages }) : null)] } diff --git a/core/src/client/schedules.js b/core/src/client/schedules.js index 56b90f718..bf63ccb72 100644 --- a/core/src/client/schedules.js +++ b/core/src/client/schedules.js @@ -119,7 +119,7 @@ export function lastRunText(run) { // The window lock, read the way the rest of the client reads it (#2232): the Settings header draws // the sentence, and this page draws no write the host would refuse. -const lockedWindow = roster => typeof roster?.readOnly === 'string' && roster.readOnly.trim() !== '' +export const lockedWindow = roster => typeof roster?.readOnly === 'string' && roster.readOnly.trim() !== '' function TaskRow({ task, actions, onView, onEdit, onCopy, showBot, roster, locked }) { const [confirming, setConfirming] = useState(false) @@ -434,8 +434,9 @@ export function CopyRow({ task, roster, actions, onView, onCancel }) { export function BotSchedules({ bot, roster, actions }) { const view = useSchedules(actions) const [editing, setEditing] = useState(null) - // A locked window keeps the list and its New task row: that row only opens the settings list, - // which draws no create control once this page respects the lock (#2232). + // A locked window keeps the list but draws no New task row: the row opened the settings list + // with this Bot's id, which asks for the create form, and a locked page draws no form — so the + // click landed on an empty page (#2232, P-457). const locked = lockedWindow(roster) const refused = schedulesReadFailure(view?.readFailure) if (refused !== null) { @@ -457,7 +458,7 @@ export function BotSchedules({ bot, roster, actions }) { tasks, editing, roster, actions, locked, onEdit: locked ? undefined : setEditing, onDone: (next) => { publish(next); setEditing(null) }, onCancel: () => setEditing(null), - extra: [h('button', { key: 'new', type: 'button', className: 'bt-set-row bt-bs-row bt-gs-add', onClick: () => actions.openSettings('schedules', bot.id) }, h(PlusIcon), t('New task'))], + extra: locked ? [] : [h('button', { key: 'new', type: 'button', className: 'bt-set-row bt-bs-row bt-gs-add', onClick: () => actions.openSettings('schedules', bot.id) }, h(PlusIcon), t('New task'))], })) } @@ -478,8 +479,10 @@ function SummaryLines({ tasks, actions }) { // The details panel's line: the Bot's next tasks, or how to add one. Paused and finished // tasks are the Bot's tasks too, so only a Bot with none at all gets the empty state (#2024). // The rows open the settings list, where a bot id would open the new-task form instead — the -// form is what the empty state's button means (#2030). -export function ScheduleSummary({ bot, actions }) { +// form is what the empty state's button means (#2030). A locked window draws neither the empty +// state nor its button, because that button asks for the form this window cannot draw (#2232, +// P-457); with tasks the rows still open the plain settings list, so the read half stays. +export function ScheduleSummary({ bot, roster, actions }) { const view = useSchedules(actions) // #2035: the shared view starts null and the first read lands a frame later, so "no view yet" is // not "no tasks". The settings card above draws nothing for it; this line did the opposite and @@ -487,6 +490,7 @@ export function ScheduleSummary({ bot, actions }) { // keeps its own line below. if (view === null || view.readFailure !== undefined) return null const tasks = view.available ? view.tasks.filter(task => task.botId === bot.id) : [] + if (lockedWindow(roster) && tasks.length === 0) return null return h('div', null, tasks.length === 0 ? h('div', { className: 'bt-section-title' }, t('Scheduled tasks')) diff --git a/core/test/composer-locked.test.js b/core/test/composer-locked.test.js new file mode 100644 index 000000000..72e9fdf83 --- /dev/null +++ b/core/test/composer-locked.test.js @@ -0,0 +1,82 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { fileURLToPath } from 'node:url' +import { runInNewContext } from 'node:vm' +import { build } from 'esbuild' + +// P-457: composer.js "+" menu → "Scheduled task" reaches +// openSettings('schedules', target), which a read-only window must not offer — the settings list +// asks for the create form and a locked page draws none (#2232). + +const { outputFiles } = await build({ + stdin: { + contents: "export * from './src/client/composer.js'", + resolveDir: fileURLToPath(new URL('..', import.meta.url)), + }, + bundle: true, format: 'cjs', platform: 'browser', write: false, logLevel: 'silent', + external: ['react', 'react-dom', '@deepseek-ai/dsh-client-ui-primitives'], +}) + +const h = (type, props, ...children) => ({ type, props: { ...props, children } }) +const LOCK = 'Another FlyBot window has this home open; this window is read-only.' +const seeded = [] +const react = { + createElement: h, Fragment: Symbol('F'), useEffect() {}, useLayoutEffect() {}, + useState: v => [seeded.length > 0 ? seeded.shift() : v, () => {}], + useMemo: f => f(), useRef: current => ({ current }), useCallback: f => f, + useSyncExternalStore: (_s, get) => get(), +} +const module = { exports: {} } +runInNewContext(outputFiles[0].text, { + module, exports: module.exports, document: { hidden: false }, navigator: { language: 'en' }, localStorage: { getItem: () => null, setItem() {} }, + requestAnimationFrame: run => run(), + require: (name) => { + if (name === 'react') return react + if (name === 'react-dom') return { createPortal: node => node } + if (name === '@deepseek-ai/dsh-client-ui-primitives') return {} + throw new Error(`Unexpected platform module ${name}`) + }, +}) +const { ComposerPlus } = module.exports + +const all = (node, out = []) => { + if (Array.isArray(node)) { for (const item of node) all(item, out); return out } + if (typeof node === 'string') out.push(node) + else if (node && typeof node === 'object') { out.push(node); all(node.props?.children, out) } + return out +} +// The menu is open already: useState hands out the position, useLinger keeps it as `kept`. +const render = (roster) => { + seeded.length = 0 + seeded.push({ left: 0, bottom: 0 }, null) + const actions = { log: [], openSettings: (...args) => actions.log.push(args) } + const element = ComposerPlus({ + sessionId: 's1', + inputActions: { captureInsertion: () => 'insert' }, + useRoster: select => select(roster), + actions: { ...actions, canReferenceChat: () => true, referenceChat: () => {} }, + }) + return { tree: element, actions } +} +const menuRows = tree => all(tree).filter(node => node?.props?.role === 'menuitem') + +const WRITABLE = { bots: [], rooms: [], byId: { s1: { id: 'b1' } }, roomsById: {}, readOnly: '' } +const LOCKED = { ...WRITABLE, readOnly: LOCK } + +test('writable window: the "+" menu offers Scheduled task and it opens the schedules page for the chat', () => { + const { tree, actions } = render(WRITABLE) + const rows = menuRows(tree).map(row => all(row).filter(item => typeof item === 'string').join('')) + assert.ok(rows.includes('Scheduled task'), `rows: ${JSON.stringify(rows)}`) + menuRows(tree).find(row => all(row).includes('Scheduled task')).props.onClick() + assert.deepEqual(actions.log, [['schedules', 'b1']]) +}) + +test('read-only window: no menu row opens the schedules page', () => { + const { tree, actions } = render(LOCKED) + const rows = menuRows(tree).map(row => all(row).filter(item => typeof item === 'string').join('')) + assert.ok(!rows.includes('Scheduled task'), `a read-only window still offers the row: ${JSON.stringify(rows)}`) + const scheduleRows = menuRows(tree).filter(row => all(row).includes('Scheduled task')) + for (const row of scheduleRows) row.props.onClick() + assert.deepEqual(actions.log, []) + assert.deepEqual(scheduleRows.map(row => all(row).filter(item => typeof item === 'string').join('')), []) +}) diff --git a/core/test/schedules-client.test.js b/core/test/schedules-client.test.js index 37df86003..fe2ca3188 100644 --- a/core/test/schedules-client.test.js +++ b/core/test/schedules-client.test.js @@ -781,3 +781,35 @@ test('#2232: Quiet hours and the form announce a refusal, not only print it', as assert.equal(formAlerts.length, 1, 'one alert on the form') assert.ok(text(formAlerts[0]).join(' ').includes(sentence)) }) + +// P-457: #2235 hid every scheduled-task write a read-only window draws, but two entry points to +// the create form stayed: a Bot's card kept its New task row and the details line kept its empty +// state's Add one, and both asked the settings list for that Bot's id — which opens the form, and +// a locked page draws none (the page itself stays form-free, the third test here). +test('P-457: a read-only Bot card draws no New task row', () => { + seeded.push({ available: true, tasks: [] }) + const card = render(BotSchedules({ bot: { id: 'b1' }, roster: LOCKED_ROSTER, actions: scheduleActions() })) + const labels = buttonsOf(card).map(labelOf) + assert.ok(!labels.includes('New task'), `a read-only Bot card still offers New task, saw ${labels.join('|')}`) + // The row is a create entry, not the lock: the same card in a writable window keeps it. + seeded.push({ available: true, tasks: [] }) + const writable = render(BotSchedules({ bot: { id: 'b1' }, roster: ROSTER, actions: scheduleActions() })) + assert.ok(buttonsOf(writable).map(labelOf).includes('New task'), 'a writable window keeps the New task row') +}) + +test('P-457: a locked page opened with a start id draws no create form', () => { + seeded.push({ available: true, tasks: [ACTIVE] }) + const page = render(SchedulesPage({ roster: LOCKED_ROSTER, actions: scheduleActions(), start: 'b1' })) + assert.deepEqual(fieldControls(page).map(node => node.type), [], 'a read-only window draws no create form') + assert.ok(!buttonsOf(page).map(labelOf).includes('New task'), 'and no New task row') +}) + +test('P-457: a read-only details line offers no Add one, and a writable one does', () => { + const actions = opened() + seeded.push({ available: true, tasks: [] }) + const locked = render(ScheduleSummary({ bot: { id: 'b1' }, roster: LOCKED_ROSTER, actions })) + assert.deepEqual(buttonsOf(locked).map(labelOf), [], 'the locked details line draws no button') + seeded.push({ available: true, tasks: [] }) + const writable = render(ScheduleSummary({ bot: { id: 'b1' }, roster: ROSTER, actions })) + assert.deepEqual(buttonsOf(writable).map(labelOf), ['No scheduled tasks yet. Add one'], 'a writable details line keeps its Add one') +}) diff --git a/docs/subsystems/core-client.md b/docs/subsystems/core-client.md index 7827709ce..954ebdb37 100644 --- a/docs/subsystems/core-client.md +++ b/docs/subsystems/core-client.md @@ -42,7 +42,7 @@ The browser side of the patched `ds-bot` core (`core/`): sources in `core/src/cl | `core/src/client/panes.js` | `PANE_MIN_WIDTH_CSS` (#705): every pane the multi-pane row flexes gets `min-width:0`, the conversation column is matched by its DSH class fragment, and the panel's long values (About text, `4.1M tok · Cache hit …`) wrap with `overflow-wrap:anywhere`, so a value that cannot wrap never widens the panel past the space reserved for it. The same sheet clips horizontal overflow on the document, the conversation column and the app frame (`overflow-x:clip`, #1086), and `watchShellLevel` / `levelShell` put a leftover `scrollLeft` back, so an error toast or dialog cannot leave the window shifted. Tests: `core/test/shell-level.test.js`. | | `core/src/shared/approval-waiting.js` | The one rule for "this hold still waits for the owner's decision" (#734, #754): `isApprovalWaiting(status)` is true for `pending` and `pending_approval` and false for every settled spelling (`approved`, `auto`, `failed`, `rejected`, `stale`, missing). The host writes `pending`; Bot Social's own outbound hold arrives as `pending_approval`. Host and client both import this one file — the held-reply card, the approvals overlay and the chat bubble on this side, `hooks.js` (`decide()`, `heldOf()`, the cap, the in-flight filter, the cleanup) and `channel.js` (a row's wait badge) on the other — and the overlay asks the host for `[...WAITING_STATUSES, 'failed']`, so the two spellings cannot drift apart again on one side only. Tests: `core/test/approval-waiting.test.js`, `core/test/host-approval-status.test.js`. | | `core/src/shared/held-retry.js` | The one rule for "this failure can still be retried" (#968): `canRetry(record)` is false for `retry: false`, for `noRetry: true`, and for a record whose stored `error`/`code` names a terminal reason (`no such remote bot` / `is not shared with your Bots` / `is no longer shared with you` / `has not granted chat.public` / `bot_not_shared` / `bot_not_found`; a bare `capability_not_granted` is not one, #988), true otherwise. The host states the same answer in two spellings (`noRetry` on an approval, `retry: false` on a held reply), so both cards and Bot Social's own dock ask this one function instead of keeping a copy each; a hold written before the flag existed is read from its stored sentence at render time, with no state migration. Tests: `core/test/held-retry.test.js`. | -| `core/src/client/schedules.js` | Settings → Scheduled tasks, a Bot's card and the details line. Each row has **Edit** (pencil; the row turns into the same form that creates a task, filled in, with **Save changes**; a new task opens with no target chosen — **Send to** reads "Pick a Bot or group chat" and **Create task** stays disabled until one is picked, so one click cannot create the task for the first Bot (#1334); an edited task keeps its time zone, a cron rule it cannot show is kept), **Copy** (`CopyRow`, #1287: the row turns into the create form's target picker, minus the chat the task already runs in, and **Copy** posts `schedule-create` with `copyFrom: { sessionId, id }` — the host writes the row, so the copy keeps the source's next run) and **Pause** / **Resume**; a paused row reads **Paused** (#354). A row with a recorded run adds one line under it: "Running since …", "Last run …", "Last run … · Stopped" or, in red, "Last run … · Failed: "; a failed run of a task that is not paused gets **Retry**, which runs the task once now (`actions.scheduleRetry`, #473). With Automation tasks off, the hint reads "Switch on Automation tasks on the Plugins page." and the button **Open Plugins** closes the Settings dialog and opens DSH's Plugins panel (`actions.selectPanel('plugins')`); the Harness settings dialog has no such switch (#378). The page opens with the team's **Quiet hours** card (#1166): **Off** with a **Turn on** button, or **On, 22:00–07:00** with the window's two time fields and **Save window** (the settings write refuses an invalid window, and the host's named refusal appears on the form's error line, as a `role="alert"` so it is announced and not only printed (#2232)). While quiet hours are on and an `every`/`cron` task exists, the view's `quietUnchecked` flag draws one line saying such a task can still run inside the window: that flag is the only place the rule is computed — the page does not scan the task list itself (#1199) — and it asks for the view again right after the settings write, so the line does not wait for the 30-second poll. Never a silent claim of silence, and no suppressed count. A repeat whose interval is not a whole number of minutes is stated as seconds (`Every 90 seconds`, #1651): the edit form opens holding that exact value in the **seconds** unit, and saving a task whose repeat was not touched sends the stored interval back unchanged instead of a rounded number of minutes. The details panel's line (`ScheduleSummary`) lists every task of that Bot, paused and finished ones included, because a Bot whose tasks are all paused has tasks while a Bot with none is the only empty state; each row answers when — `Next: