🐛 fix: workspace error (#15701)

feat: support workspace (page author, copyTo/transferTo, notifications, i18n & fixes)

Squashed 13 commits from fix/workspace-error for clean rebase onto main's submodule base.
This commit is contained in:
Rdmclin2
2026-06-12 16:08:31 +08:00
committed by GitHub
parent 365dd1ff64
commit 35b6bc55b8
119 changed files with 1061 additions and 646 deletions
@@ -58,6 +58,7 @@ export interface DocumentHistoryListItem {
isCurrent: boolean;
savedAt: string;
saveSource: DocumentHistorySaveSource;
userId: string;
}
export interface ListHistoryOutput {
@@ -85,6 +85,7 @@ export const agentSignalRouter = router({
return enqueueAgentSignalSourceEvent(sourceEvent, {
agentId: input.agentId,
userId: ctx.userId,
workspaceId: ctx.workspaceId ?? undefined,
});
}),
listReceipts: agentSignalProcedure
+12 -10
View File
@@ -1,11 +1,12 @@
import { z } from 'zod';
import { wsCompatProcedure } from '@/business/server/trpc-middlewares/workspaceAuth';
import { AgentOperationModel } from '@/database/models/agentOperation';
import { LlmGenerationTracingModel } from '@/database/models/llmGenerationTracing';
import { VerifyCheckResultModel } from '@/database/models/verifyCheckResult';
import { VerifyCriterionModel } from '@/database/models/verifyCriterion';
import { VerifyRubricModel } from '@/database/models/verifyRubric';
import { authedProcedure, router } from '@/libs/trpc/lambda';
import { router } from '@/libs/trpc/lambda';
import { serverDatabase } from '@/libs/trpc/lambda/middleware';
import {
VerifyExecutorService,
@@ -35,18 +36,19 @@ const checkItemSchema = z.object({
verifierType: verifierTypeSchema,
});
const verifyProcedure = authedProcedure.use(serverDatabase).use(async (opts) => {
const verifyProcedure = wsCompatProcedure.use(serverDatabase).use(async (opts) => {
const { ctx } = opts;
const workspaceId = ctx.workspaceId ?? undefined;
return opts.next({
ctx: {
criterionModel: new VerifyCriterionModel(ctx.serverDB, ctx.userId),
executorService: new VerifyExecutorService(ctx.serverDB, ctx.userId),
tracingModel: new LlmGenerationTracingModel(ctx.serverDB, ctx.userId),
feedbackService: new VerifyFeedbackService(ctx.serverDB, ctx.userId),
operationModel: new AgentOperationModel(ctx.serverDB, ctx.userId),
planGenerator: new VerifyPlanGeneratorService(ctx.serverDB, ctx.userId),
resultModel: new VerifyCheckResultModel(ctx.serverDB, ctx.userId),
rubricModel: new VerifyRubricModel(ctx.serverDB, ctx.userId),
criterionModel: new VerifyCriterionModel(ctx.serverDB, ctx.userId, workspaceId),
executorService: new VerifyExecutorService(ctx.serverDB, ctx.userId, workspaceId),
tracingModel: new LlmGenerationTracingModel(ctx.serverDB, ctx.userId, workspaceId),
feedbackService: new VerifyFeedbackService(ctx.serverDB, ctx.userId, workspaceId),
operationModel: new AgentOperationModel(ctx.serverDB, ctx.userId, workspaceId),
planGenerator: new VerifyPlanGeneratorService(ctx.serverDB, ctx.userId, workspaceId),
resultModel: new VerifyCheckResultModel(ctx.serverDB, ctx.userId, workspaceId),
rubricModel: new VerifyRubricModel(ctx.serverDB, ctx.userId, workspaceId),
},
});
});
@@ -231,6 +231,57 @@ describe('AgentService', () => {
// Avatar should not be present for non-builtin agents
expect((result as any)?.avatar).toBeUndefined();
});
it('should NOT inherit the member personal default model for a workspace inbox', async () => {
// Workspace inbox is persisted with an empty model/provider.
const mockAgent = {
id: 'agent-1',
slug: 'inbox',
};
const serverDefaultConfig = { model: 'system-default-model', provider: 'system-provider' };
const mockAgentModel = {
getBuiltinAgent: vi.fn().mockResolvedValue(mockAgent),
};
(AgentModel as any).mockImplementation(() => mockAgentModel);
(parseAgentConfig as any).mockReturnValue(serverDefaultConfig);
// The member opening the workspace inbox has a personal default model.
mockUserModel.getUserSettingsDefaultAgentConfig.mockResolvedValueOnce({
config: { model: 'opus-4.6', provider: 'anthropic' },
});
const workspaceService = new AgentService(mockDb, mockUserId, mockWorkspaceId);
const result = await workspaceService.getBuiltinAgent('inbox');
// Should fall back to the system default, NOT the member's personal model.
expect(result?.model).toBe('system-default-model');
expect(result?.provider).toBe('system-provider');
});
it('should still apply the personal default model for a personal inbox', async () => {
const mockAgent = {
id: 'agent-1',
slug: 'inbox',
};
const mockAgentModel = {
getBuiltinAgent: vi.fn().mockResolvedValue(mockAgent),
};
(AgentModel as any).mockImplementation(() => mockAgentModel);
(parseAgentConfig as any).mockReturnValue({});
mockUserModel.getUserSettingsDefaultAgentConfig.mockResolvedValueOnce({
config: { model: 'user-preferred-model', provider: 'user-provider' },
});
// No workspaceId → personal scope keeps the personal default behavior.
const newService = new AgentService(mockDb, mockUserId);
const result = await newService.getBuiltinAgent('inbox');
expect(result?.model).toBe('user-preferred-model');
expect(result?.provider).toBe('user-provider');
});
});
describe('getAgentConfig', () => {
+16 -4
View File
@@ -174,6 +174,13 @@ export class AgentService {
* 2. serverDefaultAgentConfig - from environment variable
* 3. userDefaultAgentConfig - from user settings (defaultAgent.config)
* 4. agent - actual agent config from database
*
* Workspace exception: a workspace is a shared resource, so its agents must
* NOT inherit any individual member's *personal* default model. Otherwise a
* shared agent persisted with an empty model (e.g. the workspace inbox)
* resolves to whoever opens it — the creator's personal default leaks in and
* the workspace looks "initialized" with their model. For workspace-scoped
* reads we skip the user layer and fall back to the system default instead.
*/
private mergeDefaultConfig(
agent: any,
@@ -181,12 +188,17 @@ export class AgentService {
): LobeAgentConfig | null {
if (!agent) return null;
const userDefaultAgentConfig =
(defaultAgentConfig as { config?: PartialDeep<LobeAgentConfig> })?.config || {};
// Merge configs in order: DEFAULT -> server -> user -> agent
// Merge configs in order: DEFAULT -> server -> [user] -> agent
const serverDefaultAgentConfig = getServerDefaultAgentConfig();
const baseConfig = merge(DEFAULT_AGENT_CONFIG, serverDefaultAgentConfig);
// Skip the personal default layer for workspace-scoped agents (see above).
if (this.workspaceId) {
return merge(baseConfig, cleanObject(agent));
}
const userDefaultAgentConfig =
(defaultAgentConfig as { config?: PartialDeep<LobeAgentConfig> })?.config || {};
const withUserConfig = merge(baseConfig, userDefaultAgentConfig);
return merge(withUserConfig, cleanObject(agent));
@@ -344,11 +344,16 @@ export class CompletionLifecycle {
metadata?.assistantMessageId,
metadata?.userId || this.userId,
);
void runVerifyOnCompletion(this.serverDB, metadata?.userId || this.userId, {
deliverable: event.lastAssistantContent ?? '',
goal,
operationId,
});
void runVerifyOnCompletion(
this.serverDB,
metadata?.userId || this.userId,
{
deliverable: event.lastAssistantContent ?? '',
goal,
operationId,
},
this.workspaceId,
);
}
if (reason === 'error') {
@@ -990,6 +990,7 @@ export class BotMessageRouter {
agentId,
db: serverDB,
userId,
workspaceId: workspaceId ?? undefined,
},
{ ignoreError: true },
);
@@ -1175,6 +1176,7 @@ export class BotMessageRouter {
agentId,
db: serverDB,
userId,
workspaceId: workspaceId ?? undefined,
},
{ ignoreError: true },
);
@@ -1392,6 +1394,7 @@ export class BotMessageRouter {
agentId,
db: serverDB,
userId,
workspaceId: workspaceId ?? undefined,
},
{ ignoreError: true },
);
@@ -185,6 +185,7 @@ export class DocumentHistoryService {
isCurrent: true,
saveSource: 'system',
savedAt: headDocument.updatedAt,
userId: headDocument.userId,
});
}
@@ -193,6 +194,7 @@ export class DocumentHistoryService {
isCurrent: false,
saveSource: row.saveSource as DocumentHistorySaveSource,
savedAt: row.savedAt,
userId: row.userId,
}));
// If head consumed a slot and we fetched a full page of history rows,
@@ -22,6 +22,7 @@ export interface DocumentHistoryListItem {
isCurrent: boolean;
savedAt: Date;
saveSource: DocumentHistorySaveSource;
userId: string;
}
export interface DocumentHistoryItemResult {
@@ -10,6 +10,7 @@ interface LobeDeliveryCheckerRuntimeContext {
operationId?: string;
serverDB: LobeChatDatabase;
userId: string;
workspaceId?: string;
}
const buildError = (content: string, code: string): BuiltinServerRuntimeOutput => ({
@@ -28,11 +29,13 @@ class LobeDeliveryCheckerExecutionRuntime {
private operationId?: string;
private db: LobeChatDatabase;
private userId: string;
private workspaceId?: string;
constructor(context: LobeDeliveryCheckerRuntimeContext) {
this.operationId = context.operationId;
this.db = context.serverDB;
this.userId = context.userId;
this.workspaceId = context.workspaceId;
}
generateVerifyPlan = async (params: {
@@ -64,7 +67,7 @@ class LobeDeliveryCheckerExecutionRuntime {
// criteria + a rubric, snapshot it onto this operation, and confirm it. The
// tool call is human-reviewed (humanIntervention); this runs post-approval.
const { VerifyPlanGeneratorService } = await import('@/server/services/verify');
const planGenerator = new VerifyPlanGeneratorService(this.db, this.userId);
const planGenerator = new VerifyPlanGeneratorService(this.db, this.userId, this.workspaceId);
const { items, rubricId } = await planGenerator.createPlanFromCriteria({
criteria,
operationId: this.operationId,
@@ -110,6 +113,7 @@ export const lobeDeliveryCheckerRuntime: ServerRuntimeRegistration = {
operationId: context.operationId,
serverDB: context.serverDB,
userId: context.userId,
workspaceId: context.workspaceId,
});
},
identifier: LobeDeliveryCheckerIdentifier,
@@ -15,6 +15,7 @@ interface VerifyResultRuntimeContext {
operationId?: string;
serverDB: LobeChatDatabase;
userId: string;
workspaceId?: string;
}
/**
@@ -27,11 +28,13 @@ class VerifyResultExecutionRuntime {
private operationId?: string;
private db: LobeChatDatabase;
private userId: string;
private workspaceId?: string;
constructor(context: VerifyResultRuntimeContext) {
this.operationId = context.operationId;
this.db = context.serverDB;
this.userId = context.userId;
this.workspaceId = context.workspaceId;
}
submitVerifyResult = async (params: SubmitVerifyResultParams) => {
@@ -47,11 +50,13 @@ class VerifyResultExecutionRuntime {
}
// The verifier runs as a sub-agent; the row to update belongs to the parent run.
const op = await new AgentOperationModel(this.db, this.userId).findById(this.operationId);
const op = await new AgentOperationModel(this.db, this.userId, this.workspaceId).findById(
this.operationId,
);
const targetOperationId = op?.parentOperationId ?? this.operationId;
const status = params.verdict === 'passed' ? 'passed' : 'failed';
await new VerifyCheckResultModel(this.db, this.userId).updateByCheckItem(
await new VerifyCheckResultModel(this.db, this.userId, this.workspaceId).updateByCheckItem(
targetOperationId,
params.checkItemId,
{
@@ -66,10 +71,12 @@ class VerifyResultExecutionRuntime {
verdict: params.verdict,
},
);
await new VerifyStatusService(this.db, this.userId).recompute(targetOperationId);
await new VerifyStatusService(this.db, this.userId, this.workspaceId).recompute(
targetOperationId,
);
// This may be the last check to resolve — kick auto-repair if the run failed
// with auto_repair checks (no-op until everything has a terminal result).
await maybeAutoRepair(this.db, this.userId, targetOperationId);
await maybeAutoRepair(this.db, this.userId, targetOperationId, this.workspaceId);
log(
'submitted verdict %s for check %s (op %s)',
@@ -94,6 +101,7 @@ export const verifyResultRuntime: ServerRuntimeRegistration = {
operationId: context.operationId,
serverDB: context.serverDB,
userId: context.userId,
workspaceId: context.workspaceId,
});
},
identifier: VerifyToolIdentifier,
@@ -52,18 +52,20 @@ export const createVerifierAgentRunner = (params: {
provider?: string | null;
topicId?: string | null;
userId: string;
workspaceId?: string;
}): VerifierAgentRunner | undefined => {
const { db, deliverable, model, provider, topicId, userId } = params;
const { db, deliverable, model, provider, topicId, userId, workspaceId } = params;
if (!topicId) return undefined;
return async ({ checkItem, goal, operationId }) => {
// The detailed instruction is the criterion's rule body, stored in a document.
const instruction = checkItem.documentId
? ((await new DocumentModel(db, userId).findById(checkItem.documentId))?.content ?? undefined)
? ((await new DocumentModel(db, userId, workspaceId).findById(checkItem.documentId))
?.content ?? undefined)
: undefined;
// Materialize the builtin verify agent (idempotent) to get an id for the thread.
const verifyAgent = await new AgentModel(db, userId).getBuiltinAgent(
const verifyAgent = await new AgentModel(db, userId, workspaceId).getBuiltinAgent(
BUILTIN_AGENT_SLUGS.verifyAgent,
);
if (!verifyAgent) {
@@ -71,7 +73,7 @@ export const createVerifierAgentRunner = (params: {
return null;
}
const thread = await new ThreadModel(db, userId).create({
const thread = await new ThreadModel(db, userId, workspaceId).create({
agentId: verifyAgent.id,
title: `Verify: ${checkItem.title}`,
topicId,
@@ -85,7 +87,7 @@ export const createVerifierAgentRunner = (params: {
// Dynamic import breaks the static cycle: aiAgent → agentRuntime completion
// → verify lifecycle → this runner → aiAgent.
const { AiAgentService } = await import('@/server/services/aiAgent');
const result = await new AiAgentService(db, userId).execAgent({
const result = await new AiAgentService(db, userId, { workspaceId }).execAgent({
appContext: { threadId: thread.id, topicId },
autoStart: true,
// Inherit the parent run's model/provider so the verifier uses a provider
+5 -5
View File
@@ -80,13 +80,13 @@ export class VerifyExecutorService {
private readonly statusService: VerifyStatusService;
private readonly documentModel: DocumentModel;
constructor(db: LobeChatDatabase, userId: string) {
constructor(db: LobeChatDatabase, userId: string, workspaceId?: string) {
this.db = db;
this.userId = userId;
this.operationModel = new AgentOperationModel(db, userId);
this.resultModel = new VerifyCheckResultModel(db, userId);
this.statusService = new VerifyStatusService(db, userId);
this.documentModel = new DocumentModel(db, userId);
this.operationModel = new AgentOperationModel(db, userId, workspaceId);
this.resultModel = new VerifyCheckResultModel(db, userId, workspaceId);
this.statusService = new VerifyStatusService(db, userId, workspaceId);
this.documentModel = new DocumentModel(db, userId, workspaceId);
}
/**
@@ -23,8 +23,8 @@ export const computeFalseFlags = (
export class VerifyFeedbackService {
private readonly resultModel: VerifyCheckResultModel;
constructor(db: LobeChatDatabase, userId: string) {
this.resultModel = new VerifyCheckResultModel(db, userId);
constructor(db: LobeChatDatabase, userId: string, workspaceId?: string) {
this.resultModel = new VerifyCheckResultModel(db, userId, workspaceId);
}
/** Record a user's decision on a result and precompute its FP/FN flags. */
+5 -3
View File
@@ -33,9 +33,10 @@ export const runVerifyOnCompletion = async (
db: LobeChatDatabase,
userId: string,
params: RunVerifyOnCompletionParams,
workspaceId?: string,
): Promise<void> => {
try {
const operationModel = new AgentOperationModel(db, userId);
const operationModel = new AgentOperationModel(db, userId, workspaceId);
const state = await operationModel.getVerifyState(params.operationId);
// Opt-in gate: only runs with a confirmed plan that hasn't been verified yet.
@@ -48,7 +49,7 @@ export const runVerifyOnCompletion = async (
return;
}
const executor = new VerifyExecutorService(db, userId);
const executor = new VerifyExecutorService(db, userId, workspaceId);
await executor.execute({
deliverable: params.deliverable,
goal: params.goal,
@@ -63,13 +64,14 @@ export const runVerifyOnCompletion = async (
provider: op.provider,
topicId: op.topicId,
userId,
workspaceId,
}),
});
// Auto-repair once verification has fully resolved. For runs with only inline
// (LLM/program) checks, everything is resolved now; runs with async agent
// checks no-op here and re-trigger from the verifier's writeback path.
await maybeAutoRepair(db, userId, params.operationId);
await maybeAutoRepair(db, userId, params.operationId, workspaceId);
} catch (error) {
log('runVerifyOnCompletion failed for op %s (non-fatal): %O', params.operationId, error);
}
@@ -75,13 +75,13 @@ export class VerifyPlanGeneratorService {
private readonly operationModel: AgentOperationModel;
private readonly documentModel: DocumentModel;
constructor(db: LobeChatDatabase, userId: string) {
constructor(db: LobeChatDatabase, userId: string, workspaceId?: string) {
this.db = db;
this.userId = userId;
this.criterionModel = new VerifyCriterionModel(db, userId);
this.rubricModel = new VerifyRubricModel(db, userId);
this.operationModel = new AgentOperationModel(db, userId);
this.documentModel = new DocumentModel(db, userId);
this.criterionModel = new VerifyCriterionModel(db, userId, workspaceId);
this.rubricModel = new VerifyRubricModel(db, userId, workspaceId);
this.operationModel = new AgentOperationModel(db, userId, workspaceId);
this.documentModel = new DocumentModel(db, userId, workspaceId);
}
/**
@@ -22,11 +22,12 @@ const resolveMaxRepairRounds = async (
db: LobeChatDatabase,
userId: string,
plan: VerifyCheckItem[],
workspaceId?: string,
): Promise<number> => {
const rubricId = plan.find((i) => i.sourceRubricId)?.sourceRubricId;
if (!rubricId) return DEFAULT_MAX_REPAIR_ROUNDS;
const rubric = await new VerifyRubricModel(db, userId).findById(rubricId);
const rubric = await new VerifyRubricModel(db, userId, workspaceId).findById(rubricId);
return rubric?.config?.maxRepairRounds ?? DEFAULT_MAX_REPAIR_ROUNDS;
};
@@ -79,12 +80,13 @@ export const createRepairRunner = (params: {
provider?: string | null;
topicId?: string | null;
userId: string;
workspaceId?: string;
}): RepairSpawner | undefined => {
const { agentId, db, maxRepairRounds, model, provider, topicId, userId } = params;
const { agentId, db, maxRepairRounds, model, provider, topicId, userId, workspaceId } = params;
if (!agentId || !topicId) return undefined;
return async ({ instruction, operationId, verifyMessageId }) => {
const operationModel = new AgentOperationModel(db, userId);
const operationModel = new AgentOperationModel(db, userId, workspaceId);
const round = await countRepairRounds(operationModel, operationId);
if (round >= maxRepairRounds) {
@@ -98,7 +100,7 @@ export const createRepairRunner = (params: {
// for the operation title / logs. `verifyMessageId` parents the new turn under
// the verify card it responds to.
const { AiAgentService } = await import('@/server/services/aiAgent');
const result = await new AiAgentService(db, userId).execAgent({
const result = await new AiAgentService(db, userId, { workspaceId }).execAgent({
agentId,
appContext: { topicId },
autoStart: true,
@@ -138,13 +140,16 @@ export const maybeAutoRepair = async (
db: LobeChatDatabase,
userId: string,
operationId: string,
workspaceId?: string,
): Promise<void> => {
const operationModel = new AgentOperationModel(db, userId);
const operationModel = new AgentOperationModel(db, userId, workspaceId);
const state = await operationModel.getVerifyState(operationId);
const plan = (state?.verifyPlan ?? []) as VerifyCheckItem[];
if (plan.length === 0) return;
const results = await new VerifyCheckResultModel(db, userId).listByOperation(operationId);
const results = await new VerifyCheckResultModel(db, userId, workspaceId).listByOperation(
operationId,
);
const byItem = new Map(results.map((r) => [r.checkItemId, r]));
// Wait until every required check has a terminal result (don't repair early).
@@ -160,13 +165,14 @@ export const maybeAutoRepair = async (
const spawner = createRepairRunner({
agentId: op?.agentId,
db,
maxRepairRounds: await resolveMaxRepairRounds(db, userId, plan),
maxRepairRounds: await resolveMaxRepairRounds(db, userId, plan, workspaceId),
model: op?.model,
provider: op?.provider,
topicId: op?.topicId,
userId,
workspaceId,
});
await new VerifyRepairService(db, userId).triggerAutoRepair(operationId, spawner);
await new VerifyRepairService(db, userId, workspaceId).triggerAutoRepair(operationId, spawner);
};
const isFailed = (r: VerifyCheckResultItem | undefined): boolean =>
@@ -191,11 +197,11 @@ export class VerifyRepairService {
private readonly resultModel: VerifyCheckResultModel;
private readonly statusService: VerifyStatusService;
constructor(db: LobeChatDatabase, userId: string) {
this.messageModel = new MessageModel(db, userId);
this.operationModel = new AgentOperationModel(db, userId);
this.resultModel = new VerifyCheckResultModel(db, userId);
this.statusService = new VerifyStatusService(db, userId);
constructor(db: LobeChatDatabase, userId: string, workspaceId?: string) {
this.messageModel = new MessageModel(db, userId, workspaceId);
this.operationModel = new AgentOperationModel(db, userId, workspaceId);
this.resultModel = new VerifyCheckResultModel(db, userId, workspaceId);
this.statusService = new VerifyStatusService(db, userId, workspaceId);
}
/** Collect the auto-repairable failures for a run. */
@@ -17,9 +17,9 @@ export class VerifyStatusService {
private readonly operationModel: AgentOperationModel;
private readonly resultModel: VerifyCheckResultModel;
constructor(db: LobeChatDatabase, userId: string) {
this.operationModel = new AgentOperationModel(db, userId);
this.resultModel = new VerifyCheckResultModel(db, userId);
constructor(db: LobeChatDatabase, userId: string, workspaceId?: string) {
this.operationModel = new AgentOperationModel(db, userId, workspaceId);
this.resultModel = new VerifyCheckResultModel(db, userId, workspaceId);
}
/**