295 lines
9.9 KiB
TypeScript
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 }),
|
|
},
|
|
),
|
|
);
|