diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ed2f45a..98531e9f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,11 @@ ## [Unreleased] +## [v0.51.338] — 2026-06-09 — Release LB (saved prompts library) + +### Added +- **A saved prompts library in the composer.** A bookmark button in the composer toolbar opens a popup of your saved prompts; click one to drop it into the composer, or save the current input as a new prompt, and delete prompts you no longer want. Prompts persist per Hermes home (`webui/saved_prompts.json`) with server-side caps (8000 chars/prompt, 200 prompts). The affordance is desktop-only — it's hidden on mobile to keep the narrow composer uncluttered. (#3571, @rodboev) + ## [v0.51.337] — 2026-06-09 — Release LA (model-picker keyboard nav + mobile new-chat) ### Added diff --git a/api/routes.py b/api/routes.py index c2694601..c4a09c6c 100644 --- a/api/routes.py +++ b/api/routes.py @@ -5087,6 +5087,30 @@ def _serve_manifest(handler) -> bool: return j(handler, {"error": "not found"}, status=404) +def _saved_prompts_path() -> "Path": + try: + from api.profiles import get_active_hermes_home + return Path(get_active_hermes_home()).expanduser() / "webui" / "saved_prompts.json" + except Exception: + return Path(os.getenv("HERMES_HOME", str(Path.home() / ".hermes"))).expanduser() / "webui" / "saved_prompts.json" + + +def _load_saved_prompts() -> list: + p = _saved_prompts_path() + if not p.exists(): + return [] + try: + return json.loads(p.read_text(encoding="utf-8")) + except Exception: + return [] + + +def _save_saved_prompts(prompts: list) -> None: + p = _saved_prompts_path() + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text(json.dumps(prompts, ensure_ascii=False, indent=2), encoding="utf-8") + + def handle_get(handler, parsed) -> bool: """Handle all GET routes. Returns True if handled, False for 404.""" @@ -5981,6 +6005,9 @@ def handle_get(handler, parsed) -> bool: "other_profile_count": len(all_projects) - len(scoped), }) + if parsed.path == "/api/prompts": + return j(handler, {"prompts": _load_saved_prompts()}) + if parsed.path == "/api/session/export": return _handle_session_export(handler, parsed) @@ -6716,6 +6743,21 @@ def handle_post(handler, parsed) -> bool: bad(handler, str(exc), status=500) return True + if parsed.path == "/api/prompts": + text = str(body.get("text") or "").strip() + label = str(body.get("label") or "").strip() + if not text: + return bad(handler, "text is required") + if len(text) > 8000: + return bad(handler, "text too long (max 8000 chars)") + prompts = _load_saved_prompts() + if len(prompts) >= 200: + return bad(handler, "saved prompts limit reached (max 200)") + new_prompt = {"id": uuid.uuid4().hex[:12], "label": label or text[:60], "text": text, "created_at": time.time()} + prompts.append(new_prompt) + _save_saved_prompts(prompts) + return j(handler, {"ok": True, "prompt": new_prompt}) + if parsed.path == "/api/session/new": try: workspace = str(resolve_trusted_workspace(body.get("workspace"))) if body.get("workspace") else None @@ -8581,6 +8623,14 @@ def handle_delete(handler, parsed) -> bool: if parsed.path.startswith("/api/mcp/servers/"): name = parsed.path[len("/api/mcp/servers/"):] return _handle_mcp_server_delete(handler, name) + if parsed.path == "/api/prompts": + pid = str(body.get("id") or "").strip() + if not pid: + return bad(handler, "id is required") + prompts = [p for p in _load_saved_prompts() if p.get("id") != pid] + _save_saved_prompts(prompts) + return j(handler, {"ok": True}) + if parsed.path.startswith("/api/kanban/"): from api.kanban_bridge import handle_kanban_delete diff --git a/static/i18n.js b/static/i18n.js index 7416fc7a..8725dc6c 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -48,6 +48,11 @@ const LOCALES = { selected_text_reply: 'Reply with selection', selected_text_reply_title: 'Append selected chat text as quoted context', selected_text_reply_appended: 'Selected text added to composer', + saved_prompts_empty: 'No saved prompts yet.', + saved_prompts_save_current: 'Save current input as prompt', + saved_prompts_saved: 'Prompt saved', + saved_prompts_delete: 'Delete prompt', + saved_prompts_empty_input: 'Type a prompt to save first', diff_loading: 'Loading diff', diff_error: 'Could not load patch file', @@ -1433,6 +1438,11 @@ const LOCALES = { selected_text_reply: 'Rispondi con selezione', selected_text_reply_title: 'Aggiungi il testo della chat selezionato come contesto citato', selected_text_reply_appended: 'Testo selezionato aggiunto al compositore', + saved_prompts_empty: 'Nessun prompt salvato.', + saved_prompts_save_current: 'Salva input corrente come prompt', + saved_prompts_saved: 'Prompt salvato', + saved_prompts_delete: 'Elimina prompt', + saved_prompts_empty_input: 'Digita un prompt da salvare prima', diff_loading: 'Caricamento diff', diff_error: 'Impossibile caricare il file patch', @@ -2810,6 +2820,11 @@ const LOCALES = { selected_text_reply: '選択範囲で返信', selected_text_reply_title: '選択したチャットテキストを引用コンテキストとして追加', selected_text_reply_appended: '選択したテキストを入力欄に追加しました', + saved_prompts_empty: '保存済みプロンプトはありません。', + saved_prompts_save_current: '現在の入力をプロンプトとして保存', + saved_prompts_saved: 'プロンプトを保存しました', + saved_prompts_delete: 'プロンプトを削除', + saved_prompts_empty_input: '保存するプロンプトを先に入力してください', diff_loading: '差分を読み込み中', diff_error: 'パッチファイルを読み込めませんでした', @@ -4189,6 +4204,11 @@ const LOCALES = { selected_text_reply: 'Ответить с выделенным', selected_text_reply_title: 'Добавить выделенный текст чата как цитируемый контекст', selected_text_reply_appended: 'Выделенный текст добавлен в поле ввода', + saved_prompts_empty: 'Сохранённых подсказок пока нет.', + saved_prompts_save_current: 'Сохранить текущий ввод как подсказку', + saved_prompts_saved: 'Подсказка сохранена', + saved_prompts_delete: 'Удалить подсказку', + saved_prompts_empty_input: 'Сначала введите подсказку для сохранения', diff_loading: 'Загрузка diff', diff_error: 'Не удалось загрузить файл патча', @@ -5498,6 +5518,11 @@ const LOCALES = { selected_text_reply: 'Responder con selección', selected_text_reply_title: 'Añadir el texto del chat seleccionado como contexto citado', selected_text_reply_appended: 'Texto seleccionado añadido al compositor', + saved_prompts_empty: 'No hay indicaciones guardadas todavía.', + saved_prompts_save_current: 'Guardar entrada actual como indicación', + saved_prompts_saved: 'Indicación guardada', + saved_prompts_delete: 'Eliminar indicación', + saved_prompts_empty_input: 'Escribe una indicación para guardar primero', diff_loading: 'Cargando diff', diff_error: 'No se pudo cargar el archivo de parche', @@ -6811,6 +6836,11 @@ const LOCALES = { selected_text_reply: 'Mit Auswahl antworten', selected_text_reply_title: 'Ausgewählten Chattext als Zitatkontext anhängen', selected_text_reply_appended: 'Auswahl zum Composer hinzugefügt', + saved_prompts_empty: 'Noch keine gespeicherten Eingabeaufforderungen.', + saved_prompts_save_current: 'Aktuelle Eingabe als Eingabeaufforderung speichern', + saved_prompts_saved: 'Eingabeaufforderung gespeichert', + saved_prompts_delete: 'Eingabeaufforderung löschen', + saved_prompts_empty_input: 'Gib zuerst eine Eingabeaufforderung ein', diff_loading: 'Lade Diff', diff_error: 'Patch-Datei konnte nicht geladen werden', @@ -8128,6 +8158,11 @@ const LOCALES = { selected_text_reply: '用所选内容回复', selected_text_reply_title: '将所选聊天文本作为引用上下文追加', selected_text_reply_appended: '已将所选文本添加到输入框', + saved_prompts_empty: '暂无保存的提示。', + saved_prompts_save_current: '将当前输入保存为提示', + saved_prompts_saved: '提示已保存', + saved_prompts_delete: '删除提示', + saved_prompts_empty_input: '请先输入要保存的提示', diff_loading: '加载 diff', diff_error: '无法加载 patch 文件', @@ -9453,6 +9488,11 @@ const LOCALES = { selected_text_reply: '用所選內容回覆', selected_text_reply_title: '將所選聊天文字附加為引用脈絡', selected_text_reply_appended: '已將所選文字加入輸入框', + saved_prompts_empty: '尚無已儲存的提示。', + saved_prompts_save_current: '將目前輸入儲存為提示', + saved_prompts_saved: '提示已儲存', + saved_prompts_delete: '刪除提示', + saved_prompts_empty_input: '請先輸入要儲存的提示', diff_loading: '載入 diff', diff_error: '無法載入 patch 檔案', @@ -10817,6 +10857,11 @@ const LOCALES = { selected_text_reply: 'Responder com seleção', selected_text_reply_title: 'Anexar o texto selecionado do chat como contexto citado', selected_text_reply_appended: 'Texto selecionado adicionado ao compositor', + saved_prompts_empty: 'Nenhum prompt salvo ainda.', + saved_prompts_save_current: 'Salvar entrada atual como prompt', + saved_prompts_saved: 'Prompt salvo', + saved_prompts_delete: 'Excluir prompt', + saved_prompts_empty_input: 'Digite um prompt para salvar primeiro', you: 'Você', thinking: 'Pensando', expand_all: 'Expandir tudo', @@ -12010,6 +12055,11 @@ const LOCALES = { selected_text_reply: '선택 항목으로 답장', selected_text_reply_title: '선택한 채팅 텍스트를 인용 컨텍스트로 추가', selected_text_reply_appended: '선택한 텍스트가 입력창에 추가되었습니다', + saved_prompts_empty: '저장된 프롬프트가 없습니다.', + saved_prompts_save_current: '현재 입력을 프롬프트로 저장', + saved_prompts_saved: '프롬프트가 저장되었습니다', + saved_prompts_delete: '프롬프트 삭제', + saved_prompts_empty_input: '먼저 저장할 프롬프트를 입력하세요', diff_loading: 'diff 불러오는 중', diff_error: '패치 파일을 로드할 수 없습니다', @@ -13386,6 +13436,11 @@ const LOCALES = { selected_text_reply: 'Répondre avec la sélection', selected_text_reply_title: 'Ajouter le texte sélectionné du chat comme contexte cité', selected_text_reply_appended: 'Texte sélectionné ajouté au compositeur', + saved_prompts_empty: 'Aucune invite sauvegardée pour l\'instant.', + saved_prompts_save_current: 'Sauvegarder l\'entrée actuelle comme invite', + saved_prompts_saved: 'Invite sauvegardée', + saved_prompts_delete: 'Supprimer l\'invite', + saved_prompts_empty_input: 'Tapez d\'abord une invite à sauvegarder', diff_loading: 'Chargement des différences', diff_error: 'Impossible de charger le fichier de correctif', diff_too_large: 'Fichier de correctif trop volumineux pour être affiché en ligne', @@ -14699,6 +14754,11 @@ const LOCALES = { selected_text_reply: 'Seçimle yanıtla', selected_text_reply_title: 'Seçilen sohbet metnini alıntılanan bağlam olarak ekle', selected_text_reply_appended: 'Seçilen metin besteciye eklendi', + saved_prompts_empty: 'Henüz kaydedilmiş istem yok.', + saved_prompts_save_current: 'Mevcut girişi istem olarak kaydet', + saved_prompts_saved: 'İstem kaydedildi', + saved_prompts_delete: 'İstemi sil', + saved_prompts_empty_input: 'Önce kaydedilecek bir istem girin', diff_loading: 'Fark yükleniyor', diff_error: 'Yama dosyası yüklenemedi', @@ -16069,6 +16129,11 @@ const LOCALES = { selected_text_reply: 'Odpowiedz z zaznaczeniem', selected_text_reply_title: 'Dodaj zaznaczony tekst czatu jako cytowany kontekst', selected_text_reply_appended: 'Dodano zaznaczony tekst do pola wprowadzania', + saved_prompts_empty: 'Brak zapisanych promptów.', + saved_prompts_save_current: 'Zapisz bieżący tekst jako prompt', + saved_prompts_saved: 'Prompt zapisany', + saved_prompts_delete: 'Usuń prompt', + saved_prompts_empty_input: 'Najpierw wpisz prompt do zapisania', diff_loading: 'Ładowanie zmian (diff)', diff_error: 'Nie można załadować pliku poprawki', diff --git a/static/index.html b/static/index.html index 611a1a8f..0b00cb16 100644 --- a/static/index.html +++ b/static/index.html @@ -573,6 +573,9 @@ + +