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.
|
- **Deep Technical Feasibility & Risk Critique**: Generates feasibility scores (0–10), technical constraints, implementation roadmaps, and risk matrices.
|
||||||
|
|
||||||
### 3. Canonical Gitea Project Repositories
|
### 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:
|
- Repositories include:
|
||||||
- `README.md`: Executive summary, prompt quotation, reference image metadata, lifecycle badge, and project structure guide.
|
- `README.md`: Executive summary, prompt quotation, reference image metadata, lifecycle badge, and project structure guide.
|
||||||
- `metadata.json`: Machine-readable metadata schema.
|
- `metadata.json`: Machine-readable metadata schema.
|
||||||
@@ -76,7 +77,7 @@ graph TD
|
|||||||
P4 -->|Web Search| SearXNG[SearXNG & Perplexica]
|
P4 -->|Web Search| SearXNG[SearXNG & Perplexica]
|
||||||
P2 & P3 & P4 & P5 -->|LLM Synthesis| Omni
|
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]
|
Queue -->|Push Gist Snippets| OpenGist[OpenGist Service]
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -220,4 +221,3 @@ python3 -m pytest tests/ -v
|
|||||||
## 📄 License & Attribution
|
## 📄 License & Attribution
|
||||||
|
|
||||||
Developed by **Labyricorn**. Licensed under the [MIT License](LICENSE).
|
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,
|
"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
|
return None
|
||||||
|
|
||||||
monkeypatch.setattr(pipeline.omniroute_svc, "chat_completion", fake_completion)
|
monkeypatch.setattr(pipeline.omniroute_svc, "chat_completion", fake_completion)
|
||||||
monkeypatch.setattr(pipeline.gitea_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_persist)
|
monkeypatch.setattr(pipeline.opengist_svc, "persist_work_track_outputs", fake_opengist_persist)
|
||||||
|
|
||||||
asyncio.run(pipeline.execute_work_track_workflow("WT-YT01"))
|
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[1]["content"].startswith("# Outline")
|
||||||
assert outputs[2]["content"].startswith("# Script")
|
assert outputs[2]["content"].startswith("# Script")
|
||||||
assert state == "COMPLETED"
|
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
|
# Rate limiting sliding window in-memory cache: ip -> list of timestamps
|
||||||
SUBMISSION_IP_LOG: Dict[str, List[float]] = {}
|
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):
|
class IdeaSubmissionRequest(BaseModel):
|
||||||
text: str
|
text: str
|
||||||
|
|
||||||
@@ -378,6 +404,86 @@ async def get_idea_detail(idea_id: str, current_user: User = Depends(require_aut
|
|||||||
except Exception:
|
except Exception:
|
||||||
sub_img = None
|
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 {
|
return {
|
||||||
"id": idea_row["id"],
|
"id": idea_row["id"],
|
||||||
"title": idea_row["title"],
|
"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"],
|
"profile_id": idea_row["profile_id"],
|
||||||
"opengist_id": idea_row["opengist_id"],
|
"opengist_id": idea_row["opengist_id"],
|
||||||
"opengist_url": idea_row["opengist_url"],
|
"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,
|
"submission_image": sub_img,
|
||||||
"categories": cats,
|
"categories": cats,
|
||||||
"tags": tags,
|
"tags": tags,
|
||||||
"urls": urls,
|
"urls": urls,
|
||||||
"work_tracks": work_tracks,
|
"work_tracks": work_tracks,
|
||||||
"provenance": provenance,
|
"provenance": provenance,
|
||||||
|
"research_stages": research_stages,
|
||||||
"relationships": rels,
|
"relationships": rels,
|
||||||
"usage_summary": {
|
"usage_summary": {
|
||||||
"total_tokens": total_tokens_sum,
|
"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":
|
if track["work_type_id"] != "CODING_PROJECT":
|
||||||
raise HTTPException(status_code=400, detail="Only Coding Project work tracks can graduate to Gitea.")
|
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()
|
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:
|
claimant_username = _resolve_gitea_claimant(conn, idea, current_user)
|
||||||
raise HTTPException(status_code=403, detail="Only the claimant can graduate this project.")
|
|
||||||
|
|
||||||
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()
|
now = get_utc_now()
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
conn.execute(
|
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()]
|
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()]
|
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 = {}
|
research_docs = {}
|
||||||
for r in runs:
|
for r in runs:
|
||||||
stage = r.get("stage")
|
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 = {}
|
out_data = {}
|
||||||
content = out_data.get("content")
|
content = out_data.get("content")
|
||||||
if 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
|
research_docs["prior-art.md"] = content
|
||||||
elif stage == "RESEARCH":
|
elif stage == "RESEARCH":
|
||||||
research_docs["analysis.md"] = content
|
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")
|
@router.post("/{idea_id}/sync-gitea")
|
||||||
async def sync_idea_to_gitea(idea_id: str, current_user: User = Depends(get_current_user)):
|
async def sync_idea_to_gitea(idea_id: str, current_user: User = Depends(require_authenticated)):
|
||||||
"""Manually triggers full Gitea Project Repository sync for an idea under 'thinkstorm' org."""
|
"""Publishes or re-syncs a claimed idea and grants its claimant write access."""
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
idea = conn.execute("SELECT * FROM ideas WHERE id = ?", (idea_id,)).fetchone()
|
idea = conn.execute("SELECT * FROM ideas WHERE id = ?", (idea_id,)).fetchone()
|
||||||
if not idea:
|
if not idea:
|
||||||
raise HTTPException(status_code=404, detail="Idea not found.")
|
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()]
|
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()]
|
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 = {}
|
research_docs = {}
|
||||||
for r in runs:
|
for r in runs:
|
||||||
stage = r.get("stage")
|
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 = {}
|
out_data = {}
|
||||||
content = out_data.get("content")
|
content = out_data.get("content")
|
||||||
if 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
|
research_docs["prior-art.md"] = content
|
||||||
elif stage == "RESEARCH":
|
elif stage == "RESEARCH":
|
||||||
research_docs["analysis.md"] = content
|
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(" ", "-")
|
track_slug = tr["name"].lower().replace(" ", "-")
|
||||||
outputs[f"{track_slug}/{out['name']}"] = out["content"]
|
outputs[f"{track_slug}/{out['name']}"] = out["content"]
|
||||||
|
|
||||||
res = await gitea_svc.persist_idea_dossier_repo(
|
try:
|
||||||
idea_id=idea["id"],
|
res = await gitea_svc.persist_idea_dossier_repo(
|
||||||
title=idea["title"] or "Untitled Idea",
|
idea_id=idea["id"],
|
||||||
summary=idea["summary"] or "",
|
title=idea["title"] or "Untitled Idea",
|
||||||
original_text=idea["original_text"] or "",
|
summary=idea["summary"] or "",
|
||||||
categories=cats,
|
original_text=idea["original_text"] or "",
|
||||||
tags=tags,
|
categories=cats,
|
||||||
lifecycle_state=idea["lifecycle_state"],
|
tags=tags,
|
||||||
research_docs=research_docs,
|
lifecycle_state=idea["lifecycle_state"],
|
||||||
outputs=outputs,
|
research_docs=research_docs,
|
||||||
provenance_runs=runs,
|
outputs=outputs,
|
||||||
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)
|
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()
|
now = get_utc_now()
|
||||||
with get_db() as conn:
|
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)
|
(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)
|
SELECT id FROM external_resources
|
||||||
VALUES (?, 'GITEA_DOSSIER', ?, ?, ?)
|
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 {
|
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_name": res["repo_name"],
|
||||||
"gitea_repo_url": res["repo_url"],
|
"gitea_repo_url": res["repo_url"],
|
||||||
"clone_url": res["clone_url"],
|
"clone_url": res["clone_url"],
|
||||||
@@ -859,7 +1018,7 @@ async def reprocess_idea(
|
|||||||
bypass_duplicate_check: bool = True,
|
bypass_duplicate_check: bool = True,
|
||||||
current_user: User = Depends(get_current_user)
|
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:
|
with get_db() as conn:
|
||||||
idea = conn.execute("SELECT * FROM ideas WHERE id = ?", (idea_id,)).fetchone()
|
idea = conn.execute("SELECT * FROM ideas WHERE id = ?", (idea_id,)).fetchone()
|
||||||
if not idea:
|
if not idea:
|
||||||
@@ -903,4 +1062,3 @@ async def get_idea_image(idea_id: str):
|
|||||||
media_type=mime,
|
media_type=mime,
|
||||||
filename=meta.get("original_filename", f"{idea_id}_reference_image")
|
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:
|
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()
|
row = conn.execute("SELECT id, username, password_hash, role, created_at FROM users WHERE username = ? OR gitea_id = ?", (username, gitea_id)).fetchone()
|
||||||
if row:
|
if row:
|
||||||
# Update role if promoted
|
# Record the verified Gitea identity when an existing local account links
|
||||||
if is_gitea_admin and row["role"] != "ADMIN":
|
# through OAuth, and promote the role when appropriate.
|
||||||
conn.execute("UPDATE users SET role = 'ADMIN' WHERE id = ?", (row["id"],))
|
resolved_role = "ADMIN" if is_gitea_admin else row["role"]
|
||||||
return User(id=row["id"], username=row["username"], password_hash=row["password_hash"], role=role, created_at=row["created_at"])
|
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
|
# Insert new user
|
||||||
dummy_pass = hash_password(secrets.token_hex(16))
|
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:
|
with get_db() as conn:
|
||||||
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 = ?", (idea_id,)).fetchall()]
|
||||||
|
|
||||||
# 1. Primary: Persist as a dedicated Gitea Project Repository under 'thinkstorm' org
|
# Persist the dossier locally. Remote Gitea publication is claimant-triggered.
|
||||||
gitea_res = await gitea_svc.persist_idea_dossier_repo(
|
await gitea_svc.persist_idea_dossier_repo(
|
||||||
idea_id=idea_id,
|
idea_id=idea_id,
|
||||||
title=title,
|
title=title,
|
||||||
summary=summary,
|
summary=summary,
|
||||||
@@ -643,10 +643,11 @@ async def execute_intake_pipeline(idea_id: str, bypass_duplicate_check: bool = F
|
|||||||
research_docs=research_docs,
|
research_docs=research_docs,
|
||||||
outputs={},
|
outputs={},
|
||||||
provenance_runs=runs,
|
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(
|
gist_res = await opengist_svc.persist_idea_artifact(
|
||||||
idea_id=idea_id,
|
idea_id=idea_id,
|
||||||
title=title,
|
title=title,
|
||||||
@@ -661,7 +662,7 @@ async def execute_intake_pipeline(idea_id: str, bypass_duplicate_check: bool = F
|
|||||||
submission_image=submission_image
|
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:
|
with get_db() as conn:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"""
|
"""
|
||||||
@@ -669,22 +670,12 @@ async def execute_intake_pipeline(idea_id: str, bypass_duplicate_check: bool = F
|
|||||||
SET lifecycle_state = 'AVAILABLE',
|
SET lifecycle_state = 'AVAILABLE',
|
||||||
processing_state = 'IDLE',
|
processing_state = 'IDLE',
|
||||||
enrichment_level = 5,
|
enrichment_level = 5,
|
||||||
gitea_repo_name = ?,
|
|
||||||
gitea_repo_url = ?,
|
|
||||||
opengist_id = ?,
|
opengist_id = ?,
|
||||||
opengist_url = ?,
|
opengist_url = ?,
|
||||||
updated_at = ?
|
updated_at = ?
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
""",
|
""",
|
||||||
(gitea_res["repo_name"], gitea_res["repo_url"], gist_res["opengist_id"], gist_res["opengist_url"], get_utc_now(), idea_id)
|
(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())
|
|
||||||
)
|
)
|
||||||
|
|
||||||
except Exception as e:
|
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()
|
start_time = get_utc_now()
|
||||||
generated_outputs: Dict[str, str] = {}
|
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":
|
if work_type == "ARTICLE" or work_type == "BLOG_ENTRY":
|
||||||
prompt_info = get_prompt_version("article-generator", is_admin=True)
|
prompt_info = get_prompt_version("article-generator", is_admin=True)
|
||||||
user_prompt = (
|
user_prompt = (
|
||||||
prompt_info["user_prompt_template"]
|
prompt_info["user_prompt_template"]
|
||||||
.replace("{{title}}", idea["title"])
|
.replace("{{title}}", idea_title)
|
||||||
.replace("{{track_name}}", track_name)
|
.replace("{{track_name}}", track_name)
|
||||||
.replace("{{research_context}}", combined_research)
|
.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)
|
prompt_info = get_prompt_version("coding-spec-generator", is_admin=True)
|
||||||
user_prompt = (
|
user_prompt = (
|
||||||
prompt_info["user_prompt_template"]
|
prompt_info["user_prompt_template"]
|
||||||
.replace("{{title}}", idea["title"])
|
.replace("{{title}}", idea_title)
|
||||||
.replace("{{summary}}", idea["summary"])
|
.replace("{{summary}}", idea_summary)
|
||||||
.replace("{{feasibility_context}}", combined_research)
|
.replace("{{feasibility_context}}", combined_research)
|
||||||
)
|
)
|
||||||
llm_resp = await omniroute_svc.chat_completion(
|
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)
|
prompt_info = get_prompt_version("youtube-video-generator", is_admin=True)
|
||||||
user_prompt = (
|
user_prompt = (
|
||||||
prompt_info["user_prompt_template"]
|
prompt_info["user_prompt_template"]
|
||||||
.replace("{{title}}", idea["title"])
|
.replace("{{title}}", idea_title)
|
||||||
.replace("{{track_name}}", track_name)
|
.replace("{{track_name}}", track_name)
|
||||||
.replace("{{summary}}", idea["summary"])
|
.replace("{{summary}}", idea_summary)
|
||||||
.replace("{{research_context}}", combined_research)
|
.replace("{{research_context}}", combined_research)
|
||||||
)
|
)
|
||||||
llm_resp = await omniroute_svc.chat_completion(
|
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))
|
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:
|
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:
|
except Exception as e:
|
||||||
print(f"[Gitea Sync Notice] {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]:
|
def _get_headers(self, token: Optional[str] = None) -> Dict[str, str]:
|
||||||
t = token or self.get_effective_token()
|
t = token or self.get_effective_token()
|
||||||
headers = {
|
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"
|
"Content-Type": "application/json"
|
||||||
}
|
}
|
||||||
if t:
|
if t:
|
||||||
@@ -62,13 +62,14 @@ class GiteaAdapter(BaseServiceAdapter):
|
|||||||
loop = asyncio.get_running_loop()
|
loop = asyncio.get_running_loop()
|
||||||
try:
|
try:
|
||||||
url = f"{self.endpoint}/api/v1/version"
|
url = f"{self.endpoint}/api/v1/version"
|
||||||
|
headers = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"}
|
||||||
def fetch():
|
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)
|
resp = await loop.run_in_executor(None, fetch)
|
||||||
|
elapsed = int((time.time() - start) * 1000)
|
||||||
if resp.status_code == 200:
|
if resp.status_code == 200:
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
version = data.get("version", "unknown")
|
version = data.get("version", "unknown")
|
||||||
elapsed = int((time.time() - start) * 1000)
|
|
||||||
return ServiceHealth(
|
return ServiceHealth(
|
||||||
service_id=self.service_id,
|
service_id=self.service_id,
|
||||||
healthy=True,
|
healthy=True,
|
||||||
@@ -77,15 +78,13 @@ class GiteaAdapter(BaseServiceAdapter):
|
|||||||
response_time_ms=elapsed,
|
response_time_ms=elapsed,
|
||||||
extra={"version": version}
|
extra={"version": version}
|
||||||
)
|
)
|
||||||
else:
|
return ServiceHealth(
|
||||||
elapsed = int((time.time() - start) * 1000)
|
service_id=self.service_id,
|
||||||
return ServiceHealth(
|
healthy=False,
|
||||||
service_id=self.service_id,
|
endpoint=self.endpoint,
|
||||||
healthy=False,
|
message=f"Gitea returned HTTP {resp.status_code}",
|
||||||
endpoint=self.endpoint,
|
response_time_ms=elapsed
|
||||||
message=f"Gitea HTTP error: {resp.status_code}",
|
)
|
||||||
response_time_ms=elapsed
|
|
||||||
)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
elapsed = int((time.time() - start) * 1000)
|
elapsed = int((time.time() - start) * 1000)
|
||||||
return ServiceHealth(
|
return ServiceHealth(
|
||||||
@@ -100,7 +99,7 @@ class GiteaAdapter(BaseServiceAdapter):
|
|||||||
"""Ensures the 'thinkstorm' organization exists."""
|
"""Ensures the 'thinkstorm' organization exists."""
|
||||||
headers = self._get_headers(token)
|
headers = self._get_headers(token)
|
||||||
try:
|
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:
|
if r.status_code == 200:
|
||||||
return
|
return
|
||||||
if r.status_code == 404:
|
if r.status_code == 404:
|
||||||
@@ -110,7 +109,7 @@ class GiteaAdapter(BaseServiceAdapter):
|
|||||||
"description": "Canonical dossiers, research, and project incubations generated by ThinkStorm",
|
"description": "Canonical dossiers, research, and project incubations generated by ThinkStorm",
|
||||||
"visibility": "public"
|
"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:
|
except Exception as e:
|
||||||
print(f"[Gitea] Org ensure notice: {e}")
|
print(f"[Gitea] Org ensure notice: {e}")
|
||||||
|
|
||||||
@@ -118,7 +117,7 @@ class GiteaAdapter(BaseServiceAdapter):
|
|||||||
"""Ensures the repository exists under the organization."""
|
"""Ensures the repository exists under the organization."""
|
||||||
headers = self._get_headers(token)
|
headers = self._get_headers(token)
|
||||||
try:
|
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:
|
if r.status_code == 200:
|
||||||
return True
|
return True
|
||||||
if r.status_code == 404:
|
if r.status_code == 404:
|
||||||
@@ -129,13 +128,38 @@ class GiteaAdapter(BaseServiceAdapter):
|
|||||||
"auto_init": True,
|
"auto_init": True,
|
||||||
"default_branch": "main"
|
"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)
|
return cr.status_code in (200, 201)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[Gitea] Repo ensure error for {repo_name}: {e}")
|
print(f"[Gitea] Repo ensure error for {repo_name}: {e}")
|
||||||
return False
|
return False
|
||||||
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:
|
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."""
|
"""Commits or updates multiple files directly into the Gitea repository via Contents API."""
|
||||||
if not files_dict or not token:
|
if not files_dict or not token:
|
||||||
@@ -147,7 +171,7 @@ class GiteaAdapter(BaseServiceAdapter):
|
|||||||
try:
|
try:
|
||||||
# 1. Check if file already exists in repo to get current SHA
|
# 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}"
|
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
|
sha = None
|
||||||
if r.status_code == 200:
|
if r.status_code == 200:
|
||||||
sha = r.json().get("sha")
|
sha = r.json().get("sha")
|
||||||
@@ -161,11 +185,11 @@ class GiteaAdapter(BaseServiceAdapter):
|
|||||||
|
|
||||||
if sha:
|
if sha:
|
||||||
payload["sha"] = 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):
|
if put_res.status_code not in (200, 201):
|
||||||
print(f"[Gitea] Update notice for {path}: {put_res.status_code} {put_res.text[:100]}")
|
print(f"[Gitea] Update notice for {path}: {put_res.status_code} {put_res.text[:100]}")
|
||||||
else:
|
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):
|
if post_res.status_code not in (200, 201):
|
||||||
print(f"[Gitea] Create notice for {path}: {post_res.status_code} {post_res.text[:100]}")
|
print(f"[Gitea] Create notice for {path}: {post_res.status_code} {post_res.text[:100]}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -184,9 +208,11 @@ class GiteaAdapter(BaseServiceAdapter):
|
|||||||
outputs: Dict[str, str],
|
outputs: Dict[str, str],
|
||||||
provenance_runs: List[Dict[str, Any]],
|
provenance_runs: List[Dict[str, Any]],
|
||||||
existing_repo_url: Optional[str] = None,
|
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]:
|
) -> 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
|
# 1. Local disk directory
|
||||||
idea_dir = ARTIFACTS_DIR / idea_id
|
idea_dir = ARTIFACTS_DIR / idea_id
|
||||||
idea_dir.mkdir(parents=True, exist_ok=True)
|
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"
|
clone_url = f"{self.endpoint}/{self.org_name}/{repo_name}.git"
|
||||||
ssh_url = f"ssh://[email protected]:22/{self.org_name}/{repo_name}.git"
|
ssh_url = f"ssh://[email protected]:22/{self.org_name}/{repo_name}.git"
|
||||||
|
|
||||||
token = self.get_effective_token()
|
token = self.get_effective_token() if publish_remote else ""
|
||||||
if token:
|
if publish_remote:
|
||||||
|
if not token:
|
||||||
|
raise RuntimeError("Gitea publishing is not configured with a service token.")
|
||||||
loop = asyncio.get_running_loop()
|
loop = asyncio.get_running_loop()
|
||||||
def sync_gitea():
|
def sync_gitea():
|
||||||
try:
|
with requests.Session() as s:
|
||||||
with requests.Session() as s:
|
self._ensure_org(s, token)
|
||||||
# 1. Ensure org and repo exist
|
if not self._ensure_repo(s, token, repo_name, f"[{idea_id}] {title}"):
|
||||||
self._ensure_org(s, token)
|
raise RuntimeError(f"Could not create or access Gitea repository '{repo_name}'.")
|
||||||
self._ensure_repo(s, token, repo_name, f"[{idea_id}] {title}")
|
if collaborator_username:
|
||||||
# 2. Push full file tree to Gitea
|
self._grant_repo_write_access(
|
||||||
self._sync_files_via_api(token, repo_name, files_to_sync)
|
s, token, repo_name, collaborator_username
|
||||||
return f"{self.endpoint}/{self.org_name}/{repo_name}"
|
)
|
||||||
except Exception as e:
|
self._sync_files_via_api(token, repo_name, files_to_sync)
|
||||||
print(f"[Gitea] Sync error: {e}")
|
return f"{self.endpoint}/{self.org_name}/{repo_name}"
|
||||||
return repo_url
|
|
||||||
|
|
||||||
try:
|
actual_url = await loop.run_in_executor(None, sync_gitea)
|
||||||
actual_url = await loop.run_in_executor(None, sync_gitea)
|
repo_url = actual_url or repo_url
|
||||||
repo_url = actual_url or repo_url
|
|
||||||
except Exception as e:
|
|
||||||
print(f"[Gitea] Async sync exception: {e}")
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"repo_name": f"{self.org_name}/{repo_name}",
|
"repo_name": f"{self.org_name}/{repo_name}",
|
||||||
"repo_url": repo_url,
|
"repo_url": repo_url,
|
||||||
"clone_url": clone_url,
|
"clone_url": clone_url,
|
||||||
"ssh_url": ssh_url,
|
"ssh_url": ssh_url,
|
||||||
"local_path": str(idea_dir)
|
"local_path": str(idea_dir),
|
||||||
|
"published": publish_remote
|
||||||
}
|
}
|
||||||
|
|
||||||
async def persist_work_track_outputs(
|
async def persist_work_track_outputs(
|
||||||
self,
|
self,
|
||||||
idea_id: str,
|
idea_id: str,
|
||||||
track_name: str,
|
track_name: str,
|
||||||
outputs: Dict[str, str]
|
outputs: Dict[str, str],
|
||||||
|
publish_remote: bool = True
|
||||||
) -> Dict[str, Any]:
|
) -> 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
|
idea_dir = ARTIFACTS_DIR / idea_id
|
||||||
out_subdir = idea_dir / "outputs" / track_name.lower().replace(" ", "-")
|
out_subdir = idea_dir / "outputs" / track_name.lower().replace(" ", "-")
|
||||||
out_subdir.mkdir(parents=True, exist_ok=True)
|
out_subdir.mkdir(parents=True, exist_ok=True)
|
||||||
@@ -335,7 +361,7 @@ class GiteaAdapter(BaseServiceAdapter):
|
|||||||
repo_name = f"ts-{clean_slug.replace('ts-', '')}"
|
repo_name = f"ts-{clean_slug.replace('ts-', '')}"
|
||||||
repo_url = f"{self.endpoint}/{self.org_name}/{repo_name}"
|
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:
|
if token and files_to_sync:
|
||||||
loop = asyncio.get_running_loop()
|
loop = asyncio.get_running_loop()
|
||||||
try:
|
try:
|
||||||
@@ -348,31 +374,40 @@ class GiteaAdapter(BaseServiceAdapter):
|
|||||||
print(f"[Gitea] Work track sync notice: {e}")
|
print(f"[Gitea] Work track sync notice: {e}")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"status": "SUCCESS",
|
"status": "SUCCESS" if publish_remote else "LOCAL_ONLY",
|
||||||
"path": str(out_subdir),
|
"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'."""
|
"""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"
|
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}"
|
slug = repo_name or f"ts-{idea_id.lower()}-{clean_title}"
|
||||||
repo_url = f"{self.endpoint}/{self.org_name}/{slug}"
|
repo_url = f"{self.endpoint}/{self.org_name}/{slug}"
|
||||||
|
|
||||||
token = self.get_effective_token()
|
token = self.get_effective_token()
|
||||||
if token:
|
if not token:
|
||||||
loop = asyncio.get_running_loop()
|
raise RuntimeError("Gitea publishing is not configured with a service token.")
|
||||||
def create_remote():
|
loop = asyncio.get_running_loop()
|
||||||
try:
|
def create_remote():
|
||||||
with requests.Session() as s:
|
with requests.Session() as s:
|
||||||
self._ensure_org(s, token)
|
self._ensure_org(s, token)
|
||||||
self._ensure_repo(s, token, slug, f"[{idea_id}] {title}")
|
if not self._ensure_repo(s, token, slug, f"[{idea_id}] {title}"):
|
||||||
return f"{self.endpoint}/{self.org_name}/{slug}"
|
raise RuntimeError(f"Could not create or access Gitea repository '{slug}'.")
|
||||||
except Exception as e:
|
if collaborator_username:
|
||||||
print(f"[Gitea] Project graduation notice: {e}")
|
self._grant_repo_write_access(
|
||||||
return repo_url
|
s, token, slug, collaborator_username
|
||||||
actual_url = await loop.run_in_executor(None, create_remote)
|
)
|
||||||
repo_url = actual_url or repo_url
|
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 {
|
return {
|
||||||
"repo_name": f"{self.org_name}/{slug}",
|
"repo_name": f"{self.org_name}/{slug}",
|
||||||
|
|||||||
+114
-36
@@ -471,6 +471,7 @@ async function syncIdeaToOpenGist(ideaId) {
|
|||||||
|
|
||||||
async function syncIdeaToGitea(ideaId) {
|
async function syncIdeaToGitea(ideaId) {
|
||||||
const btn = document.getElementById('sync-gitea-btn');
|
const btn = document.getElementById('sync-gitea-btn');
|
||||||
|
const originalLabel = btn ? btn.innerText : '';
|
||||||
if (btn) btn.innerText = 'Publishing to Gitea...';
|
if (btn) btn.innerText = 'Publishing to Gitea...';
|
||||||
sessionStorage.setItem(`thinkstorm_active_tab_${window.location.pathname}`, 'tab-artifacts');
|
sessionStorage.setItem(`thinkstorm_active_tab_${window.location.pathname}`, 'tab-artifacts');
|
||||||
history.replaceState(null, null, '#artifacts');
|
history.replaceState(null, null, '#artifacts');
|
||||||
@@ -482,7 +483,7 @@ async function syncIdeaToGitea(ideaId) {
|
|||||||
setTimeout(() => window.location.reload(), 800);
|
setTimeout(() => window.location.reload(), 800);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showToast(err.message, 'danger');
|
showToast(err.message, 'danger');
|
||||||
if (btn) btn.innerText = '🔄 Publish / Re-sync to Gitea';
|
if (btn) btn.innerText = originalLabel;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -535,33 +536,51 @@ function initMarkdownViews(root = document) {
|
|||||||
if (container.dataset.initialized === 'true') return;
|
if (container.dataset.initialized === 'true') return;
|
||||||
container.dataset.initialized = 'true';
|
container.dataset.initialized = 'true';
|
||||||
|
|
||||||
const formattedBox = container.querySelector('.markdown-formatted');
|
// Check if this container has multi-version panes
|
||||||
const codeBox = container.querySelector('.markdown-code');
|
const versionPanes = container.querySelectorAll('.research-version-pane');
|
||||||
const rawSourceEl = container.querySelector('.raw-markdown-source');
|
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 || '') : '';
|
||||||
|
|
||||||
let rawText = '';
|
if (formattedBox && rawText) {
|
||||||
if (rawSourceEl) {
|
formattedBox.innerHTML = renderMarkdownContent(rawText);
|
||||||
rawText = rawSourceEl.value || rawSourceEl.textContent || '';
|
}
|
||||||
} else if (codeBox && codeBox.querySelector('code')) {
|
if (codeBox && (!codeBox.querySelector('code') || !codeBox.querySelector('code').textContent)) {
|
||||||
rawText = codeBox.querySelector('code').textContent || '';
|
codeBox.innerHTML = `<pre><code>${escapeHtml(rawText)}</code></pre>`;
|
||||||
} else if (container.dataset.content) {
|
}
|
||||||
rawText = container.dataset.content;
|
});
|
||||||
|
} 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render formatted markdown HTML
|
||||||
|
if (formattedBox && rawText) {
|
||||||
|
formattedBox.innerHTML = renderMarkdownContent(rawText);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render formatted markdown HTML
|
|
||||||
if (formattedBox && rawText) {
|
|
||||||
formattedBox.innerHTML = renderMarkdownContent(rawText);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
// Setup toggle buttons
|
||||||
const toggleBtns = container.querySelectorAll('.btn-toggle-view');
|
const toggleBtns = container.querySelectorAll('.btn-toggle-view');
|
||||||
toggleBtns.forEach(btn => {
|
toggleBtns.forEach(btn => {
|
||||||
@@ -587,10 +606,7 @@ function initMarkdownViews(root = document) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function setMarkdownContainerView(container, mode) {
|
function setMarkdownContainerView(container, mode) {
|
||||||
const formattedBox = container.querySelector('.markdown-formatted');
|
|
||||||
const codeBox = container.querySelector('.markdown-code');
|
|
||||||
const toggleBtns = container.querySelectorAll('.btn-toggle-view');
|
const toggleBtns = container.querySelectorAll('.btn-toggle-view');
|
||||||
|
|
||||||
toggleBtns.forEach(b => {
|
toggleBtns.forEach(b => {
|
||||||
if (b.getAttribute('data-view') === mode) {
|
if (b.getAttribute('data-view') === mode) {
|
||||||
b.classList.add('active');
|
b.classList.add('active');
|
||||||
@@ -599,21 +615,82 @@ function setMarkdownContainerView(container, mode) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (mode === 'code') {
|
const versionPanes = container.querySelectorAll('.research-version-pane');
|
||||||
if (formattedBox) formattedBox.style.display = 'none';
|
if (versionPanes.length > 0) {
|
||||||
if (codeBox) codeBox.style.display = 'block';
|
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 {
|
} else {
|
||||||
// Formatted by default
|
const formattedBox = container.querySelector('.markdown-formatted');
|
||||||
if (formattedBox) formattedBox.style.display = 'block';
|
const codeBox = container.querySelector('.markdown-code');
|
||||||
if (codeBox) codeBox.style.display = 'none';
|
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) {
|
async function copyMarkdownFromContainer(btn) {
|
||||||
const container = btn.closest('.markdown-view-container');
|
const container = btn.closest('.markdown-view-container');
|
||||||
if (!container) return;
|
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 : '');
|
const text = rawSourceEl ? (rawSourceEl.value || rawSourceEl.textContent) : (codeEl ? codeEl.textContent : '');
|
||||||
|
|
||||||
if (!text) {
|
if (!text) {
|
||||||
@@ -660,4 +737,5 @@ function escapeHtml(text) {
|
|||||||
window.renderMarkdownContent = renderMarkdownContent;
|
window.renderMarkdownContent = renderMarkdownContent;
|
||||||
window.initMarkdownViews = initMarkdownViews;
|
window.initMarkdownViews = initMarkdownViews;
|
||||||
window.setMarkdownContainerView = setMarkdownContainerView;
|
window.setMarkdownContainerView = setMarkdownContainerView;
|
||||||
|
window.switchResearchVersion = switchResearchVersion;
|
||||||
window.copyMarkdownFromContainer = copyMarkdownFromContainer;
|
window.copyMarkdownFromContainer = copyMarkdownFromContainer;
|
||||||
|
|||||||
@@ -60,6 +60,6 @@
|
|||||||
|
|
||||||
<script src="/static/js/marked.min.js"></script>
|
<script src="/static/js/marked.min.js"></script>
|
||||||
<script src="/static/js/purify.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>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -278,17 +278,37 @@
|
|||||||
<!-- Tab 0: Image Context (if present) -->
|
<!-- Tab 0: Image Context (if present) -->
|
||||||
{% if idea.submission_image and idea.submission_image.present %}
|
{% if idea.submission_image and idea.submission_image.present %}
|
||||||
<div id="tab-image-context" class="tab-panel">
|
<div id="tab-image-context" class="tab-panel">
|
||||||
{% set image_ctx_run = idea.provenance | selectattr("stage", "equalto", "IMAGE_CONTEXT") | list %}
|
{% set stage_data = idea.research_stages.IMAGE_CONTEXT if idea.research_stages else None %}
|
||||||
{% if image_ctx_run and image_ctx_run|length > 0 and image_ctx_run[0].output_data.content %}
|
{% 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-container" data-markdown-view>
|
||||||
<div class="markdown-view-header">
|
<div class="markdown-view-header" style="flex-wrap:wrap; gap:0.75rem;">
|
||||||
<div class="markdown-view-meta">
|
<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(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>
|
<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 %}
|
{% 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>
|
||||||
|
|
||||||
<div class="markdown-view-actions">
|
<div class="markdown-view-actions">
|
||||||
<div class="markdown-toggle-pill">
|
<div class="markdown-toggle-pill">
|
||||||
<button type="button" class="btn-toggle-view active" data-view="formatted" title="Formatted View">
|
<button type="button" class="btn-toggle-view active" data-view="formatted" title="Formatted View">
|
||||||
@@ -303,21 +323,26 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="markdown-view-body">
|
<div class="markdown-view-body">
|
||||||
<div class="markdown-formatted markdown-body"></div>
|
{% for v in stage_data.versions %}
|
||||||
<div class="markdown-code" style="display:none;">
|
<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 %}>
|
||||||
<pre><code>{{ image_ctx_run[0].output_data.content }}</code></pre>
|
<div class="markdown-formatted markdown-body"></div>
|
||||||
</div>
|
<div class="markdown-code" style="display:none;">
|
||||||
<textarea class="raw-markdown-source" style="display:none;">{{ image_ctx_run[0].output_data.content }}</textarea>
|
<pre><code>{{ v.content }}</code></pre>
|
||||||
|
</div>
|
||||||
|
<textarea class="raw-markdown-source" style="display:none;">{{ v.content }}</textarea>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
</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 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>
|
<div style="font-size:2.2rem; margin-bottom:0.75rem;">⚠️</div>
|
||||||
<h4 style="color:#f87171; margin-bottom:0.5rem;">Vision Processing Notice</h4>
|
<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;">
|
<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:
|
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.
|
The idea continues with text-only research synthesis.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -335,16 +360,35 @@
|
|||||||
|
|
||||||
<!-- Tab 1: Prior Art -->
|
<!-- Tab 1: Prior Art -->
|
||||||
<div id="tab-prior-art" class="tab-panel active">
|
<div id="tab-prior-art" class="tab-panel active">
|
||||||
{% set prior_art_run = idea.provenance | selectattr("stage", "equalto", "PRIOR_ART") | list %}
|
{% set stage_data = idea.research_stages.PRIOR_ART if idea.research_stages else None %}
|
||||||
{% if prior_art_run and prior_art_run|length > 0 and prior_art_run[0].output_data.content %}
|
{% if stage_data and stage_data.total_versions > 0 %}
|
||||||
<div class="markdown-view-container" data-markdown-view>
|
<div class="markdown-view-container" data-markdown-view>
|
||||||
<div class="markdown-view-header">
|
<div class="markdown-view-header" style="flex-wrap:wrap; gap:0.75rem;">
|
||||||
<div class="markdown-view-meta">
|
<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>
|
<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 %}
|
{% 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>
|
||||||
|
|
||||||
<div class="markdown-view-actions">
|
<div class="markdown-view-actions">
|
||||||
<div class="markdown-toggle-pill">
|
<div class="markdown-toggle-pill">
|
||||||
<button type="button" class="btn-toggle-view active" data-view="formatted" title="Formatted View">
|
<button type="button" class="btn-toggle-view active" data-view="formatted" title="Formatted View">
|
||||||
@@ -359,12 +403,17 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="markdown-view-body">
|
<div class="markdown-view-body">
|
||||||
<div class="markdown-formatted markdown-body"></div>
|
{% for v in stage_data.versions %}
|
||||||
<div class="markdown-code" style="display:none;">
|
<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 %}>
|
||||||
<pre><code>{{ prior_art_run[0].output_data.content }}</code></pre>
|
<div class="markdown-formatted markdown-body"></div>
|
||||||
</div>
|
<div class="markdown-code" style="display:none;">
|
||||||
<textarea class="raw-markdown-source" style="display:none;">{{ prior_art_run[0].output_data.content }}</textarea>
|
<pre><code>{{ v.content }}</code></pre>
|
||||||
|
</div>
|
||||||
|
<textarea class="raw-markdown-source" style="display:none;">{{ v.content }}</textarea>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
@@ -383,16 +432,35 @@
|
|||||||
|
|
||||||
<!-- Tab 2: Research Synthesis -->
|
<!-- Tab 2: Research Synthesis -->
|
||||||
<div id="tab-research" class="tab-panel">
|
<div id="tab-research" class="tab-panel">
|
||||||
{% set research_run = idea.provenance | selectattr("stage", "equalto", "RESEARCH") | list %}
|
{% set stage_data = idea.research_stages.RESEARCH if idea.research_stages else None %}
|
||||||
{% if research_run and research_run|length > 0 and research_run[0].output_data.content %}
|
{% if stage_data and stage_data.total_versions > 0 %}
|
||||||
<div class="markdown-view-container" data-markdown-view>
|
<div class="markdown-view-container" data-markdown-view>
|
||||||
<div class="markdown-view-header">
|
<div class="markdown-view-header" style="flex-wrap:wrap; gap:0.75rem;">
|
||||||
<div class="markdown-view-meta">
|
<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>
|
<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 %}
|
{% 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>
|
||||||
|
|
||||||
<div class="markdown-view-actions">
|
<div class="markdown-view-actions">
|
||||||
<div class="markdown-toggle-pill">
|
<div class="markdown-toggle-pill">
|
||||||
<button type="button" class="btn-toggle-view active" data-view="formatted" title="Formatted View">
|
<button type="button" class="btn-toggle-view active" data-view="formatted" title="Formatted View">
|
||||||
@@ -407,12 +475,17 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="markdown-view-body">
|
<div class="markdown-view-body">
|
||||||
<div class="markdown-formatted markdown-body"></div>
|
{% for v in stage_data.versions %}
|
||||||
<div class="markdown-code" style="display:none;">
|
<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 %}>
|
||||||
<pre><code>{{ research_run[0].output_data.content }}</code></pre>
|
<div class="markdown-formatted markdown-body"></div>
|
||||||
</div>
|
<div class="markdown-code" style="display:none;">
|
||||||
<textarea class="raw-markdown-source" style="display:none;">{{ research_run[0].output_data.content }}</textarea>
|
<pre><code>{{ v.content }}</code></pre>
|
||||||
|
</div>
|
||||||
|
<textarea class="raw-markdown-source" style="display:none;">{{ v.content }}</textarea>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
@@ -431,16 +504,35 @@
|
|||||||
|
|
||||||
<!-- Tab 3: Feasibility & Critique -->
|
<!-- Tab 3: Feasibility & Critique -->
|
||||||
<div id="tab-feasibility" class="tab-panel">
|
<div id="tab-feasibility" class="tab-panel">
|
||||||
{% set feas_run = idea.provenance | selectattr("stage", "equalto", "FEASIBILITY") | list %}
|
{% set stage_data = idea.research_stages.FEASIBILITY if idea.research_stages else None %}
|
||||||
{% if feas_run and feas_run|length > 0 and feas_run[0].output_data.content %}
|
{% if stage_data and stage_data.total_versions > 0 %}
|
||||||
<div class="markdown-view-container" data-markdown-view>
|
<div class="markdown-view-container" data-markdown-view>
|
||||||
<div class="markdown-view-header">
|
<div class="markdown-view-header" style="flex-wrap:wrap; gap:0.75rem;">
|
||||||
<div class="markdown-view-meta">
|
<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>
|
<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 %}
|
{% 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>
|
||||||
|
|
||||||
<div class="markdown-view-actions">
|
<div class="markdown-view-actions">
|
||||||
<div class="markdown-toggle-pill">
|
<div class="markdown-toggle-pill">
|
||||||
<button type="button" class="btn-toggle-view active" data-view="formatted" title="Formatted View">
|
<button type="button" class="btn-toggle-view active" data-view="formatted" title="Formatted View">
|
||||||
@@ -455,12 +547,17 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="markdown-view-body">
|
<div class="markdown-view-body">
|
||||||
<div class="markdown-formatted markdown-body"></div>
|
{% for v in stage_data.versions %}
|
||||||
<div class="markdown-code" style="display:none;">
|
<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 %}>
|
||||||
<pre><code>{{ feas_run[0].output_data.content }}</code></pre>
|
<div class="markdown-formatted markdown-body"></div>
|
||||||
</div>
|
<div class="markdown-code" style="display:none;">
|
||||||
<textarea class="raw-markdown-source" style="display:none;">{{ feas_run[0].output_data.content }}</textarea>
|
<pre><code>{{ v.content }}</code></pre>
|
||||||
|
</div>
|
||||||
|
<textarea class="raw-markdown-source" style="display:none;">{{ v.content }}</textarea>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
@@ -537,7 +634,7 @@
|
|||||||
<button onclick="activateWorkTrack('{{ tr.id }}')" class="btn btn-primary btn-sm">
|
<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 %}
|
{% if tr.outputs and tr.outputs|length > 0 %}🔄 Re-run Workflow{% else %}⚡ Run Workflow{% endif %}
|
||||||
</button>
|
</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>
|
<button onclick="graduateToGitea('{{ tr.id }}')" class="btn btn-success btn-sm">📦 Graduate to Gitea</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% 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 style="display:flex; justify-content:space-between; align-items:center; margin-bottom:1rem; flex-wrap:wrap; gap:0.5rem;">
|
||||||
<div>
|
<div>
|
||||||
<h3 style="font-size:1.1rem; margin-bottom:0.25rem;">Canonical Dossier & Gitea Project</h3>
|
<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>
|
||||||
<div>
|
<div>
|
||||||
{% if idea.gitea_repo_url %}
|
{% 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>
|
<span class="badge" style="background:rgba(99,102,241,0.2); color:#a5b4fc; font-size:0.75rem;">org: thinkstorm</span>
|
||||||
</div>
|
</div>
|
||||||
<div style="font-family:var(--font-mono); font-size:0.85rem; color:var(--text-muted);">
|
<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>
|
</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);">
|
<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>
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div style="display:flex; gap:0.5rem; flex-wrap:wrap; align-items:center;">
|
<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">
|
<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>
|
</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 %}
|
{% if idea.gitea_repo_url %}
|
||||||
<a href="{{ idea.gitea_repo_url }}" target="_blank" class="btn btn-secondary btn-sm">
|
<a href="{{ idea.gitea_repo_url }}" target="_blank" class="btn btn-secondary btn-sm">
|
||||||
Open in Gitea ↗
|
Open in Gitea ↗
|
||||||
</a>
|
</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 %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user