docs: add workspace collaboration design and client demos

Document shared workspaces, realtime messaging, member handling, and source permissions. Include local interaction demos and verification scripts, and update development startup for Electron Node mode. Production collaboration services remain unimplemented.
This commit is contained in:
lofyer
2026-09-28 17:04:34 +08:00
parent 440d878d76
commit 599a0a6645
19 changed files with 880 additions and 22 deletions
+40
View File
@@ -0,0 +1,40 @@
# GoodBuddy 客户端共享演示
- `workspace.html`:组织内多工作空间协作,支持创建空间、选择成员、`@` 定向消息、上下文预览、分身草稿和本人接手。建议从此页面开始体验。
- `index.html`:访客侧共享空间,包括知识库阅读、数字分身示例对话和授权来源说明。
- `settings.html`:客户端共享设置,包括知识库选择、分身身份、提示词、监督回顾授权及原文可见性。
- 设置页底部提供「组织工作空间 · 我的处理偏好」,按空间独立设置林知远的接收方式。空间偏好使用独立保存按钮,与个人访客分身开关分别生效。
- `demo.css`、`demo.js`:两页共用的样式、示例资料与交互逻辑,无外部资源依赖。
建议通过仓库开发服务访问 `/docs/demo/client/index.html` 和 `/docs/demo/client/settings.html`。也可直接打开 HTML;浏览器对 `file:` 页面的本地存储行为可能不同,两页联动以同源 HTTP 访问为准。
配置保存到当前浏览器的 `goodbuddy-sharing-demo-v1` 本地存储项。恢复示例后需点击保存;未保存的更改离开页面时会提示。真实 GoodBuddy 设置、服务端授权、身份验证、资料发布、模型调用均未接入。对话不执行自定义提示词,使用明确标注的固定示例回答。
## 演示流程
### 组织协作
1. 打开 `workspace.html`。当前模拟林知远已加入「明日工作室」,可切换「产品设计」和「AI 应用研究」,或创建多个新空间并选定成员。
2. 输入 `@` 或点击「选择成员」,明确接收人。展开上下文选择,预览讨论摘要、共享设置草稿后勾选发送。这里只模拟单接收人消息。
3. 在产品设计空间向张明发消息,默认由分身给出固定示例回复;向陈悦发消息,默认生成待本人确认的草稿。
4. 顶部切换模拟成员到陈悦,查看并编辑草稿,再确认发送。其他成员只能看到待确认状态和确认后的回复。
5. 切换到张明,可以接手已有分身回复的线程,补充本人回复,原分身回复保留。勾选「需要本人确认」发送的消息不自动发布分身回复。
6. 调整当前成员在当前空间的接收方式,或在设置页保存林知远各空间的偏好,后续消息使用新偏好。新空间默认本人处理。
空间消息和偏好保存在 `goodbuddy-workspace-demo-v1`。空间消息互相独立,上下文保存发送时的内容快照。角色切换、组织成员关系、加入空间均为本地模拟,不构成真实认证或授权;本地存储包含所有模拟角色的数据。线程对本空间成员可见,只有指定接收者显示处理操作。回复是固定演示逻辑,不会执行提示词或调用真实分身。当前只有一个示例组织,没有真实邀请、组织切换或后台任务派发。
### 个人共享
1. 浏览两个共享知识库,切换到数字分身并点击示例问题,查看对应资料来源。
2. 在设置中取消一个知识库并保存,访客侧阅读列表和分身可用来源同步减少。
3. 在数字分身设置中授权选定的监督回顾,保存后询问产品决策;单独开启原文查看,访客侧才出现回顾原文。
4. 暂停数字分身,知识库继续提供阅读;暂停知识共享,分身继续开放,但不能使用已撤销的知识来源。
5. 将访问范围改为仅团队成员,匿名访客页面显示访问限制。
本原型为知识库与分身采用同一共享空间和访问范围,没有模拟跨范围授权。监督回顾只包含一条明确展示的虚构记录。知识库数量及更新时间为示例元数据,正文只展示节选。桌面采用侧栏与双栏布局,窄屏改为单栏。
## 交互验证
启动 `npm run dev:web -- --host 127.0.0.1 --port 5178` 后,运行 `node docs/demo/client/verify.mjs`,验证资料撤销、监督回顾原文可见性、独立暂停和团队访问限制。脚本使用独立的 JSDOM 存储,不修改浏览器中的配置。Vite 注入样式可能产生 JSDOM CSS 解析提示;此脚本验证交互与内容,不替代浏览器视觉检查。
运行 `node docs/demo/client/verify-workspace.mjs` 验证定向消息、上下文快照、草稿可见性、本人确认优先、接手、空间隔离、创建及设置持久化。
File diff suppressed because one or more lines are too long
+76
View File
@@ -0,0 +1,76 @@
/* global document, window, localStorage, structuredClone, clearTimeout, setTimeout */
const key = 'goodbuddy-sharing-demo-v1';
const defaults = { knowledge: true, avatar: true, name: '林知远', scope: 'link', sources: ['practice', 'product'], useKnowledge: true, useReview: false, showOriginal: false, prompt: '以技术顾问的身份交流,先给出判断,再解释依据。资料不足时明确说明,不替本人承诺报价和交付时间。' };
const sources = [
{ id: 'practice', name: 'AI 应用实践', meta: '12 篇资料 · 今天更新', desc: '从原型到落地,记录真实项目中的设计取舍。', title: '让 AI 应用从一个小场景开始', body: '<p>一个值得先做的场景,通常有清晰的输入、可检查的输出,以及愿意持续使用它的人。</p><h3>先确定怎样算完成</h3><p>在接入模型之前,先写下验收条件。以资料问答为例,除了能回答问题,还应能指出依据,并在资料不足时说明不知道。</p><h3>把反馈留在工作流里</h3><p>先让小范围用户完成一次真实任务,记录哪里需要人工补充,再决定是否扩大自动化范围。</p>' },
{ id: 'product', name: '产品设计笔记', meta: '8 篇资料 · 昨天更新', desc: '围绕权限、知识共享和人机协作的产品思考。', title: '知识共享与数字分身的关系', body: '<p>知识库可以独立共享,也可以作为数字分身的回答来源。同一份资料只需维护一次。</p><h3>内容复用,授权明确</h3><p>关联知识库时,需要确认这些资料能否向分身的访问者提供。团队内资料不会因为关联一个公开分身就自动公开。</p><h3>阅读资料与交流分开</h3><p>访问者可以直接阅读共享知识,也可以向分身提问。监督回顾可用于帮助分身理解个人偏好,但原始记录是否可见需要单独设置。</p>' }
];
let state = load();
let tab = 'knowledge';
let selected = 'practice';
let dirty = false;
let toastTimer;
const settings = document.body.dataset.page === 'settings';
const app = document.querySelector('#app');
function load() { try { return { ...structuredClone(defaults), ...JSON.parse(localStorage.getItem(key) || 'null') }; } catch { return structuredClone(defaults); } }
function esc(value) { return String(value).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]); }
function toast(text) { document.querySelector('.toast')?.remove(); clearTimeout(toastTimer); const node = document.createElement('div'); node.className = 'toast'; node.setAttribute('role', 'status'); node.textContent = text; document.body.append(node); toastTimer = setTimeout(() => node.remove(), 3500); }
function toggle(field, title, desc) { return `<label class="row"><span><strong>${title}</strong><p>${desc}</p></span><input class="switch" type="checkbox" data-field="${field}" ${state[field] ? 'checked' : ''} aria-label="${title}"></label>`; }
function render() {
app.innerHTML = `<div class="shell"><aside class="sidebar"><a class="brand" href="index.html"><span class="logo">g</span>GoodBuddy</a><div><div class="nav-label">${settings ? '偏好设置' : '共享空间'}</div><nav class="nav"><a href="index.html" class="${!settings ? 'active' : ''}">共享主页</a><a href="settings.html" class="${settings ? 'active' : ''}">共享设置</a></nav></div><div class="sidebar-bottom"><div class="account"><span class="mini-avatar">林</span><div>林知远<div class="muted">个人工作空间</div></div></div>交互原型 / CLIENT 01<br>所有资料与回答均为演示数据</div></aside><main><header class="topbar"><div><span class="mobile-brand">GoodBuddy · </span><span class="crumb">${settings ? '设置 / 共享与数字分身' : '个人空间 / 对外共享'}</span></div><a href="${settings ? 'index.html' : 'settings.html'}">${settings ? '查看访客页面 ↗' : '管理共享设置 ↗'}</a></header><div class="content">${settings ? settingsView() : visitorView()}</div></main></div>`;
bind();
const workspaceLink = document.createElement('a');
workspaceLink.href = 'workspace.html';
workspaceLink.textContent = '组织工作空间 ↗';
document.querySelector('.topbar').append(workspaceLink);
if (settings) window.mountWorkspacePreferences?.();
}
function settingsView() {
return `<div class="heading"><div><div class="eyebrow">SHARING & IDENTITY</div><h1>把知识分享出去,让分身参与交流</h1><p>选择愿意分享的内容,设置别人可以怎样与你的数字分身交流。</p></div><span class="badge gray">客户端设置演示</span></div><div class="tabs" role="tablist" aria-label="共享设置类别"><button class="tab ${tab === 'knowledge' ? 'active' : ''}" role="tab" aria-selected="${tab === 'knowledge'}" data-tab="knowledge">共享知识库</button><button class="tab ${tab === 'avatar' ? 'active' : ''}" role="tab" aria-selected="${tab === 'avatar'}" data-tab="avatar">数字分身</button></div><div class="columns"><div>${tab === 'knowledge' ? knowledgeSettings() : avatarSettings()}<section class="panel"><div class="panel-head"><div><h2>访问范围</h2><p>本演示中的知识库与分身使用同一个共享空间和访问范围。</p></div></div><label class="field"><span>谁可以访问</span><select data-field="scope"><option value="link" ${state.scope === 'link' ? 'selected' : ''}>持有链接的人</option><option value="team" ${state.scope === 'team' ? 'selected' : ''}>仅团队成员</option></select></label><p class="muted">团队模式下,匿名访客页面将显示访问限制。真实身份校验尚未接入。</p></section><div class="footer-actions"><span id="save-state" class="save-state" role="status">${dirty ? '有未保存的更改' : '当前为已保存的演示配置'}</span><div><button id="reset">恢复示例</button> <button class="primary" id="save">保存设置</button></div></div></div><aside class="aside"><section class="panel"><div class="eyebrow">共享关系</div><div class="flow"><div><strong>01 / 选择资料</strong><p>维护一份知识内容,选择对外共享的知识库。</p></div><div><strong>02 / 关联分身</strong><p>分身复用已授权的共享知识,再叠加个人设定。</p></div><div><strong>03 / 独立控制入口</strong><p>暂停分身后,知识库仍可共享。撤销知识共享后,分身也停止使用这些资料。</p></div></div></section><section class="panel"><h3>当前配置</h3><div class="summary-line"><span>共享知识库</span><strong>${state.knowledge ? state.sources.length + ' 个' : '已暂停'}</strong></div><div class="summary-line"><span>数字分身</span><strong>${state.avatar ? '已开启' : '已暂停'}</strong></div><div class="summary-line"><span>监督回顾</span><strong>${state.useReview ? '已选 1 条' : '未授权'}</strong></div><div class="notice">保存仅写入本浏览器,不会发布真实资料或修改 GoodBuddy 设置。</div></section></aside></div>`;
}
function knowledgeSettings() {
return `<section class="panel"><h2>共享知识库</h2>${toggle('knowledge', '开启知识共享', '允许访问者阅读选中的示例资料,供数字分身复用。')}</section><section class="panel"><div class="panel-head"><div><h2>选择共享内容</h2><p>仅选中的知识库对访问者开放。</p></div><span class="badge gray">本地知识库</span></div>${sources.map(s => `<label class="row check"><input type="checkbox" data-source="${s.id}" ${state.sources.includes(s.id) ? 'checked' : ''}><span><strong>${s.name}</strong><small>${s.meta}</small><small>${s.desc}</small></span></label>`).join('')}<div class="row"><span><strong>客户项目档案</strong><p>私人资料 · 未纳入本次共享</p></span><span class="badge gray">保持私有</span></div><div class="notice">取消选择或暂停知识共享后,分身将同步失去这些知识来源。数字分身开关不受影响。</div></section>`;
}
function avatarSettings() {
return `<section class="panel"><h2>数字分身</h2>${toggle('avatar', '开启我的数字分身', '为共享空间增加对话入口,使用你授权的资料与个人设定。')}</section><section class="panel"><h2>身份与表达</h2><p style="margin-bottom:18px">访问者会明确看到这是 AI 分身。</p><label class="field"><span>显示名称</span><input type="text" maxlength="30" data-field="name" value="${esc(state.name)}"></label><label class="field"><span>自定义提示词</span><textarea data-field="prompt" maxlength="2000">${esc(state.prompt)}</textarea></label></section><section class="panel"><h2>回答可以参考什么</h2>${toggle('useKnowledge', '关联共享知识库', `复用已授权的知识库。当前${state.knowledge ? '选中 ' + state.sources.length + ' 个' : '知识共享已暂停,暂不可用'}。`)}${toggle('useReview', '共享选定的监督回顾', '仅授权下方这一条示例回顾,不包含全部信息流或后续新记录。')}<details ${state.useReview ? 'open' : ''}><summary>查看待授权的回顾内容</summary><div class="record">示例回顾 · 09 月 24 日<br>讨论产品方案时,倾向于先确定一个可验证的小场景,再扩大范围;关注权限边界和用户能否理解操作结果。</div></details>${toggle('showOriginal', '允许访客查看回顾原文', '独立于回答参考权限;需先开启上方监督回顾授权。')}<div class="notice">默认不共享监督回顾。授权用于回答,并不自动允许访问者阅读原始记录。</div></section>`;
}
function visitorView() {
return `<div class="heading"><div class="profile"><div class="avatar">${esc(state.name.slice(0, 1) || '林')}</div><div><div class="eyebrow">PERSONAL KNOWLEDGE SPACE</div><h1>${esc(state.name || '我的')}的共享空间</h1><p>分享 AI 应用实践与产品思考,也欢迎和我的数字分身聊聊。</p></div></div><span class="badge gray">访客视角 · 演示</span></div>${state.scope === 'team' ? '<section class="panel empty"><h2>这个空间仅对团队成员开放</h2><p>当前模拟的是匿名访问者。请在共享设置中切换为「持有链接的人」,体验公开访问流程。</p><a href="settings.html">前往共享设置 →</a></section>' : `<div class="tabs" role="tablist" aria-label="共享内容"><button role="tab" aria-selected="${tab === 'knowledge'}" class="tab ${tab === 'knowledge' ? 'active' : ''}" data-tab="knowledge">知识库 <span class="muted">${state.knowledge ? state.sources.length : 0}</span></button><button role="tab" aria-selected="${tab === 'avatar'}" class="tab ${tab === 'avatar' ? 'active' : ''}" data-tab="avatar">与分身交流</button></div>${tab === 'knowledge' ? knowledgeVisitor() : avatarVisitor()}`}<p class="muted" style="margin-top:22px">交互演示:配置仅保存在当前浏览器;对话使用固定示例逻辑,未连接模型、知识检索或真实共享服务。</p>`;
}
function knowledgeVisitor() {
const available = sources.filter(s => state.sources.includes(s.id));
if (!state.knowledge || !available.length) return '<section class="panel empty"><h2>暂无开放的知识库</h2><p>主人已暂停知识共享,或尚未选择资料。数字分身可以独立开启。</p></section>';
const item = available.find(s => s.id === selected) || available[0];
selected = item.id;
return `<div class="split"><section><label class="field"><span>查找共享知识库</span><input id="search" type="search" placeholder="搜索名称或简介"></label><div class="source-list">${available.map(s => `<button class="source ${s.id === selected ? 'active' : ''}" data-select="${s.id}" aria-pressed="${s.id === selected}"><div class="source-icon">KNOWLEDGE / ${s.id === 'practice' ? '01' : '02'}</div><strong>${s.name}</strong><small>${s.desc}<br>${s.meta}</small></button>`).join('')}</div><p id="no-results" hidden>没有匹配的知识库。</p></section><section class="panel reader"><div class="panel-head"><span class="badge">已授权共享</span><span class="muted">资料节选 / 示例</span></div><h2>${item.title}</h2><p>来自 ${item.name} · 林知远</p><article>${item.body}</article><div class="row"><span class="muted">${state.avatar && state.useKnowledge ? '这份资料也用于数字分身回答' : '以共享资料形式提供阅读'}</span>${state.avatar ? '<button data-tab="avatar">向分身提问 ↗</button>' : ''}</div></section></div>`;
}
function avatarVisitor() {
if (!state.avatar) return '<section class="panel empty"><h2>数字分身已暂停</h2><p>暂时无法发起对话。已开放的共享知识库仍可阅读。</p><button data-tab="knowledge">浏览知识库</button></section>';
const available = state.knowledge && state.useKnowledge ? sources.filter(s => state.sources.includes(s.id)) : [];
return `<div class="columns"><section class="panel chat"><div class="panel-head"><div><h2>${esc(state.name || '我的')}的数字分身</h2><p>AI 分身 · 回答不代表本人承诺</p></div><span class="badge">可交流</span></div><div class="suggestions"><button data-ask="如何开始一个 AI 应用项目?">如何开始一个 AI 项目?</button><button data-ask="你通常怎样做产品决策?">你怎样做产品决策?</button></div><div class="messages" id="messages" role="log" aria-live="polite"><div class="message">你好,我可以根据主人授权的共享资料,与你交流 AI 应用和产品设计。你想从哪里开始?<small>固定示例回复,不是实时模型输出</small></div></div><form class="composer" id="chat-form"><input type="text" id="question" maxlength="500" required aria-label="向数字分身提问" placeholder="输入你的问题…"><button class="primary">发送</button></form></section><aside class="aside"><section class="panel"><h3>这次交流的知识来源</h3>${available.length ? available.map(s => `<div class="row"><span>${s.name}</span><span class="badge gray">已关联</span></div>`).join('') : '<p>当前未关联可用的共享知识库。</p>'}<div class="row"><span>监督回顾</span><span class="badge gray">${state.useReview ? '已授权 1 条' : '未共享'}</span></div>${state.useReview && state.showOriginal ? '<details><summary>查看获准公开的回顾原文</summary><div class="record">讨论产品方案时,倾向于先确定一个可验证的小场景,再扩大范围;关注权限边界和用户能否理解操作结果。</div></details>' : ''}<div class="notice">分身只能使用主人明确授权的内容。无法访问未共享的知识库和私人对话。</div></section><section class="panel"><h3>关于这个分身</h3><p>知识库提供资料依据,选定的回顾补充决策习惯,个人提示词设定表达方式。</p><p style="margin-top:12px">此原型仅演示来源开关,不执行自定义提示词。</p></section></aside></div>`;
}
function answer(question) {
const messages = document.querySelector('#messages');
const user = document.createElement('div'); user.className = 'message user'; user.textContent = question; messages.append(user);
const available = state.knowledge && state.useKnowledge ? state.sources : [];
let text = '目前授权的示例资料不足以回答这个问题。你可以询问 AI 项目起步或产品决策;具体承诺需要联系本人确认。';
let citation = '未使用资料 · 演示回复';
if (/决策|习惯/.test(question) && state.useReview) { text = '从这条已授权的回顾来看,主人倾向于先确定一个可验证的小场景,再扩大范围;同时关注权限边界和用户对操作结果的理解。'; citation = '参考:已授权监督回顾 · 09 月 24 日' + (state.showOriginal ? ' · 原文可在右侧查看' : ' · 原文未公开'); }
else if (/AI|项目|开始/i.test(question) && available.includes('practice')) { text = '可以先选一个输入清晰、输出可检查的小场景,写下验收条件,再邀请少量用户完成真实任务。对于资料问答,建议先验证回答是否有出处,以及资料不足时能否明确说明。'; citation = '来源:AI 应用实践 / 让 AI 应用从一个小场景开始'; }
else if (/产品|共享|知识库|分身/.test(question) && available.includes('product')) { text = '知识库和数字分身可以复用同一份资料,但要明确使用权限。知识库独立提供阅读,分身在授权范围内参考资料;监督回顾的回答权限与原文查看权限应分开控制。'; citation = '来源:产品设计笔记 / 知识共享与数字分身的关系'; }
const reply = document.createElement('div'); reply.className = 'message'; reply.textContent = text; const foot = document.createElement('small'); foot.textContent = citation + ' · 固定示例'; reply.append(foot); messages.append(reply); reply.scrollIntoView({ block: 'nearest' });
}
function bind() {
document.querySelectorAll('[data-tab]').forEach(el => el.addEventListener('click', () => { tab = el.dataset.tab; render(); document.querySelector(`.tab[data-tab="${tab}"]`)?.focus(); }));
document.querySelectorAll('[data-field]').forEach(el => el.addEventListener('input', () => { state[el.dataset.field] = el.type === 'checkbox' ? el.checked : el.value; dirty = true; if (el.type === 'checkbox' || el.tagName === 'SELECT') render(); else document.querySelector('#save-state').textContent = '有未保存的更改'; }));
document.querySelectorAll('[data-source]').forEach(el => el.addEventListener('change', () => { state.sources = el.checked ? [...state.sources, el.dataset.source] : state.sources.filter(id => id !== el.dataset.source); dirty = true; render(); }));
document.querySelector('#save')?.addEventListener('click', () => { if (!state.name.trim()) { toast('请填写分身显示名称'); return; } try { localStorage.setItem(key, JSON.stringify(state)); dirty = false; render(); toast('已保存演示配置,可打开访客页面查看'); } catch { toast('浏览器不允许本地存储,设置未保存'); } });
document.querySelector('#reset')?.addEventListener('click', () => { state = structuredClone(defaults); dirty = true; render(); toast('已恢复示例,保存后生效'); });
document.querySelectorAll('[data-select]').forEach(el => el.addEventListener('click', () => { selected = el.dataset.select; render(); }));
document.querySelector('#search')?.addEventListener('input', event => { const q = event.target.value.trim().toLowerCase(); let count = 0; document.querySelectorAll('[data-select]').forEach(el => { el.hidden = !el.textContent.toLowerCase().includes(q); if (!el.hidden) count++; }); document.querySelector('#no-results').hidden = count > 0; });
document.querySelector('#chat-form')?.addEventListener('submit', event => { event.preventDefault(); const input = document.querySelector('#question'); if (input.value.trim()) answer(input.value.trim()); input.value = ''; });
document.querySelectorAll('[data-ask]').forEach(el => el.addEventListener('click', () => answer(el.dataset.ask)));
}
window.addEventListener('beforeunload', event => { if (dirty) { event.preventDefault(); event.returnValue = ''; } });
window.addEventListener('storage', event => { if (event.key === key && !dirty) { state = load(); render(); } });
render();
+5
View File
@@ -0,0 +1,5 @@
<!doctype html>
<html lang="zh-CN">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>共享空间 · GoodBuddy</title><link rel="stylesheet" href="demo.css"></head>
<body data-page="visitor"><div id="app"></div><script src="demo.js"></script></body>
</html>
+5
View File
@@ -0,0 +1,5 @@
<!doctype html>
<html lang="zh-CN">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>共享设置 · GoodBuddy</title><link rel="stylesheet" href="demo.css"></head>
<body data-page="settings"><div id="app"></div><link rel="stylesheet" href="workspace.css"><script src="workspace.js"></script><script src="demo.js"></script></body>
</html>
+67
View File
@@ -0,0 +1,67 @@
/* global console, structuredClone */
import { JSDOM } from 'jsdom';
import assert from 'node:assert/strict';
const key = 'goodbuddy-workspace-demo-v1';
const windows = [];
async function open(page, saved) {
const dom = await JSDOM.fromURL('http://127.0.0.1:5178/docs/demo/client/' + page, {
resources: 'usable', runScripts: 'dangerously',
beforeParse(w) { w.structuredClone = structuredClone; if (saved) w.localStorage.setItem(key, saved); },
});
windows.push(dom.window);
await new Promise(resolve => dom.window.addEventListener('load', resolve));
return dom.window;
}
try {
const w = await open('workspace.html');
const d = w.document;
const input = (selector, value, event = 'input') => { const el = d.querySelector(selector); el.value = value; el.dispatchEvent(new w.Event(event, { bubbles: true })); };
const submit = selector => d.querySelector(selector).dispatchEvent(new w.Event('submit', { bubbles: true, cancelable: true }));
const send = (to, human = false) => {
input('#message-text', '@');
assert.equal(d.querySelector('#mention-list').hidden, false);
d.querySelector(`[data-recipient=${to}]`).click();
input('#message-text', '请评估共享方案 <script>test</script>');
d.querySelector('[data-context=doc]').click();
if (human) d.querySelector('#require-human').click();
submit('#send-form');
};
send('zhang');
assert.match(d.querySelector('.thread').textContent, /分身已回复/);
assert.equal(d.querySelector('.thread script'), null);
assert.match(d.querySelector('.thread details').textContent, /原文查看权限/);
send('chen');
assert.equal(d.querySelector('[data-draft]'), null);
input('#actor', 'chen', 'change');
assert.ok(d.querySelector('[data-draft]'));
input('[data-draft]', '已由本人确认,先验证权限流程。');
d.querySelector('[data-confirm]').click();
assert.match(d.querySelectorAll('.thread')[1].textContent, /已由本人确认/);
input('#actor', 'lin', 'change');
send('zhang', true);
assert.match(d.querySelectorAll('.thread')[2].textContent, /待本人处理/);
assert.equal(d.querySelectorAll('.thread')[2].querySelector('.thread-reply'), null);
input('#actor', 'zhang', 'change');
d.querySelectorAll('[data-takeover]')[0].click();
input('[data-reply] textarea', '我已接手,补充本人意见。');
submit('[data-reply]');
assert.match(d.querySelector('.thread').textContent, /本人已回复/);
assert.match(d.querySelector('.thread').textContent, /AI 示例回复/);
d.querySelector('[data-space=research]').click();
assert.equal(d.querySelectorAll('.thread').length, 0);
input('#space-name', '新建交付空间');
submit('#create-form');
assert.match(d.querySelector('h1').textContent, /新建交付空间/);
assert.equal(d.querySelector('#mode').value, 'human');
const saved = w.localStorage.getItem(key);
const settings = await open('settings.html', saved);
const pref = settings.document.querySelector('[data-space-mode=product]');
assert.ok(pref);
pref.value = 'draft';
settings.document.querySelector('#save-preferences').click();
const restored = await open('workspace.html', settings.localStorage.getItem(key));
assert.equal(restored.document.querySelector('#mode').value, 'draft');
assert.equal(restored.document.querySelectorAll('.thread').length, 3);
console.log('PASS: mention routing, context snapshot, private draft, confirmation override, human takeover, workspace isolation, creation, settings persistence');
} finally { windows.forEach(w => w.close()); }
+60
View File
@@ -0,0 +1,60 @@
/* global structuredClone, console */
import { JSDOM } from 'jsdom';
import assert from 'node:assert/strict';
const base = 'http://127.0.0.1:5178/docs/demo/client/';
const key = 'goodbuddy-sharing-demo-v1';
const windows = [];
async function open(page, saved) {
const dom = await JSDOM.fromURL(base + page, {
resources: 'usable', runScripts: 'dangerously',
beforeParse(window) {
window.structuredClone = structuredClone;
window.HTMLElement.prototype.scrollIntoView = function () {};
if (saved) window.localStorage.setItem(key, saved);
},
});
windows.push(dom.window);
await new Promise(resolve => dom.window.addEventListener('load', resolve));
return dom.window;
}
try {
const owner = await open('settings.html');
const d = owner.document;
d.querySelector('[data-source=practice]').click();
d.querySelector('[data-tab=avatar]').click();
d.querySelector('[data-field=useReview]').click();
d.querySelector('#save').click();
const saved = owner.localStorage.getItem(key);
const guest = await open('index.html', saved);
const g = guest.document;
assert.equal(g.querySelectorAll('[data-select]').length, 1);
g.querySelector('[data-tab=avatar]').click();
g.querySelectorAll('[data-ask]')[1].click();
assert.match(g.querySelector('#messages').textContent, /原文未公开/);
assert.equal(g.querySelector('details'), null);
d.querySelector('[data-field=showOriginal]').click();
d.querySelector('#save').click();
const visible = await open('index.html', owner.localStorage.getItem(key));
visible.document.querySelector('[data-tab=avatar]').click();
assert.match(visible.document.querySelector('details').textContent, /回顾原文/);
d.querySelector('[data-field=avatar]').click();
d.querySelector('#save').click();
const paused = await open('index.html', owner.localStorage.getItem(key));
assert.equal(paused.document.querySelectorAll('[data-select]').length, 1);
paused.document.querySelector('[data-tab=avatar]').click();
assert.match(paused.document.body.textContent, /数字分身已暂停/);
const config = JSON.parse(saved);
config.knowledge = false;
const revoked = await open('index.html', JSON.stringify(config));
revoked.document.querySelector('[data-tab=avatar]').click();
revoked.document.querySelector('[data-ask]').click();
assert.match(revoked.document.querySelector('#messages').textContent, /资料不足/);
config.scope = 'team';
const team = await open('index.html', JSON.stringify(config));
assert.match(team.document.body.textContent, /仅对团队成员开放/);
assert.equal(team.document.querySelector('#chat-form'), null);
console.log('PASS: source revocation, review privacy, original visibility, independent pause, team gate');
} finally {
windows.forEach(window => window.close());
}
+1
View File
@@ -0,0 +1 @@
.workspace-shell{grid-template-columns:235px minmax(0,1fr)}.workspace-shell main{min-width:0}.space-nav{display:grid;gap:7px}.space-nav button{text-align:left;border:0;background:transparent}.space-nav button.active{background:#dfe9fc;color:#285fc7}.workspace-content{max-width:1250px}.workspace-layout{display:grid;grid-template-columns:minmax(0,1fr) 280px;gap:22px}.workspace-feed{min-width:0}.thread{border:1px solid var(--line);background:white;border-radius:14px;margin-bottom:16px;padding:22px}.thread.selected{border-color:#9bb7ed}.thread-head{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:12px}.thread-body{white-space:pre-wrap;overflow-wrap:anywhere;line-height:1.8}.thread-context{font-size:12px;color:var(--muted);padding:12px 0}.thread-reply{border-left:2px solid #b6cbf3;padding:8px 0 8px 16px;margin:12px 0}.thread-reply p{white-space:pre-wrap;color:#40516a;overflow-wrap:anywhere}.thread-actions{display:flex;gap:8px;flex-wrap:wrap;margin-top:16px}.workspace-composer textarea{min-height:110px}.recipient{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin:12px 0}.recipient .badge{font-size:12px}.mention-list{border:1px solid var(--line);padding:8px;border-radius:10px;background:#fff;display:grid;gap:4px;margin-bottom:12px}.mention-list button{text-align:left;border:0;display:flex;justify-content:space-between}.context-options{display:grid;gap:12px;margin:16px 0}.workspace-members{display:grid;gap:16px;margin-top:20px}.workspace-member{display:flex;gap:10px;align-items:center}.workspace-member strong{display:block;margin-bottom:4px}.workspace-modal{border:1px solid var(--line);border-radius:16px;padding:26px;width:min(480px,calc(100vw - 32px));color:inherit}.workspace-modal::backdrop{background:#14283d60}.workspace-modal h2{margin-bottom:18px}.workspace-toolbar{display:flex;gap:10px;align-items:center;flex-wrap:wrap}.workspace-toolbar select{width:auto}.context-preview{padding:12px;background:#f4f7fb;border-radius:8px;font-size:12px;white-space:pre-wrap;line-height:1.8}.workspace-notice{margin-bottom:20px}.workspace-status{min-height:20px;font-size:12px;color:#3569dc}.preference-row{display:grid;grid-template-columns:1fr 180px;gap:12px;align-items:center;padding:14px 0;border-top:1px solid var(--line)}.workspace-mobile-nav{display:none}.workspace-preferences{margin-top:24px}.thread details{margin-bottom:10px}@media(max-width:1050px){.workspace-layout{grid-template-columns:1fr}.workspace-layout>.aside{grid-template-columns:1fr 1fr}}@media(max-width:720px){.workspace-shell{display:block}.workspace-mobile-nav{display:flex;gap:8px;margin-bottom:18px}.workspace-mobile-nav select{min-width:0}.workspace-layout>.aside{grid-template-columns:1fr}.thread{padding:17px}.thread-head{align-items:flex-start}.preference-row{grid-template-columns:1fr}.workspace-toolbar{width:100%}.workspace-toolbar select{flex:1;min-width:0}}
+5
View File
@@ -0,0 +1,5 @@
<!doctype html>
<html lang="zh-CN">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>组织工作空间 · GoodBuddy</title><link rel="stylesheet" href="demo.css"><link rel="stylesheet" href="workspace.css"></head>
<body data-page="workspace"><div id="app"></div><script src="workspace.js"></script></body>
</html>
+89
View File
@@ -0,0 +1,89 @@
/* global document, window, localStorage, crypto */
(() => {
const key = 'goodbuddy-workspace-demo-v1';
const people = [{ id: 'lin', name: '林知远', job: '产品负责人' }, { id: 'zhang', name: '张明', job: '技术顾问' }, { id: 'chen', name: '陈悦', job: '产品设计师' }];
const modes = { human: '本人处理', auto: '分身先处理', draft: '分身起草,本人确认' };
const initial = () => ({ spaces: [
{ id: 'product', name: '产品设计', members: ['lin', 'zhang', 'chen'], modes: { lin: 'human', zhang: 'auto', chen: 'draft' }, messages: [] },
{ id: 'research', name: 'AI 应用研究', members: ['lin', 'zhang'], modes: { lin: 'human', zhang: 'human' }, messages: [] }
] });
const read = () => { try { return JSON.parse(localStorage.getItem(key)) || initial(); } catch { return initial(); } };
const escape = value => String(value).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);
const person = id => people.find(p => p.id === id);
let data = read();
let spaceId = data.spaces[0].id;
let actor = 'lin';
let recipient = '';
let status = '';
let draft = '';
let contexts = [];
let requireHuman = false;
const current = () => data.spaces.find(s => s.id === spaceId);
const options = (value) => Object.entries(modes).map(([id, label]) => `<option value="${id}" ${value === id ? 'selected' : ''}>${label}</option>`).join('');
function save(next) {
try { localStorage.setItem(key, JSON.stringify(next)); data = next; return true; }
catch { status = '浏览器存储不可用,更改未保存。'; return false; }
}
const contextItems = () => [
{ id: 'brief', title: `${current().name} · 空间讨论摘要`, text: `空间:${current().name}\n目标:验证共享知识与数字分身协作的最小可用流程。\n待讨论:如何区分分身回复和本人的确认。` },
{ id: 'doc', title: '共享设置草稿 · 空间文档', text: '知识库与分身复用已授权资料。监督回顾的回答使用权限与原文查看权限分开设置。' }
];
function render() {
const space = current();
document.querySelector('#app').innerHTML = `<div class="shell workspace-shell"><aside class="sidebar"><a class="brand" href="workspace.html"><span class="logo">g</span>GoodBuddy</a><div><div class="nav-label">当前组织</div><h3 style="margin-top:12px">明日工作室</h3><span class="badge">已加入 · 3 位成员</span></div><div><div class="panel-head"><span class="nav-label">共享工作空间</span><button data-create aria-label="新建工作空间">+</button></div><nav class="space-nav">${data.spaces.map(s => `<button data-space="${s.id}" class="${s.id === spaceId ? 'active' : ''}"># ${escape(s.name)}</button>`).join('')}</nav></div><nav class="nav"><a href="index.html">个人共享主页 ↗</a><a href="settings.html">共享与分身设置 ↗</a></nav><div class="sidebar-bottom">组织内协作演示<br>消息不会发送给真实成员</div></aside><main><header class="topbar"><span class="crumb">明日工作室 / 共享工作空间</span><div class="workspace-toolbar"><label for="actor">模拟当前成员</label><select id="actor">${space.members.map(id => `<option value="${id}" ${actor === id ? 'selected' : ''}>${person(id).name}</option>`).join('')}</select><a href="settings.html">设置 ↗</a></div></header><div class="content workspace-content"><div class="workspace-mobile-nav"><select id="space-select" aria-label="切换工作空间">${data.spaces.map(s => `<option value="${s.id}" ${s.id === spaceId ? 'selected' : ''}>${escape(s.name)}</option>`).join('')}</select><button data-create>新建空间</button></div><div class="heading"><div><div class="eyebrow">TEAM WORKSPACE</div><h1>${escape(space.name)}</h1><p>把问题交给具体的人,由本人或获准的数字分身接着处理。</p></div><button data-create>+ 新建工作空间</button></div><div class="notice workspace-notice">当前以 ${person(actor).name} 的身份体验。切换成员可演示接收、确认草稿和本人接手;这是本地角色模拟,不是真实登录。</div><div class="workspace-layout"><div class="workspace-feed"><section aria-label="空间消息">${space.messages.length ? space.messages.map(thread).join('') : '<section class="panel empty"><h2>从一个明确的问题开始</h2><p>在下方 @ 一位空间成员,选择要附带的资料。消息和处理结果会留在这里。</p></section>'}</section>${composer()}</div><aside class="aside"><section class="panel"><h3>空间成员 · ${space.members.length}</h3><p>@ 指定真实成员,处理方式由接收者设置。</p><div class="workspace-members">${space.members.map(id => `<div class="workspace-member"><span class="mini-avatar">${person(id).name[0]}</span><div><strong>${person(id).name}${id === actor ? ' · 当前' : ''}</strong><span class="muted">${modes[space.modes[id] || 'human']}</span></div></div>`).join('')}</div></section><section class="panel"><h3>我在此空间的处理方式</h3><label class="field"><span>${person(actor).name} · 接收 @消息时</span><select id="mode">${options(space.modes[actor] || 'human')}</select></label><button id="save-mode">保存处理偏好</button><div class="notice">只影响后续消息。「需要本人确认」会优先进入本人待处理。新空间默认由本人处理。</div></section><section class="panel"><h3>空间知识与上下文</h3><p>本示例包含讨论摘要和共享设置草稿。发送时可预览并选择附带内容。</p><div class="notice">不会自动附带个人对话、私有知识库或监督记录。个人共享入口的公开范围不改变组织空间成员权限。</div><a href="index.html">查看个人共享知识库 ↗</a></section></aside></div><div class="workspace-status" role="status">${escape(status)}</div></div></main></div><dialog class="workspace-modal" id="create-dialog"><form id="create-form"><h2>新建共享工作空间</h2><label class="field"><span>所属组织</span><input type="text" value="明日工作室" disabled></label><label class="field"><span>空间名称</span><input id="space-name" type="text" required maxlength="40" placeholder="例如:客户交付讨论"></label><h3>加入空间的成员</h3><div class="context-options">${people.map(p => `<label class="check"><input type="checkbox" name="member" value="${p.id}" ${p.id === actor ? 'checked disabled' : 'checked'}>${p.name}${p.id === actor ? '(创建者)' : ''}</label>`).join('')}</div><p class="muted">成员立即加入仅为演示;真实邀请和组织管理尚未接入。</p><div class="footer-actions"><button type="button" id="cancel-create">取消</button><button class="primary">创建空间</button></div></form></dialog>`;
bind();
}
function thread(message) {
const mine = actor === message.to;
const labels = { pending: '待本人处理', draft: '待本人确认草稿', replied: '分身已回复', handling: '本人已接手', done: '本人已回复' };
return `<article class="thread" data-message="${message.id}"><div class="thread-head"><strong>${person(message.from).name} <span class="muted">→</span> @${person(message.to).name}</strong><span class="badge ${message.status === 'pending' || message.status === 'draft' ? 'gray' : ''}">${labels[message.status]}</span></div><div class="thread-body">${escape(message.text)}</div><div class="thread-context">${message.requireHuman ? '需要本人确认 · ' : ''}接收方式:${modes[message.mode]} · 附带 ${message.contexts.length} 项上下文</div>${message.contexts.length ? `<details><summary>查看发送时的上下文</summary>${message.contexts.map(c => `<h3>${escape(c.title)}</h3><div class="context-preview">${escape(c.text)}</div>`).join('')}</details>` : ''}${message.replies.map(r => `<div class="thread-reply"><h3>${person(message.to).name}${r.ai ? '的数字分身 · AI 示例回复' : ' · 本人(模拟)'}</h3><p>${escape(r.text)}</p></div>`).join('')}${message.status === 'draft' && mine ? `<div class="thread-reply"><h3>分身草稿 · 仅接收者可见</h3><label class="field"><span>确认前可编辑</span><textarea data-draft="${message.id}" maxlength="2000">${escape(message.draft)}</textarea></label><button data-confirm="${message.id}" class="primary">确认并发送</button></div>` : ''}${mine && message.status !== 'done' ? `<div class="thread-actions">${message.status !== 'handling' ? `<button data-takeover="${message.id}">由我接手</button>` : ''}</div>${message.status === 'handling' ? `<form data-reply="${message.id}"><label class="field"><span>本人回复</span><textarea required maxlength="2000" placeholder="输入你的处理意见"></textarea></label><button class="primary">发送本人回复</button></form>` : ''}` : ''}</article>`;
}
function composer() {
return `<section class="panel workspace-composer"><h2>发起协作</h2><form id="send-form"><div class="recipient"><button type="button" id="mention-toggle">@ 选择成员</button><span id="recipient-label" class="badge gray">${recipient ? '@' + person(recipient).name : '尚未指定接收人'}</span></div><div class="mention-list" id="mention-list" hidden><input type="search" id="member-search" aria-label="搜索空间成员" placeholder="搜索成员姓名">${current().members.map(id => `<button type="button" data-recipient="${id}"><strong>@${person(id).name}</strong><span class="muted">${person(id).job}</span></button>`).join('')}<p id="no-members" hidden>没有匹配的空间成员</p></div><label class="field"><span>消息内容</span><textarea id="message-text" required maxlength="2000" placeholder="输入 @ 选择成员,例如:请评估这个方案的权限设计">${escape(draft)}</textarea></label><details><summary>选择附带上下文 · <span id="context-count">${contexts.length}</span> 项</summary><div class="context-options">${contextItems().map(c => `<label class="check"><input type="checkbox" data-context="${c.id}" ${contexts.includes(c.id) ? 'checked' : ''}><span>${escape(c.title)}<small>${escape(c.text)}</small></span></label>`).join('')}</div></details><label class="check" style="margin-top:18px"><input type="checkbox" id="require-human" ${requireHuman ? 'checked' : ''}><span>需要本人确认<small>此消息直接交给本人,不自动发布分身回复。</small></span></label><div class="footer-actions"><span class="muted">只发送给所选成员,线程对空间成员可见</span><button class="primary">发送消息</button></div></form></section>`;
}
function bind() {
function switchSpace(id) { spaceId = id; if (!current().members.includes(actor)) actor = current().members[0]; recipient = ''; draft = ''; contexts = []; requireHuman = false; status = ''; render(); }
document.querySelectorAll('[data-space]').forEach(el => el.onclick = () => switchSpace(el.dataset.space));
document.querySelector('#space-select').onchange = e => switchSpace(e.target.value);
document.querySelector('#actor').onchange = e => { actor = e.target.value; recipient = ''; draft = ''; contexts = []; requireHuman = false; render(); };
document.querySelectorAll('[data-create]').forEach(el => el.onclick = () => document.querySelector('#create-dialog').showModal());
document.querySelector('#cancel-create').onclick = () => document.querySelector('#create-dialog').close();
document.querySelector('#create-form').onsubmit = e => {
e.preventDefault(); const input = document.querySelector('#space-name'); const name = input.value.trim();
if (!name || data.spaces.some(s => s.name === name)) { input.setCustomValidity('请输入不重复的空间名称'); input.reportValidity(); return; }
const members = [...document.querySelectorAll('[name=member]:checked')].map(el => el.value);
const id = crypto.randomUUID(); const next = read(); next.spaces.push({ id, name, members, modes: Object.fromEntries(members.map(id => [id, 'human'])), messages: [] });
if (save(next)) switchSpace(id); else render();
};
document.querySelector('#space-name').oninput = e => e.target.setCustomValidity('');
document.querySelector('#mention-toggle').onclick = () => { const list = document.querySelector('#mention-list'); list.hidden = !list.hidden; if (!list.hidden) document.querySelector('#member-search').focus(); };
document.querySelector('#member-search').oninput = e => { let count = 0; document.querySelectorAll('[data-recipient]').forEach(el => { el.hidden = !el.textContent.includes(e.target.value.trim()); if (!el.hidden) count++; }); document.querySelector('#no-members').hidden = count > 0; };
document.querySelectorAll('[data-recipient]').forEach(el => el.onclick = () => { recipient = el.dataset.recipient; document.querySelector('#recipient-label').textContent = '@' + person(recipient).name; document.querySelector('#mention-list').hidden = true; const input = document.querySelector('#message-text'); input.value = input.value.replace(/@$/, ''); draft = input.value; input.focus(); });
document.querySelector('#message-text').oninput = e => { draft = e.target.value; if (draft.endsWith('@')) document.querySelector('#mention-list').hidden = false; };
document.querySelectorAll('[data-context]').forEach(el => el.onchange = () => { contexts = [...document.querySelectorAll('[data-context]:checked')].map(el => el.dataset.context); document.querySelector('#context-count').textContent = contexts.length; });
document.querySelector('#require-human').onchange = e => requireHuman = e.target.checked;
document.querySelector('#save-mode').onclick = () => { const next = read(); next.spaces.find(s => s.id === spaceId).modes[actor] = document.querySelector('#mode').value; if (save(next)) status = '处理偏好已保存,仅影响后续消息。'; render(); };
document.querySelector('#send-form').onsubmit = e => {
e.preventDefault(); if (!recipient) { document.querySelector('#mention-list').hidden = false; document.querySelector('#member-search').focus(); return; }
if (!draft.trim()) { document.querySelector('#message-text').focus(); return; }
const next = read(); const space = next.spaces.find(s => s.id === spaceId); const mode = space.modes[recipient] || 'human';
const attached = contextItems().filter(c => contexts.includes(c.id));
const reply = attached.length ? `已收到问题。当前附带 ${attached.length} 项上下文,建议先核对资料授权范围,并明确标注分身回复与本人确认。具体方案仍需本人审阅。` : '已收到问题,但未附带具体资料。请补充需要评估的方案和验收条件,再进行具体判断。';
const human = requireHuman || mode === 'human';
space.messages.push({ id: crypto.randomUUID(), from: actor, to: recipient, text: draft.trim(), contexts: attached, requireHuman, mode, status: human ? 'pending' : mode === 'draft' ? 'draft' : 'replied', draft: !human && mode === 'draft' ? reply : '', replies: !human && mode === 'auto' ? [{ ai: true, text: reply }] : [] });
if (save(next)) { draft = ''; contexts = []; recipient = ''; requireHuman = false; status = '消息已保存在当前演示空间。'; } render();
};
function update(id, change) { const next = read(); const message = next.spaces.find(s => s.id === spaceId).messages.find(m => m.id === id); if (message.to !== actor || message.status === 'done') return; change(message); save(next); render(); }
document.querySelectorAll('[data-takeover]').forEach(el => el.onclick = () => update(el.dataset.takeover, m => { m.status = 'handling'; m.draft = ''; }));
document.querySelectorAll('[data-confirm]').forEach(el => el.onclick = () => { const text = document.querySelector(`[data-draft="${el.dataset.confirm}"]`).value.trim(); if (text) update(el.dataset.confirm, m => { m.status = 'done'; m.replies.push({ ai: false, text }); m.draft = ''; }); });
document.querySelectorAll('[data-reply]').forEach(el => el.onsubmit = e => { e.preventDefault(); const text = el.querySelector('textarea').value.trim(); if (text) update(el.dataset.reply, m => { m.status = 'done'; m.replies.push({ ai: false, text }); }); });
}
window.mountWorkspacePreferences = () => {
data = read();
const section = document.createElement('section'); section.className = 'panel workspace-preferences';
section.innerHTML = `<div class="panel-head"><div><h2>组织工作空间 · 我的处理偏好</h2><p>明日工作室 / 林知远。与个人访客分身入口分别设置,以下偏好独立保存。</p></div><a href="workspace.html">进入协作空间 ↗</a></div>${data.spaces.filter(s => s.members.includes('lin')).map(s => `<label class="preference-row"><span>${escape(s.name)}</span><select data-space-mode="${s.id}">${options(s.modes.lin || 'human')}</select></label>`).join('')}<div class="notice">@ 指向你本人;可授权分身先回复或仅起草。对方要求本人确认时,优先交给你。更改仅影响后续消息。</div><div class="footer-actions"><span id="preferences-status" role="status"></span><button id="save-preferences">保存空间偏好</button></div>`;
document.querySelector('.content').append(section);
section.querySelector('#save-preferences').onclick = () => { const next = read(); section.querySelectorAll('[data-space-mode]').forEach(el => { const s = next.spaces.find(s => s.id === el.dataset.spaceMode); if (s?.members.includes('lin')) s.modes.lin = el.value; }); section.querySelector('#preferences-status').textContent = save(next) ? '空间偏好已保存' : status; };
};
if (document.body.dataset.page === 'workspace') render();
})();
+5 -4
View File
@@ -2,7 +2,7 @@
ShareServer 是 GoodBuddy 设备共享网络的独立 Web 服务。它为一个或多个组织提供身份与设备
目录、能力发布、授权策略、任务路由、可选数据中继、能力包更新、网关联邦、Office 编辑和
审计管理。
审计管理。目标范围还包括共享工作空间的实时消息、成员处理请求和资料同步。
ShareServer 不属于 Electron Main,不负责桌面端局域网发现,也不自动取得设备上的能力、
文件、凭据或 SSH Host。
@@ -12,6 +12,7 @@ ShareServer 不属于 Electron Main,不负责桌面端局域网发现,也不
| 文档 | 权威职责 |
| --- | --- |
| [PRD](./prd.md) | 管理角色、产品范围、功能需求和验收 |
| [用户故事](./user-stories.md) | 工作空间、实时消息、资料同步及数字分身处理的验收场景与逻辑审查 |
| [功能逻辑](./logic-design.md) | 组织隔离、设备、发布、策略、任务和联邦状态规则 |
| [管理界面设计](./ui-design.md) | ShareServer Web 控制台的信息架构与交互 |
| [技术设计](./technical-design.md) | 服务模块、API、数据、部署、运维和验证 |
@@ -19,8 +20,8 @@ ShareServer 不属于 Electron Main,不负责桌面端局域网发现,也不
| [共享网络总体设计](../../../../goodbuddy/docs/architecture/share-network-architecture.md) | 跨端身份、能力、授权、任务、更新和协议权威(GoodBuddy 仓库) |
| [Desktop Office 协同编辑](../../../../goodbuddy/docs/features/office-document-editing/README.md) | 文档 Tab、源文件回写、冲突和 AI 选区编辑(GoodBuddy 仓库) |
当前没有单独的 User Stories:管理员、能力发布者、审计员和普通成员场景已进入 PRD 与逻辑
设计。仓库已建立可运行的全控制台交互原型;它用于确认信息架构与交互,不代表生产服务已经
工作空间协作场景由用户故事追踪;管理员、能力发布者、审计员等既有场景继续由 PRD 与逻辑
设计覆盖。仓库已建立可运行的全控制台交互原型;它用于确认信息架构与交互,不代表生产服务已经
实现。当前边界和运行方法见[实施进度](./progress.md)与
[`README.md`](../../../README.md)。跨仓库文档链接要求将 `goodbuddy` 与
`goodbuddy-shareserver` 克隆到同一级目录;服务构建和运行不依赖桌面端仓库。
@@ -32,4 +33,4 @@ ShareServer 不属于 Electron Main,不负责桌面端局域网发现,也不
- **能力发布者**:在获准范围内发布设备能力或签名能力包。
- **审批人**:处理调用、发布、更新和联邦申请。
- **审计员**:只读查看和导出获准范围内的审计事件。
- **成员**:主要通过 GoodBuddy Desktop 使用能力,只在 Web 端处理个人设备和审批。
- **成员**:主要通过 GoodBuddy Desktop 协作和使用能力;在 Web 端管理个人设备、授权、申请及自己发布的数字人。
+106 -2
View File
@@ -5,8 +5,8 @@
| 项目 | 内容 |
| --- | --- |
| 状态 | 设计中 |
| 版本 | 0.3 |
| 日期 | 2026-09-09 |
| 版本 | 0.5 |
| 日期 | 2026-09-28 |
| 关联 PRD | [ShareServer PRD](./prd.md) |
| 共享规则 | [共享网络总体设计](../../../../goodbuddy/docs/architecture/share-network-architecture.md) |
@@ -61,6 +61,9 @@ ShareServer 只路由调用方冻结的 provider,不在离线、繁忙或失
事实,不能读取原始条目、分区内容、来源正文或检索结果。新增可读分区按权限扩大重新审批,
移除分区可以立即收紧。
以上限制针对本地记忆授权。用户另行选定并发布的知识、监督回顾副本按 FR-18 计算内容授权,
不使底层记忆分区可浏览;回答使用与原文读取是不同动作。
### INV-11 服务端检查点不等于源文件已保存
ONLYOFFICE 保存只产生 ShareServer 权威的 `DocumentCheckpoint`。只有 Desktop 或远程
@@ -156,6 +159,63 @@ checkpoint_available -> dirty
`offline` 不删除已确认 checkpoint。`outcome_unknown` 必须通过对象摘要和 callback 幂等键查询
同一次保存,不能自动创建另一份 checkpoint。
### 2.8 工作空间共享与资料同步
本地 Workspace 始终具有本地身份,共享关联记录目标组织和服务端身份。每个关联单独记录:
| 维度 | 状态与含义 |
| --- | --- |
| 共享生命周期 | `local_only -> enable_pending -> shared -> revoke_pending -> revoked`;开启失败回到 local_only 并保留原因 |
| 资料同步策略 | `on_demand` 或 `periodic`;暂停只暂停资料自动交换,不影响实时消息或撤销共享 |
| 消息连接 | 实时订阅及断线补收独立运行,消息与处理状态不等待资料周期 |
| 连接 | online / offline,与共享授权独立 |
| 同步结果 | idle / syncing / partial / failed / conflict,附最近成功时间及未确认变更 |
`revoked` 表示该共享关联终止,本地 Workspace 仍可使用。重新开启使用新关联。撤销请求结果
未知时保持 `revoke_pending` 并查询同一请求;权限被拒不能伪装为已撤销。已有共享关联被
服务端移除或组织停用时,由服务端状态收紧访问,本地的 shared 缓存不产生权威。
开启共享仅发布确认清单中的内容。新增成员可以访问当前被授予的空间历史,管理者添加前
必须确认该范围;不能通过添加成员突破来源授权。首期同一关联不可跨组织移动。
同步以逐项确认推进进度;冲突保留本地和远端版本,权限与撤销始终采用服务端当前权威。
发起关闭后本端停止普通上传,联网先提交关闭;服务端已确认的关闭阻止旧队列重放。
已经发送的历史不能通过暂停、删除本地队列或关闭共享改写为从未发送。
### 2.9 消息交付与处理
消息交付:`local_pending -> submitting -> accepted | rejected`。local_pending 显示“待发送”。确认丢失时保留未确认状态,
按稳定消息身份重试或查询;不能先生成第二个身份。accepted 仅表示服务端接收,不表示成员
设备收到、已读或已执行。公开回复同样先经历交付确认。未确认 AI 草稿存入本人私有范围,
其正文不进入空间实时流、公开历史和普通成员的离线补收;只可向本人发送私有草稿就绪通知。
普通消息和提及只有交付状态;只有 accepted 且明确携带“请求处理”及处理人身份的消息进入处理流程:
```text
本人模式 / 要求本人确认 -> awaiting_human -> human_handling -> replied_by_human
分身先回复 -> ai_running -> replied_by_ai
分身起草 -> ai_running -> draft_ready -> replied_by_human_confirmed
ai_running / draft_ready / replied_by_ai -> human_handling
AI 不可用 / 失败 -> awaiting_human(保留原因)
```
状态描述一次定向请求的当前处理事实。接手保留此前回复,不覆盖作者;本次回复完成后继续
讨论可创建关联回复或新请求。空间撤销、成员移除或资料授权失效可将未完成处理阻止在当前
状态并显示原因,不能标记为已完成。已发布事实保留,新的读取仍需有效授权。
开启尚未确认时也可 `enable_pending -> revoke_pending`。关闭请求绑定原开启操作身份,服务端
必须串行确定开启是否已生效:已生效则撤销同一关联,未生效则阻止该开启操作随后生效。
仅在确认不存在仍有效的关联后显示关闭完成。重连时此检查优先于普通上传。
成员范围扩大时检查既有共享正文、附件及上下文快照的来源授权。若新增受众不被允许,拒绝
该成员变更,保留原成员集合;管理者先补齐授权或移除不兼容共享内容,再提交变更。
资料定期模式在客户端运行且联网时按周期交换资料增量。显式提交的资料按需同步在重连后继续重试,
暂停资料自动同步不取消此类显式请求;未提交本地草稿不得随重连上传。
实时消息由服务端接受后通知在线成员;客户端按服务端进度补收消息、回复与状态事件,按稳定
身份及状态版本去重合并,旧事件不能回退处理状态。断线恢复可以重复传输事件,不得重新创建
处理任务。消息附带内容准备失败时保持待发送并显示原因,不先创建缺少上下文的处理请求。
## 3. 授权计算顺序
1. 验证请求身份、会话 audience 和 organization。
@@ -169,6 +229,10 @@ checkpoint_available -> dirty
目录查询可以预计算“可能可用”,但不能替代第 1 至 8 步。
此顺序用于能力调用。空间消息先检查组织、空间、发送者、提及身份、处理人(如有)和附带内容的共享范围,
普通发言及提及无需 Publication;只有显式处理请求选择分身执行时再走上述能力授权。空间访问授权不能代替
Publication/Grant,本人草稿确认也不能绕过当前访问权限。
## 4. 组织角色边界
| 动作 | 系统管理员 | 组织管理员 | 发布者 | 审批人 | 审计员 | 成员 |
@@ -200,6 +264,42 @@ checkpoint_available -> dirty
中继容量不足只产生排队或资源错误,不切换 provider,不改变是否允许中继。
### 5.1 成员消息的处理决定
普通消息与提及不创建处理任务。按以下顺序决定一次已接收的显式处理请求的处理方式:
1. 校验当前空间、成员和附带内容权限。无权时阻止处理,不改投其他人。
2. “需要本人确认”直接进入 awaiting_human。
3. 使用服务端接受处理请求时的成员空间偏好版本;未配置时为本人处理。
4. 分身方式解析接收者明确绑定的 Publication/version/provider,并记录任务关联;不可用时转本人待处理。
5. 执行前和发布前重验权限及处理版本。普通偏好更新不改写在途任务,撤销或本人接手可以阻止后续发布。
消息身份、AI 执行身份和公开回复身份分别幂等。多个设备不能对同一待处理版本创建重复任务。
本人接手以服务端原子变更处理版本为生效点;其后旧版本 AI 结果只可作为不公开的结果保留,
不得自动发送。若 AI 回复先发布,则保留该回复,本人可在其后补充。
确认草稿绑定草稿版本、确认者和最终正文摘要。其他成员和普通审计不能读取未发送草稿,
政策审批产生的 Grant 不构成本人对该正文的确认。
确认操作原子消费待确认的处理版本。相同操作重试返回既有结果;不同设备对同一草稿提交不同
正文时仅首个成功确认生效,后续返回版本冲突并保留客户端编辑。本人接手也推进处理版本,
其后迟到的旧草稿确认不能另建回复绕过冲突。
### 5.2 来源、受众与动作
可用内容为来源授权、当前入口受众、动作、空间权限、组织策略及 Provider 本地权限的交集。
个人外部入口与组织空间独立授权;同一分身身份复用不复制其所有来源权限。
| 回顾用于回答 | 回顾原文可读 | 行为 |
| --- | --- | --- |
| 否 | 否 | 不向该入口提供回顾 |
| 是 | 否 | 分身可参考,访客不能取得原文 |
| 否 | 是 | 访客可阅读,分身不得将其作为回答来源 |
| 是 | 是 | 两种动作均需按各自授权检查 |
撤销影响新读取、检索和发布,不追溯删除已经发送的文字或已下载副本。同步内容快照不冻结
授权;发送后、执行前授权失效时,应阻止使用相应来源并说明原因,不能因为曾经发送就继续访问。
## 6. 更新策略优先级
```text
@@ -258,6 +358,10 @@ checkpoint_available -> dirty
| `FR-12` | 短期权威恢复规则和逻辑完整性;部署结构由技术设计定义 |
| `FR-13` | 注册、Publication、目录与授权不变量,provider 冻结及 `INV-10` |
| `FR-14` | `INV-11`、`INV-12`、DocumentEditSession、Office 保存与保留规则 |
| `FR-15` | 2.8 工作空间共享状态、组织与空间成员边界 |
| `FR-16` | 2.8 资料同步与消息连接分离;2.9 交付确认、实时更新与补收 |
| `FR-17` | 2.9 普通消息与处理请求分离;5.1 处理优先级与接手竞争 |
| `FR-18` | INV-10、5.2 来源、受众与动作交集 |
## 10. 逻辑完整性
+73 -5
View File
@@ -5,8 +5,8 @@
| 项目 | 内容 |
| --- | --- |
| 状态 | 设计中 |
| 版本 | 0.3 |
| 日期 | 2026-09-09 |
| 版本 | 0.6 |
| 日期 | 2026-09-28 |
| 适用产品 | 独立部署的 ShareServer 与 Web 管理控制台 |
| 关联架构 | [共享网络总体设计](../../../../goodbuddy/docs/architecture/share-network-architecture.md) |
| 关联文档 | [功能逻辑](./logic-design.md)、[管理界面](./ui-design.md)、[技术设计](./technical-design.md) |
@@ -27,6 +27,7 @@
6. ShareServer 可以只运行控制面,也可按部署策略提供数据中继和包存储。
7. 成员可以显式开放自己的数字人,获准的组织成员可通过 `@数字人` 发起有界协作任务。
8. 部署方可以启用 ONLYOFFICE Docs,使 Desktop 在多个文档 Tab 中编辑 DOCX、XLSX 和 PPTX。
9. 用户的本地工作空间可开启组织共享,使用实时消息协作、按策略同步资料;明确的处理请求由本人或其授权分身处理。
## 3. 产品范围
@@ -43,6 +44,8 @@
- 可选 Office 编辑服务、工作副本、保存检查点、编辑器启动和 ONLYOFFICE callback。
- 审计、用量、保留和导出。
- Web 管理控制台和成员自助页面。
- 工作空间的组织共享关联、成员访问、实时普通消息与线程、提及通知、处理请求和资料同步。
- 选定知识与监督回顾的共享授权,及数字分身对这些来源的复用。
### 3.2 不包含
@@ -50,7 +53,7 @@
- 发现未注册的局域网设备。
- 保存或分发用户 API Key、Cookie 和桌面登录状态。
- 默认查看任务正文、附件和模型输出。
- 上传、同步或浏览数字人所有者的本地个人、项目、会话和原始 Agent 记忆。
- 自动上传、同步或浏览数字人所有者的本地个人、项目、会话和原始 Agent 记忆;用户明确发布的知识与选定回顾按 FR-18 单独管理。
- 因为成员加入组织而自动开放其数字人、记忆、Runtime、工作区或工具。
- 未经提供者发布自动盘点设备能力。
- 自动信任同域邮箱、同一网络或其他 ShareServer。
@@ -58,6 +61,7 @@
- 替代 GoodBuddy 官方客户端、模型和 Agent 发布源。
- 自行实现 Office 渲染器,或把 ONLYOFFICE Document Server 打进 Desktop 安装包。
- 默认永久保存用户文档、选区、提示词或模型输出。
- 首期不含私聊、语音视频、已读回执、正在输入及分身输出逐字广播;空间协作按轻量 IM 范围实现。
## 4. 功能需求
@@ -150,6 +154,9 @@
### FR-13 数字人发布与组织内 `@` 调用
本节的 `@数字人` 指直接调用已发布能力。工作空间中的 `@成员` 按 FR-17 提及并通知真实成员,
不要求其发布数字人;只有显式处理请求且成员授权分身处理时才创建能力任务,两类选择器使用明确类型。
- “数字人”是运行在成员设备或组织服务节点上的可调用 Agent Publication。一个成员可以按
不同用途发布零个或多个数字人,每个数字人独立配置 handle、能力、提供者和记忆来源;
加入组织、注册设备或创建本地 Agent 都不会自动发布。
@@ -168,7 +175,8 @@
允许的记忆。
- 记忆检索和上下文组装始终在 Provider 本地完成。ShareServer 只保存 Publication 的分区
种类、经过验证的本地关联 ID 摘要和是否使用专属记忆等策略元数据;原始记忆、分区内容、
来源正文和检索结果不上传 ShareServer,也不向调用者展示。
来源正文和检索结果不通过此记忆授权上传 ShareServer,也不向调用者展示。用户另行发布的
知识或选定回顾按 FR-18 授权,不能据此浏览原始记忆库。
- 数字人输出可能受到其 Agent 记忆影响,因此发布页和调用确认必须显示“使用数字人专属记忆”
这一事实及允许的数据敏感度。Restricted 记忆不得用于远端调用;暂停或撤销数字人后停止
新调用,但保留历史任务和有界审计。
@@ -211,8 +219,67 @@
- 会话关闭、撤销或到期后停止新读取、保存和 AI 操作,并按组织保留策略清理工作副本、
checkpoint、Cookie 和对象令牌。
### FR-15 工作空间与共享属性
- 本地工作空间由 Desktop 创建和管理,无需加入组织;可创建多个,默认共享关闭。
- 开启共享保留本地空间身份,建立与一个组织的共享关联。操作者必须是有效组织成员并具备
空间管理权限;明确选择成员、资料与已有历史的共享范围,组织成员不默认取得空间权限。
- 成员读取、发送、同步、管理空间和分身处理分别授权,组织管理员不自动取得正文访问权限。
- 共享生效和关闭以服务端确认结果为准;关闭不删除本地空间。服务端撤销后拒绝新访问,
对已下载副本不作远程擦除承诺。重新开启创建新共享关联,不自动重放旧关联的待发送内容。
- 成员被移除后停止其新访问和发布;所有者离开组织时关闭共享,首期不自动转移所有权。
### FR-16 实时消息与资料同步
- 共享开启且联网时,空间消息、公开回复和处理状态实时更新;支持断线补收和历史分页,
不依赖资料同步周期。成员与权限变化在服务端立即生效,并及时通知在线客户端。
- 知识库、共享文件及资料索引按空间选择定期或按需同步,不等同于本地目录或全部历史镜像。
提供立即同步资料、暂停资料自动同步、最近成功时间、待同步数量和失败原因。
- 暂停资料自动同步不影响消息收发、提及通知、状态更新和离线补收,也不撤销服务端已有访问。
- 离线消息保留“待发送”。只有服务端持久接收后显示“已发送”,不能据此推断已读、已执行或本人已确认。
消息明确附带的资料需准备完成并通过授权检查后才能接受,不等待整库同步。
- 重试使用稳定操作身份;服务端重复接收不产生重复消息、回复或分身任务。部分成功逐项确认,
冲突保留双方版本供处理,不以静默覆盖扩大权限或丢失修改。
- 离线关闭共享先停止本端普通上传,显示“待关闭共享”;恢复连接先处理撤销,再处理其他队列。
服务端权限收紧即时生效,不等待客户端下一次定期同步。
### FR-17 成员消息与处理方式
- 每个共享空间对应一个群会话,允许不带提及的普通消息和回复线程。`@成员` 绑定组织、空间
和稳定成员身份,只产生提及通知,不能因为接收者开了分身自动处理就触发执行。
- “请求处理”是独立的显式意图,首期每个请求选择一个处理人;提及列表不等同于处理人。
自由文本姓名不能代替身份选择,也不因分身不可用而切换处理人。
- 发送前展示提及成员、处理人(如有)、所选上下文及线程受众。首期线程对获准空间成员可见,发送者必须具备
向该受众共享附带资料的权限;上下文冻结发送时的版本与内容。
- 处理人按空间为显式处理请求选择本人处理、分身先回复或分身起草;默认本人处理。分身方式明确绑定一个
已授权 Publication,其身份可复用,但空间授权与个人访客入口分别管理。
- “需要本人确认”优先进入本人待处理,不自动发出分身回复。普通偏好变更影响后续接受的
处理请求;撤销和权限收紧在任务执行与结果发布时重新检查。普通消息没有处理状态。
- 草稿仅本人及获准执行该任务的分身可访问,本人可编辑并确认发送。AI 输出、本人直接回复和
本人确认的草稿在作者标记中区分;政策审批不代替本人对草稿的确认。
- 草稿正文不进入空间广播、公开历史或普通成员的离线补收;本人确认后的最终回复才进入实时消息流。
- 本人可接手同一线程;保留已发布回复,接手生效后的迟到 AI 结果不得自动发布。重复确认只
产生一条回复。分身离线或失败则保留给同一成员处理,并解释原因。
### FR-18 共享知识与个人分身来源
- 知识库可独立共享,也可被空间和分身引用。复用来源身份与版本,各入口按来源授权、受众、
使用动作及组织策略求交集;关联资料不自动扩大权限。
- 用户可设置分身身份、表达提示词和选定知识来源。提示词不授予权限,开启分身不共享全部
信息流;既有本地 Agent 记忆仍遵循 FR-13 的执行边界。
- 选定监督回顾以明确版本的共享内容管理,“用于回答”和“查看原文”分别授权,支持仅回答、
仅阅读、两者均允许或均关闭。新回顾不自动加入已选范围。
- 明确授权共享的正文副本可进入共享内容存储;这不授予访问底层个人记忆库的能力。
关闭某一分身入口不撤销知识库其他有效授权;撤销来源授权阻止依赖该授权的新读取和回答,
已发出的内容按历史保留规则处理。
- 首期服务端范围为已登录组织成员。个人“持有链接”匿名访问仅为 demo 概念,真实匿名发布、
身份及滥用控制需另行设计,不以该 demo 改变第 3.2 节的匿名能力调用排除项。
## 5. 产品验收
工作空间、同步、成员消息和资料共享按 [用户故事](./user-stories.md) 的 `US-WS-01` 至
`US-AV-01` 验收,覆盖正常、离线、撤销、重复提交、受众限制和接手竞争场景。
- 新实例可完成初始化、登录、创建组织、邀请成员和注册真实桌面设备。
- 两个不同组织不能通过 API、搜索、计数、错误或审计结果读取对方数据。
- 设备只有发布能力后才进入获准目录,撤销后不能接收新任务。
@@ -226,7 +293,8 @@
provider。
- 数字人调用可以使用所有者在发布时勾选的 Agent、Global、指定 Project 或指定 Automation
分区,但不能开放 Conversation 和 Run;ShareServer 和调用者不能读取原始记忆、未选择的
本地分区或记忆来源正文。
本地分区或通过该记忆权限读取来源正文。FR-18 另行发布的共享副本按自身授权读取,不能由
此取得底层记忆分区的浏览权限。
- 启用 Office 服务后,Windows、macOS 和 Linux Desktop 可从同一 ShareServer 分别打开、编辑
和保存 DOCX、XLSX、PPTX;多个会话不会串文件、选区、checkpoint 或权限。
- 文件 URL、callback token 和编辑器启动授权过期或撤销后不可重用;保存失败、源文件冲突或
+27
View File
@@ -93,6 +93,8 @@ npm run build # typecheck + Vite Web + server TypeScript passed
6. Package、可选中继、联邦、保留、导出、备份恢复和真实 Desktop 端到端验证。
7. `FR-14` Office 会话、对象存储、ONLYOFFICE、callback、checkpoint、策略、管理界面和三平台
Desktop 文档 Tab 端到端验证。
8. `FR-15` 至 `FR-18` 本地空间共享关联、资料定期/按需同步、实时消息、显式处理请求和共享来源授权;
对照用户故事完成服务端契约与 Desktop 集成,不能把静态 demo 当作真实消息服务。
当前尚未满足 PRD 的产品验收,也未进行真实 Desktop/ShareServer 集成测试。
@@ -100,3 +102,28 @@ npm run build # typecheck + Vite Web + server TypeScript passed
目录、发布管理、提及选择器或 Agent 记忆边界展示,因此这些能力仍属于剩余生产与原型工作,
不能由现有通用能力目录或原型操作确认替代。
2026-09-09 PRD `FR-14` 新增 Office 编辑服务;当前原型同样没有对应导航、配置或服务端路径。
## 2026-09-28:客户端协作 US 与逻辑审查
- 新增 `user-stories.md`,补充本地工作空间、组织共享、同步、成员消息、分身处理和来源授权的
验收场景。PRD 新增 FR-15 至 FR-18,逻辑设计补充分离的共享、同步、交付与处理状态。
- 修订 `@数字人` 与 `@成员` 的范围,保留直接能力调用,同时定义发给成员后由本人或已授权
分身处理的流程。补充本人确认优先、草稿可见范围和接手时的并发检查。
- `docs/demo/client` 已有个人共享、设置及组织工作空间的本地交互演示;只使用浏览器存储和
固定回复,没有身份校验、服务端持久化、同步或真实模型执行。本轮仅修改设计文档。
- demo 仍需按最新 US 调整:统一创建本地空间及共享属性,增加同步与撤销状态;回顾原文
阅读仍依赖回答授权,尚未覆盖 US 要求的“仅阅读”组合;空间分身尚未绑定真实 Publication。
- 默认同步周期、容量和保留期限待定;匿名分享仍为概念演示,不纳入首期已登录组织协作验收。
### 同日补充:轻量 IM 与实时组件提案
- FR-16 区分实时消息与资料同步:普通消息、公开回复及处理状态实时更新,资料定期/按需同步。
暂停资料自动同步不停止消息收发、通知或断线补收。
- FR-17 区分提及和请求处理。`@成员` 只通知;显式选择处理人后才进入本人/分身处理流程。
新增 US-MSG-04,覆盖普通消息、线程、实时更新、确认丢失、离线补收和旧连接撤销。
- 技术设计记录 ShareServer + PostgreSQL + Centrifugo 为优先验证提案,并比较 Matrix、OpenIM
和 Socket.IO;没有确定组件或版本,没有安装依赖,也没有运行候选服务验证。
- 补充消息与 outbox 同事务、至少一次投递、数据库补收、草稿私有事件及权限撤销的设计要求。
这些是待实现契约,不是现有 API 或数据库能力。
- 本轮仅更新文档。静态 demo 仍要求选择接收人并自动进入处理流程,尚未支持普通消息、提及与
请求处理分离、真实实时推送及离线补收;需在后续原型迭代中对齐。
+97 -5
View File
@@ -5,8 +5,8 @@
| 项目 | 内容 |
| --- | --- |
| 状态 | 设计中 |
| 版本 | 0.3 |
| 日期 | 2026-09-09 |
| 版本 | 0.5 |
| 日期 | 2026-09-28 |
| 关联 PRD | [ShareServer PRD](./prd.md) |
| 功能逻辑 | [功能逻辑设计](./logic-design.md) |
| 共享协议 | [共享网络总体设计](../../../../goodbuddy/docs/architecture/share-network-architecture.md) |
@@ -62,12 +62,13 @@ ShareServer 仍不得导入 Electron 的 `src/shared`,也不得依赖相邻桌
## 2. 技术选型约束
- 使用与仓库一致的 TypeScript/Node.js 工具链,具体 HTTP 框架在实现前根据维护性选择。
- PostgreSQL 是组织、策略、任务元数据和审计的权威数据库。
- PostgreSQL 是组织、策略、任务元数据和审计的权威数据库;工作空间消息、处理状态及可靠投递记录
也以数据库为准,具体业务表与受控正文存储在协作模块实现时增加。
- Package、可选中继暂存和 Office 工作副本使用 S3 兼容对象存储;不开启对应功能时不要求
对象存储,启用 Office 时对象存储是必需依赖。
- Web 控制台与 API 同源部署,采用严格 CSP,不在浏览器保存设备凭据。
- 单实例部署可完整工作;水平扩展时再引入共享事件/连接路由组件,不为未验证规模预设复杂
分布式协议。
- 单实例部署可完整工作,不为未验证规模预设复杂分布式协议。工作空间实时投递优先评估独立
实时组件,与是否水平扩展分开决策;第 18 节记录候选方案,尚未确定采用。
## 3. 服务模块
@@ -80,6 +81,8 @@ ShareServer 仍不得导入 Electron 的 `src/shared`,也不得依赖相邻桌
| `policies` | Grant、强制策略和确定性授权计算 |
| `approvals` | 内容绑定申请、决定、过期和通知 |
| `tasks` | task/attempt、provider 路由、状态和取消 |
| `workspaces`(待实现) | 共享关联、成员访问、资料同步、空间消息、线程与处理请求 |
| `delivery`(待实现) | 事务投递记录、实时组件适配、去重、离线补收与历史查询 |
| `relay` | 可选有界数据中继,不解释能力正文 |
| `packages` | 不可变对象、签名目录、通道和撤销 |
| `office` | 编辑会话、工作副本、ONLYOFFICE 配置、callback、checkpoint、保留和健康 |
@@ -163,6 +166,8 @@ revocations
`project` 和 `automation` 种类及 Provider 验证后的本地关联 ID 摘要。Provider 执行时按该
revision 在本地重新解析分区并检索;服务端不接收记忆文本、来源正文、Embedding 或检索
结果。新增来源是权限扩大,移除来源是单向收紧。
- 上述记忆限制针对 Provider 私有记忆授权。FR-18 明确发布的知识或回顾副本走独立共享内容
授权与存储,不借 Publication 内存策略上传私有记忆或开放记忆浏览。
- `document_edit_sessions` 只保存组织、用户、设备、公开来源 ID、显示名、格式、对象键、摘要、
访问模式、策略 revision、状态和保留期限,不保存 Desktop 绝对路径。
- `document_checkpoints` 以 session 内单调 revision 保存对象键、大小、SHA-256、callback 幂等键
@@ -176,6 +181,8 @@ revocations
- 设备在线状态允许短暂最终一致;身份、撤销、授权和任务接受必须走权威存储。
- 重复注册挑战、联邦 offer 和 task create 使用唯一幂等键。
- 无法确认 provider 副作用时只更新 `outcome_unknown`,不靠消息队列自动重放。
- 工作空间消息/状态与 outbox 待投递事件在同一数据库事务提交,提交成功才确认已发送。
投递重试与客户端补收只传播已有事件,不重建 Task 或重放 Provider 副作用。
- Office callback 的临时对象、checkpoint 行和审计事件在同一提交边界发布。数据库提交失败时
临时对象保持不可下载并由清理任务删除;重复 callback 依赖会话内幂等键返回已有 revision。
@@ -377,3 +384,88 @@ Desktop 或部署脚本当作真实身份、策略、任务或审计 API。下
初始化、本地身份、PostgreSQL 组织作用域和事务审计开始,不在 fixture 上叠加生产状态。
当前原型没有 Office 导航、配置、会话 API、对象存储、ONLYOFFICE、callback 或 Desktop 文档
Tab 集成;上述章节仅定义目标设计。
## 17. 工作空间协作的待实现契约
本节记录 FR-15 至 FR-18 对服务端的最低契约要求,尚未确定具体 API 路径、数据库迁移和协议
包版本;跨产品网络架构继续以关联总体设计为准,进入开发前须完成 Desktop/Server 联合评审。
| 对象或契约 | 服务端要求 |
| --- | --- |
| 共享关联 | 本地 workspace ID 与服务端共享 ID 分开;绑定 organization、owner、成员和 revision;本地未共享空间不注册到服务端 |
| 资料同步请求 | 携带共享关联、客户端操作 ID、基准 revision 和增量游标;逐项返回接受、拒绝或冲突,不使用客户端时间决定权限 |
| 空间消息 | 保存发送者、message ID、thread ID、提及身份、上下文版本与受众;普通消息不创建处理任务 |
| 处理请求 | 显式意图关联一条消息,保存唯一处理人、本人确认标记和接受时的偏好版本;提及与处理人分开 |
| 处理偏好 | 按空间和成员保存模式、分身绑定、来源授权引用及 revision;只应用于处理请求,绑定 Publication 沿用能力权限检查 |
| 消息事件与历史 | 稳定事件身份、服务端顺序及对象 revision;支持增量补收和历史分页,资料暂停不停止消息事件 |
| 执行与回复 | 对消息处理版本、任务关联和回复 ID 建立幂等约束;接手与发布以同一权威版本比较,防止迟到结果发布 |
| 回顾与知识 | 引用来源身份、版本、受众和动作授权;明确共享的内容副本存入受控内容存储,原始私有分区不入库 |
ShareServer 必须持久化获准共享的消息和资料,才能提供消息离线补收及资料同步。此功能使用受控
业务内容存储,不能复用普通审计或只转发不持久化的 task relay;仅部署控制面的实例应报告
空间协作不可用,不假装接受消息。具体正文存储介质、额度、加密与保留配置在协议实现前确定。
管理员仅凭管理身份不能通过内容接口读取正文,备份策略须覆盖启用后的共享业务内容。
授权撤销、成员状态与同步接收使用服务端当前事实。在同一事务中接受一次操作并记录幂等结果,
重试只能获得同一结果;冲突不能静默覆盖成员名单、内容范围或回复。分身运行环境仍在
Provider,工作空间消息不使 ShareServer 自动成为模型运行节点。草稿确认须原子消费待确认的
处理版本;同操作重试返回原结果,不同正文的重复确认或接手后的旧确认返回版本冲突。
验收须覆盖 US 中的离线发送、确认丢失、部分成功、跨空间隔离、移除成员、关闭共享后旧队列、
同时接手与发布、不同正文的重复确认、待开启时关闭共享、新成员读取历史的限制,以及回顾
两种权限的单独撤销。静态 demo 的 localStorage 和
角色切换不能作为这些服务端契约的实现。
## 18. 实时通信组件选型提案
状态:待验证,未定版、未安装组件、未新增运行依赖。优先评估 **ShareServer + PostgreSQL +
Centrifugo**。此处是组件职责比较,不代表已核验当前版本、许可证、部署成本或生产可靠性;
选型须以固定版本的文档核对与端到端试验为依据。
| 候选 | 采用范围 | 需要 GoodBuddy 完成的部分 | 当前判断 |
| --- | --- | --- | --- |
| [Centrifugo](https://centrifugal.dev/) | 实时频道、连接及恢复相关机制 | 权威消息存储、历史、权限、成员通知和协作处理 | 优先验证,可保留 ShareServer 业务模型 |
| [Matrix / Synapse](https://matrix.org/) | 房间、成员、消息及同步体系 | 身份、空间与房间映射、界面及分身协作扩展 | 采用完整通信体系的备选 |
| [OpenIM](https://openim.io/) | IM 服务与客户端 SDK | 组织和空间集成、协作处理及来源授权 | IM 范围扩大时比较 |
| [Socket.IO](https://socket.io/) | 连接、事件及房间广播 | 持久化、可靠交付、历史、补收和业务权限 | 可作传输层,不按完整 IM 服务评估 |
### 18.1 职责与投递流程
ShareServer 接收客户端消息并执行组织、空间、受众和附件授权检查。Centrifugo 候选方案只负责
实时传输,不能由客户端绕过 ShareServer 直接广播一条被视为权威的消息。
1. 客户端携带稳定操作 ID 提交普通消息、线程回复或显式处理请求;附件版本先准备完成。
2. ShareServer 在一个事务内写入消息/状态、必要的处理请求及 outbox 事件,提交后返回接受结果。
3. 后台投递器读取 outbox,将已提交事件发布到实时组件;成功后记录投递结果。发布后未能记录
成功可能导致重复投递,因此采用至少一次投递,客户端按事件身份与对象版本合并。
4. 在线客户端收到更新通知后,按授权读取新增消息和状态。断线后按服务端游标补收;组件的
短期历史只作加速,不能替代数据库中的权威消息历史。
5. 仅显式处理请求的执行调度可创建分身 Task,创建受幂等约束;历史重放、事件重投或成员多设备
在线不会重复启动执行。
实时组件故障时,只要数据库和业务接收条件正常,ShareServer 可确认消息已保存,并说明实时
投递延迟;outbox 恢复后继续投递。数据库事务失败则不确认已发送。过期历史或游标失效时返回
明确的重新同步路径,在现有保留范围内重建进度,不伪造完整历史。
### 18.2 访问与事件范围
首期优先使用不含正文、草稿或敏感摘要的更新通知,具体订阅标识由服务端授权生成;客户端
经当前授权的内容 API 拉取正文。移除成员或关闭共享后,内容、历史及补收接口立即拒绝新
读取,并撤销其订阅/连接资格,不能仅依赖长效订阅凭据自然过期。
频道按组织、空间及受众隔离。AI 草稿使用独立私有范围,公开频道、历史、outbox 公开事件和
普通成员补收中均不含草稿正文。本人确认后的最终回复作为新的公开消息提交。已在撤销前取得
的正文无法通过断开连接擦除;旧通知也不能帮助客户端绕过当前内容授权。
### 18.3 验证与定版条件
- 两个真实客户端验证普通消息、提及、线程回复、请求处理和状态更新;暂停资料自动同步时消息仍及时到达。
- 数据库提交后、实时发布前终止投递器,恢复后消息不丢失;发布后确认丢失允许重复事件但不重复显示或执行。
- 多设备断线重连验证顺序、去重、历史分页和游标恢复;组件历史不可用时可从数据库补收。
- 移除成员后旧连接不能获取新正文,旧 token 不能读取历史或补收;验证跨组织、跨空间与草稿隔离。
- 本人接手和分身完成竞争、两个设备确认不同草稿时,只发布规则允许的结果。
- 核验候选固定版本的许可证、维护状态、SDK 与 Desktop 兼容性,以及部署、备份恢复和资源需求。
上述验证通过后再确定组件、版本、接口与部署拓扑。消息目标延迟、规模及保留期限仍待产品和
部署评审;当前不作吞吐或实时性数值承诺。运维需记录 outbox 积压、投递重试及补收失败,备份
消息与事件进度;连接、短期凭据不恢复。新增组件的配置、凭据和健康检查进入部署设计后才能上线。
+34 -4
View File
@@ -5,8 +5,8 @@
| 项目 | 内容 |
| --- | --- |
| 状态 | 设计中 |
| 版本 | 0.3 |
| 日期 | 2026-09-09 |
| 版本 | 0.5 |
| 日期 | 2026-09-28 |
| 关联 PRD | [ShareServer PRD](./prd.md) |
| 功能逻辑 | [功能逻辑设计](./logic-design.md) |
@@ -16,7 +16,7 @@ ShareServer 是独立 Web 产品,不直接复用 Electron 页面壳层实现
## 1. 使用对象
- 普通成员主要在 GoodBuddy Desktop 使用能力;Web 端只处理自己的设备、申请和授权。
- 普通成员主要在 GoodBuddy Desktop 协作和使用能力;Web 端管理自己的设备、申请、授权和已发布数字人。
- 组织管理员使用完整组织控制台。
- 系统管理员使用与组织控制台分开的实例管理范围。
- 审计员进入只读视图,不显示可变更控件。
@@ -117,10 +117,14 @@ GPU-Worker-02 AI Team Linux 离线 8 2 小时前
检索,调用者和 ShareServer 看不到内容;新增分区在保存前明确标为权限扩大,移除分区明确
标为立即收紧。
组织会话和任务输入区使用带搜索的显式选择器插入 `@数字人`,不能只靠自由文本猜测 handle。
直接能力调用输入区使用带搜索的显式选择器插入 `@数字人`,不能只靠自由文本猜测 handle。
选择后持续显示目标数字人;发送前确认实际接收者、执行位置、发送数据和授权。离线、无权或
需要审批的项保持可读并说明原因,但不能形成已发送调用。
工作空间的 `@成员` 是提及选择器,按第 17 节展示;“请求处理”单独选择处理人,不以分身在线状态限制本人收件。
上述本地记忆限制不阻止用户另行发布选定知识或回顾;内容入口按 FR-18 展示原文读取权限,
数字人目录仍不提供底层记忆浏览器。
## 7. 权限策略
策略页分“强制策略”和“Grant”。创建和编辑使用结构化表单:主体、能力、动作、输入、提供者、
@@ -223,3 +227,29 @@ shadcn/ui 的本地组件模式和 Radix 可访问 primitives,布局参考成
API,并持续标明不会写入生产数据,不能据此宣称真实审批、撤销、策略或任务操作已实现。
`FR-13` 新增的数字人目录、“我的数字人”和 `@` 选择器尚未进入当前原型。
`FR-14` 新增的 Office 编辑导航、配置、健康和会话管理也尚未进入当前原型。
## 17. Desktop 工作空间协作目标交互
本节补充 Desktop 与 ShareServer 交界处的用户交互,不改变独立 Web 控制台的页面壳层。
- 新建入口统一为“新建工作空间”,默认“仅本地”。开启“共享”后选择所属组织、空间成员和
内容范围;未加入组织时可引导加入,不阻塞本地创建。开启前提供历史内容预览。
- 空间列表允许多个本地及共享空间并存。标题附近展示共享状态、目标组织和消息连接状态;
资料面板展示最近成功同步时间、待同步数量和立即同步资料入口,失败可定位到条目。
- 资料定期/按需同步与“暂停资料自动同步”分别设置,不控制消息通道。“关闭共享”说明撤销访问的影响,离线操作保持
“待关闭共享”,不能以本地开关的外观冒充服务端确认。
- 默认输入框用于普通空间消息,支持回复线程和 `@成员` 提及,不要求处理人。提及仅通知,
单独开启“请求处理”后选择一个处理人,再显示“需要本人确认”。
- 选择器按当前空间过滤,姓名标记绑定稳定身份。发送区展示可见受众、上下文清单及内容预览;
无附带上下文也可提问。附件未就绪显示准备进度或失败,不能显示“已发送”。
- 消息即时出现,历史支持分页和断线补收。普通消息仅展示交付信息;处理请求同时展示交付和
处理信息,例如“待发送”或“已发送 · 待本人处理”。分身失败、处理人离线
和权限撤销分别说明,不能统一显示发送失败。
- 设置页按成员本人、空间保存“处理请求偏好”,不使用“收到 @ 时自动执行”的文案;分身模式选择明确的分身及来源授权。仅本人可以看到
自己的待确认草稿、编辑、确认和接手操作。实际产品禁止用 demo 的角色下拉框代替登录授权。
- 共享知识配置复用已有来源列表,授权预览说明目标受众。监督回顾的回答使用与原文阅读分别
设置;仅阅读场景不能被第二个开关或默认联动隐式改为允许回答。
`docs/demo/client` 是独立静态交互演示,已有成员消息与处理偏好;本地/共享统一创建、实时
消息、资料周期同步及撤销流程尚未实现。demo 仍把提及直接当作处理请求,需按本节调整;
其即时本地回复不是服务端送达或实时通信的验收证据。
+177
View File
@@ -0,0 +1,177 @@
# 工作空间共享与数字分身用户故事
日期:2026-09-28。状态:需求设计,尚未完成生产实现。
本文记录本轮客户端协作场景、验收用例和需求追踪。功能范围由 [PRD](./prd.md) 定义,
状态与优先级由 [功能逻辑](./logic-design.md) 定义;此前管理员、设备和联邦场景继续由 PRD 覆盖。
Office 服务需求见 PRD 的 `FR-14`;右侧 Agent 编辑、三类文件操作与选型验证由
[Office 用户故事](../../../../goodbuddy/docs/features/office-document-editing/user-stories.md) 的
`US-C1` 至 `US-C7`、`US-F1` 和 `US-F2` 定义。客户端 demo 的覆盖范围见 [实施进度](./progress.md)。
## US-WS-01 创建本地工作空间
作为用户,我希望创建多个工作空间,按主题整理资料和消息,再决定哪些空间需要共享。
关联:`FR-15`。
- 未登录组织时仍可创建两个本地空间,默认关闭共享,不向 ShareServer 注册或上传内容。
- 两个空间拥有独立的消息、资料关联和共享设置;改名不改变空间身份。
- 对已有空间开启共享时保留本地空间身份,不要求复制为另一类工作空间。
## US-WS-02 开启共享并选择成员
作为空间所有者,我希望把指定空间共享给组织内选定成员,明确共享范围。
关联:`FR-15`、`FR-5`。
- 未加入有效组织时,开启共享提示先加入组织,本地空间仍可使用。
- 选择一个组织、空间成员和共享内容后提交;服务端确认前显示“待开启共享”。组织成员不自动成为空间成员。
- 开启前可预览将共享的已有内容;未选择的本地历史、文件和个人记录不会被整体上传。
- 无权管理空间的成员不能修改成员名单或开启、关闭共享;组织管理员身份本身不授予正文读取权。
- 后续添加成员时重新检查历史正文、附件和上下文的受众授权。存在不兼容内容时拒绝扩大成员范围,
不让新成员通过历史同步或直接内容链接读取;管理者需先取得授权或移除相关共享内容再提交。
- 首期一个共享关联只属于一个组织;切换组织须关闭原关联后重新授权,不迁移原组织成员和协作内容。
## US-WS-03 资料定期与按需同步
作为空间成员,我希望按需或定期同步资料,并查看服务端对本地修改的接收结果。
关联:`FR-16`。
- 每个共享空间独立选择资料按需或定期同步;提供“立即同步资料”,定期模式显示周期和最近成功时间。
- 两个在线客户端开启资料定期同步后,甲提交的获准资料修改在乙下一次成功周期内出现,无需发送消息。
暂停资料自动同步后不再周期拉取资料;恢复后从已确认进度继续,离线错过周期不重复应用变更。
- 资料按需模式或暂停自动同步期间,在线成员仍实时收到消息、公开回复和处理状态,不等待资料周期。
- 同步部分失败时,已确认条目保持成功,未确认条目可重试;权限冲突不能显示为全部同步成功。
- 离线时明确提交的资料同步在重连后恢复同一次请求;未提交的本地资料不因消息重连而自动上传。
## US-WS-04 暂停资料同步与关闭共享
作为空间所有者,我希望控制更新频率,也能撤销空间访问,而不会误删本地空间。
关联:`FR-15`、`FR-16`。
- 暂停资料自动同步后,定期资料交换停止,服务端已有内容仍按原权限可访问;手动资料同步继续可用,
消息收发、通知、处理状态与离线补收不受影响。
- 关闭共享前显示对成员访问和未同步内容的影响。离线关闭显示“待关闭共享”,立即停止本端普通上传,但不宣称服务端已撤销。
- 联网后优先提交关闭请求;服务端确认后拒绝新读取、同步和回复发布。本地空间继续存在,未发送内容保留为本地内容。
- 关闭共享不会自动删除已下载副本;历史证据按保留策略处理。重新开启建立新共享关联,旧队列不能自动发送。
- 开启请求已被服务端接受但确认丢失时,用户仍可关闭。重连后用同一开启操作身份查明关联并撤销,
不能因本端还显示“待开启”就只删除本地开关,留下可访问的服务端空间。
## US-WS-05 成员离开与访问撤销
作为空间成员,我希望成员离开组织或空间后,相关数据和消息不会继续向其同步。
关联:`FR-15`、`FR-17`。
- 成员被移除后,即使旧客户端仍显示其姓名,发送与同步也会被服务端拒绝,不能改发给同名成员。
- 被移除者的旧凭据不能拉取新内容、提交离线消息、确认草稿或发布回复;待处理消息保留“接收者不可用”的原因。
- 已发布内容保留作者归属;撤销不能保证擦除对方已经保存的副本。
- 空间所有者离开组织时,服务端关闭共享关联,保留本地空间;首期不自动转移所有权。
## US-MSG-01 提及成员与请求处理
作为发起者,我希望提及成员参与讨论,需要他处理问题时再明确发起处理请求,并选择上下文。
关联:`FR-17`。
- 选择器只返回当前空间内获准联系的有效成员,重名时显示可区分身份。提及可用于通知,首期每个
明确的处理请求只有一个处理人;提及列表和处理人分别保存。
- 输入自由文本 `@姓名` 不能代替身份选择。“之前和 @张明 讨论过”只产生提及通知,即使张明
开启分身先处理,也不创建任务、调用模型或产生待处理事项。
- 开启“请求处理”并选择处理人后才进入本人/分身处理流程;未选择处理人时拒绝提交处理请求。
未发布数字分身的成员仍可接收本人处理请求。
- 发送前显示提及成员、处理人(如有)、正文、附带清单和线程可见范围;空间线程对获准空间成员可见。
- 选择某条消息或文档后可预览具体内容;发送保存版本与内容快照,原文变化不默默改写已发送上下文。
- 附件发送前校验整个线程可见范围的授权,不能借 `@` 转发无权共享的资料;个人对话和监督记录不自动附带。
- 明确附带的资料版本须先上传完成并通过授权校验,服务端才确认消息已发送;准备失败时保留待发送内容,
不让处理人收到缺少必要附件的请求,也不连带触发整个知识库同步。
## US-MSG-02 设置分身处理方式
作为处理人,我希望按空间设置处理请求的本人处理、分身先回复或分身起草方式,并保留回复控制权。
关联:`FR-17`、`FR-13`。
- 新空间默认本人处理。选择分身方式时明确关联本人一个已授权分身及其可用来源;个人访客入口开关与空间授权分别设置。
- 处理请求要求“本人确认”时进入本人待处理,不自动发布分身回复;修改普通偏好只影响后续被服务端接收的处理请求。
- 分身先回复的消息明确标记 AI 身份;分身起草的文本只对接收者本人及获准执行该任务的分身可见,不进入公开线程或普通审计正文。
- 其他成员的实时事件、消息历史和离线补收中都不含草稿正文;本人确认后仅广播获准发布的最终版本。
- 分身离线、未授权或执行失败时显示原因,保留给同一成员处理;不能悄悄改用其他分身或自动扩展资料范围。
- 接收者可暂停空间内分身处理;执行中权限收紧或撤销在下一执行、发布边界复核,不能依赖旧偏好继续发布。
## US-MSG-03 确认草稿和本人接手
作为接收者,我希望编辑草稿后回复,或者接手分身正在处理的消息。
关联:`FR-17`。
- 本人编辑并确认后发布的是该次确认的文本版本,公开线程标记“本人确认发送”;授权审批通过不能代替此确认。
- 本人接手后保留已发布的分身回复,后续回复标记本人身份。
- 本人接手与分身完成同时发生时,以服务端处理状态版本决定先后;接手生效后的迟到结果不能自动发布。
- 多设备重复确认或重复同步同一回复只生成一条可见回复;权限已撤销时拒绝发布并说明原因。
- 两台设备提交同一草稿的不同编辑时,首个成功确认生效,另一个返回版本冲突并保留本地编辑;
本人接手后才到达的旧草稿确认被拒绝,不因使用不同回复 ID 而多发一条。
## US-MSG-04 实时讨论与离线补收
作为空间成员,我希望直接发布普通消息、在线讨论,并在重连后补齐遗漏的消息与回复。
关联:`FR-16`、`FR-17`。
- 一个共享空间对应一个群会话,支持普通消息和回复线程。不提及任何人也可发送消息,不自动进入处理流程。
- 甲发送普通消息,在线的乙无需手动刷新或等待资料同步周期即可看到;同样验证公开回复和处理状态更新。
- 离线发送显示“待发送”,服务端持久接收后显示“已发送”。已发送不表示已读、已执行或本人确认。
- 服务端已接收但确认丢失时,重试保持同一消息身份;多个设备和推送重复不会产生重复消息或重复 AI 执行。
- 断线后补收消息、回复及处理状态;可分页查阅历史,实时事件与历史重叠时按稳定身份及版本合并,
不因乱序事件使“本人已接手”退回“分身处理中”。
- 实时组件短暂故障不丢弃已被服务端接收的消息,恢复后能继续投递;补收事件不能重新执行分身任务。
- 成员被移除后,旧实时连接不能继续取得新正文,历史与补收接口同时拒绝;已经取得的副本按既有规则处理。
- 首期验收限于空间群会话,不提供私聊、音视频、已读回执、正在输入或分身逐字广播;“已发送”不得呈现为“已读”。
## US-KB-01 复用共享知识
作为资料所有者,我希望知识库既可独立共享,也可被数字分身或工作空间复用,不必重复维护。
关联:`FR-18`。
- 同一来源可以关联多个入口,各入口仍按自己的受众和动作授权;关联不自动扩大原资料范围。
- 团队知识库关联到公开入口时,未取得新增受众授权的资料不能被检索或返回。
- 停用某个分身入口不关闭知识库的独立共享;撤销来源授权后,依赖该授权的入口停止新的读取和引用。
- 来源更新记录版本及同步结果;未同步的最新修改不能被展示为服务端已有内容。缓存不能延长已撤销权限。
## US-AV-01 配置个人分身及监督回顾
作为用户,我希望以明确的身份与提示词配置分身,并选择愿意授权使用的知识和监督回顾。
关联:`FR-13`、`FR-18`。
- 开启分身不自动共享全部信息流、记忆分区或回顾;提示词也不产生资料访问授权。
- 对选定回顾分别授予“用于回答”和“查看原文”;只允许回答时隐藏原文,只允许阅读时不能被分身检索用于回答。
- 授权给组织空间不自动授权给个人外部访客;新产生的回顾不自动加入已有选定范围。
- 发布回顾原文须明确选择共享内容与受众,不借此开放底层个人记忆库。两种授权均撤销后,新请求不能读取或使用该回顾。
- 从两种授权均允许改为仅阅读时,阅读继续可用,新的检索及尚未发布的相关回答被阻止;改为仅回答时,
新的原文请求被拒绝,但分身仍可按回答授权使用。阅读共享副本不授予浏览底层记忆分区的能力。
- 未知或资料不足的问题明确说明;AI 回复不能自动形成所有者的报价、交付或其他本人承诺。
## 逻辑审查记录
本轮发现并修正的设计问题:
| 问题 | 修订结论 | 权威位置 |
| --- | --- | --- |
| 工作空间被建模为加入组织后才存在的对象 | 本地空间先存在,共享是可选属性 | FR-15 |
| `@数字人` 和 `@成员` 混用 | 成员提及与能力调用分开;显式处理请求在分身授权有效时才触发能力调用 | FR-13、FR-17 |
| 提及被直接当成处理请求 | 提及只通知;显式请求才进入本人/分身处理 | FR-17、逻辑 5.1 |
| 消息跟随资料周期延迟送达 | 消息与处理状态实时传递,资料按策略同步 | FR-16、US-MSG-04 |
| 本地保存被当成送达和处理成功 | 同步、消息交付、处理结果分开记录 | 逻辑 2.8、2.9 |
| 暂停同步与撤销访问混用 | 暂停资料自动交换不影响消息,关闭共享等待服务端确认 | 逻辑 2.8 |
| 审批和本人确认混用 | 策略审批只授予动作资格,本人确认绑定回复版本 | 逻辑 5.1 |
| 关联知识库被理解为继承全部权限 | 按来源、入口受众与动作求交集 | 逻辑 5.2 |
| 原文禁止规则与显式回顾分享冲突 | 私有记忆继续本地检索,选定回顾另行发布共享副本 | FR-18 |
| 本人接手后迟到 AI 回复仍可能发布 | 处理状态版本在发布前检查 | 逻辑 5.1 |
待定事项:资料同步默认周期、容量与保留期限、实时消息的目标延迟,以及匿名分享的真实身份和滥用控制方案。
首期组织协作按登录成员验收;持有链接的匿名阅读与对话仍为 demo 概念,不算已确定的服务端交付。
消息实时收发取代上一版“发送触发空间同步”的表述。“所有者离开即关闭共享”保留为设计默认值。