# -*- coding: utf-8 -*- """SmartPaper R3-Free UI: Streamlit is an observer, never the paper executor.""" import hashlib import json import logging import os import re import secrets import shutil import signal import subprocess import sys import time from datetime import datetime, timezone from pathlib import Path import streamlit as st import streamlit.components.v1 as components from config import APP_CONFIG from managed_auth import ( cancel_generation_task, generation_task_status, recover_generation_task, require_alipay_login, render_logged_in_account, render_payment_purchase, start_generation_task, ) from i18n import ( LANGUAGE_OPTIONS, get_amj_csv_template, get_asq_txt_template, get_output_suffix, get_target_flag, get_target_language, tr, ) st.set_page_config( page_title="Article Agent", page_icon="📊", layout="wide", initial_sidebar_state="collapsed", ) WORKSPACE_DIR = APP_CONFIG.get("workspace_dir", ".workspace") os.makedirs(WORKSPACE_DIR, exist_ok=True) os.environ["STREAMLIT_SERVER_ENABLE_CORS"] = "false" os.environ["STREAMLIT_SERVER_ENABLE_XSRF_PROTECTION"] = "false" WECHAT_BLOCKER_JS = """""" components.html(WECHAT_BLOCKER_JS, height=0, width=0) def inject_file_uploader_i18n(language: str) -> None: """Localize Streamlit uploaders without modifying their React structure.""" payload = { "browse": tr(language, "browse_files"), "uploaders": [ { "title": tr(language, "drop_docs"), "limit": tr(language, "limit_docs"), }, { "title": tr(language, "drop_data"), "limit": tr(language, "limit_data"), }, { "title": tr(language, "drop_docs"), "limit": tr(language, "limit_docs"), }, { "title": tr(language, "drop_interviews"), "limit": tr(language, "limit_interviews"), }, ], } script = f""" """ components.html(script, height=0, width=0) def inject_wechat_browser_notice() -> None: """Warn immediately when SmartPaper is opened inside WeChat or WeCom.""" script = """ """ components.html(script, height=0, width=0) for key, default in { "is_running": False, "zip_buffer": None, "zip_filename": "", "run_mode": "amj", "sp_generation_task": None, "sp_pending_launch": None, "sp_pending_resume_task_id": "", "sp_pending_cancel_task_id": "", "sp_payment_required": False, "sp_terminal_notice": None, "sp_task_recovery_initialized": False, "sp_last_ui_task_status": "", }.items(): if key not in st.session_state: st.session_state[key] = default def _safe_key(value: str) -> str: cleaned = re.sub(r"[^A-Za-z0-9_.-]", "_", str(value or "").strip()) return cleaned[:100] or "anonymous" def _new_generation_idempotency_key() -> str: return f"gen_{int(time.time() * 1000)}_{secrets.token_urlsafe(18)}" def _atomic_json(path: Path, value: dict) -> None: path.parent.mkdir(parents=True, exist_ok=True) temporary = path.with_suffix(path.suffix + ".tmp") temporary.write_text(json.dumps(value, ensure_ascii=False), encoding="utf-8") os.replace(temporary, path) def _canonical_request_hash(value: dict) -> str: material = dict(value or {}) material.pop("request_hash", None) encoded = json.dumps(material, sort_keys=True, ensure_ascii=False, separators=(",", ":")).encode("utf-8") return hashlib.sha256(encoded).hexdigest() def _task_dir(task_id: str) -> Path: return Path(_user_workspace) / "tasks" / _safe_key(task_id) def _task_output_path(task: dict) -> Path | None: task_id = str(task.get("task_id") or "").strip() filename = os.path.basename(str(task.get("output_filename") or "").strip()) if not task_id or not filename: return None return _task_dir(task_id) / "outputs" / filename def _save_uploaded(items, inputs_dir: Path, prefix: str) -> list[str]: saved = [] for index, item in enumerate(items or []): if item is None: continue original = os.path.basename(str(getattr(item, "name", f"input_{index}"))) safe_name = re.sub(r"[^A-Za-z0-9_.-]", "_", original)[:140] or f"input_{index}" filename = f"{prefix}_{index:02d}_{safe_name}" path = inputs_dir / filename data = item.getvalue() if hasattr(item, "getvalue") else item.read() path.write_bytes(bytes(data or b"")) saved.append(str(Path("inputs") / filename)) try: item.seek(0) except Exception: pass return saved def _persist_task_request(task: dict, mode: str, lang_code: str) -> dict: task_id = str(task.get("task_id") or "").strip() numeric_id = (st.session_state.get("sp_user") or {}).get("numeric_id") if not task_id or not isinstance(numeric_id, int) or numeric_id <= 0: raise RuntimeError("task_identity_missing") task_dir = _task_dir(task_id) inputs_dir = task_dir / "inputs" inputs_dir.mkdir(parents=True, exist_ok=True) if mode == "amj": refs = _save_uploaded(st.session_state.get("up_amj_ref") or [], inputs_dir, "ref") data = _save_uploaded([st.session_state.get("up_amj_data")], inputs_dir, "data") interviews = [] else: refs = _save_uploaded(st.session_state.get("up_asq_ref") or [], inputs_dir, "ref") data = [] interviews = _save_uploaded([st.session_state.get("up_asq_data")], inputs_dir, "interview") request_data = { "task_id": task_id, "user_id": numeric_id, "mode": mode, "interface_language": lang_code, "target_lang": get_target_language(lang_code), "output_suffix": get_output_suffix(lang_code), "topic": str(st.session_state.get("sp_topic") or DEFAULT_TOPIC), "keywords": str(st.session_state.get("sp_keywords") or DEFAULT_KEYWORDS), "target_words": int(st.session_state.get("sp_words") or APP_CONFIG["target_words_default"]), "reference_files": refs, "data_files": data, "interview_files": interviews, "created_at": datetime.now(timezone.utc).isoformat(), } request_data["request_hash"] = _canonical_request_hash(request_data) _atomic_json(task_dir / "request.json", request_data) return request_data def _pid_alive(pid: int) -> bool: if not isinstance(pid, int) or pid <= 0: return False try: os.kill(pid, 0) return True except OSError: return False def _pid_is_task_executor(pid: int, task_id: str, task_dir: Path) -> bool: """Best-effort Linux identity check before trusting/killing a launch PID. Hugging Face Spaces run on Linux. A stale PID can be reused, so a bare ``os.kill(pid, 0)`` is never enough to identify our executor. """ if not _pid_alive(pid): return False try: raw = (Path("/proc") / str(pid) / "cmdline").read_bytes() command = raw.replace(b"\x00", b" ").decode("utf-8", errors="ignore") except Exception: return False return ( "task_executor.py" in command and f"--task-id {task_id}" in command and str(task_dir.resolve()) in command ) def _launch_executor(task_id: str, *, force: bool = False) -> int: """Launch only after a PID-bound claim handshake is observed. ``subprocess.Popen`` proves only that the OS accepted a child process. R3.1 waits until that exact child has successfully claimed the D1 task and written ``executor_claim.json``. A child that exits/import-fails before claim is therefore never reported as a running executor. """ task_dir = _task_dir(task_id) request_path = task_dir / "request.json" if not request_path.is_file(): raise RuntimeError("task_request_manifest_missing") request_data = json.loads(request_path.read_text(encoding="utf-8")) numeric_id = int(request_data.get("user_id") or 0) request_hash = str(request_data.get("request_hash") or "").strip() if numeric_id <= 0 or not request_hash: raise RuntimeError("task_request_identity_invalid") launch_marker_path = task_dir / "executor_launch.json" claim_marker_path = task_dir / "executor_claim.json" if launch_marker_path.is_file(): try: marker = json.loads(launch_marker_path.read_text(encoding="utf-8")) pid = int(marker.get("pid") or 0) verified_executor = _pid_is_task_executor(pid, task_id, task_dir) if verified_executor and not force: claim_marker = {} if claim_marker_path.is_file(): try: claim_marker = json.loads(claim_marker_path.read_text(encoding="utf-8")) except Exception: claim_marker = {} if int(claim_marker.get("pid") or 0) == pid and int(claim_marker.get("execution_epoch") or 0) > 0: return pid if verified_executor and force: try: os.kill(pid, signal.SIGTERM) except OSError: pass except Exception: pass # A stale claim file must never satisfy a new launch/resume handshake. try: claim_marker_path.unlink(missing_ok=True) except TypeError: if claim_marker_path.exists(): claim_marker_path.unlink() log_path = task_dir / "executor.log" task_dir.mkdir(parents=True, exist_ok=True) command = [ sys.executable, str(Path(__file__).resolve().parent / "task_executor.py"), "--task-dir", str(task_dir.resolve()), "--task-id", str(task_id), "--user-id", str(numeric_id), ] executor_env = os.environ.copy() executor_env["SMARTPAPER_EXECUTOR_LOG_PATH"] = str(log_path.resolve()) process = subprocess.Popen( command, cwd=str(Path(__file__).resolve().parent), stdin=subprocess.DEVNULL, # Inherit stdout/stderr so executor bootstrap/errors are visible in HF # Container Logs; task_executor also writes the same log to executor.log. close_fds=True, start_new_session=True, env=executor_env, ) _atomic_json(launch_marker_path, {"pid": process.pid, "launched_at": datetime.now(timezone.utc).isoformat()}) logging.info("[SP_UI] executor_spawned task_id=%s pid=%s", task_id, process.pid) timeout_seconds = float(APP_CONFIG.get("executor_bootstrap_timeout_seconds", 20)) poll_seconds = max(0.05, float(APP_CONFIG.get("executor_bootstrap_poll_ms", 100)) / 1000.0) deadline = time.monotonic() + timeout_seconds while time.monotonic() < deadline: if claim_marker_path.is_file(): try: claim_marker = json.loads(claim_marker_path.read_text(encoding="utf-8")) except Exception: claim_marker = {} if ( int(claim_marker.get("pid") or 0) == int(process.pid) and str(claim_marker.get("task_id") or "") == str(task_id) and str(claim_marker.get("request_hash") or "") == request_hash and int(claim_marker.get("execution_epoch") or 0) > 0 and str(claim_marker.get("executor_instance_id") or "").strip() ): logging.info( "[SP_UI] executor_claim_confirmed task_id=%s pid=%s epoch=%s", task_id, process.pid, claim_marker.get("execution_epoch"), ) return int(process.pid) return_code = process.poll() if return_code is not None: logging.error("[SP_UI] executor_bootstrap_exit task_id=%s pid=%s rc=%s", task_id, process.pid, return_code) raise RuntimeError(f"executor_bootstrap_failed_exit_{return_code}") time.sleep(poll_seconds) try: process.terminate() except Exception: pass logging.error("[SP_UI] executor_claim_timeout task_id=%s pid=%s", task_id, process.pid) raise RuntimeError("executor_claim_timeout") def _process_pending_cancel() -> None: task_id = str(st.session_state.get("sp_pending_cancel_task_id") or "").strip() if not task_id: return result = cancel_generation_task(task_id) if result.get("cancelled"): shutil.rmtree(_task_dir(task_id), ignore_errors=True) st.session_state["sp_pending_cancel_task_id"] = "" st.session_state["sp_generation_task"] = dict(result.get("task") or {}) or None st.rerun() def _process_pending_resume() -> None: task_id = str(st.session_state.get("sp_pending_resume_task_id") or "").strip() if not task_id: return if not (_task_dir(task_id) / "request.json").is_file(): st.session_state["sp_pending_resume_task_id"] = "" st.session_state["sp_pending_cancel_task_id"] = task_id st.session_state["sp_terminal_notice"] = { "kind": "error", "message": "Local checkpoint/request files are unavailable after runtime replacement; the reserved quota will be released.", } st.rerun() _launch_executor(task_id, force=True) st.session_state["sp_pending_resume_task_id"] = "" st.rerun() def _process_pending_launch(lang_code: str) -> None: pending = st.session_state.get("sp_pending_launch") if not isinstance(pending, dict): return mode = str(pending.get("mode") or "").strip().lower() idempotency_key = str(pending.get("idempotency_key") or "").strip() try: response = start_generation_task( idempotency_key=idempotency_key, mode=mode, interface_language=lang_code, target_words=int(st.session_state.get("sp_words") or APP_CONFIG["target_words_default"]), ) except Exception as exc: st.session_state["sp_pending_launch"] = None raw = str(exc or "") if "payment_required" in raw: st.session_state["sp_payment_required"] = True st.error(tr(lang_code, "quota_exhausted")) render_payment_purchase(lang_code) else: st.error(raw) return task = dict(response.get("task") or {}) task_id = str(task.get("task_id") or "").strip() if not task_id: st.session_state["sp_pending_launch"] = None st.error("task_start_missing_task_id") return st.session_state["sp_generation_task"] = task st.session_state["sp_last_ui_task_status"] = str(task.get("status") or "") try: request_path = _task_dir(task_id) / "request.json" if not request_path.is_file(): _persist_task_request(task, mode, lang_code) _launch_executor(task_id) except Exception as exc: st.session_state["sp_pending_launch"] = None st.session_state["sp_pending_cancel_task_id"] = task_id st.session_state["sp_terminal_notice"] = {"kind": "error", "message": str(exc)[:800]} st.rerun() st.session_state["sp_pending_launch"] = None st.session_state["sp_payment_required"] = False st.rerun() inject_wechat_browser_notice() DEFAULT_TOPIC = "Generative Artificial Intelligence in Ubiquitous Learning: Evaluating a Chatbot-based Recommendation Engine for Personalized and Context-aware Education" DEFAULT_KEYWORDS = "Ubiquitous computing; Context awareness; Deep learning; Personalized educational; Educational technologies; HCI; Recommendation system; LLMs; Generative AI; ChatGPT; Prompting engineering; Few-shot prompting" def _current_language_code() -> str: choice = st.session_state.get("interface_language_choice") return LANGUAGE_OPTIONS.get(choice, "zh") authenticated_user = require_alipay_login(_current_language_code()) if not bool(st.session_state.get("sp_task_recovery_initialized")): recover_generation_task() st.session_state["sp_task_recovery_initialized"] = True _user_workspace = os.path.join(WORKSPACE_DIR, "users", _safe_key(authenticated_user.get("id"))) os.makedirs(_user_workspace, exist_ok=True) def render_workspace_controls(user): top_toolbar = st.container() with top_toolbar: language_col, account_col = st.columns([5.4, 4.6]) with language_col: lang_choice = st.radio( "Language", list(LANGUAGE_OPTIONS.keys()), index=0, horizontal=True, label_visibility="collapsed", key="interface_language_choice", ) lang_code = LANGUAGE_OPTIONS[lang_choice] st.session_state["sp_lang_code"] = lang_code st.session_state["sp_target_lang"] = get_target_language(lang_code) st.session_state["sp_output_suffix"] = get_output_suffix(lang_code) st.session_state["sp_target_flag"] = get_target_flag(lang_code) with account_col: render_logged_in_account(lang_code, user) task = st.session_state.get("sp_generation_task") or {} task_status = str(task.get("status") or "") active = task_status in {"created", "running", "resumable", "cancelling"} st.session_state["is_running"] = active st.markdown(f"