diff --git a/apps/server/src/routers/lambda/__tests__/agent.test.ts b/apps/server/src/routers/lambda/__tests__/agent.test.ts index b73690a084..1ff500eb60 100644 --- a/apps/server/src/routers/lambda/__tests__/agent.test.ts +++ b/apps/server/src/routers/lambda/__tests__/agent.test.ts @@ -9,10 +9,16 @@ import { KnowledgeBaseModel } from '@/database/models/knowledgeBase'; import { SessionModel } from '@/database/models/session'; import { UserModel } from '@/database/models/user'; import { AgentService } from '@/server/services/agent'; +import { EditLockService } from '@/server/services/editLock'; +import { publishResourceEvent } from '@/server/services/resourceEvents'; import { KnowledgeType } from '@/types/knowledgeBase'; import { agentRouter } from '../agent'; +vi.mock('@/server/services/resourceEvents', () => ({ publishResourceEvent: vi.fn() })); + +const publishResourceEventMock = vi.mocked(publishResourceEvent); + vi.mock('@/database/models/user', () => ({ UserModel: { findById: vi.fn(), @@ -329,4 +335,122 @@ describe('agentRouter', () => { expect(agentModelMock.update).toHaveBeenCalledWith(mockInput.id, { pinned: false }); }); }); + + describe('edit lock', () => { + const wsCtx = () => ({ ...mockCtx, workspaceId: 'ws-1' }); + + describe('updateAgentConfig write guard', () => { + it('rejects the update when another member holds the lock', async () => { + agentServiceMock.updateAgentConfig = vi.fn().mockResolvedValue({ id: 'agent-1' }); + vi.spyOn(EditLockService.prototype, 'getBlockingHolder').mockResolvedValue('other-user'); + + const caller = agentRouter.createCaller(wsCtx()); + + await expect( + caller.updateAgentConfig({ agentId: 'agent-1', value: { systemRole: 'x' } }), + ).rejects.toMatchObject({ code: 'CONFLICT' }); + expect(agentServiceMock.updateAgentConfig).not.toHaveBeenCalled(); + }); + + it('allows the update when no other member holds the lock', async () => { + agentServiceMock.updateAgentConfig = vi.fn().mockResolvedValue({ id: 'agent-1' }); + vi.spyOn(EditLockService.prototype, 'getBlockingHolder').mockResolvedValue(null); + + const caller = agentRouter.createCaller(wsCtx()); + await caller.updateAgentConfig({ agentId: 'agent-1', value: { systemRole: 'x' } }); + + expect(agentServiceMock.updateAgentConfig).toHaveBeenCalledWith('agent-1', { + systemRole: 'x', + }); + }); + + it('does not check the lock for personal (non-workspace) agents', async () => { + agentServiceMock.updateAgentConfig = vi.fn().mockResolvedValue({ id: 'agent-1' }); + const guardSpy = vi.spyOn(EditLockService.prototype, 'getBlockingHolder'); + + const caller = agentRouter.createCaller(mockCtx); + await caller.updateAgentConfig({ agentId: 'agent-1', value: { systemRole: 'x' } }); + + expect(guardSpy).not.toHaveBeenCalled(); + expect(agentServiceMock.updateAgentConfig).toHaveBeenCalled(); + }); + }); + + describe('acquireAgentLock', () => { + it('returns unlocked without touching the lock service for personal agents', async () => { + const acquireSpy = vi.spyOn(EditLockService.prototype, 'acquire'); + + const caller = agentRouter.createCaller(mockCtx); + const result = await caller.acquireAgentLock({ agentId: 'agent-1' }); + + expect(result).toEqual({ expiresAt: null, holderId: null, lockedByOther: false }); + expect(acquireSpy).not.toHaveBeenCalled(); + }); + + it('broadcasts lock.changed on a holder edge (first claim)', async () => { + vi.spyOn(EditLockService.prototype, 'getActiveHolder').mockResolvedValue(undefined); + vi.spyOn(EditLockService.prototype, 'acquire').mockResolvedValue({ + expiresAt: new Date(), + holderId: userId, + lockedByOther: false, + }); + + const caller = agentRouter.createCaller(wsCtx()); + await caller.acquireAgentLock({ agentId: 'agent-1' }); + + expect(publishResourceEventMock).toHaveBeenCalledWith( + { id: 'agent-1', type: 'agent' }, + expect.objectContaining({ data: { holderId: userId }, type: 'lock.changed' }), + ); + }); + + it('does NOT broadcast on a steady-state heartbeat (same holder)', async () => { + vi.spyOn(EditLockService.prototype, 'getActiveHolder').mockResolvedValue(userId); + vi.spyOn(EditLockService.prototype, 'acquire').mockResolvedValue({ + expiresAt: new Date(), + holderId: userId, + lockedByOther: false, + }); + + const caller = agentRouter.createCaller(wsCtx()); + await caller.acquireAgentLock({ agentId: 'agent-1' }); + + expect(publishResourceEventMock).not.toHaveBeenCalled(); + }); + }); + + describe('getAgentLock', () => { + it('reports another member as the holder', async () => { + vi.spyOn(EditLockService.prototype, 'getActiveHolder').mockResolvedValue('other-user'); + + const caller = agentRouter.createCaller(wsCtx()); + const result = await caller.getAgentLock({ agentId: 'agent-1' }); + + expect(result).toEqual({ expiresAt: null, holderId: 'other-user', lockedByOther: true }); + }); + }); + + describe('releaseAgentLock', () => { + it('broadcasts unlocked only when it actually freed the lock', async () => { + vi.spyOn(EditLockService.prototype, 'release').mockResolvedValue(true); + + const caller = agentRouter.createCaller(wsCtx()); + await caller.releaseAgentLock({ agentId: 'agent-1' }); + + expect(publishResourceEventMock).toHaveBeenCalledWith( + { id: 'agent-1', type: 'agent' }, + expect.objectContaining({ data: { holderId: null }, type: 'lock.changed' }), + ); + }); + + it('does NOT broadcast when the lease expired / was taken over', async () => { + vi.spyOn(EditLockService.prototype, 'release').mockResolvedValue(false); + + const caller = agentRouter.createCaller(wsCtx()); + await caller.releaseAgentLock({ agentId: 'agent-1' }); + + expect(publishResourceEventMock).not.toHaveBeenCalled(); + }); + }); + }); }); diff --git a/apps/server/src/routers/lambda/__tests__/agentGroup.test.ts b/apps/server/src/routers/lambda/__tests__/agentGroup.test.ts index 0222b2bd5d..ce9b9b15a9 100644 --- a/apps/server/src/routers/lambda/__tests__/agentGroup.test.ts +++ b/apps/server/src/routers/lambda/__tests__/agentGroup.test.ts @@ -7,9 +7,15 @@ import * as ChatGroupModelModule from '@/database/models/chatGroup'; import * as UserModelModule from '@/database/models/user'; import * as AgentGroupRepoModule from '@/database/repositories/agentGroup'; import * as ChatGroupServiceModule from '@/server/services/agentGroup'; +import { EditLockService } from '@/server/services/editLock'; +import { publishResourceEvent } from '@/server/services/resourceEvents'; import { agentGroupRouter } from '../agentGroup'; +vi.mock('@/server/services/resourceEvents', () => ({ publishResourceEvent: vi.fn() })); + +const publishResourceEventMock = vi.mocked(publishResourceEvent); + describe('agentGroupRouter', () => { const userId = 'testUserId'; let mockCtx: any; @@ -439,4 +445,126 @@ describe('agentGroupRouter', () => { expect(result).toEqual(mockUpdatedGroup); }); }); + + describe('edit lock', () => { + const wsCtx = () => ({ serverDB: {}, userId, workspaceId: 'ws-1' }); + + describe('updateGroup write guard', () => { + it('rejects the update when another member holds the lock', async () => { + vi.spyOn(EditLockService.prototype, 'getBlockingHolder').mockResolvedValue('other-user'); + + const caller = agentGroupRouter.createCaller(wsCtx()); + + await expect( + caller.updateGroup({ id: 'group-1', value: { title: 'New' } }), + ).rejects.toMatchObject({ code: 'CONFLICT' }); + expect(chatGroupModelMock.update).not.toHaveBeenCalled(); + }); + + it('allows the update when no other member holds the lock', async () => { + vi.spyOn(EditLockService.prototype, 'getBlockingHolder').mockResolvedValue(null); + chatGroupModelMock.update.mockResolvedValue({ id: 'group-1' }); + + const caller = agentGroupRouter.createCaller(wsCtx()); + await caller.updateGroup({ id: 'group-1', value: { title: 'New' } }); + + expect(chatGroupModelMock.update).toHaveBeenCalled(); + }); + + it('does not check the lock for personal (non-workspace) groups', async () => { + const guardSpy = vi.spyOn(EditLockService.prototype, 'getBlockingHolder'); + chatGroupModelMock.update.mockResolvedValue({ id: 'group-1' }); + + const caller = agentGroupRouter.createCaller(mockCtx); + await caller.updateGroup({ id: 'group-1', value: { title: 'New' } }); + + expect(guardSpy).not.toHaveBeenCalled(); + expect(chatGroupModelMock.update).toHaveBeenCalled(); + }); + }); + + describe('acquireGroupLock', () => { + it('returns unlocked without touching the lock service for personal groups', async () => { + const acquireSpy = vi.spyOn(EditLockService.prototype, 'acquire'); + + const caller = agentGroupRouter.createCaller(mockCtx); + const result = await caller.acquireGroupLock({ id: 'group-1' }); + + expect(result).toEqual({ expiresAt: null, holderId: null, lockedByOther: false }); + expect(acquireSpy).not.toHaveBeenCalled(); + }); + + it('broadcasts lock.changed on a holder edge (first claim)', async () => { + vi.spyOn(EditLockService.prototype, 'getActiveHolder').mockResolvedValue(undefined); + vi.spyOn(EditLockService.prototype, 'acquire').mockResolvedValue({ + expiresAt: new Date(), + holderId: userId, + lockedByOther: false, + }); + + const caller = agentGroupRouter.createCaller(wsCtx()); + await caller.acquireGroupLock({ id: 'group-1' }); + + expect(publishResourceEventMock).toHaveBeenCalledWith( + { id: 'group-1', type: 'chatGroup' }, + expect.objectContaining({ data: { holderId: userId }, type: 'lock.changed' }), + ); + }); + + it('does NOT broadcast on a steady-state heartbeat (same holder)', async () => { + vi.spyOn(EditLockService.prototype, 'getActiveHolder').mockResolvedValue(userId); + vi.spyOn(EditLockService.prototype, 'acquire').mockResolvedValue({ + expiresAt: new Date(), + holderId: userId, + lockedByOther: false, + }); + + const caller = agentGroupRouter.createCaller(wsCtx()); + await caller.acquireGroupLock({ id: 'group-1' }); + + expect(publishResourceEventMock).not.toHaveBeenCalled(); + }); + }); + + describe('getGroupLock', () => { + it('reports another member as the holder', async () => { + vi.spyOn(EditLockService.prototype, 'getActiveHolder').mockResolvedValue('other-user'); + + const caller = agentGroupRouter.createCaller(wsCtx()); + const result = await caller.getGroupLock({ id: 'group-1' }); + + expect(result).toEqual({ expiresAt: null, holderId: 'other-user', lockedByOther: true }); + }); + + it('returns unlocked for personal groups', async () => { + const caller = agentGroupRouter.createCaller(mockCtx); + const result = await caller.getGroupLock({ id: 'group-1' }); + + expect(result).toEqual({ expiresAt: null, holderId: null, lockedByOther: false }); + }); + }); + + describe('releaseGroupLock', () => { + it('broadcasts unlocked only when it actually freed the lock', async () => { + vi.spyOn(EditLockService.prototype, 'release').mockResolvedValue(true); + + const caller = agentGroupRouter.createCaller(wsCtx()); + await caller.releaseGroupLock({ id: 'group-1' }); + + expect(publishResourceEventMock).toHaveBeenCalledWith( + { id: 'group-1', type: 'chatGroup' }, + expect.objectContaining({ data: { holderId: null }, type: 'lock.changed' }), + ); + }); + + it('does NOT broadcast when the lease expired / was taken over', async () => { + vi.spyOn(EditLockService.prototype, 'release').mockResolvedValue(false); + + const caller = agentGroupRouter.createCaller(wsCtx()); + await caller.releaseGroupLock({ id: 'group-1' }); + + expect(publishResourceEventMock).not.toHaveBeenCalled(); + }); + }); + }); }); diff --git a/apps/server/src/routers/lambda/agent.ts b/apps/server/src/routers/lambda/agent.ts index 72ca47f513..916f938440 100644 --- a/apps/server/src/routers/lambda/agent.ts +++ b/apps/server/src/routers/lambda/agent.ts @@ -17,6 +17,8 @@ import { workspaceMembers } from '@/database/schemas'; import { router } from '@/libs/trpc/lambda'; import { serverDatabase } from '@/libs/trpc/lambda/middleware'; import { AgentService } from '@/server/services/agent'; +import { EditLockService } from '@/server/services/editLock'; +import { publishResourceEvent } from '@/server/services/resourceEvents'; import { TransferErrorCode } from '@/types/transferError'; const agentProcedure = wsCompatProcedure.use(serverDatabase).use(async (opts) => { @@ -28,6 +30,7 @@ const agentProcedure = wsCompatProcedure.use(serverDatabase).use(async (opts) => agentModel: new AgentModel(ctx.serverDB, ctx.userId, wsId), agentService: new AgentService(ctx.serverDB, ctx.userId, wsId), chatGroupModel: new ChatGroupModel(ctx.serverDB, ctx.userId, wsId), + editLockService: new EditLockService(ctx.userId), fileModel: new FileModel(ctx.serverDB, ctx.userId, wsId), knowledgeBaseModel: new KnowledgeBaseModel(ctx.serverDB, ctx.userId, wsId), sessionModel: new SessionModel(ctx.serverDB, ctx.userId, wsId), @@ -440,6 +443,19 @@ export const agentRouter = router({ }), ) .mutation(async ({ input, ctx }) => { + // Collaborative edit lock: reject writes to a workspace agent another + // member is actively editing. Inert until a client acquires the lock. + if (ctx.workspaceId) { + const blockedBy = await ctx.editLockService.getBlockingHolder('agent', input.agentId); + if (blockedBy) { + throw new TRPCError({ + cause: { data: { code: 'DocumentLocked' } }, + code: 'CONFLICT', + message: 'Agent is being edited by another user', + }); + } + } + // Use AgentService to update and return the updated agent data return ctx.agentService.updateAgentConfig(input.agentId, input.value); }), @@ -458,4 +474,48 @@ export const agentRouter = router({ .mutation(async ({ input, ctx }) => { return ctx.agentModel.update(input.id, { pinned: input.pinned }); }), + + acquireAgentLock: agentProcedure + .use(withScopedPermission('agent:update')) + .input(z.object({ agentId: z.string() })) + .mutation(async ({ ctx, input }) => { + if (!ctx.workspaceId) return { expiresAt: null, holderId: null, lockedByOther: false }; + const prev = await ctx.editLockService.getActiveHolder('agent', input.agentId); + const result = await ctx.editLockService.acquire('agent', input.agentId); + if ((result.holderId ?? null) !== (prev ?? null)) { + void publishResourceEvent( + { id: input.agentId, type: 'agent' }, + { actorId: ctx.userId, data: { holderId: result.holderId }, type: 'lock.changed' }, + ); + } + return result; + }), + + getAgentLock: agentProcedure + .use(withScopedPermission('agent:update')) + .input(z.object({ agentId: z.string() })) + .query(async ({ ctx, input }) => { + if (!ctx.workspaceId) return { expiresAt: null, holderId: null, lockedByOther: false }; + const holder = await ctx.editLockService.getActiveHolder('agent', input.agentId); + return { + expiresAt: null, + holderId: holder ?? null, + lockedByOther: Boolean(holder) && holder !== ctx.userId, + }; + }), + + releaseAgentLock: agentProcedure + .use(withScopedPermission('agent:update')) + .input(z.object({ agentId: z.string() })) + .mutation(async ({ ctx, input }) => { + if (!ctx.workspaceId) return; + // Only broadcast "unlocked" when we actually released our own lock — if the + // lease expired and another member took over, the lock is still held. + const released = await ctx.editLockService.release('agent', input.agentId); + if (!released) return; + void publishResourceEvent( + { id: input.agentId, type: 'agent' }, + { actorId: ctx.userId, data: { holderId: null }, type: 'lock.changed' }, + ); + }), }); diff --git a/apps/server/src/routers/lambda/agentGroup.ts b/apps/server/src/routers/lambda/agentGroup.ts index d711e76ef0..8fc51e4638 100644 --- a/apps/server/src/routers/lambda/agentGroup.ts +++ b/apps/server/src/routers/lambda/agentGroup.ts @@ -14,6 +14,8 @@ import { type ChatGroupConfig } from '@/database/types/chatGroup'; import { router } from '@/libs/trpc/lambda'; import { serverDatabase } from '@/libs/trpc/lambda/middleware'; import { AgentGroupService } from '@/server/services/agentGroup'; +import { EditLockService } from '@/server/services/editLock'; +import { publishResourceEvent } from '@/server/services/resourceEvents'; import { TransferErrorCode } from '@/types/transferError'; /** @@ -55,6 +57,7 @@ const agentGroupProcedure = wsCompatProcedure.use(serverDatabase).use(async (opt agentGroupService: new AgentGroupService(ctx.serverDB, ctx.userId, wsId), agentModel: new AgentModel(ctx.serverDB, ctx.userId, wsId), chatGroupModel: new ChatGroupModel(ctx.serverDB, ctx.userId, wsId), + editLockService: new EditLockService(ctx.userId), userModel: new UserModel(ctx.serverDB, ctx.userId), }, }); @@ -402,6 +405,19 @@ export const agentGroupRouter = router({ }), ) .mutation(async ({ input, ctx }) => { + // Collaborative edit lock: reject writes to a workspace group another + // member is actively editing. Inert until a client acquires the lock. + if (ctx.workspaceId) { + const blockedBy = await ctx.editLockService.getBlockingHolder('chatGroup', input.id); + if (blockedBy) { + throw new TRPCError({ + cause: { data: { code: 'DocumentLocked' } }, + code: 'CONFLICT', + message: 'Group is being edited by another user', + }); + } + } + return ctx.chatGroupModel.update(input.id, { ...input.value, config: ctx.agentGroupService.normalizeGroupConfig( @@ -409,6 +425,47 @@ export const agentGroupRouter = router({ ), }); }), + + acquireGroupLock: agentGroupProcedureWrite + .input(z.object({ id: z.string() })) + .mutation(async ({ ctx, input }) => { + if (!ctx.workspaceId) return { expiresAt: null, holderId: null, lockedByOther: false }; + const prev = await ctx.editLockService.getActiveHolder('chatGroup', input.id); + const result = await ctx.editLockService.acquire('chatGroup', input.id); + if ((result.holderId ?? null) !== (prev ?? null)) { + void publishResourceEvent( + { id: input.id, type: 'chatGroup' }, + { actorId: ctx.userId, data: { holderId: result.holderId }, type: 'lock.changed' }, + ); + } + return result; + }), + + getGroupLock: agentGroupProcedureWrite + .input(z.object({ id: z.string() })) + .query(async ({ ctx, input }) => { + if (!ctx.workspaceId) return { expiresAt: null, holderId: null, lockedByOther: false }; + const holder = await ctx.editLockService.getActiveHolder('chatGroup', input.id); + return { + expiresAt: null, + holderId: holder ?? null, + lockedByOther: Boolean(holder) && holder !== ctx.userId, + }; + }), + + releaseGroupLock: agentGroupProcedureWrite + .input(z.object({ id: z.string() })) + .mutation(async ({ ctx, input }) => { + if (!ctx.workspaceId) return; + // Only broadcast "unlocked" when we actually released our own lock — if the + // lease expired and another member took over, the lock is still held. + const released = await ctx.editLockService.release('chatGroup', input.id); + if (!released) return; + void publishResourceEvent( + { id: input.id, type: 'chatGroup' }, + { actorId: ctx.userId, data: { holderId: null }, type: 'lock.changed' }, + ); + }), }); export type AgentGroupRouter = typeof agentGroupRouter; diff --git a/apps/server/src/routers/lambda/document.ts b/apps/server/src/routers/lambda/document.ts index 67fcbf35d7..5c0abda3a4 100644 --- a/apps/server/src/routers/lambda/document.ts +++ b/apps/server/src/routers/lambda/document.ts @@ -253,6 +253,27 @@ export const documentRouter = router({ return ctx.documentService.queryDocuments(input); }), + acquireDocumentLock: documentProcedure + .use(withScopedPermission('document:update')) + .input(z.object({ id: z.string() })) + .mutation(async ({ ctx, input }) => { + return ctx.documentService.acquireDocumentLock(input.id); + }), + + getDocumentLock: documentProcedure + .use(withScopedPermission('document:update')) + .input(z.object({ id: z.string() })) + .query(async ({ ctx, input }) => { + return ctx.documentService.getDocumentLock(input.id); + }), + + releaseDocumentLock: documentProcedure + .use(withScopedPermission('document:update')) + .input(z.object({ id: z.string() })) + .mutation(async ({ ctx, input }) => { + await ctx.documentService.releaseDocumentLock(input.id); + }), + updateDocument: documentProcedure .use(withScopedPermission('document:update')) .input(updateDocumentInputSchema) diff --git a/apps/server/src/routers/lambda/task.ts b/apps/server/src/routers/lambda/task.ts index 5daeb1a1d0..4a98e66cf4 100644 --- a/apps/server/src/routers/lambda/task.ts +++ b/apps/server/src/routers/lambda/task.ts @@ -14,6 +14,8 @@ import { TopicModel } from '@/database/models/topic'; import { workspaceMembers } from '@/database/schemas'; import { router } from '@/libs/trpc/lambda'; import { serverDatabase } from '@/libs/trpc/lambda/middleware'; +import { EditLockService } from '@/server/services/editLock'; +import { publishResourceEvent } from '@/server/services/resourceEvents'; import { TaskService } from '@/server/services/task'; import { TaskLifecycleService } from '@/server/services/taskLifecycle'; import { TaskRunnerService } from '@/server/services/taskRunner'; @@ -26,6 +28,7 @@ const taskProcedure = wsCompatProcedure.use(serverDatabase).use(async (opts) => ctx: { agentModel: new AgentModel(ctx.serverDB, ctx.userId, wsId), briefModel: new BriefModel(ctx.serverDB, ctx.userId, wsId), + editLockService: new EditLockService(ctx.userId), taskLifecycle: new TaskLifecycleService(ctx.serverDB, ctx.userId, wsId), taskModel: new TaskModel(ctx.serverDB, ctx.userId, wsId), taskService: new TaskService(ctx.serverDB, ctx.userId, wsId), @@ -927,6 +930,20 @@ export const taskRouter = router({ const model = ctx.taskModel; await assertAssigneeAgentBelongsToUser(ctx.agentModel, data.assigneeAgentId); const resolved = await resolveOrThrow(model, id); + + // Collaborative edit lock: reject writes to a workspace task another member + // is actively editing. Inert until a client acquires the lock. + if (ctx.workspaceId) { + const blockedBy = await ctx.editLockService.getBlockingHolder('task', resolved.id); + if (blockedBy) { + throw new TRPCError({ + cause: { data: { code: 'DocumentLocked' } }, + code: 'CONFLICT', + message: 'Task is being edited by another user', + }); + } + } + const resolvedParentTaskId = parentTaskId === undefined ? undefined @@ -947,6 +964,44 @@ export const taskRouter = router({ } }), + acquireTaskLock: taskProcedureWrite.input(idInput).mutation(async ({ ctx, input }) => { + if (!ctx.workspaceId) return { expiresAt: null, holderId: null, lockedByOther: false }; + const resolved = await resolveOrThrow(ctx.taskModel, input.id); + const prev = await ctx.editLockService.getActiveHolder('task', resolved.id); + const result = await ctx.editLockService.acquire('task', resolved.id); + if ((result.holderId ?? null) !== (prev ?? null)) { + void publishResourceEvent( + { id: resolved.id, type: 'task' }, + { actorId: ctx.userId, data: { holderId: result.holderId }, type: 'lock.changed' }, + ); + } + return result; + }), + + getTaskLock: taskProcedureWrite.input(idInput).query(async ({ ctx, input }) => { + if (!ctx.workspaceId) return { expiresAt: null, holderId: null, lockedByOther: false }; + const resolved = await resolveOrThrow(ctx.taskModel, input.id); + const holder = await ctx.editLockService.getActiveHolder('task', resolved.id); + return { + expiresAt: null, + holderId: holder ?? null, + lockedByOther: Boolean(holder) && holder !== ctx.userId, + }; + }), + + releaseTaskLock: taskProcedureWrite.input(idInput).mutation(async ({ ctx, input }) => { + if (!ctx.workspaceId) return; + const resolved = await resolveOrThrow(ctx.taskModel, input.id); + // Only broadcast "unlocked" when we actually released our own lock — if the + // lease expired and another member took over, the lock is still held. + const released = await ctx.editLockService.release('task', resolved.id); + if (!released) return; + void publishResourceEvent( + { id: resolved.id, type: 'task' }, + { actorId: ctx.userId, data: { holderId: null }, type: 'lock.changed' }, + ); + }), + updateConfig: taskProcedureWrite .input(idInput.merge(z.object({ config: z.record(z.unknown()) }))) .mutation(async ({ input, ctx }) => { diff --git a/apps/server/src/services/document/__tests__/index.test.ts b/apps/server/src/services/document/__tests__/index.test.ts index d4d9c8519a..d784a44e78 100644 --- a/apps/server/src/services/document/__tests__/index.test.ts +++ b/apps/server/src/services/document/__tests__/index.test.ts @@ -5,14 +5,22 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { DocumentModel } from '@/database/models/document'; import { FileModel } from '@/database/models/file'; +import { EditLockService } from '../../editLock'; import { FileService } from '../../file'; +import { publishResourceEvent } from '../../resourceEvents'; import { DocumentHistoryService } from '../history'; import { DocumentService } from '../index'; +vi.mock('@/server/modules/AgentRuntime/redis', () => ({ getAgentRuntimeRedisClient: () => null })); vi.mock('@/database/models/document'); vi.mock('@/database/models/file'); vi.mock('../../file'); vi.mock('../history'); +// Spy on the realtime broadcast so we can assert lock.changed is published only +// on a genuine state change (holder edge / actual release). +vi.mock('../../resourceEvents', () => ({ publishResourceEvent: vi.fn() })); + +const publishResourceEventMock = vi.mocked(publishResourceEvent); vi.mock('@lobechat/file-loaders', () => ({ loadFile: vi.fn(), UnsupportedFileTypeError: class UnsupportedFileTypeError extends Error { @@ -794,6 +802,168 @@ describe('DocumentService', () => { 'Document not found: missing-doc', ); }); + + it('should reject a workspace save when another member holds the edit lock', async () => { + const wsService = new DocumentService(mockDb, userId, 'ws-1'); + mockDocumentModel.findById.mockResolvedValue(createCurrentDocument({ workspaceId: 'ws-1' })); + vi.spyOn(EditLockService.prototype, 'getBlockingHolder').mockResolvedValue('other-user'); + + await expect(wsService.updateDocument('doc-1', { content: 'x' })).rejects.toMatchObject({ + code: 'CONFLICT', + }); + expect(mockDocumentModel.update).not.toHaveBeenCalled(); + }); + + it('should allow a workspace save when no other member holds the lock', async () => { + const wsService = new DocumentService(mockDb, userId, 'ws-1'); + mockDocumentModel.update.mockResolvedValue({ id: 'doc-1' }); + mockDocumentModel.findById.mockResolvedValue(createCurrentDocument({ workspaceId: 'ws-1' })); + vi.spyOn(EditLockService.prototype, 'getBlockingHolder').mockResolvedValue(null); + + await wsService.updateDocument('doc-1', { content: 'x' }); + + expect(mockDocumentModel.update).toHaveBeenCalled(); + }); + + it('allows a metadata-only save while another member holds the lock (only the body is locked)', async () => { + const wsService = new DocumentService(mockDb, userId, 'ws-1'); + mockDocumentModel.update.mockResolvedValue({ id: 'doc-1' }); + // Current body matches what the autosave re-sends — only title changes. + mockDocumentModel.findById.mockResolvedValue( + createCurrentDocument({ content: 'body', editorData: { blocks: [] }, workspaceId: 'ws-1' }), + ); + const guardSpy = vi.spyOn(EditLockService.prototype, 'getBlockingHolder'); + + await wsService.updateDocument('doc-1', { + content: 'body', + editorData: { blocks: [] }, + title: 'New Title', + }); + + // Content unchanged → the lock guard never runs and the meta save lands. + expect(guardSpy).not.toHaveBeenCalled(); + expect(mockDocumentModel.update).toHaveBeenCalledWith( + 'doc-1', + expect.objectContaining({ title: 'New Title' }), + ); + }); + + it('rejects a body change while locked even when the content string is unchanged', async () => { + const wsService = new DocumentService(mockDb, userId, 'ws-1'); + mockDocumentModel.findById.mockResolvedValue( + createCurrentDocument({ editorData: { blocks: [] }, workspaceId: 'ws-1' }), + ); + vi.spyOn(EditLockService.prototype, 'getBlockingHolder').mockResolvedValue('other-user'); + + // editorData changed (historyAppended) → guard runs even with no `content`. + await expect( + wsService.updateDocument('doc-1', { editorData: { blocks: [{ type: 'paragraph' }] } }), + ).rejects.toMatchObject({ code: 'CONFLICT' }); + expect(mockDocumentModel.update).not.toHaveBeenCalled(); + }); + }); + + describe('document edit lock', () => { + it('reports unlocked for personal documents without touching the lock service', async () => { + const acquireSpy = vi.spyOn(EditLockService.prototype, 'acquire'); + + const result = await service.acquireDocumentLock('doc-1'); + + expect(result).toEqual({ expiresAt: null, holderId: null, lockedByOther: false }); + expect(acquireSpy).not.toHaveBeenCalled(); + }); + + it('delegates to the edit lock service in workspace mode', async () => { + const wsService = new DocumentService(mockDb, userId, 'ws-1'); + const expiresAt = new Date(Date.now() + 60_000); + const acquireSpy = vi + .spyOn(EditLockService.prototype, 'acquire') + .mockResolvedValue({ expiresAt, holderId: userId, lockedByOther: false }); + + const result = await wsService.acquireDocumentLock('doc-1'); + + expect(acquireSpy).toHaveBeenCalledWith('document', 'doc-1'); + expect(result).toEqual({ expiresAt, holderId: userId, lockedByOther: false }); + }); + + it('reports another member as holder when the lock is taken', async () => { + const wsService = new DocumentService(mockDb, userId, 'ws-1'); + const expiresAt = new Date(Date.now() + 60_000); + vi.spyOn(EditLockService.prototype, 'acquire').mockResolvedValue({ + expiresAt, + holderId: 'other-user', + lockedByOther: true, + }); + + const result = await wsService.acquireDocumentLock('doc-1'); + + expect(result).toEqual({ expiresAt, holderId: 'other-user', lockedByOther: true }); + }); + + it('releaseDocumentLock is a no-op for personal documents', async () => { + const releaseSpy = vi.spyOn(EditLockService.prototype, 'release'); + await service.releaseDocumentLock('doc-1'); + expect(releaseSpy).not.toHaveBeenCalled(); + }); + + it('releaseDocumentLock delegates to the lock service in workspace mode', async () => { + const wsService = new DocumentService(mockDb, userId, 'ws-1'); + const releaseSpy = vi.spyOn(EditLockService.prototype, 'release').mockResolvedValue(true); + await wsService.releaseDocumentLock('doc-1'); + expect(releaseSpy).toHaveBeenCalledWith('document', 'doc-1'); + }); + + it('acquireDocumentLock broadcasts lock.changed on a holder edge (first claim)', async () => { + const wsService = new DocumentService(mockDb, userId, 'ws-1'); + vi.spyOn(EditLockService.prototype, 'getActiveHolder').mockResolvedValue(undefined); + vi.spyOn(EditLockService.prototype, 'acquire').mockResolvedValue({ + expiresAt: new Date(), + holderId: userId, + lockedByOther: false, + }); + + await wsService.acquireDocumentLock('doc-1'); + + expect(publishResourceEventMock).toHaveBeenCalledWith( + { id: 'doc-1', type: 'document' }, + expect.objectContaining({ data: { holderId: userId }, type: 'lock.changed' }), + ); + }); + + it('acquireDocumentLock does NOT broadcast on a steady-state heartbeat (same holder)', async () => { + const wsService = new DocumentService(mockDb, userId, 'ws-1'); + vi.spyOn(EditLockService.prototype, 'getActiveHolder').mockResolvedValue(userId); + vi.spyOn(EditLockService.prototype, 'acquire').mockResolvedValue({ + expiresAt: new Date(), + holderId: userId, + lockedByOther: false, + }); + + await wsService.acquireDocumentLock('doc-1'); + + expect(publishResourceEventMock).not.toHaveBeenCalled(); + }); + + it('releaseDocumentLock broadcasts unlocked only when it actually freed the lock', async () => { + const wsService = new DocumentService(mockDb, userId, 'ws-1'); + vi.spyOn(EditLockService.prototype, 'release').mockResolvedValue(true); + + await wsService.releaseDocumentLock('doc-1'); + + expect(publishResourceEventMock).toHaveBeenCalledWith( + { id: 'doc-1', type: 'document' }, + expect.objectContaining({ data: { holderId: null }, type: 'lock.changed' }), + ); + }); + + it('releaseDocumentLock does NOT broadcast when the lease expired / was taken over', async () => { + const wsService = new DocumentService(mockDb, userId, 'ws-1'); + vi.spyOn(EditLockService.prototype, 'release').mockResolvedValue(false); + + await wsService.releaseDocumentLock('doc-1'); + + expect(publishResourceEventMock).not.toHaveBeenCalled(); + }); }); describe('saveDocumentHistory', () => { @@ -837,6 +1007,37 @@ describe('DocumentService', () => { ); expect(mockDocumentHistoryService.createHistory).not.toHaveBeenCalled(); }); + + it('does not check the lock for personal documents', async () => { + mockDocumentModel.findById.mockResolvedValue({ id: 'doc-1', editorData: { blocks: [] } }); + const guardSpy = vi.spyOn(EditLockService.prototype, 'getBlockingHolder'); + + await service.saveDocumentHistory('doc-1', { blocks: [] }, 'llm_call'); + + expect(guardSpy).not.toHaveBeenCalled(); + expect(mockDocumentHistoryService.createHistory).toHaveBeenCalled(); + }); + + it('rejects a workspace history snapshot when another member holds the lock', async () => { + const wsService = new DocumentService(mockDb, userId, 'ws-1'); + mockDocumentModel.findById.mockResolvedValue({ id: 'doc-1', editorData: { blocks: [] } }); + vi.spyOn(EditLockService.prototype, 'getBlockingHolder').mockResolvedValue('other-user'); + + await expect( + wsService.saveDocumentHistory('doc-1', { blocks: [] }, 'llm_call'), + ).rejects.toMatchObject({ code: 'CONFLICT' }); + expect(mockDocumentHistoryService.createHistory).not.toHaveBeenCalled(); + }); + + it('allows a workspace history snapshot when no other member holds the lock', async () => { + const wsService = new DocumentService(mockDb, userId, 'ws-1'); + mockDocumentModel.findById.mockResolvedValue({ id: 'doc-1', editorData: { blocks: [] } }); + vi.spyOn(EditLockService.prototype, 'getBlockingHolder').mockResolvedValue(null); + + await wsService.saveDocumentHistory('doc-1', { blocks: [] }, 'llm_call'); + + expect(mockDocumentHistoryService.createHistory).toHaveBeenCalled(); + }); }); describe('trySaveCurrentDocumentHistory', () => { diff --git a/apps/server/src/services/document/index.ts b/apps/server/src/services/document/index.ts index 35d8c9733e..b23da20200 100644 --- a/apps/server/src/services/document/index.ts +++ b/apps/server/src/services/document/index.ts @@ -15,13 +15,16 @@ import { isValidEditorData } from '@/libs/editor/isValidEditorData'; import { normalizeEditorDataDiffNodes } from '@/libs/editor/normalizeDiffNodes'; import { type LobeDocument } from '@/types/document'; +import { EditLockService } from '../editLock'; import { FileService } from '../file'; +import { publishResourceEvent } from '../resourceEvents'; import { DocumentHistoryService } from './history'; import type { CompareDocumentHistoryItemsParams, CompareDocumentHistoryItemsResult, DocumentHistoryAccessOptions, DocumentHistorySaveSource, + DocumentLockResult, GetDocumentHistoryItemParams, ListDocumentHistoryParams, ListDocumentHistoryResult, @@ -50,6 +53,7 @@ export class DocumentService { private documentModel: DocumentModel; private documentHistoryServiceInstance?: DocumentHistoryService; private fileServiceInstance?: FileService; + private editLockService: EditLockService; private db: LobeChatDatabase; private workspaceId?: string; @@ -60,6 +64,7 @@ export class DocumentService { this.workspaceId = workspaceId; this.fileModel = new FileModel(db, userId, workspaceId); this.documentModel = new DocumentModel(db, userId, workspaceId); + this.editLockService = new EditLockService(userId); } private get fileService() { @@ -202,6 +207,63 @@ export class DocumentService { return this.documentModel.findById(id); } + /** + * Acquire (or refresh) the collaborative edit lock for a workspace document. + * + * Doubles as the heartbeat: an active editor calls this on an interval to keep + * the lease alive, and a locked-out member calls it to take the lock over once + * it frees up. Locking only applies in workspace context — personal documents + * always report as unlocked. + */ + async acquireDocumentLock(id: string): Promise { + if (!this.workspaceId) return { expiresAt: null, holderId: null, lockedByOther: false }; + + const prevHolder = await this.editLockService.getActiveHolder('document', id); + const result = await this.editLockService.acquire('document', id); + + // Broadcast only on a holder edge (first claim / takeover). This method also + // serves the periodic heartbeat, so a steady-state refresh (same holder) + // must not emit an event. + if ((result.holderId ?? null) !== (prevHolder ?? null)) { + void publishResourceEvent( + { id, type: 'document' }, + { actorId: this.userId, data: { holderId: result.holderId }, type: 'lock.changed' }, + ); + } + + return result; + } + + /** + * Read-only peek of the current edit lock (does not acquire). Lets a client + * render a workspace page read-only on open when another member holds it. + */ + async getDocumentLock(id: string): Promise { + if (!this.workspaceId) return { expiresAt: null, holderId: null, lockedByOther: false }; + const holder = await this.editLockService.getActiveHolder('document', id); + return { + expiresAt: null, + holderId: holder ?? null, + lockedByOther: Boolean(holder) && holder !== this.userId, + }; + } + + /** + * Release the edit lock if the current user holds it. No-op in personal mode. + */ + async releaseDocumentLock(id: string): Promise { + if (!this.workspaceId) return; + // Only broadcast "unlocked" when we actually released our own lock — if the + // lease had expired and another member took over, the lock is still held and + // a bogus holderId:null would wrongly flip their viewers to editable. + const released = await this.editLockService.release('document', id); + if (!released) return; + void publishResourceEvent( + { id, type: 'document' }, + { actorId: this.userId, data: { holderId: null }, type: 'lock.changed' }, + ); + } + async listDocumentHistory( params: ListDocumentHistoryParams, options?: DocumentHistoryAccessOptions, @@ -236,6 +298,21 @@ export class DocumentService { throw new Error(`Document not found: ${documentId}`); } + // Same collaborative edit-lock guard as updateDocument: don't record a + // history snapshot for a workspace document another member is editing, so a + // locked-out actor (e.g. a Copilot mutation that will itself be rejected) + // can't pollute the version timeline. + if (this.workspaceId) { + const blockedBy = await this.editLockService.getBlockingHolder('document', documentId); + if (blockedBy) { + throw new TRPCError({ + cause: { data: { code: 'DocumentLocked' } }, + code: 'CONFLICT', + message: 'Document is being edited by another user', + }); + } + } + const normalizedEditorData = normalizeEditorDataDiffNodes(editorData); const savedAt = new Date(); await this.documentHistoryService.createHistory({ @@ -331,7 +408,8 @@ export class DocumentService { * Update document */ async updateDocument(id: string, params: UpdateDocumentParams): Promise { - return this.db.transaction(async (tx) => { + let changed = false; + const result = await this.db.transaction(async (tx) => { const transactionDb = tx as unknown as LobeChatDatabase; const documentModel = new DocumentModel(transactionDb, this.userId, this.workspaceId); const fileModel = new FileModel(transactionDb, this.userId, this.workspaceId); @@ -361,6 +439,26 @@ export class DocumentService { nextEditorDataAccepted !== undefined && !isEqual(nextEditorDataAccepted, currentEditorDataAccepted); + // Collaborative edit lock guard: reject writes to a workspace document that + // another member is actively editing, so concurrent edits can't clobber + // each other. Only the rich-text BODY is locked — metadata-only saves + // (title/emoji) pass through, since the autosave always re-sends the + // unchanged body. The lease auto-expires in Redis; when Redis is down this + // returns null (fail-open) so the lock can't block saving. + const contentChanged = + historyAppended || + (params.content !== undefined && params.content !== currentDocument.content); + if (this.workspaceId && contentChanged) { + const blockedBy = await this.editLockService.getBlockingHolder('document', id); + if (blockedBy) { + throw new TRPCError({ + cause: { data: { code: 'DocumentLocked' } }, + code: 'CONFLICT', + message: 'Document is being edited by another user', + }); + } + } + const updates: Record = {}; if (params.content !== undefined) { @@ -390,6 +488,9 @@ export class DocumentService { updates.parentId = params.parentId; } + // The lock lease is refreshed by the client heartbeat (acquireDocumentLock), + // so a save does not need to touch it. + let savedAt: Date | undefined; if (historyAppended) { @@ -414,12 +515,25 @@ export class DocumentService { await fileModel.update(currentDocument.fileId, fileUpdates); } + changed = Object.keys(updates).length > 0 || historyAppended; + return { historyAppended, id, savedAt, }; }); + + // Notify other workspace members that the document changed so their open + // editor refreshes immediately (best-effort; the heartbeat is the fallback). + if (this.workspaceId && changed) { + void publishResourceEvent( + { id, type: 'document' }, + { actorId: this.userId, type: 'doc.updated' }, + ); + } + + return result; } /** diff --git a/apps/server/src/services/document/types.ts b/apps/server/src/services/document/types.ts index 2b43a61d48..cb87fd74bf 100644 --- a/apps/server/src/services/document/types.ts +++ b/apps/server/src/services/document/types.ts @@ -75,3 +75,12 @@ export interface UpdateDocumentResult { export interface SaveDocumentHistoryResult { savedAt: Date; } + +export interface DocumentLockResult { + /** Lease expiry of the active lock, if any. */ + expiresAt: Date | null; + /** The user id currently holding the lock, or null when unlocked. */ + holderId: string | null; + /** True when another active user holds the lock (caller is locked out). */ + lockedByOther: boolean; +} diff --git a/apps/server/src/services/editLock/__tests__/index.test.ts b/apps/server/src/services/editLock/__tests__/index.test.ts new file mode 100644 index 0000000000..45f73f65a2 --- /dev/null +++ b/apps/server/src/services/editLock/__tests__/index.test.ts @@ -0,0 +1,148 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { EditLockService } from '../index'; + +/** + * Minimal in-memory fake of the ioredis calls EditLockService uses: + * `set(k, v, 'EX', ttl[, 'NX'])`, `get(k)`, and the compare-and-delete `eval`. + */ +const makeFakeRedis = () => { + const store = new Map(); + return { + eval: vi.fn(async (_script: string, _numKeys: number, key: string, arg: string) => { + if (store.get(key) === arg) { + store.delete(key); + return 1; + } + return 0; + }), + get: vi.fn(async (key: string) => store.get(key) ?? null), + set: vi.fn(async (key: string, value: string, ...args: unknown[]) => { + if (args.includes('NX') && store.has(key)) return null; + store.set(key, value); + return 'OK'; + }), + store, + }; +}; + +describe('EditLockService', () => { + it('acquires a free lock and reports the caller as holder', async () => { + const redis = makeFakeRedis(); + const svc = new EditLockService('user-1', redis as any); + + const result = await svc.acquire('document', 'doc-1'); + + expect(result.holderId).toBe('user-1'); + expect(result.lockedByOther).toBe(false); + expect(result.expiresAt).toBeInstanceOf(Date); + expect(redis.store.get('editlock:document:doc-1')).toBe('user-1'); + }); + + it('reports another member as holder when the lock is already taken', async () => { + const redis = makeFakeRedis(); + await new EditLockService('user-1', redis as any).acquire('document', 'doc-1'); + + const result = await new EditLockService('user-2', redis as any).acquire('document', 'doc-1'); + + expect(result).toEqual({ expiresAt: null, holderId: 'user-1', lockedByOther: true }); + }); + + it('lets the holder refresh their own lease', async () => { + const redis = makeFakeRedis(); + const svc = new EditLockService('user-1', redis as any); + await svc.acquire('document', 'doc-1'); + + const result = await svc.acquire('document', 'doc-1'); + + expect(result.holderId).toBe('user-1'); + expect(result.lockedByOther).toBe(false); + }); + + it('getActiveHolder reports the current holder, or undefined when free', async () => { + const redis = makeFakeRedis(); + expect( + await new EditLockService('user-1', redis as any).getActiveHolder('document', 'doc-1'), + ).toBeUndefined(); + + await new EditLockService('user-1', redis as any).acquire('document', 'doc-1'); + expect( + await new EditLockService('user-2', redis as any).getActiveHolder('document', 'doc-1'), + ).toBe('user-1'); + }); + + it('keys locks per resource type, so the same id does not collide across types', async () => { + const redis = makeFakeRedis(); + await new EditLockService('user-1', redis as any).acquire('document', 'shared-id'); + + // A different resource family with the same id is independently lockable. + const result = await new EditLockService('user-2', redis as any).acquire('agent', 'shared-id'); + + expect(result.holderId).toBe('user-2'); + expect(result.lockedByOther).toBe(false); + expect(redis.store.get('editlock:document:shared-id')).toBe('user-1'); + expect(redis.store.get('editlock:agent:shared-id')).toBe('user-2'); + }); + + it('getBlockingHolder returns the holder only when it is someone else', async () => { + const redis = makeFakeRedis(); + await new EditLockService('user-1', redis as any).acquire('document', 'doc-1'); + + expect( + await new EditLockService('user-2', redis as any).getBlockingHolder('document', 'doc-1'), + ).toBe('user-1'); + expect( + await new EditLockService('user-1', redis as any).getBlockingHolder('document', 'doc-1'), + ).toBeNull(); + }); + + it('only releases the lock for the current holder', async () => { + const redis = makeFakeRedis(); + await new EditLockService('user-1', redis as any).acquire('document', 'doc-1'); + + // A non-holder release is a no-op and reports it did not release. + expect(await new EditLockService('user-2', redis as any).release('document', 'doc-1')).toBe( + false, + ); + expect(redis.store.get('editlock:document:doc-1')).toBe('user-1'); + + // The holder can release, and reports the lock was actually freed. + expect(await new EditLockService('user-1', redis as any).release('document', 'doc-1')).toBe( + true, + ); + expect(redis.store.has('editlock:document:doc-1')).toBe(false); + }); + + it('degrades to unlocked / no-op when Redis is unavailable', async () => { + const svc = new EditLockService('user-1', null); + + expect(await svc.acquire('document', 'doc-1')).toEqual({ + expiresAt: null, + holderId: null, + lockedByOther: false, + }); + expect(await svc.getBlockingHolder('document', 'doc-1')).toBeNull(); + await expect(svc.release('document', 'doc-1')).resolves.toBe(false); + }); + + it('fails open when Redis is configured but commands reject (unreachable)', async () => { + // ioredis is non-null but every command rejects after retries — the write + // guards must not turn this into a 500; treat the resource as unlocked. + const down = new Error('Connection is closed.'); + const redis = { + eval: vi.fn().mockRejectedValue(down), + get: vi.fn().mockRejectedValue(down), + set: vi.fn().mockRejectedValue(down), + }; + const svc = new EditLockService('user-1', redis as any); + + expect(await svc.acquire('document', 'doc-1')).toEqual({ + expiresAt: null, + holderId: null, + lockedByOther: false, + }); + expect(await svc.getActiveHolder('document', 'doc-1')).toBeUndefined(); + expect(await svc.getBlockingHolder('document', 'doc-1')).toBeNull(); + await expect(svc.release('document', 'doc-1')).resolves.toBe(false); + }); +}); diff --git a/apps/server/src/services/editLock/index.ts b/apps/server/src/services/editLock/index.ts new file mode 100644 index 0000000000..e0af1a3fea --- /dev/null +++ b/apps/server/src/services/editLock/index.ts @@ -0,0 +1,153 @@ +import debug from 'debug'; +import type { Redis } from 'ioredis'; + +import { getAgentRuntimeRedisClient } from '@/server/modules/AgentRuntime/redis'; + +const log = debug('lobe-server:edit-lock'); + +/** Lease lifetime in seconds; clients heartbeat well within this to keep it alive. */ +export const EDIT_LOCK_TTL_SECONDS = 30; + +/** Editable resource families that can take a collaborative edit lock. */ +export type EditLockResourceType = 'agent' | 'chatGroup' | 'document' | 'task'; + +export interface EditLockResult { + /** Lease expiry of the active lock, if the caller now holds it. */ + expiresAt: Date | null; + /** The user id currently holding the lock, or null when unlocked. */ + holderId: string | null; + /** True when another user holds the lock (caller is locked out). */ + lockedByOther: boolean; +} + +const UNLOCKED: EditLockResult = { expiresAt: null, holderId: null, lockedByOther: false }; + +const lockKey = (type: EditLockResourceType, id: string) => `editlock:${type}:${id}`; + +// Release only if the caller still holds the lock (compare-and-delete), so a +// stale releaser can't drop a lease another member has since taken over. +const RELEASE_SCRIPT = ` +if redis.call('get', KEYS[1]) == ARGV[1] then + return redis.call('del', KEYS[1]) +end +return 0 +`; + +/** + * Redis-backed collaborative edit lock, keyed by (resourceType, resourceId). + * + * Intentionally a thin, table-agnostic lease: there is no DB schema, so it + * applies uniformly to any editable resource (documents, briefs, …) and can be + * removed wholesale once real-time co-editing lands — the keys simply expire. + * + * The lock is advisory: when Redis is unavailable every method degrades to + * "unlocked" so the lock infrastructure can never block editing or saving. + */ +export class EditLockService { + private userId: string; + private explicitRedis: Redis | null | undefined; + private lazyRedis: Redis | null = null; + private lazyResolved = false; + + constructor(userId: string, redis?: Redis | null) { + this.userId = userId; + this.explicitRedis = redis; + } + + /** + * The Redis client, resolved lazily on first use. Resolving eagerly in the + * constructor would read server-only env (`getAgentRuntimeRedisClient`) the + * moment any owning service is built — which throws in client/test contexts + * that construct the service but never take a lock. + */ + private get redis(): Redis | null { + if (this.explicitRedis !== undefined) return this.explicitRedis; + if (!this.lazyResolved) { + this.lazyRedis = getAgentRuntimeRedisClient(); + this.lazyResolved = true; + } + return this.lazyRedis; + } + + /** + * Acquire the lock when it is free (or already mine), refreshing the lease; + * otherwise report whoever currently holds it. Doubles as the heartbeat. + */ + async acquire(type: EditLockResourceType, id: string): Promise { + const redis = this.redis; + if (!redis) return UNLOCKED; + const key = lockKey(type, id); + + try { + // Claim only when the key is absent (NX). The TTL gives automatic expiry, so + // a hard-closed tab frees the lock without any cleanup job. + const claimed = await redis.set(key, this.userId, 'EX', EDIT_LOCK_TTL_SECONDS, 'NX'); + if (claimed) return this.held(); + + const holder = await redis.get(key); + if (holder === this.userId) { + // Already mine — refresh the lease (heartbeat). + await redis.set(key, this.userId, 'EX', EDIT_LOCK_TTL_SECONDS); + return this.held(); + } + if (holder) return { expiresAt: null, holderId: holder, lockedByOther: true }; + + // Freed between the NX and the GET — try once more. + const reclaimed = await redis.set(key, this.userId, 'EX', EDIT_LOCK_TTL_SECONDS, 'NX'); + return reclaimed ? this.held() : UNLOCKED; + } catch (error) { + // Fail-open: a Redis outage (configured but unreachable) must never block + // editing — report unlocked rather than surfacing the command rejection. + log('acquire failed for %s:%s %O', type, id, error); + return UNLOCKED; + } + } + + /** Current holder of the lock, or undefined when unlocked / Redis is down. */ + async getActiveHolder(type: EditLockResourceType, id: string): Promise { + const redis = this.redis; + if (!redis) return undefined; + try { + const holder = await redis.get(lockKey(type, id)); + return holder ?? undefined; + } catch (error) { + // Fail-open: a Redis outage must not turn the write guards into 500s. + log('getActiveHolder failed for %s:%s %O', type, id, error); + return undefined; + } + } + + /** + * The holder when someone *other* than the caller holds the lock, else null. + * Used by write guards; returns null when Redis is down (fail-open). + */ + async getBlockingHolder(type: EditLockResourceType, id: string): Promise { + const holder = await this.getActiveHolder(type, id); + return holder && holder !== this.userId ? holder : null; + } + + /** + * Release the lock, but only if the caller still holds it (compare-and-delete). + * Returns true only when the caller's lock was actually deleted — false when + * the lease had already expired or another member has since taken it over, so + * callers can avoid broadcasting a bogus "unlocked" event. + */ + async release(type: EditLockResourceType, id: string): Promise { + if (!this.redis) return false; + try { + const deleted = await this.redis.eval(RELEASE_SCRIPT, 1, lockKey(type, id), this.userId); + return deleted === 1; + } catch (error) { + log('release failed for %s:%s %O', type, id, error); + return false; + } + } + + private held(): EditLockResult { + return { + expiresAt: new Date(Date.now() + EDIT_LOCK_TTL_SECONDS * 1000), + holderId: this.userId, + lockedByOther: false, + }; + } +} diff --git a/apps/server/src/services/resourceEvents/__tests__/index.test.ts b/apps/server/src/services/resourceEvents/__tests__/index.test.ts new file mode 100644 index 0000000000..ba940b908c --- /dev/null +++ b/apps/server/src/services/resourceEvents/__tests__/index.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest'; + +import { publishResourceEvent, resourceChannelId } from '../index'; + +describe('resourceEvents', () => { + it('formats a stable channel id per resource', () => { + expect(resourceChannelId({ id: 'doc-1', type: 'document' })).toBe('resource:document:doc-1'); + }); + + it('publish is best-effort and never throws (no Redis → in-memory)', async () => { + await expect( + publishResourceEvent( + { id: 'doc-1', type: 'document' }, + { actorId: 'u1', type: 'doc.updated' }, + ), + ).resolves.toBeUndefined(); + + await expect( + publishResourceEvent( + { id: 'doc-1', type: 'document' }, + { actorId: 'u1', data: { holderId: null }, type: 'lock.changed' }, + ), + ).resolves.toBeUndefined(); + }); +}); diff --git a/apps/server/src/services/resourceEvents/index.ts b/apps/server/src/services/resourceEvents/index.ts new file mode 100644 index 0000000000..308826d060 --- /dev/null +++ b/apps/server/src/services/resourceEvents/index.ts @@ -0,0 +1,82 @@ +import debug from 'debug'; + +// Import the transport pieces from their concrete modules rather than the +// `@/server/modules/AgentRuntime` barrel: the barrel re-exports RuntimeExecutors, +// which eagerly constructs the ModelRuntime ApiKeyManager at module load and +// throws in client/test contexts. These leaf modules pull no ModelRuntime. +import { inMemoryStreamEventManager } from '@/server/modules/AgentRuntime/InMemoryStreamEventManager'; +import { getAgentRuntimeRedisClient } from '@/server/modules/AgentRuntime/redis'; +import { StreamEventManager } from '@/server/modules/AgentRuntime/StreamEventManager'; +import type { IStreamEventManager } from '@/server/modules/AgentRuntime/types'; + +import type { ReceivedResourceEvent, ResourceEvent, ResourceRef } from './types'; + +export type { ReceivedResourceEvent, ResourceEvent, ResourceRef, ResourceType } from './types'; + +const log = debug('lobe-server:resource-events'); + +/** Redis Stream / in-memory channel key for a resource. */ +export const resourceChannelId = (ref: ResourceRef): string => `resource:${ref.type}:${ref.id}`; + +/** + * Select the underlying transport. We deliberately bypass + * `createStreamEventManager()` — its `GatewayStreamNotifier` wrapper POSTs every + * published event to the agent gateway, which must not see resource events. + * Evaluated per call so it picks up Redis becoming (un)available. + */ +const getManager = (): IStreamEventManager => + getAgentRuntimeRedisClient() !== null ? new StreamEventManager() : inMemoryStreamEventManager; + +/** + * Realtime event fan-out for editable resources, keyed by (resourceType, id). + * + * A thin, table-agnostic wrapper over the existing Redis-Streams transport so + * presence and (eventually) real-time co-editing can reuse the same channel. + * The lease/lock is advisory and this channel is best-effort: publishing never + * throws, and with no Redis the in-memory manager keeps single-instance dev + * working while clients fall back to their polling heartbeat. + */ +export const publishResourceEvent = async ( + ref: ResourceRef, + event: ResourceEvent, +): Promise => { + try { + await getManager().publishStreamEvent(resourceChannelId(ref), { + // The agent StreamEvent shape (stepIndex + closed `type` union) is an + // implementation detail of the transport; cast at this single boundary. + data: { actorId: event.actorId, ...event.data }, + stepIndex: 0, + type: event.type, + } as unknown as Parameters[1]); + } catch (error) { + // Best-effort: a transport hiccup must never break the caller's save/lock op. + log('publishResourceEvent failed for %s:%s %O', ref.type, ref.id, error); + } +}; + +/** + * Subscribe to a resource's events until `signal` aborts. Only events published + * after subscription are delivered (no history replay). + */ +export const subscribeResourceEvents = async ( + ref: ResourceRef, + onEvent: (event: ReceivedResourceEvent) => void, + signal: AbortSignal, +): Promise => { + await getManager().subscribeStreamEvents( + resourceChannelId(ref), + '$', + (events) => { + for (const e of events) { + const { actorId, ...rest } = (e.data ?? {}) as Record; + onEvent({ + actorId: typeof actorId === 'string' ? actorId : '', + data: rest, + timestamp: e.timestamp, + type: e.type as unknown as ResourceEvent['type'], + }); + } + }, + signal, + ); +}; diff --git a/apps/server/src/services/resourceEvents/types.ts b/apps/server/src/services/resourceEvents/types.ts new file mode 100644 index 0000000000..605c976781 --- /dev/null +++ b/apps/server/src/services/resourceEvents/types.ts @@ -0,0 +1,21 @@ +/** Editable resource families that can broadcast realtime events. */ +export type ResourceType = 'agent' | 'chatGroup' | 'document' | 'task'; + +export interface ResourceRef { + id: string; + type: ResourceType; +} + +export type ResourceEventType = 'doc.updated' | 'lock.changed'; + +export interface ResourceEvent { + /** User id that triggered the event; lets subscribers ignore self-originated events. */ + actorId: string; + /** Event-specific payload (e.g. `{ holderId }` for `lock.changed`). */ + data?: Record; + type: ResourceEventType; +} + +export interface ReceivedResourceEvent extends ResourceEvent { + timestamp: number; +} diff --git a/apps/server/src/services/task/index.ts b/apps/server/src/services/task/index.ts index d552b841a7..931cb699fe 100644 --- a/apps/server/src/services/task/index.ts +++ b/apps/server/src/services/task/index.ts @@ -706,6 +706,7 @@ export class TaskService { activities: activities.length > 0 ? activities : undefined, topicCount: topics.length > 0 ? topics.length : undefined, workspace: workspaceFolders.length > 0 ? workspaceFolders : undefined, + workspaceId: task.workspaceId ?? null, }; } diff --git a/locales/ar/chat.json b/locales/ar/chat.json index ca3f7e8e2f..2d3f5e1dc0 100644 --- a/locales/ar/chat.json +++ b/locales/ar/chat.json @@ -370,6 +370,14 @@ "noMatchingAgents": "لم يتم العثور على أعضاء مطابقين", "noMembersYet": "لا تحتوي هذه المجموعة على أي أعضاء بعد. انقر على زر + لدعوة وكلاء.", "noSelectedAgents": "لم يتم تحديد أي أعضاء بعد", + "opStatusTray.cost": "التكلفة", + "opStatusTray.status.compressing": "ضغط السياق", + "opStatusTray.status.generating": "جاري التوليد", + "opStatusTray.status.reasoning": "التفكير", + "opStatusTray.status.searching": "جاري البحث", + "opStatusTray.status.toolCalling": "استدعاء الأدوات", + "opStatusTray.steps": "الخطوات", + "opStatusTray.tokens": "الرموز", "openInNewWindow": "فتح في نافذة جديدة", "operation.contextCompression": "السياق طويل جدًا، يتم ضغط السجل...", "operation.execAgentRuntime": "جارٍ تحضير الرد", diff --git a/locales/ar/device.json b/locales/ar/device.json index 7632002217..eaa3a2ef7f 100644 --- a/locales/ar/device.json +++ b/locales/ar/device.json @@ -17,6 +17,10 @@ "workingDirectory.createBranchAction": "تبديل إلى فرع جديد…", "workingDirectory.createBranchTitle": "إنشاء فرع جديد", "workingDirectory.current": "دليل العمل الحالي", + "workingDirectory.deleteBranchAction": "حذف الفرع", + "workingDirectory.deleteBranchConfirm": "هل تريد حذف الفرع “{{name}}”؟ سيؤدي ذلك إلى إزالته نهائيًا، بما في ذلك أي عمليات دمج غير مكتملة.", + "workingDirectory.deleteBranchTitle": "حذف الفرع", + "workingDirectory.deleteFailed": "فشل الحذف", "workingDirectory.detachedHead": "رأس منفصل عند {{sha}}", "workingDirectory.diffStatTooltip": "تمت الإضافة {{added}} · تم التعديل {{modified}} · تم الحذف {{deleted}}", "workingDirectory.filesAdded": "تمت الإضافة", @@ -46,6 +50,9 @@ "workingDirectory.recent": "حديث", "workingDirectory.refreshGitStatus": "تحديث حالة الفرع وطلبات السحب", "workingDirectory.removeRecent": "إزالة من الحديث", + "workingDirectory.renameBranchAction": "إعادة تسمية الفرع", + "workingDirectory.renameBranchTitle": "إعادة تسمية الفرع", + "workingDirectory.renameFailed": "فشل إعادة التسمية", "workingDirectory.selectFolder": "اختر مجلدًا", "workingDirectory.title": "دليل العمل", "workingDirectory.topicDescription": "تجاوز الإعداد الافتراضي للوكيل لهذه المحادثة فقط", diff --git a/locales/ar/file.json b/locales/ar/file.json index 59887614cf..b7e37eaacc 100644 --- a/locales/ar/file.json +++ b/locales/ar/file.json @@ -94,6 +94,9 @@ "pageEditor.deleteSuccess": "تم حذف الصفحة بنجاح", "pageEditor.duplicateError": "فشل في تكرار الصفحة", "pageEditor.duplicateSuccess": "تم تكرار الصفحة بنجاح", + "pageEditor.editMode.checking": "جارٍ التحقق من توفر التعديل…", + "pageEditor.editMode.lockedByOther": "{{name}} يقوم بتعديل هذا المستند", + "pageEditor.editMode.lockedBySomeone": "شخص آخر يقوم بتعديل هذا المستند", "pageEditor.editedAt": "آخر تعديل في {{time}}", "pageEditor.editedBy": "آخر تعديل بواسطة {{name}}", "pageEditor.editorPlaceholder": "اضغط \"/\" للوصول إلى الذكاء الاصطناعي والأوامر", @@ -131,6 +134,8 @@ "pageEditor.history.versionCount_one": "نسخة واحدة {{count}}", "pageEditor.history.versionCount_other": "{{count}} نسخ", "pageEditor.linkCopied": "تم نسخ الرابط", + "pageEditor.lock.editingByOther": "{{name}} يقوم بتعديل هذه الصفحة. لا يمكن حفظ تغييراتك الآن.", + "pageEditor.lock.editingBySomeone": "شخص آخر يقوم بتعديل هذه الصفحة. لا يمكن حفظ تغييراتك الآن.", "pageEditor.menu.copyLink": "نسخ الرابط", "pageEditor.menu.export": "تصدير", "pageEditor.menu.export.markdown": "Markdown", diff --git a/locales/ar/modelProvider.json b/locales/ar/modelProvider.json index 89e12a8e78..496e8e60fe 100644 --- a/locales/ar/modelProvider.json +++ b/locales/ar/modelProvider.json @@ -307,6 +307,8 @@ "providerModels.list.enabledActions.sort": "ترتيب النماذج المخصصة", "providerModels.list.enabledEmpty": "لا توجد نماذج مفعلة. يرجى تفعيل النماذج المفضلة من القائمة أدناه~", "providerModels.list.fetcher.clear": "مسح النماذج المسحوبة", + "providerModels.list.fetcher.error": "فشل في جلب النماذج: {{message}}", + "providerModels.list.fetcher.errorFallback": "خطأ غير معروف", "providerModels.list.fetcher.fetch": "جلب النماذج", "providerModels.list.fetcher.fetching": "جارٍ جلب قائمة النماذج...", "providerModels.list.fetcher.latestTime": "آخر تحديث: {{time}}", diff --git a/locales/ar/modelRuntime.json b/locales/ar/modelRuntime.json index 16208ce1e5..f151b17972 100644 --- a/locales/ar/modelRuntime.json +++ b/locales/ar/modelRuntime.json @@ -35,6 +35,7 @@ "QuotaLimitReached": "عذرًا، تم الوصول إلى الحد الأقصى لاستخدام الرموز أو عدد الطلبات لهذه المفتاح. يرجى زيادة حصة المفتاح أو إعادة المحاولة لاحقًا.", "RateLimitExceeded": "عذرًا، تم الوصول إلى الحد الأقصى لاستخدام الرموز أو عدد الطلبات لهذه المفتاح. يرجى إعادة المحاولة لاحقًا أو زيادة حصة المفتاح.", "StateStorePersistError": "تسبب مشكلة مؤقتة في تخزين حالة المحادثة في تعطيل هذه العملية. يرجى إعادة المحاولة؛ إذا استمرت المشكلة، يرجى الاتصال بالدعم.", + "StateStoreReadError": "تعذر استئناف هذه العملية لأن حالة الجلسة غير متوفرة. يرجى إعادة فتح المحادثة للمتابعة؛ وإذا استمرت المشكلة، يرجى الاتصال بالدعم.", "StreamChunkError": "حدث خطأ أثناء تحليل جزء الرسالة من الطلب المتدفق. يرجى التحقق مما إذا كانت واجهة API الحالية تتوافق مع المواصفات القياسية، أو الاتصال بمزود API للحصول على المساعدة.", "UpstreamGatewayError": "عاد البوابة أو الوكيل المصدر بخطأ. يرجى إعادة المحاولة قريبًا؛ إذا استمرت المشكلة، تحقق من إعدادات الوكيل / نقطة النهاية.", "UpstreamHttpError": "عاد المزود بخطأ HTTP دون تفاصيل إضافية. يرجى إعادة المحاولة، أو التحقق من الطلب وإعدادات النموذج.", diff --git a/locales/ar/opStatusTray.json b/locales/ar/opStatusTray.json new file mode 100644 index 0000000000..d29c1e5258 --- /dev/null +++ b/locales/ar/opStatusTray.json @@ -0,0 +1,34 @@ +{ + "generatingPhrases": [ + "يعمل", + "يُصمم", + "يفكر", + "يحسب", + "يُخمّر", + "يُركّب", + "يُحلل", + "يُهندس", + "يؤلف", + "يُنسق", + "يرسم", + "يُبدع", + "يتأمل", + "يصنع", + "يُشعل", + "يُغلي ببطء", + "يُدور", + "يُسيطر", + "يُلمع", + "يُجهز الإجابة", + "يخبز", + "يُوجه", + "يُدمج", + "يُفك الشيفرة", + "يُصنع", + "يُوائم", + "يُرتجل", + "يستنتج", + "يُجرب", + "يتعرج" + ] +} diff --git a/locales/ar/setting.json b/locales/ar/setting.json index 4457d0e66a..16bf149a76 100644 --- a/locales/ar/setting.json +++ b/locales/ar/setting.json @@ -1186,6 +1186,9 @@ "tools.klavis.notEnabled": "خدمة Klavis غير مفعلة", "tools.klavis.oauthRequired": "يرجى إكمال التحقق من OAuth في النافذة الجديدة", "tools.klavis.pendingAuth": "في انتظار التحقق", + "tools.klavis.remove": "إزالة", + "tools.klavis.removeConfirm.desc": "سيتم إزالة {{name}} نهائيًا من خدماتك المتصلة. لا يمكن التراجع عن هذا الإجراء.", + "tools.klavis.removeConfirm.title": "إزالة {{name}}؟", "tools.klavis.serverCreated": "تم إنشاء الخادم بنجاح", "tools.klavis.serverCreatedFailed": "فشل في إنشاء الخادم", "tools.klavis.serverRemoved": "تمت إزالة الخادم", diff --git a/locales/ar/topic.json b/locales/ar/topic.json index 21a9156ed5..581ffbe4f7 100644 --- a/locales/ar/topic.json +++ b/locales/ar/topic.json @@ -135,6 +135,12 @@ "management.view.card": "بطاقة", "management.view.list": "قائمة", "newTopic": "موضوع جديد", + "projectStatus.failed_one": "{{count}} موضوع فشل", + "projectStatus.failed_other": "{{count}} مواضيع فشلت", + "projectStatus.loading_one": "{{count}} موضوع قيد التحميل", + "projectStatus.loading_other": "{{count}} مواضيع قيد التحميل", + "projectStatus.waitingForHuman_one": "{{count}} موضوع ينتظر الإدخال", + "projectStatus.waitingForHuman_other": "{{count}} مواضيع تنتظر الإدخال", "renameModal.description": "يُفضَّل أن يكون قصيرًا وسهل التعرّف.", "renameModal.title": "إعادة تسمية الموضوع", "searchPlaceholder": "ابحث في المواضيع...", diff --git a/locales/bg-BG/chat.json b/locales/bg-BG/chat.json index 371fe50b20..38722bf2a9 100644 --- a/locales/bg-BG/chat.json +++ b/locales/bg-BG/chat.json @@ -370,6 +370,14 @@ "noMatchingAgents": "Няма съвпадащи членове", "noMembersYet": "Тази група все още няма членове. Натиснете бутона +, за да поканите агенти.", "noSelectedAgents": "Все още няма избрани членове", + "opStatusTray.cost": "разход", + "opStatusTray.status.compressing": "Компресиране на контекста", + "opStatusTray.status.generating": "Генериране", + "opStatusTray.status.reasoning": "Разсъждаване", + "opStatusTray.status.searching": "Търсене", + "opStatusTray.status.toolCalling": "Извикване на инструменти", + "opStatusTray.steps": "стъпки", + "opStatusTray.tokens": "токени", "openInNewWindow": "Отвори в нов прозорец", "operation.contextCompression": "Контекстът е твърде дълъг, компресиране на историята...", "operation.execAgentRuntime": "Подготвяне на отговор", diff --git a/locales/bg-BG/device.json b/locales/bg-BG/device.json index f291037dd9..0935d3291c 100644 --- a/locales/bg-BG/device.json +++ b/locales/bg-BG/device.json @@ -17,6 +17,10 @@ "workingDirectory.createBranchAction": "Превключване към нов клон…", "workingDirectory.createBranchTitle": "Създаване на нов клон", "workingDirectory.current": "Текуща работна директория", + "workingDirectory.deleteBranchAction": "Изтриване на клон", + "workingDirectory.deleteBranchConfirm": "Да изтрия ли клона „{{name}}“? Това ще го премахне окончателно, включително всички несляти комити.", + "workingDirectory.deleteBranchTitle": "Изтриване на клон", + "workingDirectory.deleteFailed": "Неуспешно изтриване", "workingDirectory.detachedHead": "Отделен HEAD на {{sha}}", "workingDirectory.diffStatTooltip": "Добавени {{added}} · Модифицирани {{modified}} · Изтрити {{deleted}}", "workingDirectory.filesAdded": "Добавени", @@ -46,6 +50,9 @@ "workingDirectory.recent": "Скорошни", "workingDirectory.refreshGitStatus": "Обновяване на статус на клона и PR", "workingDirectory.removeRecent": "Премахване от скорошни", + "workingDirectory.renameBranchAction": "Преименуване на клон", + "workingDirectory.renameBranchTitle": "Преименуване на клон", + "workingDirectory.renameFailed": "Неуспешно преименуване", "workingDirectory.selectFolder": "Изберете папка", "workingDirectory.title": "Работна директория", "workingDirectory.topicDescription": "Замяна на настройката по подразбиране на агента само за този разговор", diff --git a/locales/bg-BG/file.json b/locales/bg-BG/file.json index e489a00fff..5dfaafb0a3 100644 --- a/locales/bg-BG/file.json +++ b/locales/bg-BG/file.json @@ -94,6 +94,9 @@ "pageEditor.deleteSuccess": "Страницата е изтрита успешно", "pageEditor.duplicateError": "Неуспешно дублиране на страницата", "pageEditor.duplicateSuccess": "Страницата е дублирана успешно", + "pageEditor.editMode.checking": "Проверка на наличността за редактиране…", + "pageEditor.editMode.lockedByOther": "{{name}} редактира този документ", + "pageEditor.editMode.lockedBySomeone": "Някой друг редактира този документ", "pageEditor.editedAt": "Последна редакция на {{time}}", "pageEditor.editedBy": "Последна редакция от {{name}}", "pageEditor.editorPlaceholder": "Натиснете \"/\" за ИИ и команди", @@ -131,6 +134,8 @@ "pageEditor.history.versionCount_one": "{{count}} версия", "pageEditor.history.versionCount_other": "{{count}} версии", "pageEditor.linkCopied": "Връзката е копирана", + "pageEditor.lock.editingByOther": "{{name}} редактира тази страница. Вашите промени не могат да бъдат запазени в момента.", + "pageEditor.lock.editingBySomeone": "Някой друг редактира тази страница. Вашите промени не могат да бъдат запазени в момента.", "pageEditor.menu.copyLink": "Копирай връзка", "pageEditor.menu.export": "Експортирай", "pageEditor.menu.export.markdown": "Markdown", diff --git a/locales/bg-BG/modelProvider.json b/locales/bg-BG/modelProvider.json index 8091b7b481..7c7d1248dd 100644 --- a/locales/bg-BG/modelProvider.json +++ b/locales/bg-BG/modelProvider.json @@ -307,6 +307,8 @@ "providerModels.list.enabledActions.sort": "Сортиране на персонализирани модели", "providerModels.list.enabledEmpty": "Няма активирани модели. Моля, активирайте предпочитаните модели от списъка по-долу~", "providerModels.list.fetcher.clear": "Изчисти изтеглените модели", + "providerModels.list.fetcher.error": "Неуспешно извличане на модели: {{message}}", + "providerModels.list.fetcher.errorFallback": "Неизвестна грешка", "providerModels.list.fetcher.fetch": "Изтегли модели", "providerModels.list.fetcher.fetching": "Изтегляне на списък с модели...", "providerModels.list.fetcher.latestTime": "Последна актуализация: {{time}}", diff --git a/locales/bg-BG/modelRuntime.json b/locales/bg-BG/modelRuntime.json index 1bd0898988..f5e0bcc73d 100644 --- a/locales/bg-BG/modelRuntime.json +++ b/locales/bg-BG/modelRuntime.json @@ -35,6 +35,7 @@ "QuotaLimitReached": "Съжаляваме, използването на токени или броят на заявките достигна лимита на квотата за този ключ. Моля, увеличете квотата на ключа или опитайте отново по-късно.", "RateLimitExceeded": "Съжаляваме, използването на токени или броят на заявките достигна лимита на скоростта за този ключ. Моля, опитайте отново по-късно или увеличете квотата на ключа.", "StateStorePersistError": "Временен проблем с хранилището на състоянието на разговора прекъсна тази операция. Моля, опитайте отново; ако проблемът продължи, свържете се с поддръжката.", + "StateStoreReadError": "Тази операция не може да бъде възобновена, защото състоянието на сесията не е налично. Моля, отворете разговора отново, за да продължите; ако проблемът продължава, свържете се с поддръжката.", "StreamChunkError": "Грешка при анализиране на част от съобщението в заявката за стрийминг. Моля, проверете дали текущият API интерфейс отговаря на стандартните спецификации или се свържете с вашия доставчик на API за помощ.", "UpstreamGatewayError": "Горният шлюз или прокси върна грешка. Моля, опитайте отново след малко; ако проблемът продължи, проверете конфигурацията на вашето прокси/крайна точка.", "UpstreamHttpError": "Доставчикът върна HTTP грешка без допълнителни подробности. Моля, опитайте отново или проверете вашата заявка и конфигурацията на модела.", diff --git a/locales/bg-BG/opStatusTray.json b/locales/bg-BG/opStatusTray.json new file mode 100644 index 0000000000..d8ff431310 --- /dev/null +++ b/locales/bg-BG/opStatusTray.json @@ -0,0 +1,34 @@ +{ + "generatingPhrases": [ + "Работя", + "Скицирам", + "Мисля", + "Изчислявам", + "Приготвям", + "Синтезирам", + "Смятам", + "Проектирам", + "Съставям", + "Оркестрирам", + "Рисувам", + "Импровизирам", + "Размишлявам", + "Създавам", + "Фламбирам", + "Задушавам", + "Въртя", + "Овладявам", + "Полиране", + "Подготвям отговора", + "Пека", + "Канализирам", + "Обединявам", + "Разшифровам", + "Изковавам", + "Хармонизирам", + "Импровизирам", + "Извеждам", + "Пипам", + "Зигзагообразно" + ] +} diff --git a/locales/bg-BG/setting.json b/locales/bg-BG/setting.json index a4dd0788a6..1b622f427e 100644 --- a/locales/bg-BG/setting.json +++ b/locales/bg-BG/setting.json @@ -1186,6 +1186,9 @@ "tools.klavis.notEnabled": "Услугата Klavis не е активирана", "tools.klavis.oauthRequired": "Моля, завършете OAuth удостоверяването в нов прозорец", "tools.klavis.pendingAuth": "Изчаква удостоверяване", + "tools.klavis.remove": "Премахни", + "tools.klavis.removeConfirm.desc": "{{name}} ще бъде окончателно премахнат от вашите свързани услуги. Това действие не може да бъде отменено.", + "tools.klavis.removeConfirm.title": "Премахване на {{name}}?", "tools.klavis.serverCreated": "Сървърът е създаден успешно", "tools.klavis.serverCreatedFailed": "Неуспешно създаване на сървър", "tools.klavis.serverRemoved": "Сървърът е премахнат", diff --git a/locales/bg-BG/topic.json b/locales/bg-BG/topic.json index 1cddc93943..97a1bbc77b 100644 --- a/locales/bg-BG/topic.json +++ b/locales/bg-BG/topic.json @@ -135,6 +135,12 @@ "management.view.card": "Карти", "management.view.list": "Списък", "newTopic": "Нова тема", + "projectStatus.failed_one": "{{count}} неуспешна тема", + "projectStatus.failed_other": "{{count}} неуспешни теми", + "projectStatus.loading_one": "{{count}} зареждаща се тема", + "projectStatus.loading_other": "{{count}} зареждащи се теми", + "projectStatus.waitingForHuman_one": "{{count}} тема, очакваща въвеждане", + "projectStatus.waitingForHuman_other": "{{count}} теми, очакващи въвеждане", "renameModal.description": "Поддържайте го кратко и лесно за разпознаване.", "renameModal.title": "Преименуване на тема", "searchPlaceholder": "Търсене в темите...", diff --git a/locales/de-DE/chat.json b/locales/de-DE/chat.json index b39aeac9b0..08e768bf76 100644 --- a/locales/de-DE/chat.json +++ b/locales/de-DE/chat.json @@ -370,6 +370,14 @@ "noMatchingAgents": "Keine passenden Mitglieder gefunden", "noMembersYet": "Diese Gruppe hat noch keine Mitglieder. Klicke auf +, um Agenten einzuladen.", "noSelectedAgents": "Noch keine Mitglieder ausgewählt", + "opStatusTray.cost": "Kosten", + "opStatusTray.status.compressing": "Kontext komprimieren", + "opStatusTray.status.generating": "Generieren", + "opStatusTray.status.reasoning": "Nachdenken", + "opStatusTray.status.searching": "Suchen", + "opStatusTray.status.toolCalling": "Werkzeuge aufrufen", + "opStatusTray.steps": "Schritte", + "opStatusTray.tokens": "Token", "openInNewWindow": "In neuem Fenster öffnen", "operation.contextCompression": "Kontext zu lang, komprimiere Verlauf...", "operation.execAgentRuntime": "Antwort wird vorbereitet", diff --git a/locales/de-DE/device.json b/locales/de-DE/device.json index a2d91d3306..5c5c048dd2 100644 --- a/locales/de-DE/device.json +++ b/locales/de-DE/device.json @@ -17,6 +17,10 @@ "workingDirectory.createBranchAction": "Neuen Zweig auschecken…", "workingDirectory.createBranchTitle": "Neuen Zweig erstellen", "workingDirectory.current": "Aktuelles Arbeitsverzeichnis", + "workingDirectory.deleteBranchAction": "Branch löschen", + "workingDirectory.deleteBranchConfirm": "Branch „{{name}}“ löschen? Dies entfernt ihn dauerhaft, einschließlich aller nicht zusammengeführten Commits.", + "workingDirectory.deleteBranchTitle": "Branch löschen", + "workingDirectory.deleteFailed": "Löschen fehlgeschlagen", "workingDirectory.detachedHead": "Losgelöster HEAD bei {{sha}}", "workingDirectory.diffStatTooltip": "Hinzugefügt {{added}} · Geändert {{modified}} · Gelöscht {{deleted}}", "workingDirectory.filesAdded": "Hinzugefügt", @@ -46,6 +50,9 @@ "workingDirectory.recent": "Kürzlich", "workingDirectory.refreshGitStatus": "Zweig- und PR-Status aktualisieren", "workingDirectory.removeRecent": "Aus Kürzlich entfernen", + "workingDirectory.renameBranchAction": "Branch umbenennen", + "workingDirectory.renameBranchTitle": "Branch umbenennen", + "workingDirectory.renameFailed": "Umbenennen fehlgeschlagen", "workingDirectory.selectFolder": "Ordner auswählen", "workingDirectory.title": "Arbeitsverzeichnis", "workingDirectory.topicDescription": "Überschreibt den Standard-Agenten nur für diese Unterhaltung", diff --git a/locales/de-DE/file.json b/locales/de-DE/file.json index b2148fb3c8..6601923f1d 100644 --- a/locales/de-DE/file.json +++ b/locales/de-DE/file.json @@ -94,6 +94,9 @@ "pageEditor.deleteSuccess": "Seite erfolgreich gelöscht", "pageEditor.duplicateError": "Fehler beim Duplizieren der Seite", "pageEditor.duplicateSuccess": "Seite erfolgreich dupliziert", + "pageEditor.editMode.checking": "Bearbeitsverfügbarkeit wird überprüft…", + "pageEditor.editMode.lockedByOther": "{{name}} bearbeitet dieses Dokument", + "pageEditor.editMode.lockedBySomeone": "Jemand anderes bearbeitet dieses Dokument", "pageEditor.editedAt": "Zuletzt bearbeitet am {{time}}", "pageEditor.editedBy": "Zuletzt bearbeitet von {{name}}", "pageEditor.editorPlaceholder": "Drücken Sie \"/\" für KI und Befehle", @@ -131,6 +134,8 @@ "pageEditor.history.versionCount_one": "{{count}} Version", "pageEditor.history.versionCount_other": "{{count}} Versionen", "pageEditor.linkCopied": "Link kopiert", + "pageEditor.lock.editingByOther": "{{name}} bearbeitet diese Seite. Ihre Änderungen können momentan nicht gespeichert werden.", + "pageEditor.lock.editingBySomeone": "Jemand anderes bearbeitet diese Seite. Ihre Änderungen können momentan nicht gespeichert werden.", "pageEditor.menu.copyLink": "Link kopieren", "pageEditor.menu.export": "Exportieren", "pageEditor.menu.export.markdown": "Markdown", diff --git a/locales/de-DE/modelProvider.json b/locales/de-DE/modelProvider.json index b1a42fb5d1..4edfdf53e7 100644 --- a/locales/de-DE/modelProvider.json +++ b/locales/de-DE/modelProvider.json @@ -307,6 +307,8 @@ "providerModels.list.enabledActions.sort": "Benutzerdefinierte Modellsortierung", "providerModels.list.enabledEmpty": "Keine aktivierten Modelle verfügbar. Bitte aktivieren Sie Ihre bevorzugten Modelle aus der Liste unten~", "providerModels.list.fetcher.clear": "Abgerufene Modelle löschen", + "providerModels.list.fetcher.error": "Fehler beim Abrufen der Modelle: {{message}}", + "providerModels.list.fetcher.errorFallback": "Unbekannter Fehler", "providerModels.list.fetcher.fetch": "Modelle abrufen", "providerModels.list.fetcher.fetching": "Modellliste wird abgerufen...", "providerModels.list.fetcher.latestTime": "Zuletzt aktualisiert: {{time}}", diff --git a/locales/de-DE/modelRuntime.json b/locales/de-DE/modelRuntime.json index 12abbe09c3..748c9a8410 100644 --- a/locales/de-DE/modelRuntime.json +++ b/locales/de-DE/modelRuntime.json @@ -35,6 +35,7 @@ "QuotaLimitReached": "Entschuldigung, die Token-Nutzung oder die Anzahl der Anfragen hat das Kontingentlimit für diesen Schlüssel erreicht. Bitte erhöhen Sie das Kontingent des Schlüssels oder versuchen Sie es später erneut.", "RateLimitExceeded": "Entschuldigung, die Token-Nutzung oder die Anzahl der Anfragen hat das Ratenlimit für diesen Schlüssel erreicht. Bitte versuchen Sie es später erneut oder erhöhen Sie das Kontingent des Schlüssels.", "StateStorePersistError": "Ein vorübergehendes Problem mit dem Konversationsstatusspeicher hat diesen Vorgang unterbrochen. Bitte versuchen Sie es erneut; falls das Problem weiterhin besteht, wenden Sie sich an den Support.", + "StateStoreReadError": "Dieser Vorgang konnte nicht fortgesetzt werden, da der Sitzungsstatus nicht verfügbar war. Bitte öffnen Sie das Gespräch erneut, um fortzufahren; wenn das Problem weiterhin besteht, wenden Sie sich an den Support.", "StreamChunkError": "Fehler beim Parsen des Nachrichtenchunks der Streaming-Anfrage. Bitte überprüfen Sie, ob die aktuelle API-Schnittstelle den Standardspezifikationen entspricht, oder wenden Sie sich an Ihren API-Anbieter, um Unterstützung zu erhalten.", "UpstreamGatewayError": "Das Upstream-Gateway oder der Proxy hat einen Fehler zurückgegeben. Bitte versuchen Sie es in Kürze erneut; falls das Problem weiterhin besteht, überprüfen Sie Ihre Proxy-/Endpunktkonfiguration.", "UpstreamHttpError": "Der Anbieter hat einen HTTP-Fehler ohne weitere Details zurückgegeben. Bitte versuchen Sie es erneut oder überprüfen Sie Ihre Anfrage- und Modellkonfiguration.", diff --git a/locales/de-DE/opStatusTray.json b/locales/de-DE/opStatusTray.json new file mode 100644 index 0000000000..75170a07ee --- /dev/null +++ b/locales/de-DE/opStatusTray.json @@ -0,0 +1,34 @@ +{ + "generatingPhrases": [ + "Arbeiten", + "Entwerfen", + "Nachdenken", + "Berechnen", + "Brauen", + "Synthesieren", + "Knacken", + "Gestalten", + "Komponieren", + "Orchestrieren", + "Skizzieren", + "Herumprobieren", + "Überlegen", + "Gestalten", + "Flambieren", + "Simmern", + "Surren", + "Zähmen", + "Polieren", + "Antwort vorbereiten", + "Backen", + "Kanalisieren", + "Verschmelzen", + "Entschlüsseln", + "Schmieden", + "Harmonisieren", + "Improvisieren", + "Schlussfolgern", + "Tüfteln", + "Zickzack bewegen" + ] +} diff --git a/locales/de-DE/setting.json b/locales/de-DE/setting.json index dd110e4402..7fde7dbb40 100644 --- a/locales/de-DE/setting.json +++ b/locales/de-DE/setting.json @@ -1186,6 +1186,9 @@ "tools.klavis.notEnabled": "Klavis-Dienst nicht aktiviert", "tools.klavis.oauthRequired": "Bitte schließen Sie die OAuth-Authentifizierung im neuen Fenster ab", "tools.klavis.pendingAuth": "Authentifizierung ausstehend", + "tools.klavis.remove": "Entfernen", + "tools.klavis.removeConfirm.desc": "{{name}} wird dauerhaft aus Ihren verbundenen Diensten entfernt. Diese Aktion kann nicht rückgängig gemacht werden.", + "tools.klavis.removeConfirm.title": "{{name}} entfernen?", "tools.klavis.serverCreated": "Server erfolgreich erstellt", "tools.klavis.serverCreatedFailed": "Servererstellung fehlgeschlagen", "tools.klavis.serverRemoved": "Server entfernt", diff --git a/locales/de-DE/topic.json b/locales/de-DE/topic.json index 5426b81f2b..6034f46fd2 100644 --- a/locales/de-DE/topic.json +++ b/locales/de-DE/topic.json @@ -135,6 +135,12 @@ "management.view.card": "Karte", "management.view.list": "Liste", "newTopic": "Neues Thema", + "projectStatus.failed_one": "{{count}} fehlgeschlagenes Thema", + "projectStatus.failed_other": "{{count}} fehlgeschlagene Themen", + "projectStatus.loading_one": "{{count}} ladendes Thema", + "projectStatus.loading_other": "{{count}} ladende Themen", + "projectStatus.waitingForHuman_one": "{{count}} Thema wartet auf Eingabe", + "projectStatus.waitingForHuman_other": "{{count}} Themen warten auf Eingabe", "renameModal.description": "Kurz und leicht erkennbar halten.", "renameModal.title": "Thema umbenennen", "searchPlaceholder": "Themen suchen...", diff --git a/locales/en-US/chat.json b/locales/en-US/chat.json index 13e267bbbb..2f2237525f 100644 --- a/locales/en-US/chat.json +++ b/locales/en-US/chat.json @@ -370,12 +370,12 @@ "noMatchingAgents": "No matching members found", "noMembersYet": "This group doesn't have any members yet. Click the + button to invite agents.", "noSelectedAgents": "No members selected yet", + "opStatusTray.cost": "cost", "opStatusTray.status.compressing": "Compressing context", "opStatusTray.status.generating": "Generating", "opStatusTray.status.reasoning": "Thinking", "opStatusTray.status.searching": "Searching", "opStatusTray.status.toolCalling": "Calling tools", - "opStatusTray.cost": "cost", "opStatusTray.steps": "steps", "opStatusTray.tokens": "tokens", "openInNewWindow": "Open in New Window", diff --git a/locales/en-US/file.json b/locales/en-US/file.json index 607a9fb0e4..5e62e5542a 100644 --- a/locales/en-US/file.json +++ b/locales/en-US/file.json @@ -94,6 +94,9 @@ "pageEditor.deleteSuccess": "Page deleted successfully", "pageEditor.duplicateError": "Failed to duplicate the page", "pageEditor.duplicateSuccess": "Page duplicated successfully", + "pageEditor.editMode.checking": "Checking edit availability…", + "pageEditor.editMode.lockedByOther": "{{name}} is editing this document", + "pageEditor.editMode.lockedBySomeone": "Someone else is editing this document", "pageEditor.editedAt": "Last edited on {{time}}", "pageEditor.editedBy": "Last edited by {{name}}", "pageEditor.editorPlaceholder": "Press \"/\" for AI and commands.", @@ -131,6 +134,8 @@ "pageEditor.history.versionCount_one": "{{count}} version", "pageEditor.history.versionCount_other": "{{count}} versions", "pageEditor.linkCopied": "Link copied", + "pageEditor.lock.editingByOther": "{{name}} is editing this page. Your changes can’t be saved right now.", + "pageEditor.lock.editingBySomeone": "Someone else is editing this page. Your changes can’t be saved right now.", "pageEditor.menu.copyLink": "Copy Link", "pageEditor.menu.export": "Export", "pageEditor.menu.export.markdown": "Markdown", diff --git a/locales/en-US/modelRuntime.json b/locales/en-US/modelRuntime.json index acc7a20a84..fd87033f97 100644 --- a/locales/en-US/modelRuntime.json +++ b/locales/en-US/modelRuntime.json @@ -35,6 +35,7 @@ "QuotaLimitReached": "Sorry, the token usage or request count has reached the quota limit for this key. Please increase the key's quota or try again later.", "RateLimitExceeded": "Sorry, the token usage or request count has reached the rate limit for this key. Please try again later or increase the key's quota.", "StateStorePersistError": "A temporary issue with the conversation state store interrupted this operation. Please try again; if it persists, contact support.", + "StateStoreReadError": "This operation could not be resumed because its session state was unavailable. Please reopen the conversation to continue; if it persists, contact support.", "StreamChunkError": "Error parsing the message chunk of the streaming request. Please check if the current API interface complies with the standard specifications, or contact your API provider for assistance.", "UpstreamGatewayError": "The upstream gateway or proxy returned an error. Please try again shortly; if it persists, check your proxy / endpoint configuration.", "UpstreamHttpError": "The provider returned an HTTP error without further detail. Please try again, or check your request and model configuration.", diff --git a/locales/en-US/setting.json b/locales/en-US/setting.json index 44871923e8..7d41e58b68 100644 --- a/locales/en-US/setting.json +++ b/locales/en-US/setting.json @@ -1179,9 +1179,6 @@ "tools.klavis.disconnect": "Disconnect", "tools.klavis.disconnected": "Disconnected", "tools.klavis.error": "Error", - "tools.klavis.remove": "Remove", - "tools.klavis.removeConfirm.desc": "{{name}} will be permanently removed from your connected services. This action cannot be undone.", - "tools.klavis.removeConfirm.title": "Remove {{name}}?", "tools.klavis.groupName": "Klavis Tools", "tools.klavis.manage": "Manage Klavis", "tools.klavis.manageTitle": "Manage Klavis Integration", @@ -1189,6 +1186,9 @@ "tools.klavis.notEnabled": "Klavis service not enabled", "tools.klavis.oauthRequired": "Please complete OAuth authentication in the new window", "tools.klavis.pendingAuth": "Pending Authentication", + "tools.klavis.remove": "Remove", + "tools.klavis.removeConfirm.desc": "{{name}} will be permanently removed from your connected services. This action cannot be undone.", + "tools.klavis.removeConfirm.title": "Remove {{name}}?", "tools.klavis.serverCreated": "Server created successfully", "tools.klavis.serverCreatedFailed": "Failed to create server", "tools.klavis.serverRemoved": "Server removed", diff --git a/locales/es-ES/chat.json b/locales/es-ES/chat.json index fd9560007f..32bd898ab0 100644 --- a/locales/es-ES/chat.json +++ b/locales/es-ES/chat.json @@ -370,6 +370,14 @@ "noMatchingAgents": "No se encontraron miembros coincidentes", "noMembersYet": "Este grupo aún no tiene miembros. Haz clic en el botón + para invitar agentes.", "noSelectedAgents": "Aún no se han seleccionado miembros", + "opStatusTray.cost": "costo", + "opStatusTray.status.compressing": "Comprimiendo contexto", + "opStatusTray.status.generating": "Generando", + "opStatusTray.status.reasoning": "Pensando", + "opStatusTray.status.searching": "Buscando", + "opStatusTray.status.toolCalling": "Llamando herramientas", + "opStatusTray.steps": "pasos", + "opStatusTray.tokens": "fichas", "openInNewWindow": "Abrir en una nueva ventana", "operation.contextCompression": "Contexto demasiado largo, comprimiendo historial...", "operation.execAgentRuntime": "Preparando respuesta", diff --git a/locales/es-ES/device.json b/locales/es-ES/device.json index 7be875ccca..55e0d7fcb6 100644 --- a/locales/es-ES/device.json +++ b/locales/es-ES/device.json @@ -17,6 +17,10 @@ "workingDirectory.createBranchAction": "Cambiar a nueva rama…", "workingDirectory.createBranchTitle": "Crear nueva rama", "workingDirectory.current": "Directorio de trabajo actual", + "workingDirectory.deleteBranchAction": "Eliminar rama", + "workingDirectory.deleteBranchConfirm": "¿Eliminar la rama “{{name}}”? Esto la eliminará permanentemente, incluyendo cualquier commit no fusionado.", + "workingDirectory.deleteBranchTitle": "Eliminar rama", + "workingDirectory.deleteFailed": "Error al eliminar", "workingDirectory.detachedHead": "HEAD separado en {{sha}}", "workingDirectory.diffStatTooltip": "Añadido {{added}} · Modificado {{modified}} · Eliminado {{deleted}}", "workingDirectory.filesAdded": "Añadido", @@ -46,6 +50,9 @@ "workingDirectory.recent": "Recientes", "workingDirectory.refreshGitStatus": "Actualizar estado de rama y PR", "workingDirectory.removeRecent": "Eliminar de recientes", + "workingDirectory.renameBranchAction": "Renombrar rama", + "workingDirectory.renameBranchTitle": "Renombrar rama", + "workingDirectory.renameFailed": "Error al renombrar", "workingDirectory.selectFolder": "Seleccionar carpeta", "workingDirectory.title": "Directorio de Trabajo", "workingDirectory.topicDescription": "Sobrescribir el valor predeterminado del Agente solo para esta conversación", diff --git a/locales/es-ES/file.json b/locales/es-ES/file.json index 8dbde0e47a..8b1244e493 100644 --- a/locales/es-ES/file.json +++ b/locales/es-ES/file.json @@ -94,6 +94,9 @@ "pageEditor.deleteSuccess": "Página eliminada correctamente", "pageEditor.duplicateError": "Error al duplicar la página", "pageEditor.duplicateSuccess": "Página duplicada correctamente", + "pageEditor.editMode.checking": "Comprobando la disponibilidad de edición…", + "pageEditor.editMode.lockedByOther": "{{name}} está editando este documento", + "pageEditor.editMode.lockedBySomeone": "Alguien más está editando este documento", "pageEditor.editedAt": "Última edición el {{time}}", "pageEditor.editedBy": "Última edición por {{name}}", "pageEditor.editorPlaceholder": "Presiona \"/\" para IA y comandos", @@ -131,6 +134,8 @@ "pageEditor.history.versionCount_one": "{{count}} versión", "pageEditor.history.versionCount_other": "{{count}} versiones", "pageEditor.linkCopied": "Enlace copiado", + "pageEditor.lock.editingByOther": "{{name}} está editando esta página. Tus cambios no se pueden guardar en este momento.", + "pageEditor.lock.editingBySomeone": "Alguien más está editando esta página. Tus cambios no se pueden guardar en este momento.", "pageEditor.menu.copyLink": "Copiar enlace", "pageEditor.menu.export": "Exportar", "pageEditor.menu.export.markdown": "Markdown", diff --git a/locales/es-ES/modelProvider.json b/locales/es-ES/modelProvider.json index 2a711a694e..76f5e0e443 100644 --- a/locales/es-ES/modelProvider.json +++ b/locales/es-ES/modelProvider.json @@ -307,6 +307,8 @@ "providerModels.list.enabledActions.sort": "Orden personalizado de modelos", "providerModels.list.enabledEmpty": "No hay modelos habilitados disponibles. Habilita tus modelos preferidos de la lista a continuación~", "providerModels.list.fetcher.clear": "Borrar modelos obtenidos", + "providerModels.list.fetcher.error": "Error al obtener los modelos: {{message}}", + "providerModels.list.fetcher.errorFallback": "Error desconocido", "providerModels.list.fetcher.fetch": "Obtener modelos", "providerModels.list.fetcher.fetching": "Obteniendo lista de modelos...", "providerModels.list.fetcher.latestTime": "Última actualización: {{time}}", diff --git a/locales/es-ES/modelRuntime.json b/locales/es-ES/modelRuntime.json index 82574dff4d..92fed68fe0 100644 --- a/locales/es-ES/modelRuntime.json +++ b/locales/es-ES/modelRuntime.json @@ -35,6 +35,7 @@ "QuotaLimitReached": "Lo sentimos, el uso de tokens o el número de solicitudes ha alcanzado el límite de cuota para esta clave. Por favor, aumenta la cuota de la clave o vuelve a intentarlo más tarde.", "RateLimitExceeded": "Lo sentimos, el uso de tokens o el número de solicitudes ha alcanzado el límite de velocidad para esta clave. Por favor, vuelve a intentarlo más tarde o aumenta la cuota de la clave.", "StateStorePersistError": "Un problema temporal con el almacenamiento del estado de la conversación interrumpió esta operación. Por favor, vuelve a intentarlo; si el problema persiste, contacta al soporte.", + "StateStoreReadError": "Esta operación no se pudo reanudar porque el estado de su sesión no estaba disponible. Por favor, vuelva a abrir la conversación para continuar; si el problema persiste, contacte con el soporte.", "StreamChunkError": "Error al analizar el fragmento de mensaje de la solicitud de transmisión. Por favor, verifica si la interfaz API actual cumple con las especificaciones estándar, o contacta a tu proveedor de API para obtener ayuda.", "UpstreamGatewayError": "El gateway o proxy del proveedor devolvió un error. Por favor, vuelve a intentarlo en breve; si el problema persiste, verifica tu configuración de proxy/punto final.", "UpstreamHttpError": "El proveedor devolvió un error HTTP sin más detalles. Por favor, vuelve a intentarlo, o verifica tu solicitud y configuración del modelo.", diff --git a/locales/es-ES/opStatusTray.json b/locales/es-ES/opStatusTray.json new file mode 100644 index 0000000000..f0f9e53199 --- /dev/null +++ b/locales/es-ES/opStatusTray.json @@ -0,0 +1,34 @@ +{ + "generatingPhrases": [ + "Trabajando", + "Redactando", + "Pensando", + "Calculando", + "Preparando", + "Sintetizando", + "Procesando", + "Arquitectando", + "Componiendo", + "Orquestando", + "Esbozando", + "Improvisando", + "Reflexionando", + "Elaborando", + "Flambeando", + "Cocinando a fuego lento", + "Zumbando", + "Domando", + "Puliendo", + "Preparando la respuesta", + "Horneando", + "Canalizando", + "Fusionando", + "Descifrando", + "Forjando", + "Armonizando", + "Improvisando", + "Deduciendo", + "Trasteando", + "Zigzagueando" + ] +} diff --git a/locales/es-ES/setting.json b/locales/es-ES/setting.json index 629b925912..663bf048cc 100644 --- a/locales/es-ES/setting.json +++ b/locales/es-ES/setting.json @@ -1186,6 +1186,9 @@ "tools.klavis.notEnabled": "Servicio Klavis no habilitado", "tools.klavis.oauthRequired": "Por favor, completa la autenticación OAuth en la nueva ventana", "tools.klavis.pendingAuth": "Autenticación Pendiente", + "tools.klavis.remove": "Eliminar", + "tools.klavis.removeConfirm.desc": "{{name}} se eliminará permanentemente de tus servicios conectados. Esta acción no se puede deshacer.", + "tools.klavis.removeConfirm.title": "¿Eliminar {{name}}?", "tools.klavis.serverCreated": "Servidor creado con éxito", "tools.klavis.serverCreatedFailed": "Error al crear el servidor", "tools.klavis.serverRemoved": "Servidor eliminado", diff --git a/locales/es-ES/topic.json b/locales/es-ES/topic.json index 592d953bc2..5ef76f9760 100644 --- a/locales/es-ES/topic.json +++ b/locales/es-ES/topic.json @@ -135,6 +135,12 @@ "management.view.card": "Tarjeta", "management.view.list": "Lista", "newTopic": "Nuevo tema", + "projectStatus.failed_one": "{{count}} tema fallido", + "projectStatus.failed_other": "{{count}} temas fallidos", + "projectStatus.loading_one": "{{count}} tema cargando", + "projectStatus.loading_other": "{{count}} temas cargando", + "projectStatus.waitingForHuman_one": "{{count}} tema esperando entrada", + "projectStatus.waitingForHuman_other": "{{count}} temas esperando entrada", "renameModal.description": "Mantenlo breve y fácil de reconocer.", "renameModal.title": "Renombrar tema", "searchPlaceholder": "Buscar temas...", diff --git a/locales/fa-IR/chat.json b/locales/fa-IR/chat.json index a6d81344e3..19a25193be 100644 --- a/locales/fa-IR/chat.json +++ b/locales/fa-IR/chat.json @@ -370,6 +370,14 @@ "noMatchingAgents": "هیچ عضوی مطابق یافت نشد", "noMembersYet": "این گروه هنوز عضوی ندارد. برای دعوت نماینده‌ها روی دکمه + کلیک کنید.", "noSelectedAgents": "هنوز عضوی انتخاب نشده است", + "opStatusTray.cost": "هزینه", + "opStatusTray.status.compressing": "فشرده‌سازی محتوا", + "opStatusTray.status.generating": "تولید", + "opStatusTray.status.reasoning": "تفکر", + "opStatusTray.status.searching": "جستجو", + "opStatusTray.status.toolCalling": "فراخوانی ابزارها", + "opStatusTray.steps": "مراحل", + "opStatusTray.tokens": "توکن‌ها", "openInNewWindow": "باز کردن در پنجره جدید", "operation.contextCompression": "متن بیش از حد طولانی است، در حال فشرده‌سازی تاریخچه...", "operation.execAgentRuntime": "در حال آماده‌سازی پاسخ", diff --git a/locales/fa-IR/device.json b/locales/fa-IR/device.json index d2a81d7117..f521e3affa 100644 --- a/locales/fa-IR/device.json +++ b/locales/fa-IR/device.json @@ -17,6 +17,10 @@ "workingDirectory.createBranchAction": "ایجاد شاخه جدید…", "workingDirectory.createBranchTitle": "ایجاد شاخه جدید", "workingDirectory.current": "دایرکتوری کاری فعلی", + "workingDirectory.deleteBranchAction": "حذف شاخه", + "workingDirectory.deleteBranchConfirm": "آیا می‌خواهید شاخه «{{name}}» را حذف کنید؟ این عمل به طور دائمی آن را حذف می‌کند، شامل هرگونه کامیت ادغام‌نشده.", + "workingDirectory.deleteBranchTitle": "حذف شاخه", + "workingDirectory.deleteFailed": "حذف ناموفق بود", "workingDirectory.detachedHead": "HEAD جدا شده در {{sha}}", "workingDirectory.diffStatTooltip": "افزوده شده {{added}} · تغییر یافته {{modified}} · حذف شده {{deleted}}", "workingDirectory.filesAdded": "افزوده شده", @@ -46,6 +50,9 @@ "workingDirectory.recent": "اخیر", "workingDirectory.refreshGitStatus": "وضعیت شاخه و درخواست‌ها را تازه‌سازی کنید", "workingDirectory.removeRecent": "حذف از موارد اخیر", + "workingDirectory.renameBranchAction": "تغییر نام شاخه", + "workingDirectory.renameBranchTitle": "تغییر نام شاخه", + "workingDirectory.renameFailed": "تغییر نام ناموفق بود", "workingDirectory.selectFolder": "انتخاب پوشه", "workingDirectory.title": "دایرکتوری کاری", "workingDirectory.topicDescription": "جایگزینی پیش‌فرض عامل فقط برای این مکالمه", diff --git a/locales/fa-IR/file.json b/locales/fa-IR/file.json index e437a2a96f..f9ea0e3288 100644 --- a/locales/fa-IR/file.json +++ b/locales/fa-IR/file.json @@ -94,6 +94,9 @@ "pageEditor.deleteSuccess": "صفحه با موفقیت حذف شد", "pageEditor.duplicateError": "تکثیر صفحه ناموفق بود", "pageEditor.duplicateSuccess": "صفحه با موفقیت تکثیر شد", + "pageEditor.editMode.checking": "در حال بررسی امکان ویرایش...", + "pageEditor.editMode.lockedByOther": "{{name}} در حال ویرایش این سند است", + "pageEditor.editMode.lockedBySomeone": "شخص دیگری در حال ویرایش این سند است", "pageEditor.editedAt": "آخرین ویرایش در {{time}}", "pageEditor.editedBy": "آخرین ویرایش توسط {{name}}", "pageEditor.editorPlaceholder": "برای هوش مصنوعی و دستورات \"/\" را فشار دهید", @@ -131,6 +134,8 @@ "pageEditor.history.versionCount_one": "{{count}} نسخه", "pageEditor.history.versionCount_other": "{{count}} نسخه", "pageEditor.linkCopied": "لینک کپی شد", + "pageEditor.lock.editingByOther": "{{name}} در حال ویرایش این صفحه است. تغییرات شما در حال حاضر ذخیره نمی‌شود.", + "pageEditor.lock.editingBySomeone": "شخص دیگری در حال ویرایش این صفحه است. تغییرات شما در حال حاضر ذخیره نمی‌شود.", "pageEditor.menu.copyLink": "کپی لینک", "pageEditor.menu.export": "صادرات", "pageEditor.menu.export.markdown": "Markdown", diff --git a/locales/fa-IR/modelProvider.json b/locales/fa-IR/modelProvider.json index e0394a4f35..e278b86cbb 100644 --- a/locales/fa-IR/modelProvider.json +++ b/locales/fa-IR/modelProvider.json @@ -307,6 +307,8 @@ "providerModels.list.enabledActions.sort": "مرتب‌سازی مدل‌های سفارشی", "providerModels.list.enabledEmpty": "هیچ مدل فعالی در دسترس نیست. لطفاً مدل‌های دلخواه خود را از لیست زیر فعال کنید~", "providerModels.list.fetcher.clear": "پاک‌سازی مدل‌های دریافت‌شده", + "providerModels.list.fetcher.error": "دریافت مدل‌ها با شکست مواجه شد: {{message}}", + "providerModels.list.fetcher.errorFallback": "خطای ناشناخته", "providerModels.list.fetcher.fetch": "دریافت مدل‌ها", "providerModels.list.fetcher.fetching": "در حال دریافت لیست مدل‌ها...", "providerModels.list.fetcher.latestTime": "آخرین به‌روزرسانی: {{time}}", diff --git a/locales/fa-IR/modelRuntime.json b/locales/fa-IR/modelRuntime.json index 997d2abd8c..dd7bcef0a4 100644 --- a/locales/fa-IR/modelRuntime.json +++ b/locales/fa-IR/modelRuntime.json @@ -35,6 +35,7 @@ "QuotaLimitReached": "متأسفیم، استفاده از توکن یا تعداد درخواست‌ها به حد سهمیه این کلید رسیده است. لطفاً سهمیه کلید را افزایش داده یا بعداً دوباره تلاش کنید.", "RateLimitExceeded": "متأسفیم، استفاده از توکن یا تعداد درخواست‌ها به حد نرخ این کلید رسیده است. لطفاً بعداً دوباره تلاش کنید یا سهمیه کلید را افزایش دهید.", "StateStorePersistError": "یک مشکل موقت در ذخیره‌سازی وضعیت مکالمه این عملیات را مختل کرد. لطفاً دوباره تلاش کنید؛ اگر مشکل ادامه داشت، با پشتیبانی تماس بگیرید.", + "StateStoreReadError": "این عملیات نمی‌تواند ادامه یابد زیرا وضعیت جلسه در دسترس نیست. لطفاً مکالمه را دوباره باز کنید تا ادامه دهید؛ اگر مشکل ادامه داشت، با پشتیبانی تماس بگیرید.", "StreamChunkError": "خطا در تجزیه بخش پیام درخواست جریان. لطفاً بررسی کنید که آیا رابط API فعلی با مشخصات استاندارد مطابقت دارد یا با ارائه‌دهنده API خود تماس بگیرید.", "UpstreamGatewayError": "دروازه یا پراکسی بالادستی خطایی بازگرداند. لطفاً به زودی دوباره تلاش کنید؛ اگر مشکل ادامه داشت، پیکربندی پراکسی/نقطه پایانی خود را بررسی کنید.", "UpstreamHttpError": "ارائه‌دهنده یک خطای HTTP بدون جزئیات بیشتر بازگرداند. لطفاً دوباره تلاش کنید یا درخواست و پیکربندی مدل خود را بررسی کنید.", diff --git a/locales/fa-IR/opStatusTray.json b/locales/fa-IR/opStatusTray.json new file mode 100644 index 0000000000..587adbfc9d --- /dev/null +++ b/locales/fa-IR/opStatusTray.json @@ -0,0 +1,34 @@ +{ + "generatingPhrases": [ + "در حال کار", + "در حال پیش‌نویس", + "در حال تفکر", + "در حال محاسبه", + "در حال دم کردن", + "در حال ترکیب", + "در حال پردازش", + "در حال معماری", + "در حال ترکیب‌بندی", + "در حال هماهنگی", + "در حال طراحی", + "در حال آزمودن", + "در حال اندیشیدن", + "در حال ساختن", + "در حال شعله‌ور کردن", + "در حال جوشاندن", + "در حال چرخیدن", + "در حال مدیریت", + "در حال پرداخت", + "در حال آماده‌سازی پاسخ", + "در حال پختن", + "در حال هدایت", + "در حال هم‌گرایی", + "در حال رمزگشایی", + "در حال شکل‌دهی", + "در حال هماهنگ‌سازی", + "در حال بداهه‌پردازی", + "در حال استنتاج", + "در حال دست‌کاری", + "در حال زیگزاگ رفتن" + ] +} diff --git a/locales/fa-IR/setting.json b/locales/fa-IR/setting.json index 18e4ddfcf6..d87bbf8c24 100644 --- a/locales/fa-IR/setting.json +++ b/locales/fa-IR/setting.json @@ -1186,6 +1186,9 @@ "tools.klavis.notEnabled": "سرویس Klavis فعال نیست", "tools.klavis.oauthRequired": "لطفاً احراز هویت OAuth را در پنجره جدید کامل کنید", "tools.klavis.pendingAuth": "در انتظار احراز هویت", + "tools.klavis.remove": "حذف", + "tools.klavis.removeConfirm.desc": "{{name}} به طور دائمی از خدمات متصل شما حذف خواهد شد. این اقدام قابل بازگشت نیست.", + "tools.klavis.removeConfirm.title": "حذف {{name}}؟", "tools.klavis.serverCreated": "سرور با موفقیت ایجاد شد", "tools.klavis.serverCreatedFailed": "ایجاد سرور ناموفق بود", "tools.klavis.serverRemoved": "سرور حذف شد", diff --git a/locales/fa-IR/topic.json b/locales/fa-IR/topic.json index 22e08cd007..e2cdfe5499 100644 --- a/locales/fa-IR/topic.json +++ b/locales/fa-IR/topic.json @@ -135,6 +135,12 @@ "management.view.card": "کارت", "management.view.list": "فهرست", "newTopic": "موضوع جدید", + "projectStatus.failed_one": "{{count}} موضوع ناموفق", + "projectStatus.failed_other": "{{count}} موضوعات ناموفق", + "projectStatus.loading_one": "{{count}} موضوع در حال بارگذاری", + "projectStatus.loading_other": "{{count}} موضوعات در حال بارگذاری", + "projectStatus.waitingForHuman_one": "{{count}} موضوع منتظر ورودی", + "projectStatus.waitingForHuman_other": "{{count}} موضوعات منتظر ورودی", "renameModal.description": "کوتاه و قابل تشخیص نگه دارید.", "renameModal.title": "تغییر نام موضوع", "searchPlaceholder": "جستجوی گفت‌وگوها...", diff --git a/locales/fr-FR/chat.json b/locales/fr-FR/chat.json index 4839abea58..9da6b3aefa 100644 --- a/locales/fr-FR/chat.json +++ b/locales/fr-FR/chat.json @@ -370,6 +370,14 @@ "noMatchingAgents": "Aucun membre correspondant trouvé", "noMembersYet": "Ce groupe n'a pas encore de membres. Cliquez sur le bouton + pour inviter des agents.", "noSelectedAgents": "Aucun membre sélectionné pour le moment", + "opStatusTray.cost": "coût", + "opStatusTray.status.compressing": "Compression du contexte", + "opStatusTray.status.generating": "Génération", + "opStatusTray.status.reasoning": "Réflexion", + "opStatusTray.status.searching": "Recherche", + "opStatusTray.status.toolCalling": "Appel des outils", + "opStatusTray.steps": "étapes", + "opStatusTray.tokens": "jetons", "openInNewWindow": "Ouvrir dans une nouvelle fenêtre", "operation.contextCompression": "Contexte trop long, compression de l'historique...", "operation.execAgentRuntime": "Préparation de la réponse", diff --git a/locales/fr-FR/device.json b/locales/fr-FR/device.json index 83bdf43493..60d815b11f 100644 --- a/locales/fr-FR/device.json +++ b/locales/fr-FR/device.json @@ -17,6 +17,10 @@ "workingDirectory.createBranchAction": "Basculer vers une nouvelle branche…", "workingDirectory.createBranchTitle": "Créer une nouvelle branche", "workingDirectory.current": "Répertoire de travail actuel", + "workingDirectory.deleteBranchAction": "Supprimer la branche", + "workingDirectory.deleteBranchConfirm": "Supprimer la branche « {{name}} » ? Cela la supprime définitivement, y compris tous les commits non fusionnés.", + "workingDirectory.deleteBranchTitle": "Supprimer la branche", + "workingDirectory.deleteFailed": "Échec de la suppression", "workingDirectory.detachedHead": "HEAD détaché à {{sha}}", "workingDirectory.diffStatTooltip": "Ajouté {{added}} · Modifié {{modified}} · Supprimé {{deleted}}", "workingDirectory.filesAdded": "Ajouté", @@ -46,6 +50,9 @@ "workingDirectory.recent": "Récents", "workingDirectory.refreshGitStatus": "Actualiser l'état des branches et des PR", "workingDirectory.removeRecent": "Supprimer des récents", + "workingDirectory.renameBranchAction": "Renommer la branche", + "workingDirectory.renameBranchTitle": "Renommer la branche", + "workingDirectory.renameFailed": "Échec du renommage", "workingDirectory.selectFolder": "Sélectionner un dossier", "workingDirectory.title": "Répertoire de travail", "workingDirectory.topicDescription": "Remplacer le répertoire par défaut de l'Agent uniquement pour cette conversation", diff --git a/locales/fr-FR/file.json b/locales/fr-FR/file.json index 8ff5f5b15c..fd551a070b 100644 --- a/locales/fr-FR/file.json +++ b/locales/fr-FR/file.json @@ -94,6 +94,9 @@ "pageEditor.deleteSuccess": "Page supprimée avec succès", "pageEditor.duplicateError": "Échec de la duplication de la page", "pageEditor.duplicateSuccess": "Page dupliquée avec succès", + "pageEditor.editMode.checking": "Vérification de la disponibilité de l'édition…", + "pageEditor.editMode.lockedByOther": "{{name}} est en train de modifier ce document", + "pageEditor.editMode.lockedBySomeone": "Quelqu'un d'autre est en train de modifier ce document", "pageEditor.editedAt": "Dernière modification le {{time}}", "pageEditor.editedBy": "Dernière modification par {{name}}", "pageEditor.editorPlaceholder": "Appuyez sur \"/\" pour l'IA et les commandes", @@ -131,6 +134,8 @@ "pageEditor.history.versionCount_one": "{{count}} version", "pageEditor.history.versionCount_other": "{{count}} versions", "pageEditor.linkCopied": "Lien copié", + "pageEditor.lock.editingByOther": "{{name}} est en train de modifier cette page. Vos modifications ne peuvent pas être enregistrées pour le moment.", + "pageEditor.lock.editingBySomeone": "Quelqu'un d'autre est en train de modifier cette page. Vos modifications ne peuvent pas être enregistrées pour le moment.", "pageEditor.menu.copyLink": "Copier le lien", "pageEditor.menu.export": "Exporter", "pageEditor.menu.export.markdown": "Markdown", diff --git a/locales/fr-FR/modelProvider.json b/locales/fr-FR/modelProvider.json index 648f063e08..5c4d58d8f5 100644 --- a/locales/fr-FR/modelProvider.json +++ b/locales/fr-FR/modelProvider.json @@ -307,6 +307,8 @@ "providerModels.list.enabledActions.sort": "Tri personnalisé des modèles", "providerModels.list.enabledEmpty": "Aucun modèle activé disponible. Veuillez activer vos modèles préférés ci-dessous~", "providerModels.list.fetcher.clear": "Effacer les modèles récupérés", + "providerModels.list.fetcher.error": "Échec de la récupération des modèles : {{message}}", + "providerModels.list.fetcher.errorFallback": "Erreur inconnue", "providerModels.list.fetcher.fetch": "Récupérer les modèles", "providerModels.list.fetcher.fetching": "Récupération de la liste des modèles...", "providerModels.list.fetcher.latestTime": "Dernière mise à jour : {{time}}", diff --git a/locales/fr-FR/modelRuntime.json b/locales/fr-FR/modelRuntime.json index c74c03b5a4..fa49d9d497 100644 --- a/locales/fr-FR/modelRuntime.json +++ b/locales/fr-FR/modelRuntime.json @@ -35,6 +35,7 @@ "QuotaLimitReached": "Désolé, l'utilisation des jetons ou le nombre de requêtes a atteint la limite de quota pour cette clé. Veuillez augmenter le quota de la clé ou réessayer plus tard.", "RateLimitExceeded": "Désolé, l'utilisation des jetons ou le nombre de requêtes a atteint la limite de débit pour cette clé. Veuillez réessayer plus tard ou augmenter le quota de la clé.", "StateStorePersistError": "Un problème temporaire avec le stockage de l'état de la conversation a interrompu cette opération. Veuillez réessayer ; si le problème persiste, contactez le support.", + "StateStoreReadError": "Cette opération n'a pas pu être reprise car l'état de la session était indisponible. Veuillez rouvrir la conversation pour continuer ; si le problème persiste, contactez le support.", "StreamChunkError": "Erreur lors de l'analyse du fragment de message de la requête en streaming. Veuillez vérifier si l'interface API actuelle est conforme aux spécifications standard ou contactez votre fournisseur d'API pour obtenir de l'aide.", "UpstreamGatewayError": "La passerelle ou le proxy en amont a renvoyé une erreur. Veuillez réessayer sous peu ; si le problème persiste, vérifiez la configuration de votre proxy / point de terminaison.", "UpstreamHttpError": "Le fournisseur a renvoyé une erreur HTTP sans plus de détails. Veuillez réessayer ou vérifier votre requête et la configuration du modèle.", diff --git a/locales/fr-FR/opStatusTray.json b/locales/fr-FR/opStatusTray.json new file mode 100644 index 0000000000..f050fd2b62 --- /dev/null +++ b/locales/fr-FR/opStatusTray.json @@ -0,0 +1,34 @@ +{ + "generatingPhrases": [ + "En cours", + "Rédaction", + "Réflexion", + "Calcul", + "Infusion", + "Synthèse", + "Analyse", + "Architecture", + "Composition", + "Orchestration", + "Esquisse", + "Gribouillage", + "Méditation", + "Artisanat", + "Flambage", + "Mijotage", + "Vrombissement", + "Domptage", + "Polissage", + "Préparation de la réponse", + "Cuisson", + "Canalisation", + "Fusion", + "Déchiffrage", + "Forge", + "Harmonisation", + "Improvisation", + "Inférence", + "Bricolage", + "Zigzag" + ] +} diff --git a/locales/fr-FR/setting.json b/locales/fr-FR/setting.json index ae205ed51a..f2c5a00afd 100644 --- a/locales/fr-FR/setting.json +++ b/locales/fr-FR/setting.json @@ -1186,6 +1186,9 @@ "tools.klavis.notEnabled": "Service Klavis non activé", "tools.klavis.oauthRequired": "Veuillez compléter l’authentification OAuth dans la nouvelle fenêtre", "tools.klavis.pendingAuth": "Authentification en attente", + "tools.klavis.remove": "Supprimer", + "tools.klavis.removeConfirm.desc": "{{name}} sera définitivement supprimé de vos services connectés. Cette action est irréversible.", + "tools.klavis.removeConfirm.title": "Supprimer {{name}} ?", "tools.klavis.serverCreated": "Serveur créé avec succès", "tools.klavis.serverCreatedFailed": "Échec de la création du serveur", "tools.klavis.serverRemoved": "Serveur supprimé", diff --git a/locales/fr-FR/topic.json b/locales/fr-FR/topic.json index b8a58e99f2..d2424895c4 100644 --- a/locales/fr-FR/topic.json +++ b/locales/fr-FR/topic.json @@ -135,6 +135,12 @@ "management.view.card": "Carte", "management.view.list": "Liste", "newTopic": "Nouveau sujet", + "projectStatus.failed_one": "{{count}} sujet échoué", + "projectStatus.failed_other": "{{count}} sujets échoués", + "projectStatus.loading_one": "{{count}} sujet en cours de chargement", + "projectStatus.loading_other": "{{count}} sujets en cours de chargement", + "projectStatus.waitingForHuman_one": "{{count}} sujet en attente d'entrée", + "projectStatus.waitingForHuman_other": "{{count}} sujets en attente d'entrée", "renameModal.description": "Gardez-le court et facile à reconnaître.", "renameModal.title": "Renommer le sujet", "searchPlaceholder": "Rechercher des sujets...", diff --git a/locales/it-IT/chat.json b/locales/it-IT/chat.json index 659d6e28fa..2848a0a97b 100644 --- a/locales/it-IT/chat.json +++ b/locales/it-IT/chat.json @@ -370,6 +370,14 @@ "noMatchingAgents": "Nessun membro corrispondente trovato", "noMembersYet": "Questo gruppo non ha ancora membri. Clicca sul pulsante + per invitare agenti.", "noSelectedAgents": "Nessun membro selezionato", + "opStatusTray.cost": "costo", + "opStatusTray.status.compressing": "Compressione del contesto", + "opStatusTray.status.generating": "Generazione", + "opStatusTray.status.reasoning": "Riflessione", + "opStatusTray.status.searching": "Ricerca", + "opStatusTray.status.toolCalling": "Chiamata degli strumenti", + "opStatusTray.steps": "passaggi", + "opStatusTray.tokens": "token", "openInNewWindow": "Apri in una nuova finestra", "operation.contextCompression": "Contesto troppo lungo, compressione della cronologia in corso...", "operation.execAgentRuntime": "Preparazione della risposta", diff --git a/locales/it-IT/device.json b/locales/it-IT/device.json index ef0c5da6fd..c1028953a1 100644 --- a/locales/it-IT/device.json +++ b/locales/it-IT/device.json @@ -17,6 +17,10 @@ "workingDirectory.createBranchAction": "Passa a un nuovo ramo…", "workingDirectory.createBranchTitle": "Crea nuovo ramo", "workingDirectory.current": "Directory di lavoro corrente", + "workingDirectory.deleteBranchAction": "Elimina ramo", + "workingDirectory.deleteBranchConfirm": "Eliminare il ramo “{{name}}”? Questo lo rimuove permanentemente, inclusi eventuali commit non uniti.", + "workingDirectory.deleteBranchTitle": "Elimina ramo", + "workingDirectory.deleteFailed": "Eliminazione fallita", "workingDirectory.detachedHead": "HEAD scollegato a {{sha}}", "workingDirectory.diffStatTooltip": "Aggiunti {{added}} · Modificati {{modified}} · Eliminati {{deleted}}", "workingDirectory.filesAdded": "Aggiunti", @@ -46,6 +50,9 @@ "workingDirectory.recent": "Recenti", "workingDirectory.refreshGitStatus": "Aggiorna stato ramo e PR", "workingDirectory.removeRecent": "Rimuovi dai recenti", + "workingDirectory.renameBranchAction": "Rinomina ramo", + "workingDirectory.renameBranchTitle": "Rinomina ramo", + "workingDirectory.renameFailed": "Rinomina fallita", "workingDirectory.selectFolder": "Seleziona cartella", "workingDirectory.title": "Directory di lavoro", "workingDirectory.topicDescription": "Sostituisci il valore predefinito dell'Agente solo per questa conversazione", diff --git a/locales/it-IT/file.json b/locales/it-IT/file.json index 608183f3e0..62efe54da0 100644 --- a/locales/it-IT/file.json +++ b/locales/it-IT/file.json @@ -94,6 +94,9 @@ "pageEditor.deleteSuccess": "Pagina eliminata con successo", "pageEditor.duplicateError": "Duplicazione della pagina non riuscita", "pageEditor.duplicateSuccess": "Pagina duplicata con successo", + "pageEditor.editMode.checking": "Verifica della disponibilità di modifica…", + "pageEditor.editMode.lockedByOther": "{{name}} sta modificando questo documento", + "pageEditor.editMode.lockedBySomeone": "Qualcun altro sta modificando questo documento", "pageEditor.editedAt": "Ultima modifica il {{time}}", "pageEditor.editedBy": "Ultima modifica di {{name}}", "pageEditor.editorPlaceholder": "Premi \"/\" per AI e comandi", @@ -131,6 +134,8 @@ "pageEditor.history.versionCount_one": "{{count}} versione", "pageEditor.history.versionCount_other": "{{count}} versioni", "pageEditor.linkCopied": "Link copiato", + "pageEditor.lock.editingByOther": "{{name}} sta modificando questa pagina. Le tue modifiche non possono essere salvate in questo momento.", + "pageEditor.lock.editingBySomeone": "Qualcun altro sta modificando questa pagina. Le tue modifiche non possono essere salvate in questo momento.", "pageEditor.menu.copyLink": "Copia link", "pageEditor.menu.export": "Esporta", "pageEditor.menu.export.markdown": "Markdown", diff --git a/locales/it-IT/modelProvider.json b/locales/it-IT/modelProvider.json index 365609fb18..c468ca17e4 100644 --- a/locales/it-IT/modelProvider.json +++ b/locales/it-IT/modelProvider.json @@ -307,6 +307,8 @@ "providerModels.list.enabledActions.sort": "Ordinamento modelli personalizzati", "providerModels.list.enabledEmpty": "Nessun modello abilitato disponibile. Abilita i tuoi modelli preferiti dall'elenco qui sotto~", "providerModels.list.fetcher.clear": "Cancella modelli recuperati", + "providerModels.list.fetcher.error": "Errore durante il recupero dei modelli: {{message}}", + "providerModels.list.fetcher.errorFallback": "Errore sconosciuto", "providerModels.list.fetcher.fetch": "Recupera modelli", "providerModels.list.fetcher.fetching": "Recupero elenco modelli in corso...", "providerModels.list.fetcher.latestTime": "Ultimo aggiornamento: {{time}}", diff --git a/locales/it-IT/modelRuntime.json b/locales/it-IT/modelRuntime.json index a12257235c..b35d45b7a1 100644 --- a/locales/it-IT/modelRuntime.json +++ b/locales/it-IT/modelRuntime.json @@ -35,6 +35,7 @@ "QuotaLimitReached": "Spiacenti, l'utilizzo dei token o il numero di richieste ha raggiunto il limite di quota per questa chiave. Aumenta la quota della chiave o riprova più tardi.", "RateLimitExceeded": "Spiacenti, l'utilizzo dei token o il numero di richieste ha raggiunto il limite di velocità per questa chiave. Riprova più tardi o aumenta la quota della chiave.", "StateStorePersistError": "Un problema temporaneo con l'archiviazione dello stato della conversazione ha interrotto questa operazione. Riprova; se il problema persiste, contatta l'assistenza.", + "StateStoreReadError": "Questa operazione non può essere ripresa perché lo stato della sessione non è disponibile. Riapri la conversazione per continuare; se il problema persiste, contatta il supporto.", "StreamChunkError": "Errore durante l'analisi del frammento di messaggio della richiesta in streaming. Controlla se l'interfaccia API corrente è conforme alle specifiche standard o contatta il tuo provider API per ricevere assistenza.", "UpstreamGatewayError": "Il gateway o proxy a monte ha restituito un errore. Riprova tra poco; se il problema persiste, controlla la configurazione del proxy / endpoint.", "UpstreamHttpError": "Il provider ha restituito un errore HTTP senza ulteriori dettagli. Riprova o controlla la tua richiesta e la configurazione del modello.", diff --git a/locales/it-IT/opStatusTray.json b/locales/it-IT/opStatusTray.json new file mode 100644 index 0000000000..f40179f982 --- /dev/null +++ b/locales/it-IT/opStatusTray.json @@ -0,0 +1,34 @@ +{ + "generatingPhrases": [ + "Lavorando", + "Redigendo", + "Pensando", + "Calcolando", + "Preparando", + "Sintetizzando", + "Elaborando", + "Progettando", + "Componendo", + "Orchestrando", + "Schizzando", + "Sperimentando", + "Riflettendo", + "Creando", + "Fiammando", + "Sobollendo", + "Frullando", + "Gestendo", + "Lucidando", + "Preparando la risposta", + "Cucinando", + "Canalizzando", + "Coagulando", + "Decifrando", + "Forjando", + "Armonizzando", + "Improvvisando", + "Deducendo", + "Trafficando", + "Zigzagando" + ] +} diff --git a/locales/it-IT/setting.json b/locales/it-IT/setting.json index 700002e043..3cb05b00b2 100644 --- a/locales/it-IT/setting.json +++ b/locales/it-IT/setting.json @@ -1186,6 +1186,9 @@ "tools.klavis.notEnabled": "Servizio Klavis non abilitato", "tools.klavis.oauthRequired": "Completa l'autenticazione OAuth nella nuova finestra", "tools.klavis.pendingAuth": "Autenticazione in Attesa", + "tools.klavis.remove": "Rimuovi", + "tools.klavis.removeConfirm.desc": "{{name}} sarà rimosso definitivamente dai tuoi servizi collegati. Questa azione non può essere annullata.", + "tools.klavis.removeConfirm.title": "Rimuovere {{name}}?", "tools.klavis.serverCreated": "Server creato con successo", "tools.klavis.serverCreatedFailed": "Creazione server fallita", "tools.klavis.serverRemoved": "Server rimosso", diff --git a/locales/it-IT/topic.json b/locales/it-IT/topic.json index 41f7592312..99b3d6cc90 100644 --- a/locales/it-IT/topic.json +++ b/locales/it-IT/topic.json @@ -135,6 +135,12 @@ "management.view.card": "Scheda", "management.view.list": "Elenco", "newTopic": "Nuovo argomento", + "projectStatus.failed_one": "{{count}} argomento fallito", + "projectStatus.failed_other": "{{count}} argomenti falliti", + "projectStatus.loading_one": "{{count}} argomento in caricamento", + "projectStatus.loading_other": "{{count}} argomenti in caricamento", + "projectStatus.waitingForHuman_one": "{{count}} argomento in attesa di input", + "projectStatus.waitingForHuman_other": "{{count}} argomenti in attesa di input", "renameModal.description": "Mantienilo breve e facile da riconoscere.", "renameModal.title": "Rinomina argomento", "searchPlaceholder": "Cerca Argomenti...", diff --git a/locales/ja-JP/chat.json b/locales/ja-JP/chat.json index 07030c33f2..072c3d8f23 100644 --- a/locales/ja-JP/chat.json +++ b/locales/ja-JP/chat.json @@ -370,6 +370,14 @@ "noMatchingAgents": "一致するメンバーが見つかりませんでした", "noMembersYet": "このグループにはまだメンバーがいません。「+」をクリックしてアシスタントを招待してください", "noSelectedAgents": "メンバーが選択されていません", + "opStatusTray.cost": "コスト", + "opStatusTray.status.compressing": "コンテキストを圧縮中", + "opStatusTray.status.generating": "生成中", + "opStatusTray.status.reasoning": "考え中", + "opStatusTray.status.searching": "検索中", + "opStatusTray.status.toolCalling": "ツールを呼び出し中", + "opStatusTray.steps": "ステップ", + "opStatusTray.tokens": "トークン", "openInNewWindow": "新しいウィンドウで開く", "operation.contextCompression": "コンテキストが長すぎるため、履歴を圧縮しています...", "operation.execAgentRuntime": "応答を準備中", diff --git a/locales/ja-JP/device.json b/locales/ja-JP/device.json index 28d51707f4..5092b1a88c 100644 --- a/locales/ja-JP/device.json +++ b/locales/ja-JP/device.json @@ -17,6 +17,10 @@ "workingDirectory.createBranchAction": "新しいブランチをチェックアウト…", "workingDirectory.createBranchTitle": "新しいブランチを作成", "workingDirectory.current": "現在の作業ディレクトリ", + "workingDirectory.deleteBranchAction": "ブランチを削除", + "workingDirectory.deleteBranchConfirm": "ブランチ「{{name}}」を削除しますか?これにより、未マージのコミットを含めて永久に削除されます。", + "workingDirectory.deleteBranchTitle": "ブランチを削除", + "workingDirectory.deleteFailed": "削除に失敗しました", "workingDirectory.detachedHead": "デタッチされたHEAD: {{sha}}", "workingDirectory.diffStatTooltip": "追加: {{added}} · 修正: {{modified}} · 削除: {{deleted}}", "workingDirectory.filesAdded": "追加済み", @@ -46,6 +50,9 @@ "workingDirectory.recent": "最近使用したもの", "workingDirectory.refreshGitStatus": "ブランチとPRのステータスを更新", "workingDirectory.removeRecent": "最近使用したものから削除", + "workingDirectory.renameBranchAction": "ブランチ名を変更", + "workingDirectory.renameBranchTitle": "ブランチ名を変更", + "workingDirectory.renameFailed": "名前の変更に失敗しました", "workingDirectory.selectFolder": "フォルダーを選択", "workingDirectory.title": "作業ディレクトリ", "workingDirectory.topicDescription": "この会話のみでエージェントのデフォルトを上書き", diff --git a/locales/ja-JP/file.json b/locales/ja-JP/file.json index 169ccc1868..c910c97a3b 100644 --- a/locales/ja-JP/file.json +++ b/locales/ja-JP/file.json @@ -94,6 +94,9 @@ "pageEditor.deleteSuccess": "ドキュメントを削除しました", "pageEditor.duplicateError": "ページの複製に失敗しました", "pageEditor.duplicateSuccess": "ページを正常に複製しました", + "pageEditor.editMode.checking": "編集可能か確認しています…", + "pageEditor.editMode.lockedByOther": "{{name}}がこのドキュメントを編集しています", + "pageEditor.editMode.lockedBySomeone": "他の誰かがこのドキュメントを編集しています", "pageEditor.editedAt": "最終編集:{{time}}", "pageEditor.editedBy": "最終編集者:{{name}}", "pageEditor.editorPlaceholder": "「/」で AI とコマンドを呼び出し", @@ -131,6 +134,8 @@ "pageEditor.history.versionCount_one": "{{count}} 件のバージョン", "pageEditor.history.versionCount_other": "{{count}} 件のバージョン", "pageEditor.linkCopied": "リンクをコピーしました", + "pageEditor.lock.editingByOther": "{{name}}がこのページを編集しています。現在、変更を保存することはできません。", + "pageEditor.lock.editingBySomeone": "他の誰かがこのページを編集しています。現在、変更を保存することはできません。", "pageEditor.menu.copyLink": "リンクをコピー", "pageEditor.menu.export": "エクスポート", "pageEditor.menu.export.markdown": "Markdown", diff --git a/locales/ja-JP/modelProvider.json b/locales/ja-JP/modelProvider.json index b58577d55d..81e462d3cc 100644 --- a/locales/ja-JP/modelProvider.json +++ b/locales/ja-JP/modelProvider.json @@ -307,6 +307,8 @@ "providerModels.list.enabledActions.sort": "カスタムモデルの並べ替え", "providerModels.list.enabledEmpty": "有効なモデルはありません。下のリストからお気に入りのモデルを有効にしてください〜", "providerModels.list.fetcher.clear": "取得したモデルをクリア", + "providerModels.list.fetcher.error": "モデルの取得に失敗しました: {{message}}", + "providerModels.list.fetcher.errorFallback": "不明なエラー", "providerModels.list.fetcher.fetch": "モデルリストを取得", "providerModels.list.fetcher.fetching": "モデルリストを取得中...", "providerModels.list.fetcher.latestTime": "最終更新日時:{{time}}", diff --git a/locales/ja-JP/modelRuntime.json b/locales/ja-JP/modelRuntime.json index 2546819d25..25578fbf9c 100644 --- a/locales/ja-JP/modelRuntime.json +++ b/locales/ja-JP/modelRuntime.json @@ -35,6 +35,7 @@ "QuotaLimitReached": "申し訳ありませんが、このキーのトークン使用量またはリクエスト数がクォータ制限に達しました。キーのクォータを増やすか、後で再試行してください。", "RateLimitExceeded": "申し訳ありませんが、このキーのトークン使用量またはリクエスト数がレート制限に達しました。後で再試行するか、キーのクォータを増やしてください。", "StateStorePersistError": "会話状態ストアの一時的な問題により、この操作が中断されました。再試行してください。それでも解決しない場合はサポートにお問い合わせください。", + "StateStoreReadError": "この操作はセッション状態が利用できないため再開できませんでした。会話を再度開いて続行してください。それでも問題が解決しない場合は、サポートにお問い合わせください。", "StreamChunkError": "ストリーミングリクエストのメッセージチャンクの解析中にエラーが発生しました。現在のAPIインターフェースが標準仕様に準拠しているか確認するか、APIプロバイダーにお問い合わせください。", "UpstreamGatewayError": "上流のゲートウェイまたはプロキシがエラーを返しました。しばらくしてから再試行してください。それでも解決しない場合は、プロキシ/エンドポイントの設定を確認してください。", "UpstreamHttpError": "プロバイダーが詳細のないHTTPエラーを返しました。再試行するか、リクエストとモデルの設定を確認してください。", diff --git a/locales/ja-JP/opStatusTray.json b/locales/ja-JP/opStatusTray.json new file mode 100644 index 0000000000..1782258e89 --- /dev/null +++ b/locales/ja-JP/opStatusTray.json @@ -0,0 +1,34 @@ +{ + "generatingPhrases": [ + "作業中", + "下書き中", + "考え中", + "計算中", + "醸造中", + "合成中", + "解析中", + "設計中", + "作曲中", + "編曲中", + "スケッチ中", + "試行錯誤中", + "熟考中", + "制作中", + "フランベ中", + "煮込み中", + "回転中", + "調整中", + "仕上げ中", + "回答を準備中", + "焼き上げ中", + "チャネリング中", + "統合中", + "解読中", + "鍛造中", + "調和中", + "即興中", + "推論中", + "微調整中", + "ジグザグ中" + ] +} diff --git a/locales/ja-JP/setting.json b/locales/ja-JP/setting.json index efa2e68cfb..73e42bbd2d 100644 --- a/locales/ja-JP/setting.json +++ b/locales/ja-JP/setting.json @@ -1186,6 +1186,9 @@ "tools.klavis.notEnabled": "Klavis サービスは有効化されていません", "tools.klavis.oauthRequired": "新しいウィンドウで OAuth 認証を完了してください", "tools.klavis.pendingAuth": "認証待ち", + "tools.klavis.remove": "削除", + "tools.klavis.removeConfirm.desc": "{{name}}は接続されたサービスから永久に削除されます。この操作は元に戻すことができません。", + "tools.klavis.removeConfirm.title": "{{name}}を削除しますか?", "tools.klavis.serverCreated": "サーバーが正常に作成されました", "tools.klavis.serverCreatedFailed": "サーバーの作成に失敗しました", "tools.klavis.serverRemoved": "サーバーが削除されました", diff --git a/locales/ja-JP/topic.json b/locales/ja-JP/topic.json index c7420e681f..0afee15b1a 100644 --- a/locales/ja-JP/topic.json +++ b/locales/ja-JP/topic.json @@ -135,6 +135,12 @@ "management.view.card": "カード", "management.view.list": "リスト", "newTopic": "新しいトピック", + "projectStatus.failed_one": "{{count}} 件のトピックが失敗しました", + "projectStatus.failed_other": "{{count}} 件のトピックが失敗しました", + "projectStatus.loading_one": "{{count}} 件のトピックを読み込み中", + "projectStatus.loading_other": "{{count}} 件のトピックを読み込み中", + "projectStatus.waitingForHuman_one": "{{count}} 件のトピックが入力待ちです", + "projectStatus.waitingForHuman_other": "{{count}} 件のトピックが入力待ちです", "renameModal.description": "短く、わかりやすい名前にしてください。", "renameModal.title": "トピック名を変更", "searchPlaceholder": "トピックを検索...", diff --git a/locales/ko-KR/chat.json b/locales/ko-KR/chat.json index a7a5b514a3..9046911d91 100644 --- a/locales/ko-KR/chat.json +++ b/locales/ko-KR/chat.json @@ -370,6 +370,14 @@ "noMatchingAgents": "일치하는 구성원을 찾을 수 없습니다", "noMembersYet": "이 그룹에는 아직 구성원이 없습니다. +를 클릭하여 도우미를 초대하세요", "noSelectedAgents": "구성원이 선택되지 않았습니다", + "opStatusTray.cost": "비용", + "opStatusTray.status.compressing": "컨텍스트 압축 중", + "opStatusTray.status.generating": "생성 중", + "opStatusTray.status.reasoning": "생각 중", + "opStatusTray.status.searching": "검색 중", + "opStatusTray.status.toolCalling": "도구 호출 중", + "opStatusTray.steps": "단계", + "opStatusTray.tokens": "토큰", "openInNewWindow": "새 창에서 열기", "operation.contextCompression": "컨텍스트가 너무 길어 기록을 압축합니다...", "operation.execAgentRuntime": "응답 준비 중", diff --git a/locales/ko-KR/device.json b/locales/ko-KR/device.json index 0bf3b652ce..881f84f3f6 100644 --- a/locales/ko-KR/device.json +++ b/locales/ko-KR/device.json @@ -17,6 +17,10 @@ "workingDirectory.createBranchAction": "새 브랜치 체크아웃…", "workingDirectory.createBranchTitle": "새 브랜치 생성", "workingDirectory.current": "현재 작업 디렉토리", + "workingDirectory.deleteBranchAction": "브랜치 삭제", + "workingDirectory.deleteBranchConfirm": "브랜치 “{{name}}”을(를) 삭제하시겠습니까? 병합되지 않은 커밋을 포함하여 영구적으로 제거됩니다.", + "workingDirectory.deleteBranchTitle": "브랜치 삭제", + "workingDirectory.deleteFailed": "삭제 실패", "workingDirectory.detachedHead": "{{sha}}에서 분리된 HEAD", "workingDirectory.diffStatTooltip": "추가됨 {{added}} · 수정됨 {{modified}} · 삭제됨 {{deleted}}", "workingDirectory.filesAdded": "추가됨", @@ -46,6 +50,9 @@ "workingDirectory.recent": "최근", "workingDirectory.refreshGitStatus": "브랜치 및 PR 상태 새로고침", "workingDirectory.removeRecent": "최근 항목에서 제거", + "workingDirectory.renameBranchAction": "브랜치 이름 변경", + "workingDirectory.renameBranchTitle": "브랜치 이름 변경", + "workingDirectory.renameFailed": "이름 변경 실패", "workingDirectory.selectFolder": "폴더 선택", "workingDirectory.title": "작업 디렉토리", "workingDirectory.topicDescription": "이 대화에 대해서만 에이전트 기본값 재정의", diff --git a/locales/ko-KR/file.json b/locales/ko-KR/file.json index b4f5a9f51e..58ca8c41a7 100644 --- a/locales/ko-KR/file.json +++ b/locales/ko-KR/file.json @@ -94,6 +94,9 @@ "pageEditor.deleteSuccess": "문서가 성공적으로 삭제되었습니다", "pageEditor.duplicateError": "페이지 복제에 실패했습니다", "pageEditor.duplicateSuccess": "페이지가 성공적으로 복제되었습니다", + "pageEditor.editMode.checking": "편집 가능 여부 확인 중…", + "pageEditor.editMode.lockedByOther": "{{name}}님이 이 문서를 편집 중입니다", + "pageEditor.editMode.lockedBySomeone": "다른 사람이 이 문서를 편집 중입니다", "pageEditor.editedAt": "마지막 편집: {{time}}", "pageEditor.editedBy": "마지막 편집자: {{name}}", "pageEditor.editorPlaceholder": "/를 눌러 AI 및 명령어 사용", @@ -131,6 +134,8 @@ "pageEditor.history.versionCount_one": "{{count}}개 버전", "pageEditor.history.versionCount_other": "{{count}}개 버전", "pageEditor.linkCopied": "링크가 복사되었습니다", + "pageEditor.lock.editingByOther": "{{name}}님이 이 페이지를 편집 중입니다. 현재는 변경 사항을 저장할 수 없습니다.", + "pageEditor.lock.editingBySomeone": "다른 사람이 이 페이지를 편집 중입니다. 현재는 변경 사항을 저장할 수 없습니다.", "pageEditor.menu.copyLink": "링크 복사", "pageEditor.menu.export": "내보내기", "pageEditor.menu.export.markdown": "마크다운", diff --git a/locales/ko-KR/modelProvider.json b/locales/ko-KR/modelProvider.json index ba29cf79cd..ed70bb370c 100644 --- a/locales/ko-KR/modelProvider.json +++ b/locales/ko-KR/modelProvider.json @@ -307,6 +307,8 @@ "providerModels.list.enabledActions.sort": "사용자 정의 모델 정렬", "providerModels.list.enabledEmpty": "활성화된 모델이 없습니다. 아래 목록에서 원하는 모델을 활성화해 보세요~", "providerModels.list.fetcher.clear": "가져온 모델 지우기", + "providerModels.list.fetcher.error": "모델을 가져오는 데 실패했습니다: {{message}}", + "providerModels.list.fetcher.errorFallback": "알 수 없는 오류", "providerModels.list.fetcher.fetch": "모델 목록 가져오기", "providerModels.list.fetcher.fetching": "모델 목록을 가져오는 중...", "providerModels.list.fetcher.latestTime": "마지막 업데이트 시간: {{time}}", diff --git a/locales/ko-KR/modelRuntime.json b/locales/ko-KR/modelRuntime.json index b166e10f2f..318b468342 100644 --- a/locales/ko-KR/modelRuntime.json +++ b/locales/ko-KR/modelRuntime.json @@ -35,6 +35,7 @@ "QuotaLimitReached": "죄송합니다. 이 키의 토큰 사용량 또는 요청 수가 할당량 한도에 도달했습니다. 키의 할당량을 늘리거나 나중에 다시 시도하세요.", "RateLimitExceeded": "죄송합니다. 이 키의 토큰 사용량 또는 요청 수가 속도 제한에 도달했습니다. 나중에 다시 시도하거나 키의 할당량을 늘리세요.", "StateStorePersistError": "대화 상태 저장소의 일시적인 문제로 인해 이 작업이 중단되었습니다. 다시 시도하세요. 문제가 지속되면 지원팀에 문의하세요.", + "StateStoreReadError": "이 작업은 세션 상태를 사용할 수 없어 다시 시작할 수 없습니다. 대화를 다시 열어 계속 진행하십시오. 문제가 지속되면 지원팀에 문의하십시오.", "StreamChunkError": "스트리밍 요청의 메시지 청크를 구문 분석하는 중 오류가 발생했습니다. 현재 API 인터페이스가 표준 사양을 준수하는지 확인하거나 API 제공업체에 문의하세요.", "UpstreamGatewayError": "상위 게이트웨이 또는 프록시에서 오류를 반환했습니다. 잠시 후 다시 시도하세요. 문제가 지속되면 프록시/엔드포인트 구성을 확인하세요.", "UpstreamHttpError": "제공업체가 추가 세부 정보 없이 HTTP 오류를 반환했습니다. 다시 시도하거나 요청 및 모델 구성을 확인하세요.", diff --git a/locales/ko-KR/opStatusTray.json b/locales/ko-KR/opStatusTray.json new file mode 100644 index 0000000000..2364aafa82 --- /dev/null +++ b/locales/ko-KR/opStatusTray.json @@ -0,0 +1,34 @@ +{ + "generatingPhrases": [ + "작업 중", + "초안 작성 중", + "생각 중", + "계산 중", + "양조 중", + "합성 중", + "분석 중", + "구상 중", + "작곡 중", + "조율 중", + "스케치 중", + "아이디어 구상 중", + "숙고 중", + "제작 중", + "플람베 중", + "끓이는 중", + "윙윙거리는 중", + "조율 중", + "다듬는 중", + "답변 준비 중", + "굽는 중", + "채널링 중", + "통합 중", + "해독 중", + "단련 중", + "조화 중", + "즉흥적으로 만드는 중", + "추론 중", + "손질 중", + "지그재그로 움직이는 중" + ] +} diff --git a/locales/ko-KR/setting.json b/locales/ko-KR/setting.json index a66601051f..423a93c083 100644 --- a/locales/ko-KR/setting.json +++ b/locales/ko-KR/setting.json @@ -1186,6 +1186,9 @@ "tools.klavis.notEnabled": "Klavis 서비스가 활성화되지 않음", "tools.klavis.oauthRequired": "새 창에서 OAuth 인증을 완료해 주세요", "tools.klavis.pendingAuth": "인증 대기 중", + "tools.klavis.remove": "제거", + "tools.klavis.removeConfirm.desc": "{{name}}이(가) 연결된 서비스에서 영구적으로 제거됩니다. 이 작업은 되돌릴 수 없습니다.", + "tools.klavis.removeConfirm.title": "{{name}}을(를) 제거하시겠습니까?", "tools.klavis.serverCreated": "서버 생성 성공", "tools.klavis.serverCreatedFailed": "서버 생성 실패", "tools.klavis.serverRemoved": "서버 삭제됨", diff --git a/locales/ko-KR/topic.json b/locales/ko-KR/topic.json index 327e24ea23..e6afccb1c9 100644 --- a/locales/ko-KR/topic.json +++ b/locales/ko-KR/topic.json @@ -135,6 +135,12 @@ "management.view.card": "카드", "management.view.list": "목록", "newTopic": "새 토픽", + "projectStatus.failed_one": "{{count}}개의 실패한 주제", + "projectStatus.failed_other": "{{count}}개의 실패한 주제들", + "projectStatus.loading_one": "{{count}}개의 로딩 중인 주제", + "projectStatus.loading_other": "{{count}}개의 로딩 중인 주제들", + "projectStatus.waitingForHuman_one": "{{count}}개의 입력 대기 중인 주제", + "projectStatus.waitingForHuman_other": "{{count}}개의 입력 대기 중인 주제들", "renameModal.description": "간단하고 알아보기 쉽게 설정하세요.", "renameModal.title": "토픽 이름 변경", "searchPlaceholder": "주제 검색...", diff --git a/locales/nl-NL/chat.json b/locales/nl-NL/chat.json index 335048bc64..08d4a54713 100644 --- a/locales/nl-NL/chat.json +++ b/locales/nl-NL/chat.json @@ -370,6 +370,14 @@ "noMatchingAgents": "Geen overeenkomende leden gevonden", "noMembersYet": "Deze groep heeft nog geen leden. Klik op de + knop om agenten uit te nodigen.", "noSelectedAgents": "Nog geen leden geselecteerd", + "opStatusTray.cost": "kosten", + "opStatusTray.status.compressing": "Context comprimeren", + "opStatusTray.status.generating": "Genereren", + "opStatusTray.status.reasoning": "Denken", + "opStatusTray.status.searching": "Zoeken", + "opStatusTray.status.toolCalling": "Hulpmiddelen oproepen", + "opStatusTray.steps": "stappen", + "opStatusTray.tokens": "tokens", "openInNewWindow": "Openen in nieuw venster", "operation.contextCompression": "Context te lang, geschiedenis wordt samengevat...", "operation.execAgentRuntime": "Reactie voorbereiden", diff --git a/locales/nl-NL/device.json b/locales/nl-NL/device.json index d2f8d13d41..f062d0868a 100644 --- a/locales/nl-NL/device.json +++ b/locales/nl-NL/device.json @@ -17,6 +17,10 @@ "workingDirectory.createBranchAction": "Nieuwe branch uitchecken…", "workingDirectory.createBranchTitle": "Nieuwe branch aanmaken", "workingDirectory.current": "Huidige werkmap", + "workingDirectory.deleteBranchAction": "Verwijder tak", + "workingDirectory.deleteBranchConfirm": "Tak “{{name}}” verwijderen? Dit verwijdert deze permanent, inclusief eventuele niet-samengevoegde commits.", + "workingDirectory.deleteBranchTitle": "Tak verwijderen", + "workingDirectory.deleteFailed": "Verwijderen mislukt", "workingDirectory.detachedHead": "Losgekoppelde HEAD bij {{sha}}", "workingDirectory.diffStatTooltip": "Toegevoegd {{added}} · Gewijzigd {{modified}} · Verwijderd {{deleted}}", "workingDirectory.filesAdded": "Toegevoegd", @@ -46,6 +50,9 @@ "workingDirectory.recent": "Recent", "workingDirectory.refreshGitStatus": "Branch- en PR-status vernieuwen", "workingDirectory.removeRecent": "Verwijderen uit recent", + "workingDirectory.renameBranchAction": "Hernoem tak", + "workingDirectory.renameBranchTitle": "Tak hernoemen", + "workingDirectory.renameFailed": "Hernoemen mislukt", "workingDirectory.selectFolder": "Map selecteren", "workingDirectory.title": "Werkmap", "workingDirectory.topicDescription": "Overschrijf standaard Agent voor alleen dit gesprek", diff --git a/locales/nl-NL/file.json b/locales/nl-NL/file.json index 4d1cdd60db..b8920c6fb1 100644 --- a/locales/nl-NL/file.json +++ b/locales/nl-NL/file.json @@ -94,6 +94,9 @@ "pageEditor.deleteSuccess": "Pagina succesvol verwijderd", "pageEditor.duplicateError": "Kopiëren van pagina mislukt", "pageEditor.duplicateSuccess": "Pagina succesvol gekopieerd", + "pageEditor.editMode.checking": "Beschikbaarheid van bewerken controleren…", + "pageEditor.editMode.lockedByOther": "{{name}} is dit document aan het bewerken", + "pageEditor.editMode.lockedBySomeone": "Iemand anders is dit document aan het bewerken", "pageEditor.editedAt": "Laatst bewerkt op {{time}}", "pageEditor.editedBy": "Laatst bewerkt door {{name}}", "pageEditor.editorPlaceholder": "Druk op \"/\" voor AI en opdrachten", @@ -131,6 +134,8 @@ "pageEditor.history.versionCount_one": "{{count}} versie", "pageEditor.history.versionCount_other": "{{count}} versies", "pageEditor.linkCopied": "Link gekopieerd", + "pageEditor.lock.editingByOther": "{{name}} is deze pagina aan het bewerken. Je wijzigingen kunnen momenteel niet worden opgeslagen.", + "pageEditor.lock.editingBySomeone": "Iemand anders is deze pagina aan het bewerken. Je wijzigingen kunnen momenteel niet worden opgeslagen.", "pageEditor.menu.copyLink": "Link kopiëren", "pageEditor.menu.export": "Exporteren", "pageEditor.menu.export.markdown": "Markdown", diff --git a/locales/nl-NL/modelProvider.json b/locales/nl-NL/modelProvider.json index 8f32a4f54a..94c2a6d8b9 100644 --- a/locales/nl-NL/modelProvider.json +++ b/locales/nl-NL/modelProvider.json @@ -307,6 +307,8 @@ "providerModels.list.enabledActions.sort": "Aangepaste modelsortering", "providerModels.list.enabledEmpty": "Geen ingeschakelde modellen beschikbaar. Schakel je favoriete modellen hieronder in~", "providerModels.list.fetcher.clear": "Opgehaalde modellen wissen", + "providerModels.list.fetcher.error": "Kan modellen niet ophalen: {{message}}", + "providerModels.list.fetcher.errorFallback": "Onbekende fout", "providerModels.list.fetcher.fetch": "Modellen ophalen", "providerModels.list.fetcher.fetching": "Modellenlijst wordt opgehaald...", "providerModels.list.fetcher.latestTime": "Laatst bijgewerkt: {{time}}", diff --git a/locales/nl-NL/modelRuntime.json b/locales/nl-NL/modelRuntime.json index 03d6c1ce95..66261c5918 100644 --- a/locales/nl-NL/modelRuntime.json +++ b/locales/nl-NL/modelRuntime.json @@ -35,6 +35,7 @@ "QuotaLimitReached": "Sorry, het tokengebruik of het aantal verzoeken heeft de quotumlimiet voor deze sleutel bereikt. Verhoog het quotum van de sleutel of probeer het later opnieuw.", "RateLimitExceeded": "Sorry, het tokengebruik of het aantal verzoeken heeft de snelheidslimiet voor deze sleutel bereikt. Probeer het later opnieuw of verhoog het quotum van de sleutel.", "StateStorePersistError": "Een tijdelijk probleem met de gespreksstatusopslag heeft deze bewerking onderbroken. Probeer het opnieuw; neem contact op met de ondersteuning als het probleem aanhoudt.", + "StateStoreReadError": "Deze bewerking kon niet worden hervat omdat de sessiestatus niet beschikbaar was. Open het gesprek opnieuw om verder te gaan; als het probleem aanhoudt, neem dan contact op met de ondersteuning.", "StreamChunkError": "Fout bij het parseren van het berichtdeel van het streamingverzoek. Controleer of de huidige API-interface voldoet aan de standaardspecificaties, of neem contact op met uw API-provider voor hulp.", "UpstreamGatewayError": "De upstream-gateway of proxy heeft een fout geretourneerd. Probeer het over een tijdje opnieuw; controleer uw proxy-/eindpuntconfiguratie als het probleem aanhoudt.", "UpstreamHttpError": "De provider heeft een HTTP-fout geretourneerd zonder verdere details. Probeer het opnieuw, of controleer uw verzoek en modelconfiguratie.", diff --git a/locales/nl-NL/opStatusTray.json b/locales/nl-NL/opStatusTray.json new file mode 100644 index 0000000000..07c307159d --- /dev/null +++ b/locales/nl-NL/opStatusTray.json @@ -0,0 +1,34 @@ +{ + "generatingPhrases": [ + "Bezig", + "Opstellen", + "Denken", + "Berekenen", + "Brouwen", + "Synthetiseren", + "Kraken", + "Ontwerpen", + "Componeren", + "Orkestreren", + "Schetsen", + "Krabbelen", + "Overdenken", + "Vervaardigen", + "Flamberen", + "Sudderen", + "Zoemen", + "Beheersen", + "Polijsten", + "Het antwoord voorbereiden", + "Bakken", + "Kanaliseren", + "Samenvoegen", + "Ontcijferen", + "Smeden", + "Harmoniëren", + "Improviseren", + "Afleiden", + "Knutselen", + "Zigzaggen" + ] +} diff --git a/locales/nl-NL/setting.json b/locales/nl-NL/setting.json index aded54c35f..b92d4b612c 100644 --- a/locales/nl-NL/setting.json +++ b/locales/nl-NL/setting.json @@ -1186,6 +1186,9 @@ "tools.klavis.notEnabled": "Klavis-service niet ingeschakeld", "tools.klavis.oauthRequired": "Voltooi OAuth-authenticatie in het nieuwe venster", "tools.klavis.pendingAuth": "Authenticatie In Afwachting", + "tools.klavis.remove": "Verwijderen", + "tools.klavis.removeConfirm.desc": "{{name}} wordt permanent verwijderd uit je verbonden diensten. Deze actie kan niet ongedaan worden gemaakt.", + "tools.klavis.removeConfirm.title": "{{name}} verwijderen?", "tools.klavis.serverCreated": "Server succesvol aangemaakt", "tools.klavis.serverCreatedFailed": "Server aanmaken mislukt", "tools.klavis.serverRemoved": "Server verwijderd", diff --git a/locales/nl-NL/topic.json b/locales/nl-NL/topic.json index 6833f1f481..b2ae25af36 100644 --- a/locales/nl-NL/topic.json +++ b/locales/nl-NL/topic.json @@ -135,6 +135,12 @@ "management.view.card": "Kaart", "management.view.list": "Lijst", "newTopic": "Nieuw onderwerp", + "projectStatus.failed_one": "{{count}} mislukt onderwerp", + "projectStatus.failed_other": "{{count}} mislukte onderwerpen", + "projectStatus.loading_one": "{{count}} onderwerp wordt geladen", + "projectStatus.loading_other": "{{count}} onderwerpen worden geladen", + "projectStatus.waitingForHuman_one": "{{count}} onderwerp wacht op invoer", + "projectStatus.waitingForHuman_other": "{{count}} onderwerpen wachten op invoer", "renameModal.description": "Houd het kort en gemakkelijk herkenbaar.", "renameModal.title": "Onderwerp hernoemen", "searchPlaceholder": "Onderwerpen zoeken...", diff --git a/locales/pl-PL/chat.json b/locales/pl-PL/chat.json index 062e295fef..bfd299047e 100644 --- a/locales/pl-PL/chat.json +++ b/locales/pl-PL/chat.json @@ -370,6 +370,14 @@ "noMatchingAgents": "Nie znaleziono pasujących członków", "noMembersYet": "Ta grupa nie ma jeszcze członków. Kliknij przycisk +, aby zaprosić agentów.", "noSelectedAgents": "Nie wybrano jeszcze członków", + "opStatusTray.cost": "koszt", + "opStatusTray.status.compressing": "Kompresowanie kontekstu", + "opStatusTray.status.generating": "Generowanie", + "opStatusTray.status.reasoning": "Myślenie", + "opStatusTray.status.searching": "Wyszukiwanie", + "opStatusTray.status.toolCalling": "Wywoływanie narzędzi", + "opStatusTray.steps": "kroki", + "opStatusTray.tokens": "tokeny", "openInNewWindow": "Otwórz w nowym oknie", "operation.contextCompression": "Kontekst jest zbyt długi, kompresowanie historii...", "operation.execAgentRuntime": "Przygotowywanie odpowiedzi", diff --git a/locales/pl-PL/device.json b/locales/pl-PL/device.json index cffcb8f005..c342b73b57 100644 --- a/locales/pl-PL/device.json +++ b/locales/pl-PL/device.json @@ -17,6 +17,10 @@ "workingDirectory.createBranchAction": "Przełącz na nową gałąź…", "workingDirectory.createBranchTitle": "Utwórz nową gałąź", "workingDirectory.current": "Obecny katalog roboczy", + "workingDirectory.deleteBranchAction": "Usuń gałąź", + "workingDirectory.deleteBranchConfirm": "Usunąć gałąź „{{name}}”? Spowoduje to trwałe usunięcie, w tym wszelkich niepołączonych commitów.", + "workingDirectory.deleteBranchTitle": "Usuń gałąź", + "workingDirectory.deleteFailed": "Usunięcie nie powiodło się", "workingDirectory.detachedHead": "Odłączony HEAD na {{sha}}", "workingDirectory.diffStatTooltip": "Dodano {{added}} · Zmodyfikowano {{modified}} · Usunięto {{deleted}}", "workingDirectory.filesAdded": "Dodano", @@ -46,6 +50,9 @@ "workingDirectory.recent": "Ostatnie", "workingDirectory.refreshGitStatus": "Odśwież status gałęzi i PR", "workingDirectory.removeRecent": "Usuń z ostatnich", + "workingDirectory.renameBranchAction": "Zmień nazwę gałęzi", + "workingDirectory.renameBranchTitle": "Zmień nazwę gałęzi", + "workingDirectory.renameFailed": "Zmiana nazwy nie powiodła się", "workingDirectory.selectFolder": "Wybierz folder", "workingDirectory.title": "Katalog roboczy", "workingDirectory.topicDescription": "Zastąp domyślny katalog Agenta tylko dla tej rozmowy", diff --git a/locales/pl-PL/file.json b/locales/pl-PL/file.json index 8496b32a30..d46bb60a3b 100644 --- a/locales/pl-PL/file.json +++ b/locales/pl-PL/file.json @@ -94,6 +94,9 @@ "pageEditor.deleteSuccess": "Strona została pomyślnie usunięta", "pageEditor.duplicateError": "Nie udało się zduplikować strony", "pageEditor.duplicateSuccess": "Strona została pomyślnie zduplikowana", + "pageEditor.editMode.checking": "Sprawdzanie dostępności edycji…", + "pageEditor.editMode.lockedByOther": "{{name}} edytuje ten dokument", + "pageEditor.editMode.lockedBySomeone": "Ktoś inny edytuje ten dokument", "pageEditor.editedAt": "Ostatnia edycja: {{time}}", "pageEditor.editedBy": "Ostatnio edytował: {{name}}", "pageEditor.editorPlaceholder": "Naciśnij \"/\" dla AI i poleceń", @@ -131,6 +134,8 @@ "pageEditor.history.versionCount_one": "{{count}} wersja", "pageEditor.history.versionCount_other": "{{count}} wersji", "pageEditor.linkCopied": "Link skopiowany", + "pageEditor.lock.editingByOther": "{{name}} edytuje tę stronę. Twoje zmiany nie mogą zostać zapisane w tej chwili.", + "pageEditor.lock.editingBySomeone": "Ktoś inny edytuje tę stronę. Twoje zmiany nie mogą zostać zapisane w tej chwili.", "pageEditor.menu.copyLink": "Kopiuj link", "pageEditor.menu.export": "Eksportuj", "pageEditor.menu.export.markdown": "Markdown", diff --git a/locales/pl-PL/modelProvider.json b/locales/pl-PL/modelProvider.json index 4a928ed206..215c24ae59 100644 --- a/locales/pl-PL/modelProvider.json +++ b/locales/pl-PL/modelProvider.json @@ -307,6 +307,8 @@ "providerModels.list.enabledActions.sort": "Sortowanie modeli niestandardowych", "providerModels.list.enabledEmpty": "Brak włączonych modeli. Włącz preferowane modele z poniższej listy~", "providerModels.list.fetcher.clear": "Wyczyść pobrane modele", + "providerModels.list.fetcher.error": "Nie udało się pobrać modeli: {{message}}", + "providerModels.list.fetcher.errorFallback": "Nieznany błąd", "providerModels.list.fetcher.fetch": "Pobierz modele", "providerModels.list.fetcher.fetching": "Pobieranie listy modeli...", "providerModels.list.fetcher.latestTime": "Ostatnia aktualizacja: {{time}}", diff --git a/locales/pl-PL/modelRuntime.json b/locales/pl-PL/modelRuntime.json index 01a66043c9..59aa0f5126 100644 --- a/locales/pl-PL/modelRuntime.json +++ b/locales/pl-PL/modelRuntime.json @@ -35,6 +35,7 @@ "QuotaLimitReached": "Przepraszamy, wykorzystanie tokenów lub liczba żądań osiągnęły limit przydziału dla tego klucza. Proszę zwiększyć przydział klucza lub spróbować ponownie później.", "RateLimitExceeded": "Przepraszamy, wykorzystanie tokenów lub liczba żądań osiągnęły limit szybkości dla tego klucza. Proszę spróbować ponownie później lub zwiększyć przydział klucza.", "StateStorePersistError": "Tymczasowy problem z magazynem stanu rozmowy przerwał tę operację. Proszę spróbować ponownie; jeśli problem będzie się powtarzał, skontaktuj się z pomocą techniczną.", + "StateStoreReadError": "Nie można wznowić tej operacji, ponieważ stan sesji był niedostępny. Proszę ponownie otworzyć rozmowę, aby kontynuować; jeśli problem będzie się powtarzał, skontaktuj się z pomocą techniczną.", "StreamChunkError": "Błąd podczas parsowania fragmentu wiadomości w żądaniu strumieniowym. Proszę sprawdzić, czy obecny interfejs API jest zgodny ze standardowymi specyfikacjami, lub skontaktować się z dostawcą API w celu uzyskania pomocy.", "UpstreamGatewayError": "Bramka upstream lub proxy zwróciły błąd. Proszę spróbować ponownie za chwilę; jeśli problem będzie się powtarzał, sprawdź konfigurację proxy / punktu końcowego.", "UpstreamHttpError": "Dostawca zwrócił błąd HTTP bez dodatkowych szczegółów. Proszę spróbować ponownie, lub sprawdzić swoje żądanie i konfigurację modelu.", diff --git a/locales/pl-PL/opStatusTray.json b/locales/pl-PL/opStatusTray.json new file mode 100644 index 0000000000..623cac0b79 --- /dev/null +++ b/locales/pl-PL/opStatusTray.json @@ -0,0 +1,34 @@ +{ + "generatingPhrases": [ + "Pracuję", + "Tworzę szkic", + "Myślę", + "Obliczam", + "Warzę", + "Syntezuję", + "Przetwarzam", + "Projektuję", + "Komponuję", + "Orkiestruję", + "Szkicuję", + "Improwizuję", + "Rozważam", + "Tworzę", + "Flambirowanie", + "Gotuję na wolnym ogniu", + "Warkoczę", + "Okiełznuje", + "Poleruję", + "Przygotowuję odpowiedź", + "Piekę", + "Kieruję", + "Łączę", + "Rozszyfrowuję", + "Kuję", + "Harmonizuję", + "Improwizuję", + "Wnioskuję", + "Majsterkuję", + "Zygzakuję" + ] +} diff --git a/locales/pl-PL/setting.json b/locales/pl-PL/setting.json index b8c561c872..03c6768577 100644 --- a/locales/pl-PL/setting.json +++ b/locales/pl-PL/setting.json @@ -1186,6 +1186,9 @@ "tools.klavis.notEnabled": "Usługa Klavis nie jest włączona", "tools.klavis.oauthRequired": "Proszę zakończyć uwierzytelnienie OAuth w nowym oknie", "tools.klavis.pendingAuth": "Oczekujące uwierzytelnienie", + "tools.klavis.remove": "Usuń", + "tools.klavis.removeConfirm.desc": "{{name}} zostanie trwale usunięty z Twoich połączonych usług. Tej operacji nie można cofnąć.", + "tools.klavis.removeConfirm.title": "Usunąć {{name}}?", "tools.klavis.serverCreated": "Serwer utworzony pomyślnie", "tools.klavis.serverCreatedFailed": "Nie udało się utworzyć serwera", "tools.klavis.serverRemoved": "Serwer usunięty", diff --git a/locales/pl-PL/topic.json b/locales/pl-PL/topic.json index 7239c5a2d7..be400c19a4 100644 --- a/locales/pl-PL/topic.json +++ b/locales/pl-PL/topic.json @@ -135,6 +135,12 @@ "management.view.card": "Karta", "management.view.list": "Lista", "newTopic": "Nowy temat", + "projectStatus.failed_one": "{{count}} nieudany temat", + "projectStatus.failed_other": "{{count}} nieudane tematy", + "projectStatus.loading_one": "{{count}} ładowany temat", + "projectStatus.loading_other": "{{count}} ładowane tematy", + "projectStatus.waitingForHuman_one": "{{count}} temat oczekujący na dane", + "projectStatus.waitingForHuman_other": "{{count}} tematy oczekujące na dane", "renameModal.description": "Utrzymaj nazwę krótką i łatwą do rozpoznania.", "renameModal.title": "Zmień nazwę tematu", "searchPlaceholder": "Szukaj tematów...", diff --git a/locales/pt-BR/chat.json b/locales/pt-BR/chat.json index 4a81e1a6e8..0c534c821b 100644 --- a/locales/pt-BR/chat.json +++ b/locales/pt-BR/chat.json @@ -370,6 +370,14 @@ "noMatchingAgents": "Nenhum membro correspondente encontrado", "noMembersYet": "Este grupo ainda não possui membros. Clique no botão + para convidar agentes.", "noSelectedAgents": "Nenhum membro selecionado ainda", + "opStatusTray.cost": "custo", + "opStatusTray.status.compressing": "Compactando contexto", + "opStatusTray.status.generating": "Gerando", + "opStatusTray.status.reasoning": "Pensando", + "opStatusTray.status.searching": "Pesquisando", + "opStatusTray.status.toolCalling": "Chamando ferramentas", + "opStatusTray.steps": "etapas", + "opStatusTray.tokens": "tokens", "openInNewWindow": "Abrir em Nova Janela", "operation.contextCompression": "Contexto muito longo, comprimindo o histórico...", "operation.execAgentRuntime": "Preparando resposta", diff --git a/locales/pt-BR/device.json b/locales/pt-BR/device.json index 70f945a82a..21c1426585 100644 --- a/locales/pt-BR/device.json +++ b/locales/pt-BR/device.json @@ -17,6 +17,10 @@ "workingDirectory.createBranchAction": "Fazer checkout de novo branch…", "workingDirectory.createBranchTitle": "Criar novo branch", "workingDirectory.current": "Diretório de trabalho atual", + "workingDirectory.deleteBranchAction": "Excluir branch", + "workingDirectory.deleteBranchConfirm": "Excluir branch “{{name}}”? Isso o remove permanentemente, incluindo quaisquer commits não mesclados.", + "workingDirectory.deleteBranchTitle": "Excluir branch", + "workingDirectory.deleteFailed": "Falha ao excluir", "workingDirectory.detachedHead": "HEAD destacado em {{sha}}", "workingDirectory.diffStatTooltip": "Adicionado {{added}} · Modificado {{modified}} · Excluído {{deleted}}", "workingDirectory.filesAdded": "Adicionado", @@ -46,6 +50,9 @@ "workingDirectory.recent": "Recente", "workingDirectory.refreshGitStatus": "Atualizar status do branch e PR", "workingDirectory.removeRecent": "Remover dos recentes", + "workingDirectory.renameBranchAction": "Renomear branch", + "workingDirectory.renameBranchTitle": "Renomear branch", + "workingDirectory.renameFailed": "Falha ao renomear", "workingDirectory.selectFolder": "Selecionar pasta", "workingDirectory.title": "Diretório de Trabalho", "workingDirectory.topicDescription": "Substituir padrão do Agente apenas para esta conversa", diff --git a/locales/pt-BR/file.json b/locales/pt-BR/file.json index b034432faf..9f8a688a9f 100644 --- a/locales/pt-BR/file.json +++ b/locales/pt-BR/file.json @@ -94,6 +94,9 @@ "pageEditor.deleteSuccess": "Página excluída com sucesso", "pageEditor.duplicateError": "Falha ao duplicar a página", "pageEditor.duplicateSuccess": "Página duplicada com sucesso", + "pageEditor.editMode.checking": "Verificando a disponibilidade de edição…", + "pageEditor.editMode.lockedByOther": "{{name}} está editando este documento", + "pageEditor.editMode.lockedBySomeone": "Outra pessoa está editando este documento", "pageEditor.editedAt": "Última edição em {{time}}", "pageEditor.editedBy": "Última edição por {{name}}", "pageEditor.editorPlaceholder": "Pressione \"/\" para IA e comandos", @@ -131,6 +134,8 @@ "pageEditor.history.versionCount_one": "{{count}} versão", "pageEditor.history.versionCount_other": "{{count}} versões", "pageEditor.linkCopied": "Link copiado", + "pageEditor.lock.editingByOther": "{{name}} está editando esta página. Suas alterações não podem ser salvas no momento.", + "pageEditor.lock.editingBySomeone": "Outra pessoa está editando esta página. Suas alterações não podem ser salvas no momento.", "pageEditor.menu.copyLink": "Copiar Link", "pageEditor.menu.export": "Exportar", "pageEditor.menu.export.markdown": "Markdown", diff --git a/locales/pt-BR/modelProvider.json b/locales/pt-BR/modelProvider.json index ddf540431e..6062f81061 100644 --- a/locales/pt-BR/modelProvider.json +++ b/locales/pt-BR/modelProvider.json @@ -307,6 +307,8 @@ "providerModels.list.enabledActions.sort": "Ordenação de Modelos Personalizados", "providerModels.list.enabledEmpty": "Nenhum modelo ativado disponível. Ative seus modelos preferidos na lista abaixo~", "providerModels.list.fetcher.clear": "Limpar modelos buscados", + "providerModels.list.fetcher.error": "Falha ao buscar modelos: {{message}}", + "providerModels.list.fetcher.errorFallback": "Erro desconhecido", "providerModels.list.fetcher.fetch": "Buscar modelos", "providerModels.list.fetcher.fetching": "Buscando lista de modelos...", "providerModels.list.fetcher.latestTime": "Última atualização: {{time}}", diff --git a/locales/pt-BR/modelRuntime.json b/locales/pt-BR/modelRuntime.json index 9c4847914e..9d989e93ec 100644 --- a/locales/pt-BR/modelRuntime.json +++ b/locales/pt-BR/modelRuntime.json @@ -35,6 +35,7 @@ "QuotaLimitReached": "Desculpe, o uso de tokens ou a contagem de solicitações atingiu o limite de cota para esta chave. Por favor, aumente a cota da chave ou tente novamente mais tarde.", "RateLimitExceeded": "Desculpe, o uso de tokens ou a contagem de solicitações atingiu o limite de taxa para esta chave. Por favor, tente novamente mais tarde ou aumente a cota da chave.", "StateStorePersistError": "Um problema temporário com o armazenamento do estado da conversa interrompeu esta operação. Por favor, tente novamente; se o problema persistir, entre em contato com o suporte.", + "StateStoreReadError": "Esta operação não pôde ser retomada porque o estado da sessão estava indisponível. Por favor, reabra a conversa para continuar; se o problema persistir, entre em contato com o suporte.", "StreamChunkError": "Erro ao analisar o fragmento de mensagem da solicitação de streaming. Por favor, verifique se a interface da API atual está em conformidade com as especificações padrão ou entre em contato com o provedor da API para obter assistência.", "UpstreamGatewayError": "O gateway ou proxy do provedor retornou um erro. Por favor, tente novamente em breve; se o problema persistir, verifique sua configuração de proxy/endpoint.", "UpstreamHttpError": "O provedor retornou um erro HTTP sem mais detalhes. Por favor, tente novamente ou verifique sua solicitação e configuração do modelo.", diff --git a/locales/pt-BR/opStatusTray.json b/locales/pt-BR/opStatusTray.json new file mode 100644 index 0000000000..f524d8f002 --- /dev/null +++ b/locales/pt-BR/opStatusTray.json @@ -0,0 +1,34 @@ +{ + "generatingPhrases": [ + "Trabalhando", + "Esboçando", + "Pensando", + "Computando", + "Preparando", + "Sintetizando", + "Calculando", + "Arquitetando", + "Compondo", + "Orquestrando", + "Desenhando", + "Experimentando", + "Refletindo", + "Criando", + "Flambando", + "Cozinhando em fogo brando", + "Zumbindo", + "Lidando", + "Polindo", + "Preparando a resposta", + "Assando", + "Canalizando", + "Unindo", + "Decifrando", + "Forjando", + "Harmonizando", + "Improvisando", + "Inferindo", + "Ajustando", + "Ziguezagueando" + ] +} diff --git a/locales/pt-BR/setting.json b/locales/pt-BR/setting.json index c456ec7b16..30e918fb8a 100644 --- a/locales/pt-BR/setting.json +++ b/locales/pt-BR/setting.json @@ -1186,6 +1186,9 @@ "tools.klavis.notEnabled": "Serviço Klavis não ativado", "tools.klavis.oauthRequired": "Por favor, conclua a autenticação OAuth na nova janela", "tools.klavis.pendingAuth": "Autenticação Pendente", + "tools.klavis.remove": "Remover", + "tools.klavis.removeConfirm.desc": "{{name}} será removido permanentemente dos seus serviços conectados. Esta ação não pode ser desfeita.", + "tools.klavis.removeConfirm.title": "Remover {{name}}?", "tools.klavis.serverCreated": "Servidor criado com sucesso", "tools.klavis.serverCreatedFailed": "Falha ao criar servidor", "tools.klavis.serverRemoved": "Servidor removido", diff --git a/locales/pt-BR/topic.json b/locales/pt-BR/topic.json index cd8a9fcb83..2d8df385d3 100644 --- a/locales/pt-BR/topic.json +++ b/locales/pt-BR/topic.json @@ -135,6 +135,12 @@ "management.view.card": "Cartão", "management.view.list": "Lista", "newTopic": "Novo tópico", + "projectStatus.failed_one": "{{count}} tópico falhou", + "projectStatus.failed_other": "{{count}} tópicos falharam", + "projectStatus.loading_one": "{{count}} tópico carregando", + "projectStatus.loading_other": "{{count}} tópicos carregando", + "projectStatus.waitingForHuman_one": "{{count}} tópico aguardando entrada", + "projectStatus.waitingForHuman_other": "{{count}} tópicos aguardando entrada", "renameModal.description": "Mantenha curto e fácil de reconhecer.", "renameModal.title": "Renomear tópico", "searchPlaceholder": "Buscar Tópicos...", diff --git a/locales/ru-RU/chat.json b/locales/ru-RU/chat.json index 7970690298..f212a4144d 100644 --- a/locales/ru-RU/chat.json +++ b/locales/ru-RU/chat.json @@ -370,6 +370,14 @@ "noMatchingAgents": "Совпадений не найдено", "noMembersYet": "В этой группе пока нет участников. Нажмите +, чтобы пригласить агентов.", "noSelectedAgents": "Участники ещё не выбраны", + "opStatusTray.cost": "стоимость", + "opStatusTray.status.compressing": "Сжатие контекста", + "opStatusTray.status.generating": "Генерация", + "opStatusTray.status.reasoning": "Размышление", + "opStatusTray.status.searching": "Поиск", + "opStatusTray.status.toolCalling": "Вызов инструментов", + "opStatusTray.steps": "шаги", + "opStatusTray.tokens": "токены", "openInNewWindow": "Открыть в новом окне", "operation.contextCompression": "Контекст слишком длинный, выполняется сжатие истории...", "operation.execAgentRuntime": "Подготовка ответа", diff --git a/locales/ru-RU/device.json b/locales/ru-RU/device.json index e8da363e2a..53c1e5c187 100644 --- a/locales/ru-RU/device.json +++ b/locales/ru-RU/device.json @@ -17,6 +17,10 @@ "workingDirectory.createBranchAction": "Создать новую ветку…", "workingDirectory.createBranchTitle": "Создать новую ветку", "workingDirectory.current": "Текущая рабочая директория", + "workingDirectory.deleteBranchAction": "Удалить ветку", + "workingDirectory.deleteBranchConfirm": "Удалить ветку «{{name}}»? Это приведет к ее окончательному удалению, включая все несмёрженные коммиты.", + "workingDirectory.deleteBranchTitle": "Удалить ветку", + "workingDirectory.deleteFailed": "Не удалось удалить", "workingDirectory.detachedHead": "Открепленный HEAD на {{sha}}", "workingDirectory.diffStatTooltip": "Добавлено {{added}} · Изменено {{modified}} · Удалено {{deleted}}", "workingDirectory.filesAdded": "Добавлено", @@ -46,6 +50,9 @@ "workingDirectory.recent": "Недавние", "workingDirectory.refreshGitStatus": "Обновить статус ветки и PR", "workingDirectory.removeRecent": "Удалить из недавних", + "workingDirectory.renameBranchAction": "Переименовать ветку", + "workingDirectory.renameBranchTitle": "Переименовать ветку", + "workingDirectory.renameFailed": "Не удалось переименовать", "workingDirectory.selectFolder": "Выбрать папку", "workingDirectory.title": "Рабочая директория", "workingDirectory.topicDescription": "Переопределить значение по умолчанию агента только для этого разговора", diff --git a/locales/ru-RU/file.json b/locales/ru-RU/file.json index abf1fee703..ea499db089 100644 --- a/locales/ru-RU/file.json +++ b/locales/ru-RU/file.json @@ -94,6 +94,9 @@ "pageEditor.deleteSuccess": "Страница успешно удалена", "pageEditor.duplicateError": "Не удалось дублировать страницу", "pageEditor.duplicateSuccess": "Страница успешно дублирована", + "pageEditor.editMode.checking": "Проверка доступности редактирования…", + "pageEditor.editMode.lockedByOther": "{{name}} редактирует этот документ", + "pageEditor.editMode.lockedBySomeone": "Кто-то другой редактирует этот документ", "pageEditor.editedAt": "Последнее редактирование: {{time}}", "pageEditor.editedBy": "Последнее редактирование: {{name}}", "pageEditor.editorPlaceholder": "Нажмите «/» для ИИ и команд", @@ -131,6 +134,8 @@ "pageEditor.history.versionCount_one": "{{count}} версия", "pageEditor.history.versionCount_other": "{{count}} версий", "pageEditor.linkCopied": "Ссылка скопирована", + "pageEditor.lock.editingByOther": "{{name}} редактирует эту страницу. Ваши изменения не могут быть сохранены прямо сейчас.", + "pageEditor.lock.editingBySomeone": "Кто-то другой редактирует эту страницу. Ваши изменения не могут быть сохранены прямо сейчас.", "pageEditor.menu.copyLink": "Скопировать ссылку", "pageEditor.menu.export": "Экспорт", "pageEditor.menu.export.markdown": "Markdown", diff --git a/locales/ru-RU/modelProvider.json b/locales/ru-RU/modelProvider.json index c75bc56ce9..aebbf803da 100644 --- a/locales/ru-RU/modelProvider.json +++ b/locales/ru-RU/modelProvider.json @@ -307,6 +307,8 @@ "providerModels.list.enabledActions.sort": "Пользовательская сортировка моделей", "providerModels.list.enabledEmpty": "Нет включенных моделей. Пожалуйста, включите нужные модели из списка ниже~", "providerModels.list.fetcher.clear": "Очистить загруженные модели", + "providerModels.list.fetcher.error": "Не удалось получить модели: {{message}}", + "providerModels.list.fetcher.errorFallback": "Неизвестная ошибка", "providerModels.list.fetcher.fetch": "Загрузить модели", "providerModels.list.fetcher.fetching": "Загрузка списка моделей...", "providerModels.list.fetcher.latestTime": "Последнее обновление: {{time}}", diff --git a/locales/ru-RU/modelRuntime.json b/locales/ru-RU/modelRuntime.json index eb90a2df1c..a8981852ca 100644 --- a/locales/ru-RU/modelRuntime.json +++ b/locales/ru-RU/modelRuntime.json @@ -35,6 +35,7 @@ "QuotaLimitReached": "Извините, использование токенов или количество запросов достигли лимита квоты для этого ключа. Пожалуйста, увеличьте квоту ключа или повторите попытку позже.", "RateLimitExceeded": "Извините, использование токенов или количество запросов достигли лимита скорости для этого ключа. Пожалуйста, повторите попытку позже или увеличьте квоту ключа.", "StateStorePersistError": "Временная проблема с хранилищем состояния разговора прервала эту операцию. Пожалуйста, повторите попытку; если проблема сохраняется, свяжитесь с поддержкой.", + "StateStoreReadError": "Эта операция не может быть продолжена, так как состояние сеанса недоступно. Пожалуйста, откройте разговор заново, чтобы продолжить; если проблема сохраняется, обратитесь в службу поддержки.", "StreamChunkError": "Ошибка при разборе фрагмента сообщения потокового запроса. Пожалуйста, проверьте, соответствует ли текущий API-интерфейс стандартным спецификациям, или свяжитесь с вашим провайдером API для получения помощи.", "UpstreamGatewayError": "Верхний шлюз или прокси вернули ошибку. Пожалуйста, повторите попытку позже; если проблема сохраняется, проверьте конфигурацию прокси/конечной точки.", "UpstreamHttpError": "Провайдер вернул HTTP-ошибку без дополнительных деталей. Пожалуйста, повторите попытку или проверьте ваш запрос и конфигурацию модели.", diff --git a/locales/ru-RU/opStatusTray.json b/locales/ru-RU/opStatusTray.json new file mode 100644 index 0000000000..37f6d94318 --- /dev/null +++ b/locales/ru-RU/opStatusTray.json @@ -0,0 +1,34 @@ +{ + "generatingPhrases": [ + "Работаю", + "Составляю", + "Думаю", + "Вычисляю", + "Завариваю", + "Синтезирую", + "Обрабатываю", + "Проектирую", + "Сочиняю", + "Организую", + "Рисую", + "Фантазирую", + "Размышляю", + "Создаю", + "Фламбирую", + "Томлю", + "Жужжу", + "Управляю", + "Полирую", + "Готовлю ответ", + "Пеку", + "Направляю", + "Объединяю", + "Расшифровываю", + "Кую", + "Гармонизирую", + "Импровизирую", + "Делаю выводы", + "Мастерю", + "Зигзагообразно двигаюсь" + ] +} diff --git a/locales/ru-RU/setting.json b/locales/ru-RU/setting.json index 0b76c8ea50..7d5b91e096 100644 --- a/locales/ru-RU/setting.json +++ b/locales/ru-RU/setting.json @@ -1186,6 +1186,9 @@ "tools.klavis.notEnabled": "Служба Klavis не включена", "tools.klavis.oauthRequired": "Пожалуйста, завершите OAuth-аутентификацию в новом окне", "tools.klavis.pendingAuth": "Ожидание аутентификации", + "tools.klavis.remove": "Удалить", + "tools.klavis.removeConfirm.desc": "{{name}} будет навсегда удален из ваших подключенных сервисов. Это действие нельзя отменить.", + "tools.klavis.removeConfirm.title": "Удалить {{name}}?", "tools.klavis.serverCreated": "Сервер успешно создан", "tools.klavis.serverCreatedFailed": "Не удалось создать сервер", "tools.klavis.serverRemoved": "Сервер удалён", diff --git a/locales/ru-RU/topic.json b/locales/ru-RU/topic.json index 3d2221ff9b..464aee562a 100644 --- a/locales/ru-RU/topic.json +++ b/locales/ru-RU/topic.json @@ -135,6 +135,12 @@ "management.view.card": "Карточка", "management.view.list": "Список", "newTopic": "Новая тема", + "projectStatus.failed_one": "{{count}} неудачная тема", + "projectStatus.failed_other": "{{count}} неудачных тем", + "projectStatus.loading_one": "{{count}} загружаемая тема", + "projectStatus.loading_other": "{{count}} загружаемых тем", + "projectStatus.waitingForHuman_one": "{{count}} тема ожидает ввода", + "projectStatus.waitingForHuman_other": "{{count}} тем ожидают ввода", "renameModal.description": "Сделайте название коротким и легко узнаваемым.", "renameModal.title": "Переименовать тему", "searchPlaceholder": "Поиск тем...", diff --git a/locales/tr-TR/chat.json b/locales/tr-TR/chat.json index 76222f96e0..cfad1ca078 100644 --- a/locales/tr-TR/chat.json +++ b/locales/tr-TR/chat.json @@ -370,6 +370,14 @@ "noMatchingAgents": "Eşleşen üye bulunamadı", "noMembersYet": "Bu grupta henüz üye yok. Ajan davet etmek için + butonuna tıklayın.", "noSelectedAgents": "Henüz üye seçilmedi", + "opStatusTray.cost": "maliyet", + "opStatusTray.status.compressing": "Bağlam sıkıştırılıyor", + "opStatusTray.status.generating": "Oluşturuluyor", + "opStatusTray.status.reasoning": "Düşünülüyor", + "opStatusTray.status.searching": "Aranıyor", + "opStatusTray.status.toolCalling": "Araçlar çağrılıyor", + "opStatusTray.steps": "adımlar", + "opStatusTray.tokens": "jetonlar", "openInNewWindow": "Yeni Pencerede Aç", "operation.contextCompression": "Bağlam çok uzun, geçmiş sıkıştırılıyor...", "operation.execAgentRuntime": "Yanıt hazırlanıyor", diff --git a/locales/tr-TR/device.json b/locales/tr-TR/device.json index 3d2d814b2b..9954bd6daa 100644 --- a/locales/tr-TR/device.json +++ b/locales/tr-TR/device.json @@ -17,6 +17,10 @@ "workingDirectory.createBranchAction": "Yeni dal oluştur ve geçiş yap…", "workingDirectory.createBranchTitle": "Yeni dal oluştur", "workingDirectory.current": "Mevcut çalışma dizini", + "workingDirectory.deleteBranchAction": "Dalı sil", + "workingDirectory.deleteBranchConfirm": "Dal “{{name}}” silinsin mi? Bu işlem, birleştirilmemiş commit'ler dahil olmak üzere dalı kalıcı olarak kaldırır.", + "workingDirectory.deleteBranchTitle": "Dal sil", + "workingDirectory.deleteFailed": "Silme başarısız oldu", "workingDirectory.detachedHead": "{{sha}} üzerinde ayrık HEAD", "workingDirectory.diffStatTooltip": "{{added}} eklendi · {{modified}} değiştirildi · {{deleted}} silindi", "workingDirectory.filesAdded": "Eklendi", @@ -46,6 +50,9 @@ "workingDirectory.recent": "Son", "workingDirectory.refreshGitStatus": "Dal ve PR durumunu yenile", "workingDirectory.removeRecent": "Son öğelerden kaldır", + "workingDirectory.renameBranchAction": "Dalı yeniden adlandır", + "workingDirectory.renameBranchTitle": "Dalı yeniden adlandır", + "workingDirectory.renameFailed": "Yeniden adlandırma başarısız oldu", "workingDirectory.selectFolder": "Klasör seç", "workingDirectory.title": "Çalışma Dizini", "workingDirectory.topicDescription": "Sadece bu konuşma için Agent varsayılanını geçersiz kıl", diff --git a/locales/tr-TR/file.json b/locales/tr-TR/file.json index d307ec7e95..820f0966de 100644 --- a/locales/tr-TR/file.json +++ b/locales/tr-TR/file.json @@ -94,6 +94,9 @@ "pageEditor.deleteSuccess": "Sayfa başarıyla silindi", "pageEditor.duplicateError": "Sayfa kopyalanamadı", "pageEditor.duplicateSuccess": "Sayfa başarıyla kopyalandı", + "pageEditor.editMode.checking": "Düzenleme uygunluğu kontrol ediliyor…", + "pageEditor.editMode.lockedByOther": "{{name}} bu belgeyi düzenliyor", + "pageEditor.editMode.lockedBySomeone": "Başka biri bu belgeyi düzenliyor", "pageEditor.editedAt": "Son düzenleme: {{time}}", "pageEditor.editedBy": "Son düzenleyen: {{name}}", "pageEditor.editorPlaceholder": "AI ve komutlar için \"/\" tuşuna basın", @@ -131,6 +134,8 @@ "pageEditor.history.versionCount_one": "{{count}} sürüm", "pageEditor.history.versionCount_other": "{{count}} sürüm", "pageEditor.linkCopied": "Bağlantı kopyalandı", + "pageEditor.lock.editingByOther": "{{name}} bu sayfayı düzenliyor. Şu anda değişiklikleriniz kaydedilemiyor.", + "pageEditor.lock.editingBySomeone": "Başka biri bu sayfayı düzenliyor. Şu anda değişiklikleriniz kaydedilemiyor.", "pageEditor.menu.copyLink": "Bağlantıyı Kopyala", "pageEditor.menu.export": "Dışa Aktar", "pageEditor.menu.export.markdown": "Markdown", diff --git a/locales/tr-TR/modelProvider.json b/locales/tr-TR/modelProvider.json index 58d0719197..446d2d75e4 100644 --- a/locales/tr-TR/modelProvider.json +++ b/locales/tr-TR/modelProvider.json @@ -307,6 +307,8 @@ "providerModels.list.enabledActions.sort": "Özel Model Sıralaması", "providerModels.list.enabledEmpty": "Etkin model bulunamadı. Lütfen aşağıdaki listeden tercih ettiğiniz modelleri etkinleştirin~", "providerModels.list.fetcher.clear": "Getirilen modelleri temizle", + "providerModels.list.fetcher.error": "Modeller alınamadı: {{message}}", + "providerModels.list.fetcher.errorFallback": "Bilinmeyen hata", "providerModels.list.fetcher.fetch": "Modelleri getir", "providerModels.list.fetcher.fetching": "Model listesi getiriliyor...", "providerModels.list.fetcher.latestTime": "Son güncelleme: {{time}}", diff --git a/locales/tr-TR/modelRuntime.json b/locales/tr-TR/modelRuntime.json index 51d7dd8329..fba6435e82 100644 --- a/locales/tr-TR/modelRuntime.json +++ b/locales/tr-TR/modelRuntime.json @@ -35,6 +35,7 @@ "QuotaLimitReached": "Üzgünüz, jeton kullanımı veya istek sayısı bu anahtar için kota sınırına ulaştı. Lütfen anahtarın kotasını artırın veya daha sonra yeniden deneyin.", "RateLimitExceeded": "Üzgünüz, jeton kullanımı veya istek sayısı bu anahtar için hız sınırına ulaştı. Lütfen daha sonra yeniden deneyin veya anahtarın kotasını artırın.", "StateStorePersistError": "Konuşma durumu deposundaki geçici bir sorun bu işlemi kesintiye uğrattı. Lütfen yeniden deneyin; devam ederse destekle iletişime geçin.", + "StateStoreReadError": "Bu işlem devam ettirilemedi çünkü oturum durumu kullanılamıyordu. Devam etmek için lütfen konuşmayı yeniden açın; sorun devam ederse, destek ile iletişime geçin.", "StreamChunkError": "Akış isteğinin mesaj parçası ayrıştırılırken hata oluştu. Lütfen mevcut API arayüzünün standart spesifikasyonlara uygun olup olmadığını kontrol edin veya API sağlayıcınızdan yardım alın.", "UpstreamGatewayError": "Yukarı akış ağ geçidi veya proxy bir hata döndürdü. Lütfen kısa bir süre sonra yeniden deneyin; devam ederse proxy / uç nokta yapılandırmanızı kontrol edin.", "UpstreamHttpError": "Sağlayıcı ayrıntı vermeden bir HTTP hatası döndürdü. Lütfen yeniden deneyin veya isteğinizi ve model yapılandırmanızı kontrol edin.", diff --git a/locales/tr-TR/opStatusTray.json b/locales/tr-TR/opStatusTray.json new file mode 100644 index 0000000000..8028184d14 --- /dev/null +++ b/locales/tr-TR/opStatusTray.json @@ -0,0 +1,34 @@ +{ + "generatingPhrases": [ + "Çalışıyor", + "Taslak hazırlıyor", + "Düşünüyor", + "Hesaplıyor", + "Demliyor", + "Sentezliyor", + "Hesap yapıyor", + "Mimari tasarlıyor", + "Besteliyor", + "Orkestralıyor", + "Eskiz yapıyor", + "Karalıyor", + "Düşünüp taşınıyor", + "İşliyor", + "Alevlendiriyor", + "Kaynatıyor", + "Vızıldıyor", + "Dizginliyor", + "Parlatıyor", + "Cevabı hazırlıyor", + "Pişiriyor", + "Kanalize ediyor", + "Birleştiriyor", + "Çözümlüyor", + "Şekillendiriyor", + "Uyum sağlıyor", + "Doğaçlama yapıyor", + "Çıkarım yapıyor", + "İnce ayar yapıyor", + "Zikzak çiziyor" + ] +} diff --git a/locales/tr-TR/setting.json b/locales/tr-TR/setting.json index 1d7b152246..66c10aacce 100644 --- a/locales/tr-TR/setting.json +++ b/locales/tr-TR/setting.json @@ -1186,6 +1186,9 @@ "tools.klavis.notEnabled": "Klavis hizmeti etkin değil", "tools.klavis.oauthRequired": "Lütfen yeni pencerede OAuth kimlik doğrulamasını tamamlayın", "tools.klavis.pendingAuth": "Bekleyen Kimlik Doğrulama", + "tools.klavis.remove": "Kaldır", + "tools.klavis.removeConfirm.desc": "{{name}} bağlı hizmetlerinizden kalıcı olarak kaldırılacaktır. Bu işlem geri alınamaz.", + "tools.klavis.removeConfirm.title": "{{name}} kaldırılacak mı?", "tools.klavis.serverCreated": "Sunucu başarıyla oluşturuldu", "tools.klavis.serverCreatedFailed": "Sunucu oluşturulamadı", "tools.klavis.serverRemoved": "Sunucu kaldırıldı", diff --git a/locales/tr-TR/topic.json b/locales/tr-TR/topic.json index 22a2b9f509..0466d76191 100644 --- a/locales/tr-TR/topic.json +++ b/locales/tr-TR/topic.json @@ -135,6 +135,12 @@ "management.view.card": "Kart", "management.view.list": "Liste", "newTopic": "Yeni Konu", + "projectStatus.failed_one": "{{count}} başarısız konu", + "projectStatus.failed_other": "{{count}} başarısız konu", + "projectStatus.loading_one": "{{count}} yükleniyor konu", + "projectStatus.loading_other": "{{count}} yükleniyor konu", + "projectStatus.waitingForHuman_one": "{{count}} konu yanıt bekliyor", + "projectStatus.waitingForHuman_other": "{{count}} konu yanıt bekliyor", "renameModal.description": "Kısa ve kolay tanınabilir olsun.", "renameModal.title": "Konuyu Yeniden Adlandır", "searchPlaceholder": "Konularda Ara...", diff --git a/locales/vi-VN/chat.json b/locales/vi-VN/chat.json index 89520e8a55..da907547c8 100644 --- a/locales/vi-VN/chat.json +++ b/locales/vi-VN/chat.json @@ -370,6 +370,14 @@ "noMatchingAgents": "Không tìm thấy thành viên phù hợp", "noMembersYet": "Nhóm này chưa có thành viên. Nhấn nút + để mời tác nhân.", "noSelectedAgents": "Chưa chọn thành viên nào", + "opStatusTray.cost": "chi phí", + "opStatusTray.status.compressing": "Đang nén ngữ cảnh", + "opStatusTray.status.generating": "Đang tạo", + "opStatusTray.status.reasoning": "Đang suy nghĩ", + "opStatusTray.status.searching": "Đang tìm kiếm", + "opStatusTray.status.toolCalling": "Đang gọi công cụ", + "opStatusTray.steps": "bước", + "opStatusTray.tokens": "mã token", "openInNewWindow": "Mở trong cửa sổ mới", "operation.contextCompression": "Ngữ cảnh quá dài, đang nén lịch sử...", "operation.execAgentRuntime": "Đang chuẩn bị phản hồi", diff --git a/locales/vi-VN/device.json b/locales/vi-VN/device.json index 1155992d0b..ba59c6ffdf 100644 --- a/locales/vi-VN/device.json +++ b/locales/vi-VN/device.json @@ -17,6 +17,10 @@ "workingDirectory.createBranchAction": "Chuyển sang nhánh mới…", "workingDirectory.createBranchTitle": "Tạo nhánh mới", "workingDirectory.current": "Thư mục làm việc hiện tại", + "workingDirectory.deleteBranchAction": "Xóa nhánh", + "workingDirectory.deleteBranchConfirm": "Xóa nhánh “{{name}}”? Điều này sẽ xóa vĩnh viễn, bao gồm cả các commit chưa được hợp nhất.", + "workingDirectory.deleteBranchTitle": "Xóa nhánh", + "workingDirectory.deleteFailed": "Xóa không thành công", "workingDirectory.detachedHead": "HEAD tách biệt tại {{sha}}", "workingDirectory.diffStatTooltip": "Đã thêm {{added}} · Đã sửa đổi {{modified}} · Đã xóa {{deleted}}", "workingDirectory.filesAdded": "Đã thêm", @@ -46,6 +50,9 @@ "workingDirectory.recent": "Gần đây", "workingDirectory.refreshGitStatus": "Làm mới trạng thái nhánh & yêu cầu kéo", "workingDirectory.removeRecent": "Xóa khỏi gần đây", + "workingDirectory.renameBranchAction": "Đổi tên nhánh", + "workingDirectory.renameBranchTitle": "Đổi tên nhánh", + "workingDirectory.renameFailed": "Đổi tên không thành công", "workingDirectory.selectFolder": "Chọn thư mục", "workingDirectory.title": "Thư mục làm việc", "workingDirectory.topicDescription": "Ghi đè mặc định của Agent cho cuộc trò chuyện này", diff --git a/locales/vi-VN/file.json b/locales/vi-VN/file.json index 37ab4d0b83..807482da49 100644 --- a/locales/vi-VN/file.json +++ b/locales/vi-VN/file.json @@ -94,6 +94,9 @@ "pageEditor.deleteSuccess": "Đã xóa trang thành công", "pageEditor.duplicateError": "Không thể sao chép trang", "pageEditor.duplicateSuccess": "Đã sao chép trang thành công", + "pageEditor.editMode.checking": "Đang kiểm tra khả năng chỉnh sửa…", + "pageEditor.editMode.lockedByOther": "{{name}} đang chỉnh sửa tài liệu này", + "pageEditor.editMode.lockedBySomeone": "Người khác đang chỉnh sửa tài liệu này", "pageEditor.editedAt": "Chỉnh sửa lần cuối vào {{time}}", "pageEditor.editedBy": "Chỉnh sửa lần cuối bởi {{name}}", "pageEditor.editorPlaceholder": "Nhấn \"/\" cho AI và lệnh", @@ -131,6 +134,8 @@ "pageEditor.history.versionCount_one": "{{count}} phiên bản", "pageEditor.history.versionCount_other": "{{count}} phiên bản", "pageEditor.linkCopied": "Đã sao chép liên kết", + "pageEditor.lock.editingByOther": "{{name}} đang chỉnh sửa trang này. Thay đổi của bạn hiện không thể lưu được.", + "pageEditor.lock.editingBySomeone": "Người khác đang chỉnh sửa trang này. Thay đổi của bạn hiện không thể lưu được.", "pageEditor.menu.copyLink": "Sao Chép Liên Kết", "pageEditor.menu.export": "Xuất", "pageEditor.menu.export.markdown": "Markdown", diff --git a/locales/vi-VN/modelProvider.json b/locales/vi-VN/modelProvider.json index e1e5592041..ebe2e1707f 100644 --- a/locales/vi-VN/modelProvider.json +++ b/locales/vi-VN/modelProvider.json @@ -307,6 +307,8 @@ "providerModels.list.enabledActions.sort": "Sắp xếp mô hình tùy chỉnh", "providerModels.list.enabledEmpty": "Không có mô hình nào được bật. Vui lòng bật các mô hình bạn muốn sử dụng từ danh sách bên dưới~", "providerModels.list.fetcher.clear": "Xóa mô hình đã lấy", + "providerModels.list.fetcher.error": "Không thể lấy danh sách mô hình: {{message}}", + "providerModels.list.fetcher.errorFallback": "Lỗi không xác định", "providerModels.list.fetcher.fetch": "Lấy mô hình", "providerModels.list.fetcher.fetching": "Đang lấy danh sách mô hình...", "providerModels.list.fetcher.latestTime": "Cập nhật lần cuối: {{time}}", diff --git a/locales/vi-VN/modelRuntime.json b/locales/vi-VN/modelRuntime.json index a4347dfda8..e37f0e8742 100644 --- a/locales/vi-VN/modelRuntime.json +++ b/locales/vi-VN/modelRuntime.json @@ -35,6 +35,7 @@ "QuotaLimitReached": "Rất tiếc, việc sử dụng token hoặc số lượng yêu cầu đã đạt đến giới hạn hạn mức cho khóa này. Vui lòng tăng hạn mức của khóa hoặc thử lại sau.", "RateLimitExceeded": "Rất tiếc, việc sử dụng token hoặc số lượng yêu cầu đã đạt đến giới hạn tốc độ cho khóa này. Vui lòng thử lại sau hoặc tăng hạn mức của khóa.", "StateStorePersistError": "Một vấn đề tạm thời với kho lưu trữ trạng thái hội thoại đã làm gián đoạn hoạt động này. Vui lòng thử lại; nếu vấn đề vẫn tiếp diễn, hãy liên hệ với bộ phận hỗ trợ.", + "StateStoreReadError": "Không thể tiếp tục thao tác này vì trạng thái phiên không khả dụng. Vui lòng mở lại cuộc trò chuyện để tiếp tục; nếu vấn đề vẫn xảy ra, hãy liên hệ với bộ phận hỗ trợ.", "StreamChunkError": "Lỗi phân tích đoạn tin nhắn của yêu cầu streaming. Vui lòng kiểm tra xem giao diện API hiện tại có tuân thủ các tiêu chuẩn quy định hay không, hoặc liên hệ với nhà cung cấp API của bạn để được hỗ trợ.", "UpstreamGatewayError": "Cổng hoặc proxy đầu nguồn trả về lỗi. Vui lòng thử lại sau; nếu vấn đề vẫn tiếp diễn, hãy kiểm tra cấu hình proxy/endpoint của bạn.", "UpstreamHttpError": "Nhà cung cấp trả về lỗi HTTP mà không có chi tiết thêm. Vui lòng thử lại, hoặc kiểm tra yêu cầu và cấu hình mô hình của bạn.", diff --git a/locales/vi-VN/opStatusTray.json b/locales/vi-VN/opStatusTray.json new file mode 100644 index 0000000000..26ad303210 --- /dev/null +++ b/locales/vi-VN/opStatusTray.json @@ -0,0 +1,34 @@ +{ + "generatingPhrases": [ + "Đang làm việc", + "Đang phác thảo", + "Đang suy nghĩ", + "Đang tính toán", + "Đang pha chế", + "Đang tổng hợp", + "Đang xử lý", + "Đang kiến tạo", + "Đang sáng tác", + "Đang phối hợp", + "Đang phác họa", + "Đang thử nghiệm", + "Đang cân nhắc", + "Đang chế tác", + "Đang nướng lửa lớn", + "Đang hầm", + "Đang quay vòng", + "Đang điều chỉnh", + "Đang hoàn thiện", + "Đang chuẩn bị câu trả lời", + "Đang nướng bánh", + "Đang truyền tải", + "Đang kết hợp", + "Đang giải mã", + "Đang rèn luyện", + "Đang hòa âm", + "Đang ứng biến", + "Đang suy luận", + "Đang tinh chỉnh", + "Đang zigzag" + ] +} diff --git a/locales/vi-VN/setting.json b/locales/vi-VN/setting.json index 201949defb..e018af2358 100644 --- a/locales/vi-VN/setting.json +++ b/locales/vi-VN/setting.json @@ -1186,6 +1186,9 @@ "tools.klavis.notEnabled": "Dịch vụ Klavis chưa được bật", "tools.klavis.oauthRequired": "Vui lòng hoàn tất xác thực OAuth trong cửa sổ mới", "tools.klavis.pendingAuth": "Đang Chờ Xác Thực", + "tools.klavis.remove": "Xóa", + "tools.klavis.removeConfirm.desc": "{{name}} sẽ bị xóa vĩnh viễn khỏi các dịch vụ được kết nối của bạn. Hành động này không thể hoàn tác.", + "tools.klavis.removeConfirm.title": "Xóa {{name}}?", "tools.klavis.serverCreated": "Tạo máy chủ thành công", "tools.klavis.serverCreatedFailed": "Tạo máy chủ thất bại", "tools.klavis.serverRemoved": "Đã xóa máy chủ", diff --git a/locales/vi-VN/topic.json b/locales/vi-VN/topic.json index 3325c65d7c..a588ae4812 100644 --- a/locales/vi-VN/topic.json +++ b/locales/vi-VN/topic.json @@ -135,6 +135,12 @@ "management.view.card": "Thẻ", "management.view.list": "Danh sách", "newTopic": "Chủ đề mới", + "projectStatus.failed_one": "{{count}} chủ đề thất bại", + "projectStatus.failed_other": "{{count}} chủ đề thất bại", + "projectStatus.loading_one": "{{count}} chủ đề đang tải", + "projectStatus.loading_other": "{{count}} chủ đề đang tải", + "projectStatus.waitingForHuman_one": "{{count}} chủ đề đang chờ đầu vào", + "projectStatus.waitingForHuman_other": "{{count}} chủ đề đang chờ đầu vào", "renameModal.description": "Giữ tiêu đề ngắn gọn và dễ nhận biết.", "renameModal.title": "Đổi tên chủ đề", "searchPlaceholder": "Tìm kiếm chủ đề...", diff --git a/locales/zh-CN/chat.json b/locales/zh-CN/chat.json index b53053a75d..d8300cbaf6 100644 --- a/locales/zh-CN/chat.json +++ b/locales/zh-CN/chat.json @@ -370,12 +370,12 @@ "noMatchingAgents": "未找到匹配的成员", "noMembersYet": "这个群组还没有成员。点击「+」邀请助理加入", "noSelectedAgents": "还未选择成员", + "opStatusTray.cost": "花费", "opStatusTray.status.compressing": "压缩上下文中", "opStatusTray.status.generating": "生成中", "opStatusTray.status.reasoning": "思考中", "opStatusTray.status.searching": "检索中", "opStatusTray.status.toolCalling": "调用工具中", - "opStatusTray.cost": "花费", "opStatusTray.steps": "步", "opStatusTray.tokens": "tokens", "openInNewWindow": "在新窗口打开", diff --git a/locales/zh-CN/file.json b/locales/zh-CN/file.json index 7ce2c78892..e5bcca7982 100644 --- a/locales/zh-CN/file.json +++ b/locales/zh-CN/file.json @@ -94,6 +94,9 @@ "pageEditor.deleteSuccess": "文稿删除成功", "pageEditor.duplicateError": "页面复制失败", "pageEditor.duplicateSuccess": "页面复制成功", + "pageEditor.editMode.checking": "正在检查可编辑状态…", + "pageEditor.editMode.lockedByOther": "{{name}} 正在编辑此文档", + "pageEditor.editMode.lockedBySomeone": "其他人正在编辑此文档", "pageEditor.editedAt": "最后编辑于 {{time}}", "pageEditor.editedBy": "最后编辑者 {{name}}", "pageEditor.editorPlaceholder": "按 \"/\" 使用 AI 和命令", @@ -131,6 +134,8 @@ "pageEditor.history.versionCount_one": "共 {{count}} 个版本", "pageEditor.history.versionCount_other": "共 {{count}} 个版本", "pageEditor.linkCopied": "链接已复制", + "pageEditor.lock.editingByOther": "{{name}} 正在编辑此页面,你的更改暂时无法保存。", + "pageEditor.lock.editingBySomeone": "其他人正在编辑此页面,你的更改暂时无法保存。", "pageEditor.menu.copyLink": "复制链接", "pageEditor.menu.export": "导出", "pageEditor.menu.export.markdown": "Markdown 格式", diff --git a/locales/zh-CN/modelRuntime.json b/locales/zh-CN/modelRuntime.json index 9b4655ce71..320dfe20c3 100644 --- a/locales/zh-CN/modelRuntime.json +++ b/locales/zh-CN/modelRuntime.json @@ -35,6 +35,7 @@ "QuotaLimitReached": "抱歉,此密钥的令牌使用量或请求次数已达到配额限制。请增加密钥配额或稍后重试。", "RateLimitExceeded": "抱歉,此密钥的令牌使用量或请求次数已达到速率限制。请稍后重试或增加密钥配额。", "StateStorePersistError": "对话状态存储的临时问题中断了此操作。请重试;如果问题仍然存在,请联系支持。", + "StateStoreReadError": "此操作无法继续,因为会话状态不可用。请重新打开对话以继续;如果问题仍然存在,请联系支持人员。", "StreamChunkError": "解析流式请求的消息块时发生错误。请检查当前API接口是否符合标准规范,或联系您的API提供商以获取帮助。", "UpstreamGatewayError": "上游网关或代理返回错误。请稍后重试;如果问题仍然存在,请检查您的代理/端点配置。", "UpstreamHttpError": "提供商返回了HTTP错误,但未提供进一步详细信息。请重试,或检查您的请求和模型配置。", diff --git a/locales/zh-CN/setting.json b/locales/zh-CN/setting.json index 6fae2ab896..e34cd876ac 100644 --- a/locales/zh-CN/setting.json +++ b/locales/zh-CN/setting.json @@ -1179,9 +1179,6 @@ "tools.klavis.disconnect": "断开连接", "tools.klavis.disconnected": "已断开连接", "tools.klavis.error": "错误", - "tools.klavis.remove": "移除", - "tools.klavis.removeConfirm.desc": "{{name}} 将从您的已连接服务中永久移除,此操作不可撤销。", - "tools.klavis.removeConfirm.title": "移除 {{name}}?", "tools.klavis.groupName": "Klavis 工具", "tools.klavis.manage": "管理 Klavis", "tools.klavis.manageTitle": "管理 Klavis 集成", @@ -1189,6 +1186,9 @@ "tools.klavis.notEnabled": "Klavis 服务未启用", "tools.klavis.oauthRequired": "请在新窗口中完成 OAuth 认证", "tools.klavis.pendingAuth": "待认证", + "tools.klavis.remove": "移除", + "tools.klavis.removeConfirm.desc": "{{name}} 将从您的已连接服务中永久移除,此操作不可撤销。", + "tools.klavis.removeConfirm.title": "移除 {{name}}?", "tools.klavis.serverCreated": "服务器创建成功", "tools.klavis.serverCreatedFailed": "服务器创建失败", "tools.klavis.serverRemoved": "服务器已删除", diff --git a/locales/zh-TW/chat.json b/locales/zh-TW/chat.json index 58c62fbe00..f2dc4971b9 100644 --- a/locales/zh-TW/chat.json +++ b/locales/zh-TW/chat.json @@ -370,6 +370,14 @@ "noMatchingAgents": "沒有符合的成員", "noMembersYet": "此群組尚未有成員。請點選 + 按鈕邀請助理。", "noSelectedAgents": "尚未選擇成員", + "opStatusTray.cost": "成本", + "opStatusTray.status.compressing": "壓縮內容", + "opStatusTray.status.generating": "生成中", + "opStatusTray.status.reasoning": "思考中", + "opStatusTray.status.searching": "搜尋中", + "opStatusTray.status.toolCalling": "呼叫工具", + "opStatusTray.steps": "步驟", + "opStatusTray.tokens": "標記", "openInNewWindow": "開啟獨立視窗", "operation.contextCompression": "內容過長,正在壓縮歷史記錄⋯⋯", "operation.execAgentRuntime": "正在準備回應", diff --git a/locales/zh-TW/device.json b/locales/zh-TW/device.json index 6c569213b5..73e40c6af1 100644 --- a/locales/zh-TW/device.json +++ b/locales/zh-TW/device.json @@ -17,6 +17,10 @@ "workingDirectory.createBranchAction": "切換到新分支…", "workingDirectory.createBranchTitle": "建立新分支", "workingDirectory.current": "目前工作目錄", + "workingDirectory.deleteBranchAction": "刪除分支", + "workingDirectory.deleteBranchConfirm": "確定要刪除分支「{{name}}」嗎?這將永久移除該分支,包括任何未合併的提交。", + "workingDirectory.deleteBranchTitle": "刪除分支", + "workingDirectory.deleteFailed": "刪除失敗", "workingDirectory.detachedHead": "分離的 HEAD 位於 {{sha}}", "workingDirectory.diffStatTooltip": "新增 {{added}} · 修改 {{modified}} · 刪除 {{deleted}}", "workingDirectory.filesAdded": "新增", @@ -46,6 +50,9 @@ "workingDirectory.recent": "最近", "workingDirectory.refreshGitStatus": "刷新分支與拉取請求狀態", "workingDirectory.removeRecent": "從最近移除", + "workingDirectory.renameBranchAction": "重新命名分支", + "workingDirectory.renameBranchTitle": "重新命名分支", + "workingDirectory.renameFailed": "重新命名失敗", "workingDirectory.selectFolder": "選擇資料夾", "workingDirectory.title": "工作目錄", "workingDirectory.topicDescription": "僅針對此對話覆蓋代理預設", diff --git a/locales/zh-TW/file.json b/locales/zh-TW/file.json index a87cb93234..cbdb664881 100644 --- a/locales/zh-TW/file.json +++ b/locales/zh-TW/file.json @@ -94,6 +94,9 @@ "pageEditor.deleteSuccess": "文稿刪除成功", "pageEditor.duplicateError": "頁面複製失敗", "pageEditor.duplicateSuccess": "頁面複製成功", + "pageEditor.editMode.checking": "正在檢查編輯可用性…", + "pageEditor.editMode.lockedByOther": "{{name}} 正在編輯此文件", + "pageEditor.editMode.lockedBySomeone": "其他人正在編輯此文件", "pageEditor.editedAt": "最後編輯於 {{time}}", "pageEditor.editedBy": "最後編輯者 {{name}}", "pageEditor.editorPlaceholder": "按 \"/\" 使用 AI 和命令", @@ -131,6 +134,8 @@ "pageEditor.history.versionCount_one": "{{count}} 個版本", "pageEditor.history.versionCount_other": "{{count}} 個版本", "pageEditor.linkCopied": "連結已複製", + "pageEditor.lock.editingByOther": "{{name}} 正在編輯此頁面。您的更改目前無法保存。", + "pageEditor.lock.editingBySomeone": "其他人正在編輯此頁面。您的更改目前無法保存。", "pageEditor.menu.copyLink": "複製連結", "pageEditor.menu.export": "匯出", "pageEditor.menu.export.markdown": "Markdown", diff --git a/locales/zh-TW/modelProvider.json b/locales/zh-TW/modelProvider.json index b8050ad08e..0afb9d9da4 100644 --- a/locales/zh-TW/modelProvider.json +++ b/locales/zh-TW/modelProvider.json @@ -307,6 +307,8 @@ "providerModels.list.enabledActions.sort": "自訂模型排序", "providerModels.list.enabledEmpty": "暫無啟用模型,請從下方列表中啟用心儀的模型吧~", "providerModels.list.fetcher.clear": "清除取得的模型", + "providerModels.list.fetcher.error": "無法取得模型:{{message}}", + "providerModels.list.fetcher.errorFallback": "未知錯誤", "providerModels.list.fetcher.fetch": "取得模型列表", "providerModels.list.fetcher.fetching": "正在取得模型列表...", "providerModels.list.fetcher.latestTime": "上次更新時間:{{time}}", diff --git a/locales/zh-TW/modelRuntime.json b/locales/zh-TW/modelRuntime.json index f3fe8e9be0..0d4e649fb2 100644 --- a/locales/zh-TW/modelRuntime.json +++ b/locales/zh-TW/modelRuntime.json @@ -35,6 +35,7 @@ "QuotaLimitReached": "抱歉,此金鑰的令牌使用量或請求次數已達配額上限。請增加金鑰配額或稍後重試。", "RateLimitExceeded": "抱歉,此金鑰的令牌使用量或請求次數已達速率限制。請稍後重試或增加金鑰配額。", "StateStorePersistError": "對話狀態存儲的臨時問題中斷了此操作。請重試;如果問題持續,請聯繫支援。", + "StateStoreReadError": "此操作無法繼續,因為其會話狀態無法取得。請重新開啟對話以繼續;如果問題持續存在,請聯絡支援。", "StreamChunkError": "解析流式請求的消息塊時發生錯誤。請檢查當前 API 接口是否符合標準規範,或聯繫您的 API 提供者以獲得協助。", "UpstreamGatewayError": "上游網關或代理返回錯誤。請稍後重試;如果問題持續,請檢查您的代理/端點配置。", "UpstreamHttpError": "提供者返回了 HTTP 錯誤但未提供更多細節。請重試,或檢查您的請求和模型配置。", diff --git a/locales/zh-TW/opStatusTray.json b/locales/zh-TW/opStatusTray.json new file mode 100644 index 0000000000..67fbdf1d48 --- /dev/null +++ b/locales/zh-TW/opStatusTray.json @@ -0,0 +1,34 @@ +{ + "generatingPhrases": [ + "工作中", + "起草中", + "思考中", + "計算中", + "釀造中", + "合成中", + "運算中", + "構築中", + "編寫中", + "編排中", + "繪製中", + "隨意嘗試中", + "沉思中", + "精心製作中", + "火焰炙燒中", + "慢煮中", + "嗡嗡運轉中", + "處理中", + "打磨中", + "準備答案中", + "烘焙中", + "引導中", + "凝聚中", + "解碼中", + "鍛造中", + "協調中", + "即興創作中", + "推斷中", + "修補中", + "曲折前進中" + ] +} diff --git a/locales/zh-TW/setting.json b/locales/zh-TW/setting.json index 6247d3f5d4..9c0685c65a 100644 --- a/locales/zh-TW/setting.json +++ b/locales/zh-TW/setting.json @@ -1186,6 +1186,9 @@ "tools.klavis.notEnabled": "Klavis 服務未啟用", "tools.klavis.oauthRequired": "請在新視窗中完成 OAuth 驗證", "tools.klavis.pendingAuth": "待驗證", + "tools.klavis.remove": "移除", + "tools.klavis.removeConfirm.desc": "{{name}} 將永久從您的連接服務中移除。此操作無法撤銷。", + "tools.klavis.removeConfirm.title": "移除 {{name}}?", "tools.klavis.serverCreated": "伺服器建立成功", "tools.klavis.serverCreatedFailed": "伺服器建立失敗", "tools.klavis.serverRemoved": "伺服器已刪除", diff --git a/locales/zh-TW/topic.json b/locales/zh-TW/topic.json index 1f1a7874f0..26a678499c 100644 --- a/locales/zh-TW/topic.json +++ b/locales/zh-TW/topic.json @@ -135,6 +135,12 @@ "management.view.card": "卡片", "management.view.list": "列表", "newTopic": "新主題", + "projectStatus.failed_one": "{{count}} 個失敗的主題", + "projectStatus.failed_other": "{{count}} 個失敗的主題", + "projectStatus.loading_one": "{{count}} 個正在載入的主題", + "projectStatus.loading_other": "{{count}} 個正在載入的主題", + "projectStatus.waitingForHuman_one": "{{count}} 個主題等待輸入", + "projectStatus.waitingForHuman_other": "{{count}} 個主題等待輸入", "renameModal.description": "請保持簡短且易於辨識。", "renameModal.title": "重新命名主題", "searchPlaceholder": "搜尋話題...", diff --git a/packages/locales/src/default/file.ts b/packages/locales/src/default/file.ts index 22342cc57c..ac1ca7de84 100644 --- a/packages/locales/src/default/file.ts +++ b/packages/locales/src/default/file.ts @@ -108,6 +108,9 @@ export default { 'pageEditor.duplicateSuccess': 'Page duplicated successfully', 'pageEditor.editedAt': 'Last edited on {{time}}', 'pageEditor.editedBy': 'Last edited by {{name}}', + 'pageEditor.editMode.checking': 'Checking edit availability…', + 'pageEditor.editMode.lockedByOther': '{{name}} is editing this document', + 'pageEditor.editMode.lockedBySomeone': 'Someone else is editing this document', 'pageEditor.editorPlaceholder': 'Press "/" for AI and commands.', 'pageEditor.empty.createNewDocument': 'Create New Page', 'pageEditor.empty.importNotion': 'Import from Notion', @@ -146,6 +149,10 @@ export default { 'pageEditor.history.versionCount_other': '{{count}} versions', 'pageEditor.linkCopied': 'Link copied', + 'pageEditor.lock.editingByOther': + '{{name}} is editing this page. Your changes can’t be saved right now.', + 'pageEditor.lock.editingBySomeone': + 'Someone else is editing this page. Your changes can’t be saved right now.', 'pageEditor.menu.copyLink': 'Copy Link', 'pageEditor.menu.export': 'Export', 'pageEditor.menu.export.markdown': 'Markdown', diff --git a/packages/types/src/agent/item.ts b/packages/types/src/agent/item.ts index a28de49754..9071949a6a 100644 --- a/packages/types/src/agent/item.ts +++ b/packages/types/src/agent/item.ts @@ -157,4 +157,6 @@ export interface AgentItem { updatedAt: Date; userId: string; virtual?: boolean | null; + /** Owning workspace; null for personal (non-workspace) agents. */ + workspaceId?: string | null; } diff --git a/packages/types/src/agentGroup/index.ts b/packages/types/src/agentGroup/index.ts index b168f01a70..b91a08a605 100644 --- a/packages/types/src/agentGroup/index.ts +++ b/packages/types/src/agentGroup/index.ts @@ -110,6 +110,8 @@ export interface ChatGroupItem { title?: string | null; updatedAt: Date; userId: string; + /** Owning workspace; null for personal (non-workspace) groups. */ + workspaceId?: string | null; } // Agent item with group role info diff --git a/packages/types/src/document/index.ts b/packages/types/src/document/index.ts index e0f5968162..24145e6624 100644 --- a/packages/types/src/document/index.ts +++ b/packages/types/src/document/index.ts @@ -90,6 +90,12 @@ export interface LobeDocument { updatedAt: Date; userId?: string; + + /** + * Owning workspace id (null for personal documents). Used client-side to gate + * workspace-only behaviour such as the collaborative edit lock. + */ + workspaceId?: string | null; } /** diff --git a/packages/types/src/task/index.ts b/packages/types/src/task/index.ts index 602a39e1ad..9b1a3e0136 100644 --- a/packages/types/src/task/index.ts +++ b/packages/types/src/task/index.ts @@ -332,4 +332,6 @@ export interface TaskDetailData { topicCount?: number; userId?: string | null; workspace?: TaskDetailWorkspaceNode[]; + /** Owning workspace; null for personal (non-workspace) tasks. */ + workspaceId?: string | null; } diff --git a/src/app/(backend)/webapi/document/events/route.ts b/src/app/(backend)/webapi/document/events/route.ts new file mode 100644 index 0000000000..d069e06669 --- /dev/null +++ b/src/app/(backend)/webapi/document/events/route.ts @@ -0,0 +1,85 @@ +import { createSSEHeaders, createSSEWriter } from '@lobechat/utils/server'; +import debug from 'debug'; + +import { checkAuth } from '@/app/(backend)/middleware/auth'; +import { DocumentService } from '@/server/services/document'; +import { subscribeResourceEvents } from '@/server/services/resourceEvents'; + +import { resolveValidWorkspaceIdFromRequest } from '../../_utils/workspace'; + +const log = debug('api-route:document:events'); + +// Long-lived SSE; rely on client auto-reconnect + the lock heartbeat across this boundary. +export const maxDuration = 300; +// ioredis (the event transport) requires the Node runtime, not Edge. +export const runtime = 'nodejs'; + +const jsonError = (message: string, status: number) => + new Response(JSON.stringify({ error: message }), { + headers: { 'Content-Type': 'application/json' }, + status, + }); + +/** + * Realtime event stream for a single workspace document. Pushes `doc.updated` + * and `lock.changed` events so an open editor (including pure viewers) syncs + * near-instantly instead of waiting for the polling heartbeat. + */ +export const GET = checkAuth(async (req, { userId, serverDB }) => { + const documentId = new URL(req.url).searchParams.get('documentId'); + if (!documentId) return jsonError('documentId is required', 400); + + // Access: must be an active member of the (header) workspace... + const workspaceId = await resolveValidWorkspaceIdFromRequest({ req, serverDB, userId }); + if (!workspaceId) return jsonError('workspace access required', 403); + + // ...and the document must be visible within that workspace (findById is + // workspace-scoped), so a member can't subscribe to a doc outside their scope. + const doc = await new DocumentService(serverDB, userId, workspaceId).getDocumentById(documentId); + if (!doc) return jsonError('document not found', 404); + + const ref = { id: documentId, type: 'document' as const }; + + const stream = new ReadableStream({ + cancel() { + (this as unknown as { _cleanup?: () => void })._cleanup?.(); + }, + start(controller) { + const writer = createSSEWriter(controller); + writer.writeConnection(documentId, '$'); + + const ac = new AbortController(); + const heartbeat = setInterval(() => { + try { + writer.writeHeartbeat(); + } catch { + clearInterval(heartbeat); + } + }, 30_000); + + const cleanup = () => { + ac.abort(); + clearInterval(heartbeat); + }; + + void subscribeResourceEvents( + ref, + (event) => { + try { + writer.writeStreamEvent(event); + } catch (error) { + log('failed to write event %O', error); + } + }, + ac.signal, + ).catch((error) => { + if (!ac.signal.aborted) log('subscription error %O', error); + }); + + req.signal?.addEventListener('abort', cleanup); + (controller as unknown as { _cleanup?: () => void })._cleanup = cleanup; + }, + }); + + return new Response(stream, { headers: createSSEHeaders() }); +}); diff --git a/src/const/documentLock.ts b/src/const/documentLock.ts new file mode 100644 index 0000000000..4ca236fa61 --- /dev/null +++ b/src/const/documentLock.ts @@ -0,0 +1,20 @@ +/** + * Collaborative edit-lock tuning for workspace pages. + * + * The lock is a lease: the holder must refresh it (heartbeat) before it expires, + * otherwise other members may take it over. There is no realtime channel, so the + * client polls by re-acquiring on an interval. + */ + +/** + * How often the active editor refreshes its lock (and a waiting member retries + * to take it over, and a locked-out viewer re-pulls the holder's content). Must + * stay comfortably below the server lease TTL (`EDIT_LOCK_TTL_SECONDS`, 30s) so + * a couple of missed beats still keep the lock alive. The lease lifetime itself + * is owned server-side (Redis EX), not here. + * + * NOTE: this is a polling cadence — it bounds how stale the lock/content can be. + * True low-latency sync needs a push channel (see the realtime-events issue); + * this value is just the stopgap pulse. + */ +export const DOCUMENT_LOCK_HEARTBEAT_MS = 10 * 1000; diff --git a/src/features/AgentTasks/AgentTaskDetail/TaskInstruction.tsx b/src/features/AgentTasks/AgentTaskDetail/TaskInstruction.tsx index 0d67a40cba..9f450c70a8 100644 --- a/src/features/AgentTasks/AgentTaskDetail/TaskInstruction.tsx +++ b/src/features/AgentTasks/AgentTaskDetail/TaskInstruction.tsx @@ -1,27 +1,60 @@ import { useEditor } from '@lobehub/editor/react'; import { ActionIcon, Flexbox } from '@lobehub/ui'; import { Paperclip } from 'lucide-react'; -import { memo, useCallback, useEffect, useMemo, useRef } from 'react'; +import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; +import { EditingIndicator, type EditLockClient, useEditLock } from '@/features/EditLock'; import { EditorCanvas } from '@/features/EditorCanvas'; import { seedAttachments } from '@/features/EditorCanvas/attachmentRegistry'; import { pickAndInsertAttachments } from '@/features/EditorCanvas/editorAttachments'; import { usePermission } from '@/hooks/usePermission'; +import { lambdaClient } from '@/libs/trpc/client'; import { useTaskStore } from '@/store/task'; import { taskDetailSelectors } from '@/store/task/selectors'; const DEBOUNCE_MS = 300; +// Stable lock RPC binding for the task resource. +const taskLockClient: EditLockClient = { + acquire: (id) => lambdaClient.task.acquireTaskLock.mutate({ id }), + peek: (id) => lambdaClient.task.getTaskLock.query({ id }), + release: async (id) => { + await lambdaClient.task.releaseTaskLock.mutate({ id }); + }, +}; + const TaskInstruction = memo(() => { const { t } = useTranslation('chat'); const { allowed: canEditTask } = usePermission('create_content'); const instruction = useTaskStore(taskDetailSelectors.activeTaskInstruction); const persistedEditorData = useTaskStore(taskDetailSelectors.activeTaskEditorData); const taskId = useTaskStore(taskDetailSelectors.activeTaskId); + const taskWorkspaceId = useTaskStore(taskDetailSelectors.activeTaskWorkspaceId); const persistedFiles = useTaskStore(taskDetailSelectors.activeTaskFiles); const updateTask = useTaskStore((s) => s.updateTask); const editor = useEditor(); + + // Collaborative edit lock for workspace tasks (same model as pages): read-only + // when another member is editing; acquired implicitly on the first edit. + const [edited, setEdited] = useState(false); + const taskIdRef = useRef(taskId); + if (taskIdRef.current !== taskId) { + taskIdRef.current = taskId; + setEdited(false); + } + const lock = useEditLock({ + client: taskLockClient, + // Only workspace tasks lock — personal (non-workspace) tasks stay fully + // editable with no peek/pending, matching the server's workspace gating. + enabled: Boolean(taskId && canEditTask && taskWorkspaceId), + isDirty: edited, + resourceId: taskId ?? undefined, + }); + // Read-only until the lock resolves, so the user can't start typing on a task + // that turns out to be locked and get bounced mid-edit. + const editable = canEditTask && !lock.lockedByOther && !lock.pending; + const debounceRef = useRef>(undefined); // Skip save when the serialized state matches the last persisted snapshot — // Lexical fires content-change for selection moves and other no-op events. @@ -49,9 +82,11 @@ const TaskInstruction = memo(() => { }, [taskId]); const handleContentChange = useCallback(() => { - if (!canEditTask) return; + if (!editable) return; if (!editor || !taskId) return; + setEdited(true); + if (debounceRef.current) clearTimeout(debounceRef.current); debounceRef.current = setTimeout(() => { const json = editor.getDocument('json') as unknown; @@ -64,7 +99,7 @@ const TaskInstruction = memo(() => { console.error('[TaskInstruction] Failed to save:', e); }); }, DEBOUNCE_MS); - }, [canEditTask, editor, taskId, updateTask]); + }, [editable, editor, taskId, updateTask]); const handleAttach = useCallback(() => { pickAndInsertAttachments(editor); @@ -72,7 +107,13 @@ const TaskInstruction = memo(() => { return ( + (({ holderId, pending }) => { + const { t } = useTranslation('file'); + const holder = useAuthorInfo(holderId ?? undefined); + + if (!holderId) { + if (!pending) return null; + + const checkingLabel = t('pageEditor.editMode.checking'); + return ( + + + + {checkingLabel} + + + ); + } + + const label = holder?.fullName + ? t('pageEditor.editMode.lockedByOther', { name: holder.fullName }) + : t('pageEditor.editMode.lockedBySomeone'); + + return ( + + + + + {label} + + + + ); +}); + +export default EditingIndicator; diff --git a/src/features/EditLock/index.ts b/src/features/EditLock/index.ts new file mode 100644 index 0000000000..16035bcb2c --- /dev/null +++ b/src/features/EditLock/index.ts @@ -0,0 +1,7 @@ +export { default as EditingIndicator } from './EditingIndicator'; +export { + type EditLockClient, + type EditLockResult, + type EditLockState, + useEditLock, +} from './useEditLock'; diff --git a/src/features/EditLock/useEditLock.ts b/src/features/EditLock/useEditLock.ts new file mode 100644 index 0000000000..5c215b3d70 --- /dev/null +++ b/src/features/EditLock/useEditLock.ts @@ -0,0 +1,141 @@ +'use client'; + +import { useEffect, useRef, useState } from 'react'; + +import { DOCUMENT_LOCK_HEARTBEAT_MS } from '@/const/documentLock'; + +export interface EditLockState { + holderId: string | null; + lockedByOther: boolean; +} + +export interface EditLockResult extends EditLockState { + /** + * True while the lock is enabled but its state hasn't been resolved yet (the + * first peek/acquire is still in flight). Callers should treat the editor as + * read-only until this clears, so a user can't start typing on a resource that + * turns out to be locked by someone else (and get bounced mid-edit). + */ + pending: boolean; +} + +/** Per-resource lock RPCs (bind these to the resource's trpc procedures). */ +export interface EditLockClient { + acquire: (id: string) => Promise; + peek: (id: string) => Promise; + release: (id: string) => Promise; +} + +interface UseEditLockOptions { + client: EditLockClient; + /** Whether the surface participates in locking (e.g. workspace-scoped + can edit). */ + enabled: boolean; + /** First real edit; latches edit-intent so the lock is acquired implicitly. */ + isDirty: boolean; + /** + * Re-peek the lock on an interval while viewing (not editing) to notice another + * member starting/stopping. Defaults to true. Set false for surfaces that get + * realtime lock pushes (e.g. pages via SSE) and only need the single peek-on-open. + */ + pollWhileViewing?: boolean; + resourceId: string | undefined; +} + +const UNLOCKED: EditLockState = { holderId: null, lockedByOther: false }; + +/** + * Generic, self-contained collaborative edit lock for any editable resource. + * + * Mirrors the page lock without depending on a specific store: peek the lock on + * open (so an already-edited resource is read-only up front), acquire it on the + * first edit and heartbeat to hold it, release on unmount. Returns the lock + * state for the caller to gate its editor (read-only) and render an indicator. + * + * `client` MUST be a stable reference (module-level / memoized) — it's an effect + * dependency. + */ +export const useEditLock = ({ + client, + enabled, + isDirty, + pollWhileViewing = true, + resourceId, +}: UseEditLockOptions): EditLockResult => { + const [state, setState] = useState(UNLOCKED); + const [editIntent, setEditIntent] = useState(false); + // False until the first peek/acquire settles, so the editor stays read-only + // until we actually know whether the resource is free. + const [resolved, setResolved] = useState(false); + + // Reset synchronously when the resource changes (React "adjust state during + // render"), so a new resource never inherits the previous one's lock/intent. + const idRef = useRef(resourceId); + if (idRef.current !== resourceId) { + idRef.current = resourceId; + setEditIntent(false); + setState(UNLOCKED); + setResolved(false); + } + + useEffect(() => { + if (enabled && isDirty) setEditIntent(true); + }, [enabled, isDirty]); + + const active = Boolean(enabled && resourceId); + const engaged = active && editIntent; + + // Viewer: poll the lock so an already-edited resource is read-only up front + // and so we notice when someone else starts/stops editing. + useEffect(() => { + if (!active || !resourceId || editIntent) return; + let cancelled = false; + const tick = () => { + client + .peek(resourceId) + .then((s) => { + if (cancelled) return; + setState(s); + setResolved(true); + }) + // Fail-open: a peek hiccup must not strand the editor read-only forever. + .catch(() => { + if (!cancelled) setResolved(true); + }); + }; + tick(); + // Surfaces with a realtime push channel only need the single peek-on-open. + const timer = pollWhileViewing ? setInterval(tick, DOCUMENT_LOCK_HEARTBEAT_MS) : undefined; + return () => { + cancelled = true; + if (timer) clearInterval(timer); + }; + }, [active, resourceId, editIntent, client, pollWhileViewing]); + + // Editor: acquire/refresh the lock while editing; release on unmount. + useEffect(() => { + if (!engaged || !resourceId) return; + let cancelled = false; + const tick = () => { + client + .acquire(resourceId) + .then((s) => { + if (cancelled) return; + setState(s); + setResolved(true); + }) + .catch(() => { + if (!cancelled) setResolved(true); + }); + }; + tick(); + const timer = setInterval(tick, DOCUMENT_LOCK_HEARTBEAT_MS); + return () => { + cancelled = true; + clearInterval(timer); + setState(UNLOCKED); + client.release(resourceId).catch(() => {}); + }; + }, [engaged, resourceId, client]); + + return { ...state, pending: active && !resolved }; +}; diff --git a/src/features/PageEditor/EditingIndicator.tsx b/src/features/PageEditor/EditingIndicator.tsx new file mode 100644 index 0000000000..11f57215f3 --- /dev/null +++ b/src/features/PageEditor/EditingIndicator.tsx @@ -0,0 +1,68 @@ +'use client'; + +import { Flexbox, Icon, Text, Tooltip } from '@lobehub/ui'; +import { cssVar } from 'antd-style'; +import { Loader2Icon, PencilIcon } from 'lucide-react'; +import { memo } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { useAuthorInfo } from '@/business/client/hooks/useAuthorInfo'; +import { useDocumentStore } from '@/store/document'; +import { editorSelectors } from '@/store/document/slices/editor'; + +import { usePageEditorStore } from './store'; + +/** + * Edit-lock status line at the top of the page body: a "checking…" hint while + * the lock resolves (page read-only meanwhile), then "someone else is editing" + * if another member holds it. Renders nothing once the page is confirmed free — + * a personal page then looks exactly the same (no edit-mode controls). + */ +const EditingIndicator = memo(() => { + const { t } = useTranslation('file'); + const documentId = usePageEditorStore((s) => s.documentId); + const isWorkspacePage = usePageEditorStore((s) => s.isWorkspacePage); + const isLockedByOther = usePageEditorStore((s) => s.isLockedByOther); + const isLockPending = usePageEditorStore((s) => s.isLockPending); + const lockHolderId = usePageEditorStore((s) => s.lockHolderId); + // Our own save was just rejected by the lock — treat as locked even if the + // lock-service state hasn't caught up yet. + const saveBlockedByLock = useDocumentStore((s) => + documentId ? editorSelectors.saveBlockedByLock(documentId)(s) : false, + ); + const holder = useAuthorInfo(lockHolderId ?? undefined); + + if (!isWorkspacePage) return null; + + const lockedByOther = isLockedByOther || saveBlockedByLock; + + if (!lockedByOther) { + if (!isLockPending) return null; + + return ( + + + + {t('pageEditor.editMode.checking')} + + + ); + } + + const label = holder?.fullName + ? t('pageEditor.editMode.lockedByOther', { name: holder.fullName }) + : t('pageEditor.editMode.lockedBySomeone'); + + return ( + + + + + {label} + + + + ); +}); + +export default EditingIndicator; diff --git a/src/features/PageEditor/EditorCanvas/index.tsx b/src/features/PageEditor/EditorCanvas/index.tsx index b3663dff26..2417d75c4f 100644 --- a/src/features/PageEditor/EditorCanvas/index.tsx +++ b/src/features/PageEditor/EditorCanvas/index.tsx @@ -7,9 +7,9 @@ import { memo } from 'react'; import { useTranslation } from 'react-i18next'; import { EditorCanvas as SharedEditorCanvas } from '@/features/EditorCanvas'; -import { usePermission } from '@/hooks/usePermission'; import { usePageEditorStore } from '../store'; +import { usePageEditable } from '../usePageEditable'; import { useAskCopilotItem } from './useAskCopilotItem'; import { useSlashItems } from './useSlashItems'; @@ -20,7 +20,7 @@ interface EditorCanvasProps { const EditorCanvas = memo(({ placeholder, style }) => { const { t } = useTranslation(['file', 'ui']); - const { allowed: canEdit } = usePermission('edit_own_content'); + const editable = usePageEditable(); const editor = usePageEditorStore((s) => s.editor); const documentId = usePageEditorStore((s) => s.documentId); @@ -35,14 +35,14 @@ const EditorCanvas = memo(({ placeholder, style }) => { return ( { ]); const { expand: showPageAgentPanel, toggle: togglePageAgentPanel } = usePageAgentPanelControl(); const { menuItems } = useMenu(); + // Page Agent edits the page — only offer it in edit mode. + const editable = usePageEditable(); return ( { > - togglePageAgentPanel()} - /> + {editable && ( + togglePageAgentPanel()} + /> + )} } /> diff --git a/src/features/PageEditor/PageEditor.tsx b/src/features/PageEditor/PageEditor.tsx index a3420fa026..cda654ae8c 100644 --- a/src/features/PageEditor/PageEditor.tsx +++ b/src/features/PageEditor/PageEditor.tsx @@ -17,6 +17,7 @@ import { systemStatusSelectors } from '@/store/global/selectors'; import { usePageStore } from '@/store/page'; import { StyleSheet } from '@/utils/styles'; +import EditingIndicator from './EditingIndicator'; import EditorCanvas from './EditorCanvas'; import Header from './Header'; import { PageAgentProvider } from './PageAgentProvider'; @@ -24,6 +25,7 @@ import { PageEditorProvider } from './PageEditorProvider'; import RightPanel from './RightPanel'; import { usePageEditorStore } from './store'; import TitleSection from './TitleSection'; +import { usePageEditable } from './usePageEditable'; /** * Header slot for PageEditor. @@ -121,7 +123,7 @@ interface PageEditorCanvasProps { } const PageEditorCanvas = memo(({ header, fullWidthHeader }) => { - const { allowed: canEdit } = usePermission('edit_own_content'); + const editable = usePageEditable(); const editor = usePageEditorStore((s) => s.editor); const documentId = usePageEditorStore((s) => s.documentId); const wideScreen = useGlobalStore(systemStatusSelectors.wideScreen); @@ -269,10 +271,10 @@ const PageEditorCanvas = memo(({ header, fullWidthHeader onScroll={handleEditorScroll} > { - if (!canEdit) return; + if (!editable) return; editor?.focus(); }} @@ -280,6 +282,8 @@ const PageEditorCanvas = memo(({ header, fullWidthHeader + {/* Body-only lock indicator: title/avatar above stay editable. */} + diff --git a/src/features/PageEditor/RightPanel/index.tsx b/src/features/PageEditor/RightPanel/index.tsx index a01c0f3ea0..3ced1594e7 100644 --- a/src/features/PageEditor/RightPanel/index.tsx +++ b/src/features/PageEditor/RightPanel/index.tsx @@ -20,6 +20,7 @@ import { import Conversation from '../Copilot/Conversation'; import HistoryPanel from '../History'; import { selectors, usePageEditorStore } from '../store'; +import { usePageEditable } from '../usePageEditable'; import { usePageAgentPanelControl } from './OverrideContext'; const styles = createStaticStyles(({ css }) => ({ @@ -105,15 +106,21 @@ PageEditorRightPanelContent.displayName = 'PageEditorRightPanelContent'; const PageEditorRightPanel = memo(() => { const { expand, toggle } = usePageAgentPanelControl(); + const rightPanelMode = usePageEditorStore(selectors.rightPanelMode); + const editable = usePageEditable(); const [width, updateSystemStatus] = useGlobalStore((s) => [ systemStatusSelectors.pageAgentPanelWidth(s), s.updateSystemStatus, ]); + // The Page Agent (copilot) edits the document, so hide it in read-only mode. + // History stays available. Re-entering edit mode restores the saved preference. + const effectiveExpand = expand && (rightPanelMode === 'history' || editable); + return ( toggle(next)} onSizeChange={(size) => { if (size?.width) { diff --git a/src/features/PageEditor/StoreUpdater.tsx b/src/features/PageEditor/StoreUpdater.tsx index bc9c7885c4..58d48cce06 100644 --- a/src/features/PageEditor/StoreUpdater.tsx +++ b/src/features/PageEditor/StoreUpdater.tsx @@ -6,10 +6,13 @@ import { createStoreUpdater } from 'zustand-utils'; import { hasMeaningfulEditorContent } from '@/libs/editor/hasMeaningfulEditorContent'; import { documentHistoryQueueService } from '@/services/documentHistoryQueue'; import { useDocumentStore } from '@/store/document'; +import { pageSelectors, usePageStore } from '@/store/page'; import { pageAgentRuntime } from '@/store/tool/slices/builtin/executors/lobe-page-agent'; import { type PublicState } from './store'; import { usePageEditorStore, useStoreApi } from './store'; +import { useDocumentLock } from './useDocumentLock'; +import { useResourceEvents } from './useResourceEvents'; type PageAgentEditor = NonNullable[0]>; @@ -43,9 +46,20 @@ const StoreUpdater = memo( const editor = usePageEditorStore((s) => s.editor); const initMeta = usePageEditorStore((s) => s.initMeta); const pageAgentEditor = editor as unknown as PageAgentEditor | undefined; + // Workspace pages are view-first; resolve once here so the lock + gating read + // a single source of truth. + const isWorkspacePage = usePageStore((s) => + Boolean(pageSelectors.getDocumentById(pageId)(s)?.workspaceId), + ); + + // Drive the collaborative edit lock for workspace pages + useDocumentLock(); + // Subscribe to realtime doc/lock events so the page syncs without polling + useResourceEvents(); // Update store with props useStoreUpdater('documentId', pageId); + useStoreUpdater('isWorkspacePage', isWorkspacePage); useStoreUpdater('knowledgeBaseId', knowledgeBaseId); useStoreUpdater('onDocumentIdChange', onDocumentIdChange); useStoreUpdater('onEmojiChange', onEmojiChange); diff --git a/src/features/PageEditor/TitleSection.tsx b/src/features/PageEditor/TitleSection.tsx index e37ff5d1c7..dbf81cba0d 100644 --- a/src/features/PageEditor/TitleSection.tsx +++ b/src/features/PageEditor/TitleSection.tsx @@ -18,10 +18,13 @@ import { usePageEditorStore } from './store'; const TitleSection = memo(() => { const { t } = useTranslation('file'); - const { allowed: canEdit } = usePermission('edit_own_content'); const locale = useGlobalStore(globalGeneralSelectors.currentLanguage); const documentId = usePageEditorStore((s) => s.documentId); + // Title/emoji are metadata, not the locked rich-text body — they stay editable + // (permission only) even while another member holds the body edit lock. The + // server lets metadata-only saves through the lock guard. + const { allowed: canEdit } = usePermission('edit_own_content'); const emoji = usePageEditorStore((s) => s.emoji); const title = usePageEditorStore((s) => s.title); const setEmoji = usePageEditorStore((s) => s.setEmoji); diff --git a/src/features/PageEditor/store/action.ts b/src/features/PageEditor/store/action.ts index bd6977cb5d..bec8e36ed9 100644 --- a/src/features/PageEditor/store/action.ts +++ b/src/features/PageEditor/store/action.ts @@ -26,6 +26,9 @@ export interface Action { initMeta: (title?: string, emoji?: string) => void; performMetaSave: () => Promise; setEmoji: (emoji: string | undefined) => void; + /** True while the lock state is still being resolved (editor read-only meanwhile). */ + setLockPending: (pending: boolean) => void; + setLockState: (lock: { holderId: string | null; lockedByOther: boolean }) => void; setRightPanelMode: (mode: RightPanelMode) => void; setTitle: (title: string) => void; triggerDebouncedMetaSave: () => void; @@ -180,6 +183,16 @@ export const store: (initState?: Partial) => StateCreator = } }, + setLockPending: (pending) => { + if (get().isLockPending !== pending) set({ isLockPending: pending }); + }, + + setLockState: ({ holderId, lockedByOther }) => { + const { isLockedByOther, lockHolderId } = get(); + if (isLockedByOther === lockedByOther && lockHolderId === holderId) return; + set({ isLockedByOther: lockedByOther, lockHolderId: holderId }); + }, + setRightPanelMode: (rightPanelMode) => { set({ rightPanelMode }); }, diff --git a/src/features/PageEditor/store/initialState.ts b/src/features/PageEditor/store/initialState.ts index 874e4be2ed..a0c836d310 100644 --- a/src/features/PageEditor/store/initialState.ts +++ b/src/features/PageEditor/store/initialState.ts @@ -20,9 +20,17 @@ export interface PublicState { export interface State extends PublicState { documentId: string | undefined; editor?: IEditor; + /** True when another workspace member is actively editing this page. */ + isLockedByOther?: boolean; + /** True until the first lock peek resolves; the editor stays read-only until then. */ + isLockPending?: boolean; isMetaDirty?: boolean; + /** True when the open page belongs to a workspace (gates view-first behaviour). */ + isWorkspacePage?: boolean; lastSavedEmoji?: string; lastSavedTitle?: string; + /** User id of the member currently holding the collaborative edit lock. */ + lockHolderId?: string | null; metaSaveStatus?: MetaSaveStatus; rightPanelMode: RightPanelMode; } @@ -31,7 +39,13 @@ export const initialState: State = { autoSave: true, documentId: undefined, emoji: undefined, + // Start pending (read-only) so the editor never flashes editable before the + // lock driver has resolved whether the page is free. + isLockPending: true, + isLockedByOther: false, isMetaDirty: false, + isWorkspacePage: false, + lockHolderId: null, metaSaveStatus: 'idle', rightPanelMode: 'copilot', title: undefined, diff --git a/src/features/PageEditor/useDocumentLock.ts b/src/features/PageEditor/useDocumentLock.ts new file mode 100644 index 0000000000..40df8ff775 --- /dev/null +++ b/src/features/PageEditor/useDocumentLock.ts @@ -0,0 +1,85 @@ +'use client'; + +import { useEffect, useRef } from 'react'; + +import { type EditLockClient, useEditLock } from '@/features/EditLock'; +import { usePermission } from '@/hooks/usePermission'; +import { mutate } from '@/libs/swr'; +import { documentService } from '@/services/document'; +import { documentSWRKeys } from '@/services/document/swrKeys'; +import { useDocumentStore } from '@/store/document'; +import { editorSelectors } from '@/store/document/slices/editor'; + +import { usePageEditorStore } from './store'; + +// Stable lock RPC binding for the document resource. +const documentLockClient: EditLockClient = { + acquire: (id) => documentService.acquireDocumentLock(id), + peek: (id) => documentService.getDocumentLock(id), + release: (id) => documentService.releaseDocumentLock(id), +}; + +/** + * Drives the collaborative edit lock for workspace pages. + * + * The core lifecycle — peek-on-open (read-only until resolved), acquire on the + * first edit, heartbeat, release on unmount — is the shared {@link useEditLock} + * primitive. This wrapper bridges its state into the PageEditor store (where + * {@link usePageEditable} and the header indicator read it) and layers on the + * page-only concerns: realtime lock pushes ({@link useResourceEvents}) replace + * the viewer poll, and a lock flip re-hydrates content so a stale local snapshot + * can't overwrite another member's edits. + */ +export const useDocumentLock = () => { + const { allowed: canEdit } = usePermission('edit_own_content'); + const documentId = usePageEditorStore((s) => s.documentId); + const isWorkspacePage = usePageEditorStore((s) => s.isWorkspacePage); + const isLockedByOther = usePageEditorStore((s) => s.isLockedByOther); + const setLockState = usePageEditorStore((s) => s.setLockState); + const setLockPending = usePageEditorStore((s) => s.setLockPending); + const isDirty = useDocumentStore((s) => + documentId ? editorSelectors.isDirty(documentId)(s) : false, + ); + const saveBlockedByLock = useDocumentStore((s) => + documentId ? editorSelectors.saveBlockedByLock(documentId)(s) : false, + ); + + const workspacePage = Boolean(documentId && canEdit && isWorkspacePage); + + // Shared lock lifecycle. Pages receive realtime lock pushes via SSE, so the + // viewer poll is off — the single peek-on-open plus those pushes keep it live. + const lock = useEditLock({ + client: documentLockClient, + enabled: workspacePage, + isDirty, + pollWhileViewing: false, + resourceId: documentId, + }); + + // Bridge lock state into the page store. A peek failure / non-workspace page + // resolves to "free" (pending false), so the editor is never stranded. + useEffect(() => { + setLockState({ holderId: lock.holderId, lockedByOther: lock.lockedByOther }); + }, [lock.holderId, lock.lockedByOther, setLockState]); + + useEffect(() => { + setLockPending(lock.pending); + }, [lock.pending, setLockPending]); + + // Re-hydrate content whenever the lock flips — on open if already held, or when + // another member takes/releases it (events land in the store via the bridge or + // useResourceEvents), or when our own save was just rejected (the holder's + // version is newer). Prevents a stale snapshot from clobbering their edits. + const wasLockedByOtherRef = useRef(false); + useEffect(() => { + if (!workspacePage || !documentId) { + wasLockedByOtherRef.current = false; + return; + } + const tookOver = wasLockedByOtherRef.current && !isLockedByOther; + wasLockedByOtherRef.current = Boolean(isLockedByOther); + if (isLockedByOther || tookOver || saveBlockedByLock) { + void mutate(documentSWRKeys.editor(documentId)); + } + }, [workspacePage, documentId, isLockedByOther, saveBlockedByLock]); +}; diff --git a/src/features/PageEditor/usePageEditable.ts b/src/features/PageEditor/usePageEditable.ts new file mode 100644 index 0000000000..7823f2960d --- /dev/null +++ b/src/features/PageEditor/usePageEditable.ts @@ -0,0 +1,34 @@ +'use client'; + +import { usePermission } from '@/hooks/usePermission'; +import { useDocumentStore } from '@/store/document'; +import { editorSelectors } from '@/store/document/slices/editor'; + +import { usePageEditorStore } from './store'; + +/** + * Whether the current user can type into the page right now. + * + * Workspace pages behave like personal pages — open and type — except that the + * page becomes read-only while another member holds the edit lock. The lock is + * acquired implicitly on the first edit; a peek on open (see {@link useDocumentLock}) + * surfaces an existing holder so the page is read-only up front. + */ +export const usePageEditable = (): boolean => { + const { allowed: hasEditPermission } = usePermission('edit_own_content'); + const documentId = usePageEditorStore((s) => s.documentId); + const isWorkspacePage = usePageEditorStore((s) => s.isWorkspacePage); + const isLockedByOther = usePageEditorStore((s) => s.isLockedByOther); + // Read-only until the lock resolves, so the user can't start typing on a page + // that turns out to be locked and get bounced mid-edit. Only workspace pages + // lock — personal pages are always immediately editable (no lock, no pending). + const isLockPending = usePageEditorStore((s) => s.isLockPending); + // A save already rejected by the lock → stop editing now (the EditingIndicator + // tells the user why). Reactive, so the editor flips read-only the moment it happens. + const saveBlockedByLock = useDocumentStore((s) => + documentId ? editorSelectors.saveBlockedByLock(documentId)(s) : false, + ); + const pendingLock = isWorkspacePage && isLockPending; + + return hasEditPermission && !isLockedByOther && !pendingLock && !saveBlockedByLock; +}; diff --git a/src/features/PageEditor/useResourceEvents.ts b/src/features/PageEditor/useResourceEvents.ts new file mode 100644 index 0000000000..a865b1fca1 --- /dev/null +++ b/src/features/PageEditor/useResourceEvents.ts @@ -0,0 +1,106 @@ +'use client'; + +import { fetchEventSource } from '@lobechat/utils/client'; +import { useEffect } from 'react'; + +import { mutate } from '@/libs/swr'; +import { documentSWRKeys } from '@/services/document/swrKeys'; +import { pageSelectors, usePageStore } from '@/store/page'; +import { useUserStore } from '@/store/user'; +import { userProfileSelectors } from '@/store/user/slices/auth/selectors'; + +import { usePageEditorStore } from './store'; + +const buildHeaders = async (): Promise> => { + // Mirror the tRPC lambda client so the SSE request carries the same auth + + // workspace (X-Workspace-Id) context the server resolves against. + const { createHeaderWithAuth } = await import('@/services/_auth'); + const headers = (await createHeaderWithAuth()) as Record; + const { getBusinessTrpcHeaders } = await import('@/business/client/trpc-headers'); + Object.assign(headers, await getBusinessTrpcHeaders()); + return headers; +}; + +/** + * Subscribes the open workspace page to its realtime event stream so content + * and lock state sync near-instantly instead of waiting for the polling + * heartbeat. Mounted alongside {@link useDocumentLock}, but gated on the page + * being a workspace page ONLY (not on holding the lock) — pure viewers must + * receive updates too. Degrades silently to polling when the stream is + * unavailable. + */ +export const useResourceEvents = () => { + const documentId = usePageEditorStore((s) => s.documentId); + const setLockState = usePageEditorStore((s) => s.setLockState); + const workspaceId = usePageStore( + (s) => pageSelectors.getDocumentById(documentId)(s)?.workspaceId, + ); + const myUserId = useUserStore(userProfileSelectors.userId); + + const enabled = Boolean(documentId && workspaceId); + + useEffect(() => { + if (!enabled || !documentId) return; + + const ac = new AbortController(); + let cancelled = false; + + const start = async () => { + const headers = await buildHeaders(); + if (cancelled) return; + + void fetchEventSource( + `/webapi/document/events?documentId=${encodeURIComponent(documentId)}`, + { + credentials: 'include', + headers, + onerror: (err: { fatal?: boolean }) => { + // 4xx (auth/not-found) won't recover; stop. Else reconnect in 5s — + // the lock heartbeat keeps things synced across the gap. + if (err?.fatal) throw err; + return 5000; + }, + onmessage: (ev) => { + if (!ev.data) return; + let parsed: { actorId?: string; data?: { holderId?: string | null }; type?: string }; + try { + parsed = JSON.parse(ev.data); + } catch { + return; + } + // Ignore our own echoes. + if (parsed.actorId && parsed.actorId === myUserId) return; + + if (parsed.type === 'doc.updated') { + // Re-fetch; DocumentIdMode re-hydrates the editor on the new + // version when the local editor isn't dirty. + void mutate(documentSWRKeys.editor(documentId)); + } else if (parsed.type === 'lock.changed') { + const holderId = parsed.data?.holderId ?? null; + setLockState({ + holderId, + lockedByOther: Boolean(holderId) && holderId !== myUserId, + }); + } + }, + onopen: async (res) => { + if (res.ok && res.headers.get('content-type')?.includes('text/event-stream')) return; + const error: Error & { fatal?: boolean } = new Error(`SSE failed: ${res.status}`); + error.fatal = res.status >= 400 && res.status < 500; + throw error; + }, + signal: ac.signal, + }, + ).catch(() => { + // Swallow — realtime is best-effort; the polling heartbeat is the fallback. + }); + }; + + void start(); + + return () => { + cancelled = true; + ac.abort(); + }; + }, [enabled, documentId, workspaceId, myUserId, setLockState]); +}; diff --git a/src/routes/(main)/agent/profile/features/EditLockDriver.tsx b/src/routes/(main)/agent/profile/features/EditLockDriver.tsx new file mode 100644 index 0000000000..a18003d551 --- /dev/null +++ b/src/routes/(main)/agent/profile/features/EditLockDriver.tsx @@ -0,0 +1,69 @@ +'use client'; + +import { memo, useEffect, useRef } from 'react'; + +import { type EditLockClient, useEditLock } from '@/features/EditLock'; +import { usePermission } from '@/hooks/usePermission'; +import { lambdaClient } from '@/libs/trpc/client'; +import { useAgentStore } from '@/store/agent'; + +import { useProfileStore } from './store'; + +// Stable lock RPC binding for the agent resource. +const agentLockClient: EditLockClient = { + acquire: (id) => lambdaClient.agent.acquireAgentLock.mutate({ agentId: id }), + peek: (id) => lambdaClient.agent.getAgentLock.query({ agentId: id }), + release: async (id) => { + await lambdaClient.agent.releaseAgentLock.mutate({ agentId: id }); + }, +}; + +/** + * Drives the collaborative edit lock for workspace agent profiles. + * + * Mounted high in the profile tree (not inside the loading-gated editor) so the + * lock is *peeked on open* before the editor renders — an agent another member + * is already editing is read-only from the first frame, mirroring the page lock. + * The resolved state is published to the profile store; the editor reads it. + */ +const EditLockDriver = memo(() => { + const { allowed: canEdit } = usePermission('edit_own_content'); + const agentId = useAgentStore((s) => s.activeAgentId); + // Only workspace agents lock — personal (non-workspace) agents stay fully + // editable with no peek/pending, matching the server's workspace gating. + const agentWorkspaceId = useAgentStore((s) => + s.activeAgentId ? s.agentMap[s.activeAgentId]?.workspaceId : undefined, + ); + const hasEdited = useProfileStore((s) => s.hasEdited); + const setLockState = useProfileStore((s) => s.setLockState); + const setHasEdited = useProfileStore((s) => s.setHasEdited); + + // Reset edit-intent whenever the open agent changes, so a new agent never + // inherits the previous one's edit-intent (and the heartbeat doesn't engage). + const agentIdRef = useRef(agentId); + useEffect(() => { + if (agentIdRef.current !== agentId) { + agentIdRef.current = agentId; + setHasEdited(false); + } + }, [agentId, setHasEdited]); + + const lock = useEditLock({ + client: agentLockClient, + enabled: Boolean(agentId && canEdit && agentWorkspaceId), + isDirty: Boolean(hasEdited), + resourceId: agentId ?? undefined, + }); + + useEffect(() => { + setLockState({ + holderId: lock.holderId, + lockedByOther: lock.lockedByOther, + pending: lock.pending, + }); + }, [lock.holderId, lock.lockedByOther, lock.pending, setLockState]); + + return null; +}); + +export default EditLockDriver; diff --git a/src/routes/(main)/agent/profile/features/EditorCanvas/index.test.tsx b/src/routes/(main)/agent/profile/features/EditorCanvas/index.test.tsx index cad2b85282..23ed8c3f77 100644 --- a/src/routes/(main)/agent/profile/features/EditorCanvas/index.test.tsx +++ b/src/routes/(main)/agent/profile/features/EditorCanvas/index.test.tsx @@ -66,7 +66,14 @@ vi.mock('../ProfileEditor/MentionList', () => ({ })); vi.mock('../store', () => ({ - useProfileStore: (selector: any) => selector({ editor, handleContentChange }), + useProfileStore: (selector: any) => + selector({ + editor, + handleContentChange, + hasEdited: false, + lockState: { holderId: null, lockedByOther: false, pending: false }, + setHasEdited: vi.fn(), + }), })); vi.mock('./TypoBar', () => ({ diff --git a/src/routes/(main)/agent/profile/features/EditorCanvas/index.tsx b/src/routes/(main)/agent/profile/features/EditorCanvas/index.tsx index 055d86295e..b2d4d48b46 100644 --- a/src/routes/(main)/agent/profile/features/EditorCanvas/index.tsx +++ b/src/routes/(main)/agent/profile/features/EditorCanvas/index.tsx @@ -7,6 +7,7 @@ import { memo, useCallback, useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { createChatInputRichPlugins } from '@/features/ChatInput/InputEditor/plugins'; +import { EditingIndicator } from '@/features/EditLock'; import { usePermission } from '@/hooks/usePermission'; import { EMPTY_EDITOR_STATE } from '@/libs/editor/constants'; import { useAgentStore } from '@/store/agent'; @@ -14,6 +15,7 @@ import { agentSelectors } from '@/store/agent/selectors'; import { useMentionOptions } from '../ProfileEditor/MentionList'; import { useProfileStore } from '../store'; +import { selectors as profileSelectors } from '../store/selectors'; import TypoBar from './TypoBar'; import { useSlashItems } from './useSlashItems'; @@ -40,13 +42,27 @@ const EditorCanvas = memo(() => { const prevStreamingRef = useRef(undefined); const wasStreamingRef = useRef(false); + // Collaborative edit-lock state, peeked-on-open and driven by the always-mounted + // EditLockDriver (see ../EditLockDriver) so it's resolved before this editor + // renders — an agent another member is editing is read-only from the first frame. + const lockedByOther = useProfileStore(profileSelectors.lockedByOther); + const lockHolderId = useProfileStore(profileSelectors.lockHolderId); + const lockPending = useProfileStore(profileSelectors.lockPending); + const setHasEdited = useProfileStore((s) => s.setHasEdited); + // Read-only until the lock resolves, so the user can't start typing on an agent + // that turns out to be locked and get bounced mid-edit. + const editable = canEdit && !lockedByOther && !lockPending; + // Wrap handleContentChange with updateConfig const handleChange = useCallback(() => { - if (!canEdit) return; + if (!editable) return; // Don't trigger save during streaming if (streamingInProgress) return; + // Latch edit-intent so the lock driver acquires the lock on the first real + // edit. Streaming systemRole writes are programmatic and skipped above. + setHasEdited(true); handleContentChange(updateConfig); - }, [canEdit, handleContentChange, updateConfig, streamingInProgress]); + }, [editable, handleContentChange, updateConfig, streamingInProgress, setHasEdited]); // Handle streaming updates - update editor with streaming content useEffect(() => { @@ -70,7 +86,7 @@ const EditorCanvas = memo(() => { // Trigger save when streaming ends useEffect(() => { if (wasStreamingRef.current && !streamingInProgress && editor && editorInit) { - if (!canEdit) return; + if (!editable) return; // Streaming just ended, wait for editor to update its internal state then save // This ensures editorData (json) is properly updated from the markdown content @@ -80,7 +96,7 @@ const EditorCanvas = memo(() => { return () => clearTimeout(timer); } wasStreamingRef.current = !!streamingInProgress; - }, [canEdit, streamingInProgress, editor, editorInit, handleContentChange, updateConfig]); + }, [editable, streamingInProgress, editor, editorInit, handleContentChange, updateConfig]); useEffect(() => { if (!editorInit || !editor || contentInit) return; @@ -101,14 +117,18 @@ const EditorCanvas = memo(() => { return (
{ e.stopPropagation(); }} > + ({ navigate: vi.fn(), profileState: { editor: undefined as { getDocument: (format: string) => string | undefined } | undefined, + lockState: { holderId: null as string | null, lockedByOther: false, pending: false }, }, versionReviewStatus: { isUnderReview: false, @@ -197,6 +198,11 @@ vi.mock('@/store/home', () => ({ })); vi.mock('../store', () => ({ + selectors: { + lockHolderId: (s: typeof mocks.profileState) => s.lockState.holderId, + lockPending: (s: typeof mocks.profileState) => s.lockState.pending, + lockedByOther: (s: typeof mocks.profileState) => s.lockState.lockedByOther, + }, useProfileStore: (selector: (state: typeof mocks.profileState) => unknown) => selector(mocks.profileState), })); diff --git a/src/routes/(main)/agent/profile/features/Header/index.tsx b/src/routes/(main)/agent/profile/features/Header/index.tsx index 86fb0c6f52..d1f66bc6ec 100644 --- a/src/routes/(main)/agent/profile/features/Header/index.tsx +++ b/src/routes/(main)/agent/profile/features/Header/index.tsx @@ -26,7 +26,7 @@ import { systemStatusSelectors } from '@/store/global/selectors'; import { useHomeStore } from '@/store/home'; import { sanitizeFileName } from '@/utils/sanitizeFileName'; -import { useProfileStore } from '../store'; +import { selectors as profileSelectors, useProfileStore } from '../store'; import AgentForkTag from './AgentForkTag'; import ForkConfirmModal from './AgentPublishButton/ForkConfirmModal'; import PublishResultModal from './AgentPublishButton/PublishResultModal'; @@ -107,6 +107,8 @@ const Header = memo(() => { ]); const removeAgent = useHomeStore((s) => s.removeAgent); const editor = useProfileStore((s) => s.editor); + const lockedByOther = useProfileStore(profileSelectors.lockedByOther); + const lockPending = useProfileStore(profileSelectors.lockPending); const { allowed: canEdit } = usePermission('edit_own_content'); const { isAuthenticated, isLoading: isAuthLoading, signIn } = useMarketAuth(); const { isUnderReview } = useVersionReviewStatus(); @@ -326,7 +328,7 @@ const Header = memo(() => { <> + @@ -342,7 +344,7 @@ const Header = memo(() => { size={DESKTOP_HEADER_ICON_SMALL_SIZE} /> - {!isHeterogeneous && isStatusInit && ( + {!isHeterogeneous && isStatusInit && !lockedByOther && !lockPending && ( Promise) => Promise; flushSave: () => void; handleContentChange: (updateConfig: (payload: SaveConfigPayload) => Promise) => void; + /** Latch edit-intent so the lock driver acquires the lock on first real edit. */ + setHasEdited: (value: boolean) => void; + /** Publish the latest edit-lock state from the always-mounted lock driver. */ + setLockState: (lockState: EditLockState) => void; /** * Start streaming mode - clears editor and prepares for streaming content */ @@ -130,6 +134,21 @@ export const store: (initState?: Partial) => StateCreator = console.error('[ProfileEditor] Failed to read editor content:', error); } }, + setHasEdited: (value) => { + if (get().hasEdited !== value) set({ hasEdited: value }); + }, + + setLockState: (lockState) => { + const prev = get().lockState; + if ( + prev.holderId !== lockState.holderId || + prev.lockedByOther !== lockState.lockedByOther || + prev.pending !== lockState.pending + ) { + set({ lockState }); + } + }, + startStreaming: () => { const { editor } = get(); diff --git a/src/routes/(main)/agent/profile/features/store/initialState.ts b/src/routes/(main)/agent/profile/features/store/initialState.ts index 0549c27e8c..5df8cfe55e 100644 --- a/src/routes/(main)/agent/profile/features/store/initialState.ts +++ b/src/routes/(main)/agent/profile/features/store/initialState.ts @@ -1,11 +1,28 @@ import { type IEditor } from '@lobehub/editor'; import { type EditorState } from '@lobehub/editor/react'; +export interface EditLockState { + holderId: string | null; + lockedByOther: boolean; + /** True until the first lock peek resolves; the editor stays read-only until then. */ + pending: boolean; +} + export interface PublicState {} export interface State extends PublicState { editor?: IEditor; editorState?: EditorState; // EditorState from useEditorState hook + /** + * Edit-intent latch: flips true on the user's first real edit so the lock + * driver acquires the lock implicitly. Reset when the open agent changes. + */ + hasEdited?: boolean; + /** + * Collaborative edit-lock state, driven by the always-mounted lock host so it + * is resolved before the (loading-gated) editor renders. + */ + lockState: EditLockState; /** * Content being streamed from AI */ @@ -17,6 +34,10 @@ export interface State extends PublicState { } export const initialState: State = { + hasEdited: false, + // Start pending (read-only) so the editor never flashes editable before the + // lock driver has resolved whether the agent is free. + lockState: { holderId: null, lockedByOther: false, pending: true }, streamingContent: undefined, streamingInProgress: false, }; diff --git a/src/routes/(main)/agent/profile/features/store/selectors.ts b/src/routes/(main)/agent/profile/features/store/selectors.ts index 28114a63a6..cc745c77f5 100644 --- a/src/routes/(main)/agent/profile/features/store/selectors.ts +++ b/src/routes/(main)/agent/profile/features/store/selectors.ts @@ -3,4 +3,8 @@ import { type Store } from './action'; export const selectors = { editor: (s: Store) => s.editor, editorState: (s: Store) => s.editorState, + hasEdited: (s: Store) => Boolean(s.hasEdited), + lockHolderId: (s: Store) => s.lockState.holderId, + lockPending: (s: Store) => s.lockState.pending, + lockedByOther: (s: Store) => s.lockState.lockedByOther, }; diff --git a/src/routes/(main)/agent/profile/index.tsx b/src/routes/(main)/agent/profile/index.tsx index ad1f0313a4..4e63c85195 100644 --- a/src/routes/(main)/agent/profile/index.tsx +++ b/src/routes/(main)/agent/profile/index.tsx @@ -12,11 +12,12 @@ import { useAgentStore } from '@/store/agent'; import { agentSelectors } from '@/store/agent/selectors'; import { StyleSheet } from '@/utils/styles'; +import EditLockDriver from './features/EditLockDriver'; import Header from './features/Header'; import ProfileEditor from './features/ProfileEditor'; import ProfileHydration from './features/ProfileHydration'; import ProfileProvider from './features/ProfileProvider'; -import { useProfileStore } from './features/store'; +import { selectors as profileSelectors, useProfileStore } from './features/store'; const styles = StyleSheet.create({ contentWrapper: { @@ -64,19 +65,32 @@ const ProfileArea = memo(() => { )} + {/* Mounted unconditionally (not behind the config-loading gate) so the lock + is peeked on open and resolved before the editor renders. */} + ); }); +// Hide the Agent Builder while another member holds the edit lock (it drives +// updateAgentConfig, which the server rejects under the lock) and while the lock +// is still resolving — so it doesn't flash in then vanish once a lock is found. +const AgentBuilderSlot = memo(() => { + const lockedByOther = useProfileStore(profileSelectors.lockedByOther); + const lockPending = useProfileStore(profileSelectors.lockPending); + if (lockedByOther || lockPending) return null; + return ; +}); + const AgentProfile: FC = () => { return ( }> - + diff --git a/src/routes/(main)/group/profile/features/GroupProfile/index.tsx b/src/routes/(main)/group/profile/features/GroupProfile/index.tsx index f5618d66f7..6f42f5775f 100644 --- a/src/routes/(main)/group/profile/features/GroupProfile/index.tsx +++ b/src/routes/(main)/group/profile/features/GroupProfile/index.tsx @@ -4,14 +4,16 @@ import { ActionIcon, Button, DropdownMenu, Flexbox } from '@lobehub/ui'; import { Divider } from 'antd'; import { useTheme } from 'antd-style'; import { MoreHorizontalIcon, PlayIcon, Settings2Icon } from 'lucide-react'; -import { memo, useCallback, useEffect, useMemo, useState } from 'react'; +import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import urlJoin from 'url-join'; import { useAgentGroupTransferMenuItem } from '@/business/client/hooks/useAgentGroupTransferMenuItem'; +import { EditingIndicator, type EditLockClient, useEditLock } from '@/features/EditLock'; import { EditorCanvas } from '@/features/EditorCanvas'; import { usePermission } from '@/hooks/usePermission'; import { useQueryRoute } from '@/hooks/useQueryRoute'; +import { lambdaClient } from '@/libs/trpc/client'; import { useAgentGroupStore } from '@/store/agentGroup'; import { agentGroupSelectors } from '@/store/agentGroup/selectors'; import { useGroupProfileStore } from '@/store/groupProfile'; @@ -24,6 +26,15 @@ import GroupHeader from './GroupHeader'; import GroupStatusTag from './GroupStatusTag'; import GroupVersionReviewTag from './GroupVersionReviewTag'; +// Stable lock RPC binding for the chatGroup resource. +const groupLockClient: EditLockClient = { + acquire: (id) => lambdaClient.group.acquireGroupLock.mutate({ id }), + peek: (id) => lambdaClient.group.getGroupLock.query({ id }), + release: async (id) => { + await lambdaClient.group.releaseGroupLock.mutate({ id }); + }, +}; + const GroupProfile = memo(() => { const { t } = useTranslation(['setting', 'chat']); const { allowed: canEdit } = usePermission('edit_own_content'); @@ -35,6 +46,26 @@ const GroupProfile = memo(() => { const router = useQueryRoute(); const transferMenuItems = useAgentGroupTransferMenuItem(groupId ?? undefined); + // Collaborative edit lock for workspace groups (same model as pages): read-only + // when another member is editing; acquired implicitly on the first edit. + const [edited, setEdited] = useState(false); + const groupIdRef = useRef(groupId); + if (groupIdRef.current !== groupId) { + groupIdRef.current = groupId; + setEdited(false); + } + const lock = useEditLock({ + client: groupLockClient, + // Only workspace groups lock — personal (non-workspace) groups stay fully + // editable with no peek/pending, matching the server's workspace gating. + enabled: Boolean(groupId && canEdit && currentGroup?.workspaceId), + isDirty: edited, + resourceId: groupId ?? undefined, + }); + // Read-only until the lock resolves, so the user can't start typing on a group + // that turns out to be locked and get bounced mid-edit. + const editable = canEdit && !lock.lockedByOther && !lock.pending; + const editor = useGroupProfileStore((s) => s.editor); const handleContentChange = useGroupProfileStore((s) => s.handleContentChange); const agentBuilderContentUpdate = useGroupProfileStore((s) => s.agentBuilderContentUpdate); @@ -54,10 +85,11 @@ const GroupProfile = memo(() => { ); const onContentChange = useCallback(() => { - if (!canEdit) return; + if (!editable) return; + setEdited(true); handleContentChange(saveContent); - }, [canEdit, handleContentChange, saveContent]); + }, [editable, handleContentChange, saveContent]); // Stabilize editorData object reference to prevent unnecessary re-renders const editorData = useMemo( @@ -89,7 +121,7 @@ const GroupProfile = memo(() => { }} > - + @@ -144,8 +176,13 @@ const GroupProfile = memo(() => { {/* Group Content Editor */} + { + await lambdaClient.document.releaseDocumentLock.mutate({ id }); + } + async saveDocumentHistory(params: SaveDocumentHistoryInput): Promise { const result = await lambdaClient.document.saveDocumentHistory.mutate(params); diff --git a/src/store/document/slices/editor/action.test.ts b/src/store/document/slices/editor/action.test.ts index 9bf4311bb4..3e146595ca 100644 --- a/src/store/document/slices/editor/action.test.ts +++ b/src/store/document/slices/editor/action.test.ts @@ -870,6 +870,71 @@ name: skill-name expect(result.current.documents['doc-1'].saveStatus).toBe('idle'); }); + it('marks the document lock-blocked (keeping unsaved content) when another editor holds the lock', async () => { + const { result } = renderHook(() => useDocumentStore()); + const mockEditor = createValidMockEditor() as any; + + act(() => { + result.current.initDocumentWithEditor({ + content: '# Test', + documentId: 'doc-1', + editor: mockEditor, + sourceType: 'page', + }); + result.current.markDirty('doc-1'); + }); + + const lockError = Object.assign(new Error('Document is being edited by another user'), { + data: { code: 'CONFLICT' }, + }); + vi.mocked(documentService.updateDocument).mockRejectedValueOnce(lockError); + + await act(async () => { + await result.current.performSave('doc-1'); + }); + + expect(result.current.documents['doc-1'].saveBlockedByLock).toBe(true); + // Unsaved content is preserved, not silently dropped. + expect(result.current.documents['doc-1'].isDirty).toBe(true); + expect(result.current.documents['doc-1'].saveStatus).toBe('idle'); + }); + + it('clears the lock-blocked flag after the next successful save', async () => { + const { result } = renderHook(() => useDocumentStore()); + const mockEditor = createValidMockEditor() as any; + + act(() => { + result.current.initDocumentWithEditor({ + content: '# Test', + documentId: 'doc-1', + editor: mockEditor, + sourceType: 'page', + }); + result.current.markDirty('doc-1'); + }); + + const lockError = Object.assign(new Error('locked'), { data: { code: 'CONFLICT' } }); + vi.mocked(documentService.updateDocument).mockRejectedValueOnce(lockError); + await act(async () => { + await result.current.performSave('doc-1'); + }); + expect(result.current.documents['doc-1'].saveBlockedByLock).toBe(true); + + vi.mocked(documentService.updateDocument).mockResolvedValue({ + historyAppended: false, + id: 'doc-1', + }); + act(() => { + result.current.markDirty('doc-1'); + }); + await act(async () => { + await result.current.performSave('doc-1'); + }); + + expect(result.current.documents['doc-1'].saveBlockedByLock).toBe(false); + expect(result.current.documents['doc-1'].isDirty).toBe(false); + }); + it('should save metadata-only updates when history is not appended', async () => { const { result } = renderHook(() => useDocumentStore()); const mockEditor = createValidMockEditor() as any; diff --git a/src/store/document/slices/editor/action.ts b/src/store/document/slices/editor/action.ts index 359e27655e..f3e69c3f11 100644 --- a/src/store/document/slices/editor/action.ts +++ b/src/store/document/slices/editor/action.ts @@ -345,12 +345,22 @@ export class EditorActionImpl { lastSavedContent: currentContent, lastSavedEditorData: structuredClone(currentEditorData), lastUpdatedTime: result.savedAt ? new Date(result.savedAt) : new Date(), + saveBlockedByLock: false, saveStatus: 'saved', }, }); } catch (error) { - console.error('[DocumentStore] Failed to save:', error); - internal_dispatchDocument({ id, type: 'updateDocument', value: { saveStatus: 'idle' } }); + // The server rejects writes to a workspace document another collaborator is + // actively editing (CONFLICT). Surface it as a lock block so the editor can + // flip to read-only at once instead of silently dropping the edit, and keep + // `isDirty` so the unsaved content stays visible to copy out. + const lockBlocked = (error as { data?: { code?: string } })?.data?.code === 'CONFLICT'; + if (!lockBlocked) console.error('[DocumentStore] Failed to save:', error); + internal_dispatchDocument({ + id, + type: 'updateDocument', + value: { saveBlockedByLock: lockBlocked || undefined, saveStatus: 'idle' }, + }); } }; diff --git a/src/store/document/slices/editor/initialState.ts b/src/store/document/slices/editor/initialState.ts index 0a9fa98dda..256b06a965 100644 --- a/src/store/document/slices/editor/initialState.ts +++ b/src/store/document/slices/editor/initialState.ts @@ -48,6 +48,12 @@ export interface EditorContentState { * Last updated time */ lastUpdatedTime: Date | null; + /** + * True when the last save was rejected because another collaborator holds the + * document's edit lock. Lets the editor flip to read-only immediately instead + * of waiting for the next lock heartbeat. Cleared on the next successful save. + */ + saveBlockedByLock?: boolean; /** * Current save status */ diff --git a/src/store/document/slices/editor/selectors.ts b/src/store/document/slices/editor/selectors.ts index 1335255f34..47a25a6ae9 100644 --- a/src/store/document/slices/editor/selectors.ts +++ b/src/store/document/slices/editor/selectors.ts @@ -18,6 +18,9 @@ const isDirty = (id: string) => (s: DocumentStore) => s.documents[id]?.isDirty ? const saveStatus = (id: string) => (s: DocumentStore) => s.documents[id]?.saveStatus ?? 'idle'; +const saveBlockedByLock = (id: string) => (s: DocumentStore) => + s.documents[id]?.saveBlockedByLock ?? false; + const content = (id: string) => (s: DocumentStore) => s.documents[id]?.content ?? ''; const editorData = (id: string) => (s: DocumentStore) => s.documents[id]?.editorData; @@ -102,6 +105,7 @@ export const editorSelectors = { isDocumentLoading, isDirty, lastUpdatedTime, + saveBlockedByLock, saveStatus, sourceType, diff --git a/src/store/page/slices/crud/action.ts b/src/store/page/slices/crud/action.ts index 71451fdc09..420fc29bb2 100644 --- a/src/store/page/slices/crud/action.ts +++ b/src/store/page/slices/crud/action.ts @@ -378,6 +378,8 @@ export class CrudActionImpl { totalCharCount: document.content?.length || 0, totalLineCount: 0, updatedAt: document.updatedAt ? new Date(document.updatedAt) : new Date(), + userId: document.userId, + workspaceId: document.workspaceId ?? null, }; return fullPage; diff --git a/src/store/page/slices/list/action.ts b/src/store/page/slices/list/action.ts index 79e77060c4..b69994bd9b 100644 --- a/src/store/page/slices/list/action.ts +++ b/src/store/page/slices/list/action.ts @@ -30,6 +30,7 @@ const documentItemToLobeDocument = (document: DocumentItem): LobeDocument => ({ totalLineCount: 0, updatedAt: document.updatedAt ? new Date(document.updatedAt) : new Date(), userId: document.userId, + workspaceId: document.workspaceId ?? null, }); const n = setNamespace('page/list'); diff --git a/src/store/task/selectors/detailSelectors.ts b/src/store/task/selectors/detailSelectors.ts index c98c8d0c66..02f0f25f35 100644 --- a/src/store/task/selectors/detailSelectors.ts +++ b/src/store/task/selectors/detailSelectors.ts @@ -64,6 +64,8 @@ const activeTaskReview = (s: TaskStoreState) => activeTaskDetail(s)?.review; const activeTaskWorkspace = (s: TaskStoreState) => activeTaskDetail(s)?.workspace ?? []; +const activeTaskWorkspaceId = (s: TaskStoreState) => activeTaskDetail(s)?.workspaceId; + const activeTaskError = (s: TaskStoreState) => activeTaskDetail(s)?.error; const activeTaskTopicCount = (s: TaskStoreState) => activeTaskDetail(s)?.topicCount ?? 0; @@ -115,6 +117,7 @@ export const taskDetailSelectors = { activeTaskSubtasks, activeTaskTopicCount, activeTaskWorkspace, + activeTaskWorkspaceId, activeTopicDrawerTopicId, canCancelActiveTask, canPauseActiveTask,