Make Gitea publishing claimant controlled
This commit is contained in:
@@ -25,7 +25,8 @@ ThinkStorm captures unformed ideas with zero friction, preserves original submis
|
||||
- **Deep Technical Feasibility & Risk Critique**: Generates feasibility scores (0–10), 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).
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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
@@ -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
@@ -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))
|
||||
|
||||
@@ -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}")
|
||||
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user