diff --git a/apps/apsal-studio/src/styles.css b/apps/apsal-studio/src/styles.css
index dce5867..527f4dc 100644
--- a/apps/apsal-studio/src/styles.css
+++ b/apps/apsal-studio/src/styles.css
@@ -325,11 +325,33 @@ button:disabled {
.detail-media h3 { margin: 0 0 8px; color: var(--text-soft); font-size: 10px; }
.detail-gallery { display: grid; grid-template-columns: repeat(3, 1fr); gap: 5px; }
.detail-gallery img { width: 100%; aspect-ratio: 1; border-radius: 7px; object-fit: cover; background: #22180f; }
-.analysis-progress { display: flex; align-items: center; gap: 10px; margin: 0 20px 14px; padding: 10px; border: 1px solid rgba(245, 165, 36, 0.18); border-radius: 9px; background: rgba(245, 165, 36, 0.05); }
-.analysis-progress > svg { width: 18px; height: 18px; color: var(--accent-soft); }
-.analysis-progress strong, .analysis-progress span { display: block; }
-.analysis-progress strong { color: var(--text-soft); font-size: 10px; }
-.analysis-progress span { margin-top: 3px; color: var(--muted); font-size: 8px; }
+.analysis-execution { margin: 0 20px 14px; overflow: hidden; border: 1px solid rgba(245, 165, 36, 0.18); border-radius: 10px; background: rgba(245, 165, 36, 0.04); }
+.analysis-execution > header { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 11px 12px; border-bottom: 1px solid var(--border); }
+.analysis-execution > header h3 { margin: 2px 0 0; color: var(--text-soft); font-size: 11px; }
+.analysis-link { padding: 4px 7px; border: 1px solid var(--border); border-radius: 99px; color: var(--muted); font-size: 8px; }
+.analysis-link.connected { border-color: rgba(62, 207, 122, .35); color: var(--ok); }
+.analysis-blocker { margin: 10px 10px 0; padding: 9px; border: 1px solid rgba(244, 107, 107, .24); border-radius: 7px; background: rgba(244, 107, 107, .06); }
+.analysis-blocker.headless { border-color: rgba(245, 165, 36, .24); background: rgba(245, 165, 36, .06); }
+.analysis-blocker strong, .analysis-blocker span { display: block; }
+.analysis-blocker strong { color: var(--text-soft); font-size: 9px; }
+.analysis-blocker span { margin-top: 4px; color: var(--muted); font-size: 8px; line-height: 1.45; }
+.analysis-jobs { display: grid; gap: 6px; margin: 0; padding: 10px; list-style: none; }
+.analysis-jobs li { display: flex; align-items: center; gap: 9px; padding: 8px; border: 1px solid var(--border); border-radius: 7px; background: rgba(0, 0, 0, .16); }
+.analysis-jobs li > i { display: grid; width: 20px; height: 20px; flex: 0 0 20px; place-items: center; border: 1px solid var(--border); border-radius: 50%; color: var(--muted); font: 8px ui-monospace, SFMono-Regular, Menlo, monospace; }
+.analysis-jobs li > i svg { width: 12px; height: 12px; }
+.analysis-jobs li.in_progress > i { border-color: var(--accent); color: var(--accent-soft); }
+.analysis-jobs li.succeeded > i { border-color: rgba(62, 207, 122, .4); color: var(--ok); }
+.analysis-jobs li strong, .analysis-jobs li span, .analysis-jobs li em { display: block; }
+.analysis-jobs li strong { color: var(--text-soft); font-size: 9px; }
+.analysis-jobs li span { margin-top: 3px; color: var(--muted); font-size: 8px; }
+.analysis-jobs li em { margin-top: 3px; color: var(--danger); font-size: 8px; font-style: normal; }
+.analysis-timeline { margin: 0 10px 10px; padding: 9px; border-top: 1px solid var(--border); }
+.analysis-timeline h4 { margin: 0 0 7px; color: var(--text-soft); font-size: 9px; }
+.analysis-timeline ol { display: grid; gap: 6px; margin: 0; padding: 0; list-style: none; }
+.analysis-timeline li { display: grid; grid-template-columns: 54px 1fr; gap: 7px; color: var(--muted); font-size: 8px; line-height: 1.4; }
+.analysis-timeline time { color: var(--accent-soft); font: 7px ui-monospace, SFMono-Regular, Menlo, monospace; }
+.analysis-timeline p { margin: 0; color: var(--muted); font-size: 8px; }
+.analysis-execution > footer { padding: 0 10px 10px; color: var(--muted-dark); font-size: 7px; line-height: 1.45; }
.detail-actions { display: grid; gap: 7px; padding: 4px 20px 16px; }
.detail-actions button { width: 100%; min-height: 40px; justify-content: center; }
.library-tags { margin: 0 20px 16px; padding: 12px; border: 1px solid var(--border); border-radius: 10px; background: rgba(255, 255, 255, .015); }
diff --git a/plugins/apsal-studio/scripts/apsal_creative.py b/plugins/apsal-studio/scripts/apsal_creative.py
index e96b719..b90f65e 100644
--- a/plugins/apsal-studio/scripts/apsal_creative.py
+++ b/plugins/apsal-studio/scripts/apsal_creative.py
@@ -287,6 +287,27 @@ def _write_analysis(project_root: Path, value: dict[str, Any]) -> None:
engine._write_private_json(value, _analysis_file(project_root, value["analysis_id"]))
+def _append_analysis_activity(
+ analysis: dict[str, Any],
+ event_type: str,
+ *,
+ job: dict[str, Any] | None = None,
+ error: str | None = None,
+) -> None:
+ event: dict[str, Any] = {
+ "event_id": f"EVENT-{uuid.uuid4().hex[:12].upper()}",
+ "type": event_type,
+ "at": engine._utc_now(),
+ }
+ if job is not None:
+ event.update({"job_id": job["job_id"], "kind": job["kind"]})
+ if error:
+ event["error"] = error
+ activity = analysis.setdefault("activity", [])
+ activity.append(event)
+ analysis["activity"] = activity[-200:]
+
+
def start_analysis(project_root: Path) -> dict[str, Any]:
root = project_root.expanduser().resolve()
references = load_project_references(root).get("references", [])
@@ -318,9 +339,11 @@ def start_analysis(project_root: Path) -> dict[str, Any]:
"project_id": _read_project(root)["project_id"],
"status": "analyzing",
"jobs": jobs,
+ "activity": [],
"created_at": engine._utc_now(),
"updated_at": engine._utc_now(),
}
+ _append_analysis_activity(value, "analysis_started")
_write_analysis(root, value)
return value
@@ -379,12 +402,22 @@ def next_analysis_job(project_root: Path, analysis_id: str) -> dict[str, Any]:
synthesis = next(job for job in analysis["jobs"] if job["kind"] == "synthesis")
if all(job["status"] == "succeeded" for job in image_jobs) and synthesis["status"] == "blocked":
synthesis["status"] = "pending"
- _write_analysis(root, analysis)
- job = next((item for item in analysis["jobs"] if item["status"] == "pending"), None)
+ _append_analysis_activity(analysis, "synthesis_ready", job=synthesis)
+ job = next((item for item in analysis["jobs"] if item["status"] == "in_progress"), None)
+ resumed = job is not None
+ if job is None:
+ job = next((item for item in analysis["jobs"] if item["status"] == "pending"), None)
if job is None:
job = next((item for item in analysis["jobs"] if item["status"] == "failed"), None)
if job is None:
return {"analysis_id": analysis_id, "status": analysis["status"], "job": None}
+ if not resumed:
+ event_type = "job_retried" if job["status"] == "failed" else "job_claimed"
+ job["status"] = "in_progress"
+ job["claimed_at"] = engine._utc_now()
+ job["claim_count"] = int(job.get("claim_count", 0)) + 1
+ _append_analysis_activity(analysis, event_type, job=job)
+ _write_analysis(root, analysis)
references = _analysis_reference_map(root)
reference_paths = [str(engine._vault_reference_path(references[item]["vault_uri"])) for item in job["reference_ids"]]
if job["kind"] == "image":
@@ -414,6 +447,8 @@ def next_analysis_job(project_root: Path, analysis_id: str) -> dict[str, Any]:
"direct_api_calls": False,
"attempt_count": len(job.get("attempts", [])),
"last_error": job.get("error"),
+ "claim_status": "resumed" if resumed else "claimed",
+ "claimed_at": job.get("claimed_at"),
}
@@ -477,13 +512,22 @@ def record_analysis(
attempt["error"] = message
job["attempts"].append(attempt)
job["status"] = status
+ job["claimed_at"] = None
+ _append_analysis_activity(
+ analysis,
+ "job_succeeded" if status == "succeeded" else "job_failed",
+ job=job,
+ error=attempt.get("error"),
+ )
image_jobs = [item for item in analysis["jobs"] if item["kind"] == "image"]
synthesis = next(item for item in analysis["jobs"] if item["kind"] == "synthesis")
if all(item["status"] == "succeeded" for item in image_jobs) and synthesis["status"] == "blocked":
synthesis["status"] = "pending"
+ _append_analysis_activity(analysis, "synthesis_ready", job=synthesis)
if synthesis["status"] == "succeeded":
analysis["status"] = "completed"
analysis["synthesis"] = synthesis["result"]
+ _append_analysis_activity(analysis, "analysis_completed")
elif any(item["status"] == "failed" for item in analysis["jobs"]):
analysis["status"] = "partial"
else:
@@ -832,6 +876,16 @@ def library_get(project_id: str, *, home: Path | None = None) -> dict[str, Any]:
"status": item.get("status"),
"job_count": len(item.get("jobs", [])),
"completed_job_count": sum(job.get("status") == "succeeded" for job in item.get("jobs", [])),
+ "jobs": [{
+ "job_id": job.get("job_id"),
+ "kind": job.get("kind"),
+ "status": job.get("status"),
+ "reference_ids": job.get("reference_ids", []),
+ "attempt_count": len(job.get("attempts", [])),
+ "last_error": job.get("error"),
+ "claimed_at": job.get("claimed_at"),
+ } for job in item.get("jobs", [])],
+ "activity": item.get("activity", []),
"design_session_id": item.get("design_session_id"),
"updated_at": item.get("updated_at"),
} for item in list_analysis(root)]
diff --git a/plugins/apsal-studio/scripts/apsal_mcp.py b/plugins/apsal-studio/scripts/apsal_mcp.py
index 5a88b7b..4314a66 100644
--- a/plugins/apsal-studio/scripts/apsal_mcp.py
+++ b/plugins/apsal-studio/scripts/apsal_mcp.py
@@ -176,9 +176,9 @@ def _schema(properties: dict[str, Any], required: list[str]) -> dict[str, Any]:
"annotations": {"readOnlyHint": False, "destructiveHint": False, "openWorldHint": False},
},
{
- "name": "get_next_analysis_job", "description": "Return the next strict multimodal analysis or synthesis job without calling an external vision API.",
+ "name": "get_next_analysis_job", "description": "Claim and return the next strict multimodal analysis or synthesis job, recording auditable progress without calling an external vision API.",
"inputSchema": _schema({"analysis_id": {"type": "string"}, "project_root": {"type": "string"}}, ["analysis_id"]),
- "annotations": {"readOnlyHint": True, "destructiveHint": False, "openWorldHint": False},
+ "annotations": {"readOnlyHint": False, "destructiveHint": False, "openWorldHint": False},
},
{
"name": "record_analysis_result", "description": "Record one validated thirteen-element Codex analysis result or failure with resumable attempts.",
diff --git a/plugins/apsal-studio/skills/apsal-theme-creator/SKILL.md b/plugins/apsal-studio/skills/apsal-theme-creator/SKILL.md
index 858d8b3..4fe875f 100644
--- a/plugins/apsal-studio/skills/apsal-theme-creator/SKILL.md
+++ b/plugins/apsal-studio/skills/apsal-theme-creator/SKILL.md
@@ -51,7 +51,7 @@ When the creator supplies 1–24 images as a reference group, prefer the project
1. Resolve each image's source, copyright/attribution, portrait consent, redistribution, AI-modification and identity permissions. Without explicit identity authorization, forbid face locking and use only observable style, space, light, color, wardrobe, prop and composition information.
2. Call `create_reference_project` once for the complete group. One upload creates one root project; do not create one unrelated project per image.
-3. Call `start_reference_analysis`, then repeatedly call `get_next_analysis_job`. For image Jobs, inspect the returned real image and produce only the strict JSON result requested by its schema. Separate observable facts from creative inference and record uncertainty and risk. For the synthesis Job, combine common visual DNA, conflicts, complements and recommended creative directions. Write every result with `record_analysis_result`; failures remain retryable.
+3. Call `start_reference_analysis`, then repeatedly call `get_next_analysis_job`. Claiming a Job records an auditable Studio activity event; every successful or failed `record_analysis_result` adds a validated timeline entry. For image Jobs, inspect the returned real image and produce only the strict JSON result requested by its schema. Separate observable facts from creative inference and record uncertainty and risk. For the synthesis Job, combine common visual DNA, conflicts, complements and recommended creative directions. Write every result with `record_analysis_result`; failures remain retryable.
4. After `get_analysis_status` reports completion, call `build_design_from_analysis`. Automatic mode may build the theme, Prompts, negative constraints, QA and Skill without routine layer questions, but never bypasses unresolved rights, identity or public-release gates.
5. For any new scene, camera, light, styling, series or nine-shot direction, call `fork_project` before generation. Record the source assets and fork type. Never mutate the parent to represent the expansion.
6. Use `reconcile_library` after project creation and successful outputs. Use library metadata only for search, tags, favorite and archive presentation; `.apsal/` remains authoritative.
diff --git a/plugins/apsal-studio/skills/apsal-theme-creator/references/INTERACTION.md b/plugins/apsal-studio/skills/apsal-theme-creator/references/INTERACTION.md
index dac899a..5c1ff94 100644
--- a/plugins/apsal-studio/skills/apsal-theme-creator/references/INTERACTION.md
+++ b/plugins/apsal-studio/skills/apsal-theme-creator/references/INTERACTION.md
@@ -28,6 +28,8 @@ An attached APSAL ZIP or directory with `run.json` is a creator artifact, not a
The creator sees natural language, compact text-only element and DNA cards, a five-stage semantic thumbnail strip, the nine-shot overview, and generation progress. YAML is the editable background source; canonical JSON is the immutable execution and lineage artifact. Do not expose either by default.
+Reference analysis progress is an auditable execution timeline rather than a raw model transcript. Studio shows whether Codex is linked, which per-image or synthesis Job was claimed, Schema-validated result/failure writes, retries, and completion. It never exposes hidden chain-of-thought. A pending Job with no claim event means Codex has not started it; do not describe that state as slow or in progress.
+
Each DNA card is textual and must display its title, stable reference, core constraints, scope (`project`, `personal`, `extension`, or `official`), version, rights license, attribution, QA state, recommendation reason, matched tags and relevant scene facets. Do not render Registry thumbnails in the selection flow. Preview sidecars remain background assets for rights review, validation and Extension Pack exchange. In a client without MCP Apps, print the same choices as a numbered list and accept a number or natural-language revision.
The stage thumbnail strip is a different surface. It contains one localized 4:3 semantic summary for Direction, Worldbuilding, Narrative, Image and Delivery, showing confirmed/current/pending progress. It may appear above the element text cards, but it must not replace the complete proposal. Each thumbnail is accessible, deterministic, rights-clear, and explicitly marked `generation_input: false`. Never place it under `assets/references/` or send it to Codex image generation.
diff --git a/tests/test_creative_library.py b/tests/test_creative_library.py
index 1c93c13..7fa05e0 100644
--- a/tests/test_creative_library.py
+++ b/tests/test_creative_library.py
@@ -212,6 +212,12 @@ def test_failed_analysis_job_can_be_retried_and_identity_requires_portrait_autho
first = protocol.handle_domain_method(
"analysis.next", {"project_root": str(project), "analysis_id": started["analysis_id"]}
)
+ claimed = protocol.handle_domain_method(
+ "analysis.status", {"project_root": str(project), "analysis_id": started["analysis_id"]}
+ )
+ self.assertEqual(claimed["jobs"][0]["status"], "in_progress")
+ self.assertEqual(first["claim_status"], "claimed")
+ self.assertEqual([event["type"] for event in claimed["activity"]], ["analysis_started", "job_claimed"])
failed = protocol.handle_domain_method(
"analysis.record",
{
@@ -237,6 +243,7 @@ def test_failed_analysis_job_can_be_retried_and_identity_requires_portrait_autho
self.assertEqual(retry["job_id"], first["job_id"])
self.assertEqual(retry["attempt_count"], 1)
self.assertEqual(retry["last_error"], "temporary interruption")
+ self.assertEqual(retry["claim_status"], "claimed")
recovered = protocol.handle_domain_method(
"analysis.record",
{
diff --git a/tests/test_studio_frontend.py b/tests/test_studio_frontend.py
index 3d07c21..06a6c2c 100644
--- a/tests/test_studio_frontend.py
+++ b/tests/test_studio_frontend.py
@@ -94,6 +94,9 @@ def test_private_reference_media_is_visible_only_through_the_safe_protocol(self)
self.assertIn("if (!wasRunning) publish('apsal-protocol:status', await protocolStatus())", main)
self.assertIn("apsal-media://asset?path=", library)
self.assertIn("参考图已安全入库并显示在项目详情中", library)
+ self.assertIn("CODEX 执行记录", library)
+ self.assertIn("当前没有 Codex 领取任务", library)
+ self.assertIn("不展示模型内部隐藏推理", library)
def test_codex_bridge_keeps_full_domain_route_without_arbitrary_proxy(self) -> None:
bridge = (STUDIO / "electron" / "apsal-link.mjs").read_text()
From 5f5580d5832d3c3d1311526bb413ab9618d6e57c Mon Sep 17 00:00:00 2001
From: henyjone <39879909+henyjone@users.noreply.github.com>
Date: Thu, 23 Jul 2026 02:18:09 +0800
Subject: [PATCH 3/4] Connect Codex to existing Studio projects
---
apps/apsal-studio/src/App.tsx | 6 ++---
docs/releases/0.16.0.md | 4 ++-
plugins/apsal-studio/scripts/apsal_mcp.py | 15 ++++++++++-
.../skills/apsal-theme-creator/SKILL.md | 2 ++
scripts/validate_plugin_bundle.py | 6 ++---
tests/test_engine.py | 5 ++--
tests/test_protocol.py | 26 +++++++++++++++++++
tests/test_studio_frontend.py | 3 ++-
8 files changed, 56 insertions(+), 11 deletions(-)
diff --git a/apps/apsal-studio/src/App.tsx b/apps/apsal-studio/src/App.tsx
index 3a08b79..3917234 100644
--- a/apps/apsal-studio/src/App.tsx
+++ b/apps/apsal-studio/src/App.tsx
@@ -397,15 +397,15 @@ function CodexLinkPanel() {
CODEX 联动
-
{linkStatus?.connected ? 'Codex 已连接' : '等待 Codex 启动'}
+ {linkStatus?.connected ? 'Codex 已连接' : '等待 Codex 选择项目'}
由插件启动
{linkStatus?.connected ? 'Codex 插件正通过本机认证桥访问当前项目。它不能代理任意路径,也不能绕过协议 revision。' : '请在 Codex 中打开 APSAL 插件并开始创作;选择“打开并联动 APSAL Studio”后,插件会自动打开并绑定此界面。'}
+ {linkStatus?.connected ? 'Codex 插件正通过本机认证桥访问当前项目。它不能代理任意路径,也不能绕过协议 revision。' : '请在 Codex 中发送“连接我刚上传的 APSAL 项目并继续分析”。插件会选择已有项目、启动本机认证桥并绑定此界面,不会额外创建主题。'}
绑定项目{snapshot?.project.project_id ?? '未选择'}
{studioUpdates.length ? `${studioUpdates.length} 条已发往 Codex` : '双方已同步'}
diff --git a/docs/releases/0.16.0.md b/docs/releases/0.16.0.md
index e048136..c214f9c 100644
--- a/docs/releases/0.16.0.md
+++ b/docs/releases/0.16.0.md
@@ -48,7 +48,9 @@ GitHub distribution, not a different project schema.
The Codex MCP exposes creator-facing equivalents for all of these capabilities
and still includes the complete headless theme, generation, Registry and Studio
-link surface. The 0.16 server exposes 50 tools.
+link surface. The 0.16 server exposes 51 tools, including a non-semantic
+`apsal_frontend_connect` action for selecting a Studio-created project without
+creating a design session.
## Project layout
diff --git a/plugins/apsal-studio/scripts/apsal_mcp.py b/plugins/apsal-studio/scripts/apsal_mcp.py
index 4314a66..a9bff20 100644
--- a/plugins/apsal-studio/scripts/apsal_mcp.py
+++ b/plugins/apsal-studio/scripts/apsal_mcp.py
@@ -260,6 +260,11 @@ def _schema(properties: dict[str, Any], required: list[str]) -> dict[str, Any]:
"inputSchema": _schema({}, []),
"annotations": {"readOnlyHint": True, "destructiveHint": False, "openWorldHint": False},
},
+ {
+ "name": "apsal_frontend_connect", "description": "Select and authenticate an existing APSAL project for Studio linkage without creating or changing a design session.",
+ "inputSchema": _schema({"project_root": {"type": "string"}}, []),
+ "annotations": {"readOnlyHint": False, "destructiveHint": False, "openWorldHint": False},
+ },
{
"name": "apsal_frontend_get_project", "description": "Read the canonical APSAL project snapshot currently open in linked APSAL Studio.",
"inputSchema": _schema({}, []),
@@ -823,6 +828,13 @@ def _tool_frontend_status(arguments: dict[str, Any]) -> dict[str, Any]:
return {**status, "selected_for_codex": selected}
+def _tool_frontend_connect(arguments: dict[str, Any]) -> dict[str, Any]:
+ root = _root(arguments)
+ status = _frontend_choice({**arguments, "frontend_mode": "studio"}, root)
+ selected = str(root.resolve()) in ACTIVE_FRONTEND_PROJECTS
+ return {**status, "selected_for_codex": selected}
+
+
def _tool_frontend_get_project(arguments: dict[str, Any]) -> dict[str, Any]:
del arguments
_selected_frontend_status()
@@ -942,6 +954,7 @@ def _tool_frontend_focus(arguments: dict[str, Any]) -> dict[str, Any]:
"publish_share": _tool_share_publish,
"get_share_status": _tool_share_status,
"apsal_frontend_status": _tool_frontend_status,
+ "apsal_frontend_connect": _tool_frontend_connect,
"apsal_frontend_get_project": _tool_frontend_get_project,
"apsal_frontend_get_updates": _tool_frontend_updates,
"apsal_frontend_preview_changes": _tool_frontend_preview,
@@ -956,7 +969,7 @@ def call_tool(name: str, arguments: dict[str, Any]) -> dict[str, Any]:
if name not in HANDLERS: raise ValidationError(f"unknown MCP tool: {name}")
value = HANDLERS[name](arguments)
if ACTIVE_FRONTEND_PROJECTS and name not in {
- "apsal_frontend_status", "apsal_frontend_get_project", "apsal_frontend_get_updates",
+ "apsal_frontend_status", "apsal_frontend_connect", "apsal_frontend_get_project", "apsal_frontend_get_updates",
}:
try:
_selected_frontend_status()
diff --git a/plugins/apsal-studio/skills/apsal-theme-creator/SKILL.md b/plugins/apsal-studio/skills/apsal-theme-creator/SKILL.md
index 4fe875f..1818a28 100644
--- a/plugins/apsal-studio/skills/apsal-theme-creator/SKILL.md
+++ b/plugins/apsal-studio/skills/apsal-theme-creator/SKILL.md
@@ -39,6 +39,8 @@ At the start of each new or resumed creative workflow, ask once before calling `
Pass `frontend_mode: studio` only after the creator chooses to open it; pass `frontend_mode: headless` when they decline. The start tool initializes or resumes the canonical project first, then launches the installed APSAL Studio app with that exact project and an authenticated link. Verify that the returned `frontend.routing_mode` is `studio` and that `apsal_frontend_status` reports both `connected: true` and `selected_for_codex: true` before using linked tools. Merely finding Studio already open or a bridge descriptor on disk is not permission to link this Codex creation.
+If the creator already made the reference project in Studio and asks Codex to continue it, do not create a design session merely to obtain linkage. Resolve the intended project from the project library, call `apsal_frontend_connect` with its `project_root`, and verify `connected: true` plus `selected_for_codex: true`. This selection changes only the authenticated routing state; it must not change the project revision, create a theme, or claim an analysis Job. Then continue the existing analysis or design state through the normal tools.
+
When Studio is selected, call `apsal_frontend_get_updates` at the start of every new creator turn and before creating another semantic preview. Then read the canonical snapshot with `apsal_frontend_get_project`. A Studio-origin update is a revision-bound creator proposal, not a second project state: summarize its field diffs and downstream invalidations, then use `apsal_frontend_apply_preview` or `apsal_frontend_reject_preview` according to the creator's instruction before continuing. Never overwrite or ignore a pending Studio proposal. Other linked tool results may also include a compact `frontend_updates` reminder; handle it before the next semantic write.
Create Codex-origin revisions with `apsal_frontend_preview_changes`, then use `apsal_frontend_apply_preview` or `apsal_frontend_reject_preview` only after explicit confirmation. Use `apsal_frontend_focus_elements` for visual location and `apsal_frontend_undo_operation` only for an operation the creator chose to undo. If the authenticated link drops after selection, stop and report it; never silently retry that write through the direct path.
diff --git a/scripts/validate_plugin_bundle.py b/scripts/validate_plugin_bundle.py
index c8d139a..400b584 100644
--- a/scripts/validate_plugin_bundle.py
+++ b/scripts/validate_plugin_bundle.py
@@ -73,8 +73,8 @@ def main() -> int:
except json.JSONDecodeError as exc: errors.append(f"MCP smoke test returned invalid JSON: {exc}"); responses = []
if len(responses) != 5: errors.append("MCP smoke test: expected five responses")
elif responses[0].get("result", {}).get("serverInfo", {}).get("version") != manifest.get("version"): errors.append("MCP smoke test: server and manifest versions differ")
- elif len(responses[1].get("result", {}).get("tools", [])) != 50: errors.append("MCP smoke test: expected fifty tools")
- elif not {"set_session_language", "recommend_dna", "recommend_layer_dna", "present_element_layer", "commit_element_layer", "suggest_dna_tags", "resolve_dna_memory", "record_dna_feedback", "export_dna_pack", "install_dna_pack", "start_generation_run", "get_next_codex_job", "import_apsal_package", "bind_import_reference", "create_reference_project", "fork_project", "start_reference_analysis", "get_next_analysis_job", "record_analysis_result", "build_design_from_analysis", "reconcile_library", "list_library_projects", "export_project", "import_project", "create_share_draft", "preview_share", "confirm_share", "publish_share", "apsal_frontend_status", "apsal_frontend_get_project", "apsal_frontend_get_updates", "apsal_frontend_preview_changes", "apsal_frontend_apply_preview", "apsal_frontend_reject_preview", "apsal_frontend_undo_operation", "apsal_frontend_focus_elements"}.issubset({tool.get("name") for tool in responses[1].get("result", {}).get("tools", [])}):
+ elif len(responses[1].get("result", {}).get("tools", [])) != 51: errors.append("MCP smoke test: expected fifty-one tools")
+ elif not {"set_session_language", "recommend_dna", "recommend_layer_dna", "present_element_layer", "commit_element_layer", "suggest_dna_tags", "resolve_dna_memory", "record_dna_feedback", "export_dna_pack", "install_dna_pack", "start_generation_run", "get_next_codex_job", "import_apsal_package", "bind_import_reference", "create_reference_project", "fork_project", "start_reference_analysis", "get_next_analysis_job", "record_analysis_result", "build_design_from_analysis", "reconcile_library", "list_library_projects", "export_project", "import_project", "create_share_draft", "preview_share", "confirm_share", "publish_share", "apsal_frontend_status", "apsal_frontend_connect", "apsal_frontend_get_project", "apsal_frontend_get_updates", "apsal_frontend_preview_changes", "apsal_frontend_apply_preview", "apsal_frontend_reject_preview", "apsal_frontend_undo_operation", "apsal_frontend_focus_elements"}.issubset({tool.get("name") for tool in responses[1].get("result", {}).get("tools", [])}):
errors.append("MCP smoke test: 0.16 creative-library, authoring, frontend, Prompt delivery, generation, memory, or exchange tools missing")
elif "execute_generation_run" in {tool.get("name") for tool in responses[1].get("result", {}).get("tools", [])}:
errors.append("MCP smoke test: direct provider execution must not be exposed")
@@ -100,7 +100,7 @@ def main() -> int:
if errors:
print("\n".join(errors))
return 1
- print(f"APSAL Studio {manifest['version']} plugin validated: manifest, Skill, 50 MCP tools, text-only DNA cards and bilingual stage thumbnails")
+ print(f"APSAL Studio {manifest['version']} plugin validated: manifest, Skill, 51 MCP tools, text-only DNA cards and bilingual stage thumbnails")
return 0
diff --git a/tests/test_engine.py b/tests/test_engine.py
index 71004d4..c1db0bd 100644
--- a/tests/test_engine.py
+++ b/tests/test_engine.py
@@ -805,7 +805,7 @@ def test_private_reference_rejects_non_image_bytes_before_vault_write(self):
engine.store_private_reference(source, home=home)
self.assertFalse((home / "vault").exists())
- def test_mcp_lists_fifty_tools_and_returns_text_only_card_data(self):
+ def test_mcp_lists_fifty_one_tools_and_returns_text_only_card_data(self):
with tempfile.TemporaryDirectory() as tmp:
project, home = Path(tmp) / "project", Path(tmp) / "home"; project.mkdir()
session = engine.start_design_session("欢喜但克制的窗边真人摄影", project_root=project, home=home, theme_id="TEST-MCP-ELEMENTS")
@@ -832,7 +832,7 @@ def test_mcp_lists_fifty_tools_and_returns_text_only_card_data(self):
process = subprocess.run([sys.executable, "scripts/apsal_mcp.py"], cwd=ROOT / "plugins/apsal-studio", input="".join(json.dumps(item) + "\n" for item in requests), text=True, capture_output=True, env=env, check=True)
responses = [json.loads(line) for line in process.stdout.splitlines()]
self.assertEqual(responses[0]["result"]["serverInfo"]["version"], "0.16.0")
- self.assertEqual(len(responses[1]["result"]["tools"]), 50)
+ self.assertEqual(len(responses[1]["result"]["tools"]), 51)
names = {item["name"] for item in responses[1]["result"]["tools"]}
start_tool = next(item for item in responses[1]["result"]["tools"] if item["name"] == "start_design_session")
self.assertEqual(start_tool["inputSchema"]["properties"]["frontend_mode"]["enum"], ["headless", "studio"])
@@ -841,6 +841,7 @@ def test_mcp_lists_fifty_tools_and_returns_text_only_card_data(self):
self.assertIn("get_next_codex_job", names)
self.assertIn("import_apsal_package", names)
self.assertIn("bind_import_reference", names)
+ self.assertIn("apsal_frontend_connect", names)
self.assertIn("apsal_frontend_preview_changes", names)
self.assertIn("apsal_frontend_get_updates", names)
self.assertIn("apsal_frontend_focus_elements", names)
diff --git a/tests/test_protocol.py b/tests/test_protocol.py
index 6e19797..e8633a4 100644
--- a/tests/test_protocol.py
+++ b/tests/test_protocol.py
@@ -499,6 +499,32 @@ def test_frontend_launcher_opens_only_the_fixed_studio_app_for_the_project(self)
self.assertEqual(command[1:], ["--project-root", str(project.resolve()), "--codex-link"])
self.assertNotIn("shell", popen.call_args.kwargs)
+ def test_existing_project_can_be_selected_for_codex_without_starting_design(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ project = Path(tmp) / "studio-project"
+ protocol.init_protocol_project(project)
+ connected = {
+ "connected": True,
+ "compatible": True,
+ "project_root": str(project.resolve()),
+ "project_id": "PROJECT-STUDIO-EXISTING",
+ "revision": 0,
+ }
+ apsal_mcp.ACTIVE_FRONTEND_PROJECTS.discard(str(project.resolve()))
+ with mock.patch.object(apsal_mcp, "launch_frontend", return_value=connected) as launch:
+ linked = apsal_mcp.call_tool(
+ "apsal_frontend_connect",
+ {"project_root": str(project)},
+ )["structuredContent"]
+ launch.assert_called_once_with(project.resolve())
+ self.assertTrue(linked["connected"])
+ self.assertTrue(linked["selected_for_codex"])
+ self.assertEqual(linked["routing_mode"], "studio")
+ self.assertEqual(protocol.project_snapshot(project)["revision"], 0)
+ self.assertEqual(list((project / ".apsal" / "themes").iterdir()), [])
+ self.assertIsNone(protocol.load_json(project / ".apsal" / "project.json").get("active_session_id"))
+ apsal_mcp.ACTIVE_FRONTEND_PROJECTS.discard(str(project.resolve()))
+
def test_start_design_session_records_explicit_studio_or_headless_choice(self):
with tempfile.TemporaryDirectory() as tmp:
studio_project = Path(tmp) / "studio"
diff --git a/tests/test_studio_frontend.py b/tests/test_studio_frontend.py
index 06a6c2c..bc090c1 100644
--- a/tests/test_studio_frontend.py
+++ b/tests/test_studio_frontend.py
@@ -120,7 +120,8 @@ def test_existing_project_can_be_opened_from_the_command_line(self) -> None:
self.assertIn(".apsal', 'project.json", main)
self.assertNotIn("apsal-link:set-enabled", main)
self.assertNotIn("setLinkEnabled", preload)
- self.assertIn("单独打开 Studio 不会连接 Codex", app)
+ self.assertIn("请从 Codex 发起当前项目联动", app)
+ self.assertIn("连接我刚上传的 APSAL 项目并继续分析", app)
self.assertIn("发送给 Codex", app)
self.assertIn("全自动创作", app)
self.assertIn("origin: 'studio'", (STUDIO / "src" / "protocol" / "store.ts").read_text())
From 302534039c782a3081695f86f42a8c0d628d7d2a Mon Sep 17 00:00:00 2001
From: henyjone <39879909+henyjone@users.noreply.github.com>
Date: Thu, 23 Jul 2026 02:59:19 +0800
Subject: [PATCH 4/4] Show complete auditable analysis records
---
apps/apsal-studio/src/CreativeLibrary.tsx | 151 +++++++++++++++++-
apps/apsal-studio/src/styles.css | 55 +++++--
docs/USAGE_GUIDE.md | 2 +
docs/USAGE_GUIDE.zh-CN.md | 2 +
docs/releases/0.16.0.md | 5 +
.../apsal-studio/scripts/apsal_creative.py | 145 +++++++++++++----
tests/test_creative_library.py | 19 +++
tests/test_studio_frontend.py | 6 +-
8 files changed, 341 insertions(+), 44 deletions(-)
diff --git a/apps/apsal-studio/src/CreativeLibrary.tsx b/apps/apsal-studio/src/CreativeLibrary.tsx
index ece7a6c..84d9ff3 100644
--- a/apps/apsal-studio/src/CreativeLibrary.tsx
+++ b/apps/apsal-studio/src/CreativeLibrary.tsx
@@ -59,8 +59,29 @@ interface LibraryAnalysis {
status: string
reference_ids: string[]
attempt_count: number
+ attempts: Array<{
+ status: string
+ recorded_at: string
+ result_digest?: string
+ schema_validated?: boolean
+ result_summary?: Record
+ error?: string
+ }>
last_error?: string | null
claimed_at?: string | null
+ claim_count: number
+ task?: {
+ instruction: string
+ codex_tool: string
+ direct_api_calls: boolean
+ input_mode: string
+ reference_ids: string[]
+ context_job_ids: string[]
+ identity_continuity_allowed: boolean
+ result_schema: Record
+ result_schema_digest: string
+ } | null
+ result?: Record | null
}>
activity: Array<{
event_id: string
@@ -69,6 +90,7 @@ interface LibraryAnalysis {
job_id?: string
kind?: 'image' | 'synthesis'
error?: string
+ details?: Record
}>
}
@@ -113,7 +135,9 @@ interface SharePreview {
const STAGE_LABELS: Record = {
references_ready: '参考图已入库',
- analyzing: 'Codex 分析中',
+ analysis_waiting: '等待 Codex 领取',
+ analyzing: 'Codex 执行中',
+ analysis_partial: '分析失败,可重试',
design_ready: '分析已完成',
skill_ready: 'Prompt / Skill 就绪',
generating: '扩展生成中',
@@ -170,6 +194,7 @@ function operationId(prefix: string): string {
}
function stageIndex(stage: string): number {
+ if (stage === 'analysis_waiting' || stage === 'analysis_partial') return 1
const index = PIPELINE.findIndex(([value]) => value === stage)
return index < 0 ? 0 : index
}
@@ -230,6 +255,124 @@ function jobStatusLabel(status: string): string {
return status
}
+type AnalysisJob = LibraryAnalysis['jobs'][number]
+
+function formatAnalysisTime(value?: string | null): string {
+ if (!value) return '未记录'
+ return new Date(value).toLocaleString('zh-CN', {
+ year: 'numeric', month: '2-digit', day: '2-digit',
+ hour: '2-digit', minute: '2-digit', second: '2-digit',
+ })
+}
+
+function jsonText(value: unknown): string {
+ return JSON.stringify(value, null, 2)
+}
+
+function stringList(value: unknown): string[] {
+ return Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : []
+}
+
+function AnalysisValueList({ title, items }: { title: string; items: string[] }) {
+ return (
+
+ {title}{items.length}
+ {items.length ? {items.map((item, index) => - {item}
)}
: 无
}
+
+ )
+}
+
+function AnalysisElementGrid({ value }: { value: unknown }) {
+ const elements = value && typeof value === 'object' && !Array.isArray(value)
+ ? Object.entries(value as Record) : []
+ return (
+
+ APSAL 五层十三要素{elements.length}
+ {elements.map(([role, decision]) => (
+
+ {APSAL_ROLE_LABELS[role] ?? role}
+ {jsonText(decision)}
+
+ ))}
+
+ )
+}
+
+function AnalysisResult({ job }: { job: AnalysisJob }) {
+ const result = job.result
+ if (!result) return {job.status === 'in_progress' ? 'Codex 正在生成结构化结果,回写后会在这里完整显示。' : '尚无结构化回写。'}
+ const image = job.kind === 'image'
+ const groups = image ? [
+ ['可观测事实', stringList(result.observed)],
+ ['创作推断', stringList(result.inferred)],
+ ['参考图角色', stringList(result.reference_roles)],
+ ['锁定元素', stringList(result.locks)],
+ ['可变化元素', stringList(result.variables)],
+ ['风险', stringList(result.risks)],
+ ['不确定项', stringList(result.uncertainties)],
+ ] as Array<[string, string[]]> : [
+ ['共同视觉 DNA', stringList(result.common_visual_dna)],
+ ['冲突项', stringList(result.conflicts)],
+ ['互补关系', stringList(result.complements)],
+ ['推荐创作方向', stringList(result.recommended_directions)],
+ ] as Array<[string, string[]]>
+ return (
+
+
{groups.map(([title, items]) =>
)}
+
+
+ 查看 Engine 原样保存的完整 JSON 回写
+ {jsonText(result)}
+
+
+ )
+}
+
+function AnalysisJobRecord({ job, index }: { job: AnalysisJob; index: number }) {
+ const task = job.task
+ return (
+
+
+
{job.status === 'succeeded' ? : job.status === 'in_progress' ? : {index + 1}}
+
{job.kind === 'image' ? `参考图 ${index + 1} · 五层十三要素` : '整组综合 · 视觉 DNA'}{jobStatusLabel(job.status)}{job.attempt_count > 0 ? ` · ${job.attempt_count} 次回写` : ''}{job.last_error && {job.last_error}}
+
+
+ 完整任务、Schema 与回写记录
+
+
+ Codex 收到的分析任务
+ {task ? <>
+ {task.instruction}
+
+ - 任务 ID
- {job.job_id}
+ - 输入
- {task.input_mode}
+ - 参考图
- {task.reference_ids.join('、') || '不读取新媒体'}
+ - 上下文任务
- {task.context_job_ids.join('、') || '无'}
+ - 人物身份保持
- {task.identity_continuity_allowed ? '已明确授权' : '禁止'}
+ - 外部视觉 API
- {task.direct_api_calls ? '允许' : '未调用'}
+ - 领取时间
- {formatAnalysisTime(job.claimed_at)}
+ - Schema SHA-256
- {task.result_schema_digest}
+
+ 查看严格 JSON Schema
{jsonText(task.result_schema)}
+ > : 旧任务没有保存任务快照;结果与回写记录仍按原文件显示。
}
+
+
+ 校验与回写尝试{job.attempts.length}
+ {job.attempts.length ? {job.attempts.map((attempt, attemptIndex) => -
+ {attempt.status === 'succeeded' ? 'Schema 校验通过并回写' : '回写失败'}
+
+ {attempt.result_digest &&
结果 SHA-256:{attempt.result_digest}}
+ {attempt.result_summary && {jsonText(attempt.result_summary)}}
+ {attempt.error && {attempt.error}}
+ )}
: 尚未回写。
}
+
+
+
+
+
+ )
+}
+
function AnalysisExecutionPanel({ analysis, linkStatus, projectId }: {
analysis: LibraryAnalysis
linkStatus?: ApsalLinkStatus | null
@@ -243,9 +386,9 @@ function AnalysisExecutionPanel({ analysis, linkStatus, projectId }: {
{!linked && waiting && 当前没有 Codex 领取任务请从 Codex 插件选择“打开并联动 APSAL Studio”,并绑定当前项目;单独打开 Studio 只会建立任务。
}
{!linked && active && 任务已被 Headless Codex 领取当前没有 Studio 实时联动,但结构化进度仍会从项目语义真源刷新。
}
- {analysis.jobs.map((job, index) => - {job.status === 'succeeded' ? : job.status === 'in_progress' ? : {index + 1}}
{job.kind === 'image' ? `参考图 ${index + 1} · 五层十三要素` : '整组综合 · 视觉 DNA'}{jobStatusLabel(job.status)}{job.attempt_count > 0 ? ` · ${job.attempt_count} 次回写` : ''}{job.last_error && {job.last_error}}
)}
- 交流时间线
{analysis.activity.length > 0 ?
{analysis.activity.slice(-12).map((event) => - {activityLabel(event)}
)}
:
旧分析批次尚无执行事件;下一次 Codex 领取或回写后开始记录。
}
-
+ {analysis.jobs.map((job, index) => )}
+ 完整执行时间线{analysis.activity.length} 条
{analysis.activity.length > 0 ?
{analysis.activity.map((event) => {activityLabel(event)}{event.event_id}{event.job_id ? ` · ${event.job_id}` : ''}{event.details &&
{jsonText(event.details)}}
)}
:
旧分析批次尚无执行事件;下一次 Codex 领取或回写后开始记录。
}
+
)
}
diff --git a/apps/apsal-studio/src/styles.css b/apps/apsal-studio/src/styles.css
index 527f4dc..83df705 100644
--- a/apps/apsal-studio/src/styles.css
+++ b/apps/apsal-studio/src/styles.css
@@ -336,20 +336,51 @@ button:disabled {
.analysis-blocker strong { color: var(--text-soft); font-size: 9px; }
.analysis-blocker span { margin-top: 4px; color: var(--muted); font-size: 8px; line-height: 1.45; }
.analysis-jobs { display: grid; gap: 6px; margin: 0; padding: 10px; list-style: none; }
-.analysis-jobs li { display: flex; align-items: center; gap: 9px; padding: 8px; border: 1px solid var(--border); border-radius: 7px; background: rgba(0, 0, 0, .16); }
-.analysis-jobs li > i { display: grid; width: 20px; height: 20px; flex: 0 0 20px; place-items: center; border: 1px solid var(--border); border-radius: 50%; color: var(--muted); font: 8px ui-monospace, SFMono-Regular, Menlo, monospace; }
-.analysis-jobs li > i svg { width: 12px; height: 12px; }
-.analysis-jobs li.in_progress > i { border-color: var(--accent); color: var(--accent-soft); }
-.analysis-jobs li.succeeded > i { border-color: rgba(62, 207, 122, .4); color: var(--ok); }
-.analysis-jobs li strong, .analysis-jobs li span, .analysis-jobs li em { display: block; }
-.analysis-jobs li strong { color: var(--text-soft); font-size: 9px; }
-.analysis-jobs li span { margin-top: 3px; color: var(--muted); font-size: 8px; }
-.analysis-jobs li em { margin-top: 3px; color: var(--danger); font-size: 8px; font-style: normal; }
+.analysis-jobs > li { overflow: hidden; border: 1px solid var(--border); border-radius: 7px; background: rgba(0, 0, 0, .16); }
+.analysis-job-heading { display: flex; align-items: center; gap: 9px; padding: 8px; }
+.analysis-job-heading > i { display: grid; width: 20px; height: 20px; flex: 0 0 20px; place-items: center; border: 1px solid var(--border); border-radius: 50%; color: var(--muted); font: 8px ui-monospace, SFMono-Regular, Menlo, monospace; }
+.analysis-job-heading > i svg { width: 12px; height: 12px; }
+.analysis-jobs > li.in_progress .analysis-job-heading > i { border-color: var(--accent); color: var(--accent-soft); }
+.analysis-jobs > li.succeeded .analysis-job-heading > i { border-color: rgba(62, 207, 122, .4); color: var(--ok); }
+.analysis-job-heading strong, .analysis-job-heading span, .analysis-job-heading em { display: block; }
+.analysis-job-heading strong { color: var(--text-soft); font-size: 9px; }
+.analysis-job-heading span { margin-top: 3px; color: var(--muted); font-size: 8px; }
+.analysis-job-heading em { margin-top: 3px; color: var(--danger); font-size: 8px; font-style: normal; }
+.analysis-job-record { border-top: 1px solid var(--border); }
+.analysis-job-record > summary, .analysis-json-record > summary, .analysis-elements summary { cursor: pointer; list-style-position: inside; color: var(--accent-soft); font-size: 8px; }
+.analysis-job-record > summary { padding: 8px 10px; background: rgba(245, 165, 36, .035); }
+.analysis-job-body { display: grid; gap: 8px; padding: 0 9px 9px; }
+.analysis-contract, .analysis-attempts, .analysis-result { padding: 9px; border: 1px solid var(--border); border-radius: 7px; background: rgba(255, 255, 255, .018); }
+.analysis-contract h5, .analysis-attempts h5, .analysis-elements h5 { display: flex; align-items: center; justify-content: space-between; margin: 0 0 7px; color: var(--text-soft); font-size: 9px; }
+.analysis-contract > p, .analysis-attempts > p, .analysis-empty-result { margin: 0; color: var(--muted); font-size: 8px; line-height: 1.55; }
+.analysis-contract dl { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1px; margin: 8px 0; overflow: hidden; border: 1px solid var(--border); border-radius: 6px; background: var(--border); }
+.analysis-contract dl div { min-width: 0; padding: 6px; background: #17120e; }
+.analysis-contract dt { color: var(--muted-dark); font-size: 7px; }
+.analysis-contract dd { overflow-wrap: anywhere; margin: 3px 0 0; color: var(--text-soft); font: 7px/1.45 ui-monospace, SFMono-Regular, Menlo, monospace; }
+.analysis-json-record { margin-top: 8px; }
+.analysis-json-record > summary { padding: 6px 0; }
+.analysis-json-record pre, .analysis-elements pre, .analysis-attempts pre, .analysis-timeline pre { overflow: auto; max-height: 280px; margin: 5px 0 0; padding: 8px; border: 1px solid var(--border); border-radius: 6px; background: #0d0a08; color: #d6c9bc; font: 7px/1.55 ui-monospace, SFMono-Regular, Menlo, monospace; white-space: pre-wrap; overflow-wrap: anywhere; }
+.analysis-attempts h5 span, .analysis-elements h5 span, .analysis-timeline h4 span, .analysis-value-list h6 span { color: var(--muted-dark); font: 7px ui-monospace, SFMono-Regular, Menlo, monospace; }
+.analysis-attempts ol { display: grid; gap: 5px; margin: 0; padding: 0; list-style: none; }
+.analysis-attempts li { display: grid; gap: 3px; padding: 7px; border: 1px solid var(--border); border-radius: 6px; }
+.analysis-attempts strong { color: var(--text-soft); font-size: 8px; }
+.analysis-attempts time, .analysis-attempts code { color: var(--muted); font: 7px/1.45 ui-monospace, SFMono-Regular, Menlo, monospace; overflow-wrap: anywhere; }
+.analysis-attempts em { color: var(--danger); font-size: 8px; font-style: normal; }
+.analysis-value-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 5px; }
+.analysis-value-list { padding: 7px; border: 1px solid var(--border); border-radius: 6px; }
+.analysis-value-list h6 { display: flex; justify-content: space-between; margin: 0 0 5px; color: var(--text-soft); font-size: 8px; }
+.analysis-value-list ul { display: grid; gap: 4px; margin: 0; padding-left: 14px; color: var(--muted); font-size: 8px; line-height: 1.45; }
+.analysis-value-list p { margin: 0; color: var(--muted-dark); font-size: 8px; }
+.analysis-elements { margin-top: 8px; }
+.analysis-elements > div { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 5px; }
+.analysis-elements details { min-width: 0; padding: 6px; border: 1px solid var(--border); border-radius: 6px; }
.analysis-timeline { margin: 0 10px 10px; padding: 9px; border-top: 1px solid var(--border); }
-.analysis-timeline h4 { margin: 0 0 7px; color: var(--text-soft); font-size: 9px; }
+.analysis-timeline h4 { display: flex; justify-content: space-between; margin: 0 0 7px; color: var(--text-soft); font-size: 9px; }
.analysis-timeline ol { display: grid; gap: 6px; margin: 0; padding: 0; list-style: none; }
-.analysis-timeline li { display: grid; grid-template-columns: 54px 1fr; gap: 7px; color: var(--muted); font-size: 8px; line-height: 1.4; }
-.analysis-timeline time { color: var(--accent-soft); font: 7px ui-monospace, SFMono-Regular, Menlo, monospace; }
+.analysis-timeline li { display: grid; grid-template-columns: 112px 1fr; gap: 7px; color: var(--muted); font-size: 8px; line-height: 1.4; }
+.analysis-timeline time { color: var(--accent-soft); font: 7px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace; }
+.analysis-timeline li div > span, .analysis-timeline li code { display: block; }
+.analysis-timeline li code { margin-top: 2px; color: var(--muted-dark); font: 7px ui-monospace, SFMono-Regular, Menlo, monospace; overflow-wrap: anywhere; }
.analysis-timeline p { margin: 0; color: var(--muted); font-size: 8px; }
.analysis-execution > footer { padding: 0 10px 10px; color: var(--muted-dark); font-size: 7px; line-height: 1.45; }
.detail-actions { display: grid; gap: 7px; padding: 4px 20px 16px; }
diff --git a/docs/USAGE_GUIDE.md b/docs/USAGE_GUIDE.md
index 6fee17c..c2c51a9 100644
--- a/docs/USAGE_GUIDE.md
+++ b/docs/USAGE_GUIDE.md
@@ -59,6 +59,8 @@ Import 1–24 images as one root project. For each image, record source/attribut
Codex receives one strict analysis Job at a time. It separates observable facts from creative inference, records uncertainty and risk, covers the five layers and thirteen protocol roles, then synthesizes common visual DNA, conflicts, complements and recommended directions for the whole set. Interrupted Jobs resume; invalid JSON is rejected; repeating a recorded operation does not duplicate project content.
+The project detail's **CODEX execution record** distinguishes waiting, actively executing, retryable failure and completion instead of calling an unclaimed Job active. Expand a Job to inspect the exact instruction, reference IDs, identity boundary, strict JSON Schema and SHA-256, every validation/write attempt, observable facts, creative inferences, risks, uncertainties, all thirteen APSAL elements and the complete JSON value stored by the Engine. The timeline shows every retained event rather than only the latest entries. Private model chain-of-thought is neither recorded nor displayed.
+
After analysis, **Build from analysis** creates the theme, positive and negative Prompts, QA contract and shareable Skill. A scene, camera, light, styling, series or complete nine-shot expansion always creates a child project. The child records source assets, change intent and parent snapshot digest; the parent stays byte-for-byte unchanged.
The library can search, tag, favorite and archive projects and show lineage. These are projection features: `.apsal/project.json`, analysis, themes, runs and share records inside each project remain authoritative. `~/.apsal/library/` can be rebuilt.
diff --git a/docs/USAGE_GUIDE.zh-CN.md b/docs/USAGE_GUIDE.zh-CN.md
index a6d79d5..98fc919 100644
--- a/docs/USAGE_GUIDE.zh-CN.md
+++ b/docs/USAGE_GUIDE.zh-CN.md
@@ -78,6 +78,8 @@ codex plugin add apsal-studio@apsal-open
Codex 每次领取一个严格 Schema 的分析任务,将可观测事实与创作推断分开,记录不确定项和风险,覆盖五层十三元素,再综合整组图片的共同视觉 DNA、冲突、互补和推荐方向。任务可以中断恢复;不合格 JSON 会被拒绝;重复写回不会重复创建项目内容。
+项目详情中的“CODEX 执行记录”会区分“等待 Codex 领取”“Codex 执行中”“分析失败,可重试”和“分析已完成”,不会再把已建立但无人领取的任务写成正在分析。展开每个任务可以查看 Codex 实际收到的指令、参考图 ID、身份权限、严格 JSON Schema 及其 SHA-256、每次校验与回写、可观测事实、创作推断、风险、不确定项、五层十三要素和 Engine 原样保存的完整 JSON。时间线显示全部事件,不只显示最近几条。模型内部隐藏思维链不记录也不展示。
+
分析完成后,“从分析构建设计”会生成主题、正负提示词、QA 与可分享 Skill。选择同系列延伸、场景、镜头、灯光、造型或完整九图方向时,系统先建立子项目,记录来源图片、变化目标和父快照摘要;父项目保持不变。
项目库可以搜索、加标签、收藏、归档并查看谱系,但这些只是投影功能。每个项目中的 `.apsal/project.json`、分析、主题、运行和分享记录才是真源;`~/.apsal/library/` 可以随时重建。
diff --git a/docs/releases/0.16.0.md b/docs/releases/0.16.0.md
index c214f9c..bf95cf9 100644
--- a/docs/releases/0.16.0.md
+++ b/docs/releases/0.16.0.md
@@ -23,6 +23,11 @@ GitHub distribution, not a different project schema.
schema. The Engine never calls a vision or image provider directly.
3. `design.build_from_analysis` compiles the complete five-layer,
thirteen-element theme, Prompts, negative constraints, QA, and Skill.
+ Studio's execution console preserves the exact structured task contract,
+ strict Schema digest, validation attempts, observable facts, creative
+ inference, risks, uncertainties, all thirteen element decisions, complete
+ stored JSON, and the full retained event timeline. Unclaimed work is labeled
+ as waiting; private model chain-of-thought is not stored.
4. Every creative expansion forks a child project with its parent snapshot
digest and source assets. The parent revision and bytes stay unchanged.
5. Successful generated outputs enter the content-addressed library; failed
diff --git a/plugins/apsal-studio/scripts/apsal_creative.py b/plugins/apsal-studio/scripts/apsal_creative.py
index b90f65e..9f62612 100644
--- a/plugins/apsal-studio/scripts/apsal_creative.py
+++ b/plugins/apsal-studio/scripts/apsal_creative.py
@@ -293,6 +293,7 @@ def _append_analysis_activity(
*,
job: dict[str, Any] | None = None,
error: str | None = None,
+ details: dict[str, Any] | None = None,
) -> None:
event: dict[str, Any] = {
"event_id": f"EVENT-{uuid.uuid4().hex[:12].upper()}",
@@ -303,6 +304,8 @@ def _append_analysis_activity(
event.update({"job_id": job["job_id"], "kind": job["kind"]})
if error:
event["error"] = error
+ if details:
+ event["details"] = json.loads(json.dumps(details))
activity = analysis.setdefault("activity", [])
activity.append(event)
analysis["activity"] = activity[-200:]
@@ -343,8 +346,13 @@ def start_analysis(project_root: Path) -> dict[str, Any]:
"created_at": engine._utc_now(),
"updated_at": engine._utc_now(),
}
- _append_analysis_activity(value, "analysis_started")
+ _append_analysis_activity(value, "analysis_started", details={
+ "reference_count": len(references),
+ "job_count": len(jobs),
+ "analysis_schema_version": ANALYSIS_SCHEMA_VERSION,
+ })
_write_analysis(root, value)
+ reconcile_library(root)
return value
@@ -395,6 +403,64 @@ def _analysis_reference_map(project_root: Path) -> dict[str, dict[str, Any]]:
return {item["reference_id"]: item for item in load_project_references(project_root).get("references", [])}
+def _analysis_task_contract(
+ job: dict[str, Any],
+ references: dict[str, dict[str, Any]],
+ image_jobs: list[dict[str, Any]],
+) -> dict[str, Any]:
+ if job["kind"] == "image":
+ identity_allowed = all(references[item].get("identity_lock_allowed") is True for item in job["reference_ids"])
+ instruction = (
+ "Analyze only observable photographic evidence and separate observations from inference. "
+ "Do not identify a person or infer sensitive traits. "
+ + ("Identity continuity may be described only as authorized visual attributes. " if identity_allowed else "Do not describe or preserve identity-specific facial traits. ")
+ + "Return every APSAL five-layer/thirteen-element role in the supplied JSON schema."
+ )
+ schema = _image_analysis_schema()
+ context_job_ids: list[str] = []
+ codex_tool = "multimodal_analysis"
+ input_mode = "private_reference_media"
+ else:
+ identity_allowed = False
+ instruction = "Synthesize the completed per-image analyses into one APSAL visual DNA without reading new media. Return the supplied JSON schema."
+ schema = _synthesis_schema()
+ context_job_ids = [item["job_id"] for item in image_jobs]
+ codex_tool = "structured_synthesis"
+ input_mode = "validated_analysis_results"
+ return {
+ "instruction": instruction,
+ "codex_tool": codex_tool,
+ "direct_api_calls": False,
+ "input_mode": input_mode,
+ "reference_ids": list(job["reference_ids"]),
+ "context_job_ids": context_job_ids,
+ "identity_continuity_allowed": identity_allowed,
+ "result_schema": schema,
+ "result_schema_digest": engine.digest(schema),
+ }
+
+
+def _analysis_result_summary(kind: str, result: dict[str, Any]) -> dict[str, Any]:
+ if kind == "image":
+ return {
+ "observed_count": len(result["observed"]),
+ "inferred_count": len(result["inferred"]),
+ "reference_role_count": len(result["reference_roles"]),
+ "lock_count": len(result["locks"]),
+ "variable_count": len(result["variables"]),
+ "risk_count": len(result["risks"]),
+ "uncertainty_count": len(result["uncertainties"]),
+ "apsal_element_count": len(result["elements"]),
+ }
+ return {
+ "visual_dna_count": len(result["common_visual_dna"]),
+ "conflict_count": len(result["conflicts"]),
+ "complement_count": len(result["complements"]),
+ "recommended_direction_count": len(result["recommended_directions"]),
+ "apsal_element_count": len(result["element_decisions"]),
+ }
+
+
def next_analysis_job(project_root: Path, analysis_id: str) -> dict[str, Any]:
root = project_root.expanduser().resolve()
analysis = load_analysis(root, analysis_id)
@@ -416,23 +482,19 @@ def next_analysis_job(project_root: Path, analysis_id: str) -> dict[str, Any]:
job["status"] = "in_progress"
job["claimed_at"] = engine._utc_now()
job["claim_count"] = int(job.get("claim_count", 0)) + 1
- _append_analysis_activity(analysis, event_type, job=job)
- _write_analysis(root, analysis)
+ _append_analysis_activity(analysis, event_type, job=job, details={
+ "claim_count": job["claim_count"],
+ "reference_ids": job["reference_ids"],
+ })
references = _analysis_reference_map(root)
reference_paths = [str(engine._vault_reference_path(references[item]["vault_uri"])) for item in job["reference_ids"]]
+ task = _analysis_task_contract(job, references, image_jobs)
+ job["task"] = task
+ _write_analysis(root, analysis)
+ reconcile_library(root)
if job["kind"] == "image":
- identity_allowed = all(references[item].get("identity_lock_allowed") is True for item in job["reference_ids"])
- instruction = (
- "Analyze only observable photographic evidence and separate observations from inference. "
- "Do not identify a person or infer sensitive traits. "
- + ("Identity continuity may be described only as authorized visual attributes. " if identity_allowed else "Do not describe or preserve identity-specific facial traits. ")
- + "Return every APSAL five-layer/thirteen-element role in the supplied JSON schema."
- )
- schema = _image_analysis_schema()
context = None
else:
- instruction = "Synthesize the completed per-image analyses into one APSAL visual DNA without reading new media. Return the supplied JSON schema."
- schema = _synthesis_schema()
context = [{"job_id": item["job_id"], "result": item["result"]} for item in image_jobs]
return {
"analysis_id": analysis_id,
@@ -440,11 +502,11 @@ def next_analysis_job(project_root: Path, analysis_id: str) -> dict[str, Any]:
"kind": job["kind"],
"reference_ids": job["reference_ids"],
"referenced_image_paths": reference_paths if job["kind"] == "image" else [],
- "instruction": instruction,
- "result_schema": schema,
+ "instruction": task["instruction"],
+ "result_schema": task["result_schema"],
"context": context,
- "codex_tool": "multimodal_analysis" if job["kind"] == "image" else "structured_synthesis",
- "direct_api_calls": False,
+ "codex_tool": task["codex_tool"],
+ "direct_api_calls": task["direct_api_calls"],
"attempt_count": len(job.get("attempts", [])),
"last_error": job.get("error"),
"claim_status": "resumed" if resumed else "claimed",
@@ -505,6 +567,8 @@ def record_analysis(
validated = _validate_analysis_result(job["kind"], result)
job["result"] = validated
attempt["result_digest"] = engine.digest(validated)
+ attempt["schema_validated"] = True
+ attempt["result_summary"] = _analysis_result_summary(job["kind"], validated)
job["error"] = None
else:
message = str(error or "analysis_error_not_reported").strip()
@@ -518,6 +582,7 @@ def record_analysis(
"job_succeeded" if status == "succeeded" else "job_failed",
job=job,
error=attempt.get("error"),
+ details={key: attempt[key] for key in ("result_digest", "schema_validated", "result_summary") if key in attempt},
)
image_jobs = [item for item in analysis["jobs"] if item["kind"] == "image"]
synthesis = next(item for item in analysis["jobs"] if item["kind"] == "synthesis")
@@ -656,7 +721,13 @@ def _project_stage(project_root: Path, analyses: list[dict[str, Any]]) -> str:
if any(item.get("status") == "completed" for item in analyses):
return "design_ready"
if analyses:
- return "analyzing"
+ latest = analyses[-1]
+ jobs = latest.get("jobs", [])
+ if any(job.get("status") == "in_progress" for job in jobs):
+ return "analyzing"
+ if any(job.get("status") == "failed" for job in jobs):
+ return "analysis_partial"
+ return "analysis_waiting"
return "references_ready"
@@ -856,6 +927,33 @@ def library_list(
connection.close()
+def _library_analysis_job(
+ analysis: dict[str, Any],
+ job: dict[str, Any],
+ references: dict[str, dict[str, Any]],
+) -> dict[str, Any]:
+ task = job.get("task")
+ if not task:
+ try:
+ image_jobs = [item for item in analysis.get("jobs", []) if item.get("kind") == "image"]
+ task = _analysis_task_contract(job, references, image_jobs)
+ except (KeyError, engine.ValidationError):
+ task = None
+ return {
+ "job_id": job.get("job_id"),
+ "kind": job.get("kind"),
+ "status": job.get("status"),
+ "reference_ids": job.get("reference_ids", []),
+ "attempt_count": len(job.get("attempts", [])),
+ "attempts": job.get("attempts", []),
+ "last_error": job.get("error"),
+ "claimed_at": job.get("claimed_at"),
+ "claim_count": int(job.get("claim_count", 0)),
+ "task": task,
+ "result": job.get("result"),
+ }
+
+
def library_get(project_id: str, *, home: Path | None = None) -> dict[str, Any]:
connection = _library_connection(home)
try:
@@ -871,20 +969,13 @@ def library_get(project_id: str, *, home: Path | None = None) -> dict[str, Any]:
item["rights"] = json.loads(item.pop("rights_json") or "{}")
project = _project_row(row)
root = Path(project["project_root"])
+ references = _analysis_reference_map(root)
analyses = [{
"analysis_id": item.get("analysis_id"),
"status": item.get("status"),
"job_count": len(item.get("jobs", [])),
"completed_job_count": sum(job.get("status") == "succeeded" for job in item.get("jobs", [])),
- "jobs": [{
- "job_id": job.get("job_id"),
- "kind": job.get("kind"),
- "status": job.get("status"),
- "reference_ids": job.get("reference_ids", []),
- "attempt_count": len(job.get("attempts", [])),
- "last_error": job.get("error"),
- "claimed_at": job.get("claimed_at"),
- } for job in item.get("jobs", [])],
+ "jobs": [_library_analysis_job(item, job, references) for job in item.get("jobs", [])],
"activity": item.get("activity", []),
"design_session_id": item.get("design_session_id"),
"updated_at": item.get("updated_at"),
diff --git a/tests/test_creative_library.py b/tests/test_creative_library.py
index 7fa05e0..be55140 100644
--- a/tests/test_creative_library.py
+++ b/tests/test_creative_library.py
@@ -161,6 +161,21 @@ def test_multi_reference_analysis_is_resumable_schema_checked_and_builds_skill(s
"analysis.status", {"project_root": str(project), "analysis_id": analysis_id}
)
self.assertEqual(status["status"], "completed")
+ historical = creative.load_analysis(project, analysis_id)
+ historical["jobs"][0].pop("task")
+ creative._write_analysis(project, historical)
+ library = protocol.handle_domain_method("library.get", {"project_root": str(project)})
+ analysis_view = next(item for item in library["analyses"] if item["analysis_id"] == analysis_id)
+ first_view = analysis_view["jobs"][0]
+ self.assertEqual(first_view["result"]["observed"], image_analysis_result()["observed"])
+ self.assertEqual(first_view["task"]["codex_tool"], "multimodal_analysis")
+ self.assertFalse(first_view["task"]["direct_api_calls"])
+ self.assertFalse(first_view["task"]["identity_continuity_allowed"])
+ self.assertEqual(len(first_view["task"]["result_schema"]["required"]), 8)
+ self.assertTrue(first_view["attempts"][0]["schema_validated"])
+ self.assertEqual(first_view["attempts"][0]["result_summary"]["apsal_element_count"], 13)
+ self.assertNotIn(str(project), json.dumps(first_view["task"]))
+ self.assertEqual(analysis_view["activity"][0]["details"]["reference_count"], 2)
first_job = status["jobs"][0]
replay = protocol.handle_domain_method(
@@ -209,9 +224,13 @@ def test_failed_analysis_job_can_be_retried_and_identity_requires_portrait_autho
"operation_id": "RETRY-START",
},
)
+ waiting_library = protocol.handle_domain_method("library.get", {"project_root": str(project)})
+ self.assertEqual(waiting_library["project"]["stage"], "analysis_waiting")
first = protocol.handle_domain_method(
"analysis.next", {"project_root": str(project), "analysis_id": started["analysis_id"]}
)
+ active_library = protocol.handle_domain_method("library.get", {"project_root": str(project)})
+ self.assertEqual(active_library["project"]["stage"], "analyzing")
claimed = protocol.handle_domain_method(
"analysis.status", {"project_root": str(project), "analysis_id": started["analysis_id"]}
)
diff --git a/tests/test_studio_frontend.py b/tests/test_studio_frontend.py
index bc090c1..72ac0bd 100644
--- a/tests/test_studio_frontend.py
+++ b/tests/test_studio_frontend.py
@@ -96,7 +96,11 @@ def test_private_reference_media_is_visible_only_through_the_safe_protocol(self)
self.assertIn("参考图已安全入库并显示在项目详情中", library)
self.assertIn("CODEX 执行记录", library)
self.assertIn("当前没有 Codex 领取任务", library)
- self.assertIn("不展示模型内部隐藏推理", library)
+ self.assertIn("Engine 原样保存的完整 JSON 回写", library)
+ self.assertIn("可观测事实", library)
+ self.assertIn("完整执行时间线", library)
+ self.assertNotIn("activity.slice(-12)", library)
+ self.assertIn("模型内部隐藏思维链不记录也不展示", library)
def test_codex_bridge_keeps_full_domain_route_without_arbitrary_proxy(self) -> None:
bridge = (STUDIO / "electron" / "apsal-link.mjs").read_text()