This commit is contained in:
Nikidze
2025-10-31 22:08:55 +03:00
commit e3f8caf59f
78 changed files with 9249 additions and 0 deletions

81
web-ui/src/App.vue Normal file
View File

@@ -0,0 +1,81 @@
<template>
<div class="w-full max-w-md mx-auto h-screen overflow-hidden flex flex-col bg-white">
<!-- Верхняя панель -->
<div v-if="store.screen != 1 && store.screen != 6" class="flex justify-between items-center p-1 border-b border-gray-200">
<!-- Кнопка Назад -->
<button
@click="goBack"
class="rounded-full p-2 hover:bg-gray-100 transition disabled:opacity-40 disabled:cursor-not-allowed"
:disabled="store.screen === 1"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-gray-700" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
</button>
<!-- Кнопка Домой -->
<button
@click="goHome"
class="rounded-full p-2 hover:bg-gray-100 transition"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-gray-700" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0h6" />
</svg>
</button>
</div>
<!-- Контейнер экранов -->
<div class="flex-1 relative overflow-hidden">
<Transition
enter-from-class="transform translate-y-full"
enter-active-class="transition-transform duration-500 ease-out"
enter-to-class="transform translate-y-0"
leave-from-class="transform translate-y-0"
leave-active-class="transition-transform duration-500 ease-out"
leave-to-class="transform -translate-y-full"
>
<component
:is="currentScreen"
:key="store.screen"
class="absolute top-0 left-0 w-full h-full"
/>
</Transition>
</div>
</div>
</template>
<script setup>
import { computed } from "vue";
import { useMainStore } from "./store/main";
import ScreenWelcome from "./components/ScreenWelcome.vue";
import ScreenAddress from "./components/ScreenAddress.vue";
import ScreenInterests from "./components/ScreenInterests.vue";
import ScreenDuration from "./components/ScreenDuration.vue";
import ScreenChat from "./components/ScreenChat.vue";
import ScreenRoute from "./components/ScreenRoute.vue";
const store = useMainStore();
const currentScreen = computed(() => {
return [
ScreenWelcome,
ScreenAddress,
ScreenInterests,
ScreenDuration,
ScreenChat,
ScreenRoute,
][store.screen - 1];
});
// Навигация
function goBack() {
if (store.screen > 1) {
store.screen--;
}
}
function goHome() {
store.screen = 1;
}
</script>

View File

@@ -0,0 +1,5 @@
@import 'tailwindcss';
:root {
--btn: #06b6d4;
}

View File

@@ -0,0 +1,44 @@
<template>
<div class="flex flex-col h-full bg-white">
<div class="p-5 border-b border-gray-100">
<h2 class="text-xl font-semibold">Где ты находишься?</h2>
<p class="text-sm text-gray-500 mt-1">
Укажи адрес в свободной форме. Для точного поиска рекомендую ввести в формате: улица Такая-то, дом N.
Приложение работает по Нижнему Новгороду.
</p>
</div>
<div class="flex-1 p-5">
<input
v-model="store.address"
class="w-full border rounded-lg p-3 focus:outline-none focus:ring-2 focus:ring-cyan-500"
placeholder="улица Дальняя, дом 8"
/>
</div>
<div class="p-4 border-t border-gray-100">
<button
:disabled="!store.address.trim()"
:class="{
'opacity-50 cursor-not-allowed': !store.address.trim()
}"
class="w-full bg-cyan-500 hover:bg-cyan-600 text-white py-3 rounded-2xl font-semibold transition"
@click="goNext"
>
Продолжить
</button>
</div>
</div>
</template>
<script setup>
import { useMainStore } from "../store/main";
const store = useMainStore();
function goNext() {
if (store.address.trim()) {
store.screen = 3;
}
}
</script>

View File

