feat: page/agent/agentGroup/task edit lock (#15786)

* feat: support page editor lock

Squashed page-lock feature work:
- support page editor lock
- support agent group / agent / task edit
- add edit lock to agent/agentgroup/task
- refactor page lock
- fix workspaceId for edit objects
- align with agent/group/task

* fix: collaborative edit lock

* chore: update i18n

* fix: redis acquire

* fix: release lock

* fix: test case

* chore: complement page lock test cases
This commit is contained in:
Rdmclin2
2026-06-14 01:40:36 +08:00
committed by GitHub
parent 99411041b9
commit 913ee4210d
192 changed files with 3346 additions and 46 deletions
@@ -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();
});
});
});
});
@@ -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();
});
});
});
});
+60
View File
@@ -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' },
);
}),
});
@@ -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;
@@ -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)
+55
View File
@@ -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 }) => {
@@ -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', () => {
+115 -1
View File
@@ -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<DocumentLockResult> {
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<DocumentLockResult> {
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<void> {
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<UpdateDocumentResult> {
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<string, unknown> = {};
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;
}
/**
@@ -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;
}
@@ -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<string, string>();
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);
});
});
+153
View File
@@ -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<EditLockResult> {
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<string | undefined> {
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<string | null> {
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<boolean> {
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,
};
}
}
@@ -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();
});
});
@@ -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<void> => {
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<IStreamEventManager['publishStreamEvent']>[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<void> => {
await getManager().subscribeStreamEvents(
resourceChannelId(ref),
'$',
(events) => {
for (const e of events) {
const { actorId, ...rest } = (e.data ?? {}) as Record<string, unknown>;
onEvent({
actorId: typeof actorId === 'string' ? actorId : '',
data: rest,
timestamp: e.timestamp,
type: e.type as unknown as ResourceEvent['type'],
});
}
},
signal,
);
};
@@ -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<string, unknown>;
type: ResourceEventType;
}
export interface ReceivedResourceEvent extends ResourceEvent {
timestamp: number;
}
+1
View File
@@ -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,
};
}
+8
View File
@@ -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": "جارٍ تحضير الرد",
+7
View File
@@ -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": "تجاوز الإعداد الافتراضي للوكيل لهذه المحادثة فقط",
+5
View File
@@ -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",
+2
View File
@@ -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}}",
+1
View File
@@ -35,6 +35,7 @@
"QuotaLimitReached": "عذرًا، تم الوصول إلى الحد الأقصى لاستخدام الرموز أو عدد الطلبات لهذه المفتاح. يرجى زيادة حصة المفتاح أو إعادة المحاولة لاحقًا.",
"RateLimitExceeded": "عذرًا، تم الوصول إلى الحد الأقصى لاستخدام الرموز أو عدد الطلبات لهذه المفتاح. يرجى إعادة المحاولة لاحقًا أو زيادة حصة المفتاح.",
"StateStorePersistError": "تسبب مشكلة مؤقتة في تخزين حالة المحادثة في تعطيل هذه العملية. يرجى إعادة المحاولة؛ إذا استمرت المشكلة، يرجى الاتصال بالدعم.",
"StateStoreReadError": "تعذر استئناف هذه العملية لأن حالة الجلسة غير متوفرة. يرجى إعادة فتح المحادثة للمتابعة؛ وإذا استمرت المشكلة، يرجى الاتصال بالدعم.",
"StreamChunkError": "حدث خطأ أثناء تحليل جزء الرسالة من الطلب المتدفق. يرجى التحقق مما إذا كانت واجهة API الحالية تتوافق مع المواصفات القياسية، أو الاتصال بمزود API للحصول على المساعدة.",
"UpstreamGatewayError": "عاد البوابة أو الوكيل المصدر بخطأ. يرجى إعادة المحاولة قريبًا؛ إذا استمرت المشكلة، تحقق من إعدادات الوكيل / نقطة النهاية.",
"UpstreamHttpError": "عاد المزود بخطأ HTTP دون تفاصيل إضافية. يرجى إعادة المحاولة، أو التحقق من الطلب وإعدادات النموذج.",
+34
View File
@@ -0,0 +1,34 @@
{
"generatingPhrases": [
"يعمل",
"يُصمم",
"يفكر",
"يحسب",
"يُخمّر",
"يُركّب",
"يُحلل",
"يُهندس",
"يؤلف",
"يُنسق",
"يرسم",
"يُبدع",
"يتأمل",
"يصنع",
"يُشعل",
"يُغلي ببطء",
"يُدور",
"يُسيطر",
"يُلمع",
"يُجهز الإجابة",
"يخبز",
"يُوجه",
"يُدمج",
"يُفك الشيفرة",
"يُصنع",
"يُوائم",
"يُرتجل",
"يستنتج",
"يُجرب",
"يتعرج"
]
}
+3
View File
@@ -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": "تمت إزالة الخادم",
+6
View File
@@ -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": "ابحث في المواضيع...",
+8
View File
@@ -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": "Подготвяне на отговор",
+7
View File
@@ -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": "Замяна на настройката по подразбиране на агента само за този разговор",
+5
View File
@@ -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",
+2
View File
@@ -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}}",
+1
View File
@@ -35,6 +35,7 @@
"QuotaLimitReached": "Съжаляваме, използването на токени или броят на заявките достигна лимита на квотата за този ключ. Моля, увеличете квотата на ключа или опитайте отново по-късно.",
"RateLimitExceeded": "Съжаляваме, използването на токени или броят на заявките достигна лимита на скоростта за този ключ. Моля, опитайте отново по-късно или увеличете квотата на ключа.",
"StateStorePersistError": "Временен проблем с хранилището на състоянието на разговора прекъсна тази операция. Моля, опитайте отново; ако проблемът продължи, свържете се с поддръжката.",
"StateStoreReadError": "Тази операция не може да бъде възобновена, защото състоянието на сесията не е налично. Моля, отворете разговора отново, за да продължите; ако проблемът продължава, свържете се с поддръжката.",
"StreamChunkError": "Грешка при анализиране на част от съобщението в заявката за стрийминг. Моля, проверете дали текущият API интерфейс отговаря на стандартните спецификации или се свържете с вашия доставчик на API за помощ.",
"UpstreamGatewayError": "Горният шлюз или прокси върна грешка. Моля, опитайте отново след малко; ако проблемът продължи, проверете конфигурацията на вашето прокси/крайна точка.",
"UpstreamHttpError": "Доставчикът върна HTTP грешка без допълнителни подробности. Моля, опитайте отново или проверете вашата заявка и конфигурацията на модела.",
+34
View File
@@ -0,0 +1,34 @@
{
"generatingPhrases": [
"Работя",
"Скицирам",
"Мисля",
"Изчислявам",
"Приготвям",
"Синтезирам",
"Смятам",
"Проектирам",
"Съставям",
"Оркестрирам",
"Рисувам",
"Импровизирам",
"Размишлявам",
"Създавам",
"Фламбирам",
"Задушавам",
"Въртя",
"Овладявам",
"Полиране",
"Подготвям отговора",
"Пека",
"Канализирам",
"Обединявам",
"Разшифровам",
"Изковавам",
"Хармонизирам",
"Импровизирам",
"Извеждам",
"Пипам",
"Зигзагообразно"
]
}
+3
View File
@@ -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": "Сървърът е премахнат",
+6
View File
@@ -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": "Търсене в темите...",
+8
View File
@@ -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",
+7
View File
@@ -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",
+5
View File
@@ -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",
+2
View File
@@ -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}}",
+1
View File
@@ -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.",
+34
View File
@@ -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"
]
}
+3
View File
@@ -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",
+6
View File
@@ -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...",
+1 -1
View File
@@ -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",
+5
View File
@@ -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 cant be saved right now.",
"pageEditor.lock.editingBySomeone": "Someone else is editing this page. Your changes cant be saved right now.",
"pageEditor.menu.copyLink": "Copy Link",
"pageEditor.menu.export": "Export",
"pageEditor.menu.export.markdown": "Markdown",
+1
View File
@@ -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.",
+3 -3
View File
@@ -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",
+8
View File
@@ -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",
+7
View File
@@ -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",
+5
View File
@@ -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",
+2
View File
@@ -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}}",
+1
View File
@@ -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.",
+34
View File
@@ -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"
]
}
+3
View File
@@ -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",
+6
View File
@@ -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...",
+8
View File
@@ -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": "در حال آماده‌سازی پاسخ",
+7
View File
@@ -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": "جایگزینی پیش‌فرض عامل فقط برای این مکالمه",
+5
View File
@@ -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",
+2
View File
@@ -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}}",
+1
View File
@@ -35,6 +35,7 @@
"QuotaLimitReached": "متأسفیم، استفاده از توکن یا تعداد درخواست‌ها به حد سهمیه این کلید رسیده است. لطفاً سهمیه کلید را افزایش داده یا بعداً دوباره تلاش کنید.",
"RateLimitExceeded": "متأسفیم، استفاده از توکن یا تعداد درخواست‌ها به حد نرخ این کلید رسیده است. لطفاً بعداً دوباره تلاش کنید یا سهمیه کلید را افزایش دهید.",
"StateStorePersistError": "یک مشکل موقت در ذخیره‌سازی وضعیت مکالمه این عملیات را مختل کرد. لطفاً دوباره تلاش کنید؛ اگر مشکل ادامه داشت، با پشتیبانی تماس بگیرید.",
"StateStoreReadError": "این عملیات نمی‌تواند ادامه یابد زیرا وضعیت جلسه در دسترس نیست. لطفاً مکالمه را دوباره باز کنید تا ادامه دهید؛ اگر مشکل ادامه داشت، با پشتیبانی تماس بگیرید.",
"StreamChunkError": "خطا در تجزیه بخش پیام درخواست جریان. لطفاً بررسی کنید که آیا رابط API فعلی با مشخصات استاندارد مطابقت دارد یا با ارائه‌دهنده API خود تماس بگیرید.",
"UpstreamGatewayError": "دروازه یا پراکسی بالادستی خطایی بازگرداند. لطفاً به زودی دوباره تلاش کنید؛ اگر مشکل ادامه داشت، پیکربندی پراکسی/نقطه پایانی خود را بررسی کنید.",
"UpstreamHttpError": "ارائه‌دهنده یک خطای HTTP بدون جزئیات بیشتر بازگرداند. لطفاً دوباره تلاش کنید یا درخواست و پیکربندی مدل خود را بررسی کنید.",
+34
View File
@@ -0,0 +1,34 @@
{
"generatingPhrases": [
"در حال کار",
"در حال پیش‌نویس",
"در حال تفکر",
"در حال محاسبه",
"در حال دم کردن",
"در حال ترکیب",
"در حال پردازش",
"در حال معماری",
"در حال ترکیب‌بندی",
"در حال هماهنگی",
"در حال طراحی",
"در حال آزمودن",
"در حال اندیشیدن",
"در حال ساختن",
"در حال شعله‌ور کردن",
"در حال جوشاندن",
"در حال چرخیدن",
"در حال مدیریت",
"در حال پرداخت",
"در حال آماده‌سازی پاسخ",
"در حال پختن",
"در حال هدایت",
"در حال هم‌گرایی",
"در حال رمزگشایی",
"در حال شکل‌دهی",
"در حال هماهنگ‌سازی",
"در حال بداهه‌پردازی",
"در حال استنتاج",
"در حال دست‌کاری",
"در حال زیگزاگ رفتن"
]
}
+3
View File
@@ -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": "سرور حذف شد",
+6
View File
@@ -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": "جستجوی گفت‌وگوها...",
+8
View File
@@ -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",
+7
View File
@@ -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",
+5
View File
@@ -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",
+2
View File
@@ -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}}",
+1
View File
@@ -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.",
+34
View File
@@ -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"
]
}
+3
View File
@@ -1186,6 +1186,9 @@
"tools.klavis.notEnabled": "Service Klavis non activé",
"tools.klavis.oauthRequired": "Veuillez compléter lauthentification 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é",
+6
View File
@@ -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...",
+8
View File
@@ -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",
+7
View File
@@ -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",
+5
View File
@@ -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",
+2
View File
@@ -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}}",
+1
View File
@@ -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.",
+34
View File
@@ -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"
]
}
+3
View File
@@ -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",
+6
View File
@@ -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...",
+8
View File
@@ -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": "応答を準備中",
+7
View File
@@ -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": "この会話のみでエージェントのデフォルトを上書き",
+5
View File
@@ -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",
+2
View File
@@ -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}}",
+1
View File
@@ -35,6 +35,7 @@
"QuotaLimitReached": "申し訳ありませんが、このキーのトークン使用量またはリクエスト数がクォータ制限に達しました。キーのクォータを増やすか、後で再試行してください。",
"RateLimitExceeded": "申し訳ありませんが、このキーのトークン使用量またはリクエスト数がレート制限に達しました。後で再試行するか、キーのクォータを増やしてください。",
"StateStorePersistError": "会話状態ストアの一時的な問題により、この操作が中断されました。再試行してください。それでも解決しない場合はサポートにお問い合わせください。",
"StateStoreReadError": "この操作はセッション状態が利用できないため再開できませんでした。会話を再度開いて続行してください。それでも問題が解決しない場合は、サポートにお問い合わせください。",
"StreamChunkError": "ストリーミングリクエストのメッセージチャンクの解析中にエラーが発生しました。現在のAPIインターフェースが標準仕様に準拠しているか確認するか、APIプロバイダーにお問い合わせください。",
"UpstreamGatewayError": "上流のゲートウェイまたはプロキシがエラーを返しました。しばらくしてから再試行してください。それでも解決しない場合は、プロキシ/エンドポイントの設定を確認してください。",
"UpstreamHttpError": "プロバイダーが詳細のないHTTPエラーを返しました。再試行するか、リクエストとモデルの設定を確認してください。",
+34
View File
@@ -0,0 +1,34 @@
{
"generatingPhrases": [
"作業中",
"下書き中",
"考え中",
"計算中",
"醸造中",
"合成中",
"解析中",
"設計中",
"作曲中",
"編曲中",
"スケッチ中",
"試行錯誤中",
"熟考中",
"制作中",
"フランベ中",
"煮込み中",
"回転中",
"調整中",
"仕上げ中",
"回答を準備中",
"焼き上げ中",
"チャネリング中",
"統合中",
"解読中",
"鍛造中",
"調和中",
"即興中",
"推論中",
"微調整中",
"ジグザグ中"
]
}
+3
View File
@@ -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": "サーバーが削除されました",
+6
View File
@@ -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": "トピックを検索...",
+8
View File
@@ -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": "응답 준비 중",
+7
View File
@@ -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": "이 대화에 대해서만 에이전트 기본값 재정의",
+5
View File
@@ -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": "마크다운",
+2
View File
@@ -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}}",
+1
View File
@@ -35,6 +35,7 @@
"QuotaLimitReached": "죄송합니다. 이 키의 토큰 사용량 또는 요청 수가 할당량 한도에 도달했습니다. 키의 할당량을 늘리거나 나중에 다시 시도하세요.",
"RateLimitExceeded": "죄송합니다. 이 키의 토큰 사용량 또는 요청 수가 속도 제한에 도달했습니다. 나중에 다시 시도하거나 키의 할당량을 늘리세요.",
"StateStorePersistError": "대화 상태 저장소의 일시적인 문제로 인해 이 작업이 중단되었습니다. 다시 시도하세요. 문제가 지속되면 지원팀에 문의하세요.",
"StateStoreReadError": "이 작업은 세션 상태를 사용할 수 없어 다시 시작할 수 없습니다. 대화를 다시 열어 계속 진행하십시오. 문제가 지속되면 지원팀에 문의하십시오.",
"StreamChunkError": "스트리밍 요청의 메시지 청크를 구문 분석하는 중 오류가 발생했습니다. 현재 API 인터페이스가 표준 사양을 준수하는지 확인하거나 API 제공업체에 문의하세요.",
"UpstreamGatewayError": "상위 게이트웨이 또는 프록시에서 오류를 반환했습니다. 잠시 후 다시 시도하세요. 문제가 지속되면 프록시/엔드포인트 구성을 확인하세요.",
"UpstreamHttpError": "제공업체가 추가 세부 정보 없이 HTTP 오류를 반환했습니다. 다시 시도하거나 요청 및 모델 구성을 확인하세요.",
+34
View File
@@ -0,0 +1,34 @@
{
"generatingPhrases": [
"작업 중",
"초안 작성 중",
"생각 중",
"계산 중",
"양조 중",
"합성 중",
"분석 중",
"구상 중",
"작곡 중",
"조율 중",
"스케치 중",
"아이디어 구상 중",
"숙고 중",
"제작 중",
"플람베 중",
"끓이는 중",
"윙윙거리는 중",
"조율 중",
"다듬는 중",
"답변 준비 중",
"굽는 중",
"채널링 중",
"통합 중",
"해독 중",
"단련 중",
"조화 중",
"즉흥적으로 만드는 중",
"추론 중",
"손질 중",
"지그재그로 움직이는 중"
]
}
+3
View File
@@ -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": "서버 삭제됨",
+6
View File
@@ -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": "주제 검색...",
+8
View File
@@ -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",
+7
View File
@@ -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",
+5
View File
@@ -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",
+2
View File
@@ -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}}",
+1
View File
@@ -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.",
+34
View File
@@ -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"
]
}
+3
View File
@@ -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",
+6
View File
@@ -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...",
+8
View File
@@ -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",

Some files were not shown because too many files have changed in this diff Show More