Add system memory monitoring, Bedrock support, and Discord integrations
- Add CPU/GPU memory monitoring endpoint and frontend widget - Add Amazon Bedrock model support (API key / inference profiles) plus model selector entries - Add Discord weather/laundry notification job (JMA forecast + LLM-generated message) - Add Discord bot that replies to mentions via the configured LLM, with weather-aware context - Add scheduled "useless tips" job with separate webhook and quiet-hours skip - Switch FastAPI startup to lifespan handler, log viewer, markdown rendering, and toast UI additions Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, nextTick } from 'vue'
|
||||
import { useChatStore } from '../stores/chat'
|
||||
import MarkdownContent from './MarkdownContent.vue'
|
||||
|
||||
const store = useChatStore()
|
||||
const scrollEl = ref<HTMLElement | null>(null)
|
||||
@@ -22,11 +23,17 @@ watch(
|
||||
<div
|
||||
v-for="(msg, i) in store.messages"
|
||||
:key="i"
|
||||
:class="['message', msg.role, { pending: msg.pending }]"
|
||||
:class="['message', msg.role, { error: msg.error }]"
|
||||
>
|
||||
<div class="bubble">
|
||||
<span class="content">{{ msg.content }}</span>
|
||||
<span v-if="msg.pending" class="cursor">▌</span>
|
||||
<!-- ユーザーメッセージはプレーンテキスト -->
|
||||
<template v-if="msg.role === 'user'">{{ msg.content }}</template>
|
||||
|
||||
<!-- アシスタントはMarkdownレンダリング -->
|
||||
<template v-else>
|
||||
<MarkdownContent :content="msg.content" :pending="msg.pending" />
|
||||
<span v-if="msg.pending && !msg.content" class="cursor">▌</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -46,9 +53,7 @@ watch(
|
||||
color: #555;
|
||||
font-size: 14px;
|
||||
}
|
||||
.message {
|
||||
display: flex;
|
||||
}
|
||||
.message { display: flex; }
|
||||
.message.user { justify-content: flex-end; }
|
||||
.message.assistant { justify-content: flex-start; }
|
||||
.bubble {
|
||||
@@ -57,20 +62,26 @@ watch(
|
||||
border-radius: 12px;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
.user .bubble {
|
||||
background: #2563eb;
|
||||
color: #fff;
|
||||
border-bottom-right-radius: 4px;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.assistant .bubble {
|
||||
background: #2a2a2a;
|
||||
color: #eee;
|
||||
border-bottom-left-radius: 4px;
|
||||
}
|
||||
.message.error .bubble {
|
||||
background: #3b1a1a;
|
||||
color: #f87171;
|
||||
border: 1px solid #7f1d1d;
|
||||
}
|
||||
.cursor {
|
||||
display: inline-block;
|
||||
animation: blink 0.8s step-end infinite;
|
||||
}
|
||||
@keyframes blink { 50% { opacity: 0; } }
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onMounted } from 'vue'
|
||||
import { fetchLogs, deleteLog, type ApiLog } from '../api/client'
|
||||
import { useChatStore } from '../stores/chat'
|
||||
|
||||
const store = useChatStore()
|
||||
|
||||
const logs = ref<ApiLog[]>([])
|
||||
const filterStatus = ref<'all' | 'ok' | 'error'>('all')
|
||||
const filterSession = ref(false)
|
||||
const expanded = ref<Set<number>>(new Set())
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
logs.value = await fetchLogs({
|
||||
status: filterStatus.value === 'all' ? undefined : filterStatus.value,
|
||||
session_id: filterSession.value && store.currentSessionId ? store.currentSessionId : undefined,
|
||||
limit: 200,
|
||||
})
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'エラーが発生しました'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(id: number) {
|
||||
await deleteLog(id)
|
||||
logs.value = logs.value.filter((l) => l.id !== id)
|
||||
}
|
||||
|
||||
function toggle(id: number) {
|
||||
if (expanded.value.has(id)) expanded.value.delete(id)
|
||||
else expanded.value.add(id)
|
||||
}
|
||||
|
||||
function formatTime(iso: string) {
|
||||
return new Date(iso).toLocaleString('ja-JP', { hour12: false })
|
||||
}
|
||||
|
||||
function requestMessages(log: ApiLog): string {
|
||||
try {
|
||||
const msgs: { role: string; content: string }[] = JSON.parse(log.request_json)
|
||||
return msgs.map((m) => `[${m.role}] ${m.content}`).join('\n\n')
|
||||
} catch {
|
||||
return log.request_json
|
||||
}
|
||||
}
|
||||
|
||||
watch([filterStatus, filterSession], load)
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="log-viewer">
|
||||
<div class="toolbar">
|
||||
<span class="title">API ログ</span>
|
||||
<div class="filters">
|
||||
<label>
|
||||
<input type="checkbox" v-model="filterSession" :disabled="!store.currentSessionId" />
|
||||
現在のセッションのみ
|
||||
</label>
|
||||
<select v-model="filterStatus">
|
||||
<option value="all">すべて</option>
|
||||
<option value="ok">成功のみ</option>
|
||||
<option value="error">エラーのみ</option>
|
||||
</select>
|
||||
</div>
|
||||
<button class="reload-btn" @click="load" :disabled="loading">↻</button>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="empty">読み込み中...</div>
|
||||
<div v-else-if="error" class="empty api-error">{{ error }}</div>
|
||||
<div v-else-if="logs.length === 0" class="empty">ログがありません</div>
|
||||
|
||||
<ul v-else>
|
||||
<li v-for="log in logs" :key="log.id" :class="{ 'has-error': !!log.error }">
|
||||
<div class="log-header" @click="toggle(log.id)">
|
||||
<span class="status-dot" :class="log.error ? 'err' : 'ok'" />
|
||||
<span class="model">{{ log.model }}</span>
|
||||
<span class="latency">{{ log.latency_ms != null ? log.latency_ms + ' ms' : '-' }}</span>
|
||||
<span class="time">{{ formatTime(log.created_at) }}</span>
|
||||
<button class="del-btn" @click.stop="remove(log.id)">✕</button>
|
||||
<span class="arrow">{{ expanded.has(log.id) ? '▲' : '▼' }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="expanded.has(log.id)" class="log-detail">
|
||||
<section>
|
||||
<h4>リクエスト</h4>
|
||||
<pre>{{ requestMessages(log) }}</pre>
|
||||
</section>
|
||||
<section v-if="log.response_text">
|
||||
<h4>レスポンス</h4>
|
||||
<pre>{{ log.response_text }}</pre>
|
||||
</section>
|
||||
<section v-if="log.error" class="error-section">
|
||||
<h4>エラー</h4>
|
||||
<pre>{{ log.error }}</pre>
|
||||
</section>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.log-viewer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: #111;
|
||||
}
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid #2a2a2a;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.title { font-size: 13px; font-weight: 600; color: #aaa; flex: 1; }
|
||||
.filters { display: flex; align-items: center; gap: 8px; font-size: 12px; color: #888; }
|
||||
.filters label { display: flex; align-items: center; gap: 4px; cursor: pointer; }
|
||||
.filters input { accent-color: #2563eb; cursor: pointer; }
|
||||
select {
|
||||
background: #2a2a2a; color: #ccc; border: 1px solid #444;
|
||||
border-radius: 5px; padding: 2px 6px; font-size: 12px;
|
||||
}
|
||||
.reload-btn {
|
||||
background: none; border: 1px solid #444; color: #aaa;
|
||||
border-radius: 5px; padding: 2px 8px; cursor: pointer; font-size: 14px;
|
||||
}
|
||||
.reload-btn:hover:not(:disabled) { color: #fff; }
|
||||
.empty { padding: 20px; text-align: center; color: #555; font-size: 13px; }
|
||||
.api-error { color: #f87171 !important; }
|
||||
ul { list-style: none; margin: 0; padding: 4px 0; overflow-y: auto; flex: 1; }
|
||||
li { border-bottom: 1px solid #1e1e1e; }
|
||||
li.has-error { border-left: 3px solid #7f1d1d; }
|
||||
.log-header {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 8px 12px; cursor: pointer;
|
||||
font-size: 12px;
|
||||
}
|
||||
.log-header:hover { background: #1a1a1a; }
|
||||
.status-dot {
|
||||
width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0;
|
||||
}
|
||||
.status-dot.ok { background: #22c55e; }
|
||||
.status-dot.err { background: #ef4444; }
|
||||
.model { color: #ccc; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.latency { color: #666; width: 60px; text-align: right; }
|
||||
.time { color: #555; white-space: nowrap; }
|
||||
.del-btn {
|
||||
background: none; border: none; color: #555;
|
||||
cursor: pointer; font-size: 11px; opacity: 0;
|
||||
}
|
||||
.log-header:hover .del-btn { opacity: 1; }
|
||||
.arrow { color: #555; font-size: 10px; }
|
||||
.log-detail {
|
||||
padding: 10px 14px 14px;
|
||||
display: flex; flex-direction: column; gap: 10px;
|
||||
}
|
||||
section h4 { font-size: 11px; color: #666; margin-bottom: 4px; text-transform: uppercase; }
|
||||
pre {
|
||||
background: #1a1a1a; color: #ccc; border-radius: 6px;
|
||||
padding: 10px; font-size: 12px; line-height: 1.5;
|
||||
white-space: pre-wrap; word-break: break-word; margin: 0;
|
||||
max-height: 300px; overflow-y: auto;
|
||||
}
|
||||
.error-section pre { background: #1f0a0a; color: #f87171; }
|
||||
</style>
|
||||
@@ -0,0 +1,110 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { marked } from 'marked'
|
||||
import DOMPurify from 'dompurify'
|
||||
|
||||
const props = defineProps<{
|
||||
content: string
|
||||
pending?: boolean
|
||||
}>()
|
||||
|
||||
marked.setOptions({ breaks: true, gfm: true })
|
||||
|
||||
function closeOpenFences(src: string): string {
|
||||
const fenceCount = (src.match(/^```/gm) ?? []).length
|
||||
if (fenceCount % 2 !== 0) return src + '\n```'
|
||||
return src
|
||||
}
|
||||
|
||||
const html = computed(() => {
|
||||
const raw = props.pending ? closeOpenFences(props.content) : props.content
|
||||
return DOMPurify.sanitize(marked.parse(raw) as string)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="md" v-html="html" />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.md { line-height: 1.7; word-break: break-word; }
|
||||
|
||||
/* インライン要素 */
|
||||
.md :deep(code) {
|
||||
background: #1a1a1a;
|
||||
color: #e2e8f0;
|
||||
padding: 1px 5px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.875em;
|
||||
font-family: 'Consolas', 'Menlo', monospace;
|
||||
}
|
||||
|
||||
/* コードブロック */
|
||||
.md :deep(pre) {
|
||||
background: #0f0f0f;
|
||||
border: 1px solid #2a2a2a;
|
||||
border-radius: 8px;
|
||||
padding: 12px 14px;
|
||||
overflow-x: auto;
|
||||
margin: 8px 0;
|
||||
}
|
||||
.md :deep(pre code) {
|
||||
background: none;
|
||||
padding: 0;
|
||||
font-size: 0.85em;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
/* 見出し */
|
||||
.md :deep(h1),
|
||||
.md :deep(h2),
|
||||
.md :deep(h3) {
|
||||
margin: 12px 0 4px;
|
||||
color: #f1f5f9;
|
||||
font-weight: 600;
|
||||
}
|
||||
.md :deep(h1) { font-size: 1.2em; }
|
||||
.md :deep(h2) { font-size: 1.1em; }
|
||||
.md :deep(h3) { font-size: 1em; }
|
||||
|
||||
/* リスト */
|
||||
.md :deep(ul),
|
||||
.md :deep(ol) {
|
||||
padding-left: 20px;
|
||||
margin: 4px 0;
|
||||
}
|
||||
.md :deep(li) { margin: 2px 0; }
|
||||
|
||||
/* 段落 */
|
||||
.md :deep(p) { margin: 4px 0; }
|
||||
|
||||
/* 引用 */
|
||||
.md :deep(blockquote) {
|
||||
border-left: 3px solid #444;
|
||||
margin: 6px 0;
|
||||
padding: 4px 12px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
/* テーブル */
|
||||
.md :deep(table) {
|
||||
border-collapse: collapse;
|
||||
margin: 8px 0;
|
||||
font-size: 0.9em;
|
||||
width: 100%;
|
||||
}
|
||||
.md :deep(th),
|
||||
.md :deep(td) {
|
||||
border: 1px solid #333;
|
||||
padding: 6px 10px;
|
||||
text-align: left;
|
||||
}
|
||||
.md :deep(th) { background: #1a1a1a; color: #cbd5e1; }
|
||||
|
||||
/* 水平線 */
|
||||
.md :deep(hr) { border-color: #333; margin: 10px 0; }
|
||||
|
||||
/* リンク */
|
||||
.md :deep(a) { color: #60a5fa; text-decoration: none; }
|
||||
.md :deep(a:hover) { text-decoration: underline; }
|
||||
</style>
|
||||
@@ -0,0 +1,123 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
|
||||
interface MemoryStats {
|
||||
cpu_percent: number
|
||||
memory_percent: number
|
||||
memory_used_mb: number
|
||||
memory_total_mb: number
|
||||
gpu_memory_used_mb?: number
|
||||
gpu_memory_total_mb?: number
|
||||
gpu_memory_percent?: number
|
||||
}
|
||||
|
||||
const stats = ref<MemoryStats | null>(null)
|
||||
let intervalId: number | null = null
|
||||
|
||||
const fetchMemoryStats = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/system/memory')
|
||||
stats.value = await response.json()
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch memory stats:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const formatMemory = (mb: number) => {
|
||||
if (mb > 1024) {
|
||||
return `${(mb / 1024).toFixed(1)}GB`
|
||||
}
|
||||
return `${Math.round(mb)}MB`
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchMemoryStats()
|
||||
intervalId = window.setInterval(fetchMemoryStats, 10000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (intervalId) {
|
||||
clearInterval(intervalId)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="memory-monitor" v-if="stats">
|
||||
<div class="stat">
|
||||
<span class="label">CPU</span>
|
||||
<div class="bar">
|
||||
<div class="fill" :style="{ width: stats.cpu_percent + '%' }"></div>
|
||||
</div>
|
||||
<span class="value">{{ stats.cpu_percent.toFixed(1) }}%</span>
|
||||
</div>
|
||||
|
||||
<div class="stat">
|
||||
<span class="label">RAM</span>
|
||||
<div class="bar">
|
||||
<div class="fill" :style="{ width: stats.memory_percent + '%' }"></div>
|
||||
</div>
|
||||
<span class="value">{{ stats.memory_percent.toFixed(1) }}% ({{ formatMemory(stats.memory_used_mb) }} / {{ formatMemory(stats.memory_total_mb) }})</span>
|
||||
</div>
|
||||
|
||||
<div class="stat" v-if="stats.gpu_memory_percent !== undefined">
|
||||
<span class="label">GPU</span>
|
||||
<div class="bar">
|
||||
<div class="fill gpu" :style="{ width: stats.gpu_memory_percent + '%' }"></div>
|
||||
</div>
|
||||
<span class="value">{{ stats.gpu_memory_percent.toFixed(1) }}% ({{ formatMemory(stats.gpu_memory_used_mb!) }} / {{ formatMemory(stats.gpu_memory_total_mb!) }})</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.memory-monitor {
|
||||
background: #1a1a1a;
|
||||
border-top: 1px solid #333;
|
||||
border-bottom: 1px solid #333;
|
||||
padding: 12px;
|
||||
font-size: 11px;
|
||||
font-family: monospace;
|
||||
gap: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.stat {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.label {
|
||||
width: 45px;
|
||||
color: #888;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.bar {
|
||||
flex: 1;
|
||||
height: 16px;
|
||||
background: #2a2a2a;
|
||||
border-radius: 3px;
|
||||
border: 1px solid #444;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #4CAF50, #8BC34A);
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.fill.gpu {
|
||||
background: linear-gradient(90deg, #2196F3, #64B5F6);
|
||||
}
|
||||
|
||||
.value {
|
||||
width: 120px;
|
||||
text-align: right;
|
||||
color: #aaa;
|
||||
}
|
||||
</style>
|
||||
@@ -4,11 +4,17 @@ import { useChatStore } from '../stores/chat'
|
||||
const store = useChatStore()
|
||||
|
||||
const presets = [
|
||||
{ label: 'GPT-4o', value: 'gpt-4o' },
|
||||
{ label: 'GPT-4o mini', value: 'gpt-4o-mini' },
|
||||
{ label: 'GPT-5.5', value: 'gpt-5.5' },
|
||||
{ label: 'GPT-5.4', value: 'gpt-5.4' },
|
||||
{ label: 'GPT-5.4 mini', value: 'gpt-5.4-mini' },
|
||||
{ label: 'Claude Sonnet 4.6', value: 'claude-sonnet-4-6' },
|
||||
{ label: 'Claude Haiku 4.5', value: 'claude-haiku-4-5-20251001' },
|
||||
{ label: 'Ollama llama3', value: 'ollama/llama3' },
|
||||
{ label: 'Ollama Gemma4', value: 'ollama/gemma4' },
|
||||
{ label: 'Gemma 4 12B (llama-server)', value: 'gpt-3.5-turbo' },
|
||||
{ label: 'Claude Sonnet 4.6 (Bedrock)', value: 'bedrock/jp.anthropic.claude-sonnet-4-6' },
|
||||
{ label: 'Llama 3 70B (Bedrock)', value: 'bedrock/meta.llama3-70b-instruct-v1:0' },
|
||||
{ label: 'Nova 2 Lite (Bedrock)', value: 'bedrock/jp.amazon.nova-2-lite-v1:0' },
|
||||
]
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,10 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useChatStore } from '../stores/chat'
|
||||
|
||||
const store = useChatStore()
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
onMounted(() => store.loadSessions())
|
||||
async function load() {
|
||||
error.value = null
|
||||
try {
|
||||
await store.loadSessions()
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'エラーが発生しました'
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -13,6 +23,7 @@ onMounted(() => store.loadSessions())
|
||||
<span>チャット履歴</span>
|
||||
<button class="new-btn" @click="store.selectSession(null)">+ 新規</button>
|
||||
</div>
|
||||
<div v-if="error" class="api-error" :title="error">⚠ 接続エラー</div>
|
||||
<ul>
|
||||
<li
|
||||
v-for="s in store.sessions"
|
||||
@@ -50,6 +61,14 @@ onMounted(() => store.loadSessions())
|
||||
color: #888;
|
||||
border-bottom: 1px solid #2a2a2a;
|
||||
}
|
||||
.api-error {
|
||||
padding: 6px 12px;
|
||||
font-size: 11px;
|
||||
color: #f87171;
|
||||
background: #1f0a0a;
|
||||
border-bottom: 1px solid #7f1d1d;
|
||||
cursor: default;
|
||||
}
|
||||
.new-btn {
|
||||
background: #2a2a2a;
|
||||
color: #ccc;
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
<script setup lang="ts">
|
||||
import { useChatStore } from '../stores/chat'
|
||||
|
||||
const store = useChatStore()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="toast-list">
|
||||
<div
|
||||
v-for="toast in store.toasts"
|
||||
:key="toast.id"
|
||||
class="toast"
|
||||
@click="store.dismissToast(toast.id)"
|
||||
>
|
||||
<span class="icon">⚠</span>
|
||||
<span class="msg">{{ toast.message }}</span>
|
||||
<button class="close">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.toast-list {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
right: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
z-index: 1000;
|
||||
pointer-events: none;
|
||||
}
|
||||
.toast {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 14px;
|
||||
background: #1f0a0a;
|
||||
border: 1px solid #7f1d1d;
|
||||
border-radius: 8px;
|
||||
color: #f87171;
|
||||
font-size: 13px;
|
||||
max-width: 360px;
|
||||
cursor: pointer;
|
||||
pointer-events: all;
|
||||
animation: slide-in 0.2s ease;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.4);
|
||||
}
|
||||
.icon { flex-shrink: 0; }
|
||||
.msg { flex: 1; word-break: break-word; }
|
||||
.close { background: none; border: none; color: #f87171; cursor: pointer; font-size: 11px; padding: 0; }
|
||||
@keyframes slide-in {
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user