完善客户端模块与工作区能力
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { create } from 'zustand';
|
||||
import { createHostEventSource, ensureHostApiToken, hostApiFetch } from '@/lib/host-api';
|
||||
import { dispatchWorksSquareTokenUsageStale } from '@/lib/works-square-usage-events';
|
||||
import { queueAgentSessionSync } from '@/lib/agent-session-sync';
|
||||
import {
|
||||
applyStreamingPartToMessage,
|
||||
applyStreamingPartDeltaToMessage,
|
||||
@@ -2405,6 +2406,7 @@ async function runSessionSubmission(
|
||||
submission: SessionSubmission,
|
||||
options?: SendSessionMessageOptions,
|
||||
): Promise<RawMessage[]> {
|
||||
const sessionProjectId = getProjectId(get().activeProject);
|
||||
const sendingUserMessage = submission.optimisticUserMessage
|
||||
? setMessageDeliveryStatus(submission.optimisticUserMessage, 'sending')
|
||||
: undefined;
|
||||
@@ -2889,7 +2891,7 @@ async function runSessionSubmission(
|
||||
}
|
||||
|
||||
if (status.type === 'idle') {
|
||||
finishSessionRunSuccessfully(
|
||||
const completed = finishSessionRunSuccessfully(
|
||||
set,
|
||||
get,
|
||||
sessionId,
|
||||
@@ -2897,6 +2899,15 @@ async function runSessionSubmission(
|
||||
messages,
|
||||
statuses,
|
||||
);
|
||||
if (
|
||||
completed
|
||||
&& submission.kind === 'prompt'
|
||||
&& sessionProjectId
|
||||
&& sendingUserMessage
|
||||
&& hasAssistantResponseAfterPrompt(messages, sendingUserMessage)
|
||||
) {
|
||||
queueAgentSessionSync(sessionProjectId, sessionId, messages);
|
||||
}
|
||||
return messages;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import i18n from '@/i18n';
|
||||
import { invokeIpc } from '@/lib/api-client';
|
||||
import { hostApiFetch } from '@/lib/host-api';
|
||||
import { resolveSupportedLanguage } from '../../shared/language';
|
||||
import type { UserSyncPreferences } from '../../shared/user-sync';
|
||||
@@ -56,7 +57,8 @@ interface SettingsState {
|
||||
setAutoCheckUpdate: (value: boolean) => void;
|
||||
setAutoDownloadUpdate: (value: boolean) => void;
|
||||
setSidebarCollapsed: (value: boolean) => void;
|
||||
setDevModeUnlocked: (value: boolean) => void;
|
||||
unlockDevMode: (password: string) => Promise<boolean>;
|
||||
lockDevMode: () => Promise<void>;
|
||||
applySyncedPreferences: (preferences: UserSyncPreferences) => void;
|
||||
markSetupComplete: () => void;
|
||||
resetSettings: () => void;
|
||||
@@ -94,23 +96,35 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
...defaultSettings,
|
||||
|
||||
init: async () => {
|
||||
let settings: Partial<typeof defaultSettings> = {};
|
||||
try {
|
||||
const settings = await hostApiFetch<Partial<typeof defaultSettings>>('/api/settings');
|
||||
const resolvedLanguage = settings.language
|
||||
? resolveSupportedLanguage(settings.language)
|
||||
: undefined;
|
||||
set((state) => ({
|
||||
...state,
|
||||
...settings,
|
||||
...(resolvedLanguage ? { language: resolvedLanguage } : {}),
|
||||
}));
|
||||
if (resolvedLanguage) {
|
||||
i18n.changeLanguage(resolvedLanguage);
|
||||
}
|
||||
settings = (await hostApiFetch<Partial<typeof defaultSettings>>('/api/settings')) ?? {};
|
||||
} catch {
|
||||
// Keep renderer-persisted settings as a fallback when the main
|
||||
// process store is not reachable.
|
||||
}
|
||||
|
||||
const { devModeUnlocked: _persistedDevModeUnlocked, ...settingsWithoutDevMode } = settings;
|
||||
let sessionUnlocked = false;
|
||||
try {
|
||||
sessionUnlocked = await invokeIpc<boolean>('admin:isUnlocked');
|
||||
} catch {
|
||||
// The protected settings remain locked if the main process cannot
|
||||
// report the current administrator session.
|
||||
}
|
||||
|
||||
const resolvedLanguage = settingsWithoutDevMode.language
|
||||
? resolveSupportedLanguage(settingsWithoutDevMode.language)
|
||||
: undefined;
|
||||
set((state) => ({
|
||||
...state,
|
||||
...settingsWithoutDevMode,
|
||||
devModeUnlocked: sessionUnlocked,
|
||||
...(resolvedLanguage ? { language: resolvedLanguage } : {}),
|
||||
}));
|
||||
if (resolvedLanguage) {
|
||||
i18n.changeLanguage(resolvedLanguage);
|
||||
}
|
||||
},
|
||||
|
||||
setTheme: (theme) => {
|
||||
@@ -159,13 +173,24 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
set({ sidebarCollapsed });
|
||||
pushSyncedPreferences({ sidebarCollapsed });
|
||||
},
|
||||
setDevModeUnlocked: (devModeUnlocked) => {
|
||||
set({ devModeUnlocked });
|
||||
void hostApiFetch('/api/settings/devModeUnlocked', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ value: devModeUnlocked }),
|
||||
}).catch(() => { });
|
||||
pushSyncedPreferences({ devModeUnlocked });
|
||||
unlockDevMode: async (password) => {
|
||||
try {
|
||||
const result = await invokeIpc<{ success: boolean }>('admin:verifyPassword', password);
|
||||
const unlocked = result?.success === true;
|
||||
if (unlocked) {
|
||||
set({ devModeUnlocked: true });
|
||||
}
|
||||
return unlocked;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
lockDevMode: async () => {
|
||||
try {
|
||||
await invokeIpc('admin:lock');
|
||||
} finally {
|
||||
set({ devModeUnlocked: false });
|
||||
}
|
||||
},
|
||||
applySyncedPreferences: (preferences) => {
|
||||
const patch: Partial<SettingsState> = {};
|
||||
@@ -180,9 +205,6 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
if (typeof preferences.sidebarCollapsed === 'boolean') {
|
||||
patch.sidebarCollapsed = preferences.sidebarCollapsed;
|
||||
}
|
||||
if (typeof preferences.devModeUnlocked === 'boolean') {
|
||||
patch.devModeUnlocked = preferences.devModeUnlocked;
|
||||
}
|
||||
set(patch);
|
||||
},
|
||||
markSetupComplete: () => set({ setupComplete: true }),
|
||||
@@ -190,6 +212,10 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
}),
|
||||
{
|
||||
name: 'niancode-settings',
|
||||
partialize: (state) => {
|
||||
const { devModeUnlocked: _sessionOnlyDevModeUnlocked, ...persistedState } = state;
|
||||
return persistedState;
|
||||
},
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
@@ -2,11 +2,13 @@ import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import {
|
||||
AgentProfileApiError,
|
||||
getAgentProfileApiErrorMessage,
|
||||
getAgentProfile,
|
||||
putAgentProfile,
|
||||
type AgentProfileRead,
|
||||
type AgentProfileUpdate,
|
||||
} from '@/lib/agent-profile';
|
||||
import type { AgentSessionData } from '../../shared/agent-session';
|
||||
|
||||
export type UserGender = 'male' | 'female' | 'other' | 'undisclosed';
|
||||
|
||||
@@ -17,6 +19,7 @@ export type UserProfileDraft = {
|
||||
};
|
||||
|
||||
export type AgentUserProfile = UserProfileDraft & {
|
||||
avatarUrl: string | null;
|
||||
shareAgeWithAgents: boolean;
|
||||
shareGenderWithAgents: boolean;
|
||||
analysisEnabled: boolean;
|
||||
@@ -36,14 +39,21 @@ type UserProfileState = {
|
||||
profilesByUserId: Record<string, AgentUserProfile>;
|
||||
syncStatesByUserId: Record<string, ProfileSyncState>;
|
||||
saveProfile: (userId: string, profile: UserProfileDraft) => void;
|
||||
setAvatarUrl: (userId: string, avatarUrl: string | null) => void;
|
||||
syncProfile: (userId: string, accessToken: string) => Promise<AgentUserProfile>;
|
||||
saveProfileToServer: (userId: string, accessToken: string) => Promise<AgentUserProfile>;
|
||||
pushSessionData: (
|
||||
userId: string,
|
||||
accessToken: string,
|
||||
sessionData: AgentSessionData,
|
||||
) => Promise<AgentUserProfile>;
|
||||
};
|
||||
|
||||
export const EMPTY_AGENT_USER_PROFILE: AgentUserProfile = {
|
||||
displayName: '',
|
||||
age: null,
|
||||
gender: 'undisclosed',
|
||||
avatarUrl: null,
|
||||
shareAgeWithAgents: false,
|
||||
shareGenderWithAgents: false,
|
||||
analysisEnabled: true,
|
||||
@@ -63,11 +73,17 @@ export function isUserProfileComplete(profile: AgentUserProfile | undefined): bo
|
||||
return Boolean(profile?.displayName.trim());
|
||||
}
|
||||
|
||||
function fromServerProfile(profile: AgentProfileRead): AgentUserProfile {
|
||||
function fromServerProfile(
|
||||
profile: AgentProfileRead,
|
||||
fallbackAvatarUrl: string | null = null,
|
||||
): AgentUserProfile {
|
||||
return {
|
||||
displayName: profile.display_name ?? '',
|
||||
age: profile.age,
|
||||
gender: profile.gender ?? 'undisclosed',
|
||||
avatarUrl: profile.avatar_url === undefined
|
||||
? fallbackAvatarUrl
|
||||
: profile.avatar_url,
|
||||
shareAgeWithAgents: profile.share_age_with_agents,
|
||||
shareGenderWithAgents: profile.share_gender_with_agents,
|
||||
analysisEnabled: profile.analysis_enabled,
|
||||
@@ -92,9 +108,7 @@ function toAgentProfileUpdate(profile: AgentUserProfile, version = profile.versi
|
||||
|
||||
function getErrorMessage(error: unknown): string {
|
||||
if (error instanceof AgentProfileApiError) {
|
||||
if (error.status === 401) return '登录已过期,请重新登录后再同步个人资料。';
|
||||
if (error.status === 503) return '个人资料服务暂时不可用,请稍后重试。';
|
||||
return error.message;
|
||||
return getAgentProfileApiErrorMessage(error);
|
||||
}
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
@@ -129,6 +143,18 @@ function profileSyncState(status: ProfileSyncStatus, error: string | null = null
|
||||
return { status, error };
|
||||
}
|
||||
|
||||
function stripPersistedAvatarUrls(
|
||||
profilesByUserId: Record<string, AgentUserProfile> | undefined,
|
||||
): Record<string, AgentUserProfile> {
|
||||
if (!profilesByUserId) return {};
|
||||
return Object.fromEntries(
|
||||
Object.entries(profilesByUserId).map(([userId, profile]) => [
|
||||
userId,
|
||||
{ ...profile, avatarUrl: null },
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
export function buildAgentUserContext(profile: AgentUserProfile | undefined): string | null {
|
||||
if (!isUserProfileComplete(profile)) return null;
|
||||
const lines = [
|
||||
@@ -162,6 +188,15 @@ export const useUserProfileStore = create<UserProfileState>()(
|
||||
[userId]: profileSyncState('idle'),
|
||||
},
|
||||
})),
|
||||
setAvatarUrl: (userId, avatarUrl) => set((state) => ({
|
||||
profilesByUserId: {
|
||||
...state.profilesByUserId,
|
||||
[userId]: {
|
||||
...(state.profilesByUserId[userId] ?? EMPTY_AGENT_USER_PROFILE),
|
||||
avatarUrl: avatarUrl?.trim() || null,
|
||||
},
|
||||
},
|
||||
})),
|
||||
syncProfile: (userId, accessToken) => {
|
||||
const existingRequest = inFlightSyncRequests.get(userId);
|
||||
if (existingRequest) return existingRequest;
|
||||
@@ -179,7 +214,7 @@ export const useUserProfileStore = create<UserProfileState>()(
|
||||
const local = get().profilesByUserId[userId];
|
||||
|
||||
if (server.version > 0) {
|
||||
const profile = fromServerProfile(server);
|
||||
const profile = fromServerProfile(server, local?.avatarUrl ?? null);
|
||||
set((state) => ({
|
||||
profilesByUserId: { ...state.profilesByUserId, [userId]: profile },
|
||||
syncStatesByUserId: {
|
||||
@@ -192,7 +227,10 @@ export const useUserProfileStore = create<UserProfileState>()(
|
||||
|
||||
if (local && isUserProfileComplete(local)) {
|
||||
await putAgentProfile(accessToken, toAgentProfileUpdate(local, 0));
|
||||
const confirmed = fromServerProfile(await getAgentProfile(accessToken));
|
||||
const confirmed = fromServerProfile(
|
||||
await getAgentProfile(accessToken),
|
||||
local.avatarUrl ?? null,
|
||||
);
|
||||
set((state) => ({
|
||||
profilesByUserId: { ...state.profilesByUserId, [userId]: confirmed },
|
||||
syncStatesByUserId: {
|
||||
@@ -203,7 +241,7 @@ export const useUserProfileStore = create<UserProfileState>()(
|
||||
return confirmed;
|
||||
}
|
||||
|
||||
const profile = fromServerProfile(server);
|
||||
const profile = fromServerProfile(server, local?.avatarUrl ?? null);
|
||||
set((state) => ({
|
||||
profilesByUserId: { ...state.profilesByUserId, [userId]: profile },
|
||||
syncStatesByUserId: {
|
||||
@@ -246,7 +284,10 @@ export const useUserProfileStore = create<UserProfileState>()(
|
||||
}));
|
||||
|
||||
try {
|
||||
const saved = fromServerProfile(await putAgentProfile(accessToken, toAgentProfileUpdate(local)));
|
||||
const saved = fromServerProfile(
|
||||
await putAgentProfile(accessToken, toAgentProfileUpdate(local)),
|
||||
local.avatarUrl ?? null,
|
||||
);
|
||||
set((state) => ({
|
||||
profilesByUserId: { ...state.profilesByUserId, [userId]: saved },
|
||||
syncStatesByUserId: {
|
||||
@@ -258,7 +299,10 @@ export const useUserProfileStore = create<UserProfileState>()(
|
||||
} catch (error) {
|
||||
if (error instanceof AgentProfileApiError && error.status === 409) {
|
||||
try {
|
||||
const latest = fromServerProfile(await getAgentProfile(accessToken));
|
||||
const latest = fromServerProfile(
|
||||
await getAgentProfile(accessToken),
|
||||
local.avatarUrl ?? null,
|
||||
);
|
||||
set((state) => ({
|
||||
profilesByUserId: { ...state.profilesByUserId, [userId]: latest },
|
||||
syncStatesByUserId: {
|
||||
@@ -285,10 +329,52 @@ export const useUserProfileStore = create<UserProfileState>()(
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
pushSessionData: async (userId, accessToken, sessionData) => {
|
||||
const local = get().profilesByUserId[userId];
|
||||
if (!local || !isUserProfileComplete(local)) {
|
||||
throw new Error('个人资料尚未加载');
|
||||
}
|
||||
|
||||
const server = await putAgentProfile(accessToken, {
|
||||
...toAgentProfileUpdate(local),
|
||||
session_data: sessionData,
|
||||
});
|
||||
|
||||
// Session telemetry is an upload-only side effect. Do not replace
|
||||
// local profile fields with the response from the cloud; only retain
|
||||
// the new version marker needed by the next profile PUT.
|
||||
set((state) => {
|
||||
const current = state.profilesByUserId[userId];
|
||||
if (!current) return state;
|
||||
return {
|
||||
profilesByUserId: {
|
||||
...state.profilesByUserId,
|
||||
[userId]: {
|
||||
...current,
|
||||
pendingSync: false,
|
||||
version: server.version,
|
||||
updatedAt: server.updated_at,
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
return get().profilesByUserId[userId] ?? local;
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'niancode-user-agent-profiles',
|
||||
partialize: (state) => ({ profilesByUserId: state.profilesByUserId }),
|
||||
version: 1,
|
||||
migrate: (persistedState) => {
|
||||
const state = persistedState as { profilesByUserId?: Record<string, AgentUserProfile> };
|
||||
return {
|
||||
...state,
|
||||
profilesByUserId: stripPersistedAvatarUrls(state.profilesByUserId),
|
||||
};
|
||||
},
|
||||
partialize: (state) => ({
|
||||
profilesByUserId: stripPersistedAvatarUrls(state.profilesByUserId),
|
||||
}),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user