Spaces:
Running
Running
A5-R3-R1 dormant product UI projection
#109
by GuXiaopeng - opened
- app.py +59 -4
- i18n.py +32 -4
- managed_auth.py +124 -6
app.py
CHANGED
|
@@ -22,6 +22,7 @@ from config import APP_CONFIG
|
|
| 22 |
from managed_auth import (
|
| 23 |
cancel_generation_task,
|
| 24 |
generation_task_status,
|
|
|
|
| 25 |
recover_generation_task,
|
| 26 |
require_alipay_login,
|
| 27 |
render_logged_in_account,
|
|
@@ -645,12 +646,19 @@ def _process_pending_launch(lang_code: str) -> None:
|
|
| 645 |
return
|
| 646 |
mode = str(pending.get("mode") or "").strip().lower()
|
| 647 |
idempotency_key = str(pending.get("idempotency_key") or "").strip()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 648 |
try:
|
| 649 |
response = start_generation_task(
|
| 650 |
idempotency_key=idempotency_key,
|
| 651 |
mode=mode,
|
| 652 |
interface_language=lang_code,
|
| 653 |
target_words=int(st.session_state.get("sp_words") or APP_CONFIG["target_words_default"]),
|
|
|
|
| 654 |
)
|
| 655 |
except Exception as exc:
|
| 656 |
st.session_state["sp_pending_launch"] = None
|
|
@@ -659,6 +667,8 @@ def _process_pending_launch(lang_code: str) -> None:
|
|
| 659 |
st.session_state["sp_payment_required"] = True
|
| 660 |
st.error(tr(lang_code, "quota_exhausted"))
|
| 661 |
render_payment_purchase(lang_code)
|
|
|
|
|
|
|
| 662 |
else:
|
| 663 |
st.error(raw)
|
| 664 |
return
|
|
@@ -1317,11 +1327,56 @@ def render_workspace_controls(user):
|
|
| 1317 |
|
| 1318 |
st.text_input(tr(lang_code, "topic_label"), value=DEFAULT_TOPIC, disabled=active, key="sp_topic")
|
| 1319 |
st.text_input(tr(lang_code, "keywords_label"), value=DEFAULT_KEYWORDS, disabled=active, key="sp_keywords")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1320 |
st.number_input(
|
| 1321 |
-
|
| 1322 |
-
min_value=
|
| 1323 |
-
max_value=
|
| 1324 |
-
value=
|
| 1325 |
step=1000,
|
| 1326 |
disabled=active,
|
| 1327 |
key="sp_words",
|
|
|
|
| 22 |
from managed_auth import (
|
| 23 |
cancel_generation_task,
|
| 24 |
generation_task_status,
|
| 25 |
+
get_product_ui_projection,
|
| 26 |
recover_generation_task,
|
| 27 |
require_alipay_login,
|
| 28 |
render_logged_in_account,
|
|
|
|
| 646 |
return
|
| 647 |
mode = str(pending.get("mode") or "").strip().lower()
|
| 648 |
idempotency_key = str(pending.get("idempotency_key") or "").strip()
|
| 649 |
+
projection = get_product_ui_projection()
|
| 650 |
+
selected_product_tier = (
|
| 651 |
+
str(st.session_state.get("sp_product_tier") or "").strip().upper()
|
| 652 |
+
if projection.get("mode") == "a5_product"
|
| 653 |
+
else None
|
| 654 |
+
)
|
| 655 |
try:
|
| 656 |
response = start_generation_task(
|
| 657 |
idempotency_key=idempotency_key,
|
| 658 |
mode=mode,
|
| 659 |
interface_language=lang_code,
|
| 660 |
target_words=int(st.session_state.get("sp_words") or APP_CONFIG["target_words_default"]),
|
| 661 |
+
product_tier=selected_product_tier,
|
| 662 |
)
|
| 663 |
except Exception as exc:
|
| 664 |
st.session_state["sp_pending_launch"] = None
|
|
|
|
| 667 |
st.session_state["sp_payment_required"] = True
|
| 668 |
st.error(tr(lang_code, "quota_exhausted"))
|
| 669 |
render_payment_purchase(lang_code)
|
| 670 |
+
elif "a5_product_task_path_not_authorized" in raw:
|
| 671 |
+
st.error(tr(lang_code, "product_generation_not_active"))
|
| 672 |
else:
|
| 673 |
st.error(raw)
|
| 674 |
return
|
|
|
|
| 1327 |
|
| 1328 |
st.text_input(tr(lang_code, "topic_label"), value=DEFAULT_TOPIC, disabled=active, key="sp_topic")
|
| 1329 |
st.text_input(tr(lang_code, "keywords_label"), value=DEFAULT_KEYWORDS, disabled=active, key="sp_keywords")
|
| 1330 |
+
|
| 1331 |
+
product_ui = get_product_ui_projection()
|
| 1332 |
+
if product_ui.get("mode") == "a5_product":
|
| 1333 |
+
products = product_ui.get("products") or {}
|
| 1334 |
+
tier_options = [tier for tier in ("STANDARD", "FLAGSHIP") if tier in products]
|
| 1335 |
+
if len(tier_options) != 2:
|
| 1336 |
+
st.error(tr(lang_code, "product_generation_not_active"))
|
| 1337 |
+
st.stop()
|
| 1338 |
+
selected_tier = str(st.session_state.get("sp_product_tier") or "STANDARD").strip().upper()
|
| 1339 |
+
if selected_tier not in tier_options:
|
| 1340 |
+
selected_tier = "STANDARD"
|
| 1341 |
+
st.session_state["sp_product_tier"] = selected_tier
|
| 1342 |
+
selected_tier = st.radio(
|
| 1343 |
+
tr(lang_code, "product_tier_label"),
|
| 1344 |
+
tier_options,
|
| 1345 |
+
index=tier_options.index(selected_tier),
|
| 1346 |
+
format_func=lambda tier: tr(lang_code, f"product_tier_{tier.lower()}"),
|
| 1347 |
+
horizontal=True,
|
| 1348 |
+
disabled=active,
|
| 1349 |
+
key="sp_product_tier",
|
| 1350 |
+
)
|
| 1351 |
+
selected_product = products[selected_tier]
|
| 1352 |
+
word_min = int(selected_product["min_body_words"])
|
| 1353 |
+
word_max = int(selected_product["max_body_words"])
|
| 1354 |
+
word_label = tr(
|
| 1355 |
+
lang_code,
|
| 1356 |
+
"words_label_product",
|
| 1357 |
+
min_words=word_min,
|
| 1358 |
+
max_words=word_max,
|
| 1359 |
+
)
|
| 1360 |
+
else:
|
| 1361 |
+
st.session_state.pop("sp_product_tier", None)
|
| 1362 |
+
word_min = int(APP_CONFIG["target_words_min"])
|
| 1363 |
+
word_max = int(APP_CONFIG["target_words_max"])
|
| 1364 |
+
word_label = tr(
|
| 1365 |
+
lang_code,
|
| 1366 |
+
"words_label",
|
| 1367 |
+
min_words=word_min,
|
| 1368 |
+
max_words=word_max,
|
| 1369 |
+
)
|
| 1370 |
+
|
| 1371 |
+
current_words = int(st.session_state.get("sp_words") or APP_CONFIG["target_words_default"])
|
| 1372 |
+
bounded_words = min(word_max, max(word_min, current_words))
|
| 1373 |
+
if st.session_state.get("sp_words") != bounded_words:
|
| 1374 |
+
st.session_state["sp_words"] = bounded_words
|
| 1375 |
st.number_input(
|
| 1376 |
+
word_label,
|
| 1377 |
+
min_value=word_min,
|
| 1378 |
+
max_value=word_max,
|
| 1379 |
+
value=bounded_words,
|
| 1380 |
step=1000,
|
| 1381 |
disabled=active,
|
| 1382 |
key="sp_words",
|
i18n.py
CHANGED
|
@@ -208,7 +208,12 @@ TEXTS: dict[str, dict[str, str]] = {
|
|
| 208 |
"title": "📊 工商管理论文智能体",
|
| 209 |
"topic_label": "📝 论文名称",
|
| 210 |
"keywords_label": "🔍 关键字",
|
| 211 |
-
"words_label": "📏 目标总单词数(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 212 |
"success": "🎉 流水线推演完毕!双语文档已就绪。",
|
| 213 |
"download": "📥 下载文件",
|
| 214 |
"system_busy_title": "⏳ 系统忙碌",
|
|
@@ -385,6 +390,8 @@ TEXTS: dict[str, dict[str, str]] = {
|
|
| 385 |
"account_chip_user": "用户 {value}",
|
| 386 |
"account_chip_free": "免费 {value}篇",
|
| 387 |
"account_chip_bonus": "长期 {value}篇",
|
|
|
|
|
|
|
| 388 |
"logout_short": "退出",
|
| 389 |
"payment_dialog_title": "支付宝付款",
|
| 390 |
"payment_notice": "今日免费额度和长期额度均已用完。支付9.9元可增加3篇长期有效额度。",
|
|
@@ -408,7 +415,12 @@ TEXTS: dict[str, dict[str, str]] = {
|
|
| 408 |
"title": "📊 Business Administration Article Agent",
|
| 409 |
"topic_label": "📝 Article Title",
|
| 410 |
"keywords_label": "🔍 Keywords",
|
| 411 |
-
"words_label": "📏 Target Word Count (
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 412 |
"success": "🎉 The pipeline is complete. The bilingual documents are ready.",
|
| 413 |
"download": "📥 Download Files",
|
| 414 |
"system_busy_title": "⏳ System Busy",
|
|
@@ -585,6 +597,8 @@ TEXTS: dict[str, dict[str, str]] = {
|
|
| 585 |
"account_chip_user": "User {value}",
|
| 586 |
"account_chip_free": "Free {value}",
|
| 587 |
"account_chip_bonus": "Bonus {value}",
|
|
|
|
|
|
|
| 588 |
"logout_short": "Sign out",
|
| 589 |
"payment_dialog_title": "Alipay Payment",
|
| 590 |
"payment_notice": "Your daily free quota and long-term quota are exhausted. Pay CNY 9.9 to add three long-term papers.",
|
|
@@ -608,7 +622,12 @@ TEXTS: dict[str, dict[str, str]] = {
|
|
| 608 |
"title": "📊 Интеллектуальный агент для научных статей по управлению бизнесом",
|
| 609 |
"topic_label": "📝 Название статьи",
|
| 610 |
"keywords_label": "🔍 Ключевые слова",
|
| 611 |
-
"words_label": "📏 Целевой объём (
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 612 |
"success": "🎉 Работа конвейера завершена. Двуязычные документы готовы.",
|
| 613 |
"download": "📥 Скачать файлы",
|
| 614 |
"system_busy_title": "⏳ Система занята",
|
|
@@ -785,6 +804,8 @@ TEXTS: dict[str, dict[str, str]] = {
|
|
| 785 |
"account_chip_user": "Пользователь {value}",
|
| 786 |
"account_chip_free": "Бесплатно {value}",
|
| 787 |
"account_chip_bonus": "Запас {value}",
|
|
|
|
|
|
|
| 788 |
"logout_short": "Выйти",
|
| 789 |
"payment_dialog_title": "Оплата через Alipay",
|
| 790 |
"payment_notice": "Ежедневная бесплатная и долгосрочная квоты исчерпаны. Оплата 9,9 юаня добавляет квоту на три статьи без срока действия.",
|
|
@@ -808,7 +829,12 @@ TEXTS: dict[str, dict[str, str]] = {
|
|
| 808 |
"title": "📊 Бизнес-басқару жөніндегі ғылыми мақала интеллектуалды агенті",
|
| 809 |
"topic_label": "📝 Мақала атау��",
|
| 810 |
"keywords_label": "🔍 Түйін сөздер",
|
| 811 |
-
"words_label": "📏 Мақсатты көлем (
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 812 |
"success": "🎉 Жұмыс үдерісі аяқталды. Екі тілдегі құжаттар дайын.",
|
| 813 |
"download": "📥 Файлдарды жүктеу",
|
| 814 |
"system_busy_title": "⏳ Жүйе бос емес",
|
|
@@ -985,6 +1011,8 @@ TEXTS: dict[str, dict[str, str]] = {
|
|
| 985 |
"account_chip_user": "Пайдаланушы {value}",
|
| 986 |
"account_chip_free": "Тегін {value}",
|
| 987 |
"account_chip_bonus": "Қор {value}",
|
|
|
|
|
|
|
| 988 |
"logout_short": "Шығу",
|
| 989 |
"payment_dialog_title": "Alipay арқылы төлеу",
|
| 990 |
"payment_notice": "Күнделікті тегін және ұзақ мерзімді квота таусылды. 9,9 юань төлегенде мерзімі шектелмеген үш мақала квотасы қосылады.",
|
|
|
|
| 208 |
"title": "📊 工商管理论文智能体",
|
| 209 |
"topic_label": "📝 论文名称",
|
| 210 |
"keywords_label": "🔍 关键字",
|
| 211 |
+
"words_label": "📏 目标总单词数({min_words}–{max_words}单词)",
|
| 212 |
+
"words_label_product": "📏 目标正文单词数({min_words}–{max_words}单词)",
|
| 213 |
+
"product_tier_label": "版本",
|
| 214 |
+
"product_tier_standard": "标准版",
|
| 215 |
+
"product_tier_flagship": "旗舰版",
|
| 216 |
+
"product_generation_not_active": "产品版任务生命周期尚未启用,请勿继续生成。",
|
| 217 |
"success": "🎉 流水线推演完毕!双语文档已就绪。",
|
| 218 |
"download": "📥 下载文件",
|
| 219 |
"system_busy_title": "⏳ 系统忙碌",
|
|
|
|
| 390 |
"account_chip_user": "用户 {value}",
|
| 391 |
"account_chip_free": "免费 {value}篇",
|
| 392 |
"account_chip_bonus": "长期 {value}篇",
|
| 393 |
+
"account_chip_standard": "标准版 {value}篇",
|
| 394 |
+
"account_chip_flagship": "旗舰版 {value}篇",
|
| 395 |
"logout_short": "退出",
|
| 396 |
"payment_dialog_title": "支付宝付款",
|
| 397 |
"payment_notice": "今日免费额度和长期额度均已用完。支付9.9元可增加3篇长期有效额度。",
|
|
|
|
| 415 |
"title": "📊 Business Administration Article Agent",
|
| 416 |
"topic_label": "📝 Article Title",
|
| 417 |
"keywords_label": "🔍 Keywords",
|
| 418 |
+
"words_label": "📏 Target Word Count ({min_words}–{max_words} words)",
|
| 419 |
+
"words_label_product": "📏 Target Body Word Count ({min_words}–{max_words} words)",
|
| 420 |
+
"product_tier_label": "Version",
|
| 421 |
+
"product_tier_standard": "Standard",
|
| 422 |
+
"product_tier_flagship": "Flagship",
|
| 423 |
+
"product_generation_not_active": "The product-tier task lifecycle is not active yet. Generation has been blocked to protect your balance.",
|
| 424 |
"success": "🎉 The pipeline is complete. The bilingual documents are ready.",
|
| 425 |
"download": "📥 Download Files",
|
| 426 |
"system_busy_title": "⏳ System Busy",
|
|
|
|
| 597 |
"account_chip_user": "User {value}",
|
| 598 |
"account_chip_free": "Free {value}",
|
| 599 |
"account_chip_bonus": "Bonus {value}",
|
| 600 |
+
"account_chip_standard": "Standard {value}",
|
| 601 |
+
"account_chip_flagship": "Flagship {value}",
|
| 602 |
"logout_short": "Sign out",
|
| 603 |
"payment_dialog_title": "Alipay Payment",
|
| 604 |
"payment_notice": "Your daily free quota and long-term quota are exhausted. Pay CNY 9.9 to add three long-term papers.",
|
|
|
|
| 622 |
"title": "📊 Интеллектуальный агент для научных статей по управлению бизнесом",
|
| 623 |
"topic_label": "📝 Название статьи",
|
| 624 |
"keywords_label": "🔍 Ключевые слова",
|
| 625 |
+
"words_label": "📏 Целевой объём ({min_words}–{max_words} слов)",
|
| 626 |
+
"words_label_product": "📏 Целевой объём основного текста ({min_words}–{max_words} слов)",
|
| 627 |
+
"product_tier_label": "Версия",
|
| 628 |
+
"product_tier_standard": "Стандарт",
|
| 629 |
+
"product_tier_flagship": "Флагман",
|
| 630 |
+
"product_generation_not_active": "Жизненный цикл задач продуктовых версий ещё не активирован. Генерация заблокирована для защиты баланса.",
|
| 631 |
"success": "🎉 Работа конвейера завершена. Двуязычные документы готовы.",
|
| 632 |
"download": "📥 Скачать файлы",
|
| 633 |
"system_busy_title": "⏳ Система занята",
|
|
|
|
| 804 |
"account_chip_user": "Пользователь {value}",
|
| 805 |
"account_chip_free": "Бесплатно {value}",
|
| 806 |
"account_chip_bonus": "Запас {value}",
|
| 807 |
+
"account_chip_standard": "Стандарт {value}",
|
| 808 |
+
"account_chip_flagship": "Флагман {value}",
|
| 809 |
"logout_short": "Выйти",
|
| 810 |
"payment_dialog_title": "Оплата через Alipay",
|
| 811 |
"payment_notice": "Ежедневная бесплатная и долгосрочная квоты исчерпаны. Оплата 9,9 юаня добавляет квоту на три статьи без срока действия.",
|
|
|
|
| 829 |
"title": "📊 Бизнес-басқару жөніндегі ғылыми мақала интеллектуалды агенті",
|
| 830 |
"topic_label": "📝 Мақала атау��",
|
| 831 |
"keywords_label": "🔍 Түйін сөздер",
|
| 832 |
+
"words_label": "📏 Мақсатты көлем ({min_words}–{max_words} сөз)",
|
| 833 |
+
"words_label_product": "📏 Негізгі мәтіннің мақсатты көлемі ({min_words}–{max_words} сөз)",
|
| 834 |
+
"product_tier_label": "Нұсқа",
|
| 835 |
+
"product_tier_standard": "Стандарт",
|
| 836 |
+
"product_tier_flagship": "Флагман",
|
| 837 |
+
"product_generation_not_active": "Өнімдік нұсқалардың тапсырма өмірлік циклі әлі іске қосылған жоқ. Балансты қорғау үшін генерация бұғатталды.",
|
| 838 |
"success": "🎉 Жұмыс үдерісі аяқталды. Екі тілдегі құжаттар дайын.",
|
| 839 |
"download": "📥 Файлдарды жүктеу",
|
| 840 |
"system_busy_title": "⏳ Жүйе бос емес",
|
|
|
|
| 1011 |
"account_chip_user": "Пайдаланушы {value}",
|
| 1012 |
"account_chip_free": "Тегін {value}",
|
| 1013 |
"account_chip_bonus": "Қор {value}",
|
| 1014 |
+
"account_chip_standard": "Стандарт {value}",
|
| 1015 |
+
"account_chip_flagship": "Флагман {value}",
|
| 1016 |
"logout_short": "Шығу",
|
| 1017 |
"payment_dialog_title": "Alipay арқылы төлеу",
|
| 1018 |
"payment_notice": "Күнделікті тегін және ұзақ мерзімді квота таусылды. 9,9 юань төлегенде мерзімі шектелмеген үш мақала квотасы қосылады.",
|
managed_auth.py
CHANGED
|
@@ -144,6 +144,7 @@ def _clear_session() -> None:
|
|
| 144 |
for key in (
|
| 145 |
"sp_session_expires_at", "sp_user",
|
| 146 |
"sp_last_verified_at", "sp_edge_assertion", "sp_auth_api_origin",
|
|
|
|
| 147 |
):
|
| 148 |
st.session_state.pop(key, None)
|
| 149 |
|
|
@@ -224,6 +225,84 @@ def _merge_quota_into_session(result: dict[str, Any]) -> None:
|
|
| 224 |
) + 1
|
| 225 |
|
| 226 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 227 |
def get_account_snapshot(*, block: bool = False) -> dict[str, Any] | None:
|
| 228 |
bucket = int(time.time() // 30)
|
| 229 |
revision = int(st.session_state.get("sp_account_revision") or 0)
|
|
@@ -240,16 +319,38 @@ def get_account_snapshot(*, block: bool = False) -> dict[str, Any] | None:
|
|
| 240 |
user = result.get("user")
|
| 241 |
if isinstance(user, dict):
|
| 242 |
st.session_state["sp_user"] = dict(user)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 243 |
# R3.1 single-writer rule: account refresh owns identity/quota only.
|
| 244 |
# Task UI state is initialized by recover_generation_task() and thereafter
|
| 245 |
# updated only by generation_task_status()/explicit task actions.
|
| 246 |
return result
|
| 247 |
|
| 248 |
|
| 249 |
-
def start_generation_task(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 250 |
normalized = str(idempotency_key or "").strip()
|
| 251 |
if not normalized:
|
| 252 |
raise RuntimeError("idempotency_key_required")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 253 |
result = _edge_call(
|
| 254 |
"task_start",
|
| 255 |
normalized,
|
|
@@ -458,8 +559,15 @@ def render_logged_in_account(
|
|
| 458 |
else:
|
| 459 |
user = user or st.session_state.get("sp_user", {})
|
| 460 |
user_id = user.get("id", "—")
|
| 461 |
-
|
| 462 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 463 |
|
| 464 |
st.markdown(
|
| 465 |
"""
|
|
@@ -581,14 +689,24 @@ def render_logged_in_account(
|
|
| 581 |
|
| 582 |
info_col, logout_col = st.columns([5.9, 1.65])
|
| 583 |
with info_col:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 584 |
st.markdown(
|
| 585 |
f"""
|
| 586 |
<div class="sp-account-card">
|
| 587 |
<span>{html.escape(tr(language, 'account_chip_user', value=user_id))}</span>
|
| 588 |
<span class="sp-account-separator">•</span>
|
| 589 |
-
|
| 590 |
-
<span class="sp-account-separator">•</span>
|
| 591 |
-
<span>{html.escape(tr(language, 'account_chip_bonus', value=bonus_papers))}</span>
|
| 592 |
</div>
|
| 593 |
""",
|
| 594 |
unsafe_allow_html=True,
|
|
|
|
| 144 |
for key in (
|
| 145 |
"sp_session_expires_at", "sp_user",
|
| 146 |
"sp_last_verified_at", "sp_edge_assertion", "sp_auth_api_origin",
|
| 147 |
+
"sp_product_ui_projection", "sp_product_tier",
|
| 148 |
):
|
| 149 |
st.session_state.pop(key, None)
|
| 150 |
|
|
|
|
| 225 |
) + 1
|
| 226 |
|
| 227 |
|
| 228 |
+
def _legacy_product_ui_projection() -> dict[str, Any]:
|
| 229 |
+
return {
|
| 230 |
+
"schema_version": "a5-product-ui-v1",
|
| 231 |
+
"mode": "legacy",
|
| 232 |
+
"product_tier_selection_required": False,
|
| 233 |
+
"products": {},
|
| 234 |
+
}
|
| 235 |
+
|
| 236 |
+
|
| 237 |
+
def _normalize_product_ui_projection(value: Any) -> dict[str, Any]:
|
| 238 |
+
"""Accept only the Worker-owned A5 UI projection; never reinterpret legacy quota fields."""
|
| 239 |
+
if not isinstance(value, dict):
|
| 240 |
+
return _legacy_product_ui_projection()
|
| 241 |
+
mode = str(value.get("mode") or "legacy").strip().lower()
|
| 242 |
+
if mode == "legacy":
|
| 243 |
+
return _legacy_product_ui_projection()
|
| 244 |
+
if mode != "a5_product":
|
| 245 |
+
raise RuntimeError("product_ui_projection_invalid_mode")
|
| 246 |
+
|
| 247 |
+
raw_products = value.get("products")
|
| 248 |
+
raw_entitlement = value.get("entitlement")
|
| 249 |
+
if not isinstance(raw_products, dict) or not isinstance(raw_entitlement, dict):
|
| 250 |
+
raise RuntimeError("product_ui_projection_incomplete")
|
| 251 |
+
|
| 252 |
+
products: dict[str, dict[str, Any]] = {}
|
| 253 |
+
for tier in ("STANDARD", "FLAGSHIP"):
|
| 254 |
+
raw = raw_products.get(tier)
|
| 255 |
+
if not isinstance(raw, dict):
|
| 256 |
+
raise RuntimeError("product_ui_projection_missing_tier")
|
| 257 |
+
try:
|
| 258 |
+
minimum = int(raw.get("min_body_words"))
|
| 259 |
+
maximum = int(raw.get("max_body_words"))
|
| 260 |
+
except (TypeError, ValueError) as exc:
|
| 261 |
+
raise RuntimeError("product_ui_projection_invalid_word_policy") from exc
|
| 262 |
+
if minimum <= 0 or maximum < minimum:
|
| 263 |
+
raise RuntimeError("product_ui_projection_invalid_word_policy")
|
| 264 |
+
products[tier] = {
|
| 265 |
+
"product_tier": tier,
|
| 266 |
+
"product_policy_version": str(raw.get("product_policy_version") or ""),
|
| 267 |
+
"word_count_policy_version": str(raw.get("word_count_policy_version") or ""),
|
| 268 |
+
"body_metric": str(raw.get("body_metric") or ""),
|
| 269 |
+
"min_body_words": minimum,
|
| 270 |
+
"max_body_words": maximum,
|
| 271 |
+
"counting_contract_version": str(raw.get("counting_contract_version") or ""),
|
| 272 |
+
}
|
| 273 |
+
if not all((
|
| 274 |
+
products[tier]["product_policy_version"],
|
| 275 |
+
products[tier]["word_count_policy_version"],
|
| 276 |
+
products[tier]["body_metric"],
|
| 277 |
+
products[tier]["counting_contract_version"],
|
| 278 |
+
)):
|
| 279 |
+
raise RuntimeError("product_ui_projection_incomplete")
|
| 280 |
+
|
| 281 |
+
try:
|
| 282 |
+
standard_remaining = int(raw_entitlement.get("standard_remaining"))
|
| 283 |
+
flagship_remaining = int(raw_entitlement.get("flagship_remaining"))
|
| 284 |
+
except (TypeError, ValueError) as exc:
|
| 285 |
+
raise RuntimeError("product_ui_projection_invalid_entitlement") from exc
|
| 286 |
+
if standard_remaining < 0 or flagship_remaining < 0:
|
| 287 |
+
raise RuntimeError("product_ui_projection_invalid_entitlement")
|
| 288 |
+
|
| 289 |
+
return {
|
| 290 |
+
"schema_version": str(value.get("schema_version") or "a5-product-ui-v1"),
|
| 291 |
+
"mode": "a5_product",
|
| 292 |
+
"product_tier_selection_required": True,
|
| 293 |
+
"products": products,
|
| 294 |
+
"entitlement": {
|
| 295 |
+
"standard_remaining": standard_remaining,
|
| 296 |
+
"flagship_remaining": flagship_remaining,
|
| 297 |
+
},
|
| 298 |
+
}
|
| 299 |
+
|
| 300 |
+
|
| 301 |
+
def get_product_ui_projection() -> dict[str, Any]:
|
| 302 |
+
value = st.session_state.get("sp_product_ui_projection")
|
| 303 |
+
return _normalize_product_ui_projection(value)
|
| 304 |
+
|
| 305 |
+
|
| 306 |
def get_account_snapshot(*, block: bool = False) -> dict[str, Any] | None:
|
| 307 |
bucket = int(time.time() // 30)
|
| 308 |
revision = int(st.session_state.get("sp_account_revision") or 0)
|
|
|
|
| 319 |
user = result.get("user")
|
| 320 |
if isinstance(user, dict):
|
| 321 |
st.session_state["sp_user"] = dict(user)
|
| 322 |
+
projection = _normalize_product_ui_projection(result.get("product_ui"))
|
| 323 |
+
st.session_state["sp_product_ui_projection"] = projection
|
| 324 |
+
if projection["mode"] != "a5_product":
|
| 325 |
+
st.session_state.pop("sp_product_tier", None)
|
| 326 |
# R3.1 single-writer rule: account refresh owns identity/quota only.
|
| 327 |
# Task UI state is initialized by recover_generation_task() and thereafter
|
| 328 |
# updated only by generation_task_status()/explicit task actions.
|
| 329 |
return result
|
| 330 |
|
| 331 |
|
| 332 |
+
def start_generation_task(
|
| 333 |
+
*,
|
| 334 |
+
idempotency_key: str,
|
| 335 |
+
mode: str,
|
| 336 |
+
interface_language: str,
|
| 337 |
+
target_words: int,
|
| 338 |
+
product_tier: str | None = None,
|
| 339 |
+
) -> dict[str, Any]:
|
| 340 |
normalized = str(idempotency_key or "").strip()
|
| 341 |
if not normalized:
|
| 342 |
raise RuntimeError("idempotency_key_required")
|
| 343 |
+
projection = get_product_ui_projection()
|
| 344 |
+
normalized_tier = str(product_tier or "").strip().upper()
|
| 345 |
+
if projection.get("mode") == "a5_product":
|
| 346 |
+
if normalized_tier not in projection.get("products", {}):
|
| 347 |
+
raise RuntimeError("product_tier_required")
|
| 348 |
+
# A5-R3-R1 is projection-only. Until entitlement reservation/task policy
|
| 349 |
+
# snapshot ownership is separately authorized, never fall through to the
|
| 350 |
+
# legacy quota task path.
|
| 351 |
+
raise RuntimeError("a5_product_task_path_not_authorized")
|
| 352 |
+
if normalized_tier:
|
| 353 |
+
raise RuntimeError("product_tier_not_available_in_legacy_mode")
|
| 354 |
result = _edge_call(
|
| 355 |
"task_start",
|
| 356 |
normalized,
|
|
|
|
| 559 |
else:
|
| 560 |
user = user or st.session_state.get("sp_user", {})
|
| 561 |
user_id = user.get("id", "—")
|
| 562 |
+
projection = get_product_ui_projection()
|
| 563 |
+
product_mode = projection.get("mode") == "a5_product"
|
| 564 |
+
if product_mode:
|
| 565 |
+
entitlement = projection["entitlement"]
|
| 566 |
+
standard_remaining = entitlement["standard_remaining"]
|
| 567 |
+
flagship_remaining = entitlement["flagship_remaining"]
|
| 568 |
+
else:
|
| 569 |
+
free_remaining = user.get("daily_free_remaining", user.get("daily_free_limit", 3))
|
| 570 |
+
bonus_papers = user.get("bonus_papers", 0)
|
| 571 |
|
| 572 |
st.markdown(
|
| 573 |
"""
|
|
|
|
| 689 |
|
| 690 |
info_col, logout_col = st.columns([5.9, 1.65])
|
| 691 |
with info_col:
|
| 692 |
+
if product_mode:
|
| 693 |
+
balance_markup = (
|
| 694 |
+
f"<span>{html.escape(tr(language, 'account_chip_standard', value=standard_remaining))}</span>"
|
| 695 |
+
f'<span class="sp-account-separator">•</span>'
|
| 696 |
+
f"<span>{html.escape(tr(language, 'account_chip_flagship', value=flagship_remaining))}</span>"
|
| 697 |
+
)
|
| 698 |
+
else:
|
| 699 |
+
balance_markup = (
|
| 700 |
+
f"<span>{html.escape(tr(language, 'account_chip_free', value=free_remaining))}</span>"
|
| 701 |
+
f'<span class="sp-account-separator">•</span>'
|
| 702 |
+
f"<span>{html.escape(tr(language, 'account_chip_bonus', value=bonus_papers))}</span>"
|
| 703 |
+
)
|
| 704 |
st.markdown(
|
| 705 |
f"""
|
| 706 |
<div class="sp-account-card">
|
| 707 |
<span>{html.escape(tr(language, 'account_chip_user', value=user_id))}</span>
|
| 708 |
<span class="sp-account-separator">•</span>
|
| 709 |
+
{balance_markup}
|
|
|
|
|
|
|
| 710 |
</div>
|
| 711 |
""",
|
| 712 |
unsafe_allow_html=True,
|