@@ -0,0 +1,181 @@
<template>
<div class="flex flex-col h-full bg-white">
<div class="p-5 border-b border-gray-100">
<h2 class="text-xl font-semibold">
Уточняем детали маршрута
</h2>
</div>
<div class="flex-1 overflow-y-auto p-5" ref="messagesContainer">
<div
v-for="(msg, index) in messages"
:key="index"
:class="[
'mb-4 p-3 rounded-lg max-w-[80%]',
msg.role === 'user'
? 'ml-auto bg-cyan-500 text-white'
: 'mr-auto bg-gray-100 text-gray-800'
]"
>
<p class="text-sm whitespace-pre-wrap">{{ msg.content }}</p>
</div>
<div v-if="loading" class="mb-4 p-3 rounded-lg max-w-[80%] mr-auto bg-gray-100 text-gray-800">
<p class="text-sm">Думаю...</p>
</div>
<div ref="bottomRef"></div>
</div>
<div class="p-4 border-t border-gray-100">
<div class="flex gap-2">
<input
v-model="userInput"
@keyup.enter="sendMessage"
:disabled="loading || routeBuilt"
class="flex-1 border rounded-lg p-3 focus:outline-none focus:ring-2 focus:ring-cyan-500 disabled:bg-gray-100"
placeholder="Напишите сообщение..."
/>
<button
@click="sendMessage"
:disabled="!userInput.trim() || loading || routeBuilt"
:class="{
'opacity-50 cursor-not-allowed': !userInput.trim() || loading || routeBuilt
}"
class="bg-cyan-500 hover:bg-cyan-600 text-white px-6 rounded-lg font-semibold transition"
>
Отправить
</button>
</div>
</div>
</div>
</template>
<script setup>
import {ref, onMounted, nextTick} from 'vue';
import {useMainStore} from '../store/main';
import axios from 'axios';
const store = useMainStore();
const messages = ref([]);
const conversationHistory = ref([]);
const userInput = ref('');
const loading = ref(false);
const routeBuilt = ref(false);
const messagesContainer = ref(null);
const bottomRef = ref(null);
onMounted(async () => {
// Отправляем первое сообщение
const initialMessage = `Я нахожусь: ${store.address}. Мне интересно: ${store.interests}. У меня есть время: ${store.duration}`;
messages.value.push({
role: 'user',
content: initialMessage
});
await scrollToBottom();
await sendToServer(initialMessage);
});
async function sendMessage() {
if (!userInput.value.trim() || loading.value || routeBuilt.value) return;
const message = userInput.value.trim();
userInput.value = '';
messages.value.push({
role: 'user',
content: message
});
await scrollToBottom();
await sendToServer(message);
}
async function sendToServer(userMessage) {
loading.value = true;
try {
const response = await axios.post(import.meta.env.VITE_API_BASE_URL+'/chat', {
user_input: userMessage,
conversation_history: conversationHistory.value
}, {
timeout: 60000 // 60 секунд
});
const data = response.data;
// Обновляем историю разговора
conversationHistory.value = data.conversation_history || [];
if (data.success && !data.need_more_info) {
messages.value.push({
role: 'assistant',
content: data.result.description
});
store.route = {
coordinates: data.result.route,
places: data.result.places,
description: data.result.description,
};
routeBuilt.value = true;
await scrollToBottom();
setTimeout(() => {
store.screen = 6;
}, 2000);
} else if (data.need_more_info) {
const lastMessage = data.conversation_history[data.conversation_history.length - 1];
const assistantMessage = lastMessage?.content || 'Пожалуйста, уточните детали.';
messages.value.push({
role: 'assistant',
content: assistantMessage
});
await scrollToBottom();
} else if (data.error) {
messages.value.push({
role: 'assistant',
content: data.error
});
await scrollToBottom();
}
} catch (error) {
console.error('Chat error:', error);
// Проверка на таймаут
if (error.code === 'ECONNABORTED') {
messages.value.push({
role: 'assistant',
content: 'Время ожидания истекло. Попробуйте еще раз.'
});
} else {
messages.value.push({
role: 'assistant',
content: 'Произошла ошибка. Попробуйте еще раз.'
});
}
await scrollToBottom();
} finally {
loading.value = false;
}
}
async function scrollToBottom() {
await nextTick();
if (bottomRef.value) {
bottomRef.value.scrollIntoView({behavior: 'smooth'});
}
}
</script>

View File

