chenbhao commited on
Commit
be4d8ed
·
1 Parent(s): d7d3273

feat: set ai friend profile picture and background add Edge glow effect

Browse files

总结一下四个任务的变更:

1. companionStore.ts — 新增 avatarUrl / backgroundUrl 状态及对应的 setter
2. CompanionVideoPanel.tsx — 中间头像区域支持上传图片(hover 显示编辑按钮),背景支持上传替换(左上角按钮),生成时背景不再变色
3. CompanionTranscript.tsx — 对话容器和输入框在 AI 回复时(generating || speaking)边框变为紫色发光效果
4. Companion.tsx — 将 generating / speaking 传递给子组件

desktop/src/components/companion/CompanionTranscript.tsx CHANGED
@@ -4,11 +4,12 @@ import { useTranslation } from '../../i18n'
4
  interface CompanionTranscriptProps {
5
  transcript: string
6
  fullTranscript: string
 
7
  onSendText: (text: string) => void
8
  disabled?: boolean
9
  }
10
 
11
- export function CompanionTranscript({ transcript, fullTranscript, onSendText, disabled }: CompanionTranscriptProps) {
12
  const scrollRef = useRef<HTMLDivElement>(null)
13
  const [inputValue, setInputValue] = useState('')
14
  const t = useTranslation()
@@ -38,7 +39,11 @@ export function CompanionTranscript({ transcript, fullTranscript, onSendText, di
38
  {/* Transcript display */}
39
  <div
40
  ref={scrollRef}
41
- className="max-h-[150px] overflow-y-auto rounded-2xl bg-black/40 backdrop-blur-md border border-white/10 p-4 mb-2 scrollbar-thin"
 
 
 
 
42
  >
43
  {fullTranscript && (
44
  <div className="text-white/40 text-xs mb-2 whitespace-pre-wrap leading-relaxed">
@@ -59,7 +64,11 @@ export function CompanionTranscript({ transcript, fullTranscript, onSendText, di
59
  </div>
60
 
61
  {/* Text input row */}
62
- <div className="flex items-center gap-2 rounded-2xl bg-black/40 backdrop-blur-md border border-white/10 px-4 py-2">
 
 
 
 
63
  <input
64
  type="text"
65
  value={inputValue}
 
4
  interface CompanionTranscriptProps {
5
  transcript: string
6
  fullTranscript: string
7
+ generating: boolean
8
  onSendText: (text: string) => void
9
  disabled?: boolean
10
  }
11
 
12
+ export function CompanionTranscript({ transcript, fullTranscript, generating, onSendText, disabled }: CompanionTranscriptProps) {
13
  const scrollRef = useRef<HTMLDivElement>(null)
14
  const [inputValue, setInputValue] = useState('')
15
  const t = useTranslation()
 
39
  {/* Transcript display */}
40
  <div
41
  ref={scrollRef}
42
+ className={`max-h-[150px] overflow-y-auto rounded-2xl bg-black/40 backdrop-blur-md border p-4 mb-2 scrollbar-thin transition-all duration-500 ${
43
+ generating
44
+ ? 'border-purple-500/50 shadow-[0_0_20px_rgba(147,51,234,0.2)]'
45
+ : 'border-white/10'
46
+ }`}
47
  >
48
  {fullTranscript && (
49
  <div className="text-white/40 text-xs mb-2 whitespace-pre-wrap leading-relaxed">
 
64
  </div>
65
 
66
  {/* Text input row */}
67
+ <div className={`flex items-center gap-2 rounded-2xl bg-black/40 backdrop-blur-md border px-4 py-2 transition-all duration-500 ${
68
+ generating
69
+ ? 'border-purple-500/50 shadow-[0_0_20px_rgba(147,51,234,0.2)]'
70
+ : 'border-white/10'
71
+ }`}>
72
  <input
73
  type="text"
74
  value={inputValue}
desktop/src/components/companion/CompanionVideoPanel.tsx CHANGED
@@ -1,5 +1,6 @@
1
- import { useEffect, useRef } from 'react'
2
  import { useTranslation } from '../../i18n'
 
3
  import type { CompanionStatus } from '../../types/companion'
4
 
5
  interface CompanionVideoPanelProps {
@@ -16,7 +17,13 @@ export function CompanionVideoPanel({
16
  status,
17
  }: CompanionVideoPanelProps) {
18
  const videoRef = useRef<HTMLVideoElement>(null)
 
 
19
  const t = useTranslation()
 
 
 
 
20
 
21
  useEffect(() => {
22
  if (videoRef.current && webcamStream) {
@@ -24,6 +31,39 @@ export function CompanionVideoPanel({
24
  }
25
  }, [webcamStream])
26
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
27
  const statusLabel = generating
28
  ? t('companion.speaking')
29
  : speaking
@@ -34,32 +74,86 @@ export function CompanionVideoPanel({
34
 
35
  return (
36
  <div className="companion-video-panel absolute inset-0 overflow-hidden">
37
- {/* Background gradient */}
38
- <div
39
- className={`absolute inset-0 transition-all duration-700 ${
40
- generating
41
- ? 'bg-gradient-to-br from-indigo-950 via-purple-900 to-slate-950'
42
- : 'bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900'
43
- }`}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
44
  />
45
 
46
  {/* AI Presence indicator */}
47
  <div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 flex flex-col items-center gap-4">
48
- <div
49
- className={`w-32 h-32 rounded-full transition-all duration-500 flex items-center justify-center ${
50
- generating
51
- ? 'bg-purple-600/30 shadow-[0_0_60px_rgba(147,51,234,0.4)] animate-pulse'
52
- : speaking
53
- ? 'bg-blue-500/20 shadow-[0_0_30px_rgba(59,130,246,0.3)]'
54
- : 'bg-gray-600/10'
55
- }`}
56
- >
57
- <span className="material-symbols-outlined text-5xl text-white/60">
58
- {generating ? 'record_voice_over' : 'psychology'}
59
- </span>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
60
  </div>
61
  <span className="text-white/40 text-sm font-medium">{statusLabel}</span>
62
  </div>
 
 
 
 
 
 
 
63
 
64
  {/* Webcam PiP overlay */}
65
  {webcamStream && (
 
1
+ import { useEffect, useRef, useCallback } from 'react'
2
  import { useTranslation } from '../../i18n'
3
+ import { useCompanionStore } from '../../stores/companionStore'
4
  import type { CompanionStatus } from '../../types/companion'
5
 
6
  interface CompanionVideoPanelProps {
 
17
  status,
18
  }: CompanionVideoPanelProps) {
19
  const videoRef = useRef<HTMLVideoElement>(null)
20
+ const avatarFileRef = useRef<HTMLInputElement>(null)
21
+ const bgFileRef = useRef<HTMLInputElement>(null)
22
  const t = useTranslation()
23
+ const avatarUrl = useCompanionStore((s) => s.avatarUrl)
24
+ const backgroundUrl = useCompanionStore((s) => s.backgroundUrl)
25
+ const setAvatarUrl = useCompanionStore((s) => s.setAvatarUrl)
26
+ const setBackgroundUrl = useCompanionStore((s) => s.setBackgroundUrl)
27
 
28
  useEffect(() => {
29
  if (videoRef.current && webcamStream) {
 
31
  }
32
  }, [webcamStream])
33
 
34
+ const handleAvatarPick = useCallback(() => {
35
+ avatarFileRef.current?.click()
36
+ }, [])
37
+
38
+ const handleBgPick = useCallback(() => {
39
+ bgFileRef.current?.click()
40
+ }, [])
41
+
42
+ const handleAvatarFile = useCallback(
43
+ (e: React.ChangeEvent<HTMLInputElement>) => {
44
+ const file = e.target.files?.[0]
45
+ if (!file) return
46
+ const reader = new FileReader()
47
+ reader.onload = () => setAvatarUrl(reader.result as string)
48
+ reader.readAsDataURL(file)
49
+ // Reset so the same file can be re-selected
50
+ e.target.value = ''
51
+ },
52
+ [setAvatarUrl],
53
+ )
54
+
55
+ const handleBgFile = useCallback(
56
+ (e: React.ChangeEvent<HTMLInputElement>) => {
57
+ const file = e.target.files?.[0]
58
+ if (!file) return
59
+ const reader = new FileReader()
60
+ reader.onload = () => setBackgroundUrl(reader.result as string)
61
+ reader.readAsDataURL(file)
62
+ e.target.value = ''
63
+ },
64
+ [setBackgroundUrl],
65
+ )
66
+
67
  const statusLabel = generating
68
  ? t('companion.speaking')
69
  : speaking
 
74
 
75
  return (
76
  <div className="companion-video-panel absolute inset-0 overflow-hidden">
77
+ {/* Background */}
78
+ {backgroundUrl ? (
79
+ <div
80
+ className="absolute inset-0 bg-cover bg-center transition-all duration-700"
81
+ style={{ backgroundImage: `url(${backgroundUrl})` }}
82
+ />
83
+ ) : (
84
+ <div className="absolute inset-0 bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 transition-all duration-700" />
85
+ )}
86
+
87
+ {/* Background edit button */}
88
+ <button
89
+ type="button"
90
+ onClick={handleBgPick}
91
+ className="absolute top-4 left-4 flex items-center gap-1.5 px-2.5 py-1.5 rounded-full bg-black/30 backdrop-blur-sm text-white/50 hover:text-white/80 hover:bg-black/50 transition-all text-xs"
92
+ title="Change background"
93
+ >
94
+ <span className="material-symbols-outlined text-sm">image</span>
95
+ Background
96
+ </button>
97
+ <input
98
+ ref={bgFileRef}
99
+ type="file"
100
+ accept="image/*"
101
+ className="hidden"
102
+ onChange={handleBgFile}
103
  />
104
 
105
  {/* AI Presence indicator */}
106
  <div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 flex flex-col items-center gap-4">
107
+ <div className="relative group">
108
+ <div
109
+ className={`w-32 h-32 rounded-full transition-all duration-500 flex items-center justify-center overflow-hidden ${
110
+ generating
111
+ ? 'shadow-[0_0_60px_rgba(147,51,234,0.4)] animate-pulse ring-2 ring-purple-500/50'
112
+ : speaking
113
+ ? 'shadow-[0_0_30px_rgba(59,130,246,0.3)] ring-2 ring-blue-500/30'
114
+ : ''
115
+ }`}
116
+ >
117
+ {avatarUrl ? (
118
+ <img
119
+ src={avatarUrl}
120
+ alt="avatar"
121
+ className="w-full h-full object-cover"
122
+ />
123
+ ) : (
124
+ <div
125
+ className={`w-full h-full flex items-center justify-center ${
126
+ generating
127
+ ? 'bg-purple-600/30'
128
+ : speaking
129
+ ? 'bg-blue-500/20'
130
+ : 'bg-gray-600/10'
131
+ }`}
132
+ >
133
+ <span className="material-symbols-outlined text-5xl text-white/60">
134
+ {generating ? 'record_voice_over' : 'psychology'}
135
+ </span>
136
+ </div>
137
+ )}
138
+ </div>
139
+ {/* Avatar edit overlay */}
140
+ <button
141
+ type="button"
142
+ onClick={handleAvatarPick}
143
+ className="absolute -bottom-1 -right-1 w-8 h-8 rounded-full bg-purple-600/80 hover:bg-purple-600 flex items-center justify-center transition-all opacity-0 group-hover:opacity-100 shadow-lg"
144
+ >
145
+ <span className="material-symbols-outlined text-sm text-white">edit</span>
146
+ </button>
147
  </div>
148
  <span className="text-white/40 text-sm font-medium">{statusLabel}</span>
149
  </div>
150
+ <input
151
+ ref={avatarFileRef}
152
+ type="file"
153
+ accept="image/*"
154
+ className="hidden"
155
+ onChange={handleAvatarFile}
156
+ />
157
 
158
  {/* Webcam PiP overlay */}
159
  {webcamStream && (
desktop/src/pages/Companion.tsx CHANGED
@@ -101,6 +101,7 @@ export function Companion() {
101
  <CompanionTranscript
102
  transcript={transcript}
103
  fullTranscript={fullTranscript}
 
104
  onSendText={handleSendText}
105
  disabled={status !== 'connected'}
106
  />
 
101
  <CompanionTranscript
102
  transcript={transcript}
103
  fullTranscript={fullTranscript}
104
+ generating={generating || speaking}
105
  onSendText={handleSendText}
106
  disabled={status !== 'connected'}
107
  />
desktop/src/stores/companionStore.ts CHANGED
@@ -12,6 +12,8 @@ interface CompanionStore {
12
  micEnabled: boolean
13
  cameraEnabled: boolean
14
  error: string | null
 
 
15
 
16
  setServerUrl: (url: string) => void
17
  setVoiceName: (name: string) => void
@@ -24,6 +26,8 @@ interface CompanionStore {
24
  setMicEnabled: (enabled: boolean) => void
25
  setCameraEnabled: (enabled: boolean) => void
26
  setError: (error: string | null) => void
 
 
27
  reset: () => void
28
  }
29
 
@@ -40,6 +44,8 @@ export const useCompanionStore = create<CompanionStore>((set) => ({
40
  micEnabled: true,
41
  cameraEnabled: true,
42
  error: null,
 
 
43
 
44
  setServerUrl: (url) => set({ serverUrl: url }),
45
  setVoiceName: (name) => set({ voiceName: name }),
@@ -54,6 +60,8 @@ export const useCompanionStore = create<CompanionStore>((set) => ({
54
  setMicEnabled: (enabled) => set({ micEnabled: enabled }),
55
  setCameraEnabled: (enabled) => set({ cameraEnabled: enabled }),
56
  setError: (error) => set({ error }),
 
 
57
  reset: () =>
58
  set({
59
  status: 'disconnected',
 
12
  micEnabled: boolean
13
  cameraEnabled: boolean
14
  error: string | null
15
+ avatarUrl: string | null
16
+ backgroundUrl: string | null
17
 
18
  setServerUrl: (url: string) => void
19
  setVoiceName: (name: string) => void
 
26
  setMicEnabled: (enabled: boolean) => void
27
  setCameraEnabled: (enabled: boolean) => void
28
  setError: (error: string | null) => void
29
+ setAvatarUrl: (url: string | null) => void
30
+ setBackgroundUrl: (url: string | null) => void
31
  reset: () => void
32
  }
33
 
 
44
  micEnabled: true,
45
  cameraEnabled: true,
46
  error: null,
47
+ avatarUrl: null,
48
+ backgroundUrl: null,
49
 
50
  setServerUrl: (url) => set({ serverUrl: url }),
51
  setVoiceName: (name) => set({ voiceName: name }),
 
60
  setMicEnabled: (enabled) => set({ micEnabled: enabled }),
61
  setCameraEnabled: (enabled) => set({ cameraEnabled: enabled }),
62
  setError: (error) => set({ error }),
63
+ setAvatarUrl: (url) => set({ avatarUrl: url }),
64
+ setBackgroundUrl: (url) => set({ backgroundUrl: url }),
65
  reset: () =>
66
  set({
67
  status: 'disconnected',