AgentIdentityPanel.vue 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158
  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. const props = withDefaults(defineProps<{
  8. profile: Profile
  9. busy?: boolean
  10. error?: string
  11. formId?: string
  12. showActions?: boolean
  13. resetVersion?: number
  14. defaultModelLabel?: string
  15. }>(), {
  16. busy: false,
  17. error: '',
  18. formId: undefined,
  19. showActions: true,
  20. resetVersion: 0,
  21. defaultModelLabel: '',
  22. })
  23. const emit = defineEmits<{
  24. save: [input: { title: string; avatarDataURL?: string | null }]
  25. 'dirty-change': [dirty: boolean]
  26. }>()
  27. const title = ref('')
  28. const avatarDataURL = ref<string | null>(null)
  29. const avatarTouched = ref(false)
  30. const localError = ref('')
  31. const fileInput = ref<HTMLInputElement>()
  32. const baseline = ref<{ title: string; avatarDataURL: string | null }>({ title: '', avatarDataURL: null })
  33. const resolvedDefaultModelLabel = ref('')
  34. let profileGeneration = 0
  35. let modelLoadGeneration = 0
  36. function resetFromProfile() {
  37. profileGeneration += 1
  38. const profile = props.profile
  39. const nextTitle = profile.agentName || profile.displayName || profile.name || ''
  40. const nextAvatarDataURL = profile.agentAvatar || null
  41. title.value = nextTitle
  42. avatarDataURL.value = nextAvatarDataURL
  43. baseline.value = { title: nextTitle, avatarDataURL: nextAvatarDataURL }
  44. avatarTouched.value = false
  45. localError.value = ''
  46. }
  47. watch([() => props.profile.name, () => props.resetVersion], resetFromProfile, { immediate: true })
  48. watch([() => props.profile.name, () => props.defaultModelLabel], async () => {
  49. const generation = ++modelLoadGeneration
  50. const fallback = props.defaultModelLabel
  51. || [props.profile.provider, props.profile.model].filter(Boolean).join(' / ')
  52. resolvedDefaultModelLabel.value = fallback
  53. try {
  54. const current = (await listModelCatalog(props.profile.name))
  55. .find(provider => provider.isCurrent && provider.currentModel)
  56. if (generation === modelLoadGeneration && current?.currentModel) {
  57. resolvedDefaultModelLabel.value = `${current.slug} / ${current.currentModel}`
  58. }
  59. } catch { /* Keep the Profile payload as a read-only fallback. */ }
  60. }, { immediate: true })
  61. const dirty = computed(() => title.value !== baseline.value.title || avatarDataURL.value !== baseline.value.avatarDataURL)
  62. watch(dirty, value => emit('dirty-change', value), { immediate: true })
  63. function readImage(file: File): Promise<string> {
  64. return new Promise((resolve, reject) => {
  65. const reader = new FileReader()
  66. reader.onload = () => typeof reader.result === 'string' ? resolve(reader.result) : reject(new Error('无法读取图片'))
  67. reader.onerror = () => reject(new Error('无法读取图片'))
  68. reader.readAsDataURL(file)
  69. })
  70. }
  71. function resizeImage(source: string): Promise<string> {
  72. return new Promise((resolve, reject) => {
  73. const image = new Image()
  74. image.onload = () => {
  75. const edge = Math.min(256, Math.max(image.width, image.height))
  76. const ratio = edge / Math.max(image.width, image.height)
  77. const canvas = document.createElement('canvas')
  78. canvas.width = Math.max(1, Math.round(image.width * ratio))
  79. canvas.height = Math.max(1, Math.round(image.height * ratio))
  80. const context = canvas.getContext('2d')
  81. if (!context) return reject(new Error('当前浏览器不支持图片处理'))
  82. context.drawImage(image, 0, 0, canvas.width, canvas.height)
  83. resolve(canvas.toDataURL('image/png'))
  84. }
  85. image.onerror = () => reject(new Error('请选择有效的 PNG、JPEG 或 WebP 图片'))
  86. image.src = source
  87. })
  88. }
  89. async function chooseAvatar(event: Event) {
  90. const file = (event.target as HTMLInputElement).files?.[0]
  91. if (!file) return
  92. const generation = profileGeneration
  93. try {
  94. if (!['image/png', 'image/jpeg', 'image/webp'].includes(file.type)) throw new Error('请选择 PNG、JPEG 或 WebP 图片')
  95. if (file.size > 10 * 1024 * 1024) throw new Error('图片不能超过 10 MB')
  96. const nextAvatar = await resizeImage(await readImage(file))
  97. if (generation !== profileGeneration) return
  98. avatarDataURL.value = nextAvatar
  99. avatarTouched.value = true
  100. localError.value = ''
  101. } catch (cause) {
  102. localError.value = cause instanceof Error ? cause.message : '处理头像失败'
  103. } finally {
  104. if (fileInput.value) fileInput.value.value = ''
  105. }
  106. }
  107. function submit() {
  108. const normalized = title.value.trim().replace(/\s+/g, ' ')
  109. if (!normalized) { localError.value = '请输入 Agent 名称'; return }
  110. emit('save', { title: normalized, ...(avatarTouched.value ? { avatarDataURL: avatarDataURL.value } : {}) })
  111. title.value = normalized
  112. }
  113. </script>
  114. <template>
  115. <form :id="formId" class="identity-form" @submit.prevent="submit">
  116. <div class="identity-avatar">
  117. <AgentAvatar :name="title || profile.name" :avatar="avatarDataURL || ''" :size="104" />
  118. <div>
  119. <strong>Bots 头像</strong>
  120. <small>会压缩为 256 px,并写入原生 Agent 头像;所有设备同步。</small>
  121. <p>
  122. <button class="quiet-button" type="button" :disabled="busy" @click="fileInput?.click()"><AppIcon name="image" :size="14" />选择图片</button>
  123. <button v-if="avatarDataURL" class="quiet-button" type="button" :disabled="busy" @click="avatarDataURL = null; avatarTouched = true">移除</button>
  124. </p>
  125. </div>
  126. <input ref="fileInput" class="sr-only" type="file" accept="image/png,image/jpeg,image/webp" @change="chooseAvatar" />
  127. </div>
  128. <label>名称<input v-model="title" :disabled="busy" maxlength="100" autocomplete="off" /></label>
  129. <div class="identity-default-model">
  130. <strong>默认全局模型</strong>
  131. <span>{{ resolvedDefaultModelLabel || '服务器未返回默认模型' }}</span>
  132. <small>仅影响之后新建的会话;已有会话保留自己的模型。</small>
  133. </div>
  134. <p v-if="localError || error" class="identity-error" role="alert">{{ localError || error }}</p>
  135. <footer v-if="showActions" class="identity-actions">
  136. <slot name="actions">
  137. <button class="primary-button" type="submit" :disabled="busy">{{ busy ? '正在同步…' : '保存并同步' }}</button>
  138. </slot>
  139. </footer>
  140. </form>
  141. </template>
  142. <style scoped>
  143. .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;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)}.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}
  144. @media(max-width:600px){.identity-avatar{align-items:flex-start;flex-direction:column}.identity-form label{max-width:none}}
  145. .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)}
  146. .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}
  147. </style>