1025 lines
39 KiB
Python
1025 lines
39 KiB
Python
import asyncio
|
|
import sys
|
|
import warnings
|
|
import os
|
|
import time
|
|
|
|
if sys.platform == "win32":
|
|
# Avoid noisy Proactor transport resets on long-running Windows sessions.
|
|
# Python 3.14+ marks this policy deprecated, so probe/set under warning suppression.
|
|
with warnings.catch_warnings():
|
|
warnings.simplefilter("ignore", DeprecationWarning)
|
|
selector_policy = getattr(asyncio, "WindowsSelectorEventLoopPolicy", None)
|
|
if selector_policy is not None:
|
|
asyncio.set_event_loop_policy(selector_policy())
|
|
|
|
from nicegui import ui, app, run
|
|
from typing import cast
|
|
from pathlib import Path
|
|
from functools import partial
|
|
from datetime import datetime
|
|
|
|
# Add project root to sys.path to allow for absolute imports
|
|
# This is now handled by the run_desktop.py and cli/main.py scripts, but good for linters.
|
|
sys.path.append(str(Path(__file__).resolve().parent.parent.parent))
|
|
from journal.core.entry import (
|
|
create_daily_entry,
|
|
create_deep_entry,
|
|
create_fragment_entry,
|
|
create_recovery_entry,
|
|
)
|
|
from journal.core.storage import (
|
|
save_entry_content,
|
|
load_all_vaults,
|
|
rebuild_all_vaults,
|
|
save_current_month_vault,
|
|
initialize_vault,
|
|
clear_data_directory,
|
|
list_journal_files as list_journal_files_backend,
|
|
load_entry_content,
|
|
)
|
|
from journal.core.config import (
|
|
DATA_DIR,
|
|
VAULT_DIR,
|
|
BACKEND_MODE,
|
|
CSHARP_SIDECAR_PATH,
|
|
NLP_BACKEND,
|
|
SPEECH_RECOGNITION_ENGINE,
|
|
WHISPER_MODEL_SIZE,
|
|
FASTER_WHISPER_DEVICE,
|
|
FASTER_WHISPER_COMPUTE_TYPE,
|
|
LLAMA_CPP_URL,
|
|
LLAMA_CPP_MODEL,
|
|
LLAMA_CPP_TIMEOUT,
|
|
EMBEDDING_API_URL,
|
|
EMBEDDING_MODEL_NAME,
|
|
CLOUDAI_API_URL,
|
|
CLOUDAI_API_KEY,
|
|
CLOUDAI_TIMEOUT,
|
|
MODEL_CONTEXT_TOKENS,
|
|
CHUNK_TOKEN_BUDGET,
|
|
)
|
|
from journal.core.models import JournalEntry
|
|
from journal.ai.bridge import summarize_entry, summarize_all_entries
|
|
from journal.ai.chat import get_cloud_ai_response
|
|
from journal.ui.components.speech import speech_to_text
|
|
from journal.ui.components.calendar import calendar_view
|
|
from journal.ui.components.editor import markdown_editor
|
|
from journal.ui.components.settings import settings_dialog
|
|
|
|
ui.dark_mode = True
|
|
_ = ui.add_head_html(
|
|
"""
|
|
<style>
|
|
.journal-drawer {
|
|
width: clamp(18rem, 30vw, 22rem) !important;
|
|
max-width: 94vw;
|
|
}
|
|
.journal-sidebar {
|
|
height: 100%;
|
|
overflow-y: auto;
|
|
overflow-x: hidden;
|
|
padding-bottom: 1rem;
|
|
}
|
|
.journal-main-content {
|
|
height: calc(100vh - 64px);
|
|
overflow: hidden;
|
|
}
|
|
.journal-root {
|
|
width: 100%;
|
|
height: 100%;
|
|
min-height: 0;
|
|
overflow: hidden;
|
|
}
|
|
.journal-header-title {
|
|
font-size: 1.55rem;
|
|
font-weight: 700;
|
|
max-width: 60vw;
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
.journal-tab-panels,
|
|
.journal-tab-panel {
|
|
min-height: 0;
|
|
}
|
|
.journal-tab-panels {
|
|
overflow: hidden;
|
|
}
|
|
.journal-tab-panel {
|
|
overflow: hidden;
|
|
}
|
|
.journal-action-row {
|
|
flex-wrap: wrap;
|
|
}
|
|
.journal-action-row .q-btn {
|
|
flex: 1 1 10rem;
|
|
}
|
|
.journal-calendar-card {
|
|
width: 100%;
|
|
max-width: 100%;
|
|
overflow-x: auto;
|
|
overflow-y: hidden;
|
|
}
|
|
.journal-calendar {
|
|
max-width: 100%;
|
|
}
|
|
.journal-sidebar .q-date {
|
|
min-width: 17.5rem;
|
|
max-width: 100%;
|
|
}
|
|
.journal-editor-wrapper {
|
|
flex: 1 1 auto;
|
|
min-height: 0;
|
|
overflow: hidden;
|
|
}
|
|
.journal-editor {
|
|
height: 100%;
|
|
min-height: 0;
|
|
}
|
|
.journal-edit-actions {
|
|
flex: 0 0 auto;
|
|
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
|
}
|
|
.journal-markdown-editor .q-field__native,
|
|
.journal-markdown-editor .q-field__input,
|
|
.journal-markdown-editor textarea {
|
|
color: #e8f1ff !important;
|
|
caret-color: #e8f1ff !important;
|
|
font-size: 1.04rem;
|
|
line-height: 1.7;
|
|
}
|
|
.journal-markdown-editor .q-field__native::placeholder,
|
|
.journal-markdown-editor .q-field__input::placeholder,
|
|
.journal-markdown-editor textarea::placeholder {
|
|
color: #9ab0d6 !important;
|
|
opacity: 1;
|
|
}
|
|
.journal-markdown-editor .q-field__label {
|
|
color: #b9c8e6 !important;
|
|
}
|
|
.journal-markdown-editor textarea,
|
|
.journal-large-textarea textarea {
|
|
min-height: 58vh !important;
|
|
height: 58vh !important;
|
|
resize: vertical !important;
|
|
}
|
|
@media (max-width: 768px) {
|
|
.journal-main-content {
|
|
height: calc(100vh - 56px);
|
|
}
|
|
.journal-header-title {
|
|
font-size: 1.1rem;
|
|
max-width: 56vw;
|
|
}
|
|
.journal-markdown-editor textarea,
|
|
.journal-large-textarea textarea {
|
|
min-height: 42vh !important;
|
|
height: 42vh !important;
|
|
}
|
|
}
|
|
</style>
|
|
""",
|
|
shared=True,
|
|
)
|
|
|
|
|
|
# Global variables
|
|
selected_file_name: str = ""
|
|
drawer: ui.left_drawer | None = None
|
|
main_tab: ui.tabs | None = None
|
|
edit_tab: ui.tab | None = None
|
|
ai_tab: ui.tab | None = None
|
|
new_tab: ui.tab | None = None
|
|
entry_content_box: ui.textarea | None = None
|
|
analysis_box: ui.textarea | None = None
|
|
new_entry_box: ui.textarea | None = None
|
|
file_map: dict[str, str] = {}
|
|
vault_password: str | None = None # Global to store the password for the shutdown hook
|
|
sidebar_content: ui.column | None = None
|
|
content_container: ui.column | None = None
|
|
# Must be created inside page/layout scope for NiceGUI.
|
|
all_analysis_dialog: ui.dialog | None = None
|
|
vault_load_lock = asyncio.Lock()
|
|
_sidebar_retry_scheduled = False
|
|
|
|
_USER_SETTINGS_DEFAULTS: dict[str, str] = {
|
|
"backend_mode": BACKEND_MODE,
|
|
"csharp_sidecar_path": CSHARP_SIDECAR_PATH,
|
|
"nlp_backend": NLP_BACKEND,
|
|
"speech_engine": SPEECH_RECOGNITION_ENGINE,
|
|
"whisper_model": WHISPER_MODEL_SIZE,
|
|
"faster_whisper_device": FASTER_WHISPER_DEVICE,
|
|
"faster_whisper_compute_type": FASTER_WHISPER_COMPUTE_TYPE,
|
|
"llama_cpp_url": LLAMA_CPP_URL,
|
|
"llama_cpp_model": LLAMA_CPP_MODEL,
|
|
"llama_cpp_timeout": str(LLAMA_CPP_TIMEOUT),
|
|
"embedding_api_url": EMBEDDING_API_URL,
|
|
"embedding_model_name": EMBEDDING_MODEL_NAME,
|
|
"cloudai_api_url": CLOUDAI_API_URL,
|
|
"cloudai_api_key": CLOUDAI_API_KEY,
|
|
"cloudai_timeout": str(CLOUDAI_TIMEOUT),
|
|
"model_context_tokens": str(MODEL_CONTEXT_TOKENS),
|
|
"chunk_token_budget": str(CHUNK_TOKEN_BUDGET),
|
|
}
|
|
|
|
_USER_SETTINGS_ENV_KEYS: dict[str, str] = {
|
|
"backend_mode": "JOURNAL_BACKEND_MODE",
|
|
"csharp_sidecar_path": "JOURNAL_CSHARP_SIDECAR_PATH",
|
|
"nlp_backend": "JOURNAL_NLP_BACKEND",
|
|
"speech_engine": "JOURNAL_SPEECH_ENGINE",
|
|
"whisper_model": "JOURNAL_WHISPER_MODEL",
|
|
"faster_whisper_device": "JOURNAL_FASTER_WHISPER_DEVICE",
|
|
"faster_whisper_compute_type": "JOURNAL_FASTER_WHISPER_COMPUTE_TYPE",
|
|
"llama_cpp_url": "JOURNAL_LLAMA_CPP_URL",
|
|
"llama_cpp_model": "JOURNAL_LLAMA_CPP_MODEL",
|
|
"llama_cpp_timeout": "JOURNAL_LLAMA_CPP_TIMEOUT",
|
|
"embedding_api_url": "JOURNAL_EMBEDDING_API_URL",
|
|
"embedding_model_name": "JOURNAL_EMBEDDING_MODEL_NAME",
|
|
"cloudai_api_url": "JOURNAL_CLOUDAI_API_URL",
|
|
"cloudai_api_key": "JOURNAL_CLOUDAI_API_KEY",
|
|
"cloudai_timeout": "JOURNAL_CLOUDAI_TIMEOUT",
|
|
"model_context_tokens": "JOURNAL_MODEL_CONTEXT_TOKENS",
|
|
"chunk_token_budget": "JOURNAL_CHUNK_TOKEN_BUDGET",
|
|
}
|
|
|
|
|
|
def _initialize_runtime_user_settings() -> None:
|
|
for key, value in _USER_SETTINGS_DEFAULTS.items():
|
|
app.storage.user.setdefault(key, value)
|
|
|
|
for key, env_key in _USER_SETTINGS_ENV_KEYS.items():
|
|
value = app.storage.user.get(key, _USER_SETTINGS_DEFAULTS.get(key, ""))
|
|
os.environ[env_key] = str(value).strip()
|
|
|
|
|
|
def _is_client_active(client: object | None) -> bool:
|
|
return bool(client is not None and getattr(client, "has_socket_connection", False))
|
|
|
|
|
|
def _is_deleted_client_error(error: RuntimeError) -> bool:
|
|
text = str(error).lower()
|
|
return "client this element belongs to has been deleted" in text or "parent slot of the element has been deleted" in text
|
|
|
|
|
|
def _render_authenticated_layout() -> bool:
|
|
global content_container
|
|
if content_container is None:
|
|
print("Authenticated layout skipped: content container is not ready.")
|
|
return False
|
|
try:
|
|
content_container.clear()
|
|
with content_container:
|
|
journal_ui_layout()
|
|
if not update_sidebar():
|
|
_schedule_sidebar_retry("layout render")
|
|
return True
|
|
except RuntimeError as error:
|
|
if _is_deleted_client_error(error):
|
|
print("Skipping layout update because the client was deleted.")
|
|
_schedule_sidebar_retry("deleted layout client")
|
|
return False
|
|
raise
|
|
|
|
|
|
def _has_runtime_auth() -> bool:
|
|
return cast(bool, app.storage.user.get("authenticated", False)) and bool(vault_password)
|
|
|
|
|
|
def _schedule_sidebar_retry(reason: str, delay_seconds: float = 0.45) -> None:
|
|
global _sidebar_retry_scheduled
|
|
if _sidebar_retry_scheduled:
|
|
return
|
|
|
|
try:
|
|
loop = asyncio.get_running_loop()
|
|
except RuntimeError:
|
|
return
|
|
|
|
_sidebar_retry_scheduled = True
|
|
|
|
async def _retry() -> None:
|
|
global _sidebar_retry_scheduled
|
|
try:
|
|
await asyncio.sleep(delay_seconds)
|
|
if not _has_runtime_auth():
|
|
return
|
|
if update_sidebar():
|
|
return
|
|
await asyncio.sleep(0.9)
|
|
update_sidebar()
|
|
finally:
|
|
_sidebar_retry_scheduled = False
|
|
|
|
print(f"Scheduling sidebar retry ({reason}).")
|
|
loop.create_task(_retry())
|
|
|
|
|
|
def list_journal_files():
|
|
files = list_journal_files_backend()
|
|
print(f"list_journal_files found: {[f[0] for f in files]}")
|
|
return files
|
|
|
|
|
|
def save_entry(content: str, mode: str, password: str | None) -> str:
|
|
# Save to the DATA_DIR, then trigger a vault save
|
|
save_entry_content(content, mode=mode)
|
|
if password:
|
|
save_current_month_vault(password)
|
|
return "Entry saved."
|
|
|
|
|
|
def load_entry(file_path: str) -> str:
|
|
try:
|
|
return load_entry_content(file_path)
|
|
except Exception as e:
|
|
return f"Error loading file: {e}"
|
|
|
|
|
|
def save_existing_entry(file_path: str, content: str, password: str | None) -> str:
|
|
try:
|
|
# Existing-entry editing should persist exactly what the user sees in editor.
|
|
save_entry_content(content, file_path=Path(file_path), mode="Overwrite")
|
|
if password:
|
|
save_current_month_vault(password)
|
|
return f"Saved changes to {Path(file_path).name}"
|
|
except Exception as e:
|
|
return f"Error saving file: {e}"
|
|
|
|
|
|
def analyze_entry(file_path: str) -> str:
|
|
try:
|
|
entry = JournalEntry(date=Path(file_path).stem, raw_content=load_entry_content(file_path))
|
|
analysis = summarize_entry(entry)
|
|
return analysis
|
|
except Exception as e:
|
|
return f"Error analyzing entry: {e}"
|
|
|
|
|
|
def analyze_all_entries() -> str:
|
|
try:
|
|
journal_files = list_journal_files()
|
|
if not journal_files:
|
|
return "No journal files found."
|
|
entries = [
|
|
JournalEntry(date=Path(path).stem, raw_content=load_entry_content(path))
|
|
for _, path in journal_files
|
|
]
|
|
analysis = summarize_all_entries(entries)
|
|
return analysis
|
|
except Exception as e:
|
|
return f"Error analyzing all entries: {e}"
|
|
|
|
|
|
def update_sidebar() -> bool:
|
|
global \
|
|
file_map, \
|
|
drawer, \
|
|
main_tab, \
|
|
edit_tab, \
|
|
entry_content_box, \
|
|
analysis_box, \
|
|
sidebar_content
|
|
print("update_sidebar called.")
|
|
if not sidebar_content:
|
|
print("Sidebar content is not ready yet; retry needed.")
|
|
return False
|
|
|
|
try:
|
|
sidebar_content.clear()
|
|
except RuntimeError as error:
|
|
if _is_deleted_client_error(error):
|
|
print("Skipping sidebar update because the client was deleted.")
|
|
return False
|
|
raise
|
|
try:
|
|
with sidebar_content:
|
|
_ = ui.label("📅 Calendar").classes("text-md font-bold mb-4")
|
|
|
|
def on_date_select(date: str | None):
|
|
global selected_file_name
|
|
if not date:
|
|
return
|
|
|
|
selected_file_name = f"{date}.md"
|
|
if main_tab is not None and edit_tab is not None:
|
|
main_tab.set_value(edit_tab)
|
|
selected_path = file_map.get(selected_file_name)
|
|
if selected_path is None:
|
|
if entry_content_box is not None:
|
|
entry_content_box.set_value("")
|
|
if analysis_box is not None:
|
|
analysis_box.set_value("")
|
|
ui.notify(
|
|
f"No entry exists for {date}. Use New Entry to create one.",
|
|
type="info",
|
|
)
|
|
if drawer:
|
|
drawer.set_value(False)
|
|
return
|
|
if entry_content_box is not None:
|
|
entry_content_box.set_value(load_entry(selected_path))
|
|
if analysis_box is not None:
|
|
analysis_box.set_value("")
|
|
if drawer:
|
|
drawer.set_value(False)
|
|
|
|
calendar_view(on_select=on_date_select)
|
|
_ = ui.separator().classes("my-4")
|
|
_ = ui.label("📁 Journal Files").classes("text-lg font-bold mb-4")
|
|
|
|
files = list_journal_files()
|
|
file_names = [f[0] for f in files]
|
|
file_map = dict(files)
|
|
print(f"file_map in update_sidebar: {file_map}")
|
|
|
|
def on_file_select(fname: str):
|
|
global selected_file_name
|
|
selected_file_name = fname
|
|
selected_path = file_map.get(fname)
|
|
if selected_path is None:
|
|
ui.notify("Selected file is no longer available.", type="warning")
|
|
return
|
|
if main_tab is not None and edit_tab is not None:
|
|
main_tab.set_value(edit_tab)
|
|
if entry_content_box is not None:
|
|
entry_content_box.set_value(load_entry(selected_path))
|
|
if analysis_box is not None:
|
|
analysis_box.set_value("")
|
|
if drawer:
|
|
drawer.set_value(False)
|
|
|
|
for fname in file_names:
|
|
_ = (
|
|
ui.button(fname, on_click=partial(on_file_select, fname))
|
|
.classes("w-full justify-start mb-1")
|
|
.props("flat")
|
|
)
|
|
|
|
_ = ui.separator().classes("my-4")
|
|
|
|
def open_new_tab():
|
|
if main_tab is not None and new_tab is not None:
|
|
main_tab.set_value(new_tab)
|
|
if drawer:
|
|
drawer.set_value(False)
|
|
|
|
_ = (
|
|
ui.button("New Entry", on_click=open_new_tab)
|
|
.props("color=primary")
|
|
.classes("w-full mb-2")
|
|
)
|
|
|
|
def open_all_analysis_dialog():
|
|
if all_analysis_dialog is not None:
|
|
all_analysis_dialog.open()
|
|
|
|
_ = (
|
|
ui.button("Analyze All Entries", on_click=open_all_analysis_dialog)
|
|
.props("color=accent")
|
|
.classes("w-full")
|
|
)
|
|
except RuntimeError as error:
|
|
if _is_deleted_client_error(error):
|
|
print("Sidebar update aborted because client elements were deleted.")
|
|
return False
|
|
raise
|
|
|
|
return True
|
|
|
|
|
|
# --- Main UI Layout Function (Inner Content) ---
|
|
def journal_ui_layout():
|
|
global \
|
|
main_tab, \
|
|
edit_tab, \
|
|
ai_tab, \
|
|
new_tab, \
|
|
entry_content_box, \
|
|
analysis_box, \
|
|
new_entry_box
|
|
|
|
# --- Main Content - Full Screen ---
|
|
with ui.element("div").classes("journal-root").style(
|
|
"padding: 0; margin: 0;"
|
|
):
|
|
with ui.element("div").style(
|
|
"width: 100%; height: 100%; padding: clamp(10px, 2vw, 16px); box-sizing: border-box; display: flex; flex-direction: column; min-height: 0;"
|
|
):
|
|
# Tabs
|
|
with ui.tabs().props("outside-arrows mobile-arrows inline-label").classes("w-full mb-2") as main_tab_instance:
|
|
global main_tab
|
|
main_tab = main_tab_instance
|
|
edit_tab = ui.tab("Edit Entry")
|
|
ai_tab = ui.tab("AI Analysis")
|
|
chat_tab = ui.tab("AI Chat")
|
|
new_tab = ui.tab("New Entry")
|
|
speech_tab = ui.tab("Speech to Text")
|
|
|
|
# Tab Panels - Full Height
|
|
with ui.tab_panels(main_tab, value=edit_tab).classes(
|
|
"w-full flex-grow bg-gray-800 journal-tab-panels"
|
|
):
|
|
# --- Edit Entry Tab ---
|
|
with ui.tab_panel(edit_tab).style(
|
|
"flex: 1; display: flex; flex-direction: column; padding: 0;"
|
|
):
|
|
with ui.column().classes("journal-editor-wrapper w-full"):
|
|
entry_content_box = markdown_editor().classes("w-full journal-editor")
|
|
with ui.row().classes("gap-2 p-3 bg-gray-900 journal-action-row journal-edit-actions").style("flex-shrink: 0;"):
|
|
|
|
async def save_selected_wrapper():
|
|
client = ui.context.client
|
|
if not selected_file_name or selected_file_name not in file_map:
|
|
ui.notify("No entry selected to save.", type="warning")
|
|
return
|
|
if entry_content_box is None:
|
|
ui.notify("Editor is not ready.", type="warning")
|
|
return
|
|
|
|
content = cast(str, entry_content_box.value or "")
|
|
password = vault_password
|
|
if not content.strip():
|
|
ui.notify("Entry content is empty.", type="warning")
|
|
return
|
|
|
|
try:
|
|
msg = await run.io_bound(
|
|
save_existing_entry,
|
|
file_map[selected_file_name],
|
|
content,
|
|
password,
|
|
)
|
|
except Exception as error:
|
|
if _is_client_active(client):
|
|
ui.notify(f"Save failed: {error}", type="negative")
|
|
return
|
|
|
|
if not _is_client_active(client):
|
|
return
|
|
|
|
if msg.startswith("Error"):
|
|
ui.notify(msg, type="negative")
|
|
else:
|
|
ui.notify(msg, type="positive")
|
|
if not update_sidebar():
|
|
_schedule_sidebar_retry("existing entry save")
|
|
|
|
async def reload_selected_wrapper():
|
|
client = ui.context.client
|
|
if not selected_file_name or selected_file_name not in file_map:
|
|
ui.notify("No entry selected to reload.", type="warning")
|
|
return
|
|
if entry_content_box is None:
|
|
ui.notify("Editor is not ready.", type="warning")
|
|
return
|
|
try:
|
|
content = await run.io_bound(load_entry, file_map[selected_file_name])
|
|
except Exception as error:
|
|
if _is_client_active(client):
|
|
ui.notify(f"Reload failed: {error}", type="negative")
|
|
return
|
|
if not _is_client_active(client):
|
|
return
|
|
entry_content_box.set_value(content)
|
|
ui.notify("Entry reloaded from disk.", type="info")
|
|
|
|
_ = ui.button("Save Changes", on_click=save_selected_wrapper).props(
|
|
"color=primary"
|
|
)
|
|
_ = ui.button("Reload From Disk", on_click=reload_selected_wrapper).props(
|
|
"color=secondary flat"
|
|
)
|
|
|
|
# --- AI Analysis Tab ---
|
|
with ui.tab_panel(ai_tab).classes("journal-tab-panel").style(
|
|
"flex: 1; display: flex; flex-direction: column; padding: 16px;"
|
|
):
|
|
_ = (
|
|
ui.label("AI Analysis")
|
|
.classes("text-xl font-bold mb-4")
|
|
.style("flex-shrink: 0;")
|
|
)
|
|
|
|
analysis_box = (
|
|
ui.textarea(
|
|
label="Analysis Results",
|
|
placeholder='Click "Analyze Entry" to get AI analysis of the selected journal entry...',
|
|
)
|
|
.props("readonly outlined")
|
|
.classes("journal-large-textarea")
|
|
.style("flex: 1; width: 100%; min-height: 0;")
|
|
)
|
|
|
|
with ui.row().classes("gap-4 mt-4 journal-action-row").style("flex-shrink: 0;"):
|
|
|
|
async def analyze_selected_wrapper():
|
|
client = ui.context.client
|
|
if (
|
|
selected_file_name
|
|
and selected_file_name in file_map
|
|
and analysis_box is not None
|
|
):
|
|
ui.notify("Analyzing entry...", type="info")
|
|
analysis = await run.io_bound(
|
|
analyze_entry, file_map[selected_file_name]
|
|
)
|
|
if not _is_client_active(client):
|
|
return
|
|
analysis_box.set_value(analysis)
|
|
ui.notify("Analysis complete!", type="positive")
|
|
else:
|
|
ui.notify("No file selected", type="warning")
|
|
|
|
_ = ui.button(
|
|
"Analyze Entry", on_click=analyze_selected_wrapper
|
|
).props("color=primary").style("min-width: 10rem;")
|
|
|
|
# --- AI Chat Tab ---
|
|
with ui.tab_panel(chat_tab).classes("journal-tab-panel").style(
|
|
"flex: 1; display: flex; flex-direction: column; padding: 16px;"
|
|
):
|
|
_ = (
|
|
ui.label("AI Chat")
|
|
.classes("text-xl font-bold mb-4")
|
|
.style("flex-shrink: 0;")
|
|
)
|
|
|
|
chat_output = (
|
|
ui.textarea(
|
|
label="Chat History",
|
|
placeholder="Chat with the AI...",
|
|
)
|
|
.props("readonly outlined")
|
|
.classes("journal-large-textarea")
|
|
.style("flex: 1; width: 100%; min-height: 0;")
|
|
)
|
|
|
|
chat_input = ui.input(placeholder="Enter your message").style(
|
|
"width: 100%;"
|
|
)
|
|
|
|
async def send_chat_message():
|
|
client = ui.context.client
|
|
chat_value = cast(str | None, chat_input.value)
|
|
if chat_value and chat_output:
|
|
prompt = chat_value
|
|
chat_input.value = ""
|
|
response = await run.io_bound(get_cloud_ai_response, prompt)
|
|
if not _is_client_active(client):
|
|
return
|
|
current_chat = str(
|
|
cast(str | None, chat_output.value) or ""
|
|
)
|
|
chat_output.value = (
|
|
current_chat + f"> {prompt}\n{response}\n"
|
|
)
|
|
|
|
_ = chat_input.on("keydown.enter", send_chat_message)
|
|
|
|
# --- New Entry Tab ---
|
|
with ui.tab_panel(new_tab).classes("journal-tab-panel").style(
|
|
"flex: 1; display: flex; flex-direction: column; padding: 16px;"
|
|
):
|
|
_ = (
|
|
ui.label("Create New Entry")
|
|
.classes("text-xl font-bold mb-4")
|
|
.style("flex-shrink: 0;")
|
|
)
|
|
|
|
mode = (
|
|
ui.select(
|
|
["Daily", "Deep Recovery", "Fragment", "Deep Entry"],
|
|
label="Entry Type",
|
|
value="Daily",
|
|
)
|
|
.classes("mb-4 w-full max-w-sm")
|
|
.style("flex-shrink: 0;")
|
|
)
|
|
|
|
with ui.column().classes("w-full flex-grow min-h-0"):
|
|
new_entry_box = markdown_editor().classes("w-full h-full")
|
|
|
|
def update_new_entry_box_on_mode_change():
|
|
if new_entry_box is None:
|
|
return # Can't do anything if the box doesn't exist yet
|
|
|
|
mode_val = cast(str | None, mode.value)
|
|
today_file_path = (
|
|
DATA_DIR / f"{datetime.now().strftime('%Y-%m-%d')}.md"
|
|
)
|
|
if (
|
|
mode_val
|
|
and mode_val in ["Daily", "Deep Recovery", "Deep Entry"]
|
|
and today_file_path.exists()
|
|
):
|
|
try:
|
|
new_entry_box.set_value(load_entry_content(str(today_file_path)))
|
|
ui.notify("Loaded today's existing entry.", type="info")
|
|
except Exception as error:
|
|
ui.notify(f"Error loading today's entry: {error}", type="negative")
|
|
else:
|
|
new_entry_box.set_value("")
|
|
|
|
_ = mode.on(
|
|
"update:model-value", update_new_entry_box_on_mode_change
|
|
)
|
|
|
|
with ui.row().classes("gap-4 mt-4 journal-action-row").style("flex-shrink: 0;"):
|
|
|
|
def fill_template():
|
|
mode_val = cast(str | None, mode.value)
|
|
if new_entry_box is not None and mode_val is not None:
|
|
template_func = {
|
|
"Daily": create_daily_entry,
|
|
"Deep Recovery": create_recovery_entry,
|
|
"Fragment": create_fragment_entry,
|
|
"Deep Entry": create_deep_entry,
|
|
}[mode_val]
|
|
new_entry_box.set_value(template_func())
|
|
ui.notify("Template loaded!", type="info")
|
|
|
|
async def save_new_wrapper():
|
|
client = ui.context.client
|
|
new_entry_value = cast(
|
|
str | None,
|
|
new_entry_box.value if new_entry_box else None,
|
|
)
|
|
mode_val = cast(str, mode.value)
|
|
password = vault_password
|
|
if new_entry_value and new_entry_value.strip():
|
|
try:
|
|
# For new entries, always append to today's file
|
|
msg = await run.io_bound(
|
|
save_entry, new_entry_value, mode_val, password
|
|
)
|
|
except Exception as error:
|
|
if _is_client_active(client):
|
|
ui.notify(f"Save failed: {error}", type="negative")
|
|
return
|
|
if not _is_client_active(client):
|
|
return
|
|
ui.notify(msg, type="positive")
|
|
if new_entry_box:
|
|
new_entry_box.set_value("")
|
|
if not update_sidebar():
|
|
_schedule_sidebar_retry("new entry save")
|
|
else:
|
|
ui.notify("Entry content is empty", type="warning")
|
|
|
|
_ = ui.button("Create Template", on_click=fill_template).props(
|
|
"color=secondary"
|
|
).style("min-width: 10rem;")
|
|
_ = ui.button("Save Entry", on_click=save_new_wrapper).props(
|
|
"color=primary"
|
|
).style("min-width: 10rem;")
|
|
# --- Speech to Text Tab ---
|
|
with ui.tab_panel(speech_tab).classes("journal-tab-panel").style(
|
|
"flex: 1; display: flex; flex-direction: column; padding: 16px;"
|
|
):
|
|
# Add a text area to display the live transcription
|
|
speech_output_box = (
|
|
ui.textarea(label="Live Transcription")
|
|
.props("outlined")
|
|
.classes("w-full flex-grow journal-large-textarea")
|
|
)
|
|
|
|
def on_speech_result(text: str):
|
|
# Append new transcribed text to the speech output box
|
|
current_text = cast(str, speech_output_box.value or "")
|
|
speech_output_box.set_value(current_text + text)
|
|
|
|
# The speech component itself, which now updates the local text area
|
|
with ui.row().classes("w-full mt-4 journal-action-row"):
|
|
speech_to_text(on_result=on_speech_result)
|
|
|
|
def append_to_new_entry():
|
|
# Safely get and cast the value from the speech box to satisfy pyright.
|
|
speech_text = cast(str, speech_output_box.value or "")
|
|
if new_entry_box is not None and speech_text:
|
|
# Append the transcribed text to the main new entry editor
|
|
current_entry_text = cast(
|
|
str, new_entry_box.value or ""
|
|
)
|
|
new_entry_box.set_value(
|
|
current_entry_text + speech_text
|
|
)
|
|
|
|
# Clear the speech box and notify the user
|
|
speech_output_box.set_value("")
|
|
ui.notify(
|
|
"Text appended to 'New Entry' tab.",
|
|
type="positive",
|
|
)
|
|
|
|
_ = ui.button(
|
|
"Append to New Entry", on_click=append_to_new_entry
|
|
).props("color=primary")
|
|
# --- Dialog for "Analyze All Entries" ---
|
|
global all_analysis_dialog
|
|
if all_analysis_dialog is None:
|
|
all_analysis_dialog = ui.dialog()
|
|
|
|
with all_analysis_dialog, ui.card().classes("w-full max-w-5xl max-h-[90vh]"):
|
|
_ = ui.label("Comprehensive Journal Analysis").classes("text-xl font-bold mb-4")
|
|
all_analysis_output = (
|
|
ui.textarea(label="Analysis Results")
|
|
.props("readonly outlined")
|
|
.classes("h-96 mb-4 w-full")
|
|
)
|
|
|
|
with ui.row().classes("gap-4 journal-action-row"):
|
|
|
|
async def do_analyze_all_wrapper():
|
|
client = ui.context.client
|
|
ui.notify("Analyzing all entries...", type="info")
|
|
result: str = await run.io_bound(analyze_all_entries)
|
|
if not _is_client_active(client):
|
|
return
|
|
all_analysis_output.set_value(result)
|
|
ui.notify("Analysis complete!", type="positive")
|
|
|
|
_ = ui.button("Run Analysis", on_click=do_analyze_all_wrapper).props(
|
|
"color=primary"
|
|
)
|
|
_ = ui.button("Close", on_click=lambda: all_analysis_dialog.close() if all_analysis_dialog else None).props(
|
|
"color=secondary"
|
|
)
|
|
|
|
|
|
@ui.page("/")
|
|
async def index_page():
|
|
global drawer, sidebar_content, content_container
|
|
_ = ui.query("body").style("background-color: #111827")
|
|
# Top-level layout elements (always present)
|
|
with (
|
|
ui.left_drawer(value=False)
|
|
.props("overlay")
|
|
.classes("journal-drawer bg-gray-900 text-white") as drawer_instance
|
|
):
|
|
drawer = drawer_instance
|
|
sidebar_content = ui.column().classes("journal-sidebar p-3 gap-2 h-full")
|
|
# Sidebar content will be updated after vault load
|
|
|
|
with ui.header(elevated=True).classes(
|
|
"items-center justify-between px-3 py-2 bg-gray-900"
|
|
):
|
|
_ = ui.button(
|
|
icon="menu", on_click=lambda: drawer.toggle() if drawer else None
|
|
).props("flat color=white")
|
|
_ = ui.label("📓 Project Journal").classes("journal-header-title")
|
|
_ = ui.space()
|
|
# Instantiate and open the settings dialog
|
|
settings = settings_dialog()
|
|
_ = ui.button(icon="settings", on_click=settings.open).props("flat color=white")
|
|
|
|
# Main content area (dynamically populated)
|
|
with ui.column().classes(
|
|
"w-full bg-gray-900 text-white journal-main-content"
|
|
) as content_container_instance:
|
|
content_container = content_container_instance
|
|
if vault_password is None and cast(bool, app.storage.user.get("authenticated", False)):
|
|
# Stale browser/session flag from a previous process start; require password again.
|
|
app.storage.user["authenticated"] = False
|
|
app.storage.user.pop("vault_password", None)
|
|
|
|
if not _has_runtime_auth():
|
|
with ui.card().classes("absolute-center bg-gray-800 text-white w-full max-w-md"):
|
|
_ = ui.label("Welcome to Your Journal").classes("text-2xl font-bold")
|
|
password_input = (
|
|
ui.input(
|
|
"Enter Vault Password",
|
|
password=True,
|
|
placeholder="Type your password",
|
|
)
|
|
.props("autofocus")
|
|
.on("keydown.enter", lambda: password_button.run_method("click"))
|
|
)
|
|
password_button = ui.button(
|
|
"Load Vault",
|
|
on_click=lambda: process_password(
|
|
cast(str | None, password_input.value)
|
|
),
|
|
)
|
|
else:
|
|
_initialize_runtime_user_settings()
|
|
if not _render_authenticated_layout():
|
|
_schedule_sidebar_retry("index page auth render")
|
|
|
|
|
|
async def process_password(password: str | None):
|
|
global vault_password, content_container
|
|
client = ui.context.client
|
|
if not password:
|
|
if _is_client_active(client):
|
|
ui.notify("Password cannot be empty.", type="negative")
|
|
return
|
|
if cast(bool, app.storage.user.get("auth_in_progress", False)):
|
|
if _is_client_active(client):
|
|
ui.notify("Vault load already in progress. Please wait...", type="warning")
|
|
return
|
|
|
|
if vault_load_lock.locked():
|
|
if _is_client_active(client):
|
|
ui.notify("Another vault operation is already in progress. Please wait...", type="warning")
|
|
return
|
|
|
|
app.storage.user["auth_in_progress"] = True
|
|
try:
|
|
async with vault_load_lock:
|
|
if not _is_client_active(client):
|
|
return
|
|
# Check if vault is empty to determine if we're setting a new password
|
|
is_new_vault = not any(VAULT_DIR.iterdir())
|
|
|
|
if is_new_vault:
|
|
await run.io_bound(initialize_vault, password)
|
|
if _is_client_active(client):
|
|
ui.notify("New vault initialized!", type="positive")
|
|
vault_password = password
|
|
# Load the newly initialized vault (which will be empty but sets up DATA_DIR)
|
|
_ = await run.io_bound(load_all_vaults, password)
|
|
else:
|
|
# Attempt to load existing vaults
|
|
load_success = await run.io_bound(load_all_vaults, password)
|
|
if not load_success:
|
|
if _is_client_active(client):
|
|
ui.notify("Incorrect password.", type="negative")
|
|
vault_password = None # Reset password if loading fails
|
|
app.storage.user["authenticated"] = False
|
|
app.storage.user.pop("vault_password", None)
|
|
return
|
|
|
|
# Store password globally for shutdown hook, and in session for this client
|
|
if not _is_client_active(client):
|
|
return
|
|
|
|
vault_password = password
|
|
app.storage.user["authenticated"] = True
|
|
app.storage.user.pop("vault_password", None)
|
|
_initialize_runtime_user_settings()
|
|
if _is_client_active(client):
|
|
ui.notify("Vault loaded successfully! Loading workspace...", type="positive")
|
|
# Deterministically rebuild page state; avoids stale-container login hangs.
|
|
await asyncio.sleep(0.05)
|
|
if _is_client_active(client):
|
|
target = f"/?auth={int(time.time() * 1000)}"
|
|
ui.navigate.to(target)
|
|
await asyncio.sleep(0.12)
|
|
if _is_client_active(client):
|
|
_ = ui.run_javascript(
|
|
f"if (window.location.pathname + window.location.search !== '{target}') "
|
|
+ "{ window.location.replace('" + target + "'); }"
|
|
)
|
|
|
|
except ValueError as e:
|
|
if _is_client_active(client):
|
|
ui.notify(f"Error: {e}", type="negative")
|
|
vault_password = None
|
|
app.storage.user["authenticated"] = False
|
|
app.storage.user.pop("vault_password", None)
|
|
except Exception as e:
|
|
if _is_client_active(client):
|
|
ui.notify(f"Error loading vault: {e}", type="negative")
|
|
vault_password = None # Reset password if loading fails
|
|
app.storage.user["authenticated"] = False
|
|
app.storage.user.pop("vault_password", None)
|
|
finally:
|
|
try:
|
|
app.storage.user["auth_in_progress"] = False
|
|
except RuntimeError:
|
|
# The page may be gone due to refresh/disconnect before callback completion.
|
|
pass
|
|
|
|
|
|
@app.get("/_health")
|
|
def healthcheck() -> dict[str, str]:
|
|
return {"status": "ok"}
|
|
|
|
|
|
def _shutdown_handler():
|
|
"""Handles saving and cleanup on application exit."""
|
|
print("NiceGUI shutdown hook triggered.")
|
|
if vault_password:
|
|
rebuild_all_vaults(vault_password)
|
|
else:
|
|
print("Vault password is None, skipping save.")
|
|
clear_data_directory()
|
|
|
|
|
|
def main():
|
|
ui.run(
|
|
title="Project Journal",
|
|
reload=False,
|
|
host="0.0.0.0",
|
|
port=8080,
|
|
show=False,
|
|
storage_secret="a_secret_key_for_session_storage",
|
|
)
|
|
|
|
|
|
app.on_shutdown(_shutdown_handler) # pyright: ignore[reportUnknownMemberType]
|
|
|
|
if __name__ in {"__main__", "__mp_main__"}:
|
|
main()
|