diff --git a/frontend/src/api/documentsApi.js b/frontend/src/api/documentsApi.js new file mode 100644 index 0000000..a221c96 --- /dev/null +++ b/frontend/src/api/documentsApi.js @@ -0,0 +1,13 @@ +import { request } from './client'; + +const documentsApi = { + searchByName: (name, limit = 10, signal) => { + const params = new URLSearchParams({ name, limit: String(limit) }); + return request(`/api/documents/search-by-name?${params}`, signal ? { signal } : undefined); + }, + getById: (id, signal) => { + return request(`/api/documents/${id}`, signal ? { signal } : undefined); + }, +}; + +export default documentsApi; diff --git a/frontend/src/components/chatPanel/FileChipInput.jsx b/frontend/src/components/chatPanel/FileChipInput.jsx index 6ea00d6..6240d14 100644 --- a/frontend/src/components/chatPanel/FileChipInput.jsx +++ b/frontend/src/components/chatPanel/FileChipInput.jsx @@ -2,14 +2,50 @@ import React, { useRef, useEffect, useCallback, useState, useImperativeHandle, f import { createPortal } from 'react-dom'; import { useTranslation } from 'react-i18next'; import gitApi from '../../api/gitApi'; -import { makeToken, makeRefToken, parseToken, baseName, fetchContent, TOKEN_RE } from './fileChips'; +import documentsApi from '../../api/documentsApi'; +import { + makeToken, + makeRefToken, + makeDocToken, + makeDocRefToken, + parseToken, + parseDocToken, + parseDocRefToken, + baseName, + fetchContent, + TOKEN_RE, +} from './fileChips'; import FilePickerDropdown from './FilePickerDropdown'; import useEscape from '../common/useEscape'; import { IconX } from '../../icons'; const DEBOUNCE_MS = 200; -const TRIGGER = '/file '; -const TRIGGER_RE = /(?:^|\s)\/file (\S*)$/; +const FILE_TRIGGER = '/file'; +const FILE_TRIGGER_RE = /(?:^|\s)\/file\s*(\S*)$/; +const DOC_TRIGGER = '/doc'; +const DOC_TRIGGER_RE = /(?:^|\s)\/doc\s*(\S*)$/; + +async function searchDocsAsync(q, signal) { + const isNumeric = q.length > 0 && /^\d+$/.test(q); + if (!isNumeric) { + return documentsApi.searchByName(q, 10, signal); + } + const [nameRes, idRes] = await Promise.all([ + documentsApi.searchByName(q, 10, signal).catch((e) => { + if (e.name === 'AbortError') throw e; + return []; + }), + documentsApi.getById(Number(q), signal).catch((e) => { + if (e.name === 'AbortError') throw e; + return null; + }), + ]); + const results = Array.isArray(nameRes) ? [...nameRes] : []; + if (idRes && !results.find((r) => r.id === idRes.id)) { + results.unshift(idRes); + } + return results; +} // ── Сериализация DOM ⇄ плоская строка с токенами ─────────────────────────────── @@ -29,8 +65,39 @@ function serialize(root) { return out.replace(/^\n/, ''); } +function makeDocChipEl(token, { id, title }, refOnly) { + const chip = document.createElement('span'); + chip.className = 'file-chip file-chip--doc' + (refOnly ? ' file-chip--ref' : ''); + chip.contentEditable = 'false'; + chip.dataset.token = token; + chip.title = `${title} (#${id})`; + + const icon = document.createElement('span'); + icon.className = 'file-chip__icon'; + icon.textContent = refOnly ? '📎' : '📋'; + + const label = document.createElement('span'); + label.className = 'file-chip__label'; + label.textContent = title; + + const remove = document.createElement('button'); + remove.type = 'button'; + remove.className = 'file-chip__remove'; + remove.textContent = '×'; + remove.tabIndex = -1; + + chip.append(icon, label, remove); + return chip; +} + /** Построить DOM-элемент чипа из строки-токена. */ function makeChipEl(token) { + const docRefParsed = parseDocRefToken(token); + if (docRefParsed) return makeDocChipEl(token, docRefParsed, true); + + const docParsed = parseDocToken(token); + if (docParsed) return makeDocChipEl(token, docParsed, false); + const parsed = parseToken(token); const path = parsed?.path ?? token; const range = parsed?.from != null ? `:${parsed.from}-${parsed.to}` : ''; @@ -108,7 +175,15 @@ const FileChipInput = forwardRef(function FileChipInput( const internalRef = useRef(value); const triggerRef = useRef(null); - const [picker, setPicker] = useState({ open: false, query: '', results: [], loading: false, anchor: null, idx: 0 }); + const [picker, setPicker] = useState({ + open: false, + query: '', + results: [], + loading: false, + anchor: null, + idx: 0, + type: 'file', + }); const [preview, setPreview] = useState(null); // { path, from, to, refOnly, rect, chipEl, data, loading, error } const debounceTimer = useRef(null); @@ -144,13 +219,14 @@ const FileChipInput = forwardRef(function FileChipInput( setPicker((p) => (p.open ? { ...p, open: false, results: [], query: '' } : p)); }, []); - const runSearch = useCallback((q) => { + const runSearch = useCallback((q, type) => { abortRef.current?.abort(); const controller = new AbortController(); abortRef.current = controller; setPicker((p) => ({ ...p, loading: true })); - gitApi - .searchFiles(q, 10, controller.signal) + const search = + type === 'doc' ? searchDocsAsync(q, controller.signal) : gitApi.searchFiles(q, 10, controller.signal); + search .then((data) => setPicker((p) => ({ ...p, loading: false, results: Array.isArray(data) ? data : [], idx: 0 }))) .catch((err) => { if (err.name !== 'AbortError') setPicker((p) => ({ ...p, loading: false, results: [] })); @@ -165,20 +241,28 @@ const FileChipInput = forwardRef(function FileChipInput( if (node.nodeType !== Node.TEXT_NODE) return dismissPicker(); const before = node.nodeValue.slice(0, range.startOffset); - const m = before.match(TRIGGER_RE); + + let m = before.match(FILE_TRIGGER_RE); + let type = 'file'; + if (!m) { + m = before.match(DOC_TRIGGER_RE); + type = 'doc'; + } if (!m) return dismissPicker(); const query = m[1]; - const start = range.startOffset - TRIGGER.length - query.length; - triggerRef.current = { node, start, query }; + const commandStr = type === 'file' ? FILE_TRIGGER : DOC_TRIGGER; + const start = before.lastIndexOf(commandStr); + + triggerRef.current = { node, start, cursorOffset: range.startOffset, query, type }; const rect = range.getBoundingClientRect(); const anchor = rect && (rect.top || rect.left) ? { top: rect.top, left: rect.left } : null; - setPicker((p) => ({ ...p, open: true, query, anchor, idx: 0 })); + setPicker((p) => ({ ...p, open: true, query, anchor, idx: 0, type })); clearTimeout(debounceTimer.current); if (query.length >= 1) { - debounceTimer.current = setTimeout(() => runSearch(query), DEBOUNCE_MS); + debounceTimer.current = setTimeout(() => runSearch(query, type), DEBOUNCE_MS); } else { setPicker((p) => ({ ...p, results: [], loading: false })); } @@ -206,17 +290,17 @@ const FileChipInput = forwardRef(function FileChipInput( setPreview((pv) => (pv ? null : pv)); }, [emitChange, detectTrigger]); - const insertFile = useCallback( - (fileNode) => { + const doInsert = useCallback( + (token) => { const trig = triggerRef.current; const root = editorRef.current; if (!trig || !root) return; - const { node, start, query } = trig; + const { node, start, cursorOffset } = trig; const before = node.nodeValue.slice(0, start); - const after = node.nodeValue.slice(start + TRIGGER.length + query.length); + const after = node.nodeValue.slice(cursorOffset); - const chip = makeChipEl(makeToken(fileNode.path)); + const chip = makeChipEl(token); const tail = document.createTextNode(' ' + after); node.nodeValue = before; node.after(chip, tail); @@ -235,6 +319,24 @@ const FileChipInput = forwardRef(function FileChipInput( [dismissPicker, emitChange], ); + // Вставить ссылку (по умолчанию: Enter / клик по строке) + const insertItem = useCallback( + (item) => { + const type = triggerRef.current?.type; + doInsert(type === 'doc' ? makeDocRefToken(item.id, item.title) : makeRefToken(item.path)); + }, + [doInsert], + ); + + // Вставить с содержимым (кнопка в дропдауне) + const insertItemWithContent = useCallback( + (item) => { + const type = triggerRef.current?.type; + doInsert(type === 'doc' ? makeDocToken(item.id, item.title) : makeToken(item.path)); + }, + [doInsert], + ); + const insertTextAtCaret = useCallback((text) => { const sel = window.getSelection(); if (!sel.rangeCount) return; @@ -290,7 +392,7 @@ const FileChipInput = forwardRef(function FileChipInput( } if (e.key === 'Enter' && picker.results.length > 0) { e.preventDefault(); - insertFile(picker.results[picker.idx]); + insertItem(picker.results[picker.idx]); return; } if (e.key === 'Escape') { @@ -338,7 +440,7 @@ const FileChipInput = forwardRef(function FileChipInput( emitChange(); } }, - [picker.open, picker.results, picker.idx, insertFile, dismissPicker, disabled, onSend, emitChange], + [picker.open, picker.results, picker.idx, insertItem, dismissPicker, disabled, onSend, emitChange], ); // Переключение чипа между режимами «содержимое» и «только путь». @@ -377,6 +479,7 @@ const FileChipInput = forwardRef(function FileChipInput( const chip = e.target.closest?.('.file-chip'); if (chip) { e.preventDefault(); + if (parseDocToken(chip.dataset.token) || parseDocRefToken(chip.dataset.token)) return; const parsed = parseToken(chip.dataset.token); if (!parsed) return; const rect = chip.getBoundingClientRect(); @@ -419,8 +522,10 @@ const FileChipInput = forwardRef(function FileChipInput( query={picker.query} anchorRect={picker.anchor} selectedIdx={picker.idx} - onSelect={insertFile} + onSelect={insertItem} + onSelectWithContent={insertItemWithContent} onDismiss={dismissPicker} + type={picker.type} /> )} diff --git a/frontend/src/components/chatPanel/FilePickerDropdown.jsx b/frontend/src/components/chatPanel/FilePickerDropdown.jsx index 3564e69..b7f695b 100644 --- a/frontend/src/components/chatPanel/FilePickerDropdown.jsx +++ b/frontend/src/components/chatPanel/FilePickerDropdown.jsx @@ -3,19 +3,31 @@ import { useTranslation } from 'react-i18next'; import { IconFileText } from '../../icons'; /** - * Плавающий список результатов поиска файлов репозитория для триггера `/file`. + * Плавающий список результатов поиска для триггеров `/file` и `/doc`. * Открывается НАД кареткой (композер прижат к низу окна). * * Props: - * results — GitFileNode[] { path, name, size } - * loading — boolean - * query — string (для пустого состояния) - * anchorRect — DOMRect-like { top, left } каретки - * selectedIdx — number - * onSelect(node) — клик/Enter - * onDismiss() — закрыть + * results — GitFileNode[] | DocumentNode[] + * loading — boolean + * query — string + * anchorRect — { top, left } каретки + * selectedIdx — number + * type — 'file' | 'doc' + * onSelect(node) — Enter / клик по строке → вставить ссылку + * onSelectWithContent — клик по кнопке → вставить содержимое + * onDismiss() — закрыть */ -const FilePickerDropdown = ({ results, loading, query, anchorRect, selectedIdx, onSelect, onDismiss }) => { +const FilePickerDropdown = ({ + results, + loading, + query, + anchorRect, + selectedIdx, + onSelect, + onSelectWithContent, + onDismiss, + type = 'file', +}) => { const { t } = useTranslation('chat'); const listRef = useRef(null); @@ -40,53 +52,108 @@ const FilePickerDropdown = ({ results, loading, query, anchorRect, selectedIdx, zIndex: 9100, }; + const hintKey = query + ? type === 'doc' + ? t('docInput.hintQuery', { query }) + : t('fileInput.hintQuery', { query }) + : type === 'doc' + ? t('docInput.hintStart') + : t('fileInput.hintStart'); + + const searchingLabel = type === 'doc' ? t('docInput.searching') : t('fileInput.searching'); + const emptyLabel = type === 'doc' ? t('docInput.empty') : t('fileInput.empty'); + const contentBtnLabel = t('fileInput.insertContent'); + return (
- - {query ? t('fileInput.hintQuery', { query }) : t('fileInput.hintStart')} - + {hintKey}
{loading && (
- {t('fileInput.searching')} + {searchingLabel}
)} {!loading && results.length === 0 && query.length >= 1 && ( -
{t('fileInput.empty')}
+
{emptyLabel}
)}
- {results.map((node, i) => { - const dir = node.path.includes('/') ? node.path.slice(0, node.path.lastIndexOf('/')) : ''; - return ( -
{ - e.preventDefault(); - onSelect(node); - }} - > - - - - - {node.name} - - {dir || node.path} + {type === 'doc' + ? results.map((node, i) => ( +
{ + e.preventDefault(); + onSelect(node); + }} + > + {node.type === 'folder' ? '📁' : '📋'} + + {node.title} + #{node.id} - -
- ); - })} +
+ +
+
+ )) + : results.map((node, i) => { + const dir = node.path.includes('/') ? node.path.slice(0, node.path.lastIndexOf('/')) : ''; + return ( +
{ + e.preventDefault(); + onSelect(node); + }} + > + + + + + {node.name} + + {dir || node.path} + + +
+ +
+
+ ); + })}
- ↑↓ {t('fileInput.navigate')} · Enter {t('fileInput.insert')} · Esc{' '} + ↑↓ {t('fileInput.navigate')} · Enter {t('fileInput.insertRef')} · Esc{' '} {t('fileInput.dismiss')}
diff --git a/frontend/src/components/chatPanel/Message.jsx b/frontend/src/components/chatPanel/Message.jsx index 8e3302c..c65b2a1 100644 --- a/frontend/src/components/chatPanel/Message.jsx +++ b/frontend/src/components/chatPanel/Message.jsx @@ -487,10 +487,25 @@ const formatFullDatetime = (ts) => { if (!ts) return null; const date = new Date(ts); if (isNaN(date)) return null; - return date.toLocaleString('ru-RU', { day: 'numeric', month: 'long', year: 'numeric', hour: '2-digit', minute: '2-digit' }); + return date.toLocaleString('ru-RU', { + day: 'numeric', + month: 'long', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); }; -const Message = ({ text, sender, toolCalls, toolCallsRunId, preparing, conversationId, onNavigateToDoc, timestamp }) => { +const Message = ({ + text, + sender, + toolCalls, + toolCallsRunId, + preparing, + conversationId, + onNavigateToDoc, + timestamp, +}) => { const { t } = useTranslation('chat'); const [showSource, setShowSource] = useState(false); const messageClass = `message ${sender}`; diff --git a/frontend/src/components/chatPanel/MessageInput.jsx b/frontend/src/components/chatPanel/MessageInput.jsx index aaf5dff..99c1ec1 100644 --- a/frontend/src/components/chatPanel/MessageInput.jsx +++ b/frontend/src/components/chatPanel/MessageInput.jsx @@ -6,7 +6,16 @@ import { expandTokensForSend } from './fileChips'; import { IconSend, IconStop, IconPaperclip } from '../../icons'; // isEmpty — true когда в чате ещё нет сообщений; тогда показываем git-подсказки -const MessageInput = ({ onSend, onStop, disabled, onAttach, isEmpty = false, resetSignal = 0, chatId = null, onTextChange }) => { +const MessageInput = ({ + onSend, + onStop, + disabled, + onAttach, + isEmpty = false, + resetSignal = 0, + chatId = null, + onTextChange, +}) => { const { t } = useTranslation('chat'); const [text, setText] = useState(''); // плоская строка с токенами ⟦file:…⟧ const [sending, setSending] = useState(false); // идёт разворачивание токенов перед отправкой @@ -63,7 +72,10 @@ const MessageInput = ({ onSend, onStop, disabled, onAttach, isEmpty = false, res { setText(v); onTextChange?.(v); }} + onChange={(v) => { + setText(v); + onTextChange?.(v); + }} onSend={handleSubmit} disabled={disabled} placeholder={t('input.placeholder')} diff --git a/frontend/src/components/chatPanel/chatWindow.css b/frontend/src/components/chatPanel/chatWindow.css index d81c0d7..79ef646 100644 --- a/frontend/src/components/chatPanel/chatWindow.css +++ b/frontend/src/components/chatPanel/chatWindow.css @@ -733,6 +733,38 @@ display: flex; flex-direction: column; min-width: 0; + flex: 1; +} +.file-picker-item__actions { + margin-left: auto; + display: flex; + align-items: center; + flex-shrink: 0; +} +.file-picker-item__content-btn { + display: flex; + align-items: center; + gap: 3px; + padding: 2px 7px; + border: 1px solid var(--kb-border); + background: var(--kb-white); + border-radius: 3px; + font-size: 0.7rem; + color: var(--kb-muted); + cursor: pointer; + opacity: 0; + pointer-events: none; + transition: opacity 0.1s, color 0.1s, border-color 0.1s, background 0.1s; + white-space: nowrap; +} +.file-picker-item:hover .file-picker-item__content-btn { + opacity: 1; + pointer-events: auto; +} +.file-picker-item__content-btn:hover { + color: var(--kb-accent); + border-color: var(--kb-accent); + background: var(--kb-accent-light); } .file-picker-item__name { font-size: 0.84rem; diff --git a/frontend/src/components/chatPanel/fileChips.js b/frontend/src/components/chatPanel/fileChips.js index ec69159..10a51a7 100644 --- a/frontend/src/components/chatPanel/fileChips.js +++ b/frontend/src/components/chatPanel/fileChips.js @@ -5,12 +5,14 @@ // ⟦ref:PATH⟧ — только ссылка (раскрывается в `PATH`) import gitApi from '../../api/gitApi'; +import documentsApi from '../../api/documentsApi'; const OPEN = '⟦'; // ⟦ const CLOSE = '⟧'; // ⟧ -// Глобальный матчер обоих видов токенов. Захватных групп нет — parseToken разбирает детально. -export const TOKEN_RE = new RegExp(`${OPEN}(?:file|ref):[^${CLOSE}]+${CLOSE}`, 'g'); +// Глобальный матчер всех видов токенов. Захватных групп нет — parse* разбирают детально. +// docref идёт перед doc, чтобы не срабатывал prefix-match при чтении. +export const TOKEN_RE = new RegExp(`${OPEN}(?:file|ref|docref|doc):[^${CLOSE}]+${CLOSE}`, 'g'); /** Токен «весь файл / диапазон». */ export function makeToken(path, from, to) { @@ -44,6 +46,44 @@ export function baseName(path) { return i >= 0 ? path.slice(i + 1) : path; } +// ── Doc-токены ──────────────────────────────────────────────────────────────── +// ⟦doc:ID:TITLE⟧ — полное содержимое (описание документа) +// ⟦docref:ID:TITLE⟧ — только упоминание (без содержимого) + +function safeDocTitle(title) { + return String(title).replace(/⟧/g, ''); +} + +/** Токен документа с раскрытием описания при отправке. */ +export function makeDocToken(id, title) { + return `${OPEN}doc:${id}:${safeDocTitle(title)}${CLOSE}`; +} + +/** Токен документа — только упоминание (без описания). */ +export function makeDocRefToken(id, title) { + return `${OPEN}docref:${id}:${safeDocTitle(title)}${CLOSE}`; +} + +/** + * Разобрать doc-токен (с содержимым). + * Возвращает { id, title } или null. + */ +export function parseDocToken(token) { + const m = token.match(new RegExp(`^${OPEN}doc:(\\d+):(.*)${CLOSE}$`)); + if (m) return { id: Number(m[1]), title: m[2] }; + return null; +} + +/** + * Разобрать docref-токен (только ссылка). + * Возвращает { id, title } или null. + */ +export function parseDocRefToken(token) { + const m = token.match(new RegExp(`^${OPEN}docref:(\\d+):(.*)${CLOSE}$`)); + if (m) return { id: Number(m[1]), title: m[2] }; + return null; +} + // ── Кеш содержимого ────────────────────────────────────────────────────────── const contentCache = new Map(); // key: `path#from-to` → GitFileContent @@ -77,6 +117,23 @@ export async function expandTokensForSend(text) { const blocks = await Promise.all( tokens.map(async (m) => { + const docRefParsed = parseDocRefToken(m[0]); + if (docRefParsed) { + return `Документ «${docRefParsed.title}» (#${docRefParsed.id})`; + } + + const docParsed = parseDocToken(m[0]); + if (docParsed) { + try { + const doc = await documentsApi.getById(docParsed.id); + const title = doc?.title ?? docParsed.title; + const description = doc?.description ?? ''; + return `\n\nДокумент «${title}» (#${docParsed.id}):\n${description}\n`; + } catch { + return `\n\n[Документ #${docParsed.id}: не удалось загрузить]\n`; + } + } + const parsed = parseToken(m[0]); if (!parsed) return m[0]; const { path, from, to, refOnly } = parsed; diff --git a/frontend/src/i18n/locales/en/chat.json b/frontend/src/i18n/locales/en/chat.json index adb46b0..8d39d20 100644 --- a/frontend/src/i18n/locales/en/chat.json +++ b/frontend/src/i18n/locales/en/chat.json @@ -73,12 +73,20 @@ "empty": "Nothing found", "navigate": "navigate", "insert": "insert", + "insertRef": "insert link", + "insertContent": "content", "dismiss": "dismiss", "closePreview": "Close preview", "previewError": "Failed to load file", "usePathOnly": "Switch to path-only (no content)", "useFullContent": "Switch to full content" }, + "docInput": { + "hintStart": "Type a document name or ID to search", + "hintQuery": "Search documents: \"{{query}}\"", + "searching": "Searching…", + "empty": "Nothing found" + }, "list": { "newChat": "New chat", "newJiraChat": "JIRA chat", diff --git a/frontend/src/i18n/locales/ru/chat.json b/frontend/src/i18n/locales/ru/chat.json index f029401..1781d72 100644 --- a/frontend/src/i18n/locales/ru/chat.json +++ b/frontend/src/i18n/locales/ru/chat.json @@ -73,12 +73,20 @@ "empty": "Ничего не найдено", "navigate": "выбрать", "insert": "вставить", + "insertRef": "вставить ссылку", + "insertContent": "содержимое", "dismiss": "закрыть", "closePreview": "Закрыть превью", "previewError": "Не удалось загрузить файл", "usePathOnly": "Только путь (без содержимого)", "useFullContent": "С содержимым" }, + "docInput": { + "hintStart": "Введите название или ID документа", + "hintQuery": "Поиск документов: «{{query}}»", + "searching": "Поиск…", + "empty": "Ничего не найдено" + }, "list": { "newChat": "Новый чат", "newJiraChat": "JIRA чат",