Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions backend/app/engines/claude.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
21 changes: 19 additions & 2 deletions backend/app/storage_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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/<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 "файл"))
Expand All @@ -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")
Expand Down
3 changes: 3 additions & 0 deletions frontend-books/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
<symbol id="i-find" viewBox="0 0 24 24"><path d="M11 19a8 8 0 100-16 8 8 0 000 16zM21 21l-4.35-4.35"/></symbol>
<symbol id="i-note" viewBox="0 0 24 24"><path d="M12 20h9M16.5 3.5a2.1 2.1 0 013 3L7 19l-4 1 1-4z"/></symbol>
<symbol id="i-tool" viewBox="0 0 24 24"><path d="M14.7 6.3a4 4 0 01-5.4 5.4L4 17v3h3l5.3-5.3a4 4 0 015.4-5.4l-2.6 2.6-1.4-1.4z"/></symbol>
<symbol id="i-clip" viewBox="0 0 24 24"><path d="M21.44 11.05l-9.19 9.19a6 6 0 01-8.49-8.49l9.19-9.19a4 4 0 015.66 5.66l-9.2 9.19a2 2 0 01-2.83-2.83l8.49-8.48"/></symbol>
</svg>

<div id="splash" data-t="opening">открываю…</div>
Expand Down Expand Up @@ -121,6 +122,8 @@ <h1 data-t="statsHead">Чтение</h1>
<div id="sheetBody"></div>
<div class="chips" id="sheetChips"></div>
<div class="sheet-foot">
<input id="sheetFile" type="file" multiple hidden>
<button class="attach" id="sheetAttach" title="Прикрепить файл" data-t-title="attachFile" aria-label="Прикрепить файл"><svg class="icon"><use href="#i-clip"/></svg></button>
<input id="sheetInput" placeholder="Уточнить…" data-t-ph="refine" autocomplete="off">
<button class="send" id="sheetSend"><svg class="icon"><use href="#i-send"/></svg></button>
</div>
Expand Down
2 changes: 2 additions & 0 deletions frontend-books/src/i18n.js
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ const RU = {
highlightDeleted: 'Выписка удалена',
ownNote: 'Своя заметка…',
refine: 'Уточнить…',
attachFile: 'Прикрепить файл',
askAboutFragment: 'Напиши вопрос про этот фрагмент',
waitForAnswer: 'Дождись ответа',
thinking: 'думает…',
Expand Down Expand Up @@ -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…',
Expand Down
15 changes: 15 additions & 0 deletions frontend-books/src/library.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
26 changes: 26 additions & 0 deletions frontend-books/src/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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(); });
}
Expand Down
5 changes: 5 additions & 0 deletions frontend-books/src/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand Down
11 changes: 11 additions & 0 deletions frontend-tasks/nginx.conf
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
48 changes: 47 additions & 1 deletion frontend-tasks/src/ChatPane.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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<HTMLDivElement>(null);
const taRef = useRef<HTMLTextAreaElement>(null);
const fileRef = useRef<HTMLInputElement>(null);