@@ -0,0 +1,45 @@
<template>
<div class="flex flex-col h-full bg-white">
<div class="p-5 border-b border-gray-100">
<h2 class="text-xl font-semibold">
Сколько времени у тебя на прогулку?
</h2>
<p class="text-sm text-gray-500 mt-1">
Укажи время в любом формате: «1 час», «90 минут», «полтора часа», «до 6 вечера» и т.д.
</p>
</div>
<div class="flex-1 p-5">
<input
v-model="store.duration"
class="w-full border rounded-lg p-3 focus:outline-none focus:ring-2 focus:ring-cyan-500"
placeholder="2 часа"
/>
</div>
<div class="p-4 border-t border-gray-100">
<button
:disabled="!store.duration || !store.duration.trim()"
:class="{
'opacity-50 cursor-not-allowed': !store.duration || !store.duration.trim()
}"
class="w-full bg-cyan-500 hover:bg-cyan-600 text-white py-3 rounded-2xl font-semibold transition"
@click="goNext"
>
Продолжить
</button>
</div>
</div>
</template>
<script setup>
import {useMainStore} from "../store/main";
const store = useMainStore();
function goNext() {
if (store.duration && store.duration.trim()) {
store.screen = 5;
}
}
</script>

View File

@@ -0,0 +1,58 @@
<template>
<div class="flex flex-col h-full bg-white">
<div class="p-5">
<h2 class="text-xl font-semibold">Как ты представляешь свой маршрут?</h2>
<p class="text-sm text-gray-500 mt-1">
Опиши в свободной форме.
</p>
</div>
<div class="flex-1 p-5 overflow-y-auto">
<textarea
v-model="store.interests"
@input="resizeTextarea"
ref="textareaRef"
class="w-full border rounded-lg p-3 focus:outline-none focus:ring-2 focus:ring-cyan-500"
rows="3"
placeholder="Я хочу культурно провести время, а потом выпить кофе"
></textarea>
</div>
<div class="p-4">
<button
class="w-full bg-cyan-500 text-white py-3 rounded-2xl font-semibold cursor-pointer"
:class="{
'hover:bg-cyan-600': isButtonEnabled,
'opacity-50 cursor-not-allowed': !isButtonEnabled,
}"
@click="store.screen = 4"
:disabled="!isButtonEnabled"
>
Далее
</button>
</div>
</div>
</template>
<script setup>
import { computed, ref, nextTick } from "vue";
import { useMainStore } from "../store/main";
const store = useMainStore();
const textareaRef = ref(null);
const isButtonEnabled = computed(() => {
return store.interests.trim().length > 0;
});
function resizeTextarea() {
if (textareaRef.value) {
textareaRef.value.style.height = "auto";
textareaRef.value.style.height = textareaRef.value.scrollHeight + "px";
}
}
nextTick(() => {
resizeTextarea();
});
</script>

View File

