feat: новая функция

This commit is contained in:
2025-11-06 16:24:50 +03:00
parent b3620b264b
commit 714a3f55c7
34 changed files with 5436 additions and 2433 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -14,68 +14,231 @@
<div class='modal-bg'>
<div class='modal'>
<h3>{{ rule ? 'Редактировать' : 'Создать' }} набор правил</h3>
<label>Название</label>
<input v-model="name" />
<label>Название *</label>
<input v-model="name" placeholder="Например: VIP Правило" />
<label>Описание</label>
<textarea v-model="description" rows="3" placeholder="Опишите правило в свободной форме" />
<label>Правила (JSON)</label>
<textarea v-model="rulesJson" rows="6"></textarea>
<textarea v-model="description" rows="2" placeholder="Опишите правило в свободной форме" />
<div class="rules-section">
<h4>Системный промпт</h4>
<textarea
v-model="ruleFields.system_prompt"
rows="4"
placeholder="Дополнительный системный промпт для этого правила. Например: 'Ты работаешь с VIP клиентами. Будь вежливым и профессиональным.'"
/>
</div>
<div class="rules-section">
<h4>Параметры LLM</h4>
<div class="form-row">
<div class="form-group">
<label>Temperature (0.0-2.0)</label>
<input
type="number"
v-model.number="ruleFields.temperature"
min="0"
max="2"
step="0.1"
placeholder="0.7"
/>
</div>
<div class="form-group">
<label>Max Tokens</label>
<input
type="number"
v-model.number="ruleFields.max_tokens"
min="1"
max="4000"
placeholder="500"
/>
</div>
</div>
</div>
<div class="rules-section">
<h4>Правила поведения</h4>
<div class="form-group">
<label>Разрешенные темы (через запятую)</label>
<input
v-model="allowedTopicsText"
placeholder="продукт, поддержка, VIP услуги"
/>
</div>
<div class="form-group">
<label>Запрещенные слова (через запятую)</label>
<input
v-model="forbiddenWordsText"
placeholder="ругательство, спам"
/>
</div>
<div class="form-checkbox">
<label>
<input type="checkbox" v-model="ruleFields.checkUserTags" />
Учитывать теги пользователя при фильтрации RAG
</label>
</div>
<div class="form-checkbox">
<label>
<input type="checkbox" v-model="ruleFields.searchRagFirst" />
Сначала искать в RAG базе знаний
</label>
</div>
<div class="form-checkbox">
<label>
<input type="checkbox" v-model="ruleFields.generateIfNoRag" />
Генерировать ответ, если ничего не найдено в RAG
</label>
</div>
</div>
<div class="rules-section" v-if="showJsonPreview">
<h4>Предпросмотр JSON</h4>
<pre class="json-preview">{{ generatedJson }}</pre>
</div>
<div v-if="error" class="error">{{ error }}</div>
<div class="actions">
<button @click="save">Сохранить</button>
<button @click="save" :disabled="!name.trim()">Сохранить</button>
<button @click="close">Отмена</button>
</div>
</div>
</div>
</template>
<script setup>
import { ref, watch } from 'vue';
import { ref, watch, computed } from 'vue';
import axios from 'axios';
const emit = defineEmits(['close']);
const props = defineProps({ rule: Object });
const name = ref(props.rule ? props.rule.name : '');
const description = ref(props.rule ? props.rule.description : '');
const rulesJson = ref(props.rule ? JSON.stringify(props.rule.rules, null, 2) : '{\n "checkUserTags": true\n}');
const error = ref('');
const showJsonPreview = ref(false);
watch(() => props.rule, (newRule) => {
name.value = newRule ? newRule.name : '';
description.value = newRule ? newRule.description : '';
rulesJson.value = newRule ? JSON.stringify(newRule.rules, null, 2) : '{\n "checkUserTags": true\n}';
// Поля правила
const ruleFields = ref({
system_prompt: props.rule?.rules?.system_prompt || '',
temperature: props.rule?.rules?.temperature ?? 0.7,
max_tokens: props.rule?.rules?.max_tokens ?? 500,
checkUserTags: props.rule?.rules?.rules?.checkUserTags ?? true,
searchRagFirst: props.rule?.rules?.rules?.searchRagFirst ?? true,
generateIfNoRag: props.rule?.rules?.rules?.generateIfNoRag ?? true,
allowed_topics: props.rule?.rules?.rules?.allowed_topics || [],
forbidden_words: props.rule?.rules?.rules?.forbidden_words || []
});
function convertToJson() {
// Простейший пример: если в описании есть "теги", выставляем checkUserTags
// В реальном проекте здесь можно интегрировать LLM или шаблоны
try {
if (/тег[а-я]* пользов/.test(description.value.toLowerCase())) {
rulesJson.value = JSON.stringify({ checkUserTags: true }, null, 2);
error.value = '';
} else {
rulesJson.value = JSON.stringify({ customRule: description.value }, null, 2);
error.value = '';
// Текстовые поля для массивов
const allowedTopicsText = ref(
props.rule?.rules?.rules?.allowed_topics?.join(', ') || ''
);
const forbiddenWordsText = ref(
props.rule?.rules?.rules?.forbidden_words?.join(', ') || ''
);
// Генерация JSON из полей формы
const generatedJson = computed(() => {
const rules = {
system_prompt: ruleFields.value.system_prompt || undefined,
temperature: ruleFields.value.temperature,
max_tokens: ruleFields.value.max_tokens,
rules: {
checkUserTags: ruleFields.value.checkUserTags,
searchRagFirst: ruleFields.value.searchRagFirst,
generateIfNoRag: ruleFields.value.generateIfNoRag,
allowed_topics: allowedTopicsText.value
.split(',')
.map(t => t.trim())
.filter(t => t.length > 0),
forbidden_words: forbiddenWordsText.value
.split(',')
.map(w => w.trim())
.filter(w => w.length > 0)
}
};
// Удаляем undefined поля
Object.keys(rules).forEach(key => {
if (rules[key] === undefined) delete rules[key];
});
return JSON.stringify(rules, null, 2);
});
watch(() => props.rule, (newRule) => {
if (newRule) {
name.value = newRule.name || '';
description.value = newRule.description || '';
ruleFields.value = {
system_prompt: newRule.rules?.system_prompt || '',
temperature: newRule.rules?.temperature ?? 0.7,
max_tokens: newRule.rules?.max_tokens ?? 500,
checkUserTags: newRule.rules?.rules?.checkUserTags ?? true,
searchRagFirst: newRule.rules?.rules?.searchRagFirst ?? true,
generateIfNoRag: newRule.rules?.rules?.generateIfNoRag ?? true,
allowed_topics: newRule.rules?.rules?.allowed_topics || [],
forbidden_words: newRule.rules?.rules?.forbidden_words || []
};
allowedTopicsText.value = (newRule.rules?.rules?.allowed_topics || []).join(', ');
forbiddenWordsText.value = (newRule.rules?.rules?.forbidden_words || []).join(', ');
} else {
// Сброс для нового правила
name.value = '';
description.value = '';
ruleFields.value = {
system_prompt: '',
temperature: 0.7,
max_tokens: 500,
checkUserTags: true,
searchRagFirst: true,
generateIfNoRag: true,
allowed_topics: [],
forbidden_words: []
};
allowedTopicsText.value = '';
forbiddenWordsText.value = '';
}
error.value = '';
});
async function save() {
if (!name.value.trim()) {
error.value = 'Название обязательно для заполнения';
return;
}
try {
// Генерируем JSON из полей формы
const rules = JSON.parse(generatedJson.value);
if (props.rule && props.rule.id) {
await axios.put(`/settings/ai-assistant-rules/${props.rule.id}`, {
name: name.value,
description: description.value,
rules
});
} else {
await axios.post('/settings/ai-assistant-rules', {
name: name.value,
description: description.value,
rules
});
}
emit('close', true);
} catch (e) {
error.value = 'Не удалось преобразовать описание в JSON';
error.value = `Ошибка сохранения: ${e.message}`;
console.error('Ошибка сохранения правила:', e);
}
}
async function save() {
let rules;
try {
rules = JSON.parse(rulesJson.value);
} catch (e) {
error.value = 'Ошибка в формате JSON!';
return;
}
if (props.rule && props.rule.id) {
await axios.put(`/settings/ai-assistant-rules/${props.rule.id}`, { name: name.value, description: description.value, rules });
} else {
await axios.post('/settings/ai-assistant-rules', { name: name.value, description: description.value, rules });
}
emit('close', true);
function close() {
emit('close', false);
}
function close() { emit('close', false); }
</script>
<style scoped>
.modal-bg {
@@ -86,48 +249,169 @@ function close() { emit('close', false); }
align-items: center;
justify-content: center;
z-index: 1000;
overflow-y: auto;
padding: 20px;
}
.modal {
background: #fff;
border-radius: 12px;
box-shadow: 0 2px 16px rgba(0,0,0,0.12);
padding: 2rem;
min-width: 320px;
max-width: 420px;
min-width: 500px;
max-width: 600px;
width: 100%;
margin: auto;
}
h3 {
margin-top: 0;
margin-bottom: 1.5rem;
color: #333;
}
h4 {
margin-top: 0;
margin-bottom: 0.75rem;
font-size: 1rem;
color: #555;
font-weight: 600;
}
label {
display: block;
margin-top: 1rem;
margin-bottom: 0.5rem;
font-weight: 500;
color: #333;
font-size: 0.9rem;
}
input, textarea {
input[type="text"],
input[type="number"],
textarea {
width: 100%;
margin-top: 0.5rem;
padding: 0.5rem;
margin-top: 0.25rem;
padding: 0.625rem;
border-radius: 6px;
border: 1px solid #ddd;
font-size: 1rem;
font-family: inherit;
box-sizing: border-box;
}
input[type="text"]:focus,
input[type="number"]:focus,
textarea:focus {
outline: none;
border-color: var(--color-primary, #007bff);
box-shadow: 0 0 0 3px rgba(0, 123, 255, 0.1);
}
textarea {
resize: vertical;
font-family: 'Courier New', monospace;
}
.rules-section {
margin-top: 1.5rem;
padding-top: 1.5rem;
border-top: 1px solid #eee;
}
.rules-section:first-of-type {
border-top: none;
padding-top: 0;
margin-top: 1rem;
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.form-group {
display: flex;
flex-direction: column;
}
.form-checkbox {
margin-top: 0.75rem;
}
.form-checkbox label {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 0;
cursor: pointer;
font-weight: normal;
}
.form-checkbox input[type="checkbox"] {
width: auto;
margin: 0;
cursor: pointer;
}
.json-preview {
background: #f5f5f5;
border: 1px solid #ddd;
border-radius: 6px;
padding: 1rem;
margin-top: 0.5rem;
font-size: 0.85rem;
font-family: 'Courier New', monospace;
max-height: 200px;
overflow-y: auto;
color: #333;
}
.actions {
display: flex;
gap: 1rem;
margin-top: 2rem;
justify-content: flex-end;
}
button {
background: var(--color-primary);
background: var(--color-primary, #007bff);
color: #fff;
border: none;
border-radius: 6px;
padding: 0.5rem 1.5rem;
padding: 0.625rem 1.5rem;
cursor: pointer;
font-size: 1rem;
font-weight: 500;
transition: background-color 0.2s;
}
button:hover:not(:disabled) {
background: var(--color-primary-dark, #0056b3);
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
button:last-child {
background: #eee;
color: #333;
}
button:last-child:hover {
background: #ddd;
}
.error {
color: #c00;
margin-top: 0.5rem;
margin-top: 1rem;
padding: 0.75rem;
background: #ffe6e6;
border-radius: 6px;
border: 1px solid #ffcccc;
font-size: 0.9rem;
}
</style>

View File

@@ -132,7 +132,7 @@ import { usePermissions } from '@/composables/usePermissions';
const props = defineProps(['rowId', 'column', 'cellValues']);
const emit = defineEmits(['update']);
const { canEditDataData } = usePermissions();
const { canEditData } = usePermissions();
const localValue = ref('');
const editing = ref(false);

View File

@@ -54,7 +54,7 @@ export async function connectWithWallet() {
const docsResponse = await axios.get('/consent/documents');
if (docsResponse.data && docsResponse.data.length > 0) {
docsResponse.data.forEach(doc => {
resources.push(`${window.location.origin}/public/page/${doc.id}`);
resources.push(`${window.location.origin}/content/published/${doc.id}`);
});
}
} catch (error) {

View File

@@ -169,9 +169,11 @@ import { useAuthContext } from '@/composables/useAuth';
import { usePermissions } from '@/composables/usePermissions';
import { PERMISSIONS } from './permissions.js';
import { useContactsAndMessagesWebSocket } from '@/composables/useContactsWebSocket';
import websocketServiceModule from '@/services/websocketService';
const { canEditContacts, canDeleteData, canManageTags, canBlockUsers, canSendToUsers, canGenerateAI, canViewContacts, hasPermission } = usePermissions();
const { address, userId: currentUserId } = useAuthContext();
const { markContactAsRead } = useContactsAndMessagesWebSocket();
const { websocketService } = websocketServiceModule;
// Подписываемся на централизованные события очистки и обновления данных
onMounted(() => {
@@ -220,6 +222,13 @@ const tagsTableId = ref(null);
const { onTagsUpdate } = useTagsWebSocket();
let unsubscribeFromTags = null;
// Обработчик обновления контактов через WebSocket
const handleContactsUpdate = async () => {
console.log('[ContactDetailsView] Получено обновление контакта, перезагружаем данные');
await reloadContact();
await loadUserTags();
};
// Функция маскировки персональных данных для читателей
function maskPersonalData(data) {
if (!data || data === '-') return '-';
@@ -725,6 +734,9 @@ onMounted(async () => {
await loadAllTags();
await loadUserTags();
});
// Подписываемся на обновления контактов (для обновления имени)
websocketService.on('contacts-updated', handleContactsUpdate);
});
onUnmounted(() => {
@@ -732,6 +744,7 @@ onUnmounted(() => {
if (unsubscribeFromTags) {
unsubscribeFromTags();
}
websocketService.off('contacts-updated', handleContactsUpdate);
});
watch(userId, async () => {
await reloadContact();

View File

@@ -43,10 +43,27 @@
<span class="page-status"><i class="fas fa-file"></i>{{ p.format || 'html' }}</span>
</div>
<div v-if="canManageLegalDocs && address" class="page-actions">
<button class="action-btn primary" title="Индексировать" @click.stop="reindex(p.id)"><i class="fas fa-sync"></i><span>Индекс</span></button>
<button
class="action-btn primary"
title="Индексировать"
:disabled="reindexStatus[p.id]?.state === 'loading'"
@click.stop="reindex(p.id)"
>
<i :class="['fas', reindexStatus[p.id]?.state === 'loading' ? 'fa-spinner fa-spin' : 'fa-sync']"></i>
<span>Индекс</span>
</button>
<button class="action-btn primary" title="Редактировать" @click.stop="goEdit(p.id)"><i class="fas fa-edit"></i><span>Ред.</span></button>
<button class="action-btn danger" title="Удалить" @click.stop="doDelete(p.id)"><i class="fas fa-trash"></i><span>Удалить</span></button>
</div>
<transition name="fade">
<div
v-if="reindexStatus[p.id]"
class="reindex-status"
:class="reindexStatus[p.id].state"
>
{{ reindexStatus[p.id].message }}
</div>
</transition>
</div>
</div>
</div>
@@ -60,7 +77,7 @@
</template>
<script setup>
import { ref, computed, onMounted } from 'vue';
import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
import { useRouter } from 'vue-router';
import BaseLayout from '../../components/BaseLayout.vue';
import pagesService from '../../services/pagesService';
@@ -79,19 +96,48 @@ const props = defineProps({
const router = useRouter();
const search = ref('');
const pages = ref([]);
const reindexStatus = ref({});
const { address } = useAuthContext();
const { hasPermission } = usePermissions();
const canManageLegalDocs = computed(() => hasPermission(SHARED_PERMISSIONS.MANAGE_LEGAL_DOCS));
const reindexTimers = new Map();
function goBack() { router.push({ name: 'content-list' }); }
function openPublic(id) { router.push({ name: 'public-page-view', params: { id } }); }
function goEdit(id) { router.push({ name: 'content-create', query: { edit: id } }); }
function updateReindexStatus(id, state, message) {
reindexStatus.value = {
...reindexStatus.value,
[id]: { state, message }
};
}
function scheduleReindexCleanup(id, delay = 4000) {
if (reindexTimers.has(id)) {
clearTimeout(reindexTimers.get(id));
}
const timer = setTimeout(() => {
const next = { ...reindexStatus.value };
delete next[id];
reindexStatus.value = next;
reindexTimers.delete(id);
}, delay);
reindexTimers.set(id, timer);
}
async function reindex(id) {
try {
if (reindexStatus.value[id]?.state === 'loading') {
return;
}
updateReindexStatus(id, 'loading', 'Индексация запущена...');
await api.post(`/pages/${id}/reindex`);
alert('Индексация выполнена');
updateReindexStatus(id, 'success', 'Индексация выполняется. Проверьте логи.');
scheduleReindexCleanup(id);
} catch (e) {
alert('Ошибка индексации: ' + (e?.response?.data?.error || e.message));
const errorMessage = e?.response?.data?.error || e.message;
updateReindexStatus(id, 'error', `Ошибка индексации: ${errorMessage}`);
scheduleReindexCleanup(id, 6000);
}
}
async function doDelete(id) {
@@ -119,6 +165,11 @@ onMounted(async () => {
pages.value = [];
}
});
onBeforeUnmount(() => {
reindexTimers.forEach(timer => clearTimeout(timer));
reindexTimers.clear();
});
</script>
<style scoped>
@@ -151,6 +202,13 @@ onMounted(async () => {
.action-btn.primary:hover { background: var(--color-primary-dark); }
.action-btn.danger { background: #fef2f2; color: #b91c1c; border-color: #fecaca; }
.action-btn.danger:hover { background: #fee2e2; }
.action-btn:disabled { opacity: 0.6; cursor: not-allowed; }
.reindex-status { margin-top: 10px; font-size: 0.9rem; font-weight: 500; }
.reindex-status.loading { color: #2563eb; }
.reindex-status.success { color: #16a34a; }
.reindex-status.error { color: #dc2626; }
.fade-enter-active, .fade-leave-active { transition: opacity 0.3s ease; }
.fade-enter-from, .fade-leave-to { opacity: 0; }
.empty-state { text-align:center; padding: 60px 20px; }
.empty-icon { font-size: 3rem; color: var(--color-grey-dark); margin-bottom: 10px; }
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -66,13 +66,21 @@ export default defineConfig({
rewrite: (path) => path,
configure: (proxy, options) => {
proxy.on('error', (err, req, res) => {
// Игнорируем ошибки ECONNREFUSED при старте сервера - это нормально
if (err.code === 'ECONNREFUSED' || err.message.includes('ECONNREFUSED')) {
// Не логируем как ошибку - это нормальное поведение при рестарте сервера
// Фронтенд автоматически переподключится
return;
}
console.log('WebSocket proxy error:', err.message);
});
proxy.on('proxyReqWs', (proxyReq, req, socket) => {
console.log('WebSocket proxy request to:', req.url);
// Убираем избыточное логирование - это происходит слишком часто
// console.log('WebSocket proxy request to:', req.url);
});
proxy.on('proxyResWs', (proxyRes, req, socket) => {
console.log('WebSocket proxy response:', proxyRes.statusCode);
// Убираем избыточное логирование
// console.log('WebSocket proxy response:', proxyRes.statusCode);
});
}
},