feat(webchat): queue outgoing messages
This commit is contained in:
@@ -616,6 +616,64 @@
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chat-queue {
|
||||||
|
margin-top: 12px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 16px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: rgba(0, 0, 0, 0.18);
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="light"] .chat-queue {
|
||||||
|
background: rgba(16, 24, 40, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-queue__title {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-queue__list {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-queue__item {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
align-items: start;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px dashed var(--border);
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="light"] .chat-queue__item {
|
||||||
|
background: rgba(16, 24, 40, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-queue__text {
|
||||||
|
color: var(--chat-text);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.4;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
overflow: hidden;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 3;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-queue__remove {
|
||||||
|
align-self: start;
|
||||||
|
padding: 4px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.chat-line {
|
.chat-line {
|
||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import type {
|
|||||||
StatusSummary,
|
StatusSummary,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
import type {
|
import type {
|
||||||
|
ChatQueueItem,
|
||||||
CronFormState,
|
CronFormState,
|
||||||
DiscordForm,
|
DiscordForm,
|
||||||
IMessageForm,
|
IMessageForm,
|
||||||
@@ -101,6 +102,7 @@ export type AppViewState = {
|
|||||||
chatStream: string | null;
|
chatStream: string | null;
|
||||||
chatRunId: string | null;
|
chatRunId: string | null;
|
||||||
chatThinkingLevel: string | null;
|
chatThinkingLevel: string | null;
|
||||||
|
chatQueue: ChatQueueItem[];
|
||||||
nodesLoading: boolean;
|
nodesLoading: boolean;
|
||||||
nodes: Array<Record<string, unknown>>;
|
nodes: Array<Record<string, unknown>>;
|
||||||
configLoading: boolean;
|
configLoading: boolean;
|
||||||
@@ -198,6 +200,7 @@ export type AppViewState = {
|
|||||||
handleWhatsAppLogout: () => Promise<void>;
|
handleWhatsAppLogout: () => Promise<void>;
|
||||||
handleTelegramSave: () => Promise<void>;
|
handleTelegramSave: () => Promise<void>;
|
||||||
handleSendChat: (messageOverride?: string, opts?: { restoreDraft?: boolean }) => Promise<void>;
|
handleSendChat: (messageOverride?: string, opts?: { restoreDraft?: boolean }) => Promise<void>;
|
||||||
|
removeQueuedMessage: (id: string) => void;
|
||||||
resetToolStream: () => void;
|
resetToolStream: () => void;
|
||||||
handleLogsScroll: (event: Event) => void;
|
handleLogsScroll: (event: Event) => void;
|
||||||
exportLogs: (lines: string[], label: string) => void;
|
exportLogs: (lines: string[], label: string) => void;
|
||||||
@@ -422,6 +425,7 @@ export function renderApp(state: AppViewState) {
|
|||||||
state.chatStream = null;
|
state.chatStream = null;
|
||||||
state.chatStreamStartedAt = null;
|
state.chatStreamStartedAt = null;
|
||||||
state.chatRunId = null;
|
state.chatRunId = null;
|
||||||
|
state.chatQueue = [];
|
||||||
state.resetToolStream();
|
state.resetToolStream();
|
||||||
state.resetChatScroll();
|
state.resetChatScroll();
|
||||||
state.applySettings({
|
state.applySettings({
|
||||||
@@ -439,6 +443,7 @@ export function renderApp(state: AppViewState) {
|
|||||||
stream: state.chatStream,
|
stream: state.chatStream,
|
||||||
streamStartedAt: state.chatStreamStartedAt,
|
streamStartedAt: state.chatStreamStartedAt,
|
||||||
draft: state.chatMessage,
|
draft: state.chatMessage,
|
||||||
|
queue: state.chatQueue,
|
||||||
connected: state.connected,
|
connected: state.connected,
|
||||||
canSend: state.connected,
|
canSend: state.connected,
|
||||||
disabledReason: chatDisabledReason,
|
disabledReason: chatDisabledReason,
|
||||||
@@ -453,6 +458,7 @@ export function renderApp(state: AppViewState) {
|
|||||||
},
|
},
|
||||||
onDraftChange: (next) => (state.chatMessage = next),
|
onDraftChange: (next) => (state.chatMessage = next),
|
||||||
onSend: () => state.handleSendChat(),
|
onSend: () => state.handleSendChat(),
|
||||||
|
onQueueRemove: (id) => state.removeQueuedMessage(id),
|
||||||
onNewSession: () =>
|
onNewSession: () =>
|
||||||
state.handleSendChat("/new", { restoreDraft: true }),
|
state.handleSendChat("/new", { restoreDraft: true }),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
type ThemeMode,
|
type ThemeMode,
|
||||||
} from "./theme";
|
} from "./theme";
|
||||||
import { truncateText } from "./format";
|
import { truncateText } from "./format";
|
||||||
|
import { generateUUID } from "./uuid";
|
||||||
import {
|
import {
|
||||||
startThemeTransition,
|
startThemeTransition,
|
||||||
type ThemeTransitionContext,
|
type ThemeTransitionContext,
|
||||||
@@ -40,6 +41,7 @@ import type {
|
|||||||
import {
|
import {
|
||||||
defaultDiscordActions,
|
defaultDiscordActions,
|
||||||
defaultSlackActions,
|
defaultSlackActions,
|
||||||
|
type ChatQueueItem,
|
||||||
type CronFormState,
|
type CronFormState,
|
||||||
type DiscordForm,
|
type DiscordForm,
|
||||||
type IMessageForm,
|
type IMessageForm,
|
||||||
@@ -49,7 +51,7 @@ import {
|
|||||||
} from "./ui-types";
|
} from "./ui-types";
|
||||||
import {
|
import {
|
||||||
loadChatHistory,
|
loadChatHistory,
|
||||||
sendChat,
|
sendChatMessage,
|
||||||
handleChatEvent,
|
handleChatEvent,
|
||||||
type ChatEventPayload,
|
type ChatEventPayload,
|
||||||
} from "./controllers/chat";
|
} from "./controllers/chat";
|
||||||
@@ -214,6 +216,7 @@ export class ClawdbotApp extends LitElement {
|
|||||||
@state() chatStreamStartedAt: number | null = null;
|
@state() chatStreamStartedAt: number | null = null;
|
||||||
@state() chatRunId: string | null = null;
|
@state() chatRunId: string | null = null;
|
||||||
@state() chatThinkingLevel: string | null = null;
|
@state() chatThinkingLevel: string | null = null;
|
||||||
|
@state() chatQueue: ChatQueueItem[] = [];
|
||||||
@state() toolOutputExpanded = new Set<string>();
|
@state() toolOutputExpanded = new Set<string>();
|
||||||
|
|
||||||
@state() nodesLoading = false;
|
@state() nodesLoading = false;
|
||||||
@@ -761,6 +764,7 @@ export class ClawdbotApp extends LitElement {
|
|||||||
const state = handleChatEvent(this, payload);
|
const state = handleChatEvent(this, payload);
|
||||||
if (state === "final" || state === "error" || state === "aborted") {
|
if (state === "final" || state === "error" || state === "aborted") {
|
||||||
this.resetToolStream();
|
this.resetToolStream();
|
||||||
|
void this.flushChatQueue();
|
||||||
}
|
}
|
||||||
if (state === "final") void loadChatHistory(this);
|
if (state === "final") void loadChatHistory(this);
|
||||||
return;
|
return;
|
||||||
@@ -1003,19 +1007,32 @@ export class ClawdbotApp extends LitElement {
|
|||||||
async loadCron() {
|
async loadCron() {
|
||||||
await Promise.all([loadCronStatus(this), loadCronJobs(this)]);
|
await Promise.all([loadCronStatus(this), loadCronJobs(this)]);
|
||||||
}
|
}
|
||||||
async handleSendChat(
|
|
||||||
messageOverride?: string,
|
private isChatBusy() {
|
||||||
opts?: { restoreDraft?: boolean },
|
return this.chatSending || Boolean(this.chatRunId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private enqueueChatMessage(text: string) {
|
||||||
|
const trimmed = text.trim();
|
||||||
|
if (!trimmed) return;
|
||||||
|
this.chatQueue = [
|
||||||
|
...this.chatQueue,
|
||||||
|
{
|
||||||
|
id: generateUUID(),
|
||||||
|
text: trimmed,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private async sendChatMessageNow(
|
||||||
|
message: string,
|
||||||
|
opts?: { previousDraft?: string; restoreDraft?: boolean },
|
||||||
) {
|
) {
|
||||||
if (!this.connected) return;
|
|
||||||
const previousDraft = this.chatMessage;
|
|
||||||
if (messageOverride != null) {
|
|
||||||
this.chatMessage = messageOverride;
|
|
||||||
}
|
|
||||||
this.resetToolStream();
|
this.resetToolStream();
|
||||||
const ok = await sendChat(this);
|
const ok = await sendChatMessage(this, message);
|
||||||
if (!ok && messageOverride != null) {
|
if (!ok && opts?.previousDraft != null) {
|
||||||
this.chatMessage = previousDraft;
|
this.chatMessage = opts.previousDraft;
|
||||||
}
|
}
|
||||||
if (ok) {
|
if (ok) {
|
||||||
this.setLastActiveSessionKey(this.sessionKey);
|
this.setLastActiveSessionKey(this.sessionKey);
|
||||||
@@ -1028,10 +1045,53 @@ export class ClawdbotApp extends LitElement {
|
|||||||
this.resetToolStream();
|
this.resetToolStream();
|
||||||
void loadChatHistory(this);
|
void loadChatHistory(this);
|
||||||
}
|
}
|
||||||
if (ok && messageOverride != null && opts?.restoreDraft && previousDraft.trim()) {
|
if (ok && opts?.restoreDraft && opts.previousDraft?.trim()) {
|
||||||
this.chatMessage = previousDraft;
|
this.chatMessage = opts.previousDraft;
|
||||||
}
|
}
|
||||||
this.scheduleChatScroll();
|
this.scheduleChatScroll();
|
||||||
|
if (ok && !this.chatRunId) {
|
||||||
|
void this.flushChatQueue();
|
||||||
|
}
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async flushChatQueue() {
|
||||||
|
if (!this.connected || this.isChatBusy()) return;
|
||||||
|
const [next, ...rest] = this.chatQueue;
|
||||||
|
if (!next) return;
|
||||||
|
this.chatQueue = rest;
|
||||||
|
const ok = await this.sendChatMessageNow(next.text);
|
||||||
|
if (!ok) {
|
||||||
|
this.chatQueue = [next, ...this.chatQueue];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
removeQueuedMessage(id: string) {
|
||||||
|
this.chatQueue = this.chatQueue.filter((item) => item.id !== id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleSendChat(
|
||||||
|
messageOverride?: string,
|
||||||
|
opts?: { restoreDraft?: boolean },
|
||||||
|
) {
|
||||||
|
if (!this.connected) return;
|
||||||
|
const previousDraft = this.chatMessage;
|
||||||
|
const message = (messageOverride ?? this.chatMessage).trim();
|
||||||
|
if (!message) return;
|
||||||
|
|
||||||
|
if (messageOverride == null) {
|
||||||
|
this.chatMessage = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.isChatBusy()) {
|
||||||
|
this.enqueueChatMessage(message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.sendChatMessageNow(message, {
|
||||||
|
previousDraft: messageOverride == null ? previousDraft : undefined,
|
||||||
|
restoreDraft: Boolean(messageOverride && opts?.restoreDraft),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async handleWhatsAppStart(force: boolean) {
|
async handleWhatsAppStart(force: boolean) {
|
||||||
|
|||||||
@@ -42,9 +42,9 @@ export async function loadChatHistory(state: ChatState) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function sendChat(state: ChatState): Promise<boolean> {
|
export async function sendChatMessage(state: ChatState, message: string): Promise<boolean> {
|
||||||
if (!state.client || !state.connected) return false;
|
if (!state.client || !state.connected) return false;
|
||||||
const msg = state.chatMessage.trim();
|
const msg = message.trim();
|
||||||
if (!msg) return false;
|
if (!msg) return false;
|
||||||
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
@@ -58,7 +58,6 @@ export async function sendChat(state: ChatState): Promise<boolean> {
|
|||||||
];
|
];
|
||||||
|
|
||||||
state.chatSending = true;
|
state.chatSending = true;
|
||||||
state.chatMessage = "";
|
|
||||||
state.lastError = null;
|
state.lastError = null;
|
||||||
const runId = generateUUID();
|
const runId = generateUUID();
|
||||||
state.chatRunId = runId;
|
state.chatRunId = runId;
|
||||||
@@ -77,7 +76,6 @@ export async function sendChat(state: ChatState): Promise<boolean> {
|
|||||||
state.chatRunId = null;
|
state.chatRunId = null;
|
||||||
state.chatStream = null;
|
state.chatStream = null;
|
||||||
state.chatStreamStartedAt = null;
|
state.chatStreamStartedAt = null;
|
||||||
state.chatMessage = msg;
|
|
||||||
state.lastError = error;
|
state.lastError = error;
|
||||||
state.chatMessages = [
|
state.chatMessages = [
|
||||||
...state.chatMessages,
|
...state.chatMessages,
|
||||||
|
|||||||
@@ -8,6 +8,12 @@ export type TelegramForm = {
|
|||||||
webhookPath: string;
|
webhookPath: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ChatQueueItem = {
|
||||||
|
id: string;
|
||||||
|
text: string;
|
||||||
|
createdAt: number;
|
||||||
|
};
|
||||||
|
|
||||||
export type DiscordForm = {
|
export type DiscordForm = {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
token: string;
|
token: string;
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { html, nothing } from "lit";
|
import { html, nothing } from "lit";
|
||||||
import { repeat } from "lit/directives/repeat.js";
|
import { repeat } from "lit/directives/repeat.js";
|
||||||
import { unsafeHTML } from "lit/directives/unsafe-html.js";
|
import { unsafeHTML } from "lit/directives/unsafe-html.js";
|
||||||
|
|
||||||
import { toSanitizedMarkdownHtml } from "../markdown";
|
import { toSanitizedMarkdownHtml } from "../markdown";
|
||||||
import { formatToolDetail, resolveToolDisplay } from "../tool-display";
|
import { formatToolDetail, resolveToolDisplay } from "../tool-display";
|
||||||
import type { SessionsListResult } from "../types";
|
import type { SessionsListResult } from "../types";
|
||||||
|
import type { ChatQueueItem } from "../ui-types";
|
||||||
|
|
||||||
export type ChatProps = {
|
export type ChatProps = {
|
||||||
sessionKey: string;
|
sessionKey: string;
|
||||||
@@ -16,6 +18,7 @@ export type ChatProps = {
|
|||||||
stream: string | null;
|
stream: string | null;
|
||||||
streamStartedAt: number | null;
|
streamStartedAt: number | null;
|
||||||
draft: string;
|
draft: string;
|
||||||
|
queue: ChatQueueItem[];
|
||||||
connected: boolean;
|
connected: boolean;
|
||||||
canSend: boolean;
|
canSend: boolean;
|
||||||
disabledReason: string | null;
|
disabledReason: string | null;
|
||||||
@@ -26,14 +29,16 @@ export type ChatProps = {
|
|||||||
onRefresh: () => void;
|
onRefresh: () => void;
|
||||||
onDraftChange: (next: string) => void;
|
onDraftChange: (next: string) => void;
|
||||||
onSend: () => void;
|
onSend: () => void;
|
||||||
|
onQueueRemove: (id: string) => void;
|
||||||
onNewSession: () => void;
|
onNewSession: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function renderChat(props: ChatProps) {
|
export function renderChat(props: ChatProps) {
|
||||||
const canCompose = props.connected && !props.sending;
|
const canCompose = props.connected;
|
||||||
|
const isBusy = props.sending || Boolean(props.stream);
|
||||||
const sessionOptions = resolveSessionOptions(props.sessionKey, props.sessions);
|
const sessionOptions = resolveSessionOptions(props.sessionKey, props.sessions);
|
||||||
const composePlaceholder = props.connected
|
const composePlaceholder = props.connected
|
||||||
? "Message (Shift+↩ for line breaks)"
|
? "Message (↩ to send, Shift+↩ for line breaks)"
|
||||||
: "Connect to the gateway to start chatting…";
|
: "Connect to the gateway to start chatting…";
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
@@ -106,6 +111,31 @@ export function renderChat(props: ChatProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
${props.queue.length
|
||||||
|
? html`
|
||||||
|
<div class="chat-queue" role="status" aria-live="polite">
|
||||||
|
<div class="chat-queue__title">Queued (${props.queue.length})</div>
|
||||||
|
<div class="chat-queue__list">
|
||||||
|
${props.queue.map(
|
||||||
|
(item) => html`
|
||||||
|
<div class="chat-queue__item">
|
||||||
|
<div class="chat-queue__text">${item.text}</div>
|
||||||
|
<button
|
||||||
|
class="btn chat-queue__remove"
|
||||||
|
type="button"
|
||||||
|
aria-label="Remove queued message"
|
||||||
|
@click=${() => props.onQueueRemove(item.id)}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
: nothing}
|
||||||
|
|
||||||
<div class="chat-compose">
|
<div class="chat-compose">
|
||||||
<label class="field chat-compose__field">
|
<label class="field chat-compose__field">
|
||||||
<span>Message</span>
|
<span>Message</span>
|
||||||
@@ -114,7 +144,9 @@ export function renderChat(props: ChatProps) {
|
|||||||
?disabled=${!props.connected}
|
?disabled=${!props.connected}
|
||||||
@keydown=${(e: KeyboardEvent) => {
|
@keydown=${(e: KeyboardEvent) => {
|
||||||
if (e.key !== "Enter") return;
|
if (e.key !== "Enter") return;
|
||||||
|
if (e.isComposing || e.keyCode === 229) return;
|
||||||
if (e.shiftKey) return; // Allow Shift+Enter for line breaks
|
if (e.shiftKey) return; // Allow Shift+Enter for line breaks
|
||||||
|
if (!props.connected) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (canCompose) props.onSend();
|
if (canCompose) props.onSend();
|
||||||
}}
|
}}
|
||||||
@@ -132,10 +164,10 @@ export function renderChat(props: ChatProps) {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="btn primary"
|
class="btn primary"
|
||||||
?disabled=${!props.connected || props.sending}
|
?disabled=${!props.connected}
|
||||||
@click=${props.onSend}
|
@click=${props.onSend}
|
||||||
>
|
>
|
||||||
${props.sending ? "Sending…" : "Send"}
|
${isBusy ? "Queue" : "Send"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user