@@ -0,0 +1,286 @@
<template>
<div class="flex flex-col h-full bg-white">
<!-- Шапка -->
<div class="p-4 flex justify-between items-center border-b">
<h2 class="text-lg font-semibold">Ваш маршрут готов</h2>
<button
class="text-sm text-cyan-600 cursor-pointer hover:underline"
@click="restart"
>
Начать заново
</button>
</div>
<!-- Основной контент с прокруткой -->
<div class="flex-1 overflow-y-auto p-4 space-y-6">
<!-- Контейнер карты -->
<div class="relative w-full h-64 rounded-lg overflow-hidden border">
<div id="map" class="absolute inset-0"></div>
</div>
<!-- Описание маршрута -->
<div v-if="store.route?.description" class="prose prose-sm max-w-none">
<div v-html="renderedDescription" class="text-gray-700 leading-relaxed"></div>
</div>
<!-- Список точек маршрута -->
<div>
<h3 class="font-semibold text-lg mb-3">Точки маршрута</h3>
<div class="space-y-5">
<div v-for="(place, index) in store.route?.places" :key="index" class="border-b pb-4 last:border-b-0">
<div class="flex items-start gap-3">
<div class="flex-shrink-0 bg-cyan-500 text-white rounded-full h-8 w-8 flex items-center justify-center font-bold">
{{ index + 1 }}
</div>
<div class="flex-grow">
<h4 class="font-semibold text-md">{{ place.title || place.name }}</h4>
<div class="text-xs text-gray-500 mt-1 flex items-center gap-4 flex-wrap">
<span v-if="place.time_to_travel" class="flex items-center gap-1">
<WalkIcon class="w-4 h-4" /> До точки: ~{{ Math.round(place.time_to_travel) }} мин.
</span>
<span v-if="place.time_to_visit" class="flex items-center gap-1">
<HourglassIcon class="w-4 h-4" /> На точке: {{ place.time_to_visit }} мин.
</span>
</div>
<p v-if="place.explanation" class="text-sm text-gray-700 mt-2">
{{ stripQuotes(place.explanation) }}
</p>
<p v-if="place.description" class="text-sm text-gray-700 mt-2">
{{ place.description }}
</p>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="p-4 border-t">
<button
class="w-full border py-3 rounded-2xl cursor-pointer hover:bg-gray-50 transition"
@click="restart"
>
Построить ещё маршрут
</button>
</div>
</div>
</template>
<script setup>
import { onMounted, nextTick, computed } from "vue";
import { useMainStore } from "../store/main";
import { marked } from "marked";
import "leaflet/dist/leaflet.css";
import { Home as HomeIcon, Footprints as WalkIcon, Hourglass as HourglassIcon } from "lucide-vue-next";
const store = useMainStore();
let map;
const osrmBaseUrl = import.meta.env.VITE_OSRM_BASE_URL;
// Рендерим markdown в HTML
const renderedDescription = computed(() => {
if (!store.route?.description) return '';
return marked(store.route.description);
});
function stripQuotes(str) {
if (!str) return '';
return str.replace(/^"|"$/g, '');
}
async function getRouteFromOSRM() {
if (!osrmBaseUrl || !store.route?.places?.length) {
console.warn("OSRM URL не сконфигурирован или нет точек для маршрута.");
return null;
}
const allCoords = [
store.route.coordinates[0],
...store.route.places.map(p => {
if (typeof p.coordinate === 'string') {
return p.coordinate.split(',').map(Number);
} else if (Array.isArray(p.coordinate)) {
return p.coordinate;
}
return null;
}).filter(c => c !== null)
];
const coordsString = allCoords.map(coord => `${coord[1]},${coord[0]}`).join(';');
const url = `${osrmBaseUrl}/route/v1/foot/${coordsString}?overview=full&geometries=geojson`;
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`OSRM request failed with status: ${response.status}`);
}
const data = await response.json();
if (data.code === 'Ok' && data.routes && data.routes.length > 0) {
console.log("Маршрут от OSRM успешно получен.");
return data.routes[0].geometry;
} else {
throw new Error(`OSRM response error: ${data.message || 'No route found'}`);
}
} catch (error) {
console.error("Ошибка при получении маршрута от OSRM:", error);
return null;
}
}
async function initMap() {
if (!store.route?.coordinates) return;
const L = await import("leaflet");
await nextTick();
if (map) {
map.remove();
map = null;
}
const container = document.getElementById("map");
if (!container) return;
map = L.map(container, {
zoomControl: false,
attributionControl: false
}).setView(store.route.coordinates[0], 14);
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png").addTo(map);
let routeLayer;
const routeGeometry = await getRouteFromOSRM();
if (routeGeometry) {
routeLayer = L.geoJSON(routeGeometry, {
style: {
color: "#06b6d4",
weight: 5,
opacity: 0.8,
}
}).addTo(map);
} else {
console.warn("Не удалось построить маршрут через OSRM, рисуем прямые линии.");
routeLayer = L.polyline(store.route.coordinates, {
color: "#06b6d4",
weight: 5,
opacity: 0.8,
}).addTo(map);
}
store.route.places.forEach((place, i) => {
let lat, lon;
if (typeof place.coordinate === 'string') {
[lat, lon] = place.coordinate.split(',').map(Number);
} else if (Array.isArray(place.coordinate)) {
[lat, lon] = place.coordinate;
} else {
console.warn(`Неверный формат координат для точки ${i}`);
return;
}
const customIcon = L.divIcon({
className: 'custom-div-icon',
html: `<div style='background-color:#06b6d4; color:white; border-radius:50%; width:24px; height:24px; display:flex; align-items:center; justify-content:center; font-size:12px; font-weight:bold; border: 2px solid white;'>${i + 1}</div>`,
iconSize: [24, 24],
iconAnchor: [12, 12]
});
const popupContent = `<b>${place.title || place.name}</b>${place.address ? '<br>' + place.address : ''}`;
L.marker([lat, lon], {icon: customIcon})
.addTo(map)
.bindPopup(popupContent);
});
const startIcon = L.divIcon({
className: 'start-div-icon',
html: `
<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' width='24' height='24' style='color:#06b6d4;'>
<path
d='M3 9.75V21h6v-5.25a3 3 0 0 1 6 0V21h6V9.75L12 3 3 9.75z'
fill='#06b6d4'
stroke='white'
stroke-width='2'
stroke-linejoin='round'
/>
</svg>
`,
iconSize: [24, 24],
iconAnchor: [12, 24]
});
L.marker(store.route.coordinates[0], {icon: startIcon})
.addTo(map)
.bindPopup("<b>Вы здесь</b><br>" + store.address);
map.fitBounds(routeLayer.getBounds().pad(0.1));
setTimeout(() => map.invalidateSize(), 350);
}
onMounted(() => initMap());
function restart() {
store.reset();
}
</script>
<style scoped>
/* Стили для markdown контента */
.prose :deep(h1) {
font-size: 1.5rem;
font-weight: 700;
margin-top: 1rem;
margin-bottom: 0.5rem;
}
.prose :deep(h2) {
font-size: 1.25rem;
font-weight: 600;
margin-top: 0.75rem;
margin-bottom: 0.5rem;
}
.prose :deep(h3) {
font-size: 1.125rem;
font-weight: 600;
margin-top: 0.5rem;
margin-bottom: 0.5rem;
}
.prose :deep(p) {
margin-bottom: 0.75rem;
}
.prose :deep(ul), .prose :deep(ol) {
margin-left: 1.5rem;
margin-bottom: 0.75rem;
}
.prose :deep(li) {
margin-bottom: 0.25rem;
}
.prose :deep(strong) {
font-weight: 600;
}
.prose :deep(em) {
font-style: italic;
}
.prose :deep(code) {
background-color: #f3f4f6;
padding: 0.125rem 0.25rem;
border-radius: 0.25rem;
font-size: 0.875rem;
}
</style>

