@xpert-ai/plugin-spaces 0.0.0-stage → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +25 -2
- package/dist/access.d.ts +38 -0
- package/dist/access.js +96 -0
- package/dist/ai.d.ts +53 -0
- package/dist/ai.js +143 -0
- package/dist/artifacts.d.ts +9 -0
- package/dist/artifacts.js +40 -0
- package/dist/capability.d.ts +22 -0
- package/dist/capability.js +63 -0
- package/dist/catalog.d.ts +196 -0
- package/dist/catalog.js +140 -0
- package/dist/content-backup.d.ts +7 -0
- package/dist/content-backup.js +35 -0
- package/dist/content-coverage.d.ts +9 -0
- package/dist/content-coverage.js +12 -0
- package/dist/content-knowledge.d.ts +40 -0
- package/dist/content-knowledge.js +168 -0
- package/dist/content.d.ts +19 -0
- package/dist/content.js +47 -0
- package/dist/documents.d.ts +38 -0
- package/dist/documents.js +177 -0
- package/dist/domain.d.ts +95 -0
- package/dist/domain.js +23 -0
- package/dist/editors.d.ts +16 -0
- package/dist/editors.js +63 -0
- package/dist/entities.d.ts +91 -0
- package/dist/entities.js +318 -0
- package/dist/files.d.ts +79 -0
- package/dist/files.js +228 -0
- package/dist/imports.d.ts +83 -0
- package/dist/imports.js +139 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +51 -0
- package/dist/item-catalog.d.ts +13 -0
- package/dist/item-catalog.js +71 -0
- package/dist/knowledge.d.ts +57 -0
- package/dist/knowledge.js +172 -0
- package/dist/middleware.d.ts +17 -0
- package/dist/middleware.js +82 -0
- package/dist/pages.d.ts +38 -0
- package/dist/pages.js +41 -0
- package/dist/proposal-id.d.ts +3 -0
- package/dist/proposal-id.js +11 -0
- package/dist/references.d.ts +22 -0
- package/dist/references.js +119 -0
- package/dist/remote/app.css +4 -0
- package/dist/remote/app.js +167 -0
- package/dist/resource-folders.d.ts +7 -0
- package/dist/resource-folders.js +21 -0
- package/dist/resource-provider.d.ts +39 -0
- package/dist/resource-provider.js +160 -0
- package/dist/spaces.d.ts +95 -0
- package/dist/spaces.js +233 -0
- package/dist/subpages.d.ts +24 -0
- package/dist/subpages.js +118 -0
- package/dist/view.d.ts +99 -0
- package/dist/view.js +259 -0
- package/docs/.mintignore +7 -0
- package/docs/AGENTS.md +3 -0
- package/docs/LICENSE +21 -0
- package/docs/README.md +3 -0
- package/docs/architecture.mdx +49 -0
- package/docs/collaboration.mdx +50 -0
- package/docs/docs.json +39 -0
- package/docs/feishu-document-import.mdx +118 -0
- package/docs/index.mdx +12 -0
- package/docs/insert-menu-plan.mdx +74 -0
- package/docs/math-subpages.mdx +62 -0
- package/docs/page-resources-and-connectors.mdx +103 -0
- package/docs/quickstart.mdx +17 -0
- package/migrations/20261023-document-import-origin.sql +4 -0
- package/migrations/20261023-subpages.sql +6 -0
- package/package.json +79 -4
package/dist/subpages.js
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
|
2
|
+
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
3
|
+
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
4
|
+
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
|
5
|
+
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
6
|
+
};
|
|
7
|
+
var __metadata = (this && this.__metadata) || function (k, v) {
|
|
8
|
+
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
|
9
|
+
};
|
|
10
|
+
import { ConflictException, Injectable } from '@nestjs/common';
|
|
11
|
+
import { z } from 'zod/v3';
|
|
12
|
+
import { SpaceAccess } from './access.js';
|
|
13
|
+
import { Space } from './entities.js';
|
|
14
|
+
import { SpaceDocuments, contentHash, safeName } from './documents.js';
|
|
15
|
+
import { SpaceArtifacts } from './artifacts.js';
|
|
16
|
+
import { denied, scoped } from './domain.js';
|
|
17
|
+
const create = z.object({ requestId: z.string().uuid(), title: z.string().trim().min(1).max(180) });
|
|
18
|
+
const missing = (error) => error !== null && typeof error === 'object' && 'code' in error && error.code === 'ENOENT';
|
|
19
|
+
const entry = (item) => ({ resourceId: item.id, title: item.title, path: item.filePath });
|
|
20
|
+
/** Logical hierarchy is metadata, never an ACL or a disguised file folder.
|
|
21
|
+
* Each create owns one stable resource/path. Retry resumes publication instead of duplicating a child.
|
|
22
|
+
* Parent rename/move/trash does not mutate independent child files; a restored parent reconnects them.
|
|
23
|
+
*/
|
|
24
|
+
let SpaceSubpages = class SpaceSubpages {
|
|
25
|
+
access;
|
|
26
|
+
documents;
|
|
27
|
+
artifacts;
|
|
28
|
+
constructor(access, documents, artifacts) {
|
|
29
|
+
this.access = access;
|
|
30
|
+
this.documents = documents;
|
|
31
|
+
this.artifacts = artifacts;
|
|
32
|
+
}
|
|
33
|
+
actor(input) { return { ...input.actor, projectId: input.projectId, assistantId: input.assistantId }; }
|
|
34
|
+
async parent(input, write = false) {
|
|
35
|
+
const result = await this.access.resource(this.actor(input), input.sourceResourceId, write ? 'write' : 'read');
|
|
36
|
+
if (!result.item || result.item.kind !== 'page' || !result.item.filePath || result.space.projectId !== input.projectId)
|
|
37
|
+
throw denied();
|
|
38
|
+
return { ...result, item: result.item };
|
|
39
|
+
}
|
|
40
|
+
async hierarchy(input) {
|
|
41
|
+
const { item, space, canWrite } = await this.parent(input);
|
|
42
|
+
const actor = this.actor(input);
|
|
43
|
+
const visible = async (candidate) => {
|
|
44
|
+
if (!candidate || candidate.deleted || candidate.editorKey !== 'markdown.tiptap' || !candidate.filePath)
|
|
45
|
+
return null;
|
|
46
|
+
try {
|
|
47
|
+
await this.access.resource(actor, candidate.id, 'read');
|
|
48
|
+
return entry(candidate);
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
if (error instanceof Error && error.message === 'spaces.access_denied')
|
|
52
|
+
return null;
|
|
53
|
+
throw error;
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
const parent = item.parentPageId ? await visible(await this.access.items.findOneBy({ ...scoped(actor), spaceId: space.id, id: item.parentPageId })) : null;
|
|
57
|
+
const rows = await this.access.items.find({ where: { ...scoped(actor), spaceId: space.id, parentPageId: item.id, deleted: false },
|
|
58
|
+
order: { createdAt: 'ASC' }, take: 101 });
|
|
59
|
+
const children = (await Promise.all(rows.slice(0, 100).map(visible))).filter((value) => value !== null);
|
|
60
|
+
return { supported: true, canCreate: canWrite, parent, children, hasMore: rows.length > 100 };
|
|
61
|
+
}
|
|
62
|
+
async createChild(input) {
|
|
63
|
+
const request = create.parse(input), actor = this.actor(input);
|
|
64
|
+
const { space } = await this.parent(input, true);
|
|
65
|
+
// Use the same Space lock as folder moves/discovery. Only the lock uses this transaction;
|
|
66
|
+
// publication is a retryable saga and its intent must survive process failure.
|
|
67
|
+
return this.access.items.manager.transaction(async (manager) => {
|
|
68
|
+
await manager.getRepository(Space).findOneOrFail({ where: { ...scoped(actor), id: space.id }, lock: { mode: 'pessimistic_write' } });
|
|
69
|
+
const { item: parent } = await this.parent(input, true);
|
|
70
|
+
let item = await this.access.items.findOneBy({ id: request.requestId });
|
|
71
|
+
if (item && (item.tenantId !== actor.tenantId || item.organizationId !== actor.organizationId || item.spaceId !== space.id ||
|
|
72
|
+
item.parentPageId !== parent.id || item.childRequestTitle !== request.title || item.createdById !== actor.userId || item.deleted))
|
|
73
|
+
throw new ConflictException('spaces.child_request_conflict');
|
|
74
|
+
if (!item) {
|
|
75
|
+
// New IDs cannot make cycles. Bound depth so corrupt metadata cannot loop forever.
|
|
76
|
+
// Count the child itself as level one, then count its ancestors.
|
|
77
|
+
let cursor = parent, depth = 1;
|
|
78
|
+
while (cursor) {
|
|
79
|
+
if (++depth > 32 || cursor.id === request.requestId)
|
|
80
|
+
throw new ConflictException('spaces.page_depth');
|
|
81
|
+
cursor = cursor.parentPageId ? await this.access.items.findOneBy({ ...scoped(actor), spaceId: space.id, id: cursor.parentPageId }) : null;
|
|
82
|
+
}
|
|
83
|
+
const directory = parent.filePath.slice(0, parent.filePath.lastIndexOf('/') + 1);
|
|
84
|
+
// Resource suffix avoids collisions between equally named children and unrelated files.
|
|
85
|
+
const filePath = `${directory}${safeName(request.title).slice(0, 100)}--${request.requestId}.md`;
|
|
86
|
+
item = await this.access.items.save(this.access.items.create({ ...scoped(actor), id: request.requestId,
|
|
87
|
+
spaceId: space.id, parentId: parent.parentId, parentPageId: parent.id, childRequestTitle: request.title,
|
|
88
|
+
createdById: actor.userId, kind: 'page', title: request.title, filePath, syncStatus: 'pending' }));
|
|
89
|
+
}
|
|
90
|
+
if (item.editorKey === 'markdown.tiptap')
|
|
91
|
+
return { documentId: item.id };
|
|
92
|
+
const fileScope = { actor, projectId: input.projectId, path: item.filePath };
|
|
93
|
+
const markdown = `# ${request.title.replace(/[\\`*_{}\[\]()#+.!<>|$]/gu, '\\$&')}\n\n`;
|
|
94
|
+
const buffer = Buffer.from(markdown), hash = contentHash(buffer);
|
|
95
|
+
const disk = await this.documents.api().read(fileScope).catch((error) => { if (missing(error))
|
|
96
|
+
return null; throw error; });
|
|
97
|
+
if (disk && disk.sha256 !== (item.fileHash ?? hash))
|
|
98
|
+
throw new ConflictException('spaces.file_conflict');
|
|
99
|
+
if (!disk) {
|
|
100
|
+
if (item.fileHash)
|
|
101
|
+
throw new ConflictException('spaces.file_conflict');
|
|
102
|
+
await this.documents.api().write({ ...fileScope, buffer, expectedHash: null });
|
|
103
|
+
}
|
|
104
|
+
await this.access.items.update(item.id, { fileHash: disk?.sha256 ?? hash, syncStatus: 'ready' });
|
|
105
|
+
await this.documents.content().register({ actor, assistantId: input.assistantId, projectId: input.projectId,
|
|
106
|
+
resourceId: item.id, path: item.filePath, title: item.title, modelKey: 'markdown.tiptap', source: { providerKey: 'spaces', resourceId: item.id } });
|
|
107
|
+
if (!item.artifactId)
|
|
108
|
+
await this.artifacts.initializePage(actor, item.id, item.title);
|
|
109
|
+
await this.access.items.update(item.id, { editorKey: 'markdown.tiptap' });
|
|
110
|
+
return { documentId: item.id };
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
SpaceSubpages = __decorate([
|
|
115
|
+
Injectable(),
|
|
116
|
+
__metadata("design:paramtypes", [SpaceAccess, SpaceDocuments, SpaceArtifacts])
|
|
117
|
+
], SpaceSubpages);
|
|
118
|
+
export { SpaceSubpages };
|
package/dist/view.d.ts
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { SpaceCatalog } from './catalog.js';
|
|
2
|
+
import { SpaceImports } from './imports.js';
|
|
3
|
+
import type { XpertExtensionViewManifest, XpertResolvedViewHostContext, XpertViewQuery, XpertViewActionRequest, XpertRemoteComponentViewSchema, XpertViewFileAccessRequest } from '@xpert-ai/contracts';
|
|
4
|
+
import { type IXpertViewExtensionProvider, type XpertViewFileActionFile } from '@xpert-ai/plugin-sdk';
|
|
5
|
+
import { SpacesService } from './spaces.js';
|
|
6
|
+
import { SpacePages } from './pages.js';
|
|
7
|
+
import { SpaceAi } from './ai.js';
|
|
8
|
+
import { SpaceEditors } from './editors.js';
|
|
9
|
+
import { SpaceFiles } from './files.js';
|
|
10
|
+
import { SpaceKnowledge } from './knowledge.js';
|
|
11
|
+
import { SpaceContent } from './content.js';
|
|
12
|
+
import { SpaceAccess } from './access.js';
|
|
13
|
+
export declare class SpacesView implements IXpertViewExtensionProvider {
|
|
14
|
+
private readonly catalog;
|
|
15
|
+
private readonly imports;
|
|
16
|
+
private readonly content;
|
|
17
|
+
private readonly spaces;
|
|
18
|
+
private readonly pages;
|
|
19
|
+
private readonly ai;
|
|
20
|
+
private readonly files;
|
|
21
|
+
private readonly editors;
|
|
22
|
+
private readonly knowledge;
|
|
23
|
+
private readonly access;
|
|
24
|
+
constructor(catalog: SpaceCatalog, imports: SpaceImports, content: SpaceContent, spaces: SpacesService, pages: SpacePages, ai: SpaceAi, files: SpaceFiles, editors: SpaceEditors, knowledge: SpaceKnowledge, access: SpaceAccess);
|
|
25
|
+
supports(context: XpertResolvedViewHostContext): boolean;
|
|
26
|
+
getViewManifests(context: XpertResolvedViewHostContext, slot: string): XpertExtensionViewManifest[];
|
|
27
|
+
getRemoteComponentEntry(context: XpertResolvedViewHostContext, viewKey: string, component: XpertRemoteComponentViewSchema['component']): Promise<{
|
|
28
|
+
html: string;
|
|
29
|
+
contentType: "text/html; charset=utf-8";
|
|
30
|
+
}>;
|
|
31
|
+
getViewData(context: XpertResolvedViewHostContext, viewKey: string, query: XpertViewQuery): Promise<{
|
|
32
|
+
items: never[];
|
|
33
|
+
meta: {
|
|
34
|
+
requiresProject: boolean;
|
|
35
|
+
};
|
|
36
|
+
} | {
|
|
37
|
+
items: never[];
|
|
38
|
+
meta: {
|
|
39
|
+
project: {
|
|
40
|
+
id: string;
|
|
41
|
+
title: string;
|
|
42
|
+
};
|
|
43
|
+
assistant: {
|
|
44
|
+
id: string;
|
|
45
|
+
title: string;
|
|
46
|
+
dataScope: string;
|
|
47
|
+
};
|
|
48
|
+
root: import("./entities.js").Space | import("./entities.js").SpaceItem;
|
|
49
|
+
role: "editor" | "manager" | "owner" | "member";
|
|
50
|
+
canWrite: boolean;
|
|
51
|
+
canManage: boolean;
|
|
52
|
+
items: (import("./entities.js").SpaceItem & {
|
|
53
|
+
role: string;
|
|
54
|
+
})[];
|
|
55
|
+
requiresProject?: undefined;
|
|
56
|
+
};
|
|
57
|
+
}>;
|
|
58
|
+
executeViewAction(context: XpertResolvedViewHostContext, viewKey: string, action: string, request: XpertViewActionRequest): Promise<{
|
|
59
|
+
success: boolean;
|
|
60
|
+
refresh: boolean;
|
|
61
|
+
data: object;
|
|
62
|
+
}>;
|
|
63
|
+
/** Check once per View request, before accessing resources or serving the empty state.
|
|
64
|
+
* Resource services still enforce their own Project and operation ACLs. */
|
|
65
|
+
private actor;
|
|
66
|
+
private runAction;
|
|
67
|
+
executeViewFileAction(context: XpertResolvedViewHostContext, viewKey: string, actionKey: string, request: XpertViewActionRequest, file: XpertViewFileActionFile): Promise<{
|
|
68
|
+
success: boolean;
|
|
69
|
+
data: import("./entities.js").SpaceItem;
|
|
70
|
+
refresh?: undefined;
|
|
71
|
+
} | {
|
|
72
|
+
success: boolean;
|
|
73
|
+
refresh: boolean;
|
|
74
|
+
data: {
|
|
75
|
+
item: import("./entities.js").SpaceItem;
|
|
76
|
+
version: import("./entities.js").SpaceFileVersion;
|
|
77
|
+
};
|
|
78
|
+
}>;
|
|
79
|
+
resolveViewFile(context: XpertResolvedViewHostContext, viewKey: string, request: XpertViewFileAccessRequest): Promise<{
|
|
80
|
+
reference: {
|
|
81
|
+
userId: string;
|
|
82
|
+
projectId: string;
|
|
83
|
+
catalog: "projects";
|
|
84
|
+
source: "platform.workspace.files";
|
|
85
|
+
filePath: string;
|
|
86
|
+
scopeId: string;
|
|
87
|
+
workspacePath: string;
|
|
88
|
+
tenantId: string;
|
|
89
|
+
organizationId: string;
|
|
90
|
+
};
|
|
91
|
+
fileName: string;
|
|
92
|
+
mimeType: string;
|
|
93
|
+
} | {
|
|
94
|
+
reference: import("@xpert-ai/plugin-sdk").WorkspacePortableFileReference;
|
|
95
|
+
fileName: string;
|
|
96
|
+
mimeType: string;
|
|
97
|
+
size: number | undefined;
|
|
98
|
+
}>;
|
|
99
|
+
}
|
package/dist/view.js
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
|
2
|
+
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
3
|
+
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
4
|
+
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
|
5
|
+
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
6
|
+
};
|
|
7
|
+
var __metadata = (this && this.__metadata) || function (k, v) {
|
|
8
|
+
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
|
9
|
+
};
|
|
10
|
+
import { SpaceCatalog, catalogQuery, catalogCreate, catalogRename, catalogDuplicate, catalogLifecycle, catalogMove } from './catalog.js';
|
|
11
|
+
import { SpaceImports, sourceSelection, importSelection } from './imports.js';
|
|
12
|
+
import { BadRequestException, Injectable } from '@nestjs/common';
|
|
13
|
+
import { readFile } from 'node:fs/promises';
|
|
14
|
+
import { z } from 'zod/v3';
|
|
15
|
+
import { ViewExtensionProvider, renderRemoteReactIframeHtml } from '@xpert-ai/plugin-sdk';
|
|
16
|
+
import { actorSchema, denied, id, PLUGIN, VIEW } from './domain.js';
|
|
17
|
+
import { createItemSchema, editItemSchema, SpacesService } from './spaces.js';
|
|
18
|
+
import { SpacePages } from './pages.js';
|
|
19
|
+
import { SpaceAi } from './ai.js';
|
|
20
|
+
import { SpaceEditors } from './editors.js';
|
|
21
|
+
import { SpaceFiles } from './files.js';
|
|
22
|
+
import { SpaceKnowledge } from './knowledge.js';
|
|
23
|
+
import { SpaceContent } from './content.js';
|
|
24
|
+
import { SpaceAccess } from './access.js';
|
|
25
|
+
const selection = z.object({ itemId: id }).strict();
|
|
26
|
+
const label = (en_US, zh_Hans) => ({ en_US, zh_Hans });
|
|
27
|
+
const actions = ['catalog.duplicate', 'catalog.backup', 'catalog.preferences', 'catalog.preference.set', 'catalog.move', 'catalog.types', 'catalog.list', 'catalog.create', 'catalog.rename', 'catalog.lifecycle', 'source.list', 'source.search', 'source.preview', 'source.import', 'document.open', 'knowledge.enable',
|
|
28
|
+
'knowledge.status', 'knowledge.refresh', 'knowledge.search', 'item.create', 'item.edit', 'page.read', 'search',
|
|
29
|
+
'file.list', 'page.export', 'artifact.list', 'artifact.collect', 'artifact.open', 'editor.list', 'editor.create'];
|
|
30
|
+
let SpacesView = class SpacesView {
|
|
31
|
+
catalog;
|
|
32
|
+
imports;
|
|
33
|
+
content;
|
|
34
|
+
spaces;
|
|
35
|
+
pages;
|
|
36
|
+
ai;
|
|
37
|
+
files;
|
|
38
|
+
editors;
|
|
39
|
+
knowledge;
|
|
40
|
+
access;
|
|
41
|
+
constructor(catalog, imports, content, spaces, pages, ai, files, editors, knowledge, access) {
|
|
42
|
+
this.catalog = catalog;
|
|
43
|
+
this.imports = imports;
|
|
44
|
+
this.content = content;
|
|
45
|
+
this.spaces = spaces;
|
|
46
|
+
this.pages = pages;
|
|
47
|
+
this.ai = ai;
|
|
48
|
+
this.files = files;
|
|
49
|
+
this.editors = editors;
|
|
50
|
+
this.knowledge = knowledge;
|
|
51
|
+
this.access = access;
|
|
52
|
+
}
|
|
53
|
+
supports(context) {
|
|
54
|
+
return context.hostType === 'agent' && context.capabilities?.features?.includes('spaces') === true;
|
|
55
|
+
}
|
|
56
|
+
getViewManifests(context, slot) {
|
|
57
|
+
if (!this.supports(context) || !['agent.workbench.main', 'agent.workbench.fixed'].includes(slot))
|
|
58
|
+
return [];
|
|
59
|
+
return [{ key: VIEW, title: label('Spaces', '协作空间'), hostType: context.hostType, slot,
|
|
60
|
+
source: { provider: 'spaces.views', plugin: PLUGIN }, refreshable: true,
|
|
61
|
+
activation: { requiredFeatures: ['spaces'] },
|
|
62
|
+
workbench: slot === 'agent.workbench.fixed' ? { fixed: true, menu: { enabled: true, label: label('Spaces', '协作空间'), order: 35 } } : {},
|
|
63
|
+
view: { type: 'remote_component', runtime: 'react', protocolVersion: 1,
|
|
64
|
+
component: { isolation: 'iframe', entry: 'spaces' }, dataSource: { mode: 'platform' } },
|
|
65
|
+
dataSource: { mode: 'platform', cache: { enabled: false }, querySchema: { supportsSelection: true, supportsParameters: true } },
|
|
66
|
+
fileAccess: { purposes: ['download'] },
|
|
67
|
+
actions: [...actions.map((key) => ({ key, label: label(key, key), actionType: 'invoke' })),
|
|
68
|
+
{ key: 'editor.import', label: label('Import document', '导入文档'), actionType: 'invoke', transport: 'file' },
|
|
69
|
+
{ key: 'file.upload', label: label('Upload file', '上传文件'), actionType: 'invoke', transport: 'file' }],
|
|
70
|
+
// Declare both phases: platform resource resolution, then container-local navigation.
|
|
71
|
+
clientCommands: ['assistant.chat.send_message', 'assistant.context.set', 'workbench.content.resolve', 'workbench.navigation.open'].map((key) => ({ key, required: key === 'workbench.content.resolve', label: label(key, key) })) }];
|
|
72
|
+
}
|
|
73
|
+
async getRemoteComponentEntry(context, viewKey, component) {
|
|
74
|
+
if (viewKey !== VIEW || component.entry !== 'spaces')
|
|
75
|
+
throw new Error('spaces.view_missing');
|
|
76
|
+
const actor = await this.actor(context, false);
|
|
77
|
+
if (actor.projectId)
|
|
78
|
+
await this.spaces.workspace(actor);
|
|
79
|
+
const [appScript, appCss] = await Promise.all(['app.js', 'app.css'].map((name) => readFile(new URL(`./remote/${name}`, import.meta.url), 'utf8')));
|
|
80
|
+
return { html: renderRemoteReactIframeHtml({ title: 'Spaces', reactUmd: '', reactDomUmd: '', appScript, appCss }), contentType: 'text/html; charset=utf-8' };
|
|
81
|
+
}
|
|
82
|
+
async getViewData(context, viewKey, query) {
|
|
83
|
+
if (viewKey !== VIEW)
|
|
84
|
+
throw new Error('spaces.view_missing');
|
|
85
|
+
const actor = await this.actor(context, false);
|
|
86
|
+
if (!actor.projectId)
|
|
87
|
+
return { items: [], meta: { requiresProject: true } };
|
|
88
|
+
const space = await this.spaces.workspace(actor);
|
|
89
|
+
const resourceId = !query.selectionId || query.selectionId === 'root' ? space.id : id.parse(query.selectionId);
|
|
90
|
+
return { items: [], meta: { ...await this.spaces.tree(actor, resourceId, query.parameters?.trash === true),
|
|
91
|
+
project: { id: space.projectId, title: space.title },
|
|
92
|
+
assistant: { id: context.hostId, title: space.title, dataScope: 'project' } } };
|
|
93
|
+
}
|
|
94
|
+
async executeViewAction(context, viewKey, action, request) {
|
|
95
|
+
try {
|
|
96
|
+
return await this.runAction(context, viewKey, action, request);
|
|
97
|
+
}
|
|
98
|
+
catch (error) {
|
|
99
|
+
if (error instanceof z.ZodError)
|
|
100
|
+
throw new BadRequestException('spaces.invalid_input');
|
|
101
|
+
throw error;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
/** Check once per View request, before accessing resources or serving the empty state.
|
|
105
|
+
* Resource services still enforce their own Project and operation ACLs. */
|
|
106
|
+
async actor(context, requireProject = true) {
|
|
107
|
+
if (!this.supports(context))
|
|
108
|
+
throw denied();
|
|
109
|
+
const actor = actorSchema.extend({ assistantId: id }).parse({ ...context, assistantId: context.hostId, projectId: context.runtimeScope?.projectId ?? undefined });
|
|
110
|
+
if (requireProject && !actor.projectId)
|
|
111
|
+
throw denied();
|
|
112
|
+
await this.access.assistant(actor);
|
|
113
|
+
return actor;
|
|
114
|
+
}
|
|
115
|
+
async runAction(context, viewKey, action, request) {
|
|
116
|
+
if (viewKey !== VIEW)
|
|
117
|
+
throw new Error('spaces.view_missing');
|
|
118
|
+
if (!actions.includes(action))
|
|
119
|
+
throw new Error('spaces.action_unknown');
|
|
120
|
+
const actor = await this.actor(context), input = request.input;
|
|
121
|
+
await this.spaces.workspace(actor);
|
|
122
|
+
let result;
|
|
123
|
+
switch (action) {
|
|
124
|
+
case 'source.list':
|
|
125
|
+
result = await this.imports.sources(actor);
|
|
126
|
+
break;
|
|
127
|
+
case 'source.search':
|
|
128
|
+
result = await this.imports.search(actor, z.object({ bindingId: id, query: z.string().trim().max(200).default(''), cursor: z.string().max(2000).optional(), pageSize: z.number().int().min(1).max(50).default(25) }).strict().parse(input));
|
|
129
|
+
break;
|
|
130
|
+
case 'source.preview':
|
|
131
|
+
result = await this.imports.preview(actor, sourceSelection.parse(input));
|
|
132
|
+
break;
|
|
133
|
+
case 'source.import':
|
|
134
|
+
result = await this.imports.apply(actor, importSelection.parse(input));
|
|
135
|
+
break;
|
|
136
|
+
case 'document.open':
|
|
137
|
+
result = await this.content.open(actor, selection.parse(input).itemId);
|
|
138
|
+
break;
|
|
139
|
+
case 'knowledge.enable':
|
|
140
|
+
case 'knowledge.status':
|
|
141
|
+
case 'knowledge.refresh': {
|
|
142
|
+
const p = z.object({ spaceId: id }).strict().parse(input);
|
|
143
|
+
result = action === 'knowledge.enable' ? await this.knowledge.enable(actor, p.spaceId)
|
|
144
|
+
: action === 'knowledge.refresh' ? await this.knowledge.refresh(actor, p.spaceId) : await this.knowledge.status(actor, p.spaceId);
|
|
145
|
+
break;
|
|
146
|
+
}
|
|
147
|
+
case 'knowledge.search': {
|
|
148
|
+
const p = z.object({ spaceId: id, query: z.string().trim().min(1).max(4000) }).strict().parse(input);
|
|
149
|
+
result = await this.knowledge.search(actor, p.spaceId, p.query);
|
|
150
|
+
break;
|
|
151
|
+
}
|
|
152
|
+
case 'catalog.backup':
|
|
153
|
+
result = await this.catalog.backup(actor, z.object({ contentId: id }).strict().parse(input).contentId);
|
|
154
|
+
break;
|
|
155
|
+
case 'catalog.preferences':
|
|
156
|
+
result = await this.catalog.preferences(actor, z.object({ contentId: id }).strict().parse(input).contentId);
|
|
157
|
+
break;
|
|
158
|
+
case 'catalog.preference.set':
|
|
159
|
+
result = await this.catalog.setPreference(actor, z.object({ contentId: id, scope: z.enum(['resource', 'project', 'organization']), editorKey: z.string().min(1).max(200).nullable(), expectedRevision: z.number().int().nonnegative() }).strict().parse(input));
|
|
160
|
+
break;
|
|
161
|
+
case 'catalog.move':
|
|
162
|
+
result = await this.catalog.move(actor, catalogMove.parse(input));
|
|
163
|
+
break;
|
|
164
|
+
case 'catalog.types':
|
|
165
|
+
result = await this.catalog.types(actor);
|
|
166
|
+
break;
|
|
167
|
+
case 'catalog.list':
|
|
168
|
+
result = await this.catalog.list(actor, catalogQuery.parse(input));
|
|
169
|
+
break;
|
|
170
|
+
case 'catalog.lifecycle':
|
|
171
|
+
result = await this.catalog.lifecycle(actor, catalogLifecycle.parse(input));
|
|
172
|
+
break;
|
|
173
|
+
case 'catalog.duplicate':
|
|
174
|
+
result = await this.catalog.duplicate(actor, catalogDuplicate.parse(input));
|
|
175
|
+
break;
|
|
176
|
+
case 'catalog.rename':
|
|
177
|
+
result = await this.catalog.rename(actor, catalogRename.parse(input));
|
|
178
|
+
break;
|
|
179
|
+
case 'catalog.create':
|
|
180
|
+
result = await this.catalog.create(actor, catalogCreate.parse(input));
|
|
181
|
+
break;
|
|
182
|
+
case 'editor.list':
|
|
183
|
+
result = await this.editors.list(actor);
|
|
184
|
+
break;
|
|
185
|
+
case 'editor.create': {
|
|
186
|
+
const p = z.object({ spaceId: id, parentId: id.nullable().default(null), title: z.string().trim().min(1).max(200), editorKey: z.string().max(100) }).strict().parse(input);
|
|
187
|
+
result = await this.editors.create(actor, p.spaceId, p.parentId, p.title, p.editorKey);
|
|
188
|
+
break;
|
|
189
|
+
}
|
|
190
|
+
case 'item.create':
|
|
191
|
+
result = await this.spaces.createItem(actor, createItemSchema.extend({ kind: z.enum(['folder', 'page']), artifactId: z.never().optional(), artifactVersionId: z.never().optional() }).parse(input));
|
|
192
|
+
break;
|
|
193
|
+
case 'item.edit':
|
|
194
|
+
result = await this.spaces.editItem(actor, editItemSchema.parse(input));
|
|
195
|
+
break;
|
|
196
|
+
case 'page.read':
|
|
197
|
+
result = await this.pages.read(actor, selection.parse(input).itemId);
|
|
198
|
+
break;
|
|
199
|
+
case 'page.export':
|
|
200
|
+
result = await this.files.exportPage(actor, selection.parse(input).itemId);
|
|
201
|
+
break;
|
|
202
|
+
case 'file.list':
|
|
203
|
+
result = await this.files.list(actor, selection.parse(input).itemId);
|
|
204
|
+
break;
|
|
205
|
+
case 'artifact.open':
|
|
206
|
+
result = await this.files.openArtifact(actor, selection.parse(input).itemId);
|
|
207
|
+
break;
|
|
208
|
+
case 'artifact.list':
|
|
209
|
+
result = await this.files.available(actor, z.object({ page: z.number().int().min(1).default(1) }).strict().parse(input).page);
|
|
210
|
+
break;
|
|
211
|
+
case 'artifact.collect': {
|
|
212
|
+
const p = z.object({ spaceId: id, parentId: id.nullable().default(null), artifactId: id, versionId: id }).strict().parse(input);
|
|
213
|
+
result = await this.files.collect(actor, p.spaceId, p.parentId, p.artifactId, p.versionId);
|
|
214
|
+
break;
|
|
215
|
+
}
|
|
216
|
+
case 'search': {
|
|
217
|
+
const p = z.object({ resourceId: id, query: z.string().trim().min(1).max(4000) }).strict().parse(input);
|
|
218
|
+
result = await this.ai.search(actor, p.resourceId, p.query);
|
|
219
|
+
break;
|
|
220
|
+
}
|
|
221
|
+
default: throw new Error('spaces.action_unknown');
|
|
222
|
+
}
|
|
223
|
+
return { success: true, refresh: false, data: result };
|
|
224
|
+
}
|
|
225
|
+
async executeViewFileAction(context, viewKey, actionKey, request, file) {
|
|
226
|
+
if (viewKey !== VIEW || !['file.upload', 'editor.import'].includes(actionKey))
|
|
227
|
+
throw new Error('spaces.action_unknown');
|
|
228
|
+
const actor = await this.actor(context);
|
|
229
|
+
if (actionKey === 'editor.import') {
|
|
230
|
+
const p = z.object({ spaceId: id, parentId: id.nullable().default(null), editorKey: z.string().max(100) }).strict().parse(request.input);
|
|
231
|
+
return { success: true, data: await this.editors.create(actor, p.spaceId, p.parentId, file.originalname ?? 'Document', p.editorKey, file.buffer) };
|
|
232
|
+
}
|
|
233
|
+
const input = z.object({ spaceId: id, parentId: id.nullable().default(null) }).strict().parse(request.input);
|
|
234
|
+
return { success: true, refresh: false, data: await this.files.upload(actor, input.spaceId, input.parentId, file.originalname ?? 'file', file.mimetype ?? 'application/octet-stream', file.buffer) };
|
|
235
|
+
}
|
|
236
|
+
async resolveViewFile(context, viewKey, request) {
|
|
237
|
+
if (viewKey !== VIEW)
|
|
238
|
+
throw new Error('spaces.view_missing');
|
|
239
|
+
const actor = await this.actor(context);
|
|
240
|
+
if (request.fileKey.startsWith('item:'))
|
|
241
|
+
return this.files.current(actor, id.parse(request.fileKey.slice(5)));
|
|
242
|
+
if (request.fileKey.startsWith('artifact:')) {
|
|
243
|
+
const { version } = await this.files.openArtifact(actor, id.parse(request.fileKey.slice('artifact:'.length)));
|
|
244
|
+
if (!version.workspaceFileRef)
|
|
245
|
+
throw new BadRequestException('spaces.artifact_file_unavailable');
|
|
246
|
+
return { reference: version.workspaceFileRef, fileName: version.fileName ?? 'artifact',
|
|
247
|
+
mimeType: version.mimeType ?? 'application/octet-stream', size: version.size ?? undefined };
|
|
248
|
+
}
|
|
249
|
+
const version = await this.files.get(actor, id.parse(request.fileKey));
|
|
250
|
+
return { reference: version.reference, fileName: version.reference.originalName ?? 'file',
|
|
251
|
+
mimeType: version.reference.mimeType ?? 'application/octet-stream', size: version.reference.size ?? undefined };
|
|
252
|
+
}
|
|
253
|
+
};
|
|
254
|
+
SpacesView = __decorate([
|
|
255
|
+
Injectable(),
|
|
256
|
+
ViewExtensionProvider('spaces.views'),
|
|
257
|
+
__metadata("design:paramtypes", [SpaceCatalog, SpaceImports, SpaceContent, SpacesService, SpacePages, SpaceAi, SpaceFiles, SpaceEditors, SpaceKnowledge, SpaceAccess])
|
|
258
|
+
], SpacesView);
|
|
259
|
+
export { SpacesView };
|
package/docs/.mintignore
ADDED
package/docs/AGENTS.md
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
# Spaces documentation
|
|
2
|
+
|
|
3
|
+
Document the behavior shipped by this plugin and its required platform capabilities. Keep the distinction between authoring workspace, Assistant file workspace, Space, and Knowledgebase explicit. Do not describe a mock model run as a real model acceptance test. Keep credentials and local deployment identities out of documentation. Use concise MDX pages with frontmatter and update `docs.json` when adding a page.
|
package/docs/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Mintlify
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/docs/README.md
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "架构和验证"
|
|
3
|
+
description: "Project 存储与成员权限、插件资源和协作能力边界"
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
## 平台与插件边界
|
|
7
|
+
|
|
8
|
+
协作空间使用内置 Project Type `platform / space`。Project 管理成员、角色、归档、多 Assistant 绑定和文件工作区;Spaces 插件提供资源树、来源信息、知识库启用入口及 Remote View。
|
|
9
|
+
|
|
10
|
+
Space 必须绑定 Project,`(tenantId, organizationId, projectId)` 唯一索引保证并发初始化只有一个扩展记录。`SpaceItem.spaceId` 仅关联插件内部的 Space 记录,不是文件存储身份。模块按全新功能开发,不提供独立 Space 卷、Assistant 私有 Space 或旧空间迁移入口。
|
|
11
|
+
|
|
12
|
+
平台提供 `platform.project.access`、`platform.project.files`、`platform.content.resources`、`platform.content.documents`、协作、知识库及 Artifact 能力。每次资源访问重新检查 Project 成员权限;通过 Assistant 打开时还检查 Assistant 与项目的绑定以及 `spaces` 能力。具有 Assistant 权限本身不授予项目内容权限。
|
|
13
|
+
|
|
14
|
+
Remote View 使用共享 shadcn/ui 组件、Tailwind 和宿主主题变量。Markdown 由独立 Tiptap 插件编辑;通用审阅与 AI 交互属于 Assistant。当前选择可以传给聊天作为上下文,但不授予权限。
|
|
15
|
+
|
|
16
|
+
## 文件引用与权威状态
|
|
17
|
+
|
|
18
|
+
所有空间文件位于 `/project/<projectId>`。正文和附件使用可见的项目相对路径,版本快照使用同一项目下的 `.space-history/<itemId>/<versionId>/<name>`。平台通用文件协议使用 `catalog: 'projects'`、`projectId` 和 `scopeId: projectId`,不包含 `spaceId`。
|
|
19
|
+
|
|
20
|
+
页面在创建或发现时直接注册 `markdown.tiptap` 平台文档;打开页面只读取已注册文档。没有 Spaces 自有的协作模型、修订/评论/提案表或 `importLegacy` 路径。只读成员发现的新 Markdown 先作为项目文件展示,编辑成员可将其注册为协作文档。
|
|
21
|
+
|
|
22
|
+
Markdown 文件是可交换内容,平台协作文档维护编辑状态和提交一致性;Space 的正文缓存、哈希和同步状态属于派生信息。写入或投影失败不得回退为仅更新数据库正文。来自其他 Assistant 的项目文件也可以在空间发现。
|
|
23
|
+
|
|
24
|
+
Artifact 以 `pluginName + resourceType + resourceId` 标识来源,`authorizationProviderKey: "spaces"` 对已有 `resourceId` 附加资源授权,`workspaceFileRef` 定位 Project 中的文件。创建时通过 `scope: { projectId, userId: null }` 显式使用项目共享归属,保留真实操作人用于授权和审计。Provider 不决定归属或列表可见性;列表对当前用户授权后分页,下载同时检查项目权限、资源权限和链接策略。移除独立 Space 卷不移除资源授权、路径校验和项目文件的写保护。
|
|
25
|
+
|
|
26
|
+
## Runtime 能力边界
|
|
27
|
+
|
|
28
|
+
- 执行身份共用 `RuntimeActor`;组织内内容要求 `OrganizationRuntimeActor`。`AssistantMiddlewareAccess.assertAccess` 使用显式 `scope`:独立 Assistant 检查发布目录访问权,项目内使用检查当前项目成员及 Assistant 绑定,不额外要求目录或创作工作区可见性;两者均检查有效组织成员和 `provider + requiredFeature`,并与平台 View Middleware Runtime 共用解析规则。
|
|
29
|
+
- View 的组件加载、数据、动作、文件传输与工具调用在入口重新检查能力;空项目状态也需独立 Assistant 授权。身份和项目范围来自可信宿主上下文,不可由 iframe 或工具参数指定。资源授权及协作广播保留项目和操作权限,不逐文档检查功能开关;移除中间件不改变已有文件的项目归属。不存在跨调用授权缓存。
|
|
30
|
+
- `ContentResources.authorize` 负责资源授权,`identities` 子接口提供当前组织身份。Provider 按需组合 `references`、`pages`、`publication`、`locations`,没有对应扩展即可省略。平台文档输入的 `resourceId` 与扩展输入的 `sourceResourceId` 明确区分;子页面创建返回 `documentId`。
|
|
31
|
+
- `ContentEditors`(`platform.content.editors`)负责通用编辑器发现、资源解析与带版本校验的文件读写;平台协议不包含表格、单元格或具体格式的修改指令。
|
|
32
|
+
- `ProjectKnowledge.ensureBinding`(`platform.project.knowledge`)按 projectId 建立一个内置知识库,以平台 `platform.project` 资源策略继承项目权限;Space 表只缓存知识库 ID。已有知识库服务仍要求匹配 actor 的请求/任务上下文。
|
|
33
|
+
- `ProjectFiles` 是显式项目身份的门面;与 `WorkspaceFiles` 共用 Volume 文件读写、路径约束和写入锁,提交前重新检查托管文档写保护。CAS 哈希防止版本覆盖。
|
|
34
|
+
|
|
35
|
+
## 权限与验证
|
|
36
|
+
|
|
37
|
+
项目 owner/manager 管理资源,owner/manager/editor 编辑内容,member 只读;归档项目只读。实时会话及后台任务重新校验当前权限。跨项目引用不能通过传入目标项目身份绕过源项目的权限。
|
|
38
|
+
|
|
39
|
+
交付需联合部署配套平台、SDK 和插件。使用最新实体初始化;本次代码收敛不操作已有开发数据库,不提供历史独立 Space 的升级脚本。
|
|
40
|
+
|
|
41
|
+
自动化回归覆盖 Project 文件引用重放、跨项目拒绝、只读和撤权拒绝写入、Assistant 解绑,以及正文和版本快照使用相同项目身份。插件还需通过构建、类型检查、元数据检查和 lifecycle harness。浏览器、双用户协作和真实模型验收需要单独执行,不能以单元测试或构建成功代替。
|
|
42
|
+
|
|
43
|
+
## 编辑器职责边界
|
|
44
|
+
|
|
45
|
+
- 平台:统一资源引用、Project 与 Assistant 绑定授权、编辑器注册及 Workbench 路由。注册区分文本协作模型与文件编辑器;Markdown 继续使用文本/Yjs 协作管线。
|
|
46
|
+
- Spaces:目录、创建和导入文件、资源权限及版本快照。`editor.describe` 描述当前资源位置;`editor.files` 处理不透明字节与版本,不解析表格。保存时检查资源版本、磁盘哈希和当前权限。
|
|
47
|
+
- Office:XLSX 创建、校验、单元格读取与修改、序列化,以及独立的 `office.files__office.file` Remote View。文件编辑模式按版本保存,冲突时阻止覆盖,不自动合并并发修改;与原生 Office 快照/Yjs 模式分别处理。
|
|
48
|
+
|
|
49
|
+
Spaces 通过 `workbench.content.resolve({ resourceId, providerKey: 'spaces' })` 解析资源,将返回的 navigation(包括 contentProvider 参数)原样交给当前 Workbench 打开。Spaces 不包含表格编辑 UI。新文档格式通过注册编辑器和独立视图扩展,不需要向平台增加专用格式 RuntimeCapability。
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: 实时协同编辑
|
|
3
|
+
description: Markdown 双账号同步、并发合并、光标与真实验收
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
## 已实现的协作链路
|
|
7
|
+
|
|
8
|
+
同一项目中,具有权限的成员可以通过不同 Assistant 打开同一 Markdown 文档。编辑器使用 Tiptap 与 Yjs;平台管理协作房间、身份、权限、更新日志和文件发布。Spaces 负责内容组织,独立 Markdown 插件负责编辑界面。
|
|
9
|
+
|
|
10
|
+
- 按文档与 epoch 隔离房间;同一文档不因入口 Assistant 不同而产生副本。
|
|
11
|
+
- 本地增量经宿主 `document.update` 提交,平台持久化后通过 WebSocket 向已授权接收者广播。Yjs 合并并发修改,重复增量不会重复追加正文。
|
|
12
|
+
- 在线人数按账号去重;光标和选区按窗口区分,姓名与颜色来自平台身份。状态不写入正文。
|
|
13
|
+
- 断线后通过状态向量补齐缺失更新。未提交增量保留在当前窗口重试;这不等于关闭浏览器后的持久离线编辑。
|
|
14
|
+
- 保存状态区分「编辑已接收」与「已写入项目文件」。只有文件发布完成才显示已保存;外部文件冲突不能被静默覆盖。
|
|
15
|
+
- 历史恢复或 schema 升级切换 epoch,旧窗口不能覆盖新版本。所有会话和广播仍检查当前项目、Assistant 及资源访问权限。
|
|
16
|
+
|
|
17
|
+
## 跨账号广播修复
|
|
18
|
+
|
|
19
|
+
2026-10-23 的真实测试复现了:两个账号能显示在线,但提交编辑会导致另一方断连。进程内广播继承了 HTTP 提交者的 AsyncLocalStorage;文档权限校验把接收者身份与提交者进行比较,误判为身份不匹配。
|
|
20
|
+
|
|
21
|
+
平台 `CollaborationGateway` 在广播投递边界退出提交者请求上下文,再按各接收者已认证的协作会话逐个授权。普通 HTTP 请求的身份一致性校验保留,撤权接收者仍被断开。Provider 必须使用显式授权上下文中的 actor,不能借用广播提交者的身份或权限。
|
|
22
|
+
|
|
23
|
+
回归测试覆盖发送方与接收方不同、HTTP 冒用 actor 被拒绝、已撤权接收者不获得广播。网关与协作服务的 6 项定向测试通过。
|
|
24
|
+
|
|
25
|
+
## 真实验收
|
|
26
|
+
|
|
27
|
+
两个独立测试账号通过正常登录获取身份,分别经两个已发布 Assistant 打开同一项目文档。测试直接连接实际 API、WebSocket、数据库和项目文件,没有替换协作服务或模拟持久化。
|
|
28
|
+
|
|
29
|
+
| 场景 | 验证结果 |
|
|
30
|
+
| --- | --- |
|
|
31
|
+
| 双账号进入 | 相同房间、不同身份,双方均看到两人在线 |
|
|
32
|
+
| 同一位置同时输入 | 两份内容通过远端 WebSocket 广播收敛,甲乙输入均保留,重复提交被去重 |
|
|
33
|
+
| 光标与选区 | 对方收到正确账号与 Yjs 相对位置 |
|
|
34
|
+
| 任务清单与公式 | 不同账号的节点修改同时保留 |
|
|
35
|
+
| 断线与重连 | 离线成员移出在线列表,重连补齐遗漏内容,缓存修改重发后收敛 |
|
|
36
|
+
| 未授权账号 | 获取会话与提交更新均被拒绝 |
|
|
37
|
+
| 文件与重开 | 两端内容、重新获取的文档状态和实际 Markdown 文件一致,SHA-256 与平台记录一致 |
|
|
38
|
+
|
|
39
|
+
这次浏览器自动化连接中断,未完成两个可见浏览器窗口的交互验收;上表为真实协议客户端验收,不能等同于双窗口视觉验收。跨 API 节点和长时间压力测试也不在本次结果中。
|
|
40
|
+
|
|
41
|
+
## 复现方式
|
|
42
|
+
|
|
43
|
+
先启动配套平台与插件,准备私有目录中的正常登录助手 `probe.mjs` 和已有测试项目配置 `editor-acceptance-fixture.json`,再从 Markdown 插件目录执行:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
SPACES_TEST_DIRECTORY=/absolute/path/to/private-test-environment \
|
|
47
|
+
node tests/collaboration.live.mjs
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
脚本创建独立验收页面,输出七项结果,并将包含实例标识的回执保存到该私有目录。凭据不写入源码、文档或终端结果。可在同一项目的协作空间中打开标题以「双账号实时协作验收」开头的页面继续人工验证。
|