AgentIdentityPanel.vue 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253
  1. <script setup lang="ts">
  2. import { computed, ref, watch } from 'vue'
  3. import type { Profile } from '@shared/types'
  4. import AgentAvatar from '@/components/common/AgentAvatar.vue'
  5. import AppIcon from '@/components/common/AppIcon.vue'
  6. import { listModelCatalog } from '@/api/agentManagement'
  7. import type { ProfileIdentityInput } from '@/api/profiles'
  8. import {
  9. AGENT_MASCOT_COLORS,
  10. AGENT_MASCOT_EXPRESSIONS,
  11. AGENT_MASCOT_SHAPES,
  12. agentIdentityFromProfile,
  13. decodeAgentMascotAvatar,
  14. defaultAgentIdentity,
  15. encodeAgentAvatar,
  16. type AgentAvatarMode,
  17. type AgentMascotExpression,
  18. type AgentMascotShape,
  19. } from '@shared/agentIdentity'
  20. const props = withDefaults(defineProps<{
  21. profile: Profile
  22. busy?: boolean
  23. error?: string
  24. formId?: string
  25. showActions?: boolean
  26. resetVersion?: number
  27. defaultModelLabel?: string
  28. }>(), {
  29. busy: false,
  30. error: '',
  31. formId: undefined,
  32. showActions: true,
  33. resetVersion: 0,
  34. defaultModelLabel: '',
  35. })
  36. const emit = defineEmits<{
  37. save: [input: ProfileIdentityInput]
  38. 'dirty-change': [dirty: boolean]
  39. }>()
  40. const title = ref('')
  41. const avatarDataURL = ref<string | null>(null)
  42. const avatarMode = ref<AgentAvatarMode>('mascot')
  43. const shape = ref<AgentMascotShape>('circle')
  44. const color = ref('#009957')
  45. const expression = ref<AgentMascotExpression>('friendly')
  46. const localError = ref('')
  47. const fileInput = ref<HTMLInputElement>()
  48. const baseline = ref('')
  49. const resolvedDefaultModelLabel = ref('')
  50. let profileGeneration = 0
  51. let modelLoadGeneration = 0
  52. function currentFingerprint(): string {
  53. return JSON.stringify({
  54. title: title.value,
  55. avatarMode: avatarMode.value,
  56. shape: shape.value,
  57. color: color.value,
  58. expression: expression.value,
  59. avatarDataURL: avatarDataURL.value,
  60. })
  61. }
  62. function resetFromProfile() {
  63. profileGeneration += 1
  64. const profile = props.profile
  65. const nextTitle = profile.agentName || profile.displayName || profile.name || ''
  66. const mascot = decodeAgentMascotAvatar(profile.agentAvatar)
  67. const fallback = defaultAgentIdentity(profile.name, nextTitle)
  68. title.value = nextTitle
  69. avatarDataURL.value = profile.agentAvatar?.startsWith('data:image/') ? profile.agentAvatar : null
  70. avatarMode.value = avatarDataURL.value ? 'image' : 'mascot'
  71. shape.value = mascot?.shape ?? fallback.shape
  72. color.value = mascot?.color ?? fallback.color
  73. expression.value = mascot?.expression ?? fallback.expression
  74. baseline.value = currentFingerprint()
  75. localError.value = ''
  76. }
  77. watch([() => props.profile.name, () => props.resetVersion], resetFromProfile, { immediate: true })
  78. watch([() => props.profile.name, () => props.defaultModelLabel], async () => {
  79. const generation = ++modelLoadGeneration
  80. const fallback = props.defaultModelLabel
  81. || [props.profile.provider, props.profile.model].filter(Boolean).join(' / ')
  82. resolvedDefaultModelLabel.value = fallback
  83. try {
  84. const current = (await listModelCatalog(props.profile.name))
  85. .find(provider => provider.isCurrent && provider.currentModel)
  86. if (generation === modelLoadGeneration && current?.currentModel) {
  87. resolvedDefaultModelLabel.value = `${current.slug} / ${current.currentModel}`
  88. }
  89. } catch { /* Keep the Profile payload as a read-only fallback. */ }
  90. }, { immediate: true })
  91. const previewAvatar = computed(() => encodeAgentAvatar({
  92. ...agentIdentityFromProfile({ name: props.profile.name, display_name: title.value }),
  93. displayName: title.value,
  94. avatarMode: avatarMode.value,
  95. shape: shape.value,
  96. color: color.value,
  97. expression: expression.value,
  98. ...(avatarDataURL.value ? { imageDataURL: avatarDataURL.value } : {}),
  99. }))
  100. const identityFingerprint = computed(currentFingerprint)
  101. const dirty = computed(() => identityFingerprint.value !== baseline.value)
  102. watch(dirty, value => emit('dirty-change', value), { immediate: true })
  103. const shapeLabel: Record<AgentMascotShape, string> = { circle: '圆形', square: '方形', triangle: '短三角' }
  104. const expressionLabel: Record<AgentMascotExpression, string> = {
  105. friendly: '友好', focused: '专注', curious: '好奇', calm: '沉稳',
  106. }
  107. function mascotPreview(overrides: Partial<{
  108. shape: AgentMascotShape
  109. color: string
  110. expression: AgentMascotExpression
  111. }> = {}): string {
  112. return encodeAgentAvatar({
  113. ...agentIdentityFromProfile({ name: props.profile.name, display_name: title.value }),
  114. displayName: title.value,
  115. avatarMode: 'mascot',
  116. shape: overrides.shape ?? shape.value,
  117. color: overrides.color ?? color.value,
  118. expression: overrides.expression ?? expression.value,
  119. })
  120. }
  121. function readImage(file: File): Promise<string> {
  122. return new Promise((resolve, reject) => {
  123. const reader = new FileReader()
  124. reader.onload = () => typeof reader.result === 'string' ? resolve(reader.result) : reject(new Error('无法读取图片'))
  125. reader.onerror = () => reject(new Error('无法读取图片'))
  126. reader.readAsDataURL(file)
  127. })
  128. }
  129. function resizeImage(source: string): Promise<string> {
  130. return new Promise((resolve, reject) => {
  131. const image = new Image()
  132. image.onload = () => {
  133. const edge = Math.min(256, Math.max(image.width, image.height))
  134. const ratio = edge / Math.max(image.width, image.height)
  135. const canvas = document.createElement('canvas')
  136. canvas.width = Math.max(1, Math.round(image.width * ratio))
  137. canvas.height = Math.max(1, Math.round(image.height * ratio))
  138. const context = canvas.getContext('2d')
  139. if (!context) return reject(new Error('当前浏览器不支持图片处理'))
  140. context.drawImage(image, 0, 0, canvas.width, canvas.height)
  141. resolve(canvas.toDataURL('image/png'))
  142. }
  143. image.onerror = () => reject(new Error('请选择有效的 PNG、JPEG 或 WebP 图片'))
  144. image.src = source
  145. })
  146. }
  147. async function chooseAvatar(event: Event) {
  148. const file = (event.target as HTMLInputElement).files?.[0]
  149. if (!file) return
  150. const generation = profileGeneration
  151. try {
  152. if (!['image/png', 'image/jpeg', 'image/webp'].includes(file.type)) throw new Error('请选择 PNG、JPEG 或 WebP 图片')
  153. if (file.size > 10 * 1024 * 1024) throw new Error('图片不能超过 10 MB')
  154. const nextAvatar = await resizeImage(await readImage(file))
  155. if (generation !== profileGeneration) return
  156. avatarDataURL.value = nextAvatar
  157. avatarMode.value = 'image'
  158. localError.value = ''
  159. } catch (cause) {
  160. localError.value = cause instanceof Error ? cause.message : '处理头像失败'
  161. } finally {
  162. if (fileInput.value) fileInput.value.value = ''
  163. }
  164. }
  165. function submit() {
  166. const normalized = title.value.trim().replace(/\s+/g, ' ')
  167. if (!normalized) { localError.value = '请输入 Agent 名称'; return }
  168. emit('save', {
  169. title: normalized,
  170. avatarMode: avatarMode.value,
  171. shape: shape.value,
  172. color: color.value,
  173. expression: expression.value,
  174. avatarDataURL: avatarDataURL.value,
  175. })
  176. title.value = normalized
  177. }
  178. </script>
  179. <template>
  180. <form :id="formId" class="identity-form" @submit.prevent="submit">
  181. <div class="identity-avatar">
  182. <AgentAvatar :name="title || profile.name" :avatar="previewAvatar" :size="112" state="idle" />
  183. <div>
  184. <strong>自定义角色</strong>
  185. <small>由夭夭独立保存并同步到 Web 与 iOS,不再读取 Bots mode 的头像。</small>
  186. <p>
  187. <button class="quiet-button" :class="{ active: avatarMode === 'mascot' }" type="button" :disabled="busy" @click="avatarMode = 'mascot'">动态吉祥物</button>
  188. <button class="quiet-button" :class="{ active: avatarMode === 'image' }" type="button" :disabled="busy || !avatarDataURL" @click="avatarMode = 'image'">上传图片</button>
  189. </p>
  190. <p><button class="quiet-button" type="button" :disabled="busy" @click="fileInput?.click()"><AppIcon name="image" :size="14" />选择图片</button><button v-if="avatarDataURL" class="quiet-button" type="button" :disabled="busy" @click="avatarDataURL = null; avatarMode = 'mascot'">移除图片</button></p>
  191. </div>
  192. <input ref="fileInput" class="sr-only" type="file" accept="image/png,image/jpeg,image/webp" @change="chooseAvatar" />
  193. </div>
  194. <label>名称<input v-model="title" :disabled="busy" maxlength="100" autocomplete="off" /></label>
  195. <section v-if="avatarMode === 'mascot'" class="mascot-controls">
  196. <div>
  197. <strong>形状</strong>
  198. <div class="mascot-grid mascot-grid--shape">
  199. <button v-for="candidate in AGENT_MASCOT_SHAPES" :key="candidate" type="button" :class="{ selected: shape === candidate }" :aria-pressed="shape === candidate" @click="shape = candidate">
  200. <AgentAvatar :name="title || profile.name" :avatar="mascotPreview({ shape: candidate })" :size="44" />
  201. <span>{{ shapeLabel[candidate] }}</span>
  202. </button>
  203. </div>
  204. </div>
  205. <div>
  206. <strong>基础表情</strong>
  207. <div class="mascot-grid mascot-grid--expression">
  208. <button v-for="candidate in AGENT_MASCOT_EXPRESSIONS" :key="candidate" type="button" :class="{ selected: expression === candidate }" :aria-pressed="expression === candidate" @click="expression = candidate">
  209. <AgentAvatar :name="title || profile.name" :avatar="mascotPreview({ expression: candidate })" :size="38" />
  210. <span>{{ expressionLabel[candidate] }}</span>
  211. </button>
  212. </div>
  213. </div>
  214. <div>
  215. <strong>颜色</strong>
  216. <div class="color-grid">
  217. <button v-for="candidate in AGENT_MASCOT_COLORS" :key="candidate" type="button" :class="{ selected: color === candidate }" :style="{ backgroundColor: candidate }" :aria-label="`使用颜色 ${candidate}`" :aria-pressed="color === candidate" @click="color = candidate" />
  218. </div>
  219. </div>
  220. </section>
  221. <div class="identity-default-model">
  222. <strong>默认全局模型</strong>
  223. <span>{{ resolvedDefaultModelLabel || '服务器未返回默认模型' }}</span>
  224. <small>仅影响之后新建的会话;已有会话保留自己的模型。</small>
  225. </div>
  226. <p v-if="localError || error" class="identity-error" role="alert">{{ localError || error }}</p>
  227. <footer v-if="showActions" class="identity-actions">
  228. <slot name="actions">
  229. <button class="primary-button" type="submit" :disabled="busy">{{ busy ? '正在同步…' : '保存并同步' }}</button>
  230. </slot>
  231. </footer>
  232. </form>
  233. </template>
  234. <style scoped>
  235. .identity-form{display:grid;max-width:720px}.identity-avatar{display:flex;align-items:flex-start;gap:24px;margin-bottom:32px;padding:0;background:transparent}.identity-avatar>div{display:grid;gap:7px;padding-top:4px}.identity-avatar strong{font-size:16px}.identity-avatar small{max-width:480px;color:var(--text-muted);font-size:13px;line-height:1.55}.identity-avatar p{display:flex;flex-wrap:wrap;gap:8px;margin:6px 0 0}.identity-form label{display:grid;max-width:480px;gap:8px;color:var(--text-secondary);font-size:14px;font-weight:650}.identity-form input:not(.sr-only){width:100%;min-height:46px;box-sizing:border-box;padding:0 13px;border:1px solid var(--line);border-radius:9px;outline:0;background:var(--surface-raised);color:var(--text-primary);font:14px var(--font-ui);font-weight:400}.identity-form input:focus{border-color:var(--line-strong);box-shadow:0 0 0 3px var(--focus-ring)}.quiet-button,.primary-button,.identity-actions :slotted(.quiet-button),.identity-actions :slotted(.primary-button){display:inline-flex;min-height:40px;align-items:center;justify-content:center;gap:7px;padding:0 14px;border:0;border-radius:9px;cursor:pointer;font-size:13px;font-weight:600}.quiet-button,.identity-actions :slotted(.quiet-button){border:1px solid var(--line);background:var(--surface-raised);color:var(--text-secondary)}.quiet-button.active{border-color:var(--accent);color:var(--text-primary);box-shadow:0 0 0 2px var(--focus-ring)}.primary-button,.identity-actions :slotted(.primary-button){min-width:118px;background:var(--accent);color:var(--text-on-solid)}.quiet-button:disabled,.primary-button:disabled,.identity-actions :slotted(.quiet-button:disabled),.identity-actions :slotted(.primary-button:disabled){cursor:wait;opacity:.5}.identity-error{margin:14px 0 0;color:var(--danger);font-size:13px}.identity-actions{display:flex;justify-content:flex-end;gap:10px;margin-top:30px;padding-top:18px;border-top:1px solid var(--line)}.sr-only{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}.mascot-controls{display:grid;gap:22px;margin-top:24px}.mascot-controls>div{display:grid;gap:10px}.mascot-controls strong{color:var(--text-secondary);font-size:13px}.mascot-grid{display:grid;gap:9px}.mascot-grid--shape{grid-template-columns:repeat(3,minmax(0,1fr))}.mascot-grid--expression{grid-template-columns:repeat(4,minmax(0,1fr))}.mascot-grid button{display:flex;min-height:78px;align-items:center;justify-content:center;gap:9px;padding:8px;border:1px solid var(--line);border-radius:12px;background:var(--surface-soft);color:var(--text-secondary);cursor:pointer}.mascot-grid button.selected{border-color:var(--accent);background:var(--surface-raised);color:var(--text-primary);box-shadow:0 0 0 2px var(--focus-ring)}.mascot-grid button span{font-size:12px;font-weight:650}.color-grid{display:flex;flex-wrap:wrap;gap:11px}.color-grid button{width:38px;height:38px;border:3px solid transparent;border-radius:50%;cursor:pointer;box-shadow:inset 0 0 0 1px rgb(255 255 255 / .2)}.color-grid button.selected{border-color:var(--surface);outline:2px solid var(--accent)}
  236. @media(max-width:600px){.identity-avatar{align-items:flex-start;flex-direction:column}.identity-form label{max-width:none}}
  237. .identity-default-model{display:grid;max-width:480px;gap:5px;margin-top:20px;padding:13px;border:1px solid var(--line);border-radius:9px;background:var(--surface-soft)}
  238. .identity-default-model strong{color:var(--text-secondary);font-size:13px}.identity-default-model span{font:12px var(--font-code);overflow-wrap:anywhere}.identity-default-model small{color:var(--text-muted);font-size:11px}
  239. </style>