feat(Agnet): support multi agents (#385)

This commit is contained in:
Haze
2026-03-10 16:13:55 +08:00
committed by GitHub
Unverified
parent 17e6ab9149
commit 36c0fcb5c7
19 changed files with 2295 additions and 690 deletions

126
src/stores/agents.ts Normal file
View File

@@ -0,0 +1,126 @@
import { create } from 'zustand';
import { hostApiFetch } from '@/lib/host-api';
import type { ChannelType } from '@/types/channel';
import type { AgentSummary, AgentsSnapshot } from '@/types/agent';
interface AgentsState {
agents: AgentSummary[];
defaultAgentId: string;
configuredChannelTypes: string[];
channelOwners: Record<string, string>;
loading: boolean;
error: string | null;
fetchAgents: () => Promise<void>;
createAgent: (name: string) => Promise<void>;
updateAgent: (agentId: string, name: string) => Promise<void>;
deleteAgent: (agentId: string) => Promise<void>;
assignChannel: (agentId: string, channelType: ChannelType) => Promise<void>;
removeChannel: (agentId: string, channelType: ChannelType) => Promise<void>;
clearError: () => void;
}
function applySnapshot(snapshot: AgentsSnapshot | undefined) {
return snapshot ? {
agents: snapshot.agents,
defaultAgentId: snapshot.defaultAgentId,
configuredChannelTypes: snapshot.configuredChannelTypes,
channelOwners: snapshot.channelOwners,
} : {};
}
export const useAgentsStore = create<AgentsState>((set) => ({
agents: [],
defaultAgentId: 'main',
configuredChannelTypes: [],
channelOwners: {},
loading: false,
error: null,
fetchAgents: async () => {
set({ loading: true, error: null });
try {
const snapshot = await hostApiFetch<AgentsSnapshot & { success?: boolean }>('/api/agents');
set({
...applySnapshot(snapshot),
loading: false,
});
} catch (error) {
set({ loading: false, error: String(error) });
}
},
createAgent: async (name: string) => {
set({ error: null });
try {
const snapshot = await hostApiFetch<AgentsSnapshot & { success?: boolean }>('/api/agents', {
method: 'POST',
body: JSON.stringify({ name }),
});
set(applySnapshot(snapshot));
} catch (error) {
set({ error: String(error) });
throw error;
}
},
updateAgent: async (agentId: string, name: string) => {
set({ error: null });
try {
const snapshot = await hostApiFetch<AgentsSnapshot & { success?: boolean }>(
`/api/agents/${encodeURIComponent(agentId)}`,
{
method: 'PUT',
body: JSON.stringify({ name }),
}
);
set(applySnapshot(snapshot));
} catch (error) {
set({ error: String(error) });
throw error;
}
},
deleteAgent: async (agentId: string) => {
set({ error: null });
try {
const snapshot = await hostApiFetch<AgentsSnapshot & { success?: boolean }>(
`/api/agents/${encodeURIComponent(agentId)}`,
{ method: 'DELETE' }
);
set(applySnapshot(snapshot));
} catch (error) {
set({ error: String(error) });
throw error;
}
},
assignChannel: async (agentId: string, channelType: ChannelType) => {
set({ error: null });
try {
const snapshot = await hostApiFetch<AgentsSnapshot & { success?: boolean }>(
`/api/agents/${encodeURIComponent(agentId)}/channels/${encodeURIComponent(channelType)}`,
{ method: 'PUT' }
);
set(applySnapshot(snapshot));
} catch (error) {
set({ error: String(error) });
throw error;
}
},
removeChannel: async (agentId: string, channelType: ChannelType) => {
set({ error: null });
try {
const snapshot = await hostApiFetch<AgentsSnapshot & { success?: boolean }>(
`/api/agents/${encodeURIComponent(agentId)}/channels/${encodeURIComponent(channelType)}`,
{ method: 'DELETE' }
);
set(applySnapshot(snapshot));
} catch (error) {
set({ error: String(error) });
throw error;
}
},
clearError: () => set({ error: null }),
}));

View File

@@ -87,6 +87,7 @@ interface ChatState {
// Sessions
sessions: ChatSession[];
currentSessionKey: string;
currentAgentId: string;
/** First user message text per session key, used as display label */
sessionLabels: Record<string, string>;
/** Last message timestamp (ms) per session key, used for sorting */
@@ -657,6 +658,19 @@ function getCanonicalPrefixFromSessions(sessions: ChatSession[]): string | null
return `${parts[0]}:${parts[1]}`;
}
function getAgentIdFromSessionKey(sessionKey: string): string {
if (!sessionKey.startsWith('agent:')) return 'main';
const parts = sessionKey.split(':');
return parts[1] || 'main';
}
function getCanonicalPrefixFromSessionKey(sessionKey: string): string | null {
if (!sessionKey.startsWith('agent:')) return null;
const parts = sessionKey.split(':');
if (parts.length < 2) return null;
return `${parts[0]}:${parts[1]}`;
}
function isToolOnlyMessage(message: RawMessage | undefined): boolean {
if (!message) return false;
if (isToolResultRole(message.role)) return true;
@@ -923,6 +937,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
sessions: [],
currentSessionKey: DEFAULT_SESSION_KEY,
currentAgentId: 'main',
sessionLabels: {},
sessionLastActivity: {},
@@ -964,7 +979,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
return true;
});
const { currentSessionKey } = get();
const { currentSessionKey, sessions: localSessions } = get();
let nextSessionKey = currentSessionKey || DEFAULT_SESSION_KEY;
if (!nextSessionKey.startsWith('agent:')) {
const canonicalMatch = canonicalBySuffix.get(nextSessionKey);
@@ -973,9 +988,10 @@ export const useChatStore = create<ChatState>((set, get) => ({
}
}
if (!dedupedSessions.find((s) => s.key === nextSessionKey) && dedupedSessions.length > 0) {
// Current session not found in the backend list
const isNewEmptySession = get().messages.length === 0;
if (!isNewEmptySession) {
// Preserve only locally-created pending sessions. On initial boot the
// default ghost key (`agent:main:main`) should yield to real history.
const hasLocalPendingSession = localSessions.some((session) => session.key === nextSessionKey);
if (!hasLocalPendingSession) {
nextSessionKey = dedupedSessions[0].key;
}
}
@@ -987,7 +1003,11 @@ export const useChatStore = create<ChatState>((set, get) => ({
]
: dedupedSessions;
set({ sessions: sessionsWithCurrent, currentSessionKey: nextSessionKey });
set({
sessions: sessionsWithCurrent,
currentSessionKey: nextSessionKey,
currentAgentId: getAgentIdFromSessionKey(nextSessionKey),
});
if (currentSessionKey !== nextSessionKey) {
get().loadHistory();
@@ -1038,6 +1058,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
const leavingEmpty = !currentSessionKey.endsWith(':main') && messages.length === 0;
set((s) => ({
currentSessionKey: key,
currentAgentId: getAgentIdFromSessionKey(key),
messages: [],
streamingText: '',
streamingMessage: null,
@@ -1108,6 +1129,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
lastUserMessageAt: null,
pendingToolImages: [],
currentSessionKey: next?.key ?? DEFAULT_SESSION_KEY,
currentAgentId: getAgentIdFromSessionKey(next?.key ?? DEFAULT_SESSION_KEY),
}));
if (next) {
get().loadHistory();
@@ -1128,13 +1150,16 @@ export const useChatStore = create<ChatState>((set, get) => ({
// NOTE: We intentionally do NOT call sessions.reset on the old session.
// sessions.reset archives (renames) the session JSONL file, making old
// conversation history inaccessible when the user switches back to it.
const { currentSessionKey, messages } = get();
const { currentSessionKey, messages, sessions } = get();
const leavingEmpty = !currentSessionKey.endsWith(':main') && messages.length === 0;
const prefix = getCanonicalPrefixFromSessions(get().sessions) ?? DEFAULT_CANONICAL_PREFIX;
const prefix = getCanonicalPrefixFromSessionKey(currentSessionKey)
?? getCanonicalPrefixFromSessions(sessions)
?? DEFAULT_CANONICAL_PREFIX;
const newKey = `${prefix}:session-${Date.now()}`;
const newSessionEntry: ChatSession = { key: newKey, displayName: newKey };
set((s) => ({
currentSessionKey: newKey,
currentAgentId: getAgentIdFromSessionKey(newKey),
sessions: [
...(leavingEmpty ? s.sessions.filter((sess) => sess.key !== currentSessionKey) : s.sessions),
newSessionEntry,