Compare commits
14 Commits
line-plugi
...
fix/node-i
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ca26e17273 | ||
|
|
34ab1d245c | ||
|
|
a11b98f801 | ||
|
|
67db63ba05 | ||
|
|
bbefb2e5a5 | ||
|
|
50f233d16d | ||
|
|
48aea87028 | ||
|
|
612a27f3dd | ||
|
|
737037129e | ||
|
|
f29f51569a | ||
|
|
bfa57aae44 | ||
|
|
98cecc9c56 | ||
|
|
6cc1f5abb8 | ||
|
|
9fbee08590 |
@@ -7,6 +7,10 @@
|
||||
[exclude-files]
|
||||
# pnpm lockfiles contain lots of high-entropy package integrity blobs.
|
||||
pattern = (^|/)pnpm-lock\.yaml$
|
||||
# Generated output and vendored assets.
|
||||
pattern = (^|/)(dist|vendor)/
|
||||
# Local config file with allowlist patterns.
|
||||
pattern = (^|/)\.detect-secrets\.cfg$
|
||||
|
||||
[exclude-lines]
|
||||
# Fastlane checks for private key marker; not a real key.
|
||||
|
||||
17
.github/actionlint.yaml
vendored
Normal file
17
.github/actionlint.yaml
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
# actionlint configuration
|
||||
# https://github.com/rhysd/actionlint/blob/main/docs/config.md
|
||||
|
||||
self-hosted-runner:
|
||||
labels:
|
||||
# Blacksmith CI runners
|
||||
- blacksmith-4vcpu-ubuntu-2404
|
||||
- blacksmith-4vcpu-windows-2025
|
||||
|
||||
# Ignore patterns for known issues
|
||||
paths:
|
||||
.github/workflows/**/*.yml:
|
||||
ignore:
|
||||
# Ignore shellcheck warnings (we run shellcheck separately)
|
||||
- 'shellcheck reported issue.+'
|
||||
# Ignore intentional if: false for disabled jobs
|
||||
- 'constant expression "false" in condition'
|
||||
113
.github/dependabot.yml
vendored
Normal file
113
.github/dependabot.yml
vendored
Normal file
@@ -0,0 +1,113 @@
|
||||
# Dependabot configuration
|
||||
# https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
|
||||
|
||||
version: 2
|
||||
|
||||
registries:
|
||||
npm-npmjs:
|
||||
type: npm-registry
|
||||
url: https://registry.npmjs.org
|
||||
replaces-base: true
|
||||
|
||||
updates:
|
||||
# npm dependencies (root)
|
||||
- package-ecosystem: npm
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
cooldown:
|
||||
default-days: 7
|
||||
groups:
|
||||
production:
|
||||
dependency-type: production
|
||||
update-types:
|
||||
- minor
|
||||
- patch
|
||||
development:
|
||||
dependency-type: development
|
||||
update-types:
|
||||
- minor
|
||||
- patch
|
||||
open-pull-requests-limit: 10
|
||||
registries:
|
||||
- npm-npmjs
|
||||
|
||||
# GitHub Actions
|
||||
- package-ecosystem: github-actions
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
cooldown:
|
||||
default-days: 7
|
||||
groups:
|
||||
actions:
|
||||
patterns:
|
||||
- "*"
|
||||
update-types:
|
||||
- minor
|
||||
- patch
|
||||
open-pull-requests-limit: 5
|
||||
|
||||
# Swift Package Manager - macOS app
|
||||
- package-ecosystem: swift
|
||||
directory: /apps/macos
|
||||
schedule:
|
||||
interval: weekly
|
||||
cooldown:
|
||||
default-days: 7
|
||||
groups:
|
||||
swift-deps:
|
||||
patterns:
|
||||
- "*"
|
||||
update-types:
|
||||
- minor
|
||||
- patch
|
||||
open-pull-requests-limit: 5
|
||||
|
||||
# Swift Package Manager - shared ClawdbotKit
|
||||
- package-ecosystem: swift
|
||||
directory: /apps/shared/ClawdbotKit
|
||||
schedule:
|
||||
interval: weekly
|
||||
cooldown:
|
||||
default-days: 7
|
||||
groups:
|
||||
swift-deps:
|
||||
patterns:
|
||||
- "*"
|
||||
update-types:
|
||||
- minor
|
||||
- patch
|
||||
open-pull-requests-limit: 5
|
||||
|
||||
# Swift Package Manager - Swabble
|
||||
- package-ecosystem: swift
|
||||
directory: /Swabble
|
||||
schedule:
|
||||
interval: weekly
|
||||
cooldown:
|
||||
default-days: 7
|
||||
groups:
|
||||
swift-deps:
|
||||
patterns:
|
||||
- "*"
|
||||
update-types:
|
||||
- minor
|
||||
- patch
|
||||
open-pull-requests-limit: 5
|
||||
|
||||
# Gradle - Android app
|
||||
- package-ecosystem: gradle
|
||||
directory: /apps/android
|
||||
schedule:
|
||||
interval: weekly
|
||||
cooldown:
|
||||
default-days: 7
|
||||
groups:
|
||||
android-deps:
|
||||
patterns:
|
||||
- "*"
|
||||
update-types:
|
||||
- minor
|
||||
- patch
|
||||
open-pull-requests-limit: 5
|
||||
105
.pre-commit-config.yaml
Normal file
105
.pre-commit-config.yaml
Normal file
@@ -0,0 +1,105 @@
|
||||
# Pre-commit hooks for clawdbot
|
||||
# Install: prek install
|
||||
# Run manually: prek run --all-files
|
||||
#
|
||||
# See https://pre-commit.com for more information
|
||||
|
||||
repos:
|
||||
# Basic file hygiene
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v6.0.0
|
||||
hooks:
|
||||
- id: trailing-whitespace
|
||||
exclude: '^(docs/|dist/|vendor/|.*\.snap$)'
|
||||
- id: end-of-file-fixer
|
||||
exclude: '^(docs/|dist/|vendor/|.*\.snap$)'
|
||||
- id: check-yaml
|
||||
args: [--allow-multiple-documents]
|
||||
- id: check-added-large-files
|
||||
args: [--maxkb=500]
|
||||
- id: check-merge-conflict
|
||||
|
||||
# Secret detection (same as CI)
|
||||
- repo: https://github.com/Yelp/detect-secrets
|
||||
rev: v1.5.0
|
||||
hooks:
|
||||
- id: detect-secrets
|
||||
args:
|
||||
- --baseline
|
||||
- .secrets.baseline
|
||||
- --exclude-files
|
||||
- '(^|/)(dist/|vendor/|pnpm-lock\.yaml$|\.detect-secrets\.cfg$)'
|
||||
- --exclude-lines
|
||||
- 'key_content\.include\?\("BEGIN PRIVATE KEY"\)'
|
||||
- --exclude-lines
|
||||
- 'case \.apiKeyEnv: "API key \(env var\)"'
|
||||
- --exclude-lines
|
||||
- 'case apikey = "apiKey"'
|
||||
- --exclude-lines
|
||||
- '"gateway\.remote\.password"'
|
||||
- --exclude-lines
|
||||
- '"gateway\.auth\.password"'
|
||||
- --exclude-lines
|
||||
- '"talk\.apiKey"'
|
||||
- --exclude-lines
|
||||
- '=== "string"'
|
||||
- --exclude-lines
|
||||
- 'typeof remote\?\.password === "string"'
|
||||
|
||||
# Shell script linting
|
||||
- repo: https://github.com/koalaman/shellcheck-precommit
|
||||
rev: v0.11.0
|
||||
hooks:
|
||||
- id: shellcheck
|
||||
args: [--severity=error] # Only fail on errors, not warnings/info
|
||||
# Exclude vendor and scripts with embedded code or known issues
|
||||
exclude: '^(vendor/|scripts/e2e/)'
|
||||
|
||||
# GitHub Actions linting
|
||||
- repo: https://github.com/rhysd/actionlint
|
||||
rev: v1.7.10
|
||||
hooks:
|
||||
- id: actionlint
|
||||
|
||||
# GitHub Actions security audit
|
||||
- repo: https://github.com/zizmorcore/zizmor-pre-commit
|
||||
rev: v1.22.0
|
||||
hooks:
|
||||
- id: zizmor
|
||||
args: [--persona=regular, --min-severity=medium, --min-confidence=medium]
|
||||
exclude: '^(vendor/|Swabble/)'
|
||||
|
||||
# Project checks (same commands as CI)
|
||||
- repo: local
|
||||
hooks:
|
||||
# oxlint --type-aware src test
|
||||
- id: oxlint
|
||||
name: oxlint
|
||||
entry: scripts/pre-commit/run-node-tool.sh oxlint --type-aware src test
|
||||
language: system
|
||||
pass_filenames: false
|
||||
types_or: [javascript, jsx, ts, tsx]
|
||||
|
||||
# oxfmt --check src test
|
||||
- id: oxfmt
|
||||
name: oxfmt
|
||||
entry: scripts/pre-commit/run-node-tool.sh oxfmt --check src test
|
||||
language: system
|
||||
pass_filenames: false
|
||||
types_or: [javascript, jsx, ts, tsx]
|
||||
|
||||
# swiftlint (same as CI)
|
||||
- id: swiftlint
|
||||
name: swiftlint
|
||||
entry: swiftlint --config .swiftlint.yml
|
||||
language: system
|
||||
pass_filenames: false
|
||||
types: [swift]
|
||||
|
||||
# swiftformat --lint (same as CI)
|
||||
- id: swiftformat
|
||||
name: swiftformat
|
||||
entry: swiftformat --lint apps/macos/Sources --config .swiftformat
|
||||
language: system
|
||||
pass_filenames: false
|
||||
types: [swift]
|
||||
1923
.secrets.baseline
1923
.secrets.baseline
File diff suppressed because it is too large
Load Diff
25
.shellcheckrc
Normal file
25
.shellcheckrc
Normal file
@@ -0,0 +1,25 @@
|
||||
# ShellCheck configuration
|
||||
# https://www.shellcheck.net/wiki/
|
||||
|
||||
# Disable common false positives and style suggestions
|
||||
|
||||
# SC2034: Variable appears unused (often exported or used indirectly)
|
||||
disable=SC2034
|
||||
|
||||
# SC2155: Declare and assign separately (common idiom, rarely causes issues)
|
||||
disable=SC2155
|
||||
|
||||
# SC2295: Expansions inside ${..} need quoting (info-level, rarely causes issues)
|
||||
disable=SC2295
|
||||
|
||||
# SC1012: \r is literal (tr -d '\r' works as intended on most systems)
|
||||
disable=SC1012
|
||||
|
||||
# SC2026: Word outside quotes (info-level, often intentional)
|
||||
disable=SC2026
|
||||
|
||||
# SC2016: Expressions don't expand in single quotes (often intentional in sed/awk)
|
||||
disable=SC2016
|
||||
|
||||
# SC2129: Consider using { cmd1; cmd2; } >> file (style preference)
|
||||
disable=SC2129
|
||||
@@ -23,7 +23,7 @@
|
||||
# Whitespace
|
||||
--trimwhitespace always
|
||||
--emptybraces no-space
|
||||
--nospaceoperators ...,..<
|
||||
--nospaceoperators ...,..<
|
||||
--ranges no-space
|
||||
--someAny true
|
||||
--voidtype void
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
## Build, Test, and Development Commands
|
||||
- Runtime baseline: Node **22+** (keep Node + Bun paths working).
|
||||
- Install deps: `pnpm install`
|
||||
- Pre-commit hooks: `prek install` (runs same checks as CI)
|
||||
- Also supported: `bun install` (keep `pnpm-lock.yaml` + Bun patching in sync when touching deps/patches).
|
||||
- Prefer Bun for TypeScript execution (scripts, dev, tests): `bun <file.ts>` / `bunx <tool>`.
|
||||
- Run CLI in dev: `pnpm clawdbot ...` (bun) or `pnpm dev`.
|
||||
|
||||
@@ -12,6 +12,7 @@ Docs: https://docs.clawd.bot
|
||||
- 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
|
||||
- TTS: add auto mode enum (off/always/inbound/tagged) with per-session `/tts` override. (#1667) Thanks @sebslight. https://docs.clawd.bot/tts
|
||||
- Dev: add prek pre-commit hooks + dependabot config for weekly updates. (#1720) Thanks @dguido.
|
||||
- Docs: expand FAQ (migration, scheduling, concurrency, model recommendations, OpenAI subscription auth, Pi sizing, hackable install, docs SSL workaround).
|
||||
- Docs: add verbose installer troubleshooting guidance.
|
||||
- Docs: add macOS VM guide with local/hosted options + VPS/nodes guidance. (#1693) Thanks @f-trycua.
|
||||
@@ -22,8 +23,12 @@ Docs: https://docs.clawd.bot
|
||||
- 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: add verbose raw-update logging for inbound Telegram updates. (#1597) Thanks @rohannagpal.
|
||||
- Diagnostics: add diagnostic flags for targeted debug logs (config + env override). https://docs.clawd.bot/diagnostics/flags
|
||||
|
||||
### Fixes
|
||||
- macOS: rearm gateway receive loop before push handling to avoid node invoke stalls. (#1752) Thanks @ngutman.
|
||||
- Gateway: include inline config env vars in service install environments. (#1735) Thanks @Seredeep.
|
||||
- BlueBubbles: route phone-number targets to DMs, avoid leaking routing IDs, and auto-create missing DMs (Private API required). (#1751) Thanks @tyler6204. https://docs.clawd.bot/channels/bluebubbles
|
||||
- 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: show Stop button during active runs, swap back to New session when idle. (#1664) Thanks @ndbroadbent.
|
||||
|
||||
@@ -459,7 +459,7 @@ Use these when you’re past the onboarding flow and want the deeper reference.
|
||||
|
||||
## Clawd
|
||||
|
||||
Clawdbot was built for **Clawd**, a space lobster AI assistant. 🦞
|
||||
Clawdbot was built for **Clawd**, a space lobster AI assistant. 🦞
|
||||
by Peter Steinberger and the community.
|
||||
|
||||
- [clawd.me](https://clawd.me)
|
||||
@@ -468,7 +468,7 @@ by Peter Steinberger and the community.
|
||||
|
||||
## Community
|
||||
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines, maintainers, and how to submit PRs.
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines, maintainers, and how to submit PRs.
|
||||
AI/vibe-coded PRs welcome! 🤖
|
||||
|
||||
Special thanks to [Mario Zechner](https://mariozechner.at/) for his support and for
|
||||
|
||||
@@ -12,4 +12,3 @@ If you believe you’ve found a security issue in Clawdbot, please report it pri
|
||||
For threat model + hardening guidance (including `clawdbot security audit --deep` and `--fix`), see:
|
||||
|
||||
- `https://docs.clawd.bot/gateway/security`
|
||||
|
||||
|
||||
@@ -212,4 +212,4 @@
|
||||
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.21/Clawdbot-2026.1.21.zip" length="22284796" type="application/octet-stream" sparkle:edSignature="pXji4NMA/cu35iMxln385d6LnsT4yIZtFtFiR7sIimKeSC2CsyeWzzSD0EhJsN98PdSoy69iEFZt4I2ZtNCECg=="/>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
||||
</rss>
|
||||
|
||||
@@ -12,4 +12,3 @@ data class CameraHudState(
|
||||
val kind: CameraHudKind,
|
||||
val message: String,
|
||||
)
|
||||
|
||||
|
||||
@@ -12,4 +12,3 @@ enum class VoiceWakeMode(val rawValue: String) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -135,7 +135,7 @@ class SmsManager(private val context: Context) {
|
||||
|
||||
/**
|
||||
* Send an SMS message.
|
||||
*
|
||||
*
|
||||
* @param paramsJson JSON with "to" (phone number) and "message" (text) fields
|
||||
* @return SendResult indicating success or failure
|
||||
*/
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#0A0A0A</color>
|
||||
</resources>
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
<resources>
|
||||
<string name="app_name">Clawdbot Node</string>
|
||||
</resources>
|
||||
|
||||
|
||||
@@ -23,4 +23,3 @@ class VoiceWakeCommandExtractorTest {
|
||||
assertNull(VoiceWakeCommandExtractor.extractCommand("hey claude!", listOf("claude")))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,4 +16,3 @@ dependencyResolutionManagement {
|
||||
|
||||
rootProject.name = "ClawdbotNodeAndroid"
|
||||
include(":app")
|
||||
|
||||
|
||||
@@ -3,4 +3,3 @@ parent_config: ../../.swiftlint.yml
|
||||
included:
|
||||
- Sources
|
||||
- ../shared/ClawdisNodeKit/Sources
|
||||
|
||||
|
||||
@@ -33,4 +33,4 @@
|
||||
],
|
||||
"squares" : "shared"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,4 +18,4 @@ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
SOFTWARE.
|
||||
|
||||
@@ -173,4 +173,4 @@
|
||||
"iPod5,1": "iPod touch (5th generation)",
|
||||
"iPod7,1": "iPod touch (6th generation)",
|
||||
"iPod9,1": "iPod touch (7th generation)"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -211,4 +211,4 @@
|
||||
"Mac Pro (2019)",
|
||||
"Mac Pro (Rack, 2019)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -427,8 +427,8 @@ public actor GatewayChannelActor {
|
||||
Task { await self.handleReceiveFailure(err) }
|
||||
case let .success(msg):
|
||||
Task {
|
||||
await self.handle(msg)
|
||||
await self.listen()
|
||||
await self.handle(msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -574,46 +574,22 @@ public actor GatewayChannelActor {
|
||||
params: [String: AnyCodable]?,
|
||||
timeoutMs: Double? = nil) async throws -> Data
|
||||
{
|
||||
do {
|
||||
try await self.connect()
|
||||
} catch {
|
||||
throw self.wrap(error, context: "gateway connect")
|
||||
}
|
||||
let id = UUID().uuidString
|
||||
try await self.connectOrThrow(context: "gateway connect")
|
||||
let effectiveTimeout = timeoutMs ?? self.defaultRequestTimeoutMs
|
||||
// Encode request using the generated models to avoid JSONSerialization/ObjC bridging pitfalls.
|
||||
let paramsObject: ProtoAnyCodable? = params.map { entries in
|
||||
let dict = entries.reduce(into: [String: ProtoAnyCodable]()) { dict, entry in
|
||||
dict[entry.key] = ProtoAnyCodable(entry.value.value)
|
||||
}
|
||||
return ProtoAnyCodable(dict)
|
||||
}
|
||||
let frame = RequestFrame(
|
||||
type: "req",
|
||||
id: id,
|
||||
method: method,
|
||||
params: paramsObject)
|
||||
let data: Data
|
||||
do {
|
||||
data = try self.encoder.encode(frame)
|
||||
} catch {
|
||||
self.logger.error(
|
||||
"gateway request encode failed \(method, privacy: .public) error=\(error.localizedDescription, privacy: .public)")
|
||||
throw error
|
||||
}
|
||||
let payload = try self.encodeRequest(method: method, params: params, kind: "request")
|
||||
let response = try await withCheckedThrowingContinuation { (cont: CheckedContinuation<GatewayFrame, Error>) in
|
||||
self.pending[id] = cont
|
||||
self.pending[payload.id] = cont
|
||||
Task { [weak self] in
|
||||
guard let self else { return }
|
||||
try? await Task.sleep(nanoseconds: UInt64(effectiveTimeout * 1_000_000))
|
||||
await self.timeoutRequest(id: id, timeoutMs: effectiveTimeout)
|
||||
await self.timeoutRequest(id: payload.id, timeoutMs: effectiveTimeout)
|
||||
}
|
||||
Task {
|
||||
do {
|
||||
try await self.task?.send(.data(data))
|
||||
try await self.task?.send(.data(payload.data))
|
||||
} catch {
|
||||
let wrapped = self.wrap(error, context: "gateway send \(method)")
|
||||
let waiter = self.pending.removeValue(forKey: id)
|
||||
let waiter = self.pending.removeValue(forKey: payload.id)
|
||||
// Treat send failures as a broken socket: mark disconnected and trigger reconnect.
|
||||
self.connected = false
|
||||
self.task?.cancel(with: .goingAway, reason: nil)
|
||||
@@ -657,6 +633,42 @@ public actor GatewayChannelActor {
|
||||
return NSError(domain: ns.domain, code: ns.code, userInfo: [NSLocalizedDescriptionKey: "\(context): \(desc)"])
|
||||
}
|
||||
|
||||
private func connectOrThrow(context: String) async throws {
|
||||
do {
|
||||
try await self.connect()
|
||||
} catch {
|
||||
throw self.wrap(error, context: context)
|
||||
}
|
||||
}
|
||||
|
||||
private func encodeRequest(
|
||||
method: String,
|
||||
params: [String: AnyCodable]?,
|
||||
kind: String) throws -> (id: String, data: Data)
|
||||
{
|
||||
let id = UUID().uuidString
|
||||
// Encode request using the generated models to avoid JSONSerialization/ObjC bridging pitfalls.
|
||||
let paramsObject: ProtoAnyCodable? = params.map { entries in
|
||||
let dict = entries.reduce(into: [String: ProtoAnyCodable]()) { dict, entry in
|
||||
dict[entry.key] = ProtoAnyCodable(entry.value.value)
|
||||
}
|
||||
return ProtoAnyCodable(dict)
|
||||
}
|
||||
let frame = RequestFrame(
|
||||
type: "req",
|
||||
id: id,
|
||||
method: method,
|
||||
params: paramsObject)
|
||||
do {
|
||||
let data = try self.encoder.encode(frame)
|
||||
return (id: id, data: data)
|
||||
} catch {
|
||||
self.logger.error(
|
||||
"gateway \(kind) encode failed \(method, privacy: .public) error=\(error.localizedDescription, privacy: .public)")
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private func failPending(_ error: Error) async {
|
||||
let waiters = self.pending
|
||||
self.pending.removeAll()
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import ClawdbotKit
|
||||
import ClawdbotProtocol
|
||||
|
||||
private final class FakeWebSocketTask: WebSocketTasking, @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var queue: [URLSessionWebSocketTask.Message] = []
|
||||
private var pendingHandler: (@Sendable (Result<URLSessionWebSocketTask.Message, Error>) -> Void)?
|
||||
private var pendingContinuation: CheckedContinuation<URLSessionWebSocketTask.Message, Error>?
|
||||
private let encoder = JSONEncoder()
|
||||
private let decoder = JSONDecoder()
|
||||
|
||||
var state: URLSessionTask.State = .running
|
||||
|
||||
func resume() {}
|
||||
|
||||
func cancel(with closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) {
|
||||
state = .canceling
|
||||
}
|
||||
|
||||
func send(_ message: URLSessionWebSocketTask.Message) async throws {
|
||||
guard case let .data(data) = message else { return }
|
||||
guard let frame = try? decoder.decode(RequestFrame.self, from: data) else { return }
|
||||
switch frame.method {
|
||||
case "connect":
|
||||
enqueueResponse(id: frame.id, payload: helloOkPayload())
|
||||
default:
|
||||
enqueueResponse(id: frame.id, payload: ["ok": true])
|
||||
}
|
||||
}
|
||||
|
||||
func receive() async throws -> URLSessionWebSocketTask.Message {
|
||||
try await withCheckedThrowingContinuation { cont in
|
||||
lock.lock()
|
||||
if !queue.isEmpty {
|
||||
let msg = queue.removeFirst()
|
||||
lock.unlock()
|
||||
cont.resume(returning: msg)
|
||||
return
|
||||
}
|
||||
pendingContinuation = cont
|
||||
lock.unlock()
|
||||
}
|
||||
}
|
||||
|
||||
func receive(
|
||||
completionHandler: @escaping @Sendable (Result<URLSessionWebSocketTask.Message, Error>) -> Void)
|
||||
{
|
||||
lock.lock()
|
||||
if !queue.isEmpty {
|
||||
let msg = queue.removeFirst()
|
||||
lock.unlock()
|
||||
completionHandler(.success(msg))
|
||||
return
|
||||
}
|
||||
pendingHandler = completionHandler
|
||||
lock.unlock()
|
||||
}
|
||||
|
||||
func enqueue(_ message: URLSessionWebSocketTask.Message) {
|
||||
lock.lock()
|
||||
if let handler = pendingHandler {
|
||||
pendingHandler = nil
|
||||
lock.unlock()
|
||||
handler(.success(message))
|
||||
return
|
||||
}
|
||||
if let continuation = pendingContinuation {
|
||||
pendingContinuation = nil
|
||||
lock.unlock()
|
||||
continuation.resume(returning: message)
|
||||
return
|
||||
}
|
||||
queue.append(message)
|
||||
lock.unlock()
|
||||
}
|
||||
|
||||
private func enqueueResponse(id: String, payload: [String: Any]) {
|
||||
let response = ResponseFrame(
|
||||
type: "res",
|
||||
id: id,
|
||||
ok: true,
|
||||
payload: ClawdbotProtocol.AnyCodable(payload),
|
||||
error: nil)
|
||||
guard let data = try? encoder.encode(response) else { return }
|
||||
enqueue(.data(data))
|
||||
}
|
||||
|
||||
private func helloOkPayload() -> [String: Any] {
|
||||
[
|
||||
"type": "hello.ok",
|
||||
"protocol": 1,
|
||||
"server": [:],
|
||||
"features": [:],
|
||||
"snapshot": [
|
||||
"presence": [],
|
||||
"health": [:],
|
||||
"stateVersion": [
|
||||
"presence": 0,
|
||||
"health": 0,
|
||||
],
|
||||
"uptimeMs": 0,
|
||||
],
|
||||
"policy": [
|
||||
"tickIntervalMs": 1000,
|
||||
],
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
private final class FakeWebSocketSession: WebSocketSessioning {
|
||||
let task: FakeWebSocketTask
|
||||
|
||||
init(task: FakeWebSocketTask) {
|
||||
self.task = task
|
||||
}
|
||||
|
||||
func makeWebSocketTask(url: URL) -> WebSocketTaskBox {
|
||||
WebSocketTaskBox(task: task)
|
||||
}
|
||||
}
|
||||
|
||||
private actor AsyncSignal {
|
||||
private var continuation: CheckedContinuation<Result<Void, Error>, Never>?
|
||||
private var stored: Result<Void, Error>?
|
||||
|
||||
func finish(_ result: Result<Void, Error>) {
|
||||
if let continuation {
|
||||
self.continuation = nil
|
||||
continuation.resume(returning: result)
|
||||
return
|
||||
}
|
||||
stored = result
|
||||
}
|
||||
|
||||
func wait() async throws {
|
||||
let result = await withCheckedContinuation { cont in
|
||||
if let stored {
|
||||
self.stored = nil
|
||||
cont.resume(returning: stored)
|
||||
return
|
||||
}
|
||||
continuation = cont
|
||||
}
|
||||
switch result {
|
||||
case .success:
|
||||
return
|
||||
case let .failure(error):
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private enum TestError: Error {
|
||||
case timeout
|
||||
}
|
||||
|
||||
struct GatewayChannelTests {
|
||||
@Test
|
||||
func listenRearmsBeforePushHandler() async throws {
|
||||
let task = FakeWebSocketTask()
|
||||
let session = FakeWebSocketSession(task: task)
|
||||
let signal = AsyncSignal()
|
||||
let url = URL(string: "ws://example.invalid")!
|
||||
final class ChannelBox { var channel: GatewayChannelActor? }
|
||||
let box = ChannelBox()
|
||||
|
||||
let channel = GatewayChannelActor(
|
||||
url: url,
|
||||
token: nil,
|
||||
session: WebSocketSessionBox(session: session),
|
||||
pushHandler: { push in
|
||||
guard case let .event(evt) = push, evt.event == "test.event" else { return }
|
||||
guard let channel = box.channel else { return }
|
||||
let params: [String: ClawdbotKit.AnyCodable] = [
|
||||
"event": ClawdbotKit.AnyCodable("test"),
|
||||
"payloadJSON": ClawdbotKit.AnyCodable(NSNull()),
|
||||
]
|
||||
do {
|
||||
_ = try await channel.request(method: "node.event", params: params, timeoutMs: 50)
|
||||
await signal.finish(.success(()))
|
||||
} catch {
|
||||
await signal.finish(.failure(error))
|
||||
}
|
||||
})
|
||||
box.channel = channel
|
||||
|
||||
let challenge = EventFrame(
|
||||
type: "event",
|
||||
event: "connect.challenge",
|
||||
payload: ClawdbotProtocol.AnyCodable(["nonce": "test-nonce"]),
|
||||
seq: nil,
|
||||
stateversion: nil)
|
||||
let encoder = JSONEncoder()
|
||||
task.enqueue(.data(try encoder.encode(challenge)))
|
||||
|
||||
try await channel.connect()
|
||||
|
||||
let event = EventFrame(
|
||||
type: "event",
|
||||
event: "test.event",
|
||||
payload: ClawdbotProtocol.AnyCodable([:]),
|
||||
seq: nil,
|
||||
stateversion: nil)
|
||||
task.enqueue(.data(try encoder.encode(event)))
|
||||
|
||||
try await AsyncTimeout.withTimeout(seconds: 1, onTimeout: { TestError.timeout }) {
|
||||
try await signal.wait()
|
||||
}
|
||||
}
|
||||
}
|
||||
1
dist/control-ui/assets/index-08nzABV3.css
vendored
Normal file
1
dist/control-ui/assets/index-08nzABV3.css
vendored
Normal file
File diff suppressed because one or more lines are too long
1
dist/control-ui/assets/index-BvhR9FCb.css
vendored
1
dist/control-ui/assets/index-BvhR9FCb.css
vendored
File diff suppressed because one or more lines are too long
3119
dist/control-ui/assets/index-DQcOTEYz.js
vendored
Normal file
3119
dist/control-ui/assets/index-DQcOTEYz.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
dist/control-ui/assets/index-DQcOTEYz.js.map
vendored
Normal file
1
dist/control-ui/assets/index-DQcOTEYz.js.map
vendored
Normal file
File diff suppressed because one or more lines are too long
3059
dist/control-ui/assets/index-DsXRcnEw.js
vendored
3059
dist/control-ui/assets/index-DsXRcnEw.js
vendored
File diff suppressed because one or more lines are too long
1
dist/control-ui/assets/index-DsXRcnEw.js.map
vendored
1
dist/control-ui/assets/index-DsXRcnEw.js.map
vendored
File diff suppressed because one or more lines are too long
3047
dist/control-ui/assets/index-bYQnHP3a.js
vendored
3047
dist/control-ui/assets/index-bYQnHP3a.js
vendored
File diff suppressed because one or more lines are too long
1
dist/control-ui/assets/index-bYQnHP3a.js.map
vendored
1
dist/control-ui/assets/index-bYQnHP3a.js.map
vendored
File diff suppressed because one or more lines are too long
4
dist/control-ui/index.html
vendored
4
dist/control-ui/index.html
vendored
@@ -6,8 +6,8 @@
|
||||
<title>Clawdbot Control</title>
|
||||
<meta name="color-scheme" content="dark light" />
|
||||
<link rel="icon" href="./favicon.ico" sizes="any" />
|
||||
<script type="module" crossorigin src="./assets/index-DsXRcnEw.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-BvhR9FCb.css">
|
||||
<script type="module" crossorigin src="./assets/index-DQcOTEYz.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-08nzABV3.css">
|
||||
</head>
|
||||
<body>
|
||||
<clawdbot-app></clawdbot-app>
|
||||
|
||||
@@ -213,6 +213,7 @@ Prefer `chat_guid` for stable routing:
|
||||
- `chat_id:123`
|
||||
- `chat_identifier:...`
|
||||
- Direct handles: `+15555550123`, `user@example.com`
|
||||
- If a direct handle does not have an existing DM chat, Clawdbot will create one via `POST /api/v1/chat/new`. This requires the BlueBubbles Private API to be enabled.
|
||||
|
||||
## Security
|
||||
- Webhook requests are authenticated by comparing `guid`/`password` query params or headers against `channels.bluebubbles.password`. Requests from `localhost` are also accepted.
|
||||
|
||||
89
docs/diagnostics/flags.md
Normal file
89
docs/diagnostics/flags.md
Normal file
@@ -0,0 +1,89 @@
|
||||
---
|
||||
summary: "Diagnostics flags for targeted debug logs"
|
||||
read_when:
|
||||
- You need targeted debug logs without raising global logging levels
|
||||
- You need to capture subsystem-specific logs for support
|
||||
---
|
||||
# Diagnostics Flags
|
||||
|
||||
Diagnostics flags let you enable targeted debug logs without turning on verbose logging everywhere. Flags are opt-in and have no effect unless a subsystem checks them.
|
||||
|
||||
## How it works
|
||||
|
||||
- Flags are strings (case-insensitive).
|
||||
- You can enable flags in config or via an env override.
|
||||
- Wildcards are supported:
|
||||
- `telegram.*` matches `telegram.http`
|
||||
- `*` enables all flags
|
||||
|
||||
## Enable via config
|
||||
|
||||
```json
|
||||
{
|
||||
"diagnostics": {
|
||||
"flags": ["telegram.http"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Multiple flags:
|
||||
|
||||
```json
|
||||
{
|
||||
"diagnostics": {
|
||||
"flags": ["telegram.http", "gateway.*"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Restart the gateway after changing flags.
|
||||
|
||||
## Env override (one-off)
|
||||
|
||||
```bash
|
||||
CLAWDBOT_DIAGNOSTICS=telegram.http,telegram.payload
|
||||
```
|
||||
|
||||
Disable all flags:
|
||||
|
||||
```bash
|
||||
CLAWDBOT_DIAGNOSTICS=0
|
||||
```
|
||||
|
||||
## Where logs go
|
||||
|
||||
Flags emit logs into the standard diagnostics log file. By default:
|
||||
|
||||
```
|
||||
/tmp/clawdbot/clawdbot-YYYY-MM-DD.log
|
||||
```
|
||||
|
||||
If you set `logging.file`, use that path instead. Logs are JSONL (one JSON object per line). Redaction still applies based on `logging.redactSensitive`.
|
||||
|
||||
## Extract logs
|
||||
|
||||
Pick the latest log file:
|
||||
|
||||
```bash
|
||||
ls -t /tmp/clawdbot/clawdbot-*.log | head -n 1
|
||||
```
|
||||
|
||||
Filter for Telegram HTTP diagnostics:
|
||||
|
||||
```bash
|
||||
rg "telegram http error" /tmp/clawdbot/clawdbot-*.log
|
||||
```
|
||||
|
||||
Or tail while reproducing:
|
||||
|
||||
```bash
|
||||
tail -f /tmp/clawdbot/clawdbot-$(date +%F).log | rg "telegram http error"
|
||||
```
|
||||
|
||||
For remote gateways, you can also use `clawdbot logs --follow` (see [/cli/logs](/cli/logs)).
|
||||
|
||||
## Notes
|
||||
|
||||
- If `logging.level` is set higher than `warn`, these logs may be suppressed. Default `info` is fine.
|
||||
- Flags are safe to leave enabled; they only affect log volume for the specific subsystem.
|
||||
- Use [/logging](/logging) to change log destinations, levels, and redaction.
|
||||
@@ -138,6 +138,7 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS,
|
||||
- [Can I use self-hosted models (llama.cpp, vLLM, Ollama)?](#can-i-use-selfhosted-models-llamacpp-vllm-ollama)
|
||||
- [What do Clawd, Flawd, and Krill use for models?](#what-do-clawd-flawd-and-krill-use-for-models)
|
||||
- [How do I switch models on the fly (without restarting)?](#how-do-i-switch-models-on-the-fly-without-restarting)
|
||||
- [Can I use GPT 5.2 for daily tasks and Codex 5.2 for coding](#can-i-use-gpt-52-for-daily-tasks-and-codex-52-for-coding)
|
||||
- [Why do I see “Model … is not allowed” and then no reply?](#why-do-i-see-model-is-not-allowed-and-then-no-reply)
|
||||
- [Why do I see “Unknown model: minimax/MiniMax-M2.1”?](#why-do-i-see-unknown-model-minimaxminimaxm21)
|
||||
- [Can I use MiniMax as my default and OpenAI for complex tasks?](#can-i-use-minimax-as-my-default-and-openai-for-complex-tasks)
|
||||
@@ -1947,6 +1948,16 @@ Re-run `/model` **without** the `@profile` suffix:
|
||||
If you want to return to the default, pick it from `/model` (or send `/model <default provider/model>`).
|
||||
Use `/model status` to confirm which auth profile is active.
|
||||
|
||||
### Can I use GPT 5.2 for daily tasks and Codex 5.2 for coding
|
||||
|
||||
Yes. Set one as default and switch as needed:
|
||||
|
||||
- **Quick switch (per session):** `/model gpt-5.2` for daily tasks, `/model gpt-5.2-codex` for coding.
|
||||
- **Default + switch:** set `agents.defaults.model.primary` to `openai-codex/gpt-5.2`, then switch to `openai-codex/gpt-5.2-codex` when coding (or the other way around).
|
||||
- **Sub-agents:** route coding tasks to sub-agents with a different default model.
|
||||
|
||||
See [Models](/concepts/models) and [Slash commands](/tools/slash-commands).
|
||||
|
||||
### Why do I see Model is not allowed and then no reply
|
||||
|
||||
If `agents.defaults.models` is set, it becomes the **allowlist** for `/model` and any
|
||||
|
||||
@@ -192,6 +192,30 @@ Use this if you want diagnostics events available to plugins or custom sinks:
|
||||
}
|
||||
```
|
||||
|
||||
### Diagnostics flags (targeted logs)
|
||||
|
||||
Use flags to turn on extra, targeted debug logs without raising `logging.level`.
|
||||
Flags are case-insensitive and support wildcards (e.g. `telegram.*` or `*`).
|
||||
|
||||
```json
|
||||
{
|
||||
"diagnostics": {
|
||||
"flags": ["telegram.http"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Env override (one-off):
|
||||
|
||||
```
|
||||
CLAWDBOT_DIAGNOSTICS=telegram.http,telegram.payload
|
||||
```
|
||||
|
||||
Notes:
|
||||
- Flag logs go to the standard log file (same as `logging.file`).
|
||||
- Output is still redacted according to `logging.redactSensitive`.
|
||||
- Full guide: [/diagnostics/flags](/diagnostics/flags).
|
||||
|
||||
### Export to OpenTelemetry
|
||||
|
||||
Diagnostics can be exported via the `diagnostics-otel` plugin (OTLP/HTTP). This
|
||||
|
||||
@@ -80,6 +80,7 @@ primary_region = "iad"
|
||||
|---------|-----|
|
||||
| `--bind lan` | Binds to `0.0.0.0` so Fly's proxy can reach the gateway |
|
||||
| `--allow-unconfigured` | Starts without a config file (you'll create one after) |
|
||||
| `internal_port = 3000` | Must match `--port 3000` (or `CLAWDBOT_GATEWAY_PORT`) for Fly health checks |
|
||||
| `memory = "2048mb"` | 512MB is too small; 2GB recommended |
|
||||
| `CLAWDBOT_STATE_DIR = "/data"` | Persists state on the volume |
|
||||
|
||||
@@ -235,6 +236,12 @@ The gateway is binding to `127.0.0.1` instead of `0.0.0.0`.
|
||||
|
||||
**Fix:** Add `--bind lan` to your process command in `fly.toml`.
|
||||
|
||||
### Health checks failing / connection refused
|
||||
|
||||
Fly can't reach the gateway on the configured port.
|
||||
|
||||
**Fix:** Ensure `internal_port` matches the gateway port (set `--port 3000` or `CLAWDBOT_GATEWAY_PORT=3000`).
|
||||
|
||||
### OOM / Memory Issues
|
||||
|
||||
Container keeps restarting or getting killed. Signs: `SIGABRT`, `v8::internal::Runtime_AllocateInYoungGeneration`, or silent restarts.
|
||||
@@ -268,11 +275,11 @@ The lock file is at `/data/gateway.*.lock` (not in a subdirectory).
|
||||
|
||||
### Config Not Being Read
|
||||
|
||||
If using `--allow-unconfigured`, the gateway creates a minimal config. Your custom config at `/data/.clawdbot/clawdbot.json` should be read on restart.
|
||||
If using `--allow-unconfigured`, the gateway creates a minimal config. Your custom config at `/data/clawdbot.json` should be read on restart.
|
||||
|
||||
Verify the config exists:
|
||||
```bash
|
||||
fly ssh console --command "cat /data/.clawdbot/clawdbot.json"
|
||||
fly ssh console --command "cat /data/clawdbot.json"
|
||||
```
|
||||
|
||||
### Writing Config via SSH
|
||||
@@ -281,18 +288,24 @@ The `fly ssh console -C` command doesn't support shell redirection. To write a c
|
||||
|
||||
```bash
|
||||
# Use echo + tee (pipe from local to remote)
|
||||
echo '{"your":"config"}' | fly ssh console -C "tee /data/.clawdbot/clawdbot.json"
|
||||
echo '{"your":"config"}' | fly ssh console -C "tee /data/clawdbot.json"
|
||||
|
||||
# Or use sftp
|
||||
fly sftp shell
|
||||
> put /local/path/config.json /data/.clawdbot/clawdbot.json
|
||||
> put /local/path/config.json /data/clawdbot.json
|
||||
```
|
||||
|
||||
**Note:** `fly sftp` may fail if the file already exists. Delete first:
|
||||
```bash
|
||||
fly ssh console --command "rm /data/.clawdbot/clawdbot.json"
|
||||
fly ssh console --command "rm /data/clawdbot.json"
|
||||
```
|
||||
|
||||
### State Not Persisting
|
||||
|
||||
If you lose credentials or sessions after a restart, the state dir is writing to the container filesystem.
|
||||
|
||||
**Fix:** Ensure `CLAWDBOT_STATE_DIR=/data` is set in `fly.toml` and redeploy.
|
||||
|
||||
## Updates
|
||||
|
||||
```bash
|
||||
@@ -330,6 +343,7 @@ fly machine update <machine-id> --vm-memory 2048 --command "node dist/index.js g
|
||||
- The Dockerfile is compatible with both architectures
|
||||
- For WhatsApp/Telegram onboarding, use `fly ssh console`
|
||||
- Persistent data lives on the volume at `/data`
|
||||
- Signal requires Java + signal-cli; use a custom image and keep memory at 2GB+.
|
||||
|
||||
## Cost
|
||||
|
||||
|
||||
@@ -385,14 +385,14 @@ describe("send", () => {
|
||||
).rejects.toThrow("password is required");
|
||||
});
|
||||
|
||||
it("throws when chatGuid cannot be resolved", async () => {
|
||||
it("throws when chatGuid cannot be resolved for non-handle targets", async () => {
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ data: [] }),
|
||||
});
|
||||
|
||||
await expect(
|
||||
sendMessageBlueBubbles("+15559999999", "Hello", {
|
||||
sendMessageBlueBubbles("chat_id:999", "Hello", {
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: "test",
|
||||
}),
|
||||
@@ -439,6 +439,57 @@ describe("send", () => {
|
||||
expect(body.method).toBeUndefined();
|
||||
});
|
||||
|
||||
it("creates a new chat when handle target is missing", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ data: [] }),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
text: () =>
|
||||
Promise.resolve(
|
||||
JSON.stringify({
|
||||
data: { guid: "new-msg-guid" },
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
const result = await sendMessageBlueBubbles("+15550009999", "Hello new chat", {
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: "test",
|
||||
});
|
||||
|
||||
expect(result.messageId).toBe("new-msg-guid");
|
||||
expect(mockFetch).toHaveBeenCalledTimes(2);
|
||||
|
||||
const createCall = mockFetch.mock.calls[1];
|
||||
expect(createCall[0]).toContain("/api/v1/chat/new");
|
||||
const body = JSON.parse(createCall[1].body);
|
||||
expect(body.addresses).toEqual(["+15550009999"]);
|
||||
expect(body.message).toBe("Hello new chat");
|
||||
});
|
||||
|
||||
it("throws when creating a new chat requires Private API", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ data: [] }),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 403,
|
||||
text: () => Promise.resolve("Private API not enabled"),
|
||||
});
|
||||
|
||||
await expect(
|
||||
sendMessageBlueBubbles("+15550008888", "Hello", {
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: "test",
|
||||
}),
|
||||
).rejects.toThrow("Private API must be enabled");
|
||||
});
|
||||
|
||||
it("uses private-api when reply metadata is present", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce({
|
||||
|
||||
@@ -33,7 +33,7 @@ export function registerMatrixAutoJoin(params: {
|
||||
// For "allowlist" mode, handle invites manually
|
||||
client.on("room.invite", async (roomId: string, _inviteEvent: unknown) => {
|
||||
if (autoJoin !== "allowlist") return;
|
||||
|
||||
|
||||
// Get room alias if available
|
||||
let alias: string | undefined;
|
||||
let altAliases: string[] = [];
|
||||
|
||||
@@ -25,7 +25,7 @@ async function fetchMatrixMediaBuffer(params: {
|
||||
// matrix-bot-sdk provides mxcToHttp helper
|
||||
const url = params.client.mxcToHttp(params.mxcUrl);
|
||||
if (!url) return null;
|
||||
|
||||
|
||||
// Use the client's download method which handles auth
|
||||
try {
|
||||
const buffer = await params.client.downloadContent(params.mxcUrl);
|
||||
@@ -61,7 +61,7 @@ async function fetchEncryptedMediaBuffer(params: {
|
||||
Buffer.from(encryptedBuffer),
|
||||
params.file,
|
||||
);
|
||||
|
||||
|
||||
return { buffer: decrypted };
|
||||
}
|
||||
|
||||
@@ -77,7 +77,7 @@ export async function downloadMatrixMedia(params: {
|
||||
placeholder: string;
|
||||
} | null> {
|
||||
let fetched: { buffer: Buffer; headerType?: string } | null;
|
||||
|
||||
|
||||
if (params.file) {
|
||||
// Encrypted media
|
||||
fetched = await fetchEncryptedMediaBuffer({
|
||||
@@ -93,7 +93,7 @@ export async function downloadMatrixMedia(params: {
|
||||
maxBytes: params.maxBytes,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
if (!fetched) return null;
|
||||
const headerType = fetched.headerType ?? params.contentType ?? undefined;
|
||||
const saved = await getMatrixRuntime().channel.media.saveMediaBuffer(
|
||||
|
||||
@@ -11,4 +11,4 @@ export function resolveMattermostGroupRequireMention(
|
||||
});
|
||||
if (typeof account.requireMention === "boolean") return account.requireMention;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,4 +112,4 @@ export function listEnabledMattermostAccounts(cfg: ClawdbotConfig): ResolvedMatt
|
||||
return listMattermostAccountIds(cfg)
|
||||
.map((accountId) => resolveMattermostAccount({ cfg, accountId }))
|
||||
.filter((account) => account.enabled);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -205,4 +205,4 @@ export async function uploadMattermostFile(
|
||||
throw new Error("Mattermost file upload failed");
|
||||
}
|
||||
return info;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,4 +147,4 @@ export function resolveThreadSessionKeys(params: {
|
||||
? `${params.baseSessionKey}:thread:${threadId}`
|
||||
: params.baseSessionKey;
|
||||
return { sessionKey, parentSessionKey: params.parentSessionKey };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,4 +67,4 @@ export async function probeMattermost(
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,4 +39,4 @@ export async function promptAccountId(params: PromptAccountIdParams): Promise<st
|
||||
);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -184,4 +184,4 @@ export const mattermostOnboardingAdapter: ChannelOnboardingAdapter = {
|
||||
mattermost: { ...cfg.channels?.mattermost, enabled: false },
|
||||
},
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -22,11 +22,11 @@ parallel:
|
||||
security = session: security_expert
|
||||
prompt: "Perform a deep security audit of the changes. Look for OWASP top 10 issues."
|
||||
context: overview
|
||||
|
||||
|
||||
perf = session: performance_expert
|
||||
prompt: "Analyze the performance implications. Identify potential bottlenecks or regressions."
|
||||
context: overview
|
||||
|
||||
|
||||
style = session: reviewer
|
||||
prompt: "Review for code style, maintainability, and adherence to best practices."
|
||||
context: overview
|
||||
|
||||
@@ -19,4 +19,3 @@ export type CallManagerContext = {
|
||||
transcriptWaiters: Map<CallId, TranscriptWaiter>;
|
||||
maxDurationTimers: Map<CallId, NodeJS.Timeout>;
|
||||
};
|
||||
|
||||
|
||||
@@ -175,4 +175,3 @@ export function processEvent(ctx: CallManagerContext, event: NormalizedEvent): v
|
||||
|
||||
persistCallRecord(ctx.storePath, call);
|
||||
}
|
||||
|
||||
|
||||
@@ -31,4 +31,3 @@ export function findCall(params: {
|
||||
providerCallId: params.callIdOrProviderCallId,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -48,4 +48,3 @@ export function addTranscriptEntry(
|
||||
};
|
||||
call.transcript.push(entry);
|
||||
}
|
||||
|
||||
|
||||
@@ -86,4 +86,3 @@ export async function getCallHistoryFromStore(
|
||||
|
||||
return calls;
|
||||
}
|
||||
|
||||
|
||||
@@ -84,4 +84,3 @@ export function waitForFinalTranscript(
|
||||
ctx.transcriptWaiters.set(callId, { resolve, reject, timeout });
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -7,4 +7,3 @@ export function generateNotifyTwiml(message: string, voice: string): string {
|
||||
<Hangup/>
|
||||
</Response>`;
|
||||
}
|
||||
|
||||
|
||||
@@ -26,4 +26,3 @@ describe("PlivoProvider", () => {
|
||||
expect(result.providerResponseBody).toContain('length="300"');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -27,4 +27,3 @@ export function verifyTwilioProviderWebhook(params: {
|
||||
reason: result.reason,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -15,4 +15,3 @@ describe("zalouser outbound chunker", () => {
|
||||
expect(chunks.every((c) => c.length <= limit)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -124,7 +124,7 @@ EOF
|
||||
# Function to list categories
|
||||
list_categories() {
|
||||
echo -e "${BLUE}Fetching VibeTunnel log categories from the last hour...${NC}\n"
|
||||
|
||||
|
||||
# Get unique categories from recent logs
|
||||
log show --predicate "subsystem == \"$SUBSYSTEM\"" --last 1h 2>/dev/null | \
|
||||
grep -E "category: \"[^\"]+\"" | \
|
||||
@@ -133,7 +133,7 @@ list_categories() {
|
||||
while read -r cat; do
|
||||
echo " • $cat"
|
||||
done
|
||||
|
||||
|
||||
echo -e "\n${YELLOW}Note: Only categories with recent activity are shown${NC}"
|
||||
}
|
||||
|
||||
@@ -230,29 +230,29 @@ fi
|
||||
if [[ "$STREAM_MODE" == true ]]; then
|
||||
# Streaming mode
|
||||
CMD="sudo log stream --predicate '$PREDICATE' --level $LOG_LEVEL --info"
|
||||
|
||||
|
||||
echo -e "${GREEN}Streaming VibeTunnel logs continuously...${NC}"
|
||||
echo -e "${YELLOW}Press Ctrl+C to stop${NC}\n"
|
||||
else
|
||||
# Show mode
|
||||
CMD="sudo log show --predicate '$PREDICATE'"
|
||||
|
||||
|
||||
# Add log level for show command
|
||||
if [[ "$LOG_LEVEL" == "debug" ]]; then
|
||||
CMD="$CMD --debug"
|
||||
else
|
||||
CMD="$CMD --info"
|
||||
fi
|
||||
|
||||
|
||||
# Add time range
|
||||
CMD="$CMD --last $TIME_RANGE"
|
||||
|
||||
|
||||
if [[ "$SHOW_TAIL" == true ]]; then
|
||||
echo -e "${GREEN}Showing last $TAIL_LINES log lines from the past $TIME_RANGE${NC}"
|
||||
else
|
||||
echo -e "${GREEN}Showing all logs from the past $TIME_RANGE${NC}"
|
||||
fi
|
||||
|
||||
|
||||
# Show applied filters
|
||||
if [[ "$ERRORS_ONLY" == true ]]; then
|
||||
echo -e "${RED}Filter: Errors only${NC}"
|
||||
@@ -277,14 +277,14 @@ if [[ -n "$OUTPUT_FILE" ]]; then
|
||||
if sudo -n /usr/bin/log show --last 1s 2>&1 | grep -q "password"; then
|
||||
handle_sudo_error
|
||||
fi
|
||||
|
||||
|
||||
echo -e "${BLUE}Exporting logs to: $OUTPUT_FILE${NC}\n"
|
||||
if [[ "$SHOW_TAIL" == true ]] && [[ "$STREAM_MODE" == false ]]; then
|
||||
eval "$CMD" 2>&1 | tail -n "$TAIL_LINES" > "$OUTPUT_FILE"
|
||||
else
|
||||
eval "$CMD" > "$OUTPUT_FILE" 2>&1
|
||||
fi
|
||||
|
||||
|
||||
# Check if file was created and has content
|
||||
if [[ -s "$OUTPUT_FILE" ]]; then
|
||||
LINE_COUNT=$(wc -l < "$OUTPUT_FILE" | tr -d ' ')
|
||||
@@ -298,7 +298,7 @@ else
|
||||
if sudo -n /usr/bin/log show --last 1s 2>&1 | grep -q "password"; then
|
||||
handle_sudo_error
|
||||
fi
|
||||
|
||||
|
||||
if [[ "$SHOW_TAIL" == true ]] && [[ "$STREAM_MODE" == false ]]; then
|
||||
# Apply tail for non-streaming mode
|
||||
eval "$CMD" 2>&1 | tail -n "$TAIL_LINES"
|
||||
|
||||
@@ -102,12 +102,12 @@ ws.send(
|
||||
);
|
||||
const connectRes = await onceFrame((o) => o?.type === \"res\" && o?.id === \"c1\");
|
||||
if (!connectRes.ok) throw new Error(\"connect failed: \" + (connectRes.error?.message ?? \"unknown\"));
|
||||
|
||||
|
||||
ws.send(JSON.stringify({ type: \"req\", id: \"h1\", method: \"health\" }));
|
||||
const healthRes = await onceFrame((o) => o?.type === \"res\" && o?.id === \"h1\", 10000);
|
||||
if (!healthRes.ok) throw new Error(\"health failed: \" + (healthRes.error?.message ?? \"unknown\"));
|
||||
if (healthRes.payload?.ok !== true) throw new Error(\"unexpected health payload\");
|
||||
|
||||
|
||||
ws.close();
|
||||
console.log(\"ok\");
|
||||
NODE"
|
||||
|
||||
31
scripts/pre-commit/run-node-tool.sh
Executable file
31
scripts/pre-commit/run-node-tool.sh
Executable file
@@ -0,0 +1,31 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
|
||||
if [[ $# -lt 1 ]]; then
|
||||
echo "usage: run-node-tool.sh <tool> [args...]" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
tool="$1"
|
||||
shift
|
||||
|
||||
if [[ -f "$ROOT_DIR/pnpm-lock.yaml" ]] && command -v pnpm >/dev/null 2>&1; then
|
||||
exec pnpm exec "$tool" "$@"
|
||||
fi
|
||||
|
||||
if { [[ -f "$ROOT_DIR/bun.lockb" ]] || [[ -f "$ROOT_DIR/bun.lock" ]]; } && command -v bun >/dev/null 2>&1; then
|
||||
exec bunx --bun "$tool" "$@"
|
||||
fi
|
||||
|
||||
if command -v npm >/dev/null 2>&1; then
|
||||
exec npm exec -- "$tool" "$@"
|
||||
fi
|
||||
|
||||
if command -v npx >/dev/null 2>&1; then
|
||||
exec npx "$tool" "$@"
|
||||
fi
|
||||
|
||||
echo "Missing package manager: pnpm, bun, or npm required." >&2
|
||||
exit 1
|
||||
@@ -30,4 +30,3 @@ export type Entry = {
|
||||
avatar_url: string;
|
||||
lines: number;
|
||||
};
|
||||
|
||||
|
||||
@@ -84,7 +84,7 @@ curl http://127.0.0.1:8000/places/{place_id}
|
||||
"open_now": true
|
||||
}
|
||||
],
|
||||
"next_page_token": "..."
|
||||
"next_page_token": "..."
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -27,4 +27,14 @@ describe("sanitizeUserFacingText", () => {
|
||||
const raw = '{"type":"error","error":{"message":"Something exploded","type":"server_error"}}';
|
||||
expect(sanitizeUserFacingText(raw)).toBe("LLM error server_error: Something exploded");
|
||||
});
|
||||
|
||||
it("collapses consecutive duplicate paragraphs", () => {
|
||||
const text = "Hello there!\n\nHello there!";
|
||||
expect(sanitizeUserFacingText(text)).toBe("Hello there!");
|
||||
});
|
||||
|
||||
it("does not collapse distinct paragraphs", () => {
|
||||
const text = "Hello there!\n\nDifferent line.";
|
||||
expect(sanitizeUserFacingText(text)).toBe(text);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -77,6 +77,29 @@ function stripFinalTagsFromText(text: string): string {
|
||||
return text.replace(FINAL_TAG_RE, "");
|
||||
}
|
||||
|
||||
function collapseConsecutiveDuplicateBlocks(text: string): string {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) return text;
|
||||
const blocks = trimmed.split(/\n{2,}/);
|
||||
if (blocks.length < 2) return text;
|
||||
|
||||
const normalizeBlock = (value: string) => value.trim().replace(/\s+/g, " ");
|
||||
const result: string[] = [];
|
||||
let lastNormalized: string | null = null;
|
||||
|
||||
for (const block of blocks) {
|
||||
const normalized = normalizeBlock(block);
|
||||
if (lastNormalized && normalized === lastNormalized) {
|
||||
continue;
|
||||
}
|
||||
result.push(block.trim());
|
||||
lastNormalized = normalized;
|
||||
}
|
||||
|
||||
if (result.length === blocks.length) return text;
|
||||
return result.join("\n\n");
|
||||
}
|
||||
|
||||
function isLikelyHttpErrorText(raw: string): boolean {
|
||||
const match = raw.match(HTTP_STATUS_PREFIX_RE);
|
||||
if (!match) return false;
|
||||
@@ -321,7 +344,7 @@ export function sanitizeUserFacingText(text: string): string {
|
||||
return formatRawAssistantErrorForUi(trimmed);
|
||||
}
|
||||
|
||||
return stripped;
|
||||
return collapseConsecutiveDuplicateBlocks(stripped);
|
||||
}
|
||||
|
||||
export function isRateLimitAssistantError(msg: AssistantMessage | undefined): boolean {
|
||||
|
||||
@@ -100,6 +100,7 @@ export async function runDaemonInstall(opts: DaemonInstallOptions) {
|
||||
if (json) warnings.push(message);
|
||||
else defaultRuntime.log(message);
|
||||
},
|
||||
config: cfg,
|
||||
});
|
||||
|
||||
try {
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from "./daemon-runtime.js";
|
||||
import { guardCancel } from "./onboard-helpers.js";
|
||||
import { ensureSystemdUserLingerInteractive } from "./systemd-linger.js";
|
||||
import { loadConfig } from "../config/config.js";
|
||||
|
||||
export async function maybeInstallDaemon(params: {
|
||||
runtime: RuntimeEnv;
|
||||
@@ -81,12 +82,14 @@ export async function maybeInstallDaemon(params: {
|
||||
|
||||
progress.setLabel("Preparing Gateway service…");
|
||||
|
||||
const cfg = loadConfig();
|
||||
const { programArguments, workingDirectory, environment } = await buildGatewayInstallPlan({
|
||||
env: process.env,
|
||||
port: params.port,
|
||||
token: params.gatewayToken,
|
||||
runtime: daemonRuntime,
|
||||
warn: (message, title) => note(message, title),
|
||||
config: cfg,
|
||||
});
|
||||
|
||||
progress.setLabel("Installing Gateway service…");
|
||||
|
||||
@@ -95,6 +95,140 @@ describe("buildGatewayInstallPlan", () => {
|
||||
expect(warn).toHaveBeenCalledWith("Node too old", "Gateway runtime");
|
||||
expect(mocks.resolvePreferredNodePath).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("merges config env vars into the environment", async () => {
|
||||
mocks.resolvePreferredNodePath.mockResolvedValue("/opt/node");
|
||||
mocks.resolveGatewayProgramArguments.mockResolvedValue({
|
||||
programArguments: ["node", "gateway"],
|
||||
workingDirectory: "/Users/me",
|
||||
});
|
||||
mocks.resolveSystemNodeInfo.mockResolvedValue({
|
||||
path: "/opt/node",
|
||||
version: "22.0.0",
|
||||
supported: true,
|
||||
});
|
||||
mocks.buildServiceEnvironment.mockReturnValue({
|
||||
CLAWDBOT_PORT: "3000",
|
||||
HOME: "/Users/me",
|
||||
});
|
||||
|
||||
const plan = await buildGatewayInstallPlan({
|
||||
env: {},
|
||||
port: 3000,
|
||||
runtime: "node",
|
||||
config: {
|
||||
env: {
|
||||
vars: {
|
||||
GOOGLE_API_KEY: "test-key",
|
||||
},
|
||||
CUSTOM_VAR: "custom-value",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Config env vars should be present
|
||||
expect(plan.environment.GOOGLE_API_KEY).toBe("test-key");
|
||||
expect(plan.environment.CUSTOM_VAR).toBe("custom-value");
|
||||
// Service environment vars should take precedence
|
||||
expect(plan.environment.CLAWDBOT_PORT).toBe("3000");
|
||||
expect(plan.environment.HOME).toBe("/Users/me");
|
||||
});
|
||||
|
||||
it("does not include empty config env values", async () => {
|
||||
mocks.resolvePreferredNodePath.mockResolvedValue("/opt/node");
|
||||
mocks.resolveGatewayProgramArguments.mockResolvedValue({
|
||||
programArguments: ["node", "gateway"],
|
||||
workingDirectory: "/Users/me",
|
||||
});
|
||||
mocks.resolveSystemNodeInfo.mockResolvedValue({
|
||||
path: "/opt/node",
|
||||
version: "22.0.0",
|
||||
supported: true,
|
||||
});
|
||||
mocks.buildServiceEnvironment.mockReturnValue({ CLAWDBOT_PORT: "3000" });
|
||||
|
||||
const plan = await buildGatewayInstallPlan({
|
||||
env: {},
|
||||
port: 3000,
|
||||
runtime: "node",
|
||||
config: {
|
||||
env: {
|
||||
vars: {
|
||||
VALID_KEY: "valid",
|
||||
EMPTY_KEY: "",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(plan.environment.VALID_KEY).toBe("valid");
|
||||
expect(plan.environment.EMPTY_KEY).toBeUndefined();
|
||||
});
|
||||
|
||||
it("drops whitespace-only config env values", async () => {
|
||||
mocks.resolvePreferredNodePath.mockResolvedValue("/opt/node");
|
||||
mocks.resolveGatewayProgramArguments.mockResolvedValue({
|
||||
programArguments: ["node", "gateway"],
|
||||
workingDirectory: "/Users/me",
|
||||
});
|
||||
mocks.resolveSystemNodeInfo.mockResolvedValue({
|
||||
path: "/opt/node",
|
||||
version: "22.0.0",
|
||||
supported: true,
|
||||
});
|
||||
mocks.buildServiceEnvironment.mockReturnValue({});
|
||||
|
||||
const plan = await buildGatewayInstallPlan({
|
||||
env: {},
|
||||
port: 3000,
|
||||
runtime: "node",
|
||||
config: {
|
||||
env: {
|
||||
vars: {
|
||||
VALID_KEY: "valid",
|
||||
},
|
||||
TRIMMED_KEY: " ",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(plan.environment.VALID_KEY).toBe("valid");
|
||||
expect(plan.environment.TRIMMED_KEY).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps service env values over config env vars", async () => {
|
||||
mocks.resolvePreferredNodePath.mockResolvedValue("/opt/node");
|
||||
mocks.resolveGatewayProgramArguments.mockResolvedValue({
|
||||
programArguments: ["node", "gateway"],
|
||||
workingDirectory: "/Users/me",
|
||||
});
|
||||
mocks.resolveSystemNodeInfo.mockResolvedValue({
|
||||
path: "/opt/node",
|
||||
version: "22.0.0",
|
||||
supported: true,
|
||||
});
|
||||
mocks.buildServiceEnvironment.mockReturnValue({
|
||||
HOME: "/Users/service",
|
||||
CLAWDBOT_PORT: "3000",
|
||||
});
|
||||
|
||||
const plan = await buildGatewayInstallPlan({
|
||||
env: {},
|
||||
port: 3000,
|
||||
runtime: "node",
|
||||
config: {
|
||||
env: {
|
||||
HOME: "/Users/config",
|
||||
vars: {
|
||||
CLAWDBOT_PORT: "9999",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(plan.environment.HOME).toBe("/Users/service");
|
||||
expect(plan.environment.CLAWDBOT_PORT).toBe("3000");
|
||||
});
|
||||
});
|
||||
|
||||
describe("gatewayInstallErrorHint", () => {
|
||||
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
} from "../daemon/runtime-paths.js";
|
||||
import { buildServiceEnvironment } from "../daemon/service-env.js";
|
||||
import { formatCliCommand } from "../cli/command-format.js";
|
||||
import { collectConfigEnvVars } from "../config/env-vars.js";
|
||||
import type { ClawdbotConfig } from "../config/types.js";
|
||||
import type { GatewayDaemonRuntime } from "./daemon-runtime.js";
|
||||
|
||||
type WarnFn = (message: string, title?: string) => void;
|
||||
@@ -31,6 +33,8 @@ export async function buildGatewayInstallPlan(params: {
|
||||
devMode?: boolean;
|
||||
nodePath?: string;
|
||||
warn?: WarnFn;
|
||||
/** Full config to extract env vars from (env vars + inline env keys). */
|
||||
config?: ClawdbotConfig;
|
||||
}): Promise<GatewayInstallPlan> {
|
||||
const devMode = params.devMode ?? resolveGatewayDevMode();
|
||||
const nodePath =
|
||||
@@ -50,7 +54,7 @@ export async function buildGatewayInstallPlan(params: {
|
||||
const warning = renderSystemNodeWarning(systemNode, programArguments[0]);
|
||||
if (warning) params.warn?.(warning, "Gateway runtime");
|
||||
}
|
||||
const environment = buildServiceEnvironment({
|
||||
const serviceEnvironment = buildServiceEnvironment({
|
||||
env: params.env,
|
||||
port: params.port,
|
||||
token: params.token,
|
||||
@@ -60,6 +64,13 @@ export async function buildGatewayInstallPlan(params: {
|
||||
: undefined,
|
||||
});
|
||||
|
||||
// Merge config env vars into the service environment (vars + inline env keys).
|
||||
// Config env vars are added first so service-specific vars take precedence.
|
||||
const environment: Record<string, string | undefined> = {
|
||||
...collectConfigEnvVars(params.config),
|
||||
};
|
||||
Object.assign(environment, serviceEnvironment);
|
||||
|
||||
return { programArguments, workingDirectory, environment };
|
||||
}
|
||||
|
||||
|
||||
@@ -161,6 +161,7 @@ export async function maybeRepairGatewayDaemon(params: {
|
||||
token: params.cfg.gateway?.auth?.token ?? process.env.CLAWDBOT_GATEWAY_TOKEN,
|
||||
runtime: daemonRuntime,
|
||||
warn: (message, title) => note(message, title),
|
||||
config: params.cfg,
|
||||
});
|
||||
try {
|
||||
await service.install({
|
||||
|
||||
@@ -110,6 +110,7 @@ export async function maybeMigrateLegacyGatewayService(
|
||||
token: cfg.gateway?.auth?.token ?? process.env.CLAWDBOT_GATEWAY_TOKEN,
|
||||
runtime: daemonRuntime,
|
||||
warn: (message, title) => note(message, title),
|
||||
config: cfg,
|
||||
});
|
||||
try {
|
||||
await service.install({
|
||||
@@ -177,6 +178,7 @@ export async function maybeRepairGatewayServiceConfig(
|
||||
runtime: needsNodeRuntime && systemNodePath ? "node" : runtimeChoice,
|
||||
nodePath: systemNodePath ?? undefined,
|
||||
warn: (message, title) => note(message, title),
|
||||
config: cfg,
|
||||
});
|
||||
const expectedEntrypoint = findGatewayEntrypoint(programArguments);
|
||||
const currentEntrypoint = findGatewayEntrypoint(command.programArguments);
|
||||
|
||||
@@ -38,6 +38,7 @@ export async function installGatewayDaemonNonInteractive(params: {
|
||||
token: gatewayToken,
|
||||
runtime: daemonRuntimeRaw,
|
||||
warn: (message) => runtime.log(message),
|
||||
config: params.nextConfig,
|
||||
});
|
||||
try {
|
||||
await service.install({
|
||||
|
||||
23
src/config/env-vars.ts
Normal file
23
src/config/env-vars.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import type { ClawdbotConfig } from "./types.js";
|
||||
|
||||
export function collectConfigEnvVars(cfg?: ClawdbotConfig): Record<string, string> {
|
||||
const envConfig = cfg?.env;
|
||||
if (!envConfig) return {};
|
||||
|
||||
const entries: Record<string, string> = {};
|
||||
|
||||
if (envConfig.vars) {
|
||||
for (const [key, value] of Object.entries(envConfig.vars)) {
|
||||
if (!value) continue;
|
||||
entries[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(envConfig)) {
|
||||
if (key === "shellEnv" || key === "vars") continue;
|
||||
if (typeof value !== "string" || !value.trim()) continue;
|
||||
entries[key] = value;
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
} from "./defaults.js";
|
||||
import { VERSION } from "../version.js";
|
||||
import { MissingEnvVarError, resolveConfigEnvVars } from "./env-substitution.js";
|
||||
import { collectConfigEnvVars } from "./env-vars.js";
|
||||
import { ConfigIncludeError, resolveConfigIncludes } from "./includes.js";
|
||||
import { findLegacyConfigIssues } from "./legacy.js";
|
||||
import { normalizeConfigPaths } from "./normalize-paths.js";
|
||||
@@ -149,24 +150,7 @@ function warnIfConfigFromFuture(cfg: ClawdbotConfig, logger: Pick<typeof console
|
||||
}
|
||||
|
||||
function applyConfigEnv(cfg: ClawdbotConfig, env: NodeJS.ProcessEnv): void {
|
||||
const envConfig = cfg.env;
|
||||
if (!envConfig) return;
|
||||
|
||||
const entries: Record<string, string> = {};
|
||||
|
||||
if (envConfig.vars) {
|
||||
for (const [key, value] of Object.entries(envConfig.vars)) {
|
||||
if (!value) continue;
|
||||
entries[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(envConfig)) {
|
||||
if (key === "shellEnv" || key === "vars") continue;
|
||||
if (typeof value !== "string" || !value.trim()) continue;
|
||||
entries[key] = value;
|
||||
}
|
||||
|
||||
const entries = collectConfigEnvVars(cfg);
|
||||
for (const [key, value] of Object.entries(entries)) {
|
||||
if (env[key]?.trim()) continue;
|
||||
env[key] = value;
|
||||
|
||||
@@ -107,6 +107,7 @@ const FIELD_LABELS: Record<string, string> = {
|
||||
"update.channel": "Update Channel",
|
||||
"update.checkOnStart": "Update Check on Start",
|
||||
"diagnostics.enabled": "Diagnostics Enabled",
|
||||
"diagnostics.flags": "Diagnostics Flags",
|
||||
"diagnostics.otel.enabled": "OpenTelemetry Enabled",
|
||||
"diagnostics.otel.endpoint": "OpenTelemetry Endpoint",
|
||||
"diagnostics.otel.protocol": "OpenTelemetry Protocol",
|
||||
@@ -388,6 +389,8 @@ const FIELD_HELP: Record<string, string> = {
|
||||
"nodeHost.browserProxy.enabled": "Expose the local browser control server via node proxy.",
|
||||
"nodeHost.browserProxy.allowProfiles":
|
||||
"Optional allowlist of browser profile names exposed via the node proxy.",
|
||||
"diagnostics.flags":
|
||||
'Enable targeted diagnostics logs by flag (e.g. ["telegram.http"]). Supports wildcards like "telegram.*" or "*".',
|
||||
"diagnostics.cacheTrace.enabled":
|
||||
"Log cache trace snapshots for embedded agent runs (default: false).",
|
||||
"diagnostics.cacheTrace.filePath":
|
||||
|
||||
@@ -135,6 +135,8 @@ export type DiagnosticsCacheTraceConfig = {
|
||||
|
||||
export type DiagnosticsConfig = {
|
||||
enabled?: boolean;
|
||||
/** Optional ad-hoc diagnostics flags (e.g. "telegram.http"). */
|
||||
flags?: string[];
|
||||
otel?: DiagnosticsOtelConfig;
|
||||
cacheTrace?: DiagnosticsCacheTraceConfig;
|
||||
};
|
||||
|
||||
@@ -62,6 +62,7 @@ export const ClawdbotSchema = z
|
||||
diagnostics: z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
flags: z.array(z.string()).optional(),
|
||||
otel: z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
writeConfigFile,
|
||||
} from "../config/config.js";
|
||||
import { isDiagnosticsEnabled } from "../infra/diagnostic-events.js";
|
||||
import { logAcceptedEnvOption } from "../infra/env.js";
|
||||
import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js";
|
||||
import { clearAgentRunContext, onAgentEvent } from "../infra/agent-events.js";
|
||||
import { onHeartbeatEvent } from "../infra/heartbeat-events.js";
|
||||
@@ -149,6 +150,14 @@ export async function startGatewayServer(
|
||||
): Promise<GatewayServer> {
|
||||
// Ensure all default port derivations (browser/canvas) see the actual runtime port.
|
||||
process.env.CLAWDBOT_GATEWAY_PORT = String(port);
|
||||
logAcceptedEnvOption({
|
||||
key: "CLAWDBOT_RAW_STREAM",
|
||||
description: "raw stream logging enabled",
|
||||
});
|
||||
logAcceptedEnvOption({
|
||||
key: "CLAWDBOT_RAW_STREAM_PATH",
|
||||
description: "raw stream log path override",
|
||||
});
|
||||
|
||||
let configSnapshot = await readConfigFileSnapshot();
|
||||
if (configSnapshot.legacyIssues.length > 0) {
|
||||
|
||||
31
src/infra/diagnostic-flags.test.ts
Normal file
31
src/infra/diagnostic-flags.test.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import { isDiagnosticFlagEnabled, resolveDiagnosticFlags } from "./diagnostic-flags.js";
|
||||
|
||||
describe("diagnostic flags", () => {
|
||||
it("merges config + env flags", () => {
|
||||
const cfg = {
|
||||
diagnostics: { flags: ["telegram.http", "cache.*"] },
|
||||
} as ClawdbotConfig;
|
||||
const env = {
|
||||
CLAWDBOT_DIAGNOSTICS: "foo,bar",
|
||||
} as NodeJS.ProcessEnv;
|
||||
|
||||
const flags = resolveDiagnosticFlags(cfg, env);
|
||||
expect(flags).toEqual(expect.arrayContaining(["telegram.http", "cache.*", "foo", "bar"]));
|
||||
expect(isDiagnosticFlagEnabled("telegram.http", cfg, env)).toBe(true);
|
||||
expect(isDiagnosticFlagEnabled("cache.hit", cfg, env)).toBe(true);
|
||||
expect(isDiagnosticFlagEnabled("foo", cfg, env)).toBe(true);
|
||||
});
|
||||
|
||||
it("treats env true as wildcard", () => {
|
||||
const env = { CLAWDBOT_DIAGNOSTICS: "1" } as NodeJS.ProcessEnv;
|
||||
expect(isDiagnosticFlagEnabled("anything.here", undefined, env)).toBe(true);
|
||||
});
|
||||
|
||||
it("treats env false as disabled", () => {
|
||||
const env = { CLAWDBOT_DIAGNOSTICS: "0" } as NodeJS.ProcessEnv;
|
||||
expect(isDiagnosticFlagEnabled("telegram.http", undefined, env)).toBe(false);
|
||||
});
|
||||
});
|
||||
70
src/infra/diagnostic-flags.ts
Normal file
70
src/infra/diagnostic-flags.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
|
||||
const DIAGNOSTICS_ENV = "CLAWDBOT_DIAGNOSTICS";
|
||||
|
||||
function normalizeFlag(value: string): string {
|
||||
return value.trim().toLowerCase();
|
||||
}
|
||||
|
||||
function parseEnvFlags(raw?: string): string[] {
|
||||
if (!raw) return [];
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return [];
|
||||
const lowered = trimmed.toLowerCase();
|
||||
if (["0", "false", "off", "none"].includes(lowered)) return [];
|
||||
if (["1", "true", "all", "*"].includes(lowered)) return ["*"];
|
||||
return trimmed
|
||||
.split(/[,\s]+/)
|
||||
.map(normalizeFlag)
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function uniqueFlags(flags: string[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
for (const flag of flags) {
|
||||
const normalized = normalizeFlag(flag);
|
||||
if (!normalized || seen.has(normalized)) continue;
|
||||
seen.add(normalized);
|
||||
out.push(normalized);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function resolveDiagnosticFlags(
|
||||
cfg?: ClawdbotConfig,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): string[] {
|
||||
const configFlags = Array.isArray(cfg?.diagnostics?.flags) ? cfg?.diagnostics?.flags : [];
|
||||
const envFlags = parseEnvFlags(env[DIAGNOSTICS_ENV]);
|
||||
return uniqueFlags([...configFlags, ...envFlags]);
|
||||
}
|
||||
|
||||
export function matchesDiagnosticFlag(flag: string, enabledFlags: string[]): boolean {
|
||||
const target = normalizeFlag(flag);
|
||||
if (!target) return false;
|
||||
for (const raw of enabledFlags) {
|
||||
const enabled = normalizeFlag(raw);
|
||||
if (!enabled) continue;
|
||||
if (enabled === "*" || enabled === "all") return true;
|
||||
if (enabled.endsWith(".*")) {
|
||||
const prefix = enabled.slice(0, -2);
|
||||
if (target === prefix || target.startsWith(`${prefix}.`)) return true;
|
||||
}
|
||||
if (enabled.endsWith("*")) {
|
||||
const prefix = enabled.slice(0, -1);
|
||||
if (target.startsWith(prefix)) return true;
|
||||
}
|
||||
if (enabled === target) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function isDiagnosticFlagEnabled(
|
||||
flag: string,
|
||||
cfg?: ClawdbotConfig,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): boolean {
|
||||
const flags = resolveDiagnosticFlags(cfg, env);
|
||||
return matchesDiagnosticFlag(flag, flags);
|
||||
}
|
||||
@@ -1,5 +1,32 @@
|
||||
import { createSubsystemLogger } from "../logging/subsystem.js";
|
||||
import { parseBooleanValue } from "../utils/boolean.js";
|
||||
|
||||
const log = createSubsystemLogger("env");
|
||||
const loggedEnv = new Set<string>();
|
||||
|
||||
type AcceptedEnvOption = {
|
||||
key: string;
|
||||
description: string;
|
||||
value?: string;
|
||||
redact?: boolean;
|
||||
};
|
||||
|
||||
function formatEnvValue(value: string, redact?: boolean): string {
|
||||
if (redact) return "<redacted>";
|
||||
const singleLine = value.replace(/\s+/g, " ").trim();
|
||||
if (singleLine.length <= 160) return singleLine;
|
||||
return `${singleLine.slice(0, 160)}…`;
|
||||
}
|
||||
|
||||
export function logAcceptedEnvOption(option: AcceptedEnvOption): void {
|
||||
if (process.env.VITEST || process.env.NODE_ENV === "test") return;
|
||||
if (loggedEnv.has(option.key)) return;
|
||||
const rawValue = option.value ?? process.env[option.key];
|
||||
if (!rawValue || !rawValue.trim()) return;
|
||||
loggedEnv.add(option.key);
|
||||
log.info(`env: ${option.key}=${formatEnvValue(rawValue, option.redact)} (${option.description})`);
|
||||
}
|
||||
|
||||
export function normalizeZaiEnv(): void {
|
||||
if (!process.env.ZAI_API_KEY?.trim() && process.env.Z_AI_API_KEY?.trim()) {
|
||||
process.env.ZAI_API_KEY = process.env.Z_AI_API_KEY;
|
||||
|
||||
@@ -321,6 +321,44 @@ describe("runMessageAction context isolation", () => {
|
||||
}),
|
||||
).rejects.toThrow(/Cross-context messaging denied/);
|
||||
});
|
||||
|
||||
it("aborts send when abortSignal is already aborted", async () => {
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
|
||||
await expect(
|
||||
runMessageAction({
|
||||
cfg: slackConfig,
|
||||
action: "send",
|
||||
params: {
|
||||
channel: "slack",
|
||||
target: "#C12345678",
|
||||
message: "hi",
|
||||
},
|
||||
dryRun: true,
|
||||
abortSignal: controller.signal,
|
||||
}),
|
||||
).rejects.toMatchObject({ name: "AbortError" });
|
||||
});
|
||||
|
||||
it("aborts broadcast when abortSignal is already aborted", async () => {
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
|
||||
await expect(
|
||||
runMessageAction({
|
||||
cfg: slackConfig,
|
||||
action: "broadcast",
|
||||
params: {
|
||||
targets: ["channel:C12345678"],
|
||||
channel: "slack",
|
||||
message: "hi",
|
||||
},
|
||||
dryRun: true,
|
||||
abortSignal: controller.signal,
|
||||
}),
|
||||
).rejects.toMatchObject({ name: "AbortError" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("runMessageAction sendAttachment hydration", () => {
|
||||
|
||||
@@ -526,6 +526,7 @@ async function handleBroadcastAction(
|
||||
input: RunMessageActionParams,
|
||||
params: Record<string, unknown>,
|
||||
): Promise<MessageActionRunResult> {
|
||||
throwIfAborted(input.abortSignal);
|
||||
const broadcastEnabled = input.cfg.tools?.message?.broadcast?.enabled !== false;
|
||||
if (!broadcastEnabled) {
|
||||
throw new Error("Broadcast is disabled. Set tools.message.broadcast.enabled to true.");
|
||||
@@ -550,8 +551,11 @@ async function handleBroadcastAction(
|
||||
error?: string;
|
||||
result?: MessageSendResult;
|
||||
}> = [];
|
||||
const isAbortError = (err: unknown): boolean => err instanceof Error && err.name === "AbortError";
|
||||
for (const targetChannel of targetChannels) {
|
||||
throwIfAborted(input.abortSignal);
|
||||
for (const target of rawTargets) {
|
||||
throwIfAborted(input.abortSignal);
|
||||
try {
|
||||
const resolved = await resolveChannelTarget({
|
||||
cfg: input.cfg,
|
||||
@@ -575,6 +579,7 @@ async function handleBroadcastAction(
|
||||
result: sendResult.kind === "send" ? sendResult.sendResult : undefined,
|
||||
});
|
||||
} catch (err) {
|
||||
if (isAbortError(err)) throw err;
|
||||
results.push({
|
||||
channel: targetChannel,
|
||||
to: target,
|
||||
|
||||
@@ -4,13 +4,16 @@ import type {
|
||||
ReactionType,
|
||||
ReactionTypeEmoji,
|
||||
} from "@grammyjs/types";
|
||||
import { type ApiClientOptions, Bot, InputFile } from "grammy";
|
||||
import { type ApiClientOptions, Bot, HttpError, InputFile } from "grammy";
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import { logVerbose } from "../globals.js";
|
||||
import { recordChannelActivity } from "../infra/channel-activity.js";
|
||||
import { formatErrorMessage } from "../infra/errors.js";
|
||||
import { formatErrorMessage, formatUncaughtError } from "../infra/errors.js";
|
||||
import { isDiagnosticFlagEnabled } from "../infra/diagnostic-flags.js";
|
||||
import type { RetryConfig } from "../infra/retry.js";
|
||||
import { createTelegramRetryRunner } from "../infra/retry-policy.js";
|
||||
import { redactSensitiveText } from "../logging/redact.js";
|
||||
import { createSubsystemLogger } from "../logging/subsystem.js";
|
||||
import { mediaKindFromMime } from "../media/constants.js";
|
||||
import { isGifMedia } from "../media/mime.js";
|
||||
import { loadWebMedia } from "../web/media.js";
|
||||
@@ -59,6 +62,19 @@ type TelegramReactionOpts = {
|
||||
};
|
||||
|
||||
const PARSE_ERR_RE = /can't parse entities|parse entities|find end of the entity/i;
|
||||
const diagLogger = createSubsystemLogger("telegram/diagnostic");
|
||||
|
||||
function createTelegramHttpLogger(cfg: ReturnType<typeof loadConfig>) {
|
||||
const enabled = isDiagnosticFlagEnabled("telegram.http", cfg);
|
||||
if (!enabled) {
|
||||
return () => {};
|
||||
}
|
||||
return (label: string, err: unknown) => {
|
||||
if (!(err instanceof HttpError)) return;
|
||||
const detail = redactSensitiveText(formatUncaughtError(err.error ?? err));
|
||||
diagLogger.warn(`telegram http error (${label}): ${detail}`);
|
||||
};
|
||||
}
|
||||
|
||||
function resolveToken(explicit: string | undefined, params: { accountId: string; token: string }) {
|
||||
if (explicit?.trim()) return explicit.trim();
|
||||
@@ -178,7 +194,12 @@ export async function sendMessageTelegram(
|
||||
configRetry: account.config.retry,
|
||||
verbose: opts.verbose,
|
||||
});
|
||||
|
||||
const logHttpError = createTelegramHttpLogger(cfg);
|
||||
const requestWithDiag = <T>(fn: () => Promise<T>, label?: string) =>
|
||||
request(fn, label).catch((err) => {
|
||||
logHttpError(label ?? "request", err);
|
||||
throw err;
|
||||
});
|
||||
const wrapChatNotFound = (err: unknown) => {
|
||||
if (!/400: Bad Request: chat not found/i.test(formatErrorMessage(err))) return err;
|
||||
return new Error(
|
||||
@@ -217,30 +238,31 @@ export async function sendMessageTelegram(
|
||||
parse_mode: "HTML" as const,
|
||||
...baseParams,
|
||||
};
|
||||
const res = await request(() => api.sendMessage(chatId, htmlText, sendParams), "message").catch(
|
||||
async (err) => {
|
||||
// Telegram rejects malformed HTML (e.g., unsupported tags or entities).
|
||||
// When that happens, fall back to plain text so the message still delivers.
|
||||
const errText = formatErrorMessage(err);
|
||||
if (PARSE_ERR_RE.test(errText)) {
|
||||
if (opts.verbose) {
|
||||
console.warn(`telegram HTML parse failed, retrying as plain text: ${errText}`);
|
||||
}
|
||||
const fallback = fallbackText ?? rawText;
|
||||
const plainParams = hasBaseParams ? baseParams : undefined;
|
||||
return await request(
|
||||
() =>
|
||||
plainParams
|
||||
? api.sendMessage(chatId, fallback, plainParams)
|
||||
: api.sendMessage(chatId, fallback),
|
||||
"message-plain",
|
||||
).catch((err2) => {
|
||||
throw wrapChatNotFound(err2);
|
||||
});
|
||||
const res = await requestWithDiag(
|
||||
() => api.sendMessage(chatId, htmlText, sendParams),
|
||||
"message",
|
||||
).catch(async (err) => {
|
||||
// Telegram rejects malformed HTML (e.g., unsupported tags or entities).
|
||||
// When that happens, fall back to plain text so the message still delivers.
|
||||
const errText = formatErrorMessage(err);
|
||||
if (PARSE_ERR_RE.test(errText)) {
|
||||
if (opts.verbose) {
|
||||
console.warn(`telegram HTML parse failed, retrying as plain text: ${errText}`);
|
||||
}
|
||||
throw wrapChatNotFound(err);
|
||||
},
|
||||
);
|
||||
const fallback = fallbackText ?? rawText;
|
||||
const plainParams = hasBaseParams ? baseParams : undefined;
|
||||
return await requestWithDiag(
|
||||
() =>
|
||||
plainParams
|
||||
? api.sendMessage(chatId, fallback, plainParams)
|
||||
: api.sendMessage(chatId, fallback),
|
||||
"message-plain",
|
||||
).catch((err2) => {
|
||||
throw wrapChatNotFound(err2);
|
||||
});
|
||||
}
|
||||
throw wrapChatNotFound(err);
|
||||
});
|
||||
return res;
|
||||
};
|
||||
|
||||
@@ -277,19 +299,20 @@ export async function sendMessageTelegram(
|
||||
| Awaited<ReturnType<typeof api.sendAnimation>>
|
||||
| Awaited<ReturnType<typeof api.sendDocument>>;
|
||||
if (isGif) {
|
||||
result = await request(() => api.sendAnimation(chatId, file, mediaParams), "animation").catch(
|
||||
(err) => {
|
||||
throw wrapChatNotFound(err);
|
||||
},
|
||||
);
|
||||
result = await requestWithDiag(
|
||||
() => api.sendAnimation(chatId, file, mediaParams),
|
||||
"animation",
|
||||
).catch((err) => {
|
||||
throw wrapChatNotFound(err);
|
||||
});
|
||||
} else if (kind === "image") {
|
||||
result = await request(() => api.sendPhoto(chatId, file, mediaParams), "photo").catch(
|
||||
result = await requestWithDiag(() => api.sendPhoto(chatId, file, mediaParams), "photo").catch(
|
||||
(err) => {
|
||||
throw wrapChatNotFound(err);
|
||||
},
|
||||
);
|
||||
} else if (kind === "video") {
|
||||
result = await request(() => api.sendVideo(chatId, file, mediaParams), "video").catch(
|
||||
result = await requestWithDiag(() => api.sendVideo(chatId, file, mediaParams), "video").catch(
|
||||
(err) => {
|
||||
throw wrapChatNotFound(err);
|
||||
},
|
||||
@@ -302,24 +325,27 @@ export async function sendMessageTelegram(
|
||||
logFallback: logVerbose,
|
||||
});
|
||||
if (useVoice) {
|
||||
result = await request(() => api.sendVoice(chatId, file, mediaParams), "voice").catch(
|
||||
(err) => {
|
||||
throw wrapChatNotFound(err);
|
||||
},
|
||||
);
|
||||
result = await requestWithDiag(
|
||||
() => api.sendVoice(chatId, file, mediaParams),
|
||||
"voice",
|
||||
).catch((err) => {
|
||||
throw wrapChatNotFound(err);
|
||||
});
|
||||
} else {
|
||||
result = await request(() => api.sendAudio(chatId, file, mediaParams), "audio").catch(
|
||||
(err) => {
|
||||
throw wrapChatNotFound(err);
|
||||
},
|
||||
);
|
||||
result = await requestWithDiag(
|
||||
() => api.sendAudio(chatId, file, mediaParams),
|
||||
"audio",
|
||||
).catch((err) => {
|
||||
throw wrapChatNotFound(err);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
result = await request(() => api.sendDocument(chatId, file, mediaParams), "document").catch(
|
||||
(err) => {
|
||||
throw wrapChatNotFound(err);
|
||||
},
|
||||
);
|
||||
result = await requestWithDiag(
|
||||
() => api.sendDocument(chatId, file, mediaParams),
|
||||
"document",
|
||||
).catch((err) => {
|
||||
throw wrapChatNotFound(err);
|
||||
});
|
||||
}
|
||||
const mediaMessageId = String(result?.message_id ?? "unknown");
|
||||
const resolvedChatId = String(result?.chat?.id ?? chatId);
|
||||
@@ -400,6 +426,12 @@ export async function reactMessageTelegram(
|
||||
configRetry: account.config.retry,
|
||||
verbose: opts.verbose,
|
||||
});
|
||||
const logHttpError = createTelegramHttpLogger(cfg);
|
||||
const requestWithDiag = <T>(fn: () => Promise<T>, label?: string) =>
|
||||
request(fn, label).catch((err) => {
|
||||
logHttpError(label ?? "request", err);
|
||||
throw err;
|
||||
});
|
||||
const remove = opts.remove === true;
|
||||
const trimmedEmoji = emoji.trim();
|
||||
// Build the reaction array. We cast emoji to the grammY union type since
|
||||
@@ -411,7 +443,7 @@ export async function reactMessageTelegram(
|
||||
if (typeof api.setMessageReaction !== "function") {
|
||||
throw new Error("Telegram reactions are unavailable in this bot API.");
|
||||
}
|
||||
await request(() => api.setMessageReaction(chatId, messageId, reactions), "reaction");
|
||||
await requestWithDiag(() => api.setMessageReaction(chatId, messageId, reactions), "reaction");
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
@@ -446,7 +478,13 @@ export async function deleteMessageTelegram(
|
||||
configRetry: account.config.retry,
|
||||
verbose: opts.verbose,
|
||||
});
|
||||
await request(() => api.deleteMessage(chatId, messageId), "deleteMessage");
|
||||
const logHttpError = createTelegramHttpLogger(cfg);
|
||||
const requestWithDiag = <T>(fn: () => Promise<T>, label?: string) =>
|
||||
request(fn, label).catch((err) => {
|
||||
logHttpError(label ?? "request", err);
|
||||
throw err;
|
||||
});
|
||||
await requestWithDiag(() => api.deleteMessage(chatId, messageId), "deleteMessage");
|
||||
logVerbose(`[telegram] Deleted message ${messageId} from chat ${chatId}`);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
@@ -169,6 +169,7 @@ export async function finalizeOnboardingWizard(options: FinalizeOnboardingOption
|
||||
token: settings.gatewayToken,
|
||||
runtime: daemonRuntime,
|
||||
warn: (message, title) => prompter.note(message, title),
|
||||
config: nextConfig,
|
||||
});
|
||||
|
||||
progress.update("Installing Gateway service…");
|
||||
|
||||
@@ -1,3 +1,2 @@
|
||||
import "./styles.css";
|
||||
import "./ui/app.ts";
|
||||
|
||||
|
||||
@@ -1,60 +1,172 @@
|
||||
@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");
|
||||
@import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap");
|
||||
|
||||
:root {
|
||||
--bg: #0a0f14;
|
||||
--bg-accent: #111826;
|
||||
--bg-grad-1: #162031;
|
||||
--bg-grad-2: #1f2a22;
|
||||
--bg-overlay: rgba(255, 255, 255, 0.05);
|
||||
--bg-glow: rgba(245, 159, 74, 0.12);
|
||||
--panel: rgba(14, 20, 30, 0.88);
|
||||
--panel-strong: rgba(18, 26, 38, 0.96);
|
||||
--chrome: rgba(9, 14, 20, 0.72);
|
||||
--chrome-strong: rgba(9, 14, 20, 0.86);
|
||||
--text: rgba(244, 246, 251, 0.96);
|
||||
--chat-text: rgba(231, 237, 244, 0.92);
|
||||
--muted: rgba(156, 169, 189, 0.72);
|
||||
--border: rgba(255, 255, 255, 0.09);
|
||||
--border-strong: rgba(255, 255, 255, 0.16);
|
||||
--accent: #f59f4a;
|
||||
--accent-2: #34c7b7;
|
||||
--ok: #2bd97f;
|
||||
--warn: #f2c94c;
|
||||
--danger: #ff6b6b;
|
||||
--focus: rgba(245, 159, 74, 0.35);
|
||||
--grid-line: rgba(255, 255, 255, 0.04);
|
||||
/* Background - Deep Navy Slate */
|
||||
--bg: #0c0d12;
|
||||
--bg-accent: #0d0e14;
|
||||
--bg-elevated: #181a21;
|
||||
--bg-hover: #252830;
|
||||
--bg-muted: #252830;
|
||||
|
||||
/* Card / Surface */
|
||||
--card: #13151c;
|
||||
--card-foreground: #f8fafc;
|
||||
--card-highlight: rgba(255, 255, 255, 0.04);
|
||||
--popover: #13151c;
|
||||
--popover-foreground: #f8fafc;
|
||||
|
||||
/* Panel */
|
||||
--panel: #0c0d12;
|
||||
--panel-strong: #181a21;
|
||||
--panel-hover: #252830;
|
||||
--chrome: rgba(12, 13, 18, 0.95);
|
||||
--chrome-strong: rgba(12, 13, 18, 0.98);
|
||||
|
||||
/* Text */
|
||||
--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-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;
|
||||
|
||||
/* Typography */
|
||||
--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;
|
||||
|
||||
/* 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;
|
||||
}
|
||||
|
||||
/* Light theme */
|
||||
:root[data-theme="light"] {
|
||||
--bg: #f5f1ea;
|
||||
--bg-accent: #ffffff;
|
||||
--bg-grad-1: #f1e6d6;
|
||||
--bg-grad-2: #e5eef4;
|
||||
--bg-overlay: rgba(28, 32, 46, 0.05);
|
||||
--bg-glow: rgba(52, 199, 183, 0.14);
|
||||
--panel: rgba(255, 255, 255, 0.9);
|
||||
--panel-strong: rgba(255, 255, 255, 0.97);
|
||||
--chrome: rgba(255, 255, 255, 0.75);
|
||||
--chrome-strong: rgba(255, 255, 255, 0.88);
|
||||
--text: rgba(27, 36, 50, 0.98);
|
||||
--chat-text: rgba(36, 48, 66, 0.9);
|
||||
--muted: rgba(80, 94, 114, 0.7);
|
||||
--border: rgba(18, 24, 40, 0.12);
|
||||
--border-strong: rgba(18, 24, 40, 0.2);
|
||||
--accent: #e28a3f;
|
||||
--accent-2: #1ba99d;
|
||||
--ok: #1aa86c;
|
||||
--warn: #b3771c;
|
||||
--danger: #d44848;
|
||||
--focus: rgba(226, 138, 63, 0.35);
|
||||
--grid-line: rgba(18, 24, 40, 0.06);
|
||||
--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, 0.04);
|
||||
--popover: #ffffff;
|
||||
--popover-foreground: #1c1917;
|
||||
|
||||
--panel: #f8f8f7;
|
||||
--panel-strong: #f0efed;
|
||||
--panel-hover: #e5e3e1;
|
||||
--chrome: rgba(248, 248, 247, 0.95);
|
||||
--chrome-strong: rgba(248, 248, 247, 0.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, 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;
|
||||
}
|
||||
|
||||
@@ -69,44 +181,21 @@ body {
|
||||
|
||||
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;
|
||||
font: 400 14px/1.5 var(--font-body);
|
||||
letter-spacing: -0.011em;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
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 */
|
||||
|
||||
/* Theme transition */
|
||||
@keyframes theme-circle-transition {
|
||||
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% {
|
||||
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%));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,7 +212,7 @@ html.theme-transition::view-transition-old(theme) {
|
||||
html.theme-transition::view-transition-new(theme) {
|
||||
mix-blend-mode: normal;
|
||||
z-index: 2;
|
||||
animation: theme-circle-transition 0.45s ease-out forwards;
|
||||
animation: theme-circle-transition 0.4s var(--ease-out) forwards;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
@@ -141,7 +230,12 @@ clawdbot-app {
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
button,
|
||||
@@ -152,10 +246,35 @@ select {
|
||||
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 {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(6px);
|
||||
transform: translateY(4px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
@@ -163,13 +282,48 @@ 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 {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(12px);
|
||||
transform: translateY(8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 16px;
|
||||
margin-left: 16px;
|
||||
margin-left: 4px;
|
||||
margin-right: 16px;
|
||||
}
|
||||
|
||||
@@ -70,23 +70,23 @@
|
||||
}
|
||||
|
||||
.chat-avatar.user {
|
||||
background: rgba(245, 159, 74, 0.2);
|
||||
color: rgba(245, 159, 74, 1);
|
||||
background: var(--accent-subtle);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.chat-avatar.assistant {
|
||||
background: rgba(52, 199, 183, 0.2);
|
||||
color: rgba(52, 199, 183, 1);
|
||||
background: var(--secondary);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.chat-avatar.other {
|
||||
background: rgba(150, 150, 150, 0.2);
|
||||
color: rgba(150, 150, 150, 1);
|
||||
background: var(--secondary);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.chat-avatar.tool {
|
||||
background: rgba(134, 142, 150, 0.2);
|
||||
color: rgba(134, 142, 150, 1);
|
||||
background: var(--secondary);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* Image avatar support */
|
||||
@@ -100,9 +100,9 @@ img.chat-avatar {
|
||||
.chat-bubble {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
border: 1px solid var(--border);
|
||||
background: rgba(0, 0, 0, 0.12);
|
||||
border-radius: 12px;
|
||||
border: 1px solid transparent;
|
||||
background: var(--card);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 10px 14px;
|
||||
box-shadow: none;
|
||||
transition: background 150ms ease-out, border-color 150ms ease-out;
|
||||
@@ -119,9 +119,9 @@ img.chat-avatar {
|
||||
top: 6px;
|
||||
right: 8px;
|
||||
border: 1px solid var(--border);
|
||||
background: rgba(0, 0, 0, 0.22);
|
||||
background: var(--bg);
|
||||
color: var(--muted);
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius-md);
|
||||
padding: 4px 6px;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
@@ -132,9 +132,40 @@ img.chat-avatar {
|
||||
}
|
||||
|
||||
.chat-copy-btn__icon {
|
||||
display: inline-block;
|
||||
width: 1em;
|
||||
text-align: center;
|
||||
display: inline-flex;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
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 {
|
||||
@@ -143,7 +174,7 @@ img.chat-avatar {
|
||||
}
|
||||
|
||||
.chat-copy-btn:hover {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.chat-copy-btn[data-copying="1"] {
|
||||
@@ -154,17 +185,17 @@ img.chat-avatar {
|
||||
.chat-copy-btn[data-error="1"] {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
border-color: rgba(255, 69, 58, 0.8);
|
||||
background: rgba(255, 69, 58, 0.18);
|
||||
color: rgba(255, 69, 58, 1);
|
||||
border-color: var(--danger-subtle);
|
||||
background: var(--danger-subtle);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.chat-copy-btn[data-copied="1"] {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
border-color: rgba(52, 199, 183, 0.8);
|
||||
background: rgba(52, 199, 183, 0.18);
|
||||
color: rgba(52, 199, 183, 1);
|
||||
border-color: var(--ok-subtle);
|
||||
background: var(--ok-subtle);
|
||||
color: var(--ok);
|
||||
}
|
||||
|
||||
.chat-copy-btn:focus-visible {
|
||||
@@ -181,18 +212,29 @@ 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 {
|
||||
background: rgba(0, 0, 0, 0.18);
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
/* User bubbles have different styling */
|
||||
.chat-group.user .chat-bubble {
|
||||
background: rgba(245, 159, 74, 0.15);
|
||||
border-color: rgba(245, 159, 74, 0.3);
|
||||
background: var(--accent-subtle);
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
: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 {
|
||||
background: rgba(245, 159, 74, 0.22);
|
||||
background: rgba(255, 77, 77, 0.15);
|
||||
}
|
||||
|
||||
/* Streaming animation */
|
||||
|
||||
@@ -52,8 +52,8 @@
|
||||
flex: 1 1 0; /* Grow, shrink, and use 0 base for proper scrolling */
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding: 12px;
|
||||
margin: 0 -12px;
|
||||
padding: 12px 4px;
|
||||
margin: 0 -4px;
|
||||
min-height: 0; /* Allow shrinking for flex scroll behavior */
|
||||
border-radius: 12px;
|
||||
background: transparent;
|
||||
@@ -87,23 +87,39 @@
|
||||
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 {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
align-items: stretch;
|
||||
gap: 12px;
|
||||
margin-top: auto; /* Push to bottom of flex container */
|
||||
padding: 16px 0 4px;
|
||||
padding: 12px 4px 4px;
|
||||
background: linear-gradient(to bottom, transparent, var(--bg) 20%);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .chat-compose {
|
||||
background: linear-gradient(to bottom, transparent, var(--bg-content) 20%);
|
||||
}
|
||||
|
||||
.chat-compose__field {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
/* Hide the "Message" label - keep textarea only */
|
||||
@@ -114,10 +130,11 @@
|
||||
/* Override .field textarea min-height (180px) from components.css */
|
||||
.chat-compose .chat-compose__field textarea {
|
||||
width: 100%;
|
||||
min-height: 36px;
|
||||
height: 40px;
|
||||
min-height: 40px;
|
||||
max-height: 150px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 10px;
|
||||
padding: 9px 12px;
|
||||
border-radius: 8px;
|
||||
resize: vertical;
|
||||
white-space: pre-wrap;
|
||||
font-family: var(--font-body);
|
||||
@@ -134,13 +151,18 @@
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.chat-compose .chat-compose__actions .btn {
|
||||
padding: 8px 16px;
|
||||
padding: 0 16px;
|
||||
font-size: 13px;
|
||||
min-height: 36px;
|
||||
height: 40px;
|
||||
min-height: 40px;
|
||||
max-height: 40px;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Chat controls - moved to content-header area, left aligned */
|
||||
@@ -194,20 +216,27 @@
|
||||
|
||||
/* Light theme icon button overrides */
|
||||
:root[data-theme="light"] .btn--icon {
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
border-color: rgba(16, 24, 40, 0.2);
|
||||
background: #ffffff;
|
||||
border-color: var(--border);
|
||||
box-shadow: 0 1px 2px rgba(16, 24, 40, 0.05);
|
||||
color: rgba(16, 24, 40, 0.7);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .btn--icon:hover {
|
||||
background: rgba(255, 255, 255, 1);
|
||||
border-color: rgba(16, 24, 40, 0.3);
|
||||
color: rgba(16, 24, 40, 0.9);
|
||||
background: #ffffff;
|
||||
border-color: var(--border-strong);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.btn--icon svg {
|
||||
display: block;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
stroke: currentColor;
|
||||
fill: none;
|
||||
stroke-width: 1.5px;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.chat-controls__session select {
|
||||
@@ -250,4 +279,3 @@
|
||||
min-width: 120px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,8 +14,8 @@
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .chat-thinking {
|
||||
border-color: rgba(16, 24, 40, 0.18);
|
||||
background: rgba(16, 24, 40, 0.03);
|
||||
border-color: rgba(16, 24, 40, 0.25);
|
||||
background: rgba(16, 24, 40, 0.04);
|
||||
}
|
||||
|
||||
.chat-text {
|
||||
@@ -75,9 +75,46 @@
|
||||
}
|
||||
|
||||
.chat-text :where(blockquote) {
|
||||
border-left: 3px solid var(--border);
|
||||
border-left: 3px solid var(--border-strong);
|
||||
padding-left: 12px;
|
||||
margin-left: 0;
|
||||
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) {
|
||||
@@ -85,4 +122,3 @@
|
||||
border-top: 1px solid var(--border);
|
||||
margin: 1em 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
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;
|
||||
/* Fixed max-height to ensure cards don't expand too much */
|
||||
max-height: 120px;
|
||||
@@ -11,8 +13,8 @@
|
||||
}
|
||||
|
||||
.chat-tool-card:hover {
|
||||
border-color: var(--accent);
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
border-color: var(--border-strong);
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
/* First tool card in a group - no top margin */
|
||||
@@ -50,33 +52,63 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
font-family: "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", sans-serif;
|
||||
vertical-align: middle;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
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 */
|
||||
.chat-tool-card__action {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--accent);
|
||||
opacity: 0.8;
|
||||
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 {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Status indicator for completed/empty results */
|
||||
.chat-tool-card__status {
|
||||
font-size: 14px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
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 {
|
||||
font-size: 11px;
|
||||
margin-top: 4px;
|
||||
@@ -94,18 +126,18 @@
|
||||
color: var(--muted);
|
||||
margin-top: 8px;
|
||||
padding: 8px 10px;
|
||||
background: rgba(0, 0, 0, 0.08);
|
||||
border-radius: 6px;
|
||||
background: var(--secondary);
|
||||
border-radius: var(--radius-md);
|
||||
white-space: pre-wrap;
|
||||
overflow: hidden;
|
||||
max-height: 44px;
|
||||
line-height: 1.4;
|
||||
border: 1px solid rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.chat-tool-card--clickable:hover .chat-tool-card__preview {
|
||||
background: rgba(0, 0, 0, 0.12);
|
||||
border-color: rgba(255, 255, 255, 0.08);
|
||||
background: var(--bg-hover);
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
|
||||
/* Short inline output */
|
||||
@@ -114,8 +146,8 @@
|
||||
color: var(--text);
|
||||
margin-top: 6px;
|
||||
padding: 6px 8px;
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
border-radius: 4px;
|
||||
background: var(--secondary);
|
||||
border-radius: var(--radius-sm);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
@@ -164,4 +196,3 @@
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,14 @@
|
||||
/* ===========================================
|
||||
Shell Layout
|
||||
=========================================== */
|
||||
|
||||
.shell {
|
||||
--shell-pad: 16px;
|
||||
--shell-gap: 16px;
|
||||
--shell-nav-width: 220px;
|
||||
--shell-topbar-height: 56px;
|
||||
--shell-focus-duration: 220ms;
|
||||
--shell-focus-ease: cubic-bezier(0.2, 0.85, 0.25, 1);
|
||||
--shell-focus-duration: 200ms;
|
||||
--shell-focus-ease: var(--ease-out);
|
||||
height: 100vh;
|
||||
display: grid;
|
||||
grid-template-columns: var(--shell-nav-width) minmax(0, 1fr);
|
||||
@@ -13,7 +17,7 @@
|
||||
"topbar topbar"
|
||||
"nav content";
|
||||
gap: 0;
|
||||
animation: dashboard-enter 0.6s ease-out;
|
||||
animation: dashboard-enter 0.4s var(--ease-out);
|
||||
transition: grid-template-columns var(--shell-focus-duration) var(--shell-focus-ease);
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -61,6 +65,10 @@
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
/* ===========================================
|
||||
Topbar
|
||||
=========================================== */
|
||||
|
||||
.topbar {
|
||||
grid-area: topbar;
|
||||
position: sticky;
|
||||
@@ -73,8 +81,7 @@
|
||||
padding: 0 20px;
|
||||
height: var(--shell-topbar-height);
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--panel);
|
||||
backdrop-filter: blur(18px);
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.topbar-left {
|
||||
@@ -84,49 +91,83 @@
|
||||
}
|
||||
|
||||
.topbar .nav-collapse-toggle {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.topbar .nav-collapse-toggle__icon {
|
||||
font-size: 22px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.topbar .nav-collapse-toggle__icon svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
/* 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;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.brand-title {
|
||||
font-family: var(--font-display);
|
||||
font-size: 16px;
|
||||
letter-spacing: 1px;
|
||||
text-transform: uppercase;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.02em;
|
||||
line-height: 1.1;
|
||||
color: var(--text-strong);
|
||||
}
|
||||
|
||||
.brand-sub {
|
||||
font-size: 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--muted);
|
||||
letter-spacing: 0.8px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.02em;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* Topbar status */
|
||||
.topbar-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* Smaller pill and theme toggle in topbar */
|
||||
.topbar-status .pill {
|
||||
padding: 4px 10px;
|
||||
padding: 6px 10px;
|
||||
gap: 6px;
|
||||
font-size: 11px;
|
||||
font-size: 12px;
|
||||
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 {
|
||||
@@ -135,9 +176,9 @@
|
||||
}
|
||||
|
||||
.topbar-status .theme-toggle {
|
||||
--theme-item: 22px;
|
||||
--theme-gap: 4px;
|
||||
--theme-pad: 4px;
|
||||
--theme-item: 24px;
|
||||
--theme-gap: 2px;
|
||||
--theme-pad: 3px;
|
||||
}
|
||||
|
||||
.topbar-status .theme-icon {
|
||||
@@ -145,17 +186,22 @@
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
/* ===========================================
|
||||
Navigation Sidebar
|
||||
=========================================== */
|
||||
|
||||
.nav {
|
||||
grid-area: nav;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding: 16px;
|
||||
padding: 16px 12px;
|
||||
border-right: 1px solid var(--border);
|
||||
background: var(--panel);
|
||||
backdrop-filter: blur(18px);
|
||||
transition: width var(--shell-focus-duration) var(--shell-focus-ease),
|
||||
padding var(--shell-focus-duration) var(--shell-focus-ease);
|
||||
min-height: 0; /* Allow grid item to shrink and enable scrolling */
|
||||
background: var(--bg);
|
||||
transition:
|
||||
width 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;
|
||||
}
|
||||
|
||||
.shell--chat-focus .nav {
|
||||
@@ -164,9 +210,9 @@
|
||||
border-width: 0;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* Collapsed nav sidebar - completely hidden */
|
||||
.nav--collapsed {
|
||||
width: 0;
|
||||
min-width: 0;
|
||||
@@ -177,7 +223,7 @@
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Nav collapse toggle button */
|
||||
/* Nav collapse toggle */
|
||||
.nav-collapse-toggle {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
@@ -186,71 +232,88 @@
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 6px;
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
transition: background 150ms ease, border-color 150ms ease;
|
||||
transition:
|
||||
background var(--duration-fast) ease,
|
||||
border-color var(--duration-fast) ease;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.nav-collapse-toggle:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
background: var(--bg-hover);
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .nav-collapse-toggle:hover {
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.nav-collapse-toggle__icon {
|
||||
font-size: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
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 {
|
||||
margin-bottom: 18px;
|
||||
margin-bottom: 20px;
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px dashed rgba(255, 255, 255, 0.08);
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.nav-group:last-child {
|
||||
margin-bottom: 0;
|
||||
padding-bottom: 0;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.nav-group__items {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.nav-group--collapsed .nav-group__items {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Nav label */
|
||||
.nav-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 4px 0;
|
||||
padding: 6px 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1.4px;
|
||||
color: var(--text);
|
||||
opacity: 0.7;
|
||||
color: var(--muted);
|
||||
margin-bottom: 4px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
border-radius: var(--radius-sm);
|
||||
transition:
|
||||
color var(--duration-fast) ease,
|
||||
background var(--duration-fast) ease;
|
||||
}
|
||||
|
||||
.nav-label:hover {
|
||||
opacity: 1;
|
||||
color: var(--text);
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.nav-label--static {
|
||||
@@ -258,7 +321,8 @@
|
||||
}
|
||||
|
||||
.nav-label--static:hover {
|
||||
opacity: 0.7;
|
||||
color: var(--muted);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.nav-label__text {
|
||||
@@ -266,131 +330,152 @@
|
||||
}
|
||||
|
||||
.nav-label__chevron {
|
||||
font-size: 12px;
|
||||
opacity: 0.6;
|
||||
font-size: 10px;
|
||||
opacity: 0.5;
|
||||
transition: transform var(--duration-fast) ease;
|
||||
}
|
||||
|
||||
.nav-group--collapsed .nav-label__chevron {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
/* Nav items */
|
||||
.nav-item {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 8px;
|
||||
padding: 10px 12px 10px 14px;
|
||||
border-radius: 12px;
|
||||
gap: 10px;
|
||||
padding: 8px 10px;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
transition: border-color 160ms ease, background 160ms ease, color 160ms ease;
|
||||
transition:
|
||||
border-color var(--duration-fast) ease,
|
||||
background var(--duration-fast) ease,
|
||||
color var(--duration-fast) ease;
|
||||
}
|
||||
|
||||
.nav-item__icon {
|
||||
font-size: 16px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
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 {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.nav-item:hover {
|
||||
color: var(--text);
|
||||
border-color: rgba(255, 255, 255, 0.12);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
background: var(--bg-hover);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.nav-item::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 50%;
|
||||
width: 4px;
|
||||
height: 60%;
|
||||
border-radius: 0 999px 999px 0;
|
||||
transform: translateY(-50%);
|
||||
background: transparent;
|
||||
.nav-item:hover .nav-item__icon {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.nav-item.active {
|
||||
color: var(--text);
|
||||
border-color: rgba(245, 159, 74, 0.45);
|
||||
background: rgba(245, 159, 74, 0.12);
|
||||
color: var(--text-strong);
|
||||
background: var(--accent-subtle);
|
||||
}
|
||||
|
||||
.nav-item.active::before {
|
||||
background: var(--accent);
|
||||
box-shadow: 0 0 12px rgba(245, 159, 74, 0.4);
|
||||
.nav-item.active .nav-item__icon {
|
||||
opacity: 1;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* ===========================================
|
||||
Content Area
|
||||
=========================================== */
|
||||
|
||||
.content {
|
||||
grid-area: content;
|
||||
padding: 8px 6px 20px;
|
||||
padding: 8px 8px 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
min-height: 0;
|
||||
overflow-y: auto; /* Enable vertical scrolling for pages with long content */
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* Chat handles its own scrolling (chat-thread); avoid double scrollbars. */
|
||||
:root[data-theme="light"] .content {
|
||||
background: var(--bg-content);
|
||||
}
|
||||
|
||||
.content--chat {
|
||||
overflow: hidden;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.shell--chat .content {
|
||||
/* No-op: keep chat layout consistent with other tabs */
|
||||
}
|
||||
|
||||
/* Content header */
|
||||
.content-header {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 0 6px;
|
||||
gap: 16px;
|
||||
padding: 4px 8px;
|
||||
overflow: hidden;
|
||||
transform-origin: top center;
|
||||
transition: opacity var(--shell-focus-duration) var(--shell-focus-ease),
|
||||
transition:
|
||||
opacity 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),
|
||||
padding var(--shell-focus-duration) var(--shell-focus-ease);
|
||||
max-height: 90px;
|
||||
max-height: 80px;
|
||||
}
|
||||
|
||||
.shell--chat-focus .content-header {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
transform: translateY(-8px);
|
||||
max-height: 0px;
|
||||
padding: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-family: var(--font-display);
|
||||
font-size: 26px;
|
||||
letter-spacing: 0.6px;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.02em;
|
||||
line-height: 1.2;
|
||||
color: var(--text-strong);
|
||||
}
|
||||
|
||||
.page-sub {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.4px;
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.page-meta {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* Chat view: header and controls side by side */
|
||||
/* Chat view header adjustments */
|
||||
.content--chat .content-header {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
@@ -410,9 +495,13 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ===========================================
|
||||
Grid Utilities
|
||||
=========================================== */
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.grid-cols-2 {
|
||||
@@ -425,39 +514,42 @@
|
||||
|
||||
.stat-grid {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
gap: 12px;
|
||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
}
|
||||
|
||||
.note-grid {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
gap: 12px;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.stack {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* ===========================================
|
||||
Responsive - Tablet
|
||||
=========================================== */
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.shell {
|
||||
--shell-pad: 12px;
|
||||
--shell-gap: 12px;
|
||||
--shell-nav-col: 1fr;
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: auto auto 1fr;
|
||||
grid-template-areas:
|
||||
@@ -470,17 +562,18 @@
|
||||
position: static;
|
||||
max-height: none;
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
gap: 6px;
|
||||
overflow-x: auto;
|
||||
border-right: none;
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 10px 14px;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.nav-group {
|
||||
grid-auto-flow: column;
|
||||
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
|
||||
border-bottom: none;
|
||||
padding-bottom: 0;
|
||||
grid-template-columns: repeat(auto-fit, minmax(100px, 1fr));
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.grid-cols-2,
|
||||
@@ -490,13 +583,11 @@
|
||||
|
||||
.topbar {
|
||||
position: static;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 12px 14px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.topbar-status {
|
||||
width: 100%;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
/* Tablet/Mobile nav fix (under 1100px) - single horizontal scroll row */
|
||||
/* ===========================================
|
||||
Mobile Layout
|
||||
=========================================== */
|
||||
|
||||
/* Tablet: Horizontal nav */
|
||||
@media (max-width: 1100px) {
|
||||
/* Flatten nav into single horizontal scroll */
|
||||
.nav {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: nowrap;
|
||||
gap: 6px;
|
||||
padding: 10px 12px;
|
||||
gap: 4px;
|
||||
padding: 10px 14px;
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scrollbar-width: none;
|
||||
@@ -16,21 +19,18 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Nav groups should flow inline, not stack */
|
||||
.nav-group {
|
||||
display: contents; /* Flatten group wrapper - items flow directly into .nav */
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.nav-group__items {
|
||||
display: contents; /* Flatten items wrapper too */
|
||||
display: contents;
|
||||
}
|
||||
|
||||
/* Hide group labels on tablet/mobile */
|
||||
.nav-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Don't hide nav items even if group is "collapsed" */
|
||||
.nav-group--collapsed .nav-group__items {
|
||||
display: contents;
|
||||
}
|
||||
@@ -38,27 +38,22 @@
|
||||
.nav-item {
|
||||
padding: 8px 14px;
|
||||
font-size: 13px;
|
||||
border-radius: 10px;
|
||||
border-radius: var(--radius-md);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.nav-item::before {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile-specific improvements */
|
||||
/* Mobile-specific styles */
|
||||
@media (max-width: 600px) {
|
||||
.shell {
|
||||
--shell-pad: 8px;
|
||||
--shell-gap: 8px;
|
||||
}
|
||||
|
||||
/* Compact topbar for mobile */
|
||||
/* Topbar */
|
||||
.topbar {
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
gap: 8px;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
@@ -72,8 +67,7 @@
|
||||
}
|
||||
|
||||
.brand-title {
|
||||
font-size: 15px;
|
||||
letter-spacing: 0.3px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.brand-sub {
|
||||
@@ -100,11 +94,10 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Horizontal scrollable nav for mobile */
|
||||
/* Nav */
|
||||
.nav {
|
||||
padding: 8px;
|
||||
border-radius: 12px;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
gap: 4px;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
@@ -122,18 +115,14 @@
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
padding: 7px 10px;
|
||||
padding: 6px 10px;
|
||||
font-size: 12px;
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius-md);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.nav-item::before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Hide page title on mobile - nav already shows where you are */
|
||||
/* Content */
|
||||
.content-header {
|
||||
display: none;
|
||||
}
|
||||
@@ -143,17 +132,17 @@
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* Smaller cards on mobile */
|
||||
/* Cards */
|
||||
.card {
|
||||
padding: 12px;
|
||||
border-radius: 12px;
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 14px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* Stat grid adjustments */
|
||||
/* Stats */
|
||||
.stat-grid {
|
||||
gap: 8px;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
@@ -161,24 +150,24 @@
|
||||
|
||||
.stat {
|
||||
padding: 10px;
|
||||
border-radius: 10px;
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 10px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 16px;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
/* Notes grid */
|
||||
/* Notes */
|
||||
.note-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 10px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* Form fields */
|
||||
/* Forms */
|
||||
.form-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 10px;
|
||||
@@ -188,14 +177,14 @@
|
||||
.field textarea,
|
||||
.field select {
|
||||
padding: 8px 10px;
|
||||
border-radius: 10px;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
padding: 8px 12px;
|
||||
font-size: 13px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* Pills */
|
||||
@@ -204,7 +193,7 @@
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* Chat-specific mobile improvements */
|
||||
/* Chat */
|
||||
.chat-header {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
@@ -227,17 +216,16 @@
|
||||
|
||||
.chat-thread {
|
||||
margin-top: 8px;
|
||||
padding: 10px 8px;
|
||||
border-radius: 12px;
|
||||
padding: 12px 8px;
|
||||
}
|
||||
|
||||
.chat-msg {
|
||||
max-width: 92%;
|
||||
max-width: 90%;
|
||||
}
|
||||
|
||||
.chat-bubble {
|
||||
padding: 8px 10px;
|
||||
border-radius: 12px;
|
||||
padding: 8px 12px;
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.chat-compose {
|
||||
@@ -247,14 +235,14 @@
|
||||
.chat-compose__field textarea {
|
||||
min-height: 60px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 12px;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* Log stream mobile */
|
||||
/* Log stream */
|
||||
.log-stream {
|
||||
border-radius: 10px;
|
||||
max-height: 400px;
|
||||
border-radius: var(--radius-md);
|
||||
max-height: 380px;
|
||||
}
|
||||
|
||||
.log-row {
|
||||
@@ -279,14 +267,14 @@
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* List items */
|
||||
/* Lists */
|
||||
.list-item {
|
||||
padding: 10px;
|
||||
border-radius: 10px;
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.list-title {
|
||||
font-size: 14px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.list-sub {
|
||||
@@ -296,19 +284,91 @@
|
||||
/* Code blocks */
|
||||
.code-block {
|
||||
padding: 8px;
|
||||
border-radius: 10px;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* Theme toggle smaller */
|
||||
/* Theme toggle */
|
||||
.theme-toggle {
|
||||
--theme-item: 24px;
|
||||
--theme-gap: 4px;
|
||||
--theme-pad: 4px;
|
||||
--theme-gap: 2px;
|
||||
--theme-pad: 3px;
|
||||
}
|
||||
|
||||
.theme-icon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,4 +3,3 @@ export type EventLogEntry = {
|
||||
event: string;
|
||||
payload?: unknown;
|
||||
};
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { repeat } from "lit/directives/repeat.js";
|
||||
|
||||
import type { AppViewState } from "./app-view-state";
|
||||
import { iconForTab, pathForTab, titleForTab, type Tab } from "./navigation";
|
||||
import { icons } from "./icons";
|
||||
import { loadChatHistory } from "./controllers/chat";
|
||||
import { syncUrlWithSessionKey } from "./app-settings";
|
||||
import type { SessionsListResult } from "./types";
|
||||
@@ -31,7 +32,7 @@ export function renderTab(state: AppViewState, tab: Tab) {
|
||||
}}
|
||||
title=${titleForTab(tab)}
|
||||
>
|
||||
<span class="nav-item__icon" aria-hidden="true">${iconForTab(tab)}</span>
|
||||
<span class="nav-item__icon" aria-hidden="true">${icons[iconForTab(tab)]}</span>
|
||||
<span class="nav-item__text">${titleForTab(tab)}</span>
|
||||
</a>
|
||||
`;
|
||||
@@ -108,7 +109,7 @@ export function renderChatControls(state: AppViewState) {
|
||||
? "Disabled during onboarding"
|
||||
: "Toggle assistant thinking/working output"}
|
||||
>
|
||||
🧠
|
||||
${icons.brain}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn--sm btn--icon ${focusActive ? "active" : ""}"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user