merge: integrate upstream main with local Makelore changes

This commit is contained in:
inman
2026-08-31 10:55:48 +08:00
128 changed files with 8278 additions and 3030 deletions

View File

@@ -56,10 +56,16 @@ type MainSessionResponse = AuthActionResponse & {
session?: MainSession | null;
};
type ModuleAccessResponse = AuthActionResponse & {
type CurrentAccountResponse = AuthActionResponse & {
user?: unknown;
moduleAccess?: unknown;
};
type CurrentAccount = {
user: AuthUser | null;
moduleAccess: ModuleAccess;
};
type RefreshSessionOptions = {
forceRefresh?: boolean;
};
@@ -112,6 +118,7 @@ const DEFAULT_CLIENT_ID = NIANCODE_AUTH_CLIENT_ID;
const DEFAULT_AUTH_BASE = NIANCODE_AUTH_GATEWAY_URL;
const TOKEN_EXPIRY_SKEW_MS = 30_000;
const IDLE_LOGIN_MESSAGE = '登录已超过 7 天未使用,请重新授权。';
const INCOMPLETE_IDENTITY_MESSAGE = '登录身份无法确认,请重新登录。';
let authSessionEpoch = 0;
function advanceAuthSessionEpoch(): void {
@@ -137,19 +144,39 @@ function asStringOrNumber(value: unknown): string | number | null {
function asAuthorities(value: unknown): string[] {
if (!Array.isArray(value)) return [];
return value.filter((item): item is string => typeof item === 'string' && item.length > 0);
return value
.map((item) => asString(item))
.filter((item): item is string => item !== null);
}
function createUserFromToken(token: AuthTokenPayload): AuthUser {
function asStringId(value: unknown): string | null {
if (typeof value === 'number' && Number.isFinite(value)) return String(value);
return asString(value);
}
function parseAuthUser(value: unknown): AuthUser | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
const user = value as Record<string, unknown>;
const username = asString(user.username);
if (!username) return null;
return {
username: asString(token.username) ?? '',
userId: asString(token.user_id),
tenantId: asStringOrNumber(token.tenant_id),
deptId: asStringOrNumber(token.dept_id),
authorities: asAuthorities(token.authorities),
username,
userId: asStringId(user.userId ?? user.user_id),
tenantId: asStringOrNumber(user.tenantId ?? user.tenant_id),
deptId: asStringOrNumber(user.deptId ?? user.dept_id),
authorities: asAuthorities(user.authorities),
};
}
function createUserFromToken(token: AuthTokenPayload): AuthUser | null {
return parseAuthUser(token);
}
function hasAuthenticatedIdentity(user: AuthUser | null): user is AuthUser {
return Boolean(user && asString(user.username));
}
function isSessionIdle(lastActiveAt: number | null, nowMs = Date.now()): boolean {
return lastActiveAt != null
&& nowMs - lastActiveAt >= WORKS_SQUARE_SESSION_IDLE_TIMEOUT_MS;
@@ -243,14 +270,37 @@ async function syncMainSession(session: {
}
}
async function readCurrentModuleAccess(fallback: ModuleAccess): Promise<ModuleAccess> {
async function readCurrentAccount(
fallbackModuleAccess: ModuleAccess,
fallbackUser: AuthUser | null,
): Promise<CurrentAccount> {
const normalizedFallbackUser = parseAuthUser(fallbackUser);
try {
const response = await hostApiFetch<ModuleAccessResponse>('/api/auth/me');
if (!response.success) return fallback;
return normalizeModuleAccess(response.moduleAccess);
const response = await hostApiFetch<CurrentAccountResponse>('/api/auth/me');
if (!response.success) {
return {
user: normalizedFallbackUser,
moduleAccess: normalizeModuleAccess(fallbackModuleAccess),
};
}
return {
user: parseAuthUser(response.user) ?? normalizedFallbackUser,
moduleAccess: normalizeModuleAccess(response.moduleAccess),
};
} catch (error) {
if (isTerminalAuthError(error)) throw error;
return fallback;
return {
user: normalizedFallbackUser,
moduleAccess: normalizeModuleAccess(fallbackModuleAccess),
};
}
}
async function clearMainSessionBestEffort(): Promise<void> {
try {
await hostApiFetch<AuthActionResponse>('/api/auth/session/clear', { method: 'POST' });
} catch {
// Login will clean any remaining Main-owned runtime before accepting a new session.
}
}
@@ -273,8 +323,16 @@ async function loginViaHost(
}
if (!isCurrentAuthSessionEpoch(operationEpoch)) return;
const moduleAccess = await readCurrentModuleAccess({ ...DEFAULT_MODULE_ACCESS });
const account = await readCurrentAccount(
{ ...DEFAULT_MODULE_ACCESS },
createUserFromToken(response.token),
);
if (!isCurrentAuthSessionEpoch(operationEpoch)) return;
if (!account.user) {
await clearMainSessionBestEffort();
if (!isCurrentAuthSessionEpoch(operationEpoch)) return;
throw new Error(INCOMPLETE_IDENTITY_MESSAGE);
}
set({
initialized: true,
@@ -283,8 +341,8 @@ async function loginViaHost(
authBase: trimTrailingSlash(DEFAULT_AUTH_BASE),
clientId: DEFAULT_CLIENT_ID,
...sessionFieldsFromMain(session),
user: createUserFromToken(response.token),
moduleAccess,
user: account.user,
moduleAccess: account.moduleAccess,
});
} catch (error) {
if (!isCurrentAuthSessionEpoch(operationEpoch)) return;
@@ -320,6 +378,7 @@ export const useAuthStore = create<AuthState>()(
const restoredLastActiveAt = state.accessToken
? (state.lastActiveAt ?? nowMs)
: null;
const restoredUser = parseAuthUser(state.user);
if (authBaseChanged) {
advanceAuthSessionEpoch();
@@ -347,6 +406,7 @@ export const useAuthStore = create<AuthState>()(
clientId: DEFAULT_CLIENT_ID,
lastActiveAt: restoredLastActiveAt,
canRefresh: state.canRefresh || Boolean(state.legacyRefreshToken),
user: restoredUser,
});
const synchronized = await syncMainSession({
@@ -369,6 +429,18 @@ export const useAuthStore = create<AuthState>()(
return;
}
if (synchronized.kind === 'unavailable') {
if (state.accessToken && !restoredUser) {
await clearMainSessionBestEffort();
if (!isCurrentAuthSessionEpoch(operationEpoch)) return;
advanceAuthSessionEpoch();
set({
initialized: true,
loading: false,
error: INCOMPLETE_IDENTITY_MESSAGE,
...getClearedSession(),
});
return;
}
set({ initialized: true, loading: false });
return;
}
@@ -404,10 +476,11 @@ export const useAuthStore = create<AuthState>()(
return;
}
let moduleAccess: ModuleAccess;
let account: CurrentAccount;
try {
moduleAccess = await readCurrentModuleAccess(
account = await readCurrentAccount(
normalizeModuleAccess(state.moduleAccess),
restoredUser,
);
} catch (error) {
if (!isCurrentAuthSessionEpoch(operationEpoch)) return;
@@ -421,10 +494,31 @@ export const useAuthStore = create<AuthState>()(
});
return;
}
moduleAccess = normalizeModuleAccess(state.moduleAccess);
account = {
user: restoredUser,
moduleAccess: normalizeModuleAccess(state.moduleAccess),
};
}
if (!isCurrentAuthSessionEpoch(operationEpoch)) return;
set({ initialized: true, loading: false, error: null, moduleAccess });
if (!account.user) {
await clearMainSessionBestEffort();
if (!isCurrentAuthSessionEpoch(operationEpoch)) return;
advanceAuthSessionEpoch();
set({
initialized: true,
loading: false,
error: INCOMPLETE_IDENTITY_MESSAGE,
...getClearedSession(),
});
return;
}
set({
initialized: true,
loading: false,
error: null,
user: account.user,
moduleAccess: account.moduleAccess,
});
},
loginWithPassword: (input) => loginViaHost('/api/auth/login', input, set),
@@ -459,15 +553,28 @@ export const useAuthStore = create<AuthState>()(
throw new Error(response.error || 'Refresh failed');
}
const moduleAccess = await readCurrentModuleAccess(state.moduleAccess);
const account = await readCurrentAccount(state.moduleAccess, state.user);
if (!isCurrentAuthSessionEpoch(operationEpoch)) return null;
if (!account.user) {
await clearMainSessionBestEffort();
if (!isCurrentAuthSessionEpoch(operationEpoch)) return null;
advanceAuthSessionEpoch();
set({
initialized: true,
loading: false,
error: INCOMPLETE_IDENTITY_MESSAGE,
...getClearedSession(),
});
return null;
}
set({
initialized: true,
loading: false,
error: null,
...sessionFieldsFromMain(session),
moduleAccess,
user: account.user,
moduleAccess: account.moduleAccess,
});
return session.accessToken;
} catch (error) {
@@ -488,13 +595,18 @@ export const useAuthStore = create<AuthState>()(
},
getValidAccessToken: async () => {
const { accessToken, expiresAt, canRefresh } = get();
const {
accessToken,
expiresAt,
canRefresh,
user,
} = get();
if (!accessToken || !hasAuthenticatedIdentity(user)) return null;
const expired = Boolean(
accessToken
&& expiresAt
expiresAt
&& expiresAt <= Date.now() + TOKEN_EXPIRY_SKEW_MS,
);
if (!accessToken || expired) {
if (expired) {
return canRefresh
? await get().refreshSession({ forceRefresh: false })
: null;
@@ -504,7 +616,7 @@ export const useAuthStore = create<AuthState>()(
markActivity: async () => {
const state = get();
if (!state.accessToken) return;
if (!state.accessToken || !hasAuthenticatedIdentity(state.user)) return;
const operationEpoch = authSessionEpoch;
const nowMs = Date.now();
@@ -555,7 +667,7 @@ export const useAuthStore = create<AuthState>()(
maintainSession: async () => {
let state = get();
if (!state.accessToken) return;
if (!state.accessToken || !hasAuthenticatedIdentity(state.user)) return;
const operationEpoch = authSessionEpoch;
if (state.legacyRefreshToken) {
@@ -655,8 +767,16 @@ export const useAuthStore = create<AuthState>()(
},
isAuthenticated: () => {
const { accessToken, expiresAt, lastActiveAt, canRefresh } = get();
if (!accessToken || isSessionIdle(lastActiveAt)) return false;
const {
accessToken,
expiresAt,
lastActiveAt,
canRefresh,
user,
} = get();
if (!accessToken || !hasAuthenticatedIdentity(user) || isSessionIdle(lastActiveAt)) {
return false;
}
if (expiresAt && expiresAt <= Date.now() + TOKEN_EXPIRY_SKEW_MS) {
return canRefresh;
}
@@ -665,7 +785,7 @@ export const useAuthStore = create<AuthState>()(
}),
{
name: 'niancode-auth',
version: 2,
version: 3,
migrate: (persistedState: unknown) => {
const state = persistedState && typeof persistedState === 'object'
? persistedState as Record<string, unknown>
@@ -677,6 +797,7 @@ export const useAuthStore = create<AuthState>()(
...rest,
canRefresh: state.canRefresh === true || Boolean(legacyRefreshToken),
legacyRefreshToken,
user: parseAuthUser(state.user),
moduleAccess: normalizeModuleAccess(state.moduleAccess),
};
},

View File

@@ -36,6 +36,8 @@ export type CodingConversationLoadState =
| 'recovering'
| 'error';
export type CodingConversationSnapshotLoadMode = 'loading' | 'recovering' | 'silent';
export interface CodingConversationEntry {
reducer: ConversationReducerState;
loadState: CodingConversationLoadState;
@@ -72,7 +74,10 @@ export interface CodingConversationStoreState {
primeConversation(snapshot: ConversationSnapshot): void;
clearConversationSelection(): void;
selectConversation(conversationId: string): Promise<void>;
loadSnapshot(conversationId: string, recovering?: boolean): Promise<ConversationSnapshot>;
loadSnapshot(
conversationId: string,
mode?: CodingConversationSnapshotLoadMode,
): Promise<ConversationSnapshot>;
recoverConversation(conversationId: string): Promise<void>;
connectEvents(): Promise<void>;
disconnectEvents(): void;
@@ -434,7 +439,7 @@ export function createCodingConversationStore(
recoveryRefreshKeys.set(conversationId, refreshKey);
queueMicrotask(() => {
if (!snapshotLoads.has(conversationId)) {
void store.getState().loadSnapshot(conversationId, true).catch(() => undefined);
void store.getState().loadSnapshot(conversationId, 'recovering').catch(() => undefined);
}
});
}
@@ -509,27 +514,32 @@ export function createCodingConversationStore(
const snapshotFlight = !entry?.reducer.snapshot
|| entry.reducer.invalidation
|| entry.loadState !== 'live'
? get().loadSnapshot(conversationId, Boolean(entry?.reducer.invalidation))
? get().loadSnapshot(
conversationId,
entry?.reducer.invalidation ? 'recovering' : 'loading',
)
: Promise.resolve(entry.reducer.snapshot);
await Promise.all([snapshotFlight, get().connectEvents()]);
},
loadSnapshot(conversationId, recovering = false) {
loadSnapshot(conversationId, mode = 'loading') {
const prior = snapshotLoads.get(conversationId);
if (prior) return prior;
set((state) => {
const entry = state.entriesByConversationId[conversationId] ?? emptyEntry();
return {
entriesByConversationId: {
...state.entriesByConversationId,
[conversationId]: {
...entry,
loadState: recovering ? 'recovering' : 'loading',
error: null,
if (mode !== 'silent') {
set((state) => {
const entry = state.entriesByConversationId[conversationId] ?? emptyEntry();
return {
entriesByConversationId: {
...state.entriesByConversationId,
[conversationId]: {
...entry,
loadState: mode,
error: null,
},
},
},
};
});
};
});
}
const flight = withPreparationDeadline(
deps.getSnapshot(conversationId),
deps.preparationTimeoutMs,
@@ -545,16 +555,18 @@ export function createCodingConversationStore(
return snapshot;
})
.catch((error) => {
const failure = errorDetails(error);
set((state) => {
const entry = state.entriesByConversationId[conversationId] ?? emptyEntry();
return {
entriesByConversationId: {
...state.entriesByConversationId,
[conversationId]: { ...entry, loadState: 'error', error: failure.message },
},
};
});
if (mode !== 'silent') {
const failure = errorDetails(error);
set((state) => {
const entry = state.entriesByConversationId[conversationId] ?? emptyEntry();
return {
entriesByConversationId: {
...state.entriesByConversationId,
[conversationId]: { ...entry, loadState: 'error', error: failure.message },
},
};
});
}
throw error;
})
.finally(() => {
@@ -579,7 +591,7 @@ export function createCodingConversationStore(
});
try {
await deps.recover(conversationId);
await get().loadSnapshot(conversationId, true);
await get().loadSnapshot(conversationId, 'recovering');
} catch (error) {
const failure = errorDetails(error);
set((state) => {
@@ -689,7 +701,10 @@ export function createCodingConversationStore(
async submitPrompt(input) {
let entry = get().entriesByConversationId[input.conversationId];
if (!entry?.reducer.snapshot || entry.reducer.invalidation) {
await get().loadSnapshot(input.conversationId, Boolean(entry?.reducer.invalidation));
await get().loadSnapshot(
input.conversationId,
entry?.reducer.invalidation ? 'recovering' : 'loading',
);
entry = get().entriesByConversationId[input.conversationId];
}
if (!entry?.reducer.snapshot) throw new Error('Conversation snapshot is unavailable');
@@ -915,30 +930,43 @@ export function createCodingConversationStore(
|| Boolean(currentBeforeBatch?.reducer.invalidation);
if (!validRange) {
if (!snapshotLoads.has(event.conversationId)) {
void get().loadSnapshot(event.conversationId, true).catch(() => undefined);
void get().loadSnapshot(event.conversationId, 'recovering').catch(() => undefined);
}
return;
}
let applicableEvent = event;
if (currentSnapshot
&& event.workerGeneration === currentSnapshot.cursor.workerGeneration
&& event.fromSeq <= currentSnapshot.cursor.seq) {
const unseenItems = event.items.filter((item) => item.seq > currentSnapshot.cursor.seq);
if (unseenItems.length === 0) return;
applicableEvent = {
...event,
fromSeq: unseenItems[0]!.seq,
toSeq: unseenItems.at(-1)!.seq,
items: unseenItems,
};
}
if (recovering
|| !currentSnapshot
|| event.workerGeneration !== currentSnapshot.cursor.workerGeneration
|| event.fromSeq !== currentSnapshot.cursor.seq + 1) {
bufferRecoveryBatch(event);
if (!snapshotLoads.has(event.conversationId)) {
void get().loadSnapshot(event.conversationId, true).catch(() => undefined);
|| applicableEvent.workerGeneration !== currentSnapshot.cursor.workerGeneration
|| applicableEvent.fromSeq !== currentSnapshot.cursor.seq + 1) {
bufferRecoveryBatch(applicableEvent);
if (!snapshotLoads.has(applicableEvent.conversationId)) {
void get().loadSnapshot(applicableEvent.conversationId, 'recovering').catch(() => undefined);
}
return;
}
const shouldSyncSettledSession = completesOrdinaryPrompt(event, currentSnapshot);
const shouldSyncSettledSession = completesOrdinaryPrompt(applicableEvent, currentSnapshot);
let recover = false;
let applied = false;
set((state) => {
const current = state.entriesByConversationId[event.conversationId] ?? emptyEntry();
const current = state.entriesByConversationId[applicableEvent.conversationId] ?? emptyEntry();
let reducer = current.reducer;
for (const item of event.items) {
for (const item of applicableEvent.items) {
reducer = reduceConversationPatch(reducer, {
conversationId: event.conversationId,
workerGeneration: event.workerGeneration,
conversationId: applicableEvent.conversationId,
workerGeneration: applicableEvent.workerGeneration,
...(item.runId ? { runId: item.runId } : {}),
seq: item.seq,
at: item.at,
@@ -951,10 +979,10 @@ export function createCodingConversationStore(
}
if (reducer === current.reducer) return state;
applied = true;
const incomingMessages = event.items.flatMap((item) => (
const incomingMessages = applicableEvent.items.flatMap((item) => (
item.patch.op === 'message.upsert' ? [item.patch.node] : []
));
const unread = state.selectedConversationId !== event.conversationId
const unread = state.selectedConversationId !== applicableEvent.conversationId
&& incomingMessages.some((message) => message.role === 'assistant')
? true
: current.unread;
@@ -965,7 +993,7 @@ export function createCodingConversationStore(
error: null,
unread,
};
const requests = state.requestsByConversationId[event.conversationId] ?? {};
const requests = state.requestsByConversationId[applicableEvent.conversationId] ?? {};
const nextRequests = reducer.snapshot
? withoutReconciledRequests(requests, reducer.snapshot)
: requests;
@@ -973,21 +1001,21 @@ export function createCodingConversationStore(
return {
entriesByConversationId: {
...state.entriesByConversationId,
[event.conversationId]: entry,
[applicableEvent.conversationId]: entry,
},
summariesByConversationId: summariesWithEntry(state.summariesByConversationId, entry),
requestsByConversationId: !requestsChanged
? state.requestsByConversationId
: {
...state.requestsByConversationId,
[event.conversationId]: nextRequests,
[applicableEvent.conversationId]: nextRequests,
},
};
});
if (recover) {
void get().loadSnapshot(event.conversationId, true).catch(() => undefined);
void get().loadSnapshot(applicableEvent.conversationId, 'recovering').catch(() => undefined);
} else if (applied && shouldSyncSettledSession) {
const settledSnapshot = selectCodingConversationSnapshot(event.conversationId)(get());
const settledSnapshot = selectCodingConversationSnapshot(applicableEvent.conversationId)(get());
if (settledSnapshot) deps.queueSettledSessionSync(settledSnapshot);
}
},

View File

@@ -6,7 +6,6 @@ import {
getCodingProjectConfig,
listCodingProjectConversations,
listCodingProjects,
acknowledgeLegacyCodingConversationNotice,
openCodingProject,
patchCodingProjectConversation,
removeCodingProject,
@@ -48,11 +47,6 @@ interface CodingWorkspaceDependencies {
}): Promise<{ project: CodingProjectSummary; config: CodingProjectConfig; knowledgeFiles: string[] }>;
setActiveProject(projectId: string): Promise<CodingProjectSummary>;
removeProject(projectId: string): Promise<void>;
acknowledgeLegacyNotice(projectId: string): Promise<{
project: CodingProjectSummary;
config: CodingProjectConfig;
knowledgeFiles: string[];
}>;
}
export interface CodingWorkspaceState {
@@ -77,7 +71,6 @@ export interface CodingWorkspaceState {
}): Promise<CodingProjectSummary>;
setActiveProject(projectId: string): Promise<CodingProjectSummary>;
removeProject(projectId: string): Promise<void>;
acknowledgeLegacyNotice(): Promise<void>;
selectAgent(agentId: string): void;
ensureConversation(agentId: string): Promise<CodingConversationMetadata>;
createConversation(agentId: string): Promise<CodingConversationMetadata>;
@@ -136,7 +129,6 @@ function defaultDependencies(): CodingWorkspaceDependencies {
createProject: createCodingProject,
setActiveProject: setActiveCodingProject,
removeProject: removeCodingProject,
acknowledgeLegacyNotice: acknowledgeLegacyCodingConversationNotice,
};
}
@@ -238,13 +230,6 @@ export function createCodingWorkspaceStore(
await get().load();
},
async acknowledgeLegacyNotice() {
const projectId = get().activeProjectId;
if (!projectId) return;
const snapshot = await deps.acknowledgeLegacyNotice(projectId);
if (get().activeProjectId === projectId) set({ config: snapshot.config });
},
selectAgent(agentId) {
if (!enabledAgent(get().config, agentId)) return;
set({ selectedAgentId: agentId });
@@ -259,7 +244,7 @@ export function createCodingWorkspaceStore(
async createConversation(agentId) {
const state = get();
if (!state.activeProject || !enabledAgent(state.config, agentId)) {
throw new Error('当前项目没有可用的伙伴。');
throw new Error('当前项目没有可用的智能体。');
}
const flightKey = `${state.activeProject.id}:${agentId}`;
const existingFlight = conversationFlights.get(flightKey);