diff --git a/server/app/api/tasks.py b/server/app/api/tasks.py index 652b858..680e74c 100644 --- a/server/app/api/tasks.py +++ b/server/app/api/tasks.py @@ -10,7 +10,7 @@ from ..auth import current_user from ..core.engine.engine import REGISTRY, latest_checkpoint, step_durations -from ..core.engine.task_templates import TEMPLATES, apply_template +from ..core.engine.task_templates import apply_template, localized_templates from ..db import get_db from ..models import Task, User @@ -29,8 +29,9 @@ class TaskIn(BaseModel): @router.get("/templates") -def list_templates(): - return TEMPLATES +def list_templates(lang: str = "en"): + """Task templates for the picker. ``lang`` (en/zh) localizes the display strings.""" + return localized_templates("zh" if lang == "zh" else "en") def _parse_prompt(prompt: str) -> tuple[str, dict, str]: diff --git a/server/app/core/engine/task_templates.py b/server/app/core/engine/task_templates.py index 3b1f55b..e68dd1e 100644 --- a/server/app/core/engine/task_templates.py +++ b/server/app/core/engine/task_templates.py @@ -2,69 +2,130 @@ The template wording targets the general pattern of "knowledge-work monitoring"; the current connectors are arXiv/Semantic Scholar (covering disciplines such as CS/physics/math/finance/biology/engineering), and other information sources extend along the same graph structure. + +Display strings (name/description/field label/placeholder) are bilingual: the API localizes them per request language via +``localized_templates(lang)``. Functional keys (id/task_type/field key/type/default) stay language-independent. """ from __future__ import annotations + +def _L(en: str, zh: str) -> dict: + """A bilingual display string. Resolved to one language by ``_pick`` at request time.""" + return {"en": en, "zh": zh} + + TEMPLATES = [ { "id": "literature_watch", - "name": "Topic watch", - "description": "Continuously monitor new publications on any topic — AI, finance, " - "biology, engineering — auto-triage, deep-summarize, archive, brief", + "name": _L("Topic watch", "主题追踪"), + "description": _L( + "Continuously monitor new publications on any topic — AI, finance, " + "biology, engineering — auto-triage, deep-summarize, archive, brief", + "持续追踪任意主题的新成果——AI、金融、生物、工程——自动初筛、精读摘要、归档、生成简报", + ), "task_type": "arxiv_watch", "fields": [ - {"key": "query", "label": "Topic keywords", "type": "text", - "placeholder": "e.g. LLM agent security / portfolio optimization"}, - {"key": "max_results", "label": "Papers per poll", "type": "number", "default": 15}, + {"key": "query", "type": "text", + "label": _L("Topic keywords", "主题关键词"), + "placeholder": _L("e.g. LLM agent security / portfolio optimization", + "如 LLM agent security / 投资组合优化")}, + {"key": "max_results", "type": "number", "default": 15, + "label": _L("Papers per poll", "每轮抓取篇数")}, ], }, { "id": "security_watch", - "name": "Security watch", - "description": "Track security research the way an on-call team tracks advisories — " - "new attack & defense work on your stack, triaged and summarized", + "name": _L("Security watch", "安全追踪"), + "description": _L( + "Track security research the way an on-call team tracks advisories — " + "new attack & defense work on your stack, triaged and summarized", + "像值班团队盯安全通告一样追踪安全研究——你技术栈上的新攻防工作,自动初筛并摘要", + ), "task_type": "arxiv_watch", "fields": [ - {"key": "query", "label": "What to guard", "type": "text", - "placeholder": "e.g. prompt injection defense / container escape"}, - {"key": "max_results", "label": "Papers per poll", "type": "number", "default": 15}, + {"key": "query", "type": "text", + "label": _L("What to guard", "关注的防护面"), + "placeholder": _L("e.g. prompt injection defense / container escape", + "如 提示注入防御 / 容器逃逸")}, + {"key": "max_results", "type": "number", "default": 15, + "label": _L("Papers per poll", "每轮抓取篇数")}, ], }, { "id": "market_watch", - "name": "Market & finance watch", - "description": "Track markets both ways — breaking financial news (stocks, earnings, " - "M&A) and quantitative finance preprints — triaged and briefed every morning", + "name": _L("Market & finance watch", "市场与金融追踪"), + "description": _L( + "Track markets both ways — breaking financial news (stocks, earnings, " + "M&A) and quantitative finance preprints — triaged and briefed every morning", + "双向追踪市场——突发财经新闻(股票、财报、并购)与量化金融预印本——每早初筛并生成简报", + ), "task_type": "arxiv_watch", "fields": [ - {"key": "query", "label": "Market topic or ticker", "type": "text", - "placeholder": "e.g. Micron MU stock / portfolio optimization"}, - {"key": "max_results", "label": "Items per poll", "type": "number", "default": 15}, + {"key": "query", "type": "text", + "label": _L("Market topic or ticker", "市场主题或股票代码"), + "placeholder": _L("e.g. Micron MU stock / portfolio optimization", + "如 美光 MU 股票 / 投资组合优化")}, + {"key": "max_results", "type": "number", "default": 15, + "label": _L("Items per poll", "每轮抓取条数")}, ], }, { "id": "proposal_research", - "name": "Deep-dive survey", - "description": "One focused sweep over a specific question — search wide, triage, " - "summarize everything relevant. Great before starting a project", + "name": _L("Deep-dive survey", "深度调研"), + "description": _L( + "One focused sweep over a specific question — search wide, triage, " + "summarize everything relevant. Great before starting a project", + "针对一个具体问题做一次集中扫描——广泛检索、初筛、把相关内容全部摘要。开题前很好用", + ), "task_type": "arxiv_watch", "fields": [ - {"key": "query", "label": "Research question", "type": "text", - "placeholder": "e.g. catastrophic forgetting in multi-agent RL"}, - {"key": "max_results", "label": "Papers to sweep (go big)", "type": "number", "default": 30}, + {"key": "query", "type": "text", + "label": _L("Research question", "研究问题"), + "placeholder": _L("e.g. catastrophic forgetting in multi-agent RL", + "如 多智能体强化学习中的灾难性遗忘")}, + {"key": "max_results", "type": "number", "default": 30, + "label": _L("Papers to sweep (go big)", "扫描篇数(可以大胆调大)")}, ], }, { "id": "weekly_briefing", - "name": "Briefing now", - "description": "Don't wait for tomorrow morning — aggregate the last 24h of " - "summaries into a briefing right now", + "name": _L("Briefing now", "立即生成简报"), + "description": _L( + "Don't wait for tomorrow morning — aggregate the last 24h of " + "summaries into a briefing right now", + "不必等到明早——现在就把过去 24 小时的摘要汇总成一份简报", + ), "task_type": "briefing", "fields": [], }, ] +def _pick(value, lang: str): + """Resolve a bilingual dict to one language; pass through plain values unchanged.""" + if isinstance(value, dict) and ("en" in value or "zh" in value): + return value.get(lang) or value.get("en") or "" + return value + + +def localized_templates(lang: str = "en") -> list[dict]: + """Return templates with display strings resolved to ``lang`` (functional keys untouched).""" + out = [] + for t in TEMPLATES: + out.append({ + "id": t["id"], + "name": _pick(t["name"], lang), + "description": _pick(t["description"], lang), + "task_type": t["task_type"], + "fields": [ + {k: (_pick(v, lang) if k in ("label", "placeholder") else v) + for k, v in f.items()} + for f in t["fields"] + ], + }) + return out + + def apply_template(template_id: str, values: dict) -> tuple[str, dict, str]: tpl = next((t for t in TEMPLATES if t["id"] == template_id), None) if tpl is None: @@ -74,7 +135,7 @@ def apply_template(template_id: str, values: dict) -> tuple[str, dict, str]: val = values.get(f["key"], f.get("default", "")) if f["type"] == "text" and not str(val).strip(): # text fields without a default are required: an empty query makes the arXiv API error out directly and the task is bound to fail - raise ValueError(f"Field '{f['label']}' is required") + raise ValueError(f"Field '{_pick(f['label'], 'en')}' is required") params[f["key"]] = val - title = f"{tpl['name']}: {params.get('query', '')}".strip(": ")[:60] + title = f"{_pick(tpl['name'], 'en')}: {params.get('query', '')}".strip(": ")[:60] return tpl["task_type"], params, title diff --git a/web/app/deploy/page.tsx b/web/app/deploy/page.tsx index 4d31653..3fd51c1 100644 --- a/web/app/deploy/page.tsx +++ b/web/app/deploy/page.tsx @@ -23,12 +23,38 @@ export default function Deploy() { const { lang } = useLang(); const L = (en: T, zh: T): T => (lang === "zh" ? zh : en); const [copied, setCopied] = useState(false); + const [failed, setFailed] = useState(false); function copy() { - navigator.clipboard.writeText(PROMPT).then(() => { + const done = () => { + setFailed(false); setCopied(true); setTimeout(() => setCopied(false), 2500); - }); + }; + // navigator.clipboard only exists in secure contexts (HTTPS / localhost). + // The public demo is served over plain HTTP, where it is undefined — fall back to execCommand. + if (navigator.clipboard && window.isSecureContext) { + navigator.clipboard.writeText(PROMPT).then(done).catch(legacyCopy); + } else { + legacyCopy(); + } + + function legacyCopy() { + try { + const ta = document.createElement("textarea"); + ta.value = PROMPT; + ta.setAttribute("readonly", ""); + ta.style.position = "fixed"; + ta.style.left = "-9999px"; + document.body.appendChild(ta); + ta.select(); + const ok = document.execCommand("copy"); + document.body.removeChild(ta); + ok ? done() : setFailed(true); + } catch { + setFailed(true); + } + } } return ( @@ -50,6 +76,12 @@ export default function Deploy() { {copied ? L("✅ Copied!", "✅ 已复制!") : L("📋 Copy the prompt", "📋 一键复制")} + {failed && ( +

+ {L("Auto-copy is blocked here — select the text below and copy manually (Ctrl/Cmd+C).", + "此环境禁用了自动复制,请手动选中下方文本复制(Ctrl/Cmd+C)。")} +

+ )}
 {PROMPT}
         
diff --git a/web/app/tasks/page.tsx b/web/app/tasks/page.tsx index 144cbf4..450e331 100644 --- a/web/app/tasks/page.tsx +++ b/web/app/tasks/page.tsx @@ -36,9 +36,13 @@ export default function Tasks() { const load = () => api("/api/tasks").then(setTasks).catch(() => {}); + // Templates carry backend-provided display copy; re-fetch on language change so the picker follows the UI language. + useEffect(() => { + api(`/api/tasks/templates?lang=${lang}`).then(setTemplates).catch(() => {}); + }, [lang]); + useEffect(() => { load(); - api("/api/tasks/templates").then(setTemplates).catch(() => {}); return subscribe((type, ev) => { if (type === "task_progress") { setTasks((ts) => (ts || []).map((t) => t.id === ev.task_id