import { create } from 'zustand'; import { createJSONStorage, persist, type StateStorage } from 'zustand/middleware'; import { toUserSyncPartner, type UserSyncPartner } from '../../shared/user-sync'; export const partnerAvatarToneOptions = [ 'bg-accent-soft', 'bg-brand-soft', 'bg-accent', 'bg-surface-tertiary', 'bg-surface-subtle', 'bg-brand/20', ] as const; export type PartnerProfile = { nickname?: string; description?: string; avatarTone?: string; avatarDataUrl?: string; updatedAt: string; }; export type PartnerProfileInput = { nickname?: string; description?: string; avatarTone?: string; avatarDataUrl?: string; }; // Kept as a serialized compatibility field for synced partner records. New // partners are always manually configured and use the single custom value. export type PartnerTemplateRoleId = 'custom'; export type CreatedPartnerSubagent = { id: string; displayName: string; templateRoleId: PartnerTemplateRoleId; skillIds?: string[]; prompt?: string; description?: string; avatarTone?: string; avatarDataUrl?: string; filePath?: string; createdAt: string; updatedAt: string; }; export type ProjectSubagentRuntimeConfig = { filePath?: string; skillIds?: string[]; updatedAt: string; }; type PartnerProfilesState = { subagents: CreatedPartnerSubagent[]; projectRuntimeConfigsByProjectId: Record>; profilesById: Record; addSubagent: (subagent: CreatedPartnerSubagent) => void; updatePartnerProfile: (agentId: string, profile: PartnerProfileInput) => void; updateSubagentRuntimeConfig: (agentId: string, config: Pick) => void; updateProjectSubagentRuntimeConfig: (projectId: string, agentId: string, config: ProjectSubagentRuntimeConfig) => void; applySyncedPartners: (partners: UserSyncPartner[]) => void; resetPartnerProfile: (agentId: string) => void; removeSubagent: (agentId: string) => void; }; const inMemoryPartnerProfileStorage = (() => { const data = new Map(); return { getItem: (name: string) => data.get(name) ?? null, setItem: (name: string, value: string) => { data.set(name, value); }, removeItem: (name: string) => { data.delete(name); }, } satisfies StateStorage; })(); function getPartnerProfileStorage(): StateStorage { if (typeof window === 'undefined') { return inMemoryPartnerProfileStorage; } if (typeof navigator !== 'undefined' && navigator.userAgent.toLowerCase().includes('jsdom')) { return inMemoryPartnerProfileStorage; } try { const storage = window.localStorage; const testKey = 'niancode-partner-profile-storage-test'; storage.setItem(testKey, '1'); storage.removeItem(testKey); return storage; } catch { return inMemoryPartnerProfileStorage; } } function cleanOptionalText(value: string | undefined): string | undefined { const trimmed = value?.trim(); return trimmed ? trimmed : undefined; } function cleanPartnerProfileInput(input: PartnerProfileInput): Omit { return { nickname: cleanOptionalText(input.nickname), description: cleanOptionalText(input.description), avatarTone: cleanOptionalText(input.avatarTone), avatarDataUrl: cleanOptionalText(input.avatarDataUrl), }; } function hasProfileData(profile: Omit): boolean { return Boolean(profile.nickname || profile.description || profile.avatarTone || profile.avatarDataUrl); } function normalizeSkillIds(value: string[] | undefined): string[] | undefined { if (!Array.isArray(value)) return undefined; const seen = new Set(); const result: string[] = []; for (const item of value) { const skillId = item.trim(); if (skillId && !seen.has(skillId)) { seen.add(skillId); result.push(skillId); } } return result; } function pushSyncedPartner(partner: CreatedPartnerSubagent): void { void import('@/stores/user-sync') .then(({ useUserSyncStore }) => useUserSyncStore.getState().pushPartner(toUserSyncPartner(partner))) .catch(() => {}); } export const usePartnerProfilesStore = create()( persist( (set) => ({ subagents: [], projectRuntimeConfigsByProjectId: {}, profilesById: {}, addSubagent: (subagent) => { const now = new Date().toISOString(); const normalized: CreatedPartnerSubagent = { ...subagent, displayName: cleanOptionalText(subagent.displayName) ?? subagent.displayName, description: cleanOptionalText(subagent.description), avatarTone: cleanOptionalText(subagent.avatarTone), avatarDataUrl: cleanOptionalText(subagent.avatarDataUrl), prompt: cleanOptionalText(subagent.prompt), skillIds: normalizeSkillIds(subagent.skillIds), filePath: undefined, createdAt: subagent.createdAt || now, updatedAt: subagent.updatedAt || now, }; set((state) => { return { subagents: [ normalized, ...state.subagents.filter((current) => current.id !== normalized.id), ], }; }); pushSyncedPartner(normalized); }, updatePartnerProfile: (agentId, profile) => { let updatedSubagent: CreatedPartnerSubagent | null = null; set((state) => { const cleaned = cleanPartnerProfileInput(profile); const now = new Date().toISOString(); const nextSubagents = state.subagents.map((subagent) => { if (subagent.id !== agentId) return subagent; updatedSubagent = { ...subagent, displayName: cleaned.nickname ?? subagent.displayName, description: cleaned.description, avatarTone: cleaned.avatarTone ?? subagent.avatarTone, avatarDataUrl: cleaned.avatarDataUrl, updatedAt: now, }; return updatedSubagent; }); if (nextSubagents.some((subagent) => subagent.id === agentId)) { return { subagents: nextSubagents }; } const nextProfiles = { ...state.profilesById }; if (!hasProfileData(cleaned)) { delete nextProfiles[agentId]; return { profilesById: nextProfiles }; } nextProfiles[agentId] = { ...cleaned, updatedAt: now }; return { profilesById: nextProfiles }; }); if (updatedSubagent) { pushSyncedPartner(updatedSubagent); } }, updateSubagentRuntimeConfig: (agentId, config) => { let updatedSubagent: CreatedPartnerSubagent | null = null; set((state) => ({ subagents: state.subagents.map((subagent) => ( subagent.id === agentId ? (updatedSubagent = { ...subagent, skillIds: normalizeSkillIds(config.skillIds) ?? [], updatedAt: config.updatedAt || new Date().toISOString(), }) : subagent )), })); if (updatedSubagent) { pushSyncedPartner(updatedSubagent); } }, updateProjectSubagentRuntimeConfig: (projectId, agentId, config) => { const cleanProjectId = projectId.trim(); const cleanAgentId = agentId.trim(); if (!cleanProjectId || !cleanAgentId) return; set((state) => ({ projectRuntimeConfigsByProjectId: { ...state.projectRuntimeConfigsByProjectId, [cleanProjectId]: { ...(state.projectRuntimeConfigsByProjectId[cleanProjectId] ?? {}), [cleanAgentId]: { filePath: cleanOptionalText(config.filePath), skillIds: normalizeSkillIds(config.skillIds) ?? [], updatedAt: config.updatedAt || new Date().toISOString(), }, }, }, })); }, resetPartnerProfile: (agentId) => { let updatedSubagent: CreatedPartnerSubagent | null = null; set((state) => { if (state.subagents.some((subagent) => subagent.id === agentId)) { return { subagents: state.subagents.map((subagent) => ( subagent.id === agentId ? (updatedSubagent = { ...subagent, description: undefined, avatarDataUrl: undefined, updatedAt: new Date().toISOString(), }) : subagent )), }; } const nextProfiles = { ...state.profilesById }; delete nextProfiles[agentId]; return { profilesById: nextProfiles }; }); if (updatedSubagent) { pushSyncedPartner(updatedSubagent); } }, applySyncedPartners: (partners) => { set((state) => ({ subagents: partners.map((partner) => ({ id: partner.id, displayName: partner.displayName, templateRoleId: 'custom', description: partner.description, avatarTone: partner.avatarTone, avatarDataUrl: partner.avatarDataUrl, prompt: partner.prompt, skillIds: normalizeSkillIds(partner.skillIds), createdAt: partner.createdAt ?? new Date().toISOString(), updatedAt: partner.updatedAt ?? new Date().toISOString(), })), projectRuntimeConfigsByProjectId: state.projectRuntimeConfigsByProjectId, })); }, removeSubagent: (agentId) => { set((state) => ({ subagents: state.subagents.filter((subagent) => subagent.id !== agentId), projectRuntimeConfigsByProjectId: Object.fromEntries( Object.entries(state.projectRuntimeConfigsByProjectId).map(([projectId, configs]) => { const nextConfigs = { ...configs }; delete nextConfigs[agentId]; return [projectId, nextConfigs]; }), ), })); }, }), { name: 'niancode-partner-profiles', storage: createJSONStorage(getPartnerProfileStorage), partialize: (state) => ({ subagents: state.subagents, projectRuntimeConfigsByProjectId: state.projectRuntimeConfigsByProjectId, }), }, ), );