@nextclaw/ui 0.11.17 → 0.11.19
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/CHANGELOG.md +26 -0
- package/dist/assets/ChannelsList-DAx7wv0_.js +8 -0
- package/dist/assets/{ChatPage-C47h6sfA.js → ChatPage-l2PYwCeB.js} +9 -7
- package/dist/assets/DocBrowser-CIHLqoIm.js +1 -0
- package/dist/assets/{DocBrowser-C_C7daBv.js → DocBrowser-DKkE3Y4I.js} +1 -1
- package/dist/assets/{DocBrowserContext-CJ-YKtWh.js → DocBrowserContext-BcZRBsCg.js} +1 -1
- package/dist/assets/{LogoBadge-DRDmIa7o.js → LogoBadge-BIPDLEwK.js} +1 -1
- package/dist/assets/{MarketplacePage-DaSRsFUA.js → MarketplacePage-Dlp5BgCh.js} +1 -1
- package/dist/assets/MarketplacePage-TVeyVOuO.js +1 -0
- package/dist/assets/{McpMarketplacePage-B7HZn8zG.js → McpMarketplacePage-CwKtAil8.js} +1 -1
- package/dist/assets/{ModelConfig-MSi8VF9p.js → ModelConfig-Dg6F3Ldb.js} +1 -1
- package/dist/assets/{ProvidersList-_NBpSQWn.js → ProvidersList-f7bQdRxA.js} +1 -1
- package/dist/assets/{RemoteAccessPage-DSmdSsCJ.js → RemoteAccessPage-w_dY7P4T.js} +1 -1
- package/dist/assets/{RuntimeConfig-msA8NZOj.js → RuntimeConfig-M4OKjmgU.js} +1 -1
- package/dist/assets/{SearchConfig-BBtxHIN_.js → SearchConfig-v46R5a2U.js} +1 -1
- package/dist/assets/{SecretsConfig-BMAqj52o.js → SecretsConfig-CXvUpbB_.js} +1 -1
- package/dist/assets/{SessionsConfig-CEJqgz8F.js → SessionsConfig-7vUHMtOh.js} +1 -1
- package/dist/assets/{book-open-1agbn9dT.js → book-open-DzSduAaw.js} +1 -1
- package/dist/assets/{chat-session-display-DBBUJOYN.js → chat-session-display-CGfXhJoT.js} +1 -1
- package/dist/assets/{chunk-JZWAC4HX-BUooP92l.js → chunk-JZWAC4HX-C1vpvW4r.js} +1 -1
- package/dist/assets/{config-jOAXZWun.js → config-Df97LeLR.js} +1 -1
- package/dist/assets/{createLucideIcon-B8FV3fzy.js → createLucideIcon-CcR5wVoU.js} +1 -1
- package/dist/assets/{dist-D3OJg9V0.js → dist-BMlnBah3.js} +1 -1
- package/dist/assets/{dist-Cy668qFZ.js → dist-Dii9v3X9.js} +1 -1
- package/dist/assets/{external-link-DI4ZmR3r.js → external-link-CnSDrvJE.js} +1 -1
- package/dist/assets/{hash-DoXBhX9w.js → hash-CAnX6PNt.js} +1 -1
- package/dist/assets/i18n-CXBpwAwA.js +1 -0
- package/dist/assets/{index-bAeWRAyo.js → index-B0DzQqwv.js} +3 -3
- package/dist/assets/index-BahpXJg8.css +1 -0
- package/dist/assets/{label-Cz0q8fx4.js → label-CtIFj7_6.js} +1 -1
- package/dist/assets/loader-circle-qgU4zQDw.js +1 -0
- package/dist/assets/{logos-DjrINZ7P.js → logos-3KFNiOej.js} +1 -1
- package/dist/assets/{page-layout-Hr-Dvq4o.js → page-layout-BMwpn87D.js} +1 -1
- package/dist/assets/plus-C9cYVbL-.js +1 -0
- package/dist/assets/{popover-_nEUAtWY.js → popover-BIzq25oH.js} +1 -1
- package/dist/assets/{react-Bsr_GLhi.js → react-ji6GGP_j.js} +1 -1
- package/dist/assets/{save-Caodcm4q.js → save-CMgYkJ-y.js} +1 -1
- package/dist/assets/search-sl1OeJFl.js +1 -0
- package/dist/assets/{security-config-Zf1RBeS1.js → security-config-Xi5DYW7j.js} +1 -1
- package/dist/assets/{select-D60QRHg9.js → select-Cz82gl01.js} +1 -1
- package/dist/assets/skeleton-rgIt7a5q.js +1 -0
- package/dist/assets/{status-dot-D43lBF1a.js → status-dot-C7q1HvLH.js} +1 -1
- package/dist/assets/{switch-CcBS0F3U.js → switch-DYswvkYj.js} +1 -1
- package/dist/assets/{tabs-custom-UTbefkqB.js → tabs-custom-DKYQxrx1.js} +1 -1
- package/dist/assets/{trash-2-DvPrU1xO.js → trash-2-DfXI7-ap.js} +1 -1
- package/dist/assets/{useConfirmDialog-B89bxcd6.js → useConfirmDialog-CXDAxtRL.js} +1 -1
- package/dist/assets/{useMutation-BpXHE2OV.js → useMutation-s2sn2yzh.js} +1 -1
- package/dist/assets/x-MIimOGs6.js +1 -0
- package/dist/index.html +18 -18
- package/package.json +6 -6
- package/src/components/chat/ChatConversationPanel.tsx +1 -0
- package/src/components/chat/adapters/chat-input-bar.adapter.test.ts +103 -1
- package/src/components/chat/adapters/chat-input-bar.adapter.ts +80 -2
- package/src/components/chat/adapters/chat-message-inline-content.adapter.ts +95 -0
- package/src/components/chat/adapters/chat-message-part.adapter.ts +384 -0
- package/src/components/chat/adapters/chat-message.adapter.test.ts +49 -2
- package/src/components/chat/adapters/chat-message.adapter.ts +18 -366
- package/src/components/chat/adapters/chat-message.subagent-tool-card.ts +46 -17
- package/src/components/chat/chat-composer-state.test.ts +2 -6
- package/src/components/chat/chat-composer-state.ts +27 -6
- package/src/components/chat/chat-inline-token.utils.test.ts +87 -0
- package/src/components/chat/chat-inline-token.utils.ts +146 -0
- package/src/components/chat/chat-input/chat-input-bar.controller.test.tsx +24 -0
- package/src/components/chat/chat-input/chat-input-bar.controller.ts +81 -44
- package/src/components/chat/chat-recent-skills.manager.ts +8 -0
- package/src/components/chat/containers/chat-input-bar.container.tsx +31 -4
- package/src/components/chat/containers/chat-message-list.container.test.tsx +45 -0
- package/src/components/chat/containers/chat-message-list.container.tsx +11 -5
- package/src/components/chat/ncp/NcpChatPage.tsx +10 -1
- package/src/components/chat/ncp/ncp-chat-input.manager.ts +18 -4
- package/src/components/chat/presenter/chat-presenter-context.tsx +1 -0
- package/src/components/config/ChannelForm.tsx +71 -39
- package/src/components/config/channel-form-fields.test.ts +28 -0
- package/src/components/config/channel-form-fields.ts +95 -30
- package/src/components/config/weixin-channel-auth-section.test.tsx +26 -0
- package/src/components/config/weixin-channel-auth-section.tsx +6 -2
- package/src/lib/i18n.channel-auth.ts +5 -0
- package/dist/assets/ChannelsList-askIl_uW.js +0 -8
- package/dist/assets/DocBrowser-Cf7uSIoM.js +0 -1
- package/dist/assets/MarketplacePage-q12sRrvZ.js +0 -1
- package/dist/assets/i18n-Cn8SErDV.js +0 -1
- package/dist/assets/index-B2VeWxfm.css +0 -1
- package/dist/assets/loader-circle-d_mzMi2S.js +0 -1
- package/dist/assets/plus-BnGg0mB-.js +0 -1
- package/dist/assets/search-CQCQaN4Z.js +0 -1
- package/dist/assets/skeleton-BvV_2nf3.js +0 -1
- package/dist/assets/x-C8AWDn7c.js +0 -1
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import type { ChatComposerNode } from "@nextclaw/agent-chat-ui";
|
|
2
|
+
|
|
3
|
+
export const CHAT_UI_INLINE_TOKENS_METADATA_KEY = "ui_inline_tokens";
|
|
4
|
+
const CHAT_SKILL_TOKEN_PREFIX = "$";
|
|
5
|
+
|
|
6
|
+
export type ChatInlineTokenSource = {
|
|
7
|
+
kind: string;
|
|
8
|
+
key: string;
|
|
9
|
+
label: string;
|
|
10
|
+
rawText: string;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export type ChatInlineTextFragment =
|
|
14
|
+
| {
|
|
15
|
+
type: "text";
|
|
16
|
+
text: string;
|
|
17
|
+
}
|
|
18
|
+
| {
|
|
19
|
+
type: "token";
|
|
20
|
+
token: ChatInlineTokenSource;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
24
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function readOptionalString(value: unknown): string | null {
|
|
28
|
+
if (typeof value !== "string") {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
const trimmed = value.trim();
|
|
32
|
+
return trimmed.length > 0 ? trimmed : null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function dedupeInlineTokens(
|
|
36
|
+
tokens: readonly ChatInlineTokenSource[],
|
|
37
|
+
): ChatInlineTokenSource[] {
|
|
38
|
+
const seen = new Set<string>();
|
|
39
|
+
const output: ChatInlineTokenSource[] = [];
|
|
40
|
+
for (const token of tokens) {
|
|
41
|
+
const dedupeKey = `${token.kind}:${token.key}:${token.rawText}`;
|
|
42
|
+
if (seen.has(dedupeKey)) {
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
seen.add(dedupeKey);
|
|
46
|
+
output.push(token);
|
|
47
|
+
}
|
|
48
|
+
return output;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function buildInlineSkillTokensFromComposer(
|
|
52
|
+
nodes: readonly ChatComposerNode[],
|
|
53
|
+
): ChatInlineTokenSource[] {
|
|
54
|
+
const tokens: ChatInlineTokenSource[] = [];
|
|
55
|
+
for (const node of nodes) {
|
|
56
|
+
if (node.type !== "token" || node.tokenKind !== "skill") {
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
tokens.push({
|
|
60
|
+
kind: "skill",
|
|
61
|
+
key: node.tokenKey,
|
|
62
|
+
label: node.label,
|
|
63
|
+
rawText: `${CHAT_SKILL_TOKEN_PREFIX}${node.tokenKey}`,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
return dedupeInlineTokens(tokens);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function readInlineTokensFromMetadata(
|
|
70
|
+
metadata: Record<string, unknown> | undefined,
|
|
71
|
+
): ChatInlineTokenSource[] {
|
|
72
|
+
const raw = metadata?.[CHAT_UI_INLINE_TOKENS_METADATA_KEY];
|
|
73
|
+
if (!Array.isArray(raw)) {
|
|
74
|
+
return [];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const tokens: ChatInlineTokenSource[] = [];
|
|
78
|
+
for (const entry of raw) {
|
|
79
|
+
if (!isRecord(entry)) {
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
const kind = readOptionalString(entry.kind);
|
|
83
|
+
const key = readOptionalString(entry.key);
|
|
84
|
+
const rawText = readOptionalString(entry.rawText);
|
|
85
|
+
if (!kind || !key || !rawText) {
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
tokens.push({
|
|
89
|
+
kind,
|
|
90
|
+
key,
|
|
91
|
+
rawText,
|
|
92
|
+
label: readOptionalString(entry.label) ?? key,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return dedupeInlineTokens(tokens);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function splitTextByInlineTokens(
|
|
100
|
+
text: string,
|
|
101
|
+
tokens: readonly ChatInlineTokenSource[],
|
|
102
|
+
): ChatInlineTextFragment[] {
|
|
103
|
+
if (text.length === 0 || tokens.length === 0) {
|
|
104
|
+
return text.length === 0 ? [] : [{ type: "text", text }];
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const orderedTokens = [...tokens].sort(
|
|
108
|
+
(left, right) => right.rawText.length - left.rawText.length,
|
|
109
|
+
);
|
|
110
|
+
const fragments: ChatInlineTextFragment[] = [];
|
|
111
|
+
let cursor = 0;
|
|
112
|
+
|
|
113
|
+
while (cursor < text.length) {
|
|
114
|
+
let matchedToken: ChatInlineTokenSource | null = null;
|
|
115
|
+
for (const token of orderedTokens) {
|
|
116
|
+
if (text.startsWith(token.rawText, cursor)) {
|
|
117
|
+
matchedToken = token;
|
|
118
|
+
break;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (!matchedToken) {
|
|
123
|
+
let nextCursor = cursor + 1;
|
|
124
|
+
while (nextCursor < text.length) {
|
|
125
|
+
if (orderedTokens.some((token) => text.startsWith(token.rawText, nextCursor))) {
|
|
126
|
+
break;
|
|
127
|
+
}
|
|
128
|
+
nextCursor += 1;
|
|
129
|
+
}
|
|
130
|
+
fragments.push({
|
|
131
|
+
type: "text",
|
|
132
|
+
text: text.slice(cursor, nextCursor),
|
|
133
|
+
});
|
|
134
|
+
cursor = nextCursor;
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
fragments.push({
|
|
139
|
+
type: "token",
|
|
140
|
+
token: matchedToken,
|
|
141
|
+
});
|
|
142
|
+
cursor += matchedToken.rawText.length;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return fragments;
|
|
146
|
+
}
|
|
@@ -125,4 +125,28 @@ describe('useChatInputBarController', () => {
|
|
|
125
125
|
});
|
|
126
126
|
expect(onSend).toHaveBeenCalledTimes(1);
|
|
127
127
|
});
|
|
128
|
+
|
|
129
|
+
it('does not send on enter while a response is still running', () => {
|
|
130
|
+
const onSend = vi.fn();
|
|
131
|
+
const event = createKeyEvent('Enter');
|
|
132
|
+
const { result } = renderHook(() =>
|
|
133
|
+
useChatInputBarController({
|
|
134
|
+
isSlashMode: false,
|
|
135
|
+
slashItems: [],
|
|
136
|
+
isSlashLoading: false,
|
|
137
|
+
onSelectSlashItem: vi.fn(),
|
|
138
|
+
onSend,
|
|
139
|
+
onStop: vi.fn(),
|
|
140
|
+
isSending: true,
|
|
141
|
+
canStopGeneration: true
|
|
142
|
+
})
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
act(() => {
|
|
146
|
+
result.current.onTextareaKeyDown(event);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
expect(onSend).not.toHaveBeenCalled();
|
|
150
|
+
expect(event.preventDefault).toHaveBeenCalledTimes(1);
|
|
151
|
+
});
|
|
128
152
|
});
|
|
@@ -13,15 +13,32 @@ type UseChatInputBarControllerParams = {
|
|
|
13
13
|
canStopGeneration: boolean;
|
|
14
14
|
};
|
|
15
15
|
|
|
16
|
+
function isSubmitKey(event: Pick<KeyboardEvent<HTMLTextAreaElement>, 'key' | 'shiftKey'>): boolean {
|
|
17
|
+
return event.key === 'Enter' && !event.shiftKey;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function isSlashDismissKey(event: Pick<KeyboardEvent<HTMLTextAreaElement>, 'key' | 'code' | 'nativeEvent'>): boolean {
|
|
21
|
+
return !event.nativeEvent.isComposing && (event.key === ' ' || event.code === 'Space');
|
|
22
|
+
}
|
|
23
|
+
|
|
16
24
|
export function useChatInputBarController(params: UseChatInputBarControllerParams) {
|
|
25
|
+
const {
|
|
26
|
+
isSlashMode,
|
|
27
|
+
slashItems,
|
|
28
|
+
onSelectSlashItem,
|
|
29
|
+
onSend,
|
|
30
|
+
onStop,
|
|
31
|
+
isSending,
|
|
32
|
+
canStopGeneration
|
|
33
|
+
} = params;
|
|
17
34
|
const [activeSlashIndex, setActiveSlashIndex] = useState(0);
|
|
18
35
|
const [dismissedSlashPanel, setDismissedSlashPanel] = useState(false);
|
|
19
36
|
|
|
20
|
-
const isSlashPanelOpen =
|
|
21
|
-
const activeSlashItem =
|
|
37
|
+
const isSlashPanelOpen = isSlashMode && !dismissedSlashPanel;
|
|
38
|
+
const activeSlashItem = slashItems[activeSlashIndex] ?? null;
|
|
22
39
|
|
|
23
40
|
useEffect(() => {
|
|
24
|
-
if (!isSlashPanelOpen ||
|
|
41
|
+
if (!isSlashPanelOpen || slashItems.length === 0) {
|
|
25
42
|
setActiveSlashIndex(0);
|
|
26
43
|
return;
|
|
27
44
|
}
|
|
@@ -29,65 +46,85 @@ export function useChatInputBarController(params: UseChatInputBarControllerParam
|
|
|
29
46
|
if (current < 0) {
|
|
30
47
|
return 0;
|
|
31
48
|
}
|
|
32
|
-
if (current >=
|
|
33
|
-
return
|
|
49
|
+
if (current >= slashItems.length) {
|
|
50
|
+
return slashItems.length - 1;
|
|
34
51
|
}
|
|
35
52
|
return current;
|
|
36
53
|
});
|
|
37
|
-
}, [isSlashPanelOpen,
|
|
54
|
+
}, [isSlashPanelOpen, slashItems.length]);
|
|
38
55
|
|
|
39
56
|
useEffect(() => {
|
|
40
|
-
if (!
|
|
57
|
+
if (!isSlashMode && dismissedSlashPanel) {
|
|
41
58
|
setDismissedSlashPanel(false);
|
|
42
59
|
}
|
|
43
|
-
}, [dismissedSlashPanel,
|
|
60
|
+
}, [dismissedSlashPanel, isSlashMode]);
|
|
44
61
|
|
|
45
62
|
const handleSelectSlashItem = useCallback((item: ChatSlashItem) => {
|
|
46
|
-
|
|
63
|
+
onSelectSlashItem(item);
|
|
47
64
|
setDismissedSlashPanel(false);
|
|
48
|
-
}, [
|
|
65
|
+
}, [onSelectSlashItem]);
|
|
66
|
+
|
|
67
|
+
const handleSlashKeyDown = useCallback((event: KeyboardEvent<HTMLTextAreaElement>): boolean => {
|
|
68
|
+
if (!isSlashPanelOpen || slashItems.length === 0) {
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
if (event.key === 'ArrowDown') {
|
|
72
|
+
event.preventDefault();
|
|
73
|
+
setActiveSlashIndex((current) => (current + 1) % slashItems.length);
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
if (event.key === 'ArrowUp') {
|
|
77
|
+
event.preventDefault();
|
|
78
|
+
setActiveSlashIndex((current) => (current - 1 + slashItems.length) % slashItems.length);
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
if (!isSubmitKey(event) && event.key !== 'Tab') {
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
event.preventDefault();
|
|
85
|
+
const selected = slashItems[activeSlashIndex];
|
|
86
|
+
if (selected) {
|
|
87
|
+
handleSelectSlashItem(selected);
|
|
88
|
+
}
|
|
89
|
+
return true;
|
|
90
|
+
}, [activeSlashIndex, handleSelectSlashItem, isSlashPanelOpen, slashItems]);
|
|
91
|
+
|
|
92
|
+
const handleEscapeKeyDown = useCallback((event: KeyboardEvent<HTMLTextAreaElement>): boolean => {
|
|
93
|
+
if (event.key !== 'Escape') {
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
if (isSlashPanelOpen) {
|
|
97
|
+
event.preventDefault();
|
|
98
|
+
setDismissedSlashPanel(true);
|
|
99
|
+
return true;
|
|
100
|
+
}
|
|
101
|
+
if (!isSending || !canStopGeneration) {
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
event.preventDefault();
|
|
105
|
+
void onStop();
|
|
106
|
+
return true;
|
|
107
|
+
}, [canStopGeneration, isSlashPanelOpen, isSending, onStop]);
|
|
49
108
|
|
|
50
109
|
const onTextareaKeyDown = useCallback((event: KeyboardEvent<HTMLTextAreaElement>) => {
|
|
51
|
-
if (
|
|
110
|
+
if (isSubmitKey(event) && isSending) {
|
|
111
|
+
event.preventDefault();
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
if (isSlashPanelOpen && isSlashDismissKey(event)) {
|
|
52
115
|
setDismissedSlashPanel(true);
|
|
53
116
|
}
|
|
54
|
-
if (
|
|
55
|
-
|
|
56
|
-
event.preventDefault();
|
|
57
|
-
setActiveSlashIndex((current) => (current + 1) % params.slashItems.length);
|
|
58
|
-
return;
|
|
59
|
-
}
|
|
60
|
-
if (event.key === 'ArrowUp') {
|
|
61
|
-
event.preventDefault();
|
|
62
|
-
setActiveSlashIndex((current) => (current - 1 + params.slashItems.length) % params.slashItems.length);
|
|
63
|
-
return;
|
|
64
|
-
}
|
|
65
|
-
if ((event.key === 'Enter' && !event.shiftKey) || event.key === 'Tab') {
|
|
66
|
-
event.preventDefault();
|
|
67
|
-
const selected = params.slashItems[activeSlashIndex];
|
|
68
|
-
if (selected) {
|
|
69
|
-
handleSelectSlashItem(selected);
|
|
70
|
-
}
|
|
71
|
-
return;
|
|
72
|
-
}
|
|
117
|
+
if (handleSlashKeyDown(event)) {
|
|
118
|
+
return;
|
|
73
119
|
}
|
|
74
|
-
if (event
|
|
75
|
-
|
|
76
|
-
event.preventDefault();
|
|
77
|
-
setDismissedSlashPanel(true);
|
|
78
|
-
return;
|
|
79
|
-
}
|
|
80
|
-
if (params.isSending && params.canStopGeneration) {
|
|
81
|
-
event.preventDefault();
|
|
82
|
-
void params.onStop();
|
|
83
|
-
return;
|
|
84
|
-
}
|
|
120
|
+
if (handleEscapeKeyDown(event)) {
|
|
121
|
+
return;
|
|
85
122
|
}
|
|
86
|
-
if (event
|
|
123
|
+
if (isSubmitKey(event)) {
|
|
87
124
|
event.preventDefault();
|
|
88
|
-
void
|
|
125
|
+
void onSend();
|
|
89
126
|
}
|
|
90
|
-
}, [
|
|
127
|
+
}, [handleEscapeKeyDown, handleSlashKeyDown, isSending, isSlashPanelOpen, onSend]);
|
|
91
128
|
|
|
92
129
|
return {
|
|
93
130
|
isSlashPanelOpen,
|
|
@@ -20,6 +20,10 @@ import {
|
|
|
20
20
|
CHAT_RECENT_MODELS_MIN_OPTIONS,
|
|
21
21
|
chatRecentModelsManager
|
|
22
22
|
} from '@/components/chat/chat-recent-models.manager';
|
|
23
|
+
import {
|
|
24
|
+
CHAT_RECENT_SKILLS_MIN_OPTIONS,
|
|
25
|
+
chatRecentSkillsManager
|
|
26
|
+
} from '@/components/chat/chat-recent-skills.manager';
|
|
23
27
|
import { useI18n } from '@/components/providers/I18nProvider';
|
|
24
28
|
import { useChatInputStore } from '@/components/chat/stores/chat-input.store';
|
|
25
29
|
import { t } from '@/lib/i18n';
|
|
@@ -112,6 +116,14 @@ export function ChatInputBarContainer() {
|
|
|
112
116
|
availableValues: modelRecords.map((option) => option.value),
|
|
113
117
|
minAvailableCount: CHAT_RECENT_MODELS_MIN_OPTIONS
|
|
114
118
|
});
|
|
119
|
+
const recentSkillValues = chatRecentSkillsManager.resolveVisible({
|
|
120
|
+
availableValues: skillRecords.map((record) => record.key),
|
|
121
|
+
minAvailableCount: 0
|
|
122
|
+
});
|
|
123
|
+
const recentSkillGroupValues = chatRecentSkillsManager.resolveVisible({
|
|
124
|
+
availableValues: skillRecords.map((record) => record.key),
|
|
125
|
+
minAvailableCount: CHAT_RECENT_SKILLS_MIN_OPTIONS
|
|
126
|
+
});
|
|
115
127
|
|
|
116
128
|
const hasModelOptions = modelRecords.length > 0;
|
|
117
129
|
const isModelOptionsLoading = !snapshot.isProviderStateResolved && !hasModelOptions;
|
|
@@ -126,10 +138,12 @@ export function ChatInputBarContainer() {
|
|
|
126
138
|
: t('chatModelNoOptions');
|
|
127
139
|
const recentModelsLabel = language === 'zh' ? '最近选择' : 'Recent';
|
|
128
140
|
const allModelsLabel = language === 'zh' ? '全部模型' : 'All models';
|
|
141
|
+
const recentSkillsLabel = language === 'zh' ? '最近使用' : 'Recent';
|
|
142
|
+
const allSkillsLabel = language === 'zh' ? '全部技能' : 'All skills';
|
|
129
143
|
|
|
130
144
|
const slashItems = useMemo(
|
|
131
|
-
() => buildChatSlashItems(skillRecords, slashQuery ?? '', slashTexts),
|
|
132
|
-
[slashQuery, skillRecords, slashTexts]
|
|
145
|
+
() => buildChatSlashItems(skillRecords, slashQuery ?? '', slashTexts, recentSkillValues),
|
|
146
|
+
[slashQuery, skillRecords, slashTexts, recentSkillValues]
|
|
133
147
|
);
|
|
134
148
|
|
|
135
149
|
const selectedModelOption = modelRecords.find((option) => option.value === snapshot.selectedModel);
|
|
@@ -206,6 +220,8 @@ export function ChatInputBarContainer() {
|
|
|
206
220
|
|
|
207
221
|
const skillPicker = buildSkillPickerModel({
|
|
208
222
|
skillRecords,
|
|
223
|
+
recentSkillValues,
|
|
224
|
+
groupedRecentSkillValues: recentSkillGroupValues,
|
|
209
225
|
selectedSkills: snapshot.selectedSkills,
|
|
210
226
|
isLoading: snapshot.isSkillsLoading,
|
|
211
227
|
onSelectedKeysChange: presenter.chatInputManager.selectSkills,
|
|
@@ -214,7 +230,9 @@ export function ChatInputBarContainer() {
|
|
|
214
230
|
searchPlaceholder: t('chatSkillsPickerSearchPlaceholder'),
|
|
215
231
|
emptyLabel: t('chatSkillsPickerEmpty'),
|
|
216
232
|
loadingLabel: t('sessionsLoading'),
|
|
217
|
-
manageLabel: t('chatSkillsPickerManage')
|
|
233
|
+
manageLabel: t('chatSkillsPickerManage'),
|
|
234
|
+
recentSkillsLabel,
|
|
235
|
+
allSkillsLabel
|
|
218
236
|
}
|
|
219
237
|
});
|
|
220
238
|
|
|
@@ -233,6 +251,11 @@ export function ChatInputBarContainer() {
|
|
|
233
251
|
slashMenu={{
|
|
234
252
|
isLoading: snapshot.isSkillsLoading,
|
|
235
253
|
items: slashItems,
|
|
254
|
+
onSelectItem: (item) => {
|
|
255
|
+
if (item.value) {
|
|
256
|
+
presenter.chatInputManager.rememberSkillSelection(item.value);
|
|
257
|
+
}
|
|
258
|
+
},
|
|
236
259
|
texts: {
|
|
237
260
|
slashLoadingLabel: t('chatSlashLoading'),
|
|
238
261
|
slashSectionLabel: t('chatSlashSectionSkills'),
|
|
@@ -274,7 +297,11 @@ export function ChatInputBarContainer() {
|
|
|
274
297
|
isSending: snapshot.isSending,
|
|
275
298
|
canStopGeneration: snapshot.canStopGeneration,
|
|
276
299
|
sendDisabled:
|
|
277
|
-
(
|
|
300
|
+
(
|
|
301
|
+
snapshot.draft.trim().length === 0 &&
|
|
302
|
+
snapshot.attachments.length === 0 &&
|
|
303
|
+
snapshot.selectedSkills.length === 0
|
|
304
|
+
) ||
|
|
278
305
|
!hasModelOptions ||
|
|
279
306
|
snapshot.sessionTypeUnavailable,
|
|
280
307
|
stopDisabled: !snapshot.canStopGeneration,
|
|
@@ -99,3 +99,48 @@ it("keeps historical adapted message references stable when only the streaming m
|
|
|
99
99
|
expect(secondMessages[0]).toBe(firstMessages[0]);
|
|
100
100
|
expect(secondMessages[1]).not.toBe(firstMessages[1]);
|
|
101
101
|
});
|
|
102
|
+
|
|
103
|
+
it("adapts persisted inline token metadata into rich message parts", () => {
|
|
104
|
+
const message = {
|
|
105
|
+
id: "user-inline-token",
|
|
106
|
+
sessionId: "session-1",
|
|
107
|
+
role: "user",
|
|
108
|
+
status: "final",
|
|
109
|
+
timestamp: "2026-03-31T10:00:00.000Z",
|
|
110
|
+
metadata: {
|
|
111
|
+
ui_inline_tokens: [
|
|
112
|
+
{
|
|
113
|
+
kind: "skill",
|
|
114
|
+
key: "weather",
|
|
115
|
+
label: "Weather",
|
|
116
|
+
rawText: "$weather",
|
|
117
|
+
},
|
|
118
|
+
],
|
|
119
|
+
},
|
|
120
|
+
parts: [{ type: "text", text: "please use $weather now" }],
|
|
121
|
+
} satisfies NcpMessage;
|
|
122
|
+
|
|
123
|
+
render(<ChatMessageListContainer messages={[message]} isSending={false} />);
|
|
124
|
+
|
|
125
|
+
const renderedMessages =
|
|
126
|
+
captures.renders[captures.renders.length - 1]?.messages ?? [];
|
|
127
|
+
expect(renderedMessages[0]).toMatchObject({
|
|
128
|
+
parts: [
|
|
129
|
+
{
|
|
130
|
+
type: "inline-content",
|
|
131
|
+
segments: [
|
|
132
|
+
{ type: "markdown", text: "please use " },
|
|
133
|
+
{
|
|
134
|
+
type: "token",
|
|
135
|
+
token: {
|
|
136
|
+
kind: "skill",
|
|
137
|
+
key: "weather",
|
|
138
|
+
label: "Weather",
|
|
139
|
+
},
|
|
140
|
+
},
|
|
141
|
+
{ type: "markdown", text: " now" },
|
|
142
|
+
],
|
|
143
|
+
},
|
|
144
|
+
],
|
|
145
|
+
});
|
|
146
|
+
});
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
type ChatMessageAdapterTexts,
|
|
10
10
|
type ChatMessageSource,
|
|
11
11
|
} from "@/components/chat/adapters/chat-message.adapter";
|
|
12
|
+
import { readInlineTokensFromMetadata } from "@/components/chat/chat-inline-token.utils";
|
|
12
13
|
import { adaptNcpMessageToUiMessage } from "@/components/chat/ncp/ncp-session-adapter";
|
|
13
14
|
import { useI18n } from "@/components/providers/I18nProvider";
|
|
14
15
|
import { formatDateTime, t } from "@/lib/i18n";
|
|
@@ -63,7 +64,11 @@ function buildChatMessageTexts(language: string) {
|
|
|
63
64
|
};
|
|
64
65
|
}
|
|
65
66
|
|
|
66
|
-
export function ChatMessageListContainer(
|
|
67
|
+
export function ChatMessageListContainer({
|
|
68
|
+
messages: rawMessages,
|
|
69
|
+
isSending,
|
|
70
|
+
className,
|
|
71
|
+
}: ChatMessageListContainerProps) {
|
|
67
72
|
const { language } = useI18n();
|
|
68
73
|
const texts = useMemo<ChatMessageAdapterTexts>(
|
|
69
74
|
() => buildChatMessageAdapterTexts(language),
|
|
@@ -71,7 +76,7 @@ export function ChatMessageListContainer(props: ChatMessageListContainerProps) {
|
|
|
71
76
|
);
|
|
72
77
|
|
|
73
78
|
const messages = useMemo(() => {
|
|
74
|
-
return
|
|
79
|
+
return rawMessages.map((message) => {
|
|
75
80
|
const cached = messageViewModelCache.get(message);
|
|
76
81
|
if (cached && cached.language === language) {
|
|
77
82
|
return cached.viewModel;
|
|
@@ -84,6 +89,7 @@ export function ChatMessageListContainer(props: ChatMessageListContainerProps) {
|
|
|
84
89
|
meta: {
|
|
85
90
|
timestamp: uiMessage.meta?.timestamp,
|
|
86
91
|
status: uiMessage.meta?.status,
|
|
92
|
+
inlineTokens: readInlineTokensFromMetadata(message.metadata),
|
|
87
93
|
},
|
|
88
94
|
parts: uiMessage.parts as unknown as ChatMessageSource["parts"],
|
|
89
95
|
};
|
|
@@ -95,7 +101,7 @@ export function ChatMessageListContainer(props: ChatMessageListContainerProps) {
|
|
|
95
101
|
messageViewModelCache.set(message, { language, viewModel });
|
|
96
102
|
return viewModel;
|
|
97
103
|
});
|
|
98
|
-
}, [language,
|
|
104
|
+
}, [language, rawMessages, texts]);
|
|
99
105
|
|
|
100
106
|
const hasAssistantDraft = useMemo(
|
|
101
107
|
() =>
|
|
@@ -114,9 +120,9 @@ export function ChatMessageListContainer(props: ChatMessageListContainerProps) {
|
|
|
114
120
|
return (
|
|
115
121
|
<ChatMessageList
|
|
116
122
|
messages={messages}
|
|
117
|
-
isSending={
|
|
123
|
+
isSending={isSending}
|
|
118
124
|
hasAssistantDraft={hasAssistantDraft}
|
|
119
|
-
className={
|
|
125
|
+
className={className}
|
|
120
126
|
texts={messageTexts}
|
|
121
127
|
/>
|
|
122
128
|
);
|
|
@@ -10,6 +10,7 @@ import { API_BASE } from '@/api/api-base';
|
|
|
10
10
|
import { fetchNcpSessionMessages } from '@/api/ncp-session';
|
|
11
11
|
import { ChatPageLayout, type ChatPageProps, useChatSessionSync } from '@/components/chat/chat-page-shell';
|
|
12
12
|
import { sessionDisplayName } from '@/components/chat/chat-session-display';
|
|
13
|
+
import { buildInlineSkillTokensFromComposer, CHAT_UI_INLINE_TOKENS_METADATA_KEY } from '@/components/chat/chat-inline-token.utils';
|
|
13
14
|
import { createNcpAppClientFetch } from '@/components/chat/ncp/ncp-app-client-fetch';
|
|
14
15
|
import { parseSessionKeyFromRoute, resolveAgentIdFromSessionKey } from '@/components/chat/chat-session-route';
|
|
15
16
|
import { useNcpChatPageData } from '@/components/chat/ncp/ncp-chat-page-data';
|
|
@@ -29,6 +30,7 @@ function buildNcpSendMetadata(payload: {
|
|
|
29
30
|
thinkingLevel?: string;
|
|
30
31
|
sessionType?: string;
|
|
31
32
|
requestedSkills?: string[];
|
|
33
|
+
composerNodes?: Parameters<typeof buildInlineSkillTokensFromComposer>[0];
|
|
32
34
|
}): Record<string, unknown> {
|
|
33
35
|
const metadata: Record<string, unknown> = {};
|
|
34
36
|
if (payload.model?.trim()) {
|
|
@@ -46,6 +48,12 @@ function buildNcpSendMetadata(payload: {
|
|
|
46
48
|
if (requestedSkills.length > 0) {
|
|
47
49
|
metadata.requested_skills = requestedSkills;
|
|
48
50
|
}
|
|
51
|
+
const inlineSkillTokens = payload.composerNodes
|
|
52
|
+
? buildInlineSkillTokensFromComposer(payload.composerNodes)
|
|
53
|
+
: [];
|
|
54
|
+
if (inlineSkillTokens.length > 0) {
|
|
55
|
+
metadata[CHAT_UI_INLINE_TOKENS_METADATA_KEY] = inlineSkillTokens;
|
|
56
|
+
}
|
|
49
57
|
return metadata;
|
|
50
58
|
}
|
|
51
59
|
|
|
@@ -195,7 +203,8 @@ export function NcpChatPage({ view }: ChatPageProps) {
|
|
|
195
203
|
model: payload.model,
|
|
196
204
|
thinkingLevel: payload.thinkingLevel,
|
|
197
205
|
sessionType: payload.sessionType,
|
|
198
|
-
requestedSkills: payload.requestedSkills
|
|
206
|
+
requestedSkills: payload.requestedSkills,
|
|
207
|
+
composerNodes: payload.composerNodes
|
|
199
208
|
});
|
|
200
209
|
const envelope = buildNcpRequestEnvelope({
|
|
201
210
|
sessionId: payload.sessionKey,
|
|
@@ -20,6 +20,7 @@ import type { ChatStreamActionsManager } from '@/components/chat/managers/chat-s
|
|
|
20
20
|
import type { ChatUiManager } from '@/components/chat/managers/chat-ui.manager';
|
|
21
21
|
import { ChatSessionPreferenceSync } from '@/components/chat/chat-session-preference-sync';
|
|
22
22
|
import { chatRecentModelsManager } from '@/components/chat/chat-recent-models.manager';
|
|
23
|
+
import { chatRecentSkillsManager } from '@/components/chat/chat-recent-skills.manager';
|
|
23
24
|
import type { ChatModelOption } from '@/components/chat/chat-input.types';
|
|
24
25
|
import { normalizeSessionType } from '@/components/chat/useChatSessionTypeState';
|
|
25
26
|
|
|
@@ -252,11 +253,24 @@ export class NcpChatInputManager {
|
|
|
252
253
|
this.sessionPreferenceSync.syncSelectedSessionPreferences();
|
|
253
254
|
};
|
|
254
255
|
|
|
256
|
+
rememberSkillSelection = (value: string) => {
|
|
257
|
+
chatRecentSkillsManager.remember(value);
|
|
258
|
+
};
|
|
259
|
+
|
|
255
260
|
selectSkills = (next: string[]) => {
|
|
261
|
+
const prev = useChatInputStore.getState().snapshot.selectedSkills;
|
|
262
|
+
for (const value of next) {
|
|
263
|
+
if (!prev.includes(value)) {
|
|
264
|
+
this.rememberSkillSelection(value);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
256
267
|
this.setSelectedSkills(next);
|
|
257
268
|
};
|
|
258
269
|
|
|
259
|
-
private resolveThinkingForModel
|
|
270
|
+
private resolveThinkingForModel = (
|
|
271
|
+
modelOption: ChatModelOption | undefined,
|
|
272
|
+
current: ThinkingLevel | null
|
|
273
|
+
): ThinkingLevel | null => {
|
|
260
274
|
const capability = modelOption?.thinkingCapability;
|
|
261
275
|
if (!capability || capability.supported.length === 0) {
|
|
262
276
|
return null;
|
|
@@ -271,9 +285,9 @@ export class NcpChatInputManager {
|
|
|
271
285
|
return capability.default;
|
|
272
286
|
}
|
|
273
287
|
return 'off';
|
|
274
|
-
}
|
|
288
|
+
};
|
|
275
289
|
|
|
276
|
-
private reconcileThinkingForModel(model: string): void {
|
|
290
|
+
private reconcileThinkingForModel = (model: string): void => {
|
|
277
291
|
const snapshot = useChatInputStore.getState().snapshot;
|
|
278
292
|
const modelOption = snapshot.modelOptions.find((option) => option.value === model);
|
|
279
293
|
const { selectedThinkingLevel } = snapshot;
|
|
@@ -281,5 +295,5 @@ export class NcpChatInputManager {
|
|
|
281
295
|
if (nextThinking !== selectedThinkingLevel) {
|
|
282
296
|
useChatInputStore.getState().setSnapshot({ selectedThinkingLevel: nextThinking });
|
|
283
297
|
}
|
|
284
|
-
}
|
|
298
|
+
};
|
|
285
299
|
}
|
|
@@ -29,6 +29,7 @@ export type ChatInputManagerLike = {
|
|
|
29
29
|
selectModel: (value: string) => void;
|
|
30
30
|
selectThinkingLevel: (value: ThinkingLevel) => void;
|
|
31
31
|
selectSkills: (next: string[]) => void;
|
|
32
|
+
rememberSkillSelection: (value: string) => void;
|
|
32
33
|
};
|
|
33
34
|
|
|
34
35
|
export type ChatThreadManagerLike = {
|