Compare commits

..
Author SHA1 Message Date
Peter Steinberger fbe59ae8e0 fix: soften form save guardrails in config UI (#1678) (thanks @Glucksberg) 2026-01-25 05:46:27 +00:00
GlucksbergandClaude Opus 4.5 ffb25253b4 fix(ui): improve config save UX
Follow-up to #1609 fix:
- Remove formUnsafe check from canSave (was blocking save even with valid changes)
- Suppress disconnect message for code 1012 (service restart is expected during config save)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 05:37:49 +00:00
81 changed files with 7400 additions and 6406 deletions

No files matched your search

+1 -5
View File
@@ -9,7 +9,6 @@ Docs: https://docs.clawd.bot
- Venius (Venice AI): highlight provider guide + cross-links + expanded guidance. https://docs.clawd.bot/providers/venice - Venius (Venice AI): highlight provider guide + cross-links + expanded guidance. https://docs.clawd.bot/providers/venice
### Changes ### Changes
- Control UI: refresh design, typography, and iconography; ship local Clawdbot logo asset. (#1745) Thanks @EnzeD.
- TTS: add Edge TTS provider fallback, defaulting to keyless Edge with MP3 retry on format failures. (#1668) Thanks @steipete. https://docs.clawd.bot/tts - TTS: add Edge TTS provider fallback, defaulting to keyless Edge with MP3 retry on format failures. (#1668) Thanks @steipete. https://docs.clawd.bot/tts
- Web search: add Brave freshness filter parameter for time-scoped results. (#1688) Thanks @JonUleis. https://docs.clawd.bot/tools/web - Web search: add Brave freshness filter parameter for time-scoped results. (#1688) Thanks @JonUleis. https://docs.clawd.bot/tools/web
- TTS: add auto mode enum (off/always/inbound/tagged) with per-session `/tts` override. (#1667) Thanks @sebslight. https://docs.clawd.bot/tts - TTS: add auto mode enum (off/always/inbound/tagged) with per-session `/tts` override. (#1667) Thanks @sebslight. https://docs.clawd.bot/tts
@@ -20,15 +19,14 @@ Docs: https://docs.clawd.bot
- Docs: add Bedrock EC2 instance role setup + IAM steps. (#1625) Thanks @sergical. https://docs.clawd.bot/bedrock - Docs: add Bedrock EC2 instance role setup + IAM steps. (#1625) Thanks @sergical. https://docs.clawd.bot/bedrock
- Exec approvals: forward approval prompts to chat with `/approve` for all channels (including plugins). (#1621) Thanks @czekaj. https://docs.clawd.bot/tools/exec-approvals https://docs.clawd.bot/tools/slash-commands - Exec approvals: forward approval prompts to chat with `/approve` for all channels (including plugins). (#1621) Thanks @czekaj. https://docs.clawd.bot/tools/exec-approvals https://docs.clawd.bot/tools/slash-commands
- Gateway: expose config.patch in the gateway tool with safe partial updates + restart sentinel. (#1653) Thanks @Glucksberg. - Gateway: expose config.patch in the gateway tool with safe partial updates + restart sentinel. (#1653) Thanks @Glucksberg.
- Telegram: add `channels.telegram.linkPreview` to toggle outbound link previews. (#1700) Thanks @zerone0x. https://docs.clawd.bot/channels/telegram
- Telegram: treat DM topics as separate sessions and keep DM history limits stable with thread suffixes. (#1597) Thanks @rohannagpal. - Telegram: treat DM topics as separate sessions and keep DM history limits stable with thread suffixes. (#1597) Thanks @rohannagpal.
- Telegram: add verbose raw-update logging for inbound Telegram updates. (#1597) Thanks @rohannagpal. - Telegram: add verbose raw-update logging for inbound Telegram updates. (#1597) Thanks @rohannagpal.
### Fixes ### Fixes
- BlueBubbles: keep part-index GUIDs in reply tags when short IDs are missing. - BlueBubbles: keep part-index GUIDs in reply tags when short IDs are missing.
- Web UI: hide internal `message_id` hints in chat bubbles. - Web UI: hide internal `message_id` hints in chat bubbles.
- Web UI: allow form saves with unsupported schema paths while blocking missing schema; clear stale disconnect banners on reconnect. (#1678) Thanks @Glucksberg.
- Web UI: show Stop button during active runs, swap back to New session when idle. (#1664) Thanks @ndbroadbent. - Web UI: show Stop button during active runs, swap back to New session when idle. (#1664) Thanks @ndbroadbent.
- Web UI: clear stale disconnect banners on reconnect; allow form saves with unsupported schema paths but block missing schema. (#1707) Thanks @Glucksberg.
- Heartbeat: normalize target identifiers for consistent routing. - Heartbeat: normalize target identifiers for consistent routing.
- TUI: reload history after gateway reconnect to restore session state. (#1663) - TUI: reload history after gateway reconnect to restore session state. (#1663)
- Telegram: use wrapped fetch for long-polling on Node to normalize AbortSignal handling. (#1639) - Telegram: use wrapped fetch for long-polling on Node to normalize AbortSignal handling. (#1639)
@@ -42,8 +40,6 @@ Docs: https://docs.clawd.bot
- Gateway: skip Tailscale DNS probing when tailscale.mode is off. (#1671) - Gateway: skip Tailscale DNS probing when tailscale.mode is off. (#1671)
- Gateway: reduce log noise for late invokes + remote node probes; debounce skills refresh. (#1607) Thanks @petter-b. - Gateway: reduce log noise for late invokes + remote node probes; debounce skills refresh. (#1607) Thanks @petter-b.
- Gateway: clarify Control UI/WebChat auth error hints for missing tokens. (#1690) - Gateway: clarify Control UI/WebChat auth error hints for missing tokens. (#1690)
- Gateway: listen on IPv6 loopback when bound to 127.0.0.1 so localhost webhooks work.
- Gateway: store lock files in the temp directory to avoid stale locks on persistent volumes. (#1676)
- macOS: default direct-transport `ws://` URLs to port 18789; document `gateway.remote.transport`. (#1603) Thanks @ngutman. - macOS: default direct-transport `ws://` URLs to port 18789; document `gateway.remote.transport`. (#1603) Thanks @ngutman.
- Voice Call: return stream TwiML for outbound conversation calls on initial Twilio webhook. (#1634) - Voice Call: return stream TwiML for outbound conversation calls on initial Twilio webhook. (#1634)
- Google Chat: tighten email allowlist matching, typing cleanup, media caps, and onboarding/docs/tests. (#1635) Thanks @iHildy. - Google Chat: tighten email allowlist matching, typing cleanup, media caps, and onboarding/docs/tests. (#1635) Thanks @iHildy.
+1
View File
@@ -0,0 +1 @@
@import"https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=Unbounded:wght@400;500;600&family=Work+Sans:wght@400;500;600;700&display=swap";:root{--bg: #0a0f14;--bg-accent: #111826;--bg-grad-1: #162031;--bg-grad-2: #1f2a22;--bg-overlay: rgba(255, 255, 255, .05);--bg-glow: rgba(245, 159, 74, .12);--panel: rgba(14, 20, 30, .88);--panel-strong: rgba(18, 26, 38, .96);--chrome: rgba(9, 14, 20, .72);--chrome-strong: rgba(9, 14, 20, .86);--text: rgba(244, 246, 251, .96);--chat-text: rgba(231, 237, 244, .92);--muted: rgba(156, 169, 189, .72);--border: rgba(255, 255, 255, .09);--border-strong: rgba(255, 255, 255, .16);--accent: #f59f4a;--accent-2: #34c7b7;--ok: #2bd97f;--warn: #f2c94c;--danger: #ff6b6b;--focus: rgba(245, 159, 74, .35);--grid-line: rgba(255, 255, 255, .04);--theme-switch-x: 50%;--theme-switch-y: 50%;--mono: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--font-body: "Work Sans", system-ui, sans-serif;--font-display: "Unbounded", "Times New Roman", serif;color-scheme:dark}:root[data-theme=light]{--bg: #f5f1ea;--bg-accent: #ffffff;--bg-grad-1: #f1e6d6;--bg-grad-2: #e5eef4;--bg-overlay: rgba(28, 32, 46, .05);--bg-glow: rgba(52, 199, 183, .14);--panel: rgba(255, 255, 255, .9);--panel-strong: rgba(255, 255, 255, .97);--chrome: rgba(255, 255, 255, .75);--chrome-strong: rgba(255, 255, 255, .88);--text: rgba(27, 36, 50, .98);--chat-text: rgba(36, 48, 66, .9);--muted: rgba(80, 94, 114, .7);--border: rgba(18, 24, 40, .12);--border-strong: rgba(18, 24, 40, .2);--accent: #e28a3f;--accent-2: #1ba99d;--ok: #1aa86c;--warn: #b3771c;--danger: #d44848;--focus: rgba(226, 138, 63, .35);--grid-line: rgba(18, 24, 40, .06);color-scheme:light}*{box-sizing:border-box}html,body{height:100%}body{margin:0;font:15px/1.5 var(--font-body);background:radial-gradient(1200px 900px at 15% -10%,var(--bg-grad-1) 0%,transparent 55%) fixed,radial-gradient(900px 700px at 80% 10%,var(--bg-grad-2) 0%,transparent 60%) fixed,linear-gradient(160deg,var(--bg) 0%,var(--bg-accent) 100%) fixed;color:var(--text)}body:before{content:"";position:fixed;inset:0;background:linear-gradient(140deg,var(--bg-overlay) 0%,rgba(255,255,255,0) 40%),radial-gradient(620px 420px at 75% 75%,var(--bg-glow),transparent 60%);pointer-events:none;z-index:0}@keyframes theme-circle-transition{0%{clip-path:circle(0% at var(--theme-switch-x, 50%) var(--theme-switch-y, 50%))}to{clip-path:circle(150% at var(--theme-switch-x, 50%) var(--theme-switch-y, 50%))}}html.theme-transition{view-transition-name:theme}html.theme-transition::view-transition-old(theme){mix-blend-mode:normal;animation:none;z-index:1}html.theme-transition::view-transition-new(theme){mix-blend-mode:normal;z-index:2;animation:theme-circle-transition .45s ease-out forwards}@media(prefers-reduced-motion:reduce){html.theme-transition::view-transition-old(theme),html.theme-transition::view-transition-new(theme){animation:none!important}}clawdbot-app{display:block;position:relative;z-index:1;min-height:100vh}a{color:inherit}button,input,textarea,select{font:inherit;color:inherit}@keyframes rise{0%{opacity:0;transform:translateY(6px)}to{opacity:1;transform:translateY(0)}}@keyframes dashboard-enter{0%{opacity:0;transform:translateY(12px)}to{opacity:1;transform:translateY(0)}}.shell{--shell-pad: 16px;--shell-gap: 16px;--shell-nav-width: 220px;--shell-topbar-height: 56px;--shell-focus-duration: .22s;--shell-focus-ease: cubic-bezier(.2, .85, .25, 1);height:100vh;display:grid;grid-template-columns:var(--shell-nav-width) minmax(0,1fr);grid-template-rows:var(--shell-topbar-height) 1fr;grid-template-areas:"topbar topbar" "nav content";gap:0;animation:dashboard-enter .6s ease-out;transition:grid-template-columns var(--shell-focus-duration) var(--shell-focus-ease);overflow:hidden}@supports (height: 100dvh){.shell{height:100dvh}}.shell--chat{min-height:100vh;height:100vh;overflow:hidden}@supports (height: 100dvh){.shell--chat{height:100dvh}}.shell--nav-collapsed,.shell--chat-focus{grid-template-columns:0px minmax(0,1fr)}.shell--onboarding{grid-template-rows:0 1fr}.shell--onboarding .topbar{display:none}.shell--onboarding .content{padding-top:0}.shell--chat-focus .content{padding-top:0;gap:0}.topbar{grid-area:topbar;position:sticky;top:0;z-index:40;display:flex;justify-content:space-between;align-items:center;gap:16px;padding:0 20px;height:var(--shell-topbar-height);border-bottom:1px solid var(--border);background:var(--panel);-webkit-backdrop-filter:blur(18px);backdrop-filter:blur(18px)}.topbar-left{display:flex;align-items:center;gap:12px}.topbar .nav-collapse-toggle{width:44px;height:44px;margin-bottom:0}.topbar .nav-collapse-toggle__icon{font-size:22px}.brand{display:flex;flex-direction:column;gap:2px}.brand-title{font-family:var(--font-display);font-size:16px;letter-spacing:1px;text-transform:uppercase;font-weight:600;line-height:1.1}.brand-sub{font-size:10px;color:var(--muted);letter-spacing:.8px;text-transform:uppercase;line-heightLine truncated
File diff suppressed because it is too large. Load diff
-1
View File
@@ -1 +0,0 @@
{"version":3,"file":"index-CJVJFuAg.js","sources":["../../../node_modules/.pnpm/@lit+reactive-element@2.1.2/node_modules/@lit/reactive-element/css-tag.js","../../../node_modules/.pnpm/@lit+reactive-element@2.1.2/node_modules/@lit/reactive-element/reactive-element.js","../../../node_modules/.pnpm/lit-html@3.3.2/node_modules/lit-html/lit-html.js","../../../node_modules/.pnpm/lit-element@4.2.2/node_modules/lit-element/lit-element.js","../../../node_modules/.pnpm/@lit+reactive-element@2.1.2/node_modules/@lit/reactive-element/decorators/custom-element.js","../../../node_modules/.pnpm/@lit+reactive-element@2.1.2/node_modules/@lit/reactive-element/decorators/property.js","../../../node_modules/.pnpm/@lit+reactive-element@2.1.2/node_modules/@lit/reactive-element/decorators/state.js","../../../ui/src/ui/assistant-identity.ts","../../../ui/src/ui/storage.ts","../../../src/sessions/session-key-utils.ts","../../../ui/src/ui/navigation.ts","../../../ui/src/ui/icons.ts","../../../src/shared/text/reasoning-tags.ts","../../../ui/src/ui/format.ts","../../../ui/src/ui/chat/message-extract.ts","../../../ui/src/ui/uuid.ts","../../../ui/src/ui/controllers/chat.ts","../../../ui/src/ui/controllers/sessions.ts","../../../ui/src/ui/app-tool-stream.ts","../../../ui/src/ui/app-scroll.ts","../../../ui/src/ui/controllers/config/form-utils.ts","../../../ui/src/ui/controllers/config.ts","../../../ui/src/ui/controllers/cron.ts","../../../ui/src/ui/controllers/channels.ts","../../../ui/src/ui/controllers/debug.ts","../../../ui/src/ui/controllers/logs.ts","../../../node_modules/.pnpm/@noble+ed25519@3.0.0/node_modules/@noble/ed25519/index.js","../../../ui/src/ui/device-identity.ts","../../../ui/src/ui/device-auth.ts","../../../ui/src/ui/controllers/devices.ts","../../../ui/src/ui/controllers/nodes.ts","../../../ui/src/ui/controllers/exec-approvals.ts","../../../ui/src/ui/controllers/presence.ts","../../../ui/src/ui/controllers/skills.ts","../../../ui/src/ui/theme.ts","../../../ui/src/ui/theme-transition.ts","../../../ui/src/ui/app-polling.ts","../../../ui/src/ui/app-settings.ts","../../../ui/src/ui/app-chat.ts","../../../node_modules/.pnpm/lit-html@3.3.2/node_modules/lit-html/directive.js","../../../node_modules/.pnpm/lit-html@3.3.2/node_modules/lit-html/directive-helpers.js","../../../node_modules/.pnpm/lit-html@3.3.2/node_modules/lit-html/directives/repeat.js","../../../ui/src/ui/chat/message-normalizer.ts","../../../node_modules/.pnpm/lit-html@3.3.2/node_modules/lit-html/directives/unsafe-html.js","../../../node_modules/.pnpm/dompurify@3.3.1/node_modules/dompurify/dist/purify.es.mjs","../../../node_modules/.pnpm/marked@17.0.1/node_modules/marked/lib/marked.esm.js","../../../ui/src/ui/markdown.ts","../../../ui/src/ui/chat/copy-as-markdown.ts","../../../ui/src/ui/tool-display.ts","../../../ui/src/ui/chat/constants.ts","../../../ui/src/ui/chat/tool-helpers.ts","../../../ui/src/ui/chat/tool-cards.ts","../../../ui/src/ui/chat/grouped-render.ts","../../../ui/src/ui/views/markdown-sidebar.ts","../../../ui/src/ui/components/resizable-divider.ts","../../../ui/src/ui/views/chat.ts","../../../ui/src/ui/views/config-form.shared.ts","../../../ui/src/ui/views/config-form.node.ts","../../../ui/src/ui/views/config-form.render.ts","../../../ui/src/ui/views/config-form.analyze.ts","../../../ui/src/ui/views/config.ts","../../../ui/src/ui/views/channels.shared.ts","../../../ui/src/ui/views/channels.config.ts","../../../ui/src/ui/views/channels.discord.ts","../../../ui/src/ui/views/channels.googlechat.ts","../../../ui/src/ui/views/channels.imessage.ts","../../../ui/src/ui/views/channels.nostr-profile-form.ts","../../../ui/src/ui/views/channels.nostr.ts","../../../ui/src/ui/views/channels.signal.ts","../../../ui/src/ui/views/channels.slack.ts","../../../ui/src/ui/views/channels.telegram.ts","../../../ui/src/ui/views/channels.whatsapp.ts","../../../ui/src/ui/views/channels.ts","../../../ui/src/ui/presenter.ts","../../../ui/src/ui/views/cron.ts","../../../ui/src/ui/views/debug.ts","../../../ui/src/ui/views/instances.ts","../../../ui/src/ui/views/logs.ts","../../../ui/src/ui/views/nodes.ts","../../../ui/src/ui/views/overview.ts","../../../ui/src/ui/views/sessions.ts","../../../ui/src/ui/views/exec-approval.ts","../../../ui/src/ui/views/skills.ts","../../../ui/src/ui/app-render.helpers.ts","../../../ui/src/ui/app-render.ts","../../../ui/src/ui/app-defaults.ts","../../../ui/src/ui/controllers/agents.ts","../../../src/gateway/protocol/client-info.ts","../../../src/gateway/device-auth.ts","../../../ui/src/ui/gateway.ts","../../../ui/src/ui/controllers/exec-approval.ts","../../../ui/src/ui/controllers/assistant-identity.ts","../../../ui/src/ui/app-gateway.ts","../../../ui/src/ui/app-lifecycle.ts","../../../ui/src/ui/app-channels.ts","../../../ui/src/ui/app.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2019 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n */\nconst t=globalThis,e=t.ShadowRoot&&(void 0===t.ShadyCSS||t.ShadyCSS.nativeShadow)&&\"adoptedStyLine truncated
File diff suppressed because it is too large. Load diff
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"index-DsXRcnEw.js","sources":["../../../node_modules/.pnpm/@lit+reactive-element@2.1.2/node_modules/@lit/reactive-element/css-tag.js","../../../node_modules/.pnpm/@lit+reactive-element@2.1.2/node_modules/@lit/reactive-element/reactive-element.js","../../../node_modules/.pnpm/lit-html@3.3.2/node_modules/lit-html/lit-html.js","../../../node_modules/.pnpm/lit-element@4.2.2/node_modules/lit-element/lit-element.js","../../../node_modules/.pnpm/@lit+reactive-element@2.1.2/node_modules/@lit/reactive-element/decorators/custom-element.js","../../../node_modules/.pnpm/@lit+reactive-element@2.1.2/node_modules/@lit/reactive-element/decorators/property.js","../../../node_modules/.pnpm/@lit+reactive-element@2.1.2/node_modules/@lit/reactive-element/decorators/state.js","../../../ui/src/ui/assistant-identity.ts","../../../ui/src/ui/storage.ts","../../../src/sessions/session-key-utils.ts","../../../ui/src/ui/navigation.ts","../../../ui/src/ui/format.ts","../../../ui/src/ui/chat/message-extract.ts","../../../ui/src/ui/uuid.ts","../../../ui/src/ui/controllers/chat.ts","../../../ui/src/ui/controllers/sessions.ts","../../../ui/src/ui/app-tool-stream.ts","../../../ui/src/ui/app-scroll.ts","../../../ui/src/ui/controllers/config/form-utils.ts","../../../ui/src/ui/controllers/config.ts","../../../ui/src/ui/controllers/cron.ts","../../../ui/src/ui/controllers/channels.ts","../../../ui/src/ui/controllers/debug.ts","../../../ui/src/ui/controllers/logs.ts","../../../node_modules/.pnpm/@noble+ed25519@3.0.0/node_modules/@noble/ed25519/index.js","../../../ui/src/ui/device-identity.ts","../../../ui/src/ui/device-auth.ts","../../../ui/src/ui/controllers/devices.ts","../../../ui/src/ui/controllers/nodes.ts","../../../ui/src/ui/controllers/exec-approvals.ts","../../../ui/src/ui/controllers/presence.ts","../../../ui/src/ui/controllers/skills.ts","../../../ui/src/ui/theme.ts","../../../ui/src/ui/theme-transition.ts","../../../ui/src/ui/app-polling.ts","../../../ui/src/ui/app-settings.ts","../../../ui/src/ui/app-chat.ts","../../../node_modules/.pnpm/lit-html@3.3.2/node_modules/lit-html/directive.js","../../../node_modules/.pnpm/lit-html@3.3.2/node_modules/lit-html/directive-helpers.js","../../../node_modules/.pnpm/lit-html@3.3.2/node_modules/lit-html/directives/repeat.js","../../../ui/src/ui/chat/message-normalizer.ts","../../../node_modules/.pnpm/lit-html@3.3.2/node_modules/lit-html/directives/unsafe-html.js","../../../node_modules/.pnpm/dompurify@3.3.1/node_modules/dompurify/dist/purify.es.mjs","../../../node_modules/.pnpm/marked@17.0.1/node_modules/marked/lib/marked.esm.js","../../../ui/src/ui/markdown.ts","../../../ui/src/ui/icons.ts","../../../ui/src/ui/chat/copy-as-markdown.ts","../../../ui/src/ui/tool-display.ts","../../../ui/src/ui/chat/constants.ts","../../../ui/src/ui/chat/tool-helpers.ts","../../../ui/src/ui/chat/tool-cards.ts","../../../ui/src/ui/chat/grouped-render.ts","../../../ui/src/ui/views/markdown-sidebar.ts","../../../ui/src/ui/components/resizable-divider.ts","../../../ui/src/ui/views/chat.ts","../../../ui/src/ui/views/config-form.shared.ts","../../../ui/src/ui/views/config-form.node.ts","../../../ui/src/ui/views/config-form.render.ts","../../../ui/src/ui/views/config-form.analyze.ts","../../../ui/src/ui/views/config.ts","../../../ui/src/ui/views/channels.shared.ts","../../../ui/src/ui/views/channels.config.ts","../../../ui/src/ui/views/channels.discord.ts","../../../ui/src/ui/views/channels.imessage.ts","../../../ui/src/ui/views/channels.nostr-profile-form.ts","../../../ui/src/ui/views/channels.nostr.ts","../../../ui/src/ui/views/channels.signal.ts","../../../ui/src/ui/views/channels.slack.ts","../../../ui/src/ui/views/channels.telegram.ts","../../../ui/src/ui/views/channels.whatsapp.ts","../../../ui/src/ui/views/channels.ts","../../../ui/src/ui/presenter.ts","../../../ui/src/ui/views/cron.ts","../../../ui/src/ui/views/debug.ts","../../../ui/src/ui/views/instances.ts","../../../ui/src/ui/views/logs.ts","../../../ui/src/ui/views/nodes.ts","../../../ui/src/ui/views/overview.ts","../../../ui/src/ui/views/sessions.ts","../../../ui/src/ui/views/exec-approval.ts","../../../ui/src/ui/views/skills.ts","../../../ui/src/ui/app-render.helpers.ts","../../../ui/src/ui/app-render.ts","../../../ui/src/ui/app-defaults.ts","../../../ui/src/ui/controllers/agents.ts","../../../src/gateway/protocol/client-info.ts","../../../src/gateway/device-auth.ts","../../../ui/src/ui/gateway.ts","../../../ui/src/ui/controllers/exec-approval.ts","../../../ui/src/ui/controllers/assistant-identity.ts","../../../ui/src/ui/app-gateway.ts","../../../ui/src/ui/app-lifecycle.ts","../../../ui/src/ui/app-channels.ts","../../../ui/src/ui/app.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2019 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n */\nconst t=globalThis,e=t.ShadowRoot&&(void 0===t.ShadyCSS||t.ShadyCSS.nativeShadow)&&\"adoptedStyleSheets\"in Document.prototype&&\"replace\"in CSSStyleSheet.prototype,s=Symbol(),o=new WeakMapLine truncated
File diff suppressed because it is too large. Load diff
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"index-bYQnHP3a.js","sources":["../../../node_modules/.pnpm/@lit+reactive-element@2.1.2/node_modules/@lit/reactive-element/css-tag.js","../../../node_modules/.pnpm/@lit+reactive-element@2.1.2/node_modules/@lit/reactive-element/reactive-element.js","../../../node_modules/.pnpm/lit-html@3.3.2/node_modules/lit-html/lit-html.js","../../../node_modules/.pnpm/lit-element@4.2.2/node_modules/lit-element/lit-element.js","../../../node_modules/.pnpm/@lit+reactive-element@2.1.2/node_modules/@lit/reactive-element/decorators/custom-element.js","../../../node_modules/.pnpm/@lit+reactive-element@2.1.2/node_modules/@lit/reactive-element/decorators/property.js","../../../node_modules/.pnpm/@lit+reactive-element@2.1.2/node_modules/@lit/reactive-element/decorators/state.js","../../../ui/src/ui/assistant-identity.ts","../../../ui/src/ui/storage.ts","../../../src/sessions/session-key-utils.ts","../../../ui/src/ui/navigation.ts","../../../ui/src/ui/format.ts","../../../ui/src/ui/chat/message-extract.ts","../../../ui/src/ui/uuid.ts","../../../ui/src/ui/controllers/chat.ts","../../../ui/src/ui/controllers/sessions.ts","../../../ui/src/ui/app-tool-stream.ts","../../../ui/src/ui/app-scroll.ts","../../../ui/src/ui/controllers/config/form-utils.ts","../../../ui/src/ui/controllers/config.ts","../../../ui/src/ui/controllers/cron.ts","../../../ui/src/ui/controllers/channels.ts","../../../ui/src/ui/controllers/debug.ts","../../../ui/src/ui/controllers/logs.ts","../../../node_modules/.pnpm/@noble+ed25519@3.0.0/node_modules/@noble/ed25519/index.js","../../../ui/src/ui/device-identity.ts","../../../ui/src/ui/device-auth.ts","../../../ui/src/ui/controllers/devices.ts","../../../ui/src/ui/controllers/nodes.ts","../../../ui/src/ui/controllers/exec-approvals.ts","../../../ui/src/ui/controllers/presence.ts","../../../ui/src/ui/controllers/skills.ts","../../../ui/src/ui/theme.ts","../../../ui/src/ui/theme-transition.ts","../../../ui/src/ui/app-polling.ts","../../../ui/src/ui/app-settings.ts","../../../ui/src/ui/app-chat.ts","../../../node_modules/.pnpm/lit-html@3.3.2/node_modules/lit-html/directive.js","../../../node_modules/.pnpm/lit-html@3.3.2/node_modules/lit-html/directive-helpers.js","../../../node_modules/.pnpm/lit-html@3.3.2/node_modules/lit-html/directives/repeat.js","../../../ui/src/ui/chat/message-normalizer.ts","../../../node_modules/.pnpm/lit-html@3.3.2/node_modules/lit-html/directives/unsafe-html.js","../../../node_modules/.pnpm/dompurify@3.3.1/node_modules/dompurify/dist/purify.es.mjs","../../../node_modules/.pnpm/marked@17.0.1/node_modules/marked/lib/marked.esm.js","../../../ui/src/ui/markdown.ts","../../../ui/src/ui/icons.ts","../../../ui/src/ui/chat/copy-as-markdown.ts","../../../ui/src/ui/tool-display.ts","../../../ui/src/ui/chat/constants.ts","../../../ui/src/ui/chat/tool-helpers.ts","../../../ui/src/ui/chat/tool-cards.ts","../../../ui/src/ui/chat/grouped-render.ts","../../../ui/src/ui/views/markdown-sidebar.ts","../../../ui/src/ui/components/resizable-divider.ts","../../../ui/src/ui/views/chat.ts","../../../ui/src/ui/views/config-form.shared.ts","../../../ui/src/ui/views/config-form.node.ts","../../../ui/src/ui/views/config-form.render.ts","../../../ui/src/ui/views/config-form.analyze.ts","../../../ui/src/ui/views/config.ts","../../../ui/src/ui/views/channels.shared.ts","../../../ui/src/ui/views/channels.config.ts","../../../ui/src/ui/views/channels.discord.ts","../../../ui/src/ui/views/channels.imessage.ts","../../../ui/src/ui/views/channels.nostr-profile-form.ts","../../../ui/src/ui/views/channels.nostr.ts","../../../ui/src/ui/views/channels.signal.ts","../../../ui/src/ui/views/channels.slack.ts","../../../ui/src/ui/views/channels.telegram.ts","../../../ui/src/ui/views/channels.whatsapp.ts","../../../ui/src/ui/views/channels.ts","../../../ui/src/ui/presenter.ts","../../../ui/src/ui/views/cron.ts","../../../ui/src/ui/views/debug.ts","../../../ui/src/ui/views/instances.ts","../../../ui/src/ui/views/logs.ts","../../../ui/src/ui/views/nodes.ts","../../../ui/src/ui/views/overview.ts","../../../ui/src/ui/views/sessions.ts","../../../ui/src/ui/views/exec-approval.ts","../../../ui/src/ui/views/skills.ts","../../../ui/src/ui/app-render.helpers.ts","../../../ui/src/ui/app-render.ts","../../../ui/src/ui/app-defaults.ts","../../../ui/src/ui/controllers/agents.ts","../../../src/gateway/protocol/client-info.ts","../../../src/gateway/device-auth.ts","../../../ui/src/ui/gateway.ts","../../../ui/src/ui/controllers/exec-approval.ts","../../../ui/src/ui/controllers/assistant-identity.ts","../../../ui/src/ui/app-gateway.ts","../../../ui/src/ui/app-lifecycle.ts","../../../ui/src/ui/app-channels.ts","../../../ui/src/ui/app.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2019 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n */\nconst t=globalThis,e=t.ShadowRoot&&(void 0===t.ShadyCSS||t.ShadyCSS.nativeShadow)&&\"adoptedStyleSheets\"in Document.prototype&&\"replace\"in CSSStyleSheet.prototype,s=Symbol(),o=new WeakMapLine truncated
-1
View File
@@ -1 +0,0 @@
@import"https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap";:root{--bg: #0c0d12;--bg-accent: #0d0e14;--bg-elevated: #181a21;--bg-hover: #252830;--bg-muted: #252830;--card: #13151c;--card-foreground: #f8fafc;--card-highlight: rgba(255, 255, 255, .04);--popover: #13151c;--popover-foreground: #f8fafc;--panel: #0c0d12;--panel-strong: #181a21;--panel-hover: #252830;--chrome: rgba(12, 13, 18, .95);--chrome-strong: rgba(12, 13, 18, .98);--text: #f8fafc;--text-strong: #ffffff;--chat-text: #f8fafc;--muted: #94a3b8;--muted-strong: #64748b;--muted-foreground: #94a3b8;--border: #333842;--border-strong: #454d5c;--border-hover: #5a6373;--input: #333842;--ring: #ff4d4d;--accent: #ff4d4d;--accent-hover: #ff6666;--accent-muted: #ff4d4d;--accent-subtle: rgba(255, 77, 77, .12);--accent-foreground: #f8fafc;--primary: #ff4d4d;--primary-foreground: #ffffff;--secondary: #252830;--secondary-foreground: #f8fafc;--accent-2: #3b82f6;--accent-2-muted: rgba(59, 130, 246, .7);--ok: #22c55e;--ok-muted: rgba(34, 197, 94, .7);--ok-subtle: rgba(34, 197, 94, .1);--destructive: #ef4444;--destructive-foreground: #fafafa;--warn: #eab308;--warn-muted: rgba(234, 179, 8, .7);--warn-subtle: rgba(234, 179, 8, .1);--danger: #ef4444;--danger-muted: rgba(239, 68, 68, .7);--danger-subtle: rgba(239, 68, 68, .1);--info: #3b82f6;--focus: rgba(255, 77, 77, .2);--focus-ring: 0 0 0 2px var(--bg), 0 0 0 4px var(--ring);--grid-line: rgba(255, 255, 255, .03);--theme-switch-x: 50%;--theme-switch-y: 50%;--mono: "JetBrains Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Monaco, Consolas, monospace;--font-body: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;--font-display: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, .05);--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, .1), 0 2px 4px -2px rgba(0, 0, 0, .1);--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, .1), 0 4px 6px -4px rgba(0, 0, 0, .1);--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, .1), 0 8px 10px -6px rgba(0, 0, 0, .1);--radius-sm: 4px;--radius-md: 6px;--radius-lg: 8px;--radius-xl: 12px;--radius-full: 9999px;--radius: 6px;--ease-out: cubic-bezier(.16, 1, .3, 1);--ease-in-out: cubic-bezier(.4, 0, .2, 1);--duration-fast: .15s;--duration-normal: .2s;--duration-slow: .3s;color-scheme:dark}:root[data-theme=light]{--bg: #f8f8f7;--bg-accent: #f3f2f0;--bg-elevated: #ffffff;--bg-hover: #eae8e6;--bg-muted: #eae8e6;--bg-content: #f0efed;--card: #ffffff;--card-foreground: #1c1917;--card-highlight: rgba(0, 0, 0, .04);--popover: #ffffff;--popover-foreground: #1c1917;--panel: #f8f8f7;--panel-strong: #f0efed;--panel-hover: #e5e3e1;--chrome: rgba(248, 248, 247, .95);--chrome-strong: rgba(248, 248, 247, .98);--text: #44403c;--text-strong: #292524;--chat-text: #44403c;--muted: #5c5856;--muted-strong: #44403c;--muted-foreground: #5c5856;--border: #e0dedc;--border-strong: #d6d3d1;--border-hover: #a8a5a0;--input: #e0dedc;--accent: #b91c1c;--accent-hover: #dc2626;--accent-muted: #b91c1c;--accent-subtle: rgba(185, 28, 28, .18);--accent-foreground: #ffffff;--primary: #b91c1c;--primary-foreground: #ffffff;--secondary: #eae8e6;--secondary-foreground: #44403c;--ok: #15803d;--ok-muted: rgba(21, 128, 61, .75);--ok-subtle: rgba(21, 128, 61, .12);--destructive: #b91c1c;--destructive-foreground: #fafafa;--warn: #a16207;--warn-muted: rgba(161, 98, 7, .75);--warn-subtle: rgba(161, 98, 7, .12);--danger: #b91c1c;--danger-muted: rgba(185, 28, 28, .75);--danger-subtle: rgba(185, 28, 28, .12);--info: #1d4ed8;--focus: rgba(185, 28, 28, .25);--grid-line: rgba(0, 0, 0, .06);color-scheme:light}*{box-sizing:border-box}html,body{height:100%}body{margin:0;font:400 14px/1.5 var(--font-body);letter-spacing:-.011em;background:var(--bg);color:var(--text);-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}@keyframes theme-circle-transition{0%{clip-path:circle(0% at var(--theme-switch-x, 50%) var(--theme-switch-y, 50%))}to{clip-path:circle(150% at var(--theme-switch-x, 50%) var(--theme-switch-y, 50%))}}html.theme-transition{view-transition-name:theme}html.theme-transition::view-transition-old(theme){mix-blend-mode:normal;animation:none;z-index:1}html.theme-transition::view-transition-new(theme){mix-blend-mode:normal;z-index:2;animation:theme-circle-transition .4s var(--ease-out) forwards}@media(prefers-reduced-motion:reduce){html.theme-transition::view-transition-old(theme),html.theme-transition::view-transition-new(theme){animation:none!important}}clawdbot-app{display:block;position:relative;z-index:1;min-height:100vh}a{color:var(--accent);text-decoration:none}a:hover{text-decoration:underline}button,input,textarea,select{font:inherit;color:inherit}::selection{background:var(--accent-subtle);color:var(--text-strong)}::-webkit-scrollbar{width:8px;height:8px}::-webkit-scrollbar-track{background:transparent}::-webkit-scrollbar-thumb{background:var(--border);border-radius:var(--radius-full)}::-webLine truncated
+2 -2
View File
@@ -6,8 +6,8 @@
<title>Clawdbot Control</title> <title>Clawdbot Control</title>
<meta name="color-scheme" content="dark light" /> <meta name="color-scheme" content="dark light" />
<link rel="icon" href="./favicon.ico" sizes="any" /> <link rel="icon" href="./favicon.ico" sizes="any" />
<script type="module" crossorigin src="./assets/index-CJVJFuAg.js"></script> <script type="module" crossorigin src="./assets/index-DsXRcnEw.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-tlTzxSME.css"> <link rel="stylesheet" crossorigin href="./assets/index-BvhR9FCb.css">
</head> </head>
<body> <body>
<clawdbot-app></clawdbot-app> <clawdbot-app></clawdbot-app>
-60
View File
@@ -1,60 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 16 16" role="img" aria-label="Pixel lobster">
<rect width="16" height="16" fill="none"/>
<g fill="#3a0a0d">
<rect x="1" y="5" width="1" height="3"/>
<rect x="2" y="4" width="1" height="1"/>
<rect x="2" y="8" width="1" height="1"/>
<rect x="3" y="3" width="1" height="1"/>
<rect x="3" y="9" width="1" height="1"/>
<rect x="4" y="2" width="1" height="1"/>
<rect x="4" y="10" width="1" height="1"/>
<rect x="5" y="2" width="6" height="1"/>
<rect x="11" y="2" width="1" height="1"/>
<rect x="12" y="3" width="1" height="1"/>
<rect x="12" y="9" width="1" height="1"/>
<rect x="13" y="4" width="1" height="1"/>
<rect x="13" y="8" width="1" height="1"/>
<rect x="14" y="5" width="1" height="3"/>
<rect x="5" y="11" width="6" height="1"/>
<rect x="4" y="12" width="1" height="1"/>
<rect x="11" y="12" width="1" height="1"/>
<rect x="3" y="13" width="1" height="1"/>
<rect x="12" y="13" width="1" height="1"/>
<rect x="5" y="14" width="6" height="1"/>
</g>
<g fill="#ff4f40">
<rect x="5" y="3" width="6" height="1"/>
<rect x="4" y="4" width="8" height="1"/>
<rect x="3" y="5" width="10" height="1"/>
<rect x="3" y="6" width="10" height="1"/>
<rect x="3" y="7" width="10" height="1"/>
<rect x="4" y="8" width="8" height="1"/>
<rect x="5" y="9" width="6" height="1"/>
<rect x="5" y="12" width="6" height="1"/>
<rect x="6" y="13" width="4" height="1"/>
</g>
<g fill="#ff775f">
<rect x="1" y="6" width="2" height="1"/>
<rect x="2" y="5" width="1" height="1"/>
<rect x="2" y="7" width="1" height="1"/>
<rect x="13" y="6" width="2" height="1"/>
<rect x="13" y="5" width="1" height="1"/>
<rect x="13" y="7" width="1" height="1"/>
</g>
<g fill="#081016">
<rect x="6" y="5" width="1" height="1"/>
<rect x="9" y="5" width="1" height="1"/>
</g>
<g fill="#f5fbff">
<rect x="6" y="4" width="1" height="1"/>
<rect x="9" y="4" width="1" height="1"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 2.1 KiB

+1 -1
View File
@@ -17,7 +17,7 @@ read_when:
- **Proxy:** optional `channels.telegram.proxy` uses `undici.ProxyAgent` through grammY’s `client.baseFetch`. - **Proxy:** optional `channels.telegram.proxy` uses `undici.ProxyAgent` through grammY’s `client.baseFetch`.
- **Webhook support:** `webhook-set.ts` wraps `setWebhook/deleteWebhook`; `webhook.ts` hosts the callback with health + graceful shutdown. Gateway enables webhook mode when `channels.telegram.webhookUrl` is set (otherwise it long-polls). - **Webhook support:** `webhook-set.ts` wraps `setWebhook/deleteWebhook`; `webhook.ts` hosts the callback with health + graceful shutdown. Gateway enables webhook mode when `channels.telegram.webhookUrl` is set (otherwise it long-polls).
- **Sessions:** direct chats collapse into the agent main session (`agent:<agentId>:<mainKey>`); groups use `agent:<agentId>:telegram:group:<chatId>`; replies route back to the same channel. - **Sessions:** direct chats collapse into the agent main session (`agent:<agentId>:<mainKey>`); groups use `agent:<agentId>:telegram:group:<chatId>`; replies route back to the same channel.
- **Config knobs:** `channels.telegram.botToken`, `channels.telegram.dmPolicy`, `channels.telegram.groups` (allowlist + mention defaults), `channels.telegram.allowFrom`, `channels.telegram.groupAllowFrom`, `channels.telegram.groupPolicy`, `channels.telegram.mediaMaxMb`, `channels.telegram.linkPreview`, `channels.telegram.proxy`, `channels.telegram.webhookSecret`, `channels.telegram.webhookUrl`. - **Config knobs:** `channels.telegram.botToken`, `channels.telegram.dmPolicy`, `channels.telegram.groups` (allowlist + mention defaults), `channels.telegram.allowFrom`, `channels.telegram.groupAllowFrom`, `channels.telegram.groupPolicy`, `channels.telegram.mediaMaxMb`, `channels.telegram.proxy`, `channels.telegram.webhookSecret`, `channels.telegram.webhookUrl`.
- **Draft streaming:** optional `channels.telegram.streamMode` uses `sendMessageDraft` in private topic chats (Bot API 9.3+). This is separate from channel block streaming. - **Draft streaming:** optional `channels.telegram.streamMode` uses `sendMessageDraft` in private topic chats (Bot API 9.3+). This is separate from channel block streaming.
- **Tests:** grammy mocks cover DM + group mention gating and outbound send; more media/webhook fixtures still welcome. - **Tests:** grammy mocks cover DM + group mention gating and outbound send; more media/webhook fixtures still welcome.
-1
View File
@@ -525,7 +525,6 @@ Provider options:
- `channels.telegram.replyToMode`: `off | first | all` (default: `first`). - `channels.telegram.replyToMode`: `off | first | all` (default: `first`).
- `channels.telegram.textChunkLimit`: outbound chunk size (chars). - `channels.telegram.textChunkLimit`: outbound chunk size (chars).
- `channels.telegram.chunkMode`: `length` (default) or `newline` to split on newlines before length chunking. - `channels.telegram.chunkMode`: `length` (default) or `newline` to split on newlines before length chunking.
- `channels.telegram.linkPreview`: toggle link previews for outbound messages (default: true).
- `channels.telegram.streamMode`: `off | partial | block` (draft streaming). - `channels.telegram.streamMode`: `off | partial | block` (draft streaming).
- `channels.telegram.mediaMaxMb`: inbound/outbound media cap (MB). - `channels.telegram.mediaMaxMb`: inbound/outbound media cap (MB).
- `channels.telegram.retry`: retry policy for outbound Telegram API calls (attempts, minDelayMs, maxDelayMs, jitter). - `channels.telegram.retry`: retry policy for outbound Telegram API calls (attempts, minDelayMs, maxDelayMs, jitter).
-2
View File
@@ -89,8 +89,6 @@ Clawdbot ships with the pi‑ai catalog. These providers require **no**
- Gemini CLI OAuth is shipped as a bundled plugin (`google-gemini-cli-auth`, disabled by default). - Gemini CLI OAuth is shipped as a bundled plugin (`google-gemini-cli-auth`, disabled by default).
- Enable: `clawdbot plugins enable google-gemini-cli-auth` - Enable: `clawdbot plugins enable google-gemini-cli-auth`
- Login: `clawdbot models auth login --provider google-gemini-cli --set-default` - Login: `clawdbot models auth login --provider google-gemini-cli --set-default`
- Note: you do **not** paste a client id or secret into `clawdbot.json`. The CLI login flow stores
tokens in auth profiles on the gateway host.
### Z.AI (GLM) ### Z.AI (GLM)
-1
View File
@@ -1021,7 +1021,6 @@ Set `channels.telegram.configWrites: false` to block Telegram-initiated config w
], ],
historyLimit: 50, // include last N group messages as context (0 disables) historyLimit: 50, // include last N group messages as context (0 disables)
replyToMode: "first", // off | first | all replyToMode: "first", // off | first | all
linkPreview: true, // toggle outbound link previews
streamMode: "partial", // off | partial | block (draft streaming; separate from block streaming) streamMode: "partial", // off | partial | block (draft streaming; separate from block streaming)
draftChunk: { // optional; only for streamMode=block draftChunk: { // optional; only for streamMode=block
minChars: 200, minChars: 200,
+3 -36
View File
@@ -24,7 +24,6 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS,
- [How do I try the latest bits?](#how-do-i-try-the-latest-bits) - [How do I try the latest bits?](#how-do-i-try-the-latest-bits)
- [How long does install and onboarding usually take?](#how-long-does-install-and-onboarding-usually-take) - [How long does install and onboarding usually take?](#how-long-does-install-and-onboarding-usually-take)
- [Installer stuck? How do I get more feedback?](#installer-stuck-how-do-i-get-more-feedback) - [Installer stuck? How do I get more feedback?](#installer-stuck-how-do-i-get-more-feedback)
- [Windows install says git not found or clawdbot not recognized](#windows-install-says-git-not-found-or-clawdbot-not-recognized)
- [The docs didn’t answer my question - how do I get a better answer?](#the-docs-didnt-answer-my-question-how-do-i-get-a-better-answer) - [The docs didn’t answer my question - how do I get a better answer?](#the-docs-didnt-answer-my-question-how-do-i-get-a-better-answer)
- [How do I install Clawdbot on Linux?](#how-do-i-install-clawdbot-on-linux) - [How do I install Clawdbot on Linux?](#how-do-i-install-clawdbot-on-linux)
- [How do I install Clawdbot on a VPS?](#how-do-i-install-clawdbot-on-a-vps) - [How do I install Clawdbot on a VPS?](#how-do-i-install-clawdbot-on-a-vps)
@@ -40,7 +39,6 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS,
- [Is AWS Bedrock supported?](#is-aws-bedrock-supported) - [Is AWS Bedrock supported?](#is-aws-bedrock-supported)
- [How does Codex auth work?](#how-does-codex-auth-work) - [How does Codex auth work?](#how-does-codex-auth-work)
- [Do you support OpenAI subscription auth (Codex OAuth)?](#do-you-support-openai-subscription-auth-codex-oauth) - [Do you support OpenAI subscription auth (Codex OAuth)?](#do-you-support-openai-subscription-auth-codex-oauth)
- [How do I set up Gemini CLI OAuth](#how-do-i-set-up-gemini-cli-oauth)
- [Is a local model OK for casual chats?](#is-a-local-model-ok-for-casual-chats) - [Is a local model OK for casual chats?](#is-a-local-model-ok-for-casual-chats)
- [How do I keep hosted model traffic in a specific region?](#how-do-i-keep-hosted-model-traffic-in-a-specific-region) - [How do I keep hosted model traffic in a specific region?](#how-do-i-keep-hosted-model-traffic-in-a-specific-region)
- [Do I have to buy a Mac Mini to install this?](#do-i-have-to-buy-a-mac-mini-to-install-this) - [Do I have to buy a Mac Mini to install this?](#do-i-have-to-buy-a-mac-mini-to-install-this)
@@ -513,26 +511,6 @@ curl -fsSL https://clawd.bot/install.sh | bash -s -- --install-method git --verb
More options: [Installer flags](/install/installer). More options: [Installer flags](/install/installer).
### Windows install says git not found or clawdbot not recognized
Two common Windows issues:
**1) npm error spawn git / git not found**
- Install **Git for Windows** and make sure `git` is on your PATH.
- Close and reopen PowerShell, then re-run the installer.
**2) clawdbot is not recognized after install**
- Your npm global bin folder is not on PATH.
- Check the path:
```powershell
npm config get prefix
```
- Ensure `<prefix>\\bin` is on PATH (on most systems it is `%AppData%\\npm`).
- Close and reopen PowerShell after updating PATH.
If you want the smoothest Windows setup, use **WSL2** instead of native Windows.
Docs: [Windows](/platforms/windows).
### The docs didnt answer my question how do I get a better answer ### The docs didnt answer my question how do I get a better answer
Use the **hackable (git) install** so you have the full source and docs locally, then ask Use the **hackable (git) install** so you have the full source and docs locally, then ask
@@ -632,10 +610,9 @@ Docs: [Anthropic](/providers/anthropic), [OpenAI](/providers/openai),
Yes. You can authenticate with **Claude Code CLI OAuth** or a **setup-token** Yes. You can authenticate with **Claude Code CLI OAuth** or a **setup-token**
instead of an API key. This is the subscription path. instead of an API key. This is the subscription path.
Claude Pro/Max subscriptions **do not include an API key**, so this is the Important: you must verify with Anthropic that this usage is allowed under
correct approach for subscription accounts. Important: you must verify with their subscription policy and terms. If you want the most explicit, supported
Anthropic that this usage is allowed under their subscription policy and terms. path, use an Anthropic API key.
If you want the most explicit, supported path, use an Anthropic API key.
### How does Anthropic setuptoken auth work ### How does Anthropic setuptoken auth work
@@ -687,16 +664,6 @@ can import the CLI login or run the OAuth flow for you.
See [OAuth](/concepts/oauth), [Model providers](/concepts/model-providers), and [Wizard](/start/wizard). See [OAuth](/concepts/oauth), [Model providers](/concepts/model-providers), and [Wizard](/start/wizard).
### How do I set up Gemini CLI OAuth
Gemini CLI uses a **plugin auth flow**, not a client id or secret in `clawdbot.json`.
Steps:
1) Enable the plugin: `clawdbot plugins enable google-gemini-cli-auth`
2) Login: `clawdbot models auth login --provider google-gemini-cli --set-default`
This stores OAuth tokens in auth profiles on the gateway host. Details: [Model providers](/concepts/model-providers).
### Is a local model OK for casual chats ### Is a local model OK for casual chats
Usually no. Clawdbot needs large context + strong safety; small cards truncate and leak. If you must, run the **largest** MiniMax M2.1 build you can locally (LM Studio) and see [/gateway/local-models](/gateway/local-models). Smaller/quantized models increase prompt-injection risk - see [Security](/gateway/security). Usually no. Clawdbot needs large context + strong safety; small cards truncate and leak. If you must, run the **largest** MiniMax M2.1 build you can locally (LM Studio) and see [/gateway/local-models](/gateway/local-models). Smaller/quantized models increase prompt-injection risk - see [Security](/gateway/security).
-6
View File
@@ -114,9 +114,3 @@ Git requirement:
If you choose `-InstallMethod git` and Git is missing, the installer will print the If you choose `-InstallMethod git` and Git is missing, the installer will print the
Git for Windows link (`https://git-scm.com/download/win`) and exit. Git for Windows link (`https://git-scm.com/download/win`) and exit.
Common Windows issues:
- **npm error spawn git / ENOENT**: install Git for Windows and reopen PowerShell, then rerun the installer.
- **"clawdbot" is not recognized**: your npm global bin folder is not on PATH. Most systems use
`%AppData%\\npm`. You can also run `npm config get prefix` and add `\\bin` to PATH, then reopen PowerShell.
-16
View File
@@ -67,22 +67,6 @@ Plugins can register:
Plugins run **in‑process** with the Gateway, so treat them as trusted code. Plugins run **in‑process** with the Gateway, so treat them as trusted code.
Tool authoring guide: [Plugin agent tools](/plugins/agent-tools). Tool authoring guide: [Plugin agent tools](/plugins/agent-tools).
## Runtime helpers
Plugins can access selected core helpers via `api.runtime`. For telephony TTS:
```ts
const result = await api.runtime.tts.textToSpeechTelephony({
text: "Hello from Clawdbot",
cfg: api.config,
});
```
Notes:
- Uses core `messages.tts` configuration (OpenAI or ElevenLabs).
- Returns PCM audio buffer + sample rate. Plugins must resample/encode for providers.
- Edge TTS is not supported for telephony.
## Discovery & precedence ## Discovery & precedence
Clawdbot scans, in order: Clawdbot scans, in order:
-81
View File
@@ -104,87 +104,6 @@ Notes:
- `mock` is a local dev provider (no network calls). - `mock` is a local dev provider (no network calls).
- `skipSignatureVerification` is for local testing only. - `skipSignatureVerification` is for local testing only.
## TTS for calls
Voice Call uses the core `messages.tts` configuration (OpenAI or ElevenLabs) for
streaming speech on calls. You can override it under the plugin config with the
**same shape** — it deep‑merges with `messages.tts`.
```json5
{
tts: {
provider: "elevenlabs",
elevenlabs: {
voiceId: "pMsXgVXv3BLzUgSXRplE",
modelId: "eleven_multilingual_v2"
}
}
}
```
Notes:
- **Edge TTS is ignored for voice calls** (telephony audio needs PCM; Edge output is unreliable).
- Core TTS is used when Twilio media streaming is enabled; otherwise calls fall back to provider native voices.
### More examples
Use core TTS only (no override):
```json5
{
messages: {
tts: {
provider: "openai",
openai: { voice: "alloy" }
}
}
}
```
Override to ElevenLabs just for calls (keep core default elsewhere):
```json5
{
plugins: {
entries: {
"voice-call": {
config: {
tts: {
provider: "elevenlabs",
elevenlabs: {
apiKey: "elevenlabs_key",
voiceId: "pMsXgVXv3BLzUgSXRplE",
modelId: "eleven_multilingual_v2"
}
}
}
}
}
}
}
```
Override only the OpenAI model for calls (deep‑merge example):
```json5
{
plugins: {
entries: {
"voice-call": {
config: {
tts: {
openai: {
model: "gpt-4o-mini-tts",
voice: "marin"
}
}
}
}
}
}
}
```
## Inbound calls ## Inbound calls
Inbound policy defaults to `disabled`. To enable inbound calls, set: Inbound policy defaults to `disabled`. To enable inbound calls, set:
+3 -5
View File
@@ -16,7 +16,7 @@ and you configure everything via the `/setup` web wizard.
## One-click deploy ## One-click deploy
<a href="https://railway.app/new/template?template=https://github.com/vignesh07/clawdbot-railway-template" target="_blank" rel="noreferrer">Deploy on Railway</a> <a href="https://railway.com/deploy/clawdbot-railway-template" target="_blank" rel="noreferrer">Deploy on Railway</a>
After deploy, find your public URL in **Railway → your service → Settings → Domains**. After deploy, find your public URL in **Railway → your service → Settings → Domains**.
@@ -55,7 +55,6 @@ Attach a volume mounted at:
Set these variables on the service: Set these variables on the service:
- `SETUP_PASSWORD` (required) - `SETUP_PASSWORD` (required)
- `PORT=8080` (required — must match the port in Public Networking)
- `CLAWDBOT_STATE_DIR=/data/.clawdbot` (recommended) - `CLAWDBOT_STATE_DIR=/data/.clawdbot` (recommended)
- `CLAWDBOT_WORKSPACE_DIR=/data/workspace` (recommended) - `CLAWDBOT_WORKSPACE_DIR=/data/workspace` (recommended)
- `CLAWDBOT_GATEWAY_TOKEN` (recommended; treat as an admin secret) - `CLAWDBOT_GATEWAY_TOKEN` (recommended; treat as an admin secret)
@@ -83,9 +82,8 @@ If Telegram DMs are set to pairing, the setup wizard can approve the pairing cod
1) Go to https://discord.com/developers/applications 1) Go to https://discord.com/developers/applications
2) **New Application** → choose a name 2) **New Application** → choose a name
3) **Bot** → **Add Bot** 3) **Bot** → **Add Bot**
4) **Enable MESSAGE CONTENT INTENT** under Bot → Privileged Gateway Intents (required or the bot will crash on startup) 4) Copy the **Bot Token** and paste into `/setup`
5) Copy the **Bot Token** and paste into `/setup` 5) Invite the bot to your server (OAuth2 URL Generator; scopes: `bot`, `applications.commands`)
6) Invite the bot to your server (OAuth2 URL Generator; scopes: `bot`, `applications.commands`)
## Backups & migration ## Backups & migration
-7
View File
@@ -1,12 +1,5 @@
# Changelog # Changelog
## 2026.1.24
### Changes
- Breaking: voice-call TTS now uses core `messages.tts` (plugin TTS config deep‑merges with core).
- Telephony TTS supports OpenAI + ElevenLabs; Edge TTS is ignored for calls.
- Removed legacy `tts.model`/`tts.voice`/`tts.instructions` plugin fields.
## 2026.1.23 ## 2026.1.23
### Changes ### Changes
-21
View File
@@ -75,27 +75,6 @@ Notes:
- Twilio/Telnyx/Plivo require a **publicly reachable** webhook URL. - Twilio/Telnyx/Plivo require a **publicly reachable** webhook URL.
- `mock` is a local dev provider (no network calls). - `mock` is a local dev provider (no network calls).
## TTS for calls
Voice Call uses the core `messages.tts` configuration (OpenAI or ElevenLabs) for
streaming speech on calls. You can override it under the plugin config with the
same shape — overrides deep-merge with `messages.tts`.
```json5
{
tts: {
provider: "openai",
openai: {
voice: "alloy"
}
}
}
```
Notes:
- Edge TTS is ignored for voice calls (telephony audio needs PCM; Edge output is unreliable).
- Core TTS is used when Twilio media streaming is enabled; otherwise calls fall back to provider native voices.
## CLI ## CLI
```bash ```bash
+11 -207
View File
@@ -99,39 +99,16 @@
"label": "Media Stream Path", "label": "Media Stream Path",
"advanced": true "advanced": true
}, },
"tts.provider": { "tts.model": {
"label": "TTS Provider Override", "label": "TTS Model",
"help": "Deep-merges with messages.tts (Edge is ignored for calls).",
"advanced": true "advanced": true
}, },
"tts.openai.model": { "tts.voice": {
"label": "OpenAI TTS Model", "label": "TTS Voice",
"advanced": true "advanced": true
}, },
"tts.openai.voice": { "tts.instructions": {
"label": "OpenAI TTS Voice", "label": "TTS Instructions",
"advanced": true
},
"tts.openai.apiKey": {
"label": "OpenAI API Key",
"sensitive": true,
"advanced": true
},
"tts.elevenlabs.modelId": {
"label": "ElevenLabs Model ID",
"advanced": true
},
"tts.elevenlabs.voiceId": {
"label": "ElevenLabs Voice ID",
"advanced": true
},
"tts.elevenlabs.apiKey": {
"label": "ElevenLabs API Key",
"sensitive": true,
"advanced": true
},
"tts.elevenlabs.baseUrl": {
"label": "ElevenLabs Base URL",
"advanced": true "advanced": true
}, },
"publicUrl": { "publicUrl": {
@@ -393,193 +370,20 @@
"type": "object", "type": "object",
"additionalProperties": false, "additionalProperties": false,
"properties": { "properties": {
"auto": {
"type": "string",
"enum": [
"off",
"always",
"inbound",
"tagged"
]
},
"enabled": {
"type": "boolean"
},
"mode": {
"type": "string",
"enum": [
"final",
"all"
]
},
"provider": { "provider": {
"type": "string", "type": "string",
"enum": [ "enum": [
"openai", "openai"
"elevenlabs",
"edge"
] ]
}, },
"summaryModel": { "model": {
"type": "string" "type": "string"
}, },
"modelOverrides": { "voice": {
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": {
"type": "boolean"
},
"allowText": {
"type": "boolean"
},
"allowProvider": {
"type": "boolean"
},
"allowVoice": {
"type": "boolean"
},
"allowModelId": {
"type": "boolean"
},
"allowVoiceSettings": {
"type": "boolean"
},
"allowNormalization": {
"type": "boolean"
},
"allowSeed": {
"type": "boolean"
}
}
},
"elevenlabs": {
"type": "object",
"additionalProperties": false,
"properties": {
"apiKey": {
"type": "string"
},
"baseUrl": {
"type": "string"
},
"voiceId": {
"type": "string"
},
"modelId": {
"type": "string"
},
"seed": {
"type": "integer",
"minimum": 0,
"maximum": 4294967295
},
"applyTextNormalization": {
"type": "string",
"enum": [
"auto",
"on",
"off"
]
},
"languageCode": {
"type": "string"
},
"voiceSettings": {
"type": "object",
"additionalProperties": false,
"properties": {
"stability": {
"type": "number",
"minimum": 0,
"maximum": 1
},
"similarityBoost": {
"type": "number",
"minimum": 0,
"maximum": 1
},
"style": {
"type": "number",
"minimum": 0,
"maximum": 1
},
"useSpeakerBoost": {
"type": "boolean"
},
"speed": {
"type": "number",
"minimum": 0.5,
"maximum": 2
}
}
}
}
},
"openai": {
"type": "object",
"additionalProperties": false,
"properties": {
"apiKey": {
"type": "string"
},
"model": {
"type": "string"
},
"voice": {
"type": "string"
}
}
},
"edge": {
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": {
"type": "boolean"
},
"voice": {
"type": "string"
},
"lang": {
"type": "string"
},
"outputFormat": {
"type": "string"
},
"pitch": {
"type": "string"
},
"rate": {
"type": "string"
},
"volume": {
"type": "string"
},
"saveSubtitles": {
"type": "boolean"
},
"proxy": {
"type": "string"
},
"timeoutMs": {
"type": "integer",
"minimum": 1000,
"maximum": 120000
}
}
},
"prefsPath": {
"type": "string" "type": "string"
}, },
"maxTextLength": { "instructions": {
"type": "integer", "type": "string"
"minimum": 1
},
"timeoutMs": {
"type": "integer",
"minimum": 1000,
"maximum": 120000
} }
} }
}, },
+3 -21
View File
@@ -74,26 +74,9 @@ const voiceCallConfigSchema = {
}, },
"streaming.sttModel": { label: "Realtime STT Model", advanced: true }, "streaming.sttModel": { label: "Realtime STT Model", advanced: true },
"streaming.streamPath": { label: "Media Stream Path", advanced: true }, "streaming.streamPath": { label: "Media Stream Path", advanced: true },
"tts.provider": { "tts.model": { label: "TTS Model", advanced: true },
label: "TTS Provider Override", "tts.voice": { label: "TTS Voice", advanced: true },
help: "Deep-merges with messages.tts (Edge is ignored for calls).", "tts.instructions": { label: "TTS Instructions", advanced: true },
advanced: true,
},
"tts.openai.model": { label: "OpenAI TTS Model", advanced: true },
"tts.openai.voice": { label: "OpenAI TTS Voice", advanced: true },
"tts.openai.apiKey": {
label: "OpenAI API Key",
sensitive: true,
advanced: true,
},
"tts.elevenlabs.modelId": { label: "ElevenLabs Model ID", advanced: true },
"tts.elevenlabs.voiceId": { label: "ElevenLabs Voice ID", advanced: true },
"tts.elevenlabs.apiKey": {
label: "ElevenLabs API Key",
sensitive: true,
advanced: true,
},
"tts.elevenlabs.baseUrl": { label: "ElevenLabs Base URL", advanced: true },
publicUrl: { label: "Public Webhook URL", advanced: true }, publicUrl: { label: "Public Webhook URL", advanced: true },
skipSignatureVerification: { skipSignatureVerification: {
label: "Skip Signature Verification", label: "Skip Signature Verification",
@@ -178,7 +161,6 @@ const voiceCallPlugin = {
runtimePromise = createVoiceCallRuntime({ runtimePromise = createVoiceCallRuntime({
config: cfg, config: cfg,
coreConfig: api.config as CoreConfig, coreConfig: api.config as CoreConfig,
ttsRuntime: api.runtime.tts,
logger: api.logger, logger: api.logger,
}); });
} }
+22 -73
View File
@@ -82,82 +82,31 @@ export const SttConfigSchema = z
.default({ provider: "openai", model: "whisper-1" }); .default({ provider: "openai", model: "whisper-1" });
export type SttConfig = z.infer<typeof SttConfigSchema>; export type SttConfig = z.infer<typeof SttConfigSchema>;
export const TtsProviderSchema = z.enum(["openai", "elevenlabs", "edge"]);
export const TtsModeSchema = z.enum(["final", "all"]);
export const TtsAutoSchema = z.enum(["off", "always", "inbound", "tagged"]);
export const TtsConfigSchema = z export const TtsConfigSchema = z
.object({ .object({
auto: TtsAutoSchema.optional(), /** TTS provider (currently only OpenAI supported) */
enabled: z.boolean().optional(), provider: z.literal("openai").default("openai"),
mode: TtsModeSchema.optional(), /**
provider: TtsProviderSchema.optional(), * TTS model to use:
summaryModel: z.string().optional(), * - gpt-4o-mini-tts: newest, supports instructions for tone/style control (recommended)
modelOverrides: z * - tts-1: lower latency
.object({ * - tts-1-hd: higher quality
enabled: z.boolean().optional(), */
allowText: z.boolean().optional(), model: z.string().min(1).default("gpt-4o-mini-tts"),
allowProvider: z.boolean().optional(), /**
allowVoice: z.boolean().optional(), * Voice ID. For best quality, use marin or cedar.
allowModelId: z.boolean().optional(), * All voices: alloy, ash, ballad, coral, echo, fable, nova, onyx, sage, shimmer, verse, marin, cedar
allowVoiceSettings: z.boolean().optional(), */
allowNormalization: z.boolean().optional(), voice: z.string().min(1).default("coral"),
allowSeed: z.boolean().optional(), /**
}) * Instructions for speech style (only works with gpt-4o-mini-tts).
.strict() * Examples: "Speak in a cheerful tone", "Talk like a sympathetic customer service agent"
.optional(), */
elevenlabs: z instructions: z.string().optional(),
.object({
apiKey: z.string().optional(),
baseUrl: z.string().optional(),
voiceId: z.string().optional(),
modelId: z.string().optional(),
seed: z.number().int().min(0).max(4294967295).optional(),
applyTextNormalization: z.enum(["auto", "on", "off"]).optional(),
languageCode: z.string().optional(),
voiceSettings: z
.object({
stability: z.number().min(0).max(1).optional(),
similarityBoost: z.number().min(0).max(1).optional(),
style: z.number().min(0).max(1).optional(),
useSpeakerBoost: z.boolean().optional(),
speed: z.number().min(0.5).max(2).optional(),
})
.strict()
.optional(),
})
.strict()
.optional(),
openai: z
.object({
apiKey: z.string().optional(),
model: z.string().optional(),
voice: z.string().optional(),
})
.strict()
.optional(),
edge: z
.object({
enabled: z.boolean().optional(),
voice: z.string().optional(),
lang: z.string().optional(),
outputFormat: z.string().optional(),
pitch: z.string().optional(),
rate: z.string().optional(),
volume: z.string().optional(),
saveSubtitles: z.boolean().optional(),
proxy: z.string().optional(),
timeoutMs: z.number().int().min(1000).max(120000).optional(),
})
.strict()
.optional(),
prefsPath: z.string().optional(),
maxTextLength: z.number().int().min(1).optional(),
timeoutMs: z.number().int().min(1000).max(120000).optional(),
}) })
.strict() .strict()
.optional(); .default({ provider: "openai", model: "gpt-4o-mini-tts", voice: "coral" });
export type VoiceCallTtsConfig = z.infer<typeof TtsConfigSchema>; export type TtsConfig = z.infer<typeof TtsConfigSchema>;
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// Webhook Server Configuration // Webhook Server Configuration
@@ -358,7 +307,7 @@ export const VoiceCallConfigSchema = z
/** STT configuration */ /** STT configuration */
stt: SttConfigSchema, stt: SttConfigSchema,
/** TTS override (deep-merges with core messages.tts) */ /** TTS configuration */
tts: TtsConfigSchema, tts: TtsConfigSchema,
/** Store path for call logs */ /** Store path for call logs */
-6
View File
@@ -2,16 +2,10 @@ import fs from "node:fs";
import path from "node:path"; import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url"; import { fileURLToPath, pathToFileURL } from "node:url";
import type { VoiceCallTtsConfig } from "./config.js";
export type CoreConfig = { export type CoreConfig = {
session?: { session?: {
store?: string; store?: string;
}; };
messages?: {
tts?: VoiceCallTtsConfig;
};
[key: string]: unknown;
}; };
type CoreAgentDeps = { type CoreAgentDeps = {
+2 -4
View File
@@ -143,7 +143,7 @@ export class CallManager {
// For notify mode with a message, use inline TwiML with <Say> // For notify mode with a message, use inline TwiML with <Say>
let inlineTwiml: string | undefined; let inlineTwiml: string | undefined;
if (mode === "notify" && initialMessage) { if (mode === "notify" && initialMessage) {
const pollyVoice = mapVoiceToPolly(this.config.tts?.openai?.voice); const pollyVoice = mapVoiceToPolly(this.config.tts.voice);
inlineTwiml = this.generateNotifyTwiml(initialMessage, pollyVoice); inlineTwiml = this.generateNotifyTwiml(initialMessage, pollyVoice);
console.log( console.log(
`[voice-call] Using inline TwiML for notify mode (voice: ${pollyVoice})`, `[voice-call] Using inline TwiML for notify mode (voice: ${pollyVoice})`,
@@ -210,13 +210,11 @@ export class CallManager {
this.addTranscriptEntry(call, "bot", text); this.addTranscriptEntry(call, "bot", text);
// Play TTS // Play TTS
const voice =
this.provider?.name === "twilio" ? this.config.tts?.openai?.voice : undefined;
await this.provider.playTts({ await this.provider.playTts({
callId, callId,
providerCallId: call.providerCallId, providerCallId: call.providerCallId,
text, text,
voice, voice: this.config.tts.voice,
}); });
return { success: true }; return { success: true };
@@ -68,7 +68,7 @@ export async function initiateCall(
// For notify mode with a message, use inline TwiML with <Say>. // For notify mode with a message, use inline TwiML with <Say>.
let inlineTwiml: string | undefined; let inlineTwiml: string | undefined;
if (mode === "notify" && initialMessage) { if (mode === "notify" && initialMessage) {
const pollyVoice = mapVoiceToPolly(ctx.config.tts?.openai?.voice); const pollyVoice = mapVoiceToPolly(ctx.config.tts.voice);
inlineTwiml = generateNotifyTwiml(initialMessage, pollyVoice); inlineTwiml = generateNotifyTwiml(initialMessage, pollyVoice);
console.log(`[voice-call] Using inline TwiML for notify mode (voice: ${pollyVoice})`); console.log(`[voice-call] Using inline TwiML for notify mode (voice: ${pollyVoice})`);
} }
@@ -120,13 +120,11 @@ export async function speak(
addTranscriptEntry(call, "bot", text); addTranscriptEntry(call, "bot", text);
const voice =
ctx.provider?.name === "twilio" ? ctx.config.tts?.openai?.voice : undefined;
await ctx.provider.playTts({ await ctx.provider.playTts({
callId, callId,
providerCallId: call.providerCallId, providerCallId: call.providerCallId,
text, text,
voice, voice: ctx.config.tts.voice,
}); });
return { success: true }; return { success: true };
@@ -246,3 +244,4 @@ export async function endCall(
return { success: false, error: err instanceof Error ? err.message : String(err) }; return { success: false, error: err instanceof Error ? err.message : String(err) };
} }
} }
+14 -14
View File
@@ -15,9 +15,9 @@ import type {
WebhookVerificationResult, WebhookVerificationResult,
} from "../types.js"; } from "../types.js";
import { escapeXml, mapVoiceToPolly } from "../voice-mapping.js"; import { escapeXml, mapVoiceToPolly } from "../voice-mapping.js";
import { chunkAudio } from "../telephony-audio.js";
import type { TelephonyTtsProvider } from "../telephony-tts.js";
import type { VoiceCallProvider } from "./base.js"; import type { VoiceCallProvider } from "./base.js";
import type { OpenAITTSProvider } from "./tts-openai.js";
import { chunkAudio } from "./tts-openai.js";
import { twilioApiRequest } from "./twilio/api.js"; import { twilioApiRequest } from "./twilio/api.js";
import { verifyTwilioProviderWebhook } from "./twilio/webhook.js"; import { verifyTwilioProviderWebhook } from "./twilio/webhook.js";
@@ -53,8 +53,8 @@ export class TwilioProvider implements VoiceCallProvider {
/** Current public webhook URL (set when tunnel starts or from config) */ /** Current public webhook URL (set when tunnel starts or from config) */
private currentPublicUrl: string | null = null; private currentPublicUrl: string | null = null;
/** Optional telephony TTS provider for streaming TTS */ /** Optional OpenAI TTS provider for streaming TTS */
private ttsProvider: TelephonyTtsProvider | null = null; private ttsProvider: OpenAITTSProvider | null = null;
/** Optional media stream handler for sending audio */ /** Optional media stream handler for sending audio */
private mediaStreamHandler: MediaStreamHandler | null = null; private mediaStreamHandler: MediaStreamHandler | null = null;
@@ -119,7 +119,7 @@ export class TwilioProvider implements VoiceCallProvider {
return this.currentPublicUrl; return this.currentPublicUrl;
} }
setTTSProvider(provider: TelephonyTtsProvider): void { setTTSProvider(provider: OpenAITTSProvider): void {
this.ttsProvider = provider; this.ttsProvider = provider;
} }
@@ -454,13 +454,13 @@ export class TwilioProvider implements VoiceCallProvider {
* Play TTS audio via Twilio. * Play TTS audio via Twilio.
* *
* Two modes: * Two modes:
* 1. Core TTS + Media Streams: If TTS provider and media stream are available, * 1. OpenAI TTS + Media Streams: If TTS provider and media stream are available,
* generates audio via core TTS and streams it through WebSocket (preferred). * generates audio via OpenAI and streams it through WebSocket (preferred).
* 2. TwiML <Say>: Falls back to Twilio's native TTS with Polly voices. * 2. TwiML <Say>: Falls back to Twilio's native TTS with Polly voices.
* Note: This may not work on all Twilio accounts. * Note: This may not work on all Twilio accounts.
*/ */
async playTts(input: PlayTtsInput): Promise<void> { async playTts(input: PlayTtsInput): Promise<void> {
// Try telephony TTS via media stream first (if configured) // Try OpenAI TTS via media stream first (if configured)
const streamSid = this.callStreamMap.get(input.providerCallId); const streamSid = this.callStreamMap.get(input.providerCallId);
if (this.ttsProvider && this.mediaStreamHandler && streamSid) { if (this.ttsProvider && this.mediaStreamHandler && streamSid) {
try { try {
@@ -468,7 +468,7 @@ export class TwilioProvider implements VoiceCallProvider {
return; return;
} catch (err) { } catch (err) {
console.warn( console.warn(
`[voice-call] Telephony TTS failed, falling back to Twilio <Say>:`, `[voice-call] OpenAI TTS failed, falling back to Twilio <Say>:`,
err instanceof Error ? err.message : err, err instanceof Error ? err.message : err,
); );
// Fall through to TwiML <Say> fallback // Fall through to TwiML <Say> fallback
@@ -484,7 +484,7 @@ export class TwilioProvider implements VoiceCallProvider {
} }
console.warn( console.warn(
"[voice-call] Using TwiML <Say> fallback - telephony TTS not configured or media stream not active", "[voice-call] Using TwiML <Say> fallback - OpenAI TTS not configured or media stream not active",
); );
const pollyVoice = mapVoiceToPolly(input.voice); const pollyVoice = mapVoiceToPolly(input.voice);
@@ -502,8 +502,8 @@ export class TwilioProvider implements VoiceCallProvider {
} }
/** /**
* Play TTS via core TTS and Twilio Media Streams. * Play TTS via OpenAI and Twilio Media Streams.
* Generates audio with core TTS, converts to mu-law, and streams via WebSocket. * Generates audio with OpenAI TTS, converts to mu-law, and streams via WebSocket.
* Uses a jitter buffer to smooth out timing variations. * Uses a jitter buffer to smooth out timing variations.
*/ */
private async playTtsViaStream( private async playTtsViaStream(
@@ -514,8 +514,8 @@ export class TwilioProvider implements VoiceCallProvider {
throw new Error("TTS provider and media stream handler required"); throw new Error("TTS provider and media stream handler required");
} }
// Generate audio with core TTS (returns mu-law at 8kHz) // Generate audio with OpenAI TTS (returns mu-law at 8kHz)
const muLawAudio = await this.ttsProvider.synthesizeForTelephony(text); const muLawAudio = await this.ttsProvider.synthesizeForTwilio(text);
// Stream audio in 20ms chunks (160 bytes at 8kHz mu-law) // Stream audio in 20ms chunks (160 bytes at 8kHz mu-law)
const CHUNK_SIZE = 160; const CHUNK_SIZE = 160;
+13 -12
View File
@@ -6,9 +6,8 @@ import type { VoiceCallProvider } from "./providers/base.js";
import { MockProvider } from "./providers/mock.js"; import { MockProvider } from "./providers/mock.js";
import { PlivoProvider } from "./providers/plivo.js"; import { PlivoProvider } from "./providers/plivo.js";
import { TelnyxProvider } from "./providers/telnyx.js"; import { TelnyxProvider } from "./providers/telnyx.js";
import { OpenAITTSProvider } from "./providers/tts-openai.js";
import { TwilioProvider } from "./providers/twilio.js"; import { TwilioProvider } from "./providers/twilio.js";
import type { TelephonyTtsRuntime } from "./telephony-tts.js";
import { createTelephonyTtsProvider } from "./telephony-tts.js";
import { startTunnel, type TunnelResult } from "./tunnel.js"; import { startTunnel, type TunnelResult } from "./tunnel.js";
import { import {
cleanupTailscaleExposure, cleanupTailscaleExposure,
@@ -82,10 +81,9 @@ function resolveProvider(config: VoiceCallConfig): VoiceCallProvider {
export async function createVoiceCallRuntime(params: { export async function createVoiceCallRuntime(params: {
config: VoiceCallConfig; config: VoiceCallConfig;
coreConfig: CoreConfig; coreConfig: CoreConfig;
ttsRuntime?: TelephonyTtsRuntime;
logger?: Logger; logger?: Logger;
}): Promise<VoiceCallRuntime> { }): Promise<VoiceCallRuntime> {
const { config, coreConfig, ttsRuntime, logger } = params; const { config, coreConfig, logger } = params;
const log = logger ?? { const log = logger ?? {
info: console.log, info: console.log,
warn: console.warn, warn: console.warn,
@@ -151,24 +149,27 @@ export async function createVoiceCallRuntime(params: {
if (provider.name === "twilio" && config.streaming?.enabled) { if (provider.name === "twilio" && config.streaming?.enabled) {
const twilioProvider = provider as TwilioProvider; const twilioProvider = provider as TwilioProvider;
if (ttsRuntime?.textToSpeechTelephony) { const openaiApiKey =
config.streaming.openaiApiKey || process.env.OPENAI_API_KEY;
if (openaiApiKey) {
try { try {
const ttsProvider = createTelephonyTtsProvider({ const ttsProvider = new OpenAITTSProvider({
coreConfig, apiKey: openaiApiKey,
ttsOverride: config.tts, voice: config.tts.voice,
runtime: ttsRuntime, model: config.tts.model,
instructions: config.tts.instructions,
}); });
twilioProvider.setTTSProvider(ttsProvider); twilioProvider.setTTSProvider(ttsProvider);
log.info("[voice-call] Telephony TTS provider configured"); log.info("[voice-call] OpenAI TTS provider configured");
} catch (err) { } catch (err) {
log.warn( log.warn(
`[voice-call] Failed to initialize telephony TTS: ${ `[voice-call] Failed to initialize OpenAI TTS: ${
err instanceof Error ? err.message : String(err) err instanceof Error ? err.message : String(err)
}`, }`,
); );
} }
} else { } else {
log.warn("[voice-call] Telephony TTS unavailable; streaming TTS disabled"); log.warn("[voice-call] OpenAI TTS key missing; streaming TTS disabled");
} }
const mediaHandler = webhookServer.getMediaStreamHandler(); const mediaHandler = webhookServer.getMediaStreamHandler();
@@ -1,88 +0,0 @@
const TELEPHONY_SAMPLE_RATE = 8000;
function clamp16(value: number): number {
return Math.max(-32768, Math.min(32767, value));
}
/**
* Resample 16-bit PCM (little-endian mono) to 8kHz using linear interpolation.
*/
export function resamplePcmTo8k(input: Buffer, inputSampleRate: number): Buffer {
if (inputSampleRate === TELEPHONY_SAMPLE_RATE) return input;
const inputSamples = Math.floor(input.length / 2);
if (inputSamples === 0) return Buffer.alloc(0);
const ratio = inputSampleRate / TELEPHONY_SAMPLE_RATE;
const outputSamples = Math.floor(inputSamples / ratio);
const output = Buffer.alloc(outputSamples * 2);
for (let i = 0; i < outputSamples; i++) {
const srcPos = i * ratio;
const srcIndex = Math.floor(srcPos);
const frac = srcPos - srcIndex;
const s0 = input.readInt16LE(srcIndex * 2);
const s1Index = Math.min(srcIndex + 1, inputSamples - 1);
const s1 = input.readInt16LE(s1Index * 2);
const sample = Math.round(s0 + frac * (s1 - s0));
output.writeInt16LE(clamp16(sample), i * 2);
}
return output;
}
/**
* Convert 16-bit PCM to 8-bit mu-law (G.711).
*/
export function pcmToMulaw(pcm: Buffer): Buffer {
const samples = Math.floor(pcm.length / 2);
const mulaw = Buffer.alloc(samples);
for (let i = 0; i < samples; i++) {
const sample = pcm.readInt16LE(i * 2);
mulaw[i] = linearToMulaw(sample);
}
return mulaw;
}
export function convertPcmToMulaw8k(
pcm: Buffer,
inputSampleRate: number,
): Buffer {
const pcm8k = resamplePcmTo8k(pcm, inputSampleRate);
return pcmToMulaw(pcm8k);
}
/**
* Chunk audio buffer into 20ms frames for streaming (8kHz mono mu-law).
*/
export function chunkAudio(
audio: Buffer,
chunkSize = 160,
): Generator<Buffer, void, unknown> {
return (function* () {
for (let i = 0; i < audio.length; i += chunkSize) {
yield audio.subarray(i, Math.min(i + chunkSize, audio.length));
}
})();
}
function linearToMulaw(sample: number): number {
const BIAS = 132;
const CLIP = 32635;
const sign = sample < 0 ? 0x80 : 0;
if (sample < 0) sample = -sample;
if (sample > CLIP) sample = CLIP;
sample += BIAS;
let exponent = 7;
for (let expMask = 0x4000; (sample & expMask) === 0 && exponent > 0; exponent--) {
expMask >>= 1;
}
const mantissa = (sample >> (exponent + 3)) & 0x0f;
return ~(sign | (exponent << 4) | mantissa) & 0xff;
}
@@ -1,95 +0,0 @@
import type { CoreConfig } from "./core-bridge.js";
import type { VoiceCallTtsConfig } from "./config.js";
import { convertPcmToMulaw8k } from "./telephony-audio.js";
export type TelephonyTtsRuntime = {
textToSpeechTelephony: (params: {
text: string;
cfg: CoreConfig;
prefsPath?: string;
}) => Promise<{
success: boolean;
audioBuffer?: Buffer;
sampleRate?: number;
provider?: string;
error?: string;
}>;
};
export type TelephonyTtsProvider = {
synthesizeForTelephony: (text: string) => Promise<Buffer>;
};
export function createTelephonyTtsProvider(params: {
coreConfig: CoreConfig;
ttsOverride?: VoiceCallTtsConfig;
runtime: TelephonyTtsRuntime;
}): TelephonyTtsProvider {
const { coreConfig, ttsOverride, runtime } = params;
const mergedConfig = applyTtsOverride(coreConfig, ttsOverride);
return {
synthesizeForTelephony: async (text: string) => {
const result = await runtime.textToSpeechTelephony({
text,
cfg: mergedConfig,
});
if (!result.success || !result.audioBuffer || !result.sampleRate) {
throw new Error(result.error ?? "TTS conversion failed");
}
return convertPcmToMulaw8k(result.audioBuffer, result.sampleRate);
},
};
}
function applyTtsOverride(
coreConfig: CoreConfig,
override?: VoiceCallTtsConfig,
): CoreConfig {
if (!override) return coreConfig;
const base = coreConfig.messages?.tts;
const merged = mergeTtsConfig(base, override);
if (!merged) return coreConfig;
return {
...coreConfig,
messages: {
...(coreConfig.messages ?? {}),
tts: merged,
},
};
}
function mergeTtsConfig(
base?: VoiceCallTtsConfig,
override?: VoiceCallTtsConfig,
): VoiceCallTtsConfig | undefined {
if (!base && !override) return undefined;
if (!override) return base;
if (!base) return override;
return deepMerge(base, override);
}
function deepMerge<T>(base: T, override: T): T {
if (!isPlainObject(base) || !isPlainObject(override)) {
return override;
}
const result: Record<string, unknown> = { ...base };
for (const [key, value] of Object.entries(override)) {
if (value === undefined) continue;
const existing = (base as Record<string, unknown>)[key];
if (isPlainObject(existing) && isPlainObject(value)) {
result[key] = deepMerge(existing, value);
} else {
result[key] = value;
}
}
return result as T;
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
+25 -73
View File
@@ -1,5 +1,5 @@
import crypto from "node:crypto"; import crypto from "node:crypto";
import type { ChildProcessWithoutNullStreams } from "node:child_process"; import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
import path from "node:path"; import path from "node:path";
import type { AgentTool, AgentToolResult } from "@mariozechner/pi-agent-core"; import type { AgentTool, AgentToolResult } from "@mariozechner/pi-agent-core";
import { Type } from "@sinclair/typebox"; import { Type } from "@sinclair/typebox";
@@ -27,7 +27,6 @@ import {
} from "../infra/shell-env.js"; } from "../infra/shell-env.js";
import { enqueueSystemEvent } from "../infra/system-events.js"; import { enqueueSystemEvent } from "../infra/system-events.js";
import { logInfo, logWarn } from "../logger.js"; import { logInfo, logWarn } from "../logger.js";
import { formatSpawnError, spawnWithFallback } from "../process/spawn-utils.js";
import { import {
type ProcessSession, type ProcessSession,
type SessionStdin, type SessionStdin,
@@ -363,38 +362,23 @@ async function runExecProcess(opts: {
let stdin: SessionStdin | undefined; let stdin: SessionStdin | undefined;
if (opts.sandbox) { if (opts.sandbox) {
const { child: spawned } = await spawnWithFallback({ child = spawn(
argv: [ "docker",
"docker", buildDockerExecArgs({
...buildDockerExecArgs({ containerName: opts.sandbox.containerName,
containerName: opts.sandbox.containerName, command: opts.command,
command: opts.command, workdir: opts.containerWorkdir ?? opts.sandbox.containerWorkdir,
workdir: opts.containerWorkdir ?? opts.sandbox.containerWorkdir, env: opts.env,
env: opts.env, tty: opts.usePty,
tty: opts.usePty, }),
}), {
],
options: {
cwd: opts.workdir, cwd: opts.workdir,
env: process.env, env: process.env,
detached: process.platform !== "win32", detached: process.platform !== "win32",
stdio: ["pipe", "pipe", "pipe"], stdio: ["pipe", "pipe", "pipe"],
windowsHide: true, windowsHide: true,
}, },
fallbacks: [ ) as ChildProcessWithoutNullStreams;
{
label: "no-detach",
options: { detached: false },
},
],
onFallback: (err, fallback) => {
const errText = formatSpawnError(err);
const warning = `Warning: spawn failed (${errText}); retrying with ${fallback.label}.`;
logWarn(`exec: spawn failed (${errText}); retrying with ${fallback.label}.`);
opts.warnings.push(warning);
},
});
child = spawned as ChildProcessWithoutNullStreams;
stdin = child.stdin; stdin = child.stdin;
} else if (opts.usePty) { } else if (opts.usePty) {
const { shell, args: shellArgs } = getShellConfig(); const { shell, args: shellArgs } = getShellConfig();
@@ -438,56 +422,24 @@ async function runExecProcess(opts: {
const warning = `Warning: PTY spawn failed (${errText}); retrying without PTY for \`${opts.command}\`.`; const warning = `Warning: PTY spawn failed (${errText}); retrying without PTY for \`${opts.command}\`.`;
logWarn(`exec: PTY spawn failed (${errText}); retrying without PTY for "${opts.command}".`); logWarn(`exec: PTY spawn failed (${errText}); retrying without PTY for "${opts.command}".`);
opts.warnings.push(warning); opts.warnings.push(warning);
const { child: spawned } = await spawnWithFallback({ child = spawn(shell, [...shellArgs, opts.command], {
argv: [shell, ...shellArgs, opts.command],
options: {
cwd: opts.workdir,
env: opts.env,
detached: process.platform !== "win32",
stdio: ["pipe", "pipe", "pipe"],
windowsHide: true,
},
fallbacks: [
{
label: "no-detach",
options: { detached: false },
},
],
onFallback: (fallbackErr, fallback) => {
const fallbackText = formatSpawnError(fallbackErr);
const fallbackWarning = `Warning: spawn failed (${fallbackText}); retrying with ${fallback.label}.`;
logWarn(`exec: spawn failed (${fallbackText}); retrying with ${fallback.label}.`);
opts.warnings.push(fallbackWarning);
},
});
child = spawned as ChildProcessWithoutNullStreams;
stdin = child.stdin;
}
} else {
const { shell, args: shellArgs } = getShellConfig();
const { child: spawned } = await spawnWithFallback({
argv: [shell, ...shellArgs, opts.command],
options: {
cwd: opts.workdir, cwd: opts.workdir,
env: opts.env, env: opts.env,
detached: process.platform !== "win32", detached: process.platform !== "win32",
stdio: ["pipe", "pipe", "pipe"], stdio: ["pipe", "pipe", "pipe"],
windowsHide: true, windowsHide: true,
}, }) as ChildProcessWithoutNullStreams;
fallbacks: [ stdin = child.stdin;
{ }
label: "no-detach", } else {
options: { detached: false }, const { shell, args: shellArgs } = getShellConfig();
}, child = spawn(shell, [...shellArgs, opts.command], {
], cwd: opts.workdir,
onFallback: (err, fallback) => { env: opts.env,
const errText = formatSpawnError(err); detached: process.platform !== "win32",
const warning = `Warning: spawn failed (${errText}); retrying with ${fallback.label}.`; stdio: ["pipe", "pipe", "pipe"],
logWarn(`exec: spawn failed (${errText}); retrying with ${fallback.label}.`); windowsHide: true,
opts.warnings.push(warning); }) as ChildProcessWithoutNullStreams;
},
});
child = spawned as ChildProcessWithoutNullStreams;
stdin = child.stdin; stdin = child.stdin;
} }
+2 -19
View File
@@ -1,8 +1,7 @@
import type { NoticeLevel, ReasoningLevel } from "../thinking.js"; import type { ReasoningLevel } from "../thinking.js";
import { import {
type ElevatedLevel, type ElevatedLevel,
normalizeElevatedLevel, normalizeElevatedLevel,
normalizeNoticeLevel,
normalizeReasoningLevel, normalizeReasoningLevel,
normalizeThinkLevel, normalizeThinkLevel,
normalizeVerboseLevel, normalizeVerboseLevel,
@@ -113,22 +112,6 @@ export function extractVerboseDirective(body?: string): {
}; };
} }
export function extractNoticeDirective(body?: string): {
cleaned: string;
noticeLevel?: NoticeLevel;
rawLevel?: string;
hasDirective: boolean;
} {
if (!body) return { cleaned: "", hasDirective: false };
const extracted = extractLevelDirective(body, ["notice", "notices"], normalizeNoticeLevel);
return {
cleaned: extracted.cleaned,
noticeLevel: extracted.level,
rawLevel: extracted.rawLevel,
hasDirective: extracted.hasDirective,
};
}
export function extractElevatedDirective(body?: string): { export function extractElevatedDirective(body?: string): {
cleaned: string; cleaned: string;
elevatedLevel?: ElevatedLevel; elevatedLevel?: ElevatedLevel;
@@ -169,5 +152,5 @@ export function extractStatusDirective(body?: string): {
return extractSimpleDirective(body, ["status"]); return extractSimpleDirective(body, ["status"]);
} }
export type { ElevatedLevel, NoticeLevel, ReasoningLevel, ThinkLevel, VerboseLevel }; export type { ElevatedLevel, ReasoningLevel, ThinkLevel, VerboseLevel };
export { extractExecDirective } from "./exec/directive.js"; export { extractExecDirective } from "./exec/directive.js";
-11
View File
@@ -1,6 +1,5 @@
export type ThinkLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh"; export type ThinkLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh";
export type VerboseLevel = "off" | "on" | "full"; export type VerboseLevel = "off" | "on" | "full";
export type NoticeLevel = "off" | "on" | "full";
export type ElevatedLevel = "off" | "on" | "ask" | "full"; export type ElevatedLevel = "off" | "on" | "ask" | "full";
export type ElevatedMode = "off" | "ask" | "full"; export type ElevatedMode = "off" | "ask" | "full";
export type ReasoningLevel = "off" | "on" | "stream"; export type ReasoningLevel = "off" | "on" | "stream";
@@ -94,16 +93,6 @@ export function normalizeVerboseLevel(raw?: string | null): VerboseLevel | undef
return undefined; return undefined;
} }
// Normalize system notice flags used to toggle system notifications.
export function normalizeNoticeLevel(raw?: string | null): NoticeLevel | undefined {
if (!raw) return undefined;
const key = raw.toLowerCase();
if (["off", "false", "no", "0"].includes(key)) return "off";
if (["full", "all", "everything"].includes(key)) return "full";
if (["on", "minimal", "true", "yes", "1"].includes(key)) return "on";
return undefined;
}
// Normalize response-usage display modes used to toggle per-response usage footers. // Normalize response-usage display modes used to toggle per-response usage footers.
export function normalizeUsageDisplay(raw?: string | null): UsageDisplayLevel | undefined { export function normalizeUsageDisplay(raw?: string | null): UsageDisplayLevel | undefined {
if (!raw) return undefined; if (!raw) return undefined;
-11
View File
@@ -59,17 +59,6 @@ export const CONFIG_PATH_CLAWDBOT = resolveConfigPath();
export const DEFAULT_GATEWAY_PORT = 18789; export const DEFAULT_GATEWAY_PORT = 18789;
/**
* Gateway lock directory (ephemeral).
* Default: os.tmpdir()/clawdbot-<uid> (uid suffix when available).
*/
export function resolveGatewayLockDir(tmpdir: () => string = os.tmpdir): string {
const base = tmpdir();
const uid = typeof process.getuid === "function" ? process.getuid() : undefined;
const suffix = uid != null ? `clawdbot-${uid}` : "clawdbot";
return path.join(base, suffix);
}
const OAUTH_FILENAME = "oauth.json"; const OAUTH_FILENAME = "oauth.json";
/** /**
-2
View File
@@ -118,8 +118,6 @@ export type TelegramAccountConfig = {
reactionLevel?: "off" | "ack" | "minimal" | "extensive"; reactionLevel?: "off" | "ack" | "minimal" | "extensive";
/** Heartbeat visibility settings for this channel. */ /** Heartbeat visibility settings for this channel. */
heartbeat?: ChannelHeartbeatVisibilityConfig; heartbeat?: ChannelHeartbeatVisibilityConfig;
/** Controls whether link previews are shown in outbound messages. Default: true. */
linkPreview?: boolean;
}; };
export type TelegramTopicConfig = { export type TelegramTopicConfig = {
-1
View File
@@ -125,7 +125,6 @@ export const TelegramAccountSchemaBase = z
reactionNotifications: z.enum(["off", "own", "all"]).optional(), reactionNotifications: z.enum(["off", "own", "all"]).optional(),
reactionLevel: z.enum(["off", "ack", "minimal", "extensive"]).optional(), reactionLevel: z.enum(["off", "ack", "minimal", "extensive"]).optional(),
heartbeat: ChannelHeartbeatVisibilitySchema, heartbeat: ChannelHeartbeatVisibilitySchema,
linkPreview: z.boolean().optional(),
}) })
.strict(); .strict();
+68 -19
View File
@@ -1,28 +1,77 @@
import { describe, expect, it } from "vitest"; import { beforeEach, describe, expect, test, vi } from "vitest";
import { resolveGatewayListenHosts } from "./net.js"; const testTailnetIPv4 = { value: undefined as string | undefined };
const testTailnetIPv6 = { value: undefined as string | undefined };
describe("resolveGatewayListenHosts", () => { vi.mock("../infra/tailnet.js", () => ({
it("returns the input host when not loopback", async () => { pickPrimaryTailnetIPv4: () => testTailnetIPv4.value,
const hosts = await resolveGatewayListenHosts("0.0.0.0", { pickPrimaryTailnetIPv6: () => testTailnetIPv6.value,
canBindToHost: async () => { }));
throw new Error("should not be called");
}, import { isLocalGatewayAddress, resolveGatewayClientIp } from "./net.js";
});
expect(hosts).toEqual(["0.0.0.0"]); describe("gateway net", () => {
beforeEach(() => {
testTailnetIPv4.value = undefined;
testTailnetIPv6.value = undefined;
}); });
it("adds ::1 when IPv6 loopback is available", async () => { test("treats loopback as local", () => {
const hosts = await resolveGatewayListenHosts("127.0.0.1", { expect(isLocalGatewayAddress("127.0.0.1")).toBe(true);
canBindToHost: async () => true, expect(isLocalGatewayAddress("127.0.1.1")).toBe(true);
}); expect(isLocalGatewayAddress("::1")).toBe(true);
expect(hosts).toEqual(["127.0.0.1", "::1"]); expect(isLocalGatewayAddress("::ffff:127.0.0.1")).toBe(true);
}); });
it("keeps only IPv4 loopback when IPv6 is unavailable", async () => { test("treats local tailnet IPv4 as local", () => {
const hosts = await resolveGatewayListenHosts("127.0.0.1", { testTailnetIPv4.value = "100.64.0.1";
canBindToHost: async () => false, expect(isLocalGatewayAddress("100.64.0.1")).toBe(true);
expect(isLocalGatewayAddress("::ffff:100.64.0.1")).toBe(true);
});
test("ignores non-matching tailnet IPv4", () => {
testTailnetIPv4.value = "100.64.0.1";
expect(isLocalGatewayAddress("100.64.0.2")).toBe(false);
});
test("treats local tailnet IPv6 as local", () => {
testTailnetIPv6.value = "fd7a:115c:a1e0::123";
expect(isLocalGatewayAddress("fd7a:115c:a1e0::123")).toBe(true);
});
test("uses forwarded-for when remote is a trusted proxy", () => {
const clientIp = resolveGatewayClientIp({
remoteAddr: "10.0.0.2",
forwardedFor: "203.0.113.9, 10.0.0.2",
trustedProxies: ["10.0.0.2"],
}); });
expect(hosts).toEqual(["127.0.0.1"]); expect(clientIp).toBe("203.0.113.9");
});
test("ignores forwarded-for from untrusted proxies", () => {
const clientIp = resolveGatewayClientIp({
remoteAddr: "10.0.0.3",
forwardedFor: "203.0.113.9",
trustedProxies: ["10.0.0.2"],
});
expect(clientIp).toBe("10.0.0.3");
});
test("normalizes trusted proxy IPs and strips forwarded ports", () => {
const clientIp = resolveGatewayClientIp({
remoteAddr: "::ffff:10.0.0.2",
forwardedFor: "203.0.113.9:1234",
trustedProxies: ["10.0.0.2"],
});
expect(clientIp).toBe("203.0.113.9");
});
test("falls back to x-real-ip when forwarded-for is missing", () => {
const clientIp = resolveGatewayClientIp({
remoteAddr: "10.0.0.2",
realIp: "203.0.113.10",
trustedProxies: ["10.0.0.2"],
});
expect(clientIp).toBe("203.0.113.10");
}); });
}); });
+6 -16
View File
@@ -97,14 +97,14 @@ export async function resolveGatewayBindHost(
if (mode === "loopback") { if (mode === "loopback") {
// 127.0.0.1 rarely fails, but handle gracefully // 127.0.0.1 rarely fails, but handle gracefully
if (await canBindToHost("127.0.0.1")) return "127.0.0.1"; if (await canBindTo("127.0.0.1")) return "127.0.0.1";
return "0.0.0.0"; // extreme fallback return "0.0.0.0"; // extreme fallback
} }
if (mode === "tailnet") { if (mode === "tailnet") {
const tailnetIP = pickPrimaryTailnetIPv4(); const tailnetIP = pickPrimaryTailnetIPv4();
if (tailnetIP && (await canBindToHost(tailnetIP))) return tailnetIP; if (tailnetIP && (await canBindTo(tailnetIP))) return tailnetIP;
if (await canBindToHost("127.0.0.1")) return "127.0.0.1"; if (await canBindTo("127.0.0.1")) return "127.0.0.1";
return "0.0.0.0"; return "0.0.0.0";
} }
@@ -116,13 +116,13 @@ export async function resolveGatewayBindHost(
const host = customHost?.trim(); const host = customHost?.trim();
if (!host) return "0.0.0.0"; // invalid config → fall back to all if (!host) return "0.0.0.0"; // invalid config → fall back to all
if (isValidIPv4(host) && (await canBindToHost(host))) return host; if (isValidIPv4(host) && (await canBindTo(host))) return host;
// Custom IP failed → fall back to LAN // Custom IP failed → fall back to LAN
return "0.0.0.0"; return "0.0.0.0";
} }
if (mode === "auto") { if (mode === "auto") {
if (await canBindToHost("127.0.0.1")) return "127.0.0.1"; if (await canBindTo("127.0.0.1")) return "127.0.0.1";
return "0.0.0.0"; return "0.0.0.0";
} }
@@ -136,7 +136,7 @@ export async function resolveGatewayBindHost(
* @param host - The host address to test * @param host - The host address to test
* @returns True if we can successfully bind to this address * @returns True if we can successfully bind to this address
*/ */
export async function canBindToHost(host: string): Promise<boolean> { async function canBindTo(host: string): Promise<boolean> {
return new Promise((resolve) => { return new Promise((resolve) => {
const testServer = net.createServer(); const testServer = net.createServer();
testServer.once("error", () => { testServer.once("error", () => {
@@ -151,16 +151,6 @@ export async function canBindToHost(host: string): Promise<boolean> {
}); });
} }
export async function resolveGatewayListenHosts(
bindHost: string,
opts?: { canBindToHost?: (host: string) => Promise<boolean> },
): Promise<string[]> {
if (bindHost !== "127.0.0.1") return [bindHost];
const canBind = opts?.canBindToHost ?? canBindToHost;
if (await canBind("::1")) return [bindHost, "::1"];
return [bindHost];
}
/** /**
* Validate if a string is a valid IPv4 address. * Validate if a string is a valid IPv4 address.
* *
+8 -15
View File
@@ -28,7 +28,6 @@ export function createGatewayCloseHandler(params: {
browserControl: { stop: () => Promise<void> } | null; browserControl: { stop: () => Promise<void> } | null;
wss: WebSocketServer; wss: WebSocketServer;
httpServer: HttpServer; httpServer: HttpServer;
httpServers?: HttpServer[];
}) { }) {
return async (opts?: { reason?: string; restartExpectedMs?: number | null }) => { return async (opts?: { reason?: string; restartExpectedMs?: number | null }) => {
const reasonRaw = typeof opts?.reason === "string" ? opts.reason.trim() : ""; const reasonRaw = typeof opts?.reason === "string" ? opts.reason.trim() : "";
@@ -109,20 +108,14 @@ export function createGatewayCloseHandler(params: {
await params.browserControl.stop().catch(() => {}); await params.browserControl.stop().catch(() => {});
} }
await new Promise<void>((resolve) => params.wss.close(() => resolve())); await new Promise<void>((resolve) => params.wss.close(() => resolve()));
const servers = const httpServer = params.httpServer as HttpServer & {
params.httpServers && params.httpServers.length > 0 closeIdleConnections?: () => void;
? params.httpServers };
: [params.httpServer]; if (typeof httpServer.closeIdleConnections === "function") {
for (const server of servers) { httpServer.closeIdleConnections();
const httpServer = server as HttpServer & {
closeIdleConnections?: () => void;
};
if (typeof httpServer.closeIdleConnections === "function") {
httpServer.closeIdleConnections();
}
await new Promise<void>((resolve, reject) =>
httpServer.close((err) => (err ? reject(err) : resolve())),
);
} }
await new Promise<void>((resolve, reject) =>
params.httpServer.close((err) => (err ? reject(err) : resolve())),
);
}; };
} }
+19 -44
View File
@@ -10,7 +10,6 @@ import type { ChatAbortControllerEntry } from "./chat-abort.js";
import type { HooksConfigResolved } from "./hooks.js"; import type { HooksConfigResolved } from "./hooks.js";
import { createGatewayHooksRequestHandler } from "./server/hooks.js"; import { createGatewayHooksRequestHandler } from "./server/hooks.js";
import { listenGatewayHttpServer } from "./server/http-listen.js"; import { listenGatewayHttpServer } from "./server/http-listen.js";
import { resolveGatewayListenHosts } from "./net.js";
import { createGatewayPluginRequestHandler } from "./server/plugins-http.js"; import { createGatewayPluginRequestHandler } from "./server/plugins-http.js";
import type { GatewayWsClient } from "./server/ws-types.js"; import type { GatewayWsClient } from "./server/ws-types.js";
import { createGatewayBroadcaster } from "./server-broadcast.js"; import { createGatewayBroadcaster } from "./server-broadcast.js";
@@ -39,14 +38,11 @@ export async function createGatewayRuntimeState(params: {
canvasHostEnabled: boolean; canvasHostEnabled: boolean;
allowCanvasHostInTests?: boolean; allowCanvasHostInTests?: boolean;
logCanvas: { info: (msg: string) => void; warn: (msg: string) => void }; logCanvas: { info: (msg: string) => void; warn: (msg: string) => void };
log: { info: (msg: string) => void; warn: (msg: string) => void };
logHooks: ReturnType<typeof createSubsystemLogger>; logHooks: ReturnType<typeof createSubsystemLogger>;
logPlugins: ReturnType<typeof createSubsystemLogger>; logPlugins: ReturnType<typeof createSubsystemLogger>;
}): Promise<{ }): Promise<{
canvasHost: CanvasHostHandler | null; canvasHost: CanvasHostHandler | null;
httpServer: HttpServer; httpServer: HttpServer;
httpServers: HttpServer[];
httpBindHosts: string[];
wss: WebSocketServer; wss: WebSocketServer;
clients: Set<GatewayWsClient>; clients: Set<GatewayWsClient>;
broadcast: ( broadcast: (
@@ -104,49 +100,30 @@ export async function createGatewayRuntimeState(params: {
log: params.logPlugins, log: params.logPlugins,
}); });
const bindHosts = await resolveGatewayListenHosts(params.bindHost); const httpServer = createGatewayHttpServer({
const httpServers: HttpServer[] = []; canvasHost,
const httpBindHosts: string[] = []; controlUiEnabled: params.controlUiEnabled,
for (const host of bindHosts) { controlUiBasePath: params.controlUiBasePath,
const httpServer = createGatewayHttpServer({ openAiChatCompletionsEnabled: params.openAiChatCompletionsEnabled,
canvasHost, openResponsesEnabled: params.openResponsesEnabled,
controlUiEnabled: params.controlUiEnabled, openResponsesConfig: params.openResponsesConfig,
controlUiBasePath: params.controlUiBasePath, handleHooksRequest,
openAiChatCompletionsEnabled: params.openAiChatCompletionsEnabled, handlePluginRequest,
openResponsesEnabled: params.openResponsesEnabled, resolvedAuth: params.resolvedAuth,
openResponsesConfig: params.openResponsesConfig, tlsOptions: params.gatewayTls?.enabled ? params.gatewayTls.tlsOptions : undefined,
handleHooksRequest, });
handlePluginRequest,
resolvedAuth: params.resolvedAuth, await listenGatewayHttpServer({
tlsOptions: params.gatewayTls?.enabled ? params.gatewayTls.tlsOptions : undefined, httpServer,
}); bindHost: params.bindHost,
try { port: params.port,
await listenGatewayHttpServer({ });
httpServer,
bindHost: host,
port: params.port,
});
httpServers.push(httpServer);
httpBindHosts.push(host);
} catch (err) {
if (host === bindHosts[0]) throw err;
params.log.warn(
`gateway: failed to bind loopback alias ${host}:${params.port} (${String(err)})`,
);
}
}
const httpServer = httpServers[0];
if (!httpServer) {
throw new Error("Gateway HTTP server failed to start");
}
const wss = new WebSocketServer({ const wss = new WebSocketServer({
noServer: true, noServer: true,
maxPayload: MAX_PAYLOAD_BYTES, maxPayload: MAX_PAYLOAD_BYTES,
}); });
for (const server of httpServers) { attachGatewayUpgradeHandler({ httpServer, wss, canvasHost });
attachGatewayUpgradeHandler({ httpServer: server, wss, canvasHost });
}
const clients = new Set<GatewayWsClient>(); const clients = new Set<GatewayWsClient>();
const { broadcast } = createGatewayBroadcaster({ clients }); const { broadcast } = createGatewayBroadcaster({ clients });
@@ -163,8 +140,6 @@ export async function createGatewayRuntimeState(params: {
return { return {
canvasHost, canvasHost,
httpServer, httpServer,
httpServers,
httpBindHosts,
wss, wss,
clients, clients,
broadcast, broadcast,
+1 -9
View File
@@ -7,7 +7,6 @@ import { getResolvedLoggerSettings } from "../logging.js";
export function logGatewayStartup(params: { export function logGatewayStartup(params: {
cfg: ReturnType<typeof loadConfig>; cfg: ReturnType<typeof loadConfig>;
bindHost: string; bindHost: string;
bindHosts?: string[];
port: number; port: number;
tlsEnabled?: boolean; tlsEnabled?: boolean;
log: { info: (msg: string, meta?: Record<string, unknown>) => void }; log: { info: (msg: string, meta?: Record<string, unknown>) => void };
@@ -23,16 +22,9 @@ export function logGatewayStartup(params: {
consoleMessage: `agent model: ${chalk.whiteBright(modelRef)}`, consoleMessage: `agent model: ${chalk.whiteBright(modelRef)}`,
}); });
const scheme = params.tlsEnabled ? "wss" : "ws"; const scheme = params.tlsEnabled ? "wss" : "ws";
const formatHost = (host: string) => (host.includes(":") ? `[${host}]` : host);
const hosts =
params.bindHosts && params.bindHosts.length > 0 ? params.bindHosts : [params.bindHost];
const primaryHost = hosts[0] ?? params.bindHost;
params.log.info( params.log.info(
`listening on ${scheme}://${formatHost(primaryHost)}:${params.port} (PID ${process.pid})`, `listening on ${scheme}://${params.bindHost}:${params.port} (PID ${process.pid})`,
); );
for (const host of hosts.slice(1)) {
params.log.info(`listening on ${scheme}://${formatHost(host)}:${params.port}`);
}
params.log.info(`log file: ${getResolvedLoggerSettings().file}`); params.log.info(`log file: ${getResolvedLoggerSettings().file}`);
if (params.isNixMode) { if (params.isNixMode) {
params.log.info("gateway: running in Nix mode (config managed externally)"); params.log.info("gateway: running in Nix mode (config managed externally)");
-5
View File
@@ -263,8 +263,6 @@ export async function startGatewayServer(
const { const {
canvasHost, canvasHost,
httpServer, httpServer,
httpServers,
httpBindHosts,
wss, wss,
clients, clients,
broadcast, broadcast,
@@ -294,7 +292,6 @@ export async function startGatewayServer(
canvasHostEnabled, canvasHostEnabled,
allowCanvasHostInTests: opts.allowCanvasHostInTests, allowCanvasHostInTests: opts.allowCanvasHostInTests,
logCanvas, logCanvas,
log,
logHooks, logHooks,
logPlugins, logPlugins,
}); });
@@ -467,7 +464,6 @@ export async function startGatewayServer(
logGatewayStartup({ logGatewayStartup({
cfg: cfgAtStart, cfg: cfgAtStart,
bindHost, bindHost,
bindHosts: httpBindHosts,
port, port,
tlsEnabled: gatewayTls.enabled, tlsEnabled: gatewayTls.enabled,
log, log,
@@ -556,7 +552,6 @@ export async function startGatewayServer(
browserControl, browserControl,
wss, wss,
httpServer, httpServer,
httpServers,
}); });
return { return {
+2 -4
View File
@@ -7,13 +7,12 @@ import path from "node:path";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { acquireGatewayLock, GatewayLockError } from "./gateway-lock.js"; import { acquireGatewayLock, GatewayLockError } from "./gateway-lock.js";
import { resolveConfigPath, resolveGatewayLockDir, resolveStateDir } from "../config/paths.js"; import { resolveConfigPath, resolveStateDir } from "../config/paths.js";
async function makeEnv() { async function makeEnv() {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gateway-lock-")); const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gateway-lock-"));
const configPath = path.join(dir, "clawdbot.json"); const configPath = path.join(dir, "clawdbot.json");
await fs.writeFile(configPath, "{}", "utf8"); await fs.writeFile(configPath, "{}", "utf8");
await fs.mkdir(resolveGatewayLockDir(), { recursive: true });
return { return {
env: { env: {
...process.env, ...process.env,
@@ -30,8 +29,7 @@ function resolveLockPath(env: NodeJS.ProcessEnv) {
const stateDir = resolveStateDir(env); const stateDir = resolveStateDir(env);
const configPath = resolveConfigPath(env, stateDir); const configPath = resolveConfigPath(env, stateDir);
const hash = createHash("sha1").update(configPath).digest("hex").slice(0, 8); const hash = createHash("sha1").update(configPath).digest("hex").slice(0, 8);
const lockDir = resolveGatewayLockDir(); return { lockPath: path.join(stateDir, `gateway.${hash}.lock`), configPath };
return { lockPath: path.join(lockDir, `gateway.${hash}.lock`), configPath };
} }
function makeProcStat(pid: number, startTime: number) { function makeProcStat(pid: number, startTime: number) {
+2 -3
View File
@@ -3,7 +3,7 @@ import fs from "node:fs/promises";
import fsSync from "node:fs"; import fsSync from "node:fs";
import path from "node:path"; import path from "node:path";
import { resolveConfigPath, resolveGatewayLockDir, resolveStateDir } from "../config/paths.js"; import { resolveConfigPath, resolveStateDir } from "../config/paths.js";
const DEFAULT_TIMEOUT_MS = 5000; const DEFAULT_TIMEOUT_MS = 5000;
const DEFAULT_POLL_INTERVAL_MS = 100; const DEFAULT_POLL_INTERVAL_MS = 100;
@@ -150,8 +150,7 @@ function resolveGatewayLockPath(env: NodeJS.ProcessEnv) {
const stateDir = resolveStateDir(env); const stateDir = resolveStateDir(env);
const configPath = resolveConfigPath(env, stateDir); const configPath = resolveConfigPath(env, stateDir);
const hash = createHash("sha1").update(configPath).digest("hex").slice(0, 8); const hash = createHash("sha1").update(configPath).digest("hex").slice(0, 8);
const lockDir = resolveGatewayLockDir(); const lockPath = path.join(stateDir, `gateway.${hash}.lock`);
const lockPath = path.join(lockDir, `gateway.${hash}.lock`);
return { lockPath, configPath }; return { lockPath, configPath };
} }
-4
View File
@@ -124,7 +124,6 @@ import { startWebLoginWithQr, waitForWebLogin } from "../../web/login-qr.js";
import { sendMessageWhatsApp, sendPollWhatsApp } from "../../web/outbound.js"; import { sendMessageWhatsApp, sendPollWhatsApp } from "../../web/outbound.js";
import { registerMemoryCli } from "../../cli/memory-cli.js"; import { registerMemoryCli } from "../../cli/memory-cli.js";
import { formatNativeDependencyHint } from "./native-deps.js"; import { formatNativeDependencyHint } from "./native-deps.js";
import { textToSpeechTelephony } from "../../tts/tts.js";
import type { PluginRuntime } from "./types.js"; import type { PluginRuntime } from "./types.js";
@@ -163,9 +162,6 @@ export function createPluginRuntime(): PluginRuntime {
getImageMetadata, getImageMetadata,
resizeToJpeg, resizeToJpeg,
}, },
tts: {
textToSpeechTelephony,
},
tools: { tools: {
createMemoryGetTool, createMemoryGetTool,
createMemorySearchTool, createMemorySearchTool,
-4
View File
@@ -16,7 +16,6 @@ type UpsertChannelPairingRequest =
typeof import("../../pairing/pairing-store.js").upsertChannelPairingRequest; typeof import("../../pairing/pairing-store.js").upsertChannelPairingRequest;
type FetchRemoteMedia = typeof import("../../media/fetch.js").fetchRemoteMedia; type FetchRemoteMedia = typeof import("../../media/fetch.js").fetchRemoteMedia;
type SaveMediaBuffer = typeof import("../../media/store.js").saveMediaBuffer; type SaveMediaBuffer = typeof import("../../media/store.js").saveMediaBuffer;
type TextToSpeechTelephony = typeof import("../../tts/tts.js").textToSpeechTelephony;
type BuildMentionRegexes = typeof import("../../auto-reply/reply/mentions.js").buildMentionRegexes; type BuildMentionRegexes = typeof import("../../auto-reply/reply/mentions.js").buildMentionRegexes;
type MatchesMentionPatterns = type MatchesMentionPatterns =
typeof import("../../auto-reply/reply/mentions.js").matchesMentionPatterns; typeof import("../../auto-reply/reply/mentions.js").matchesMentionPatterns;
@@ -174,9 +173,6 @@ export type PluginRuntime = {
getImageMetadata: GetImageMetadata; getImageMetadata: GetImageMetadata;
resizeToJpeg: ResizeToJpeg; resizeToJpeg: ResizeToJpeg;
}; };
tts: {
textToSpeechTelephony: TextToSpeechTelephony;
};
tools: { tools: {
createMemoryGetTool: CreateMemoryGetTool; createMemoryGetTool: CreateMemoryGetTool;
createMemorySearchTool: CreateMemorySearchTool; createMemorySearchTool: CreateMemorySearchTool;
-2
View File
@@ -43,7 +43,6 @@ function setup(config: Record<string, unknown>): Registered {
source: "test", source: "test",
config: {}, config: {},
pluginConfig: config, pluginConfig: config,
runtime: { tts: { textToSpeechTelephony: vi.fn() } },
logger: noopLogger, logger: noopLogger,
registerGatewayMethod: (method, handler) => methods.set(method, handler), registerGatewayMethod: (method, handler) => methods.set(method, handler),
registerTool: (tool) => tools.push(tool), registerTool: (tool) => tools.push(tool),
@@ -143,7 +142,6 @@ describe("voice-call plugin", () => {
source: "test", source: "test",
config: {}, config: {},
pluginConfig: { provider: "mock" }, pluginConfig: { provider: "mock" },
runtime: { tts: { textToSpeechTelephony: vi.fn() } },
logger: noopLogger, logger: noopLogger,
registerGatewayMethod: () => {}, registerGatewayMethod: () => {},
registerTool: () => {}, registerTool: () => {},
+7 -11
View File
@@ -4,7 +4,6 @@ import { promisify } from "node:util";
import { danger, shouldLogVerbose } from "../globals.js"; import { danger, shouldLogVerbose } from "../globals.js";
import { logDebug, logError } from "../logger.js"; import { logDebug, logError } from "../logger.js";
import { resolveCommandStdio } from "./spawn-utils.js";
const execFileAsync = promisify(execFile); const execFileAsync = promisify(execFile);
@@ -79,22 +78,19 @@ export async function runCommandWithTimeout(
if (resolvedEnv.npm_config_fund == null) resolvedEnv.npm_config_fund = "false"; if (resolvedEnv.npm_config_fund == null) resolvedEnv.npm_config_fund = "false";
} }
const stdio = resolveCommandStdio({ hasInput, preferInherit: true });
const child = spawn(argv[0], argv.slice(1), {
stdio,
cwd,
env: resolvedEnv,
windowsVerbatimArguments,
});
// Spawn with inherited stdin (TTY) so tools like `pi` stay interactive when needed. // Spawn with inherited stdin (TTY) so tools like `pi` stay interactive when needed.
return await new Promise((resolve, reject) => { return await new Promise((resolve, reject) => {
const child = spawn(argv[0], argv.slice(1), {
stdio: [hasInput ? "pipe" : "inherit", "pipe", "pipe"],
cwd,
env: resolvedEnv,
windowsVerbatimArguments,
});
let stdout = ""; let stdout = "";
let stderr = ""; let stderr = "";
let settled = false; let settled = false;
const timer = setTimeout(() => { const timer = setTimeout(() => {
if (typeof child.kill === "function") { child.kill("SIGKILL");
child.kill("SIGKILL");
}
}, timeoutMs); }, timeoutMs);
if (hasInput && child.stdin) { if (hasInput && child.stdin) {
-64
View File
@@ -1,64 +0,0 @@
import { EventEmitter } from "node:events";
import { PassThrough } from "node:stream";
import type { ChildProcess } from "node:child_process";
import { describe, expect, it, vi } from "vitest";
import { spawnWithFallback } from "./spawn-utils.js";
function createStubChild() {
const child = new EventEmitter() as ChildProcess;
child.stdin = new PassThrough() as ChildProcess["stdin"];
child.stdout = new PassThrough() as ChildProcess["stdout"];
child.stderr = new PassThrough() as ChildProcess["stderr"];
child.pid = 1234;
child.killed = false;
child.kill = vi.fn(() => true) as ChildProcess["kill"];
queueMicrotask(() => {
child.emit("spawn");
});
return child;
}
describe("spawnWithFallback", () => {
it("retries on EBADF using fallback options", async () => {
const spawnMock = vi
.fn()
.mockImplementationOnce(() => {
const err = new Error("spawn EBADF");
(err as NodeJS.ErrnoException).code = "EBADF";
throw err;
})
.mockImplementationOnce(() => createStubChild());
const result = await spawnWithFallback({
argv: ["echo", "ok"],
options: { stdio: ["pipe", "pipe", "pipe"] },
fallbacks: [{ label: "safe-stdin", options: { stdio: ["ignore", "pipe", "pipe"] } }],
spawnImpl: spawnMock,
});
expect(result.usedFallback).toBe(true);
expect(result.fallbackLabel).toBe("safe-stdin");
expect(spawnMock).toHaveBeenCalledTimes(2);
expect(spawnMock.mock.calls[0]?.[2]?.stdio).toEqual(["pipe", "pipe", "pipe"]);
expect(spawnMock.mock.calls[1]?.[2]?.stdio).toEqual(["ignore", "pipe", "pipe"]);
});
it("does not retry on non-EBADF errors", async () => {
const spawnMock = vi.fn().mockImplementationOnce(() => {
const err = new Error("spawn ENOENT");
(err as NodeJS.ErrnoException).code = "ENOENT";
throw err;
});
await expect(
spawnWithFallback({
argv: ["missing"],
options: { stdio: ["pipe", "pipe", "pipe"] },
fallbacks: [{ label: "safe-stdin", options: { stdio: ["ignore", "pipe", "pipe"] } }],
spawnImpl: spawnMock,
}),
).rejects.toThrow(/ENOENT/);
expect(spawnMock).toHaveBeenCalledTimes(1);
});
});
-127
View File
@@ -1,127 +0,0 @@
import type { ChildProcess, SpawnOptions } from "node:child_process";
import { spawn } from "node:child_process";
export type SpawnFallback = {
label: string;
options: SpawnOptions;
};
export type SpawnWithFallbackResult = {
child: ChildProcess;
usedFallback: boolean;
fallbackLabel?: string;
};
type SpawnWithFallbackParams = {
argv: string[];
options: SpawnOptions;
fallbacks?: SpawnFallback[];
spawnImpl?: typeof spawn;
retryCodes?: string[];
onFallback?: (err: unknown, fallback: SpawnFallback) => void;
};
const DEFAULT_RETRY_CODES = ["EBADF"];
export function resolveCommandStdio(params: {
hasInput: boolean;
preferInherit: boolean;
}): ["pipe" | "inherit" | "ignore", "pipe", "pipe"] {
const stdin = params.hasInput ? "pipe" : params.preferInherit ? "inherit" : "pipe";
return [stdin, "pipe", "pipe"];
}
export function formatSpawnError(err: unknown): string {
if (!(err instanceof Error)) return String(err);
const details = err as NodeJS.ErrnoException;
const parts: string[] = [];
const message = err.message?.trim();
if (message) parts.push(message);
if (details.code && !message?.includes(details.code)) parts.push(details.code);
if (details.syscall) parts.push(`syscall=${details.syscall}`);
if (typeof details.errno === "number") parts.push(`errno=${details.errno}`);
return parts.join(" ");
}
function shouldRetry(err: unknown, codes: string[]): boolean {
const code =
err && typeof err === "object" && "code" in err ? String((err as { code?: unknown }).code) : "";
return code.length > 0 && codes.includes(code);
}
async function spawnAndWaitForSpawn(
spawnImpl: typeof spawn,
argv: string[],
options: SpawnOptions,
): Promise<ChildProcess> {
const child = spawnImpl(argv[0], argv.slice(1), options);
return await new Promise((resolve, reject) => {
let settled = false;
const cleanup = () => {
child.removeListener("error", onError);
child.removeListener("spawn", onSpawn);
};
const finishResolve = () => {
if (settled) return;
settled = true;
cleanup();
resolve(child);
};
const onError = (err: unknown) => {
if (settled) return;
settled = true;
cleanup();
reject(err);
};
const onSpawn = () => {
finishResolve();
};
child.once("error", onError);
child.once("spawn", onSpawn);
// Ensure mocked spawns that never emit "spawn" don't stall.
process.nextTick(() => {
if (typeof child.pid === "number") {
finishResolve();
}
});
});
}
export async function spawnWithFallback(
params: SpawnWithFallbackParams,
): Promise<SpawnWithFallbackResult> {
const spawnImpl = params.spawnImpl ?? spawn;
const retryCodes = params.retryCodes ?? DEFAULT_RETRY_CODES;
const baseOptions = { ...params.options };
const fallbacks = params.fallbacks ?? [];
const attempts: Array<{ label?: string; options: SpawnOptions }> = [
{ options: baseOptions },
...fallbacks.map((fallback) => ({
label: fallback.label,
options: { ...baseOptions, ...fallback.options },
})),
];
let lastError: unknown;
for (let index = 0; index < attempts.length; index += 1) {
const attempt = attempts[index];
try {
const child = await spawnAndWaitForSpawn(spawnImpl, params.argv, attempt.options);
return {
child,
usedFallback: index > 0,
fallbackLabel: attempt.label,
};
} catch (err) {
lastError = err;
const nextFallback = fallbacks[index];
if (!nextFallback || !shouldRetry(err, retryCodes)) {
throw err;
}
params.onFallback?.(err, nextFallback);
}
}
throw lastError;
}
-1
View File
@@ -151,7 +151,6 @@ export const dispatchTelegramMessage = async ({
tableMode, tableMode,
chunkMode, chunkMode,
onVoiceRecording: sendRecordVoice, onVoiceRecording: sendRecordVoice,
linkPreview: telegramCfg.linkPreview,
}); });
}, },
onError: (err, info) => { onError: (err, info) => {
-1
View File
@@ -348,7 +348,6 @@ export const registerTelegramNativeCommands = ({
messageThreadId: resolvedThreadId, messageThreadId: resolvedThreadId,
tableMode, tableMode,
chunkMode, chunkMode,
linkPreview: telegramCfg.linkPreview,
}); });
}, },
onError: (err, info) => { onError: (err, info) => {
-56
View File
@@ -108,60 +108,4 @@ describe("deliverReplies", () => {
}), }),
); );
}); });
it("includes link_preview_options when linkPreview is false", async () => {
const runtime = { error: vi.fn(), log: vi.fn() };
const sendMessage = vi.fn().mockResolvedValue({
message_id: 3,
chat: { id: "123" },
});
const bot = { api: { sendMessage } } as unknown as Bot;
await deliverReplies({
replies: [{ text: "Check https://example.com" }],
chatId: "123",
token: "tok",
runtime,
bot,
replyToMode: "off",
textLimit: 4000,
linkPreview: false,
});
expect(sendMessage).toHaveBeenCalledWith(
"123",
expect.any(String),
expect.objectContaining({
link_preview_options: { is_disabled: true },
}),
);
});
it("does not include link_preview_options when linkPreview is true", async () => {
const runtime = { error: vi.fn(), log: vi.fn() };
const sendMessage = vi.fn().mockResolvedValue({
message_id: 4,
chat: { id: "123" },
});
const bot = { api: { sendMessage } } as unknown as Bot;
await deliverReplies({
replies: [{ text: "Check https://example.com" }],
chatId: "123",
token: "tok",
runtime,
bot,
replyToMode: "off",
textLimit: 4000,
linkPreview: true,
});
expect(sendMessage).toHaveBeenCalledWith(
"123",
expect.any(String),
expect.not.objectContaining({
link_preview_options: expect.anything(),
}),
);
});
}); });
+1 -12
View File
@@ -36,11 +36,8 @@ export async function deliverReplies(params: {
chunkMode?: ChunkMode; chunkMode?: ChunkMode;
/** Callback invoked before sending a voice message to switch typing indicator. */ /** Callback invoked before sending a voice message to switch typing indicator. */
onVoiceRecording?: () => Promise<void> | void; onVoiceRecording?: () => Promise<void> | void;
/** Controls whether link previews are shown. Default: true (previews enabled). */
linkPreview?: boolean;
}) { }) {
const { replies, chatId, runtime, bot, replyToMode, textLimit, messageThreadId, linkPreview } = const { replies, chatId, runtime, bot, replyToMode, textLimit, messageThreadId } = params;
params;
const chunkMode = params.chunkMode ?? "length"; const chunkMode = params.chunkMode ?? "length";
const threadParams = buildTelegramThreadParams(messageThreadId); const threadParams = buildTelegramThreadParams(messageThreadId);
let hasReplied = false; let hasReplied = false;
@@ -88,7 +85,6 @@ export async function deliverReplies(params: {
messageThreadId, messageThreadId,
textMode: "html", textMode: "html",
plainText: chunk.text, plainText: chunk.text,
linkPreview,
}); });
if (replyToId && !hasReplied) { if (replyToId && !hasReplied) {
hasReplied = true; hasReplied = true;
@@ -184,7 +180,6 @@ export async function deliverReplies(params: {
messageThreadId, messageThreadId,
textMode: "html", textMode: "html",
plainText: chunk.text, plainText: chunk.text,
linkPreview,
}); });
if (replyToId && !hasReplied) { if (replyToId && !hasReplied) {
hasReplied = true; hasReplied = true;
@@ -253,22 +248,17 @@ async function sendTelegramText(
messageThreadId?: number; messageThreadId?: number;
textMode?: "markdown" | "html"; textMode?: "markdown" | "html";
plainText?: string; plainText?: string;
linkPreview?: boolean;
}, },
): Promise<number | undefined> { ): Promise<number | undefined> {
const baseParams = buildTelegramSendParams({ const baseParams = buildTelegramSendParams({
replyToMessageId: opts?.replyToMessageId, replyToMessageId: opts?.replyToMessageId,
messageThreadId: opts?.messageThreadId, messageThreadId: opts?.messageThreadId,
}); });
// Add link_preview_options when link preview is disabled.
const linkPreviewEnabled = opts?.linkPreview ?? true;
const linkPreviewOptions = linkPreviewEnabled ? undefined : { is_disabled: true };
const textMode = opts?.textMode ?? "markdown"; const textMode = opts?.textMode ?? "markdown";
const htmlText = textMode === "html" ? text : markdownToTelegramHtml(text); const htmlText = textMode === "html" ? text : markdownToTelegramHtml(text);
try { try {
const res = await bot.api.sendMessage(chatId, htmlText, { const res = await bot.api.sendMessage(chatId, htmlText, {
parse_mode: "HTML", parse_mode: "HTML",
...(linkPreviewOptions ? { link_preview_options: linkPreviewOptions } : {}),
...baseParams, ...baseParams,
}); });
return res.message_id; return res.message_id;
@@ -278,7 +268,6 @@ async function sendTelegramText(
runtime.log?.(`telegram HTML parse failed; retrying without formatting: ${errText}`); runtime.log?.(`telegram HTML parse failed; retrying without formatting: ${errText}`);
const fallbackText = opts?.plainText ?? text; const fallbackText = opts?.plainText ?? text;
const res = await bot.api.sendMessage(chatId, fallbackText, { const res = await bot.api.sendMessage(chatId, fallbackText, {
...(linkPreviewOptions ? { link_preview_options: linkPreviewOptions } : {}),
...baseParams, ...baseParams,
}); });
return res.message_id; return res.message_id;
@@ -152,62 +152,6 @@ describe("sendMessageTelegram", () => {
expect(res.messageId).toBe("42"); expect(res.messageId).toBe("42");
}); });
it("adds link_preview_options when previews are disabled in config", async () => {
const chatId = "123";
const sendMessage = vi.fn().mockResolvedValue({
message_id: 7,
chat: { id: chatId },
});
const api = { sendMessage } as unknown as {
sendMessage: typeof sendMessage;
};
loadConfig.mockReturnValue({
channels: { telegram: { linkPreview: false } },
});
await sendMessageTelegram(chatId, "hi", { token: "tok", api });
expect(sendMessage).toHaveBeenCalledWith(chatId, "hi", {
parse_mode: "HTML",
link_preview_options: { is_disabled: true },
});
});
it("keeps link_preview_options on plain-text fallback when disabled", async () => {
const chatId = "123";
const parseErr = new Error(
"400: Bad Request: can't parse entities: Can't find end of the entity starting at byte offset 9",
);
const sendMessage = vi
.fn()
.mockRejectedValueOnce(parseErr)
.mockResolvedValueOnce({
message_id: 42,
chat: { id: chatId },
});
const api = { sendMessage } as unknown as {
sendMessage: typeof sendMessage;
};
loadConfig.mockReturnValue({
channels: { telegram: { linkPreview: false } },
});
await sendMessageTelegram(chatId, "_oops_", {
token: "tok",
api,
});
expect(sendMessage).toHaveBeenNthCalledWith(1, chatId, "<i>oops</i>", {
parse_mode: "HTML",
link_preview_options: { is_disabled: true },
});
expect(sendMessage).toHaveBeenNthCalledWith(2, chatId, "_oops_", {
link_preview_options: { is_disabled: true },
});
});
it("uses native fetch for BAN compatibility when api is omitted", async () => { it("uses native fetch for BAN compatibility when api is omitted", async () => {
const originalFetch = globalThis.fetch; const originalFetch = globalThis.fetch;
const originalBun = (globalThis as { Bun?: unknown }).Bun; const originalBun = (globalThis as { Bun?: unknown }).Bun;
+9 -14
View File
@@ -198,25 +198,20 @@ export async function sendMessageTelegram(
}); });
const renderHtmlText = (value: string) => renderTelegramHtmlText(value, { textMode, tableMode }); const renderHtmlText = (value: string) => renderTelegramHtmlText(value, { textMode, tableMode });
// Resolve link preview setting from config (default: enabled).
const linkPreviewEnabled = account.config.linkPreview ?? true;
const linkPreviewOptions = linkPreviewEnabled ? undefined : { is_disabled: true };
const sendTelegramText = async ( const sendTelegramText = async (
rawText: string, rawText: string,
params?: Record<string, unknown>, params?: Record<string, unknown>,
fallbackText?: string, fallbackText?: string,
) => { ) => {
const htmlText = renderHtmlText(rawText); const htmlText = renderHtmlText(rawText);
const baseParams = params ? { ...params } : {}; const sendParams = params
if (linkPreviewOptions) { ? {
baseParams.link_preview_options = linkPreviewOptions; parse_mode: "HTML" as const,
} ...params,
const hasBaseParams = Object.keys(baseParams).length > 0; }
const sendParams = { : {
parse_mode: "HTML" as const, parse_mode: "HTML" as const,
...baseParams, };
};
const res = await request(() => api.sendMessage(chatId, htmlText, sendParams), "message").catch( const res = await request(() => api.sendMessage(chatId, htmlText, sendParams), "message").catch(
async (err) => { async (err) => {
// Telegram rejects malformed HTML (e.g., unsupported tags or entities). // Telegram rejects malformed HTML (e.g., unsupported tags or entities).
@@ -227,7 +222,7 @@ export async function sendMessageTelegram(
console.warn(`telegram HTML parse failed, retrying as plain text: ${errText}`); console.warn(`telegram HTML parse failed, retrying as plain text: ${errText}`);
} }
const fallback = fallbackText ?? rawText; const fallback = fallbackText ?? rawText;
const plainParams = hasBaseParams ? baseParams : undefined; const plainParams = params && Object.keys(params).length > 0 ? { ...params } : undefined;
return await request( return await request(
() => () =>
plainParams plainParams
+6 -8
View File
@@ -109,13 +109,13 @@ describe("tts", () => {
}); });
describe("isValidOpenAIModel", () => { describe("isValidOpenAIModel", () => {
it("accepts supported models", () => { it("accepts gpt-4o-mini-tts model", () => {
expect(isValidOpenAIModel("gpt-4o-mini-tts")).toBe(true); expect(isValidOpenAIModel("gpt-4o-mini-tts")).toBe(true);
expect(isValidOpenAIModel("tts-1")).toBe(true);
expect(isValidOpenAIModel("tts-1-hd")).toBe(true);
}); });
it("rejects unsupported models", () => { it("rejects other models", () => {
expect(isValidOpenAIModel("tts-1")).toBe(false);
expect(isValidOpenAIModel("tts-1-hd")).toBe(false);
expect(isValidOpenAIModel("invalid")).toBe(false); expect(isValidOpenAIModel("invalid")).toBe(false);
expect(isValidOpenAIModel("")).toBe(false); expect(isValidOpenAIModel("")).toBe(false);
expect(isValidOpenAIModel("gpt-4")).toBe(false); expect(isValidOpenAIModel("gpt-4")).toBe(false);
@@ -123,11 +123,9 @@ describe("tts", () => {
}); });
describe("OPENAI_TTS_MODELS", () => { describe("OPENAI_TTS_MODELS", () => {
it("contains supported models", () => { it("contains only gpt-4o-mini-tts", () => {
expect(OPENAI_TTS_MODELS).toContain("gpt-4o-mini-tts"); expect(OPENAI_TTS_MODELS).toContain("gpt-4o-mini-tts");
expect(OPENAI_TTS_MODELS).toContain("tts-1"); expect(OPENAI_TTS_MODELS).toHaveLength(1);
expect(OPENAI_TTS_MODELS).toContain("tts-1-hd");
expect(OPENAI_TTS_MODELS).toHaveLength(3);
}); });
it("is a non-empty array", () => { it("is a non-empty array", () => {
+3 -126
View File
@@ -76,11 +76,6 @@ const DEFAULT_OUTPUT = {
voiceCompatible: false, voiceCompatible: false,
}; };
const TELEPHONY_OUTPUT = {
openai: { format: "pcm" as const, sampleRate: 24000 },
elevenlabs: { format: "pcm_22050", sampleRate: 22050 },
};
const TTS_AUTO_MODES = new Set<TtsAutoMode>(["off", "always", "inbound", "tagged"]); const TTS_AUTO_MODES = new Set<TtsAutoMode>(["off", "always", "inbound", "tagged"]);
export type ResolvedTtsConfig = { export type ResolvedTtsConfig = {
@@ -185,16 +180,6 @@ export type TtsResult = {
voiceCompatible?: boolean; voiceCompatible?: boolean;
}; };
export type TtsTelephonyResult = {
success: boolean;
audioBuffer?: Buffer;
error?: string;
latencyMs?: number;
provider?: string;
outputFormat?: string;
sampleRate?: number;
};
type TtsStatusEntry = { type TtsStatusEntry = {
timestamp: number; timestamp: number;
success: boolean; success: boolean;
@@ -751,17 +736,7 @@ function parseTtsDirectives(
}; };
} }
export const OPENAI_TTS_MODELS = ["gpt-4o-mini-tts", "tts-1", "tts-1-hd"] as const; export const OPENAI_TTS_MODELS = ["gpt-4o-mini-tts"] as const;
/**
* Custom OpenAI-compatible TTS endpoint.
* When set, model/voice validation is relaxed to allow non-OpenAI models.
* Example: OPENAI_TTS_BASE_URL=http://localhost:8880/v1
*/
const OPENAI_TTS_BASE_URL = (
process.env.OPENAI_TTS_BASE_URL?.trim() || "https://api.openai.com/v1"
).replace(/\/+$/, "");
const isCustomOpenAIEndpoint = OPENAI_TTS_BASE_URL !== "https://api.openai.com/v1";
export const OPENAI_TTS_VOICES = [ export const OPENAI_TTS_VOICES = [
"alloy", "alloy",
"ash", "ash",
@@ -777,14 +752,10 @@ export const OPENAI_TTS_VOICES = [
type OpenAiTtsVoice = (typeof OPENAI_TTS_VOICES)[number]; type OpenAiTtsVoice = (typeof OPENAI_TTS_VOICES)[number];
function isValidOpenAIModel(model: string): boolean { function isValidOpenAIModel(model: string): boolean {
// Allow any model when using custom endpoint (e.g., Kokoro, LocalAI)
if (isCustomOpenAIEndpoint) return true;
return OPENAI_TTS_MODELS.includes(model as (typeof OPENAI_TTS_MODELS)[number]); return OPENAI_TTS_MODELS.includes(model as (typeof OPENAI_TTS_MODELS)[number]);
} }
function isValidOpenAIVoice(voice: string): voice is OpenAiTtsVoice { function isValidOpenAIVoice(voice: string): voice is OpenAiTtsVoice {
// Allow any voice when using custom endpoint (e.g., Kokoro Chinese voices)
if (isCustomOpenAIEndpoint) return true;
return OPENAI_TTS_VOICES.includes(voice as OpenAiTtsVoice); return OPENAI_TTS_VOICES.includes(voice as OpenAiTtsVoice);
} }
@@ -995,7 +966,7 @@ async function openaiTTS(params: {
apiKey: string; apiKey: string;
model: string; model: string;
voice: string; voice: string;
responseFormat: "mp3" | "opus" | "pcm"; responseFormat: "mp3" | "opus";
timeoutMs: number; timeoutMs: number;
}): Promise<Buffer> { }): Promise<Buffer> {
const { text, apiKey, model, voice, responseFormat, timeoutMs } = params; const { text, apiKey, model, voice, responseFormat, timeoutMs } = params;
@@ -1011,7 +982,7 @@ async function openaiTTS(params: {
const timeout = setTimeout(() => controller.abort(), timeoutMs); const timeout = setTimeout(() => controller.abort(), timeoutMs);
try { try {
const response = await fetch(`${OPENAI_TTS_BASE_URL}/audio/speech`, { const response = await fetch("https://api.openai.com/v1/audio/speech", {
method: "POST", method: "POST",
headers: { headers: {
Authorization: `Bearer ${apiKey}`, Authorization: `Bearer ${apiKey}`,
@@ -1239,100 +1210,6 @@ export async function textToSpeech(params: {
}; };
} }
export async function textToSpeechTelephony(params: {
text: string;
cfg: ClawdbotConfig;
prefsPath?: string;
}): Promise<TtsTelephonyResult> {
const config = resolveTtsConfig(params.cfg);
const prefsPath = params.prefsPath ?? resolveTtsPrefsPath(config);
if (params.text.length > config.maxTextLength) {
return {
success: false,
error: `Text too long (${params.text.length} chars, max ${config.maxTextLength})`,
};
}
const userProvider = getTtsProvider(config, prefsPath);
const providers = resolveTtsProviderOrder(userProvider);
let lastError: string | undefined;
for (const provider of providers) {
const providerStart = Date.now();
try {
if (provider === "edge") {
lastError = "edge: unsupported for telephony";
continue;
}
const apiKey = resolveTtsApiKey(config, provider);
if (!apiKey) {
lastError = `No API key for ${provider}`;
continue;
}
if (provider === "elevenlabs") {
const output = TELEPHONY_OUTPUT.elevenlabs;
const audioBuffer = await elevenLabsTTS({
text: params.text,
apiKey,
baseUrl: config.elevenlabs.baseUrl,
voiceId: config.elevenlabs.voiceId,
modelId: config.elevenlabs.modelId,
outputFormat: output.format,
seed: config.elevenlabs.seed,
applyTextNormalization: config.elevenlabs.applyTextNormalization,
languageCode: config.elevenlabs.languageCode,
voiceSettings: config.elevenlabs.voiceSettings,
timeoutMs: config.timeoutMs,
});
return {
success: true,
audioBuffer,
latencyMs: Date.now() - providerStart,
provider,
outputFormat: output.format,
sampleRate: output.sampleRate,
};
}
const output = TELEPHONY_OUTPUT.openai;
const audioBuffer = await openaiTTS({
text: params.text,
apiKey,
model: config.openai.model,
voice: config.openai.voice,
responseFormat: output.format,
timeoutMs: config.timeoutMs,
});
return {
success: true,
audioBuffer,
latencyMs: Date.now() - providerStart,
provider,
outputFormat: output.format,
sampleRate: output.sampleRate,
};
} catch (err) {
const error = err as Error;
if (error.name === "AbortError") {
lastError = `${provider}: request timed out`;
} else {
lastError = `${provider}: ${error.message}`;
}
}
}
return {
success: false,
error: `TTS conversion failed: ${lastError || "no providers available"}`,
};
}
export async function maybeApplyTtsToPayload(params: { export async function maybeApplyTtsToPayload(params: {
payload: ReplyPayload; payload: ReplyPayload;
cfg: ClawdbotConfig; cfg: ClawdbotConfig;
-60
View File
@@ -1,60 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 16 16" role="img" aria-label="Pixel lobster">
<rect width="16" height="16" fill="none"/>
<g fill="#3a0a0d">
<rect x="1" y="5" width="1" height="3"/>
<rect x="2" y="4" width="1" height="1"/>
<rect x="2" y="8" width="1" height="1"/>
<rect x="3" y="3" width="1" height="1"/>
<rect x="3" y="9" width="1" height="1"/>
<rect x="4" y="2" width="1" height="1"/>
<rect x="4" y="10" width="1" height="1"/>
<rect x="5" y="2" width="6" height="1"/>
<rect x="11" y="2" width="1" height="1"/>
<rect x="12" y="3" width="1" height="1"/>
<rect x="12" y="9" width="1" height="1"/>
<rect x="13" y="4" width="1" height="1"/>
<rect x="13" y="8" width="1" height="1"/>
<rect x="14" y="5" width="1" height="3"/>
<rect x="5" y="11" width="6" height="1"/>
<rect x="4" y="12" width="1" height="1"/>
<rect x="11" y="12" width="1" height="1"/>
<rect x="3" y="13" width="1" height="1"/>
<rect x="12" y="13" width="1" height="1"/>
<rect x="5" y="14" width="6" height="1"/>
</g>
<g fill="#ff4f40">
<rect x="5" y="3" width="6" height="1"/>
<rect x="4" y="4" width="8" height="1"/>
<rect x="3" y="5" width="10" height="1"/>
<rect x="3" y="6" width="10" height="1"/>
<rect x="3" y="7" width="10" height="1"/>
<rect x="4" y="8" width="8" height="1"/>
<rect x="5" y="9" width="6" height="1"/>
<rect x="5" y="12" width="6" height="1"/>
<rect x="6" y="13" width="4" height="1"/>
</g>
<g fill="#ff775f">
<rect x="1" y="6" width="2" height="1"/>
<rect x="2" y="5" width="1" height="1"/>
<rect x="2" y="7" width="1" height="1"/>
<rect x="13" y="6" width="2" height="1"/>
<rect x="13" y="5" width="1" height="1"/>
<rect x="13" y="7" width="1" height="1"/>
</g>
<g fill="#081016">
<rect x="6" y="5" width="1" height="1"/>
<rect x="9" y="5" width="1" height="1"/>
</g>
<g fill="#f5fbff">
<rect x="6" y="4" width="1" height="1"/>
<rect x="9" y="4" width="1" height="1"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 2.1 KiB

+84 -238
View File
@@ -1,172 +1,60 @@
@import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap"); @import url("https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=Unbounded:wght@400;500;600&family=Work+Sans:wght@400;500;600;700&display=swap");
:root { :root {
/* Background - Deep Navy Slate */ --bg: #0a0f14;
--bg: #0c0d12; --bg-accent: #111826;
--bg-accent: #0d0e14; --bg-grad-1: #162031;
--bg-elevated: #181a21; --bg-grad-2: #1f2a22;
--bg-hover: #252830; --bg-overlay: rgba(255, 255, 255, 0.05);
--bg-muted: #252830; --bg-glow: rgba(245, 159, 74, 0.12);
--panel: rgba(14, 20, 30, 0.88);
/* Card / Surface */ --panel-strong: rgba(18, 26, 38, 0.96);
--card: #13151c; --chrome: rgba(9, 14, 20, 0.72);
--card-foreground: #f8fafc; --chrome-strong: rgba(9, 14, 20, 0.86);
--card-highlight: rgba(255, 255, 255, 0.04); --text: rgba(244, 246, 251, 0.96);
--popover: #13151c; --chat-text: rgba(231, 237, 244, 0.92);
--popover-foreground: #f8fafc; --muted: rgba(156, 169, 189, 0.72);
--border: rgba(255, 255, 255, 0.09);
/* Panel */ --border-strong: rgba(255, 255, 255, 0.16);
--panel: #0c0d12; --accent: #f59f4a;
--panel-strong: #181a21; --accent-2: #34c7b7;
--panel-hover: #252830; --ok: #2bd97f;
--chrome: rgba(12, 13, 18, 0.95); --warn: #f2c94c;
--chrome-strong: rgba(12, 13, 18, 0.98); --danger: #ff6b6b;
--focus: rgba(245, 159, 74, 0.35);
/* Text */ --grid-line: rgba(255, 255, 255, 0.04);
--text: #f8fafc;
--text-strong: #ffffff;
--chat-text: #f8fafc;
--muted: #94a3b8;
--muted-strong: #64748b;
--muted-foreground: #94a3b8;
/* Border */
--border: #333842;
--border-strong: #454d5c;
--border-hover: #5a6373;
--input: #333842;
--ring: #ff4d4d;
/* Accent - The signature red */
--accent: #ff4d4d;
--accent-hover: #ff6666;
--accent-muted: #ff4d4d;
--accent-subtle: rgba(255, 77, 77, 0.12);
--accent-foreground: #f8fafc;
--primary: #ff4d4d;
--primary-foreground: #ffffff;
/* Secondary */
--secondary: #252830;
--secondary-foreground: #f8fafc;
--accent-2: #3b82f6;
--accent-2-muted: rgba(59, 130, 246, 0.7);
/* Semantic */
--ok: #22c55e;
--ok-muted: rgba(34, 197, 94, 0.7);
--ok-subtle: rgba(34, 197, 94, 0.1);
--destructive: #ef4444;
--destructive-foreground: #fafafa;
--warn: #eab308;
--warn-muted: rgba(234, 179, 8, 0.7);
--warn-subtle: rgba(234, 179, 8, 0.1);
--danger: #ef4444;
--danger-muted: rgba(239, 68, 68, 0.7);
--danger-subtle: rgba(239, 68, 68, 0.1);
--info: #3b82f6;
/* Focus */
--focus: rgba(255, 77, 77, 0.2);
--focus-ring: 0 0 0 2px var(--bg), 0 0 0 4px var(--ring);
/* Grid */
--grid-line: rgba(255, 255, 255, 0.03);
/* Theme transition */
--theme-switch-x: 50%; --theme-switch-x: 50%;
--theme-switch-y: 50%; --theme-switch-y: 50%;
--mono: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
/* Typography */ "Liberation Mono", "Courier New", monospace;
--mono: "JetBrains Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Monaco, Consolas, monospace; --font-body: "Work Sans", system-ui, sans-serif;
--font-body: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; --font-display: "Unbounded", "Times New Roman", serif;
--font-display: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
/* Shadows - minimal */
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1);
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.1);
/* Radii - shadcn uses smaller radii */
--radius-sm: 4px;
--radius-md: 6px;
--radius-lg: 8px;
--radius-xl: 12px;
--radius-full: 9999px;
--radius: 6px;
/* Transitions */
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
--ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
--duration-fast: 150ms;
--duration-normal: 200ms;
--duration-slow: 300ms;
color-scheme: dark; color-scheme: dark;
} }
/* Light theme */
:root[data-theme="light"] { :root[data-theme="light"] {
--bg: #f8f8f7; --bg: #f5f1ea;
--bg-accent: #f3f2f0; --bg-accent: #ffffff;
--bg-elevated: #ffffff; --bg-grad-1: #f1e6d6;
--bg-hover: #eae8e6; --bg-grad-2: #e5eef4;
--bg-muted: #eae8e6; --bg-overlay: rgba(28, 32, 46, 0.05);
--bg-content: #f0efed; --bg-glow: rgba(52, 199, 183, 0.14);
--panel: rgba(255, 255, 255, 0.9);
--card: #ffffff; --panel-strong: rgba(255, 255, 255, 0.97);
--card-foreground: #1c1917; --chrome: rgba(255, 255, 255, 0.75);
--card-highlight: rgba(0, 0, 0, 0.04); --chrome-strong: rgba(255, 255, 255, 0.88);
--popover: #ffffff; --text: rgba(27, 36, 50, 0.98);
--popover-foreground: #1c1917; --chat-text: rgba(36, 48, 66, 0.9);
--muted: rgba(80, 94, 114, 0.7);
--panel: #f8f8f7; --border: rgba(18, 24, 40, 0.12);
--panel-strong: #f0efed; --border-strong: rgba(18, 24, 40, 0.2);
--panel-hover: #e5e3e1; --accent: #e28a3f;
--chrome: rgba(248, 248, 247, 0.95); --accent-2: #1ba99d;
--chrome-strong: rgba(248, 248, 247, 0.98); --ok: #1aa86c;
--warn: #b3771c;
--text: #44403c; --danger: #d44848;
--text-strong: #292524; --focus: rgba(226, 138, 63, 0.35);
--chat-text: #44403c; --grid-line: rgba(18, 24, 40, 0.06);
--muted: #5c5856;
--muted-strong: #44403c;
--muted-foreground: #5c5856;
--border: #e0dedc;
--border-strong: #d6d3d1;
--border-hover: #a8a5a0;
--input: #e0dedc;
--accent: #b91c1c;
--accent-hover: #dc2626;
--accent-muted: #b91c1c;
--accent-subtle: rgba(185, 28, 28, 0.18);
--accent-foreground: #ffffff;
--primary: #b91c1c;
--primary-foreground: #ffffff;
--secondary: #eae8e6;
--secondary-foreground: #44403c;
--ok: #15803d;
--ok-muted: rgba(21, 128, 61, 0.75);
--ok-subtle: rgba(21, 128, 61, 0.12);
--destructive: #b91c1c;
--destructive-foreground: #fafafa;
--warn: #a16207;
--warn-muted: rgba(161, 98, 7, 0.75);
--warn-subtle: rgba(161, 98, 7, 0.12);
--danger: #b91c1c;
--danger-muted: rgba(185, 28, 28, 0.75);
--danger-subtle: rgba(185, 28, 28, 0.12);
--info: #1d4ed8;
--focus: rgba(185, 28, 28, 0.25);
--grid-line: rgba(0, 0, 0, 0.06);
color-scheme: light; color-scheme: light;
} }
@@ -181,21 +69,44 @@ body {
body { body {
margin: 0; margin: 0;
font: 400 14px/1.5 var(--font-body); font: 15px/1.5 var(--font-body);
letter-spacing: -0.011em; background:
background: var(--bg); radial-gradient(1200px 900px at 15% -10%, var(--bg-grad-1) 0%, transparent 55%)
fixed,
radial-gradient(900px 700px at 80% 10%, var(--bg-grad-2) 0%, transparent 60%)
fixed,
linear-gradient(160deg, var(--bg) 0%, var(--bg-accent) 100%) fixed;
color: var(--text); color: var(--text);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
} }
/* Theme transition */ body::before {
content: "";
position: fixed;
inset: 0;
background:
linear-gradient(
140deg,
var(--bg-overlay) 0%,
rgba(255, 255, 255, 0) 40%
),
radial-gradient(620px 420px at 75% 75%, var(--bg-glow), transparent 60%);
pointer-events: none;
z-index: 0;
}
/* Grid overlay removed for cleaner look */
@keyframes theme-circle-transition { @keyframes theme-circle-transition {
0% { 0% {
clip-path: circle(0% at var(--theme-switch-x, 50%) var(--theme-switch-y, 50%)); clip-path: circle(
0% at var(--theme-switch-x, 50%) var(--theme-switch-y, 50%)
);
} }
100% { 100% {
clip-path: circle(150% at var(--theme-switch-x, 50%) var(--theme-switch-y, 50%)); clip-path: circle(
150% at var(--theme-switch-x, 50%) var(--theme-switch-y, 50%)
);
} }
} }
@@ -212,7 +123,7 @@ html.theme-transition::view-transition-old(theme) {
html.theme-transition::view-transition-new(theme) { html.theme-transition::view-transition-new(theme) {
mix-blend-mode: normal; mix-blend-mode: normal;
z-index: 2; z-index: 2;
animation: theme-circle-transition 0.4s var(--ease-out) forwards; animation: theme-circle-transition 0.45s ease-out forwards;
} }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
@@ -230,12 +141,7 @@ clawdbot-app {
} }
a { a {
color: var(--accent); color: inherit;
text-decoration: none;
}
a:hover {
text-decoration: underline;
} }
button, button,
@@ -246,35 +152,10 @@ select {
color: inherit; color: inherit;
} }
::selection {
background: var(--accent-subtle);
color: var(--text-strong);
}
/* Scrollbar styling */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: var(--radius-full);
}
::-webkit-scrollbar-thumb:hover {
background: var(--border-strong);
}
/* Animations */
@keyframes rise { @keyframes rise {
from { from {
opacity: 0; opacity: 0;
transform: translateY(4px); transform: translateY(6px);
} }
to { to {
opacity: 1; opacity: 1;
@@ -282,48 +163,13 @@ select {
} }
} }
@keyframes fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes scale-in {
from {
opacity: 0;
transform: scale(0.96);
}
to {
opacity: 1;
transform: scale(1);
}
}
@keyframes dashboard-enter { @keyframes dashboard-enter {
from { from {
opacity: 0; opacity: 0;
transform: translateY(8px); transform: translateY(12px);
} }
to { to {
opacity: 1; opacity: 1;
transform: translateY(0); transform: translateY(0);
} }
} }
@keyframes shimmer {
0% {
background-position: -200% 0;
}
100% {
background-position: 200% 0;
}
}
/* Focus visible styles */
:focus-visible {
outline: none;
box-shadow: var(--focus-ring);
}
+28 -70
View File
@@ -8,7 +8,7 @@
gap: 12px; gap: 12px;
align-items: flex-start; align-items: flex-start;
margin-bottom: 16px; margin-bottom: 16px;
margin-left: 4px; margin-left: 16px;
margin-right: 16px; margin-right: 16px;
} }
@@ -70,23 +70,23 @@
} }
.chat-avatar.user { .chat-avatar.user {
background: var(--accent-subtle); background: rgba(245, 159, 74, 0.2);
color: var(--accent); color: rgba(245, 159, 74, 1);
} }
.chat-avatar.assistant { .chat-avatar.assistant {
background: var(--secondary); background: rgba(52, 199, 183, 0.2);
color: var(--muted); color: rgba(52, 199, 183, 1);
} }
.chat-avatar.other { .chat-avatar.other {
background: var(--secondary); background: rgba(150, 150, 150, 0.2);
color: var(--muted); color: rgba(150, 150, 150, 1);
} }
.chat-avatar.tool { .chat-avatar.tool {
background: var(--secondary); background: rgba(134, 142, 150, 0.2);
color: var(--muted); color: rgba(134, 142, 150, 1);
} }
/* Image avatar support */ /* Image avatar support */
@@ -100,9 +100,9 @@ img.chat-avatar {
.chat-bubble { .chat-bubble {
position: relative; position: relative;
display: inline-block; display: inline-block;
border: 1px solid transparent; border: 1px solid var(--border);
background: var(--card); background: rgba(0, 0, 0, 0.12);
border-radius: var(--radius-lg); border-radius: 12px;
padding: 10px 14px; padding: 10px 14px;
box-shadow: none; box-shadow: none;
transition: background 150ms ease-out, border-color 150ms ease-out; transition: background 150ms ease-out, border-color 150ms ease-out;
@@ -119,9 +119,9 @@ img.chat-avatar {
top: 6px; top: 6px;
right: 8px; right: 8px;
border: 1px solid var(--border); border: 1px solid var(--border);
background: var(--bg); background: rgba(0, 0, 0, 0.22);
color: var(--muted); color: var(--muted);
border-radius: var(--radius-md); border-radius: 8px;
padding: 4px 6px; padding: 4px 6px;
font-size: 14px; font-size: 14px;
line-height: 1; line-height: 1;
@@ -132,40 +132,9 @@ img.chat-avatar {
} }
.chat-copy-btn__icon { .chat-copy-btn__icon {
display: inline-flex; display: inline-block;
width: 14px; width: 1em;
height: 14px; text-align: center;
position: relative;
}
.chat-copy-btn__icon svg {
width: 14px;
height: 14px;
stroke: currentColor;
fill: none;
stroke-width: 1.5px;
stroke-linecap: round;
stroke-linejoin: round;
}
.chat-copy-btn__icon-copy,
.chat-copy-btn__icon-check {
position: absolute;
top: 0;
left: 0;
transition: opacity 150ms ease;
}
.chat-copy-btn__icon-check {
opacity: 0;
}
.chat-copy-btn[data-copied="1"] .chat-copy-btn__icon-copy {
opacity: 0;
}
.chat-copy-btn[data-copied="1"] .chat-copy-btn__icon-check {
opacity: 1;
} }
.chat-bubble:hover .chat-copy-btn { .chat-bubble:hover .chat-copy-btn {
@@ -174,7 +143,7 @@ img.chat-avatar {
} }
.chat-copy-btn:hover { .chat-copy-btn:hover {
background: var(--bg-hover); background: rgba(0, 0, 0, 0.3);
} }
.chat-copy-btn[data-copying="1"] { .chat-copy-btn[data-copying="1"] {
@@ -185,17 +154,17 @@ img.chat-avatar {
.chat-copy-btn[data-error="1"] { .chat-copy-btn[data-error="1"] {
opacity: 1; opacity: 1;
pointer-events: auto; pointer-events: auto;
border-color: var(--danger-subtle); border-color: rgba(255, 69, 58, 0.8);
background: var(--danger-subtle); background: rgba(255, 69, 58, 0.18);
color: var(--danger); color: rgba(255, 69, 58, 1);
} }
.chat-copy-btn[data-copied="1"] { .chat-copy-btn[data-copied="1"] {
opacity: 1; opacity: 1;
pointer-events: auto; pointer-events: auto;
border-color: var(--ok-subtle); border-color: rgba(52, 199, 183, 0.8);
background: var(--ok-subtle); background: rgba(52, 199, 183, 0.18);
color: var(--ok); color: rgba(52, 199, 183, 1);
} }
.chat-copy-btn:focus-visible { .chat-copy-btn:focus-visible {
@@ -212,29 +181,18 @@ img.chat-avatar {
} }
} }
/* Light mode: restore borders */
:root[data-theme="light"] .chat-bubble {
border-color: var(--border);
box-shadow: inset 0 1px 0 var(--card-highlight);
}
.chat-bubble:hover { .chat-bubble:hover {
background: var(--bg-hover); background: rgba(0, 0, 0, 0.18);
} }
/* User bubbles have different styling */ /* User bubbles have different styling */
.chat-group.user .chat-bubble { .chat-group.user .chat-bubble {
background: var(--accent-subtle); background: rgba(245, 159, 74, 0.15);
border-color: transparent; border-color: rgba(245, 159, 74, 0.3);
}
:root[data-theme="light"] .chat-group.user .chat-bubble {
border-color: rgba(234, 88, 12, 0.2);
background: rgba(251, 146, 60, 0.12);
} }
.chat-group.user .chat-bubble:hover { .chat-group.user .chat-bubble:hover {
background: rgba(255, 77, 77, 0.15); background: rgba(245, 159, 74, 0.22);
} }
/* Streaming animation */ /* Streaming animation */
+15 -44
View File
@@ -52,8 +52,8 @@
flex: 1 1 0; /* Grow, shrink, and use 0 base for proper scrolling */ flex: 1 1 0; /* Grow, shrink, and use 0 base for proper scrolling */
overflow-y: auto; overflow-y: auto;
overflow-x: hidden; overflow-x: hidden;
padding: 12px 4px; padding: 12px;
margin: 0 -4px; margin: 0 -12px;
min-height: 0; /* Allow shrinking for flex scroll behavior */ min-height: 0; /* Allow shrinking for flex scroll behavior */
border-radius: 12px; border-radius: 12px;
background: transparent; background: transparent;
@@ -87,39 +87,23 @@
border-color: var(--accent); border-color: var(--accent);
} }
.chat-focus-exit svg {
width: 16px;
height: 16px;
stroke: currentColor;
fill: none;
stroke-width: 2px;
stroke-linecap: round;
stroke-linejoin: round;
}
/* Chat compose - sticky at bottom */ /* Chat compose - sticky at bottom */
.chat-compose { .chat-compose {
position: sticky; position: sticky;
bottom: 0; bottom: 0;
flex-shrink: 0; flex-shrink: 0;
display: flex; display: flex;
align-items: stretch; align-items: flex-end;
gap: 12px; gap: 12px;
margin-top: auto; /* Push to bottom of flex container */ margin-top: auto; /* Push to bottom of flex container */
padding: 12px 4px 4px; padding: 16px 0 4px;
background: linear-gradient(to bottom, transparent, var(--bg) 20%); background: linear-gradient(to bottom, transparent, var(--bg) 20%);
z-index: 10; z-index: 10;
} }
:root[data-theme="light"] .chat-compose {
background: linear-gradient(to bottom, transparent, var(--bg-content) 20%);
}
.chat-compose__field { .chat-compose__field {
flex: 1 1 auto; flex: 1 1 auto;
min-width: 0; min-width: 0;
display: flex;
align-items: stretch;
} }
/* Hide the "Message" label - keep textarea only */ /* Hide the "Message" label - keep textarea only */
@@ -130,11 +114,10 @@
/* Override .field textarea min-height (180px) from components.css */ /* Override .field textarea min-height (180px) from components.css */
.chat-compose .chat-compose__field textarea { .chat-compose .chat-compose__field textarea {
width: 100%; width: 100%;
height: 40px; min-height: 36px;
min-height: 40px;
max-height: 150px; max-height: 150px;
padding: 9px 12px; padding: 8px 12px;
border-radius: 8px; border-radius: 10px;
resize: vertical; resize: vertical;
white-space: pre-wrap; white-space: pre-wrap;
font-family: var(--font-body); font-family: var(--font-body);
@@ -151,18 +134,13 @@
flex-shrink: 0; flex-shrink: 0;
display: flex; display: flex;
align-items: stretch; align-items: stretch;
gap: 8px;
} }
.chat-compose .chat-compose__actions .btn { .chat-compose .chat-compose__actions .btn {
padding: 0 16px; padding: 8px 16px;
font-size: 13px; font-size: 13px;
height: 40px; min-height: 36px;
min-height: 40px;
max-height: 40px;
line-height: 1;
white-space: nowrap; white-space: nowrap;
box-sizing: border-box;
} }
/* Chat controls - moved to content-header area, left aligned */ /* Chat controls - moved to content-header area, left aligned */
@@ -216,27 +194,20 @@
/* Light theme icon button overrides */ /* Light theme icon button overrides */
:root[data-theme="light"] .btn--icon { :root[data-theme="light"] .btn--icon {
background: #ffffff; background: rgba(255, 255, 255, 0.9);
border-color: var(--border); border-color: rgba(16, 24, 40, 0.2);
box-shadow: 0 1px 2px rgba(16, 24, 40, 0.05); box-shadow: 0 1px 2px rgba(16, 24, 40, 0.05);
color: var(--muted); color: rgba(16, 24, 40, 0.7);
} }
:root[data-theme="light"] .btn--icon:hover { :root[data-theme="light"] .btn--icon:hover {
background: #ffffff; background: rgba(255, 255, 255, 1);
border-color: var(--border-strong); border-color: rgba(16, 24, 40, 0.3);
color: var(--text); color: rgba(16, 24, 40, 0.9);
} }
.btn--icon svg { .btn--icon svg {
display: block; display: block;
width: 18px;
height: 18px;
stroke: currentColor;
fill: none;
stroke-width: 1.5px;
stroke-linecap: round;
stroke-linejoin: round;
} }
.chat-controls__session select { .chat-controls__session select {
+3 -40
View File
@@ -14,8 +14,8 @@
} }
:root[data-theme="light"] .chat-thinking { :root[data-theme="light"] .chat-thinking {
border-color: rgba(16, 24, 40, 0.25); border-color: rgba(16, 24, 40, 0.18);
background: rgba(16, 24, 40, 0.04); background: rgba(16, 24, 40, 0.03);
} }
.chat-text { .chat-text {
@@ -75,46 +75,9 @@
} }
.chat-text :where(blockquote) { .chat-text :where(blockquote) {
border-left: 3px solid var(--border-strong); border-left: 3px solid var(--border);
padding-left: 12px; padding-left: 12px;
margin-left: 0;
color: var(--muted); color: var(--muted);
background: rgba(255, 255, 255, 0.02);
padding: 8px 12px;
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
}
.chat-text :where(blockquote blockquote) {
margin-top: 8px;
border-left-color: var(--border-hover);
background: rgba(255, 255, 255, 0.03);
}
.chat-text :where(blockquote blockquote blockquote) {
border-left-color: var(--muted-strong);
background: rgba(255, 255, 255, 0.04);
}
:root[data-theme="light"] .chat-text :where(blockquote) {
background: rgba(0, 0, 0, 0.03);
}
:root[data-theme="light"] .chat-text :where(blockquote blockquote) {
background: rgba(0, 0, 0, 0.05);
}
:root[data-theme="light"] .chat-text :where(blockquote blockquote blockquote) {
background: rgba(0, 0, 0, 0.04);
}
:root[data-theme="light"] .chat-text :where(:not(pre) > code) {
background: rgba(0, 0, 0, 0.08);
border: 1px solid rgba(0, 0, 0, 0.1);
}
:root[data-theme="light"] .chat-text :where(pre) {
background: rgba(0, 0, 0, 0.05);
border: 1px solid rgba(0, 0, 0, 0.1);
} }
.chat-text :where(hr) { .chat-text :where(hr) {
+16 -48
View File
@@ -4,8 +4,6 @@
border-radius: 8px; border-radius: 8px;
padding: 12px; padding: 12px;
margin-top: 8px; margin-top: 8px;
background: var(--card);
box-shadow: inset 0 1px 0 var(--card-highlight);
transition: border-color 150ms ease-out, background 150ms ease-out; transition: border-color 150ms ease-out, background 150ms ease-out;
/* Fixed max-height to ensure cards don't expand too much */ /* Fixed max-height to ensure cards don't expand too much */
max-height: 120px; max-height: 120px;
@@ -13,8 +11,8 @@
} }
.chat-tool-card:hover { .chat-tool-card:hover {
border-color: var(--border-strong); border-color: var(--accent);
background: var(--bg-hover); background: rgba(0, 0, 0, 0.06);
} }
/* First tool card in a group - no top margin */ /* First tool card in a group - no top margin */
@@ -52,63 +50,33 @@
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 16px; width: 18px;
height: 16px; height: 18px;
font-size: 14px;
line-height: 1;
font-family: "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", sans-serif;
vertical-align: middle;
flex-shrink: 0; flex-shrink: 0;
} }
.chat-tool-card__icon svg {
width: 14px;
height: 14px;
stroke: currentColor;
fill: none;
stroke-width: 1.5px;
stroke-linecap: round;
stroke-linejoin: round;
}
/* "View >" action link */ /* "View >" action link */
.chat-tool-card__action { .chat-tool-card__action {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 12px; font-size: 12px;
color: var(--accent); color: var(--accent);
opacity: 0.8; opacity: 0.8;
transition: opacity 150ms ease-out; transition: opacity 150ms ease-out;
} }
.chat-tool-card__action svg {
width: 12px;
height: 12px;
stroke: currentColor;
fill: none;
stroke-width: 1.5px;
stroke-linecap: round;
stroke-linejoin: round;
}
.chat-tool-card--clickable:hover .chat-tool-card__action { .chat-tool-card--clickable:hover .chat-tool-card__action {
opacity: 1; opacity: 1;
} }
/* Status indicator for completed/empty results */ /* Status indicator for completed/empty results */
.chat-tool-card__status { .chat-tool-card__status {
display: inline-flex; font-size: 14px;
align-items: center;
color: var(--ok); color: var(--ok);
} }
.chat-tool-card__status svg {
width: 14px;
height: 14px;
stroke: currentColor;
fill: none;
stroke-width: 2px;
stroke-linecap: round;
stroke-linejoin: round;
}
.chat-tool-card__status-text { .chat-tool-card__status-text {
font-size: 11px; font-size: 11px;
margin-top: 4px; margin-top: 4px;
@@ -126,18 +94,18 @@
color: var(--muted); color: var(--muted);
margin-top: 8px; margin-top: 8px;
padding: 8px 10px; padding: 8px 10px;
background: var(--secondary); background: rgba(0, 0, 0, 0.08);
border-radius: var(--radius-md); border-radius: 6px;
white-space: pre-wrap; white-space: pre-wrap;
overflow: hidden; overflow: hidden;
max-height: 44px; max-height: 44px;
line-height: 1.4; line-height: 1.4;
border: 1px solid var(--border); border: 1px solid rgba(255, 255, 255, 0.04);
} }
.chat-tool-card--clickable:hover .chat-tool-card__preview { .chat-tool-card--clickable:hover .chat-tool-card__preview {
background: var(--bg-hover); background: rgba(0, 0, 0, 0.12);
border-color: var(--border-strong); border-color: rgba(255, 255, 255, 0.08);
} }
/* Short inline output */ /* Short inline output */
@@ -146,8 +114,8 @@
color: var(--text); color: var(--text);
margin-top: 6px; margin-top: 6px;
padding: 6px 8px; padding: 6px 8px;
background: var(--secondary); background: rgba(0, 0, 0, 0.06);
border-radius: var(--radius-sm); border-radius: 4px;
white-space: pre-wrap; white-space: pre-wrap;
word-break: break-word; word-break: break-word;
} }
File diff suppressed because it is too large. Load diff
+296 -340
View File
File diff suppressed because it is too large. Load diff
+114 -205
View File
@@ -1,14 +1,10 @@
/* ===========================================
Shell Layout
=========================================== */
.shell { .shell {
--shell-pad: 16px; --shell-pad: 16px;
--shell-gap: 16px; --shell-gap: 16px;
--shell-nav-width: 220px; --shell-nav-width: 220px;
--shell-topbar-height: 56px; --shell-topbar-height: 56px;
--shell-focus-duration: 200ms; --shell-focus-duration: 220ms;
--shell-focus-ease: var(--ease-out); --shell-focus-ease: cubic-bezier(0.2, 0.85, 0.25, 1);
height: 100vh; height: 100vh;
display: grid; display: grid;
grid-template-columns: var(--shell-nav-width) minmax(0, 1fr); grid-template-columns: var(--shell-nav-width) minmax(0, 1fr);
@@ -17,7 +13,7 @@
"topbar topbar" "topbar topbar"
"nav content"; "nav content";
gap: 0; gap: 0;
animation: dashboard-enter 0.4s var(--ease-out); animation: dashboard-enter 0.6s ease-out;
transition: grid-template-columns var(--shell-focus-duration) var(--shell-focus-ease); transition: grid-template-columns var(--shell-focus-duration) var(--shell-focus-ease);
overflow: hidden; overflow: hidden;
} }
@@ -65,10 +61,6 @@
gap: 0; gap: 0;
} }
/* ===========================================
Topbar
=========================================== */
.topbar { .topbar {
grid-area: topbar; grid-area: topbar;
position: sticky; position: sticky;
@@ -81,7 +73,8 @@
padding: 0 20px; padding: 0 20px;
height: var(--shell-topbar-height); height: var(--shell-topbar-height);
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
background: var(--bg); background: var(--panel);
backdrop-filter: blur(18px);
} }
.topbar-left { .topbar-left {
@@ -91,83 +84,49 @@
} }
.topbar .nav-collapse-toggle { .topbar .nav-collapse-toggle {
width: 36px; width: 44px;
height: 36px; height: 44px;
margin-bottom: 0; margin-bottom: 0;
} }
.topbar .nav-collapse-toggle__icon { .topbar .nav-collapse-toggle__icon {
width: 20px; font-size: 22px;
height: 20px;
} }
.topbar .nav-collapse-toggle__icon svg {
width: 20px;
height: 20px;
}
/* Brand */
.brand { .brand {
display: flex;
align-items: center;
gap: 10px;
}
.brand-logo {
width: 28px;
height: 28px;
flex-shrink: 0;
}
.brand-logo img {
width: 100%;
height: 100%;
object-fit: contain;
}
.brand-text {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1px; gap: 2px;
} }
.brand-title { .brand-title {
font-size: 15px; font-family: var(--font-display);
font-size: 16px;
letter-spacing: 1px;
text-transform: uppercase;
font-weight: 600; font-weight: 600;
letter-spacing: -0.02em;
line-height: 1.1; line-height: 1.1;
color: var(--text-strong);
} }
.brand-sub { .brand-sub {
font-size: 11px; font-size: 10px;
font-weight: 500;
color: var(--muted); color: var(--muted);
letter-spacing: 0.02em; letter-spacing: 0.8px;
text-transform: uppercase;
line-height: 1; line-height: 1;
} }
/* Topbar status */
.topbar-status { .topbar-status {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
} }
/* Smaller pill and theme toggle in topbar */
.topbar-status .pill { .topbar-status .pill {
padding: 6px 10px; padding: 4px 10px;
gap: 6px; gap: 6px;
font-size: 12px; font-size: 11px;
font-weight: 500;
height: 32px;
box-sizing: border-box;
}
.topbar-status .pill .mono {
display: flex;
align-items: center;
line-height: 1;
margin-top: 0px;
} }
.topbar-status .statusDot { .topbar-status .statusDot {
@@ -176,9 +135,9 @@
} }
.topbar-status .theme-toggle { .topbar-status .theme-toggle {
--theme-item: 24px; --theme-item: 22px;
--theme-gap: 2px; --theme-gap: 4px;
--theme-pad: 3px; --theme-pad: 4px;
} }
.topbar-status .theme-icon { .topbar-status .theme-icon {
@@ -186,22 +145,17 @@
height: 12px; height: 12px;
} }
/* ===========================================
Navigation Sidebar
=========================================== */
.nav { .nav {
grid-area: nav; grid-area: nav;
overflow-y: auto; overflow-y: auto;
overflow-x: hidden; overflow-x: hidden;
padding: 16px 12px; padding: 16px;
border-right: 1px solid var(--border); border-right: 1px solid var(--border);
background: var(--bg); background: var(--panel);
transition: backdrop-filter: blur(18px);
width var(--shell-focus-duration) var(--shell-focus-ease), transition: width var(--shell-focus-duration) var(--shell-focus-ease),
padding var(--shell-focus-duration) var(--shell-focus-ease), padding var(--shell-focus-duration) var(--shell-focus-ease);
opacity var(--shell-focus-duration) var(--shell-focus-ease); min-height: 0; /* Allow grid item to shrink and enable scrolling */
min-height: 0;
} }
.shell--chat-focus .nav { .shell--chat-focus .nav {
@@ -210,9 +164,9 @@
border-width: 0; border-width: 0;
overflow: hidden; overflow: hidden;
pointer-events: none; pointer-events: none;
opacity: 0;
} }
/* Collapsed nav sidebar - completely hidden */
.nav--collapsed { .nav--collapsed {
width: 0; width: 0;
min-width: 0; min-width: 0;
@@ -223,7 +177,7 @@
pointer-events: none; pointer-events: none;
} }
/* Nav collapse toggle */ /* Nav collapse toggle button */
.nav-collapse-toggle { .nav-collapse-toggle {
width: 32px; width: 32px;
height: 32px; height: 32px;
@@ -232,88 +186,71 @@
justify-content: center; justify-content: center;
background: transparent; background: transparent;
border: 1px solid transparent; border: 1px solid transparent;
border-radius: var(--radius-md); border-radius: 6px;
cursor: pointer; cursor: pointer;
transition: transition: background 150ms ease, border-color 150ms ease;
background var(--duration-fast) ease,
border-color var(--duration-fast) ease;
margin-bottom: 16px; margin-bottom: 16px;
} }
.nav-collapse-toggle:hover { .nav-collapse-toggle:hover {
background: var(--bg-hover); background: rgba(255, 255, 255, 0.08);
border-color: var(--border); border-color: var(--border);
} }
:root[data-theme="light"] .nav-collapse-toggle:hover {
background: rgba(0, 0, 0, 0.06);
}
.nav-collapse-toggle__icon { .nav-collapse-toggle__icon {
display: flex; font-size: 16px;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
color: var(--muted); color: var(--muted);
transition: color var(--duration-fast) ease;
} }
.nav-collapse-toggle__icon svg {
width: 18px;
height: 18px;
stroke: currentColor;
fill: none;
stroke-width: 1.5px;
stroke-linecap: round;
stroke-linejoin: round;
}
.nav-collapse-toggle:hover .nav-collapse-toggle__icon {
color: var(--text);
}
/* Nav groups */
.nav-group { .nav-group {
margin-bottom: 20px; margin-bottom: 18px;
display: grid; display: grid;
gap: 2px; gap: 6px;
padding-bottom: 12px;
border-bottom: 1px dashed rgba(255, 255, 255, 0.08);
} }
.nav-group:last-child { .nav-group:last-child {
margin-bottom: 0; margin-bottom: 0;
padding-bottom: 0;
border-bottom: none;
} }
.nav-group__items { .nav-group__items {
display: grid; display: grid;
gap: 1px; gap: 4px;
} }
.nav-group--collapsed .nav-group__items { .nav-group--collapsed .nav-group__items {
display: none; display: none;
} }
/* Nav label */
.nav-label { .nav-label {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 8px; gap: 8px;
width: 100%; width: 100%;
padding: 6px 10px; padding: 4px 0;
font-size: 11px; font-size: 11px;
font-weight: 500; font-weight: 500;
color: var(--muted); text-transform: uppercase;
letter-spacing: 1.4px;
color: var(--text);
opacity: 0.7;
margin-bottom: 4px; margin-bottom: 4px;
background: transparent; background: transparent;
border: none; border: none;
cursor: pointer; cursor: pointer;
text-align: left; text-align: left;
border-radius: var(--radius-sm);
transition:
color var(--duration-fast) ease,
background var(--duration-fast) ease;
} }
.nav-label:hover { .nav-label:hover {
color: var(--text); opacity: 1;
background: var(--bg-hover);
} }
.nav-label--static { .nav-label--static {
@@ -321,8 +258,7 @@
} }
.nav-label--static:hover { .nav-label--static:hover {
color: var(--muted); opacity: 0.7;
background: transparent;
} }
.nav-label__text { .nav-label__text {
@@ -330,152 +266,131 @@
} }
.nav-label__chevron { .nav-label__chevron {
font-size: 10px; font-size: 12px;
opacity: 0.5; opacity: 0.6;
transition: transform var(--duration-fast) ease;
} }
.nav-group--collapsed .nav-label__chevron {
transform: rotate(-90deg);
}
/* Nav items */
.nav-item { .nav-item {
position: relative; position: relative;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: flex-start; justify-content: flex-start;
gap: 10px; gap: 8px;
padding: 8px 10px; padding: 10px 12px 10px 14px;
border-radius: var(--radius-md); border-radius: 12px;
border: 1px solid transparent; border: 1px solid transparent;
background: transparent; background: transparent;
color: var(--muted); color: var(--muted);
cursor: pointer; cursor: pointer;
text-decoration: none; text-decoration: none;
transition: transition: border-color 160ms ease, background 160ms ease, color 160ms ease;
border-color var(--duration-fast) ease,
background var(--duration-fast) ease,
color var(--duration-fast) ease;
} }
.nav-item__icon { .nav-item__icon {
width: 16px; font-size: 16px;
height: 16px; width: 18px;
height: 18px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
flex-shrink: 0; flex-shrink: 0;
opacity: 0.7;
transition: opacity var(--duration-fast) ease;
}
.nav-item__icon svg {
width: 16px;
height: 16px;
stroke: currentColor;
fill: none;
stroke-width: 1.5px;
stroke-linecap: round;
stroke-linejoin: round;
} }
.nav-item__text { .nav-item__text {
font-size: 13px; font-size: 13px;
font-weight: 500;
white-space: nowrap; white-space: nowrap;
} }
.nav-item:hover { .nav-item:hover {
color: var(--text); color: var(--text);
background: var(--bg-hover); border-color: rgba(255, 255, 255, 0.12);
text-decoration: none; background: rgba(255, 255, 255, 0.06);
} }
.nav-item:hover .nav-item__icon { .nav-item::before {
opacity: 1; content: "";
position: absolute;
left: 0;
top: 50%;
width: 4px;
height: 60%;
border-radius: 0 999px 999px 0;
transform: translateY(-50%);
background: transparent;
} }
.nav-item.active { .nav-item.active {
color: var(--text-strong); color: var(--text);
background: var(--accent-subtle); border-color: rgba(245, 159, 74, 0.45);
background: rgba(245, 159, 74, 0.12);
} }
.nav-item.active .nav-item__icon { .nav-item.active::before {
opacity: 1; background: var(--accent);
color: var(--accent); box-shadow: 0 0 12px rgba(245, 159, 74, 0.4);
} }
/* ===========================================
Content Area
=========================================== */
.content { .content {
grid-area: content; grid-area: content;
padding: 8px 8px 24px; padding: 8px 6px 20px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 20px; gap: 20px;
min-height: 0; min-height: 0;
overflow-y: auto; overflow-y: auto; /* Enable vertical scrolling for pages with long content */
overflow-x: hidden; overflow-x: hidden;
} }
:root[data-theme="light"] .content { /* Chat handles its own scrolling (chat-thread); avoid double scrollbars. */
background: var(--bg-content);
}
.content--chat { .content--chat {
overflow: hidden; overflow: hidden;
padding-bottom: 0;
} }
/* Content header */ .shell--chat .content {
/* No-op: keep chat layout consistent with other tabs */
}
.content-header { .content-header {
display: flex; display: flex;
align-items: flex-end; align-items: flex-end;
justify-content: space-between; justify-content: space-between;
gap: 16px; gap: 12px;
padding: 4px 8px; padding: 0 6px;
overflow: hidden; overflow: hidden;
transform-origin: top center; transform-origin: top center;
transition: transition: opacity var(--shell-focus-duration) var(--shell-focus-ease),
opacity var(--shell-focus-duration) var(--shell-focus-ease),
transform var(--shell-focus-duration) var(--shell-focus-ease), transform var(--shell-focus-duration) var(--shell-focus-ease),
max-height var(--shell-focus-duration) var(--shell-focus-ease), max-height var(--shell-focus-duration) var(--shell-focus-ease),
padding var(--shell-focus-duration) var(--shell-focus-ease); padding var(--shell-focus-duration) var(--shell-focus-ease);
max-height: 80px; max-height: 90px;
} }
.shell--chat-focus .content-header { .shell--chat-focus .content-header {
opacity: 0; opacity: 0;
transform: translateY(-8px); transform: translateY(-10px);
max-height: 0px; max-height: 0px;
padding: 0; padding: 0;
pointer-events: none; pointer-events: none;
} }
.page-title { .page-title {
font-size: 24px; font-family: var(--font-display);
font-weight: 600; font-size: 26px;
letter-spacing: -0.02em; letter-spacing: 0.6px;
line-height: 1.2;
color: var(--text-strong);
} }
.page-sub { .page-sub {
color: var(--muted); color: var(--muted);
font-size: 13px; font-size: 12px;
font-weight: 400; letter-spacing: 0.4px;
margin-top: 4px;
} }
.page-meta { .page-meta {
display: flex; display: flex;
gap: 8px; gap: 10px;
} }
/* Chat view header adjustments */ /* Chat view: header and controls side by side */
.content--chat .content-header { .content--chat .content-header {
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;
@@ -495,13 +410,9 @@
flex-shrink: 0; flex-shrink: 0;
} }
/* ===========================================
Grid Utilities
=========================================== */
.grid { .grid {
display: grid; display: grid;
gap: 16px; gap: 18px;
} }
.grid-cols-2 { .grid-cols-2 {
@@ -514,42 +425,39 @@
.stat-grid { .stat-grid {
display: grid; display: grid;
gap: 12px; gap: 14px;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
} }
.note-grid { .note-grid {
display: grid; display: grid;
gap: 12px; gap: 14px;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
} }
.row { .row {
display: flex; display: flex;
gap: 10px; gap: 12px;
align-items: center; align-items: center;
} }
.stack { .stack {
display: grid; display: grid;
gap: 12px; gap: 14px;
} }
.filters { .filters {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 8px; gap: 10px;
align-items: center; align-items: center;
} }
/* ===========================================
Responsive - Tablet
=========================================== */
@media (max-width: 1100px) { @media (max-width: 1100px) {
.shell { .shell {
--shell-pad: 12px; --shell-pad: 12px;
--shell-gap: 12px; --shell-gap: 12px;
--shell-nav-col: 1fr;
grid-template-columns: 1fr; grid-template-columns: 1fr;
grid-template-rows: auto auto 1fr; grid-template-rows: auto auto 1fr;
grid-template-areas: grid-template-areas:
@@ -562,18 +470,17 @@
position: static; position: static;
max-height: none; max-height: none;
display: flex; display: flex;
gap: 6px; gap: 16px;
overflow-x: auto; overflow-x: auto;
border-right: none; border-right: none;
border-bottom: 1px solid var(--border); padding: 12px;
padding: 10px 14px;
background: var(--bg);
} }
.nav-group { .nav-group {
grid-auto-flow: column; grid-auto-flow: column;
grid-template-columns: repeat(auto-fit, minmax(100px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
margin-bottom: 0; border-bottom: none;
padding-bottom: 0;
} }
.grid-cols-2, .grid-cols-2,
@@ -583,11 +490,13 @@
.topbar { .topbar {
position: static; position: static;
padding: 12px 14px; flex-direction: column;
gap: 10px; align-items: flex-start;
gap: 12px;
} }
.topbar-status { .topbar-status {
width: 100%;
flex-wrap: wrap; flex-wrap: wrap;
} }
+61 -121
View File
@@ -1,15 +1,12 @@
/* =========================================== /* Tablet/Mobile nav fix (under 1100px) - single horizontal scroll row */
Mobile Layout
=========================================== */
/* Tablet: Horizontal nav */
@media (max-width: 1100px) { @media (max-width: 1100px) {
/* Flatten nav into single horizontal scroll */
.nav { .nav {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
flex-wrap: nowrap; flex-wrap: nowrap;
gap: 4px; gap: 6px;
padding: 10px 14px; padding: 10px 12px;
overflow-x: auto; overflow-x: auto;
-webkit-overflow-scrolling: touch; -webkit-overflow-scrolling: touch;
scrollbar-width: none; scrollbar-width: none;
@@ -19,18 +16,21 @@
display: none; display: none;
} }
/* Nav groups should flow inline, not stack */
.nav-group { .nav-group {
display: contents; display: contents; /* Flatten group wrapper - items flow directly into .nav */
} }
.nav-group__items { .nav-group__items {
display: contents; display: contents; /* Flatten items wrapper too */
} }
/* Hide group labels on tablet/mobile */
.nav-label { .nav-label {
display: none; display: none;
} }
/* Don't hide nav items even if group is "collapsed" */
.nav-group--collapsed .nav-group__items { .nav-group--collapsed .nav-group__items {
display: contents; display: contents;
} }
@@ -38,22 +38,27 @@
.nav-item { .nav-item {
padding: 8px 14px; padding: 8px 14px;
font-size: 13px; font-size: 13px;
border-radius: var(--radius-md); border-radius: 10px;
white-space: nowrap; white-space: nowrap;
flex-shrink: 0; flex-shrink: 0;
} }
.nav-item::before {
display: none;
}
} }
/* Mobile-specific styles */ /* Mobile-specific improvements */
@media (max-width: 600px) { @media (max-width: 600px) {
.shell { .shell {
--shell-pad: 8px; --shell-pad: 8px;
--shell-gap: 8px; --shell-gap: 8px;
} }
/* Topbar */ /* Compact topbar for mobile */
.topbar { .topbar {
padding: 10px 12px; padding: 10px 12px;
border-radius: 12px;
gap: 8px; gap: 8px;
flex-direction: row; flex-direction: row;
flex-wrap: wrap; flex-wrap: wrap;
@@ -67,7 +72,8 @@
} }
.brand-title { .brand-title {
font-size: 14px; font-size: 15px;
letter-spacing: 0.3px;
} }
.brand-sub { .brand-sub {
@@ -94,10 +100,11 @@
display: none; display: none;
} }
/* Nav */ /* Horizontal scrollable nav for mobile */
.nav { .nav {
padding: 8px 10px; padding: 8px;
gap: 4px; border-radius: 12px;
gap: 8px;
-webkit-overflow-scrolling: touch; -webkit-overflow-scrolling: touch;
scrollbar-width: none; scrollbar-width: none;
} }
@@ -115,14 +122,18 @@
} }
.nav-item { .nav-item {
padding: 6px 10px; padding: 7px 10px;
font-size: 12px; font-size: 12px;
border-radius: var(--radius-md); border-radius: 8px;
white-space: nowrap; white-space: nowrap;
flex-shrink: 0; flex-shrink: 0;
} }
/* Content */ .nav-item::before {
display: none;
}
/* Hide page title on mobile - nav already shows where you are */
.content-header { .content-header {
display: none; display: none;
} }
@@ -132,17 +143,17 @@
gap: 12px; gap: 12px;
} }
/* Cards */ /* Smaller cards on mobile */
.card { .card {
padding: 12px; padding: 12px;
border-radius: var(--radius-md); border-radius: 12px;
} }
.card-title { .card-title {
font-size: 13px; font-size: 14px;
} }
/* Stats */ /* Stat grid adjustments */
.stat-grid { .stat-grid {
gap: 8px; gap: 8px;
grid-template-columns: repeat(2, 1fr); grid-template-columns: repeat(2, 1fr);
@@ -150,24 +161,24 @@
.stat { .stat {
padding: 10px; padding: 10px;
border-radius: var(--radius-md); border-radius: 10px;
} }
.stat-label { .stat-label {
font-size: 11px; font-size: 10px;
} }
.stat-value { .stat-value {
font-size: 18px; font-size: 16px;
} }
/* Notes */ /* Notes grid */
.note-grid { .note-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
gap: 8px; gap: 10px;
} }
/* Forms */ /* Form fields */
.form-grid { .form-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
gap: 10px; gap: 10px;
@@ -177,14 +188,14 @@
.field textarea, .field textarea,
.field select { .field select {
padding: 8px 10px; padding: 8px 10px;
border-radius: var(--radius-md); border-radius: 10px;
font-size: 14px; font-size: 14px;
} }
/* Buttons */ /* Buttons */
.btn { .btn {
padding: 8px 12px; padding: 8px 12px;
font-size: 12px; font-size: 13px;
} }
/* Pills */ /* Pills */
@@ -193,7 +204,7 @@
font-size: 12px; font-size: 12px;
} }
/* Chat */ /* Chat-specific mobile improvements */
.chat-header { .chat-header {
flex-direction: column; flex-direction: column;
align-items: stretch; align-items: stretch;
@@ -216,16 +227,17 @@
.chat-thread { .chat-thread {
margin-top: 8px; margin-top: 8px;
padding: 12px 8px; padding: 10px 8px;
border-radius: 12px;
} }
.chat-msg { .chat-msg {
max-width: 90%; max-width: 92%;
} }
.chat-bubble { .chat-bubble {
padding: 8px 12px; padding: 8px 10px;
border-radius: var(--radius-md); border-radius: 12px;
} }
.chat-compose { .chat-compose {
@@ -235,14 +247,14 @@
.chat-compose__field textarea { .chat-compose__field textarea {
min-height: 60px; min-height: 60px;
padding: 8px 10px; padding: 8px 10px;
border-radius: var(--radius-md); border-radius: 12px;
font-size: 14px; font-size: 14px;
} }
/* Log stream */ /* Log stream mobile */
.log-stream { .log-stream {
border-radius: var(--radius-md); border-radius: 10px;
max-height: 380px; max-height: 400px;
} }
.log-row { .log-row {
@@ -267,14 +279,14 @@
font-size: 12px; font-size: 12px;
} }
/* Lists */ /* List items */
.list-item { .list-item {
padding: 10px; padding: 10px;
border-radius: var(--radius-md); border-radius: 10px;
} }
.list-title { .list-title {
font-size: 13px; font-size: 14px;
} }
.list-sub { .list-sub {
@@ -284,91 +296,19 @@
/* Code blocks */ /* Code blocks */
.code-block { .code-block {
padding: 8px; padding: 8px;
border-radius: var(--radius-md); border-radius: 10px;
font-size: 11px; font-size: 11px;
} }
/* Theme toggle */ /* Theme toggle smaller */
.theme-toggle { .theme-toggle {
--theme-item: 24px; --theme-item: 24px;
--theme-gap: 2px; --theme-gap: 4px;
--theme-pad: 3px; --theme-pad: 4px;
} }
.theme-icon { .theme-icon {
width: 12px; width: 14px;
height: 12px; height: 14px;
}
}
/* Small mobile */
@media (max-width: 400px) {
.shell {
--shell-pad: 4px;
}
.topbar {
padding: 8px 10px;
}
.brand-title {
font-size: 13px;
}
.nav {
padding: 6px 8px;
}
.nav-item {
padding: 6px 8px;
font-size: 11px;
}
.content {
padding: 4px 4px 12px;
gap: 10px;
}
.card {
padding: 10px;
}
.stat {
padding: 8px;
}
.stat-value {
font-size: 16px;
}
.chat-bubble {
padding: 8px 10px;
}
.chat-compose__field textarea {
min-height: 52px;
padding: 8px 10px;
font-size: 13px;
}
.btn {
padding: 6px 10px;
font-size: 11px;
}
.topbar-status .pill {
padding: 3px 6px;
font-size: 10px;
}
.theme-toggle {
--theme-item: 22px;
--theme-gap: 2px;
--theme-pad: 2px;
}
.theme-icon {
width: 11px;
height: 11px;
} }
} }
+2 -3
View File
@@ -3,7 +3,6 @@ import { repeat } from "lit/directives/repeat.js";
import type { AppViewState } from "./app-view-state"; import type { AppViewState } from "./app-view-state";
import { iconForTab, pathForTab, titleForTab, type Tab } from "./navigation"; import { iconForTab, pathForTab, titleForTab, type Tab } from "./navigation";
import { icons } from "./icons";
import { loadChatHistory } from "./controllers/chat"; import { loadChatHistory } from "./controllers/chat";
import { syncUrlWithSessionKey } from "./app-settings"; import { syncUrlWithSessionKey } from "./app-settings";
import type { SessionsListResult } from "./types"; import type { SessionsListResult } from "./types";
@@ -32,7 +31,7 @@ export function renderTab(state: AppViewState, tab: Tab) {
}} }}
title=${titleForTab(tab)} title=${titleForTab(tab)}
> >
<span class="nav-item__icon" aria-hidden="true">${icons[iconForTab(tab)]}</span> <span class="nav-item__icon" aria-hidden="true">${iconForTab(tab)}</span>
<span class="nav-item__text">${titleForTab(tab)}</span> <span class="nav-item__text">${titleForTab(tab)}</span>
</a> </a>
`; `;
@@ -109,7 +108,7 @@ export function renderChatControls(state: AppViewState) {
? "Disabled during onboarding" ? "Disabled during onboarding"
: "Toggle assistant thinking/working output"} : "Toggle assistant thinking/working output"}
> >
${icons.brain} 🧠
</button> </button>
<button <button
class="btn btn--sm btn--icon ${focusActive ? "active" : ""}" class="btn btn--sm btn--icon ${focusActive ? "active" : ""}"
+4 -10
View File
@@ -11,7 +11,6 @@ import {
titleForTab, titleForTab,
type Tab, type Tab,
} from "./navigation"; } from "./navigation";
import { icons } from "./icons";
import type { UiSettings } from "./storage"; import type { UiSettings } from "./storage";
import type { ThemeMode } from "./theme"; import type { ThemeMode } from "./theme";
import type { ThemeTransitionContext } from "./theme-transition"; import type { ThemeTransitionContext } from "./theme-transition";
@@ -125,16 +124,11 @@ export function renderApp(state: AppViewState) {
title="${state.settings.navCollapsed ? "Expand sidebar" : "Collapse sidebar"}" title="${state.settings.navCollapsed ? "Expand sidebar" : "Collapse sidebar"}"
aria-label="${state.settings.navCollapsed ? "Expand sidebar" : "Collapse sidebar"}" aria-label="${state.settings.navCollapsed ? "Expand sidebar" : "Collapse sidebar"}"
> >
<span class="nav-collapse-toggle__icon">${icons.menu}</span> <span class="nav-collapse-toggle__icon">☰</span>
</button> </button>
<div class="brand"> <div class="brand">
<div class="brand-logo"> <div class="brand-title">CLAWDBOT</div>
<img src="/pixel-lobster.svg" alt="Clawdbot" /> <div class="brand-sub">Gateway Dashboard</div>
</div>
<div class="brand-text">
<div class="brand-title">CLAWDBOT</div>
<div class="brand-sub">Gateway Dashboard</div>
</div>
</div> </div>
</div> </div>
<div class="topbar-status"> <div class="topbar-status">
@@ -185,7 +179,7 @@ export function renderApp(state: AppViewState) {
rel="noreferrer" rel="noreferrer"
title="Docs (opens in new tab)" title="Docs (opens in new tab)"
> >
<span class="nav-item__icon" aria-hidden="true">${icons.book}</span> <span class="nav-item__icon" aria-hidden="true">📚</span>
<span class="nav-item__text">Docs</span> <span class="nav-item__text">Docs</span>
</a> </a>
</div> </div>
+10 -6
View File
@@ -1,11 +1,14 @@
import { html, type TemplateResult } from "lit"; import { html, type TemplateResult } from "lit";
import { icons } from "../icons"; import { renderEmojiIcon, setEmojiIcon } from "../icons";
const COPIED_FOR_MS = 1500; const COPIED_FOR_MS = 1500;
const ERROR_FOR_MS = 2000; const ERROR_FOR_MS = 2000;
const COPY_LABEL = "Copy as markdown"; const COPY_LABEL = "Copy as markdown";
const COPIED_LABEL = "Copied"; const COPIED_LABEL = "Copied";
const ERROR_LABEL = "Copy failed"; const ERROR_LABEL = "Copy failed";
const COPY_ICON = "📋";
const COPIED_ICON = "✓";
const ERROR_ICON = "!";
type CopyButtonOptions = { type CopyButtonOptions = {
text: () => string; text: () => string;
@@ -38,7 +41,7 @@ function createCopyButton(options: CopyButtonOptions): TemplateResult {
aria-label=${idleLabel} aria-label=${idleLabel}
@click=${async (e: Event) => { @click=${async (e: Event) => {
const btn = e.currentTarget as HTMLButtonElement | null; const btn = e.currentTarget as HTMLButtonElement | null;
const iconContainer = btn?.querySelector( const icon = btn?.querySelector(
".chat-copy-btn__icon", ".chat-copy-btn__icon",
) as HTMLElement | null; ) as HTMLElement | null;
@@ -58,29 +61,30 @@ function createCopyButton(options: CopyButtonOptions): TemplateResult {
if (!copied) { if (!copied) {
btn.dataset.error = "1"; btn.dataset.error = "1";
setButtonLabel(btn, ERROR_LABEL); setButtonLabel(btn, ERROR_LABEL);
setEmojiIcon(icon, ERROR_ICON);
window.setTimeout(() => { window.setTimeout(() => {
if (!btn.isConnected) return; if (!btn.isConnected) return;
delete btn.dataset.error; delete btn.dataset.error;
setButtonLabel(btn, idleLabel); setButtonLabel(btn, idleLabel);
setEmojiIcon(icon, COPY_ICON);
}, ERROR_FOR_MS); }, ERROR_FOR_MS);
return; return;
} }
btn.dataset.copied = "1"; btn.dataset.copied = "1";
setButtonLabel(btn, COPIED_LABEL); setButtonLabel(btn, COPIED_LABEL);
setEmojiIcon(icon, COPIED_ICON);
window.setTimeout(() => { window.setTimeout(() => {
if (!btn.isConnected) return; if (!btn.isConnected) return;
delete btn.dataset.copied; delete btn.dataset.copied;
setButtonLabel(btn, idleLabel); setButtonLabel(btn, idleLabel);
setEmojiIcon(icon, COPY_ICON);
}, COPIED_FOR_MS); }, COPIED_FOR_MS);
}} }}
> >
<span class="chat-copy-btn__icon" aria-hidden="true"> ${renderEmojiIcon(COPY_ICON, "chat-copy-btn__icon")}
<span class="chat-copy-btn__icon-copy">${icons.copy}</span>
<span class="chat-copy-btn__icon-check">${icons.check}</span>
</span>
</button> </button>
`; `;
} }
+3 -4
View File
@@ -1,7 +1,6 @@
import { html, nothing } from "lit"; import { html, nothing } from "lit";
import { formatToolDetail, resolveToolDisplay } from "../tool-display"; import { formatToolDetail, resolveToolDisplay } from "../tool-display";
import { icons } from "../icons";
import type { ToolCard } from "../types/chat-types"; import type { ToolCard } from "../types/chat-types";
import { TOOL_INLINE_THRESHOLD } from "./constants"; import { TOOL_INLINE_THRESHOLD } from "./constants";
import { import {
@@ -96,13 +95,13 @@ export function renderToolCardSidebar(
> >
<div class="chat-tool-card__header"> <div class="chat-tool-card__header">
<div class="chat-tool-card__title"> <div class="chat-tool-card__title">
<span class="chat-tool-card__icon">${icons[display.icon]}</span> <span class="chat-tool-card__icon">${display.emoji}</span>
<span>${display.label}</span> <span>${display.label}</span>
</div> </div>
${canClick ${canClick
? html`<span class="chat-tool-card__action">${hasText ? "View" : ""} ${icons.check}</span>` ? html`<span class="chat-tool-card__action">${hasText ? "View ›" : "›"}</span>`
: nothing} : nothing}
${isEmpty && !canClick ? html`<span class="chat-tool-card__status">${icons.check}</span>` : nothing} ${isEmpty && !canClick ? html`<span class="chat-tool-card__status">✓</span>` : nothing}
</div> </div>
${detail ${detail
? html`<div class="chat-tool-card__detail">${detail}</div>` ? html`<div class="chat-tool-card__detail">${detail}</div>`
+2 -54
View File
@@ -1,59 +1,7 @@
import { html, type TemplateResult } from "lit"; import { html, type TemplateResult } from "lit";
// Lucide-style SVG icons export function renderEmojiIcon(icon: string, className: string): TemplateResult {
// All icons use currentColor for stroke return html`<span class=${className} aria-hidden="true">${icon}</span>`;
export const icons = {
// Navigation icons
messageSquare: html`<svg viewBox="0 0 24 24"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>`,
barChart: html`<svg viewBox="0 0 24 24"><line x1="12" x2="12" y1="20" y2="10"/><line x1="18" x2="18" y1="20" y2="4"/><line x1="6" x2="6" y1="20" y2="16"/></svg>`,
link: html`<svg viewBox="0 0 24 24"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>`,
radio: html`<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="2"/><path d="M16.24 7.76a6 6 0 0 1 0 8.49m-8.48-.01a6 6 0 0 1 0-8.49m11.31-2.82a10 10 0 0 1 0 14.14m-14.14 0a10 10 0 0 1 0-14.14"/></svg>`,
fileText: html`<svg viewBox="0 0 24 24"><path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"/><polyline points="14 2 14 8 20 8"/><line x1="16" x2="8" y1="13" y2="13"/><line x1="16" x2="8" y1="17" y2="17"/><line x1="10" x2="8" y1="9" y2="9"/></svg>`,
zap: html`<svg viewBox="0 0 24 24"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>`,
monitor: html`<svg viewBox="0 0 24 24"><rect width="20" height="14" x="2" y="3" rx="2"/><line x1="8" x2="16" y1="21" y2="21"/><line x1="12" x2="12" y1="17" y2="21"/></svg>`,
settings: html`<svg viewBox="0 0 24 24"><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/></svg>`,
bug: html`<svg viewBox="0 0 24 24"><path d="m8 2 1.88 1.88"/><path d="M14.12 3.88 16 2"/><path d="M9 7.13v-1a3.003 3.003 0 1 1 6 0v1"/><path d="M12 20c-3.3 0-6-2.7-6-6v-3a4 4 0 0 1 4-4h4a4 4 0 0 1 4 4v3c0 3.3-2.7 6-6 6"/><path d="M12 20v-9"/><path d="M6.53 9C4.6 8.8 3 7.1 3 5"/><path d="M6 13H2"/><path d="M3 21c0-2.1 1.7-3.9 3.8-4"/><path d="M20.97 5c0 2.1-1.6 3.8-3.5 4"/><path d="M22 13h-4"/><path d="M17.2 17c2.1.1 3.8 1.9 3.8 4"/></svg>`,
scrollText: html`<svg viewBox="0 0 24 24"><path d="M8 21h12a2 2 0 0 0 2-2v-2H10v2a2 2 0 1 1-4 0V5a2 2 0 1 0-4 0v3h4"/><path d="M19 17V5a2 2 0 0 0-2-2H4"/><path d="M15 8h-5"/><path d="M15 12h-5"/></svg>`,
folder: html`<svg viewBox="0 0 24 24"><path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/></svg>`,
// UI icons
menu: html`<svg viewBox="0 0 24 24"><line x1="4" x2="20" y1="12" y2="12"/><line x1="4" x2="20" y1="6" y2="6"/><line x1="4" x2="20" y1="18" y2="18"/></svg>`,
x: html`<svg viewBox="0 0 24 24"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>`,
check: html`<svg viewBox="0 0 24 24"><path d="M20 6 9 17l-5-5"/></svg>`,
copy: html`<svg viewBox="0 0 24 24"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>`,
search: html`<svg viewBox="0 0 24 24"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>`,
brain: html`<svg viewBox="0 0 24 24"><path d="M12 5a3 3 0 1 0-5.997.125 4 4 0 0 0-2.526 5.77 4 4 0 0 0 .556 6.588A4 4 0 1 0 12 18Z"/><path d="M12 5a3 3 0 1 1 5.997.125 4 4 0 0 1 2.526 5.77 4 4 0 0 1-.556 6.588A4 4 0 1 1 12 18Z"/><path d="M15 13a4.5 4.5 0 0 1-3-4 4.5 4.5 0 0 1-3 4"/><path d="M17.599 6.5a3 3 0 0 0 .399-1.375"/><path d="M6.003 5.125A3 3 0 0 0 6.401 6.5"/><path d="M3.477 10.896a4 4 0 0 1 .585-.396"/><path d="M19.938 10.5a4 4 0 0 1 .585.396"/><path d="M6 18a4 4 0 0 1-1.967-.516"/><path d="M19.967 17.484A4 4 0 0 1 18 18"/></svg>`,
book: html`<svg viewBox="0 0 24 24"><path d="M4 19.5v-15A2.5 2.5 0 0 1 6.5 2H20v20H6.5a2.5 2.5 0 0 1 0-5H20"/></svg>`,
loader: html`<svg viewBox="0 0 24 24"><path d="M12 2v4"/><path d="m16.2 7.8 2.9-2.9"/><path d="M18 12h4"/><path d="m16.2 16.2 2.9 2.9"/><path d="M12 18v4"/><path d="m4.9 19.1 2.9-2.9"/><path d="M2 12h4"/><path d="m4.9 4.9 2.9 2.9"/></svg>`,
// Tool icons
wrench: html`<svg viewBox="0 0 24 24"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg>`,
fileCode: html`<svg viewBox="0 0 24 24"><path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"/><polyline points="14 2 14 8 20 8"/><path d="m10 13-2 2 2 2"/><path d="m14 17 2-2-2-2"/></svg>`,
edit: html`<svg viewBox="0 0 24 24"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>`,
penLine: html`<svg viewBox="0 0 24 24"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>`,
paperclip: html`<svg viewBox="0 0 24 24"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 1 1 18 8.84l-8.59 8.57a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>`,
globe: html`<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"/><path d="M2 12h20"/></svg>`,
image: html`<svg viewBox="0 0 24 24"><rect width="18" height="18" x="3" y="3" rx="2" ry="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/></svg>`,
smartphone: html`<svg viewBox="0 0 24 24"><rect width="14" height="20" x="5" y="2" rx="2" ry="2"/><path d="M12 18h.01"/></svg>`,
plug: html`<svg viewBox="0 0 24 24"><path d="M12 22v-5"/><path d="M9 8V2"/><path d="M15 8V2"/><path d="M18 8v5a4 4 0 0 1-4 4h-4a4 4 0 0 1-4-4V8Z"/></svg>`,
circle: html`<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/></svg>`,
puzzle: html`<svg viewBox="0 0 24 24"><path d="M19.439 7.85c-.049.322.059.648.289.878l1.568 1.568c.47.47.706 1.087.706 1.704s-.235 1.233-.706 1.704l-1.611 1.611a.98.98 0 0 1-.837.276c-.47-.07-.802-.48-.968-.925a2.501 2.501 0 1 0-3.214 3.214c.446.166.855.497.925.968a.979.979 0 0 1-.276.837l-1.61 1.61a2.404 2.404 0 0 1-1.705.707 2.402 2.402 0 0 1-1.704-.706l-1.568-1.568a1.026 1.026 0 0 0-.877-.29c-.493.074-.84.504-1.02.968a2.5 2.5 0 1 1-3.237-3.237c.464-.18.894-.527.967-1.02a1.026 1.026 0 0 0-.289-.877l-1.568-1.568A2.402 2.402 0 0 1 1.998 12c0-.617.236-1.234.706-1.704L4.23 8.77c.24-.24.581-.353.917-.303.515.076.874.54 1.02 1.02a2.5 2.5 0 1 0 3.237-3.237c-.48-.146-.944-.505-1.02-1.02a.98.98 0 0 1 .303-.917l1.526-1.526A2.402 2.402 0 0 1 11.998 2c.617 0 1.234.236 1.704.706l1.568 1.568c.23.23.556.338.877.29.493-.074.84-.504 1.02-.968a2.5 2.5 0 1 1 3.236 3.236c-.464.18-.894.527-.967 1.02Z"/></svg>`,
} as const;
export type IconName = keyof typeof icons;
export function icon(name: IconName): TemplateResult {
return icons[name];
}
export function renderIcon(name: IconName, className = "nav-item__icon"): TemplateResult {
return html`<span class=${className} aria-hidden="true">${icons[name]}</span>`;
}
// Legacy function for compatibility
export function renderEmojiIcon(iconContent: string | TemplateResult, className: string): TemplateResult {
return html`<span class=${className} aria-hidden="true">${iconContent}</span>`;
} }
export function setEmojiIcon(target: HTMLElement | null, icon: string): void { export function setEmojiIcon(target: HTMLElement | null, icon: string): void {
+13 -15
View File
@@ -1,5 +1,3 @@
import type { IconName } from "./icons.js";
export const TAB_GROUPS = [ export const TAB_GROUPS = [
{ label: "Chat", tabs: ["chat"] }, { label: "Chat", tabs: ["chat"] },
{ {
@@ -100,32 +98,32 @@ export function inferBasePathFromPathname(pathname: string): string {
return `/${segments.join("/")}`; return `/${segments.join("/")}`;
} }
export function iconForTab(tab: Tab): IconName { export function iconForTab(tab: Tab): string {
switch (tab) { switch (tab) {
case "chat": case "chat":
return "messageSquare"; return "💬";
case "overview": case "overview":
return "barChart"; return "📊";
case "channels": case "channels":
return "link"; return "🔗";
case "instances": case "instances":
return "radio"; return "📡";
case "sessions": case "sessions":
return "fileText"; return "📄";
case "cron": case "cron":
return "loader"; return "⏰";
case "skills": case "skills":
return "zap"; return "⚡️";
case "nodes": case "nodes":
return "monitor"; return "🖥️";
case "config": case "config":
return "settings"; return "⚙️";
case "debug": case "debug":
return "bug"; return "🐞";
case "logs": case "logs":
return "scrollText"; return "🧾";
default: default:
return "folder"; return "📁";
} }
} }
+15 -15
View File
@@ -1,7 +1,7 @@
{ {
"version": 1, "version": 1,
"fallback": { "fallback": {
"icon": "puzzle", "emoji": "🧩",
"detailKeys": [ "detailKeys": [
"command", "command",
"path", "path",
@@ -26,37 +26,37 @@
}, },
"tools": { "tools": {
"bash": { "bash": {
"icon": "wrench", "emoji": "🛠️",
"title": "Bash", "title": "Bash",
"detailKeys": ["command"] "detailKeys": ["command"]
}, },
"process": { "process": {
"icon": "wrench", "emoji": "🧰",
"title": "Process", "title": "Process",
"detailKeys": ["sessionId"] "detailKeys": ["sessionId"]
}, },
"read": { "read": {
"icon": "fileText", "emoji": "📖",
"title": "Read", "title": "Read",
"detailKeys": ["path"] "detailKeys": ["path"]
}, },
"write": { "write": {
"icon": "edit", "emoji": "✍️",
"title": "Write", "title": "Write",
"detailKeys": ["path"] "detailKeys": ["path"]
}, },
"edit": { "edit": {
"icon": "penLine", "emoji": "📝",
"title": "Edit", "title": "Edit",
"detailKeys": ["path"] "detailKeys": ["path"]
}, },
"attach": { "attach": {
"icon": "paperclip", "emoji": "📎",
"title": "Attach", "title": "Attach",
"detailKeys": ["path", "url", "fileName"] "detailKeys": ["path", "url", "fileName"]
}, },
"browser": { "browser": {
"icon": "globe", "emoji": "🌐",
"title": "Browser", "title": "Browser",
"actions": { "actions": {
"status": { "label": "status" }, "status": { "label": "status" },
@@ -95,7 +95,7 @@
} }
}, },
"canvas": { "canvas": {
"icon": "image", "emoji": "🖼️",
"title": "Canvas", "title": "Canvas",
"actions": { "actions": {
"present": { "label": "present", "detailKeys": ["target", "node", "nodeId"] }, "present": { "label": "present", "detailKeys": ["target", "node", "nodeId"] },
@@ -108,7 +108,7 @@
} }
}, },
"nodes": { "nodes": {
"icon": "smartphone", "emoji": "📱",
"title": "Nodes", "title": "Nodes",
"actions": { "actions": {
"status": { "label": "status" }, "status": { "label": "status" },
@@ -127,7 +127,7 @@
} }
}, },
"cron": { "cron": {
"icon": "loader", "emoji": "⏰",
"title": "Cron", "title": "Cron",
"actions": { "actions": {
"status": { "label": "status" }, "status": { "label": "status" },
@@ -144,7 +144,7 @@
} }
}, },
"gateway": { "gateway": {
"icon": "plug", "emoji": "🔌",
"title": "Gateway", "title": "Gateway",
"actions": { "actions": {
"restart": { "label": "restart", "detailKeys": ["reason", "delayMs"] }, "restart": { "label": "restart", "detailKeys": ["reason", "delayMs"] },
@@ -161,7 +161,7 @@
} }
}, },
"whatsapp_login": { "whatsapp_login": {
"icon": "circle", "emoji": "🟢",
"title": "WhatsApp Login", "title": "WhatsApp Login",
"actions": { "actions": {
"start": { "label": "start" }, "start": { "label": "start" },
@@ -169,7 +169,7 @@
} }
}, },
"discord": { "discord": {
"icon": "messageSquare", "emoji": "💬",
"title": "Discord", "title": "Discord",
"actions": { "actions": {
"react": { "label": "react", "detailKeys": ["channelId", "messageId", "emoji"] }, "react": { "label": "react", "detailKeys": ["channelId", "messageId", "emoji"] },
@@ -204,7 +204,7 @@
} }
}, },
"slack": { "slack": {
"icon": "messageSquare", "emoji": "💬",
"title": "Slack", "title": "Slack",
"actions": { "actions": {
"react": { "label": "react", "detailKeys": ["channelId", "messageId", "emoji"] }, "react": { "label": "react", "detailKeys": ["channelId", "messageId", "emoji"] },
+8 -7
View File
@@ -1,5 +1,4 @@
import rawConfig from "./tool-display.json"; import rawConfig from "./tool-display.json";
import type { IconName } from "./icons";
type ToolDisplayActionSpec = { type ToolDisplayActionSpec = {
label?: string; label?: string;
@@ -7,7 +6,7 @@ type ToolDisplayActionSpec = {
}; };
type ToolDisplaySpec = { type ToolDisplaySpec = {
icon?: string; emoji?: string;
title?: string; title?: string;
label?: string; label?: string;
detailKeys?: string[]; detailKeys?: string[];
@@ -22,7 +21,7 @@ type ToolDisplayConfig = {
export type ToolDisplay = { export type ToolDisplay = {
name: string; name: string;
icon: IconName; emoji: string;
title: string; title: string;
label: string; label: string;
verb?: string; verb?: string;
@@ -30,7 +29,7 @@ export type ToolDisplay = {
}; };
const TOOL_DISPLAY_CONFIG = rawConfig as ToolDisplayConfig; const TOOL_DISPLAY_CONFIG = rawConfig as ToolDisplayConfig;
const FALLBACK = TOOL_DISPLAY_CONFIG.fallback ?? { icon: "puzzle" }; const FALLBACK = TOOL_DISPLAY_CONFIG.fallback ?? { emoji: "🧩" };
const TOOL_MAP = TOOL_DISPLAY_CONFIG.tools ?? {}; const TOOL_MAP = TOOL_DISPLAY_CONFIG.tools ?? {};
function normalizeToolName(name?: string): string { function normalizeToolName(name?: string): string {
@@ -136,7 +135,7 @@ export function resolveToolDisplay(params: {
const name = normalizeToolName(params.name); const name = normalizeToolName(params.name);
const key = name.toLowerCase(); const key = name.toLowerCase();
const spec = TOOL_MAP[key]; const spec = TOOL_MAP[key];
const icon = (spec?.icon ?? FALLBACK.icon ?? "puzzle") as IconName; const emoji = spec?.emoji ?? FALLBACK.emoji ?? "🧩";
const title = spec?.title ?? defaultTitle(name); const title = spec?.title ?? defaultTitle(name);
const label = spec?.label ?? name; const label = spec?.label ?? name;
const actionRaw = const actionRaw =
@@ -169,7 +168,7 @@ export function resolveToolDisplay(params: {
return { return {
name, name,
icon, emoji,
title, title,
label, label,
verb, verb,
@@ -187,7 +186,9 @@ export function formatToolDetail(display: ToolDisplay): string | undefined {
export function formatToolSummary(display: ToolDisplay): string { export function formatToolSummary(display: ToolDisplay): string {
const detail = formatToolDetail(display); const detail = formatToolDetail(display);
return detail ? `${display.label}: ${detail}` : display.label; return detail
? `${display.emoji} ${display.label}: ${detail}`
: `${display.emoji} ${display.label}`;
} }
function shortenHomeInString(input: string): string { function shortenHomeInString(input: string): string {
+6 -7
View File
@@ -3,7 +3,6 @@ import { repeat } from "lit/directives/repeat.js";
import type { SessionsListResult } from "../types"; import type { SessionsListResult } from "../types";
import type { ChatQueueItem } from "../ui-types"; import type { ChatQueueItem } from "../ui-types";
import type { ChatItem, MessageGroup } from "../types/chat-types"; import type { ChatItem, MessageGroup } from "../types/chat-types";
import { icons } from "../icons";
import { import {
normalizeMessage, normalizeMessage,
normalizeRoleForGrouping, normalizeRoleForGrouping,
@@ -75,18 +74,18 @@ function renderCompactionIndicator(status: CompactionIndicatorStatus | null | un
if (status.active) { if (status.active) {
return html` return html`
<div class="callout info compaction-indicator compaction-indicator--active"> <div class="callout info compaction-indicator compaction-indicator--active">
${icons.loader} Compacting context... 🧹 Compacting context...
</div> </div>
`; `;
} }
// Show "compaction complete" briefly after completion // Show "compaction complete" briefly after completion
if (status.completedAt) { if (status.completedAt) {
const elapsed = Date.now() - status.completedAt; const elapsed = Date.now() - status.completedAt;
if (elapsed < COMPACTION_TOAST_DURATION_MS) { if (elapsed < COMPACTION_TOAST_DURATION_MS) {
return html` return html`
<div class="callout success compaction-indicator compaction-indicator--complete"> <div class="callout success compaction-indicator compaction-indicator--complete">
${icons.check} Context compacted 🧹 Context compacted
</div> </div>
`; `;
} }
@@ -172,7 +171,7 @@ export function renderChat(props: ChatProps) {
aria-label="Exit focus mode" aria-label="Exit focus mode"
title="Exit focus mode" title="Exit focus mode"
> >
${icons.x} ✕
</button> </button>
` `
: nothing} : nothing}
@@ -224,7 +223,7 @@ export function renderChat(props: ChatProps) {
aria-label="Remove queued message" aria-label="Remove queued message"
@click=${() => props.onQueueRemove(item.id)} @click=${() => props.onQueueRemove(item.id)}
> >
${icons.x} ✕
</button> </button>
</div> </div>
`, `,
@@ -266,7 +265,7 @@ export function renderChat(props: ChatProps) {
?disabled=${!props.connected} ?disabled=${!props.connected}
@click=${props.onSend} @click=${props.onSend}
> >
${isBusy ? "Queue" : "Send"}<kbd class="btn-kbd">↵</kbd> ${isBusy ? "Queue" : "Send"}
</button> </button>
</div> </div>
</div> </div>
+1 -2
View File
@@ -1,6 +1,5 @@
import { html, nothing } from "lit"; import { html, nothing } from "lit";
import type { ConfigUiHints } from "../types"; import type { ConfigUiHints } from "../types";
import { icons } from "../icons";
import { import {
hintForPath, hintForPath,
humanize, humanize,
@@ -190,7 +189,7 @@ export function renderConfigForm(props: ConfigFormProps) {
if (filteredEntries.length === 0) { if (filteredEntries.length === 0) {
return html` return html`
<div class="config-empty"> <div class="config-empty">
<div class="config-empty__icon">${icons.search}</div> <div class="config-empty__icon">🔍</div>
<div class="config-empty__text"> <div class="config-empty__text">
${searchQuery ${searchQuery
? `No settings match "${searchQuery}"` ? `No settings match "${searchQuery}"`
+1 -2
View File
@@ -1,7 +1,6 @@
import { html, nothing } from "lit"; import { html, nothing } from "lit";
import { unsafeHTML } from "lit/directives/unsafe-html.js"; import { unsafeHTML } from "lit/directives/unsafe-html.js";
import { icons } from "../icons";
import { toSanitizedMarkdownHtml } from "../markdown"; import { toSanitizedMarkdownHtml } from "../markdown";
export type MarkdownSidebarProps = { export type MarkdownSidebarProps = {
@@ -17,7 +16,7 @@ export function renderMarkdownSidebar(props: MarkdownSidebarProps) {
<div class="sidebar-header"> <div class="sidebar-header">
<div class="sidebar-title">Tool Output</div> <div class="sidebar-title">Tool Output</div>
<button @click=${props.onClose} class="btn" title="Close sidebar"> <button @click=${props.onClose} class="btn" title="Close sidebar">
${icons.x} ✕
</button> </button>
</div> </div>
<div class="sidebar-content"> <div class="sidebar-content">