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 = `
GoodBuddy · ${settings ? '设置 / 共享与数字分身' : '个人空间 / 对外共享'}
${settings ? '查看访客页面 ↗' : '管理共享设置 ↗'}
${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', '开启知识共享', '允许访问者阅读选中的示例资料,供数字分身复用。')}

选择共享内容

仅选中的知识库对访问者开放。

本地知识库
${sources.map(s => ``).join('')}
客户项目档案

私人资料 · 未纳入本次共享

保持私有
取消选择或暂停知识共享后,分身将同步失去这些知识来源。数字分身开关不受影响。
`; +} +function avatarSettings() { + return `

数字分身

${toggle('avatar', '开启我的数字分身', '为共享空间增加对话入口,使用你授权的资料与个人设定。')}

身份与表达

访问者会明确看到这是 AI 分身。

回答可以参考什么

${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)}

新建共享工作空间

加入空间的成员

${people.map(p => ``).join('')}

成员立即加入仅为演示;真实邀请和组织管理尚未接入。

`; + 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 `

发起协作

${recipient ? '@' + person(recipient).name : '尚未指定接收人'}
选择附带上下文 · ${contexts.length} 项
${contextItems().map(c => ``).join('')}
`; + } + 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 })