Просмотр исходного кода

发布 v0.3.7:恢复新建团队预设与群内角色分工

LSamien 1 месяц назад
Родитель
Сommit
b947e42a7b

+ 1 - 1
README.md

@@ -1,6 +1,6 @@
 # 夭夭 Web
 
-当前发布版本:**v0.3.6**,配套 iOS **1.3(171)**。详见 [发布说明](docs/releases/v0.3.6.md)。
+当前发布版本:**v0.3.7**,配套 iOS **1.3(172)**。详见 [发布说明](docs/releases/v0.3.7.md)。
 
 夭夭 Web 为网页和 iOS 提供统一的 Agent、群聊、文件库及语音配置服务。Hermes 通过标准 9119 HTTP/WebSocket 接口执行任务;无需安装 Yaoyao 插件。
 

+ 16 - 0
docs/releases/v0.3.7.md

@@ -0,0 +1,16 @@
+# v0.3.7
+
+恢复迁移时漏接的新建团队模板入口。Web 使用原来的模板卡片,iOS 复用“选择预设团队”页面,聊天首页及团队入口保持原有布局。
+
+- 恢复信息收集、产品设计、软件开发、文案书写、项目管理、数据分析、会议协作、运维保障 8 套预设,以及自定义团队。
+- 模板带入名称、群规则、角色分工和管理员;成员从已创建的 Agent 中选择,角色可以重新分配,人数不足时提示补充 Agent。
+- 角色分工保存在对应群聊中,不修改已有 Agent 的名称、头像和独立规则;执行时使用成员的最新名称,单聊不继承群内职责。
+- 保留群成员增减与管理员保护,移除成员时同步清理其群内分工。
+
+配套 iOS 1.3(172)。
+
+## 验证
+
+- 85 个文件、469 项 Web 测试通过,类型检查和生产构建通过。
+- 浏览器验证 8 套模板、人数不足提示、角色重新分配、保存后的分工和聊天。
+- iOS 12 项协议与模板测试及完整界面验收通过,模板选择、创建、分工保存使用原有导航层级。

+ 2 - 0
docs/workspace-chat.md

@@ -6,6 +6,8 @@
 
 创建群聊选择 2~8 位成员;编辑时可以只保留管理员,最多 8 位。当前管理员不能移除,须先更换管理员并保存后再移除原管理员。新增成员必须属于当前用户且未归档。移除成员保留聊天记录及原会话,更新头像组合和提及名单,同时清理自动回复名单;进行中的回复可完成,后续接力不再调度已移除成员。
 
+新建群聊保留原有 8 套团队预设。模板将角色分配给已创建的 Agent,`memberRoles` 以成员 ID 保存群内角色名称与职责。分工仅在对应群聊执行时加入提示词,不修改 Agent 的身份与独立规则;移除成员会同步清理分工。模板所需成员不足时,先创建足够的 Agent 再使用模板。
+
 应用数据保存在 Web 数据目录的 `workspace.sqlite3`。每一条实体、命令和事件包含服务端用户归属。隐藏上游会话只由 WorkspaceRuntime 驱动,原生历史及 RPC 不允许直接访问。基础 Hermes Profile 的记忆和工具权限没有被复制为新的隔离运行环境。
 
 ## API

+ 2 - 2
package-lock.json

