308 lines
10 KiB
TypeScript
308 lines
10 KiB
TypeScript
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<string, Record<string, ProjectSubagentRuntimeConfig>>;
|
|
profilesById: Record<string, PartnerProfile>;
|
|
addSubagent: (subagent: CreatedPartnerSubagent) => void;
|
|
updatePartnerProfile: (agentId: string, profile: PartnerProfileInput) => void;
|
|
updateSubagentRuntimeConfig: (agentId: string, config: Pick<CreatedPartnerSubagent, 'skillIds' | 'filePath' | 'updatedAt'>) => 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<string, string>();
|
|
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<PartnerProfile, 'updatedAt'> {
|
|
return {
|
|
nickname: cleanOptionalText(input.nickname),
|
|
description: cleanOptionalText(input.description),
|
|
avatarTone: cleanOptionalText(input.avatarTone),
|
|
avatarDataUrl: cleanOptionalText(input.avatarDataUrl),
|
|
};
|
|
}
|
|
|
|
function hasProfileData(profile: Omit<PartnerProfile, 'updatedAt'>): 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<string>();
|
|
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<PartnerProfilesState>()(
|
|
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,
|
|
}),
|
|
},
|
|
),
|
|
);
|