完善客户端模块与工作区能力
Some checks failed
Electron E2E / Electron E2E (macos-latest) (push) Has been cancelled
Electron E2E / Electron E2E (ubuntu-latest) (push) Has been cancelled
Electron E2E / Electron E2E (windows-latest) (push) Has been cancelled

This commit is contained in:
inman
2026-08-13 19:45:30 +08:00
parent 0148b91a15
commit 22add3f01f
97 changed files with 4756 additions and 3226 deletions

View File

@@ -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;
}

View File

@@ -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;
},
}
)
);

View File

@@ -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),
}),
},
),
);