From 1aef82ec4045442ac2766cd711a3f747765f4f54 Mon Sep 17 00:00:00 2001 From: team3 Date: Thu, 4 Jun 2026 15:24:33 +0200 Subject: [PATCH] update --- .env copy.example | 7 ++ .env.example | 3 - Dockerfile | 4 +- Makefile | 17 ++- backend/agents.py | 139 ++++++++++++++++++++++ backend/config.py | 25 +++- backend/generator.py | 93 ++++----------- backend/models.py | 14 +++ backend/routes.py | 29 +++-- dev-ops/opencode.json | 77 ++++++++++++ docker-compose.yml | 1 + frontend/src/App.vue | 36 +++++- frontend/src/api.js | 37 +++--- frontend/src/components/BausteineView.vue | 9 +- frontend/src/components/HelpChat.vue | 6 +- frontend/src/components/TopicDetail.vue | 2 + frontend/src/components/TopicSidebar.vue | 58 ++++++++- 17 files changed, 440 insertions(+), 117 deletions(-) create mode 100644 .env copy.example delete mode 100644 .env.example create mode 100644 backend/agents.py create mode 100644 dev-ops/opencode.json diff --git a/.env copy.example b/.env copy.example new file mode 100644 index 0000000..8147a44 --- /dev/null +++ b/.env copy.example @@ -0,0 +1,7 @@ +# Datei nach .env kopieren (wird nicht committet). + +# Claude-Provider: lokal einmal 'claude setup-token' ausführen, Token eintragen. +CLAUDE_CODE_OAUTH_TOKEN= + +# MiniMax-Provider: API-Key aus der MiniMax-Console (Coding-Plan). +MINIMAX_API_KEY= diff --git a/.env.example b/.env.example deleted file mode 100644 index 083b8c0..0000000 --- a/.env.example +++ /dev/null @@ -1,3 +0,0 @@ -# Lokal einmal 'claude setup-token' ausführen und Token hier eintragen. -# Datei nach .env kopieren (wird nicht committet). -CLAUDE_CODE_OAUTH_TOKEN= diff --git a/Dockerfile b/Dockerfile index 86f1c32..f7e2f5c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -23,7 +23,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ gnupg \ && curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \ && apt-get install -y nodejs \ - && npm install -g @anthropic-ai/claude-code \ + && npm install -g @anthropic-ai/claude-code opencode-ai \ + && pip install --no-cache-dir uv \ && rm -rf /var/lib/apt/lists/* RUN useradd -m -u 1000 app @@ -34,6 +35,7 @@ RUN pip install --no-cache-dir -r /app/backend/requirements.txt COPY --chown=app:app backend/ /app/backend/ COPY --chown=app:app templates/ /app/templates/ COPY --chown=app:app --from=frontend /build/dist /app/frontend/dist +COPY --chown=app:app dev-ops/opencode.json /home/app/.config/opencode/opencode.json RUN chown app:app /app diff --git a/Makefile b/Makefile index 336f407..5911720 100644 --- a/Makefile +++ b/Makefile @@ -6,18 +6,29 @@ auth: @mkdir -p .claude-data storage projects @chown -R 1000:1000 .claude-data storage projects @grep -q "^CLAUDE_CODE_OAUTH_TOKEN=.\+" .env 2>/dev/null \ - || echo "WARNUNG: CLAUDE_CODE_OAUTH_TOKEN fehlt in .env. Lokal 'claude setup-token' ausführen und Token eintragen." + || echo "WARNUNG: CLAUDE_CODE_OAUTH_TOKEN fehlt in .env (Claude-Provider inaktiv)." + @grep -q "^MINIMAX_API_KEY=.\+" .env 2>/dev/null \ + || echo "WARNUNG: MINIMAX_API_KEY fehlt in .env (MiniMax-Provider inaktiv)." @echo "Verzeichnisse angelegt und auf uid 1000 chowned." install: sudo apt install -y poppler-utils libpango-1.0-0 libcairo2 libgdk-pixbuf-2.0-0 libffi-dev - pip install --break-system-packages fastapi uvicorn[standard] aiosqlite weasyprint pdf2image + pip install --break-system-packages fastapi uvicorn[standard] aiosqlite weasyprint pdf2image uv cd frontend && npm install + npm install -g opencode-ai + @mkdir -p $(HOME)/.config/opencode + @ln -sfn $(CURDIR)/dev-ops/opencode.json $(HOME)/.config/opencode/opencode.json + @if grep -q "^MINIMAX_API_KEY=.\+" .env 2>/dev/null; then \ + echo "OpenCode-Config verlinkt. MINIMAX_API_KEY aus .env wird von 'make dev' geladen."; \ + else \ + echo "OpenCode-Config verlinkt. MINIMAX_API_KEY noch in .env eintragen."; \ + fi dev: @echo "Backend: http://localhost:8000" @echo "Frontend: http://localhost:5173" - @cd backend && uvicorn main:app --reload --port 8000 & + @set -a; [ -f .env ] && . ./.env; set +a; \ + cd backend && uvicorn main:app --reload --port 8000 & @cd frontend && npx vite --port 5173 prod: auth diff --git a/backend/agents.py b/backend/agents.py new file mode 100644 index 0000000..411893c --- /dev/null +++ b/backend/agents.py @@ -0,0 +1,139 @@ +"""Provider-Schicht: führt Agent-Aufrufe über die Claude-CLI oder OpenCode (MiniMax) aus. + +Beide Runner sind unabhängig. Fehlt ein Binary/Key, schlägt nur der +jeweilige Provider fehl — der andere läuft unverändert weiter. +""" + +import asyncio +import os +import re +import shutil +import tempfile +from pathlib import Path + +from config import PROVIDERS, DEFAULT_PROVIDER + +_active_processes: dict[str, asyncio.subprocess.Process] = {} + +# Capability → Claude --allowedTools +_CLAUDE_TOOLS = { + "full": "Write,Bash,Read,WebSearch,WebFetch", + "files": "Read,Bash,Write", + "read": "Read", + "none": None, +} + +# Capability → OpenCode-Agent (Tool-Rechte in dev-ops/opencode.json definiert) +_OPENCODE_AGENTS = { + "full": "full", + "files": "files", + "read": "readonly", + "none": "text", +} + + +def provider_available(provider: str) -> bool: + cfg = PROVIDERS.get(provider) + if not cfg: + return False + if shutil.which(cfg["cli"]) is None: + return False + env_key = cfg.get("env_key") + if env_key and not os.environ.get(env_key): + return False + return True + + +def kill_process(agent_key: str) -> None: + process = _active_processes.get(agent_key) + if process and process.returncode is None: + process.kill() + + +async def run_agent( + agent_key: str, + prompt: str, + timeout: int, + provider: str = DEFAULT_PROVIDER, + role: str = "fast", + capabilities: str = "none", +) -> tuple[int, str, str]: + if provider not in PROVIDERS: + return 1, "", f"Unbekannter Provider: {provider}" + if shutil.which(PROVIDERS[provider]["cli"]) is None: + return 1, "", f"CLI '{PROVIDERS[provider]['cli']}' nicht installiert (Provider: {provider})" + timeout = int(timeout * PROVIDERS[provider].get("timeout_factor", 1)) + if provider == "minimax": + return await _run_opencode(agent_key, prompt, timeout, role, capabilities) + return await _run_claude_cli(agent_key, prompt, timeout, role, capabilities) + + +async def _communicate(agent_key: str, cmd: list[str], stdin_data: bytes | None, timeout: int) -> tuple[int, str, str]: + process = await asyncio.create_subprocess_exec( + *cmd, + stdin=asyncio.subprocess.PIPE if stdin_data is not None else asyncio.subprocess.DEVNULL, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + _active_processes[agent_key] = process + try: + try: + stdout, stderr = await asyncio.wait_for( + process.communicate(input=stdin_data), + timeout=timeout, + ) + except asyncio.TimeoutError: + process.kill() + try: + await asyncio.wait_for(process.wait(), timeout=5) + except asyncio.TimeoutError: + pass + raise + return process.returncode, stdout.decode("utf-8", errors="replace"), stderr.decode("utf-8", errors="replace") + finally: + _active_processes.pop(agent_key, None) + + +async def _run_claude_cli(agent_key: str, prompt: str, timeout: int, role: str, capabilities: str) -> tuple[int, str, str]: + cfg = PROVIDERS["claude"] + cmd = [cfg["cli"], "-p", "--model", cfg[role]] + tools = _CLAUDE_TOOLS.get(capabilities) + if tools: + cmd += ["--allowedTools", tools] + cmd += ["--dangerously-skip-permissions"] + return await _communicate(agent_key, cmd, prompt.encode("utf-8"), timeout) + + +async def _run_opencode(agent_key: str, prompt: str, timeout: int, role: str, capabilities: str) -> tuple[int, str, str]: + cfg = PROVIDERS["minimax"] + # Prompt über Tempdatei statt argv (ARG_MAX-Schutz bei großen Projekt-Prompts) + with tempfile.NamedTemporaryFile("w", suffix=".md", delete=False, encoding="utf-8", dir=tempfile.gettempdir()) as f: + f.write(prompt) + prompt_path = Path(f.name) + # Positional-Message MUSS vor -f stehen: -f ist ein Array-Flag und + # frisst sonst den Text als zweiten Dateinamen ("File not found"). + cmd = [ + cfg["cli"], "run", + "Folge exakt den Anweisungen in der angehängten Datei. Sie sind der vollständige Auftrag.", + "-m", cfg[role], + "--agent", _OPENCODE_AGENTS.get(capabilities, "text"), + "--dangerously-skip-permissions", + "-f", str(prompt_path), + ] + try: + rc, stdout, stderr = await _communicate(agent_key, cmd, None, timeout) + return rc, _clean_opencode_output(stdout), stderr + finally: + prompt_path.unlink(missing_ok=True) + + +_ANSI_RE = re.compile(r"\x1b\[[0-9;]*m") + + +def _clean_opencode_output(text: str) -> str: + """Entfernt ANSI-Codes und den führenden Banner ("> agent · modell").""" + text = _ANSI_RE.sub("", text) + lines = text.splitlines() + while lines and (not lines[0].strip() or lines[0].lstrip().startswith(">")): + lines.pop(0) + return "\n".join(lines).strip() diff --git a/backend/config.py b/backend/config.py index ae1fe4a..92b6278 100644 --- a/backend/config.py +++ b/backend/config.py @@ -27,10 +27,23 @@ FORMAT_META = { AGENT_TIMEOUT = 3600 MAX_CONCURRENT_GENERATIONS = 6 -CLAUDE_CLI = "claude" -MODEL_GUIDE = "claude-opus-4-8[1m]" -MODEL_BAUSTEIN_GEN = "claude-sonnet-4-6" -MODEL_BAUSTEIN_REWORK = "claude-sonnet-4-6" -MODEL_CHAT = "claude-sonnet-4-6" -MODEL_PROJECT_INDEX = MODEL_BAUSTEIN_GEN +# Provider-Stacks: komplett unabhängig, einer kann jederzeit entfernt werden. +# Rollen: "guide" = große Generierung/Review, "fast" = Bausteine/Chat/Sortierung. +DEFAULT_PROVIDER = "claude" +PROVIDERS = { + "claude": { + "cli": "claude", + "guide": "claude-opus-4-8[1m]", + "fast": "claude-sonnet-4-6", + "env_key": None, # Auth via CLAUDE_CODE_OAUTH_TOKEN oder ~/.claude + "timeout_factor": 1, + }, + "minimax": { + "cli": "opencode", + "guide": "minimax/MiniMax-M3", + "fast": "minimax/MiniMax-M3", + "env_key": "MINIMAX_API_KEY", + "timeout_factor": 3, # M3 ist bei großen Dokumenten deutlich langsamer + }, +} diff --git a/backend/generator.py b/backend/generator.py index 9ad7358..0e7c83f 100644 --- a/backend/generator.py +++ b/backend/generator.py @@ -8,16 +8,12 @@ import uuid from datetime import datetime, timezone from pathlib import Path +from agents import run_agent, kill_process from config import ( AGENT_TIMEOUT, - CLAUDE_CLI, + DEFAULT_PROVIDER, TEMPLATES_DIR, MAX_CONCURRENT_GENERATIONS, - MODEL_GUIDE, - MODEL_BAUSTEIN_GEN, - MODEL_BAUSTEIN_REWORK, - MODEL_CHAT, - MODEL_PROJECT_INDEX, STORAGE_DIR, ) from database import ( @@ -32,15 +28,12 @@ from database import ( from paths import final_paths, temp_paths, project_dir, project_cache_path _semaphore = asyncio.Semaphore(MAX_CONCURRENT_GENERATIONS) -_active_processes: dict[str, asyncio.subprocess.Process] = {} _cancelled: set[str] = set() async def cancel_guide(guide_id: str) -> bool: _cancelled.add(guide_id) - process = _active_processes.get(guide_id) - if process and process.returncode is None: - process.kill() + kill_process(guide_id) now = datetime.now(timezone.utc).isoformat() await update_guide(guide_id, status="error", progress=None, error_msg="Abgebrochen", updated_at=now) return True @@ -51,38 +44,6 @@ async def _set_progress(guide_id: str, progress: str) -> None: await update_guide(guide_id, progress=progress, updated_at=now) -async def _run_claude(guide_id: str, prompt: str, timeout: int, tools: str | None = "Write,Bash,Read,WebSearch,WebFetch", model: str | None = None) -> tuple[int, str, str]: - cmd = [CLAUDE_CLI, "-p"] - if model: - cmd += ["--model", model] - if tools: - cmd += ["--allowedTools", tools] - cmd += ["--dangerously-skip-permissions"] - process = await asyncio.create_subprocess_exec( - *cmd, - stdin=asyncio.subprocess.PIPE, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - ) - _active_processes[guide_id] = process - try: - try: - stdout, stderr = await asyncio.wait_for( - process.communicate(input=prompt.encode("utf-8")), - timeout=timeout, - ) - except asyncio.TimeoutError: - process.kill() - try: - await asyncio.wait_for(process.wait(), timeout=5) - except asyncio.TimeoutError: - pass - raise - return process.returncode, stdout.decode("utf-8", errors="replace"), stderr.decode("utf-8", errors="replace") - finally: - _active_processes.pop(guide_id, None) - - async def _render_pdf(html_path: Path, pdf_path: Path) -> tuple[bool, str]: proc = await asyncio.create_subprocess_exec( "weasyprint", str(html_path), str(pdf_path), @@ -225,7 +186,7 @@ FAIL """ -async def generate_guide(guide_id: str, topic: str, format_name: str, instructions: str = "", reindex: bool = False) -> None: +async def generate_guide(guide_id: str, topic: str, format_name: str, instructions: str = "", reindex: bool = False, provider: str = DEFAULT_PROVIDER) -> None: async with _semaphore: now = datetime.now(timezone.utc).isoformat() await update_guide(guide_id, status="generating", progress="Recherche…", updated_at=now) @@ -247,9 +208,9 @@ async def generate_guide(guide_id: str, topic: str, format_name: str, instructio await _set_progress(guide_id, "Lese Projekt…") current_step = "Projekt-Einlesen" index_prompt = _build_project_index_prompt(topic, cache_path, cache_path.exists()) - returncode, idx_out, idx_err = await _run_claude( + returncode, idx_out, idx_err = await run_agent( guide_id, index_prompt, AGENT_TIMEOUT, - tools="Read,Bash,Write", model=MODEL_PROJECT_INDEX, + provider=provider, role="fast", capabilities="files", ) if guide_id in _cancelled: return @@ -264,7 +225,7 @@ async def generate_guide(guide_id: str, topic: str, format_name: str, instructio # Step 1: Generator-Agent erstellt HTML await _set_progress(guide_id, "Generiere HTML…") gen_prompt = _build_generator_prompt(topic, format_name, html_path, instructions, project_content) - returncode, stdout, stderr = await _run_claude(guide_id, gen_prompt, AGENT_TIMEOUT, model=MODEL_GUIDE) + returncode, stdout, stderr = await run_agent(guide_id, gen_prompt, AGENT_TIMEOUT, provider=provider, role="guide", capabilities="full") if guide_id in _cancelled: return @@ -284,7 +245,7 @@ async def generate_guide(guide_id: str, topic: str, format_name: str, instructio current_step = "Inhalts-Review" current_timeout = AGENT_TIMEOUT content_prompt = _build_content_review_prompt(topic, format_name, html_path, project_content) - returncode, review_out, review_err = await _run_claude(guide_id, content_prompt, AGENT_TIMEOUT, model=MODEL_GUIDE) + returncode, review_out, review_err = await run_agent(guide_id, content_prompt, AGENT_TIMEOUT, provider=provider, role="guide", capabilities="full") if returncode != 0: await _fail(guide_id, _claude_error("Inhalts-Review-Fehler", returncode, review_out, review_err)) @@ -300,7 +261,7 @@ async def generate_guide(guide_id: str, topic: str, format_name: str, instructio current_step = "Inhalts-Korrektur" current_timeout = AGENT_TIMEOUT fix_prompt = _build_fix_prompt(topic, format_name, html_path, feedback) - returncode, fix_out, fix_err = await _run_claude(guide_id, fix_prompt, AGENT_TIMEOUT, model=MODEL_GUIDE) + returncode, fix_out, fix_err = await run_agent(guide_id, fix_prompt, AGENT_TIMEOUT, provider=provider, role="guide", capabilities="full") if returncode != 0: await _fail(guide_id, _claude_error("Fix-Fehler", returncode, fix_out, fix_err)) @@ -326,11 +287,10 @@ async def generate_guide(guide_id: str, topic: str, format_name: str, instructio except Exception as e: await _fail(guide_id, str(e)[:2000]) finally: - _active_processes.pop(guide_id, None) _cancelled.discard(guide_id) -async def rework_guide(guide_id: str, topic: str, format_name: str, instructions: str) -> None: +async def rework_guide(guide_id: str, topic: str, format_name: str, instructions: str, provider: str = DEFAULT_PROVIDER) -> None: async with _semaphore: now = datetime.now(timezone.utc).isoformat() await update_guide(guide_id, status="generating", progress="Überarbeite…", updated_at=now) @@ -352,7 +312,7 @@ async def rework_guide(guide_id: str, topic: str, format_name: str, instructions current_timeout = AGENT_TIMEOUT rework_prompt = _build_rework_prompt(topic, format_name, tmp_html, instructions) - returncode, stdout, stderr = await _run_claude(guide_id, rework_prompt, AGENT_TIMEOUT, model=MODEL_GUIDE) + returncode, stdout, stderr = await run_agent(guide_id, rework_prompt, AGENT_TIMEOUT, provider=provider, role="guide", capabilities="full") if guide_id in _cancelled: return @@ -384,7 +344,6 @@ async def rework_guide(guide_id: str, topic: str, format_name: str, instructions except Exception as e: await _fail(guide_id, str(e)[:2000]) finally: - _active_processes.pop(guide_id, None) _cancelled.discard(guide_id) tmp_html.unlink(missing_ok=True) tmp_pdf.unlink(missing_ok=True) @@ -480,7 +439,7 @@ Orientiere dich an der Spezifikation und Referenz. Kein weiterer Text, nur das J """ -async def generate_suggestions(topic: str, html_paths: list[Path]) -> None: +async def generate_suggestions(topic: str, html_paths: list[Path], provider: str = DEFAULT_PROVIDER) -> None: _suggestions_generating.add(topic) try: existing = await list_bausteine(topic) @@ -489,8 +448,8 @@ async def generate_suggestions(topic: str, html_paths: list[Path]) -> None: await delete_pending_suggestions(topic) prompt = _build_suggestions_prompt(topic, html_paths, existing_titles) - tools = "Read" if html_paths else None - returncode, stdout, stderr = await _run_claude("suggestions-" + topic, prompt, 1800, tools=tools, model=MODEL_BAUSTEIN_GEN) + capabilities = "read" if html_paths else "none" + returncode, stdout, stderr = await run_agent("suggestions-" + topic, prompt, 1800, provider=provider, role="fast", capabilities=capabilities) if returncode != 0: return @@ -520,10 +479,10 @@ async def generate_suggestions(topic: str, html_paths: list[Path]) -> None: _suggestions_generating.discard(topic) -async def generate_baustein_detail(baustein_id: str, topic: str, title: str, instructions: str = "") -> None: +async def generate_baustein_detail(baustein_id: str, topic: str, title: str, instructions: str = "", provider: str = DEFAULT_PROVIDER) -> None: try: prompt = _build_baustein_detail_prompt(topic, title, instructions) - returncode, stdout, stderr = await _run_claude("baustein-" + baustein_id, prompt, 60, tools=None, model=MODEL_BAUSTEIN_GEN) + returncode, stdout, stderr = await run_agent("baustein-" + baustein_id, prompt, 180, provider=provider, role="fast", capabilities="none") if returncode != 0: return @@ -544,10 +503,10 @@ async def generate_baustein_detail(baustein_id: str, topic: str, title: str, ins pass -async def rework_baustein(baustein_id: str, topic: str, title: str, current: dict, instructions: str) -> None: +async def rework_baustein(baustein_id: str, topic: str, title: str, current: dict, instructions: str, provider: str = DEFAULT_PROVIDER) -> None: try: prompt = _build_baustein_rework_prompt(topic, title, current, instructions) - returncode, stdout, stderr = await _run_claude("baustein-" + baustein_id, prompt, 60, tools=None, model=MODEL_BAUSTEIN_REWORK) + returncode, stdout, stderr = await run_agent("baustein-" + baustein_id, prompt, 180, provider=provider, role="fast", capabilities="none") if returncode != 0: return @@ -654,11 +613,11 @@ WICHTIG – Antwortstil: Gib NUR die Antwort aus, kein Präfix wie "Assistent:".""" -async def chat_with_guide(topic: str, format_name: str, section: str, outline: str, messages: list[dict]) -> str: +async def chat_with_guide(topic: str, format_name: str, section: str, outline: str, messages: list[dict], provider: str = DEFAULT_PROVIDER) -> str: try: prompt = _build_guide_chat_prompt(topic, format_name, section, outline, messages) - returncode, stdout, stderr = await _run_claude( - "chat-" + str(uuid.uuid4()), prompt, 120, tools=None, model=MODEL_CHAT + returncode, stdout, stderr = await run_agent( + "chat-" + str(uuid.uuid4()), prompt, 240, provider=provider, role="fast", capabilities="none" ) if returncode != 0: return "Entschuldigung, das hat nicht geklappt. Bitte versuche es erneut." @@ -668,11 +627,11 @@ async def chat_with_guide(topic: str, format_name: str, section: str, outline: s return "Entschuldigung, das hat nicht geklappt. Bitte versuche es erneut." -async def suggest_topics(problem: str, existing_topics: list[str] | None = None) -> list[dict]: +async def suggest_topics(problem: str, existing_topics: list[str] | None = None, provider: str = DEFAULT_PROVIDER) -> list[dict]: try: prompt = _build_topic_suggest_prompt(problem, existing_topics or []) - returncode, stdout, stderr = await _run_claude( - "topic-suggest-" + str(uuid.uuid4()), prompt, 120, tools=None, model=MODEL_BAUSTEIN_GEN + returncode, stdout, stderr = await run_agent( + "topic-suggest-" + str(uuid.uuid4()), prompt, 240, provider=provider, role="fast", capabilities="none" ) if returncode != 0: return [] @@ -692,11 +651,11 @@ async def suggest_topics(problem: str, existing_topics: list[str] | None = None) return [] -async def sort_bausteine(topic: str, bausteine: list[dict], instructions: str = "") -> None: +async def sort_bausteine(topic: str, bausteine: list[dict], instructions: str = "", provider: str = DEFAULT_PROVIDER) -> None: _sorting.add(topic) try: prompt = _build_sort_prompt(topic, bausteine, instructions) - returncode, stdout, stderr = await _run_claude("sort-" + topic, prompt, 300, tools=None, model=MODEL_BAUSTEIN_GEN) + returncode, stdout, stderr = await run_agent("sort-" + topic, prompt, 600, provider=provider, role="fast", capabilities="none") if returncode != 0: return ids = _parse_json(stdout) diff --git a/backend/models.py b/backend/models.py index 47b655a..25d08da 100644 --- a/backend/models.py +++ b/backend/models.py @@ -9,12 +9,15 @@ FormatType = Literal[ "EndGuide", ] +ProviderType = Literal["claude", "minimax"] + class GuideCreateRequest(BaseModel): topic: str = Field(min_length=1, max_length=100) format: FormatType instructions: str = Field(default="", max_length=2000) reindex: bool = False + provider: ProviderType = "claude" class ProjectResponse(BaseModel): @@ -22,8 +25,14 @@ class ProjectResponse(BaseModel): cached: bool +class ProviderInfo(BaseModel): + id: str + available: bool + + class GuideReworkRequest(BaseModel): instructions: str = Field(min_length=1, max_length=2000) + provider: ProviderType = "claude" class GuideResponse(BaseModel): @@ -41,10 +50,12 @@ class BausteinCreateRequest(BaseModel): topic: str = Field(min_length=1, max_length=100) title: str = Field(min_length=1, max_length=200) instructions: str = Field(default="", max_length=2000) + provider: ProviderType = "claude" class BausteinReworkRequest(BaseModel): instructions: str = Field(min_length=1, max_length=2000) + provider: ProviderType = "claude" class BausteinResponse(BaseModel): @@ -61,6 +72,7 @@ class BausteinResponse(BaseModel): class BausteinSortRequest(BaseModel): instructions: str = Field(default="", max_length=2000) + provider: ProviderType = "claude" class SuggestionResponse(BaseModel): @@ -76,6 +88,7 @@ class SuggestionResponse(BaseModel): class TopicSuggestRequest(BaseModel): problem: str = Field(min_length=1, max_length=2000) + provider: ProviderType = "claude" class TopicSuggestion(BaseModel): @@ -92,6 +105,7 @@ class GuideChatRequest(BaseModel): section: str = Field(default="", max_length=20000) outline: str = Field(default="", max_length=8000) messages: list[ChatMessage] = Field(min_length=1) + provider: ProviderType = "claude" class GuideChatResponse(BaseModel): diff --git a/backend/routes.py b/backend/routes.py index a567d62..a90651b 100644 --- a/backend/routes.py +++ b/backend/routes.py @@ -6,7 +6,8 @@ from datetime import datetime, timezone from fastapi import APIRouter, HTTPException from fastapi.responses import FileResponse -from config import FORMAT_META, PROJECTS_DIR +from agents import provider_available +from config import FORMAT_META, PROJECTS_DIR, PROVIDERS from database import ( create_guide, delete_guide, get_guide, list_guides, create_baustein as db_create_baustein, list_bausteine, get_baustein, delete_baustein as db_delete_baustein, @@ -19,7 +20,7 @@ from models import ( BausteinCreateRequest, BausteinReworkRequest, BausteinSortRequest, BausteinResponse, SuggestionResponse, TopicSuggestRequest, TopicSuggestion, GuideChatRequest, GuideChatResponse, - ProgressUpdate, ProgressResponse, ProjectResponse, + ProgressUpdate, ProgressResponse, ProjectResponse, ProviderInfo, ) from paths import final_paths, project_dir, project_cache_path @@ -31,6 +32,11 @@ async def get_formats(): return FORMAT_META +@router.get("/providers", response_model=list[ProviderInfo]) +async def get_providers(): + return [{"id": pid, "available": provider_available(pid)} for pid in PROVIDERS] + + def _safe_project_name(name: str) -> str: if not name or "/" in name or "\\" in name or ".." in name or "\x00" in name: raise HTTPException(400, "Ungültiger Projektname") @@ -63,7 +69,7 @@ async def remove_project(name: str): async def topic_suggestions(req: TopicSuggestRequest): guides = await list_guides() existing_topics = sorted({g["topic"] for g in guides}) - return await suggest_topics(req.problem.strip(), existing_topics) + return await suggest_topics(req.problem.strip(), existing_topics, provider=req.provider) @router.post("/guides", response_model=GuideResponse) @@ -80,7 +86,7 @@ async def create(req: GuideCreateRequest): "updated_at": now, } await create_guide(guide) - asyncio.create_task(generate_guide(guide["id"], guide["topic"], guide["format"], guide["instructions"], req.reindex)) + asyncio.create_task(generate_guide(guide["id"], guide["topic"], guide["format"], guide["instructions"], req.reindex, req.provider)) return guide @@ -117,7 +123,7 @@ async def rework(guide_id: str, req: GuideReworkRequest): raise HTTPException(404, "Guide nicht gefunden") if guide["status"] != "done": raise HTTPException(400, "Guide muss fertig sein") - asyncio.create_task(rework_guide(guide_id, guide["topic"], guide["format"], req.instructions.strip())) + asyncio.create_task(rework_guide(guide_id, guide["topic"], guide["format"], req.instructions.strip(), req.provider)) return {"ok": True} @@ -129,6 +135,7 @@ async def guide_chat(guide_id: str, req: GuideChatRequest): reply = await chat_with_guide( guide["topic"], guide["format"], req.section, req.outline, [m.model_dump() for m in req.messages], + provider=req.provider, ) return {"reply": reply} @@ -205,7 +212,7 @@ async def add_baustein(req: BausteinCreateRequest): "updated_at": now, } await db_create_baustein(baustein) - asyncio.create_task(generate_baustein_detail(baustein["id"], baustein["topic"], baustein["title"], req.instructions.strip())) + asyncio.create_task(generate_baustein_detail(baustein["id"], baustein["topic"], baustein["title"], req.instructions.strip(), req.provider)) return baustein @@ -233,7 +240,7 @@ async def rework_baustein_route(baustein_id: str, req: BausteinReworkRequest): "purpose": b.get("purpose", ""), "examples": examples, } - asyncio.create_task(rework_baustein(baustein_id, b["topic"], b["title"], current, req.instructions.strip())) + asyncio.create_task(rework_baustein(baustein_id, b["topic"], b["title"], current, req.instructions.strip(), req.provider)) return {"ok": True} @@ -244,7 +251,7 @@ async def sort_bausteine_route(topic: str, req: BausteinSortRequest): bausteine = await list_bausteine(topic) if not bausteine: return {"ok": True} - asyncio.create_task(sort_bausteine(topic, bausteine, req.instructions.strip())) + asyncio.create_task(sort_bausteine(topic, bausteine, req.instructions.strip(), req.provider)) return {"ok": True} @@ -261,7 +268,9 @@ async def get_suggestions(topic: str): @router.post("/bausteine/suggestions/generate") -async def trigger_suggestions(topic: str): +async def trigger_suggestions(topic: str, provider: str = "claude"): + if provider not in PROVIDERS: + raise HTTPException(400, "Unbekannter Provider") if is_suggestions_generating(topic): return {"ok": True, "status": "already_generating"} guides = await list_guides() @@ -271,7 +280,7 @@ async def trigger_suggestions(topic: str): html_path, _ = final_paths(g["topic"], g["format"]) if html_path.exists(): html_paths.append(html_path) - asyncio.create_task(generate_suggestions(topic, html_paths)) + asyncio.create_task(generate_suggestions(topic, html_paths, provider)) return {"ok": True} diff --git a/dev-ops/opencode.json b/dev-ops/opencode.json new file mode 100644 index 0000000..5e4f819 --- /dev/null +++ b/dev-ops/opencode.json @@ -0,0 +1,77 @@ +{ + "$schema": "https://opencode.ai/config.json", + "provider": { + "minimax": { + "options": { + "apiKey": "{env:MINIMAX_API_KEY}" + }, + "models": { + "MiniMax-M3": { + "name": "MiniMax M3" + } + } + } + }, + "mcp": { + "minimax-search": { + "type": "local", + "command": ["uvx", "minimax-coding-plan-mcp"], + "environment": { + "MINIMAX_API_KEY": "{env:MINIMAX_API_KEY}", + "MINIMAX_API_HOST": "https://api.minimax.io" + } + } + }, + "agent": { + "full": { + "description": "Alle Tools: Dateien, Bash, Websuche", + "permission": { + "edit": "allow", + "bash": "allow", + "webfetch": "allow" + } + }, + "files": { + "description": "Dateien lesen/schreiben + Bash, keine Websuche", + "permission": { + "edit": "allow", + "bash": "allow", + "webfetch": "deny" + }, + "tools": { + "minimax-search*": false + } + }, + "readonly": { + "description": "Nur Dateien lesen", + "permission": { + "edit": "deny", + "bash": "deny", + "webfetch": "deny" + }, + "tools": { + "write": false, + "edit": false, + "bash": false, + "minimax-search*": false + } + }, + "text": { + "description": "Reine Textantwort, keine Tools", + "permission": { + "edit": "deny", + "bash": "deny", + "webfetch": "deny" + }, + "tools": { + "write": false, + "edit": false, + "bash": false, + "read": false, + "glob": false, + "grep": false, + "minimax-search*": false + } + } + } +} diff --git a/docker-compose.yml b/docker-compose.yml index c997959..42f47ac 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,6 +6,7 @@ services: restart: unless-stopped environment: - CLAUDE_CODE_OAUTH_TOKEN=${CLAUDE_CODE_OAUTH_TOKEN:-} + - MINIMAX_API_KEY=${MINIMAX_API_KEY:-} networks: - web volumes: diff --git a/frontend/src/App.vue b/frontend/src/App.vue index c1dd099..8402ac8 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -1,6 +1,6 @@