useEffect(() => {
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: "smooth" });
Expand All @@ -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 (
<button className="chat-rail" onClick={onToggle} aria-label={t("open_assistant")}>
Expand Down Expand Up @@ -83,6 +112,23 @@ export default function ChatPane({

<div className="chat-foot">
<div className="chat-inputrow">
<input
ref={fileRef}
type="file"
multiple
hidden
onChange={(e) => onAttach(e.target.files)}
/>
<button
className="chat-attach"
type="button"
disabled={busy || uploading}
onClick={() => fileRef.current?.click()}
aria-label={t("attach_file")}
title={t("attach_file")}
>
{uploading ? <Loader2 size={16} className="spin" /> : <Paperclip size={16} strokeWidth={2} />}
</button>
<textarea
ref={taRef}
rows={1}
Expand Down
26 changes: 26 additions & 0 deletions frontend-tasks/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,32 @@ export async function login(password: string): Promise<string> {
return token;
}

/** Загрузка файла в общее хранилище /storage. С parse=1 бэкенд отдаёт готовый
* parse_prompt со свежим путём внутри контейнера — эту строку кладём в чат,
* агент откроет файл через Read и разберёт. */
export interface StorageUploadResult {
ok: boolean;
path: string;
size: number;
parse_prompt?: string;
}

export async function storageUpload(
dir: string, file: File, opts: { parse?: boolean } = {},
): Promise<StorageUploadResult> {
const fd = new FormData();
fd.append("file", file, file.name);
const qs = new URLSearchParams({ dir });
if (opts.parse) qs.set("parse", "1");
const res = await fetch("/storage/upload?" + qs.toString(), {
method: "POST",
headers: getToken() ? { authorization: `Bearer ${getToken()}` } : {},
body: fd,
});
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).detail ?? res.statusText);
return res.json();
}

/** Speech-to-text via the shared backend ASR endpoint (same as the wiki uses). */
export async function transcribeAudio(blob: Blob): Promise<string | null> {
const fd = new FormData();
Expand Down
2 changes: 2 additions & 0 deletions frontend-tasks/src/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,7 @@ const RU = {
clear_context: "Очистить контекст",
collapse: "Свернуть",
send: "Отправить",
attach_file: "Прикрепить файл",
ask_assistant: "Спросите ассистента…",
chat_empty_1: "Спросите ассистента про ваши задачи и планы.",
chat_empty_2: "Напр.: «что у меня на сегодня?», «перенеси отчёт на пятницу».",
Expand Down Expand Up @@ -354,6 +355,7 @@ const EN: Record<keyof typeof RU, string> = {
clear_context: "Clear context",
collapse: "Collapse",
send: "Send",
attach_file: "Attach a file",
ask_assistant: "Ask the assistant…",
chat_empty_1: "Ask the assistant about your tasks and plans.",
chat_empty_2: "E.g. “what's on for today?”, “move the report to Friday”.",
Expand Down
5 changes: 5 additions & 0 deletions frontend-tasks/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -458,6 +458,11 @@ button, input, textarea, select { font-family: inherit; -webkit-tap-highlight-co
.chat-send:hover { filter: brightness(1.06); }
.chat-send:active { transform: scale(.93); }
.chat-send:disabled { opacity: .3; box-shadow: none; cursor: default; transform: none; }
.chat-attach { flex: none; width: 34px; height: 34px; margin-bottom: 3px; border: 0; border-radius: 50%; background: transparent; color: var(--text-3); display: grid; place-items: center; cursor: pointer; transition: background .15s, color .15s; }
.chat-attach:hover { background: var(--bg-3); color: var(--accent-ink); }
.chat-attach:disabled { opacity: .4; cursor: default; background: transparent; }
.chat-attach .spin { animation: chatAttachSpin .8s linear infinite; }
@keyframes chatAttachSpin { to { transform: rotate(360deg); } }

/* Voice input button (mic / waveform / stop / spinner) */
.mic-btn { flex: none; width: 34px; height: 34px; margin-bottom: 3px; border: 0; border-radius: 50%; background: transparent; color: var(--text-3); display: grid; place-items: center; cursor: pointer; position: relative; transition: background .2s, color .2s; }
Expand Down
11 changes: 11 additions & 0 deletions frontend-wiki/src/components/ChatPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,17 @@ export function ChatPane({ onAssistantDone, onLogout, currentPath, currentTitle,
onClearSelection()
}, [send, getContext, onClearSelection, pageOff])

// Мост от «Файлы» (StorageView.dispatchChat) до чата: не тащить пропы через
// WikiApp и не заводить глобальный стор ради одного edge-case'а с загрузкой.
useEffect(() => {
const listener = (e: Event) => {
const detail = (e as CustomEvent<{ text: string }>).detail
if (detail?.text) handleSend(detail.text)
}
window.addEventListener('bender:chat-send', listener)
return () => window.removeEventListener('bender:chat-send', listener)
}, [handleSend])

if (collapsed) {
return (
<button className={styles.rail} onClick={onToggle} aria-label={t('openAssistant')} title={t('openAssistant')}>
Expand Down
36 changes: 36 additions & 0 deletions frontend-wiki/src/components/InputArea.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -74,9 +74,45 @@
transform: none;
}

.attachBtn {
width: 36px;
height: 36px;
border-radius: 50%;
border: none;
background: transparent;
color: var(--text-3);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
margin-bottom: 3px;
transition: background 0.15s ease, color 0.15s ease;
}

.attachBtn:hover {
background: var(--bg-3);
color: var(--accent-ink);
}

.attachBtn:disabled {
opacity: 0.4;
cursor: default;
background: transparent;
}

.spin {
animation: attachSpin 0.8s linear infinite;
}

@keyframes attachSpin {
to { transform: rotate(360deg); }
}

@media (max-width: 760px) {
.footer { padding: 8px 12px 12px; }
/* 16px stops iOS Safari from zooming in when the field is focused */
.textarea { font-size: 16px; min-height: 40px; }
.sendBtn { width: 38px; height: 38px; }
.attachBtn { width: 38px; height: 38px; }
}
Loading