Files
makelore/src/stores/user-profile.ts
2026-07-29 17:22:35 +08:00

295 lines
9.9 KiB
TypeScript

import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import {
AgentProfileApiError,
getAgentProfile,
putAgentProfile,
type AgentProfileRead,
type AgentProfileUpdate,
} from '@/lib/agent-profile';
export type UserGender = 'male' | 'female' | 'other' | 'undisclosed';
export type UserProfileDraft = {
displayName: string;
age: number | null;
gender: UserGender;
};
export type AgentUserProfile = UserProfileDraft & {
shareAgeWithAgents: boolean;
shareGenderWithAgents: boolean;
analysisEnabled: boolean;
pendingSync: boolean;
version: number;
updatedAt: string | null;
};
export type ProfileSyncStatus = 'idle' | 'loading' | 'ready' | 'saving' | 'error';
export type ProfileSyncState = {
status: ProfileSyncStatus;
error: string | null;
};
type UserProfileState = {
profilesByUserId: Record<string, AgentUserProfile>;
syncStatesByUserId: Record<string, ProfileSyncState>;
saveProfile: (userId: string, profile: UserProfileDraft) => void;
syncProfile: (userId: string, accessToken: string) => Promise<AgentUserProfile>;
saveProfileToServer: (userId: string, accessToken: string) => Promise<AgentUserProfile>;
};
export const EMPTY_AGENT_USER_PROFILE: AgentUserProfile = {
displayName: '',
age: null,
gender: 'undisclosed',
shareAgeWithAgents: false,
shareGenderWithAgents: false,
analysisEnabled: true,
pendingSync: false,
version: 0,
updatedAt: null,
};
const inFlightSyncRequests = new Map<string, Promise<AgentUserProfile>>();
export function getProfileAccountKey(userId: string | null | undefined): string | null {
const value = userId?.trim();
return value || null;
}
export function isUserProfileComplete(profile: AgentUserProfile | undefined): boolean {
return Boolean(profile?.displayName.trim());
}
function fromServerProfile(profile: AgentProfileRead): AgentUserProfile {
return {
displayName: profile.display_name ?? '',
age: profile.age,
gender: profile.gender ?? 'undisclosed',
shareAgeWithAgents: profile.share_age_with_agents,
shareGenderWithAgents: profile.share_gender_with_agents,
analysisEnabled: profile.analysis_enabled,
pendingSync: false,
version: profile.version,
updatedAt: profile.updated_at,
};
}
function toAgentProfileUpdate(profile: AgentUserProfile, version = profile.version): AgentProfileUpdate {
const gender = profile.gender === 'undisclosed' ? null : profile.gender;
return {
display_name: profile.displayName.trim(),
age: profile.age,
gender,
share_age_with_agents: profile.age !== null && (profile.shareAgeWithAgents ?? true),
share_gender_with_agents: gender !== null && (profile.shareGenderWithAgents ?? true),
analysis_enabled: profile.analysisEnabled ?? true,
version,
};
}
function getErrorMessage(error: unknown): string {
if (error instanceof AgentProfileApiError) {
if (error.status === 401) return '登录已过期,请重新登录后再同步个人资料。';
if (error.status === 503) return '个人资料服务暂时不可用,请稍后重试。';
return error.message;
}
return error instanceof Error ? error.message : String(error);
}
function buildLocalProfile(
existing: AgentUserProfile | undefined,
draft: UserProfileDraft,
): AgentUserProfile {
const age = draft.age;
const gender = draft.gender;
const hadAge = typeof existing?.age === 'number';
const hadGender = Boolean(existing && existing.gender !== 'undisclosed');
return {
...(existing ?? EMPTY_AGENT_USER_PROFILE),
displayName: draft.displayName.trim(),
age,
gender,
shareAgeWithAgents: age !== null
? (hadAge ? (existing?.shareAgeWithAgents ?? true) : true)
: false,
shareGenderWithAgents: gender !== 'undisclosed'
? (hadGender ? (existing?.shareGenderWithAgents ?? true) : true)
: false,
pendingSync: true,
version: existing?.version ?? 0,
updatedAt: existing?.updatedAt ?? null,
};
}
function profileSyncState(status: ProfileSyncStatus, error: string | null = null): ProfileSyncState {
return { status, error };
}
export function buildAgentUserContext(profile: AgentUserProfile | undefined): string | null {
if (!isUserProfileComplete(profile)) return null;
const lines = [
'## 当前用户资料',
`当前与你协作的用户名字是「${profile!.displayName.trim()}」。`,
`可以根据这份资料调整称呼、表达方式、解释深度和互动方式,但不要反复提及资料。`,
];
if (profile!.age !== null && (profile!.shareAgeWithAgents ?? true)) {
lines.push(`用户年龄:${profile!.age} 岁。`);
}
if (profile!.gender !== 'undisclosed' && (profile!.shareGenderWithAgents ?? true)) {
const genderLabel = profile!.gender === 'male' ? '男' : profile!.gender === 'female' ? '女' : '其他';
lines.push(`用户性别:${genderLabel}`);
}
lines.push('不要根据年龄或性别推断用户的健康、能力、身份或其他敏感属性。');
return lines.join('\n');
}
export const useUserProfileStore = create<UserProfileState>()(
persist(
(set, get) => ({
profilesByUserId: {},
syncStatesByUserId: {},
saveProfile: (userId, profile) => set((state) => ({
profilesByUserId: {
...state.profilesByUserId,
[userId]: buildLocalProfile(state.profilesByUserId[userId], profile),
},
syncStatesByUserId: {
...state.syncStatesByUserId,
[userId]: profileSyncState('idle'),
},
})),
syncProfile: (userId, accessToken) => {
const existingRequest = inFlightSyncRequests.get(userId);
if (existingRequest) return existingRequest;
const request = (async () => {
set((state) => ({
syncStatesByUserId: {
...state.syncStatesByUserId,
[userId]: profileSyncState('loading'),
},
}));
try {
const server = await getAgentProfile(accessToken);
const local = get().profilesByUserId[userId];
if (server.version > 0) {
const profile = fromServerProfile(server);
set((state) => ({
profilesByUserId: { ...state.profilesByUserId, [userId]: profile },
syncStatesByUserId: {
...state.syncStatesByUserId,
[userId]: profileSyncState('ready'),
},
}));
return profile;
}
if (local && isUserProfileComplete(local)) {
await putAgentProfile(accessToken, toAgentProfileUpdate(local, 0));
const confirmed = fromServerProfile(await getAgentProfile(accessToken));
set((state) => ({
profilesByUserId: { ...state.profilesByUserId, [userId]: confirmed },
syncStatesByUserId: {
...state.syncStatesByUserId,
[userId]: profileSyncState('ready'),
},
}));
return confirmed;
}
const profile = fromServerProfile(server);
set((state) => ({
profilesByUserId: { ...state.profilesByUserId, [userId]: profile },
syncStatesByUserId: {
...state.syncStatesByUserId,
[userId]: profileSyncState('ready'),
},
}));
return profile;
} catch (error) {
set((state) => ({
syncStatesByUserId: {
...state.syncStatesByUserId,
[userId]: profileSyncState('error', getErrorMessage(error)),
},
}));
throw error;
}
})();
inFlightSyncRequests.set(userId, request);
void request.then(
() => {
if (inFlightSyncRequests.get(userId) === request) inFlightSyncRequests.delete(userId);
},
() => {
if (inFlightSyncRequests.get(userId) === request) inFlightSyncRequests.delete(userId);
},
);
return request;
},
saveProfileToServer: async (userId, accessToken) => {
const local = get().profilesByUserId[userId];
if (!local) throw new Error('个人资料尚未加载');
set((state) => ({
syncStatesByUserId: {
...state.syncStatesByUserId,
[userId]: profileSyncState('saving'),
},
}));
try {
const saved = fromServerProfile(await putAgentProfile(accessToken, toAgentProfileUpdate(local)));
set((state) => ({
profilesByUserId: { ...state.profilesByUserId, [userId]: saved },
syncStatesByUserId: {
...state.syncStatesByUserId,
[userId]: profileSyncState('ready'),
},
}));
return saved;
} catch (error) {
if (error instanceof AgentProfileApiError && error.status === 409) {
try {
const latest = fromServerProfile(await getAgentProfile(accessToken));
set((state) => ({
profilesByUserId: { ...state.profilesByUserId, [userId]: latest },
syncStatesByUserId: {
...state.syncStatesByUserId,
[userId]: profileSyncState('ready'),
},
}));
} catch (refreshError) {
set((state) => ({
syncStatesByUserId: {
...state.syncStatesByUserId,
[userId]: profileSyncState('error', getErrorMessage(refreshError)),
},
}));
}
} else {
set((state) => ({
syncStatesByUserId: {
...state.syncStatesByUserId,
[userId]: profileSyncState('error', getErrorMessage(error)),
},
}));
}
throw error;
}
},
}),
{
name: 'niancode-user-agent-profiles',
partialize: (state) => ({ profilesByUserId: state.profilesByUserId }),
},
),
);