diff --git a/docs/demo/client/README.md b/docs/demo/client/README.md
new file mode 100644
index 0000000..a107ae4
--- /dev/null
+++ b/docs/demo/client/README.md
@@ -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` 验证定向消息、上下文快照、草稿可见性、本人确认优先、接手、空间隔离、创建及设置持久化。
diff --git a/docs/demo/client/demo.css b/docs/demo/client/demo.css
new file mode 100644
index 0000000..59f7e3c
--- /dev/null
+++ b/docs/demo/client/demo.css
@@ -0,0 +1 @@
+:root{font-family:Inter,"Segoe UI","Microsoft YaHei",sans-serif;color:#233247;background:#f4f7fb;font-size:13px;--muted:#718095;--line:#e1e7ef;--blue:#3569dc;--soft:#edf3ff}*{box-sizing:border-box}body{margin:0}button,input,textarea,select{font:inherit}button,a,input,textarea,select{outline-offset:4px}button{cursor:pointer}a{color:var(--blue);text-decoration:none}button{border:1px solid var(--line);background:#fff;border-radius:9px;padding:9px 14px;color:inherit}button:hover{background:#f1f5fb}button:disabled{opacity:.5;cursor:not-allowed}.primary{background:var(--blue);border-color:var(--blue);color:white}.primary:hover{background:#2859c2}.shell{display:grid;grid-template-columns:222px 1fr;min-height:100vh}.sidebar{background:#edf2f8;border-right:1px solid var(--line);padding:27px 18px;display:flex;flex-direction:column;gap:28px}.brand{display:flex;align-items:center;gap:10px;font-size:19px;font-weight:700;letter-spacing:-.6px}.logo{width:31px;height:31px;background:var(--blue);border-radius:10px;color:white;display:grid;place-items:center;font-size:20px}.nav-label,.eyebrow{font-size:11px;letter-spacing:1.6px;color:var(--muted);font-weight:600}.nav{display:grid;gap:7px;margin-top:12px}.nav a{padding:11px 13px;color:#627087;border-radius:9px}.nav a.active{background:#dfe9fc;color:#285fc7;font-weight:600}.sidebar-bottom{margin-top:auto;color:var(--muted);font-size:12px;line-height:1.8}.account{display:flex;align-items:center;gap:10px;margin-bottom:18px;color:#233247}.mini-avatar,.avatar{background:#dee9f5;color:#466b91;border:1px solid #cbdced;display:grid;place-items:center;border-radius:12px;font-weight:600}.mini-avatar{width:36px;height:36px}.avatar{width:64px;height:64px;font-size:24px;border-radius:21px}.topbar{height:65px;border-bottom:1px solid var(--line);display:flex;align-items:center;justify-content:space-between;padding:0 36px;background:#ffffffb8;gap:12px}.crumb{color:var(--muted)}.badge{display:inline-flex;align-items:center;gap:6px;font-size:11px;background:#e9f5f1;color:#26745b;padding:5px 9px;border-radius:6px;white-space:nowrap}.badge.gray{background:#eef1f5;color:#738092}.content{max-width:1140px;margin:auto;padding:34px 36px 50px}.heading{display:flex;justify-content:space-between;align-items:center;gap:20px;margin-bottom:25px}h1{font-size:26px;letter-spacing:-.7px;margin:6px 0 10px;font-weight:650}h2{font-size:16px;margin:0 0 7px}h3{font-size:14px;margin:0 0 8px}p{line-height:1.75;margin:0;color:var(--muted)}.tabs{display:flex;gap:24px;border-bottom:1px solid var(--line);margin-bottom:23px}.tab{border:0;background:transparent;border-radius:0;padding:12px 0;color:var(--muted);border-bottom:2px solid transparent}.tab.active{color:var(--blue);border-bottom-color:var(--blue);font-weight:600}.panel{border:1px solid var(--line);border-radius:14px;background:white;padding:24px;margin-bottom:18px}.panel-head,.row{display:flex;align-items:center;justify-content:space-between;gap:18px}.panel-head{margin-bottom:19px}.row{padding:17px 0;border-top:1px solid #eef1f5}.row:last-child{padding-bottom:0}.row p{font-size:12px;margin-top:5px}.switch{appearance:none;width:36px;height:21px;border-radius:20px;background:#c7d0dc;position:relative;cursor:pointer;flex-shrink:0;transition:background .18s}.switch:before{content:"";position:absolute;width:15px;height:15px;border-radius:100%;background:white;top:3px;left:3px;transition:transform .18s}.switch:checked{background:var(--blue)}.switch:checked:before{transform:translateX(15px)}.columns{display:grid;grid-template-columns:minmax(0,1fr) 284px;gap:22px}.aside .panel{padding:21px}.aside p{font-size:12px}.flow{border-left:2px solid #dce7fc;padding-left:15px;margin:20px 0;display:grid;gap:20px}.flow strong{display:block;font-size:12px;margin-bottom:5px}.field{display:grid;gap:8px;margin-bottom:18px}.field:last-child{margin-bottom:0}.field>span{font-size:12px;font-weight:600}input[type=text],textarea,select,input[type=search]{width:100%;padding:10px 12px;border:1px solid #d5deea;border-radius:8px;background:#fff;color:inherit}textarea{resize:vertical;line-height:1.7;min-height:100px}.check{display:flex;gap:10px;align-items:flex-start;line-height:1.65}.check input{accent-color:var(--blue);margin-top:4px}.check small{display:block;color:var(--muted)}.notice{padding:12px 14px;background:#f2f6fd;border-radius:9px;color:#596f92;font-size:12px;line-height:1.7;margin-top:16px}.footer-actions{display:flex;justify-content:space-between;align-items:center;gap:12px;margin-top:22px}.muted{color:var(--muted);font-size:12px}.profile{display:flex;align-items:center;gap:18px;margin-bottom:24px}.profile h1{margin:0 0 7px}.split{display:grid;grid-template-columns:300px minmax(0,1fr);gap:20px}.source-list{display:grid;gap:10px;margin-top:16px}.source{width:100%;text-align:left;padding:16px;background:#fff}.source.active{border-color:#93afe9;background:#f0f5ff}.source strong{display:block;margin-bottom:8px}.source small{color:var(--muted);line-height:1.65}.source-icon{color:var(--blue);font-size:11px;margin-bottom:10px;letter-spacing:1px}.reader{min-height:450px}.reader article{padding:20px 0;line-height:1.9;color:#46576b;font-size:14px}.reader article h3{margin-top:20px}.reader article p{color:inherit;margin-bottom:15px}.chat{display:flex;flex-direction:column;min-height:430px}.messages{flex:1;display:grid;align-content:start;gap:15px;margin:20px 0}.message{padding:15px 17px;background:#f4f7fb;border-radius:0 12px 12px 12px;line-height:1.8;white-space:pre-wrap}.message.user{background:#edf3ff;margin-left:45px;border-radius:12px 0 12px 12px}.message small{display:block;color:var(--muted);margin-top:8px;font-size:11px}.composer{display:flex;gap:9px}.composer input{flex:1;min-width:0}.suggestions{display:flex;gap:8px;flex-wrap:wrap}.suggestions button{font-size:12px}.empty{padding:65px 24px;text-align:center}.empty h2{margin-bottom:12px}.toast{position:fixed;bottom:24px;left:50%;transform:translateX(-50%);background:#253951;color:white;border-radius:10px;padding:12px 20px;box-shadow:0 6px 30px #1a2a4525;z-index:5;max-width:90vw}.save-state{color:#9a6a25;font-size:12px}.summary-line{display:flex;justify-content:space-between;margin-top:16px;font-size:12px;gap:10px}details{margin-top:15px}summary{cursor:pointer;color:#526a8b;font-size:12px}.record{font-size:12px;line-height:1.8;margin-top:12px;color:#63768e}.mobile-brand{display:none}[hidden]{display:none!important}@media(min-width:1450px){.content{padding-top:45px}}@media(max-width:1000px){.columns{grid-template-columns:1fr}.aside{display:grid;grid-template-columns:1fr 1fr;gap:16px}.split{grid-template-columns:245px 1fr}.content{padding:25px 24px}.topbar{padding:0 24px}.shell{grid-template-columns:190px 1fr}}@media(max-width:720px){.shell{display:block}.sidebar{display:none}.mobile-brand{display:inline;color:#233247;font-weight:700}.topbar{height:auto;min-height:60px;padding:14px 18px;flex-wrap:wrap}.content{padding:24px 16px}.heading{align-items:flex-start;flex-direction:column;gap:12px}h1{font-size:23px}.split,.aside{grid-template-columns:1fr}.source-list{grid-template-columns:1fr 1fr}.panel{padding:19px}.tabs{gap:20px}.footer-actions{align-items:flex-start;flex-wrap:wrap}.profile{align-items:flex-start}.avatar{width:50px;height:50px;flex-shrink:0}.composer{flex-wrap:wrap}.reader{min-height:0}}@media(prefers-reduced-motion:reduce){*{transition:none!important}}
diff --git a/docs/demo/client/demo.js b/docs/demo/client/demo.js
new file mode 100644
index 0000000..e2f453d
--- /dev/null
+++ b/docs/demo/client/demo.js
@@ -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: '
一个值得先做的场景,通常有清晰的输入、可检查的输出,以及愿意持续使用它的人。
先确定怎样算完成
在接入模型之前,先写下验收条件。以资料问答为例,除了能回答问题,还应能指出依据,并在资料不足时说明不知道。
把反馈留在工作流里
先让小范围用户完成一次真实任务,记录哪里需要人工补充,再决定是否扩大自动化范围。
' },
+ { id: 'product', name: '产品设计笔记', meta: '8 篇资料 · 昨天更新', desc: '围绕权限、知识共享和人机协作的产品思考。', title: '知识共享与数字分身的关系', body: '知识库可以独立共享,也可以作为数字分身的回答来源。同一份资料只需维护一次。
内容复用,授权明确
关联知识库时,需要确认这些资料能否向分身的访问者提供。团队内资料不会因为关联一个公开分身就自动公开。
阅读资料与交流分开
访问者可以直接阅读共享知识,也可以向分身提问。监督回顾可用于帮助分身理解个人偏好,但原始记录是否可见需要单独设置。
' }
+];
+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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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 ``; }
+function render() {
+ app.innerHTML = `${settings ? settingsView() : visitorView()}
`;
+ bind();
+ const workspaceLink = document.createElement('a');
+ workspaceLink.href = 'workspace.html';
+ workspaceLink.textContent = '组织工作空间 ↗';
+ document.querySelector('.topbar').append(workspaceLink);
+ if (settings) window.mountWorkspacePreferences?.();
+}
+function settingsView() {
+ return `SHARING & IDENTITY
把知识分享出去,让分身参与交流
选择愿意分享的内容,设置别人可以怎样与你的数字分身交流。
客户端设置演示${tab === 'knowledge' ? knowledgeSettings() : avatarSettings()}
访问范围
本演示中的知识库与分身使用同一个共享空间和访问范围。
团队模式下,匿名访客页面将显示访问限制。真实身份校验尚未接入。
`;
+}
+function knowledgeSettings() {
+ return `共享知识库
${toggle('knowledge', '开启知识共享', '允许访问者阅读选中的示例资料,供数字分身复用。')}`;
+}
+function avatarSettings() {
+ return `数字分身
${toggle('avatar', '开启我的数字分身', '为共享空间增加对话入口,使用你授权的资料与个人设定。')}回答可以参考什么
${toggle('useKnowledge', '关联共享知识库', `复用已授权的知识库。当前${state.knowledge ? '选中 ' + state.sources.length + ' 个' : '知识共享已暂停,暂不可用'}。`)}${toggle('useReview', '共享选定的监督回顾', '仅授权下方这一条示例回顾,不包含全部信息流或后续新记录。')}查看待授权的回顾内容
示例回顾 · 09 月 24 日
讨论产品方案时,倾向于先确定一个可验证的小场景,再扩大范围;关注权限边界和用户能否理解操作结果。
${toggle('showOriginal', '允许访客查看回顾原文', '独立于回答参考权限;需先开启上方监督回顾授权。')}默认不共享监督回顾。授权用于回答,并不自动允许访问者阅读原始记录。
`;
+}
+function visitorView() {
+ return `${esc(state.name.slice(0, 1) || '林')}
PERSONAL KNOWLEDGE SPACE
${esc(state.name || '我的')}的共享空间
分享 AI 应用实践与产品思考,也欢迎和我的数字分身聊聊。
访客视角 · 演示 ${state.scope === 'team' ? '这个空间仅对团队成员开放
当前模拟的是匿名访问者。请在共享设置中切换为「持有链接的人」,体验公开访问流程。
前往共享设置 →' : `${tab === 'knowledge' ? knowledgeVisitor() : avatarVisitor()}`}交互演示:配置仅保存在当前浏览器;对话使用固定示例逻辑,未连接模型、知识检索或真实共享服务。
`;
+}
+function knowledgeVisitor() {
+ const available = sources.filter(s => state.sources.includes(s.id));
+ if (!state.knowledge || !available.length) return '暂无开放的知识库
主人已暂停知识共享,或尚未选择资料。数字分身可以独立开启。
';
+ const item = available.find(s => s.id === selected) || available[0];
+ selected = item.id;
+ return `${available.map(s => `
`).join('')}
没有匹配的知识库。
已授权共享资料节选 / 示例
${item.title}
来自 ${item.name} · 林知远
${item.body}${state.avatar && state.useKnowledge ? '这份资料也用于数字分身回答' : '以共享资料形式提供阅读'}${state.avatar ? '' : ''}
`;
+}
+function avatarVisitor() {
+ if (!state.avatar) return '数字分身已暂停
暂时无法发起对话。已开放的共享知识库仍可阅读。
';
+ const available = state.knowledge && state.useKnowledge ? sources.filter(s => state.sources.includes(s.id)) : [];
+ return `${esc(state.name || '我的')}的数字分身
AI 分身 · 回答不代表本人承诺
可交流你好,我可以根据主人授权的共享资料,与你交流 AI 应用和产品设计。你想从哪里开始?固定示例回复,不是实时模型输出
`;
+}
+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();
diff --git a/docs/demo/client/index.html b/docs/demo/client/index.html
new file mode 100644
index 0000000..9413817
--- /dev/null
+++ b/docs/demo/client/index.html
@@ -0,0 +1,5 @@
+
+
+共享空间 · GoodBuddy
+
+
diff --git a/docs/demo/client/settings.html b/docs/demo/client/settings.html
new file mode 100644
index 0000000..7a02a0d
--- /dev/null
+++ b/docs/demo/client/settings.html
@@ -0,0 +1,5 @@
+
+
+共享设置 · GoodBuddy
+
+
diff --git a/docs/demo/client/verify-workspace.mjs b/docs/demo/client/verify-workspace.mjs
new file mode 100644
index 0000000..6dc4719
--- /dev/null
+++ b/docs/demo/client/verify-workspace.mjs
@@ -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', '请评估共享方案 ');
+ 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()); }
diff --git a/docs/demo/client/verify.mjs b/docs/demo/client/verify.mjs
new file mode 100644
index 0000000..b0dae40
--- /dev/null
+++ b/docs/demo/client/verify.mjs
@@ -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());
+}
diff --git a/docs/demo/client/workspace.css b/docs/demo/client/workspace.css
new file mode 100644
index 0000000..bae0cc1
--- /dev/null
+++ b/docs/demo/client/workspace.css
@@ -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}}
diff --git a/docs/demo/client/workspace.html b/docs/demo/client/workspace.html
new file mode 100644
index 0000000..d1656a9
--- /dev/null
+++ b/docs/demo/client/workspace.html
@@ -0,0 +1,5 @@
+
+
+组织工作空间 · GoodBuddy
+
+
diff --git a/docs/demo/client/workspace.js b/docs/demo/client/workspace.js
new file mode 100644
index 0000000..1daed0c
--- /dev/null
+++ b/docs/demo/client/workspace.js
@@ -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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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]) => ``).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 = `TEAM WORKSPACE
${escape(space.name)}
把问题交给具体的人,由本人或获准的数字分身接着处理。
当前以 ${person(actor).name} 的身份体验。切换成员可演示接收、确认草稿和本人接手;这是本地角色模拟,不是真实登录。
${space.messages.length ? space.messages.map(thread).join('') : '从一个明确的问题开始
在下方 @ 一位空间成员,选择要附带的资料。消息和处理结果会留在这里。
'}${composer()}
${escape(status)}
`;
+ bind();
+ }
+ function thread(message) {
+ const mine = actor === message.to;
+ const labels = { pending: '待本人处理', draft: '待本人确认草稿', replied: '分身已回复', handling: '本人已接手', done: '本人已回复' };
+ return `${person(message.from).name} → @${person(message.to).name}${labels[message.status]}
${escape(message.text)}
${message.requireHuman ? '需要本人确认 · ' : ''}接收方式:${modes[message.mode]} · 附带 ${message.contexts.length} 项上下文
${message.contexts.length ? `查看发送时的上下文
${message.contexts.map(c => `${escape(c.title)}
${escape(c.text)}
`).join('')} ` : ''}${message.replies.map(r => `${person(message.to).name}${r.ai ? '的数字分身 · AI 示例回复' : ' · 本人(模拟)'}
${escape(r.text)}
`).join('')}${message.status === 'draft' && mine ? `分身草稿 · 仅接收者可见
` : ''}${mine && message.status !== 'done' ? `${message.status !== 'handling' ? `` : ''}
${message.status === 'handling' ? `` : ''}` : ''}`;
+ }
+ function composer() {
+ return ``;
+ }
+ 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 = `组织工作空间 · 我的处理偏好
明日工作室 / 林知远。与个人访客分身入口分别设置,以下偏好独立保存。
进入协作空间 ↗ ${data.spaces.filter(s => s.members.includes('lin')).map(s => ``).join('')}@ 指向你本人;可授权分身先回复或仅起草。对方要求本人确认时,优先交给你。更改仅影响后续消息。
`;
+ 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();
+})();
diff --git a/docs/features/share-server/README.md b/docs/features/share-server/README.md
index 370d36f..79fae85 100644
--- a/docs/features/share-server/README.md
+++ b/docs/features/share-server/README.md
@@ -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 端管理个人设备、授权、申请及自己发布的数字人。
diff --git a/docs/features/share-server/logic-design.md b/docs/features/share-server/logic-design.md
index eb38b6f..938de5a 100644
--- a/docs/features/share-server/logic-design.md
+++ b/docs/features/share-server/logic-design.md
@@ -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. 逻辑完整性
diff --git a/docs/features/share-server/prd.md b/docs/features/share-server/prd.md
index 1c67fbf..863842a 100644
--- a/docs/features/share-server/prd.md
+++ b/docs/features/share-server/prd.md
@@ -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 和编辑器启动授权过期或撤销后不可重用;保存失败、源文件冲突或
diff --git a/docs/features/share-server/progress.md b/docs/features/share-server/progress.md
index d8fda32..c9e7d59 100644
--- a/docs/features/share-server/progress.md
+++ b/docs/features/share-server/progress.md
@@ -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 仍要求选择接收人并自动进入处理流程,尚未支持普通消息、提及与
+ 请求处理分离、真实实时推送及离线补收;需在后续原型迭代中对齐。
diff --git a/docs/features/share-server/technical-design.md b/docs/features/share-server/technical-design.md
index 8fd4148..5771bb0 100644
--- a/docs/features/share-server/technical-design.md
+++ b/docs/features/share-server/technical-design.md
@@ -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 积压、投递重试及补收失败,备份
+消息与事件进度;连接、短期凭据不恢复。新增组件的配置、凭据和健康检查进入部署设计后才能上线。
diff --git a/docs/features/share-server/ui-design.md b/docs/features/share-server/ui-design.md
index f3f7728..f416391 100644
--- a/docs/features/share-server/ui-design.md
+++ b/docs/features/share-server/ui-design.md
@@ -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 仍把提及直接当作处理请求,需按本节调整;
+其即时本地回复不是服务端送达或实时通信的验收证据。
diff --git a/docs/features/share-server/user-stories.md b/docs/features/share-server/user-stories.md
new file mode 100644
index 0000000..2922d44
--- /dev/null
+++ b/docs/features/share-server/user-stories.md
@@ -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 概念,不算已确定的服务端交付。
+消息实时收发取代上一版“发送触发空间同步”的表述。“所有者离开即关闭共享”保留为设计默认值。
diff --git a/package.json b/package.json
index 8a9672b..0dc5b92 100644
--- a/package.json
+++ b/package.json
@@ -4,8 +4,8 @@
"private": true,
"type": "module",
"scripts": {
- "dev": "concurrently -k -n api,web -c cyan,magenta \"npm:dev:api\" \"npm:dev:web\"",
- "dev:api": "tsx watch server/index.ts",
+ "dev": "node scripts/dev.mjs",
+ "dev:api": "node --watch --import tsx server/index.ts",
"dev:web": "vite",
"build": "npm run typecheck && vite build && npm run build:server",
"build:server": "node -e \"require('node:fs').rmSync('build-server',{recursive:true,force:true})\" && tsc -p tsconfig.server.json",
diff --git a/scripts/dev.mjs b/scripts/dev.mjs
new file mode 100644
index 0000000..27a5aee
--- /dev/null
+++ b/scripts/dev.mjs
@@ -0,0 +1,10 @@
+import process from 'node:process'
+import { concurrently } from 'concurrently'
+
+// Avoid CLI argv detection treating Electron's Node mode as a bundled app.
+const { result } = concurrently([
+ { command: 'npm run dev:api', name: 'api', prefixColor: 'cyan' },
+ { command: 'npm run dev:web -- --host 127.0.0.1', name: 'web', prefixColor: 'magenta' }
+], { killOthersOn: ['success', 'failure'] })
+
+result.then(() => { process.exitCode = 0 }, () => { process.exitCode = 1 })