@agentscope-ai/chat 1.1.70 → 1.1.71-beta.1782109675297
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/components/AgentScopeRuntimeWebUI/core/Chat/Input/index.tsx +75 -9
- package/components/AgentScopeRuntimeWebUI/core/Chat/InputQueue/Panel.tsx +289 -0
- package/components/AgentScopeRuntimeWebUI/core/Chat/InputQueue/__tests__/inputQueue.test.ts +235 -0
- package/components/AgentScopeRuntimeWebUI/core/Chat/InputQueue/index.ts +319 -0
- package/components/AgentScopeRuntimeWebUI/core/Chat/MessageList/index.tsx +4 -4
- package/components/AgentScopeRuntimeWebUI/core/Chat/hooks/useChatController.tsx +536 -6
- package/components/AgentScopeRuntimeWebUI/core/Chat/index.tsx +42 -3
- package/components/AgentScopeRuntimeWebUI/core/Chat/styles.tsx +320 -4
- package/components/AgentScopeRuntimeWebUI/core/ChatAnywhere/index.tsx +1 -1
- package/components/AgentScopeRuntimeWebUI/core/Context/ChatAnywhereI18nContext.tsx +34 -8
- package/components/AgentScopeRuntimeWebUI/core/types/IChatAnywhere.ts +54 -1
- package/components/AgentScopeRuntimeWebUI/starter/index.tsx +103 -14
- package/lib/AgentScopeRuntimeWebUI/core/Chat/Input/index.d.ts +8 -0
- package/lib/AgentScopeRuntimeWebUI/core/Chat/Input/index.js +103 -16
- package/lib/AgentScopeRuntimeWebUI/core/Chat/InputQueue/Panel.d.ts +15 -0
- package/lib/AgentScopeRuntimeWebUI/core/Chat/InputQueue/Panel.js +275 -0
- package/lib/AgentScopeRuntimeWebUI/core/Chat/InputQueue/index.d.ts +83 -0
- package/lib/AgentScopeRuntimeWebUI/core/Chat/InputQueue/index.js +202 -0
- package/lib/AgentScopeRuntimeWebUI/core/Chat/MessageList/index.js +4 -4
- package/lib/AgentScopeRuntimeWebUI/core/Chat/hooks/useChatController.d.ts +14 -0
- package/lib/AgentScopeRuntimeWebUI/core/Chat/hooks/useChatController.js +687 -86
- package/lib/AgentScopeRuntimeWebUI/core/Chat/index.js +33 -2
- package/lib/AgentScopeRuntimeWebUI/core/Chat/styles.js +155 -1
- package/lib/AgentScopeRuntimeWebUI/core/Context/ChatAnywhereI18nContext.d.ts +23 -1
- package/lib/AgentScopeRuntimeWebUI/core/Context/ChatAnywhereI18nContext.js +32 -8
- package/lib/AgentScopeRuntimeWebUI/core/types/IChatAnywhere.d.ts +65 -1
- package/lib/AgentScopeRuntimeWebUI/starter/index.js +148 -21
- package/package.json +2 -1
- package/bin/starter_webui/README.md +0 -75
- package/bin/starter_webui/eslint.config.js +0 -28
- package/bin/starter_webui/index.html +0 -12
- package/bin/starter_webui/package.json +0 -34
- package/bin/starter_webui/src/App.tsx +0 -20
- package/bin/starter_webui/src/components/Chat/OptionsPanel/FormItem.tsx +0 -37
- package/bin/starter_webui/src/components/Chat/OptionsPanel/OptionsEditor.tsx +0 -160
- package/bin/starter_webui/src/components/Chat/OptionsPanel/defaultConfig.ts +0 -41
- package/bin/starter_webui/src/components/Chat/OptionsPanel/index.tsx +0 -27
- package/bin/starter_webui/src/components/Chat/index.tsx +0 -45
- package/bin/starter_webui/src/components/Chat/sessionApi/index.ts +0 -53
- package/bin/starter_webui/src/main.tsx +0 -9
- package/bin/starter_webui/src/vite-env.d.ts +0 -4
- package/bin/starter_webui/tsconfig.app.json +0 -24
- package/bin/starter_webui/tsconfig.json +0 -7
- package/bin/starter_webui/tsconfig.node.json +0 -22
- package/bin/starter_webui/vite.config.ts +0 -11
|
@@ -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) {
|
|
@@ -38,24 +45,83 @@ export default function Input(props: InputProps) {
|
|
|
38
45
|
uploadFileListHeader
|
|
39
46
|
} = useAttachments(attachments, { disabled: !!inputContext.disabled });
|
|
40
47
|
|
|
48
|
+
const getSubmittableData = useCallback(() => {
|
|
49
|
+
const fileList = (getFileList?.() || []).filter(i => i.response?.url);
|
|
50
|
+
const query = getContent();
|
|
51
|
+
if (!query.trim() && fileList.length === 0) return;
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
query,
|
|
55
|
+
text: query,
|
|
56
|
+
fileList,
|
|
57
|
+
attachments: fileList,
|
|
58
|
+
};
|
|
59
|
+
}, [getContent, getFileList]);
|
|
60
|
+
|
|
61
|
+
const clearInput = useCallback(() => {
|
|
62
|
+
setContent('');
|
|
63
|
+
setFileList?.([]);
|
|
64
|
+
}, [setContent, setFileList]);
|
|
65
|
+
|
|
66
|
+
const handleEnqueue = useCallback(async () => {
|
|
67
|
+
const next = await beforeSubmit();
|
|
68
|
+
if (!next) return;
|
|
69
|
+
|
|
70
|
+
const data = getSubmittableData();
|
|
71
|
+
if (!data) return;
|
|
72
|
+
if (!props.queue) return;
|
|
73
|
+
|
|
74
|
+
const result = await props.queue.onEnqueue(data);
|
|
75
|
+
if (result.ok) {
|
|
76
|
+
clearInput();
|
|
77
|
+
}
|
|
78
|
+
}, [beforeSubmit, clearInput, getSubmittableData, props.queue]);
|
|
41
79
|
|
|
42
80
|
const handleSubmit = useCallback(async () => {
|
|
43
81
|
const next = await beforeSubmit();
|
|
44
82
|
if (!next) return;
|
|
45
83
|
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
84
|
+
const data = getSubmittableData();
|
|
85
|
+
if (!data) return;
|
|
86
|
+
|
|
87
|
+
if (props.queue && (
|
|
88
|
+
inputContext.loading ||
|
|
89
|
+
props.queue.items.length ||
|
|
90
|
+
props.queue.isOwner === false
|
|
91
|
+
)) {
|
|
92
|
+
const result = await props.queue.onEnqueue(data);
|
|
93
|
+
if (result.ok) {
|
|
94
|
+
clearInput();
|
|
95
|
+
}
|
|
96
|
+
} else {
|
|
97
|
+
props.onSubmit(data);
|
|
98
|
+
clearInput();
|
|
99
|
+
}
|
|
100
|
+
}, [beforeSubmit, clearInput, getSubmittableData, inputContext.loading, props.onSubmit, props.queue]);
|
|
101
|
+
|
|
102
|
+
const handleKeyDownCapture = useCallback((event: React.KeyboardEvent<HTMLDivElement>) => {
|
|
103
|
+
if (event.key !== 'Enter' || event.shiftKey) return;
|
|
104
|
+
if (event.nativeEvent?.isComposing) return;
|
|
105
|
+
if (!props.queue) return;
|
|
106
|
+
const forceEnqueue = event.ctrlKey || event.metaKey;
|
|
107
|
+
if (!forceEnqueue && !inputContext.loading && !props.queue?.items.length) return;
|
|
108
|
+
|
|
109
|
+
const data = getSubmittableData();
|
|
110
|
+
if (!data) return;
|
|
111
|
+
|
|
112
|
+
event.preventDefault();
|
|
113
|
+
event.stopPropagation();
|
|
114
|
+
void (forceEnqueue ? handleEnqueue() : handleSubmit());
|
|
115
|
+
}, [getSubmittableData, handleEnqueue, handleSubmit, inputContext.loading, props.queue, props.queue?.items.length]);
|
|
51
116
|
|
|
52
117
|
const handleCancel = useCallback(() => {
|
|
53
118
|
props.onCancel();
|
|
54
|
-
}, []);
|
|
119
|
+
}, [props.onCancel]);
|
|
55
120
|
|
|
56
|
-
return <div className={prefixCls}>
|
|
121
|
+
return <div className={prefixCls} onKeyDownCapture={handleKeyDownCapture}>
|
|
57
122
|
<div className={`${prefixCls}-wrapper`}>
|
|
58
123
|
{beforeUI}
|
|
124
|
+
{props.queue?.panel}
|
|
59
125
|
<ChatInput
|
|
60
126
|
loading={inputContext.loading}
|
|
61
127
|
disabled={inputContext.disabled}
|
|
@@ -80,4 +146,4 @@ export default function Input(props: InputProps) {
|
|
|
80
146
|
disclaimer ? <Disclaimer desc={disclaimer} /> : <div className={`${prefixCls}-blank`}></div>
|
|
81
147
|
}
|
|
82
148
|
</div>;
|
|
83
|
-
}
|
|
149
|
+
}
|
|
@@ -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,235 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import {
|
|
3
|
+
assignInputQueueOwner,
|
|
4
|
+
canSubmitDirectly,
|
|
5
|
+
createEmptyInputQueueState,
|
|
6
|
+
createQueuedInputItem,
|
|
7
|
+
createSendNowCommand,
|
|
8
|
+
dequeueNextQueuedInput,
|
|
9
|
+
enqueueInputQueueState,
|
|
10
|
+
enqueueQueuedInput,
|
|
11
|
+
INPUT_QUEUE_OWNER_TTL,
|
|
12
|
+
isInputQueueOwner,
|
|
13
|
+
isInputQueueStateEmpty,
|
|
14
|
+
reorderQueuedInput,
|
|
15
|
+
removeQueuedInput,
|
|
16
|
+
restoreFailedQueuedInput,
|
|
17
|
+
retryQueuedInput,
|
|
18
|
+
updateQueuedInputQuery,
|
|
19
|
+
} from '../index';
|
|
20
|
+
|
|
21
|
+
const input = (query: string) => ({ query });
|
|
22
|
+
|
|
23
|
+
function test(name: string, fn: () => void) {
|
|
24
|
+
try {
|
|
25
|
+
fn();
|
|
26
|
+
console.log(`ok - ${name}`);
|
|
27
|
+
} catch (error) {
|
|
28
|
+
console.error(`not ok - ${name}`);
|
|
29
|
+
throw error;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
test('enqueue appends inputs in FIFO order and dequeue consumes the head', () => {
|
|
34
|
+
let queue = enqueueQueuedInput([], input('first'), {
|
|
35
|
+
id: 'q1',
|
|
36
|
+
now: 1,
|
|
37
|
+
}).queue;
|
|
38
|
+
queue = enqueueQueuedInput(queue, input('second'), {
|
|
39
|
+
id: 'q2',
|
|
40
|
+
now: 2,
|
|
41
|
+
}).queue;
|
|
42
|
+
|
|
43
|
+
const first = dequeueNextQueuedInput(queue);
|
|
44
|
+
assert.equal(first.item?.data.query, 'first');
|
|
45
|
+
assert.deepEqual(first.queue.map(item => item.data.query), ['second']);
|
|
46
|
+
|
|
47
|
+
const second = dequeueNextQueuedInput(first.queue);
|
|
48
|
+
assert.equal(second.item?.data.query, 'second');
|
|
49
|
+
assert.equal(second.queue.length, 0);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('direct submit is allowed only when idle, queue is empty and no drain is active', () => {
|
|
53
|
+
assert.equal(
|
|
54
|
+
canSubmitDirectly({ loading: false, queueLength: 0, draining: false }),
|
|
55
|
+
true,
|
|
56
|
+
);
|
|
57
|
+
assert.equal(
|
|
58
|
+
canSubmitDirectly({ loading: true, queueLength: 0, draining: false }),
|
|
59
|
+
false,
|
|
60
|
+
);
|
|
61
|
+
assert.equal(
|
|
62
|
+
canSubmitDirectly({ loading: false, queueLength: 1, draining: false }),
|
|
63
|
+
false,
|
|
64
|
+
);
|
|
65
|
+
assert.equal(
|
|
66
|
+
canSubmitDirectly({ loading: false, queueLength: 0, draining: true }),
|
|
67
|
+
false,
|
|
68
|
+
);
|
|
69
|
+
assert.equal(
|
|
70
|
+
canSubmitDirectly({
|
|
71
|
+
loading: false,
|
|
72
|
+
queueLength: 0,
|
|
73
|
+
draining: false,
|
|
74
|
+
paused: true,
|
|
75
|
+
}),
|
|
76
|
+
false,
|
|
77
|
+
);
|
|
78
|
+
assert.equal(
|
|
79
|
+
canSubmitDirectly({
|
|
80
|
+
loading: false,
|
|
81
|
+
queueLength: 0,
|
|
82
|
+
draining: false,
|
|
83
|
+
canExecute: false,
|
|
84
|
+
}),
|
|
85
|
+
false,
|
|
86
|
+
);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('enqueue rejects new input when the queue reaches max size', () => {
|
|
90
|
+
const queue = enqueueQueuedInput([], input('first'), {
|
|
91
|
+
id: 'q1',
|
|
92
|
+
maxSize: 1,
|
|
93
|
+
}).queue;
|
|
94
|
+
const result = enqueueQueuedInput(queue, input('second'), {
|
|
95
|
+
id: 'q2',
|
|
96
|
+
maxSize: 1,
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
assert.equal(result.reason, 'full');
|
|
100
|
+
assert.deepEqual(result.queue.map(item => item.id), ['q1']);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('full queue state is not rewritten when enqueue is rejected', () => {
|
|
104
|
+
const state = enqueueInputQueueState(createEmptyInputQueueState(1), input('first'), {
|
|
105
|
+
id: 'q1',
|
|
106
|
+
maxSize: 1,
|
|
107
|
+
now: 2,
|
|
108
|
+
}).state;
|
|
109
|
+
const result = enqueueInputQueueState(state, input('second'), {
|
|
110
|
+
id: 'q2',
|
|
111
|
+
maxSize: 1,
|
|
112
|
+
now: 3,
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
assert.equal(result.reason, 'full');
|
|
116
|
+
assert.equal(result.state, state);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test('queued input item preserves full message body aliases', () => {
|
|
120
|
+
const file = { uid: 'f1', name: 'shot.png', response: { url: '/shot.png' } };
|
|
121
|
+
const item = createQueuedInputItem({
|
|
122
|
+
query: 'with file',
|
|
123
|
+
fileList: [file as any],
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
assert.equal(item.data.text, 'with file');
|
|
127
|
+
assert.deepEqual(item.data.attachments, [file]);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test('failed send is restored at the queue head and blocks automatic dequeue', () => {
|
|
131
|
+
const original = enqueueQueuedInput([], input('send me'), {
|
|
132
|
+
id: 'q1',
|
|
133
|
+
}).queue[0];
|
|
134
|
+
const queue = restoreFailedQueuedInput([], original, new Error('network'));
|
|
135
|
+
|
|
136
|
+
assert.equal(queue[0].status, 'failed');
|
|
137
|
+
assert.equal(queue[0].retryCount, 1);
|
|
138
|
+
assert.equal(queue[0].errorMessage, 'network');
|
|
139
|
+
|
|
140
|
+
const next = dequeueNextQueuedInput(queue);
|
|
141
|
+
assert.equal(next.item, undefined);
|
|
142
|
+
assert.equal(next.queue, queue);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test('retry marks a failed item pending so it can be sent again', () => {
|
|
146
|
+
const original = enqueueQueuedInput([], input('retry me'), {
|
|
147
|
+
id: 'q1',
|
|
148
|
+
}).queue[0];
|
|
149
|
+
const failed = restoreFailedQueuedInput([], original, 'boom');
|
|
150
|
+
const retried = retryQueuedInput(failed, 'q1');
|
|
151
|
+
const next = dequeueNextQueuedInput(retried);
|
|
152
|
+
|
|
153
|
+
assert.equal(retried[0].status, 'pending');
|
|
154
|
+
assert.equal(retried[0].errorMessage, undefined);
|
|
155
|
+
assert.equal(next.item?.id, 'q1');
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test('remove deletes only the selected queued input', () => {
|
|
159
|
+
let queue = enqueueQueuedInput([], input('first'), { id: 'q1' }).queue;
|
|
160
|
+
queue = enqueueQueuedInput(queue, input('second'), { id: 'q2' }).queue;
|
|
161
|
+
|
|
162
|
+
assert.deepEqual(removeQueuedInput(queue, 'q1').map(item => item.id), ['q2']);
|
|
163
|
+
assert.deepEqual(removeQueuedInput(queue, 'missing').map(item => item.id), [
|
|
164
|
+
'q1',
|
|
165
|
+
'q2',
|
|
166
|
+
]);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test('queue state keeps owner and paused metadata separate from items', () => {
|
|
170
|
+
const empty = createEmptyInputQueueState(1);
|
|
171
|
+
const queued = enqueueInputQueueState(empty, input('first'), {
|
|
172
|
+
id: 'q1',
|
|
173
|
+
now: 2,
|
|
174
|
+
ownerTabId: 'tab-a',
|
|
175
|
+
}).state;
|
|
176
|
+
|
|
177
|
+
assert.equal(queued.ownerTabId, 'tab-a');
|
|
178
|
+
assert.equal(queued.ownerUpdatedAt, 2);
|
|
179
|
+
assert.equal(queued.paused, false);
|
|
180
|
+
assert.deepEqual(queued.items.map(item => item.id), ['q1']);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test('queue owner is isolated by tab and can be reclaimed when stale', () => {
|
|
184
|
+
const owned = assignInputQueueOwner(createEmptyInputQueueState(1), 'tab-a', 10);
|
|
185
|
+
|
|
186
|
+
assert.equal(isInputQueueOwner(owned, 'tab-a', 12), true);
|
|
187
|
+
assert.equal(isInputQueueOwner(owned, 'tab-b', 12), false);
|
|
188
|
+
assert.equal(isInputQueueOwner(owned, 'tab-b', 10 + INPUT_QUEUE_OWNER_TTL + 1), true);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test('drag reorder moves a queued input before the drop target', () => {
|
|
192
|
+
let queue = enqueueQueuedInput([], input('first'), { id: 'q1' }).queue;
|
|
193
|
+
queue = enqueueQueuedInput(queue, input('second'), { id: 'q2' }).queue;
|
|
194
|
+
queue = enqueueQueuedInput(queue, input('third'), { id: 'q3' }).queue;
|
|
195
|
+
|
|
196
|
+
assert.deepEqual(reorderQueuedInput(queue, 'q3', 'q1').map(item => item.id), [
|
|
197
|
+
'q3',
|
|
198
|
+
'q1',
|
|
199
|
+
'q2',
|
|
200
|
+
]);
|
|
201
|
+
assert.equal(reorderQueuedInput(queue, 'missing', 'q1'), queue);
|
|
202
|
+
assert.equal(reorderQueuedInput(queue, 'q2', 'q2'), queue);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test('queued input query can be edited before sending', () => {
|
|
206
|
+
const queue = enqueueQueuedInput([], input('old'), { id: 'q1' }).queue;
|
|
207
|
+
const edited = updateQueuedInputQuery(
|
|
208
|
+
restoreFailedQueuedInput([], queue[0], 'boom'),
|
|
209
|
+
'q1',
|
|
210
|
+
'new',
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
assert.equal(edited[0].data.query, 'new');
|
|
214
|
+
assert.equal(edited[0].data.text, 'new');
|
|
215
|
+
assert.equal(edited[0].status, 'pending');
|
|
216
|
+
assert.equal(edited[0].errorMessage, undefined);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test('empty queue state is removable from storage', () => {
|
|
220
|
+
assert.equal(isInputQueueStateEmpty(createEmptyInputQueueState()), true);
|
|
221
|
+
|
|
222
|
+
const queued = enqueueInputQueueState(createEmptyInputQueueState(), input('first'), {
|
|
223
|
+
id: 'q1',
|
|
224
|
+
}).state;
|
|
225
|
+
assert.equal(isInputQueueStateEmpty(queued), false);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test('send-now command carries selected task and source tab', () => {
|
|
229
|
+
const command = createSendNowCommand('q1', 'tab-a', 100);
|
|
230
|
+
|
|
231
|
+
assert.equal(command.type, 'send-now');
|
|
232
|
+
assert.equal(command.itemId, 'q1');
|
|
233
|
+
assert.equal(command.sourceTabId, 'tab-a');
|
|
234
|
+
assert.equal(command.createdAt, 100);
|
|
235
|
+
});
|