@@ -1,12 +1,12 @@
 {
   "name": "hermes-yaoyao",
-  "version": "0.3.6",
+  "version": "0.3.7",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "hermes-yaoyao",
-      "version": "0.3.6",
+      "version": "0.3.7",
       "license": "BSL-1.1",
       "dependencies": {
         "@koa/bodyparser": "^5.0.0",

+ 1 - 1
package.json

@@ -1,6 +1,6 @@
 {
   "name": "hermes-yaoyao",
-  "version": "0.3.6",
+  "version": "0.3.7",
   "private": true,
   "license": "BSL-1.1",
   "description": "夭夭 Web:直连 Hermes 9119 的聊天、团队、文件库与产物工作台",

+ 3 - 3
release.json

@@ -1,6 +1,6 @@
 {
   "schemaVersion": 1,
-  "releaseVersion": "0.3.6",
-  "webVersion": "0.3.6",
-  "gitTag": "v0.3.6"
+  "releaseVersion": "0.3.7",
+  "webVersion": "0.3.7",
+  "gitTag": "v0.3.7"
 }

+ 25 - 0
src/client/components/workspace/TeamPresetPicker.vue

@@ -0,0 +1,25 @@
+<script setup lang="ts">
+import { TEAM_PRESETS, type TeamPreset } from '@/components/groups/teamPresets'
+const props = defineProps<{ selected: string; available: number }>()
+const emit = defineEmits<{ select: [preset?: TeamPreset] }>()
+const shortage = (preset: TeamPreset) => Math.max(0, preset.roles.length - props.available)
+</script>
+<template>
+  <section class="preset-picker" aria-label="团队预设">
+    <div class="preset-picker__heading"><span>团队预设</span><small>选择后分配已创建的 Agent</small></div>
+    <div class="preset-grid">
+      <button type="button" :class="{ selected: selected === 'custom' }" @click="emit('select')"><strong>自定义团队</strong><small>手动选择 Agent 和协作配置</small><em>自由配置</em></button>
+      <button v-for="preset in TEAM_PRESETS" :key="preset.id" type="button" :class="{ selected: selected === preset.id }" :disabled="shortage(preset) > 0" :aria-label="preset.name" @click="emit('select', preset)">
+        <strong>{{ preset.name }}</strong><small>{{ preset.summary }}</small><em>{{ shortage(preset) ? `还缺 ${shortage(preset)} 人` : `${preset.roles.length} 人` }}</em>
+      </button>
+    </div>
+    <small v-if="available < 5" class="preset-help">成员不足时,请先创建所需的 Agent。</small>
+  </section>
+</template>
+<style scoped>
+.preset-picker { margin-bottom: 15px; }.preset-picker__heading, .role-mapping__heading { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin-bottom: 7px; color: var(--text-secondary); font-size: 10px; }.preset-picker__heading small, .role-mapping__heading small { color: var(--text-muted); font-size: 9px; }.preset-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 7px; }.preset-grid button { position: relative; display: grid; min-height: 82px; align-content: start; gap: 4px; padding: 10px; border: 1px solid var(--line); border-radius: 11px; background: var(--surface-soft); color: var(--text-primary); cursor: pointer; text-align: left; }.preset-grid button:hover { border-color: var(--line-strong); background: var(--surface-hover); }.preset-grid button.selected { border-color: color-mix(in srgb, var(--accent) 58%, var(--line)); box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 13%, transparent); }.preset-grid button:disabled { cursor: not-allowed; opacity: .48; }.preset-grid strong { padding-right: 42px; font-size: 10px; }.preset-grid small { color: var(--text-muted); font-size: 9px; line-height: 1.4; }.preset-grid em { position: absolute; top: 9px; right: 9px; color: var(--accent); font-size: 8px; font-style: normal; }
+
+.preset-help{display:block;margin-top:8px;color:var(--text-muted);font-size:11px}
+@media(max-width:620px){.preset-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}
+@media(max-width:420px){.preset-grid{grid-template-columns:1fr}}
+</style>

+ 50 - 1
src/client/views/ConversationsView.vue

@@ -16,6 +16,9 @@ import type { UiLibraryItem } from '@/components/library/types'
 import type { UiMessage } from '@/components/messages/types'
 import AgentIdentityPanel from '@/components/app/AgentIdentityPanel.vue'
 import TeamAvatar from '@/components/common/TeamAvatar.vue'
+import TeamPresetPicker from '@/components/workspace/TeamPresetPicker.vue'
+import { TEAM_PRESETS, type TeamPreset } from '@/components/groups/teamPresets'
+import type { WorkspaceMemberRole } from '@shared/workspace'
 import AppIcon from '@/components/common/AppIcon.vue'
 import { workspaceAvatarMembers, workspaceAvatarState, workspaceMessagesToUi } from '@/components/workspace/viewModels'
 import { useAuthStore } from '@/stores/auth'
@@ -101,11 +104,36 @@ const form = reactive({
   instructions: '',
   source: '',
   memberIds: [] as string[],
+  memberRoles: {} as Record<string, WorkspaceMemberRole>,
   administratorId: '',
   mode: 'host' as 'host' | 'free',
   autoReplyIds: [] as string[],
   maxReplyRounds: 3,
 })
+const selectedPresetId = ref('custom')
+const selectedPreset = computed(() => dialog.value === 'group' ? TEAM_PRESETS.find(p => p.id === selectedPresetId.value) : undefined)
+function applyPresetRoles(preset: TeamPreset) {
+  form.memberRoles = Object.fromEntries(preset.roles.flatMap((role, index) => {
+    const id = form.memberIds[index]
+    return id ? [[id, { name: role.name, description: role.description }]] : []
+  }))
+  form.administratorId = form.memberIds[Math.max(0, preset.roles.findIndex(role => role.host))] ?? ''
+}
+function choosePreset(preset?: TeamPreset) {
+  const available = agents.value.filter(a => !a.archived)
+  if (preset && available.length < preset.roles.length) return
+  selectedPresetId.value = preset?.id ?? 'custom'
+  Object.assign(form, { name: preset?.name ?? '', instructions: preset?.instructions ?? '', memberIds: preset ? available.slice(0, preset.roles.length).map(a => a.id) : [], memberRoles: {}, administratorId: '', mode: 'host', autoReplyIds: [], maxReplyRounds: 3 })
+  if (preset) applyPresetRoles(preset)
+}
+function assignPresetRole(index: number, id: string) {
+  if (!selectedPreset.value) return
+  const next = [...form.memberIds], previous = next[index], occupied = next.indexOf(id)
+  next[index] = id
+  if (occupied >= 0 && occupied !== index && previous) next[occupied] = previous
+  form.memberIds = next
+  applyPresetRoles(selectedPreset.value)
+}
 const answers = reactive<Record<string, string>>({})
 let cursor = 0,
   disposed = false,
@@ -212,12 +240,14 @@ async function openDialog(kind: NonNullable<typeof dialog.value>) {
   error.value = ''
   dialog.value = kind
   editingId.value = ''
+  selectedPresetId.value = 'custom'
   Object.assign(form, {
     name: '',
     avatar: '',
     instructions: '',
     source: '',
     memberIds: [],
+    memberRoles: {},
     administratorId: '',
     mode: 'host',
     autoReplyIds: [],
@@ -240,6 +270,7 @@ async function openDialog(kind: NonNullable<typeof dialog.value>) {
       Object.assign(form, active.value, {
         memberIds: [...active.value.memberIds],
         autoReplyIds: [...active.value.autoReplyIds],
+        memberRoles: Object.fromEntries(Object.entries(active.value.memberRoles ?? {}).map(([id, role]) => [id, { ...role }])),
       })
     }
     await nextTick()
@@ -258,6 +289,7 @@ watch(
   (ids) => {
     if (!ids.includes(form.administratorId)) form.administratorId = ids[0] ?? ''
     form.autoReplyIds = form.autoReplyIds.filter((id) => ids.includes(id))
+    form.memberRoles = Object.fromEntries(Object.entries(form.memberRoles).filter(([id]) => ids.includes(id)))
   },
 )
 async function save() {
@@ -286,6 +318,7 @@ async function save() {
         autoReplyIds: form.autoReplyIds,
         maxReplyRounds: form.maxReplyRounds,
         memberIds: form.memberIds,
+        memberRoles: form.memberRoles,
       }
       const result = await apiRequest<{ conversation: Conversation }>(
         dialog.value === 'group'
@@ -521,6 +554,7 @@ onBeforeUnmount(() => {
           <button type="button" aria-label="关闭" @click="closeDialog">×</button>
         </header>
         <p v-if="error" class="error" role="alert">{{ error }}</p>
+        <TeamPresetPicker v-if="dialog === 'group'" :selected="selectedPresetId" :available="agents.filter(a => !a.archived).length" @select="choosePreset" />
         <label>名称<input v-model="form.name" required maxlength="100" /></label>
         <AgentIdentityPanel v-if="isAgentDialog" :key="`${dialog}:${editingId}`" :profile="avatarProfile" embedded :show-name="false" :show-default-model="false" :show-actions="false" @avatar-change="form.avatar = $event" />
         <div v-else class="team-avatar-settings">
@@ -553,7 +587,18 @@ onBeforeUnmount(() => {
             "
           />
         </label>
-        <fieldset v-if="!isAgentDialog">
+        <fieldset v-if="selectedPreset" class="preset-role-mapping">
+          <legend>角色分配</legend>
+          <label v-for="(role, index) in selectedPreset.roles" :key="role.name">
+            {{ role.name }}{{ role.host ? ' · 管理员' : '' }}
+            <select :value="form.memberIds[index]" :aria-label="`${role.name}对应的 Agent`" @change="assignPresetRole(index, ($event.target as HTMLSelectElement).value)">
+              <option v-for="a in agents.filter(a => !a.archived)" :key="a.id" :value="a.id">{{ a.name }}</option>
+            </select>
+            <small>{{ role.description }}</small>
+          </label>
+          <small>角色分工仅在本群生效,聊天中保留 Agent 的名称和头像。</small>
+        </fieldset>
+        <fieldset v-else-if="!isAgentDialog">
           <legend>选择成员</legend>
           <label
             v-for="a in agents.filter((a) =>
@@ -571,6 +616,10 @@ onBeforeUnmount(() => {
           >
           <small v-if="dialog === 'editGroup'">可增减成员,最多 8 位。当前管理员不能移除;如需移除,请先更换管理员并保存。</small>
         </fieldset>
+        <fieldset v-if="dialog === 'editGroup' && Object.keys(form.memberRoles).length">
+          <legend>角色分工</legend>
+          <p v-for="(role, id) in form.memberRoles" :key="id">{{ agents.find(a => a.id === id)?.name }} · {{ role.name }}<br /><small>{{ role.description }}</small></p>
+        </fieldset>
         <template v-if="!isAgentDialog"
           ><label
             >管理员<select v-model="form.administratorId" required>

+ 6 - 0
src/server/workspaceRuntime.ts

@@ -583,6 +583,12 @@ export class WorkspaceRuntime {
         const members = run.turnConfiguration!.members
         const rules = [
           `你是 ${agent.name}。以下是用户为这个独立 Agent 配置的角色与规则(版本 ${agent.revision}):\n${agent.instructions}`,
+          c.kind === 'group' && Object.keys(c.memberRoles ?? {}).length
+            ? `本群角色分工(仅在本群生效):\n${members.flatMap(member => {
+                const role = c.memberRoles?.[member.id]
+                return role ? [`@${member.name}:${role.name};${role.description}`] : []
+              }).join('\n')}\n协作时使用上面的真实成员名称进行 @,不要使用职责名称代替成员名称。`
+            : '',
           c.kind === 'group'
             ? `你正在群聊「${c.name}」发言。群成员:${members.map((a) => `@${a.name} (id=${a.id})`).join('、')}。\n群规则:${c.instructions}\n${c.mode === 'host' ? (agent.id === c.administratorId ? '你是管理员。必要时用精确 @成员名称 委派工作;收到结果后复核并给用户结论。任务完成时不要继续 @。' : '执行当前委派任务。公开给出结果,由管理员复核;不要安排其他成员。') : '按自己的职责回复,只在需要协作时 @成员。不要重复已完成的工作。'}`
             : '',

+ 11 - 2
src/server/workspaceStore.ts

@@ -45,11 +45,16 @@ export const agentPatch = z
     archived: z.boolean().optional(),
   })
   .strict()
+const memberRoles = z.record(z.string().uuid(), z.object({
+  name: z.string().trim().min(1).max(100),
+  description: z.string().max(500),
+}).strict()).refine(value => Object.keys(value).length <= 8, '最多 8 个群内角色')
 export const groupInput = z
   .object({
     name,
     avatar: avatar.default(''),
     memberIds: z.array(z.string().uuid()).min(2).max(8),
+    memberRoles: memberRoles.default({}),
     instructions: z.string().max(24_000).default(''),
     administratorId: z.string().uuid(),
     mode: z.enum(['host', 'free']).default('host'),
@@ -63,6 +68,7 @@ export const conversationPatch = z
     avatar: avatar.optional(),
     instructions: z.string().max(24_000).optional(),
     memberIds: z.array(z.string().uuid()).min(1).max(8).optional(),
+    memberRoles: memberRoles.optional(),
     administratorId: z.string().uuid().optional(),
     mode: z.enum(['host', 'free']).optional(),
     autoReplyIds: z.array(z.string().uuid()).max(8).optional(),
@@ -272,7 +278,8 @@ export class WorkspaceStore {
     if (
       ids.size !== body.memberIds.length ||
       !ids.has(body.administratorId) ||
-      body.autoReplyIds.some((id) => !ids.has(id))
+      body.autoReplyIds.some((id) => !ids.has(id)) ||
+      Object.keys(body.memberRoles).some(id => !ids.has(id))
     )
       throw new HttpError(400, '群成员或管理员无效', 'invalid_members')
     for (const id of ids)
@@ -309,7 +316,8 @@ export class WorkspaceStore {
     if (
       members.size !== memberIds.length ||
       !members.has(patch.administratorId ?? c.administratorId) ||
-      patch.autoReplyIds?.some((a) => !members.has(a) && !c.memberIds.includes(a))
+      patch.autoReplyIds?.some((a) => !members.has(a) && !c.memberIds.includes(a)) ||
+      Object.keys(patch.memberRoles ?? {}).some(a => !members.has(a) && !c.memberIds.includes(a))
     )
       throw new HttpError(400, '群成员或管理员无效', 'invalid_members')
     for (const memberId of memberIds) {
@@ -320,6 +328,7 @@ export class WorkspaceStore {
     const next = {
       ...c, ...patch, memberIds,
       autoReplyIds: (patch.autoReplyIds ?? c.autoReplyIds).filter(a => members.has(a)),
+      memberRoles: Object.fromEntries(Object.entries(patch.memberRoles ?? c.memberRoles ?? {}).filter(([id]) => members.has(id))),
       updatedAt: Date.now(),
     }
     this.atomic(() => {

+ 5 - 0
src/shared/workspace.ts

@@ -11,12 +11,17 @@ export interface WorkspaceAgent {
   createdAt: number
   updatedAt: number
 }
+export interface WorkspaceMemberRole {
+  name: string
+  description: string
+}
 export interface WorkspaceConversation {
   id: string
   kind: 'direct' | 'group'
   name: string
   avatar: string
   memberIds: string[]
+  memberRoles?: Record<string, WorkspaceMemberRole>
   instructions: string
   administratorId: string
   mode: 'host' | 'free'

+ 32 - 0
tests/e2e/workspace-chat.spec.ts

@@ -122,4 +122,36 @@ test('created roles and editable teams share a durable chat list without plugin
   await expect(page).toHaveURL(/\/chat/)
   expect(pluginCalls).toEqual([])
   await second.close()
+  await page.goto('/conversations')
+  await page.getByRole('button', { name: '+ 创建群聊', exact: true }).click()
+  await expect(dialog.getByRole('button', { name: '信息收集团队', exact: true })).toBeDisabled()
+  await dialog.getByRole('button', { name: '取消', exact: true }).click()
+  await createAgent(page, '模板成员四')
+  await createAgent(page, '模板成员五')
+  await page.getByRole('button', { name: '+ 创建群聊', exact: true }).click()
+  for (const name of ['信息收集团队', '产品设计团队', '软件开发团队', '文案书写团队', '项目管理团队', '数据分析团队', '会议协作团队', '运维保障团队']) {
+    await expect(dialog.getByRole('button', { name, exact: true })).toBeEnabled()
+  }
+  await page.screenshot({ path: testInfo.outputPath('restored-team-presets.png'), fullPage: true })
+  await dialog.getByRole('button', { name: '产品设计团队', exact: true }).click()
+  await expect(dialog.getByRole('textbox', { name: '名称', exact: true })).toHaveValue('产品设计团队')
+  await dialog.getByRole('combobox', { name: '产品负责人对应的 Agent', exact: true }).selectOption({ label: '产品经理' })
+  await dialog.getByRole('combobox', { name: '交互设计对应的 Agent', exact: true }).selectOption({ label: '开发工程师' })
+  const [submitted] = await Promise.all([
+    page.waitForRequest(r => r.method() === 'POST' && r.url().endsWith('/api/app/conversations')),
+    dialog.getByRole('button', { name: '保存', exact: true }).click(),
+  ])
+  const payload = submitted.postDataJSON()
+  expect(new Set(payload.memberIds).size).toBe(5)
+  expect(Object.keys(payload.memberRoles)).toHaveLength(5)
+  expect(payload.memberRoles[payload.administratorId].name).toBe('产品负责人')
+  await expect(page.getByRole('heading', { name: '产品设计团队', exact: true })).toBeVisible()
+  await page.getByRole('button', { name: '聊天设置', exact: true }).click()
+  await expect(dialog.getByText('产品经理 · 产品负责人', { exact: false })).toBeVisible()
+  await expect(dialog.getByText('开发工程师 · 交互设计', { exact: false })).toBeVisible()
+  await dialog.getByRole('button', { name: '取消', exact: true }).click()
+  await page.getByPlaceholder('输入消息,Enter 发送,Shift + Enter 换行').fill('请汇总设计方案')
+  await page.getByRole('button', { name: '发送消息', exact: true }).click()
+  await expect(page.locator('.message--assistant')).toContainText('我是产品经理')
+  expect(pluginCalls).toEqual([])
 })

+ 19 - 0
tests/server/workspace.test.ts

@@ -365,6 +365,25 @@ describe('Web-owned workspace', () => {
     expect(requests.filter((r) => r.method === 'prompt.submit')).toHaveLength(1)
     expect(store.messages(owner, c.id).map((m) => m.role)).toEqual(['user', 'assistant'])
   })
+  it('keeps template responsibilities inside their group and follows Agent identity after rename', async () => {
+    const a = agent('现有甲'), b = agent('现有乙')
+    const before = store.require<WorkspaceAgent>(owner, 'agent', a.id)
+    const c = store.createGroup(owner, { name: '模板群', memberIds: [a.id, b.id], administratorId: a.id, memberRoles: {
+      [a.id]: { name: '调研负责人', description: '汇总可信结论' },
+      [b.id]: { name: '事实核验', description: '交叉验证来源' },
+    } })
+    expect(store.require<WorkspaceAgent>(owner, 'agent', a.id)).toEqual(before)
+    store.updateAgent(owner, a.id, { name: '改名后的甲' })
+    await finished(runtime.send(owner, c.id, { requestId: randomUUID(), content: '开始调研' }).id)
+    const groupPrompt = requests.filter(r => r.method === 'prompt.submit').at(-1)!.params.text
+    expect(groupPrompt).toContain('@改名后的甲:调研负责人;汇总可信结论')
+    expect(groupPrompt).toContain('@现有乙:事实核验;交叉验证来源')
+    await finished(runtime.send(owner, direct(a.id).id, { requestId: randomUUID(), content: '单聊' }).id)
+    expect(requests.filter(r => r.method === 'prompt.submit').at(-1)!.params.text).not.toContain('汇总可信结论')
+    const changed = store.updateConversation(owner, c.id, { memberIds: [a.id] })
+    expect(changed.memberRoles).toEqual({ [a.id]: { name: '调研负责人', description: '汇总可信结论' } })
+    expect(() => store.updateConversation(owner, c.id, { memberRoles: { [b.id]: { name: '已移除', description: '' } } })).toThrow('群成员或管理员无效')
+  })
   it('isolates roles over the same profile and applies edited rules on the next turn', async () => {
     const a = agent('编辑'),
       b = agent('审查'),