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:
2026-06-19 00:59:38 +09:00
co-authored by Claude Sonnet 4.6
parent 903d34761c
commit 4a08ba29fa
34 changed files with 5051 additions and 160 deletions
+1381
View File
File diff suppressed because it is too large Load Diff
+4 -1
View File
@@ -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
View File
@@ -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>
+58 -5
View File
@@ -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)
}
}
}
+18 -7
View File
@@ -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; } }
+176
View File
@@ -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>
+110
View File
@@ -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>
+123
View File
@@ -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>
+8 -2
View File
@@ -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>
+21 -2
View File
@@ -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;
+56
View File
@@ -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
View File
@@ -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
View File
@@ -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',
},
},