initial_commit

This commit is contained in:
2026-06-01 20:16:53 +09:00
commit 903d34761c
31 changed files with 1123 additions and 0 deletions
+53
View File
@@ -0,0 +1,53 @@
<script setup lang="ts">
import SessionList from './components/SessionList.vue'
import ChatWindow from './components/ChatWindow.vue'
import MessageInput from './components/MessageInput.vue'
import ModelSelector from './components/ModelSelector.vue'
</script>
<template>
<div class="app">
<SessionList />
<div class="main">
<header>
<span class="title">PlayChat</span>
<ModelSelector />
</header>
<ChatWindow />
<MessageInput />
</div>
</div>
</template>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body, #app { height: 100%; }
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;
}
header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16px;
height: 48px;
border-bottom: 1px solid #333;
background: #1a1a1a;
}
.title {
font-size: 16px;
font-weight: 600;
color: #fff;
}
</style>
+69
View File
@@ -0,0 +1,69 @@
export interface SessionInfo {
id: string
title: string
model: string
updated_at: string
}
export interface SSEChunk {
type: 'session_id' | 'chunk' | 'done'
value?: string
}
const BASE = ''
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()
}
export async function deleteSession(sessionId: string): Promise<void> {
await fetch(`${BASE}/chat/sessions/${sessionId}`, { method: 'DELETE' })
}
export function streamChat(
message: string,
sessionId: string | null,
model: string,
onChunk: (chunk: SSEChunk) => void,
onError: (err: Error) => void,
): () => void {
const controller = new AbortController()
fetch(`${BASE}/chat/stream`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message, session_id: sessionId, model }),
signal: controller.signal,
})
.then(async (res) => {
if (!res.body) throw new Error('No response body')
const reader = res.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n\n')
buffer = lines.pop() ?? ''
for (const line of lines) {
if (line.startsWith('data: ')) {
try {
const parsed: SSEChunk = JSON.parse(line.slice(6))
onChunk(parsed)
} catch {
// ignore malformed
}
}
}
}
})
.catch((err) => {
if (err.name !== 'AbortError') onError(err)
})
return () => controller.abort()
}
+77
View File
@@ -0,0 +1,77 @@
<script setup lang="ts">
import { ref, watch, nextTick } from 'vue'
import { useChatStore } from '../stores/chat'
const store = useChatStore()
const scrollEl = ref<HTMLElement | null>(null)
watch(
() => store.messages.length,
async () => {
await nextTick()
if (scrollEl.value) scrollEl.value.scrollTop = scrollEl.value.scrollHeight
},
)
</script>
<template>
<div class="chat-window" ref="scrollEl">
<div v-if="store.messages.length === 0" class="empty">
メッセージを送信してチャットを開始してください
</div>
<div
v-for="(msg, i) in store.messages"
:key="i"
:class="['message', msg.role, { pending: msg.pending }]"
>
<div class="bubble">
<span class="content">{{ msg.content }}</span>
<span v-if="msg.pending" class="cursor">▌</span>
</div>
</div>
</div>
</template>
<style scoped>
.chat-window {
flex: 1;
overflow-y: auto;
padding: 16px 20px;
display: flex;
flex-direction: column;
gap: 12px;
}
.empty {
margin: auto;
color: #555;
font-size: 14px;
}
.message {
display: flex;
}
.message.user { justify-content: flex-end; }
.message.assistant { justify-content: flex-start; }
.bubble {
max-width: 72%;
padding: 10px 14px;
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;
}
.assistant .bubble {
background: #2a2a2a;
color: #eee;
border-bottom-left-radius: 4px;
}
.cursor {
animation: blink 0.8s step-end infinite;
}
@keyframes blink { 50% { opacity: 0; } }
</style>
+69
View File
@@ -0,0 +1,69 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useChatStore } from '../stores/chat'
const store = useChatStore()
const text = ref('')
function submit() {
const msg = text.value.trim()
if (!msg || store.isStreaming) return
store.sendMessage(msg)
text.value = ''
}
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
submit()
}
}
</script>
<template>
<div class="input-bar">
<textarea
v-model="text"
:disabled="store.isStreaming"
placeholder="メッセージを入力 (Enter で送信、Shift+Enter で改行)"
rows="1"
@keydown="onKeydown"
/>
<button :disabled="store.isStreaming || !text.trim()" @click="submit">送信</button>
</div>
</template>
<style scoped>
.input-bar {
display: flex;
gap: 8px;
padding: 12px 16px;
border-top: 1px solid #333;
background: #1a1a1a;
}
textarea {
flex: 1;
background: #2a2a2a;
color: #eee;
border: 1px solid #444;
border-radius: 8px;
padding: 10px 12px;
font-size: 14px;
resize: none;
font-family: inherit;
line-height: 1.5;
}
textarea:focus { outline: none; border-color: #2563eb; }
button {
background: #2563eb;
color: #fff;
border: none;
border-radius: 8px;
padding: 0 20px;
font-size: 14px;
cursor: pointer;
transition: background 0.15s;
}
button:hover:not(:disabled) { background: #1d4ed8; }
button:disabled { background: #333; color: #666; cursor: not-allowed; }
</style>
+43
View File
@@ -0,0 +1,43 @@
<script setup lang="ts">
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: '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' },
]
</script>
<template>
<div class="model-selector">
<label>モデル</label>
<select v-model="store.selectedModel">
<option v-for="p in presets" :key="p.value" :value="p.value">{{ p.label }}</option>
</select>
</div>
</template>
<style scoped>
.model-selector {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
}
label {
font-size: 12px;
color: #888;
}
select {
background: #2a2a2a;
color: #eee;
border: 1px solid #444;
border-radius: 6px;
padding: 4px 8px;
font-size: 13px;
}
</style>
+99
View File
@@ -0,0 +1,99 @@
<script setup lang="ts">
import { onMounted } from 'vue'
import { useChatStore } from '../stores/chat'
const store = useChatStore()
onMounted(() => store.loadSessions())
</script>
<template>
<aside class="session-list">
<div class="header">
<span>チャット履歴</span>
<button class="new-btn" @click="store.selectSession(null)">+ 新規</button>
</div>
<ul>
<li
v-for="s in store.sessions"
:key="s.id"
:class="{ active: s.id === store.currentSessionId }"
@click="store.selectSession(s.id)"
>
<span class="title">{{ s.title }}</span>
<button
class="del-btn"
@click.stop="store.removeSession(s.id)"
title="削除"
>✕</button>
</li>
</ul>
</aside>
</template>
<style scoped>
.session-list {
width: 220px;
min-width: 220px;
background: #1a1a1a;
border-right: 1px solid #333;
display: flex;
flex-direction: column;
overflow: hidden;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 12px 8px;
font-size: 12px;
color: #888;
border-bottom: 1px solid #2a2a2a;
}
.new-btn {
background: #2a2a2a;
color: #ccc;
border: 1px solid #444;
border-radius: 6px;
padding: 2px 8px;
font-size: 12px;
cursor: pointer;
}
ul {
list-style: none;
margin: 0;
padding: 4px 0;
overflow-y: auto;
flex: 1;
}
li {
display: flex;
align-items: center;
padding: 8px 12px;
cursor: pointer;
border-radius: 6px;
margin: 2px 6px;
transition: background 0.15s;
}
li:hover { background: #2a2a2a; }
li.active { background: #333; }
.title {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 13px;
color: #ddd;
}
.del-btn {
background: none;
border: none;
color: #666;
cursor: pointer;
font-size: 11px;
padding: 0 2px;
opacity: 0;
transition: opacity 0.15s;
}
li:hover .del-btn { opacity: 1; }
</style>
+5
View File
@@ -0,0 +1,5 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
createApp(App).use(createPinia()).mount('#app')
+84
View File
@@ -0,0 +1,84 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { fetchSessions, deleteSession, streamChat, type SessionInfo } from '../api/client'
export interface Message {
role: 'user' | 'assistant'
content: string
pending?: boolean
}
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 currentSession = computed(() =>
sessions.value.find((s) => s.id === currentSessionId.value) ?? null,
)
async function loadSessions() {
sessions.value = await fetchSessions()
}
function selectSession(sessionId: string | null) {
currentSessionId.value = sessionId
messages.value = []
}
async function removeSession(sessionId: string) {
await deleteSession(sessionId)
if (currentSessionId.value === sessionId) {
currentSessionId.value = null
messages.value = []
}
await loadSessions()
}
function sendMessage(text: string): void {
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)
isStreaming.value = true
streamChat(
text,
currentSessionId.value,
selectedModel.value,
(chunk) => {
if (chunk.type === 'session_id' && chunk.value) {
currentSessionId.value = chunk.value
loadSessions()
} else if (chunk.type === 'chunk' && chunk.value) {
assistantMsg.content += chunk.value
} else if (chunk.type === 'done') {
assistantMsg.pending = false
isStreaming.value = false
loadSessions()
}
},
(_err) => {
assistantMsg.content = 'エラーが発生しました。'
assistantMsg.pending = false
isStreaming.value = false
},
)
}
return {
sessions,
currentSessionId,
messages,
isStreaming,
selectedModel,
currentSession,
loadSessions,
selectSession,
removeSession,
sendMessage,
}
})