Files
lobe-chat/src/server/routers/lambda/user.ts
T
Innei a59a9c4943 feat(onboarding): structured hunk ops for updateDocument (#13989)
*  feat(onboarding): structured hunk ops for updateDocument

Extend `updateDocument` (and the underlying `@lobechat/markdown-patch`) with
explicit hunk modes so agents can unambiguously express deletes and inserts
instead of encoding them as clever search/replace pairs.

Modes: `replace` (default, backward-compatible), `delete`, `deleteLines`,
`insertAt`, `replaceLines`. Line-based modes use 1-based inclusive ranges
and are applied after content-based hunks, sorted by anchor line descending
so earlier lines stay stable. New error codes: `LINE_OUT_OF_RANGE`,
`INVALID_LINE_RANGE`, `LINE_OVERLAP`.

Onboarding document injection now prefixes each line with its 1-based number
(cat -n style) so the agent can cite line numbers when issuing line-based
hunks. Tool description, system role, and per-phase action hints updated to
teach the new shape.

* 🐛 fix(onboarding): align patchOnboardingDocument zod schema with structured hunks

The tRPC input schema still accepted only the legacy `{search, replace}` shape,
so agent calls using the new `insertAt`/`delete`/`deleteLines`/`replaceLines`
hunk modes were rejected before reaching `applyMarkdownPatch`. Switch to a
z.union matching MarkdownPatchHunk.

* 🐛 fix(markdown-patch): validate line ranges before overlap detection

Previously the overlap loop ran before per-hunk range validation, so an
invalid range (e.g. startLine=0 or endLine<startLine) combined with another
line hunk would be misreported as LINE_OVERLAP instead of the real
LINE_OUT_OF_RANGE / INVALID_LINE_RANGE. Validate each line hunk against the
baseline line count first, then run overlap detection on valid ranges only.
2026-04-20 21:17:28 +08:00

451 lines
16 KiB
TypeScript

import { EMPTY_DOCUMENT_MESSAGES } from '@lobechat/builtin-tool-web-onboarding/utils';
import { isDesktop } from '@lobechat/const';
import { applyMarkdownPatch, formatMarkdownPatchError } from '@lobechat/markdown-patch';
import {
type UserInitializationState,
type UserPreference,
type UserSettings,
} from '@lobechat/types';
import {
Plans,
SaveUserQuestionInputSchema,
UserAgentOnboardingSchema,
UserGuideSchema,
UserOnboardingSchema,
UserPreferenceSchema,
UserSettingsSchema,
} from '@lobechat/types';
import { TRPCError } from '@trpc/server';
import { after } from 'next/server';
import { v4 as uuidv4 } from 'uuid';
import { z } from 'zod';
import { getReferralStatus, getSubscriptionPlan } from '@/business/server/user';
import { MessageModel } from '@/database/models/message';
import { SessionModel } from '@/database/models/session';
import { UserModel } from '@/database/models/user';
import { authedProcedure, router } from '@/libs/trpc/lambda';
import { serverDatabase } from '@/libs/trpc/lambda/middleware';
import { KeyVaultsGateKeeper } from '@/server/modules/KeyVaultsEncrypt';
import { FileS3 } from '@/server/modules/S3';
import { AgentDocumentsService } from '@/server/services/agentDocuments';
import { FileService } from '@/server/services/file';
import { OnboardingService } from '@/server/services/onboarding';
const usernameSchema = z
.string()
.trim()
.min(1, { message: 'USERNAME_REQUIRED' })
.max(64, { message: 'USERNAME_TOO_LONG' })
.regex(/^\w+$/, { message: 'USERNAME_INVALID' });
const AVATAR_WEBAPI_PREFIX = '/webapi/';
// Accept only: base64 data URL, absolute http(s) URL, empty string,
// or an internal /webapi/user/avatar/<userId>/... path scoped to the caller.
// Any other value (relative path, file://, s3://, path-traversal, or another
// user's prefix) is rejected so a later upload can't be tricked into deleting
// an arbitrary S3 key via the "delete old avatar" step.
const assertSafeAvatarInput = (input: string, userId: string) => {
if (input.length === 0) return;
if (input.startsWith('data:image')) return;
const ownPrefix = `${AVATAR_WEBAPI_PREFIX}user/avatar/${userId}/`;
if (input.startsWith(ownPrefix) && !input.includes('..')) return;
try {
const { protocol } = new URL(input);
if (protocol === 'http:' || protocol === 'https:') return;
} catch {
/* not a parseable absolute URL — fall through to reject */
}
throw new TRPCError({ code: 'BAD_REQUEST', message: 'INVALID_AVATAR_URL' });
};
const userProcedure = authedProcedure.use(serverDatabase).use(async ({ ctx, next }) => {
return next({
ctx: {
fileService: new FileService(ctx.serverDB, ctx.userId),
messageModel: new MessageModel(ctx.serverDB, ctx.userId),
sessionModel: new SessionModel(ctx.serverDB, ctx.userId),
userModel: new UserModel(ctx.serverDB, ctx.userId),
},
});
});
export const userRouter = router({
getUserRegistrationDuration: userProcedure.query(async ({ ctx }) => {
return ctx.userModel.getUserRegistrationDuration();
}),
getUserSSOProviders: userProcedure.query(async ({ ctx }) => {
return ctx.userModel.getUserSSOProviders();
}),
getUserState: userProcedure.query(async ({ ctx }): Promise<UserInitializationState> => {
try {
after(async () => {
try {
await ctx.userModel.updateUser({ lastActiveAt: new Date() });
} catch (err) {
console.error('update lastActiveAt failed, error:', err);
}
});
} catch {
// `after` may fail outside request scope (e.g., in tests), ignore silently
}
// For desktop mode, ensure user exists before getting state
if (isDesktop) {
await UserModel.makeSureUserExist(ctx.serverDB, ctx.userId);
}
// Run user state fetch and count queries in parallel
const [state, messageCount, hasExtraSession, referralStatus, subscriptionPlan] =
await Promise.all([
ctx.userModel.getUserState(KeyVaultsGateKeeper.getUserKeyVaults),
ctx.messageModel.countUpTo(5),
ctx.sessionModel.hasMoreThanN(1),
getReferralStatus(ctx.userId),
getSubscriptionPlan(ctx.userId),
]);
const hasMoreThan4Messages = messageCount > 4;
const hasAnyMessages = messageCount > 0;
return {
avatar: state.avatar,
canEnablePWAGuide: hasMoreThan4Messages,
canEnableTrace: hasMoreThan4Messages,
email: state.email,
firstName: state.firstName,
fullName: state.fullName,
// Has conversation if there are messages or has created any assistant
hasConversation: hasAnyMessages || hasExtraSession,
agentOnboarding: state.agentOnboarding,
interests: state.interests,
// always return true for community version
isOnboard: state.isOnboarded ?? true,
lastName: state.lastName,
onboarding: state.onboarding,
preference: state.preference as UserPreference,
settings: state.settings,
userId: ctx.userId,
username: state.username,
// business features
referralStatus,
subscriptionPlan,
isFreePlan: !subscriptionPlan || subscriptionPlan === Plans.Free,
} satisfies UserInitializationState;
}),
makeUserOnboarded: userProcedure.mutation(async ({ ctx }) => {
return ctx.userModel.updateUser({ isOnboarded: true });
}),
resetSettings: userProcedure.mutation(async ({ ctx }) => {
return ctx.userModel.deleteSetting();
}),
updateAvatar: userProcedure.input(z.string()).mutation(async ({ ctx, input }) => {
assertSafeAvatarInput(input, ctx.userId);
// If it's Base64 data, need to upload to S3
if (input.startsWith('data:image')) {
try {
// Extract mimeType, e.g., "image/png"
const prefix = 'data:';
const semicolonIndex = input.indexOf(';');
const mimeType =
semicolonIndex !== -1 ? input.slice(prefix.length, semicolonIndex) : 'image/png';
const fileType = mimeType.split('/')[1];
// Split string to get the Base64 part
const commaIndex = input.indexOf(',');
if (commaIndex === -1) {
throw new Error('Invalid Base64 data');
}
const base64Data = input.slice(commaIndex + 1);
// Create S3 client
const s3 = new FileS3();
// Use UUID to generate unique filename to prevent caching issues
// Get old avatar URL for later deletion
const userState = await ctx.userModel.getUserState(KeyVaultsGateKeeper.getUserKeyVaults);
const oldAvatarUrl = userState.avatar;
const fileName = `${uuidv4()}.${fileType}`;
const filePath = `user/avatar/${ctx.userId}/${fileName}`;
// Convert Base64 data to Buffer and upload to S3
const buffer = Buffer.from(base64Data, 'base64');
await s3.uploadBuffer(filePath, buffer, mimeType);
// Delete old avatar — defense in depth: only touch keys inside the
// caller's own avatar prefix, never external URLs or traversal paths.
const ownAvatarWebapiPrefix = `${AVATAR_WEBAPI_PREFIX}user/avatar/${ctx.userId}/`;
if (
oldAvatarUrl &&
oldAvatarUrl.startsWith(ownAvatarWebapiPrefix) &&
!oldAvatarUrl.includes('..')
) {
const oldFilePath = oldAvatarUrl.slice(AVATAR_WEBAPI_PREFIX.length);
await s3.deleteFile(oldFilePath);
}
const avatarUrl = '/webapi/' + filePath;
return ctx.userModel.updateUser({ avatar: avatarUrl });
} catch (error) {
throw new Error(
'Error uploading avatar: ' + (error instanceof Error ? error.message : String(error)),
{ cause: error },
);
}
}
// If it's not Base64 data, directly use URL to update user avatar
return ctx.userModel.updateUser({ avatar: input });
}),
updateFullName: userProcedure
.input(z.string().trim().max(64, { message: 'FULLNAME_TOO_LONG' }))
.mutation(async ({ ctx, input }) => {
return ctx.userModel.updateUser({ fullName: input });
}),
updateGuide: userProcedure.input(UserGuideSchema).mutation(async ({ ctx, input }) => {
return ctx.userModel.updateGuide(input);
}),
updateInterests: userProcedure.input(z.array(z.string())).mutation(async ({ ctx, input }) => {
return ctx.userModel.updateUser({ interests: input });
}),
getOrCreateOnboardingState: userProcedure.query(async ({ ctx }) => {
const onboardingService = new OnboardingService(ctx.serverDB, ctx.userId);
return onboardingService.getOrCreateState();
}),
getOnboardingState: userProcedure.query(async ({ ctx }) => {
const onboardingService = new OnboardingService(ctx.serverDB, ctx.userId);
return onboardingService.getState();
}),
saveUserQuestion: userProcedure
.input(SaveUserQuestionInputSchema)
.mutation(async ({ ctx, input }) => {
const onboardingService = new OnboardingService(ctx.serverDB, ctx.userId);
return onboardingService.saveUserQuestion(input);
}),
finishOnboarding: userProcedure.input(z.object({})).mutation(async ({ ctx, input }) => {
const onboardingService = new OnboardingService(ctx.serverDB, ctx.userId);
void input;
return onboardingService.finishOnboarding();
}),
readOnboardingDocument: userProcedure
.input(z.object({ type: z.enum(['soul', 'persona']) }))
.query(async ({ ctx, input }) => {
if (input.type === 'soul') {
const onboardingService = new OnboardingService(ctx.serverDB, ctx.userId);
const docService = new AgentDocumentsService(ctx.serverDB, ctx.userId);
const inboxAgentId = await onboardingService.getInboxAgentId();
const doc = await docService.getDocumentByFilename(inboxAgentId, 'SOUL.md');
return {
content: doc?.content || EMPTY_DOCUMENT_MESSAGES.soul,
id: doc?.id ?? null,
type: 'soul' as const,
};
}
const { UserPersonaModel } = await import('@/database/models/userMemory/persona');
const personaModel = new UserPersonaModel(ctx.serverDB, ctx.userId);
const persona = await personaModel.getLatestPersonaDocument();
return {
content: persona?.persona || EMPTY_DOCUMENT_MESSAGES.persona,
id: persona?.id ?? null,
type: 'persona' as const,
};
}),
updateOnboardingDocument: userProcedure
.input(z.object({ content: z.string(), type: z.enum(['soul', 'persona']) }))
.mutation(async ({ ctx, input }) => {
if (input.type === 'soul') {
const onboardingService = new OnboardingService(ctx.serverDB, ctx.userId);
const docService = new AgentDocumentsService(ctx.serverDB, ctx.userId);
const inboxAgentId = await onboardingService.getInboxAgentId();
const doc = await docService.upsertDocumentByFilename({
agentId: inboxAgentId,
content: input.content,
filename: 'SOUL.md',
});
return { id: doc?.id, type: 'soul' as const };
}
const { UserPersonaModel } = await import('@/database/models/userMemory/persona');
const personaModel = new UserPersonaModel(ctx.serverDB, ctx.userId);
const result = await personaModel.upsertPersona({
editedBy: 'agent_tool',
persona: input.content,
profile: 'default',
});
return { id: result.document.id, type: 'persona' as const };
}),
patchOnboardingDocument: userProcedure
.input(
z.object({
hunks: z
.array(
z.union([
z.object({
mode: z.literal('replace').optional(),
replace: z.string(),
replaceAll: z.boolean().optional(),
search: z.string(),
}),
z.object({
mode: z.literal('delete'),
replaceAll: z.boolean().optional(),
search: z.string(),
}),
z.object({
endLine: z.number().int(),
mode: z.literal('deleteLines'),
startLine: z.number().int(),
}),
z.object({
content: z.string(),
line: z.number().int(),
mode: z.literal('insertAt'),
}),
z.object({
content: z.string(),
endLine: z.number().int(),
mode: z.literal('replaceLines'),
startLine: z.number().int(),
}),
]),
)
.min(1),
type: z.enum(['soul', 'persona']),
}),
)
.mutation(async ({ ctx, input }) => {
const readCurrent = async (): Promise<string> => {
if (input.type === 'soul') {
const onboardingService = new OnboardingService(ctx.serverDB, ctx.userId);
const docService = new AgentDocumentsService(ctx.serverDB, ctx.userId);
const inboxAgentId = await onboardingService.getInboxAgentId();
const doc = await docService.getDocumentByFilename(inboxAgentId, 'SOUL.md');
return doc?.content ?? '';
}
const { UserPersonaModel } = await import('@/database/models/userMemory/persona');
const personaModel = new UserPersonaModel(ctx.serverDB, ctx.userId);
const persona = await personaModel.getLatestPersonaDocument();
return persona?.persona ?? '';
};
const current = await readCurrent();
const patched = applyMarkdownPatch(current, input.hunks);
if (!patched.ok) {
throw new TRPCError({
cause: patched.error,
code: 'BAD_REQUEST',
message: formatMarkdownPatchError(patched.error),
});
}
if (input.type === 'soul') {
const onboardingService = new OnboardingService(ctx.serverDB, ctx.userId);
const docService = new AgentDocumentsService(ctx.serverDB, ctx.userId);
const inboxAgentId = await onboardingService.getInboxAgentId();
const doc = await docService.upsertDocumentByFilename({
agentId: inboxAgentId,
content: patched.content,
filename: 'SOUL.md',
});
return { applied: patched.applied, id: doc?.id, type: 'soul' as const };
}
const { UserPersonaModel } = await import('@/database/models/userMemory/persona');
const personaModel = new UserPersonaModel(ctx.serverDB, ctx.userId);
const result = await personaModel.upsertPersona({
editedBy: 'agent_tool',
persona: patched.content,
profile: 'default',
});
return { applied: patched.applied, id: result.document.id, type: 'persona' as const };
}),
resetAgentOnboarding: userProcedure.mutation(async ({ ctx }) => {
const onboardingService = new OnboardingService(ctx.serverDB, ctx.userId);
return onboardingService.reset();
}),
updateAgentOnboarding: userProcedure
.input(UserAgentOnboardingSchema)
.mutation(async ({ ctx, input }) => {
return ctx.userModel.updateUser({ agentOnboarding: input });
}),
updateOnboarding: userProcedure.input(UserOnboardingSchema).mutation(async ({ ctx, input }) => {
return ctx.userModel.updateUser({ onboarding: input });
}),
updatePreference: userProcedure.input(UserPreferenceSchema).mutation(async ({ ctx, input }) => {
return ctx.userModel.updatePreference(input);
}),
updateSettings: userProcedure.input(UserSettingsSchema).mutation(async ({ ctx, input }) => {
const { keyVaults, ...res } = input as Partial<UserSettings>;
// Encrypt keyVaults
let encryptedKeyVaults: string | null = null;
if (keyVaults) {
// TODO: better to add a validation
const data = JSON.stringify(keyVaults);
const gateKeeper = await KeyVaultsGateKeeper.initWithEnvKey();
encryptedKeyVaults = await gateKeeper.encrypt(data);
}
const nextValue = { ...res, keyVaults: encryptedKeyVaults };
return ctx.userModel.updateSetting(nextValue);
}),
updateUsername: userProcedure.input(usernameSchema).mutation(async ({ ctx, input }) => {
const existedUser = await UserModel.findByUsername(ctx.serverDB, input);
if (existedUser && existedUser.id !== ctx.userId) {
throw new TRPCError({ code: 'CONFLICT', message: 'USERNAME_TAKEN' });
}
return ctx.userModel.updateUser({ username: input });
}),
});
export type UserRouter = typeof userRouter;