Skip to content

Commit 80e9da9

Browse files
committed
refactor(chat): integrate ai-elements-nuxt and modularize chat UI
1 parent a9fefb5 commit 80e9da9

19 files changed

Lines changed: 2577 additions & 1487 deletions

assets/css/ai-elements-hypar.css

Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,253 @@
1+
/**
2+
* hypar terminal theme overrides for ai-elements-nuxt [data-ai-*] hooks.
3+
*/
4+
5+
.terminal-theme [data-ai-message] {
6+
font-family: var(--term-font-mono);
7+
padding: 0.5rem 0.75rem;
8+
border-radius: var(--term-radius);
9+
max-width: 90%;
10+
gap: 0.35rem;
11+
}
12+
13+
.terminal-theme [data-ai-message][data-role="user"] {
14+
align-self: flex-end;
15+
background: var(--term-accent-soft);
16+
border: 1px solid var(--term-accent-line);
17+
color: var(--term-text);
18+
}
19+
20+
.terminal-theme [data-ai-message][data-role="assistant"] {
21+
align-self: flex-start;
22+
background: var(--term-bg-soft);
23+
border: 1px solid var(--term-accent-faint);
24+
color: var(--term-text);
25+
}
26+
27+
.terminal-theme [data-ai-prompt-input] {
28+
font-family: var(--term-font-mono);
29+
border: 0;
30+
border-radius: var(--term-radius);
31+
background: transparent;
32+
padding: 0;
33+
align-items: center;
34+
gap: 0.5rem;
35+
}
36+
37+
.terminal-theme [data-ai-prompt-input] textarea {
38+
font-size: 0.875rem;
39+
color: var(--term-text);
40+
min-height: 1.5rem;
41+
}
42+
43+
.terminal-theme [data-ai-prompt-input] textarea::placeholder {
44+
color: var(--term-text-faint);
45+
}
46+
47+
.terminal-theme [data-ai-prompt-input] button {
48+
display: none;
49+
}
50+
51+
.terminal-theme [data-ai-sources] {
52+
font-size: 0.75rem;
53+
margin-top: 0.5rem;
54+
padding-top: 0.5rem;
55+
border-top: 1px solid var(--term-accent-faint);
56+
}
57+
58+
.terminal-theme [data-ai-source] a {
59+
color: var(--term-accent);
60+
text-decoration: none;
61+
}
62+
63+
.terminal-theme [data-ai-source] a:hover {
64+
text-decoration: underline;
65+
}
66+
67+
.terminal-theme [data-ai-tool] {
68+
font-size: 0.6875rem;
69+
font-family: var(--term-font-mono);
70+
margin-bottom: 0.35rem;
71+
padding: 0.35rem 0.5rem;
72+
border: 1px dashed var(--term-accent-line);
73+
border-radius: var(--term-radius);
74+
color: var(--term-text-dim);
75+
}
76+
77+
.terminal-theme [data-ai-tool-name] {
78+
color: var(--term-accent);
79+
}
80+
81+
.terminal-theme [data-ai-markdown] {
82+
font-size: 0.875rem;
83+
line-height: 1.625;
84+
overflow-wrap: break-word;
85+
word-break: break-word;
86+
}
87+
88+
.terminal-theme [data-ai-markdown] p {
89+
margin: 0 0 0.5rem;
90+
}
91+
92+
.terminal-theme [data-ai-markdown] p:last-child {
93+
margin-bottom: 0;
94+
}
95+
96+
.terminal-theme [data-ai-markdown] code {
97+
background: var(--term-accent-soft);
98+
padding: 0.1em 0.3em;
99+
border-radius: 3px;
100+
font-size: 0.9em;
101+
}
102+
103+
.terminal-theme [data-ai-markdown] pre {
104+
background: var(--term-accent-soft);
105+
padding: 0.6rem 0.8rem;
106+
border-radius: 4px;
107+
overflow-x: auto;
108+
margin: 0.5rem 0;
109+
}
110+
111+
.terminal-theme [data-ai-markdown] pre code {
112+
background: transparent;
113+
padding: 0;
114+
}
115+
116+
.terminal-theme [data-ai-markdown] ul,
117+
.terminal-theme [data-ai-markdown] ol {
118+
margin: 0.25rem 0 0.5rem 1.25rem;
119+
}
120+
121+
.terminal-theme [data-ai-markdown] a {
122+
color: var(--term-accent);
123+
text-decoration: underline;
124+
}
125+
126+
.terminal-theme [data-ai-cursor] {
127+
background: var(--term-accent);
128+
}
129+
130+
.terminal-theme [data-ai-shimmer-line] {
131+
background: linear-gradient(
132+
90deg,
133+
var(--term-accent-soft),
134+
var(--term-accent-faint),
135+
var(--term-accent-soft)
136+
);
137+
}
138+
139+
.terminal-theme [data-ai-suggestions] {
140+
display: flex;
141+
flex-wrap: wrap;
142+
gap: 0.5rem;
143+
justify-content: center;
144+
}
145+
146+
.terminal-theme [data-ai-suggestion] {
147+
font-family: var(--term-font-mono);
148+
font-size: 0.75rem;
149+
padding: 0.35rem 0.65rem;
150+
border: 1px solid var(--term-accent-line);
151+
border-radius: var(--term-radius);
152+
background: transparent;
153+
color: var(--term-text-muted);
154+
cursor: pointer;
155+
}
156+
157+
.terminal-theme [data-ai-suggestion]:hover {
158+
color: var(--term-accent);
159+
border-color: var(--term-accent);
160+
}
161+
162+
.terminal-theme [data-ai-conversation] {
163+
font-family: var(--term-font-mono);
164+
font-size: 0.75rem;
165+
}
166+
167+
.terminal-theme [data-ai-conversation-header] {
168+
display: none;
169+
}
170+
171+
.terminal-theme [data-ai-conversation-list] {
172+
display: flex;
173+
flex-direction: column;
174+
gap: 0.375rem;
175+
}
176+
177+
.terminal-theme [data-ai-conversation-item] button {
178+
width: 100%;
179+
text-align: left;
180+
padding: 0.5rem;
181+
border: 1px solid var(--term-accent-faint);
182+
border-radius: var(--term-radius);
183+
background: var(--term-bg-soft);
184+
color: var(--term-text);
185+
cursor: pointer;
186+
}
187+
188+
.terminal-theme [data-ai-conversation-item][data-active] button {
189+
border-color: var(--term-accent-line);
190+
background: var(--term-accent-soft);
191+
}
192+
193+
.terminal-theme [data-ai-model-selector] button {
194+
font-family: var(--term-font-mono);
195+
font-size: 0.625rem;
196+
background: transparent;
197+
border: none;
198+
color: var(--term-text-dim);
199+
cursor: pointer;
200+
padding: 0;
201+
}
202+
203+
.terminal-theme [data-ai-model-selector] button:hover {
204+
color: var(--term-accent);
205+
}
206+
207+
.terminal-theme [data-ai-model-selector-dropdown] {
208+
position: absolute;
209+
bottom: 100%;
210+
right: 0;
211+
margin-bottom: 0.5rem;
212+
width: 16rem;
213+
max-height: 20rem;
214+
z-index: 10;
215+
display: flex;
216+
flex-direction: column;
217+
border: 1px solid var(--term-accent-line);
218+
border-radius: var(--term-radius);
219+
background: var(--term-bg);
220+
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
221+
overflow: hidden;
222+
}
223+
224+
.terminal-theme [data-ai-model-selector-dropdown] input {
225+
width: 100%;
226+
padding: 0.375rem 0.5rem;
227+
font-family: var(--term-font-mono);
228+
font-size: 0.6875rem;
229+
background: transparent;
230+
border: none;
231+
border-bottom: 1px solid var(--term-accent-line);
232+
color: var(--term-text);
233+
outline: none;
234+
}
235+
236+
.terminal-theme [data-ai-error-boundary] {
237+
font-family: var(--term-font-mono);
238+
font-size: 0.875rem;
239+
}
240+
241+
.terminal-theme [data-ai-error-boundary-message] {
242+
color: var(--term-danger);
243+
}
244+
245+
.terminal-theme .hypar-chat-thread {
246+
display: flex;
247+
flex-direction: column;
248+
gap: 0.75rem;
249+
}
250+
251+
.terminal-theme .hypar-chat-thread [data-ai-message] {
252+
width: fit-content;
253+
}

