228 lines
6.6 KiB
Vue
228 lines
6.6 KiB
Vue
<script setup lang="ts">
|
|
import { ref, watch, nextTick, onUnmounted } from 'vue'
|
|
import { useRouter } from 'vue-router'
|
|
import { useI18n } from 'vue-i18n'
|
|
import { api, ApiError, type ChatMessage } from '../api'
|
|
import Badge from '../components/Badge.vue'
|
|
|
|
const props = defineProps<{ projectId: string }>()
|
|
const router = useRouter()
|
|
const { t, locale } = useI18n()
|
|
|
|
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] error #' + consecutiveErrors.value + ':', e)
|
|
if (consecutiveErrors.value >= 3) {
|
|
error.value = t('chat.server_unavailable')
|
|
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(locale.value === 'ru' ? 'ru-RU' : 'en-US', { 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"
|
|
>{{ t('chat.back_to_project') }}</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">{{ t('chat.chat_label') }}</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">{{ t('chat.loading') }}</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">
|
|
{{ t('chat.empty_hint') }}
|
|
</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="t('chat.input_placeholder')"
|
|
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 ? t('chat.sending') : t('chat.send') }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</template>
|