kin/web/frontend/src/views/ChatView.vue

226 lines
6.6 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script setup lang="ts">
import { ref, watch, nextTick, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { api, ApiError, type ChatMessage } from '../api'
import Badge from '../components/Badge.vue'
const props = defineProps<{ projectId: string }>()
const router = useRouter()
const messages = ref<ChatMessage[]>([])
const input = ref('')
const sending = ref(false)
const loading = ref(true)
const error = ref('')
const consecutiveErrors = ref(0)
const projectName = ref('')
const messagesEl = ref<HTMLElement | null>(null)
let pollTimer: ReturnType<typeof setInterval> | null = null
function stopPoll() {
if (pollTimer) {
clearInterval(pollTimer)
pollTimer = null
}
}
function hasRunningTasks(msgs: ChatMessage[]) {
return msgs.some(
m => m.task_stub?.status === 'in_progress' || m.task_stub?.status === 'pending'
)
}
function checkAndPoll() {
stopPoll()
if (!hasRunningTasks(messages.value)) return
pollTimer = setInterval(async () => {
try {
const updated = await api.chatHistory(props.projectId)
messages.value = updated
consecutiveErrors.value = 0
error.value = ''
if (!hasRunningTasks(updated)) stopPoll()
} catch (e: any) {
consecutiveErrors.value++
console.warn(`[polling] ошибка #${consecutiveErrors.value}:`, e)
if (consecutiveErrors.value >= 3) {
error.value = 'Сервер недоступен. Проверьте подключение.'
stopPoll()
}
}
}, 3000)
}
async function load() {
stopPoll()
loading.value = true
error.value = ''
consecutiveErrors.value = 0
try {
const [msgs, project] = await Promise.all([
api.chatHistory(props.projectId),
api.project(props.projectId),
])
messages.value = msgs
projectName.value = project.name
} catch (e: any) {
if (e instanceof ApiError && e.message.includes('not found')) {
router.push('/')
return
}
error.value = e.message
} finally {
loading.value = false
}
await nextTick()
scrollToBottom()
checkAndPoll()
}
watch(() => props.projectId, () => {
messages.value = []
input.value = ''
error.value = ''
projectName.value = ''
loading.value = true
load()
}, { immediate: true })
onUnmounted(stopPoll)
async function send() {
const text = input.value.trim()
if (!text || sending.value) return
sending.value = true
error.value = ''
try {
const result = await api.sendChatMessage(props.projectId, text)
input.value = ''
messages.value.push(result.user_message, result.assistant_message)
await nextTick()
scrollToBottom()
checkAndPoll()
} catch (e: any) {
error.value = e.message
} finally {
sending.value = false
}
}
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
send()
}
}
function scrollToBottom() {
if (messagesEl.value) {
messagesEl.value.scrollTop = messagesEl.value.scrollHeight
}
}
function taskStatusColor(status: string): string {
const map: Record<string, string> = {
done: 'green',
in_progress: 'blue',
review: 'yellow',
blocked: 'red',
pending: 'gray',
cancelled: 'gray',
}
return map[status] ?? 'gray'
}
function formatTime(dt: string) {
return new Date(dt).toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })
}
</script>
<template>
<div class="flex flex-col h-[calc(100vh-112px)]">
<!-- Header -->
<div class="flex items-center gap-3 pb-4 border-b border-gray-800">
<router-link
:to="`/project/${projectId}`"
class="text-gray-400 hover:text-gray-200 text-sm no-underline"
> Проект</router-link>
<span class="text-gray-600">|</span>
<h1 class="text-base font-semibold text-gray-100">
{{ projectName || projectId }}
</h1>
<span class="text-xs text-gray-500 ml-1"> чат</span>
</div>
<!-- Error -->
<div v-if="error" class="mt-3 text-sm text-red-400 bg-red-900/20 border border-red-800 rounded px-3 py-2">
{{ error }}
</div>
<!-- Loading -->
<div v-if="loading" class="flex-1 flex items-center justify-center">
<span class="text-gray-500 text-sm">Загрузка...</span>
</div>
<!-- Messages -->
<div
v-else
ref="messagesEl"
class="flex-1 overflow-y-auto py-4 flex flex-col gap-3 min-h-0"
>
<div v-if="messages.length === 0" class="text-center text-gray-500 text-sm mt-8">
Опишите задачу или спросите о статусе проекта
</div>
<div
v-for="msg in messages"
:key="msg.id"
class="flex"
:class="msg.role === 'user' ? 'justify-end' : 'justify-start'"
>
<div
class="max-w-[70%] rounded-2xl px-4 py-2.5"
:class="msg.role === 'user'
? 'bg-indigo-900/60 border border-indigo-700/50 text-gray-100 rounded-br-sm'
: 'bg-gray-800/70 border border-gray-700/50 text-gray-200 rounded-bl-sm'"
>
<p class="text-sm whitespace-pre-wrap break-words">{{ msg.content }}</p>
<!-- Task stub for task_created messages -->
<div
v-if="msg.message_type === 'task_created' && msg.task_stub"
class="mt-2 pt-2 border-t border-gray-700/40 flex items-center gap-2"
>
<router-link
:to="`/task/${msg.task_stub.id}`"
class="text-xs text-indigo-400 hover:text-indigo-300 no-underline font-mono"
>{{ msg.task_stub.id }}</router-link>
<Badge :color="taskStatusColor(msg.task_stub.status)" :text="msg.task_stub.status" />
</div>
<p class="text-xs mt-1.5 text-gray-500">{{ formatTime(msg.created_at) }}</p>
</div>
</div>
</div>
<!-- Input -->
<div class="pt-3 border-t border-gray-800 flex gap-2 items-end">
<textarea
v-model="input"
:disabled="sending || loading"
placeholder="Опишите задачу или вопрос... (Enter — отправить, Shift+Enter — перенос)"
rows="2"
class="flex-1 bg-gray-800/60 border border-gray-700 rounded-xl px-4 py-2.5 text-sm text-gray-100 placeholder-gray-500 resize-none focus:outline-none focus:border-indigo-600 disabled:opacity-50"
@keydown="onKeydown"
/>
<button
:disabled="sending || loading || !input.trim()"
class="px-4 py-2.5 bg-indigo-600 hover:bg-indigo-500 disabled:opacity-40 disabled:cursor-not-allowed text-white text-sm rounded-xl font-medium transition-colors"
@click="send"
>
{{ sending ? '...' : 'Отправить' }}
</button>
</div>
</div>
</template>