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=
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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=
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 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 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
{
|
| 59 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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',
|