@agentscope-ai/chat 1.1.70 → 1.1.71-beta.1781610744021
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 +38 -5
- package/components/AgentScopeRuntimeWebUI/core/Chat/InputQueue/Panel.tsx +82 -0
- package/components/AgentScopeRuntimeWebUI/core/Chat/InputQueue/__tests__/inputQueue.test.ts +112 -0
- package/components/AgentScopeRuntimeWebUI/core/Chat/InputQueue/index.ts +122 -0
- package/components/AgentScopeRuntimeWebUI/core/Chat/hooks/useChatController.tsx +111 -4
- package/components/AgentScopeRuntimeWebUI/core/Chat/index.tsx +21 -3
- package/components/AgentScopeRuntimeWebUI/core/Chat/styles.tsx +68 -1
- package/components/AgentScopeRuntimeWebUI/core/ChatAnywhere/index.tsx +1 -1
- package/components/AgentScopeRuntimeWebUI/core/Context/ChatAnywhereI18nContext.tsx +14 -0
- package/components/AgentScopeRuntimeWebUI/starter/index.tsx +100 -14
- package/components/AgentScopeRuntimeWebUI/starterForMe/index.tsx +31 -0
- package/lib/AgentScopeRuntimeWebUI/core/Chat/Input/index.d.ts +8 -0
- package/lib/AgentScopeRuntimeWebUI/core/Chat/Input/index.js +36 -8
- package/lib/AgentScopeRuntimeWebUI/core/Chat/InputQueue/Panel.d.ts +9 -0
- package/lib/AgentScopeRuntimeWebUI/core/Chat/InputQueue/Panel.js +78 -0
- package/lib/AgentScopeRuntimeWebUI/core/Chat/InputQueue/index.d.ts +37 -0
- package/lib/AgentScopeRuntimeWebUI/core/Chat/InputQueue/index.js +74 -0
- package/lib/AgentScopeRuntimeWebUI/core/Chat/hooks/useChatController.d.ts +7 -0
- package/lib/AgentScopeRuntimeWebUI/core/Chat/hooks/useChatController.js +204 -63
- package/lib/AgentScopeRuntimeWebUI/core/Chat/index.js +14 -2
- package/lib/AgentScopeRuntimeWebUI/core/Chat/styles.js +31 -1
- package/lib/AgentScopeRuntimeWebUI/core/Context/ChatAnywhereI18nContext.d.ts +11 -1
- package/lib/AgentScopeRuntimeWebUI/core/Context/ChatAnywhereI18nContext.js +12 -0
- package/lib/AgentScopeRuntimeWebUI/starter/index.js +144 -20
- package/lib/AgentScopeRuntimeWebUI/starterForMe/index.d.ts +1 -0
- package/lib/AgentScopeRuntimeWebUI/starterForMe/index.js +34 -0
- 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
|
@@ -5,10 +5,19 @@ 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 InputQueuePanel from "../InputQueue/Panel";
|
|
9
|
+
import type { QueuedInputItem } from "../InputQueue";
|
|
8
10
|
|
|
9
11
|
export interface InputProps {
|
|
10
12
|
onCancel: () => void;
|
|
11
13
|
onSubmit: (data: IAgentScopeRuntimeWebUIInputData) => void;
|
|
14
|
+
queue?: {
|
|
15
|
+
items: QueuedInputItem[];
|
|
16
|
+
onEnqueue: (data: IAgentScopeRuntimeWebUIInputData) => void;
|
|
17
|
+
onRemove: (id: string) => void;
|
|
18
|
+
onClear: () => void;
|
|
19
|
+
onRetry: (id: string) => void;
|
|
20
|
+
};
|
|
12
21
|
}
|
|
13
22
|
|
|
14
23
|
export default function Input(props: InputProps) {
|
|
@@ -44,18 +53,42 @@ export default function Input(props: InputProps) {
|
|
|
44
53
|
if (!next) return;
|
|
45
54
|
|
|
46
55
|
const fileList = (getFileList?.() || []).filter(i => i.response?.url);
|
|
47
|
-
|
|
56
|
+
const data = { query: getContent(), fileList };
|
|
57
|
+
if (inputContext.loading || props.queue?.items.length) {
|
|
58
|
+
props.queue?.onEnqueue(data);
|
|
59
|
+
} else {
|
|
60
|
+
props.onSubmit(data);
|
|
61
|
+
}
|
|
48
62
|
setContent('');
|
|
49
|
-
setFileList
|
|
50
|
-
}, []);
|
|
63
|
+
setFileList?.([]);
|
|
64
|
+
}, [beforeSubmit, getContent, getFileList, inputContext.loading, props.onSubmit, props.queue, setContent, setFileList]);
|
|
65
|
+
|
|
66
|
+
const handleKeyDownCapture = useCallback((event: React.KeyboardEvent<HTMLDivElement>) => {
|
|
67
|
+
if (event.key !== 'Enter' || event.shiftKey) return;
|
|
68
|
+
if (event.nativeEvent?.isComposing) return;
|
|
69
|
+
if (!inputContext.loading && !props.queue?.items.length) return;
|
|
70
|
+
|
|
71
|
+
const fileList = (getFileList?.() || []).filter(i => i.response?.url);
|
|
72
|
+
if (!getContent().trim() && fileList.length === 0) return;
|
|
73
|
+
|
|
74
|
+
event.preventDefault();
|
|
75
|
+
event.stopPropagation();
|
|
76
|
+
void handleSubmit();
|
|
77
|
+
}, [getContent, getFileList, handleSubmit, inputContext.loading, props.queue?.items.length]);
|
|
51
78
|
|
|
52
79
|
const handleCancel = useCallback(() => {
|
|
53
80
|
props.onCancel();
|
|
54
81
|
}, []);
|
|
55
82
|
|
|
56
|
-
return <div className={prefixCls}>
|
|
83
|
+
return <div className={prefixCls} onKeyDownCapture={handleKeyDownCapture}>
|
|
57
84
|
<div className={`${prefixCls}-wrapper`}>
|
|
58
85
|
{beforeUI}
|
|
86
|
+
{props.queue?.items.length ? <InputQueuePanel
|
|
87
|
+
items={props.queue.items}
|
|
88
|
+
onRemove={props.queue.onRemove}
|
|
89
|
+
onClear={props.queue.onClear}
|
|
90
|
+
onRetry={props.queue.onRetry}
|
|
91
|
+
/> : null}
|
|
59
92
|
<ChatInput
|
|
60
93
|
loading={inputContext.loading}
|
|
61
94
|
disabled={inputContext.disabled}
|
|
@@ -80,4 +113,4 @@ export default function Input(props: InputProps) {
|
|
|
80
113
|
disclaimer ? <Disclaimer desc={disclaimer} /> : <div className={`${prefixCls}-blank`}></div>
|
|
81
114
|
}
|
|
82
115
|
</div>;
|
|
83
|
-
}
|
|
116
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { IconButton } from '@agentscope-ai/design';
|
|
2
|
+
import {
|
|
3
|
+
SparkClearLine,
|
|
4
|
+
SparkDeleteLine,
|
|
5
|
+
SparkRefreshLine,
|
|
6
|
+
} from '@agentscope-ai/icons';
|
|
7
|
+
import { Tooltip } from 'antd';
|
|
8
|
+
import { useProviderContext } from '@agentscope-ai/chat';
|
|
9
|
+
import { useTranslation } from '../../Context/ChatAnywhereI18nContext';
|
|
10
|
+
import type { QueuedInputItem } from './index';
|
|
11
|
+
|
|
12
|
+
interface InputQueuePanelProps {
|
|
13
|
+
items: QueuedInputItem[];
|
|
14
|
+
onRemove: (id: string) => void;
|
|
15
|
+
onClear: () => void;
|
|
16
|
+
onRetry: (id: string) => void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export default function InputQueuePanel(props: InputQueuePanelProps) {
|
|
20
|
+
const { items, onRemove, onClear, onRetry } = props;
|
|
21
|
+
const prefixCls = useProviderContext().getPrefixCls('chat-anywhere-input-queue');
|
|
22
|
+
const { t } = useTranslation();
|
|
23
|
+
const tr = (key: Parameters<typeof t>[0]) => t?.(key) || key;
|
|
24
|
+
|
|
25
|
+
if (!items.length) return null;
|
|
26
|
+
|
|
27
|
+
return (
|
|
28
|
+
<div className={prefixCls}>
|
|
29
|
+
<div className={`${prefixCls}-header`}>
|
|
30
|
+
<span>
|
|
31
|
+
{tr('queue.title')} ({items.length})
|
|
32
|
+
</span>
|
|
33
|
+
<Tooltip title={tr('queue.clear')}>
|
|
34
|
+
<IconButton
|
|
35
|
+
size="small"
|
|
36
|
+
bordered={false}
|
|
37
|
+
icon={<SparkClearLine />}
|
|
38
|
+
onClick={onClear}
|
|
39
|
+
/>
|
|
40
|
+
</Tooltip>
|
|
41
|
+
</div>
|
|
42
|
+
<div className={`${prefixCls}-list`}>
|
|
43
|
+
{items.map((item, index) => {
|
|
44
|
+
const failed = item.status === 'failed';
|
|
45
|
+
return (
|
|
46
|
+
<div className={`${prefixCls}-item`} key={item.id}>
|
|
47
|
+
<span className={`${prefixCls}-index`}>{index + 1}</span>
|
|
48
|
+
<div className={`${prefixCls}-content`}>
|
|
49
|
+
<div className={`${prefixCls}-text`}>
|
|
50
|
+
{item.data.query || tr('queue.attachmentOnly')}
|
|
51
|
+
</div>
|
|
52
|
+
{failed ? (
|
|
53
|
+
<div className={`${prefixCls}-error`}>
|
|
54
|
+
{item.errorMessage || tr('queue.failed')}
|
|
55
|
+
</div>
|
|
56
|
+
) : null}
|
|
57
|
+
</div>
|
|
58
|
+
{failed ? (
|
|
59
|
+
<Tooltip title={tr('queue.retry')}>
|
|
60
|
+
<IconButton
|
|
61
|
+
size="small"
|
|
62
|
+
bordered={false}
|
|
63
|
+
icon={<SparkRefreshLine />}
|
|
64
|
+
onClick={() => onRetry(item.id)}
|
|
65
|
+
/>
|
|
66
|
+
</Tooltip>
|
|
67
|
+
) : null}
|
|
68
|
+
<Tooltip title={tr('common.delete')}>
|
|
69
|
+
<IconButton
|
|
70
|
+
size="small"
|
|
71
|
+
bordered={false}
|
|
72
|
+
icon={<SparkDeleteLine />}
|
|
73
|
+
onClick={() => onRemove(item.id)}
|
|
74
|
+
/>
|
|
75
|
+
</Tooltip>
|
|
76
|
+
</div>
|
|
77
|
+
);
|
|
78
|
+
})}
|
|
79
|
+
</div>
|
|
80
|
+
</div>
|
|
81
|
+
);
|
|
82
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import {
|
|
3
|
+
canSubmitDirectly,
|
|
4
|
+
dequeueNextQueuedInput,
|
|
5
|
+
enqueueQueuedInput,
|
|
6
|
+
removeQueuedInput,
|
|
7
|
+
restoreFailedQueuedInput,
|
|
8
|
+
retryQueuedInput,
|
|
9
|
+
} from '../index';
|
|
10
|
+
|
|
11
|
+
const input = (query: string) => ({ query });
|
|
12
|
+
|
|
13
|
+
function test(name: string, fn: () => void) {
|
|
14
|
+
try {
|
|
15
|
+
fn();
|
|
16
|
+
console.log(`ok - ${name}`);
|
|
17
|
+
} catch (error) {
|
|
18
|
+
console.error(`not ok - ${name}`);
|
|
19
|
+
throw error;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
test('enqueue appends inputs in FIFO order and dequeue consumes the head', () => {
|
|
24
|
+
let queue = enqueueQueuedInput([], input('first'), {
|
|
25
|
+
id: 'q1',
|
|
26
|
+
now: 1,
|
|
27
|
+
}).queue;
|
|
28
|
+
queue = enqueueQueuedInput(queue, input('second'), {
|
|
29
|
+
id: 'q2',
|
|
30
|
+
now: 2,
|
|
31
|
+
}).queue;
|
|
32
|
+
|
|
33
|
+
const first = dequeueNextQueuedInput(queue);
|
|
34
|
+
assert.equal(first.item?.data.query, 'first');
|
|
35
|
+
assert.deepEqual(first.queue.map(item => item.data.query), ['second']);
|
|
36
|
+
|
|
37
|
+
const second = dequeueNextQueuedInput(first.queue);
|
|
38
|
+
assert.equal(second.item?.data.query, 'second');
|
|
39
|
+
assert.equal(second.queue.length, 0);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('direct submit is allowed only when idle, queue is empty and no drain is active', () => {
|
|
43
|
+
assert.equal(
|
|
44
|
+
canSubmitDirectly({ loading: false, queueLength: 0, draining: false }),
|
|
45
|
+
true,
|
|
46
|
+
);
|
|
47
|
+
assert.equal(
|
|
48
|
+
canSubmitDirectly({ loading: true, queueLength: 0, draining: false }),
|
|
49
|
+
false,
|
|
50
|
+
);
|
|
51
|
+
assert.equal(
|
|
52
|
+
canSubmitDirectly({ loading: false, queueLength: 1, draining: false }),
|
|
53
|
+
false,
|
|
54
|
+
);
|
|
55
|
+
assert.equal(
|
|
56
|
+
canSubmitDirectly({ loading: false, queueLength: 0, draining: true }),
|
|
57
|
+
false,
|
|
58
|
+
);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('enqueue rejects new input when the queue reaches max size', () => {
|
|
62
|
+
const queue = enqueueQueuedInput([], input('first'), {
|
|
63
|
+
id: 'q1',
|
|
64
|
+
maxSize: 1,
|
|
65
|
+
}).queue;
|
|
66
|
+
const result = enqueueQueuedInput(queue, input('second'), {
|
|
67
|
+
id: 'q2',
|
|
68
|
+
maxSize: 1,
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
assert.equal(result.reason, 'full');
|
|
72
|
+
assert.deepEqual(result.queue.map(item => item.id), ['q1']);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('failed send is restored at the queue head and blocks automatic dequeue', () => {
|
|
76
|
+
const original = enqueueQueuedInput([], input('send me'), {
|
|
77
|
+
id: 'q1',
|
|
78
|
+
}).queue[0];
|
|
79
|
+
const queue = restoreFailedQueuedInput([], original, new Error('network'));
|
|
80
|
+
|
|
81
|
+
assert.equal(queue[0].status, 'failed');
|
|
82
|
+
assert.equal(queue[0].retryCount, 1);
|
|
83
|
+
assert.equal(queue[0].errorMessage, 'network');
|
|
84
|
+
|
|
85
|
+
const next = dequeueNextQueuedInput(queue);
|
|
86
|
+
assert.equal(next.item, undefined);
|
|
87
|
+
assert.equal(next.queue, queue);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('retry marks a failed item pending so it can be sent again', () => {
|
|
91
|
+
const original = enqueueQueuedInput([], input('retry me'), {
|
|
92
|
+
id: 'q1',
|
|
93
|
+
}).queue[0];
|
|
94
|
+
const failed = restoreFailedQueuedInput([], original, 'boom');
|
|
95
|
+
const retried = retryQueuedInput(failed, 'q1');
|
|
96
|
+
const next = dequeueNextQueuedInput(retried);
|
|
97
|
+
|
|
98
|
+
assert.equal(retried[0].status, 'pending');
|
|
99
|
+
assert.equal(retried[0].errorMessage, undefined);
|
|
100
|
+
assert.equal(next.item?.id, 'q1');
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('remove deletes only the selected queued input', () => {
|
|
104
|
+
let queue = enqueueQueuedInput([], input('first'), { id: 'q1' }).queue;
|
|
105
|
+
queue = enqueueQueuedInput(queue, input('second'), { id: 'q2' }).queue;
|
|
106
|
+
|
|
107
|
+
assert.deepEqual(removeQueuedInput(queue, 'q1').map(item => item.id), ['q2']);
|
|
108
|
+
assert.deepEqual(removeQueuedInput(queue, 'missing').map(item => item.id), [
|
|
109
|
+
'q1',
|
|
110
|
+
'q2',
|
|
111
|
+
]);
|
|
112
|
+
});
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import type { IAgentScopeRuntimeWebUIInputData } from '../../types';
|
|
2
|
+
|
|
3
|
+
export type QueuedInputStatus = 'pending' | 'failed';
|
|
4
|
+
|
|
5
|
+
export interface QueuedInputItem {
|
|
6
|
+
id: string;
|
|
7
|
+
data: IAgentScopeRuntimeWebUIInputData;
|
|
8
|
+
status: QueuedInputStatus;
|
|
9
|
+
retryCount: number;
|
|
10
|
+
errorMessage?: string;
|
|
11
|
+
createdAt: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface EnqueueQueuedInputResult {
|
|
15
|
+
queue: QueuedInputItem[];
|
|
16
|
+
item?: QueuedInputItem;
|
|
17
|
+
reason?: 'full';
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const MAX_INPUT_QUEUE_SIZE = 50;
|
|
21
|
+
|
|
22
|
+
let queueId = 0;
|
|
23
|
+
|
|
24
|
+
export function createQueuedInputItem(
|
|
25
|
+
data: IAgentScopeRuntimeWebUIInputData,
|
|
26
|
+
options?: {
|
|
27
|
+
id?: string;
|
|
28
|
+
now?: number;
|
|
29
|
+
},
|
|
30
|
+
): QueuedInputItem {
|
|
31
|
+
return {
|
|
32
|
+
id:
|
|
33
|
+
options?.id ||
|
|
34
|
+
`input-queue-${Date.now().toString(36)}-${(++queueId).toString(36)}`,
|
|
35
|
+
data,
|
|
36
|
+
status: 'pending',
|
|
37
|
+
retryCount: 0,
|
|
38
|
+
createdAt: options?.now ?? Date.now(),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function enqueueQueuedInput(
|
|
43
|
+
queue: QueuedInputItem[],
|
|
44
|
+
data: IAgentScopeRuntimeWebUIInputData,
|
|
45
|
+
options?: {
|
|
46
|
+
maxSize?: number;
|
|
47
|
+
id?: string;
|
|
48
|
+
now?: number;
|
|
49
|
+
},
|
|
50
|
+
): EnqueueQueuedInputResult {
|
|
51
|
+
const maxSize = options?.maxSize ?? MAX_INPUT_QUEUE_SIZE;
|
|
52
|
+
if (queue.length >= maxSize) {
|
|
53
|
+
return { queue, reason: 'full' };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const item = createQueuedInputItem(data, options);
|
|
57
|
+
return {
|
|
58
|
+
queue: [...queue, item],
|
|
59
|
+
item,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function dequeueNextQueuedInput(queue: QueuedInputItem[]): {
|
|
64
|
+
item?: QueuedInputItem;
|
|
65
|
+
queue: QueuedInputItem[];
|
|
66
|
+
} {
|
|
67
|
+
const next = queue[0];
|
|
68
|
+
if (!next || next.status !== 'pending') {
|
|
69
|
+
return { queue };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
item: next,
|
|
74
|
+
queue: queue.slice(1),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function removeQueuedInput(
|
|
79
|
+
queue: QueuedInputItem[],
|
|
80
|
+
id: string,
|
|
81
|
+
): QueuedInputItem[] {
|
|
82
|
+
return queue.filter(item => item.id !== id);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function retryQueuedInput(
|
|
86
|
+
queue: QueuedInputItem[],
|
|
87
|
+
id: string,
|
|
88
|
+
): QueuedInputItem[] {
|
|
89
|
+
return queue.map(item =>
|
|
90
|
+
item.id === id
|
|
91
|
+
? {
|
|
92
|
+
...item,
|
|
93
|
+
status: 'pending',
|
|
94
|
+
errorMessage: undefined,
|
|
95
|
+
}
|
|
96
|
+
: item,
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function restoreFailedQueuedInput(
|
|
101
|
+
queue: QueuedInputItem[],
|
|
102
|
+
item: QueuedInputItem,
|
|
103
|
+
error?: unknown,
|
|
104
|
+
): QueuedInputItem[] {
|
|
105
|
+
return [
|
|
106
|
+
{
|
|
107
|
+
...item,
|
|
108
|
+
status: 'failed',
|
|
109
|
+
retryCount: item.retryCount + 1,
|
|
110
|
+
errorMessage: error instanceof Error ? error.message : String(error || ''),
|
|
111
|
+
},
|
|
112
|
+
...queue,
|
|
113
|
+
];
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function canSubmitDirectly(options: {
|
|
117
|
+
loading: boolean | string;
|
|
118
|
+
queueLength: number;
|
|
119
|
+
draining: boolean;
|
|
120
|
+
}) {
|
|
121
|
+
return !options.loading && options.queueLength === 0 && !options.draining;
|
|
122
|
+
}
|
|
@@ -1,11 +1,21 @@
|
|
|
1
1
|
import { sleep } from "@agentscope-ai/chat";
|
|
2
|
-
import { useCallback, useEffect, useRef } from "react";
|
|
2
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
3
3
|
import { useContextSelector } from "use-context-selector";
|
|
4
4
|
import { ChatAnywhereInputContext } from "../../Context/ChatAnywhereInputContext";
|
|
5
5
|
import { ChatAnywhereSessionsContext } from "../../Context/ChatAnywhereSessionsContext";
|
|
6
6
|
import useChatAnywhereEventEmitter from "../../Context/useChatAnywhereEventEmitter";
|
|
7
7
|
import { IAgentScopeRuntimeWebUIMessage } from "@agentscope-ai/chat";
|
|
8
8
|
import { InputProps } from "../Input";
|
|
9
|
+
import {
|
|
10
|
+
canSubmitDirectly,
|
|
11
|
+
dequeueNextQueuedInput,
|
|
12
|
+
enqueueQueuedInput,
|
|
13
|
+
MAX_INPUT_QUEUE_SIZE,
|
|
14
|
+
removeQueuedInput,
|
|
15
|
+
restoreFailedQueuedInput,
|
|
16
|
+
retryQueuedInput,
|
|
17
|
+
type QueuedInputItem,
|
|
18
|
+
} from "../InputQueue";
|
|
9
19
|
import useChatMessageHandler from "./useChatMessageHandler";
|
|
10
20
|
import useChatRequest from "./useChatRequest";
|
|
11
21
|
import useChatSessionHandler from "./useChatSessionHandler";
|
|
@@ -18,6 +28,7 @@ import ReactDOM from "react-dom";
|
|
|
18
28
|
*/
|
|
19
29
|
export default function useChatController() {
|
|
20
30
|
const setLoading = useContextSelector(ChatAnywhereInputContext, v => v.setLoading);
|
|
31
|
+
const getLoading = useContextSelector(ChatAnywhereInputContext, v => v.getLoading);
|
|
21
32
|
const currentSessionId = useContextSelector(ChatAnywhereSessionsContext, v => v.currentSessionId);
|
|
22
33
|
const apiOptions = useChatAnywhereOptions(v => v.api);
|
|
23
34
|
const apiOptionsRef = useRef(apiOptions);
|
|
@@ -43,6 +54,15 @@ export default function useChatController() {
|
|
|
43
54
|
*/
|
|
44
55
|
activeSessionId?: string;
|
|
45
56
|
}>({ activeRequestId: 0 });
|
|
57
|
+
const [inputQueue, setInputQueue] = useState<QueuedInputItem[]>([]);
|
|
58
|
+
const inputQueueRef = useRef<QueuedInputItem[]>([]);
|
|
59
|
+
const drainTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
60
|
+
const drainingRef = useRef(false);
|
|
61
|
+
const drainQueueRef = useRef<(() => Promise<void>) | null>(null);
|
|
62
|
+
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
inputQueueRef.current = inputQueue;
|
|
65
|
+
}, [inputQueue]);
|
|
46
66
|
|
|
47
67
|
// Message handler
|
|
48
68
|
const messageHandler = useChatMessageHandler({ currentQARef });
|
|
@@ -53,6 +73,17 @@ export default function useChatController() {
|
|
|
53
73
|
/**
|
|
54
74
|
* Finalize the current response and reset UI loading state.
|
|
55
75
|
*/
|
|
76
|
+
const scheduleDrainQueue = useCallback(() => {
|
|
77
|
+
if (drainTimerRef.current) {
|
|
78
|
+
clearTimeout(drainTimerRef.current);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
drainTimerRef.current = setTimeout(() => {
|
|
82
|
+
drainTimerRef.current = null;
|
|
83
|
+
void drainQueueRef.current?.();
|
|
84
|
+
}, 0);
|
|
85
|
+
}, []);
|
|
86
|
+
|
|
56
87
|
const finishResponse = useCallback((status: 'finished' | 'interrupted' = 'finished') => {
|
|
57
88
|
if (!currentQARef.current.response) return;
|
|
58
89
|
|
|
@@ -63,7 +94,8 @@ export default function useChatController() {
|
|
|
63
94
|
});
|
|
64
95
|
|
|
65
96
|
sessionHandler.syncSessionMessages(messageHandler.getMessages());
|
|
66
|
-
|
|
97
|
+
scheduleDrainQueue();
|
|
98
|
+
}, [setLoading, messageHandler, sessionHandler, scheduleDrainQueue]);
|
|
67
99
|
|
|
68
100
|
// API request handling
|
|
69
101
|
const { request, reconnect } = useChatRequest({
|
|
@@ -76,7 +108,7 @@ export default function useChatController() {
|
|
|
76
108
|
/**
|
|
77
109
|
* Handle user message submission.
|
|
78
110
|
*/
|
|
79
|
-
const
|
|
111
|
+
const submitNow = useCallback<InputProps['onSubmit']>(async (data) => {
|
|
80
112
|
// 0. Abort any previous in-flight SSE. We do NOT call the cancel API here
|
|
81
113
|
// — the user is sending a new message, not explicitly cancelling.
|
|
82
114
|
// Cancel is only invoked from handleCancel.
|
|
@@ -120,6 +152,61 @@ export default function useChatController() {
|
|
|
120
152
|
// mockRequest(mockdata);
|
|
121
153
|
}, [messageHandler, sessionHandler, request, setLoading]);
|
|
122
154
|
|
|
155
|
+
const enqueueInput = useCallback((data: Parameters<InputProps['onSubmit']>[0]) => {
|
|
156
|
+
const result = enqueueQueuedInput(inputQueueRef.current, data, {
|
|
157
|
+
maxSize: MAX_INPUT_QUEUE_SIZE,
|
|
158
|
+
});
|
|
159
|
+
inputQueueRef.current = result.queue;
|
|
160
|
+
setInputQueue(result.queue);
|
|
161
|
+
}, []);
|
|
162
|
+
|
|
163
|
+
const drainQueue = useCallback(async () => {
|
|
164
|
+
if (drainingRef.current || getLoading()) return;
|
|
165
|
+
|
|
166
|
+
const result = dequeueNextQueuedInput(inputQueueRef.current);
|
|
167
|
+
const nextItem = result.item;
|
|
168
|
+
|
|
169
|
+
if (!nextItem) return;
|
|
170
|
+
|
|
171
|
+
inputQueueRef.current = result.queue;
|
|
172
|
+
setInputQueue(result.queue);
|
|
173
|
+
|
|
174
|
+
drainingRef.current = true;
|
|
175
|
+
try {
|
|
176
|
+
await submitNow(nextItem.data);
|
|
177
|
+
} catch (error) {
|
|
178
|
+
setLoading(false);
|
|
179
|
+
const restored = restoreFailedQueuedInput(inputQueueRef.current, nextItem, error);
|
|
180
|
+
inputQueueRef.current = restored;
|
|
181
|
+
setInputQueue(restored);
|
|
182
|
+
} finally {
|
|
183
|
+
drainingRef.current = false;
|
|
184
|
+
}
|
|
185
|
+
}, [getLoading, setLoading, submitNow]);
|
|
186
|
+
|
|
187
|
+
useEffect(() => {
|
|
188
|
+
drainQueueRef.current = drainQueue;
|
|
189
|
+
}, [drainQueue]);
|
|
190
|
+
|
|
191
|
+
const handleSubmit = useCallback<InputProps['onSubmit']>(async (data) => {
|
|
192
|
+
if (!canSubmitDirectly({
|
|
193
|
+
loading: getLoading(),
|
|
194
|
+
queueLength: inputQueueRef.current.length,
|
|
195
|
+
draining: drainingRef.current,
|
|
196
|
+
})) {
|
|
197
|
+
enqueueInput(data);
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
await submitNow(data);
|
|
202
|
+
}, [enqueueInput, getLoading, submitNow]);
|
|
203
|
+
|
|
204
|
+
useEffect(() => {
|
|
205
|
+
if (!getLoading() && inputQueue.length > 0) {
|
|
206
|
+
scheduleDrainQueue();
|
|
207
|
+
}
|
|
208
|
+
}, [getLoading, inputQueue.length, scheduleDrainQueue]);
|
|
209
|
+
|
|
123
210
|
|
|
124
211
|
const handleApproval = useCallback(async ({ input }) => {
|
|
125
212
|
currentQARef.current.abortController?.abort();
|
|
@@ -259,6 +346,10 @@ export default function useChatController() {
|
|
|
259
346
|
};
|
|
260
347
|
|
|
261
348
|
return () => {
|
|
349
|
+
if (drainTimerRef.current) {
|
|
350
|
+
clearTimeout(drainTimerRef.current);
|
|
351
|
+
drainTimerRef.current = null;
|
|
352
|
+
}
|
|
262
353
|
currentQARef.current.abortController?.abort();
|
|
263
354
|
currentQARef.current.activeRequestId += 1;
|
|
264
355
|
};
|
|
@@ -298,6 +389,22 @@ export default function useChatController() {
|
|
|
298
389
|
return {
|
|
299
390
|
handleSubmit,
|
|
300
391
|
handleCancel,
|
|
392
|
+
inputQueue,
|
|
393
|
+
enqueueQueuedInput: enqueueInput,
|
|
394
|
+
removeQueuedInput: (id: string) => {
|
|
395
|
+
const next = removeQueuedInput(inputQueueRef.current, id);
|
|
396
|
+
inputQueueRef.current = next;
|
|
397
|
+
setInputQueue(next);
|
|
398
|
+
},
|
|
399
|
+
clearQueuedInputs: () => {
|
|
400
|
+
inputQueueRef.current = [];
|
|
401
|
+
setInputQueue([]);
|
|
402
|
+
},
|
|
403
|
+
retryQueuedInput: (id: string) => {
|
|
404
|
+
const next = retryQueuedInput(inputQueueRef.current, id);
|
|
405
|
+
inputQueueRef.current = next;
|
|
406
|
+
setInputQueue(next);
|
|
407
|
+
scheduleDrainQueue();
|
|
408
|
+
},
|
|
301
409
|
};
|
|
302
410
|
}
|
|
303
|
-
|
|
@@ -7,14 +7,32 @@ import { useChatAnywhereSessionLoader } from "../Context/ChatAnywhereSessionsCon
|
|
|
7
7
|
|
|
8
8
|
export default function Chat() {
|
|
9
9
|
const prefixCls = useProviderContext().getPrefixCls('chat-anywhere-chat');
|
|
10
|
-
const {
|
|
10
|
+
const {
|
|
11
|
+
handleSubmit,
|
|
12
|
+
handleCancel,
|
|
13
|
+
inputQueue,
|
|
14
|
+
enqueueQueuedInput,
|
|
15
|
+
removeQueuedInput,
|
|
16
|
+
clearQueuedInputs,
|
|
17
|
+
retryQueuedInput,
|
|
18
|
+
} = useChatController();
|
|
11
19
|
useChatAnywhereSessionLoader();
|
|
12
20
|
|
|
13
21
|
return <>
|
|
14
22
|
<Style />
|
|
15
23
|
<div className={prefixCls}>
|
|
16
24
|
<MessageList onSubmit={handleSubmit} />
|
|
17
|
-
<Input
|
|
25
|
+
<Input
|
|
26
|
+
onCancel={handleCancel}
|
|
27
|
+
onSubmit={handleSubmit}
|
|
28
|
+
queue={{
|
|
29
|
+
items: inputQueue,
|
|
30
|
+
onEnqueue: enqueueQueuedInput,
|
|
31
|
+
onRemove: removeQueuedInput,
|
|
32
|
+
onClear: clearQueuedInputs,
|
|
33
|
+
onRetry: retryQueuedInput,
|
|
34
|
+
}}
|
|
35
|
+
/>
|
|
18
36
|
</div>
|
|
19
37
|
</>;
|
|
20
|
-
}
|
|
38
|
+
}
|
|
@@ -54,6 +54,73 @@ export default createGlobalStyle`
|
|
|
54
54
|
min-width: 300px;
|
|
55
55
|
margin: 0 auto;
|
|
56
56
|
}
|
|
57
|
+
|
|
58
|
+
.${(p) => p.theme.prefixCls}-chat-anywhere-input-queue {
|
|
59
|
+
display: flex;
|
|
60
|
+
flex-direction: column;
|
|
61
|
+
gap: 6px;
|
|
62
|
+
margin-bottom: 8px;
|
|
63
|
+
padding: 8px;
|
|
64
|
+
border: 1px solid ${(p) => p.theme.colorBorderSecondary};
|
|
65
|
+
border-radius: 8px;
|
|
66
|
+
background: ${(p) => p.theme.colorFillTertiary};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
.${(p) => p.theme.prefixCls}-chat-anywhere-input-queue-header {
|
|
70
|
+
display: flex;
|
|
71
|
+
align-items: center;
|
|
72
|
+
justify-content: space-between;
|
|
73
|
+
color: ${(p) => p.theme.colorTextSecondary};
|
|
74
|
+
font-size: 12px;
|
|
75
|
+
line-height: 20px;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
.${(p) => p.theme.prefixCls}-chat-anywhere-input-queue-list {
|
|
79
|
+
display: flex;
|
|
80
|
+
max-height: 180px;
|
|
81
|
+
flex-direction: column;
|
|
82
|
+
gap: 4px;
|
|
83
|
+
overflow-y: auto;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
.${(p) => p.theme.prefixCls}-chat-anywhere-input-queue-item {
|
|
87
|
+
display: grid;
|
|
88
|
+
grid-template-columns: 20px minmax(0, 1fr) auto auto;
|
|
89
|
+
align-items: center;
|
|
90
|
+
gap: 6px;
|
|
91
|
+
min-height: 32px;
|
|
92
|
+
padding: 4px 6px;
|
|
93
|
+
border-radius: 6px;
|
|
94
|
+
background: ${(p) => p.theme.colorBgContainer};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
.${(p) => p.theme.prefixCls}-chat-anywhere-input-queue-index {
|
|
98
|
+
color: ${(p) => p.theme.colorTextQuaternary};
|
|
99
|
+
font-size: 12px;
|
|
100
|
+
text-align: center;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
.${(p) => p.theme.prefixCls}-chat-anywhere-input-queue-content {
|
|
104
|
+
min-width: 0;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
.${(p) => p.theme.prefixCls}-chat-anywhere-input-queue-text {
|
|
108
|
+
overflow: hidden;
|
|
109
|
+
color: ${(p) => p.theme.colorText};
|
|
110
|
+
font-size: 12px;
|
|
111
|
+
line-height: 18px;
|
|
112
|
+
text-overflow: ellipsis;
|
|
113
|
+
white-space: nowrap;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
.${(p) => p.theme.prefixCls}-chat-anywhere-input-queue-error {
|
|
117
|
+
overflow: hidden;
|
|
118
|
+
color: ${(p) => p.theme.colorError};
|
|
119
|
+
font-size: 11px;
|
|
120
|
+
line-height: 16px;
|
|
121
|
+
text-overflow: ellipsis;
|
|
122
|
+
white-space: nowrap;
|
|
123
|
+
}
|
|
57
124
|
.${(p) => p.theme.prefixCls}-chat-anywhere-input-blank {
|
|
58
125
|
height: 16px;
|
|
59
|
-
`;
|
|
126
|
+
`;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import Layout from '../Layout';
|
|
2
2
|
import type { IAgentScopeRuntimeWebUIOptions } from '@agentscope-ai/chat';
|
|
3
|
-
import { forwardRef, useMemo
|
|
3
|
+
import { forwardRef, useMemo } from 'react';
|
|
4
4
|
import AgentScopeRuntimeRequestCard from '../AgentScopeRuntime/Request/Card';
|
|
5
5
|
import AgentScopeRuntimeResponseCard from '../AgentScopeRuntime/Response/Card';
|
|
6
6
|
import ComposedProvider from './ComposedProvider';
|