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:
Generated
+1381
File diff suppressed because it is too large
Load Diff
@@ -10,14 +10,17 @@
|
||||
"type-check": "vue-tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"dompurify": "^3.1.0",
|
||||
"marked": "^12.0.0",
|
||||
"pinia": "^2.1.7",
|
||||
"vue": "^3.4.0",
|
||||
"vue-router": "^4.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"@vitejs/plugin-vue": "^5.0.4",
|
||||
"typescript": "^5.4.0",
|
||||
"vite": "^5.2.0",
|
||||
"vite": "^8.0.16",
|
||||
"vue-tsc": "^2.0.6"
|
||||
}
|
||||
}
|
||||
|
||||
+37
-24
@@ -1,22 +1,42 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import SessionList from './components/SessionList.vue'
|
||||
import ChatWindow from './components/ChatWindow.vue'
|
||||
import MessageInput from './components/MessageInput.vue'
|
||||
import ModelSelector from './components/ModelSelector.vue'
|
||||
import LogViewer from './components/LogViewer.vue'
|
||||
import ToastList from './components/ToastList.vue'
|
||||
import MemoryMonitor from './components/MemoryMonitor.vue'
|
||||
|
||||
const showLogs = ref(false)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="app">
|
||||
<SessionList />
|
||||
<div class="sidebar">
|
||||
<SessionList />
|
||||
<MemoryMonitor />
|
||||
</div>
|
||||
<div class="main">
|
||||
<header>
|
||||
<span class="title">PlayChat</span>
|
||||
<ModelSelector />
|
||||
<button class="log-toggle" :class="{ active: showLogs }" @click="showLogs = !showLogs">
|
||||
ログ
|
||||
</button>
|
||||
</header>
|
||||
<ChatWindow />
|
||||
<MessageInput />
|
||||
<div class="body">
|
||||
<div class="chat-pane">
|
||||
<ChatWindow />
|
||||
<MessageInput />
|
||||
</div>
|
||||
<div v-if="showLogs" class="log-pane">
|
||||
<LogViewer />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ToastList />
|
||||
</template>
|
||||
|
||||
<style>
|
||||
@@ -26,28 +46,21 @@ body { background: #111; color: #eee; font-family: system-ui, sans-serif; }
|
||||
</style>
|
||||
|
||||
<style scoped>
|
||||
.app {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
}
|
||||
.main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
.app { display: flex; height: 100vh; }
|
||||
.sidebar { display: flex; flex-direction: column; border-right: 1px solid #333; overflow: hidden; }
|
||||
.main { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
|
||||
header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 16px;
|
||||
height: 48px;
|
||||
border-bottom: 1px solid #333;
|
||||
background: #1a1a1a;
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 0 16px; height: 48px;
|
||||
border-bottom: 1px solid #333; background: #1a1a1a; flex-shrink: 0;
|
||||
}
|
||||
.title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
.title { font-size: 16px; font-weight: 600; color: #fff; }
|
||||
.log-toggle {
|
||||
background: #2a2a2a; color: #aaa; border: 1px solid #444;
|
||||
border-radius: 6px; padding: 4px 14px; font-size: 13px; cursor: pointer;
|
||||
}
|
||||
.log-toggle.active { background: #1d3557; color: #93c5fd; border-color: #2563eb; }
|
||||
.body { flex: 1; display: flex; overflow: hidden; }
|
||||
.chat-pane { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
|
||||
.log-pane { width: 400px; min-width: 300px; border-left: 1px solid #2a2a2a; overflow: hidden; }
|
||||
</style>
|
||||
|
||||
@@ -1,3 +1,14 @@
|
||||
export interface ApiLog {
|
||||
id: number
|
||||
session_id: string | null
|
||||
model: string
|
||||
request_json: string
|
||||
response_text: string | null
|
||||
error: string | null
|
||||
latency_ms: number | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface SessionInfo {
|
||||
id: string
|
||||
title: string
|
||||
@@ -6,22 +17,64 @@ export interface SessionInfo {
|
||||
}
|
||||
|
||||
export interface SSEChunk {
|
||||
type: 'session_id' | 'chunk' | 'done'
|
||||
type: 'session_id' | 'chunk' | 'done' | 'error'
|
||||
value?: string
|
||||
}
|
||||
|
||||
const BASE = ''
|
||||
|
||||
async function parseJson<T>(res: Response, label: string): Promise<T> {
|
||||
const ct = res.headers.get('content-type') ?? ''
|
||||
if (!ct.includes('application/json')) {
|
||||
throw new Error(`${label}: バックエンドに接続できません (${res.status} ${res.statusText})`)
|
||||
}
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}))
|
||||
throw new Error(`${label}: ${body.detail ?? res.statusText}`)
|
||||
}
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function fetchSessions(): Promise<SessionInfo[]> {
|
||||
const res = await fetch(`${BASE}/chat/sessions`)
|
||||
if (!res.ok) throw new Error('Failed to fetch sessions')
|
||||
return res.json()
|
||||
return parseJson<SessionInfo[]>(res, 'セッション取得')
|
||||
}
|
||||
|
||||
export interface MessageRecord {
|
||||
id: number
|
||||
role: 'user' | 'assistant' | 'system'
|
||||
content: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export async function fetchMessages(sessionId: string): Promise<MessageRecord[]> {
|
||||
const res = await fetch(`${BASE}/chat/sessions/${sessionId}/messages`)
|
||||
return parseJson<MessageRecord[]>(res, 'メッセージ取得')
|
||||
}
|
||||
|
||||
export async function deleteSession(sessionId: string): Promise<void> {
|
||||
await fetch(`${BASE}/chat/sessions/${sessionId}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
export async function fetchLogs(params: {
|
||||
session_id?: string
|
||||
status?: 'ok' | 'error'
|
||||
limit?: number
|
||||
offset?: number
|
||||
} = {}): Promise<ApiLog[]> {
|
||||
const q = new URLSearchParams()
|
||||
if (params.session_id) q.set('session_id', params.session_id)
|
||||
if (params.status) q.set('status', params.status)
|
||||
if (params.limit != null) q.set('limit', String(params.limit))
|
||||
if (params.offset != null) q.set('offset', String(params.offset))
|
||||
const res = await fetch(`${BASE}/logs?${q}`)
|
||||
return parseJson<ApiLog[]>(res, 'ログ取得')
|
||||
}
|
||||
|
||||
export async function deleteLog(logId: number): Promise<void> {
|
||||
await fetch(`${BASE}/logs/${logId}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
export function streamChat(
|
||||
message: string,
|
||||
sessionId: string | null,
|
||||
@@ -54,8 +107,8 @@ export function streamChat(
|
||||
try {
|
||||
const parsed: SSEChunk = JSON.parse(line.slice(6))
|
||||
onChunk(parsed)
|
||||
} catch {
|
||||
// ignore malformed
|
||||
} catch (e) {
|
||||
console.error('[SSE] JSON parse error:', e, '| raw:', line)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
+51
-12
@@ -1,31 +1,61 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { fetchSessions, deleteSession, streamChat, type SessionInfo } from '../api/client'
|
||||
import { fetchSessions, fetchMessages, deleteSession, streamChat, type SessionInfo } from '../api/client'
|
||||
|
||||
export interface Message {
|
||||
role: 'user' | 'assistant'
|
||||
content: string
|
||||
pending?: boolean
|
||||
error?: boolean
|
||||
}
|
||||
|
||||
export interface Toast {
|
||||
id: number
|
||||
message: string
|
||||
}
|
||||
|
||||
let toastSeq = 0
|
||||
|
||||
export const useChatStore = defineStore('chat', () => {
|
||||
const sessions = ref<SessionInfo[]>([])
|
||||
const currentSessionId = ref<string | null>(null)
|
||||
const messages = ref<Message[]>([])
|
||||
const isStreaming = ref(false)
|
||||
const selectedModel = ref('gpt-4o')
|
||||
const selectedModel = ref('gpt-5.4')
|
||||
const toasts = ref<Toast[]>([])
|
||||
|
||||
const currentSession = computed(() =>
|
||||
sessions.value.find((s) => s.id === currentSessionId.value) ?? null,
|
||||
)
|
||||
|
||||
function pushToast(message: string, durationMs = 5000) {
|
||||
const id = ++toastSeq
|
||||
toasts.value.push({ id, message })
|
||||
setTimeout(() => {
|
||||
toasts.value = toasts.value.filter((t) => t.id !== id)
|
||||
}, durationMs)
|
||||
}
|
||||
|
||||
function dismissToast(id: number) {
|
||||
toasts.value = toasts.value.filter((t) => t.id !== id)
|
||||
}
|
||||
|
||||
async function loadSessions() {
|
||||
sessions.value = await fetchSessions()
|
||||
}
|
||||
|
||||
function selectSession(sessionId: string | null) {
|
||||
async function selectSession(sessionId: string | null) {
|
||||
currentSessionId.value = sessionId
|
||||
messages.value = []
|
||||
if (!sessionId) return
|
||||
try {
|
||||
const records = await fetchMessages(sessionId)
|
||||
messages.value = records
|
||||
.filter((r) => r.role === 'user' || r.role === 'assistant')
|
||||
.map((r) => ({ role: r.role as 'user' | 'assistant', content: r.content }))
|
||||
} catch (e) {
|
||||
pushToast(`メッセージ取得失敗: ${e instanceof Error ? e.message : e}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function removeSession(sessionId: string) {
|
||||
@@ -41,8 +71,8 @@ export const useChatStore = defineStore('chat', () => {
|
||||
if (isStreaming.value || !text.trim()) return
|
||||
|
||||
messages.value.push({ role: 'user', content: text })
|
||||
const assistantMsg: Message = { role: 'assistant', content: '', pending: true }
|
||||
messages.value.push(assistantMsg)
|
||||
messages.value.push({ role: 'assistant', content: '', pending: true })
|
||||
const msgIdx = messages.value.length - 1 // reactive proxy 経由で更新するためインデックスを保持
|
||||
isStreaming.value = true
|
||||
|
||||
streamChat(
|
||||
@@ -52,18 +82,24 @@ export const useChatStore = defineStore('chat', () => {
|
||||
(chunk) => {
|
||||
if (chunk.type === 'session_id' && chunk.value) {
|
||||
currentSessionId.value = chunk.value
|
||||
loadSessions()
|
||||
loadSessions().catch((e) => pushToast(`セッション更新失敗: ${e.message}`))
|
||||
} else if (chunk.type === 'chunk' && chunk.value) {
|
||||
assistantMsg.content += chunk.value
|
||||
messages.value[msgIdx].content += chunk.value
|
||||
} else if (chunk.type === 'done') {
|
||||
assistantMsg.pending = false
|
||||
messages.value[msgIdx].pending = false
|
||||
isStreaming.value = false
|
||||
loadSessions().catch((e) => pushToast(`セッション更新失敗: ${e.message}`))
|
||||
} else if (chunk.type === 'error') {
|
||||
messages.value[msgIdx].content = chunk.value ?? 'バックエンドでエラーが発生しました。'
|
||||
messages.value[msgIdx].pending = false
|
||||
messages.value[msgIdx].error = true
|
||||
isStreaming.value = false
|
||||
loadSessions()
|
||||
}
|
||||
},
|
||||
(_err) => {
|
||||
assistantMsg.content = 'エラーが発生しました。'
|
||||
assistantMsg.pending = false
|
||||
(err) => {
|
||||
messages.value[msgIdx].content = err.message
|
||||
messages.value[msgIdx].pending = false
|
||||
messages.value[msgIdx].error = true
|
||||
isStreaming.value = false
|
||||
},
|
||||
)
|
||||
@@ -75,7 +111,10 @@ export const useChatStore = defineStore('chat', () => {
|
||||
messages,
|
||||
isStreaming,
|
||||
selectedModel,
|
||||
toasts,
|
||||
currentSession,
|
||||
pushToast,
|
||||
dismissToast,
|
||||
loadSessions,
|
||||
selectSession,
|
||||
removeSession,
|
||||
|
||||
+12
-1
@@ -6,8 +6,19 @@ export default defineConfig({
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/chat': 'http://localhost:8000',
|
||||
'/chat': {
|
||||
target: 'http://localhost:8000',
|
||||
changeOrigin: true,
|
||||
// SSE ストリーミングのバッファリングを無効化
|
||||
configure: (proxy) => {
|
||||
proxy.on('proxyRes', (proxyRes) => {
|
||||
proxyRes.headers['x-accel-buffering'] = 'no'
|
||||
})
|
||||
},
|
||||
},
|
||||
'/api': 'http://localhost:8000',
|
||||
'/memory': 'http://localhost:8000',
|
||||
'/logs': 'http://localhost:8000',
|
||||
'/health': 'http://localhost:8000',
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user