@nextclaw/ui 0.11.23 → 0.12.0
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 +20 -0
- package/dist/assets/{ChannelsList-DVDu1xvz.js → ChannelsList-NKNKsf1J.js} +1 -1
- package/dist/assets/ChatPage-p23OnnEI.js +43 -0
- package/dist/assets/DocBrowser-C8b2uPgL.js +1 -0
- package/dist/assets/{DocBrowser-BmtBLFU0.js → DocBrowser-DxdSujSc.js} +1 -1
- package/dist/assets/{DocBrowserContext-YIKkPb76.js → DocBrowserContext-CQ-8jMha.js} +1 -1
- package/dist/assets/{LogoBadge-F7ZWdxLT.js → LogoBadge-D-KQIN4U.js} +1 -1
- package/dist/assets/{MarketplacePage-Buo9HrOz.js → MarketplacePage-CRNvxtvx.js} +2 -2
- package/dist/assets/MarketplacePage-GGkEXowp.js +1 -0
- package/dist/assets/{McpMarketplacePage-JnkYwK7p.js → McpMarketplacePage-Cu7GmCcc.js} +2 -2
- package/dist/assets/{ModelConfig-BYRhgp0c.js → ModelConfig-CEpx9fro.js} +1 -1
- package/dist/assets/{ProvidersList-DmLyyHvX.js → ProvidersList-BWbUb7-2.js} +1 -1
- package/dist/assets/{RemoteAccessPage-CDSSvH7Z.js → RemoteAccessPage-NsawrZb0.js} +1 -1
- package/dist/assets/RuntimeConfig-BJHBsVTd.js +1 -0
- package/dist/assets/{SearchConfig-D5f1EkLE.js → SearchConfig-BsaX_WYy.js} +1 -1
- package/dist/assets/{SecretsConfig-D61IKcYt.js → SecretsConfig-CgDZOd3w.js} +1 -1
- package/dist/assets/{SessionsConfig-BRIxVTEv.js → SessionsConfig-Dd-KM7F7.js} +2 -2
- package/dist/assets/{book-open-CXoF5nQC.js → book-open-FnK2xCQd.js} +1 -1
- package/dist/assets/chat-session-display-BD_AN71I.js +1 -0
- package/dist/assets/{chunk-JZWAC4HX-CvRWvTy5.js → chunk-JZWAC4HX-B5l0hr_u.js} +1 -1
- package/dist/assets/{config-DJswxxE8.js → config-JKmXfZ3q.js} +1 -1
- package/dist/assets/{createLucideIcon-CjGHOWb6.js → createLucideIcon-o1WWhwhd.js} +1 -1
- package/dist/assets/{dist-nqTTbVdA.js → dist-C_moWYv7.js} +1 -1
- package/dist/assets/{dist-Cl2QB-2y.js → dist-DazA6Wd_.js} +1 -1
- package/dist/assets/{external-link-tIO7zING.js → external-link-BKje3SiD.js} +1 -1
- package/dist/assets/{hash-JWUyl1pT.js → hash-DfW4DT8O.js} +1 -1
- package/dist/assets/i18n-BK1w-oBy.js +1 -0
- package/dist/assets/index-BZaB1TqM.js +6 -0
- package/dist/assets/index-DaR9igPC.css +1 -0
- package/dist/assets/{label-BIpeNu4r.js → label-BzDWmdOe.js} +1 -1
- package/dist/assets/loader-circle-DdZPxBUz.js +1 -0
- package/dist/assets/{logos-DThdM9lk.js → logos-CTLlde_T.js} +1 -1
- package/dist/assets/{page-layout-D3Xo605Z.js → page-layout-BagR3t59.js} +1 -1
- package/dist/assets/plus-DP2PSCPO.js +1 -0
- package/dist/assets/{popover-BJRUGA_H.js → popover-5DWhNfd4.js} +1 -1
- package/dist/assets/{provider-models-bz5y28rq.js → provider-models-DJ29qHuA.js} +1 -1
- package/dist/assets/{react-7ZHqQtEV.js → react-C3yu5yge.js} +1 -1
- package/dist/assets/{refresh-ccw-CC6-_QuL.js → refresh-ccw-BAJf-h7w.js} +1 -1
- package/dist/assets/{save-DJM5RRWW.js → save-aa6z4GJL.js} +1 -1
- package/dist/assets/search-pD6ZwQYF.js +1 -0
- package/dist/assets/{security-config-DbUyWcQz.js → security-config-DRDxrApx.js} +1 -1
- package/dist/assets/{select-DSkTc61S.js → select-BHJPiJWt.js} +1 -1
- package/dist/assets/skeleton-D6kCk9Y6.js +1 -0
- package/dist/assets/{status-dot-LNBlDu3q.js → status-dot-DUwsTIdv.js} +1 -1
- package/dist/assets/{switch-Bo-Y46HZ.js → switch-B6nCfcOB.js} +1 -1
- package/dist/assets/{tabs-custom-DXv507_2.js → tabs-custom-B57SMElx.js} +1 -1
- package/dist/assets/{trash-2-DFZmW6Gg.js → trash-2-CrjYH5ok.js} +1 -1
- package/dist/assets/{useConfirmDialog-COwYXDKm.js → useConfirmDialog-DsxnXB1B.js} +1 -1
- package/dist/assets/{useMutation-DrZrOgVL.js → useMutation-oTTWXgLG.js} +1 -1
- package/dist/assets/x-CTIQHUuD.js +1 -0
- package/dist/index.html +18 -18
- package/package.json +6 -6
- package/src/App.tsx +2 -0
- package/src/api/agents.ts +26 -0
- package/src/api/types.ts +23 -2
- package/src/components/agents/AgentsPage.test.tsx +70 -0
- package/src/components/agents/AgentsPage.tsx +353 -0
- package/src/components/chat/ChatConversationPanel.test.tsx +141 -13
- package/src/components/chat/ChatConversationPanel.tsx +29 -7
- package/src/components/chat/ChatSidebar.test.tsx +8 -0
- package/src/components/chat/ChatSidebar.tsx +11 -0
- package/src/components/chat/ChatWelcome.test.tsx +25 -0
- package/src/components/chat/ChatWelcome.tsx +47 -1
- package/src/components/chat/adapters/chat-message-part.adapter.ts +5 -0
- package/src/components/chat/adapters/chat-message-tool-agent-id.test.ts +102 -0
- package/src/components/chat/adapters/chat-message-tool-agent-id.ts +47 -0
- package/src/components/chat/adapters/chat-message.adapter.test.ts +6 -0
- package/src/components/chat/adapters/chat-message.session-request-tool-card.ts +24 -15
- package/src/components/chat/chat-child-session-panel.tsx +115 -49
- package/src/components/chat/chat-page-shell.tsx +8 -17
- package/src/components/chat/chat-session-route.ts +0 -14
- package/src/components/chat/chat-sidebar-session-item.tsx +16 -1
- package/src/components/chat/containers/chat-input-bar.container.tsx +2 -1
- package/src/components/chat/containers/chat-message-list.container.tsx +7 -0
- package/src/components/chat/ncp/NcpChatPage.tsx +58 -160
- package/src/components/chat/ncp/README.md +3 -0
- package/src/components/chat/ncp/ncp-chat-page-data.test.ts +2 -0
- package/src/components/chat/ncp/ncp-chat-thread.manager.ts +66 -10
- package/src/components/chat/ncp/ncp-session-adapter.test.ts +2 -0
- package/src/components/chat/ncp/ncp-session-adapter.ts +1 -0
- package/src/components/chat/ncp/page/ncp-chat-derived-state.ts +128 -0
- package/src/components/chat/ncp/session-conversation/use-ncp-child-session-tabs-view.ts +52 -0
- package/src/components/chat/ncp/session-conversation/use-ncp-session-conversation.test.tsx +101 -0
- package/src/components/chat/ncp/session-conversation/use-ncp-session-conversation.ts +72 -0
- package/src/components/chat/presenter/chat-presenter-context.tsx +1 -0
- package/src/components/chat/stores/chat-thread.store.ts +20 -6
- package/src/components/common/AgentAvatar.tsx +63 -0
- package/src/components/common/agent-identity/agent-identity-avatar.tsx +27 -0
- package/src/components/common/agent-identity/index.ts +3 -0
- package/src/components/common/agent-identity/use-agent-identity.ts +50 -0
- package/src/components/config/RuntimeConfig.tsx +13 -79
- package/src/components/config/runtime-config-agent.utils.ts +95 -0
- package/src/components/layout/AppLayout.tsx +3 -1
- package/src/components/layout/Sidebar.tsx +6 -1
- package/src/components/layout/app-layout.test.tsx +30 -0
- package/src/components/ui/tabs.tsx +2 -0
- package/src/hooks/README.md +3 -0
- package/src/hooks/agents/useAgents.ts +44 -0
- package/src/lib/i18n.agents.ts +66 -0
- package/src/lib/i18n.chat.ts +5 -0
- package/src/lib/i18n.ts +4 -4
- package/src/lib/ui-document-title.ts +1 -0
- package/dist/assets/ChatPage-Z9tRzm_n.js +0 -43
- package/dist/assets/DocBrowser-B9OaZjmg.js +0 -1
- package/dist/assets/MarketplacePage-D6rVQEQR.js +0 -1
- package/dist/assets/RuntimeConfig-v7a7Fe3x.js +0 -1
- package/dist/assets/chat-session-display-D0WpnuRZ.js +0 -1
- package/dist/assets/i18n-CDHMXlRZ.js +0 -1
- package/dist/assets/index-BuwbBgmT.js +0 -6
- package/dist/assets/index-bZ8cqQIS.css +0 -1
- package/dist/assets/loader-circle-Cs8XVFTw.js +0 -1
- package/dist/assets/plus-PHf8q-Ct.js +0 -1
- package/dist/assets/search-C91yH_6y.js +0 -1
- package/dist/assets/skeleton-Dzg-HOiN.js +0 -1
- package/dist/assets/x-D7Q1yqSF.js +0 -1
- /package/src/lib/{i18n → i18n-runtime}/i18n-language-owner.ts +0 -0
- /package/src/lib/{i18n → i18n-runtime}/i18n.path-picker.ts +0 -0
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { renderHook } from "@testing-library/react";
|
|
2
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { fetchNcpSessionConversationSeed, useNcpSessionConversation } from "./use-ncp-session-conversation";
|
|
4
|
+
|
|
5
|
+
const mocks = vi.hoisted(() => ({
|
|
6
|
+
fetchNcpSessionMessages: vi.fn(),
|
|
7
|
+
hydratedCalls: [] as Array<{ client: unknown }>,
|
|
8
|
+
useHydratedNcpAgent: vi.fn(() => ({
|
|
9
|
+
snapshot: {
|
|
10
|
+
messages: [],
|
|
11
|
+
streamingMessage: null,
|
|
12
|
+
activeRun: null,
|
|
13
|
+
error: null,
|
|
14
|
+
},
|
|
15
|
+
visibleMessages: [],
|
|
16
|
+
activeRunId: null,
|
|
17
|
+
isRunning: false,
|
|
18
|
+
isSending: false,
|
|
19
|
+
send: vi.fn(),
|
|
20
|
+
abort: vi.fn(),
|
|
21
|
+
streamRun: vi.fn(),
|
|
22
|
+
isHydrating: false,
|
|
23
|
+
hydrateError: null,
|
|
24
|
+
})),
|
|
25
|
+
clientInstances: [] as unknown[],
|
|
26
|
+
}));
|
|
27
|
+
|
|
28
|
+
vi.mock("@/api/ncp-session", () => ({
|
|
29
|
+
fetchNcpSessionMessages: mocks.fetchNcpSessionMessages,
|
|
30
|
+
}));
|
|
31
|
+
|
|
32
|
+
vi.mock("@nextclaw/ncp-react", () => ({
|
|
33
|
+
useHydratedNcpAgent: vi.fn((params: { client: unknown }) => {
|
|
34
|
+
mocks.hydratedCalls.push(params);
|
|
35
|
+
return mocks.useHydratedNcpAgent();
|
|
36
|
+
}),
|
|
37
|
+
}));
|
|
38
|
+
|
|
39
|
+
vi.mock("@nextclaw/ncp-http-agent-client", () => ({
|
|
40
|
+
NcpHttpAgentClientEndpoint: vi.fn().mockImplementation(function MockClient(this: object) {
|
|
41
|
+
mocks.clientInstances.push(this);
|
|
42
|
+
}),
|
|
43
|
+
}));
|
|
44
|
+
|
|
45
|
+
describe("useNcpSessionConversation", () => {
|
|
46
|
+
beforeEach(() => {
|
|
47
|
+
mocks.fetchNcpSessionMessages.mockReset();
|
|
48
|
+
mocks.useHydratedNcpAgent.mockClear();
|
|
49
|
+
mocks.hydratedCalls.length = 0;
|
|
50
|
+
mocks.clientInstances.length = 0;
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("hydrates seed from the shared session messages endpoint payload", async () => {
|
|
54
|
+
mocks.fetchNcpSessionMessages.mockResolvedValue({
|
|
55
|
+
sessionId: "session-1",
|
|
56
|
+
status: "running",
|
|
57
|
+
total: 1,
|
|
58
|
+
messages: [{ id: "msg-1" }],
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const result = await fetchNcpSessionConversationSeed(
|
|
62
|
+
"session-1",
|
|
63
|
+
new AbortController().signal,
|
|
64
|
+
300,
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
expect(mocks.fetchNcpSessionMessages).toHaveBeenCalledWith("session-1", 300);
|
|
68
|
+
expect(result).toEqual({
|
|
69
|
+
messages: [{ id: "msg-1" }],
|
|
70
|
+
status: "running",
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("treats a missing session as an empty idle draft seed", async () => {
|
|
75
|
+
mocks.fetchNcpSessionMessages.mockRejectedValue(
|
|
76
|
+
new Error("ncp session not found: draft-session"),
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
const result = await fetchNcpSessionConversationSeed(
|
|
80
|
+
"draft-session",
|
|
81
|
+
new AbortController().signal,
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
expect(result).toEqual({
|
|
85
|
+
messages: [],
|
|
86
|
+
status: "idle",
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("creates an isolated endpoint instance per viewer", () => {
|
|
91
|
+
renderHook(() => useNcpSessionConversation("session-a"));
|
|
92
|
+
renderHook(() => useNcpSessionConversation("session-b"));
|
|
93
|
+
|
|
94
|
+
expect(mocks.useHydratedNcpAgent).toHaveBeenCalledTimes(2);
|
|
95
|
+
expect(mocks.clientInstances).toHaveLength(2);
|
|
96
|
+
expect(mocks.hydratedCalls).toHaveLength(2);
|
|
97
|
+
expect(mocks.clientInstances[0]).not.toBe(mocks.clientInstances[1]);
|
|
98
|
+
expect(mocks.hydratedCalls[0]?.client).toBe(mocks.clientInstances[0]);
|
|
99
|
+
expect(mocks.hydratedCalls[1]?.client).toBe(mocks.clientInstances[1]);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { useCallback, useState } from "react";
|
|
2
|
+
import { NcpHttpAgentClientEndpoint } from "@nextclaw/ncp-http-agent-client";
|
|
3
|
+
import { useHydratedNcpAgent, type NcpConversationSeed } from "@nextclaw/ncp-react";
|
|
4
|
+
import { API_BASE } from "@/api/api-base";
|
|
5
|
+
import { fetchNcpSessionMessages } from "@/api/ncp-session";
|
|
6
|
+
import { createNcpAppClientFetch } from "@/components/chat/ncp/ncp-app-client-fetch";
|
|
7
|
+
|
|
8
|
+
const DEFAULT_MESSAGE_LIMIT = 300;
|
|
9
|
+
|
|
10
|
+
type UseNcpSessionConversationOptions = {
|
|
11
|
+
messageLimit?: number;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
function isMissingNcpSessionError(error: unknown): boolean {
|
|
15
|
+
if (!(error instanceof Error)) {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
return error.message.includes("ncp session not found:");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function createNcpSessionConversationClient(): NcpHttpAgentClientEndpoint {
|
|
22
|
+
return new NcpHttpAgentClientEndpoint({
|
|
23
|
+
baseUrl: API_BASE,
|
|
24
|
+
basePath: "/api/ncp/agent",
|
|
25
|
+
fetchImpl: createNcpAppClientFetch(),
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function fetchNcpSessionConversationSeed(
|
|
30
|
+
sessionId: string,
|
|
31
|
+
signal: AbortSignal,
|
|
32
|
+
messageLimit = DEFAULT_MESSAGE_LIMIT,
|
|
33
|
+
): Promise<NcpConversationSeed> {
|
|
34
|
+
signal.throwIfAborted();
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
const response = await fetchNcpSessionMessages(sessionId, messageLimit);
|
|
38
|
+
signal.throwIfAborted();
|
|
39
|
+
return {
|
|
40
|
+
messages: response.messages,
|
|
41
|
+
status: response.status ?? "idle",
|
|
42
|
+
};
|
|
43
|
+
} catch (error) {
|
|
44
|
+
signal.throwIfAborted();
|
|
45
|
+
if (!isMissingNcpSessionError(error)) {
|
|
46
|
+
throw error;
|
|
47
|
+
}
|
|
48
|
+
return {
|
|
49
|
+
messages: [],
|
|
50
|
+
status: "idle",
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function useNcpSessionConversation(
|
|
56
|
+
sessionId: string,
|
|
57
|
+
options: UseNcpSessionConversationOptions = {},
|
|
58
|
+
) {
|
|
59
|
+
const [client] = useState(() => createNcpSessionConversationClient());
|
|
60
|
+
const messageLimit = options.messageLimit ?? DEFAULT_MESSAGE_LIMIT;
|
|
61
|
+
const loadSeed = useCallback(
|
|
62
|
+
(targetSessionId: string, signal: AbortSignal) =>
|
|
63
|
+
fetchNcpSessionConversationSeed(targetSessionId, signal, messageLimit),
|
|
64
|
+
[messageLimit],
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
return useHydratedNcpAgent({
|
|
68
|
+
sessionId,
|
|
69
|
+
client,
|
|
70
|
+
loadSeed,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
@@ -38,6 +38,7 @@ export type ChatThreadManagerLike = {
|
|
|
38
38
|
createSession: () => void;
|
|
39
39
|
goToProviders: () => void;
|
|
40
40
|
openSessionFromToolAction: (action: ChatToolActionViewModel) => void;
|
|
41
|
+
selectChildSessionDetail: (sessionKey: string) => void;
|
|
41
42
|
closeChildSessionDetail: () => void;
|
|
42
43
|
goToParentSession: () => void;
|
|
43
44
|
};
|
|
@@ -2,6 +2,14 @@ import { create } from 'zustand';
|
|
|
2
2
|
import type { MutableRefObject } from 'react';
|
|
3
3
|
import type { NcpMessage } from '@nextclaw/ncp';
|
|
4
4
|
import type { ChatModelOption } from '@/components/chat/chat-input.types';
|
|
5
|
+
import type { AgentProfileView } from '@/api/types';
|
|
6
|
+
|
|
7
|
+
export type ChatChildSessionTab = {
|
|
8
|
+
sessionKey: string;
|
|
9
|
+
parentSessionKey: string | null;
|
|
10
|
+
label?: string | null;
|
|
11
|
+
agentId?: string | null;
|
|
12
|
+
};
|
|
5
13
|
|
|
6
14
|
export type ChatThreadSnapshot = {
|
|
7
15
|
isProviderStateResolved: boolean;
|
|
@@ -10,6 +18,10 @@ export type ChatThreadSnapshot = {
|
|
|
10
18
|
sessionTypeUnavailableMessage?: string | null;
|
|
11
19
|
sessionTypeLabel?: string | null;
|
|
12
20
|
sessionKey: string | null;
|
|
21
|
+
agentId?: string | null;
|
|
22
|
+
agentDisplayName?: string | null;
|
|
23
|
+
agentAvatarUrl?: string | null;
|
|
24
|
+
availableAgents?: AgentProfileView[];
|
|
13
25
|
sessionDisplayName?: string;
|
|
14
26
|
sessionProjectRoot?: string | null;
|
|
15
27
|
sessionProjectName?: string | null;
|
|
@@ -22,9 +34,8 @@ export type ChatThreadSnapshot = {
|
|
|
22
34
|
isAwaitingAssistantOutput: boolean;
|
|
23
35
|
parentSessionKey?: string | null;
|
|
24
36
|
parentSessionLabel?: string | null;
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
childSessionDetailLabel?: string | null;
|
|
37
|
+
childSessionTabs: ChatChildSessionTab[];
|
|
38
|
+
activeChildSessionKey?: string | null;
|
|
28
39
|
};
|
|
29
40
|
|
|
30
41
|
type ChatThreadStore = {
|
|
@@ -39,6 +50,10 @@ const initialSnapshot: ChatThreadSnapshot = {
|
|
|
39
50
|
sessionTypeUnavailableMessage: null,
|
|
40
51
|
sessionTypeLabel: null,
|
|
41
52
|
sessionKey: null,
|
|
53
|
+
agentId: null,
|
|
54
|
+
agentDisplayName: null,
|
|
55
|
+
agentAvatarUrl: null,
|
|
56
|
+
availableAgents: [],
|
|
42
57
|
sessionDisplayName: undefined,
|
|
43
58
|
sessionProjectRoot: null,
|
|
44
59
|
sessionProjectName: null,
|
|
@@ -51,9 +66,8 @@ const initialSnapshot: ChatThreadSnapshot = {
|
|
|
51
66
|
isAwaitingAssistantOutput: false,
|
|
52
67
|
parentSessionKey: null,
|
|
53
68
|
parentSessionLabel: null,
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
childSessionDetailLabel: null,
|
|
69
|
+
childSessionTabs: [],
|
|
70
|
+
activeChildSessionKey: null,
|
|
57
71
|
};
|
|
58
72
|
|
|
59
73
|
export const useChatThreadStore = create<ChatThreadStore>((set) => ({
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { cn } from '@/lib/utils';
|
|
2
|
+
|
|
3
|
+
type AgentAvatarProps = {
|
|
4
|
+
agentId: string;
|
|
5
|
+
displayName?: string | null;
|
|
6
|
+
avatarUrl?: string | null;
|
|
7
|
+
className?: string;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
const PALETTE = [
|
|
11
|
+
['bg-amber-100', 'text-amber-700'],
|
|
12
|
+
['bg-emerald-100', 'text-emerald-700'],
|
|
13
|
+
['bg-blue-100', 'text-blue-700'],
|
|
14
|
+
['bg-rose-100', 'text-rose-700'],
|
|
15
|
+
['bg-cyan-100', 'text-cyan-700'],
|
|
16
|
+
['bg-violet-100', 'text-violet-700']
|
|
17
|
+
] as const;
|
|
18
|
+
|
|
19
|
+
function hashText(value: string): number {
|
|
20
|
+
let hash = 0;
|
|
21
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
22
|
+
hash = (hash << 5) - hash + value.charCodeAt(index);
|
|
23
|
+
hash |= 0;
|
|
24
|
+
}
|
|
25
|
+
return Math.abs(hash);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function resolveLetter(value: string): string {
|
|
29
|
+
const trimmed = value.trim();
|
|
30
|
+
if (!trimmed) {
|
|
31
|
+
return 'A';
|
|
32
|
+
}
|
|
33
|
+
return trimmed.slice(0, 1).toUpperCase();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function AgentAvatar({ agentId, displayName, avatarUrl, className }: AgentAvatarProps) {
|
|
37
|
+
const seed = displayName?.trim() || agentId;
|
|
38
|
+
const [bgClass, textClass] = PALETTE[hashText(agentId) % PALETTE.length] ?? PALETTE[0];
|
|
39
|
+
|
|
40
|
+
if (avatarUrl?.trim()) {
|
|
41
|
+
return (
|
|
42
|
+
<img
|
|
43
|
+
src={avatarUrl}
|
|
44
|
+
alt={displayName?.trim() || agentId}
|
|
45
|
+
className={cn('rounded-full object-cover', className)}
|
|
46
|
+
/>
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return (
|
|
51
|
+
<div
|
|
52
|
+
className={cn(
|
|
53
|
+
'rounded-full flex items-center justify-center font-semibold',
|
|
54
|
+
bgClass,
|
|
55
|
+
textClass,
|
|
56
|
+
className
|
|
57
|
+
)}
|
|
58
|
+
aria-label={displayName?.trim() || agentId}
|
|
59
|
+
>
|
|
60
|
+
{resolveLetter(seed)}
|
|
61
|
+
</div>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { AgentAvatar } from '@/components/common/AgentAvatar';
|
|
2
|
+
import { useAgentIdentity } from '@/components/common/agent-identity/use-agent-identity';
|
|
3
|
+
|
|
4
|
+
type AgentIdentityAvatarProps = {
|
|
5
|
+
agentId?: string | null;
|
|
6
|
+
className?: string;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export function AgentIdentityAvatar({
|
|
10
|
+
agentId,
|
|
11
|
+
className,
|
|
12
|
+
}: AgentIdentityAvatarProps) {
|
|
13
|
+
const identity = useAgentIdentity(agentId);
|
|
14
|
+
|
|
15
|
+
if (!identity) {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return (
|
|
20
|
+
<AgentAvatar
|
|
21
|
+
agentId={identity.agentId}
|
|
22
|
+
displayName={identity.displayName}
|
|
23
|
+
avatarUrl={identity.avatarUrl}
|
|
24
|
+
className={className}
|
|
25
|
+
/>
|
|
26
|
+
);
|
|
27
|
+
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export { AgentIdentityAvatar } from '@/components/common/agent-identity/agent-identity-avatar';
|
|
2
|
+
export { useAgentIdentity } from '@/components/common/agent-identity/use-agent-identity';
|
|
3
|
+
export type { ResolvedAgentIdentity } from '@/components/common/agent-identity/use-agent-identity';
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { useMemo } from 'react';
|
|
2
|
+
import type { AgentProfileView } from '@/api/types';
|
|
3
|
+
import { useChatThreadStore } from '@/components/chat/stores/chat-thread.store';
|
|
4
|
+
import { useAgents } from '@/hooks/agents/useAgents';
|
|
5
|
+
|
|
6
|
+
export type ResolvedAgentIdentity = {
|
|
7
|
+
agentId: string;
|
|
8
|
+
profile: AgentProfileView | null;
|
|
9
|
+
displayName: string;
|
|
10
|
+
avatarUrl: string | null;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
function buildAgentProfileMap(
|
|
14
|
+
scopedAgents: readonly AgentProfileView[],
|
|
15
|
+
queryAgents: readonly AgentProfileView[],
|
|
16
|
+
): Map<string, AgentProfileView> {
|
|
17
|
+
return new Map(
|
|
18
|
+
[...queryAgents, ...scopedAgents]
|
|
19
|
+
.filter((agent) => typeof agent.id === 'string' && agent.id.trim().length > 0)
|
|
20
|
+
.map((agent) => [agent.id, agent]),
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function useAgentIdentity(
|
|
25
|
+
agentId?: string | null,
|
|
26
|
+
): ResolvedAgentIdentity | null {
|
|
27
|
+
const normalizedAgentId = agentId?.trim() ?? '';
|
|
28
|
+
const scopedAgents = useChatThreadStore(
|
|
29
|
+
(state) => state.snapshot.availableAgents ?? [],
|
|
30
|
+
);
|
|
31
|
+
const agentsQuery = useAgents();
|
|
32
|
+
|
|
33
|
+
const agentById = useMemo(
|
|
34
|
+
() => buildAgentProfileMap(scopedAgents, agentsQuery.data?.agents ?? []),
|
|
35
|
+
[agentsQuery.data?.agents, scopedAgents],
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
return useMemo(() => {
|
|
39
|
+
if (!normalizedAgentId) {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
const profile = agentById.get(normalizedAgentId) ?? null;
|
|
43
|
+
return {
|
|
44
|
+
agentId: normalizedAgentId,
|
|
45
|
+
profile,
|
|
46
|
+
displayName: profile?.displayName?.trim() || normalizedAgentId,
|
|
47
|
+
avatarUrl: profile?.avatarUrl?.trim() || null,
|
|
48
|
+
};
|
|
49
|
+
}, [agentById, normalizedAgentId]);
|
|
50
|
+
}
|
|
@@ -6,6 +6,14 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
|
|
|
6
6
|
import { Input } from '@/components/ui/input';
|
|
7
7
|
import { Switch } from '@/components/ui/switch';
|
|
8
8
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
|
9
|
+
import {
|
|
10
|
+
createEmptyRuntimeAgent,
|
|
11
|
+
createEmptyRuntimeBinding,
|
|
12
|
+
hydrateRuntimeAgent,
|
|
13
|
+
hydrateRuntimeBinding,
|
|
14
|
+
parseOptionalInt,
|
|
15
|
+
toPersistedRuntimeAgent
|
|
16
|
+
} from '@/components/config/runtime-config-agent.utils';
|
|
9
17
|
import { hintForPath } from '@/lib/config-hints';
|
|
10
18
|
import { t } from '@/lib/i18n';
|
|
11
19
|
import { PageLayout, PageHeader } from '@/components/layout/page-layout';
|
|
@@ -22,37 +30,6 @@ const DM_SCOPE_OPTIONS: Array<{ value: DmScope; label: string }> = [
|
|
|
22
30
|
{ value: 'per-account-channel-peer', label: 'per-account-channel-peer' }
|
|
23
31
|
];
|
|
24
32
|
|
|
25
|
-
function createEmptyAgent(): AgentProfileView {
|
|
26
|
-
return {
|
|
27
|
-
id: '',
|
|
28
|
-
default: false,
|
|
29
|
-
workspace: '',
|
|
30
|
-
model: '',
|
|
31
|
-
engine: '',
|
|
32
|
-
contextTokens: undefined,
|
|
33
|
-
maxToolIterations: undefined
|
|
34
|
-
};
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
function createEmptyBinding(): AgentBindingView {
|
|
38
|
-
return {
|
|
39
|
-
agentId: '',
|
|
40
|
-
match: {
|
|
41
|
-
channel: '',
|
|
42
|
-
accountId: ''
|
|
43
|
-
}
|
|
44
|
-
};
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
function parseOptionalInt(value: string): number | undefined {
|
|
48
|
-
const trimmed = value.trim();
|
|
49
|
-
if (!trimmed) {
|
|
50
|
-
return undefined;
|
|
51
|
-
}
|
|
52
|
-
const parsed = Number.parseInt(trimmed, 10);
|
|
53
|
-
return Number.isFinite(parsed) ? parsed : undefined;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
33
|
export function RuntimeConfig() {
|
|
57
34
|
const { data: config, isLoading } = useConfig();
|
|
58
35
|
const { data: schema } = useConfigSchema();
|
|
@@ -69,32 +46,8 @@ export function RuntimeConfig() {
|
|
|
69
46
|
if (!config) {
|
|
70
47
|
return;
|
|
71
48
|
}
|
|
72
|
-
setAgents(
|
|
73
|
-
|
|
74
|
-
id: agent.id ?? '',
|
|
75
|
-
default: Boolean(agent.default),
|
|
76
|
-
workspace: agent.workspace ?? '',
|
|
77
|
-
model: agent.model ?? '',
|
|
78
|
-
engine: agent.engine ?? '',
|
|
79
|
-
contextTokens: agent.contextTokens,
|
|
80
|
-
maxToolIterations: agent.maxToolIterations
|
|
81
|
-
}))
|
|
82
|
-
);
|
|
83
|
-
setBindings(
|
|
84
|
-
(config.bindings ?? []).map((binding) => ({
|
|
85
|
-
agentId: binding.agentId ?? '',
|
|
86
|
-
match: {
|
|
87
|
-
channel: binding.match?.channel ?? '',
|
|
88
|
-
accountId: binding.match?.accountId ?? '',
|
|
89
|
-
peer: binding.match?.peer
|
|
90
|
-
? {
|
|
91
|
-
kind: binding.match.peer.kind,
|
|
92
|
-
id: binding.match.peer.id
|
|
93
|
-
}
|
|
94
|
-
: undefined
|
|
95
|
-
}
|
|
96
|
-
}))
|
|
97
|
-
);
|
|
49
|
+
setAgents((config.agents.list ?? []).map(hydrateRuntimeAgent));
|
|
50
|
+
setBindings((config.bindings ?? []).map(hydrateRuntimeBinding));
|
|
98
51
|
setDmScope((config.session?.dmScope as DmScope) ?? 'per-channel-peer');
|
|
99
52
|
setMaxPingPongTurns(config.session?.agentToAgent?.maxPingPongTurns ?? 0);
|
|
100
53
|
setDefaultContextTokens(config.agents.defaults.contextTokens ?? 200000);
|
|
@@ -137,26 +90,7 @@ export function RuntimeConfig() {
|
|
|
137
90
|
if (!id) {
|
|
138
91
|
throw new Error(t('agentIdRequiredError').replace('{index}', String(index)));
|
|
139
92
|
}
|
|
140
|
-
|
|
141
|
-
if (agent.default) {
|
|
142
|
-
normalized.default = true;
|
|
143
|
-
}
|
|
144
|
-
if (agent.workspace?.trim()) {
|
|
145
|
-
normalized.workspace = agent.workspace.trim();
|
|
146
|
-
}
|
|
147
|
-
if (agent.model?.trim()) {
|
|
148
|
-
normalized.model = agent.model.trim();
|
|
149
|
-
}
|
|
150
|
-
if (agent.engine?.trim()) {
|
|
151
|
-
normalized.engine = agent.engine.trim();
|
|
152
|
-
}
|
|
153
|
-
if (typeof agent.contextTokens === 'number') {
|
|
154
|
-
normalized.contextTokens = Math.max(1000, agent.contextTokens);
|
|
155
|
-
}
|
|
156
|
-
if (typeof agent.maxToolIterations === 'number') {
|
|
157
|
-
normalized.maxToolIterations = agent.maxToolIterations;
|
|
158
|
-
}
|
|
159
|
-
return normalized;
|
|
93
|
+
return toPersistedRuntimeAgent(agent);
|
|
160
94
|
});
|
|
161
95
|
|
|
162
96
|
const duplicates = normalizedAgents
|
|
@@ -389,7 +323,7 @@ export function RuntimeConfig() {
|
|
|
389
323
|
</div>
|
|
390
324
|
))}
|
|
391
325
|
|
|
392
|
-
<Button type="button" variant="outline" onClick={() => setAgents((prev) => [...prev,
|
|
326
|
+
<Button type="button" variant="outline" onClick={() => setAgents((prev) => [...prev, createEmptyRuntimeAgent()])}>
|
|
393
327
|
<Plus className="h-4 w-4 mr-2" />
|
|
394
328
|
{t('addAgent')}
|
|
395
329
|
</Button>
|
|
@@ -510,7 +444,7 @@ export function RuntimeConfig() {
|
|
|
510
444
|
);
|
|
511
445
|
})}
|
|
512
446
|
|
|
513
|
-
<Button type="button" variant="outline" onClick={() => setBindings((prev) => [...prev,
|
|
447
|
+
<Button type="button" variant="outline" onClick={() => setBindings((prev) => [...prev, createEmptyRuntimeBinding()])}>
|
|
514
448
|
<Plus className="h-4 w-4 mr-2" />
|
|
515
449
|
{t('addBinding')}
|
|
516
450
|
</Button>
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import type { AgentBindingView, AgentProfileView } from '@/api/types';
|
|
2
|
+
|
|
3
|
+
export function createEmptyRuntimeAgent(): AgentProfileView {
|
|
4
|
+
return {
|
|
5
|
+
id: '',
|
|
6
|
+
default: false,
|
|
7
|
+
workspace: '',
|
|
8
|
+
model: '',
|
|
9
|
+
engine: '',
|
|
10
|
+
contextTokens: undefined,
|
|
11
|
+
maxToolIterations: undefined
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function createEmptyRuntimeBinding(): AgentBindingView {
|
|
16
|
+
return {
|
|
17
|
+
agentId: '',
|
|
18
|
+
match: {
|
|
19
|
+
channel: '',
|
|
20
|
+
accountId: ''
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function hydrateRuntimeAgent(agent: AgentProfileView): AgentProfileView {
|
|
26
|
+
return {
|
|
27
|
+
id: agent.id ?? '',
|
|
28
|
+
default: Boolean(agent.default),
|
|
29
|
+
displayName: agent.displayName ?? '',
|
|
30
|
+
description: agent.description ?? '',
|
|
31
|
+
avatar: agent.avatar ?? '',
|
|
32
|
+
workspace: agent.workspace ?? '',
|
|
33
|
+
model: agent.model ?? '',
|
|
34
|
+
engine: agent.engine ?? '',
|
|
35
|
+
contextTokens: agent.contextTokens,
|
|
36
|
+
maxToolIterations: agent.maxToolIterations
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function hydrateRuntimeBinding(binding: AgentBindingView): AgentBindingView {
|
|
41
|
+
return {
|
|
42
|
+
agentId: binding.agentId ?? '',
|
|
43
|
+
match: {
|
|
44
|
+
channel: binding.match?.channel ?? '',
|
|
45
|
+
accountId: binding.match?.accountId ?? '',
|
|
46
|
+
peer: binding.match?.peer
|
|
47
|
+
? {
|
|
48
|
+
kind: binding.match.peer.kind,
|
|
49
|
+
id: binding.match.peer.id
|
|
50
|
+
}
|
|
51
|
+
: undefined
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function parseOptionalInt(value: string): number | undefined {
|
|
57
|
+
const trimmed = value.trim();
|
|
58
|
+
if (!trimmed) {
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
const parsed = Number.parseInt(trimmed, 10);
|
|
62
|
+
return Number.isFinite(parsed) ? parsed : undefined;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function toPersistedRuntimeAgent(agent: AgentProfileView): AgentProfileView {
|
|
66
|
+
const normalized: AgentProfileView = { id: agent.id.trim() };
|
|
67
|
+
if (agent.default) {
|
|
68
|
+
normalized.default = true;
|
|
69
|
+
}
|
|
70
|
+
if (agent.displayName?.trim()) {
|
|
71
|
+
normalized.displayName = agent.displayName.trim();
|
|
72
|
+
}
|
|
73
|
+
if (agent.description?.trim()) {
|
|
74
|
+
normalized.description = agent.description.trim();
|
|
75
|
+
}
|
|
76
|
+
if (agent.avatar?.trim()) {
|
|
77
|
+
normalized.avatar = agent.avatar.trim();
|
|
78
|
+
}
|
|
79
|
+
if (agent.workspace?.trim()) {
|
|
80
|
+
normalized.workspace = agent.workspace.trim();
|
|
81
|
+
}
|
|
82
|
+
if (agent.model?.trim()) {
|
|
83
|
+
normalized.model = agent.model.trim();
|
|
84
|
+
}
|
|
85
|
+
if (agent.engine?.trim()) {
|
|
86
|
+
normalized.engine = agent.engine.trim();
|
|
87
|
+
}
|
|
88
|
+
if (typeof agent.contextTokens === 'number') {
|
|
89
|
+
normalized.contextTokens = Math.max(1000, agent.contextTokens);
|
|
90
|
+
}
|
|
91
|
+
if (typeof agent.maxToolIterations === 'number') {
|
|
92
|
+
normalized.maxToolIterations = agent.maxToolIterations;
|
|
93
|
+
}
|
|
94
|
+
return normalized;
|
|
95
|
+
}
|
|
@@ -21,7 +21,9 @@ function isMainWorkspaceRoute(pathname: string): boolean {
|
|
|
21
21
|
normalized === '/skills' ||
|
|
22
22
|
normalized.startsWith('/skills/') ||
|
|
23
23
|
normalized === '/cron' ||
|
|
24
|
-
normalized.startsWith('/cron/')
|
|
24
|
+
normalized.startsWith('/cron/') ||
|
|
25
|
+
normalized === '/agents' ||
|
|
26
|
+
normalized.startsWith('/agents/')
|
|
25
27
|
);
|
|
26
28
|
}
|
|
27
29
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { cn } from '@/lib/utils';
|
|
2
2
|
import { LANGUAGE_OPTIONS, t, type I18nLanguage } from '@/lib/i18n';
|
|
3
3
|
import { THEME_OPTIONS, type UiTheme } from '@/lib/theme';
|
|
4
|
-
import { Cpu, GitBranch, History, MessageCircle, MessageSquare, Sparkles, BookOpen, Plug, BrainCircuit, AlarmClock, Languages, Palette, KeyRound, Settings, ArrowLeft, Search, Shield, Wrench, Wifi } from 'lucide-react';
|
|
4
|
+
import { Cpu, GitBranch, History, MessageCircle, MessageSquare, Sparkles, BookOpen, Plug, BrainCircuit, AlarmClock, Languages, Palette, KeyRound, Settings, ArrowLeft, Search, Shield, Wrench, Wifi, Bot } from 'lucide-react';
|
|
5
5
|
import { NavLink } from 'react-router-dom';
|
|
6
6
|
import { useDocBrowser } from '@/components/doc-browser';
|
|
7
7
|
import { BrandHeader } from '@/components/common/BrandHeader';
|
|
@@ -61,6 +61,11 @@ export function Sidebar({ mode }: SidebarProps) {
|
|
|
61
61
|
target: '/chat/skills',
|
|
62
62
|
label: t('marketplaceFilterSkills'),
|
|
63
63
|
icon: BrainCircuit,
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
target: '/agents',
|
|
67
|
+
label: t('agentsPageTitle'),
|
|
68
|
+
icon: Bot,
|
|
64
69
|
}
|
|
65
70
|
];
|
|
66
71
|
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { render, screen } from '@testing-library/react';
|
|
2
|
+
import { MemoryRouter, Route, Routes } from 'react-router-dom';
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
|
+
import { AppLayout } from '@/components/layout/AppLayout';
|
|
5
|
+
import { I18nProvider } from '@/components/providers/I18nProvider';
|
|
6
|
+
|
|
7
|
+
describe('AppLayout', () => {
|
|
8
|
+
it('treats /agents as a main workspace route instead of the settings shell', () => {
|
|
9
|
+
const { container } = render(
|
|
10
|
+
<I18nProvider>
|
|
11
|
+
<MemoryRouter initialEntries={['/agents']}>
|
|
12
|
+
<Routes>
|
|
13
|
+
<Route
|
|
14
|
+
path="*"
|
|
15
|
+
element={(
|
|
16
|
+
<AppLayout>
|
|
17
|
+
<div data-testid="agents-content">Agents Content</div>
|
|
18
|
+
</AppLayout>
|
|
19
|
+
)}
|
|
20
|
+
/>
|
|
21
|
+
</Routes>
|
|
22
|
+
</MemoryRouter>
|
|
23
|
+
</I18nProvider>
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
expect(screen.getByTestId('agents-content')).toBeTruthy();
|
|
27
|
+
expect(screen.queryByTestId('settings-sidebar-header')).toBeNull();
|
|
28
|
+
expect(container.querySelector('aside')).toBeNull();
|
|
29
|
+
});
|
|
30
|
+
});
|
|
@@ -61,6 +61,8 @@ export function TabsTrigger({ value, children, className }: TabsTriggerProps) {
|
|
|
61
61
|
<button
|
|
62
62
|
type="button"
|
|
63
63
|
onClick={() => context.onValueChange(value)}
|
|
64
|
+
aria-pressed={isActive}
|
|
65
|
+
data-state={isActive ? 'active' : 'inactive'}
|
|
64
66
|
className={cn(
|
|
65
67
|
'inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-[13px] font-medium ring-offset-white transition-all duration-fast focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
|
|
66
68
|
isActive
|