@carys-cloud/collab-editor
v0.3.0
Published
可嵌入的协同文档编辑器(TipTap v3 + Yjs),carys-doc-collab 前端交付包。
Readme
@carys-cloud/collab-editor
可嵌入的 React 协同文档 + 故事图 / 代码图编辑器。在你自己的 React 应用里 import 即用,
用宿主的 React(peer,不自带),模型经 BYO aiProvider 注入——本包与协同引擎都不接触你的 key。
特性
- 实时协同:TipTap v3 + Yjs + y-websocket,多人同房编辑、在线光标、断线重连。
- 故事图 / 代码图:正文 ⇄ 结构图双向联动(@xyflow/react 画布)。
graph.profile.type === 'codebase'自动进入代码图模式(符号/调用邻域),否则是故事图模式(拍 ⇄ 节点 ⇄ 场景)。 - BYO 模型:AI 编辑 / 摘要 / 拍·节点解读 / 读故事出图谱全走你注入的
AiProvider,key、prompt、端点都在你手里。 - 黑白主题:
--cc-*CSS 变量跟随<html data-theme="dark|light">,无 attr 时回落prefers-color-scheme。 - 中英双语:
locale?: 'zh' | 'en',经LocaleContext下发到所有子组件(含富块 NodeView)。 - 富块:HTML 嵌入 / Mermaid 图 / KaTeX 公式,皆为 TipTap Node。
- 字体色 / 背景色:选中气泡里的
A套用上次用色,⌄展开调色板(8 字体色 + 16 背景色 + 恢复默认)。 字体色是TextStyle + Color(<span style="color">),背景色是 multicolorHighlight(<mark data-color>)。 两者都随 Yjs 协同同步;但 Markdown 里表达不了颜色,getMarkdown()快照只保留文字与==…==结构。
现在主推 npm 组件(同进程):宿主直接渲染
<CollabDocWorkspace>,用 props/回调交互。 iframe 嵌入轨(bridge+ postMessage)仍保留,但不是主推形态。
安装
pnpm add @carys-cloud/collab-editor
# peer:用宿主自己的 React,不由本包携带
pnpm add react react-dom # ^18.3.1 || ^19.0.0React 19(单实例)已实测可跑,无双 React hooks 冲突——只要宿主与本包共享同一份 React 即可。
引入样式(一次即可):
import '@carys-cloud/collab-editor/styles.css';
// 若用到公式富块(MathBlock),再引 katex 样式(宿主自行 add katex)
import 'katex/dist/katex.min.css';快速上手
纯文档模式(不传 graph → 退化为 CollabDocEditor)
CollabDocWorkspace 不传 graph 时自身退化为纯协同富文档编辑器,同一套协同/富块/AI/主题内核。
import { CollabDocWorkspace, createAnonAuth } from '@carys-cloud/collab-editor';
import '@carys-cloud/collab-editor/styles.css';
export function Doc() {
return (
<CollabDocWorkspace
docId="my-doc" // = 协同房 id / WS roomname
user={{ id: 'u1', name: '张三' }}
adapters={{
transport: { wsUrl: 'ws://127.0.0.1:8090' }, // CRDT 引擎地址,内部自动拼 /collab
auth: createAnonAuth(), // 本地/受信网匿名连接
}}
/>
);
}不需要故事图外壳时也可以直接用 CollabDocEditor(二者对文档模式等价)。
故事图 / 代码图模式(传 graph → 进入工作台)
import { CollabDocWorkspace, createAnonAuth, SAMPLE_GRAPH, SAMPLE_STORY_MD } from '@carys-cloud/collab-editor';
import '@carys-cloud/collab-editor/styles.css';
export function Workspace() {
return (
<CollabDocWorkspace
docId="story-1"
user={{ id: 'u1', name: '张三' }}
adapters={{ transport: { wsUrl: 'ws://127.0.0.1:8090' }, auth: createAnonAuth() }}
graph={SAMPLE_GRAPH} // 传入即进入工作台三栏
seed={SAMPLE_STORY_MD} // 空房首次灌入的故事正文(仅文档为空时)
/>
);
}传 graph 后:故事模式是「大纲 · 正文 · 图谱」三栏并排;代码图模式(graph.profile.type === 'codebase')只保留
图谱 + 解读。图谱存进 Y.Doc 的 storygraph Map,多人改图 / AI 生成图谱都实时协同同步。
Adapters
依赖注入装配。除 transport 外全可选,缺失即降级:
| 字段 | 类型 | 缺失时 |
|------|------|--------|
| transport | TransportConfig({ wsUrl: string }) | 必填;缺失降级为只读占位(不白屏) |
| auth | AuthAdapter | 匿名连接(WS 不带 ticket) |
| aiProvider | AiProvider | AI 入口整组隐藏 |
| docStore | DocStoreAdapter | 引擎自带 ystore 恢复正文 |
| enterprise | unknown(占位,本包不实现) | 条款/审批 UI 整组隐藏 |
transport:wsUrl 是 CRDT 引擎基址(如 ws://127.0.0.1:8090),内部自动拼 /collab,最终连
ws://host/collab/{docId}?ticket=。
auth:五个工厂,都返回 AuthAdapter(契约就是 getTicket(docId): Promise<string | null>):
import {
createAnonAuth,
createBearerExchangeAuth,
createCallbackAuth,
createStaticAuth,
createTicketAuth,
} from '@carys-cloud/collab-editor';
createAnonAuth(); // 始终返回 null → WS 不带 ticket(引擎 COLLAB_ALLOW_ANON=1 时放行,本地演示默认)
createStaticAuth(myTicket); // 宿主已自己调过签发端点,把现成 ticket 直接喂进来
createCallbackAuth(async (docId) => { // internal_ticket 生产:只调用宿主自己的后端
const res = await fetch('/api/collab-ticket', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ docId }),
});
if (!res.ok) throw new Error(`ticket: ${res.status}`);
return (await res.json()).ticket;
});
createTicketAuth({ // 仅本地/受信服务端:直调 collab /api/v1/tickets
issuerUrl: '', // 默认同源,配合 dev proxy;内部拼 /api/v1/tickets
sub: 'user-1', // 身份,写进 ticket 的 sub
perm: 'edit', // 'view' | 'comment' | 'edit' | 'manage',默认 edit
enterpriseId: 'ent-1', // 可选,企业隔离
internalToken: '...', // 可选,签发端点要求的入站内部 token
});
createBearerExchangeAuth({ // trusted_jwt 生产:复用宿主 Bearer JWT
issuerUrl: 'https://collab.example.com',
getBearerToken: () => authStore.getState().token,
});getTicket 失败时 transport 会退回匿名连接(生产 COLLAB_ALLOW_ANON=0 时会被服务端拒绝),
不白屏。部署可以按宿主需求启用其中一种或同时启用两种:
trusted_jwt:使用createBearerExchangeAuth,collab 验证宿主 JWT;宿主无需新增协同接口。internal_ticket:使用createCallbackAuth调宿主后端;宿主后端完成 ACL 后,携带COLLAB_INBOUND_INTERNAL_TOKEN调 collab/api/v1/tickets。
严禁把 COLLAB_INBOUND_INTERNAL_TOKEN 放进浏览器、静态 bundle 或公开环境变量。
createTicketAuth({ internalToken }) 仅适合本地或受信服务端代码,不是生产浏览器方案。
BYO 模型(aiProvider)
编辑器只消费模型产出的 delta 流,怎么调模型由你实现。契约:
interface AiProvider {
streamEdit(input: AiEditInput): AsyncIterable<string>; // 选区改写 / 拍·节点 AI 解读(逐 delta)
streamDraft(input: AiDraftInput): AsyncIterable<string>; // 从提示起草(逐 delta)
summarize(contentMarkdown: string): Promise<string>; // 全文摘要(一次性)
proposeAlternatives?(clauseText: string): Promise<string[]>; // enterprise-only,未实现 → 入口隐藏
analyzeStory?(storyMarkdown: string): Promise<StoryGraphDraft>; // 读故事出图谱,未实现 →「AI 生成图谱」按钮隐藏
}推荐做法:继承 PromptedAiProvider,只实现 chatStream。 提示词层(edit/draft/summarize/analyze,
中英各一套、locale 感知)已在基类里拼好,你只负责「把 messages 送进模型」这一段传输:
import { PromptedAiProvider } from '@carys-cloud/collab-editor';
import type { ChatMessage, PromptLocale } from '@carys-cloud/collab-editor';
class MyAiProvider extends PromptedAiProvider {
constructor(locale: PromptLocale, private token: string) { super(locale); }
protected async *chatStream(messages: ChatMessage[], signal?: AbortSignal): AsyncIterable<string> {
const res = await fetch('/api/chat/completions', { // 打你自家后端,key 留在后端
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'text/event-stream',
Authorization: `Bearer ${this.token}` },
body: JSON.stringify({ messages, stream: true }),
signal,
});
if (!res.ok || !res.body) throw new Error(`model responded ${res.status}`);
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() ?? '';
for (const line of lines) {
const s = line.trim();
if (!s.startsWith('data:')) continue;
const payload = s.slice(5).trim();
if (payload === '[DONE]') return;
try {
const delta = JSON.parse(payload).choices?.[0]?.delta?.content;
if (typeof delta === 'string' && delta) yield delta;
} catch { /* 忽略非补全帧 */ }
}
}
}
}AiEditInput:{ action: 'polish'|'translate'|'expand'|'shrink'|'tone'|'custom', selectedText, instruction?, contextBefore?, contextAfter?, signal? };
AiDraftInput:{ instruction, signal? }。
也可直接用内置实现省去自研:
MockAiProvider——演示用假流。OpenAiCompatProvider({ baseUrl, apiKey, model, headers?, temperature?, locale? })——直连任意 OpenAI 兼容/chat/completions端点(浏览器直连需端点允许 CORS,否则走自家后端转发)。PostMessageAiProvider——iframe 场景经宿主 postMessage 代发模型。
故事图里的「AI 生成图谱」「AI 解读这一拍 / 这个节点」全部经此契约,无额外接口。
<CollabDocWorkspace> props
继承 CollabDocEditorProps(docId / adapters / user / readOnly / className / locale),额外新增故事图相关项。
| prop | 类型 | 默认 | 说明 |
|------|------|------|------|
| docId | string | — | 必填。协同房 id(= WS roomname)。 |
| adapters | Adapters | — | 必填。依赖注入(transport 必填 / auth?/ aiProvider?…)。 |
| user | EditorUser({ id?, name?, color? }) | — | 驱动在线光标标签与配色;color 缺省按 id 确定性生成。 |
| readOnly | boolean | false | 初始只读预览态。 |
| className | string | — | 根节点样式类(纯文档模式生效)。 |
| locale | 'zh' \| 'en' | 'zh' | UI 语言,经 LocaleContext 下发全部子组件;AI 解读指令也随此语言。 |
| graph | StoryGraph | — | 传入即进入工作台三栏;不传退化为 CollabDocEditor。 |
| seed | string | — | 空房首次 seed 的故事 markdown(仅文档为空时灌入,单权威去重)。 |
| autoAnalyze | boolean | false | 初始化让 BYO 模型自动读故事生成图谱(需 aiProvider.analyzeStory;本房仅跑一次)。 |
| view | 'split' \| 'doc' \| 'graph' | 'split' | 展示视图:三栏 / 只正文 / 只图谱(铺满,适合窄容器)。编辑器始终挂载(供拍数据)。 |
| bridge | boolean | false | iframe 嵌入桥:点节点 postMessage collab:open-file;监听 collab:focus-node / collab:set-graph。 |
| onOpenFile | (path: string, node: string) => void | — | npm 消费方:点图谱节点回调(path = SgNode.file,node = 节点 id)。 |
| focusFile | string \| null | — | npm 消费方:高亮该文件对应的节点(反向导航,等价 iframe 的 collab:focus-node)。 |
| onStoryChanged | (payload: StoryChangePayload) => void | — | 宿主桥:首次同步及正文变化后返回辅助文本和完整 beats 快照;连续更新合并 200ms。 |
| focusBeat | string \| null | — | 宿主桥:高亮并滚动到指定拍;null 清除宿主控制的拍高亮。 |
| onAgentRewrite | (beatId: string, text: string) => void | — | 宿主桥:把当前拍交给宿主 Agent;collab 不直接落盘。 |
| onDeployChoice | (nodeId: string, choice: string) => void | — | 宿主桥:把部署选择交给宿主生成 Changes;collab 不直接修改图谱。 |
onOpenFile 与 bridge 可并存或二选一:同进程消费走 onOpenFile 回调,iframe 嵌入走 postMessage。
focusFile 匹配规则:先精确匹配 node.file === focusFile,再退化到 focusFile.endsWith(node.file)(同目录/后缀)。
onStoryChanged 会复用编辑器自身的拍解析,payload.beats 是宿主执行 baseline/diff 和生成 Changes 的权威数据。payload.markdown 由 TipTap 官方 Markdown 序列化器生成,供宿主预览或辅助判断;工作区 story.md 的正式变更仍应按 beats 生成 Changes,而不是直接整篇覆盖。hydrate、seed、本地编辑和远端协同合并都会触发同一通知链路。
onAgentRewrite 与 onDeployChoice 都是 controlled host action:回调后界面数据保持不变,直到宿主通过现有 props/协同数据回传新状态。
代码图模式 vs 故事图模式
由 graph.profile.type 决定,不是两套组件:
| | 故事图模式(profile.type !== 'codebase') | 代码图模式(profile.type === 'codebase') |
|---|---|---|
| 面板 | 大纲 · 正文 · 图谱三栏,多视图(拍 / 节点 / 场景 / 一致性…) | 只保留图谱 + 解读 tab(铺满,配 view="graph" 用) |
| 节点 | 业务节点(name 中文名 / layer 分层 / verdict 判词三态) | 符号节点:显示符号名(name)、签名(signature)、调用邻域 |
| 联动 | 正文里点拍 → 高亮节点 → 滚动到该拍;AI 解读这一拍 | 点节点 → onOpenFile 开源码;开文件 → focusFile 高亮节点 |
| 边 | calls / writes / emits(SgEdge.kind) | calls / references / implements / extends / imports |
StoryGraph 结构:{ profile: { type, protagonist, axis }, nodes: SgNode[], edges: SgEdge[],
beats: Record<拍id, 节点id[]>, scenarios: SgScenario[] }。
SgNode:{ id, name, kind, layer, constraint?, file?, signature?, origin?, choices?, verdict?('ok'|'pending'|'miss'), verdictText? }。
SgEdge:{ from, to, kind }。
标准 storygraph/story.md + graph.yaml + map.yaml + scenarios.yaml 可直接加载:
const { storyMarkdown, graph } = await loadStoryGraphBundle({
readFile: (path) => hostWorkspaceApi.readFile(path),
});宿主只负责读取文件,npm 包完成并行加载、YAML 解析和 StoryGraph 组合。
导出清单(index.ts)
- 组件:
CollabDocEditor、CollabDocWorkspace - AiProvider:
PromptedAiProvider(抽象基类,推荐继承)、OpenAiCompatProvider、MockAiProvider、PostMessageAiProvider - 鉴权工厂:
createAnonAuth、createStaticAuth、createCallbackAuth、createTicketAuth、createBearerExchangeAuth - StoryGraph 加载:
loadStoryGraphBundle - 传输 hook / 工具:
useCollabProvider、resolveWsBase - i18n:
LocaleContext、useT(+ 类型Locale) - 富块:
HtmlBlock、MermaidBlock(+DEFAULT_MERMAID)、MathBlock - 示例数据:
SAMPLE_GRAPH、SAMPLE_STORY_MD、SAMPLE_GRAPH_EN、SAMPLE_STORY_MD_EN、sampleStory、sampleGraph - 类型:
CollabDocEditorProps、CollabDocWorkspaceProps、EditorUser、Adapters、AuthAdapter、DocStoreAdapter、TransportConfig、TicketAuthOptions、BearerExchangeAuthOptions、StoryGraphBundle、StoryGraphBundleSource、StoryChangePayload、AiProvider、AiEditInput、AiEditAction、AiDraftInput、ChatMessage、PromptLocale、OpenAiCompatOptions、StoryGraph、SgNode、SgEdge、SgScenario、SgProfile、CollabConnStatus、CollabConnection、Locale、HtmlBlockAttrs、HtmlBlockMode、MermaidBlockAttrs、MathBlockAttrs、MathDisplay
后端依赖
协同同步需要 CRDT 引擎:Python FastAPI + pycrdt-websocket,生产暴露
wss://host/collab/{docId}?ticket=,持久化 Postgres。本包直接连它,不经过不支持二进制
CRDT 帧的业务网关。两种生产鉴权模式、部署和 smoke 见仓根 DEPLOY.md;x-coder 的
trusted_jwt 示例见 docs/x-coder-integration.md。
