Skip to content

Commit 74136b9

Browse files
feat(stage-ui,stage-web): custom chat background (#825)
1 parent 2e4c5a7 commit 74136b9

File tree

20 files changed

+1100
-85
lines changed

20 files changed

+1100
-85
lines changed

Cargo.toml

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
[workspace]
2-
members = [ "crates/tauri-plugin-ipc-audio-transcription-ort", "crates/tauri-plugin-ipc-audio-vad-ort", "crates/tauri-plugin-mcp", "crates/tauri-plugin-rdev", "crates/tauri-plugin-window-pass-through-on-hover", "crates/tauri-plugin-window-router-link" ]
2+
members = [
3+
"crates/tauri-plugin-ipc-audio-transcription-ort",
4+
"crates/tauri-plugin-ipc-audio-vad-ort",
5+
"crates/tauri-plugin-mcp",
6+
"crates/tauri-plugin-rdev",
7+
"crates/tauri-plugin-window-pass-through-on-hover",
8+
"crates/tauri-plugin-window-router-link"
9+
]
310
resolver = "2"
411

512
[workspace.package]
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
<script setup lang="ts">
2+
import { BackgroundPickerDialog } from '@proj-airi/stage-ui/components'
3+
import { storeToRefs } from 'pinia'
4+
5+
import { useBackgroundStore } from '../../stores/background'
6+
7+
const show = defineModel<boolean>({ default: false })
8+
9+
const backgroundStore = useBackgroundStore()
10+
const { options, selectedOption } = storeToRefs(backgroundStore)
11+
</script>
12+
13+
<template>
14+
<BackgroundPickerDialog
15+
v-model="show"
16+
:selected="selectedOption"
17+
:options="options"
18+
@apply="backgroundStore.applyPickerSelection"
19+
@remove="option => backgroundStore.removeOption(option.id)"
20+
/>
21+
</template>
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<script setup lang="ts">
2+
import AnimatedWave from '../Widgets/AnimatedWave.vue'
3+
import Cross from './Cross.vue'
4+
</script>
5+
6+
<template>
7+
<Cross>
8+
<AnimatedWave
9+
fill-color="linear-gradient(120deg, hsl(var(--chromatic-hue) 72% 75%), hsl(var(--chromatic-hue) 70% 62%))"
10+
class="h-full w-full"
11+
>
12+
<div class="relative h-full w-full from-black/10 via-black/0 to-black/0 bg-gradient-to-b" />
13+
</AnimatedWave>
14+
</Cross>
15+
</template>
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
<script setup lang="ts">
2+
import type { BackgroundItem } from '../../stores/background'
3+
4+
import ThemeOverlay from '@proj-airi/stage-ui/components/ThemeOverlay.vue'
5+
6+
import { useTheme } from '@proj-airi/ui'
7+
import { computed, ref } from 'vue'
8+
9+
import AnimatedWave from '../Widgets/AnimatedWave.vue'
10+
import Cross from './Cross.vue'
11+
12+
import { BackgroundKind } from '../../stores/background'
13+
14+
defineProps<{
15+
background: BackgroundItem
16+
topColor?: string
17+
}>()
18+
19+
const { isDark: dark } = useTheme()
20+
const containerRef = ref<HTMLElement | null>(null)
21+
22+
const waveFillColor = computed(() => {
23+
const hue = 'var(--chromatic-hue)'
24+
return dark.value
25+
? `hsl(${hue} 60% 32%)`
26+
: `hsl(${hue} 75% 78%)`
27+
})
28+
29+
defineExpose({
30+
surfaceEl: containerRef,
31+
})
32+
</script>
33+
34+
<template>
35+
<div ref="containerRef" class="customized-background relative min-h-100dvh w-full overflow-hidden">
36+
<!-- Background layers -->
37+
<div
38+
class="absolute inset-0 z-0 transition-all duration-300"
39+
:class="[(background.blur && background.kind !== BackgroundKind.Wave) ? 'blur-md scale-110' : '']"
40+
>
41+
<template v-if="background.kind === BackgroundKind.Wave">
42+
<Cross class="h-full w-full">
43+
<AnimatedWave
44+
class="h-full w-full"
45+
:fill-color="waveFillColor"
46+
/>
47+
</Cross>
48+
</template>
49+
<template v-else-if="background.kind === BackgroundKind.Image">
50+
<img
51+
:src="background.src"
52+
class="h-full w-full object-cover"
53+
loading="lazy"
54+
decoding="async"
55+
>
56+
</template>
57+
<template v-else>
58+
<div class="h-full w-full bg-neutral-950" />
59+
</template>
60+
</div>
61+
62+
<!-- Overlay (not for wave) -->
63+
<ThemeOverlay v-if="background.kind !== BackgroundKind.Wave" :color="topColor" />
64+
65+
<!-- Content layer (kept mounted during background switches) -->
66+
<div class="relative z-10 h-full w-full">
67+
<slot />
68+
</div>
69+
</div>
70+
</template>
71+
72+
<style scoped>
73+
</style>

apps/stage-web/src/components/Layouts/MobileHeaderLink.vue

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,29 @@
11
<script setup lang="ts">
22
import { useTheme } from '@proj-airi/ui'
3+
import { storeToRefs } from 'pinia'
34
import { RouterLink } from 'vue-router'
45

56
import LogoDark from '../../assets/logo-dark.svg'
67
import Logo from '../../assets/logo.svg'
78

9+
import { BackgroundKind, useBackgroundStore } from '../../stores/background'
10+
811
const { isDark: dark } = useTheme()
12+
const { selectedOption } = storeToRefs(useBackgroundStore())
913
</script>
1014

1115
<template>
1216
<RouterLink
1317
to="/" flex="~" items-center
1418
gap-2 px-2 text-nowrap text-2xl outline-none
1519
>
16-
<template v-if="dark">
17-
<img :src="LogoDark" h-8 w-8 class="theme-colored">
18-
</template>
19-
<template v-else>
20-
<img :src="Logo" h-8 w-8 class="theme-colored">
20+
<template v-if="selectedOption?.kind === BackgroundKind.Wave">
21+
<template v-if="dark">
22+
<img :src="LogoDark" h-8 w-8 class="theme-colored">
23+
</template>
24+
<template v-else>
25+
<img :src="Logo" h-8 w-8 class="theme-colored">
26+
</template>
2127
</template>
2228
</RouterLink>
2329
</template>

apps/stage-web/src/components/Layouts/MobileInteractiveArea.vue

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { onMounted, onUnmounted, ref, useTemplateRef, watch } from 'vue'
1515
import { useI18n } from 'vue-i18n'
1616
import { RouterLink } from 'vue-router'
1717

18+
import AppBackgroundPickerDialog from '../Backgrounds/AppBackgroundPickerDialog.vue'
1819
import IndicatorMicVolume from '../Widgets/IndicatorMicVolume.vue'
1920
import ActionAbout from './InteractiveArea/Actions/About.vue'
2021
import ActionViewControls from './InteractiveArea/Actions/ViewControls.vue'
@@ -29,6 +30,7 @@ const viewControlsInputsRef = useTemplateRef<InstanceType<typeof ViewControlInpu
2930

3031
const messageInput = ref('')
3132
const isComposing = ref(false)
33+
const backgroundDialogOpen = ref(false)
3234

3335
const screenSafeArea = useScreenSafeArea()
3436
const providersStore = useProvidersStore()
@@ -130,6 +132,7 @@ onMounted(() => {
130132

131133
<template>
132134
<div fixed bottom-0 w-full flex flex-col>
135+
<AppBackgroundPickerDialog v-model="backgroundDialogOpen" />
133136
<KeepAlive>
134137
<Transition name="fade">
135138
<ChatHistory
@@ -180,6 +183,9 @@ onMounted(() => {
180183
<div v-else i-solar:sun-2-outline size-5 text="neutral-500 dark:neutral-400" />
181184
</Transition>
182185
</button>
186+
<button border="2 solid neutral-100/60 dark:neutral-800/30" bg="neutral-50/70 dark:neutral-800/70" w-fit flex items-center self-end justify-center rounded-xl p-2 backdrop-blur-md title="Background" @click="backgroundDialogOpen = true">
187+
<div i-solar:gallery-wide-bold-duotone size-5 text="neutral-500 dark:neutral-400" />
188+
</button>
183189
<!-- <button border="2 solid neutral-100/60 dark:neutral-800/30" bg="neutral-50/70 dark:neutral-800/70" w-fit flex items-center self-end justify-center rounded-xl p-2 backdrop-blur-md title="Language">
184190
<div i-solar:earth-outline size-5 text="neutral-500 dark:neutral-400" />
185191
</button> -->

apps/stage-web/src/components/Widgets/ChatActionButtons.vue

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,18 @@
11
<script setup lang="ts">
22
import { useChatStore } from '@proj-airi/stage-ui/stores/chat'
33
import { useTheme } from '@proj-airi/ui'
4+
import { ref } from 'vue'
5+
6+
import AppBackgroundPickerDialog from '../Backgrounds/AppBackgroundPickerDialog.vue'
47

58
const { cleanupMessages } = useChatStore()
69
const { isDark, toggleDark } = useTheme()
10+
11+
const backgroundDialogOpen = ref(false)
712
</script>
813

914
<template>
15+
<AppBackgroundPickerDialog v-model="backgroundDialogOpen" />
1016
<div absolute bottom--8 right-0 flex gap-2>
1117
<button
1218
class="max-h-[10lh] min-h-[1lh]"
@@ -33,5 +39,16 @@ const { isDark, toggleDark } = useTheme()
3339
<div v-else i-solar:sun-2-bold />
3440
</Transition>
3541
</button>
42+
<button
43+
class="max-h-[10lh] min-h-[1lh]"
44+
bg="neutral-100 dark:neutral-800"
45+
text="lg neutral-500 dark:neutral-400"
46+
flex items-center justify-center rounded-md p-2 outline-none
47+
transition-colors transition-transform active:scale-95
48+
title="Background"
49+
@click="backgroundDialogOpen = true"
50+
>
51+
<div i-solar:gallery-wide-bold-duotone />
52+
</button>
3653
</div>
3754
</template>

apps/stage-web/src/composables/theme-color.ts

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,18 @@
1+
import type { Ref } from 'vue'
2+
3+
import type CustomizedBackground from '../components/Backgrounds/CustomizedBackground.vue'
4+
import type { BackgroundItem } from '../stores/background'
5+
16
import Color from 'colorjs.io'
27

38
import { withRetry } from '@moeru/std'
9+
import { colorFromElement, patchThemeSamplingHtml2CanvasClone } from '@proj-airi/stage-ui/libs'
10+
import { useSettings } from '@proj-airi/stage-ui/stores/settings'
411
import { useTheme } from '@proj-airi/ui'
12+
import { useDocumentVisibility, useIntervalFn } from '@vueuse/core'
13+
import { nextTick, watch } from 'vue'
14+
15+
import { BackgroundKind } from '../stores/background'
516

617
export function themeColorFromPropertyOf(colorFromClass: string, property: string): () => Promise<string> {
718
return async () => {
@@ -44,3 +55,140 @@ export function useThemeColor(colorFrom: () => string | Promise<string>) {
4455
updateThemeColor,
4556
}
4657
}
58+
59+
export function useBackgroundThemeColor({
60+
backgroundSurface,
61+
selectedOption,
62+
sampledColor,
63+
}: {
64+
backgroundSurface: Ref<InstanceType<typeof CustomizedBackground> | undefined | null>
65+
selectedOption: Ref<BackgroundItem | undefined>
66+
sampledColor: Ref<string>
67+
}) {
68+
const { themeColorsHue, themeColorsHueDynamic } = useSettings()
69+
70+
let samplingToken = 0
71+
72+
const { isDark } = useTheme()
73+
74+
function getWaveThemeColor() {
75+
// We read directly from computed style to catch the animation value
76+
return isDark.value ? `hsl(${themeColorsHue} 60% 32%)` : `hsl(${themeColorsHue} 75% 78%)`
77+
}
78+
79+
const { updateThemeColor } = useThemeColor(() => {
80+
if (selectedOption.value?.kind === BackgroundKind.Wave) {
81+
return getWaveThemeColor()
82+
}
83+
return sampledColor.value
84+
})
85+
86+
// Keep theme-color reasonably fresh for animated wave backgrounds without doing per-frame work.
87+
const { pause, resume } = useIntervalFn(() => {
88+
if (useDocumentVisibility().value !== 'visible')
89+
return
90+
if (selectedOption.value?.kind === BackgroundKind.Wave && themeColorsHueDynamic)
91+
void updateThemeColor()
92+
}, 250, { immediate: false })
93+
94+
watch([() => selectedOption.value?.kind, () => themeColorsHueDynamic], ([kind, dynamic]) => {
95+
if (kind === BackgroundKind.Wave && dynamic) {
96+
void updateThemeColor()
97+
resume()
98+
}
99+
else {
100+
pause()
101+
}
102+
}, { immediate: true })
103+
104+
async function waitForBackgroundReady() {
105+
await nextTick()
106+
const image = backgroundSurface.value?.surfaceEl?.querySelector('img')
107+
if (image && !image.complete) {
108+
await new Promise<void>((resolve, reject) => {
109+
image.addEventListener('load', () => resolve(), { once: true })
110+
image.addEventListener('error', () => reject(new Error('Background image failed to load')), { once: true })
111+
})
112+
}
113+
}
114+
115+
// Exposed for optional manual triggers; also used within syncBackgroundTheme.
116+
async function sampleBackgroundColor() {
117+
const token = ++samplingToken
118+
const optionId = selectedOption.value?.id
119+
if (selectedOption.value?.kind === BackgroundKind.Wave) {
120+
await updateThemeColor()
121+
return
122+
}
123+
124+
const el = backgroundSurface.value?.surfaceEl
125+
if (!el)
126+
return
127+
128+
await waitForBackgroundReady()
129+
130+
const result = await colorFromElement(el, {
131+
mode: 'html2canvas',
132+
html2canvas: {
133+
region: {
134+
x: 0,
135+
y: 0,
136+
width: el.offsetWidth,
137+
height: Math.min(140, el.offsetHeight),
138+
},
139+
sampleHeight: 20,
140+
sampleStride: 10,
141+
scale: 0.5,
142+
backgroundColor: null,
143+
allowTaint: true,
144+
useCORS: true,
145+
onclone: patchThemeSamplingHtml2CanvasClone,
146+
},
147+
})
148+
149+
const color = result.html2canvas?.average
150+
if (token !== samplingToken)
151+
return
152+
if (optionId && selectedOption.value?.id !== optionId)
153+
return
154+
155+
if (color) {
156+
sampledColor.value = color
157+
}
158+
}
159+
160+
async function syncBackgroundTheme() {
161+
if (selectedOption.value?.kind === BackgroundKind.Wave) {
162+
await updateThemeColor()
163+
}
164+
else if (sampledColor.value) {
165+
await updateThemeColor()
166+
}
167+
else {
168+
await sampleBackgroundColor()
169+
}
170+
}
171+
172+
watch([selectedOption], () => {
173+
syncBackgroundTheme()
174+
}, { immediate: true })
175+
176+
watch(sampledColor, () => {
177+
syncBackgroundTheme()
178+
})
179+
180+
watch(() => backgroundSurface.value?.surfaceEl, (el) => {
181+
if (el)
182+
syncBackgroundTheme()
183+
})
184+
185+
watch(isDark, () => {
186+
syncBackgroundTheme()
187+
})
188+
189+
return {
190+
sampledColor,
191+
sampleBackgroundColor,
192+
syncBackgroundTheme,
193+
}
194+
}

0 commit comments

Comments
 (0)