diff --git a/apps/macos/Sources/Clawdis/Resources/WebChat/ChatPanel.js b/apps/macos/Sources/Clawdis/Resources/WebChat/ChatPanel.js index 2aec93b127..b2f59fa845 100644 --- a/apps/macos/Sources/Clawdis/Resources/WebChat/ChatPanel.js +++ b/apps/macos/Sources/Clawdis/Resources/WebChat/ChatPanel.js @@ -52,6 +52,7 @@ let ChatPanel = class ChatPanel extends LitElement { // Create AgentInterface this.agentInterface = document.createElement("agent-interface"); this.agentInterface.session = agent; + this.agentInterface.sessionThinkingLevel = config?.sessionThinkingLevel ?? agent?.state?.thinkingLevel ?? "off"; this.agentInterface.enableAttachments = true; // Hide model selector in the embedded chat; use fixed model configured at bootstrap. this.agentInterface.enableModelSelector = false; diff --git a/apps/macos/Sources/Clawdis/Resources/WebChat/agent/agent.js b/apps/macos/Sources/Clawdis/Resources/WebChat/agent/agent.js index 306816e9ac..ab30f3cafa 100644 --- a/apps/macos/Sources/Clawdis/Resources/WebChat/agent/agent.js +++ b/apps/macos/Sources/Clawdis/Resources/WebChat/agent/agent.js @@ -80,7 +80,7 @@ export class Agent { const { systemPrompt, model, messages } = this._state; console.log(message, { systemPrompt, model, messages }); } - async prompt(input, attachments) { + async prompt(input, attachments, opts) { const model = this._state.model; if (!model) { this.emit({ type: "error-no-model" }); @@ -111,16 +111,20 @@ export class Agent { this.abortController = new AbortController(); this.patch({ isStreaming: true, streamMessage: null, error: undefined }); this.emit({ type: "started" }); - const reasoning = this._state.thinkingLevel === "off" + const thinkingLevel = (opts?.thinkingOverride ?? this._state.thinkingLevel) ?? "off"; + const reasoning = thinkingLevel === "off" ? undefined - : this._state.thinkingLevel === "minimal" + : thinkingLevel === "minimal" ? "low" - : this._state.thinkingLevel; + : thinkingLevel; + const shouldSendOverride = opts?.transient === true; const cfg = { systemPrompt: this._state.systemPrompt, tools: this._state.tools, model, reasoning, + thinkingOverride: shouldSendOverride ? thinkingLevel : undefined, + thinkingOnce: shouldSendOverride ? thinkingLevel : undefined, getQueuedMessages: async () => { // Return queued messages (they'll be added to state via message_end event) const queued = this.messageQueue.slice(); @@ -269,4 +273,4 @@ export class Agent { } } } -//# sourceMappingURL=agent.js.map \ No newline at end of file +//# sourceMappingURL=agent.js.map diff --git a/apps/macos/Sources/Clawdis/Resources/WebChat/bootstrap.js b/apps/macos/Sources/Clawdis/Resources/WebChat/bootstrap.js index bf4369f0ea..c04b2d4ea9 100644 --- a/apps/macos/Sources/Clawdis/Resources/WebChat/bootstrap.js +++ b/apps/macos/Sources/Clawdis/Resources/WebChat/bootstrap.js @@ -31,6 +31,7 @@ async function fetchBootstrap() { sessionKey, basePath: info.basePath || "/webchat/", initialMessages: Array.isArray(info.initialMessages) ? info.initialMessages : [], + thinkingLevel: typeof info.thinkingLevel === "string" ? info.thinkingLevel : "off", }; } @@ -50,14 +51,20 @@ class NativeTransport { : btoa(String.fromCharCode(...new Uint8Array(a.content))), })); const rpcUrl = new URL("./rpc", window.location.href); + const rpcBody = { + text: userMessage.content?.[0]?.text ?? "", + session: this.sessionKey, + attachments, + }; + if (cfg?.thinkingOnce) { + rpcBody.thinkingOnce = cfg.thinkingOnce; + } else if (cfg?.thinkingOverride) { + rpcBody.thinking = cfg.thinkingOverride; + } const resultResp = await fetch(rpcUrl, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - text: userMessage.content?.[0]?.text ?? "", - session: this.sessionKey, - attachments, - }), + body: JSON.stringify(rpcBody), signal, }); @@ -98,7 +105,7 @@ class NativeTransport { const startChat = async () => { logStatus("boot: fetching session info"); - const { initialMessages, sessionKey } = await fetchBootstrap(); + const { initialMessages, sessionKey, thinkingLevel } = await fetchBootstrap(); logStatus("boot: starting imports"); const { Agent } = await import("./agent/agent.js"); @@ -154,7 +161,7 @@ const startChat = async () => { initialState: { systemPrompt: "You are Clawd (primary session).", model: getModel("anthropic", "claude-opus-4-5"), - thinkingLevel: "off", + thinkingLevel, messages: initialMessages, }, transport: new NativeTransport(sessionKey), @@ -175,7 +182,7 @@ const startChat = async () => { const panel = new ChatPanel(); panel.style.height = "100%"; panel.style.display = "block"; - await panel.setAgent(agent); + await panel.setAgent(agent, { sessionThinkingLevel: thinkingLevel }); const mount = document.getElementById("app"); if (!mount) throw new Error("#app container missing"); diff --git a/apps/macos/Sources/Clawdis/Resources/WebChat/components/AgentInterface.js b/apps/macos/Sources/Clawdis/Resources/WebChat/components/AgentInterface.js index 2b32b1026a..94602fcd64 100644 --- a/apps/macos/Sources/Clawdis/Resources/WebChat/components/AgentInterface.js +++ b/apps/macos/Sources/Clawdis/Resources/WebChat/components/AgentInterface.js @@ -5,7 +5,7 @@ var __decorate = (this && this.__decorate) || function (decorators, target, key, return c > 3 && r && Object.defineProperty(target, key, r), r; }; import { html, LitElement } from "lit"; -import { customElement, property, query } from "lit/decorators.js"; +import { customElement, property, query, state } from "lit/decorators.js"; import { ModelSelector } from "../dialogs/ModelSelector.js"; import "./MessageEditor.js"; import "./MessageList.js"; @@ -21,6 +21,8 @@ let AgentInterface = class AgentInterface extends LitElement { this.enableModelSelector = true; this.enableThinkingSelector = true; this.showThemeToggle = false; + this.sessionThinkingLevel = "off"; + this.pendingThinkingLevel = null; this._autoScroll = true; this._lastScrollTop = 0; this._lastClientHeight = 0; @@ -121,13 +123,16 @@ let AgentInterface = class AgentInterface extends LitElement { } if (!this.session) return; - this._unsubscribeSession = this.session.subscribe(async (ev) => { - if (ev.type === "state-update") { - if (this._streamingContainer) { - this._streamingContainer.isStreaming = ev.state.isStreaming; - this._streamingContainer.setMessage(ev.state.streamMessage, !ev.state.isStreaming); - } - this.requestUpdate(); + this._unsubscribeSession = this.session.subscribe(async (ev) => { + if (ev.type === "state-update") { + if (this.pendingThinkingLevel === null && ev.state.thinkingLevel) { + this.sessionThinkingLevel = ev.state.thinkingLevel; + } + if (this._streamingContainer) { + this._streamingContainer.isStreaming = ev.state.isStreaming; + this._streamingContainer.setMessage(ev.state.streamMessage, !ev.state.isStreaming); + } + this.requestUpdate(); } else if (ev.type === "error-no-model") { // TODO show some UI feedback @@ -164,11 +169,25 @@ let AgentInterface = class AgentInterface extends LitElement { if (this.onBeforeSend) { await this.onBeforeSend(); } + const baseThinking = + this.sessionThinkingLevel || session.state.thinkingLevel || "off"; + const thinkingOverride = this.pendingThinkingLevel ?? baseThinking; + const transient = + this.pendingThinkingLevel !== null && + this.pendingThinkingLevel !== baseThinking; // Only clear editor after we know we can send this._messageEditor.value = ""; this._messageEditor.attachments = []; this._autoScroll = true; // Enable auto-scroll when sending a message - await this.session?.prompt(input, attachments); + await this.session?.prompt(input, attachments, { + thinkingOverride, + transient, + }); + this.pendingThinkingLevel = null; + // Reset editor thinking selector to session baseline + if (this._messageEditor) { + this._messageEditor.thinkingLevel = this.sessionThinkingLevel || "off"; + } } renderMessages() { if (!this.session) @@ -261,12 +280,12 @@ let AgentInterface = class AgentInterface extends LitElement {
{ + .currentModel=${state.model} + .thinkingLevel=${this.pendingThinkingLevel ?? this.sessionThinkingLevel ?? state.thinkingLevel} + .showAttachmentButton=${this.enableAttachments} + .showModelSelector=${this.enableModelSelector} + .showThinkingSelector=${this.enableThinkingSelector} + .onSend=${(input, attachments) => { this.sendMessage(input, attachments); }} .onAbort=${() => session.abort()} @@ -275,7 +294,11 @@ let AgentInterface = class AgentInterface extends LitElement { }} .onThinkingChange=${this.enableThinkingSelector ? (level) => { - session.setThinkingLevel(level); + this.pendingThinkingLevel = level; + if (this._messageEditor) { + this._messageEditor.thinkingLevel = level; + } + this.requestUpdate(); } : undefined} > @@ -298,6 +321,9 @@ __decorate([ __decorate([ property({ type: Boolean }) ], AgentInterface.prototype, "enableThinkingSelector", void 0); +__decorate([ + property({ type: String }) +], AgentInterface.prototype, "sessionThinkingLevel", void 0); __decorate([ property({ type: Boolean }) ], AgentInterface.prototype, "showThemeToggle", void 0); @@ -319,6 +345,9 @@ __decorate([ __decorate([ query("streaming-message-container") ], AgentInterface.prototype, "_streamingContainer", void 0); +__decorate([ + state() +], AgentInterface.prototype, "pendingThinkingLevel", void 0); AgentInterface = __decorate([ customElement("agent-interface") ], AgentInterface); @@ -327,4 +356,4 @@ export { AgentInterface }; if (!customElements.get("agent-interface")) { customElements.define("agent-interface", AgentInterface); } -//# sourceMappingURL=AgentInterface.js.map \ No newline at end of file +//# sourceMappingURL=AgentInterface.js.map diff --git a/apps/macos/Sources/Clawdis/Resources/WebChat/webchat.bundle.js b/apps/macos/Sources/Clawdis/Resources/WebChat/webchat.bundle.js index cbb24e2793..8a04360652 100644 --- a/apps/macos/Sources/Clawdis/Resources/WebChat/webchat.bundle.js +++ b/apps/macos/Sources/Clawdis/Resources/WebChat/webchat.bundle.js @@ -196,7 +196,7 @@ var init_agent = __esmMin((() => { messages }); } - async prompt(input, attachments) { + async prompt(input, attachments, opts) { const model = this._state.model; if (!model) { this.emit({ type: "error-no-model" }); @@ -236,12 +236,16 @@ var init_agent = __esmMin((() => { error: undefined }); this.emit({ type: "started" }); - const reasoning = this._state.thinkingLevel === "off" ? undefined : this._state.thinkingLevel === "minimal" ? "low" : this._state.thinkingLevel; + const thinkingLevel = opts?.thinkingOverride ?? this._state.thinkingLevel ?? "off"; + const reasoning = thinkingLevel === "off" ? undefined : thinkingLevel === "minimal" ? "low" : thinkingLevel; + const shouldSendOverride = opts?.transient === true; const cfg = { systemPrompt: this._state.systemPrompt, tools: this._state.tools, model, reasoning, + thinkingOverride: shouldSendOverride ? thinkingLevel : undefined, + thinkingOnce: shouldSendOverride ? thinkingLevel : undefined, getQueuedMessages: async () => { const queued = this.messageQueue.slice(); this.messageQueue = []; @@ -107901,6 +107905,8 @@ var init_AgentInterface = __esmMin((() => { this.enableModelSelector = true; this.enableThinkingSelector = true; this.showThemeToggle = false; + this.sessionThinkingLevel = "off"; + this.pendingThinkingLevel = null; this._autoScroll = true; this._lastScrollTop = 0; this._lastClientHeight = 0; @@ -107989,6 +107995,9 @@ var init_AgentInterface = __esmMin((() => { if (!this.session) return; this._unsubscribeSession = this.session.subscribe(async (ev) => { if (ev.type === "state-update") { + if (this.pendingThinkingLevel === null && ev.state.thinkingLevel) { + this.sessionThinkingLevel = ev.state.thinkingLevel; + } if (this._streamingContainer) { this._streamingContainer.isStreaming = ev.state.isStreaming; this._streamingContainer.setMessage(ev.state.streamMessage, !ev.state.isStreaming); @@ -108017,10 +108026,20 @@ var init_AgentInterface = __esmMin((() => { if (this.onBeforeSend) { await this.onBeforeSend(); } + const baseThinking = this.sessionThinkingLevel || session.state.thinkingLevel || "off"; + const thinkingOverride = this.pendingThinkingLevel ?? baseThinking; + const transient = this.pendingThinkingLevel !== null && this.pendingThinkingLevel !== baseThinking; this._messageEditor.value = ""; this._messageEditor.attachments = []; this._autoScroll = true; - await this.session?.prompt(input, attachments); + await this.session?.prompt(input, attachments, { + thinkingOverride, + transient + }); + this.pendingThinkingLevel = null; + if (this._messageEditor) { + this._messageEditor.thinkingLevel = this.sessionThinkingLevel || "off"; + } } renderMessages() { if (!this.session) return x`
${i18n("No session available")}
`; @@ -108109,12 +108128,12 @@ var init_AgentInterface = __esmMin((() => {
{ + .currentModel=${state$1.model} + .thinkingLevel=${this.pendingThinkingLevel ?? this.sessionThinkingLevel ?? state$1.thinkingLevel} + .showAttachmentButton=${this.enableAttachments} + .showModelSelector=${this.enableModelSelector} + .showThinkingSelector=${this.enableThinkingSelector} + .onSend=${(input, attachments) => { this.sendMessage(input, attachments); }} .onAbort=${() => session.abort()} @@ -108122,7 +108141,11 @@ var init_AgentInterface = __esmMin((() => { ModelSelector.open(state$1.model, (model) => session.setModel(model)); }} .onThinkingChange=${this.enableThinkingSelector ? (level) => { - session.setThinkingLevel(level); + this.pendingThinkingLevel = level; + if (this._messageEditor) { + this._messageEditor.thinkingLevel = level; + } + this.requestUpdate(); } : undefined} > ${this.renderStats()} @@ -108136,6 +108159,7 @@ var init_AgentInterface = __esmMin((() => { __decorate$17([n$1({ type: Boolean })], AgentInterface.prototype, "enableAttachments", void 0); __decorate$17([n$1({ type: Boolean })], AgentInterface.prototype, "enableModelSelector", void 0); __decorate$17([n$1({ type: Boolean })], AgentInterface.prototype, "enableThinkingSelector", void 0); + __decorate$17([n$1({ type: String })], AgentInterface.prototype, "sessionThinkingLevel", void 0); __decorate$17([n$1({ type: Boolean })], AgentInterface.prototype, "showThemeToggle", void 0); __decorate$17([n$1({ attribute: false })], AgentInterface.prototype, "onApiKeyRequired", void 0); __decorate$17([n$1({ attribute: false })], AgentInterface.prototype, "onBeforeSend", void 0); @@ -108143,6 +108167,7 @@ var init_AgentInterface = __esmMin((() => { __decorate$17([n$1({ attribute: false })], AgentInterface.prototype, "onCostClick", void 0); __decorate$17([e$1("message-editor")], AgentInterface.prototype, "_messageEditor", void 0); __decorate$17([e$1("streaming-message-container")], AgentInterface.prototype, "_streamingContainer", void 0); + __decorate$17([r()], AgentInterface.prototype, "pendingThinkingLevel", void 0); AgentInterface = __decorate$17([t("agent-interface")], AgentInterface); if (!customElements.get("agent-interface")) { customElements.define("agent-interface", AgentInterface); @@ -195731,6 +195756,7 @@ var init_ChatPanel = __esmMin((() => { this.agent = agent; this.agentInterface = document.createElement("agent-interface"); this.agentInterface.session = agent; + this.agentInterface.sessionThinkingLevel = config?.sessionThinkingLevel ?? agent?.state?.thinkingLevel ?? "off"; this.agentInterface.enableAttachments = true; this.agentInterface.enableModelSelector = false; this.agentInterface.enableThinkingSelector = true; @@ -196264,7 +196290,8 @@ async function fetchBootstrap() { return { sessionKey, basePath: info$1.basePath || "/webchat/", - initialMessages: Array.isArray(info$1.initialMessages) ? info$1.initialMessages : [] + initialMessages: Array.isArray(info$1.initialMessages) ? info$1.initialMessages : [], + thinkingLevel: typeof info$1.thinkingLevel === "string" ? info$1.thinkingLevel : "off" }; } var NativeTransport = class { @@ -196279,14 +196306,20 @@ var NativeTransport = class { content: typeof a$2.content === "string" ? a$2.content : btoa(String.fromCharCode(...new Uint8Array(a$2.content))) })); const rpcUrl = new URL("./rpc", window.location.href); + const rpcBody = { + text: userMessage.content?.[0]?.text ?? "", + session: this.sessionKey, + attachments + }; + if (cfg?.thinkingOnce) { + rpcBody.thinkingOnce = cfg.thinkingOnce; + } else if (cfg?.thinkingOverride) { + rpcBody.thinking = cfg.thinkingOverride; + } const resultResp = await fetch(rpcUrl, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - text: userMessage.content?.[0]?.text ?? "", - session: this.sessionKey, - attachments - }), + body: JSON.stringify(rpcBody), signal }); if (!resultResp.ok) { @@ -196339,7 +196372,7 @@ var NativeTransport = class { }; const startChat = async () => { logStatus("boot: fetching session info"); - const { initialMessages, sessionKey } = await fetchBootstrap(); + const { initialMessages, sessionKey, thinkingLevel } = await fetchBootstrap(); logStatus("boot: starting imports"); const { Agent: Agent$1 } = await Promise.resolve().then(() => (init_agent(), agent_exports)); const { ChatPanel: ChatPanel$1 } = await Promise.resolve().then(() => (init_ChatPanel(), ChatPanel_exports)); @@ -196386,7 +196419,7 @@ const startChat = async () => { initialState: { systemPrompt: "You are Clawd (primary session).", model: getModel$1("anthropic", "claude-opus-4-5"), - thinkingLevel: "off", + thinkingLevel, messages: initialMessages }, transport: new NativeTransport(sessionKey) @@ -196408,7 +196441,7 @@ const startChat = async () => { const panel = new ChatPanel$1(); panel.style.height = "100%"; panel.style.display = "block"; - await panel.setAgent(agent); + await panel.setAgent(agent, { sessionThinkingLevel: thinkingLevel }); const mount = document.getElementById("app"); if (!mount) throw new Error("#app container missing"); mount.dataset.booted = "1"; diff --git a/docs/thinking.md b/docs/thinking.md index 384e2a16b9..d2efd8f6e3 100644 --- a/docs/thinking.md +++ b/docs/thinking.md @@ -31,3 +31,8 @@ ## Heartbeats - Heartbeat probe body is `HEARTBEAT /think:high`, so it always asks for max thinking on the probe. Inline directive wins; session/global defaults are used only when no directive is present. + +## Web chat UI +- The web chat thinking selector mirrors the session's stored level from the inbound session store/config when the page loads. +- Picking another level applies only to the next message (`thinkingOnce`); after sending, the selector snaps back to the stored session level. +- To change the session default, send a `/think:` directive (as before); the selector will reflect it after the next reload. diff --git a/src/commands/agent.ts b/src/commands/agent.ts index 4e28416443..21e65600cb 100644 --- a/src/commands/agent.ts +++ b/src/commands/agent.ts @@ -172,11 +172,17 @@ export async function agentCommand( .filter((val) => val.length > 1); const thinkOverride = normalizeThinkLevel(opts.thinking); + const thinkOnce = normalizeThinkLevel(opts.thinkingOnce); if (opts.thinking && !thinkOverride) { throw new Error( "Invalid thinking level. Use one of: off, minimal, low, medium, high.", ); } + if (opts.thinkingOnce && !thinkOnce) { + throw new Error( + "Invalid one-shot thinking level. Use one of: off, minimal, low, medium, high.", + ); + } const verboseOverride = normalizeVerboseLevel(opts.verbose); if (opts.verbose && !verboseOverride) { throw new Error('Invalid verbose level. Use "on" or "off".'); @@ -213,8 +219,9 @@ export async function agentCommand( const sendSystemOnce = sessionCfg?.sendSystemOnce === true; const isFirstTurnInSession = isNewSession || !systemSent; - // Merge thinking/verbose levels: flag override > persisted > defaults. + // Merge thinking/verbose levels: one-shot override > flag override > persisted > defaults. const resolvedThinkLevel: ThinkLevel | undefined = + thinkOnce ?? thinkOverride ?? persistedThinking ?? (replyCfg.thinkingDefault as ThinkLevel | undefined); diff --git a/src/webchat/server.ts b/src/webchat/server.ts index 67bcff9f99..9032667101 100644 --- a/src/webchat/server.ts +++ b/src/webchat/server.ts @@ -1,19 +1,20 @@ +import http from "node:http"; +import path from "node:path"; +import os from "node:os"; +import { fileURLToPath } from "node:url"; import crypto from "node:crypto"; import fs from "node:fs"; -import http from "node:http"; -import os from "node:os"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; import sharp from "sharp"; -import { agentCommand } from "../commands/agent.js"; import { loadConfig } from "../config/config.js"; import { loadSessionStore, resolveStorePath, type SessionEntry, } from "../config/sessions.js"; +import { danger, info } from "../globals.js"; import { logDebug } from "../logger.js"; +import { agentCommand } from "../commands/agent.js"; import type { RuntimeEnv } from "../runtime.js"; const WEBCHAT_DEFAULT_PORT = 18788; @@ -23,14 +24,6 @@ type WebChatServerState = { port: number; }; -type ChatMessage = { role: string; content: string }; -type AttachmentInput = { - content?: string; - mimeType?: string; - fileName?: string; - type?: string; -}; - let state: WebChatServerState | null = null; function resolveWebRoot() { @@ -39,17 +32,11 @@ function resolveWebRoot() { // 1) Packaged app: resources live next to the relay bundle at // Contents/Resources/WebChat. The relay binary runs from // Contents/Resources/Relay/bun, so walk up one and check. - const packagedRoot = path.resolve( - path.dirname(process.execPath), - "../WebChat", - ); + const packagedRoot = path.resolve(path.dirname(process.execPath), "../WebChat"); if (fs.existsSync(packagedRoot)) return packagedRoot; // 2) Dev / source checkout: repo-relative path. - return path.resolve( - here, - "../../apps/macos/Sources/Clawdis/Resources/WebChat", - ); + return path.resolve(here, "../../apps/macos/Sources/Clawdis/Resources/WebChat"); } function readBody(req: http.IncomingMessage): Promise { @@ -62,28 +49,15 @@ function readBody(req: http.IncomingMessage): Promise { }); } -function pickSessionId( - sessionKey: string, - store: Record, -): string | null { +function pickSessionId(sessionKey: string, store: Record): string | null { if (store[sessionKey]?.sessionId) return store[sessionKey].sessionId; const first = Object.values(store)[0]?.sessionId; return first ?? null; } -function readSessionMessages( - sessionId: string, - storePath: string, -): ChatMessage[] { +function readSessionMessages(sessionId: string, storePath: string): any[] { const dir = path.dirname(storePath); - const candidates = [ - path.join(dir, `${sessionId}.jsonl`), - path.join( - os.homedir(), - ".tau/agent/sessions/clawdis", - `${sessionId}.jsonl`, - ), - ]; + const candidates = [path.join(dir, `${sessionId}.jsonl`), path.join(os.homedir(), ".tau/agent/sessions/clawdis", `${sessionId}.jsonl`)]; let content: string | null = null; for (const p of candidates) { if (fs.existsSync(p)) { @@ -97,7 +71,7 @@ function readSessionMessages( } if (!content) return []; - const messages: ChatMessage[] = []; + const messages: any[] = []; for (const line of content.split(/\r?\n/)) { if (!line.trim()) continue; try { @@ -113,32 +87,22 @@ function readSessionMessages( } async function persistAttachments( - attachments: AttachmentInput[], + attachments: any[], sessionId: string, ): Promise<{ placeholder: string; path: string }[]> { const out: { placeholder: string; path: string }[] = []; if (!attachments?.length) return out; - const root = path.join( - os.homedir(), - ".clawdis", - "webchat-uploads", - sessionId, - ); + const root = path.join(os.homedir(), ".clawdis", "webchat-uploads", sessionId); await fs.promises.mkdir(root, { recursive: true }); let idx = 1; for (const att of attachments) { try { if (!att?.content || typeof att.content !== "string") continue; - const mime = - typeof att.mimeType === "string" - ? att.mimeType - : "application/octet-stream"; + const mime = typeof att.mimeType === "string" ? att.mimeType : "application/octet-stream"; const baseName = att.fileName || `${att.type || "attachment"}-${idx}`; - const ext = mime.startsWith("image/") - ? mime.split("/")[1] || "bin" - : "bin"; + const ext = mime.startsWith("image/") ? mime.split("/")[1] || "bin" : "bin"; const fileName = `${baseName}.${ext}`.replace(/[^a-zA-Z0-9._-]/g, "_"); const buf = Buffer.from(att.content, "base64"); @@ -149,8 +113,7 @@ async function persistAttachments( const image = sharp(buf, { failOn: "none" }); meta = await image.metadata(); const needsResize = - (meta.width && meta.width > 2000) || - (meta.height && meta.height > 2000); + (meta.width && meta.width > 2000) || (meta.height && meta.height > 2000); if (needsResize) { const resized = await image .resize({ width: 2000, height: 2000, fit: "inside" }) @@ -174,8 +137,7 @@ async function persistAttachments( await fs.promises.writeFile(dest, finalBuf); const sizeLabel = `${(finalBuf.length / 1024).toFixed(0)} KB`; - const dimLabel = - meta?.width && meta?.height ? `, ${meta.width}x${meta.height}` : ""; + const dimLabel = meta?.width && meta?.height ? `, ${meta.width}x${meta.height}` : ""; const placeholder = `[Attachment saved: ${dest} (${mime}${dimLabel}, ${sizeLabel})]`; out.push({ placeholder, path: dest }); } catch (err) { @@ -187,34 +149,16 @@ async function persistAttachments( return out; } -function formatMessageWithAttachments( - text: string, - saved: { placeholder: string }[], -): string { +function formatMessageWithAttachments(text: string, saved: { placeholder: string }[]): string { if (!saved || saved.length === 0) return text; const parts = [text, ...saved.map((s) => `\n\n${s.placeholder}`)]; return parts.join(""); } -type RpcPayload = { role: string; content: string }; - -async function handleRpc( - body: unknown, - sessionKey: string, -): Promise<{ ok: boolean; payloads?: RpcPayload[]; error?: string }> { - const payload = body as { - text?: unknown; - attachments?: unknown; - thinking?: unknown; - deliver?: unknown; - to?: unknown; - }; - - const text: string = (payload.text ?? "").toString(); +async function handleRpc(body: any, sessionKey: string): Promise<{ ok: boolean; payloads?: any[]; error?: string }> { + const text: string = (body?.text ?? "").toString(); if (!text.trim()) return { ok: false, error: "empty text" }; - const attachments = Array.isArray(payload.attachments) - ? (payload.attachments as AttachmentInput[]) - : []; + const attachments = Array.isArray(body?.attachments) ? body.attachments : []; const cfg = loadConfig(); const replyCfg = cfg.inbound?.reply; @@ -246,6 +190,7 @@ async function handleRpc( message: formatMessageWithAttachments(text, savedAttachments), sessionId, thinking: body?.thinking, + thinkingOnce: body?.thinkingOnce, deliver: Boolean(body?.deliver), to: body?.to, json: true, @@ -278,10 +223,7 @@ export async function startWebChatServer(port = WEBCHAT_DEFAULT_PORT) { const server = http.createServer(async (req, res) => { if (!req.url) return notFound(res); // enforce loopback only - if ( - req.socket.remoteAddress && - !req.socket.remoteAddress.startsWith("127.") - ) { + if (req.socket.remoteAddress && !req.socket.remoteAddress.startsWith("127.")) { res.statusCode = 403; res.end("loopback only"); return; @@ -300,9 +242,9 @@ export async function startWebChatServer(port = WEBCHAT_DEFAULT_PORT) { : resolveStorePath(undefined); const store = loadSessionStore(storePath); const sessionId = pickSessionId(sessionKey, store); - const messages = sessionId - ? readSessionMessages(sessionId, storePath) - : []; + const sessionEntry = sessionKey ? store[sessionKey] : undefined; + const persistedThinking = sessionEntry?.thinkingLevel; + const messages = sessionId ? readSessionMessages(sessionId, storePath) : []; res.setHeader("Content-Type", "application/json"); res.end( JSON.stringify({ @@ -312,6 +254,10 @@ export async function startWebChatServer(port = WEBCHAT_DEFAULT_PORT) { sessionId, initialMessages: messages, basePath: "/", + thinkingLevel: + typeof persistedThinking === "string" + ? persistedThinking + : cfg.inbound?.reply?.thinkingDefault ?? "off", }), ); return; @@ -319,7 +265,7 @@ export async function startWebChatServer(port = WEBCHAT_DEFAULT_PORT) { if (isRpc && req.method === "POST") { const bodyBuf = await readBody(req); - let body: Record = {}; + let body: any = {}; try { body = JSON.parse(bodyBuf.toString("utf-8")); } catch { @@ -334,7 +280,7 @@ export async function startWebChatServer(port = WEBCHAT_DEFAULT_PORT) { if (url.pathname.startsWith("/webchat")) { let rel = url.pathname.replace(/^\/webchat\/?/, ""); - if (!rel || rel.endsWith("/")) rel = `${rel}index.html`; + if (!rel || rel.endsWith("/")) rel = rel + "index.html"; const filePath = path.join(root, rel); if (!filePath.startsWith(root)) return notFound(res); if (!fs.existsSync(filePath)) return notFound(res);