Make Gitea publishing claimant controlled

This commit is contained in:
2026-08-27 16:13:29 -07:00
parent 41e08611c9
commit a76ad5258b
10 changed files with 797 additions and 215 deletions
+3 -3
View File
@@ -25,7 +25,8 @@ ThinkStorm captures unformed ideas with zero friction, preserves original submis
- **Deep Technical Feasibility & Risk Critique**: Generates feasibility scores (010), technical constraints, implementation roadmaps, and risk matrices.
### 3. Canonical Gitea Project Repositories
- Every incubated idea is automatically provisioned as a full Git repository under the **`thinkstorm`** organization on Gitea (e.g. `https://git.labyricorn.com/thinkstorm/ts-0032`).
- Ideas and generated Work Track artifacts remain local until a Gitea-linked user claims the idea and deliberately publishes it.
- Claimants can publish or re-sync an idempotent repository under the **`thinkstorm`** organization (for example, `https://git.labyricorn.com/thinkstorm/ts-0032`) and receive write access for normal Git pushes.
- Repositories include:
- `README.md`: Executive summary, prompt quotation, reference image metadata, lifecycle badge, and project structure guide.
- `metadata.json`: Machine-readable metadata schema.
@@ -76,7 +77,7 @@ graph TD
P4 -->|Web Search| SearXNG[SearXNG & Perplexica]
P2 & P3 & P4 & P5 -->|LLM Synthesis| Omni
Queue -->|Push Dossier Repo| Gitea[Gitea Project Host]
Web -->|Claimant-triggered publish| Gitea[Gitea Project Host]
Queue -->|Push Gist Snippets| OpenGist[OpenGist Service]
```
@@ -220,4 +221,3 @@ python3 -m pytest tests/ -v
## 📄 License & Attribution
Developed by **Labyricorn**. Licensed under the [MIT License](LICENSE).
+191
View File
@@ -0,0 +1,191 @@
import asyncio
import pytest
from fastapi import HTTPException
from thinkstorm import database
from thinkstorm import auth
from thinkstorm.api import ideas as ideas_api
from thinkstorm.models import User, UserRole
from thinkstorm.services import gitea as gitea_module
@pytest.fixture
def isolated_db(tmp_path, monkeypatch):
monkeypatch.setattr(database, "DB_PATH", str(tmp_path / "claimant-gitea.db"))
database.init_db()
return tmp_path
def _insert_idea(idea_id, claimed_by=None):
now = database.get_utc_now()
with database.get_db() as conn:
conn.execute(
"""
INSERT INTO ideas
(id, original_text, submitted_at, title, summary, lifecycle_state,
processing_state, claimed_by, claimed_at, created_at, updated_at)
VALUES (?, 'Original idea', ?, 'Claimant Publishing', 'Summary', ?,
'IDLE', ?, ?, ?, ?)
""",
(
idea_id,
now,
"CLAIMED" if claimed_by else "AVAILABLE",
claimed_by,
now if claimed_by else None,
now,
now,
),
)
def _user(username, role=UserRole.USER):
return User(id=10, username=username, password_hash="", role=role)
def test_unclaimed_idea_cannot_be_published(isolated_db):
_insert_idea("TS-PUB1")
with pytest.raises(HTTPException) as exc:
asyncio.run(
ideas_api.sync_idea_to_gitea(
"TS-PUB1", current_user=_user("researcher")
)
)
assert exc.value.status_code == 409
def test_claimant_must_have_verified_gitea_identity(isolated_db):
_insert_idea("TS-PUB2", claimed_by="researcher")
with pytest.raises(HTTPException) as exc:
asyncio.run(
ideas_api.sync_idea_to_gitea(
"TS-PUB2", current_user=_user("researcher")
)
)
assert exc.value.status_code == 403
assert "sign in with Gitea" in exc.value.detail
def test_only_claimant_can_publish(isolated_db):
_insert_idea("TS-PUB3", claimed_by="researcher")
with database.get_db() as conn:
conn.execute(
"UPDATE users SET gitea_id = 1234 WHERE username = 'researcher'"
)
with pytest.raises(HTTPException) as exc:
asyncio.run(
ideas_api.sync_idea_to_gitea(
"TS-PUB3", current_user=_user("someone-else")
)
)
assert exc.value.status_code == 403
def test_claimant_publish_is_idempotent_and_grants_write_access(
isolated_db, monkeypatch
):
_insert_idea("TS-PUB4", claimed_by="researcher")
with database.get_db() as conn:
conn.execute(
"UPDATE users SET gitea_id = 1234 WHERE username = 'researcher'"
)
calls = []
async def fake_publish(**kwargs):
calls.append(kwargs)
return {
"repo_name": "thinkstorm/ts-pub4",
"repo_url": "https://git.example/thinkstorm/ts-pub4",
"clone_url": "https://git.example/thinkstorm/ts-pub4.git",
"ssh_url": "ssh://[email protected]/thinkstorm/ts-pub4.git",
"local_path": str(isolated_db / "TS-PUB4"),
"published": True,
}
monkeypatch.setattr(
ideas_api.gitea_svc, "persist_idea_dossier_repo", fake_publish
)
first = asyncio.run(
ideas_api.sync_idea_to_gitea(
"TS-PUB4", current_user=_user("researcher")
)
)
second = asyncio.run(
ideas_api.sync_idea_to_gitea(
"TS-PUB4", current_user=_user("researcher")
)
)
assert first["gitea_repo_name"] == "thinkstorm/ts-pub4"
assert second["gitea_repo_url"].endswith("/ts-pub4")
assert all(call["publish_remote"] is True for call in calls)
assert all(call["collaborator_username"] == "researcher" for call in calls)
with database.get_db() as conn:
idea = conn.execute(
"SELECT gitea_repo_name, gitea_repo_url FROM ideas WHERE id = 'TS-PUB4'"
).fetchone()
resources = conn.execute(
"""
SELECT COUNT(*) AS count FROM external_resources
WHERE idea_id = 'TS-PUB4' AND resource_type = 'GITEA_DOSSIER'
"""
).fetchone()["count"]
assert idea["gitea_repo_name"] == "thinkstorm/ts-pub4"
assert resources == 1
def test_oauth_links_existing_local_account_to_gitea(isolated_db):
linked_user = auth.get_or_create_gitea_user(
{"login": "researcher", "id": 9876, "is_admin": False}
)
with database.get_db() as conn:
row = conn.execute(
"SELECT gitea_id FROM users WHERE username = 'researcher'"
).fetchone()
assert linked_user.username == "researcher"
assert row["gitea_id"] == 9876
def test_local_only_dossier_persistence_never_contacts_gitea(
isolated_db, monkeypatch
):
artifact_root = isolated_db / "artifacts"
monkeypatch.setattr(gitea_module, "ARTIFACTS_DIR", artifact_root)
adapter = gitea_module.GiteaAdapter(api_token="configured-token")
def unexpected_remote_call(*_args, **_kwargs):
raise AssertionError("local persistence attempted a remote Gitea call")
monkeypatch.setattr(adapter, "_ensure_repo", unexpected_remote_call)
result = asyncio.run(
adapter.persist_idea_dossier_repo(
idea_id="TS-LOCAL",
title="Local only",
summary="Summary",
original_text="Original",
categories=[],
tags=[],
lifecycle_state="AVAILABLE",
research_docs={"analysis.md": "Research"},
outputs={},
provenance_runs=[],
publish_remote=False,
)
)
assert result["published"] is False
assert (artifact_root / "TS-LOCAL" / "README.md").exists()
+10 -3
View File
@@ -79,12 +79,18 @@ def test_youtube_work_track_generates_three_versioned_artifacts(tmp_path, monkey
"duration_ms": 1,
}
async def fake_persist(*_args, **_kwargs):
gitea_persist_options = {}
async def fake_gitea_persist(*_args, **kwargs):
gitea_persist_options.update(kwargs)
return None
async def fake_opengist_persist(*_args, **_kwargs):
return None
monkeypatch.setattr(pipeline.omniroute_svc, "chat_completion", fake_completion)
monkeypatch.setattr(pipeline.gitea_svc, "persist_work_track_outputs", fake_persist)
monkeypatch.setattr(pipeline.opengist_svc, "persist_work_track_outputs", fake_persist)
monkeypatch.setattr(pipeline.gitea_svc, "persist_work_track_outputs", fake_gitea_persist)
monkeypatch.setattr(pipeline.opengist_svc, "persist_work_track_outputs", fake_opengist_persist)
asyncio.run(pipeline.execute_work_track_workflow("WT-YT01"))
@@ -111,3 +117,4 @@ def test_youtube_work_track_generates_three_versioned_artifacts(tmp_path, monkey
assert outputs[1]["content"].startswith("# Outline")
assert outputs[2]["content"].startswith("# Script")
assert state == "COMPLETED"
assert gitea_persist_options["publish_remote"] is False
+190 -32
View File
@@ -35,6 +35,32 @@ opengist_svc = OpenGistAdapter()
# Rate limiting sliding window in-memory cache: ip -> list of timestamps
SUBMISSION_IP_LOG: Dict[str, List[float]] = {}
def _resolve_gitea_claimant(conn, idea, current_user: User) -> str:
"""Authorizes claimant-controlled publication and returns the Gitea username."""
claimed_by = idea["claimed_by"]
if not claimed_by:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="The idea must be claimed before it can be published to Gitea."
)
if claimed_by != current_user.username and current_user.role != UserRole.ADMIN:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only the current claimant can publish this idea to Gitea."
)
claimant = conn.execute(
"SELECT username, gitea_id FROM users WHERE username = ?",
(claimed_by,)
).fetchone()
if not claimant or claimant["gitea_id"] is None:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="The claimant must sign in with Gitea before publishing this idea."
)
return claimant["username"]
class IdeaSubmissionRequest(BaseModel):
text: str
@@ -378,6 +404,86 @@ async def get_idea_detail(idea_id: str, current_user: User = Depends(require_aut
except Exception:
sub_img = None
# Build multi-version research stages
stage_definitions = {
"IMAGE_CONTEXT": {"name": "Visual & Image Context", "doc_name": "image-context.md", "icon": "🖼️"},
"PRIOR_ART": {"name": "Prior Art & Alternatives", "doc_name": "prior-art.md", "icon": "🔍"},
"RESEARCH": {"name": "Deep Research Synthesis", "doc_name": "analysis.md", "icon": "📚"},
"FEASIBILITY": {"name": "Feasibility & Critique", "doc_name": "feasibility.md", "icon": "⚙️"}
}
research_stages = {}
for stage_key, defn in stage_definitions.items():
stage_runs = [
rn for rn in provenance
if rn["stage"] == stage_key and rn["output_data"] and rn["output_data"].get("content")
]
stage_runs_sorted = sorted(stage_runs, key=lambda x: x["started_at"] or "")
v_list = []
for idx, r_item in enumerate(stage_runs_sorted, start=1):
v_obj = {
"version": idx,
"is_latest": False,
"run_id": r_item["id"],
"processor_name": r_item["processor_name"],
"model": r_item["resolved_model"] or "Default Policy",
"provider": r_item["resolved_provider"] or "",
"tokens": {
"input": r_item["input_tokens"],
"output": r_item["output_tokens"],
"total": r_item["total_tokens"]
},
"started_at": r_item["started_at"],
"completed_at": r_item["completed_at"],
"duration_ms": r_item["duration_ms"],
"prompt_id": r_item["prompt_id"],
"prompt_version": r_item["prompt_version"],
"artifact_path": r_item["output_artifact"] or f"research/{defn['doc_name']}",
"content": r_item["output_data"].get("content", "")
}
v_list.append(v_obj)
if v_list:
v_list[-1]["is_latest"] = True
latest_version = v_list[-1]
else:
latest_version = None
research_stages[stage_key] = {
"stage": stage_key,
"name": defn["name"],
"doc_name": defn["doc_name"],
"icon": defn["icon"],
"versions": v_list,
"latest": latest_version,
"total_versions": len(v_list)
}
claimant_gitea_linked = False
if idea_row["claimed_by"]:
claimant_row = conn.execute(
"SELECT gitea_id FROM users WHERE username = ?",
(idea_row["claimed_by"],)
).fetchone()
claimant_gitea_linked = bool(
claimant_row and claimant_row["gitea_id"] is not None
)
actor_is_claimant = idea_row["claimed_by"] == current_user.username
gitea_publish_allowed = bool(
idea_row["claimed_by"]
and claimant_gitea_linked
and (actor_is_claimant or is_admin)
)
if not idea_row["claimed_by"]:
gitea_publish_reason = "Claim this idea before publishing it to Gitea."
elif not actor_is_claimant and not is_admin:
gitea_publish_reason = "Only the current claimant can publish this idea."
elif not claimant_gitea_linked:
gitea_publish_reason = "The claimant must sign in with Gitea before publishing."
else:
gitea_publish_reason = ""
return {
"id": idea_row["id"],
"title": idea_row["title"],
@@ -395,12 +501,17 @@ async def get_idea_detail(idea_id: str, current_user: User = Depends(require_aut
"profile_id": idea_row["profile_id"],
"opengist_id": idea_row["opengist_id"],
"opengist_url": idea_row["opengist_url"],
"gitea_repo_name": idea_row["gitea_repo_name"] if "gitea_repo_name" in idea_row.keys() else "",
"gitea_repo_url": idea_row["gitea_repo_url"] if "gitea_repo_url" in idea_row.keys() else "",
"gitea_publish_allowed": gitea_publish_allowed,
"gitea_publish_reason": gitea_publish_reason,
"submission_image": sub_img,
"categories": cats,
"tags": tags,
"urls": urls,
"work_tracks": work_tracks,
"provenance": provenance,
"research_stages": research_stages,
"relationships": rels,
"usage_summary": {
"total_tokens": total_tokens_sum,
@@ -535,10 +646,20 @@ async def graduate_work_track_to_gitea(track_id: str, current_user: User = Depen
if track["work_type_id"] != "CODING_PROJECT":
raise HTTPException(status_code=400, detail="Only Coding Project work tracks can graduate to Gitea.")
idea = conn.execute("SELECT * FROM ideas WHERE id = ?", (track["idea_id"],)).fetchone()
if idea["claimed_by"] != current_user.username and current_user.role != UserRole.ADMIN:
raise HTTPException(status_code=403, detail="Only the claimant can graduate this project.")
claimant_username = _resolve_gitea_claimant(conn, idea, current_user)
res = await gitea_svc.graduate_project(idea["id"], idea["title"], idea["summary"])
try:
res = await gitea_svc.graduate_project(
idea["id"],
idea["title"],
idea["summary"],
collaborator_username=claimant_username
)
except RuntimeError as exc:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=str(exc)
) from exc
now = get_utc_now()
with get_db() as conn:
conn.execute(
@@ -721,9 +842,9 @@ async def sync_idea_to_opengist(idea_id: str, current_user: User = Depends(get_c
cats = [r["name"] for r in conn.execute("SELECT c.name FROM categories c JOIN idea_categories ic ON c.id = ic.category_id WHERE ic.idea_id = ?", (idea_id,)).fetchall()]
tags = [r["name"] for r in conn.execute("SELECT t.name FROM tags t JOIN idea_tags it ON t.id = it.tag_id WHERE it.idea_id = ?", (idea_id,)).fetchall()]
runs = [dict(r) for r in conn.execute("SELECT * FROM processor_runs WHERE idea_id = ?", (idea_id,)).fetchall()]
runs = [dict(r) for r in conn.execute("SELECT * FROM processor_runs WHERE idea_id = ? ORDER BY started_at ASC", (idea_id,)).fetchall()]
# Build research docs
# Build research docs (chronologically latest run content takes precedence)
research_docs = {}
for r in runs:
stage = r.get("stage")
@@ -734,7 +855,9 @@ async def sync_idea_to_opengist(idea_id: str, current_user: User = Depends(get_c
out_data = {}
content = out_data.get("content")
if content:
if stage == "PRIOR_ART":
if stage == "IMAGE_CONTEXT":
research_docs["image-context.md"] = content
elif stage == "PRIOR_ART":
research_docs["prior-art.md"] = content
elif stage == "RESEARCH":
research_docs["analysis.md"] = content
@@ -772,18 +895,19 @@ async def sync_idea_to_opengist(idea_id: str, current_user: User = Depends(get_c
}
@router.post("/{idea_id}/sync-gitea")
async def sync_idea_to_gitea(idea_id: str, current_user: User = Depends(get_current_user)):
"""Manually triggers full Gitea Project Repository sync for an idea under 'thinkstorm' org."""
async def sync_idea_to_gitea(idea_id: str, current_user: User = Depends(require_authenticated)):
"""Publishes or re-syncs a claimed idea and grants its claimant write access."""
with get_db() as conn:
idea = conn.execute("SELECT * FROM ideas WHERE id = ?", (idea_id,)).fetchone()
if not idea:
raise HTTPException(status_code=404, detail="Idea not found.")
claimant_username = _resolve_gitea_claimant(conn, idea, current_user)
cats = [r["name"] for r in conn.execute("SELECT c.name FROM categories c JOIN idea_categories ic ON c.id = ic.category_id WHERE ic.idea_id = ?", (idea_id,)).fetchall()]
tags = [r["name"] for r in conn.execute("SELECT t.name FROM tags t JOIN idea_tags it ON t.id = it.tag_id WHERE it.idea_id = ?", (idea_id,)).fetchall()]
runs = [dict(r) for r in conn.execute("SELECT * FROM processor_runs WHERE idea_id = ?", (idea_id,)).fetchall()]
runs = [dict(r) for r in conn.execute("SELECT * FROM processor_runs WHERE idea_id = ? ORDER BY started_at ASC", (idea_id,)).fetchall()]
# Build research docs
# Build research docs (chronologically latest run content takes precedence)
research_docs = {}
for r in runs:
stage = r.get("stage")
@@ -794,7 +918,9 @@ async def sync_idea_to_gitea(idea_id: str, current_user: User = Depends(get_curr
out_data = {}
content = out_data.get("content")
if content:
if stage == "PRIOR_ART":
if stage == "IMAGE_CONTEXT":
research_docs["image-context.md"] = content
elif stage == "PRIOR_ART":
research_docs["prior-art.md"] = content
elif stage == "RESEARCH":
research_docs["analysis.md"] = content
@@ -813,19 +939,30 @@ async def sync_idea_to_gitea(idea_id: str, current_user: User = Depends(get_curr
track_slug = tr["name"].lower().replace(" ", "-")
outputs[f"{track_slug}/{out['name']}"] = out["content"]
res = await gitea_svc.persist_idea_dossier_repo(
idea_id=idea["id"],
title=idea["title"] or "Untitled Idea",
summary=idea["summary"] or "",
original_text=idea["original_text"] or "",
categories=cats,
tags=tags,
lifecycle_state=idea["lifecycle_state"],
research_docs=research_docs,
outputs=outputs,
provenance_runs=runs,
existing_repo_url=idea.get("gitea_repo_url") if isinstance(idea, dict) else (idea["gitea_repo_url"] if "gitea_repo_url" in idea.keys() else None)
)
try:
res = await gitea_svc.persist_idea_dossier_repo(
idea_id=idea["id"],
title=idea["title"] or "Untitled Idea",
summary=idea["summary"] or "",
original_text=idea["original_text"] or "",
categories=cats,
tags=tags,
lifecycle_state=idea["lifecycle_state"],
research_docs=research_docs,
outputs=outputs,
provenance_runs=runs,
existing_repo_url=(
idea["gitea_repo_url"]
if "gitea_repo_url" in idea.keys() else None
),
publish_remote=True,
collaborator_username=claimant_username
)
except RuntimeError as exc:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=str(exc)
) from exc
now = get_utc_now()
with get_db() as conn:
@@ -837,16 +974,38 @@ async def sync_idea_to_gitea(idea_id: str, current_user: User = Depends(get_curr
""",
(res["repo_name"], res["repo_url"], now, idea_id)
)
conn.execute(
existing_resource = conn.execute(
"""
INSERT INTO external_resources (idea_id, resource_type, url, metadata, created_at)
VALUES (?, 'GITEA_DOSSIER', ?, ?, ?)
SELECT id FROM external_resources
WHERE idea_id = ? AND resource_type = 'GITEA_DOSSIER'
ORDER BY id ASC LIMIT 1
""",
(idea_id, res["repo_url"], json.dumps(res), now)
)
(idea_id,)
).fetchone()
if existing_resource:
conn.execute(
"""
UPDATE external_resources
SET url = ?, metadata = ?
WHERE id = ?
""",
(res["repo_url"], json.dumps(res), existing_resource["id"])
)
else:
conn.execute(
"""
INSERT INTO external_resources
(idea_id, resource_type, url, metadata, created_at)
VALUES (?, 'GITEA_DOSSIER', ?, ?, ?)
""",
(idea_id, res["repo_url"], json.dumps(res), now)
)
return {
"message": f"Dossier successfully published to Gitea under '{res['repo_name']}'.",
"message": (
f"Dossier published to '{res['repo_name']}' and write access granted "
f"to '{claimant_username}'."
),
"gitea_repo_name": res["repo_name"],
"gitea_repo_url": res["repo_url"],
"clone_url": res["clone_url"],
@@ -859,7 +1018,7 @@ async def reprocess_idea(
bypass_duplicate_check: bool = True,
current_user: User = Depends(get_current_user)
):
"""Triggers end-to-end reprocessing of an idea (Prior Art, Deep Research, Feasibility, and Gitea Dossier)."""
"""Triggers research reprocessing without automatically publishing to Gitea."""
with get_db() as conn:
idea = conn.execute("SELECT * FROM ideas WHERE id = ?", (idea_id,)).fetchone()
if not idea:
@@ -903,4 +1062,3 @@ async def get_idea_image(idea_id: str):
media_type=mime,
filename=meta.get("original_filename", f"{idea_id}_reference_image")
)
+14 -4
View File
@@ -72,10 +72,20 @@ def get_or_create_gitea_user(gitea_user_data: Dict[str, Any]) -> User:
with get_db() as conn:
row = conn.execute("SELECT id, username, password_hash, role, created_at FROM users WHERE username = ? OR gitea_id = ?", (username, gitea_id)).fetchone()
if row:
# Update role if promoted
if is_gitea_admin and row["role"] != "ADMIN":
conn.execute("UPDATE users SET role = 'ADMIN' WHERE id = ?", (row["id"],))
return User(id=row["id"], username=row["username"], password_hash=row["password_hash"], role=role, created_at=row["created_at"])
# Record the verified Gitea identity when an existing local account links
# through OAuth, and promote the role when appropriate.
resolved_role = "ADMIN" if is_gitea_admin else row["role"]
conn.execute(
"UPDATE users SET gitea_id = ?, role = ? WHERE id = ?",
(gitea_id, resolved_role, row["id"])
)
return User(
id=row["id"],
username=row["username"],
password_hash=row["password_hash"],
role=UserRole(resolved_role),
created_at=row["created_at"]
)
# Insert new user
dummy_pass = hash_password(secrets.token_hex(16))
+22 -23
View File
@@ -631,8 +631,8 @@ async def execute_intake_pipeline(idea_id: str, bypass_duplicate_check: bool = F
with get_db() as conn:
runs = [dict(r) for r in conn.execute("SELECT * FROM processor_runs WHERE idea_id = ?", (idea_id,)).fetchall()]
# 1. Primary: Persist as a dedicated Gitea Project Repository under 'thinkstorm' org
gitea_res = await gitea_svc.persist_idea_dossier_repo(
# Persist the dossier locally. Remote Gitea publication is claimant-triggered.
await gitea_svc.persist_idea_dossier_repo(
idea_id=idea_id,
title=title,
summary=summary,
@@ -643,10 +643,11 @@ async def execute_intake_pipeline(idea_id: str, bypass_duplicate_check: bool = F
research_docs=research_docs,
outputs={},
provenance_runs=runs,
submission_image=submission_image
submission_image=submission_image,
publish_remote=False
)
# 2. Secondary: Persist local durable files & OpenGist
# Publish the research dossier to OpenGist as before.
gist_res = await opengist_svc.persist_idea_artifact(
idea_id=idea_id,
title=title,
@@ -661,7 +662,7 @@ async def execute_intake_pipeline(idea_id: str, bypass_duplicate_check: bool = F
submission_image=submission_image
)
# Finalize Idea to AVAILABLE and record repository links
# Finalize the idea without creating or predicting a Gitea repository.
with get_db() as conn:
conn.execute(
"""
@@ -669,22 +670,12 @@ async def execute_intake_pipeline(idea_id: str, bypass_duplicate_check: bool = F
SET lifecycle_state = 'AVAILABLE',
processing_state = 'IDLE',
enrichment_level = 5,
gitea_repo_name = ?,
gitea_repo_url = ?,
opengist_id = ?,
opengist_url = ?,
updated_at = ?
WHERE id = ?
""",
(gitea_res["repo_name"], gitea_res["repo_url"], gist_res["opengist_id"], gist_res["opengist_url"], get_utc_now(), idea_id)
)
# Store in external_resources
conn.execute(
"""
INSERT INTO external_resources (idea_id, resource_type, url, metadata, created_at)
VALUES (?, 'GITEA_DOSSIER', ?, ?, ?)
""",
(idea_id, gitea_res["repo_url"], json.dumps(gitea_res), get_utc_now())
(gist_res["opengist_id"], gist_res["opengist_url"], get_utc_now(), idea_id)
)
except Exception as e:
@@ -738,11 +729,14 @@ async def execute_work_track_workflow(work_track_id: str, model_override: Option
start_time = get_utc_now()
generated_outputs: Dict[str, str] = {}
idea_title = str(idea["title"] or idea["id"])
idea_summary = str(idea["summary"] or "")
if work_type == "ARTICLE" or work_type == "BLOG_ENTRY":
prompt_info = get_prompt_version("article-generator", is_admin=True)
user_prompt = (
prompt_info["user_prompt_template"]
.replace("{{title}}", idea["title"])
.replace("{{title}}", idea_title)
.replace("{{track_name}}", track_name)
.replace("{{research_context}}", combined_research)
)
@@ -761,8 +755,8 @@ async def execute_work_track_workflow(work_track_id: str, model_override: Option
prompt_info = get_prompt_version("coding-spec-generator", is_admin=True)
user_prompt = (
prompt_info["user_prompt_template"]
.replace("{{title}}", idea["title"])
.replace("{{summary}}", idea["summary"])
.replace("{{title}}", idea_title)
.replace("{{summary}}", idea_summary)
.replace("{{feasibility_context}}", combined_research)
)
llm_resp = await omniroute_svc.chat_completion(
@@ -781,9 +775,9 @@ async def execute_work_track_workflow(work_track_id: str, model_override: Option
prompt_info = get_prompt_version("youtube-video-generator", is_admin=True)
user_prompt = (
prompt_info["user_prompt_template"]
.replace("{{title}}", idea["title"])
.replace("{{title}}", idea_title)
.replace("{{track_name}}", track_name)
.replace("{{summary}}", idea["summary"])
.replace("{{summary}}", idea_summary)
.replace("{{research_context}}", combined_research)
)
llm_resp = await omniroute_svc.chat_completion(
@@ -821,9 +815,14 @@ async def execute_work_track_workflow(work_track_id: str, model_override: Option
)
conn.execute("UPDATE work_tracks SET state = 'COMPLETED', completed_at = ? WHERE id = ?", (end_time, work_track_id))
# Sync deliverables to Gitea Repository & OpenGist
# Keep Work Track deliverables local until the claimant publishes the dossier.
try:
await gitea_svc.persist_work_track_outputs(idea_id, track_name, generated_outputs)
await gitea_svc.persist_work_track_outputs(
idea_id,
track_name,
generated_outputs,
publish_remote=False
)
except Exception as e:
print(f"[Gitea Sync Notice] {e}")
+94 -59
View File
@@ -50,7 +50,7 @@ class GiteaAdapter(BaseServiceAdapter):
def _get_headers(self, token: Optional[str] = None) -> Dict[str, str]:
t = token or self.get_effective_token()
headers = {
"User-Agent": "ThinkStorm-Orchestrator/0.1",
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36",
"Content-Type": "application/json"
}
if t:
@@ -62,13 +62,14 @@ class GiteaAdapter(BaseServiceAdapter):
loop = asyncio.get_running_loop()
try:
url = f"{self.endpoint}/api/v1/version"
headers = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"}
def fetch():
return requests.get(url, headers={"User-Agent": "ThinkStorm-Orchestrator/0.1"}, timeout=6.0)
return requests.get(url, headers=headers, timeout=15.0)
resp = await loop.run_in_executor(None, fetch)
elapsed = int((time.time() - start) * 1000)
if resp.status_code == 200:
data = resp.json()
version = data.get("version", "unknown")
elapsed = int((time.time() - start) * 1000)
return ServiceHealth(
service_id=self.service_id,
healthy=True,
@@ -77,15 +78,13 @@ class GiteaAdapter(BaseServiceAdapter):
response_time_ms=elapsed,
extra={"version": version}
)
else:
elapsed = int((time.time() - start) * 1000)
return ServiceHealth(
service_id=self.service_id,
healthy=False,
endpoint=self.endpoint,
message=f"Gitea HTTP error: {resp.status_code}",
response_time_ms=elapsed
)
return ServiceHealth(
service_id=self.service_id,
healthy=False,
endpoint=self.endpoint,
message=f"Gitea returned HTTP {resp.status_code}",
response_time_ms=elapsed
)
except Exception as e:
elapsed = int((time.time() - start) * 1000)
return ServiceHealth(
@@ -100,7 +99,7 @@ class GiteaAdapter(BaseServiceAdapter):
"""Ensures the 'thinkstorm' organization exists."""
headers = self._get_headers(token)
try:
r = session.get(f"{self.endpoint}/api/v1/orgs/{self.org_name}", headers=headers, timeout=8)
r = session.get(f"{self.endpoint}/api/v1/orgs/{self.org_name}", headers=headers, timeout=60)
if r.status_code == 200:
return
if r.status_code == 404:
@@ -110,7 +109,7 @@ class GiteaAdapter(BaseServiceAdapter):
"description": "Canonical dossiers, research, and project incubations generated by ThinkStorm",
"visibility": "public"
}
session.post(f"{self.endpoint}/api/v1/orgs", json=payload, headers=headers, timeout=8)
session.post(f"{self.endpoint}/api/v1/orgs", json=payload, headers=headers, timeout=60)
except Exception as e:
print(f"[Gitea] Org ensure notice: {e}")
@@ -118,7 +117,7 @@ class GiteaAdapter(BaseServiceAdapter):
"""Ensures the repository exists under the organization."""
headers = self._get_headers(token)
try:
r = session.get(f"{self.endpoint}/api/v1/repos/{self.org_name}/{repo_name}", headers=headers, timeout=8)
r = session.get(f"{self.endpoint}/api/v1/repos/{self.org_name}/{repo_name}", headers=headers, timeout=60)
if r.status_code == 200:
return True
if r.status_code == 404:
@@ -129,13 +128,38 @@ class GiteaAdapter(BaseServiceAdapter):
"auto_init": True,
"default_branch": "main"
}
cr = session.post(f"{self.endpoint}/api/v1/orgs/{self.org_name}/repos", json=payload, headers=headers, timeout=10)
cr = session.post(f"{self.endpoint}/api/v1/orgs/{self.org_name}/repos", json=payload, headers=headers, timeout=60)
return cr.status_code in (200, 201)
except Exception as e:
print(f"[Gitea] Repo ensure error for {repo_name}: {e}")
return False
return False
def _grant_repo_write_access(
self,
session: requests.Session,
token: str,
repo_name: str,
username: str
) -> None:
"""Idempotently grants the verified claimant write access to a repository."""
safe_username = requests.utils.quote(username, safe="")
url = (
f"{self.endpoint}/api/v1/repos/{self.org_name}/{repo_name}"
f"/collaborators/{safe_username}"
)
response = session.put(
url,
json={"permission": "write"},
headers=self._get_headers(token),
timeout=60
)
if response.status_code not in (200, 201, 204):
raise RuntimeError(
f"Could not grant Gitea write access to '{username}' "
f"(HTTP {response.status_code})."
)
def _sync_files_via_api(self, token: str, repo_slug: str, files_dict: Dict[str, str]) -> None:
"""Commits or updates multiple files directly into the Gitea repository via Contents API."""
if not files_dict or not token:
@@ -147,7 +171,7 @@ class GiteaAdapter(BaseServiceAdapter):
try:
# 1. Check if file already exists in repo to get current SHA
get_url = f"{self.endpoint}/api/v1/repos/{self.org_name}/{repo_slug}/contents/{path}"
r = s.get(get_url, headers=headers, timeout=8)
r = s.get(get_url, headers=headers, timeout=60)
sha = None
if r.status_code == 200:
sha = r.json().get("sha")
@@ -161,11 +185,11 @@ class GiteaAdapter(BaseServiceAdapter):
if sha:
payload["sha"] = sha
put_res = s.put(get_url, json=payload, headers=headers, timeout=10)
put_res = s.put(get_url, json=payload, headers=headers, timeout=60)
if put_res.status_code not in (200, 201):
print(f"[Gitea] Update notice for {path}: {put_res.status_code} {put_res.text[:100]}")
else:
post_res = s.post(get_url, json=payload, headers=headers, timeout=10)
post_res = s.post(get_url, json=payload, headers=headers, timeout=60)
if post_res.status_code not in (200, 201):
print(f"[Gitea] Create notice for {path}: {post_res.status_code} {post_res.text[:100]}")
except Exception as e:
@@ -184,9 +208,11 @@ class GiteaAdapter(BaseServiceAdapter):
outputs: Dict[str, str],
provenance_runs: List[Dict[str, Any]],
existing_repo_url: Optional[str] = None,
submission_image: Optional[Dict[str, Any]] = None
submission_image: Optional[Dict[str, Any]] = None,
publish_remote: bool = True,
collaborator_username: Optional[str] = None
) -> Dict[str, Any]:
"""Creates a dedicated Gitea Project repository and synchronizes all files into it."""
"""Persists a dossier locally and optionally publishes it to Gitea."""
# 1. Local disk directory
idea_dir = ARTIFACTS_DIR / idea_id
idea_dir.mkdir(parents=True, exist_ok=True)
@@ -284,43 +310,43 @@ class GiteaAdapter(BaseServiceAdapter):
clone_url = f"{self.endpoint}/{self.org_name}/{repo_name}.git"
ssh_url = f"ssh://[email protected]:22/{self.org_name}/{repo_name}.git"
token = self.get_effective_token()
if token:
token = self.get_effective_token() if publish_remote else ""
if publish_remote:
if not token:
raise RuntimeError("Gitea publishing is not configured with a service token.")
loop = asyncio.get_running_loop()
def sync_gitea():
try:
with requests.Session() as s:
# 1. Ensure org and repo exist
self._ensure_org(s, token)
self._ensure_repo(s, token, repo_name, f"[{idea_id}] {title}")
# 2. Push full file tree to Gitea
self._sync_files_via_api(token, repo_name, files_to_sync)
return f"{self.endpoint}/{self.org_name}/{repo_name}"
except Exception as e:
print(f"[Gitea] Sync error: {e}")
return repo_url
with requests.Session() as s:
self._ensure_org(s, token)
if not self._ensure_repo(s, token, repo_name, f"[{idea_id}] {title}"):
raise RuntimeError(f"Could not create or access Gitea repository '{repo_name}'.")
if collaborator_username:
self._grant_repo_write_access(
s, token, repo_name, collaborator_username
)
self._sync_files_via_api(token, repo_name, files_to_sync)
return f"{self.endpoint}/{self.org_name}/{repo_name}"
try:
actual_url = await loop.run_in_executor(None, sync_gitea)
repo_url = actual_url or repo_url
except Exception as e:
print(f"[Gitea] Async sync exception: {e}")
actual_url = await loop.run_in_executor(None, sync_gitea)
repo_url = actual_url or repo_url
return {
"repo_name": f"{self.org_name}/{repo_name}",
"repo_url": repo_url,
"clone_url": clone_url,
"ssh_url": ssh_url,
"local_path": str(idea_dir)
"local_path": str(idea_dir),
"published": publish_remote
}
async def persist_work_track_outputs(
self,
idea_id: str,
track_name: str,
outputs: Dict[str, str]
outputs: Dict[str, str],
publish_remote: bool = True
) -> Dict[str, Any]:
"""Persists Work Track deliverables to local dossier and commits to Gitea project."""
"""Persists Work Track deliverables locally and optionally pushes them."""
idea_dir = ARTIFACTS_DIR / idea_id
out_subdir = idea_dir / "outputs" / track_name.lower().replace(" ", "-")
out_subdir.mkdir(parents=True, exist_ok=True)
@@ -335,7 +361,7 @@ class GiteaAdapter(BaseServiceAdapter):
repo_name = f"ts-{clean_slug.replace('ts-', '')}"
repo_url = f"{self.endpoint}/{self.org_name}/{repo_name}"
token = self.get_effective_token()
token = self.get_effective_token() if publish_remote else ""
if token and files_to_sync:
loop = asyncio.get_running_loop()
try:
@@ -348,31 +374,40 @@ class GiteaAdapter(BaseServiceAdapter):
print(f"[Gitea] Work track sync notice: {e}")
return {
"status": "SUCCESS",
"status": "SUCCESS" if publish_remote else "LOCAL_ONLY",
"path": str(out_subdir),
"repo_url": repo_url
"repo_url": repo_url if publish_remote else ""
}
async def graduate_project(self, idea_id: str, title: str, summary: str, repo_name: str = "") -> Dict[str, Any]:
async def graduate_project(
self,
idea_id: str,
title: str,
summary: str,
repo_name: str = "",
collaborator_username: Optional[str] = None
) -> Dict[str, Any]:
"""Graduates an incubated Coding Project work track to a dedicated repository under 'thinkstorm'."""
clean_title = re.sub(r'[^a-zA-Z0-9_-]', '-', title.lower()).strip('-')[:30] or "project"
slug = repo_name or f"ts-{idea_id.lower()}-{clean_title}"
repo_url = f"{self.endpoint}/{self.org_name}/{slug}"
token = self.get_effective_token()
if token:
loop = asyncio.get_running_loop()
def create_remote():
try:
with requests.Session() as s:
self._ensure_org(s, token)
self._ensure_repo(s, token, slug, f"[{idea_id}] {title}")
return f"{self.endpoint}/{self.org_name}/{slug}"
except Exception as e:
print(f"[Gitea] Project graduation notice: {e}")
return repo_url
actual_url = await loop.run_in_executor(None, create_remote)
repo_url = actual_url or repo_url
if not token:
raise RuntimeError("Gitea publishing is not configured with a service token.")
loop = asyncio.get_running_loop()
def create_remote():
with requests.Session() as s:
self._ensure_org(s, token)
if not self._ensure_repo(s, token, slug, f"[{idea_id}] {title}"):
raise RuntimeError(f"Could not create or access Gitea repository '{slug}'.")
if collaborator_username:
self._grant_repo_write_access(
s, token, slug, collaborator_username
)
return f"{self.endpoint}/{self.org_name}/{slug}"
actual_url = await loop.run_in_executor(None, create_remote)
repo_url = actual_url or repo_url
return {
"repo_name": f"{self.org_name}/{slug}",
+113 -35
View File
@@ -471,6 +471,7 @@ async function syncIdeaToOpenGist(ideaId) {
async function syncIdeaToGitea(ideaId) {
const btn = document.getElementById('sync-gitea-btn');
const originalLabel = btn ? btn.innerText : '';
if (btn) btn.innerText = 'Publishing to Gitea...';
sessionStorage.setItem(`thinkstorm_active_tab_${window.location.pathname}`, 'tab-artifacts');
history.replaceState(null, null, '#artifacts');
@@ -482,7 +483,7 @@ async function syncIdeaToGitea(ideaId) {
setTimeout(() => window.location.reload(), 800);
} catch (err) {
showToast(err.message, 'danger');
if (btn) btn.innerText = '🔄 Publish / Re-sync to Gitea';
if (btn) btn.innerText = originalLabel;
}
}
@@ -535,32 +536,50 @@ function initMarkdownViews(root = document) {
if (container.dataset.initialized === 'true') return;
container.dataset.initialized = 'true';
const formattedBox = container.querySelector('.markdown-formatted');
const codeBox = container.querySelector('.markdown-code');
const rawSourceEl = container.querySelector('.raw-markdown-source');
let rawText = '';
if (rawSourceEl) {
rawText = rawSourceEl.value || rawSourceEl.textContent || '';
} else if (codeBox && codeBox.querySelector('code')) {
rawText = codeBox.querySelector('code').textContent || '';
} else if (container.dataset.content) {
rawText = container.dataset.content;
}
// Check if this container has multi-version panes
const versionPanes = container.querySelectorAll('.research-version-pane');
if (versionPanes.length > 0) {
versionPanes.forEach(pane => {
const formattedBox = pane.querySelector('.markdown-formatted');
const codeBox = pane.querySelector('.markdown-code');
const rawSourceEl = pane.querySelector('.raw-markdown-source');
const rawText = rawSourceEl ? (rawSourceEl.value || rawSourceEl.textContent || '') : '';
// Render formatted markdown HTML
if (formattedBox && rawText) {
formattedBox.innerHTML = renderMarkdownContent(rawText);
}
if (formattedBox && rawText) {
formattedBox.innerHTML = renderMarkdownContent(rawText);
}
if (codeBox && (!codeBox.querySelector('code') || !codeBox.querySelector('code').textContent)) {
codeBox.innerHTML = `<pre><code>${escapeHtml(rawText)}</code></pre>`;
}
});
} else {
const formattedBox = container.querySelector('.markdown-formatted');
const codeBox = container.querySelector('.markdown-code');
const rawSourceEl = container.querySelector('.raw-markdown-source');
let rawText = '';
if (rawSourceEl) {
rawText = rawSourceEl.value || rawSourceEl.textContent || '';
} else if (codeBox && codeBox.querySelector('code')) {
rawText = codeBox.querySelector('code').textContent || '';
} else if (container.dataset.content) {
rawText = container.dataset.content;
}
// Ensure codeBox has raw text
if (codeBox && (!codeBox.querySelector('code') || !codeBox.querySelector('code').textContent)) {
codeBox.innerHTML = `<pre><code>${escapeHtml(rawText)}</code></pre>`;
}
// Render formatted markdown HTML
if (formattedBox && rawText) {
formattedBox.innerHTML = renderMarkdownContent(rawText);
}
// Default to Formatted view
if (formattedBox) formattedBox.style.display = 'block';
if (codeBox) codeBox.style.display = 'none';
// Ensure codeBox has raw text
if (codeBox && (!codeBox.querySelector('code') || !codeBox.querySelector('code').textContent)) {
codeBox.innerHTML = `<pre><code>${escapeHtml(rawText)}</code></pre>`;
}
// Default to Formatted view
if (formattedBox) formattedBox.style.display = 'block';
if (codeBox) codeBox.style.display = 'none';
}
// Setup toggle buttons
const toggleBtns = container.querySelectorAll('.btn-toggle-view');
@@ -587,10 +606,7 @@ function initMarkdownViews(root = document) {
}
function setMarkdownContainerView(container, mode) {
const formattedBox = container.querySelector('.markdown-formatted');
const codeBox = container.querySelector('.markdown-code');
const toggleBtns = container.querySelectorAll('.btn-toggle-view');
toggleBtns.forEach(b => {
if (b.getAttribute('data-view') === mode) {
b.classList.add('active');
@@ -599,21 +615,82 @@ function setMarkdownContainerView(container, mode) {
}
});
if (mode === 'code') {
if (formattedBox) formattedBox.style.display = 'none';
if (codeBox) codeBox.style.display = 'block';
const versionPanes = container.querySelectorAll('.research-version-pane');
if (versionPanes.length > 0) {
versionPanes.forEach(pane => {
const formattedBox = pane.querySelector('.markdown-formatted');
const codeBox = pane.querySelector('.markdown-code');
if (mode === 'code') {
if (formattedBox) formattedBox.style.display = 'none';
if (codeBox) codeBox.style.display = 'block';
} else {
if (formattedBox) formattedBox.style.display = 'block';
if (codeBox) codeBox.style.display = 'none';
}
});
} else {
// Formatted by default
if (formattedBox) formattedBox.style.display = 'block';
if (codeBox) codeBox.style.display = 'none';
const formattedBox = container.querySelector('.markdown-formatted');
const codeBox = container.querySelector('.markdown-code');
if (mode === 'code') {
if (formattedBox) formattedBox.style.display = 'none';
if (codeBox) codeBox.style.display = 'block';
} else {
if (formattedBox) formattedBox.style.display = 'block';
if (codeBox) codeBox.style.display = 'none';
}
}
}
function switchResearchVersion(selectEl, stage) {
const selectedVersion = String(selectEl.value);
const container = selectEl.closest('.tab-panel') || document.getElementById(`tab-${stage.toLowerCase().replace('_', '-')}`);
if (!container) return;
// Toggle panes for this stage
const panes = container.querySelectorAll(`.research-version-pane[data-stage="${stage}"]`);
let activePane = null;
panes.forEach(pane => {
if (String(pane.dataset.version) === selectedVersion) {
pane.style.display = 'block';
activePane = pane;
} else {
pane.style.display = 'none';
}
});
// Update header meta badges
if (activePane) {
const metaContainer = container.querySelector('.markdown-view-meta');
if (metaContainer) {
const modelBadge = metaContainer.querySelector('.research-meta-model');
if (modelBadge && activePane.dataset.model) {
modelBadge.innerText = `🤖 ${activePane.dataset.model}`;
}
const tokenBadge = metaContainer.querySelector('.research-meta-tokens');
if (tokenBadge && activePane.dataset.tokens) {
tokenBadge.innerText = `${activePane.dataset.tokens} tokens`;
}
const timeBadge = metaContainer.querySelector('.research-meta-time');
if (timeBadge && activePane.dataset.time) {
timeBadge.innerText = `🕒 ${activePane.dataset.time}`;
}
}
}
}
async function copyMarkdownFromContainer(btn) {
const container = btn.closest('.markdown-view-container');
if (!container) return;
const rawSourceEl = container.querySelector('.raw-markdown-source');
const codeEl = container.querySelector('.markdown-code code');
// Find visible pane if multi-version
let targetRoot = container;
const visiblePane = container.querySelector('.research-version-pane:not([style*="display:none"]):not([style*="display: none"])');
if (visiblePane) {
targetRoot = visiblePane;
}
const rawSourceEl = targetRoot.querySelector('.raw-markdown-source');
const codeEl = targetRoot.querySelector('.markdown-code code');
const text = rawSourceEl ? (rawSourceEl.value || rawSourceEl.textContent) : (codeEl ? codeEl.textContent : '');
if (!text) {
@@ -660,4 +737,5 @@ function escapeHtml(text) {
window.renderMarkdownContent = renderMarkdownContent;
window.initMarkdownViews = initMarkdownViews;
window.setMarkdownContainerView = setMarkdownContainerView;
window.switchResearchVersion = switchResearchVersion;
window.copyMarkdownFromContainer = copyMarkdownFromContainer;
+1 -1
View File
@@ -60,6 +60,6 @@
<script src="/static/js/marked.min.js"></script>
<script src="/static/js/purify.min.js"></script>
<script src="/static/js/app.js?v=20260822_v1"></script>
<script src="/static/js/app.js?v=20260827_v2"></script>
</body>
</html>
+159 -55
View File
@@ -278,17 +278,37 @@
<!-- Tab 0: Image Context (if present) -->
{% if idea.submission_image and idea.submission_image.present %}
<div id="tab-image-context" class="tab-panel">
{% set image_ctx_run = idea.provenance | selectattr("stage", "equalto", "IMAGE_CONTEXT") | list %}
{% if image_ctx_run and image_ctx_run|length > 0 and image_ctx_run[0].output_data.content %}
{% set stage_data = idea.research_stages.IMAGE_CONTEXT if idea.research_stages else None %}
{% set failed_image_run = idea.provenance | selectattr("stage", "equalto", "IMAGE_CONTEXT") | selectattr("status", "equalto", "FAILED") | list %}
{% if stage_data and stage_data.total_versions > 0 %}
<div class="markdown-view-container" data-markdown-view>
<div class="markdown-view-header">
<div class="markdown-view-meta">
<div class="markdown-view-header" style="flex-wrap:wrap; gap:0.75rem;">
<div class="markdown-view-meta" style="display:flex; align-items:center; flex-wrap:wrap; gap:0.5rem;">
<span class="badge" style="background:rgba(99,102,241,0.15); color:#a5b4fc;">Stage: IMAGE_CONTEXT</span>
<span class="badge" style="background:rgba(34,197,94,0.15); color:#4ade80;">AI Visual Interpretation</span>
{% if image_ctx_run[0].resolved_model %}
<span class="badge" style="background:rgba(255,255,255,0.06); font-size:0.75rem;">🤖 {{ image_ctx_run[0].resolved_model }}</span>
{% if stage_data.total_versions > 1 %}
<div class="version-pulldown-wrapper" style="display:inline-flex; align-items:center; gap:0.35rem;">
<label for="select-version-IMAGE_CONTEXT" style="font-size:0.75rem; color:var(--text-muted); font-weight:600;">Version:</label>
<select id="select-version-IMAGE_CONTEXT" class="form-select form-select-sm research-version-select" data-stage="IMAGE_CONTEXT" onchange="switchResearchVersion(this, 'IMAGE_CONTEXT')" style="font-size:0.8rem; padding:0.25rem 0.6rem; background:rgba(0,0,0,0.5); border:1px solid var(--border-glass); border-radius:var(--radius-sm); color:var(--text-primary); cursor:pointer;">
{% for v in stage_data.versions | reverse %}
<option value="{{ v.version }}" {% if v.is_latest %}selected{% endif %}>
v{{ v.version }}{% if v.is_latest %} (Latest){% endif %} — {{ v.started_at[:19].replace('T', ' ') }} UTC [{{ v.model }}]
</option>
{% endfor %}
</select>
</div>
{% else %}
<span class="badge" style="background:rgba(16,185,129,0.15); color:#34d399; border:1px solid rgba(16,185,129,0.3); font-size:0.75rem;">v1 (Latest)</span>
{% endif %}
<span class="badge research-meta-model" style="background:rgba(255,255,255,0.06); font-size:0.75rem;">🤖 {{ stage_data.latest.model }}</span>
{% if stage_data.latest.tokens.total %}
<span class="badge research-meta-tokens" style="background:rgba(255,255,255,0.05); font-size:0.75rem; color:var(--text-muted);">⚡ {{ stage_data.latest.tokens.total }} tokens</span>
{% endif %}
<span class="research-meta-time" style="font-size:0.75rem; color:var(--text-muted); font-family:var(--font-mono);">🕒 {{ stage_data.latest.started_at[:19].replace('T', ' ') }} UTC</span>
</div>
<div class="markdown-view-actions">
<div class="markdown-toggle-pill">
<button type="button" class="btn-toggle-view active" data-view="formatted" title="Formatted View">
@@ -303,21 +323,26 @@
</button>
</div>
</div>
<div class="markdown-view-body">
<div class="markdown-formatted markdown-body"></div>
<div class="markdown-code" style="display:none;">
<pre><code>{{ image_ctx_run[0].output_data.content }}</code></pre>
</div>
<textarea class="raw-markdown-source" style="display:none;">{{ image_ctx_run[0].output_data.content }}</textarea>
{% for v in stage_data.versions %}
<div class="research-version-pane" data-stage="IMAGE_CONTEXT" data-version="{{ v.version }}" data-model="{{ v.model }}" data-tokens="{{ v.tokens.total }}" data-time="{{ v.started_at[:19].replace('T', ' ') }} UTC" {% if not v.is_latest %}style="display:none;"{% endif %}>
<div class="markdown-formatted markdown-body"></div>
<div class="markdown-code" style="display:none;">
<pre><code>{{ v.content }}</code></pre>
</div>
<textarea class="raw-markdown-source" style="display:none;">{{ v.content }}</textarea>
</div>
{% endfor %}
</div>
</div>
{% elif image_ctx_run and image_ctx_run|length > 0 and image_ctx_run[0].status == 'FAILED' %}
{% elif failed_image_run and failed_image_run|length > 0 %}
<div class="glass-card" style="padding:2.5rem 2rem; text-align:center; background:rgba(239,68,68,0.04); border:1px dashed rgba(239,68,68,0.3); border-radius:var(--radius-md);">
<div style="font-size:2.2rem; margin-bottom:0.75rem;">⚠️</div>
<h4 style="color:#f87171; margin-bottom:0.5rem;">Vision Processing Notice</h4>
<p style="color:var(--text-secondary); max-width:550px; margin:0 auto; font-size:0.9rem;">
The reference image was safely preserved, but automated vision analysis was unavailable or encountered an error:
<code>{{ image_ctx_run[0].error_message or "Model refused or failed image processing." }}</code>.
<code>{{ failed_image_run[0].error_message or "Model refused or failed image processing." }}</code>.
The idea continues with text-only research synthesis.
</p>
</div>
@@ -335,16 +360,35 @@
<!-- Tab 1: Prior Art -->
<div id="tab-prior-art" class="tab-panel active">
{% set prior_art_run = idea.provenance | selectattr("stage", "equalto", "PRIOR_ART") | list %}
{% if prior_art_run and prior_art_run|length > 0 and prior_art_run[0].output_data.content %}
{% set stage_data = idea.research_stages.PRIOR_ART if idea.research_stages else None %}
{% if stage_data and stage_data.total_versions > 0 %}
<div class="markdown-view-container" data-markdown-view>
<div class="markdown-view-header">
<div class="markdown-view-meta">
<div class="markdown-view-header" style="flex-wrap:wrap; gap:0.75rem;">
<div class="markdown-view-meta" style="display:flex; align-items:center; flex-wrap:wrap; gap:0.5rem;">
<span class="badge" style="background:rgba(99,102,241,0.15); color:#a5b4fc;">Stage: PRIOR_ART</span>
{% if prior_art_run[0].resolved_model %}
<span class="badge" style="background:rgba(255,255,255,0.06); font-size:0.75rem;">🤖 {{ prior_art_run[0].resolved_model }}</span>
{% if stage_data.total_versions > 1 %}
<div class="version-pulldown-wrapper" style="display:inline-flex; align-items:center; gap:0.35rem;">
<label for="select-version-PRIOR_ART" style="font-size:0.75rem; color:var(--text-muted); font-weight:600;">Version:</label>
<select id="select-version-PRIOR_ART" class="form-select form-select-sm research-version-select" data-stage="PRIOR_ART" onchange="switchResearchVersion(this, 'PRIOR_ART')" style="font-size:0.8rem; padding:0.25rem 0.6rem; background:rgba(0,0,0,0.5); border:1px solid var(--border-glass); border-radius:var(--radius-sm); color:var(--text-primary); cursor:pointer;">
{% for v in stage_data.versions | reverse %}
<option value="{{ v.version }}" {% if v.is_latest %}selected{% endif %}>
v{{ v.version }}{% if v.is_latest %} (Latest){% endif %} — {{ v.started_at[:19].replace('T', ' ') }} UTC [{{ v.model }}]
</option>
{% endfor %}
</select>
</div>
{% else %}
<span class="badge" style="background:rgba(16,185,129,0.15); color:#34d399; border:1px solid rgba(16,185,129,0.3); font-size:0.75rem;">v1 (Latest)</span>
{% endif %}
<span class="badge research-meta-model" style="background:rgba(255,255,255,0.06); font-size:0.75rem;">🤖 {{ stage_data.latest.model }}</span>
{% if stage_data.latest.tokens.total %}
<span class="badge research-meta-tokens" style="background:rgba(255,255,255,0.05); font-size:0.75rem; color:var(--text-muted);">⚡ {{ stage_data.latest.tokens.total }} tokens</span>
{% endif %}
<span class="research-meta-time" style="font-size:0.75rem; color:var(--text-muted); font-family:var(--font-mono);">🕒 {{ stage_data.latest.started_at[:19].replace('T', ' ') }} UTC</span>
</div>
<div class="markdown-view-actions">
<div class="markdown-toggle-pill">
<button type="button" class="btn-toggle-view active" data-view="formatted" title="Formatted View">
@@ -359,12 +403,17 @@
</button>
</div>
</div>
<div class="markdown-view-body">
<div class="markdown-formatted markdown-body"></div>
<div class="markdown-code" style="display:none;">
<pre><code>{{ prior_art_run[0].output_data.content }}</code></pre>
</div>
<textarea class="raw-markdown-source" style="display:none;">{{ prior_art_run[0].output_data.content }}</textarea>
{% for v in stage_data.versions %}
<div class="research-version-pane" data-stage="PRIOR_ART" data-version="{{ v.version }}" data-model="{{ v.model }}" data-tokens="{{ v.tokens.total }}" data-time="{{ v.started_at[:19].replace('T', ' ') }} UTC" {% if not v.is_latest %}style="display:none;"{% endif %}>
<div class="markdown-formatted markdown-body"></div>
<div class="markdown-code" style="display:none;">
<pre><code>{{ v.content }}</code></pre>
</div>
<textarea class="raw-markdown-source" style="display:none;">{{ v.content }}</textarea>
</div>
{% endfor %}
</div>
</div>
{% else %}
@@ -383,16 +432,35 @@
<!-- Tab 2: Research Synthesis -->
<div id="tab-research" class="tab-panel">
{% set research_run = idea.provenance | selectattr("stage", "equalto", "RESEARCH") | list %}
{% if research_run and research_run|length > 0 and research_run[0].output_data.content %}
{% set stage_data = idea.research_stages.RESEARCH if idea.research_stages else None %}
{% if stage_data and stage_data.total_versions > 0 %}
<div class="markdown-view-container" data-markdown-view>
<div class="markdown-view-header">
<div class="markdown-view-meta">
<div class="markdown-view-header" style="flex-wrap:wrap; gap:0.75rem;">
<div class="markdown-view-meta" style="display:flex; align-items:center; flex-wrap:wrap; gap:0.5rem;">
<span class="badge" style="background:rgba(99,102,241,0.15); color:#a5b4fc;">Stage: RESEARCH</span>
{% if research_run[0].resolved_model %}
<span class="badge" style="background:rgba(255,255,255,0.06); font-size:0.75rem;">🤖 {{ research_run[0].resolved_model }}</span>
{% if stage_data.total_versions > 1 %}
<div class="version-pulldown-wrapper" style="display:inline-flex; align-items:center; gap:0.35rem;">
<label for="select-version-RESEARCH" style="font-size:0.75rem; color:var(--text-muted); font-weight:600;">Version:</label>
<select id="select-version-RESEARCH" class="form-select form-select-sm research-version-select" data-stage="RESEARCH" onchange="switchResearchVersion(this, 'RESEARCH')" style="font-size:0.8rem; padding:0.25rem 0.6rem; background:rgba(0,0,0,0.5); border:1px solid var(--border-glass); border-radius:var(--radius-sm); color:var(--text-primary); cursor:pointer;">
{% for v in stage_data.versions | reverse %}
<option value="{{ v.version }}" {% if v.is_latest %}selected{% endif %}>
v{{ v.version }}{% if v.is_latest %} (Latest){% endif %} — {{ v.started_at[:19].replace('T', ' ') }} UTC [{{ v.model }}]
</option>
{% endfor %}
</select>
</div>
{% else %}
<span class="badge" style="background:rgba(16,185,129,0.15); color:#34d399; border:1px solid rgba(16,185,129,0.3); font-size:0.75rem;">v1 (Latest)</span>
{% endif %}
<span class="badge research-meta-model" style="background:rgba(255,255,255,0.06); font-size:0.75rem;">🤖 {{ stage_data.latest.model }}</span>
{% if stage_data.latest.tokens.total %}
<span class="badge research-meta-tokens" style="background:rgba(255,255,255,0.05); font-size:0.75rem; color:var(--text-muted);">⚡ {{ stage_data.latest.tokens.total }} tokens</span>
{% endif %}
<span class="research-meta-time" style="font-size:0.75rem; color:var(--text-muted); font-family:var(--font-mono);">🕒 {{ stage_data.latest.started_at[:19].replace('T', ' ') }} UTC</span>
</div>
<div class="markdown-view-actions">
<div class="markdown-toggle-pill">
<button type="button" class="btn-toggle-view active" data-view="formatted" title="Formatted View">
@@ -407,12 +475,17 @@
</button>
</div>
</div>
<div class="markdown-view-body">
<div class="markdown-formatted markdown-body"></div>
<div class="markdown-code" style="display:none;">
<pre><code>{{ research_run[0].output_data.content }}</code></pre>
</div>
<textarea class="raw-markdown-source" style="display:none;">{{ research_run[0].output_data.content }}</textarea>
{% for v in stage_data.versions %}
<div class="research-version-pane" data-stage="RESEARCH" data-version="{{ v.version }}" data-model="{{ v.model }}" data-tokens="{{ v.tokens.total }}" data-time="{{ v.started_at[:19].replace('T', ' ') }} UTC" {% if not v.is_latest %}style="display:none;"{% endif %}>
<div class="markdown-formatted markdown-body"></div>
<div class="markdown-code" style="display:none;">
<pre><code>{{ v.content }}</code></pre>
</div>
<textarea class="raw-markdown-source" style="display:none;">{{ v.content }}</textarea>
</div>
{% endfor %}
</div>
</div>
{% else %}
@@ -431,16 +504,35 @@
<!-- Tab 3: Feasibility & Critique -->
<div id="tab-feasibility" class="tab-panel">
{% set feas_run = idea.provenance | selectattr("stage", "equalto", "FEASIBILITY") | list %}
{% if feas_run and feas_run|length > 0 and feas_run[0].output_data.content %}
{% set stage_data = idea.research_stages.FEASIBILITY if idea.research_stages else None %}
{% if stage_data and stage_data.total_versions > 0 %}
<div class="markdown-view-container" data-markdown-view>
<div class="markdown-view-header">
<div class="markdown-view-meta">
<div class="markdown-view-header" style="flex-wrap:wrap; gap:0.75rem;">
<div class="markdown-view-meta" style="display:flex; align-items:center; flex-wrap:wrap; gap:0.5rem;">
<span class="badge" style="background:rgba(99,102,241,0.15); color:#a5b4fc;">Stage: FEASIBILITY</span>
{% if feas_run[0].resolved_model %}
<span class="badge" style="background:rgba(255,255,255,0.06); font-size:0.75rem;">🤖 {{ feas_run[0].resolved_model }}</span>
{% if stage_data.total_versions > 1 %}
<div class="version-pulldown-wrapper" style="display:inline-flex; align-items:center; gap:0.35rem;">
<label for="select-version-FEASIBILITY" style="font-size:0.75rem; color:var(--text-muted); font-weight:600;">Version:</label>
<select id="select-version-FEASIBILITY" class="form-select form-select-sm research-version-select" data-stage="FEASIBILITY" onchange="switchResearchVersion(this, 'FEASIBILITY')" style="font-size:0.8rem; padding:0.25rem 0.6rem; background:rgba(0,0,0,0.5); border:1px solid var(--border-glass); border-radius:var(--radius-sm); color:var(--text-primary); cursor:pointer;">
{% for v in stage_data.versions | reverse %}
<option value="{{ v.version }}" {% if v.is_latest %}selected{% endif %}>
v{{ v.version }}{% if v.is_latest %} (Latest){% endif %} — {{ v.started_at[:19].replace('T', ' ') }} UTC [{{ v.model }}]
</option>
{% endfor %}
</select>
</div>
{% else %}
<span class="badge" style="background:rgba(16,185,129,0.15); color:#34d399; border:1px solid rgba(16,185,129,0.3); font-size:0.75rem;">v1 (Latest)</span>
{% endif %}
<span class="badge research-meta-model" style="background:rgba(255,255,255,0.06); font-size:0.75rem;">🤖 {{ stage_data.latest.model }}</span>
{% if stage_data.latest.tokens.total %}
<span class="badge research-meta-tokens" style="background:rgba(255,255,255,0.05); font-size:0.75rem; color:var(--text-muted);">⚡ {{ stage_data.latest.tokens.total }} tokens</span>
{% endif %}
<span class="research-meta-time" style="font-size:0.75rem; color:var(--text-muted); font-family:var(--font-mono);">🕒 {{ stage_data.latest.started_at[:19].replace('T', ' ') }} UTC</span>
</div>
<div class="markdown-view-actions">
<div class="markdown-toggle-pill">
<button type="button" class="btn-toggle-view active" data-view="formatted" title="Formatted View">
@@ -455,12 +547,17 @@
</button>
</div>
</div>
<div class="markdown-view-body">
<div class="markdown-formatted markdown-body"></div>
<div class="markdown-code" style="display:none;">
<pre><code>{{ feas_run[0].output_data.content }}</code></pre>
</div>
<textarea class="raw-markdown-source" style="display:none;">{{ feas_run[0].output_data.content }}</textarea>
{% for v in stage_data.versions %}
<div class="research-version-pane" data-stage="FEASIBILITY" data-version="{{ v.version }}" data-model="{{ v.model }}" data-tokens="{{ v.tokens.total }}" data-time="{{ v.started_at[:19].replace('T', ' ') }} UTC" {% if not v.is_latest %}style="display:none;"{% endif %}>
<div class="markdown-formatted markdown-body"></div>
<div class="markdown-code" style="display:none;">
<pre><code>{{ v.content }}</code></pre>
</div>
<textarea class="raw-markdown-source" style="display:none;">{{ v.content }}</textarea>
</div>
{% endfor %}
</div>
</div>
{% else %}
@@ -537,7 +634,7 @@
<button onclick="activateWorkTrack('{{ tr.id }}')" class="btn btn-primary btn-sm">
{% if tr.outputs and tr.outputs|length > 0 %}🔄 Re-run Workflow{% else %}⚡ Run Workflow{% endif %}
</button>
{% if tr.work_type_id == 'CODING_PROJECT' and tr.state == 'COMPLETED' %}
{% if tr.work_type_id == 'CODING_PROJECT' and tr.state == 'COMPLETED' and idea.gitea_publish_allowed %}
<button onclick="graduateToGitea('{{ tr.id }}')" class="btn btn-success btn-sm">📦 Graduate to Gitea</button>
{% endif %}
{% endif %}
@@ -681,7 +778,7 @@
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:1rem; flex-wrap:wrap; gap:0.5rem;">
<div>
<h3 style="font-size:1.1rem; margin-bottom:0.25rem;">Canonical Dossier & Gitea Project</h3>
<p style="color:var(--text-secondary); font-size:0.88rem; margin:0;">Every idea is maintained as a full Git repository under the <strong>thinkstorm</strong> organization on Gitea.</p>
<p style="color:var(--text-secondary); font-size:0.88rem; margin:0;">Ideas remain local until a Gitea-linked claimant deliberately publishes them under the <strong>thinkstorm</strong> organization.</p>
</div>
<div>
{% if idea.gitea_repo_url %}
@@ -701,24 +798,31 @@
<span class="badge" style="background:rgba(99,102,241,0.2); color:#a5b4fc; font-size:0.75rem;">org: thinkstorm</span>
</div>
<div style="font-family:var(--font-mono); font-size:0.85rem; color:var(--text-muted);">
Repo: <span style="color:var(--accent-primary);">{{ idea.gitea_repo_name or "thinkstorm/ts-" ~ idea.id|lower|replace('ts-', '') }}</span>
Repo:
{% if idea.gitea_repo_url %}
<span style="color:var(--accent-primary);">{{ idea.gitea_repo_name }}</span>
{% else %}
<span style="color:var(--text-secondary);">Not published</span>
{% endif %}
</div>
{% if idea.gitea_repo_url %}
<div style="font-size:0.8rem; color:var(--text-secondary); margin-top:0.25rem; font-family:var(--font-mono);">
Clone: <code style="color:#a5b4fc; background:rgba(0,0,0,0.4); padding:0.2rem 0.4rem; border-radius:4px;">git clone https://git.labyricorn.com/{{ idea.gitea_repo_name or "thinkstorm/ts-" ~ idea.id|lower|replace('ts-', '') }}.git</code>
Clone: <code style="color:#a5b4fc; background:rgba(0,0,0,0.4); padding:0.2rem 0.4rem; border-radius:4px;">git clone https://git.labyricorn.com/{{ idea.gitea_repo_name }}.git</code>
</div>
{% endif %}
</div>
<div style="display:flex; gap:0.5rem; flex-wrap:wrap; align-items:center;">
{% if idea.gitea_publish_allowed %}
<button id="sync-gitea-btn" onclick="syncIdeaToGitea('{{ idea.id }}')" class="btn btn-primary btn-sm">
🔄 Publish / Re-sync to Gitea
{% if idea.gitea_repo_url %}🔄 Re-sync to Gitea{% else %}📤 Publish to Gitea{% endif %}
</button>
{% elif idea.gitea_publish_reason %}
<span style="color:var(--text-muted); font-size:0.82rem; max-width:330px;">{{ idea.gitea_publish_reason }}</span>
{% endif %}
{% if idea.gitea_repo_url %}
<a href="{{ idea.gitea_repo_url }}" target="_blank" class="btn btn-secondary btn-sm">
Open in Gitea ↗
</a>
{% else %}
<a href="https://git.labyricorn.com/thinkstorm/ts-{{ idea.id|lower|replace('ts-', '') }}" target="_blank" class="btn btn-secondary btn-sm">
Open in Gitea ↗
</a>
{% endif %}
</div>
</div>