@agentscope-ai/chat 1.1.71 → 1.1.72-beta.1782807370681

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.
Files changed (56) hide show
  1. package/components/AgentScopeRuntimeWebUI/core/Chat/Input/index.tsx +75 -8
  2. package/components/AgentScopeRuntimeWebUI/core/Chat/InputQueue/Panel.tsx +289 -0
  3. package/components/AgentScopeRuntimeWebUI/core/Chat/InputQueue/__tests__/inputQueue.scenarios.zh-CN.md +51 -0
  4. package/components/AgentScopeRuntimeWebUI/core/Chat/InputQueue/__tests__/inputQueue.test.ts +758 -0
  5. package/components/AgentScopeRuntimeWebUI/core/Chat/InputQueue/background.ts +421 -0
  6. package/components/AgentScopeRuntimeWebUI/core/Chat/InputQueue/index.ts +375 -0
  7. package/components/AgentScopeRuntimeWebUI/core/Chat/InputQueue/session.ts +102 -0
  8. package/components/AgentScopeRuntimeWebUI/core/Chat/InputQueue/storage.ts +87 -0
  9. package/components/AgentScopeRuntimeWebUI/core/Chat/MessageList/index.tsx +4 -4
  10. package/components/AgentScopeRuntimeWebUI/core/Chat/hooks/useChatController.tsx +265 -122
  11. package/components/AgentScopeRuntimeWebUI/core/Chat/hooks/useChatRequest.tsx +32 -6
  12. package/components/AgentScopeRuntimeWebUI/core/Chat/hooks/useChatSessionHandler.tsx +17 -9
  13. package/components/AgentScopeRuntimeWebUI/core/Chat/hooks/useInputQueueController.tsx +1207 -0
  14. package/components/AgentScopeRuntimeWebUI/core/Chat/index.tsx +42 -3
  15. package/components/AgentScopeRuntimeWebUI/core/Chat/styles.tsx +324 -3
  16. package/components/AgentScopeRuntimeWebUI/core/ChatAnywhere/index.tsx +1 -1
  17. package/components/AgentScopeRuntimeWebUI/core/Context/ChatAnywhereI18nContext.tsx +34 -8
  18. package/components/AgentScopeRuntimeWebUI/core/Context/ChatAnywhereInputContext.tsx +4 -11
  19. package/components/AgentScopeRuntimeWebUI/core/Context/ChatAnywhereSessionsContext.tsx +82 -15
  20. package/components/AgentScopeRuntimeWebUI/core/types/IChatAnywhere.ts +68 -0
  21. package/components/AgentScopeRuntimeWebUI/core/types/ISessions.tsx +5 -1
  22. package/components/AgentScopeRuntimeWebUI/starter/index.tsx +99 -13
  23. package/components/Sender/index.tsx +11 -2
  24. package/lib/AgentScopeRuntimeWebUI/core/Chat/Input/index.d.ts +8 -0
  25. package/lib/AgentScopeRuntimeWebUI/core/Chat/Input/index.js +104 -16
  26. package/lib/AgentScopeRuntimeWebUI/core/Chat/InputQueue/Panel.d.ts +15 -0
  27. package/lib/AgentScopeRuntimeWebUI/core/Chat/InputQueue/Panel.js +275 -0
  28. package/lib/AgentScopeRuntimeWebUI/core/Chat/InputQueue/background.d.ts +10 -0
  29. package/lib/AgentScopeRuntimeWebUI/core/Chat/InputQueue/background.js +586 -0
  30. package/lib/AgentScopeRuntimeWebUI/core/Chat/InputQueue/index.d.ts +90 -0
  31. package/lib/AgentScopeRuntimeWebUI/core/Chat/InputQueue/index.js +242 -0
  32. package/lib/AgentScopeRuntimeWebUI/core/Chat/InputQueue/session.d.ts +17 -0
  33. package/lib/AgentScopeRuntimeWebUI/core/Chat/InputQueue/session.js +39 -0
  34. package/lib/AgentScopeRuntimeWebUI/core/Chat/InputQueue/storage.d.ts +5 -0
  35. package/lib/AgentScopeRuntimeWebUI/core/Chat/InputQueue/storage.js +108 -0
  36. package/lib/AgentScopeRuntimeWebUI/core/Chat/MessageList/index.js +4 -4
  37. package/lib/AgentScopeRuntimeWebUI/core/Chat/hooks/useChatController.d.ts +15 -1
  38. package/lib/AgentScopeRuntimeWebUI/core/Chat/hooks/useChatController.js +259 -147
  39. package/lib/AgentScopeRuntimeWebUI/core/Chat/hooks/useChatRequest.d.ts +1 -1
  40. package/lib/AgentScopeRuntimeWebUI/core/Chat/hooks/useChatRequest.js +98 -65
  41. package/lib/AgentScopeRuntimeWebUI/core/Chat/hooks/useChatSessionHandler.d.ts +3 -3
  42. package/lib/AgentScopeRuntimeWebUI/core/Chat/hooks/useChatSessionHandler.js +33 -12
  43. package/lib/AgentScopeRuntimeWebUI/core/Chat/hooks/useInputQueueController.d.ts +59 -0
  44. package/lib/AgentScopeRuntimeWebUI/core/Chat/hooks/useInputQueueController.js +1018 -0
  45. package/lib/AgentScopeRuntimeWebUI/core/Chat/index.js +33 -2
  46. package/lib/AgentScopeRuntimeWebUI/core/Chat/styles.js +161 -1
  47. package/lib/AgentScopeRuntimeWebUI/core/Context/ChatAnywhereI18nContext.d.ts +23 -1
  48. package/lib/AgentScopeRuntimeWebUI/core/Context/ChatAnywhereI18nContext.js +32 -8
  49. package/lib/AgentScopeRuntimeWebUI/core/Context/ChatAnywhereInputContext.js +4 -8
  50. package/lib/AgentScopeRuntimeWebUI/core/Context/ChatAnywhereSessionsContext.js +110 -21
  51. package/lib/AgentScopeRuntimeWebUI/core/types/IChatAnywhere.d.ts +79 -0
  52. package/lib/AgentScopeRuntimeWebUI/core/types/ISessions.d.ts +4 -0
  53. package/lib/AgentScopeRuntimeWebUI/starter/index.js +144 -20
  54. package/lib/Sender/index.d.ts +5 -0
  55. package/lib/Sender/index.js +8 -5
  56. package/package.json +2 -1