View File

@@ -0,0 +1,35 @@
<template>
<div class="h-full flex flex-col justify-between bg-[#0b61a8] text-white">
<div
class="flex-1 flex flex-col items-center justify-center text-center p-6"
>
<h1 class="text-2xl font-bold mb-2">
Привет. Я ИИ-помощник туриста.
</h1>
<p class="mt-6 mb-6">Давай составим пешеходный маршрут по Нижнему Новгороду?</p>
<div class="text-6xl mt-4 flex gap-4 justify-center">
<PlaneIcon class="w-12 h-12" />
<MapIcon class="w-12 h-12" />
</div>
<p class="mt-10">
Я помогу подобрать прогулку расскажи, где ты, что любишь и
сколько времени хочешь потратить.
</p>
</div>
<div class="p-4">
<button
class="w-full bg-cyan-500 hover:bg-cyan-600 text-white font-semibold py-3 rounded-2xl cursor-pointer"
@click="store.screen = 2"
>
Давай
</button>
</div>
</div>
</template>
<script setup>
import { useMainStore } from "../store/main";
import { Plane as PlaneIcon, Map as MapIcon } from "lucide-vue-next";
const store = useMainStore();
</script>

8
web-ui/src/main.js Normal file
View File

@@ -0,0 +1,8 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import './assets/tailwind.css'
const app = createApp(App)
app.use(createPinia())
app.mount('#app')

26
web-ui/src/store/main.js Normal file
View File

@@ -0,0 +1,26 @@
import { defineStore } from 'pinia'
import { ref, reactive } from 'vue'
export const useMainStore = defineStore('main', () => {
const screen = ref(1)
const address = ref('')
const interests = ref('')
const duration = ref('')
const chat = reactive({ messages: [], asked: 0 })
function reset() {
screen.value = 1
address.value = ''
duration.value = ''
interests.value = ''
}
return {
screen,
address,
duration,
interests,
chat,
reset,
}
})