diff --git a/backend/app/engines/claude.py b/backend/app/engines/claude.py index 4cdc714..2d3a672 100644 --- a/backend/app/engines/claude.py +++ b/backend/app/engines/claude.py @@ -129,10 +129,12 @@ def build_options(resume: str | None, surface: str, instructions: str, skills=allowed_skills, hooks=hooks, cwd=config.WIKI_DIR, - # Библиотека лежит вне рабочей директории, а Read по ней нужен: только так агент - # видит рисунок из книги (пути выдают chapter_images/page_image). Запись закрыта - # хуком выше. - add_dirs=[config.BOOKS_DIR], + # Библиотека и файловое хранилище лежат вне рабочей директории, а Read по ним + # нужен: только так агент видит рисунок из книги (пути выдают chapter_images/ + # page_image) и разбирает файл, который пользователь загрузил в «Файлы» + # (POST /storage/upload?parse=1 отдаёт полный путь в parse_prompt). Запись в + # BOOKS_DIR закрыта хуком выше; FILES_DIR открыт полностью. + add_dirs=[config.BOOKS_DIR, config.FILES_DIR], include_partial_messages=True, resume=resume, # Don't inherit host ~/.claude project/user settings — keep the agent self-contained. diff --git a/backend/app/storage_api.py b/backend/app/storage_api.py index 992e8b3..7563311 100644 --- a/backend/app/storage_api.py +++ b/backend/app/storage_api.py @@ -104,7 +104,16 @@ async def download(path: str, token: str = ""): @router.post("/upload") -async def upload(file: UploadFile, dir: str = "", _: bool = Depends(require_auth)): +async def upload(file: UploadFile, dir: str = "", parse: int = 0, + _: bool = Depends(require_auth)): + """Загрузить файл в FILES_DIR/. + + parse=1 — вернуть готовый `parse_prompt` с абсолютным путём внутри контейнера. + Фронт отправляет эту строку в чат обычным сообщением; агент по подсказке + открывает файл через Read (FILES_DIR смонтирован в add_dirs Claude-движка). + Сам агент здесь не дёргается: /storage/upload — REST-ручка, а разбор идёт по + той же WebSocket-нити, что и любой запрос от пользователя. + """ abs_dir = safe_path(dir) os.makedirs(abs_dir, exist_ok=True) name = clean_name(os.path.basename(file.filename or "файл")) @@ -126,7 +135,15 @@ async def upload(file: UploadFile, dir: str = "", _: bool = Depends(require_auth if os.path.exists(tmp): os.remove(tmp) rel = os.path.relpath(dest, config.FILES_DIR) - return {"ok": True, "path": rel, "size": size} + result: dict = {"ok": True, "path": rel, "size": size} + if parse: + result["parse_prompt"] = ( + f"[Пользователь загрузил файл в «Файлы»: {dest}. " + "Открой его через Read и коротко разбери: что это, ключевые данные, " + "что с этим можно сделать. Если это заметки/задачи/чек-лист — предложи " + "разложить в вики или задачи.]" + ) + return result @router.post("/mkdir") diff --git a/frontend-books/index.html b/frontend-books/index.html index ae13ee6..30dc724 100644 --- a/frontend-books/index.html +++ b/frontend-books/index.html @@ -38,6 +38,7 @@ +
открываю…
@@ -121,6 +122,8 @@

Чтение

+ +
diff --git a/frontend-books/src/i18n.js b/frontend-books/src/i18n.js index ad79b4b..20f059d 100644 --- a/frontend-books/src/i18n.js +++ b/frontend-books/src/i18n.js @@ -127,6 +127,7 @@ const RU = { highlightDeleted: 'Выписка удалена', ownNote: 'Своя заметка…', refine: 'Уточнить…', + attachFile: 'Прикрепить файл', askAboutFragment: 'Напиши вопрос про этот фрагмент', waitForAnswer: 'Дождись ответа', thinking: 'думает…', @@ -299,6 +300,7 @@ const EN = { highlightDeleted: 'Highlight deleted', ownNote: 'Your own note…', refine: 'Follow up…', + attachFile: 'Attach a file', askAboutFragment: 'Type your question about this passage', waitForAnswer: 'Wait for the answer', thinking: 'thinking…', diff --git a/frontend-books/src/library.js b/frontend-books/src/library.js index 208c1c8..8b7db1e 100644 --- a/frontend-books/src/library.js +++ b/frontend-books/src/library.js @@ -66,6 +66,21 @@ export async function uploadBook(file) { return r.json(); } +/** Скрепка в чат: файл кладётся в общий /storage/Входящие и бэкенд возвращает + * готовую строку-подсказку агенту (parse_prompt). Ту строку и отправляем + * в текущий чат — surface=books, агент разберётся в контексте книги. */ +export async function storageUploadForParse(file, dir = 'Входящие') { + const body = new FormData(); + body.append('file', file, file.name || 'file'); + const qs = new URLSearchParams({ dir, parse: '1' }); + const r = await fetch(`${API}/storage/upload?${qs.toString()}`, { method: 'POST', headers: head(), body }); + if (!r.ok) { + const detail = await r.json().catch(() => ({})); + throw new Error(detail.detail || 'не вышло загрузить файл'); + } + return r.json(); // {ok, path, size, parse_prompt?} +} + export async function deleteBook(id) { const r = await fetch(`${API}/books/${id}`, { method: 'DELETE', headers: head() }); if (!r.ok) throw new Error('удаление: ' + r.status); diff --git a/frontend-books/src/main.js b/frontend-books/src/main.js index ac4f856..821e62f 100644 --- a/frontend-books/src/main.js +++ b/frontend-books/src/main.js @@ -8,6 +8,7 @@ import { allToWiki, closeDrawer, drawerFind, drawerHighlights, drawerPrefs, draw import { applyTheme, closeBook, epubSurface, openBook, wireGlobal, wireScrub } from './reader.js' import { bubbleMe, closeSheet, contextAround, followUp, openHighlight, promptFor, send, wireScrim, wireSheetKeyboard } from './sheet.js' import { buildShelf, pickFile, refreshShelf, wireShelfDrop } from './shelf.js' +import { storageUploadForParse } from './library.js' import { lib } from './store.js' import { live, sync } from './sync.js' import { closeStats, openStats, wireReadingBeat } from './stats.js' @@ -43,6 +44,31 @@ function wireUI() { followUp(v); }; $('#sheetInput').addEventListener('keydown', e => { if (e.key === 'Enter') $('#sheetSend').click(); }); + // Скрепка: файл — в /storage/Входящие, готовый prompt агенту — в открытый чат + // выделения (нужен активный highlight; иначе некуда цеплять thread). + $('#sheetAttach').onclick = () => $('#sheetFile').click(); + $('#sheetFile').addEventListener('change', async e => { + const files = e.target.files; + if (!files || !files.length) return; + const btn = $('#sheetAttach'); + btn.disabled = true; + try { + for (const f of Array.from(files)) { + try { + const r = await storageUploadForParse(f); + if (r.parse_prompt) { + bubbleMe(`📎 ${f.name}`); + send(r.parse_prompt); + } + } catch (err) { + bubbleMe(`⚠️ ${f.name}: ${err.message || err}`); + } + } + } finally { + btn.disabled = false; + e.target.value = ''; + } + }); $('#authGo').onclick = doLogin; $('#authPass').addEventListener('keydown', e => { if (e.key === 'Enter') doLogin(); }); } diff --git a/frontend-books/src/style.css b/frontend-books/src/style.css index f2dbb1d..3a0d9e1 100644 --- a/frontend-books/src/style.css +++ b/frontend-books/src/style.css @@ -356,6 +356,11 @@ canvas.pdfpage { display: block; box-shadow: 0 1px 8px rgba(0, 0, 0, .12); borde .send { width: 38px; height: 38px; border-radius: 50%; background: var(--accent); color: var(--on-accent); display: grid; place-items: center; flex: none; } .send[disabled] { opacity: .45; } +.sheet-foot .attach { width: 38px; height: 38px; border-radius: 50%; background: transparent; color: var(--text-3); + display: grid; place-items: center; flex: none; border: 0; cursor: pointer; transition: background .15s, color .15s; } +.sheet-foot .attach:hover { background: var(--bg-3); color: var(--accent); } +.sheet-foot .attach[disabled] { opacity: .4; } +.sheet-foot .attach .icon { width: 18px; height: 18px; } .chips { display: flex; gap: 7px; padding: 0 16px 12px; flex-wrap: wrap; flex: none; } .chip { padding: 7px 13px; border-radius: var(--r-pill); background: var(--bg-2); font-size: 12.5px; font-weight: 600; display: flex; align-items: center; gap: 5px; } diff --git a/frontend-tasks/nginx.conf b/frontend-tasks/nginx.conf index d49e5fd..2915cf2 100644 --- a/frontend-tasks/nginx.conf +++ b/frontend-tasks/nginx.conf @@ -26,6 +26,17 @@ server { client_max_body_size 25m; } + # Файловое хранилище — тот же путь, что у вики: отдельным блоком, потому что + # тут большие загрузки и без буферизации. Без него SPA-fallback возвращает + # index.html вместо JSON и клиент падает на res.json(). + location /storage { + proxy_pass http://backend:8000; + proxy_set_header Host $host; + client_max_body_size 512m; + proxy_request_buffering off; + proxy_read_timeout 300; + } + location /api/ { proxy_pass http://backend:8000; proxy_set_header Host $host; diff --git a/frontend-tasks/src/ChatPane.tsx b/frontend-tasks/src/ChatPane.tsx index 61b2fd2..4bbd279 100644 --- a/frontend-tasks/src/ChatPane.tsx +++ b/frontend-tasks/src/ChatPane.tsx @@ -1,8 +1,13 @@ import { useEffect, useLayoutEffect, useRef, useState } from "react"; -import { ArrowUp, ChevronRight, Sparkles, TriangleAlert, Wrench } from "lucide-react"; +import { ArrowUp, ChevronRight, Loader2, Paperclip, Sparkles, TriangleAlert, Wrench } from "lucide-react"; import MicButton from "./MicButton"; import { t } from "./i18n"; import { useChat } from "./useChat"; +import { storageUpload } from "./api"; + +// Скрепка кладёт файл в общий inbox — ту же папку, куда падает всё, что пришло +// боту в Telegram. Один поток входящих на всё приложение. +const ATTACH_DIR = "Входящие"; export default function ChatPane({ onActivity, @@ -15,8 +20,10 @@ export default function ChatPane({ }) { const { messages, streaming, busy, send } = useChat(onActivity); const [input, setInput] = useState(""); + const [uploading, setUploading] = useState(false); const scrollRef = useRef(null); const taRef = useRef(null); + const fileRef = useRef(null); useEffect(() => { scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: "smooth" }); @@ -42,6 +49,28 @@ export default function ChatPane({ taRef.current?.focus(); }; + const onAttach = async (files: FileList | null) => { + if (!files?.length || uploading || busy) return; + setUploading(true); + try { + for (const f of Array.from(files)) { + try { + const r = await storageUpload(ATTACH_DIR, f, { parse: true }); + if (r.parse_prompt) send(r.parse_prompt); + } catch (e) { + // В WS писать нельзя: если он не подключён, useChat.send() покажет + // «связь с ассистентом потеряна» — это выглядит как поломка чата, + // хотя упал upload. Логируем в консоль + системный alert. + console.error("attach upload failed", f.name, e); + alert(`Не вышло прикрепить ${f.name}: ${(e as Error).message}`); + } + } + } finally { + setUploading(false); + if (fileRef.current) fileRef.current.value = ""; + } + }; + if (collapsed) { return (