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 (