Sync third-party and MCP marketplace plugins
Constraint: Public skills are published only by explicit administrator action unless they are tracked third-party market sources. Confidence: high Scope-risk: narrow Directive: Keep private/internal skills out of the public marketplace and preserve normal incremental market Git history. Tested: Marketplace validation passed.
This commit is contained in:
@@ -208,30 +208,6 @@
|
||||
},
|
||||
"category": "文档处理"
|
||||
},
|
||||
{
|
||||
"name": "mcp-playwright",
|
||||
"source": {
|
||||
"source": "local",
|
||||
"path": "./plugins/codex/plugins/mcp-playwright"
|
||||
},
|
||||
"policy": {
|
||||
"installation": "AVAILABLE",
|
||||
"authentication": "ON_INSTALL"
|
||||
},
|
||||
"category": "MCP"
|
||||
},
|
||||
{
|
||||
"name": "shadcn",
|
||||
"source": {
|
||||
"source": "local",
|
||||
"path": "./plugins/codex/plugins/shadcn"
|
||||
},
|
||||
"policy": {
|
||||
"installation": "AVAILABLE",
|
||||
"authentication": "ON_INSTALL"
|
||||
},
|
||||
"category": "开发工具"
|
||||
},
|
||||
{
|
||||
"name": "frontend-slides",
|
||||
"source": {
|
||||
@@ -256,6 +232,30 @@
|
||||
},
|
||||
"category": "开发工具"
|
||||
},
|
||||
{
|
||||
"name": "next-skills",
|
||||
"source": {
|
||||
"source": "local",
|
||||
"path": "./plugins/codex/plugins/next-skills"
|
||||
},
|
||||
"policy": {
|
||||
"installation": "AVAILABLE",
|
||||
"authentication": "ON_INSTALL"
|
||||
},
|
||||
"category": "开发工具"
|
||||
},
|
||||
{
|
||||
"name": "shadcn",
|
||||
"source": {
|
||||
"source": "local",
|
||||
"path": "./plugins/codex/plugins/shadcn"
|
||||
},
|
||||
"policy": {
|
||||
"installation": "AVAILABLE",
|
||||
"authentication": "ON_INSTALL"
|
||||
},
|
||||
"category": "开发工具"
|
||||
},
|
||||
{
|
||||
"name": "ppt-master",
|
||||
"source": {
|
||||
@@ -269,16 +269,16 @@
|
||||
"category": "文档处理"
|
||||
},
|
||||
{
|
||||
"name": "next-skills",
|
||||
"name": "mcp-playwright",
|
||||
"source": {
|
||||
"source": "local",
|
||||
"path": "./plugins/codex/plugins/next-skills"
|
||||
"path": "./plugins/codex/plugins/mcp-playwright"
|
||||
},
|
||||
"policy": {
|
||||
"installation": "AVAILABLE",
|
||||
"authentication": "ON_INSTALL"
|
||||
},
|
||||
"category": "开发工具"
|
||||
"category": "MCP"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -33,8 +33,8 @@
|
||||
"repo": "https://github.com/shadcn-ui/ui.git",
|
||||
"ref": "main",
|
||||
"adapter": "claude-skill",
|
||||
"commit": "ea9d371a2dda3365a382ff361f96b55daeeab88d",
|
||||
"syncedAt": "2026-06-12T15:59:59Z"
|
||||
"commit": "9197676b3d09b77f51b17b63e5978db29e498ca4",
|
||||
"syncedAt": "2026-06-15T15:59:59Z"
|
||||
},
|
||||
{
|
||||
"id": "frontend-slides",
|
||||
@@ -69,8 +69,8 @@
|
||||
"repo": "https://github.com/hugohe3/ppt-master.git",
|
||||
"ref": "main",
|
||||
"adapter": "claude-skill",
|
||||
"commit": "34d1f0057d51ff2cc15bcbbec071ef35f6fc1ae1",
|
||||
"syncedAt": "2026-06-14T16:00:01Z"
|
||||
"commit": "45d9a79874d8700583feb60ddfbca46df437864b",
|
||||
"syncedAt": "2026-06-15T15:59:59Z"
|
||||
},
|
||||
{
|
||||
"id": "next-skills",
|
||||
|
||||
@@ -208,30 +208,6 @@
|
||||
},
|
||||
"category": "文档处理"
|
||||
},
|
||||
{
|
||||
"name": "mcp-playwright",
|
||||
"source": {
|
||||
"source": "local",
|
||||
"path": "./plugins/mcp-playwright"
|
||||
},
|
||||
"policy": {
|
||||
"installation": "AVAILABLE",
|
||||
"authentication": "ON_INSTALL"
|
||||
},
|
||||
"category": "MCP"
|
||||
},
|
||||
{
|
||||
"name": "shadcn",
|
||||
"source": {
|
||||
"source": "local",
|
||||
"path": "./plugins/shadcn"
|
||||
},
|
||||
"policy": {
|
||||
"installation": "AVAILABLE",
|
||||
"authentication": "ON_INSTALL"
|
||||
},
|
||||
"category": "开发工具"
|
||||
},
|
||||
{
|
||||
"name": "frontend-slides",
|
||||
"source": {
|
||||
@@ -256,6 +232,30 @@
|
||||
},
|
||||
"category": "开发工具"
|
||||
},
|
||||
{
|
||||
"name": "next-skills",
|
||||
"source": {
|
||||
"source": "local",
|
||||
"path": "./plugins/next-skills"
|
||||
},
|
||||
"policy": {
|
||||
"installation": "AVAILABLE",
|
||||
"authentication": "ON_INSTALL"
|
||||
},
|
||||
"category": "开发工具"
|
||||
},
|
||||
{
|
||||
"name": "shadcn",
|
||||
"source": {
|
||||
"source": "local",
|
||||
"path": "./plugins/shadcn"
|
||||
},
|
||||
"policy": {
|
||||
"installation": "AVAILABLE",
|
||||
"authentication": "ON_INSTALL"
|
||||
},
|
||||
"category": "开发工具"
|
||||
},
|
||||
{
|
||||
"name": "ppt-master",
|
||||
"source": {
|
||||
@@ -269,16 +269,16 @@
|
||||
"category": "文档处理"
|
||||
},
|
||||
{
|
||||
"name": "next-skills",
|
||||
"name": "mcp-playwright",
|
||||
"source": {
|
||||
"source": "local",
|
||||
"path": "./plugins/next-skills"
|
||||
"path": "./plugins/mcp-playwright"
|
||||
},
|
||||
"policy": {
|
||||
"installation": "AVAILABLE",
|
||||
"authentication": "ON_INSTALL"
|
||||
},
|
||||
"category": "开发工具"
|
||||
"category": "MCP"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -3,5 +3,5 @@
|
||||
"name": "playwright浏览器自动化操作",
|
||||
"version": "20260605",
|
||||
"keySource": "none",
|
||||
"syncedAt": "2026-06-12T09:39:35Z"
|
||||
"syncedAt": "2026-06-15T16:01:15Z"
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
"sourceId": "ppt-master",
|
||||
"repo": "https://github.com/hugohe3/ppt-master.git",
|
||||
"ref": "main",
|
||||
"commit": "34d1f0057d51ff2cc15bcbbec071ef35f6fc1ae1",
|
||||
"commit": "45d9a79874d8700583feb60ddfbca46df437864b",
|
||||
"adapter": "claude-skill",
|
||||
"sourcePath": "skills/ppt-master",
|
||||
"syncedAt": "2026-06-14T16:00:01Z"
|
||||
"syncedAt": "2026-06-15T15:59:59Z"
|
||||
}
|
||||
|
||||
@@ -285,6 +285,33 @@ Read references/strategist.md
|
||||
7. Typography plan, including formula rendering policy
|
||||
8. Image usage approach
|
||||
|
||||
**Confirm UI Auto-Launch (Mandatory — default visual confirmation surface)**: by default the Eight Confirmations are presented through an interactive local page (color swatches, live font previews, candidate picks); the chat path is the always-valid fallback. Steps:
|
||||
|
||||
1. Write the recommendations to `<project_path>/confirm_ui/recommendations.json` (full schema + field mapping: [`scripts/docs/confirm_ui.md`](scripts/docs/confirm_ui.md)). Two kinds of field: **enumerable** (canvas / mode / visual_style / icons / formula policy / generation mode; plus image usage with a Custom path; plus AI source only when image usage may include `ai`) — the page lists common options from `confirm_ui/static/catalogs.json`, so you only name the recommended canonical `id` in a `recommend` block (canvas may be a catalog id like `ppt169` or a custom size/prose; style = `mode` + `visual_style`, two independent picks; icon ids are real libraries such as `tabler-outline`, or `emoji` for system emoji; image usage uses `ai` / `web` / `provided` / `placeholder` / `none`, or a custom prose plan when several sources must be combined; never write bare `"custom"` for image usage — write the actual mixed plan, e.g. "AI cover + user product assets + web industry images"; write `image_ai_path` only when recommending `image_usage: "ai"` or a custom plan that includes AI); **generative** (color, typography, generated-image style) — author a few **candidates** (color: user-facing core `palette` with background/secondary_bg/primary/accent/secondary_accent/body_text; typography: CJK + Latin for `heading` and `body` with `css` preview stacks, plus `body_size` as the body baseline px; when recommending generated images, `image_strategy.candidates` with rendering × palette combinations from strategist h.5). `page_count` / `audience` are plain values. Only open fields show a Custom box: `canvas`, `mode`, `visual_style`, `icons`, `image_usage`, and typography custom text. Closed fields (`image_ai_path`, `formula_policy`, `generation_mode`, `refine_spec`) stay finite. Set `lang` to the page language; visible candidate text should match `lang`, or provide bilingual `name_zh` / `name_en` and `note_zh` / `note_en` fields. Reuse the same candidate thinking as strategist h.5.
|
||||
2. Launch the page **in the background and wait for the browser confirmation** (the child server runs detached; the parent command returns after `result.json` is freshly written). **Run this command with a long tool timeout — 600000 ms** — so the `--wait` (≈590 s budget) can complete:
|
||||
```bash
|
||||
python3 ${SKILL_DIR}/scripts/confirm_ui/server.py <project_path> --daemon --wait
|
||||
```
|
||||
Page opens at `http://localhost:4040`; port conflict → `--port <other>` and report the actual URL. When the user clicks **Confirm**, the command exits 0 and Step 4 reads `result.json` immediately; do not require a second chat confirmation. **Launch or wait failure is non-fatal**: if it fails or times out (flask missing, port blocked, no GUI / remote / web host, browser never confirms in time), do **NOT** troubleshoot. The detached page stays open, so a slow user may confirm after the wait returns — therefore **on any non-zero exit, re-check `<project_path>/confirm_ui/result.json` once (a fresh `status: confirmed`) before** dropping to the chat-summary fallback below.
|
||||
3. **Always also print the eight recommendations as a short summary in chat, with the URL.** This keeps the chat fallback valid whether or not the browser opened. If the page never appears, the user simply confirms or edits in chat as before.
|
||||
4. This is the ⛔ BLOCKING wait. Preferred page path: the `--wait` command returns after the page writes a fresh `<project_path>/confirm_ui/result.json`; immediately read that file and use its values. On a non-zero exit, re-check `result.json` once (per step 2) — a fresh `status: confirmed` still wins. Chat fallback path: only if no fresh result exists (page didn't open, wait timed out with no confirmation, or the user replies in chat with edits) take the chat values directly. Either path converges. A confirmed `result.json` is an explicit user choice: `generation_mode: "split"` means split mode was chosen; `refine_spec: true` means the refine-spec workflow was chosen.
|
||||
|
||||
**Honoring the confirmation (result.json is authoritative — Mandatory)**: the confirmed values **override your own recommendations** when you write `design_spec.md` / `spec_lock.md`. A user who changed any field changed it on purpose. In particular, map `image_usage` to §VIII `Acquire Via` (its value names differ from §h options — translate):
|
||||
|
||||
| `result.json.image_usage` | §VIII `Acquire Via` | h.5 + Step 5 generation |
|
||||
|---|---|---|
|
||||
| `ai` (or a custom plan that includes AI) | `ai` rows | Run h.5 (lock rendering + palette); Step 5 generates |
|
||||
| `web` | `web` rows | None |
|
||||
| `provided` | **`user`** rows | None — never generate |
|
||||
| `placeholder` | `placeholder` rows | None |
|
||||
| `none` | no image rows (§h option A) | None |
|
||||
|
||||
When the confirmed `image_usage` is not `ai` (and the plan has no AI part), do **NOT** run h.5, do **NOT** write `ai` rows, and do **NOT** generate images in Step 5 — regardless of what you recommended. The same "confirmed value wins" rule applies to every field (color → §III, typography → §IV, etc.).
|
||||
|
||||
**Opt-out**: if the user has said they don't want the page (e.g. "不要网页" / "just confirm in chat" / "纯聊天确认"), skip the launch entirely (step 2) and present the Eight Confirmations in chat as before — steps 1, 3, 4 still apply (recommendations summary in chat; wait; take chat values).
|
||||
|
||||
The page is a **confirmation surface only** — Strategist still authors every recommendation; the page never generates content.
|
||||
|
||||
**Mandatory — split-mode note** (not a ninth confirmation): after listing the eight confirmation details, you MUST append exactly one short line (rendered in the user's language, prefixed with 💡) about generation mode. Pick the variant by qualitative read of Phase A signals — recommended page count, source-material bulk, whether `topic-research` ran with substantial web-fetch accumulation:
|
||||
|
||||
| Signal read | Line content |
|
||||
@@ -292,9 +319,9 @@ Read references/strategist.md
|
||||
| Heavy (long page count / bulky sources / heavy web-fetch accumulation) | State estimated page count and large source size; recommend switching to [split mode](workflows/resume-execute.md) after Step 5 — stop this chat, open a fresh window and input `继续生成 projects/<project_name>` to enter Phase B (SVG generation + export); no response or "continue" = default continuous mode. |
|
||||
| Normal (default) | State scale is moderate, default continuous mode generates in one go; if mid-way window switch is desired, input `继续生成 projects/<project_name>` after Step 5 to switch to [split mode](workflows/resume-execute.md). |
|
||||
|
||||
This line is required output every run — the user must always see the mode choice exists. Whether to act on it is the user's call.
|
||||
This line is required output every run — the user must always see the mode choice exists. Whether to act on it is the user's call. When the Confirm UI is used, this choice also appears as the in-page generation-mode toggle and is captured in `result.json` (`generation_mode`); the chat-summary fallback still prints this line.
|
||||
|
||||
**Mandatory — spec-refinement note** (not a ninth confirmation): after the split-mode line, you MUST append one short opt-in line (rendered in the user's language, prefixed with 💡) telling the user they may **refine the spec first** — Strategist will produce the full design spec, then stop for review/revision of any part of it before any generation, via the [refine-spec](workflows/refine-spec.md) workflow. Default is OFF: no request → the spec is written in one go and the pipeline auto-proceeds as usual. Only when the user explicitly asks (e.g. "refine the spec first") does the [refine-spec](workflows/refine-spec.md) workflow take over after the Eight Confirmations. This line, like the split-mode line, is required output every run — the user must see the choice exists; whether to act on it is theirs.
|
||||
**Mandatory — spec-refinement note** (not a ninth confirmation): after the split-mode line, you MUST append one short opt-in line (rendered in the user's language, prefixed with 💡) telling the user they may **refine the spec first** — Strategist will produce the full design spec, then stop for review/revision of any part of it before any generation, via the [refine-spec](workflows/refine-spec.md) workflow. Default is OFF: no request → the spec is written in one go and the pipeline auto-proceeds as usual. Only when the user explicitly asks in chat (e.g. "refine the spec first") or confirms `refine_spec: true` through Confirm UI does the [refine-spec](workflows/refine-spec.md) workflow take over after the Eight Confirmations. This line, like the split-mode line, is required output every run — the user must see the choice exists; whether to act on it is theirs. When the Confirm UI is used, this choice also appears as the in-page refine-spec toggle and is captured in `result.json` (`refine_spec`); the chat-summary fallback still prints this line.
|
||||
|
||||
**Formula rendering policy lives inside item 7 (Typography plan)**:
|
||||
|
||||
@@ -330,7 +357,7 @@ python3 ${SKILL_DIR}/scripts/analyze_images.py <project_path>/images
|
||||
**✅ Checkpoint — Phase deliverables complete, auto-proceed to next step**:
|
||||
```markdown
|
||||
## ✅ Strategist Phase Complete
|
||||
- [x] Eight Confirmations completed (user confirmed)
|
||||
- [x] Eight Confirmations completed (user confirmed via Confirm UI `result.json` or chat fallback)
|
||||
- [x] Split-mode note appended below the eight items (heavy or normal variant)
|
||||
- [x] Spec-refinement opt-in line appended (default OFF; only the user's explicit request enters the refine-spec workflow)
|
||||
- [x] Design Specification & Content Outline generated
|
||||
@@ -379,7 +406,7 @@ Workflow:
|
||||
- [x] Each row: status is `Generated` / `Sourced` / `Needs-Manual` (no `Pending` remaining)
|
||||
```
|
||||
|
||||
**Default — auto-proceed to Step 6.** Only when the user's Step 4 response explicitly opted into split mode (in reply to the optional hint), output the Phase A hand-off below and stop this conversation:
|
||||
**Default — auto-proceed to Step 6.** Only when the user's Step 4 response explicitly opted into split mode (in chat or via Confirm UI `result.json` with `generation_mode: "split"`), output the Phase A hand-off below and stop this conversation:
|
||||
|
||||
```markdown
|
||||
## ✅ Phase A Complete
|
||||
|
||||
@@ -653,7 +653,9 @@ Gradients defined in `<defs>` and referenced via `fill="url(#id)"` convert to na
|
||||
|
||||
`<pattern>` fills convert to native PPTX `<a:pattFill prst="...">` — but only PPTX's built-in preset patterns are reachable. The converter does **not** render hand-drawn `<path>` geometry inside the pattern; instead it reads two annotations off the `<pattern>` element and emits the matching DrawingML preset.
|
||||
|
||||
**Required annotations**:
|
||||
**Prefer explicit geometry when spacing matters.** A `<pattern>` renders at PowerPoint's **fixed preset density** — you cannot reproduce a specific tile size (e.g. a 40px grid). For grids / textures whose spacing or line weight is part of the design, draw the lines as **one `<path>` with all lines as subpaths** (`M40 0V720 M80 0V720 … M0 40H1280 …`, `fill="none" stroke=…`) — the converter supports `M/L/H/V` and multi-subpath, so it becomes **one editable vector shape that reproduces the exact spacing** across all four renderers. Reserve `<pattern>` + `data-pptx-pattern` for **round-tripping an existing PPTX** (decks imported via `pptx_to_svg`), where the source genuinely used a native preset fill. For pure display where no PPT-side editing is needed, `--svg-snapshot` is the other faithful option.
|
||||
|
||||
**Required annotations** (only when you intentionally use a `<pattern>` preset):
|
||||
|
||||
| Attribute | Purpose | Without it |
|
||||
|---|---|---|
|
||||
|
||||
@@ -28,6 +28,8 @@ As a top-tier AI presentation strategist, receive source documents, perform cont
|
||||
>
|
||||
> **One opt-in exception**: present the spec-refinement line alongside the split-mode note (SKILL.md Step 4). It is OFF by default — the above discipline holds unchanged. Only when the user *explicitly* asks to refine the spec do you hand off to the [refine-spec](../workflows/refine-spec.md) workflow, which produces the full spec first and stops for user review/revision of any part before generation. Never enter it unprompted.
|
||||
|
||||
> **Default presentation surface — Confirm UI.** Deliver the bundled package through the interactive page: write your recommendations to `<project>/confirm_ui/recommendations.json`, then launch per [SKILL.md Step 4](../SKILL.md). You still author everything — enumerable fields name a recommended `id`; generative fields (color `palette`, CJK + Latin typography, generated-image style) carry a few **candidates**, same thinking as h.5. **Always also print the recommendations + URL in chat** as the always-valid fallback. On confirm, read `<project>/confirm_ui/result.json` (`generation_mode: "split"` / `refine_spec: true` are explicit user choices). Skip the page if the user wants chat-only. Full launch flow, field rules, and JSON schema live in [SKILL.md Step 4](../SKILL.md) + [`scripts/docs/confirm_ui.md`](../scripts/docs/confirm_ui.md) — don't restate them here. The page is a confirmation surface only.
|
||||
|
||||
### a. Canvas Format Confirmation
|
||||
|
||||
Recommend format based on scenario (see [`canvas-formats.md`](canvas-formats.md)).
|
||||
@@ -286,6 +288,8 @@ The script renders PNGs into `images/`, trying `codecogs`, `quicklatex`, `mathpa
|
||||
| **D** | Web-sourced | Real-world reference imagery, editorial support, stock-style needs (no API key required for default providers) |
|
||||
| **E** | Placeholders | Images to be added later |
|
||||
|
||||
> **Confirmed value wins.** The `image_usage` in `result.json` (or the chat reply) **overrides the recommendation here** — map it to §VIII `Acquire Via` (`ai`→`ai`, `web`→`web`, `provided`→**`user`**, `placeholder`→`placeholder`, `none`→option A, no image rows). When it is not `ai` (and the plan has no AI part), skip h.5 entirely and write no `ai` rows. See SKILL.md Step 4 for the full mapping.
|
||||
|
||||
**When recommending C** — surface its three implementation modes so the user knows "no API key" is a supported state:
|
||||
|
||||
| Mode | Trigger | Mechanism |
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""PPT Master - interactive Eight Confirmations UI (Step 4)."""
|
||||
@@ -0,0 +1,356 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
PPT Master - Eight Confirmations UI Server (Step 4)
|
||||
|
||||
Lightweight Flask backend for the interactive, visual Eight Confirmations page.
|
||||
Strategist writes its recommendations to
|
||||
``<project>/confirm_ui/recommendations.json``; this server renders them as a
|
||||
clickable page (color swatches, live font previews, candidate picks). On
|
||||
submit it writes the user's final choices to
|
||||
``<project>/confirm_ui/result.json`` for the AI to read back.
|
||||
|
||||
This is the confirmation surface only. The chat fallback always remains valid:
|
||||
if the browser cannot open (remote / headless / web host), the AI presents the
|
||||
same Eight Confirmations in chat.
|
||||
|
||||
See scripts/docs/confirm_ui.md for the round-trip data contract and schema.
|
||||
|
||||
Usage:
|
||||
python3 scripts/confirm_ui/server.py <project_dir>
|
||||
|
||||
Examples:
|
||||
python3 scripts/confirm_ui/server.py projects/my-project
|
||||
python3 scripts/confirm_ui/server.py projects/my-project --port 4041
|
||||
python3 scripts/confirm_ui/server.py projects/my-project --no-browser
|
||||
python3 scripts/confirm_ui/server.py projects/my-project --daemon --wait
|
||||
|
||||
Dependencies:
|
||||
flask>=3.0.0
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import atexit
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import webbrowser
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from flask import Flask, jsonify, request, send_from_directory
|
||||
|
||||
# Local — sys.path injection for sibling module (code-style.md §3)
|
||||
_SCRIPTS_DIR = Path(__file__).resolve().parent.parent
|
||||
if str(_SCRIPTS_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(_SCRIPTS_DIR))
|
||||
|
||||
from server_common import ( # noqa: E402
|
||||
claim_lock as _claim_lock,
|
||||
process_alive as _process_alive,
|
||||
read_lock as _read_lock,
|
||||
release_lock as _release_lock,
|
||||
)
|
||||
|
||||
logger = logging.getLogger('confirm_ui')
|
||||
|
||||
# Per-project lock file. Lives at <project_path>/.confirm_ui.lock and matches
|
||||
# the *.lock entry already in the repo .gitignore. Independent of the live
|
||||
# preview lock so the two surfaces never collide.
|
||||
LOCK_FILE_NAME = '.confirm_ui.lock'
|
||||
|
||||
# Round-trip files, both under <project_path>/confirm_ui/.
|
||||
CONFIRM_DIR_NAME = 'confirm_ui'
|
||||
RECOMMENDATIONS_NAME = 'recommendations.json'
|
||||
RESULT_NAME = 'result.json'
|
||||
|
||||
DEFAULT_PORT = 4040
|
||||
|
||||
# Default --wait budget, kept just under the 600s Bash-tool ceiling so the
|
||||
# parent (waiting) command returns before the calling harness kills it. The
|
||||
# detached child server keeps running on its own --timeout idle budget, so a
|
||||
# slow user can still confirm after the wait returns; the caller re-checks
|
||||
# result.json before falling back to chat.
|
||||
WAIT_TIMEOUT_DEFAULT = 590
|
||||
|
||||
|
||||
def _wait_for_result(
|
||||
result_file: Path,
|
||||
proc: subprocess.Popen,
|
||||
started_at: float,
|
||||
timeout: int,
|
||||
) -> int:
|
||||
"""Wait until this launch writes a fresh result file or the server exits."""
|
||||
logger.info('waiting for browser confirmation...')
|
||||
deadline = None if timeout <= 0 else time.time() + timeout
|
||||
while True:
|
||||
if result_file.exists():
|
||||
try:
|
||||
if result_file.stat().st_mtime >= started_at:
|
||||
logger.info('confirmation received: %s', result_file)
|
||||
try:
|
||||
proc.wait(timeout=3)
|
||||
except subprocess.TimeoutExpired:
|
||||
pass
|
||||
return 0
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
returncode = proc.poll()
|
||||
if returncode is not None:
|
||||
logger.error('confirm UI exited before a fresh result was written')
|
||||
return returncode or 1
|
||||
|
||||
if deadline is not None and time.time() >= deadline:
|
||||
logger.error(
|
||||
'timed out waiting for browser confirmation — the page is still '
|
||||
'open; re-check %s before falling back to chat', result_file,
|
||||
)
|
||||
return 124
|
||||
|
||||
time.sleep(0.5)
|
||||
|
||||
|
||||
# --- app --------------------------------------------------------------------
|
||||
|
||||
def create_app(
|
||||
project_dir: str,
|
||||
idle_timeout: int = 900,
|
||||
lock_file: Optional[Path] = None,
|
||||
) -> Flask:
|
||||
"""Create and configure the Flask app for a given project directory."""
|
||||
project_path = Path(project_dir).resolve()
|
||||
confirm_dir = project_path / CONFIRM_DIR_NAME
|
||||
|
||||
app = Flask(__name__, static_folder='static', static_url_path='/static')
|
||||
app.config['PROJECT_PATH'] = project_path
|
||||
app.config['CONFIRM_DIR'] = confirm_dir
|
||||
app.config['LOCK_FILE'] = lock_file
|
||||
app.config['LAST_REQUEST_TIME'] = time.time()
|
||||
|
||||
@app.before_request
|
||||
def _update_activity():
|
||||
app.config['LAST_REQUEST_TIME'] = time.time()
|
||||
|
||||
def _exit_with_lock_release(code: int = 0) -> None:
|
||||
lf = app.config.get('LOCK_FILE')
|
||||
if lf is not None:
|
||||
_release_lock(lf)
|
||||
os._exit(code)
|
||||
|
||||
def _idle_watchdog():
|
||||
if idle_timeout <= 0:
|
||||
return
|
||||
while True:
|
||||
time.sleep(10)
|
||||
elapsed = time.time() - app.config['LAST_REQUEST_TIME']
|
||||
if elapsed > idle_timeout:
|
||||
logger.info('idle for %ds, shutting down', idle_timeout)
|
||||
_exit_with_lock_release(0)
|
||||
|
||||
watchdog = threading.Thread(target=_idle_watchdog, daemon=True)
|
||||
watchdog.start()
|
||||
|
||||
@app.route('/api/shutdown', methods=['POST'])
|
||||
def shutdown():
|
||||
data = request.get_json(silent=True) or {}
|
||||
reason = data.get('reason') or 'shutdown'
|
||||
|
||||
def _stop():
|
||||
time.sleep(0.5) # let HTTP response flush before killing the process
|
||||
logger.info('shutting down (%s)', reason)
|
||||
_exit_with_lock_release(0)
|
||||
threading.Thread(target=_stop, daemon=True).start()
|
||||
return jsonify({'status': 'ok'})
|
||||
|
||||
@app.route('/')
|
||||
def index():
|
||||
return send_from_directory(app.static_folder, 'index.html')
|
||||
|
||||
@app.route('/api/recommendations')
|
||||
def get_recommendations():
|
||||
"""Serve the Strategist-authored recommendations for this project."""
|
||||
rec_file = confirm_dir / RECOMMENDATIONS_NAME
|
||||
if not rec_file.exists():
|
||||
return jsonify({'error': 'recommendations not found'}), 404
|
||||
try:
|
||||
data = json.loads(rec_file.read_text(encoding='utf-8'))
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
return jsonify({'error': f'invalid recommendations.json: {exc}'}), 400
|
||||
# Report whether a result already exists (re-open after confirm).
|
||||
result_file = confirm_dir / RESULT_NAME
|
||||
data['_already_confirmed'] = result_file.exists()
|
||||
return jsonify(data)
|
||||
|
||||
@app.route('/api/confirm', methods=['POST'])
|
||||
def confirm():
|
||||
"""Persist the user's final choices to result.json for the AI to read."""
|
||||
payload = request.get_json(silent=True)
|
||||
if not isinstance(payload, dict):
|
||||
return jsonify({'error': 'invalid payload'}), 400
|
||||
confirm_dir.mkdir(parents=True, exist_ok=True)
|
||||
result = dict(payload)
|
||||
result['status'] = 'confirmed'
|
||||
result['confirmed_at'] = time.strftime('%Y-%m-%dT%H:%M:%S')
|
||||
result_file = confirm_dir / RESULT_NAME
|
||||
result_file.write_text(
|
||||
json.dumps(result, ensure_ascii=False, indent=2),
|
||||
encoding='utf-8',
|
||||
)
|
||||
logger.info('confirmation written to %s', result_file)
|
||||
return jsonify({'status': 'ok'})
|
||||
|
||||
return app
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
description='PPT Master Eight Confirmations UI',
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
)
|
||||
parser.add_argument('project_dir', help='Path to project directory')
|
||||
parser.add_argument(
|
||||
'--port', type=int, default=DEFAULT_PORT,
|
||||
help=f'Port to listen on (default: {DEFAULT_PORT})',
|
||||
)
|
||||
parser.add_argument('--no-browser', action='store_true', help='Do not auto-open browser')
|
||||
parser.add_argument(
|
||||
'--daemon', action='store_true',
|
||||
help='Start the server in the background; combine with --wait to block until confirmation',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--wait', action='store_true',
|
||||
help='With --daemon, wait until a fresh result.json is written',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--wait-timeout', type=int, default=WAIT_TIMEOUT_DEFAULT,
|
||||
help=f'Seconds the --wait parent blocks before returning (default: {WAIT_TIMEOUT_DEFAULT}; '
|
||||
'0 = no limit). Kept under the caller\'s tool timeout; the detached server lives on.',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--timeout', type=int, default=900,
|
||||
help='Server idle timeout in seconds (default: 900; 0 = disabled)',
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: Optional[list[str]] = None) -> int:
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='[%(asctime)s] [%(levelname)s] confirm_ui: %(message)s',
|
||||
datefmt='%H:%M:%S',
|
||||
)
|
||||
|
||||
project_path = Path(args.project_dir).resolve()
|
||||
if not project_path.is_dir():
|
||||
logger.error('%s is not a directory', project_path)
|
||||
return 1
|
||||
|
||||
rec_file = project_path / CONFIRM_DIR_NAME / RECOMMENDATIONS_NAME
|
||||
if not rec_file.exists():
|
||||
logger.error(
|
||||
'%s not found — Strategist must write recommendations.json before launch',
|
||||
rec_file,
|
||||
)
|
||||
return 1
|
||||
|
||||
if args.daemon:
|
||||
lock_file = project_path / LOCK_FILE_NAME
|
||||
existing = _read_lock(lock_file)
|
||||
if existing and _process_alive(int(existing.get('pid', 0))):
|
||||
existing_pid = existing.get('pid', '?')
|
||||
existing_port = existing.get('port', '?')
|
||||
logger.error(
|
||||
'confirm UI is already running for this project '
|
||||
'(pid=%s, port=%s). Open http://localhost:%s',
|
||||
existing_pid, existing_port, existing_port,
|
||||
)
|
||||
return 1
|
||||
|
||||
confirm_dir = project_path / CONFIRM_DIR_NAME
|
||||
confirm_dir.mkdir(parents=True, exist_ok=True)
|
||||
log_path = confirm_dir / 'server.log'
|
||||
result_file = confirm_dir / RESULT_NAME
|
||||
started_at = time.time()
|
||||
cmd = [
|
||||
sys.executable,
|
||||
str(Path(__file__).resolve()),
|
||||
str(project_path),
|
||||
'--port',
|
||||
str(args.port),
|
||||
'--timeout',
|
||||
str(args.timeout),
|
||||
]
|
||||
if args.no_browser:
|
||||
cmd.append('--no-browser')
|
||||
creationflags = 0
|
||||
popen_kwargs = {}
|
||||
if os.name == 'nt':
|
||||
creationflags = subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.DETACHED_PROCESS
|
||||
else:
|
||||
popen_kwargs['start_new_session'] = True
|
||||
with log_path.open('a', encoding='utf-8') as log:
|
||||
proc = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=log,
|
||||
stderr=subprocess.STDOUT,
|
||||
stdin=subprocess.DEVNULL,
|
||||
creationflags=creationflags,
|
||||
**popen_kwargs,
|
||||
)
|
||||
url = f'http://localhost:{args.port}'
|
||||
logger.info('started confirm UI in background: %s (pid=%s)', url, proc.pid)
|
||||
logger.info('log: %s', log_path)
|
||||
if args.wait:
|
||||
return _wait_for_result(result_file, proc, started_at, args.wait_timeout)
|
||||
return 0
|
||||
|
||||
# Per-project mutual exclusion: refuse duplicate launches. Stale locks
|
||||
# (dead pid) are overwritten by _claim_lock.
|
||||
lock_file = project_path / LOCK_FILE_NAME
|
||||
existing = _claim_lock(lock_file, args.port)
|
||||
if existing:
|
||||
existing_pid = existing.get('pid', '?')
|
||||
existing_port = existing.get('port', '?')
|
||||
logger.error(
|
||||
'confirm UI is already running for this project '
|
||||
'(pid=%s, port=%s). Open http://localhost:%s, or run: kill %s',
|
||||
existing_pid, existing_port, existing_port, existing_pid,
|
||||
)
|
||||
return 1
|
||||
atexit.register(_release_lock, lock_file)
|
||||
|
||||
def _on_sigterm(signum: int, _frame) -> None:
|
||||
logger.info('received signal %s, exiting', signum)
|
||||
sys.exit(0)
|
||||
try:
|
||||
signal.signal(signal.SIGTERM, _on_sigterm)
|
||||
except (ValueError, OSError):
|
||||
pass
|
||||
|
||||
app = create_app(
|
||||
str(project_path),
|
||||
idle_timeout=args.timeout,
|
||||
lock_file=lock_file,
|
||||
)
|
||||
|
||||
url = f'http://localhost:{args.port}'
|
||||
if not args.no_browser:
|
||||
webbrowser.open(url)
|
||||
|
||||
logger.info('running at %s', url)
|
||||
logger.info('project: %s', project_path)
|
||||
logger.info('idle timeout: %ds (0 = disabled)', args.timeout)
|
||||
app.run(host='127.0.0.1', port=args.port, debug=False)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,932 @@
|
||||
/* PPT Master - Eight Confirmations UI
|
||||
* Finite/enumerable fields (canvas, mode, visual style, icons, image usage,
|
||||
* AI source, formula policy, generation mode) list ALL options from
|
||||
* /static/catalogs.json with the AI's recommendation marked. Open/generative
|
||||
* fields (color, typography, generated-image style) show a few AI candidates. Open fields also expose
|
||||
* Custom controls. On confirm the page saves result.json and closes.
|
||||
*/
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
// ---- i18n ------------------------------------------------------------
|
||||
var MESSAGES = {
|
||||
en: {
|
||||
page_title: "PPT Master - Confirm Design",
|
||||
topbar_hint: "Pick or type your choices, then click Confirm — the page closes and you return to the chat.",
|
||||
loading: "Loading…",
|
||||
load_error: "Could not load recommendations.json. The AI must write it before launch.",
|
||||
btn_confirm: "Confirm",
|
||||
already_confirmed: "Already confirmed once. Re-submitting overwrites the previous choices.",
|
||||
confirmed_title: "✓ Confirmed",
|
||||
confirmed_hint: "Your choices are saved. You can close this page and return to the chat.",
|
||||
lang_toggle_title: "Switch language",
|
||||
sec_canvas: "Canvas format",
|
||||
sec_pages: "Page count",
|
||||
sec_audience: "Target audience",
|
||||
sec_style: "Style objective",
|
||||
sec_color: "Color scheme",
|
||||
sec_icons: "Icon usage",
|
||||
sec_type: "Typography",
|
||||
sec_images: "Image usage",
|
||||
sec_mode: "Generation mode",
|
||||
sec_refine: "Refine spec first",
|
||||
sub_mode: "Narrative mode",
|
||||
sub_visual: "Visual style",
|
||||
custom: "Custom",
|
||||
custom_placeholder: "Type your own…",
|
||||
recommended: "Recommended",
|
||||
placeholder_audience: "Who is this deck for?",
|
||||
placeholder_pages: "e.g. 12-15",
|
||||
hex_override: "Custom HEX override:",
|
||||
formula_policy: "Formula rendering policy",
|
||||
image_ai_path: "AI image source",
|
||||
image_strategy: "Generated image style",
|
||||
image_strategy_empty: "No generated-image style candidates were provided.",
|
||||
image_strategy_rendering: "Rendering",
|
||||
image_strategy_palette: "Palette",
|
||||
image_strategy_visual: "Visual",
|
||||
image_strategy_color: "Color",
|
||||
image_strategy_mood: "Mood",
|
||||
image_usage_custom_required: "Describe the custom image plan before confirming.",
|
||||
font_heading: "Heading",
|
||||
font_body: "Body",
|
||||
font_body_size: "Body baseline size",
|
||||
font_body_size_hint: "All type sizes derive from this body baseline.",
|
||||
custom_typography: "Custom typography",
|
||||
custom_typography_placeholder: "Type your font plan, e.g. Heading: Georgia + KaiTi; Body: Microsoft YaHei + Arial…",
|
||||
role_background: "bg",
|
||||
role_secondary_bg: "2nd bg",
|
||||
role_primary: "primary",
|
||||
role_accent: "accent",
|
||||
role_secondary_accent: "2nd accent",
|
||||
role_body_text: "body text",
|
||||
cjk: "CJK",
|
||||
latin: "Latin",
|
||||
sample_cjk: "数字化转型战略",
|
||||
sample_latin: "Digital Transformation",
|
||||
mode_continuous_desc: "Generate the whole deck in one pass.",
|
||||
mode_split_desc: "Stop after the spec; resume SVG generation in a fresh window.",
|
||||
refine_off_desc: "Spec is written in one go; the pipeline auto-proceeds.",
|
||||
refine_on_desc: "Stop after the spec for review/revision before any generation.",
|
||||
off_default: "Off",
|
||||
on: "On",
|
||||
option_prefix: "Option",
|
||||
error_retry: "Error - retry"
|
||||
},
|
||||
zh: {
|
||||
page_title: "确认设计方案",
|
||||
topbar_hint: "选择或自定义各项后点「确认」;页面会关闭,请回到聊天窗口。",
|
||||
loading: "加载中…",
|
||||
load_error: "无法加载推荐文件,需在启动前写入。",
|
||||
btn_confirm: "确认",
|
||||
already_confirmed: "已确认过一次,重新提交会覆盖之前的选择。",
|
||||
confirmed_title: "✓ 已确认",
|
||||
confirmed_hint: "选择已保存,可关闭此页并回到聊天窗口。",
|
||||
lang_toggle_title: "切换语言",
|
||||
sec_canvas: "画布格式",
|
||||
sec_pages: "页数",
|
||||
sec_audience: "目标受众",
|
||||
sec_style: "风格目标",
|
||||
sec_color: "色彩方案",
|
||||
sec_icons: "图标使用",
|
||||
sec_type: "字体方案",
|
||||
sec_images: "图片使用",
|
||||
sec_mode: "生成模式",
|
||||
sec_refine: "先精修设计规范",
|
||||
sub_mode: "叙事模式",
|
||||
sub_visual: "视觉风格",
|
||||
custom: "自定义",
|
||||
custom_placeholder: "输入自定义内容…",
|
||||
recommended: "推荐",
|
||||
placeholder_audience: "这份演示文稿面向谁?",
|
||||
placeholder_pages: "如:12-15",
|
||||
hex_override: "自定义色值覆盖:",
|
||||
formula_policy: "公式渲染策略",
|
||||
image_ai_path: "生成配图来源",
|
||||
image_strategy: "生成图风格",
|
||||
image_strategy_empty: "还没有提供生成图风格候选。",
|
||||
image_strategy_rendering: "渲染风格",
|
||||
image_strategy_palette: "图像调色",
|
||||
image_strategy_visual: "视觉",
|
||||
image_strategy_color: "色彩",
|
||||
image_strategy_mood: "情绪",
|
||||
image_usage_custom_required: "请先写清楚自定义图片方案。",
|
||||
font_heading: "标题",
|
||||
font_body: "正文",
|
||||
font_body_size: "正文基准字号",
|
||||
font_body_size_hint: "所有字号按这个正文基准推导。",
|
||||
custom_typography: "自定义字体方案",
|
||||
custom_typography_placeholder: "输入字体方案,如:标题用楷体;正文用微软雅黑…",
|
||||
role_background: "背景",
|
||||
role_secondary_bg: "次级背景",
|
||||
role_primary: "主色",
|
||||
role_accent: "强调",
|
||||
role_secondary_accent: "次强调",
|
||||
role_body_text: "正文文字",
|
||||
cjk: "中文",
|
||||
latin: "西文",
|
||||
sample_cjk: "数字化转型战略",
|
||||
sample_latin: "Digital Transformation",
|
||||
mode_continuous_desc: "一次性连续生成整份演示文稿。",
|
||||
mode_split_desc: "写完设计规范后停止,另开窗口继续生成页面。",
|
||||
refine_off_desc: "设计规范一次写完,流程自动继续。",
|
||||
refine_on_desc: "写完设计规范后停下供你审阅或修改,再开始生成。",
|
||||
off_default: "关",
|
||||
on: "开",
|
||||
option_prefix: "方案",
|
||||
error_retry: "出错,请重试"
|
||||
}
|
||||
};
|
||||
|
||||
var LANG = (function () {
|
||||
try {
|
||||
var stored = window.localStorage.getItem("ppt_lang");
|
||||
if (stored === "zh" || stored === "en") return stored;
|
||||
} catch (e) { /* ignore */ }
|
||||
var nav = (navigator.language || navigator.userLanguage || "en").toLowerCase();
|
||||
return nav.indexOf("zh") === 0 ? "zh" : "en";
|
||||
})();
|
||||
|
||||
function t(key) {
|
||||
var dict = MESSAGES[LANG] || MESSAGES.en;
|
||||
return dict[key] != null ? dict[key] : key;
|
||||
}
|
||||
|
||||
function localized(obj, base) {
|
||||
if (!obj) return "";
|
||||
var langKey = base + "_" + LANG;
|
||||
var fallbackKey = base + "_" + (LANG === "zh" ? "en" : "zh");
|
||||
if (obj[langKey] != null) return obj[langKey];
|
||||
if (obj[base] != null) {
|
||||
if (typeof obj[base] === "object") {
|
||||
return obj[base][LANG] || obj[base].en || obj[base].zh || "";
|
||||
}
|
||||
return obj[base];
|
||||
}
|
||||
return obj[fallbackKey] || "";
|
||||
}
|
||||
|
||||
function optionLabel(option) {
|
||||
return localized(option, "label") || String(option && option.id);
|
||||
}
|
||||
|
||||
function optionDesc(option) {
|
||||
return localized(option, "desc");
|
||||
}
|
||||
|
||||
function groupLabel(group) {
|
||||
return localized(group, "group");
|
||||
}
|
||||
|
||||
function applyStaticTranslations() {
|
||||
document.documentElement.setAttribute("lang", LANG === "zh" ? "zh-CN" : "en");
|
||||
document.querySelectorAll("[data-i18n]").forEach(function (node) {
|
||||
node.textContent = t(node.getAttribute("data-i18n"));
|
||||
});
|
||||
}
|
||||
|
||||
function refreshLangToggle(toggleBtn) {
|
||||
toggleBtn.textContent = LANG === "zh" ? "EN" : "中";
|
||||
toggleBtn.title = t("lang_toggle_title");
|
||||
}
|
||||
|
||||
// ---- state -----------------------------------------------------------
|
||||
var CAT = null; // catalogs.json — finite option universe
|
||||
var REC = null; // recommendations.json — AI picks + candidates
|
||||
var STATE = {};
|
||||
var REC_ALIASES = {
|
||||
icons: {
|
||||
line: "tabler-outline",
|
||||
filled: "tabler-filled",
|
||||
monochrome: "chunk-filled"
|
||||
},
|
||||
image_usage: {
|
||||
search: "web"
|
||||
},
|
||||
image_ai_path: {
|
||||
default: "auto",
|
||||
builtin: "host-native"
|
||||
}
|
||||
};
|
||||
|
||||
// ---- DOM helpers -----------------------------------------------------
|
||||
function el(tag, cls, text) {
|
||||
var node = document.createElement(tag);
|
||||
if (cls) node.className = cls;
|
||||
if (text != null) node.textContent = text;
|
||||
return node;
|
||||
}
|
||||
|
||||
function section(num, titleKey, noteText) {
|
||||
var sec = el("div", "section");
|
||||
var head = el("div", "section-head");
|
||||
head.appendChild(el("span", "section-num", String(num)));
|
||||
head.appendChild(el("span", "section-title", t(titleKey)));
|
||||
if (noteText) head.appendChild(el("span", "section-note", noteText));
|
||||
sec.appendChild(head);
|
||||
return sec;
|
||||
}
|
||||
|
||||
function setSectionNote(sec, text) {
|
||||
var head = sec.querySelector(".section-head");
|
||||
var note = head.querySelector(".section-note");
|
||||
if (!note) {
|
||||
note = el("span", "section-note");
|
||||
head.appendChild(note);
|
||||
}
|
||||
note.textContent = text;
|
||||
}
|
||||
|
||||
function normalizeRecId(field, value) {
|
||||
if (value == null || value === "") return value;
|
||||
var aliases = REC_ALIASES[field] || {};
|
||||
return aliases[value] || value;
|
||||
}
|
||||
|
||||
function legacyRecId(field) {
|
||||
if (!REC) return null;
|
||||
if (field === "canvas") return REC.canvas && REC.canvas.value;
|
||||
if (field === "visual_style") return REC.visual_style || (REC.style && REC.style.value);
|
||||
if (field === "icons") return REC.icons && REC.icons.value;
|
||||
if (field === "image_usage") return REC.images && REC.images.value;
|
||||
if (field === "image_ai_path") return REC.image_ai_path || (REC.images && REC.images.ai_path);
|
||||
if (field === "formula_policy") return REC.typography && REC.typography.formula_policy && REC.typography.formula_policy.value;
|
||||
if (field === "generation_mode") return REC.generation_mode && REC.generation_mode.value;
|
||||
return REC[field] && REC[field].value;
|
||||
}
|
||||
|
||||
function recId(field) {
|
||||
var value = (REC && REC.recommend && REC.recommend[field]) || legacyRecId(field);
|
||||
return normalizeRecId(field, value || null);
|
||||
}
|
||||
// Guaranteed recommendation: the AI's pick, or the first catalog option as a
|
||||
// fallback so an enumerable field ALWAYS shows a badged recommendation.
|
||||
function recOrFirst(field, list) {
|
||||
var r = recId(field);
|
||||
if (r != null && r !== "") return r;
|
||||
return firstId(list);
|
||||
}
|
||||
|
||||
// Render an enumerable field: ALL options from the catalog, recommended one
|
||||
// badged, current selection from STATE, plus a trailing Custom box.
|
||||
// `list` is either a flat array of {id,label,desc,dim} or a grouped array
|
||||
// of {group, items:[...]}.
|
||||
function enumField(parent, list, recommendedId, getVal, setVal, opts2) {
|
||||
list = list || [];
|
||||
opts2 = opts2 || {};
|
||||
var grouped = list.length && list[0] && list[0].items;
|
||||
var flat = grouped ? list.reduce(function (a, g) { return a.concat(g.items || []); }, []) : list;
|
||||
var ids = flat.map(function (o) { return o.id; });
|
||||
var allowCustom = opts2.allowCustom === true; // only for fields not fully enumerable
|
||||
var customSentinel = opts2.customSentinel || "";
|
||||
var customInvalidValues = opts2.customInvalidValues || [];
|
||||
var cur = getVal();
|
||||
var isCustom = cur != null && cur !== "" && ids.indexOf(cur) === -1;
|
||||
if (!allowCustom && isCustom) {
|
||||
// closed field with an out-of-catalog value → snap to recommended/first
|
||||
cur = ids.indexOf(recommendedId) >= 0 ? recommendedId : ids[0];
|
||||
setVal(cur);
|
||||
isCustom = false;
|
||||
}
|
||||
|
||||
var allChips = [];
|
||||
var customInput = el("input", "text-input custom-input");
|
||||
if (opts2.inputClass) customInput.classList.add(opts2.inputClass);
|
||||
customInput.type = "text";
|
||||
customInput.placeholder = opts2.placeholder || t("custom_placeholder");
|
||||
customInput.style.display = "none";
|
||||
|
||||
function deselect() { allChips.forEach(function (c) { c.classList.remove("selected"); }); }
|
||||
function makeChip(o) {
|
||||
var label = optionLabel(o);
|
||||
if (o.dim) label += " · " + o.dim;
|
||||
var desc = optionDesc(o);
|
||||
if (desc) label += (LANG === "zh" ? ":" : " — ") + desc;
|
||||
var chip = el("div", "chip");
|
||||
chip.appendChild(el("span", "chip-text", label));
|
||||
if (o.id === recommendedId) {
|
||||
chip.classList.add("recommended");
|
||||
chip.appendChild(el("span", "rec-badge", "★ " + t("recommended")));
|
||||
}
|
||||
if (!isCustom && o.id === cur) chip.classList.add("selected");
|
||||
chip.addEventListener("click", function () {
|
||||
deselect();
|
||||
chip.classList.add("selected");
|
||||
customInput.style.display = "none";
|
||||
setVal(o.id);
|
||||
});
|
||||
allChips.push(chip);
|
||||
return chip;
|
||||
}
|
||||
|
||||
if (grouped) {
|
||||
list.forEach(function (g) {
|
||||
if (groupLabel(g)) parent.appendChild(el("div", "group-label", groupLabel(g)));
|
||||
var row = el("div", "chips");
|
||||
(g.items || []).forEach(function (o) { row.appendChild(makeChip(o)); });
|
||||
parent.appendChild(row);
|
||||
});
|
||||
if (allowCustom) {
|
||||
var lastRow = el("div", "chips");
|
||||
lastRow.appendChild(buildCustomChip());
|
||||
parent.appendChild(lastRow);
|
||||
}
|
||||
} else {
|
||||
var wrap = el("div", "chips");
|
||||
flat.forEach(function (o) { wrap.appendChild(makeChip(o)); });
|
||||
if (allowCustom) wrap.appendChild(buildCustomChip());
|
||||
parent.appendChild(wrap);
|
||||
}
|
||||
if (allowCustom) parent.appendChild(customInput);
|
||||
|
||||
function buildCustomChip() {
|
||||
var customChip = el("div", "chip", t("custom"));
|
||||
if (recommendedId && ids.indexOf(recommendedId) === -1) {
|
||||
customChip.classList.add("recommended");
|
||||
customChip.appendChild(el("span", "rec-badge", "★ " + t("recommended")));
|
||||
}
|
||||
if (isCustom) {
|
||||
customChip.classList.add("selected");
|
||||
customInput.style.display = "block";
|
||||
customInput.value = customInvalidValues.indexOf(cur) >= 0 ? "" : cur;
|
||||
}
|
||||
customChip.addEventListener("click", function () {
|
||||
deselect();
|
||||
customChip.classList.add("selected");
|
||||
customInput.style.display = "block";
|
||||
customInput.focus();
|
||||
setVal(customInput.value || customSentinel);
|
||||
});
|
||||
allChips.push(customChip);
|
||||
return customChip;
|
||||
}
|
||||
customInput.addEventListener("input", function () { setVal(customInput.value || customSentinel); });
|
||||
}
|
||||
|
||||
function textField(parent, getVal, setVal, placeholderKey, numeric) {
|
||||
var input = el("input", numeric ? "num-input" : "text-input");
|
||||
input.type = "text";
|
||||
input.value = getVal() || "";
|
||||
input.placeholder = t(placeholderKey);
|
||||
input.addEventListener("input", function () { setVal(input.value); });
|
||||
parent.appendChild(input);
|
||||
}
|
||||
|
||||
function normPalette(c) {
|
||||
function read(src, keys) {
|
||||
if (!src) return undefined;
|
||||
for (var i = 0; i < keys.length; i += 1) {
|
||||
if (src[keys[i]] != null) return src[keys[i]];
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
function collect(src) {
|
||||
return {
|
||||
background: read(src, ["background", "bg"]),
|
||||
secondary_bg: read(src, ["secondary_bg", "secondary_background", "card_bg", "card_background"]),
|
||||
primary: read(src, ["primary"]),
|
||||
accent: read(src, ["accent"]),
|
||||
secondary_accent: read(src, ["secondary_accent", "secondary"]),
|
||||
body_text: read(src, ["body_text", "text"])
|
||||
};
|
||||
}
|
||||
if (c && c.palette) {
|
||||
return collect(c.palette);
|
||||
}
|
||||
if (!c) return {};
|
||||
return collect(c);
|
||||
}
|
||||
|
||||
function normTypography(c) {
|
||||
c = c || {};
|
||||
if (c.heading && typeof c.heading === "object" && c.body && typeof c.body === "object") {
|
||||
return Object.assign({}, c, { body_size: typographyBodySize(c) });
|
||||
}
|
||||
return {
|
||||
name: c.name || "",
|
||||
note: c.note || "",
|
||||
custom: c.custom || "",
|
||||
body_size: typographyBodySize(c),
|
||||
heading: {
|
||||
cjk: c.heading || "",
|
||||
latin: c.heading_latin || "",
|
||||
css: c.heading_css || "",
|
||||
sample_cjk: c.sample_heading || "",
|
||||
sample_latin: c.sample_heading_latin || ""
|
||||
},
|
||||
body: {
|
||||
cjk: c.body || "",
|
||||
latin: c.body_latin || "",
|
||||
css: c.body_css || "",
|
||||
sample_cjk: c.sample_body || "",
|
||||
sample_latin: c.sample_body_latin || ""
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function typographyBodySize(c) {
|
||||
c = c || {};
|
||||
var value = c.body_size || c.body_baseline || c.body_px ||
|
||||
(c.sizes && c.sizes.body) ||
|
||||
(c.size && c.size.body) ||
|
||||
(c.body && typeof c.body === "object" && (c.body.size || c.body.font_size));
|
||||
return value == null ? "" : String(value).replace(/px$/i, "");
|
||||
}
|
||||
|
||||
function imageStrategySpec() {
|
||||
return (REC && REC.image_strategy) ||
|
||||
(REC && REC.images && REC.images.strategy) ||
|
||||
(REC && REC.images && REC.images.ai_strategy) ||
|
||||
{};
|
||||
}
|
||||
|
||||
function imageStrategyCandidates() {
|
||||
var spec = imageStrategySpec();
|
||||
return spec.candidates || spec.options || [];
|
||||
}
|
||||
|
||||
function imageStrategySelectedIndex() {
|
||||
var spec = imageStrategySpec();
|
||||
var idx = spec.selected || 0;
|
||||
return Math.min(idx, Math.max(imageStrategyCandidates().length - 1, 0));
|
||||
}
|
||||
|
||||
// ---- section renderers ----------------------------------------------
|
||||
function renderCanvas(host) {
|
||||
var sec = section(1, "sec_canvas");
|
||||
enumField(sec, CAT.canvas, recOrFirst("canvas", CAT.canvas),
|
||||
function () { return STATE.canvas; }, function (v) { STATE.canvas = v; }, { allowCustom: true });
|
||||
host.appendChild(sec);
|
||||
}
|
||||
|
||||
function renderPages(host) {
|
||||
var sec = section(2, "sec_pages");
|
||||
textField(sec, function () { return STATE.page_count; },
|
||||
function (v) { STATE.page_count = v; }, "placeholder_pages", true);
|
||||
host.appendChild(sec);
|
||||
}
|
||||
|
||||
function renderAudience(host) {
|
||||
var sec = section(3, "sec_audience");
|
||||
textField(sec, function () { return STATE.audience; },
|
||||
function (v) { STATE.audience = v; }, "placeholder_audience", false);
|
||||
host.appendChild(sec);
|
||||
}
|
||||
|
||||
function renderStyle(host) {
|
||||
var sec = section(4, "sec_style");
|
||||
sec.appendChild(el("div", "subfield-label", t("sub_mode")));
|
||||
enumField(sec, CAT.modes, recOrFirst("mode", CAT.modes),
|
||||
function () { return STATE.mode; }, function (v) { STATE.mode = v; }, { allowCustom: true });
|
||||
var sub2 = el("div", "subfield");
|
||||
sub2.appendChild(el("div", "subfield-label", t("sub_visual")));
|
||||
enumField(sub2, CAT.visual_styles, recOrFirst("visual_style", CAT.visual_styles),
|
||||
function () { return STATE.visual_style; }, function (v) { STATE.visual_style = v; }, { allowCustom: true });
|
||||
sec.appendChild(sub2);
|
||||
host.appendChild(sec);
|
||||
}
|
||||
|
||||
var PALETTE_ROLES = [
|
||||
"background",
|
||||
"secondary_bg",
|
||||
"primary",
|
||||
"accent",
|
||||
"secondary_accent",
|
||||
"body_text"
|
||||
];
|
||||
|
||||
function renderColor(host) {
|
||||
var cands = (REC.color && REC.color.candidates) || [];
|
||||
var sec = section(5, "sec_color");
|
||||
var grid = el("div", "color-grid");
|
||||
var hexInputs = {};
|
||||
|
||||
function applyHexInputs(pal) {
|
||||
PALETTE_ROLES.forEach(function (role) { if (hexInputs[role]) hexInputs[role].value = pal[role] || ""; });
|
||||
}
|
||||
function selectCard(idx) {
|
||||
var c = cands[idx] || {};
|
||||
STATE.color = { name: c.name || "", palette: Object.assign({}, normPalette(c)) };
|
||||
grid.querySelectorAll(".color-card").forEach(function (card, i) { card.classList.toggle("selected", i === idx); });
|
||||
applyHexInputs(STATE.color.palette);
|
||||
}
|
||||
|
||||
cands.forEach(function (c, idx) {
|
||||
var pal = normPalette(c);
|
||||
var card = el("div", "color-card");
|
||||
var sw = el("div", "swatches");
|
||||
PALETTE_ROLES.forEach(function (role) {
|
||||
if (!pal[role]) return;
|
||||
var col = el("div", "swatch-col");
|
||||
var s = el("div", "swatch"); s.style.background = pal[role];
|
||||
col.appendChild(s); col.appendChild(el("div", "swatch-role", t("role_" + role)));
|
||||
sw.appendChild(col);
|
||||
});
|
||||
card.appendChild(sw);
|
||||
card.appendChild(el("div", "color-name", localized(c, "name") || (t("option_prefix") + " " + (idx + 1))));
|
||||
if (localized(c, "note")) card.appendChild(el("div", "color-note", localized(c, "note")));
|
||||
card.addEventListener("click", function () { selectCard(idx); });
|
||||
grid.appendChild(card);
|
||||
});
|
||||
sec.appendChild(grid);
|
||||
|
||||
var override = el("div", "hex-override");
|
||||
override.appendChild(el("div", "subfield-label", t("hex_override")));
|
||||
var row = el("div", "hex-row");
|
||||
PALETTE_ROLES.forEach(function (role) {
|
||||
var wrap = el("div", "hex-cell");
|
||||
wrap.appendChild(el("div", "hex-cell-label", t("role_" + role)));
|
||||
var inp = document.createElement("input");
|
||||
inp.type = "text"; inp.placeholder = "#";
|
||||
inp.addEventListener("input", function () {
|
||||
if (!STATE.color) STATE.color = { name: "custom", palette: {} };
|
||||
if (!STATE.color.palette) STATE.color.palette = {};
|
||||
STATE.color.palette[role] = inp.value;
|
||||
});
|
||||
hexInputs[role] = inp; wrap.appendChild(inp); row.appendChild(wrap);
|
||||
});
|
||||
override.appendChild(row);
|
||||
sec.appendChild(override);
|
||||
host.appendChild(sec);
|
||||
|
||||
var selIdx = -1;
|
||||
if (STATE.color && STATE.color.name) cands.forEach(function (c, i) { if (c.name === STATE.color.name) selIdx = i; });
|
||||
if (selIdx >= 0) selectCard(selIdx);
|
||||
else applyHexInputs((STATE.color && STATE.color.palette) || {});
|
||||
}
|
||||
|
||||
function renderIcons(host) {
|
||||
var sec = section(6, "sec_icons");
|
||||
enumField(sec, CAT.icons, recOrFirst("icons", CAT.icons),
|
||||
function () { return STATE.icons; }, function (v) { STATE.icons = v; }, { allowCustom: true });
|
||||
host.appendChild(sec);
|
||||
}
|
||||
|
||||
function fontSample(box, slot, css) {
|
||||
var line = el("div", "font-sample-line");
|
||||
var cjk = el("span", "fs-cjk", slot.sample_cjk || t("sample_cjk"));
|
||||
var lat = el("span", "fs-latin", slot.sample_latin || t("sample_latin"));
|
||||
if (css) { cjk.style.fontFamily = css; lat.style.fontFamily = css; }
|
||||
line.appendChild(cjk); line.appendChild(lat); box.appendChild(line);
|
||||
}
|
||||
|
||||
function renderTypography(host) {
|
||||
var f = REC.typography || {};
|
||||
var cands = f.candidates || [];
|
||||
var sec = section(7, "sec_type");
|
||||
var grid = el("div", "font-grid");
|
||||
var customInput = el("textarea", "text-input custom-typography-input");
|
||||
customInput.rows = 2;
|
||||
customInput.placeholder = t("custom_typography_placeholder");
|
||||
customInput.style.display = "none";
|
||||
|
||||
function selectFont(idx) {
|
||||
var c = normTypography(cands[idx] || {});
|
||||
STATE.typography = {
|
||||
name: c.name || "",
|
||||
heading: c.heading || {},
|
||||
body: c.body || {},
|
||||
body_size: c.body_size || (STATE.typography && STATE.typography.body_size) || ""
|
||||
};
|
||||
if (sizeInput) sizeInput.value = STATE.typography.body_size || "";
|
||||
customInput.style.display = "none";
|
||||
grid.querySelectorAll(".font-card").forEach(function (card, i) { card.classList.toggle("selected", i === idx); });
|
||||
}
|
||||
|
||||
function selectCustomTypography() {
|
||||
STATE.typography = {
|
||||
name: "custom",
|
||||
custom: customInput.value || "",
|
||||
heading: {},
|
||||
body: {},
|
||||
body_size: (STATE.typography && STATE.typography.body_size) || ""
|
||||
};
|
||||
grid.querySelectorAll(".font-card").forEach(function (card) { card.classList.remove("selected"); });
|
||||
customCard.classList.add("selected");
|
||||
customInput.style.display = "block";
|
||||
customInput.focus();
|
||||
}
|
||||
|
||||
cands.forEach(function (c, idx) {
|
||||
c = normTypography(c);
|
||||
var head = c.heading || {}, body = c.body || {};
|
||||
var card = el("div", "font-card");
|
||||
var top = el("div", "font-card-head");
|
||||
top.appendChild(el("span", "font-card-name", localized(c, "name") || (t("option_prefix") + " " + (idx + 1))));
|
||||
var meta = t("font_heading") + " " + t("cjk") + ":" + (head.cjk || "—") + " / " + t("latin") + ":" + (head.latin || "—")
|
||||
+ " · " + t("font_body") + " " + t("cjk") + ":" + (body.cjk || "—") + " / " + t("latin") + ":" + (body.latin || "—");
|
||||
if (c.body_size) meta += " · " + t("font_body_size") + ":" + c.body_size + "px";
|
||||
top.appendChild(el("span", "font-card-meta", meta));
|
||||
card.appendChild(top);
|
||||
var hbox = el("div", "font-sample-heading-box"); fontSample(hbox, head, head.css); card.appendChild(hbox);
|
||||
var bbox = el("div", "font-sample-body-box"); fontSample(bbox, body, body.css); card.appendChild(bbox);
|
||||
if (localized(c, "note")) card.appendChild(el("div", "color-note", localized(c, "note")));
|
||||
card.addEventListener("click", function () { selectFont(idx); });
|
||||
grid.appendChild(card);
|
||||
});
|
||||
var customCard = el("div", "font-card font-card-custom");
|
||||
customCard.appendChild(el("div", "font-card-name", t("custom_typography")));
|
||||
customCard.addEventListener("click", selectCustomTypography);
|
||||
grid.appendChild(customCard);
|
||||
sec.appendChild(grid);
|
||||
customInput.addEventListener("input", function () {
|
||||
if (!STATE.typography || STATE.typography.name !== "custom") selectCustomTypography();
|
||||
STATE.typography.custom = customInput.value;
|
||||
});
|
||||
sec.appendChild(customInput);
|
||||
|
||||
var sizeField = el("div", "subfield");
|
||||
sizeField.appendChild(el("div", "subfield-label", t("font_body_size")));
|
||||
var sizeRow = el("div", "font-size-row");
|
||||
var sizeInput = el("input", "num-input font-size-input");
|
||||
sizeInput.type = "number";
|
||||
sizeInput.min = "8";
|
||||
sizeInput.max = "96";
|
||||
sizeInput.step = "1";
|
||||
sizeInput.value = (STATE.typography && STATE.typography.body_size) || "";
|
||||
sizeInput.placeholder = "18 / 24";
|
||||
sizeInput.addEventListener("input", function () {
|
||||
if (!STATE.typography) STATE.typography = { name: "", heading: {}, body: {} };
|
||||
STATE.typography.body_size = sizeInput.value;
|
||||
});
|
||||
sizeRow.appendChild(sizeInput);
|
||||
sizeRow.appendChild(el("div", "toggle-desc", t("font_body_size_hint")));
|
||||
sizeField.appendChild(sizeRow);
|
||||
sec.appendChild(sizeField);
|
||||
|
||||
var subfp = el("div", "subfield");
|
||||
subfp.appendChild(el("div", "subfield-label", t("formula_policy")));
|
||||
enumField(subfp, CAT.formula_policy, recOrFirst("formula_policy", CAT.formula_policy),
|
||||
function () { return STATE.formula_policy; }, function (v) { STATE.formula_policy = v; });
|
||||
sec.appendChild(subfp);
|
||||
host.appendChild(sec);
|
||||
|
||||
var selIdx = -1;
|
||||
if (STATE.typography && STATE.typography.name) cands.forEach(function (c, i) { if (c.name === STATE.typography.name) selIdx = i; });
|
||||
if (selIdx >= 0) selectFont(selIdx);
|
||||
else if (STATE.typography && STATE.typography.name === "custom") {
|
||||
customInput.value = STATE.typography.custom || "";
|
||||
customCard.classList.add("selected");
|
||||
customInput.style.display = "block";
|
||||
}
|
||||
}
|
||||
|
||||
function renderImages(host) {
|
||||
var sec = section(8, "sec_images");
|
||||
var sub = el("div", "subfield");
|
||||
sub.appendChild(el("div", "subfield-label", t("image_ai_path")));
|
||||
var strategySub = el("div", "subfield image-strategy-subfield");
|
||||
strategySub.appendChild(el("div", "subfield-label", t("image_strategy")));
|
||||
var strategyGrid = el("div", "font-grid");
|
||||
var strategyCands = imageStrategyCandidates();
|
||||
function usesCustomImagePlan() {
|
||||
var ids = (CAT.image_usage || []).map(function (item) { return item.id; });
|
||||
return STATE.image_usage && ids.indexOf(STATE.image_usage) === -1;
|
||||
}
|
||||
function needsGeneratedImages() {
|
||||
return STATE.image_usage === "ai" || usesCustomImagePlan();
|
||||
}
|
||||
function refreshAiControls() {
|
||||
var needsAiPath = needsGeneratedImages();
|
||||
sub.style.display = needsAiPath ? "block" : "none";
|
||||
strategySub.style.display = needsAiPath ? "block" : "none";
|
||||
}
|
||||
function selectImageStrategy(idx) {
|
||||
var c = strategyCands[idx] || {};
|
||||
STATE.image_strategy = {
|
||||
name: localized(c, "name") || c.name || "",
|
||||
rendering: c.rendering || "",
|
||||
palette: c.palette || "",
|
||||
visual: localized(c, "visual") || "",
|
||||
color: localized(c, "color") || "",
|
||||
mood: localized(c, "mood") || ""
|
||||
};
|
||||
strategyGrid.querySelectorAll(".font-card").forEach(function (card, i) { card.classList.toggle("selected", i === idx); });
|
||||
}
|
||||
strategyCands.forEach(function (c, idx) {
|
||||
var card = el("div", "font-card");
|
||||
var top = el("div", "font-card-head");
|
||||
top.appendChild(el("span", "font-card-name", localized(c, "name") || (t("option_prefix") + " " + (idx + 1))));
|
||||
var meta = [];
|
||||
if (c.rendering) meta.push(t("image_strategy_rendering") + ":" + c.rendering);
|
||||
if (c.palette) meta.push(t("image_strategy_palette") + ":" + c.palette);
|
||||
if (meta.length) top.appendChild(el("span", "font-card-meta", meta.join(" · ")));
|
||||
card.appendChild(top);
|
||||
[
|
||||
["image_strategy_visual", localized(c, "visual")],
|
||||
["image_strategy_color", localized(c, "color")],
|
||||
["image_strategy_mood", localized(c, "mood")]
|
||||
].forEach(function (row) {
|
||||
if (row[1]) card.appendChild(el("div", "color-note", t(row[0]) + ":" + row[1]));
|
||||
});
|
||||
card.addEventListener("click", function () { selectImageStrategy(idx); });
|
||||
strategyGrid.appendChild(card);
|
||||
});
|
||||
if (!strategyCands.length) strategyGrid.appendChild(el("div", "toggle-desc", t("image_strategy_empty")));
|
||||
strategySub.appendChild(strategyGrid);
|
||||
enumField(sec, CAT.image_usage, recOrFirst("image_usage", CAT.image_usage),
|
||||
function () { return STATE.image_usage; },
|
||||
function (v) {
|
||||
STATE.image_usage = v;
|
||||
refreshAiControls();
|
||||
},
|
||||
{
|
||||
allowCustom: true,
|
||||
customSentinel: "custom",
|
||||
customInvalidValues: ["custom"],
|
||||
inputClass: "image-usage-custom-input",
|
||||
placeholder: LANG === "zh"
|
||||
? "例如:封面用 AI 生成,产品页用用户素材,行业页用网络来源"
|
||||
: "e.g. AI cover + user product assets + web industry images"
|
||||
});
|
||||
enumField(sub, CAT.image_ai_path, recOrFirst("image_ai_path", CAT.image_ai_path),
|
||||
function () { return STATE.image_ai_path; }, function (v) { STATE.image_ai_path = v; });
|
||||
sec.appendChild(sub);
|
||||
sec.appendChild(strategySub);
|
||||
if (strategyCands.length) selectImageStrategy(imageStrategySelectedIndex());
|
||||
refreshAiControls();
|
||||
host.appendChild(sec);
|
||||
}
|
||||
|
||||
function renderMode(host) {
|
||||
var sec = section("M", "sec_mode");
|
||||
function refresh() {
|
||||
setSectionNote(sec, STATE.generation_mode === "split" ? t("mode_split_desc") : t("mode_continuous_desc"));
|
||||
}
|
||||
enumField(sec, CAT.generation_mode, recOrFirst("generation_mode", CAT.generation_mode),
|
||||
function () { return STATE.generation_mode; }, function (v) { STATE.generation_mode = v; refresh(); });
|
||||
refresh();
|
||||
host.appendChild(sec);
|
||||
}
|
||||
|
||||
function renderRefine(host) {
|
||||
var sec = section("R", "sec_refine");
|
||||
var opts = [{ id: "off", label: t("off_default") }, { id: "on", label: t("on") }];
|
||||
function refresh() {
|
||||
setSectionNote(sec, STATE.refine_spec ? t("refine_on_desc") : t("refine_off_desc"));
|
||||
}
|
||||
enumField(sec, opts, STATE.refine_spec ? "on" : "off",
|
||||
function () { return STATE.refine_spec ? "on" : "off"; },
|
||||
function (v) { STATE.refine_spec = (v === "on"); refresh(); });
|
||||
refresh();
|
||||
host.appendChild(sec);
|
||||
}
|
||||
|
||||
function renderAll() {
|
||||
var host = document.getElementById("sections");
|
||||
host.innerHTML = "";
|
||||
renderCanvas(host);
|
||||
renderPages(host);
|
||||
renderAudience(host);
|
||||
renderStyle(host);
|
||||
renderColor(host);
|
||||
renderIcons(host);
|
||||
renderTypography(host);
|
||||
renderImages(host);
|
||||
renderMode(host);
|
||||
renderRefine(host);
|
||||
}
|
||||
|
||||
// ---- state init (once) ----------------------------------------------
|
||||
function firstId(list) {
|
||||
if (!list || !list.length) return undefined;
|
||||
if (list[0].items) return (list[0].items[0] || {}).id;
|
||||
return list[0].id;
|
||||
}
|
||||
function pick(field, catList) {
|
||||
return recOrFirst(field, catList);
|
||||
}
|
||||
|
||||
function initState() {
|
||||
STATE.canvas = pick("canvas", CAT.canvas);
|
||||
STATE.page_count = (REC.page_count && REC.page_count.value != null) ? String(REC.page_count.value) : "";
|
||||
STATE.audience = (REC.audience && REC.audience.value) || "";
|
||||
STATE.mode = pick("mode", CAT.modes);
|
||||
STATE.visual_style = pick("visual_style", CAT.visual_styles);
|
||||
|
||||
var cc = (REC.color && REC.color.candidates) || [];
|
||||
var csel = (REC.color && REC.color.selected) || 0;
|
||||
var c0 = cc[Math.min(csel, Math.max(cc.length - 1, 0))] || {};
|
||||
STATE.color = { name: c0.name || "", palette: Object.assign({}, normPalette(c0)) };
|
||||
|
||||
STATE.icons = pick("icons", CAT.icons);
|
||||
|
||||
var tc = (REC.typography && REC.typography.candidates) || [];
|
||||
var tsel = (REC.typography && REC.typography.selected) || 0;
|
||||
var t0 = normTypography(tc[Math.min(tsel, Math.max(tc.length - 1, 0))] || {});
|
||||
STATE.typography = {
|
||||
name: t0.name || "",
|
||||
heading: t0.heading || {},
|
||||
body: t0.body || {},
|
||||
body_size: t0.body_size || typographyBodySize(REC.typography)
|
||||
};
|
||||
STATE.formula_policy = pick("formula_policy", CAT.formula_policy);
|
||||
|
||||
STATE.image_usage = pick("image_usage", CAT.image_usage);
|
||||
STATE.image_ai_path = pick("image_ai_path", CAT.image_ai_path);
|
||||
|
||||
STATE.generation_mode = pick("generation_mode", CAT.generation_mode);
|
||||
STATE.refine_spec = !!((REC.refine_spec && REC.refine_spec.value) || (REC.recommend && REC.recommend.refine_spec));
|
||||
}
|
||||
|
||||
// ---- confirm + close -------------------------------------------------
|
||||
function showConfirmedOverlay() {
|
||||
var ov = document.getElementById("confirmed-overlay");
|
||||
ov.querySelector(".cf-title").textContent = t("confirmed_title");
|
||||
ov.querySelector(".cf-hint").textContent = t("confirmed_hint");
|
||||
ov.style.display = "flex";
|
||||
}
|
||||
|
||||
function confirm() {
|
||||
var btn = document.getElementById("btn-confirm");
|
||||
var payload = Object.assign({}, STATE);
|
||||
var imageUsageIds = (CAT.image_usage || []).map(function (item) { return item.id; });
|
||||
var customImagePlan = payload.image_usage && imageUsageIds.indexOf(payload.image_usage) === -1;
|
||||
if (payload.image_usage === "custom" || (customImagePlan && !String(payload.image_usage).trim())) {
|
||||
document.getElementById("confirm-status").textContent = t("image_usage_custom_required");
|
||||
var customImageInput = document.querySelector(".image-usage-custom-input");
|
||||
if (customImageInput) customImageInput.focus();
|
||||
return;
|
||||
}
|
||||
if (customImagePlan) payload.image_usage = String(payload.image_usage).trim();
|
||||
if (payload.image_usage !== "ai" && !customImagePlan) {
|
||||
delete payload.image_ai_path;
|
||||
delete payload.image_strategy;
|
||||
}
|
||||
btn.disabled = true;
|
||||
fetch("/api/confirm", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload)
|
||||
}).then(function (r) {
|
||||
if (!r.ok) throw new Error("confirm failed");
|
||||
showConfirmedOverlay();
|
||||
fetch("/api/shutdown", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ reason: "confirmed" })
|
||||
}).catch(function () { /* server already gone — fine */ });
|
||||
setTimeout(function () { try { window.close(); } catch (e) { /* ignore */ } }, 400);
|
||||
}).catch(function () {
|
||||
btn.disabled = false;
|
||||
document.getElementById("confirm-status").textContent = t("error_retry");
|
||||
});
|
||||
}
|
||||
|
||||
// ---- boot ------------------------------------------------------------
|
||||
function showError(msg) {
|
||||
document.getElementById("loading").style.display = "none";
|
||||
var e = document.getElementById("error");
|
||||
e.style.display = "block";
|
||||
e.textContent = msg;
|
||||
}
|
||||
|
||||
function loadCatalogs() {
|
||||
return fetch("/api/catalogs")
|
||||
.then(function (r) { if (r.ok) return r.json(); throw new Error("no api"); })
|
||||
.catch(function () { return fetch("/static/catalogs.json").then(function (r) { return r.json(); }); });
|
||||
}
|
||||
|
||||
function boot() {
|
||||
applyStaticTranslations();
|
||||
var toggleBtn = document.getElementById("btn-lang-toggle");
|
||||
refreshLangToggle(toggleBtn);
|
||||
toggleBtn.addEventListener("click", function () {
|
||||
LANG = (LANG === "zh") ? "en" : "zh";
|
||||
try { window.localStorage.setItem("ppt_lang", LANG); } catch (e) { /* ignore */ }
|
||||
applyStaticTranslations();
|
||||
refreshLangToggle(toggleBtn);
|
||||
if (REC && CAT) renderAll(); // STATE persists → selections preserved
|
||||
});
|
||||
document.getElementById("btn-confirm").addEventListener("click", confirm);
|
||||
|
||||
Promise.all([
|
||||
loadCatalogs(),
|
||||
fetch("/api/recommendations").then(function (r) { if (!r.ok) throw new Error("load failed"); return r.json(); })
|
||||
]).then(function (res) {
|
||||
CAT = res[0];
|
||||
REC = res[1];
|
||||
if (REC.lang === "zh" || REC.lang === "en") {
|
||||
var hasStored = false;
|
||||
try { hasStored = !!window.localStorage.getItem("ppt_lang"); } catch (e) { /* ignore */ }
|
||||
if (!hasStored) { LANG = REC.lang; applyStaticTranslations(); refreshLangToggle(toggleBtn); }
|
||||
}
|
||||
initState();
|
||||
document.getElementById("loading").style.display = "none";
|
||||
document.getElementById("sections").style.display = "block";
|
||||
document.getElementById("actionbar").style.display = "flex";
|
||||
renderAll();
|
||||
if (REC._already_confirmed) {
|
||||
document.getElementById("confirm-status").textContent = t("already_confirmed");
|
||||
}
|
||||
}).catch(function () {
|
||||
showError(t("load_error"));
|
||||
});
|
||||
}
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", boot);
|
||||
} else {
|
||||
boot();
|
||||
}
|
||||
})();
|
||||
+452
@@ -0,0 +1,452 @@
|
||||
{
|
||||
"_comment": "Enumerable (finite) option universe for the confirm page. Served by Flask static at /static/catalogs.json; the front-end prefers /api/catalogs when the server provides it. These fields list ALL options and the AI marks one as recommended via recommendations.json `recommend`. Open/generative fields (color, typography, generated-image style) are NOT here — the AI authors a few candidates for those. 'canvas' mirrors scripts/config.py CANVAS_FORMATS — keep in sync. User-facing catalog text supports label_zh/label_en, desc_zh/desc_en, group_zh/group_en.",
|
||||
"canvas": [
|
||||
{
|
||||
"id": "ppt169",
|
||||
"label": "PPT 16:9",
|
||||
"label_zh": "宽屏演示 16:9",
|
||||
"label_en": "PPT 16:9",
|
||||
"dim": "1280×720",
|
||||
"use_zh": "现代投影 / 在线演示",
|
||||
"use_en": "Modern projectors / online"
|
||||
},
|
||||
{
|
||||
"id": "ppt43",
|
||||
"label": "PPT 4:3",
|
||||
"label_zh": "传统演示 4:3",
|
||||
"label_en": "PPT 4:3",
|
||||
"dim": "1024×768",
|
||||
"use_zh": "传统投影",
|
||||
"use_en": "Traditional projectors"
|
||||
},
|
||||
{
|
||||
"id": "wechat",
|
||||
"label": "WeChat Article Header",
|
||||
"label_zh": "微信公众号头图",
|
||||
"label_en": "WeChat Article Header",
|
||||
"dim": "900×383",
|
||||
"use_zh": "微信公众号文章封面",
|
||||
"use_en": "WeChat article cover"
|
||||
},
|
||||
{
|
||||
"id": "xiaohongshu",
|
||||
"label": "Xiaohongshu (RED)",
|
||||
"label_zh": "小红书图文",
|
||||
"label_en": "Xiaohongshu (RED)",
|
||||
"dim": "1242×1660",
|
||||
"use_zh": "知识分享 / 种草封面",
|
||||
"use_en": "Knowledge sharing"
|
||||
},
|
||||
{
|
||||
"id": "moments",
|
||||
"label": "WeChat Moments / IG",
|
||||
"label_zh": "社交方图",
|
||||
"label_en": "WeChat Moments / IG",
|
||||
"dim": "1080×1080",
|
||||
"use_zh": "社交媒体方图",
|
||||
"use_en": "Social square"
|
||||
},
|
||||
{
|
||||
"id": "story",
|
||||
"label": "Story / TikTok",
|
||||
"label_zh": "竖屏短视频",
|
||||
"label_en": "Story / TikTok",
|
||||
"dim": "1080×1920",
|
||||
"use_zh": "短视频 / 竖屏封面",
|
||||
"use_en": "Short-video / story cover"
|
||||
},
|
||||
{
|
||||
"id": "banner",
|
||||
"label": "Landscape Banner",
|
||||
"label_zh": "横幅",
|
||||
"label_en": "Landscape Banner",
|
||||
"dim": "1920×1080",
|
||||
"use_zh": "网页横幅 / 大屏",
|
||||
"use_en": "Web banner / big screen"
|
||||
},
|
||||
{
|
||||
"id": "a4",
|
||||
"label": "A4 Print",
|
||||
"label_zh": "A4 打印",
|
||||
"label_en": "A4 Print",
|
||||
"dim": "1240×1754",
|
||||
"use_zh": "打印 / 文档导出",
|
||||
"use_en": "Print / PDF export"
|
||||
}
|
||||
],
|
||||
"modes": [
|
||||
{
|
||||
"id": "pyramid",
|
||||
"label": "pyramid",
|
||||
"label_zh": "金字塔",
|
||||
"label_en": "pyramid",
|
||||
"desc_zh": "结论先行、相互独立且完全穷尽的论证、每个数据都带比较。适合决策、分析、战略、汇报。",
|
||||
"desc_en": "Lead with the answer, use MECE reasoning, and compare every key data point. Best for decisions, analysis, strategy, and reporting."
|
||||
},
|
||||
{
|
||||
"id": "narrative",
|
||||
"label": "narrative",
|
||||
"label_zh": "叙事",
|
||||
"label_en": "narrative",
|
||||
"desc_zh": "情境、张力、解决方案形成故事弧线,保留悬念与转折。适合路演、案例、品牌、融资。",
|
||||
"desc_en": "Build a story arc from context to tension to resolution, with suspense and turns. Best for roadshows, cases, brand stories, and fundraising."
|
||||
},
|
||||
{
|
||||
"id": "instructional",
|
||||
"label": "instructional",
|
||||
"label_zh": "教学",
|
||||
"label_en": "instructional",
|
||||
"desc_zh": "概念拆解、分步推进、并列讲解。适合培训、教程、科普、知识分享。",
|
||||
"desc_en": "Break concepts down, advance step by step, and explain parallel ideas clearly. Best for training, tutorials, popular science, and knowledge sharing."
|
||||
},
|
||||
{
|
||||
"id": "showcase",
|
||||
"label": "showcase",
|
||||
"label_zh": "展演",
|
||||
"label_en": "showcase",
|
||||
"desc_zh": "视觉主导,大图、大数和情绪节奏优先。适合发布、品牌秀、活动。",
|
||||
"desc_en": "Use visual rhythm, large images, and strong numbers as the lead. Best for launches, brand showcases, and events."
|
||||
},
|
||||
{
|
||||
"id": "briefing",
|
||||
"label": "briefing",
|
||||
"label_zh": "简报",
|
||||
"label_en": "briefing",
|
||||
"desc_zh": "中性完整、便于速览,采用主题式标题和均匀权重。适合周报、参考材料、常见问题、会议包。",
|
||||
"desc_en": "Stay neutral, complete, and scannable with topical headings and balanced weight. Best for weekly reports, reference packs, FAQs, and meeting decks."
|
||||
}
|
||||
],
|
||||
"visual_styles": [
|
||||
{
|
||||
"group": "Corporate / product",
|
||||
"group_zh": "企业 / 产品",
|
||||
"group_en": "Corporate / product",
|
||||
"items": [
|
||||
{
|
||||
"id": "swiss-minimal",
|
||||
"label": "swiss-minimal",
|
||||
"label_zh": "瑞士极简",
|
||||
"label_en": "swiss-minimal",
|
||||
"desc_zh": "网格锁定、锐利层级、大留白、少装饰。",
|
||||
"desc_en": "Grid-locked layout, sharp hierarchy, generous whitespace, and minimal decoration."
|
||||
},
|
||||
{
|
||||
"id": "soft-rounded",
|
||||
"label": "soft-rounded",
|
||||
"label_zh": "柔和圆角",
|
||||
"label_en": "soft-rounded",
|
||||
"desc_zh": "圆角卡片、柔和投影、亲和现代。",
|
||||
"desc_en": "Rounded cards, soft shadows, and a friendly modern tone."
|
||||
},
|
||||
{
|
||||
"id": "glassmorphism",
|
||||
"label": "glassmorphism",
|
||||
"label_zh": "玻璃拟态",
|
||||
"label_en": "glassmorphism",
|
||||
"desc_zh": "半透明玻璃面板、渐变光感、漂浮层次。",
|
||||
"desc_en": "Translucent glass panels, gradient light, and floating depth."
|
||||
},
|
||||
{
|
||||
"id": "dark-tech",
|
||||
"label": "dark-tech",
|
||||
"label_zh": "深色科技",
|
||||
"label_en": "dark-tech",
|
||||
"desc_zh": "深色画布、发光强调、几何精确。",
|
||||
"desc_en": "Dark canvas, glowing accents, and precise geometry."
|
||||
},
|
||||
{
|
||||
"id": "blueprint",
|
||||
"label": "blueprint",
|
||||
"label_zh": "蓝图",
|
||||
"label_en": "blueprint",
|
||||
"desc_zh": "深底蓝图线稿、等距结构、工程标注。",
|
||||
"desc_en": "Dark blueprint linework, isometric structure, and engineering annotations."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Editorial / publication",
|
||||
"group_zh": "编辑 / 出版",
|
||||
"group_en": "Editorial / publication",
|
||||
"items": [
|
||||
{
|
||||
"id": "editorial",
|
||||
"label": "editorial",
|
||||
"label_zh": "编辑出版",
|
||||
"label_en": "editorial",
|
||||
"desc_zh": "杂志式层级、栏线、衬线与无衬线交织。",
|
||||
"desc_en": "Magazine hierarchy, column rules, and a serif/sans-serif mix."
|
||||
},
|
||||
{
|
||||
"id": "photo-editorial",
|
||||
"label": "photo-editorial",
|
||||
"label_zh": "摄影编辑",
|
||||
"label_en": "photo-editorial",
|
||||
"desc_zh": "满版摄影主导,文字点缀与图注配合。",
|
||||
"desc_en": "Full-bleed photography leads, supported by light text and captions."
|
||||
},
|
||||
{
|
||||
"id": "data-journalism",
|
||||
"label": "data-journalism",
|
||||
"label_zh": "数据新闻",
|
||||
"label_en": "data-journalism",
|
||||
"desc_zh": "多栏微图表、侧栏、来源线、信息密度高。",
|
||||
"desc_en": "Multi-column microcharts, sidebars, source lines, and dense information."
|
||||
},
|
||||
{
|
||||
"id": "brutalist",
|
||||
"label": "brutalist",
|
||||
"label_zh": "粗野主义",
|
||||
"label_en": "brutalist",
|
||||
"desc_zh": "报纸密度、粗线框、原始结构、扁平表达。",
|
||||
"desc_en": "Newspaper-like density, heavy rules, raw structure, and flat treatment."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Expressive / print",
|
||||
"group_zh": "表现 / 印刷",
|
||||
"group_en": "Expressive / print",
|
||||
"items": [
|
||||
{
|
||||
"id": "memphis",
|
||||
"label": "memphis",
|
||||
"label_zh": "孟菲斯",
|
||||
"label_en": "memphis",
|
||||
"desc_zh": "撞色块、几何碎片、粗描边。",
|
||||
"desc_en": "Contrasting color blocks, geometric scraps, and heavy outlines."
|
||||
},
|
||||
{
|
||||
"id": "zine",
|
||||
"label": "zine",
|
||||
"label_zh": "小刊",
|
||||
"label_en": "zine",
|
||||
"desc_zh": "孔版印刷错位、半调、有限色、印刷颗粒。",
|
||||
"desc_en": "Riso offsets, halftones, limited colors, and print grain."
|
||||
},
|
||||
{
|
||||
"id": "vintage-poster",
|
||||
"label": "vintage-poster",
|
||||
"label_zh": "复古海报",
|
||||
"label_en": "vintage-poster",
|
||||
"desc_zh": "中世纪扁平色块、半调、复古几何暖意。",
|
||||
"desc_en": "Mid-century flat color blocks, halftones, and warm vintage geometry."
|
||||
},
|
||||
{
|
||||
"id": "paper-cut",
|
||||
"label": "paper-cut",
|
||||
"label_zh": "剪纸",
|
||||
"label_en": "paper-cut",
|
||||
"desc_zh": "层叠剪纸、层间柔影、手作质感。",
|
||||
"desc_en": "Layered cut paper, soft inter-layer shadows, and a handmade feel."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Hand-drawn / brush",
|
||||
"group_zh": "手绘 / 笔触",
|
||||
"group_en": "Hand-drawn / brush",
|
||||
"items": [
|
||||
{
|
||||
"id": "sketch-notes",
|
||||
"label": "sketch-notes",
|
||||
"label_zh": "手绘笔记",
|
||||
"label_en": "sketch-notes",
|
||||
"desc_zh": "暖纸底、涂鸦线条、柔和彩块。",
|
||||
"desc_en": "Warm paper, doodle lines, and soft color blocks."
|
||||
},
|
||||
{
|
||||
"id": "ink-notes",
|
||||
"label": "ink-notes",
|
||||
"label_zh": "墨迹笔记",
|
||||
"label_en": "ink-notes",
|
||||
"desc_zh": "浅底、黑色手写墨迹、稀疏语义强调。",
|
||||
"desc_en": "Light background, black handwritten ink, and sparse semantic emphasis."
|
||||
},
|
||||
{
|
||||
"id": "chalkboard",
|
||||
"label": "chalkboard",
|
||||
"label_zh": "黑板",
|
||||
"label_en": "chalkboard",
|
||||
"desc_zh": "深板岩底、粉笔笔触、粉彩强调。",
|
||||
"desc_en": "Dark slate surface, chalk strokes, and pastel emphasis."
|
||||
},
|
||||
{
|
||||
"id": "ink-wash",
|
||||
"label": "ink-wash",
|
||||
"label_zh": "水墨",
|
||||
"label_en": "ink-wash",
|
||||
"desc_zh": "宣纸留白、笔触、印章、静气,新中式表达。",
|
||||
"desc_en": "Rice-paper whitespace, brushwork, seals, and a quiet contemporary Chinese tone."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Specialty",
|
||||
"group_zh": "特殊风格",
|
||||
"group_en": "Specialty",
|
||||
"items": [
|
||||
{
|
||||
"id": "pixel-art",
|
||||
"label": "pixel-art",
|
||||
"label_zh": "像素风",
|
||||
"label_en": "pixel-art",
|
||||
"desc_zh": "严格像素网格、块状形体、有限色、扁平表达。",
|
||||
"desc_en": "Strict pixel grid, blocky shapes, limited colors, and flat treatment."
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"icons": [
|
||||
{
|
||||
"id": "chunk-filled",
|
||||
"label": "chunk-filled",
|
||||
"label_zh": "几何实心图标",
|
||||
"label_en": "chunk-filled",
|
||||
"desc_zh": "实心、直线几何、锐利厚重,适合科技、工程、企业结构图。",
|
||||
"desc_en": "Solid, straight, geometric, sharp, and heavy. Good for tech, engineering, and corporate structure diagrams."
|
||||
},
|
||||
{
|
||||
"id": "tabler-filled",
|
||||
"label": "tabler-filled",
|
||||
"label_zh": "圆润实心图标",
|
||||
"label_en": "tabler-filled",
|
||||
"desc_zh": "实心、圆润曲线、中等视觉重量,适合亲和现代风格。",
|
||||
"desc_en": "Solid, rounded, and medium-weight. Good for friendly modern styles."
|
||||
},
|
||||
{
|
||||
"id": "tabler-outline",
|
||||
"label": "tabler-outline",
|
||||
"label_zh": "线性描边图标",
|
||||
"label_en": "tabler-outline",
|
||||
"desc_zh": "线性描边,轻盈精致,适合屏幕阅读和编辑型图标。",
|
||||
"desc_en": "Linear stroke, light and refined. Good for screen reading and editable icons."
|
||||
},
|
||||
{
|
||||
"id": "phosphor-duotone",
|
||||
"label": "phosphor-duotone",
|
||||
"label_zh": "双色层次图标",
|
||||
"label_en": "phosphor-duotone",
|
||||
"desc_zh": "双色层次、中等重量,适合现代产品和平台类表达。",
|
||||
"desc_en": "Two-tone layering with medium weight. Good for modern product and platform narratives."
|
||||
},
|
||||
{
|
||||
"id": "emoji",
|
||||
"label": "Emoji",
|
||||
"label_zh": "表情符号",
|
||||
"label_en": "Emoji",
|
||||
"desc_zh": "系统表情符号,适合轻量、社交、教学或快速识别场景;正式企业 / 咨询风谨慎使用。",
|
||||
"desc_en": "System emoji for lightweight, social, instructional, or quick-recognition contexts; use carefully in formal corporate or consulting decks."
|
||||
},
|
||||
{
|
||||
"id": "none",
|
||||
"label": "不用图标",
|
||||
"label_zh": "不用图标",
|
||||
"label_en": "No icons"
|
||||
}
|
||||
],
|
||||
"image_usage": [
|
||||
{
|
||||
"id": "ai",
|
||||
"label": "AI 生成",
|
||||
"label_zh": "生成配图",
|
||||
"label_en": "AI-generated"
|
||||
},
|
||||
{
|
||||
"id": "web",
|
||||
"label": "Web 来源",
|
||||
"label_zh": "网络来源",
|
||||
"label_en": "Web-sourced"
|
||||
},
|
||||
{
|
||||
"id": "provided",
|
||||
"label": "用户提供",
|
||||
"label_zh": "用户提供",
|
||||
"label_en": "User-provided"
|
||||
},
|
||||
{
|
||||
"id": "placeholder",
|
||||
"label": "占位符",
|
||||
"label_zh": "占位符",
|
||||
"label_en": "Placeholder"
|
||||
},
|
||||
{
|
||||
"id": "none",
|
||||
"label": "不使用图片",
|
||||
"label_zh": "不使用图片",
|
||||
"label_en": "No images"
|
||||
}
|
||||
],
|
||||
"image_ai_path": [
|
||||
{
|
||||
"id": "auto",
|
||||
"label": "Automatic Path A → Path B → Offline Manual",
|
||||
"label_zh": "自动选择",
|
||||
"label_en": "Automatic Path A → Path B → Offline Manual"
|
||||
},
|
||||
{
|
||||
"id": "api",
|
||||
"label": "Path A",
|
||||
"label_zh": "接口后端",
|
||||
"label_en": "Path A",
|
||||
"desc_zh": "触发条件:已配置图像后端;机制:第 5 步运行图像生成脚本。",
|
||||
"desc_en": "IMAGE_BACKEND configured; image_gen.py runs in Step 5."
|
||||
},
|
||||
{
|
||||
"id": "host-native",
|
||||
"label": "Path B",
|
||||
"label_zh": "宿主原生工具",
|
||||
"label_en": "Path B",
|
||||
"desc_zh": "触发条件:未配置图像后端,且当前环境提供原生图像工具;机制:直接调用宿主工具生成。",
|
||||
"desc_en": "IMAGE_BACKEND not configured and host has a native image tool; host-native generation."
|
||||
},
|
||||
{
|
||||
"id": "manual",
|
||||
"label": "Offline Manual",
|
||||
"label_zh": "离线手动",
|
||||
"label_en": "Offline Manual",
|
||||
"desc_zh": "机制:写入图片提示词文件;用户在外部生成后放入项目图片目录。",
|
||||
"desc_en": "Prompts written to images/image_prompts.json; user generates externally and places files in project/images/."
|
||||
}
|
||||
],
|
||||
"formula_policy": [
|
||||
{
|
||||
"id": "mixed",
|
||||
"label": "mixed",
|
||||
"label_zh": "混合",
|
||||
"label_en": "mixed",
|
||||
"desc_zh": "复杂公式渲染为图片;简单行内公式保持为可编辑文本或字符。",
|
||||
"desc_en": "Render complex formula-worthy expressions to PNG; keep simple inline math as editable text / Unicode."
|
||||
},
|
||||
{
|
||||
"id": "render-all",
|
||||
"label": "render-all",
|
||||
"label_zh": "全部渲染",
|
||||
"label_en": "render-all",
|
||||
"desc_zh": "所有需要按公式处理的表达式都渲染为图片。",
|
||||
"desc_en": "Render every formula-worthy expression to PNG."
|
||||
},
|
||||
{
|
||||
"id": "text-only",
|
||||
"label": "text-only",
|
||||
"label_zh": "仅文本",
|
||||
"label_en": "text-only",
|
||||
"desc_zh": "不渲染公式;表达式保持为可编辑文本或字符。",
|
||||
"desc_en": "Do not render formulas; keep expressions as editable text / Unicode."
|
||||
}
|
||||
],
|
||||
"generation_mode": [
|
||||
{
|
||||
"id": "continuous",
|
||||
"label": "continuous",
|
||||
"label_zh": "连续模式",
|
||||
"label_en": "continuous"
|
||||
},
|
||||
{
|
||||
"id": "split",
|
||||
"label": "split",
|
||||
"label_zh": "分段模式",
|
||||
"label_en": "split"
|
||||
}
|
||||
]
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title data-i18n="page_title">PPT Master - Confirm Design</title>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header id="topbar">
|
||||
<div id="topbar-inner">
|
||||
<div class="topbar-titles">
|
||||
<h1 data-i18n="page_title">PPT Master - Confirm Design</h1>
|
||||
<p id="topbar-hint" data-i18n="topbar_hint">After confirming, return to the chat and say “done”.</p>
|
||||
</div>
|
||||
<button id="btn-lang-toggle" class="btn-lang-toggle" title="Switch language">中</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main id="form">
|
||||
<div id="loading" data-i18n="loading">Loading recommendations…</div>
|
||||
<div id="error" style="display:none;"></div>
|
||||
<div id="sections" style="display:none;"></div>
|
||||
</main>
|
||||
|
||||
<footer id="actionbar" style="display:none;">
|
||||
<span id="confirm-status"></span>
|
||||
<button id="btn-confirm" data-i18n="btn_confirm">Confirm</button>
|
||||
</footer>
|
||||
|
||||
<div id="confirmed-overlay" style="display:none;">
|
||||
<div class="cf-card">
|
||||
<div class="cf-title">✓ Confirmed</div>
|
||||
<div class="cf-hint">Your choices are saved. You can close this page.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/static/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
+286
@@ -0,0 +1,286 @@
|
||||
:root {
|
||||
--bg: #f4f5f7;
|
||||
--card: #ffffff;
|
||||
--ink: #1d2430;
|
||||
--muted: #6b7280;
|
||||
--line: #e3e6ea;
|
||||
--accent: #2f6df0;
|
||||
--accent-soft: #eaf1fe;
|
||||
--radius: 12px;
|
||||
--shadow: 0 1px 3px rgba(20, 30, 50, 0.08), 0 4px 16px rgba(20, 30, 50, 0.05);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Microsoft YaHei",
|
||||
"PingFang SC", "Hiragino Sans GB", sans-serif;
|
||||
line-height: 1.5;
|
||||
padding-bottom: 88px;
|
||||
}
|
||||
|
||||
/* ---- top bar ---- */
|
||||
#topbar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
background: var(--card);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
#topbar-inner {
|
||||
max-width: 880px;
|
||||
margin: 0 auto;
|
||||
padding: 16px 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
.topbar-titles h1 { margin: 0; font-size: 18px; font-weight: 650; }
|
||||
.topbar-titles p { margin: 2px 0 0; font-size: 13px; color: var(--muted); }
|
||||
|
||||
.btn-lang-toggle {
|
||||
flex: none;
|
||||
width: 38px; height: 38px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--card);
|
||||
color: var(--ink);
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn-lang-toggle:hover { background: var(--bg); }
|
||||
|
||||
/* ---- form ---- */
|
||||
#form { max-width: 880px; margin: 0 auto; padding: 24px; }
|
||||
#loading, #error { padding: 40px 0; text-align: center; color: var(--muted); }
|
||||
#error { color: #b42318; }
|
||||
|
||||
.section {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
padding: 18px 20px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.section-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.section-num {
|
||||
flex: none;
|
||||
width: 22px; height: 22px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
font-size: 12px; font-weight: 700;
|
||||
display: grid; place-items: center;
|
||||
}
|
||||
.section-title { font-size: 15px; font-weight: 620; }
|
||||
.section-note { font-size: 12.5px; color: var(--muted); }
|
||||
|
||||
/* ---- generic controls ---- */
|
||||
.chips { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
.chip {
|
||||
border: 1px solid var(--line);
|
||||
background: var(--card);
|
||||
border-radius: 8px;
|
||||
padding: 7px 14px;
|
||||
font-size: 13.5px;
|
||||
line-height: 1.35;
|
||||
cursor: pointer;
|
||||
transition: all .12s ease;
|
||||
max-width: 100%;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.chip:hover { border-color: var(--accent); }
|
||||
.chip.selected {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
.chip-text { min-width: 0; }
|
||||
|
||||
.text-input, .num-input {
|
||||
width: 100%;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
padding: 9px 12px;
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
color: var(--ink);
|
||||
}
|
||||
.text-input:focus, .num-input:focus { outline: none; border-color: var(--accent); }
|
||||
.num-input { width: 140px; }
|
||||
|
||||
/* ---- color candidates ---- */
|
||||
.color-grid { display: flex; flex-wrap: wrap; gap: 12px; }
|
||||
.color-card {
|
||||
border: 2px solid var(--line);
|
||||
border-radius: 10px;
|
||||
padding: 10px;
|
||||
cursor: pointer;
|
||||
min-width: 150px;
|
||||
transition: border-color .12s ease;
|
||||
}
|
||||
.color-card:hover { border-color: var(--accent); }
|
||||
.color-card.selected { border-color: var(--accent); }
|
||||
.swatches { display: flex; gap: 6px; margin-bottom: 8px; }
|
||||
.swatch {
|
||||
width: 34px; height: 34px;
|
||||
border-radius: 7px;
|
||||
border: 1px solid rgba(0,0,0,.06);
|
||||
}
|
||||
.color-name { font-size: 13.5px; font-weight: 600; }
|
||||
.color-hex { font-size: 11.5px; color: var(--muted); font-family: ui-monospace, monospace; }
|
||||
.color-note { font-size: 12px; color: var(--muted); margin-top: 4px; }
|
||||
|
||||
.hex-override { margin-top: 12px; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
.hex-override label { font-size: 12.5px; color: var(--muted); }
|
||||
.hex-override input {
|
||||
width: 96px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
padding: 5px 8px;
|
||||
font-family: ui-monospace, monospace;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
/* ---- typography candidates ---- */
|
||||
.font-grid { display: flex; flex-direction: column; gap: 10px; }
|
||||
.font-card {
|
||||
border: 2px solid var(--line);
|
||||
border-radius: 10px;
|
||||
padding: 12px 14px;
|
||||
cursor: pointer;
|
||||
transition: border-color .12s ease;
|
||||
}
|
||||
.font-card:hover { border-color: var(--accent); }
|
||||
.font-card.selected { border-color: var(--accent); }
|
||||
.font-card-head {
|
||||
display: flex; align-items: baseline; gap: 10px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.font-card-name { font-size: 13.5px; font-weight: 600; }
|
||||
.font-card-meta { font-size: 11.5px; color: var(--muted); }
|
||||
.font-sample-heading { font-size: 24px; font-weight: 700; line-height: 1.3; }
|
||||
.font-sample-body { font-size: 14px; color: #333; margin-top: 4px; }
|
||||
.custom-typography-input { margin-top: 8px; min-height: 58px; resize: vertical; }
|
||||
.font-size-row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
||||
.font-size-input { width: 96px; }
|
||||
|
||||
.subfield { margin-top: 14px; }
|
||||
.subfield-label { font-size: 12.5px; color: var(--muted); margin-bottom: 7px; }
|
||||
|
||||
/* ---- toggles ---- */
|
||||
.toggle-row { display: flex; align-items: center; gap: 12px; }
|
||||
.toggle-desc { font-size: 12.5px; color: var(--muted); }
|
||||
|
||||
/* ---- action bar ---- */
|
||||
#actionbar {
|
||||
position: fixed;
|
||||
bottom: 0; left: 0; right: 0;
|
||||
background: var(--card);
|
||||
border-top: 1px solid var(--line);
|
||||
padding: 14px 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 16px;
|
||||
z-index: 10;
|
||||
}
|
||||
#confirm-status { font-size: 13px; color: var(--muted); }
|
||||
#confirm-status.done { color: #067647; font-weight: 600; }
|
||||
#btn-confirm {
|
||||
border: none;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
font-size: 14.5px;
|
||||
font-weight: 600;
|
||||
padding: 10px 26px;
|
||||
border-radius: 9px;
|
||||
cursor: pointer;
|
||||
}
|
||||
#btn-confirm:hover { background: #2459cf; }
|
||||
#btn-confirm:disabled { background: #9db4e8; cursor: default; }
|
||||
|
||||
/* ---- custom input ---- */
|
||||
.custom-input { margin-top: 8px; }
|
||||
|
||||
/* ---- recommended chip + group labels ---- */
|
||||
.chip.recommended {
|
||||
border-color: var(--line);
|
||||
box-shadow: none;
|
||||
}
|
||||
.rec-badge {
|
||||
margin-left: 6px;
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
color: var(--muted);
|
||||
}
|
||||
.chip.selected .rec-badge { color: var(--accent); }
|
||||
.group-label {
|
||||
font-size: 11.5px;
|
||||
color: var(--muted);
|
||||
margin: 10px 0 5px;
|
||||
letter-spacing: .02em;
|
||||
}
|
||||
.chips + .group-label { margin-top: 12px; }
|
||||
|
||||
/* ---- palette swatches with role labels ---- */
|
||||
.swatches { display: flex; gap: 8px; margin-bottom: 8px; flex-wrap: wrap; }
|
||||
.swatch-col { display: flex; flex-direction: column; align-items: center; gap: 3px; }
|
||||
.swatch-role { font-size: 10px; color: var(--muted); }
|
||||
|
||||
/* ---- custom HEX grid ---- */
|
||||
.hex-override { margin-top: 14px; }
|
||||
.hex-row { display: flex; gap: 10px; flex-wrap: wrap; }
|
||||
.hex-cell { display: flex; flex-direction: column; gap: 4px; }
|
||||
.hex-cell-label { font-size: 11px; color: var(--muted); }
|
||||
.hex-cell input {
|
||||
width: 92px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
padding: 5px 8px;
|
||||
font-family: ui-monospace, monospace;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
/* ---- typography CJK / Latin previews ---- */
|
||||
.font-sample-line { display: flex; align-items: baseline; gap: 16px; flex-wrap: wrap; }
|
||||
.font-sample-heading-box .fs-cjk { font-size: 24px; font-weight: 700; }
|
||||
.font-sample-heading-box .fs-latin { font-size: 22px; font-weight: 700; }
|
||||
.font-sample-body-box { margin-top: 6px; }
|
||||
.font-sample-body-box .fs-cjk,
|
||||
.font-sample-body-box .fs-latin { font-size: 14px; color: #333; }
|
||||
.font-card-meta { display: block; margin-top: 4px; }
|
||||
|
||||
/* ---- confirmed overlay ---- */
|
||||
#confirmed-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(244, 245, 247, 0.94);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 50;
|
||||
}
|
||||
.cf-card {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 16px;
|
||||
box-shadow: var(--shadow);
|
||||
padding: 36px 44px;
|
||||
text-align: center;
|
||||
max-width: 420px;
|
||||
}
|
||||
.cf-title { font-size: 22px; font-weight: 700; color: #067647; }
|
||||
.cf-hint { font-size: 14px; color: var(--muted); margin-top: 10px; line-height: 1.6; }
|
||||
@@ -0,0 +1,140 @@
|
||||
# Confirm UI — Eight Confirmations Page
|
||||
|
||||
> The interactive, visual surface for SKILL.md Step 4 (the Eight Confirmations). Enumerable fields list **all** options from a catalog with the AI's recommendation badged; generative fields (color, typography, generated-image style) show a few AI candidates. Fields whose universe is open (canvas, mode, visual style, icons, image usage) also get a **Custom** box; fully closed fields (AI source when applicable, formula policy, generation mode, refine spec) do not. The AI writes its recommendation to `recommendations.json`; the user's final choices are written back to `result.json` for the AI to read. On confirm the page saves the result and shuts the server down (auto-close). The chat path is always a valid fallback — if the browser cannot open (remote / headless / web host), the AI presents the same confirmations in chat.
|
||||
|
||||
## `confirm_ui/server.py`
|
||||
|
||||
```bash
|
||||
python3 scripts/confirm_ui/server.py <project_path> --daemon --wait
|
||||
python3 scripts/confirm_ui/server.py <project_path> --daemon
|
||||
python3 scripts/confirm_ui/server.py <project_path> --daemon --port 4041
|
||||
python3 scripts/confirm_ui/server.py <project_path> --no-browser
|
||||
python3 scripts/confirm_ui/server.py <project_path> --timeout 0 # disable idle auto-shutdown
|
||||
```
|
||||
|
||||
- Binds `127.0.0.1:4040` by default and auto-opens the browser (suppress with `--no-browser`).
|
||||
- `--daemon` starts the Flask process in the background; add `--wait` in the main pipeline so the parent command returns only after the page writes a fresh `result.json`. The `--wait` budget defaults to **590 s** (`--wait-timeout`), kept under the typical 600 s tool ceiling — run the launch with a long tool timeout (≈600000 ms). On timeout the parent returns non-zero but the detached server keeps running, so the caller must re-check `result.json` once before the chat fallback (a slow user may confirm just after the wait returns).
|
||||
- Refuses to start unless `<project_path>/confirm_ui/recommendations.json` exists.
|
||||
- Per-project lock at `<project_path>/.confirm_ui.lock` — duplicate launches are refused; stale locks (dead pid) are overwritten.
|
||||
- Idle auto-shutdown after 900 s by default; `/api/shutdown` exits gracefully and releases the lock.
|
||||
- Independent of the live preview server (`svg_editor/server.py`, port 5050) in process, lock, and content model.
|
||||
|
||||
Dependency:
|
||||
|
||||
```bash
|
||||
pip install flask
|
||||
```
|
||||
|
||||
## Two kinds of field
|
||||
|
||||
- **Enumerable + custom** — canvas / mode / visual_style / icons / image usage. The page lists common options from `static/catalogs.json`, badges the AI's recommendation, and still offers a Custom box for edge cases (custom canvas size, bespoke narrative mode, mixed image plan, self-provided icon system, etc.).
|
||||
- **Closed enumerable** — formula policy / generation mode / refine spec, plus AI source only when image usage may include `ai`. These have no Custom box; out-of-catalog values snap back to the recommended option. Use pipeline vocabulary: icon ids are actual library ids such as `tabler-outline`, or `emoji` for system emoji; image usage labels mirror Strategist terminology: `ai` = AI-generated, `web` = Web-sourced, `provided` = User-provided, `placeholder` = Placeholder, `none` = No images. Use custom prose only when several sources are mixed.
|
||||
- **Generative (open)** — color, typography, generated-image style. No finite catalog; the AI authors a few **candidates** the page renders as cards. `page_count` and `audience` are free inputs.
|
||||
|
||||
**Custom box** appears only on fields whose universe is genuinely open — `canvas`, `mode`, `visual_style`, `icons`, and `image_usage`. Fully closed sets — `image_ai_path`, `formula_policy`, `generation_mode`, `refine_spec` — have **no** Custom box; an out-of-catalog value there is snapped back to the recommended option.
|
||||
|
||||
`image_ai_path` is conditional: the page shows it and writes it to `result.json` only when `image_usage` is `ai` or a custom image plan that may include AI. Web-sourced / User-provided / Placeholder / No images paths do not carry an AI backend choice.
|
||||
|
||||
## Catalogs — `static/catalogs.json` (the finite option universe)
|
||||
|
||||
Served by Flask static at `/static/catalogs.json` (the front-end prefers `/api/catalogs` when the server provides it — e.g. canvas sourced live from `config.py CANVAS_FORMATS` for zero drift). Keys: `canvas`, `modes`, `visual_styles` (grouped), `icons`, `image_usage`, `image_ai_path`, `formula_policy`, `generation_mode`. Each entry is `{ "id", "label", "label_zh", "label_en", ... }`; descriptions use `desc_zh` / `desc_en`, and `visual_styles` groups use `group_zh` / `group_en`. The front-end falls back to legacy `label` / `desc` / `group`, so old catalogs still load, but new user-facing catalog text must be bilingual. English labels should mirror canonical reference names (`pyramid`, `swiss-minimal`, `Path A`, `mixed`, etc.); Chinese labels should be translated for users. Descriptions render inline after the option title, not as a separate selected-option line. `visual_styles` is `[{ "group", "group_zh", "group_en", "items": [...] }]`. `canvas` mirrors `config.py CANVAS_FORMATS` — keep in sync.
|
||||
|
||||
## Round-trip data contract
|
||||
|
||||
Both files live under `<project_path>/confirm_ui/`.
|
||||
|
||||
### Input — `recommendations.json` (written by Strategist before launch)
|
||||
|
||||
```json
|
||||
{
|
||||
"lang": "zh",
|
||||
"recommend": {
|
||||
"canvas": "ppt169",
|
||||
"mode": "pyramid",
|
||||
"visual_style": "swiss-minimal",
|
||||
"icons": "tabler-outline",
|
||||
"image_usage": "web",
|
||||
"formula_policy": "mixed",
|
||||
"generation_mode": "continuous"
|
||||
},
|
||||
"page_count": { "value": "12-15" },
|
||||
"audience": { "value": "..." },
|
||||
"color": {
|
||||
"selected": 0,
|
||||
"candidates": [
|
||||
{ "name": "...", "note": "...",
|
||||
"palette": {
|
||||
"background": "#FFFFFF",
|
||||
"secondary_bg": "#F4F6F8",
|
||||
"primary": "#1A3A6B",
|
||||
"accent": "#E8A317",
|
||||
"secondary_accent": "#4A7BB5",
|
||||
"body_text": "#1D2430"
|
||||
} }
|
||||
]
|
||||
},
|
||||
"typography": {
|
||||
"selected": 0,
|
||||
"candidates": [
|
||||
{ "name": "...", "note": "...",
|
||||
"heading": { "cjk": "思源黑体", "latin": "Inter", "css": "'Source Han Sans SC','Inter',sans-serif", "sample_cjk": "标题示例", "sample_latin": "Heading Sample" },
|
||||
"body": { "cjk": "思源黑体", "latin": "Inter", "css": "...", "sample_cjk": "正文示例", "sample_latin": "Body sample" },
|
||||
"body_size": 18 }
|
||||
]
|
||||
},
|
||||
"image_strategy": {
|
||||
"selected": 0,
|
||||
"candidates": [
|
||||
{
|
||||
"name": "方案 A",
|
||||
"rendering": "vector-illustration",
|
||||
"palette": "cool-corporate",
|
||||
"visual": "扁平矢量、实色块、少阴影",
|
||||
"color": "背景 60-70% + 主色 25-30% + 强调色少量点题",
|
||||
"mood": "稳定、可信、克制"
|
||||
}
|
||||
]
|
||||
},
|
||||
"refine_spec": { "value": false }
|
||||
}
|
||||
```
|
||||
|
||||
- `recommend.*` names the recommended `id` for each enumerable field (must match a `catalogs.json` id, or be a free string for a recommended custom value). The page badges and pre-selects it. **Guarantee**: if a `recommend.*` is omitted, the page falls back to the first catalog option so every enumerable field always shows one badged recommendation — but the AI should still set them for a meaningful default. Legacy aliases are accepted for old files (`line` → `tabler-outline`, `filled` → `tabler-filled`, `monochrome` → `chunk-filled`, `search` → `web`, `default` → `auto`, `builtin` → `host-native`), but new files should write canonical ids. For `recommend.image_usage`, do not write bare `"custom"`; if several image sources are mixed, write the concrete prose plan directly, such as `"封面用 AI 生成,产品页用用户素材,行业页用网络来源"` / `"AI cover + user product assets + web industry images"`.
|
||||
- When `recommend.image_usage` is `ai` or a custom plan that includes AI, also set `recommend.image_ai_path` to one of `auto` / `api` / `host-native` / `manual`; the page presents these as explicit choices.
|
||||
- **Color candidates carry the user-facing core `palette`**: `background`, `secondary_bg`, `primary`, `accent`, `secondary_accent`, and `body_text`. The page renders labelled swatches and offers per-role override inputs. Legacy `text` is accepted as an alias for `body_text`, but new files should write `body_text`. Strategist derives secondary text, borders, state colors, and visual-style neutral tiers later when writing `design_spec.md` / `spec_lock.md`; those are not user-facing confirmation choices.
|
||||
- **Candidate display text may be bilingual**: color / typography candidates can provide `name_zh` / `name_en` and `note_zh` / `note_en`; the page falls back to legacy `name` / `note`.
|
||||
- **Typography candidates split CJK and Latin** for both `heading` and `body`; `css` is the preview `font-family` stack. Each candidate should also include `body_size` (the body baseline in px; common values are 18 and 24, but any reasonable integer is valid). The page exposes `body_size` as an editable numeric field, and also offers a custom typography text box so the user is not limited to the proposed candidates.
|
||||
- **Generated image style candidates** live in `image_strategy.candidates` and are shown only when `image_usage` is `ai` or a custom image plan may include generated images. Each candidate records `rendering`, `palette`, and short `visual` / `color` / `mood` lines from Strategist h.5. The chosen value is written to `result.json.image_strategy`; it is omitted when generated images are not part of the plan.
|
||||
- `recommend.generation_mode` and `refine_spec` mirror the two mandatory notes in SKILL.md Step 4. Confirmed `generation_mode: "split"` / `refine_spec: true` are explicit user choices, equivalent to opting in through chat.
|
||||
- `lang` is a soft default; an explicit user language choice in the page (persisted to `localStorage`) wins.
|
||||
|
||||
### Output — `result.json` (written on submit, read by the AI)
|
||||
|
||||
```json
|
||||
{
|
||||
"canvas": "ppt169",
|
||||
"page_count": "12-15",
|
||||
"audience": "...",
|
||||
"mode": "pyramid",
|
||||
"visual_style": "swiss-minimal",
|
||||
"color": { "name": "...", "palette": { "background": "#...", "secondary_bg": "#...", "primary": "#...", "accent": "#...", "secondary_accent": "#...", "body_text": "#..." } },
|
||||
"icons": "tabler-outline",
|
||||
"typography": { "name": "...", "heading": { "cjk": "...", "latin": "...", "css": "..." }, "body": { "cjk": "...", "latin": "...", "css": "..." }, "body_size": 18 },
|
||||
"formula_policy": "mixed",
|
||||
"image_usage": "web",
|
||||
"image_strategy": { "name": "方案 A", "rendering": "vector-illustration", "palette": "cool-corporate", "visual": "...", "color": "...", "mood": "..." },
|
||||
"generation_mode": "continuous",
|
||||
"refine_spec": false,
|
||||
"status": "confirmed",
|
||||
"confirmed_at": "2026-06-15T11:44:44"
|
||||
}
|
||||
```
|
||||
|
||||
- Any option field may instead hold a **free-text custom string** (the user picked **Custom**); `color` / `typography` custom entries set `name: "custom"`. Image usage custom values must be concrete prose plans, not the literal string `"custom"`. The AI interprets custom text against the canonical references.
|
||||
- `image_ai_path` and `image_strategy` are omitted from `result.json` unless `image_usage` is `ai` or a custom image plan that may include generated images.
|
||||
- After the user clicks **Confirm**, the page saves `result.json` and shuts the server down (auto-close). In the default `--daemon --wait` flow, the waiting command returns and the AI reads `result.json` immediately; no second chat confirmation is required. Chat confirmation remains the fallback when the page cannot be used.
|
||||
|
||||
## Scope
|
||||
|
||||
- Confirmation surface only — Strategist authors every recommendation; the page never generates deck content.
|
||||
- No SVG / layout preview here — that is the live preview server's job (`workflows/live-preview.md`, Step 6).
|
||||
@@ -0,0 +1,120 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
PPT Master - Local Preview Server Helpers
|
||||
|
||||
Shared per-project mutual-exclusion (lock) and liveness helpers for the local
|
||||
Flask preview servers (`svg_editor/server.py`, `confirm_ui/server.py`). Each
|
||||
server keeps its own lock filename and Flask app; this module owns only the
|
||||
cross-platform process-liveness check and the claim/read/release lock logic so
|
||||
the two servers cannot drift apart.
|
||||
|
||||
Usage:
|
||||
from server_common import process_alive, read_lock, claim_lock, release_lock
|
||||
|
||||
Dependencies:
|
||||
None (only uses standard library)
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
|
||||
def process_alive(pid: int) -> bool:
|
||||
"""Return True if a process with this pid is reachable.
|
||||
|
||||
On POSIX, ``os.kill(pid, 0)`` succeeds when the process exists even without
|
||||
permission to signal it; ``PermissionError`` therefore still counts as
|
||||
alive. On Windows there is no ``os.kill(pid, 0)`` equivalent, so probe via
|
||||
``OpenProcess`` + ``WaitForSingleObject``.
|
||||
"""
|
||||
if pid <= 0:
|
||||
return False
|
||||
if os.name == 'nt':
|
||||
import ctypes
|
||||
import ctypes.wintypes
|
||||
|
||||
kernel32 = ctypes.WinDLL('kernel32', use_last_error=True)
|
||||
kernel32.OpenProcess.argtypes = [
|
||||
ctypes.wintypes.DWORD,
|
||||
ctypes.wintypes.BOOL,
|
||||
ctypes.wintypes.DWORD,
|
||||
]
|
||||
kernel32.OpenProcess.restype = ctypes.wintypes.HANDLE
|
||||
kernel32.WaitForSingleObject.argtypes = [
|
||||
ctypes.wintypes.HANDLE,
|
||||
ctypes.wintypes.DWORD,
|
||||
]
|
||||
kernel32.WaitForSingleObject.restype = ctypes.wintypes.DWORD
|
||||
kernel32.CloseHandle.argtypes = [ctypes.wintypes.HANDLE]
|
||||
kernel32.CloseHandle.restype = ctypes.wintypes.BOOL
|
||||
|
||||
process_query_limited_information = 0x1000
|
||||
synchronize = 0x00100000
|
||||
wait_timeout = 0x00000102
|
||||
wait_object_0 = 0x00000000
|
||||
wait_failed = 0xFFFFFFFF
|
||||
|
||||
handle = kernel32.OpenProcess(
|
||||
process_query_limited_information | synchronize,
|
||||
False,
|
||||
pid,
|
||||
)
|
||||
if not handle:
|
||||
return ctypes.get_last_error() == 5 # ERROR_ACCESS_DENIED
|
||||
try:
|
||||
result = kernel32.WaitForSingleObject(handle, 0)
|
||||
if result == wait_timeout:
|
||||
return True
|
||||
if result in (wait_object_0, wait_failed):
|
||||
return False
|
||||
return False
|
||||
finally:
|
||||
kernel32.CloseHandle(handle)
|
||||
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
except ProcessLookupError:
|
||||
return False
|
||||
except PermissionError:
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def read_lock(lock_file: Path) -> Optional[dict]:
|
||||
"""Read a lock file, returning the lock dict or None if absent/corrupt."""
|
||||
try:
|
||||
data = json.loads(lock_file.read_text(encoding='utf-8'))
|
||||
return data if isinstance(data, dict) else None
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return None
|
||||
|
||||
|
||||
def claim_lock(lock_file: Path, port: int) -> Optional[dict]:
|
||||
"""Try to claim the per-project preview slot.
|
||||
|
||||
Returns ``None`` on success. If another live process already holds the
|
||||
slot, returns the existing lock dict (caller surfaces it as an error).
|
||||
A stale lock (pointing at a dead pid) is silently overwritten.
|
||||
"""
|
||||
existing = read_lock(lock_file)
|
||||
if existing and process_alive(int(existing.get('pid', 0))):
|
||||
return existing
|
||||
lock_file.write_text(
|
||||
json.dumps({'pid': os.getpid(), 'port': port}),
|
||||
encoding='utf-8',
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def release_lock(lock_file: Path) -> None:
|
||||
"""Best-effort cleanup: only delete the lock if it still names *us*."""
|
||||
try:
|
||||
current = read_lock(lock_file)
|
||||
if current and int(current.get('pid', 0)) == os.getpid():
|
||||
lock_file.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
@@ -50,6 +50,18 @@ _FINALIZE_DIR = _SCRIPTS_DIR.parent / 'svg_finalize'
|
||||
if str(_FINALIZE_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(_FINALIZE_DIR))
|
||||
|
||||
# scripts/ root for cross-server shared helpers
|
||||
_ROOT_SCRIPTS_DIR = _SCRIPTS_DIR.parent
|
||||
if str(_ROOT_SCRIPTS_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(_ROOT_SCRIPTS_DIR))
|
||||
|
||||
from server_common import ( # noqa: E402
|
||||
claim_lock as _claim_lock,
|
||||
process_alive as _process_alive,
|
||||
read_lock as _read_lock,
|
||||
release_lock as _release_lock,
|
||||
)
|
||||
|
||||
from annotations import ( # noqa: E402
|
||||
assign_temp_ids,
|
||||
is_editable_attr,
|
||||
@@ -98,60 +110,8 @@ def _cache_put(cache: dict, lock: threading.Lock, path: str, mtime: float, value
|
||||
cache[path] = (mtime, value)
|
||||
|
||||
|
||||
def _process_alive(pid: int) -> bool:
|
||||
"""Return True if a process with this pid is reachable.
|
||||
|
||||
``os.kill(pid, 0)`` succeeds when the process exists even without
|
||||
permission to signal it; ``PermissionError`` therefore still counts
|
||||
as alive (a real lock holder owned by another user). ``ESRCH`` /
|
||||
other ``OSError`` means the pid is gone.
|
||||
"""
|
||||
if pid <= 0:
|
||||
return False
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
except ProcessLookupError:
|
||||
return False
|
||||
except PermissionError:
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _read_lock(lock_file: Path) -> Optional[dict]:
|
||||
try:
|
||||
data = json.loads(lock_file.read_text(encoding='utf-8'))
|
||||
return data if isinstance(data, dict) else None
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return None
|
||||
|
||||
|
||||
def _claim_lock(lock_file: Path, port: int) -> Optional[dict]:
|
||||
"""Try to claim the per-project preview slot.
|
||||
|
||||
Returns ``None`` on success. If another live process already holds the
|
||||
slot, returns the existing lock dict (caller surfaces it as an error).
|
||||
A stale lock (pointing at a dead pid) is silently overwritten.
|
||||
"""
|
||||
existing = _read_lock(lock_file)
|
||||
if existing and _process_alive(int(existing.get('pid', 0))):
|
||||
return existing
|
||||
lock_file.write_text(
|
||||
json.dumps({'pid': os.getpid(), 'port': port}),
|
||||
encoding='utf-8',
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _release_lock(lock_file: Path) -> None:
|
||||
"""Best-effort cleanup: only delete the lock if it still names *us*."""
|
||||
try:
|
||||
current = _read_lock(lock_file)
|
||||
if current and int(current.get('pid', 0)) == os.getpid():
|
||||
lock_file.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
# Lock / liveness helpers are shared with confirm_ui via server_common
|
||||
# (imported above as _process_alive / _read_lock / _claim_lock / _release_lock).
|
||||
|
||||
|
||||
def _inline_icons(content: str) -> tuple[str, list[dict]]:
|
||||
|
||||
+44
-1
@@ -10,6 +10,7 @@ import re
|
||||
import posixpath
|
||||
import shutil
|
||||
import tempfile
|
||||
import uuid
|
||||
import zipfile
|
||||
from concurrent.futures import ProcessPoolExecutor, as_completed
|
||||
from datetime import datetime, timezone
|
||||
@@ -124,6 +125,48 @@ def _as_dict(value: Any) -> dict[str, Any]:
|
||||
return value if isinstance(value, dict) else {}
|
||||
|
||||
|
||||
def _create_writable_work_dir(output_path: Path) -> Path:
|
||||
"""Create a real writable work directory for PPTX assembly."""
|
||||
parents = [output_path.parent, Path.cwd(), Path(tempfile.gettempdir())]
|
||||
seen: set[str] = set()
|
||||
errors: list[str] = []
|
||||
|
||||
for parent in parents:
|
||||
parent = parent if str(parent) else Path(".")
|
||||
try:
|
||||
key = str(parent.resolve())
|
||||
except OSError:
|
||||
key = str(parent.absolute())
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
|
||||
try:
|
||||
parent.mkdir(parents=True, exist_ok=True)
|
||||
except OSError as exc:
|
||||
errors.append(f"{parent}: cannot create parent ({exc})")
|
||||
continue
|
||||
|
||||
for _ in range(3):
|
||||
work_dir = parent / f".pptx-build-{os.getpid()}-{uuid.uuid4().hex}"
|
||||
try:
|
||||
work_dir.mkdir(mode=0o700)
|
||||
probe_path = work_dir / ".write-probe"
|
||||
probe_path.write_text("ok", encoding="utf-8")
|
||||
probe_path.unlink()
|
||||
return work_dir
|
||||
except OSError as exc:
|
||||
errors.append(f"{work_dir}: {exc}")
|
||||
shutil.rmtree(work_dir, ignore_errors=True)
|
||||
|
||||
details = "\n - ".join(errors) if errors else "no candidate directories available"
|
||||
raise PermissionError(
|
||||
"Unable to create a writable PPTX work directory. "
|
||||
"Set the output path to a writable project directory or adjust sandbox permissions. "
|
||||
f"Tried:\n - {details}"
|
||||
)
|
||||
|
||||
|
||||
def _to_float(value: Any, default: float) -> float:
|
||||
if value is None:
|
||||
return default
|
||||
@@ -543,7 +586,7 @@ def create_pptx_with_native_svg(
|
||||
|
||||
animation_cli_overrides = animation_cli_overrides or {}
|
||||
|
||||
temp_dir = Path(tempfile.mkdtemp())
|
||||
temp_dir = _create_writable_work_dir(output_path)
|
||||
|
||||
try:
|
||||
# Create base PPTX with python-pptx
|
||||
|
||||
@@ -42,6 +42,7 @@
|
||||
### Color Scheme
|
||||
|
||||
> Strategist: determine values from project content, industry, brand colors.
|
||||
> Step 4 Confirm UI: present a few color candidates, each with a user-facing core `palette` (background / secondary_bg / primary / accent / secondary_accent / body_text), in `confirm_ui/recommendations.json`; the confirmed candidate from `result.json` seeds this table. Strategist derives the remaining text, border, state, and style-neutral colors when writing this full scheme. Schema: [`scripts/docs/confirm_ui.md`](../scripts/docs/confirm_ui.md).
|
||||
|
||||
| Role | HEX | Purpose |
|
||||
| ---- | --- | ------- |
|
||||
@@ -96,6 +97,8 @@
|
||||
|
||||
**Typography direction**: [Fill in one phrase, e.g., "modern CJK sans" / "academic serif" / "brand-specific: McKinsey Bower (requires font install)"]
|
||||
|
||||
> Step 4 Confirm UI: present a few typography candidates, each splitting CJK + Latin for `heading` and `body` (with `css` preview stacks) and declaring `body_size` as the body baseline px, in `confirm_ui/recommendations.json`; the confirmed candidate from `result.json` seeds the plan below. Schema: [`scripts/docs/confirm_ui.md`](../scripts/docs/confirm_ui.md).
|
||||
|
||||
Two views on the same font decisions — fill both, keep them consistent:
|
||||
|
||||
- **Role breakdown** (table below) — lists the *pieces* per role: CJK font, Latin font, CSS generic fallback. Human-readable design language.
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
"sourceId": "shadcn",
|
||||
"repo": "https://github.com/shadcn-ui/ui.git",
|
||||
"ref": "main",
|
||||
"commit": "ea9d371a2dda3365a382ff361f96b55daeeab88d",
|
||||
"commit": "9197676b3d09b77f51b17b63e5978db29e498ca4",
|
||||
"adapter": "claude-skill",
|
||||
"sourcePath": "skills/shadcn",
|
||||
"syncedAt": "2026-06-12T15:59:59Z"
|
||||
"syncedAt": "2026-06-15T15:59:59Z"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user