Browse Source

统一角色偏好样式并稳定空态预览

Samien 1 month ago
parent
commit
67d4f01

+ 2 - 2
src/client/components/messages/MessageTimeline.vue

@@ -236,7 +236,7 @@ defineExpose({ scrollToMessage, scrollToAnchor, scrollToBottom, isFollowingBotto
         <div v-for="n in 4" :key="n" :class="{ user: n % 2 === 0 }"><i /><span /></div>
       </div>
       <div v-else-if="!messages.length && emptyLogo" class="new-chat-empty">
-        <img class="new-chat-empty__logo" src="/brand/AppIcon-1024.png" alt="" aria-hidden="true" />
+        <img class="new-chat-empty__logo" src="/brand/AppIcon-1024.png" width="1024" height="1024" alt="" aria-hidden="true" />
         <p>聊点什么</p>
       </div>
       <EmptyState v-else-if="!messages.length" icon="chat" :title="emptyTitle" :description="emptyDescription" />
@@ -438,7 +438,7 @@ defineExpose({ scrollToMessage, scrollToAnchor, scrollToBottom, isFollowingBotto
 @keyframes reveal-message { 0%, 28% { box-shadow: 0 0 0 5px var(--focus-ring); } 100% { box-shadow: 0 0 0 0 transparent; } }
 .jump-bottom { display: flex; position: absolute; z-index: 8; bottom: 8px; left: 50%; align-items: center; gap: 5px; padding: 6px 10px; border: 1px solid var(--line); border-radius: 999px; background: var(--surface-raised); color: var(--text-secondary); cursor: pointer; font-size: 10px; box-shadow: 0 6px 18px rgba(0,0,0,.1); transform: translateX(-50%); }
 .new-chat-empty { display: flex; min-height: 100%; align-items: center; justify-content: center; gap: 18px; padding: 12vh 24px; box-sizing: border-box; flex-direction: column; color: var(--text-muted); text-align: center; }
-.new-chat-empty__logo { display: block; width: min(180px, 34vw); height: auto; opacity: .18; filter: grayscale(1); }
+.new-chat-empty__logo { display: block; width: min(180px, 34vw); height: auto; aspect-ratio: 1; opacity: .18; filter: grayscale(1); }
 .new-chat-empty p { margin: 0; color: var(--text-secondary); font-size: 17px; font-weight: 520; letter-spacing: .02em; }
 .message-skeletons { width: min(760px, 100%); margin: 0 auto; padding: 35px 24px; }.message-skeletons div { display: flex; gap: 10px; margin: 0 0 25px; }.message-skeletons div.user { justify-content: flex-end; }.message-skeletons i { width: 27px; height: 27px; border-radius: 9px; background: var(--surface-hover); }.message-skeletons span { width: min(480px, 70%); height: 64px; border-radius: 12px; background: linear-gradient(90deg, var(--surface-soft), var(--surface-hover), var(--surface-soft)); background-size: 200% 100%; animation: shimmer 1.5s linear infinite; }.message-skeletons .user i { display: none; }.message-skeletons .user span { width: 44%; height: 46px; }
 @keyframes shimmer { to { background-position: -200% 0; } }

+ 36 - 10
src/client/components/workspace/BotProfileDialog.vue

@@ -15,9 +15,22 @@ interface Draft {name:string;avatar:string;instructions:string;description?:stri
 type Page='overview'|'identity'|'rules'|'usage'|'modelSettings'|'models'|'memory'|'permissions'
 const props=defineProps<{agent:WorkspaceAgent;draft:Draft;sources:WorkspaceSource[];profiles:Profile[];agents:WorkspaceAgent[];conversations:WorkspaceConversation[];conversationId?:string;isAdmin?:boolean;knowledgeEnabled?:boolean;busy?:boolean;error?:string}>()
 const emit=defineEmits<{close:[];save:[draft:Draft & {approvalPolicy:WorkspaceApprovalPolicy}];changed:[]}>()
-const draft=reactive({...props.draft,approvalPolicy:props.draft.approvalPolicy??'ask'})
+const draft=reactive({...props.draft,voice:props.draft.voice??'',voiceCustom:props.draft.voiceCustom??'',actBias:props.draft.actBias??'',approvalPolicy:props.draft.approvalPolicy??'ask'})
 const page=ref<Page>('overview'),query=ref(''),localError=ref(''),modelDirty=ref(false),modelSaving=ref(false),memoryDirty=ref(false)
 const usage=ref<WorkspaceAgentUsage|null>(null),usageLoading=ref(false),usageError=ref('')
+const voiceOptions:ReadonlyArray<{value:NonNullable<Draft['voice']>;label:string;description:string}>=[
+  {value:'',label:'未指定',description:'由模型根据对话自行判断。'},
+  {value:'concise',label:'简洁专业',description:'直接、克制,重点清晰。'},
+  {value:'casual',label:'轻松随和',description:'自然亲切,减少正式感。'},
+  {value:'rigorous',label:'严谨细致',description:'完整说明依据与边界。'},
+  {value:'custom',label:'自定义',description:'使用下方描述的专属语气。'},
+]
+const actionOptions:ReadonlyArray<{value:NonNullable<Draft['actBias']>;label:string;description:string}>=[
+  {value:'',label:'未指定',description:'由模型自行判断。'},
+  {value:'ask_first',label:'先问再做',description:'任务不明确先澄清,确认后执行。'},
+  {value:'ask_key_then_act',label:'问关键问题即行动',description:'最多问 2–3 个关键问题,职责清楚就开工。'},
+  {value:'act_now',label:'直接行动',description:'合理默认即开工,重要分叉才询问。'},
+]
 const formatTokens=(value:number)=>value.toLocaleString('zh-CN')
 onMounted(async()=>{
   usageLoading.value=true
@@ -101,13 +114,16 @@ function submit(){
             <ul v-if="draft.antiJobs?.length" class="anti-list"><li v-for="item in draft.antiJobs" :key="item"><span>{{ item }}</span><button type="button" :disabled="busy||readOnly" @click="removeAntiJob(item)" :aria-label="`移除 ${item}`"><AppIcon name="close" :size="12"/></button></li></ul>
             <div class="anti-input"><input v-model="antiDraft" maxlength="120" :readonly="readOnly" :disabled="busy||(draft.antiJobs?.length??0)>=8" placeholder="例如:绝不直接发送邮件或提交表单" @keydown.enter.prevent="addAntiJob"/><button type="button" :disabled="busy||readOnly||!antiDraft.trim()" @click="addAntiJob">添加</button></div>
           </fieldset>
-          <label>语气<select v-model="draft.voice" :disabled="busy||readOnly"><option value="">未指定</option><option value="concise">简洁专业</option><option value="casual">轻松随和</option><option value="rigorous">严谨细致</option><option value="custom">自定义</option></select></label>
+          <fieldset class="persona-field persona-choice-group"><legend>语气</legend>
+            <div class="persona-options">
+              <label v-for="option in voiceOptions" :key="option.value" class="radio-row" :class="{selected:draft.voice===option.value}"><input v-model="draft.voice" type="radio" :value="option.value" :disabled="busy||readOnly"/><span><strong>{{option.label}}</strong><small>{{option.description}}</small></span></label>
+            </div>
+          </fieldset>
           <label v-if="draft.voice==='custom'">自定义语气<textarea v-model="draft.voiceCustom" rows="2" maxlength="200" :readonly="readOnly" :disabled="busy" placeholder="用一句话描述语气,例如:随意一点,像疯狂科学家,多用短句。"/></label>
-          <fieldset class="persona-field"><legend>行动偏好</legend>
-            <label class="radio-row"><input v-model="draft.actBias" type="radio" value="" :disabled="busy||readOnly"/><span>未指定<small>由模型自行判断。</small></span></label>
-            <label class="radio-row"><input v-model="draft.actBias" type="radio" value="ask_first" :disabled="busy||readOnly"/><span>先问再做<small>任务不明确先澄清,确认后执行。</small></span></label>
-            <label class="radio-row"><input v-model="draft.actBias" type="radio" value="ask_key_then_act" :disabled="busy||readOnly"/><span>问关键问题即行动<small>最多问 2–3 个关键问题,职责清楚就开工。</small></span></label>
-            <label class="radio-row"><input v-model="draft.actBias" type="radio" value="act_now" :disabled="busy||readOnly"/><span>直接行动<small>合理默认即开工,重要分叉才询问。</small></span></label>
+          <fieldset class="persona-field persona-choice-group"><legend>行动偏好</legend>
+            <div class="persona-options">
+              <label v-for="option in actionOptions" :key="option.value" class="radio-row" :class="{selected:draft.actBias===option.value}"><input v-model="draft.actBias" type="radio" :value="option.value" :disabled="busy||readOnly"/><span><strong>{{option.label}}</strong><small>{{option.description}}</small></span></label>
+            </div>
           </fieldset>
           <details class="persona-advanced"><summary>高级:完整提示词</summary><label>角色提示词与规则<textarea v-model="draft.instructions" rows="10" maxlength="24000" :readonly="readOnly" :disabled="busy" placeholder="描述这个 Bot 的职责、回答方式和工作规则。"/></label><p class="profile-note">结构化规范会自动插入在完整提示词之前;这些规则属于当前 Bot,会在它的单聊和群聊中使用。</p></details>
         </form>
@@ -169,8 +185,18 @@ function submit(){
 .anti-list li button{border:0;background:transparent;min-height:24px;padding:2px;cursor:pointer;color:var(--text-muted)}
 .anti-input{display:flex;gap:8px}
 .anti-input input{flex:1}
-.radio-row{display:flex;align-items:flex-start;gap:8px}
-.radio-row input{width:16px;height:16px;margin-top:3px;accent-color:var(--accent);flex:none}
-.radio-row span{display:grid;gap:2px}
+.persona-choice-group{gap:10px;padding:12px;background:color-mix(in srgb,var(--surface-soft) 55%,transparent)}
+.persona-options{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px}
+.radio-row{display:flex;align-items:flex-start;gap:10px;min-width:0;min-height:66px;padding:11px 12px;box-sizing:border-box;border:1px solid var(--line);border-radius:9px;background:var(--surface);cursor:pointer;transition:border-color 140ms ease,background-color 140ms ease,box-shadow 140ms ease}
+.radio-row:hover:not(:has(input:disabled)){border-color:var(--line-strong);background:var(--surface-raised)}
+.radio-row.selected{border-color:var(--accent);background:color-mix(in srgb,var(--accent) 7%,var(--surface));box-shadow:0 0 0 1px color-mix(in srgb,var(--accent) 16%,transparent)}
+.radio-row:has(input:focus-visible){outline:2px solid var(--accent);outline-offset:2px}.radio-row input:focus-visible{outline:none}
+.radio-row:has(input:disabled){cursor:default;opacity:.55}
+.radio-row input{width:16px;height:16px;min-height:16px;margin:2px 0 0;padding:0;accent-color:var(--accent);flex:none}
+.radio-row span{display:grid;gap:4px;min-width:0}
+.radio-row strong{font-size:13px;line-height:1.35;font-weight:600;color:var(--text-primary)}
+.radio-row small{font-size:11.5px;line-height:1.5;color:var(--text-muted)}
 .persona-advanced summary{cursor:pointer;font-size:13px;color:var(--text-secondary);margin-bottom:8px}
+@media(max-width:600px){.persona-options{grid-template-columns:1fr}.radio-row{min-height:60px}}
+@media(prefers-reduced-motion:reduce){.radio-row{transition:none}}
 </style>

+ 6 - 1
src/client/components/workspace/LocalVmChatPanel.vue

@@ -31,7 +31,12 @@ const previewTargets=computed(()=>{
  return list
 })
 const previewKey=ref('')
-const previewTarget=computed(()=>previewKey.value?previewTargets.value.find(t=>t.key===previewKey.value):previewTargets.value.find(t=>t.backend!=='desktop'||desktopHosts.value.find(h=>h.id===t.host)?.online))
+const automaticPreviewTarget=computed(()=>{
+ const vm=previewTargets.value.find(target=>target.backend==='vm')
+ if(vm&&state.value?.enabled&&state.value.container==='running'&&state.value.ready&&!state.value.problem)return vm
+ return previewTargets.value.find(target=>target.backend!=='desktop'||desktopHosts.value.some(host=>host.id===target.host&&host.online))
+})
+const previewTarget=computed(()=>previewKey.value?previewTargets.value.find(t=>t.key===previewKey.value):automaticPreviewTarget.value)
 const previewLoading=ref(false)
 const previewEmptyText=computed(()=>{
  const target=previewTarget.value

+ 2 - 2
src/client/components/workspace/WorkspaceMessageTimeline.vue

@@ -315,7 +315,7 @@ defineExpose({ scrollToMessage, scrollToAnchor, scrollToBottom, isFollowingBotto
         <div v-for="n in 4" :key="n" :class="{ user: n % 2 === 0 }"><i /><span /></div>
       </div>
       <div v-else-if="!messages.length && emptyLogo" class="new-chat-empty">
-        <img class="new-chat-empty__logo" src="/brand/AppIcon-1024.png" alt="" aria-hidden="true" />
+        <img class="new-chat-empty__logo" src="/brand/AppIcon-1024.png" width="1024" height="1024" alt="" aria-hidden="true" />
         <p>聊点什么</p>
       </div>
       <EmptyState v-else-if="!messages.length" icon="chat" :title="emptyTitle" :description="emptyDescription" />
@@ -533,7 +533,7 @@ defineExpose({ scrollToMessage, scrollToAnchor, scrollToBottom, isFollowingBotto
 @keyframes reveal-message { 0%, 28% { box-shadow: 0 0 0 5px var(--focus-ring); } 100% { box-shadow: 0 0 0 0 transparent; } }
 .jump-bottom { display: flex; position: absolute; z-index: 8; bottom: 8px; left: 50%; align-items: center; gap: 5px; padding: 6px 10px; border: 1px solid var(--line); border-radius: 999px; background: var(--surface-raised); color: var(--text-secondary); cursor: pointer; font-size: 10px; box-shadow: 0 6px 18px rgba(0,0,0,.1); transform: translateX(-50%); }
 .new-chat-empty { display: flex; min-height: 100%; align-items: center; justify-content: center; gap: 18px; padding: 12vh 24px; box-sizing: border-box; flex-direction: column; color: var(--text-muted); text-align: center; }
-.new-chat-empty__logo { display: block; width: min(180px, 34vw); height: auto; opacity: .18; filter: grayscale(1); }
+.new-chat-empty__logo { display: block; width: min(180px, 34vw); height: auto; aspect-ratio: 1; opacity: .18; filter: grayscale(1); }
 .new-chat-empty p { margin: 0; color: var(--text-secondary); font-size: 17px; font-weight: 520; letter-spacing: .02em; }
 .message-skeletons { width: min(760px, 100%); margin: 0 auto; padding: 35px 24px; }.message-skeletons div { display: flex; gap: 10px; margin: 0 0 25px; }.message-skeletons div.user { justify-content: flex-end; }.message-skeletons i { width: 27px; height: 27px; border-radius: 9px; background: var(--surface-hover); }.message-skeletons span { width: min(480px, 70%); height: 64px; border-radius: 12px; background: linear-gradient(90deg, var(--surface-soft), var(--surface-hover), var(--surface-soft)); background-size: 200% 100%; animation: shimmer 1.5s linear infinite; }.message-skeletons .user i { display: none; }.message-skeletons .user span { width: 44%; height: 46px; }
 @keyframes shimmer { to { background-position: -200% 0; } }

+ 2 - 1
src/client/stores/chat.ts

@@ -629,7 +629,8 @@ export const useChatStore = defineStore('chat', () => {
     // REST owns history, but an owned chat also needs a runtime subscription:
     // another device can start a turn while this viewer has never sent one.
     // History-only sessions and unsent drafts must remain unattached.
-    if (!state.historySynced) await loadHistory(state)
+    if (sessionId.startsWith('draft-')) state.historySynced = true
+    else if (!state.historySynced) await loadHistory(state)
     const refreshed = sessions.value.find(item => item.id === sessionId && item.profile === selectedProfile)
     syncSelectedModel(refreshed?.model, refreshed?.provider)
     if (refreshed?.owned === true && !sessionId.startsWith('draft-')

+ 1 - 1
src/client/views/ChatView.vue

@@ -430,7 +430,7 @@ watch(() => chat.activeSessionId, async id => {
         :messages="messages"
         :title="activeSession?.title || '新会话'"
         :subtitle="activeSession ? `${activeSession.profile || auth.activeProfile?.name || ''} · ${activeSession.model || chat.selectedModel?.name || '默认模型'}` : '选择会话或直接开始输入'"
-        :loading="chat.activeRouteState?.isLoadingHistory || chat.isLoading"
+        :loading="chat.activeRouteState?.isLoadingHistory"
         :loading-older="chat.activeRouteState?.isLoadingHistory"
         :has-older="chat.hasMoreBefore"
         :connected="connected"

+ 19 - 2
tests/client/botProfileDialog.test.ts

@@ -41,12 +41,29 @@ it('keeps identity drafts on the right-clicked Bot while the account has another
   expect(identity.props('showDefaultModel')).toBe(false)
   await view.get('input[maxlength="100"]').setValue('新的名称')
   await page('角色与规则').trigger('click')
-  await view.get('textarea').setValue('新的规则')
+  await view.get('.persona-advanced summary').trigger('click')
+  await view.get('textarea[maxlength="24000"]').setValue('新的规则')
   await view.get('.profile-save').trigger('click')
-  expect(view.emitted('save')).toEqual([[{ name: '新的名称', avatar: '', instructions: '新的规则', source: '["local","ops/team"]', canManageTeam: false, canCollaborate: true, memoryEnabled: true, approvalPolicy: 'ask' }]])
+  expect(view.emitted('save')?.[0]?.[0]).toMatchObject({ name: '新的名称', avatar: '', instructions: '新的规则', voice: '', actBias: '', source: '["local","ops/team"]', canManageTeam: false, canCollaborate: true, memoryEnabled: true, approvalPolicy: 'ask' })
   expect(agent.name).toBe('目标机器人')
 })
 
+it('uses the same selectable-card treatment for tone and action preference', async () => {
+  const view = open()
+  await page('角色与规则').trigger('click')
+  const groups = view.findAll('.persona-choice-group')
+  expect(groups).toHaveLength(2)
+  expect(groups[0].findAll('.radio-row')).toHaveLength(5)
+  expect(groups[1].findAll('.radio-row')).toHaveLength(4)
+  expect(groups[0].get('.radio-row.selected').text()).toContain('未指定')
+  expect(groups[1].get('.radio-row.selected').text()).toContain('未指定')
+
+  await groups[0].find('input[value="rigorous"]').setValue(true)
+  await groups[1].find('input[value="act_now"]').setValue(true)
+  expect(groups[0].get('.radio-row.selected').text()).toContain('严谨细致')
+  expect(groups[1].get('.radio-row.selected').text()).toContain('直接行动')
+})
+
 it('uses the saved target Profile for model services and blocks an unsaved source switch', async () => {
   const view = open()
   await page('模型与 Provider').trigger('click')

+ 12 - 0
tests/client/chatModelSync.test.ts

@@ -57,6 +57,18 @@ describe('chat model realtime synchronization', () => {
     setActivePinia(createPinia())
   })
 
+  it('keeps an unsent draft in its stable empty state without requesting history', async () => {
+    const chat = useChatStore()
+    const id = chat.createSession('alpha')
+    chat.clearSelection()
+
+    await chat.selectSession(id, 'alpha')
+
+    expect(sessionsApi.getMessages).not.toHaveBeenCalled()
+    expect(chat.activeRouteState?.historySynced).toBe(true)
+    expect(chat.activeRouteState?.isLoadingHistory).toBe(false)
+  })
+
   it('applies another viewer model switch to the active selector and session summary', () => {
     const chat = useChatStore()
     const oldModel = { id: 'model-a', name: 'Model A', provider: 'provider-a' }

+ 3 - 1
tests/client/computerTargetSelection.test.ts

@@ -38,6 +38,7 @@ it('scopes an unavailable VM to its own settings while the server preview remain
  const wrapper=mount(Preview,{props:{agents:[agent],isAdmin:true,active:true},global:{stubs}})
  try{
   await flushPromises()
+  expect(wrapper.get('[aria-label="显示的桌面"] option:checked').text()).toBe('自动 · 服务器 · 服务器')
   expect(wrapper.get('.preview img').attributes('src')).toContain('base64,local')
   expect(wrapper.find('.panel-body > .problem').exists()).toBe(false)
   expect(wrapper.get('.computer-options [aria-label="本地虚拟机状态"]').text()).toBe(vmProblem)
@@ -54,7 +55,8 @@ it('refreshes the selected preview immediately and never replaces it with a late
  const wrapper=mount(Preview,{props:{agents:[agent],isAdmin:true,active:true},global:{stubs}})
  try{
   await flushPromises()
-  expect(wrapper.get('.preview img').attributes('src')).toContain('base64,local')
+  expect(wrapper.get('[aria-label="显示的桌面"] option:checked').text()).toBe('自动 · 虚拟环境')
+  expect(wrapper.get('.preview img').attributes('src')).toContain('base64,vm')
   let resolveClient!:(value:unknown)=>void
   readFrame=key=>key==='client-mac'?new Promise(resolve=>{resolveClient=resolve}):screenshot(key)
   await wrapper.get('[aria-label="显示的桌面"]').setValue('desktop:client-mac')

+ 16 - 0
tests/client/typingIndicator.test.ts

@@ -115,6 +115,22 @@ describe('message timeline typing state', () => {
     wrapper.unmount()
   })
 
+  it.each(timelines)('reserves the empty logo aspect ratio before the image loads ($name)', ({ component }) => {
+    const wrapper = mount(component, {
+      props: {
+        ...(component === WorkspaceMessageTimeline ? { identity: 'empty-timeline' } : {}),
+        messages: [],
+        emptyLogo: true,
+      },
+      global: { stubs },
+    })
+
+    const logo = wrapper.get('.new-chat-empty__logo')
+    expect(logo.attributes()).toMatchObject({ width: '1024', height: '1024' })
+    expect(wrapper.text()).toContain('聊点什么')
+    wrapper.unmount()
+  })
+
   it.each(timelines)('falls back from streaming agent to history agent and default agent ($name)', ({ component }) => {
     const streaming = mountTimeline(component, [
       user,