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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cue-console",
3
- "version": "0.1.16",
3
+ "version": "0.1.17",
4
4
  "description": "Cue Hub console launcher (Next.js UI)",
5
5
  "license": "Apache-2.0",
6
6
  "keywords": ["mcp", "cue", "console", "nextjs"],
@@ -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
  );
@@ -0,0 +1,8 @@
1
+ "use client";
2
+
3
+ import type { ReactNode } from "react";
4
+ import { ConfigProvider } from "@/contexts/config-context";
5
+
6
+ export function Providers({ children }: { children: ReactNode }) {
7
+ return <ConfigProvider>{children}</ConfigProvider>;
8
+ }
@@ -1,13 +1,11 @@
1
1
  "use client";
2
2
 
3
- import { memo, useMemo, useState, type ReactNode } from "react";
3
+ import { memo } from "react";
4
4
  import { Button } from "@/components/ui/button";
5
- import { Badge } from "@/components/ui/badge";
6
- import { cn, formatFullTime, getAgentEmoji, getWaitingDuration } from "@/lib/utils";
7
- import { MarkdownRenderer } from "@/components/markdown-renderer";
8
- import { PayloadCard } from "@/components/payload-card";
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 { ReactNode } from "react";
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
- {parsed.text && (
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(parsed.text, parsed.mentions)}
131
+ {renderTextWithMentions(displayText, parsed.mentions)}
97
132
  </p>
98
133
  ) : (
99
- <MarkdownRenderer>{parsed.text}</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
- <p className="text-xs opacity-70 mt-1 text-right">{formatFullTime(response.created_at)}</p>
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 [isAtBottom, setIsAtBottom] = useState(true);
73
- const [members, setMembers] = useState<string[]>([]);
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, text, [], []);
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
- const result = await submitResponse(requestId, input, currentImages, mentions);
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
  }
@@ -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 });
@@ -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 = 10 * 60 * 1000; // 10 minutes
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 10 minutes
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
  }