@@ -1,14 +1,21 @@
1
- import { useCallback } from "react";
1
+ import { useCallback, type ReactNode } from "react";
2
2
  import { useProviderContext, ChatInput, Disclaimer } from '@agentscope-ai/chat';
3
3
  import { useChatAnywhereOptions } from "../../Context/ChatAnywhereOptionsContext";
4
4
  import { useGetState } from 'ahooks';
5
5
  import { useChatAnywhereInput } from "../../Context/ChatAnywhereInputContext";
6
6
  import useAttachments from "./useAttachments";
7
7
  import { IAgentScopeRuntimeWebUIInputData } from "@agentscope-ai/chat";
8
+ import type { QueueEnqueueResult, QueuedInputItem } from "../InputQueue";
8
9
 
9
10
  export interface InputProps {
10
11
  onCancel: () => void;
11
12
  onSubmit: (data: IAgentScopeRuntimeWebUIInputData) => void;
13
+ queue?: {
14
+ items: QueuedInputItem[];
15
+ isOwner: boolean;
16
+ panel?: ReactNode;
17
+ onEnqueue: (data: IAgentScopeRuntimeWebUIInputData) => QueueEnqueueResult | Promise<QueueEnqueueResult>;
18
+ };
12
19
  }
13
20
 
14
21
  export default function Input(props: InputProps) {
@@ -42,24 +49,83 @@ export default function Input(props: InputProps) {
42
49
  uploadFileListHeader
43
50
  } = useAttachments(attachments, { disabled: !!inputContext.disabled });
44
51
 
52
+ const getSubmittableData = useCallback(() => {
53
+ const fileList = (getFileList?.() || []).filter(i => i.response?.url);
54
+ const query = getContent();
55
+ if (!query.trim() && fileList.length === 0) return;
56
+
57
+ return {
58
+ query,
59
+ text: query,
60
+ fileList,
61
+ attachments: fileList,
62
+ };
63
+ }, [getContent, getFileList]);
64
+
65
+ const clearInput = useCallback(() => {
66
+ setContent('');
67
+ setFileList?.([]);
68
+ }, [setContent, setFileList]);
69
+
70
+ const handleEnqueue = useCallback(async () => {
71
+ const next = await beforeSubmit();
72
+ if (!next) return;
73
+
74
+ const data = getSubmittableData();
75
+ if (!data) return;
76
+ if (!props.queue) return;
77
+
78
+ const result = await props.queue.onEnqueue(data);
79
+ if (result.ok) {
80
+ clearInput();
81
+ }
82
+ }, [beforeSubmit, clearInput, getSubmittableData, props.queue]);
45
83
 
46
84
  const handleSubmit = useCallback(async () => {
47
85
  const next = await beforeSubmit();
48
86
  if (!next) return;
49
87
 
50
- const fileList = (getFileList?.() || []).filter(i => i.response?.url);
51
- props.onSubmit({ query: getContent(), fileList });
52
- setContent('');
53
- setFileList?.([]);
54
- }, []);
88
+ const data = getSubmittableData();
89
+ if (!data) return;
90
+
91
+ if (props.queue && (
92
+ inputContext.loading ||
93
+ props.queue.items.length ||
94
+ props.queue.isOwner === false
95
+ )) {
96
+ const result = await props.queue.onEnqueue(data);
97
+ if (result.ok) {
98
+ clearInput();
99
+ }
100
+ } else {
101
+ props.onSubmit(data);
102
+ clearInput();
103
+ }
104
+ }, [beforeSubmit, clearInput, getSubmittableData, inputContext.loading, props.onSubmit, props.queue]);
105
+
106
+ const handleKeyDownCapture = useCallback((event: React.KeyboardEvent<HTMLDivElement>) => {
107
+ if (event.key !== 'Enter' || event.shiftKey) return;
108
+ if (event.nativeEvent?.isComposing) return;
109
+ if (!props.queue) return;
110
+ const forceEnqueue = event.ctrlKey || event.metaKey;
111
+ if (!forceEnqueue && !inputContext.loading && !props.queue?.items.length) return;
112
+
113
+ const data = getSubmittableData();
114
+ if (!data) return;
115
+
116
+ event.preventDefault();
117
+ event.stopPropagation();
118
+ void (forceEnqueue ? handleEnqueue() : handleSubmit());
119
+ }, [getSubmittableData, handleEnqueue, handleSubmit, inputContext.loading, props.queue, props.queue?.items.length]);
55
120
 
