final
This commit is contained in:
2
web-ui/.env
Normal file
2
web-ui/.env
Normal file
@@ -0,0 +1,2 @@
|
||||
VITE_API_BASE_URL=https://xczl-a4zl-n2ko.gw-1a.dockhost.net
|
||||
VITE_OSRM_BASE_URL=https://8msn-80q0-el3y.gw-1a.dockhost.net
|
||||
35
web-ui/.gitignore
vendored
Normal file
35
web-ui/.gitignore
vendored
Normal file
@@ -0,0 +1,35 @@
|
||||
# логи
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
# зависимости
|
||||
node_modules/
|
||||
|
||||
# сборка
|
||||
dist/
|
||||
dist-ssr/
|
||||
|
||||
# локальные настройки
|
||||
*.local
|
||||
|
||||
# папки редакторов
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea/
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
# временные/отладочные файлы
|
||||
*.tgz
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
3
web-ui/.vscode/extensions.json
vendored
Normal file
3
web-ui/.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["Vue.volar"]
|
||||
}
|
||||
37
web-ui/Dockerfile
Normal file
37
web-ui/Dockerfile
Normal file
@@ -0,0 +1,37 @@
|
||||
# Stage 1: Build the Vue application
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
# Set the working directory inside the container
|
||||
WORKDIR /app
|
||||
|
||||
ARG VITE_API_BASE_URL
|
||||
ARG VITE_OSRM_BASE_URL
|
||||
|
||||
# Make them available as environment variables during build
|
||||
ENV VITE_API_BASE_URL=$VITE_API_BASE_URL
|
||||
ENV VITE_OSRM_BASE_URL=$VITE_OSRM_BASE_URL
|
||||
|
||||
# Copy package.json and package-lock.json (if it exists)
|
||||
# to leverage Docker cache for dependencies
|
||||
COPY package.json package-lock.json* ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm install
|
||||
|
||||
# Copy the rest of the application code
|
||||
COPY . .
|
||||
|
||||
# Build the Vue application for production
|
||||
RUN npm run build
|
||||
|
||||
# Stage 2: Serve the application with Nginx
|
||||
FROM nginx:alpine
|
||||
|
||||
# Copy the built application from the builder stage to Nginx's default public directory
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
|
||||
# Expose port 80, which is the default for Nginx
|
||||
EXPOSE 80
|
||||
|
||||
# Command to run Nginx in the foreground
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
5
web-ui/Makefile
Normal file
5
web-ui/Makefile
Normal file
@@ -0,0 +1,5 @@
|
||||
build_image:
|
||||
docker build -t gitea.private.nikidze.ru/nikidze/gorkycode-web-ui .
|
||||
|
||||
push_image:
|
||||
docker push gitea.private.nikidze.ru/nikidze/gorkycode-web-ui
|
||||
5
web-ui/README.md
Normal file
5
web-ui/README.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# Vue 3 + Vite
|
||||
|
||||
This template should help get you started developing with Vue 3 in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
|
||||
|
||||
Learn more about IDE Support for Vue in the [Vue Docs Scaling up Guide](https://vuejs.org/guide/scaling-up/tooling.html#ide-support).
|
||||
13
web-ui/index.html
Normal file
13
web-ui/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Gorkycode: Туризм</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
2473
web-ui/package-lock.json
generated
Normal file
2473
web-ui/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
27
web-ui/package.json
Normal file
27
web-ui/package.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "tourist-ai",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/vite": "^4.1.16",
|
||||
"axios": "^1.13.1",
|
||||
"leaflet": "^1.9.4",
|
||||
"lucide-vue-next": "^0.552.0",
|
||||
"marked": "^16.4.1",
|
||||
"pinia": "^3.0.3",
|
||||
"vue": "^3.5.22"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^6.0.1",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^4.1.16",
|
||||
"vite": "^7.1.7"
|
||||
}
|
||||
}
|
||||
1
web-ui/public/vite.svg
Normal file
1
web-ui/public/vite.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="#000000" height="200px" width="200px" version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 464.843 464.843" xml:space="preserve"><g id="SVGRepo_bgCarrier" stroke-width="0"></g><g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"></g><g id="SVGRepo_iconCarrier"> <g> <path d="M231.671,262.343c-15.991,0-29,13.01-29,29c0,15.99,13.009,29,29,29c15.991,0,29-13.01,29-29 C260.671,275.353,247.662,262.343,231.671,262.343z M231.671,312.343c-11.58,0-21-9.421-21-21s9.42-21,21-21 c11.58,0,21,9.421,21,21S243.251,312.343,231.671,312.343z"></path> <path d="M185.671,380.343c-2.209,0-4,1.791-4,4v12c0,2.209,1.791,4,4,4s4-1.791,4-4v-12 C189.671,382.134,187.88,380.343,185.671,380.343z"></path> <path d="M208.921,380.343c-2.209,0-4,1.791-4,4v12c0,2.209,1.791,4,4,4s4-1.791,4-4v-12 C212.921,382.134,211.13,380.343,208.921,380.343z"></path> <path d="M232.171,380.343c-2.209,0-4,1.791-4,4v12c0,2.209,1.791,4,4,4s4-1.791,4-4v-12 C236.171,382.134,234.38,380.343,232.171,380.343z"></path> <path d="M255.421,380.343c-2.209,0-4,1.791-4,4v12c0,2.209,1.791,4,4,4s4-1.791,4-4v-12 C259.421,382.134,257.63,380.343,255.421,380.343z"></path> <path d="M278.671,380.343c-2.209,0-4,1.791-4,4v12c0,2.209,1.791,4,4,4c2.209,0,4-1.791,4-4v-12 C282.671,382.134,280.88,380.343,278.671,380.343z"></path> <path d="M218.645,16.132l5.961,2.329l-3.454,5.388c-1.143,1.784-0.698,4.149,1.017,5.396c0.708,0.515,1.531,0.764,2.349,0.764 c1.16,0,2.308-0.503,3.096-1.464l4.057-4.95l4.057,4.95c0.799,0.974,1.978,1.453,3.143,1.464c2.196-0.016,3.971-1.801,3.971-4 c0-0.928-0.316-1.781-0.846-2.46l-3.261-5.087l5.961-2.329c1.974-0.77,3.003-2.947,2.349-4.962 c-0.655-2.015-2.765-3.162-4.817-2.634l-6.191,1.62l-0.373-6.39C235.541,1.652,233.79,0,231.671,0c-2.119,0-3.87,1.652-3.993,3.767 l-0.373,6.39l-6.191-1.62c-2.05-0.53-4.163,0.618-4.817,2.634C215.642,13.185,216.672,15.361,218.645,16.132z"></path> <path d="M397.987,360.278c-1.342-0.683-2.951-0.557-4.168,0.329l-10.688,7.773l-10.698-7.773c-1.217-0.884-2.826-1.012-4.167-0.328 c-1.34,0.684-2.184,2.061-2.184,3.565v26.5h-16.15v-26.5c0-1.505-0.844-2.882-2.185-3.565c-1.342-0.683-2.951-0.557-4.168,0.329 l-10.688,7.773l-10.688-7.773c-1.217-0.885-2.825-1.011-4.168-0.329c-1.34,0.683-2.184,2.06-2.184,3.565v26.5h-16.18v-59.5 c0-2.209-1.791-4-4-4h-2v-19.067c0-2.209-1.791-4-4-4c-2.209,0-4,1.791-4,4v19.067h-5.5v-75c0-2.209-1.791-4-4-4h-7v-91.5 c0-2.209-1.791-4-4-4h-7.959L235.602,37.851c-0.357-1.89-2.008-3.258-3.931-3.258c-1.923,0-3.574,1.368-3.931,3.258L206.13,152.343 h-7.959c-2.209,0-4,1.791-4,4v91.5h-7c-2.209,0-4,1.791-4,4v75h-6v-19.067c0-2.209-1.791-4-4-4s-4,1.791-4,4v19.067h-2 c-2.209,0-4,1.791-4,4v59.5h-14.18v-26.5c0-1.505-0.844-2.882-2.185-3.565c-1.341-0.683-2.951-0.557-4.168,0.329l-10.688,7.773 l-10.688-7.773c-1.216-0.885-2.826-1.011-4.168-0.329c-1.34,0.683-2.185,2.06-2.185,3.565v26.5h-16.15v-26.5 c0-1.504-0.844-2.881-2.184-3.565c-1.34-0.682-2.95-0.555-4.167,0.328l-10.697,7.773l-10.688-7.773 c-1.216-0.885-2.826-1.011-4.168-0.329c-1.34,0.683-2.185,2.06-2.185,3.565v97c0,2.209,1.791,4,4,4h327.5c2.209,0,4-1.791,4-4v-97 C400.171,362.338,399.327,360.961,397.987,360.278z M171.171,334.843h19.75v28.2c0,2.209,1.791,4,4,4s4-1.791,4-4v-28.2h28.5v28.2 c0,2.209,1.791,4,4,4s4-1.791,4-4v-28.2h28.5v28.2c0,2.209,1.791,4,4,4c2.209,0,4-1.791,4-4v-28.2h19.75v85h-120.5V334.843z M261.171,215.343h-11v-55h11V215.343z M242.171,160.343v55h-21v-55H242.171z M231.671,60.159l17.4,92.184h-34.8L231.671,60.159z M213.171,160.343v55h-11v-55H213.171z M202.171,223.343h59v24.5h-59V223.343z M191.171,255.843h81v71h-81V255.843z M72.671,371.697l6.688,4.863c1.402,1.02,3.302,1.018,4.704,0.001l6.699-4.868v22.649c0,2.209,1.791,4,4,4h24.15 c2.209,0,4-1.791,4-4v-22.646l6.688,4.863c1.402,1.02,3.303,1.02,4.705,0l6.688-4.863v22.646c0,2.209,1.791,4,4,4h18.18v58.5h-90.5 V371.697z M171.171,456.843v-29h120.5v29H171.171z M392.171,456.843h-92.5v-58.5h20.18c2.209,0,4-1.791,4-4v-22.646l6.688,4.863 c1.402,1.02,3.303,1.02,4.705,0l6.688-4.863v22.646c0,2.209,1.791,4,4,4h24.15c2.209,0,4-1.791,4-4v-22.649l6.699,4.868 c1.402,1.018,3.302,1.019,4.704-0.001l6.688-4.863V456.843z"></path> <path d="M241.671,287.343h-6v-6.125c0-2.209-1.791-4-4-4s-4,1.791-4,4v10.125c0,2.209,1.791,4,4,4h10c2.209,0,4-1.791,4-4 C245.671,289.134,243.88,287.343,241.671,287.343z"></path> </g> </g></svg>
|
||||
|
After Width: | Height: | Size: 4.2 KiB |
81
web-ui/src/App.vue
Normal file
81
web-ui/src/App.vue
Normal 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>
|
||||
5
web-ui/src/assets/tailwind.css
Normal file
5
web-ui/src/assets/tailwind.css
Normal file
@@ -0,0 +1,5 @@
|
||||
@import 'tailwindcss';
|
||||
|
||||
:root {
|
||||
--btn: #06b6d4;
|
||||
}
|
||||
44
web-ui/src/components/ScreenAddress.vue
Normal file
44
web-ui/src/components/ScreenAddress.vue
Normal 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>
|
||||
181
web-ui/src/components/ScreenChat.vue
Normal file
181
web-ui/src/components/ScreenChat.vue
Normal 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>
|
||||
45
web-ui/src/components/ScreenDuration.vue
Normal file
45
web-ui/src/components/ScreenDuration.vue
Normal 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>
|
||||
58
web-ui/src/components/ScreenInterests.vue
Normal file
58
web-ui/src/components/ScreenInterests.vue
Normal 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>
|
||||
286
web-ui/src/components/ScreenRoute.vue
Normal file
286
web-ui/src/components/ScreenRoute.vue
Normal 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>
|
||||
35
web-ui/src/components/ScreenWelcome.vue
Normal file
35
web-ui/src/components/ScreenWelcome.vue
Normal 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
8
web-ui/src/main.js
Normal 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
26
web-ui/src/store/main.js
Normal 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,
|
||||
}
|
||||
})
|
||||
11
web-ui/tailwind.config.js
Normal file
11
web-ui/tailwind.config.js
Normal file
@@ -0,0 +1,11 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{vue,js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
10
web-ui/vite.config.js
Normal file
10
web-ui/vite.config.js
Normal file
@@ -0,0 +1,10 @@
|
||||
import {defineConfig} from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
tailwindcss(),
|
||||
vue(),
|
||||
],
|
||||
})
|
||||
Reference in New Issue
Block a user