Luxx/dashboard/src/views/ConversationView.vue

640 lines
26 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="page-container conversations">
<div class="conv-layout">
<!-- 左侧会话列表 -->
<aside class="conv-sidebar">
<div class="sidebar-header">
<button @click="showModal = true" class="btn-new-conv">+ 新建会话</button>
</div>
<div v-if="loading" class="loading"><div class="spinner-small"></div>加载中...</div>
<div v-else-if="error" class="error-msg">{{ error }} <button @click="fetchData">重试</button></div>
<div v-else-if="!list.length" class="empty-sidebar">暂无会话</div>
<div v-else class="conv-list">
<div
v-for="c in list"
:key="c.id"
class="conv-item"
:class="{ active: selectedId === c.id }"
@click="selectConv(c)"
>
<div class="conv-item-header">
<span class="conv-item-title">{{ c.title || c.first_message || '未命名会话' }}</span>
<span class="conv-item-time">{{ formatDate(c.updated_at) }}</span>
</div>
<div class="conv-item-meta">
<span class="conv-item-model">
{{ c.model || '-' }}
<!-- 显示活跃流指示器 -->
<span v-if="hasActiveStream(c.id)" class="streaming-indicator" title="正在生成中"></span>
</span>
<div class="conv-item-actions" @click.stop>
<button @click="editTitle(c)" class="btn-icon" title="重命名">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path>
</svg>
</button>
<button @click="deleteConv(c)" class="btn-icon btn-delete-icon" title="删除">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="3 6 5 6 21 6"></polyline>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
<line x1="10" y1="11" x2="10" y2="17"></line>
<line x1="14" y1="11" x2="14" y2="17"></line>
</svg>
</button>
</div>
</div>
</div>
</div>
<div v-if="totalPages > 1" class="sidebar-pagination">
<button @click="page--; fetchData()" :disabled="page === 1" class="btn-page"></button>
<span>{{ page }} / {{ totalPages }}</span>
<button @click="page++; fetchData()" :disabled="page >= totalPages" class="btn-page"></button>
</div>
</aside>
<!-- 右侧内容区 - 对话界面 -->
<main class="conv-content">
<div v-if="!selectedConv" class="empty-content">
<div class="empty-icon">💬</div>
<p>选择一个会话查看</p>
</div>
<div v-else class="chat-view-container">
<div class="chat-messages" ref="messagesContainer">
<div v-if="loadingMessages" class="loading-messages">
<div class="spinner-small"></div>
<span>加载中...</span>
</div>
<div v-else-if="convMessages.length || currentStreamState">
<!-- 历史消息 -->
<div v-for="msg in convMessages" :key="msg.id" class="chat-message" :class="msg.role" :data-msg-id="msg.id">
<div class="message-avatar">{{ msg.role === 'user' ? '👤' : '🤖' }}</div>
<div class="message-content">
<!-- 工具调用步骤显示(包含思考和文本内容) -->
<ProcessBlock
v-if="msg.process_steps && msg.process_steps.length"
:process-steps="msg.process_steps"
/>
<!-- 或仅显示消息内容 -->
<div v-else class="message-text" v-html="renderMsgContent(msg)"></div>
</div>
</div>
<!-- 流式消息 - 使用 store 中的状态 -->
<div v-if="currentStreamState" class="chat-message assistant streaming">
<div class="message-avatar">🤖</div>
<div class="message-content">
<ProcessBlock
:process-steps="currentStreamState.process_steps"
:streaming="currentStreamState.status === 'streaming'"
/>
</div>
</div>
</div>
<div v-else class="chat-empty">
<p>暂无消息,开始对话吧</p>
</div>
</div>
<div class="chat-input-area">
<input
v-model="newMessage"
@keyup.enter="sendMessage"
type="text"
placeholder="输入消息..."
class="chat-input"
:disabled="sending"
/>
<button @click="sendMessage" class="btn-send" :disabled="sending || !newMessage.trim()" :title="sending ? '发送中...' : '发送'">
<span v-if="sending">...</span>
<svg v-else viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="22" y1="2" x2="11" y2="13"></line>
<polygon points="22 2 15 22 11 13 2 9 22 2"></polygon>
</svg>
</button>
</div>
</div>
</main>
</div>
<!-- 新建会话弹窗 -->
<div v-if="showModal" class="modal-overlay" @click.self="showModal = false">
<div class="modal">
<h2>新建会话</h2>
<div class="form-group"><label>标题</label><input v-model="form.title" placeholder="会话标题(可选)" /></div>
<div class="form-group">
<label>Provider</label>
<select v-model="form.provider_id" @change="onProviderChange">
<option :value="null" disabled>选择 Provider</option>
<option v-for="p in providers" :key="p.id" :value="p.id">
{{ p.name }} ({{ p.provider_type }})
</option>
</select>
</div>
<div class="form-group">
<label>模型</label>
<input v-model="form.model" placeholder="如: gpt-4, deepseek-chat" />
</div>
<div class="modal-actions">
<button @click="showModal = false" class="btn-secondary">取消</button>
<button @click="createConv" :disabled="creating || !form.provider_id" class="btn-primary">{{ creating ? '创建中...' : '创建' }}</button>
</div>
</div>
</div>
<!-- 重命名弹窗 -->
<div v-if="editConv" class="modal-overlay" @click.self="editConv = null">
<div class="modal">
<h2>重命名会话</h2>
<div class="form-group"><label>标题</label><input v-model="editConv.title" /></div>
<div class="modal-actions">
<button @click="editConv = null" class="btn-secondary">取消</button>
<button @click="saveTitle" class="btn-primary">保存</button>
</div>
</div>
</div>
<!-- 消息导航栏 -->
<MessageNav
v-if="selectedConv && convMessages.length > 0"
:messages="convMessages"
:active-id="activeMessageId"
@scroll-to="scrollToMessageById"
/>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
import { useRouter } from 'vue-router'
import { conversationsAPI, providersAPI, messagesAPI, toolsAPI } from '../utils/api.js'
import { streamManager } from '../utils/streamManager.js'
import { useStreamStore } from '../utils/streamStore.js'
import { renderMarkdown } from '../utils/markdown.js'
import ProcessBlock from '../components/ProcessBlock.vue'
import MessageNav from '../components/MessageNav.vue'
const router = useRouter()
const streamStore = useStreamStore()
const list = ref([])
const providers = ref([])
const page = ref(1)
const pageSize = 20
const total = ref(0)
const loading = ref(true)
const error = ref('')
const showModal = ref(false)
const creating = ref(false)
const form = ref({ title: '', provider_id: null, model: '' })
const selectedId = ref(null)
const selectedConv = ref(null)
const convMessages = ref([])
const loadingMessages = ref(false)
const enabledTools = ref([])
const totalPages = computed(() => Math.ceil(total.value / pageSize))
// 当前会话的流状态
const currentStreamState = computed(() => {
return streamStore.getStreamState(selectedId.value)
})
// 检查指定会话是否有活跃流
const hasActiveStream = (convId) => {
return streamStore.hasActiveStream(convId)
}
// 加载启用的工具列表
const loadEnabledTools = async () => {
try {
const res = await toolsAPI.list()
if (res.success) {
const tools = res.data?.tools || []
enabledTools.value = tools.map(t => t.function?.name || t.name)
}
} catch (e) {
console.error('Failed to load tools:', e)
}
}
const onProviderChange = () => {
const p = providers.value.find(p => p.id === form.value.provider_id)
if (p) form.value.model = p.default_model || ''
}
const fetchData = async () => {
loading.value = true
error.value = ''
try {
const [convRes, provRes] = await Promise.allSettled([
conversationsAPI.list({ page: page.value, page_size: pageSize }),
providersAPI.list()
])
if (convRes.status === 'fulfilled' && convRes.value.success) {
list.value = convRes.value.data?.items || []
total.value = convRes.value.data?.total || 0
// 默认选中最后一个会话
if (list.value.length > 0 && !selectedId.value) {
selectConv(list.value[0])
}
}
if (provRes.status === 'fulfilled' && provRes.value.success) {
providers.value = provRes.value.data?.providers || []
}
} catch (e) { error.value = e.message }
finally { loading.value = false }
}
const selectConv = async (c) => {
selectedId.value = c.id
selectedConv.value = c
await fetchConvMessages(c.id)
// 设置流状态监听
setupStreamWatch()
}
const newMessage = ref('')
const messagesContainer = ref(null)
const activeMessageId = ref(null)
let scrollObserver = null
const observedElements = new Set()
// sending 状态与流状态同步
const sending = computed(() => {
const state = streamStore.getStreamState(selectedId.value)
return state && state.status === 'streaming'
})
// 初始化 IntersectionObserver 来跟踪可见消息
const initScrollObserver = () => {
if (!messagesContainer.value) return
scrollObserver?.disconnect()
scrollObserver = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
activeMessageId.value = entry.target.dataset.msgId || null
}
}
},
{ root: messagesContainer.value, threshold: 0.5 }
)
// 观察已有的消息元素
nextTick(() => {
if (!messagesContainer.value) return
const wrappers = messagesContainer.value.querySelectorAll('[data-msg-id]')
wrappers.forEach(el => {
if (!observedElements.has(el)) {
scrollObserver.observe(el)
observedElements.add(el)
}
})
})
}
// 观察新添加的消息元素
watch(() => convMessages.value.length, () => {
nextTick(() => {
if (!scrollObserver || !messagesContainer.value) return
const wrappers = messagesContainer.value.querySelectorAll('[data-msg-id]')
wrappers.forEach(el => {
if (!observedElements.has(el)) {
scrollObserver.observe(el)
observedElements.add(el)
}
})
})
})
const fetchConvMessages = async (convId) => {
loadingMessages.value = true
convMessages.value = []
// 重置观察的元素集合
observedElements.clear()
try {
const res = await messagesAPI.list(convId)
if (res.success) {
convMessages.value = res.data?.messages || []
}
} catch (e) {
console.error('获取消息失败:', e)
} finally {
loadingMessages.value = false
// 加载完成后初始化 observer
nextTick(() => initScrollObserver())
}
}
// 导航到指定消息
const scrollToMessage = (index) => {
if (!messagesContainer.value) return
const items = messagesContainer.value.querySelectorAll('.chat-message')
if (items[index]) {
items[index].scrollIntoView({ behavior: 'smooth', block: 'start' })
}
}
// 导航到指定消息通过ID
const scrollToMessageById = (msgId) => {
nextTick(() => {
if (!messagesContainer.value) return
// 使用 data-msg-id 直接定位消息元素
const el = messagesContainer.value.querySelector(`[data-msg-id="${msgId}"]`)
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'start' })
activeMessageId.value = msgId
}
})
}
// 自动滚动到底部
const scrollToBottom = () => {
nextTick(() => {
if (messagesContainer.value) {
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
}
})
}
watch(convMessages, () => {
scrollToBottom()
}, { deep: true })
watch(() => currentStreamState.value?.process_steps?.length, () => {
scrollToBottom()
})
// 渲染消息内容Markdown
const renderMsgContent = (msg) => {
const content = msg.content || msg.text || ''
if (!content) return '-'
return renderMarkdown(content)
}
// 设置流状态监听
let unwatchStream = null
const setupStreamWatch = () => {
// 取消之前的监听
if (unwatchStream) {
unwatchStream()
}
unwatchStream = watch(
() => streamStore.getStreamState(selectedId.value),
(state) => {
if (!state) return
if (state.status === 'done') {
// 流完成,添加到消息列表
const completedMessage = {
id: state.id,
role: 'assistant',
process_steps: state.process_steps,
token_count: state.token_count,
usage: state.usage,
created_at: new Date().toISOString()
}
convMessages.value.push(completedMessage)
// 清除流状态
streamStore.clearStream(selectedId.value)
} else if (state.status === 'error') {
// 流错误
console.error('Stream error:', state.error)
streamStore.clearStream(selectedId.value)
}
},
{ deep: true }
)
}
const sendMessage = async () => {
if (!newMessage.value.trim() || !selectedConv.value || sending.value) return
const content = newMessage.value.trim()
newMessage.value = ''
// 添加用户消息到列表
const userMsgId = 'user-' + Date.now()
const userMsg = {
id: userMsgId,
role: 'user',
content: content,
created_at: new Date().toISOString()
}
convMessages.value.push(userMsg)
// 如果还没有标题或标题为默认标题,使用第一条消息作为标题
const currentTitle = selectedConv.value?.title
const isDefaultTitle = !currentTitle || currentTitle === 'New Conversation' || currentTitle.trim() === ''
if (isDefaultTitle) {
const title = content.slice(0, 30) + (content.length > 30 ? '...' : '')
selectedConv.value.title = title
// 更新列表中的标题
const conv = list.value.find(c => c.id === selectedConv.value.id)
if (conv) conv.title = title
// 调用 API 保存
await conversationsAPI.update(selectedConv.value.id, { title })
}
// 使用 StreamManager 发送流式请求
await streamManager.startStream(
selectedConv.value.id,
{
conversation_id: selectedConv.value.id,
content,
enabled_tools: enabledTools.value
},
userMsgId
)
}
const createConv = async () => {
creating.value = true
try {
const res = await conversationsAPI.create(form.value)
if (res.success && res.data?.id) {
showModal.value = false
form.value = { title: '', provider_id: null, model: '' }
// 刷新列表
await fetchData()
// 选中新创建的会话
const newConv = list.value.find(c => c.id === res.data.id)
if (newConv) {
selectConv(newConv)
}
}
} catch (e) { alert('创建失败: ' + e.message) }
finally { creating.value = false }
}
const deleteConv = async (c) => {
// 如果有活跃流,先取消
if (hasActiveStream(c.id)) {
streamManager.cancelStream(c.id)
}
if (!confirm(`删除${c.title || '未命名会话'}`)) return
await conversationsAPI.delete(c.id)
if (selectedId.value === c.id) {
selectedId.value = null
selectedConv.value = null
}
fetchData()
}
const editConv = ref(null)
const editTitle = (c) => {
editConv.value = { ...c }
}
const saveTitle = async () => {
if (!editConv.value) return
await conversationsAPI.update(editConv.value.id, { title: editConv.value.title })
editConv.value = null
fetchData()
}
const formatDate = (dateStr) => {
if (!dateStr) return '-'
return new Date(dateStr).toLocaleDateString('zh-CN', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })
}
onMounted(() => {
fetchData()
loadEnabledTools()
})
onUnmounted(() => {
scrollObserver?.disconnect()
if (unwatchStream) {
unwatchStream()
}
})
</script>
<style scoped>
/* 布局 */
.page-container { padding: 0 !important; overflow: hidden; height: 100% !important; min-height: 0; display: flex; flex-direction: column; }
.page-container.conversations { height: 100% !important; }
.conv-layout { display: flex; gap: 1rem; height: 100%; min-height: 0; flex: 1; }
/* 左侧边栏 */
.conv-sidebar { width: 20%; min-width: 160px; max-width: 280px; background: var(--bg-primary); border: 1px solid var(--border-light); border-radius: 12px; display: flex; flex-direction: column; overflow: hidden; }
.sidebar-header { padding: 0.75rem; border-bottom: 1px solid var(--border-light); }
.btn-new-conv { width: 100%; padding: 0.5rem; background: var(--accent-primary); color: white; border: none; border-radius: 6px; cursor: pointer; font-size: 0.85rem; font-weight: 500; transition: all 0.2s; }
.btn-new-conv:hover { background: var(--accent-primary-hover); }
.conv-list { flex: 1; overflow-y: auto; }
/* 会话列表项 */
.conv-item { padding: 0.85rem 1rem; border-bottom: 1px solid var(--border-light); cursor: pointer; transition: all 0.15s ease; }
.conv-item:hover { background: var(--bg-hover); }
.conv-item.active { background: var(--accent-primary-light); border-left: 3px solid var(--accent-primary); }
.conv-item-header { display: flex; justify-content: space-between; align-items: flex-start; gap: 0.5rem; margin-bottom: 0.4rem; }
.conv-item-title { font-size: 0.8rem; font-weight: 500; color: var(--text-primary); overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-box-orient: vertical; flex: 1; }
.conv-item-time { font-size: 0.65rem; color: var(--text-tertiary); flex-shrink: 0; }
.conv-item-meta { display: flex; justify-content: space-between; align-items: center; }
.conv-item-model { font-size: 0.7rem; color: var(--text-secondary); display: flex; align-items: center; gap: 4px; }
.streaming-indicator {
width: 6px;
height: 6px;
background: #4ade80;
border-radius: 50%;
animation: pulse 1.5s ease-in-out infinite;
}
.conv-item-actions { display: flex; gap: 0.25rem; opacity: 1; }
.btn-icon {
background: transparent;
border: none;
cursor: pointer;
padding: 4px;
border-radius: 4px;
color: var(--text-secondary);
transition: all 0.15s;
display: flex;
align-items: center;
justify-content: center;
}
.btn-icon:hover {
background: var(--bg-hover);
color: var(--accent-primary);
}
.btn-icon svg {
width: 14px;
height: 14px;
}
.btn-delete-icon { color: var(--danger-color); }
.btn-delete-icon:hover { color: var(--danger-color); }
/* 侧边栏分页 */
.sidebar-pagination { display: flex; justify-content: center; align-items: center; gap: 0.75rem; padding: 0.75rem; border-top: 1px solid var(--border-light); font-size: 0.8rem; color: var(--text-secondary); }
.btn-page { padding: 0.25rem 0.5rem; background: var(--bg-secondary); border: 1px solid var(--border-light); border-radius: 4px; cursor: pointer; font-size: 0.9rem; }
.btn-page:disabled { opacity: 0.4; cursor: not-allowed; }
/* 右侧内容区 */
.conv-content { flex: 1; background: var(--bg-primary); border: 1px solid var(--border-light); border-radius: 12px; display: flex; flex-direction: column; overflow: hidden; min-height: 0; }
.empty-content { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; color: var(--text-secondary); }
.empty-icon { font-size: 3rem; margin-bottom: 1rem; opacity: 0.5; }
.empty-content p { font-size: 0.9rem; }
/* 聊天视图容器 */
.chat-view-container { flex: 1; display: flex; flex-direction: column; height: 100%; overflow: hidden; }
.chat-header { display: flex; justify-content: flex-end; padding: 8px; border-bottom: 1px solid var(--border-light); }
.btn-nav-toggle { background: none; border: none; font-size: 18px; cursor: pointer; padding: 4px 8px; border-radius: 4px; transition: background 0.15s; }
.btn-nav-toggle:hover { background: var(--bg-hover); }
.chat-messages { flex: 1; overflow-y: auto; padding: 0.75rem; }
.chat-message { display: flex; gap: 0.75rem; margin-bottom: 0.75rem; }
.chat-message.user { flex-direction: row-reverse; }
.chat-message.streaming { opacity: 0.9; }
.chat-message.streaming .message-avatar { animation: pulse 1.5s ease-in-out infinite; }
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.6; } }
.message-avatar { width: 32px; height: 32px; border-radius: 50%; background: var(--bg-secondary); display: flex; align-items: center; justify-content: center; font-size: 1rem; flex-shrink: 0; }
.message-content { max-width: 80%; width: 80%; }
.chat-message.user .message-content { max-width: 80%; width: auto; }
.message-text { padding: 0.65rem 0.9rem; border-radius: 12px; font-size: 0.9rem; line-height: 1.5; background: var(--bg-secondary); color: var(--text-primary); word-break: break-word; }
.chat-message.user .message-text { background: var(--accent-primary); color: white; }
.chat-empty { flex: 1; display: flex; align-items: center; justify-content: center; color: var(--text-tertiary); font-size: 0.85rem; }
.loading-messages { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 2rem; color: var(--text-secondary); font-size: 0.85rem; }
.loading-messages .spinner-small { margin-bottom: 0.5rem; }
/* Markdown 内容样式 */
.message-text { line-height: 1.6; }
.message-text :deep(pre) { background: var(--bg-code); border-radius: 8px; padding: 0.75rem; overflow-x: auto; margin: 0.5rem 0; }
.message-text :deep(code) { background: var(--bg-code); padding: 0.15rem 0.35rem; border-radius: 4px; font-size: 0.85em; }
.message-text :deep(pre code) { background: none; padding: 0; }
.message-text :deep(p) { margin: 0.5rem 0; }
.message-text :deep(p:first-child) { margin-top: 0; }
.message-text :deep(p:last-child) { margin-bottom: 0; }
/* 聊天输入区 */
.chat-input-area { padding: 1rem; border-top: 1px solid var(--border-light); display: flex; gap: 0.75rem; }
.chat-input { flex: 1; padding: 0.65rem 0.9rem; border: 1px solid var(--border-input); border-radius: 8px; background: var(--bg-input); color: var(--text-primary); font-size: 0.9rem; }
.chat-input:focus { outline: none; border-color: var(--accent-primary); }
.btn-send { width: 40px; height: 40px; background: var(--accent-primary); color: white; border: none; border-radius: 8px; cursor: pointer; display: flex; align-items: center; justify-content: center; transition: all 0.15s ease; }
.btn-send:hover:not(:disabled) { background: var(--accent-primary-hover); transform: translateY(-1px); box-shadow: 0 2px 8px rgba(37, 99, 235, 0.3); }
.btn-send:disabled { opacity: 0.5; cursor: not-allowed; }
/* 按钮样式 */
.btn-primary { padding: 0.5rem 1rem; background: var(--accent-primary); color: white; border: none; border-radius: 6px; cursor: pointer; font-size: 0.85rem; transition: all 0.2s; }
.btn-primary:hover { background: var(--accent-primary-hover); }
.btn-secondary { padding: 0.5rem 1rem; background: var(--bg-secondary); color: var(--text-primary); border: 1px solid var(--border-light); border-radius: 6px; cursor: pointer; font-size: 0.85rem; transition: all 0.2s; }
.btn-secondary:hover { background: var(--bg-hover); }
/* 加载和空状态 */
.loading, .empty-sidebar { text-align: center; padding: 2rem; color: var(--text-secondary); font-size: 0.85rem; }
.error-msg { text-align: center; padding: 1rem; color: var(--danger-color); background: var(--danger-bg); border-radius: 8px; margin: 1rem; font-size: 0.85rem; }
.spinner-small { width: 24px; height: 24px; border: 3px solid var(--border-light); border-top-color: var(--accent-primary); border-radius: 50%; animation: spin 1s linear infinite; margin: 0 auto 0.5rem; }
@keyframes spin { to { transform: rotate(360deg); } }
/* 模态框 */
.modal-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.5); display: flex; align-items: center; justify-content: center; z-index: 1000; }
.modal { background: var(--bg-primary); border-radius: 16px; padding: 1.25rem; width: 100%; max-width: 400px; }
.modal h2 { margin: 0 0 1.25rem; font-size: 1.1rem; color: var(--text-primary); }
.form-group { margin-bottom: 1rem; }
.form-group label { display: block; margin-bottom: 0.5rem; font-weight: 500; font-size: 0.9rem; color: var(--text-primary); }
.form-group input, .form-group select { width: 100%; padding: 0.65rem; border: 1px solid var(--border-input); border-radius: 8px; background: var(--bg-input); box-sizing: border-box; font-size: 0.9rem; color: var(--text-primary); }
.modal-actions { display: flex; justify-content: flex-end; gap: 0.75rem; margin-top: 1.25rem; }
</style>