initial_commit
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ja">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>PlayChat</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "playchat-frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"type-check": "vue-tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"pinia": "^2.1.7",
|
||||
"vue": "^3.4.0",
|
||||
"vue-router": "^4.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.0.4",
|
||||
"typescript": "^5.4.0",
|
||||
"vite": "^5.2.0",
|
||||
"vue-tsc": "^2.0.6"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,5 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import App from './App.vue'
|
||||
|
||||
createApp(App).use(createPinia()).mount('#app')
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "preserve",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"]
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/chat': 'http://localhost:8000',
|
||||
'/memory': 'http://localhost:8000',
|
||||
'/health': 'http://localhost:8000',
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user