cue-console 0.1.16 → 0.1.17
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/app/layout.tsx +2 -1
- package/src/app/providers.tsx +8 -0
- package/src/components/chat/timeline-list.tsx +5 -351
- package/src/components/chat/user-response-bubble.tsx +50 -5
- package/src/components/chat-view.tsx +62 -14
- package/src/components/conversation-list.tsx +78 -0
- package/src/contexts/config-context.tsx +77 -0
- package/src/hooks/use-message-sender.ts +4 -3
- package/src/lib/actions.ts +34 -0
- package/src/lib/chat-logic.ts +17 -2
- package/src/lib/db.ts +48 -0
package/package.json
CHANGED
package/src/app/layout.tsx
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Metadata } from "next";
|
|
2
2
|
import "@fontsource-variable/source-sans-3";
|
|
3
3
|
import "./globals.css";
|
|
4
|
+
import { Providers } from "./providers";
|
|
4
5
|
|
|
5
6
|
export const metadata: Metadata = {
|
|
6
7
|
title: "cue-console",
|
|
@@ -18,7 +19,7 @@ export default function RootLayout({
|
|
|
18
19
|
className="antialiased"
|
|
19
20
|
suppressHydrationWarning
|
|
20
21
|
>
|
|
21
|
-
{children}
|
|
22
|
+
<Providers>{children}</Providers>
|
|
22
23
|
</body>
|
|
23
24
|
</html>
|
|
24
25
|
);
|
|
@@ -1,13 +1,11 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { memo
|
|
3
|
+
import { memo } from "react";
|
|
4
4
|
import { Button } from "@/components/ui/button";
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import type { AgentTimelineItem, CueRequest, CueResponse } from "@/lib/actions";
|
|
10
|
-
import { Copy, Check } from "lucide-react";
|
|
5
|
+
import { cn } from "@/lib/utils";
|
|
6
|
+
import type { AgentTimelineItem } from "@/lib/actions";
|
|
7
|
+
import { MessageBubble } from "@/components/chat/message-bubble";
|
|
8
|
+
import { UserResponseBubble } from "@/components/chat/user-response-bubble";
|
|
11
9
|
|
|
12
10
|
function parseDbTime(dateStr: string) {
|
|
13
11
|
return new Date((dateStr || "").replace(" ", "T"));
|
|
@@ -153,347 +151,3 @@ export const TimelineList = memo(function TimelineList({
|
|
|
153
151
|
</>
|
|
154
152
|
);
|
|
155
153
|
});
|
|
156
|
-
|
|
157
|
-
const MessageBubble = memo(function MessageBubble({
|
|
158
|
-
request,
|
|
159
|
-
showAgent,
|
|
160
|
-
agentNameMap,
|
|
161
|
-
avatarUrlMap,
|
|
162
|
-
isHistory,
|
|
163
|
-
showName,
|
|
164
|
-
showAvatar,
|
|
165
|
-
compact,
|
|
166
|
-
disabled,
|
|
167
|
-
currentInput,
|
|
168
|
-
isGroup,
|
|
169
|
-
onPasteChoice,
|
|
170
|
-
onSubmitConfirm,
|
|
171
|
-
onMentionAgent,
|
|
172
|
-
onReply,
|
|
173
|
-
onCancel,
|
|
174
|
-
}: {
|
|
175
|
-
request: CueRequest;
|
|
176
|
-
showAgent?: boolean;
|
|
177
|
-
agentNameMap?: Record<string, string>;
|
|
178
|
-
avatarUrlMap?: Record<string, string>;
|
|
179
|
-
isHistory?: boolean;
|
|
180
|
-
showName?: boolean;
|
|
181
|
-
showAvatar?: boolean;
|
|
182
|
-
compact?: boolean;
|
|
183
|
-
disabled?: boolean;
|
|
184
|
-
currentInput?: string;
|
|
185
|
-
isGroup?: boolean;
|
|
186
|
-
onPasteChoice?: (text: string, mode?: "replace" | "append" | "upsert") => void;
|
|
187
|
-
onSubmitConfirm?: (
|
|
188
|
-
requestId: string,
|
|
189
|
-
text: string,
|
|
190
|
-
cancelled: boolean
|
|
191
|
-
) => void | Promise<void>;
|
|
192
|
-
onMentionAgent?: (agentId: string) => void;
|
|
193
|
-
onReply?: () => void;
|
|
194
|
-
onCancel?: () => void;
|
|
195
|
-
}) {
|
|
196
|
-
const isPending = request.status === "PENDING";
|
|
197
|
-
const [copied, setCopied] = useState(false);
|
|
198
|
-
|
|
199
|
-
const handleCopy = async () => {
|
|
200
|
-
try {
|
|
201
|
-
await navigator.clipboard.writeText(request.prompt || "");
|
|
202
|
-
setCopied(true);
|
|
203
|
-
setTimeout(() => setCopied(false), 2000);
|
|
204
|
-
} catch (err) {
|
|
205
|
-
console.error("Failed to copy:", err);
|
|
206
|
-
}
|
|
207
|
-
};
|
|
208
|
-
|
|
209
|
-
const isPause = useMemo(() => {
|
|
210
|
-
if (!request.payload) return false;
|
|
211
|
-
try {
|
|
212
|
-
const obj = JSON.parse(request.payload) as Record<string, unknown>;
|
|
213
|
-
return obj?.type === "confirm" && obj?.variant === "pause";
|
|
214
|
-
} catch {
|
|
215
|
-
return false;
|
|
216
|
-
}
|
|
217
|
-
}, [request.payload]);
|
|
218
|
-
|
|
219
|
-
const selectedLines = useMemo(() => {
|
|
220
|
-
const text = (currentInput || "").trim();
|
|
221
|
-
if (!text) return new Set<string>();
|
|
222
|
-
return new Set(
|
|
223
|
-
text
|
|
224
|
-
.split(/\r?\n/)
|
|
225
|
-
.map((s) => s.trim())
|
|
226
|
-
.filter(Boolean)
|
|
227
|
-
);
|
|
228
|
-
}, [currentInput]);
|
|
229
|
-
|
|
230
|
-
const rawId = request.agent_id || "";
|
|
231
|
-
const displayName = (agentNameMap && rawId ? agentNameMap[rawId] || rawId : rawId) || "";
|
|
232
|
-
const cardMaxWidth = (showAvatar ?? true) ? "calc(100% - 3rem)" : "100%";
|
|
233
|
-
const avatarUrl = rawId && avatarUrlMap ? avatarUrlMap[`agent:${rawId}`] : "";
|
|
234
|
-
|
|
235
|
-
return (
|
|
236
|
-
<div
|
|
237
|
-
className={cn(
|
|
238
|
-
"flex max-w-full min-w-0 items-start gap-3",
|
|
239
|
-
compact && "gap-2",
|
|
240
|
-
isHistory && "opacity-60"
|
|
241
|
-
)}
|
|
242
|
-
>
|
|
243
|
-
{(showAvatar ?? true) ? (
|
|
244
|
-
<span
|
|
245
|
-
className={cn(
|
|
246
|
-
"flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-muted text-lg",
|
|
247
|
-
isGroup && request.agent_id && onMentionAgent && "cursor-pointer"
|
|
248
|
-
)}
|
|
249
|
-
title={
|
|
250
|
-
isGroup && request.agent_id && onMentionAgent
|
|
251
|
-
? "Double-click avatar to @mention"
|
|
252
|
-
: undefined
|
|
253
|
-
}
|
|
254
|
-
onDoubleClick={() => {
|
|
255
|
-
if (!isGroup) return;
|
|
256
|
-
const agentId = request.agent_id;
|
|
257
|
-
if (!agentId) return;
|
|
258
|
-
onMentionAgent?.(agentId);
|
|
259
|
-
}}
|
|
260
|
-
>
|
|
261
|
-
{avatarUrl ? (
|
|
262
|
-
<img src={avatarUrl} alt="" className="h-full w-full rounded-full" />
|
|
263
|
-
) : (
|
|
264
|
-
getAgentEmoji(request.agent_id || "")
|
|
265
|
-
)}
|
|
266
|
-
</span>
|
|
267
|
-
) : (
|
|
268
|
-
<span className="h-9 w-9 shrink-0" />
|
|
269
|
-
)}
|
|
270
|
-
<div className="flex-1 min-w-0 overflow-hidden">
|
|
271
|
-
{(showName ?? true) && (showAgent || displayName) && (
|
|
272
|
-
<p className="mb-1 text-xs text-muted-foreground truncate">{displayName}</p>
|
|
273
|
-
)}
|
|
274
|
-
<div
|
|
275
|
-
className={cn(
|
|
276
|
-
"rounded-3xl p-3 sm:p-4 max-w-full flex-1 basis-0 min-w-0 overflow-hidden",
|
|
277
|
-
"glass-surface-soft glass-noise",
|
|
278
|
-
isPending ? "ring-1 ring-ring/25" : "ring-1 ring-white/25"
|
|
279
|
-
)}
|
|
280
|
-
style={{ clipPath: "inset(0 round 1rem)", maxWidth: cardMaxWidth }}
|
|
281
|
-
>
|
|
282
|
-
<div className="text-sm wrap-anywhere overflow-hidden min-w-0">
|
|
283
|
-
<MarkdownRenderer>{request.prompt || ""}</MarkdownRenderer>
|
|
284
|
-
</div>
|
|
285
|
-
<PayloadCard
|
|
286
|
-
raw={request.payload}
|
|
287
|
-
disabled={disabled}
|
|
288
|
-
onPasteChoice={onPasteChoice}
|
|
289
|
-
onSubmitConfirm={(text, cancelled) =>
|
|
290
|
-
isPending ? onSubmitConfirm?.(request.request_id, text, cancelled) : undefined
|
|
291
|
-
}
|
|
292
|
-
selectedLines={selectedLines}
|
|
293
|
-
/>
|
|
294
|
-
</div>
|
|
295
|
-
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
|
296
|
-
<span className="shrink-0">{formatFullTime(request.created_at || "")}</span>
|
|
297
|
-
<Button
|
|
298
|
-
variant="ghost"
|
|
299
|
-
size="sm"
|
|
300
|
-
className="h-auto p-1 text-xs"
|
|
301
|
-
onClick={handleCopy}
|
|
302
|
-
disabled={disabled}
|
|
303
|
-
title={copied ? "已复制" : "复制"}
|
|
304
|
-
>
|
|
305
|
-
{copied ? <Check className="h-3 w-3" /> : <Copy className="h-3 w-3" />}
|
|
306
|
-
<span className="ml-1">{copied ? "已复制" : "复制"}</span>
|
|
307
|
-
</Button>
|
|
308
|
-
{isPending && (
|
|
309
|
-
<>
|
|
310
|
-
<Badge variant="outline" className="text-xs shrink-0">
|
|
311
|
-
Waiting {getWaitingDuration(request.created_at || "")}
|
|
312
|
-
</Badge>
|
|
313
|
-
{!isPause && (
|
|
314
|
-
<>
|
|
315
|
-
<Badge variant="default" className="text-xs shrink-0">
|
|
316
|
-
Pending
|
|
317
|
-
</Badge>
|
|
318
|
-
{onReply && (
|
|
319
|
-
<Button
|
|
320
|
-
variant="link"
|
|
321
|
-
size="sm"
|
|
322
|
-
className="h-auto p-0 text-xs"
|
|
323
|
-
onClick={onReply}
|
|
324
|
-
disabled={disabled}
|
|
325
|
-
>
|
|
326
|
-
Reply
|
|
327
|
-
</Button>
|
|
328
|
-
)}
|
|
329
|
-
{onCancel && (
|
|
330
|
-
<Button
|
|
331
|
-
variant="link"
|
|
332
|
-
size="sm"
|
|
333
|
-
className="h-auto p-0 text-xs text-destructive"
|
|
334
|
-
onClick={onCancel}
|
|
335
|
-
disabled={disabled}
|
|
336
|
-
>
|
|
337
|
-
End
|
|
338
|
-
</Button>
|
|
339
|
-
)}
|
|
340
|
-
</>
|
|
341
|
-
)}
|
|
342
|
-
</>
|
|
343
|
-
)}
|
|
344
|
-
{request.status === "COMPLETED" && (
|
|
345
|
-
<Badge variant="secondary" className="text-xs shrink-0">
|
|
346
|
-
Replied
|
|
347
|
-
</Badge>
|
|
348
|
-
)}
|
|
349
|
-
{request.status === "CANCELLED" && (
|
|
350
|
-
<Badge variant="destructive" className="text-xs shrink-0">
|
|
351
|
-
Ended
|
|
352
|
-
</Badge>
|
|
353
|
-
)}
|
|
354
|
-
</div>
|
|
355
|
-
</div>
|
|
356
|
-
</div>
|
|
357
|
-
);
|
|
358
|
-
});
|
|
359
|
-
|
|
360
|
-
const UserResponseBubble = memo(function UserResponseBubble({
|
|
361
|
-
response,
|
|
362
|
-
showAvatar = true,
|
|
363
|
-
compact = false,
|
|
364
|
-
onPreview,
|
|
365
|
-
}: {
|
|
366
|
-
response: CueResponse;
|
|
367
|
-
showAvatar?: boolean;
|
|
368
|
-
compact?: boolean;
|
|
369
|
-
onPreview?: (img: { mime_type: string; base64_data: string }) => void;
|
|
370
|
-
}) {
|
|
371
|
-
const parsed = JSON.parse(response.response_json || "{}") as {
|
|
372
|
-
text?: string;
|
|
373
|
-
mentions?: { userId: string; start: number; length: number; display: string }[];
|
|
374
|
-
};
|
|
375
|
-
|
|
376
|
-
const filesRaw = (response as unknown as { files?: unknown }).files;
|
|
377
|
-
const files = Array.isArray(filesRaw) ? filesRaw : [];
|
|
378
|
-
const imageFiles = files.filter((f) => {
|
|
379
|
-
const obj = f && typeof f === "object" ? (f as Record<string, unknown>) : null;
|
|
380
|
-
const mime = String(obj?.mime_type || "");
|
|
381
|
-
const b64 = obj?.inline_base64;
|
|
382
|
-
return mime.startsWith("image/") && typeof b64 === "string" && b64.length > 0;
|
|
383
|
-
});
|
|
384
|
-
const otherFiles = files.filter((f) => {
|
|
385
|
-
const obj = f && typeof f === "object" ? (f as Record<string, unknown>) : null;
|
|
386
|
-
const mime = String(obj?.mime_type || "");
|
|
387
|
-
return !mime.startsWith("image/");
|
|
388
|
-
});
|
|
389
|
-
|
|
390
|
-
const renderTextWithMentions = (text: string, mentions?: { start: number; length: number }[]) => {
|
|
391
|
-
if (!mentions || mentions.length === 0) return text;
|
|
392
|
-
const safe = [...mentions]
|
|
393
|
-
.filter((m) => m.start >= 0 && m.length > 0 && m.start + m.length <= text.length)
|
|
394
|
-
.sort((a, b) => a.start - b.start);
|
|
395
|
-
|
|
396
|
-
const nodes: ReactNode[] = [];
|
|
397
|
-
let cursor = 0;
|
|
398
|
-
for (const m of safe) {
|
|
399
|
-
if (m.start < cursor) continue;
|
|
400
|
-
if (m.start > cursor) {
|
|
401
|
-
nodes.push(text.slice(cursor, m.start));
|
|
402
|
-
}
|
|
403
|
-
const seg = text.slice(m.start, m.start + m.length);
|
|
404
|
-
nodes.push(
|
|
405
|
-
<span key={`m-${m.start}`} className="text-emerald-900/90 dark:text-emerald-950 font-semibold">
|
|
406
|
-
{seg}
|
|
407
|
-
</span>
|
|
408
|
-
);
|
|
409
|
-
cursor = m.start + m.length;
|
|
410
|
-
}
|
|
411
|
-
if (cursor < text.length) nodes.push(text.slice(cursor));
|
|
412
|
-
return nodes;
|
|
413
|
-
};
|
|
414
|
-
|
|
415
|
-
if (response.cancelled) {
|
|
416
|
-
return (
|
|
417
|
-
<div className="flex justify-end gap-3 max-w-full min-w-0">
|
|
418
|
-
<div
|
|
419
|
-
className="rounded-3xl p-3 sm:p-4 max-w-full flex-1 basis-0 min-w-0 sm:max-w-215 sm:flex-none sm:w-fit overflow-hidden glass-surface-soft glass-noise ring-1 ring-white/25"
|
|
420
|
-
style={{
|
|
421
|
-
clipPath: "inset(0 round 1rem)",
|
|
422
|
-
maxWidth: showAvatar ? "calc(100% - 3rem)" : "100%",
|
|
423
|
-
}}
|
|
424
|
-
>
|
|
425
|
-
<p className="text-sm text-muted-foreground italic">Conversation ended</p>
|
|
426
|
-
<p className="text-xs text-muted-foreground mt-1">{formatFullTime(response.created_at)}</p>
|
|
427
|
-
</div>
|
|
428
|
-
</div>
|
|
429
|
-
);
|
|
430
|
-
}
|
|
431
|
-
|
|
432
|
-
return (
|
|
433
|
-
<div className={cn("flex justify-end gap-3 max-w-full min-w-0", compact && "gap-2")}>
|
|
434
|
-
<div
|
|
435
|
-
className="rounded-3xl p-3 sm:p-4 max-w-full flex-1 basis-0 min-w-0 sm:max-w-215 sm:flex-none sm:w-fit overflow-hidden glass-surface-soft glass-noise ring-1 ring-white/25"
|
|
436
|
-
style={{
|
|
437
|
-
clipPath: "inset(0 round 1rem)",
|
|
438
|
-
maxWidth: showAvatar ? "calc(100% - 3rem)" : "100%",
|
|
439
|
-
}}
|
|
440
|
-
>
|
|
441
|
-
{parsed.text && (
|
|
442
|
-
<div className="text-sm wrap-anywhere overflow-hidden min-w-0">
|
|
443
|
-
{parsed.mentions && parsed.mentions.length > 0 ? (
|
|
444
|
-
<p className="whitespace-pre-wrap">{renderTextWithMentions(parsed.text, parsed.mentions)}</p>
|
|
445
|
-
) : (
|
|
446
|
-
<MarkdownRenderer>{parsed.text}</MarkdownRenderer>
|
|
447
|
-
)}
|
|
448
|
-
</div>
|
|
449
|
-
)}
|
|
450
|
-
{imageFiles.length > 0 && (
|
|
451
|
-
<div className="flex flex-wrap gap-2 mt-2 max-w-full">
|
|
452
|
-
{imageFiles.map((f, i) => {
|
|
453
|
-
const obj = f && typeof f === "object" ? (f as Record<string, unknown>) : null;
|
|
454
|
-
const mime = String(obj?.mime_type || "image/png");
|
|
455
|
-
const b64 = String(obj?.inline_base64 || "");
|
|
456
|
-
const img = { mime_type: mime, base64_data: b64 };
|
|
457
|
-
return (
|
|
458
|
-
<img
|
|
459
|
-
key={i}
|
|
460
|
-
src={`data:${img.mime_type};base64,${img.base64_data}`}
|
|
461
|
-
alt=""
|
|
462
|
-
className="max-h-32 max-w-full h-auto rounded cursor-pointer"
|
|
463
|
-
onClick={() => onPreview?.(img)}
|
|
464
|
-
/>
|
|
465
|
-
);
|
|
466
|
-
})}
|
|
467
|
-
</div>
|
|
468
|
-
)}
|
|
469
|
-
|
|
470
|
-
{otherFiles.length > 0 && (
|
|
471
|
-
<div className="mt-2 flex flex-col gap-1 max-w-full">
|
|
472
|
-
{otherFiles.map((f, i) => {
|
|
473
|
-
const obj = f && typeof f === "object" ? (f as Record<string, unknown>) : null;
|
|
474
|
-
const fileRef = String(obj?.file || "");
|
|
475
|
-
const name = fileRef.split("/").filter(Boolean).pop() || fileRef || "file";
|
|
476
|
-
return (
|
|
477
|
-
<div
|
|
478
|
-
key={i}
|
|
479
|
-
className="px-2 py-1 rounded-lg bg-white/40 dark:bg-black/20 ring-1 ring-border/40 text-xs text-foreground/80 truncate"
|
|
480
|
-
title={fileRef}
|
|
481
|
-
>
|
|
482
|
-
{name}
|
|
483
|
-
</div>
|
|
484
|
-
);
|
|
485
|
-
})}
|
|
486
|
-
</div>
|
|
487
|
-
)}
|
|
488
|
-
<p className="text-xs opacity-70 mt-1 text-right">{formatFullTime(response.created_at)}</p>
|
|
489
|
-
</div>
|
|
490
|
-
{showAvatar ? (
|
|
491
|
-
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-muted text-lg">
|
|
492
|
-
👤
|
|
493
|
-
</span>
|
|
494
|
-
) : (
|
|
495
|
-
<span className="h-9 w-9 shrink-0" />
|
|
496
|
-
)}
|
|
497
|
-
</div>
|
|
498
|
-
);
|
|
499
|
-
});
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { useMemo, type ReactNode } from "react";
|
|
2
2
|
import { cn, formatFullTime } from "@/lib/utils";
|
|
3
3
|
import { MarkdownRenderer } from "@/components/markdown-renderer";
|
|
4
4
|
import type { CueResponse } from "@/lib/actions";
|
|
5
|
+
import { useConfig } from "@/contexts/config-context";
|
|
5
6
|
|
|
6
7
|
interface UserResponseBubbleProps {
|
|
7
8
|
response: CueResponse;
|
|
@@ -16,11 +17,45 @@ export function UserResponseBubble({
|
|
|
16
17
|
compact = false,
|
|
17
18
|
onPreview,
|
|
18
19
|
}: UserResponseBubbleProps) {
|
|
20
|
+
const { config } = useConfig();
|
|
19
21
|
const parsed = JSON.parse(response.response_json || "{}") as {
|
|
20
22
|
text?: string;
|
|
21
23
|
mentions?: { userId: string; start: number; length: number; display: string }[];
|
|
22
24
|
};
|
|
23
25
|
|
|
26
|
+
const analysisOnlyInstruction = config.chat_mode_append_text;
|
|
27
|
+
const { analysisOnlyApplied, displayText } = useMemo(() => {
|
|
28
|
+
const text = parsed.text;
|
|
29
|
+
if (typeof text !== "string") return { analysisOnlyApplied: false, displayText: text };
|
|
30
|
+
|
|
31
|
+
const raw = text;
|
|
32
|
+
const lines = raw.split(/\r?\n/);
|
|
33
|
+
let lastNonEmpty = -1;
|
|
34
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
35
|
+
if (lines[i]?.trim().length) {
|
|
36
|
+
lastNonEmpty = i;
|
|
37
|
+
break;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
if (lastNonEmpty === -1) return { analysisOnlyApplied: false, displayText: raw };
|
|
41
|
+
|
|
42
|
+
const tail = (lines[lastNonEmpty] ?? "").trim();
|
|
43
|
+
if (tail !== analysisOnlyInstruction) {
|
|
44
|
+
return { analysisOnlyApplied: false, displayText: raw };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
let cut = lastNonEmpty;
|
|
48
|
+
if (cut > 0 && (lines[cut - 1] ?? "").trim().length === 0) {
|
|
49
|
+
cut -= 1;
|
|
50
|
+
}
|
|
51
|
+
const stripped = lines.slice(0, cut).join("\n").replace(/\s+$/, "");
|
|
52
|
+
if (stripped.trim().length === 0) {
|
|
53
|
+
return { analysisOnlyApplied: false, displayText: raw };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return { analysisOnlyApplied: true, displayText: stripped };
|
|
57
|
+
}, [parsed.text, analysisOnlyInstruction]);
|
|
58
|
+
|
|
24
59
|
const filesRaw = (response as unknown as { files?: unknown }).files;
|
|
25
60
|
const files = Array.isArray(filesRaw) ? filesRaw : [];
|
|
26
61
|
const imageFiles = files.filter((f) => {
|
|
@@ -89,14 +124,14 @@ export function UserResponseBubble({
|
|
|
89
124
|
maxWidth: showAvatar ? "calc(100% - 3rem)" : "100%",
|
|
90
125
|
}}
|
|
91
126
|
>
|
|
92
|
-
{
|
|
127
|
+
{displayText && (
|
|
93
128
|
<div className="text-sm wrap-anywhere overflow-hidden min-w-0">
|
|
94
129
|
{parsed.mentions && parsed.mentions.length > 0 ? (
|
|
95
130
|
<p className="whitespace-pre-wrap">
|
|
96
|
-
{renderTextWithMentions(
|
|
131
|
+
{renderTextWithMentions(displayText, parsed.mentions)}
|
|
97
132
|
</p>
|
|
98
133
|
) : (
|
|
99
|
-
<MarkdownRenderer>{
|
|
134
|
+
<MarkdownRenderer>{displayText}</MarkdownRenderer>
|
|
100
135
|
)}
|
|
101
136
|
</div>
|
|
102
137
|
)}
|
|
@@ -138,7 +173,17 @@ export function UserResponseBubble({
|
|
|
138
173
|
})}
|
|
139
174
|
</div>
|
|
140
175
|
)}
|
|
141
|
-
<
|
|
176
|
+
<div className="mt-1 flex items-center justify-end gap-2 text-xs opacity-70">
|
|
177
|
+
{analysisOnlyApplied && (
|
|
178
|
+
<span
|
|
179
|
+
className="rounded-full bg-white/40 dark:bg-black/20 px-2 py-0.5 ring-1 ring-border/40"
|
|
180
|
+
title="Chat 模式:只做分析,不做改动(该规则会发送给模型,但不会显示在消息正文里)"
|
|
181
|
+
>
|
|
182
|
+
Chat
|
|
183
|
+
</span>
|
|
184
|
+
)}
|
|
185
|
+
<span>{formatFullTime(response.created_at)}</span>
|
|
186
|
+
</div>
|
|
142
187
|
</div>
|
|
143
188
|
{showAvatar ? (
|
|
144
189
|
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-muted text-lg">
|
|
@@ -37,6 +37,7 @@ import { useMentions } from "@/hooks/use-mentions";
|
|
|
37
37
|
import { useAvatarManagement } from "@/hooks/use-avatar-management";
|
|
38
38
|
import { useAudioNotification } from "@/hooks/use-audio-notification";
|
|
39
39
|
import { ChatProviders } from "@/contexts/chat-providers";
|
|
40
|
+
import { useConfig } from "@/contexts/config-context";
|
|
40
41
|
import { useInputContext } from "@/contexts/input-context";
|
|
41
42
|
import { useUIStateContext } from "@/contexts/ui-state-context";
|
|
42
43
|
import { useMessageSender } from "@/hooks/use-message-sender";
|
|
@@ -44,6 +45,7 @@ import { useFileHandler } from "@/hooks/use-file-handler";
|
|
|
44
45
|
import { useDraftPersistence } from "@/hooks/use-draft-persistence";
|
|
45
46
|
import { isPauseRequest, filterPendingRequests } from "@/lib/chat-logic";
|
|
46
47
|
import type { ChatType, MentionDraft } from "@/types/chat";
|
|
48
|
+
import { ArrowDown } from "lucide-react";
|
|
47
49
|
|
|
48
50
|
function perfEnabled(): boolean {
|
|
49
51
|
try {
|
|
@@ -69,15 +71,8 @@ export function ChatView({ type, id, name, onBack }: ChatViewProps) {
|
|
|
69
71
|
}
|
|
70
72
|
|
|
71
73
|
function ChatViewContent({ type, id, name, onBack }: ChatViewProps) {
|
|
72
|
-
const
|
|
73
|
-
const
|
|
74
|
-
const [agentNameMap, setAgentNameMap] = useState<Record<string, string>>({});
|
|
75
|
-
const [groupTitle, setGroupTitle] = useState(name);
|
|
76
|
-
const [previewImage, setPreviewImage] = useState<
|
|
77
|
-
{ mime_type: string; base64_data: string } | null
|
|
78
|
-
>(null);
|
|
79
|
-
|
|
80
|
-
const { input, images, conversationMode, setConversationMode, setInput, setImages } = useInputContext();
|
|
74
|
+
const { config } = useConfig();
|
|
75
|
+
const { input, images, conversationMode, setInput, setImages, setConversationMode } = useInputContext();
|
|
81
76
|
const { busy, error, notice, setBusy, setError, setNotice } = useUIStateContext();
|
|
82
77
|
const deferredInput = useDeferredValue(input);
|
|
83
78
|
const imagesRef = useRef(images);
|
|
@@ -100,6 +95,12 @@ function ChatViewContent({ type, id, name, onBack }: ChatViewProps) {
|
|
|
100
95
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
101
96
|
const scrollRef = useRef<HTMLDivElement>(null);
|
|
102
97
|
|
|
98
|
+
const [members, setMembers] = useState<string[]>([]);
|
|
99
|
+
const [agentNameMap, setAgentNameMap] = useState<Record<string, string>>({});
|
|
100
|
+
const [groupTitle, setGroupTitle] = useState<string>(name);
|
|
101
|
+
const [previewImage, setPreviewImage] = useState<{ mime_type: string; base64_data: string } | null>(null);
|
|
102
|
+
const [isAtBottom, setIsAtBottom] = useState(true);
|
|
103
|
+
|
|
103
104
|
const [composerPadPx, setComposerPadPx] = useState(36 * 4);
|
|
104
105
|
|
|
105
106
|
const nextCursorRef = useRef<string | null>(null);
|
|
@@ -427,9 +428,17 @@ function ChatViewContent({ type, id, name, onBack }: ChatViewProps) {
|
|
|
427
428
|
setBusy(true);
|
|
428
429
|
setError(null);
|
|
429
430
|
|
|
431
|
+
const analysisOnlyInstruction = config.chat_mode_append_text;
|
|
432
|
+
const textToSend =
|
|
433
|
+
conversationMode === "chat"
|
|
434
|
+
? text.trim().length > 0
|
|
435
|
+
? `${text}\n\n${analysisOnlyInstruction}`
|
|
436
|
+
: analysisOnlyInstruction
|
|
437
|
+
: text;
|
|
438
|
+
|
|
430
439
|
const result = cancelled
|
|
431
440
|
? await cancelRequest(requestId)
|
|
432
|
-
: await submitResponse(requestId,
|
|
441
|
+
: await submitResponse(requestId, textToSend, [], []);
|
|
433
442
|
|
|
434
443
|
if (!result.success) {
|
|
435
444
|
setError(result.error || "Send failed");
|
|
@@ -439,7 +448,7 @@ function ChatViewContent({ type, id, name, onBack }: ChatViewProps) {
|
|
|
439
448
|
|
|
440
449
|
await refreshLatest();
|
|
441
450
|
setBusy(false);
|
|
442
|
-
}, [busy, setBusy, setError, refreshLatest]);
|
|
451
|
+
}, [busy, conversationMode, setBusy, setError, refreshLatest, config.chat_mode_append_text]);
|
|
443
452
|
|
|
444
453
|
const handleCancel = useCallback(async (requestId: string) => {
|
|
445
454
|
if (busy) return;
|
|
@@ -461,7 +470,16 @@ function ChatViewContent({ type, id, name, onBack }: ChatViewProps) {
|
|
|
461
470
|
if (busy) return;
|
|
462
471
|
setBusy(true);
|
|
463
472
|
setError(null);
|
|
464
|
-
|
|
473
|
+
|
|
474
|
+
const analysisOnlyInstruction = config.chat_mode_append_text;
|
|
475
|
+
const textToSend =
|
|
476
|
+
conversationMode === "chat"
|
|
477
|
+
? input.trim().length > 0
|
|
478
|
+
? `${input}\n\n${analysisOnlyInstruction}`
|
|
479
|
+
: analysisOnlyInstruction
|
|
480
|
+
: input;
|
|
481
|
+
|
|
482
|
+
const result = await submitResponse(requestId, textToSend, currentImages, mentions);
|
|
465
483
|
if (!result.success) {
|
|
466
484
|
setError(result.error || "Reply failed");
|
|
467
485
|
setBusy(false);
|
|
@@ -472,7 +490,7 @@ function ChatViewContent({ type, id, name, onBack }: ChatViewProps) {
|
|
|
472
490
|
setMentions([]);
|
|
473
491
|
await refreshLatest();
|
|
474
492
|
setBusy(false);
|
|
475
|
-
}, [input, mentions, busy, imagesRef, setBusy, setError, setInput, setImages, setMentions, refreshLatest]);
|
|
493
|
+
}, [input, mentions, busy, conversationMode, imagesRef, setBusy, setError, setInput, setImages, setMentions, refreshLatest, config.chat_mode_append_text]);
|
|
476
494
|
|
|
477
495
|
|
|
478
496
|
const hasPendingRequests = pendingRequests.length > 0;
|
|
@@ -517,8 +535,18 @@ function ChatViewContent({ type, id, name, onBack }: ChatViewProps) {
|
|
|
517
535
|
return () => ro.disconnect();
|
|
518
536
|
}, []);
|
|
519
537
|
|
|
538
|
+
const scrollToBottom = useCallback(() => {
|
|
539
|
+
const el = scrollRef.current;
|
|
540
|
+
if (!el) return;
|
|
541
|
+
try {
|
|
542
|
+
el.scrollTo({ top: el.scrollHeight, behavior: "smooth" });
|
|
543
|
+
} catch {
|
|
544
|
+
el.scrollTop = el.scrollHeight;
|
|
545
|
+
}
|
|
546
|
+
}, []);
|
|
547
|
+
|
|
520
548
|
return (
|
|
521
|
-
<div className="flex h-full flex-1 flex-col overflow-hidden">
|
|
549
|
+
<div className="relative flex h-full flex-1 flex-col overflow-hidden">
|
|
522
550
|
{notice && (
|
|
523
551
|
<div className="pointer-events-none fixed right-5 top-5 z-50">
|
|
524
552
|
<div className="rounded-2xl border bg-background/95 px-3 py-2 text-sm shadow-lg backdrop-blur">
|
|
@@ -598,6 +626,26 @@ function ChatViewContent({ type, id, name, onBack }: ChatViewProps) {
|
|
|
598
626
|
</div>
|
|
599
627
|
</ScrollArea>
|
|
600
628
|
|
|
629
|
+
{!bootstrapping && !isAtBottom && (
|
|
630
|
+
<Button
|
|
631
|
+
type="button"
|
|
632
|
+
variant="outline"
|
|
633
|
+
size="icon"
|
|
634
|
+
onClick={scrollToBottom}
|
|
635
|
+
className={cn(
|
|
636
|
+
"absolute right-4 z-40",
|
|
637
|
+
"h-10 w-10 rounded-full",
|
|
638
|
+
"bg-background/85 backdrop-blur",
|
|
639
|
+
"shadow-sm",
|
|
640
|
+
"hover:bg-background"
|
|
641
|
+
)}
|
|
642
|
+
style={{ bottom: Math.max(16, composerPadPx - 8) }}
|
|
643
|
+
title="Scroll to bottom"
|
|
644
|
+
>
|
|
645
|
+
<ArrowDown className="h-4 w-4" />
|
|
646
|
+
</Button>
|
|
647
|
+
)}
|
|
648
|
+
|
|
601
649
|
{error && (
|
|
602
650
|
<div className="border-t bg-background px-3 py-2 text-sm text-destructive">
|
|
603
651
|
{error}
|
|
@@ -116,6 +116,8 @@ export function ConversationList({
|
|
|
116
116
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
|
117
117
|
const [soundEnabled, setSoundEnabled] = useState(true);
|
|
118
118
|
const [conversationModeDefault, setConversationModeDefault] = useState<"chat" | "agent">("agent");
|
|
119
|
+
const [chatModeAppendText, setChatModeAppendText] = useState("只做分析,不要对代码/文件做任何改动。");
|
|
120
|
+
const [pendingRequestTimeoutMs, setPendingRequestTimeoutMs] = useState("600000");
|
|
119
121
|
const [pinnedKeys, setPinnedKeys] = useState<string[]>([]);
|
|
120
122
|
|
|
121
123
|
useEffect(() => {
|
|
@@ -125,6 +127,8 @@ export function ConversationList({
|
|
|
125
127
|
setSoundEnabled(Boolean(cfg.sound_enabled));
|
|
126
128
|
const nextMode = cfg.conversation_mode_default === "chat" ? "chat" : "agent";
|
|
127
129
|
setConversationModeDefault(nextMode);
|
|
130
|
+
setChatModeAppendText(String(cfg.chat_mode_append_text || "只做分析,不要对代码/文件做任何改动。"));
|
|
131
|
+
setPendingRequestTimeoutMs(String(cfg.pending_request_timeout_ms ?? 600000));
|
|
128
132
|
try {
|
|
129
133
|
window.localStorage.setItem("cue-console:conversationModeDefault", nextMode);
|
|
130
134
|
} catch {
|
|
@@ -1181,6 +1185,80 @@ export function ConversationList({
|
|
|
1181
1185
|
</Button>
|
|
1182
1186
|
</div>
|
|
1183
1187
|
</div>
|
|
1188
|
+
|
|
1189
|
+
<div className="mt-6">
|
|
1190
|
+
<div className="text-sm font-medium">Chat mode append text</div>
|
|
1191
|
+
<div className="text-xs text-muted-foreground">
|
|
1192
|
+
Appended to every message in Chat mode (single line)
|
|
1193
|
+
</div>
|
|
1194
|
+
<div className="mt-2 flex items-center justify-end gap-2">
|
|
1195
|
+
<Input
|
|
1196
|
+
value={chatModeAppendText}
|
|
1197
|
+
onChange={(e) => setChatModeAppendText(e.target.value)}
|
|
1198
|
+
placeholder="Append text"
|
|
1199
|
+
className="h-9 flex-1 min-w-0"
|
|
1200
|
+
/>
|
|
1201
|
+
<Button
|
|
1202
|
+
type="button"
|
|
1203
|
+
variant="secondary"
|
|
1204
|
+
size="sm"
|
|
1205
|
+
className="h-9 rounded-md px-3 text-xs"
|
|
1206
|
+
onClick={async () => {
|
|
1207
|
+
const next = chatModeAppendText;
|
|
1208
|
+
try {
|
|
1209
|
+
await setUserConfig({ chat_mode_append_text: next });
|
|
1210
|
+
} catch {
|
|
1211
|
+
}
|
|
1212
|
+
window.dispatchEvent(
|
|
1213
|
+
new CustomEvent("cue-console:configUpdated", {
|
|
1214
|
+
detail: { chat_mode_append_text: next },
|
|
1215
|
+
})
|
|
1216
|
+
);
|
|
1217
|
+
}}
|
|
1218
|
+
>
|
|
1219
|
+
Save
|
|
1220
|
+
</Button>
|
|
1221
|
+
</div>
|
|
1222
|
+
</div>
|
|
1223
|
+
|
|
1224
|
+
<div className="mt-4">
|
|
1225
|
+
<div className="text-sm font-medium">Pending request timeout (ms)</div>
|
|
1226
|
+
<div className="text-xs text-muted-foreground">
|
|
1227
|
+
Filter out pending requests older than this duration
|
|
1228
|
+
</div>
|
|
1229
|
+
<div className="mt-2 flex items-center justify-end gap-2">
|
|
1230
|
+
<Input
|
|
1231
|
+
value={pendingRequestTimeoutMs}
|
|
1232
|
+
onChange={(e) => setPendingRequestTimeoutMs(e.target.value)}
|
|
1233
|
+
placeholder="600000"
|
|
1234
|
+
inputMode="numeric"
|
|
1235
|
+
className="h-9 flex-1 min-w-0"
|
|
1236
|
+
/>
|
|
1237
|
+
<Button
|
|
1238
|
+
type="button"
|
|
1239
|
+
variant="secondary"
|
|
1240
|
+
size="sm"
|
|
1241
|
+
className="h-9 rounded-md px-3 text-xs"
|
|
1242
|
+
onClick={async () => {
|
|
1243
|
+
const raw = pendingRequestTimeoutMs.trim();
|
|
1244
|
+
const parsed = Number(raw);
|
|
1245
|
+
const next = Number.isFinite(parsed) ? parsed : 600000;
|
|
1246
|
+
try {
|
|
1247
|
+
await setUserConfig({ pending_request_timeout_ms: next });
|
|
1248
|
+
} catch {
|
|
1249
|
+
}
|
|
1250
|
+
window.dispatchEvent(
|
|
1251
|
+
new CustomEvent("cue-console:configUpdated", {
|
|
1252
|
+
detail: { pending_request_timeout_ms: next },
|
|
1253
|
+
})
|
|
1254
|
+
);
|
|
1255
|
+
setPendingRequestTimeoutMs(String(next));
|
|
1256
|
+
}}
|
|
1257
|
+
>
|
|
1258
|
+
Save
|
|
1259
|
+
</Button>
|
|
1260
|
+
</div>
|
|
1261
|
+
</div>
|
|
1184
1262
|
</DialogContent>
|
|
1185
1263
|
</Dialog>
|
|
1186
1264
|
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { createContext, useContext, useEffect, useMemo, useState, type ReactNode } from "react";
|
|
4
|
+
import { getUserConfig, type UserConfig } from "@/lib/actions";
|
|
5
|
+
|
|
6
|
+
const defaultConfig: UserConfig = {
|
|
7
|
+
sound_enabled: true,
|
|
8
|
+
conversation_mode_default: "agent",
|
|
9
|
+
chat_mode_append_text: "只做分析,不要对代码/文件做任何改动。",
|
|
10
|
+
pending_request_timeout_ms: 10 * 60 * 1000,
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
type ConfigContextValue = {
|
|
14
|
+
config: UserConfig;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const ConfigContext = createContext<ConfigContextValue | null>(null);
|
|
18
|
+
|
|
19
|
+
export function useConfig() {
|
|
20
|
+
const ctx = useContext(ConfigContext);
|
|
21
|
+
if (!ctx) {
|
|
22
|
+
throw new Error("useConfig must be used within ConfigProvider");
|
|
23
|
+
}
|
|
24
|
+
return ctx;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function ConfigProvider({ children }: { children: ReactNode }) {
|
|
28
|
+
const [config, setConfig] = useState<UserConfig>(defaultConfig);
|
|
29
|
+
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
let cancelled = false;
|
|
32
|
+
void (async () => {
|
|
33
|
+
try {
|
|
34
|
+
const cfg = await getUserConfig();
|
|
35
|
+
if (cancelled) return;
|
|
36
|
+
setConfig(cfg);
|
|
37
|
+
} catch {
|
|
38
|
+
}
|
|
39
|
+
})();
|
|
40
|
+
|
|
41
|
+
return () => {
|
|
42
|
+
cancelled = true;
|
|
43
|
+
};
|
|
44
|
+
}, []);
|
|
45
|
+
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
const onConfigUpdated = (evt: Event) => {
|
|
48
|
+
const e = evt as CustomEvent<Partial<UserConfig>>;
|
|
49
|
+
const next = e.detail;
|
|
50
|
+
if (!next || typeof next !== "object") return;
|
|
51
|
+
setConfig((prev) => ({ ...prev, ...next }));
|
|
52
|
+
};
|
|
53
|
+
window.addEventListener("cue-console:configUpdated", onConfigUpdated);
|
|
54
|
+
return () => window.removeEventListener("cue-console:configUpdated", onConfigUpdated);
|
|
55
|
+
}, []);
|
|
56
|
+
|
|
57
|
+
useEffect(() => {
|
|
58
|
+
try {
|
|
59
|
+
window.localStorage.setItem(
|
|
60
|
+
"cue-console:pending_request_timeout_ms",
|
|
61
|
+
String(config.pending_request_timeout_ms)
|
|
62
|
+
);
|
|
63
|
+
} catch {
|
|
64
|
+
}
|
|
65
|
+
try {
|
|
66
|
+
window.localStorage.setItem(
|
|
67
|
+
"cue-console:chat_mode_append_text",
|
|
68
|
+
String(config.chat_mode_append_text)
|
|
69
|
+
);
|
|
70
|
+
} catch {
|
|
71
|
+
}
|
|
72
|
+
}, [config.pending_request_timeout_ms, config.chat_mode_append_text]);
|
|
73
|
+
|
|
74
|
+
const value = useMemo<ConfigContextValue>(() => ({ config }), [config]);
|
|
75
|
+
|
|
76
|
+
return <ConfigContext.Provider value={value}>{children}</ConfigContext.Provider>;
|
|
77
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useCallback, useRef } from "react";
|
|
2
2
|
import { submitResponse, batchRespond } from "@/lib/actions";
|
|
3
3
|
import { calculateMessageTargets } from "@/lib/chat-logic";
|
|
4
|
+
import { useConfig } from "@/contexts/config-context";
|
|
4
5
|
import { useInputContext } from "@/contexts/input-context";
|
|
5
6
|
import { useUIStateContext } from "@/contexts/ui-state-context";
|
|
6
7
|
import type { CueRequest } from "@/lib/actions";
|
|
@@ -14,6 +15,7 @@ interface UseMessageSenderParams {
|
|
|
14
15
|
}
|
|
15
16
|
|
|
16
17
|
export function useMessageSender({ type, pendingRequests, mentions, onSuccess }: UseMessageSenderParams) {
|
|
18
|
+
const { config } = useConfig();
|
|
17
19
|
const { input, images, conversationMode, setInput, setImages } = useInputContext();
|
|
18
20
|
const { busy, setBusy, setError } = useUIStateContext();
|
|
19
21
|
const imagesRef = useRef(images);
|
|
@@ -47,8 +49,7 @@ export function useMessageSender({ type, pendingRequests, mentions, onSuccess }:
|
|
|
47
49
|
try {
|
|
48
50
|
let result;
|
|
49
51
|
|
|
50
|
-
const analysisOnlyInstruction =
|
|
51
|
-
"只做分析,不要对代码/文件做任何改动。";
|
|
52
|
+
const analysisOnlyInstruction = config.chat_mode_append_text;
|
|
52
53
|
const textToSend =
|
|
53
54
|
conversationMode === "chat"
|
|
54
55
|
? input.trim().length > 0
|
|
@@ -87,7 +88,7 @@ export function useMessageSender({ type, pendingRequests, mentions, onSuccess }:
|
|
|
87
88
|
} finally {
|
|
88
89
|
setBusy(false);
|
|
89
90
|
}
|
|
90
|
-
}, [type, input, conversationMode, mentions, pendingRequests, busy, setBusy, setError, setInput, setImages, onSuccess]);
|
|
91
|
+
}, [type, input, conversationMode, mentions, pendingRequests, busy, setBusy, setError, setInput, setImages, onSuccess, config.chat_mode_append_text]);
|
|
91
92
|
|
|
92
93
|
return { send };
|
|
93
94
|
}
|
package/src/lib/actions.ts
CHANGED
|
@@ -59,6 +59,8 @@ import { v4 as uuidv4 } from "uuid";
|
|
|
59
59
|
export type UserConfig = {
|
|
60
60
|
sound_enabled: boolean;
|
|
61
61
|
conversation_mode_default: "chat" | "agent";
|
|
62
|
+
chat_mode_append_text: string;
|
|
63
|
+
pending_request_timeout_ms: number;
|
|
62
64
|
};
|
|
63
65
|
|
|
64
66
|
export type QueuedMessage = {
|
|
@@ -71,8 +73,20 @@ export type QueuedMessage = {
|
|
|
71
73
|
const defaultUserConfig: UserConfig = {
|
|
72
74
|
sound_enabled: true,
|
|
73
75
|
conversation_mode_default: "agent",
|
|
76
|
+
chat_mode_append_text: "只做分析,不要对代码/文件做任何改动。",
|
|
77
|
+
pending_request_timeout_ms: 10 * 60 * 1000,
|
|
74
78
|
};
|
|
75
79
|
|
|
80
|
+
function clampNumber(n: number, min: number, max: number): number {
|
|
81
|
+
if (!Number.isFinite(n)) return min;
|
|
82
|
+
return Math.max(min, Math.min(max, n));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function normalizeSingleLine(s: string): string {
|
|
86
|
+
const t = String(s ?? "").replace(/\r?\n/g, " ").trim();
|
|
87
|
+
return t;
|
|
88
|
+
}
|
|
89
|
+
|
|
76
90
|
function getUserConfigPath(): string {
|
|
77
91
|
return path.join(os.homedir(), ".cue", "config.json");
|
|
78
92
|
}
|
|
@@ -91,6 +105,16 @@ export async function getUserConfig(): Promise<UserConfig> {
|
|
|
91
105
|
parsed.conversation_mode_default === "chat" || parsed.conversation_mode_default === "agent"
|
|
92
106
|
? parsed.conversation_mode_default
|
|
93
107
|
: defaultUserConfig.conversation_mode_default,
|
|
108
|
+
|
|
109
|
+
chat_mode_append_text:
|
|
110
|
+
typeof parsed.chat_mode_append_text === "string" && normalizeSingleLine(parsed.chat_mode_append_text).length > 0
|
|
111
|
+
? normalizeSingleLine(parsed.chat_mode_append_text)
|
|
112
|
+
: defaultUserConfig.chat_mode_append_text,
|
|
113
|
+
|
|
114
|
+
pending_request_timeout_ms:
|
|
115
|
+
typeof parsed.pending_request_timeout_ms === "number"
|
|
116
|
+
? clampNumber(parsed.pending_request_timeout_ms, 60_000, 86_400_000)
|
|
117
|
+
: defaultUserConfig.pending_request_timeout_ms,
|
|
94
118
|
};
|
|
95
119
|
} catch {
|
|
96
120
|
return defaultUserConfig;
|
|
@@ -106,6 +130,16 @@ export async function setUserConfig(next: Partial<UserConfig>): Promise<UserConf
|
|
|
106
130
|
next.conversation_mode_default === "chat" || next.conversation_mode_default === "agent"
|
|
107
131
|
? next.conversation_mode_default
|
|
108
132
|
: prev.conversation_mode_default,
|
|
133
|
+
|
|
134
|
+
chat_mode_append_text:
|
|
135
|
+
typeof next.chat_mode_append_text === "string" && normalizeSingleLine(next.chat_mode_append_text).length > 0
|
|
136
|
+
? normalizeSingleLine(next.chat_mode_append_text)
|
|
137
|
+
: prev.chat_mode_append_text,
|
|
138
|
+
|
|
139
|
+
pending_request_timeout_ms:
|
|
140
|
+
typeof next.pending_request_timeout_ms === "number"
|
|
141
|
+
? clampNumber(next.pending_request_timeout_ms, 60_000, 86_400_000)
|
|
142
|
+
: prev.pending_request_timeout_ms,
|
|
109
143
|
};
|
|
110
144
|
const p = getUserConfigPath();
|
|
111
145
|
await fs.mkdir(path.dirname(p), { recursive: true });
|
package/src/lib/chat-logic.ts
CHANGED
|
@@ -1,6 +1,21 @@
|
|
|
1
1
|
import type { CueRequest } from "@/lib/actions";
|
|
2
2
|
import type { MessageActionParams, MentionDraft, ImageAttachment } from "@/types/chat";
|
|
3
3
|
|
|
4
|
+
function clampNumber(n: number, min: number, max: number): number {
|
|
5
|
+
if (!Number.isFinite(n)) return min;
|
|
6
|
+
return Math.max(min, Math.min(max, n));
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function getPendingRequestTimeoutMs(): number {
|
|
10
|
+
try {
|
|
11
|
+
const raw = window.localStorage.getItem("cue-console:pending_request_timeout_ms");
|
|
12
|
+
const n = Number(raw);
|
|
13
|
+
if (Number.isFinite(n)) return clampNumber(n, 60_000, 86_400_000);
|
|
14
|
+
} catch {
|
|
15
|
+
}
|
|
16
|
+
return 10 * 60 * 1000;
|
|
17
|
+
}
|
|
18
|
+
|
|
4
19
|
export function isPauseRequest(req: CueRequest): boolean {
|
|
5
20
|
if (!req.payload) return false;
|
|
6
21
|
try {
|
|
@@ -13,12 +28,12 @@ export function isPauseRequest(req: CueRequest): boolean {
|
|
|
13
28
|
|
|
14
29
|
export function filterPendingRequests(requests: CueRequest[]): CueRequest[] {
|
|
15
30
|
const now = Date.now();
|
|
16
|
-
const TIMEOUT_MS =
|
|
31
|
+
const TIMEOUT_MS = getPendingRequestTimeoutMs();
|
|
17
32
|
|
|
18
33
|
return requests.filter((r) => {
|
|
19
34
|
if (r.status !== "PENDING") return false;
|
|
20
35
|
|
|
21
|
-
// Filter out requests older than
|
|
36
|
+
// Filter out requests older than timeout
|
|
22
37
|
if (r.created_at) {
|
|
23
38
|
const createdTime = new Date(r.created_at).getTime();
|
|
24
39
|
const age = now - createdTime;
|
package/src/lib/db.ts
CHANGED
|
@@ -592,6 +592,8 @@ export function processMessageQueueTick(workerId: string, options?: { limit?: nu
|
|
|
592
592
|
failed: number;
|
|
593
593
|
removedQueueIds: string[];
|
|
594
594
|
} {
|
|
595
|
+
cancelExpiredPendingRequests({ timeoutMs: 10 * 60 * 1000, excludePause: true });
|
|
596
|
+
|
|
595
597
|
const limit = Math.max(1, Math.min(50, options?.limit ?? 20));
|
|
596
598
|
const claimed = claimDueQueueItems(workerId, limit);
|
|
597
599
|
let sent = 0;
|
|
@@ -688,6 +690,52 @@ export function processMessageQueueTick(workerId: string, options?: { limit?: nu
|
|
|
688
690
|
};
|
|
689
691
|
}
|
|
690
692
|
|
|
693
|
+
function cancelExpiredPendingRequests(options?: { timeoutMs?: number; excludePause?: boolean }): {
|
|
694
|
+
considered: number;
|
|
695
|
+
cancelled: number;
|
|
696
|
+
} {
|
|
697
|
+
const timeoutMs = Math.max(1, options?.timeoutMs ?? 10 * 60 * 1000);
|
|
698
|
+
const excludePause = options?.excludePause ?? true;
|
|
699
|
+
|
|
700
|
+
const db = getDb();
|
|
701
|
+
const now = Date.now();
|
|
702
|
+
const cutoff = now - timeoutMs;
|
|
703
|
+
|
|
704
|
+
const rows = db
|
|
705
|
+
.prepare(
|
|
706
|
+
`SELECT request_id, created_at, payload
|
|
707
|
+
FROM cue_requests
|
|
708
|
+
WHERE status = 'PENDING'
|
|
709
|
+
ORDER BY created_at ASC`
|
|
710
|
+
)
|
|
711
|
+
.all() as Array<{ request_id: string; created_at: string; payload: string | null }>;
|
|
712
|
+
|
|
713
|
+
let cancelled = 0;
|
|
714
|
+
|
|
715
|
+
for (const r of rows) {
|
|
716
|
+
if (excludePause) {
|
|
717
|
+
const payload = r.payload;
|
|
718
|
+
if (payload && payload.includes('"type"') && payload.includes('"confirm"')) {
|
|
719
|
+
const looksLikePause =
|
|
720
|
+
payload.includes('"variant"') && payload.includes('"pause"');
|
|
721
|
+
if (looksLikePause) continue;
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
const createdAtMs = new Date(r.created_at).getTime();
|
|
726
|
+
if (!Number.isFinite(createdAtMs)) continue;
|
|
727
|
+
if (createdAtMs > cutoff) continue;
|
|
728
|
+
|
|
729
|
+
try {
|
|
730
|
+
sendResponse(String(r.request_id), { text: "" }, true);
|
|
731
|
+
cancelled += 1;
|
|
732
|
+
} catch {
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
return { considered: rows.length, cancelled };
|
|
737
|
+
}
|
|
738
|
+
|
|
691
739
|
function metaKey(type: "agent" | "group", id: string): string {
|
|
692
740
|
return `${type}:${id}`;
|
|
693
741
|
}
|