56
121
  const handleCancel = useCallback(() => {
57
122
  props.onCancel();
58
- }, []);
123
+ }, [props.onCancel]);
59
124
 
60
- return <div className={prefixCls}>
125
+ return <div className={prefixCls} onKeyDownCapture={handleKeyDownCapture}>
61
126
  <div className={`${prefixCls}-wrapper`}>
62
127
  {beforeUI}
128
+ {props.queue?.panel}
63
129
  <ChatInput
64
130
  loading={inputContext.loading}
65
131
  disabled={inputContext.disabled}
@@ -77,6 +143,7 @@ export default function Input(props: InputProps) {
77
143
  actionAffix={actionAffix}
78
144
  onSubmit={handleSubmit}
79
145
  onCancel={handleCancel}
146
+ allowSubmitWhenLoading={!!props.queue}
80
147
  allowSpeech={allowSpeech}
81
148
  onPasteFile={handlePasteFile}
82
149
  onDropFile={handleDropFile}
@@ -0,0 +1,289 @@
1
+ import { useEffect, useState } from 'react';
2
+ import { useProviderContext } from '@agentscope-ai/chat';
3
+ import { IconButton } from '@agentscope-ai/design';
4
+ import {
5
+ SparkClearLine,
6
+ SparkDeleteLine,
7
+ SparkDragDotLine,
8
+ SparkEditLine,
9
+ SparkPauseLine,
10
+ SparkPlayLine,
11
+ SparkRefreshLine,
12
+ SparkSendLine,
13
+ } from '@agentscope-ai/icons';
14
+ import { Tooltip } from 'antd';
15
+ import { useTranslation } from '../../Context/ChatAnywhereI18nContext';
16
+ import type { QueuedInputItem } from './index';
17
+
18
+ interface InputQueuePanelProps {
19
+ items: QueuedInputItem[];
20
+ paused: boolean;
21
+ isOwner: boolean;
22
+ onRemove: (id: string) => void;
23
+ onClear: () => void;
24
+ onRetry: (id: string) => void;
25
+ onTogglePaused: () => void;
26
+ onReorder: (sourceId: string, targetId: string) => void;
27
+ onUpdateQuery: (id: string, query: string) => void;
28
+ onSendNow: (id: string) => void;
29
+ }
30
+
31
+ export default function InputQueuePanel(props: InputQueuePanelProps) {
32
+ const {
33
+ items,
34
+ paused,
35
+ isOwner,
36
+ onRemove,
37
+ onClear,
38
+ onRetry,
39
+ onTogglePaused,
40
+ onReorder,
41
+ onUpdateQuery,
42
+ onSendNow,
43
+ } = props;
44
+ const [draggingId, setDraggingId] = useState<string>();
45
+ const [dragOverId, setDragOverId] = useState<string>();
46
+ const [editingId, setEditingId] = useState<string>();
47
+ const [draftQuery, setDraftQuery] = useState('');
48
+ const prefixCls = useProviderContext().getPrefixCls(
49
+ 'chat-anywhere-input-queue',
50
+ );
51
+ const { t } = useTranslation();
52
+ const tr = (key: Parameters<typeof t>[0]) => t?.(key) || key;
53
+
54
+ useEffect(() => {
55
+ if (editingId && !items.some(item => item.id === editingId)) {
56
+ setEditingId(undefined);
57
+ setDraftQuery('');
58
+ }
59
+ }, [editingId, items]);
60
+
61
+ const isNoDragTarget = (target: EventTarget | null) => {
62
+ return target instanceof HTMLElement && !!target.closest('[data-no-drag]');
63
+ };
64
+
65
+ const startEdit = (item: QueuedInputItem) => {
66
+ setEditingId(item.id);
67
+ setDraftQuery(item.data.query || '');
68
+ };
69
+
70
+ const commitEdit = () => {
71
+ if (!editingId) return;
72
+ onUpdateQuery(editingId, draftQuery);
73
+ setEditingId(undefined);
74
+ setDraftQuery('');
75
+ };
76
+
77
+ const cancelEdit = () => {
78
+ setEditingId(undefined);
79
+ setDraftQuery('');
80
+ };
81
+
82
+ const clearDragState = () => {
83
+ setDraggingId(undefined);
84
+ setDragOverId(undefined);
85
+ };
86
+
87
+ const getFileName = (file: NonNullable<QueuedInputItem['data']['fileList']>[number]) => {
88
+ return file.name || file.response?.name || file.response?.url || file.url || tr('queue.attachmentOnly');
89
+ };
90
+
91
+ const getFileUrl = (file: NonNullable<QueuedInputItem['data']['fileList']>[number]) => {
92
+ return file.thumbUrl || file.url || file.response?.url;
93
+ };
94
+
95
+ const isImageFile = (file: NonNullable<QueuedInputItem['data']['fileList']>[number]) => {
96
+ return file.type?.startsWith('image/') || /\.(png|jpe?g|gif|webp|bmp|svg)$/i.test(getFileName(file));
97
+ };
98
+
99
+ if (!items.length) return null;
100
+
101
+ return (
102
+ <div className={prefixCls}>
103
+ <div className={`${prefixCls}-header`}>
104
+ <div className={`${prefixCls}-title`}>
105
+ <span className={`${prefixCls}-pulse`} />
106
+ <span>{tr('queue.title')}</span>
107
+ {!isOwner ? (
108
+ <span className={`${prefixCls}-owner`}>{tr('queue.remoteOwner')}</span>
109
+ ) : null}
110
+ <span className={`${prefixCls}-count`}>{items.length}</span>
111
+ </div>
112
+ {isOwner ? <div className={`${prefixCls}-header-actions`}>
113
+ <Tooltip title={paused ? tr('queue.resume') : tr('queue.pause')}>
114
+ <IconButton
115
+ size="small"
116
+ bordered={false}
117
+ className={`${prefixCls}-clear`}
118
+ icon={paused ? <SparkPlayLine /> : <SparkPauseLine />}
119
+ onClick={onTogglePaused}
120
+ />
121
+ </Tooltip>
122
+ <Tooltip title={tr('queue.clear')}>
123
+ <IconButton
124
+ size="small"
125
+ bordered={false}
126
+ className={`${prefixCls}-clear`}
127
+ icon={<SparkClearLine />}
128
+ onClick={onClear}
129
+ />
130
+ </Tooltip>
131
+ </div> : null}
132
+ </div>
133
+ <div className={`${prefixCls}-list`}>
134
+ {items.map((item, index) => {
135
+ const failed = item.status === 'failed';
136
+ const files = item.data.attachments || item.data.fileList || [];
137
+ const statusText = failed
138
+ ? tr('queue.failed')
139
+ : index === 0
140
+ ? tr('queue.next')
141
+ : `${index + 1}`;
142
+ const queryText = item.data.query || tr('queue.attachmentOnly');
143
+ const itemClassName = [
144
+ `${prefixCls}-item`,
145
+ index === 0 ? `${prefixCls}-item-next` : '',
146
+ failed ? `${prefixCls}-item-failed` : '',
147
+ ]
148
+ .filter(Boolean)
149
+ .join(' ');
150
+
151
+ return (
152
+ <div
153
+ className={[
154
+ itemClassName,
155
+ draggingId === item.id ? `${prefixCls}-item-dragging` : '',
156
+ dragOverId === item.id && draggingId !== item.id
157
+ ? `${prefixCls}-item-drag-over`
158
+ : '',
159
+ ].filter(Boolean).join(' ')}
160
+ draggable
161
+ key={item.id}
162
+ onDragStart={(event) => {
163
+ if (isNoDragTarget(event.target)) {
164
+ event.preventDefault();
165
+ return;
166
+ }
167
+
168
+ setDraggingId(item.id);
169
+ event.dataTransfer.effectAllowed = 'move';
170
+ event.dataTransfer.setData('text/plain', item.id);
171
+ }}
172
+ onDragOver={(event) => {
173
+ if (!draggingId || draggingId === item.id) return;
174
+ event.preventDefault();
175
+ event.dataTransfer.dropEffect = 'move';
176
+ setDragOverId(item.id);
177
+ }}
178
+ onDragLeave={() => {
179
+ if (dragOverId === item.id) setDragOverId(undefined);
180
+ }}
181
+ onDrop={(event) => {
182
+ event.preventDefault();
183
+ const sourceId = draggingId || event.dataTransfer.getData('text/plain');
184
+ clearDragState();
185
+ if (sourceId && sourceId !== item.id) {
186
+ onReorder(sourceId, item.id);
187
+ }
188
+ }}
189
+ onDragEnd={clearDragState}
190
+ >
191
+ <span className={`${prefixCls}-drag-handle`}>
192
+ <SparkDragDotLine />
193
+ </span>
194
+ <span className={`${prefixCls}-index`}>{statusText}</span>
195
+ <div className={`${prefixCls}-content`}>
196
+ {editingId === item.id ? (
197
+ <input
198
+ autoFocus
199
+ className={`${prefixCls}-edit-input`}
200
+ data-no-drag
201
+ value={draftQuery}
202
+ onBlur={commitEdit}
203
+ onChange={(event) => setDraftQuery(event.target.value)}
204
+ onKeyDown={(event) => {
205
+ if (event.key === 'Enter') {
206
+ event.preventDefault();
207
+ commitEdit();
208
+ }
209
+ if (event.key === 'Escape') {
210
+ event.preventDefault();
211
+ cancelEdit();
212
+ }
213
+ }}
214
+ />
215
+ ) : (
216
+ <span className={`${prefixCls}-text`} title={queryText}>
217
+ {queryText}
218
+ </span>
219
+ )}
220
+ {files.length ? (
221
+ <span className={`${prefixCls}-files`}>
222
+ {files.slice(0, 2).map(file => {
223
+ const name = getFileName(file);
224
+ const url = getFileUrl(file);
225
+ return (
226
+ <span className={`${prefixCls}-file`} key={file.uid || name} title={name}>
227
+ {isImageFile(file) && url ? (
228
+ <img alt="" className={`${prefixCls}-file-thumb`} src={url} />
229
+ ) : (
230
+ <span className={`${prefixCls}-file-icon`}>#</span>
231
+ )}
232
+ <span className={`${prefixCls}-file-name`}>{name}</span>
233
+ </span>
234
+ );
235
+ })}
236
+ {files.length > 2 ? (
237
+ <span className={`${prefixCls}-file-more`}>+{files.length - 2}</span>
238
+ ) : null}
239
+ </span>
240
+ ) : null}
241
+ {failed ? (
242
+ <span className={`${prefixCls}-error`}>
243
+ {item.errorMessage || tr('queue.failed')}
244
+ </span>
245
+ ) : null}
246
+ </div>
247
+ <div className={`${prefixCls}-actions`} data-no-drag>
248
+ <Tooltip title={tr('common.edit')}>
249
+ <IconButton
250
+ size="small"
251
+ bordered={false}
252
+ icon={<SparkEditLine />}
253
+ onClick={() => startEdit(item)}
254
+ />
255
+ </Tooltip>
256
+ {isOwner ? <Tooltip title={tr('queue.sendNow')}>
257
+ <IconButton
258
+ size="small"
259
+ bordered={false}
260
+ icon={<SparkSendLine />}
261
+ onClick={() => onSendNow(item.id)}
262
+ />
263
+ </Tooltip> : null}
264
+ {isOwner && failed ? (
265
+ <Tooltip title={tr('queue.retry')}>
266
+ <IconButton
267
+ size="small"
268
+ bordered={false}
269
+ icon={<SparkRefreshLine />}
270
+ onClick={() => onRetry(item.id)}
271
+ />
272
+ </Tooltip>
273
+ ) : null}
274
+ {isOwner ? <Tooltip title={tr('common.delete')}>
275
+ <IconButton
276
+ size="small"
277
+ bordered={false}
278
+ icon={<SparkDeleteLine />}
279
+ onClick={() => onRemove(item.id)}
280
+ />
281
+ </Tooltip> : null}
282
+ </div>
283
+ </div>
284
+ );
285
+ })}
286
+ </div>
287
+ </div>
288
+ );
289
+ }
@@ -0,0 +1,51 @@
1
+ # 输入队列测试用例
2
+
3
+ > 语雀参考文档在当前环境返回 BUC SSO 登录页,无法读取正文。以下用例基于当前输入队列实现、组件配置项以及已知业务场景整理。
4
+
5
+ ## 自动化覆盖
6
+
7
+ | ID | 场景 | 前置条件 | 操作 | 预期 |
8
+ | ------ | -------------------------- | ------------------------------------------------- | ---------------------------------- | ------------------------------------------------------ |
9
+ | IQ-A01 | FIFO 入队和出队 | 空队列 | 连续加入 first、second,再出队两次 | 出队顺序为 first、second |
10
+ | IQ-A02 | 直接发送条件 | loading=false、队列为空、未 drain | 判断是否可直接发送 | 返回 true |
11
+ | IQ-A03 | loading 时不直接发送 | loading=true | 判断是否可直接发送 | 返回 false,应进入入队路径 |
12
+ | IQ-A04 | 队列已有任务时不直接发送 | queueLength>0 | 判断是否可直接发送 | 返回 false,后续输入排到队尾 |
13
+ | IQ-A05 | 队列暂停时不直接发送 | paused=true | 判断是否可直接发送 | 返回 false |
14
+ | IQ-A06 | 非 owner tab 不直接发送 | canExecute=false | 判断是否可直接发送 | 返回 false,仅允许编辑/排序 |
15
+ | IQ-A07 | 队列满 | maxSize=1 且已有 1 条 | 再入队 1 条 | 返回 full,原队列不变 |
16
+ | IQ-A08 | 附件消息别名 | 输入包含 fileList | 创建 queued item | text/query、attachments/fileList 保持可发送结构 |
17
+ | IQ-A09 | 发送失败恢复 | 任务发送失败 | restore failed item | 任务回到队首,状态 failed,自动 drain 被阻塞 |
18
+ | IQ-A10 | 重试失败任务 | failed item | retry | 状态变回 pending,可再次出队 |
19
+ | IQ-A11 | 删除任务 | 队列含 q1、q2 | 删除 q1 | 仅剩 q2;删除不存在 id 不影响队列 |
20
+ | IQ-A12 | 拖拽排序 | 队列含 q1、q2、q3 | q3 移到 q1 前 | 顺序变为 q3、q1、q2 |
21
+ | IQ-A13 | 编辑 queued query | failed item | 修改 query | query/text 同步更新,状态回 pending |
22
+ | IQ-A14 | 空队列可清理存储 | 队列为空/非空 | 判断 empty | 空队列为 true,非空为 false |
23
+ | IQ-A15 | send-now 命令 | 指定 itemId 和 sourceTabId | 创建命令 | 命令携带 itemId、sourceTabId、createdAt |
24
+ | IQ-A16 | 无 session 不入队 | current/pending/active 均为空 | 解析 queue session | 返回 undefined,用于触发 session-not-ready 提示 |
25
+ | IQ-A17 | 初始化会话 route 未回填 | currentSessionId 为空,pendingRouteSessionId 有值 | 解析 route queue session | 使用 pendingRouteSessionId 作为队列 key |
26
+ | IQ-A18 | active request 兜底 | current/pending 为空,activeSessionId 有值 | 解析 visible queue session | visible queue 使用 activeSessionId;route queue 仍为空 |
27
+ | IQ-A19 | session 优先级 | current、pending、active 同时存在 | 解析 visible session | current 优先,其次 pending,再次 active |
28
+ | IQ-A20 | CoPaw temp/realId 稳定 key | tempId 和 realId 都映射到同一 backend session_id | 分别解析 queue session | 两者得到同一个 queue key |
29
+ | IQ-A21 | 切换 session 存储隔离 | session-a 和 session-b 各有队列 | 分别读写 localStorage key | 两个 session 的队列互不污染 |
30
+ | IQ-A22 | 多 tab 同 session | tab-a/tab-b 打开同一 session | 比较 storage key 和 owner | storage key 相同;仅 owner tab 可发送 |
31
+
32
+ ## 浏览器回归用例
33
+
34
+ | ID | 场景 | 前置条件 | 操作 | 预期 |
35
+ | ------ | -------------------------- | ----------------------------------------------- | -------------------------- | --------------------------------------------------- |
36
+ | IQ-M01 | 初始化对话首条消息 | `/chat` 无 sessionId,队列开启 | 输入首条消息并发送 | 直接创建会话并发送,不出现队列项 |
37
+ | IQ-M02 | 初始化会话生成中再次输入 | 首条消息已发送,外部受控 sessionId 尚未稳定 | 再次尝试入队 | 不发起入队,出现“当前会话生成中”提示,输入不被清空 |
38
+ | IQ-M03 | 初始化会话 pending id 可用 | createSession 已返回 id,但外部受控 sessionId 还未回填 | 再次输入 | 队列绑定 pending id,不落到空 key |
39
+ | IQ-M04 | sessionId alias 变化后队列不丢 | 新会话已有队列,随后受控 sessionId 从临时 id 更新为同一会话的真实 id | 查看队列 | 队列仍展示在同一会话,不丢失、不串到其他会话 |
40
+ | IQ-M04b | sessionId alias 变化不打断 drain | 新会话首条消息 SSE 中已有队列,随后受控 sessionId 从临时 id 更新为同一会话的真实 id | 等待首条 SSE 完成 | SSE 不被误判为切会话而中断;finish 后自动 drain 下一条 |
41
+ | IQ-M05 | 切换 session 隔离 | session A 有队列,session B 无队列 | 从 A 切到 B,再切回 A | B 不显示 A 队列;切回 A 队列仍在 |
42
+ | IQ-M06 | 切换时队列任务不写入新会话 | A 正在 drain 队列,用户切到 B | 等待队列发送 | A 的任务不渲染到 B;若检测到切换,任务恢复到 A 队列 |
43
+ | IQ-M07 | 多 tab 同 session 同步 | 两个标签页打开同一 session | tab-a 入队,tab-b 观察 | tab-b 同步显示队列 |
44
+ | IQ-M08 | 多 tab owner 限制 | tab-a 是 owner,tab-b 同 session | tab-b 点击发送/立即发送 | tab-b 只能编辑/排序;真实发送由 owner tab 执行 |
45
+ | IQ-M09 | owner tab 关闭 | tab-a owner,tab-b 同 session | 关闭 tab-a,等待 owner TTL | tab-b 可接管发送 |
46
+ | IQ-M10 | 暂停/恢复跨 tab 同步 | 同 session 两个 tab | tab-a 暂停/恢复 | tab-b 状态同步;暂停时不自动 drain |
47
+ | IQ-M11 | 队列满提示 | maxSize 设小 | 连续入队到超过上限 | 超限输入不入队,提示 queue full,原队列不变 |
48
+ | IQ-M12 | 附件队列 | 上传成功附件但文本为空 | 入队并发送 | 队列显示附件消息,发送体保留 attachments/fileList |
49
+ | IQ-M13 | 失败任务重试 | 模拟请求失败 | 点击重试 | 任务回到 pending 并重新发送 |
50
+ | IQ-M14 | 立即发送 | 队列中有多条任务 | 对第二条点击立即发送 | 该任务优先发送,其他任务顺序保持 |
51
+ | IQ-M15 | 清空队列 | 队列非空 | 点击清空 | 队列清空,localStorage 对应 key 被移除 |