assets/css/main.css

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
@import "tailwindcss";
22
@import "@nuxt/ui";
3+
@import "./ai-elements-hypar.css";
34

45
/* ── Global tokens ───────────────────────────────────────────── */
56
:root {

components/AppHeader.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
<div class="px-4 h-12 flex items-center justify-between text-xs">
44

55
<NuxtLink to="/" class="flex items-center gap-3 shrink-0">
6-
<HyparMark />
6+
<BrandHyparMark />
77
<div class="flex items-center gap-2">
88
<span class="wz-strong font-semibold">{{ t('app.brand') }}</span>
99
</div>
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
<script setup lang="ts">
2+
import type { SearchMode } from '~/composables/useHyparChat'
3+
4+
const selectedSearchMode = defineModel<SearchMode>('searchMode', { required: true })
5+
const selectedModel = defineModel<string>('model', { required: true })
6+
7+
defineProps<{
8+
llmProvider?: string
9+
modelOptions: Array<{ id: string; name: string; provider: string }>
10+
showModelSelector: boolean
11+
}>()
12+
13+
const searchModeMenuOpen = ref(false)
14+
const searchModeRef = ref<HTMLElement | null>(null)
15+
16+
function onDocClick(e: MouseEvent) {
17+
if (searchModeRef.value && !searchModeRef.value.contains(e.target as Node)) {
18+
searchModeMenuOpen.value = false
19+
}
20+
}
21+
22+
onMounted(() => document.addEventListener('click', onDocClick))
23+
onUnmounted(() => document.removeEventListener('click', onDocClick))
24+
25+
const SEARCH_MODES: { value: SearchMode; label: string; description: string }[] = [
26+
{ value: 'auto', label: 'auto', description: 'agent decides when to search' },
27+
{ value: 'search', label: 'search', description: 'always search the knowledge base' },
28+
{ value: 'direct', label: 'direct', description: 'skip KB, answer from model' },
29+
]
30+
31+
function selectSearchMode(mode: SearchMode) {
32+
selectedSearchMode.value = mode
33+
searchModeMenuOpen.value = false
34+
}
35+
</script>
36+
37+
<template>
38+
<div class="flex items-center gap-3">
39+
<div ref="searchModeRef" class="relative">
40+
<button
41+
type="button"
42+
class="font-mono text-[10px] flex items-center gap-1 cursor-pointer select-none wz-faint hover:wz-accent"
43+
:class="selectedSearchMode !== 'auto' ? 'wz-accent' : ''"
44+
:title="`search mode: ${selectedSearchMode}`"
45+
@click="searchModeMenuOpen = !searchModeMenuOpen"
46+
>
47+
<span class="opacity-50">mode/</span>
48+
<span>{{ selectedSearchMode }}</span>
49+
<span class="opacity-50">▾</span>
50+
</button>
51+
<div
52+
v-if="searchModeMenuOpen"
53+
class="absolute bottom-full right-0 mb-2 w-56 z-10 rounded border border-[color:var(--term-accent-line)] bg-[color:var(--term-bg)] shadow-xl"
54+
>
55+
<div class="px-3 py-1.5 text-[9px] wz-faint uppercase tracking-widest border-b border-[color:var(--term-accent-line)]">// search mode</div>
56+
<button
57+
v-for="m in SEARCH_MODES"
58+
:key="m.value"
59+
type="button"
60+
class="w-full text-left px-3 py-2 flex flex-col gap-0.5"
61+
:class="m.value === selectedSearchMode
62+
? 'wz-accent bg-[color:var(--term-accent-soft)]'
63+
: 'hover:bg-[color:var(--term-accent-soft)]'"
64+
@click="selectSearchMode(m.value)"
65+
>
66+
<div class="flex items-center justify-between">
67+
<span class="font-mono text-[11px]">{{ m.label }}</span>
68+
<span v-if="m.value === selectedSearchMode" class="text-[9px] opacity-40">✓</span>
69+
</div>
70+
<span class="text-[9px] wz-faint opacity-60">{{ m.description }}</span>
71+
</button>
72+
</div>
73+
</div>
74+
75+
<AiModelSelector
76+
v-if="showModelSelector"
77+
v-model="selectedModel"
78+
:models="modelOptions"
79+
:group-by-provider="false"
80+
>
81+
<template #trigger="{ selected, toggle, isOpen }">
82+
<button
83+
type="button"
84+
class="font-mono text-[10px] flex items-center gap-1 cursor-pointer select-none wz-faint hover:wz-accent"
85+
:title="`${llmProvider} · click to change model`"
86+
@click.stop="toggle"
87+
>
88+
<span class="opacity-50">{{ llmProvider }}/</span>
89+
<span>{{ selected?.name ?? selectedModel }}</span>
90+
<span class="opacity-50">▾</span>
91+
</button>
92+
</template>
93+
<template #option="{ model, selected }">
94+
<span
95+
class="w-full text-left px-3 py-1.5 text-[11px] font-mono flex items-center justify-between"
96+
:class="selected ? 'wz-accent' : ''"
97+
>
98+
<span class="truncate">{{ model.name }}</span>
99+
<span v-if="selected" class="text-[9px] opacity-40 ml-2 shrink-0">✓</span>
100+
</span>
101+
</template>
102+
</AiModelSelector>
103+
</div>
104+
</template>

0 commit comments

Comments
 (0)