update
This commit is contained in:
7
.env copy.example
Normal file
7
.env copy.example
Normal file
@@ -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=
|
||||||
@@ -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=
|
|
||||||
@@ -23,7 +23,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
gnupg \
|
gnupg \
|
||||||
&& curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
|
&& curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
|
||||||
&& apt-get install -y nodejs \
|
&& 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/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
RUN useradd -m -u 1000 app
|
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 backend/ /app/backend/
|
||||||
COPY --chown=app:app templates/ /app/templates/
|
COPY --chown=app:app templates/ /app/templates/
|
||||||
COPY --chown=app:app --from=frontend /build/dist /app/frontend/dist
|
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
|
RUN chown app:app /app
|
||||||
|
|
||||||
|
|||||||
17
Makefile
17
Makefile
@@ -6,18 +6,29 @@ auth:
|
|||||||
@mkdir -p .claude-data storage projects
|
@mkdir -p .claude-data storage projects
|
||||||
@chown -R 1000:1000 .claude-data storage projects
|
@chown -R 1000:1000 .claude-data storage projects
|
||||||
@grep -q "^CLAUDE_CODE_OAUTH_TOKEN=.\+" .env 2>/dev/null \
|
@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."
|
@echo "Verzeichnisse angelegt und auf uid 1000 chowned."
|
||||||
|
|
||||||
install:
|
install:
|
||||||
sudo apt install -y poppler-utils libpango-1.0-0 libcairo2 libgdk-pixbuf-2.0-0 libffi-dev
|
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
|
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:
|
dev:
|
||||||
@echo "Backend: http://localhost:8000"
|
@echo "Backend: http://localhost:8000"
|
||||||
@echo "Frontend: http://localhost:5173"
|
@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
|
@cd frontend && npx vite --port 5173
|
||||||
|
|
||||||
prod: auth
|
prod: auth
|
||||||
|
|||||||
139
backend/agents.py
Normal file
139
backend/agents.py
Normal file
@@ -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()
|
||||||
@@ -27,10 +27,23 @@ FORMAT_META = {
|
|||||||
AGENT_TIMEOUT = 3600
|
AGENT_TIMEOUT = 3600
|
||||||
|
|
||||||
MAX_CONCURRENT_GENERATIONS = 6
|
MAX_CONCURRENT_GENERATIONS = 6
|
||||||
CLAUDE_CLI = "claude"
|
|
||||||
|
|
||||||
MODEL_GUIDE = "claude-opus-4-8[1m]"
|
# Provider-Stacks: komplett unabhängig, einer kann jederzeit entfernt werden.
|
||||||
MODEL_BAUSTEIN_GEN = "claude-sonnet-4-6"
|
# Rollen: "guide" = große Generierung/Review, "fast" = Bausteine/Chat/Sortierung.
|
||||||
MODEL_BAUSTEIN_REWORK = "claude-sonnet-4-6"
|
DEFAULT_PROVIDER = "claude"
|
||||||
MODEL_CHAT = "claude-sonnet-4-6"
|
PROVIDERS = {
|
||||||
MODEL_PROJECT_INDEX = MODEL_BAUSTEIN_GEN
|
"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
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,16 +8,12 @@ import uuid
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from agents import run_agent, kill_process
|
||||||
from config import (
|
from config import (
|
||||||
AGENT_TIMEOUT,
|
AGENT_TIMEOUT,
|
||||||
CLAUDE_CLI,
|
DEFAULT_PROVIDER,
|
||||||
TEMPLATES_DIR,
|
TEMPLATES_DIR,
|
||||||
MAX_CONCURRENT_GENERATIONS,
|
MAX_CONCURRENT_GENERATIONS,
|
||||||
MODEL_GUIDE,
|
|
||||||
MODEL_BAUSTEIN_GEN,
|
|
||||||
MODEL_BAUSTEIN_REWORK,
|
|
||||||
MODEL_CHAT,
|
|
||||||
MODEL_PROJECT_INDEX,
|
|
||||||
STORAGE_DIR,
|
STORAGE_DIR,
|
||||||
)
|
)
|
||||||
from database import (
|
from database import (
|
||||||
@@ -32,15 +28,12 @@ from database import (
|
|||||||
from paths import final_paths, temp_paths, project_dir, project_cache_path
|
from paths import final_paths, temp_paths, project_dir, project_cache_path
|
||||||
|
|
||||||
_semaphore = asyncio.Semaphore(MAX_CONCURRENT_GENERATIONS)
|
_semaphore = asyncio.Semaphore(MAX_CONCURRENT_GENERATIONS)
|
||||||
_active_processes: dict[str, asyncio.subprocess.Process] = {}
|
|
||||||
_cancelled: set[str] = set()
|
_cancelled: set[str] = set()
|
||||||
|
|
||||||
|
|
||||||
async def cancel_guide(guide_id: str) -> bool:
|
async def cancel_guide(guide_id: str) -> bool:
|
||||||
_cancelled.add(guide_id)
|
_cancelled.add(guide_id)
|
||||||
process = _active_processes.get(guide_id)
|
kill_process(guide_id)
|
||||||
if process and process.returncode is None:
|
|
||||||
process.kill()
|
|
||||||
now = datetime.now(timezone.utc).isoformat()
|
now = datetime.now(timezone.utc).isoformat()
|
||||||
await update_guide(guide_id, status="error", progress=None, error_msg="Abgebrochen", updated_at=now)
|
await update_guide(guide_id, status="error", progress=None, error_msg="Abgebrochen", updated_at=now)
|
||||||
return True
|
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)
|
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]:
|
async def _render_pdf(html_path: Path, pdf_path: Path) -> tuple[bool, str]:
|
||||||
proc = await asyncio.create_subprocess_exec(
|
proc = await asyncio.create_subprocess_exec(
|
||||||
"weasyprint", str(html_path), str(pdf_path),
|
"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:
|
async with _semaphore:
|
||||||
now = datetime.now(timezone.utc).isoformat()
|
now = datetime.now(timezone.utc).isoformat()
|
||||||
await update_guide(guide_id, status="generating", progress="Recherche…", updated_at=now)
|
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…")
|
await _set_progress(guide_id, "Lese Projekt…")
|
||||||
current_step = "Projekt-Einlesen"
|
current_step = "Projekt-Einlesen"
|
||||||
index_prompt = _build_project_index_prompt(topic, cache_path, cache_path.exists())
|
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,
|
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:
|
if guide_id in _cancelled:
|
||||||
return
|
return
|
||||||
@@ -264,7 +225,7 @@ async def generate_guide(guide_id: str, topic: str, format_name: str, instructio
|
|||||||
# Step 1: Generator-Agent erstellt HTML
|
# Step 1: Generator-Agent erstellt HTML
|
||||||
await _set_progress(guide_id, "Generiere HTML…")
|
await _set_progress(guide_id, "Generiere HTML…")
|
||||||
gen_prompt = _build_generator_prompt(topic, format_name, html_path, instructions, project_content)
|
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:
|
if guide_id in _cancelled:
|
||||||
return
|
return
|
||||||
@@ -284,7 +245,7 @@ async def generate_guide(guide_id: str, topic: str, format_name: str, instructio
|
|||||||
current_step = "Inhalts-Review"
|
current_step = "Inhalts-Review"
|
||||||
current_timeout = AGENT_TIMEOUT
|
current_timeout = AGENT_TIMEOUT
|
||||||
content_prompt = _build_content_review_prompt(topic, format_name, html_path, project_content)
|
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:
|
if returncode != 0:
|
||||||
await _fail(guide_id, _claude_error("Inhalts-Review-Fehler", returncode, review_out, review_err))
|
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_step = "Inhalts-Korrektur"
|
||||||
current_timeout = AGENT_TIMEOUT
|
current_timeout = AGENT_TIMEOUT
|
||||||
fix_prompt = _build_fix_prompt(topic, format_name, html_path, feedback)
|
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:
|
if returncode != 0:
|
||||||
await _fail(guide_id, _claude_error("Fix-Fehler", returncode, fix_out, fix_err))
|
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:
|
except Exception as e:
|
||||||
await _fail(guide_id, str(e)[:2000])
|
await _fail(guide_id, str(e)[:2000])
|
||||||
finally:
|
finally:
|
||||||
_active_processes.pop(guide_id, None)
|
|
||||||
_cancelled.discard(guide_id)
|
_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:
|
async with _semaphore:
|
||||||
now = datetime.now(timezone.utc).isoformat()
|
now = datetime.now(timezone.utc).isoformat()
|
||||||
await update_guide(guide_id, status="generating", progress="Überarbeite…", updated_at=now)
|
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
|
current_timeout = AGENT_TIMEOUT
|
||||||
|
|
||||||
rework_prompt = _build_rework_prompt(topic, format_name, tmp_html, instructions)
|
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:
|
if guide_id in _cancelled:
|
||||||
return
|
return
|
||||||
@@ -384,7 +344,6 @@ async def rework_guide(guide_id: str, topic: str, format_name: str, instructions
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
await _fail(guide_id, str(e)[:2000])
|
await _fail(guide_id, str(e)[:2000])
|
||||||
finally:
|
finally:
|
||||||
_active_processes.pop(guide_id, None)
|
|
||||||
_cancelled.discard(guide_id)
|
_cancelled.discard(guide_id)
|
||||||
tmp_html.unlink(missing_ok=True)
|
tmp_html.unlink(missing_ok=True)
|
||||||
tmp_pdf.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)
|
_suggestions_generating.add(topic)
|
||||||
try:
|
try:
|
||||||
existing = await list_bausteine(topic)
|
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)
|
await delete_pending_suggestions(topic)
|
||||||
|
|
||||||
prompt = _build_suggestions_prompt(topic, html_paths, existing_titles)
|
prompt = _build_suggestions_prompt(topic, html_paths, existing_titles)
|
||||||
tools = "Read" if html_paths else None
|
capabilities = "read" if html_paths else "none"
|
||||||
returncode, stdout, stderr = await _run_claude("suggestions-" + topic, prompt, 1800, tools=tools, model=MODEL_BAUSTEIN_GEN)
|
returncode, stdout, stderr = await run_agent("suggestions-" + topic, prompt, 1800, provider=provider, role="fast", capabilities=capabilities)
|
||||||
|
|
||||||
if returncode != 0:
|
if returncode != 0:
|
||||||
return
|
return
|
||||||
@@ -520,10 +479,10 @@ async def generate_suggestions(topic: str, html_paths: list[Path]) -> None:
|
|||||||
_suggestions_generating.discard(topic)
|
_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:
|
try:
|
||||||
prompt = _build_baustein_detail_prompt(topic, title, instructions)
|
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:
|
if returncode != 0:
|
||||||
return
|
return
|
||||||
@@ -544,10 +503,10 @@ async def generate_baustein_detail(baustein_id: str, topic: str, title: str, ins
|
|||||||
pass
|
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:
|
try:
|
||||||
prompt = _build_baustein_rework_prompt(topic, title, current, instructions)
|
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:
|
if returncode != 0:
|
||||||
return
|
return
|
||||||
@@ -654,11 +613,11 @@ WICHTIG – Antwortstil:
|
|||||||
Gib NUR die Antwort aus, kein Präfix wie "Assistent:"."""
|
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:
|
try:
|
||||||
prompt = _build_guide_chat_prompt(topic, format_name, section, outline, messages)
|
prompt = _build_guide_chat_prompt(topic, format_name, section, outline, messages)
|
||||||
returncode, stdout, stderr = await _run_claude(
|
returncode, stdout, stderr = await run_agent(
|
||||||
"chat-" + str(uuid.uuid4()), prompt, 120, tools=None, model=MODEL_CHAT
|
"chat-" + str(uuid.uuid4()), prompt, 240, provider=provider, role="fast", capabilities="none"
|
||||||
)
|
)
|
||||||
if returncode != 0:
|
if returncode != 0:
|
||||||
return "Entschuldigung, das hat nicht geklappt. Bitte versuche es erneut."
|
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."
|
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:
|
try:
|
||||||
prompt = _build_topic_suggest_prompt(problem, existing_topics or [])
|
prompt = _build_topic_suggest_prompt(problem, existing_topics or [])
|
||||||
returncode, stdout, stderr = await _run_claude(
|
returncode, stdout, stderr = await run_agent(
|
||||||
"topic-suggest-" + str(uuid.uuid4()), prompt, 120, tools=None, model=MODEL_BAUSTEIN_GEN
|
"topic-suggest-" + str(uuid.uuid4()), prompt, 240, provider=provider, role="fast", capabilities="none"
|
||||||
)
|
)
|
||||||
if returncode != 0:
|
if returncode != 0:
|
||||||
return []
|
return []
|
||||||
@@ -692,11 +651,11 @@ async def suggest_topics(problem: str, existing_topics: list[str] | None = None)
|
|||||||
return []
|
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)
|
_sorting.add(topic)
|
||||||
try:
|
try:
|
||||||
prompt = _build_sort_prompt(topic, bausteine, instructions)
|
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:
|
if returncode != 0:
|
||||||
return
|
return
|
||||||
ids = _parse_json(stdout)
|
ids = _parse_json(stdout)
|
||||||
|
|||||||
@@ -9,12 +9,15 @@ FormatType = Literal[
|
|||||||
"EndGuide",
|
"EndGuide",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
ProviderType = Literal["claude", "minimax"]
|
||||||
|
|
||||||
|
|
||||||
class GuideCreateRequest(BaseModel):
|
class GuideCreateRequest(BaseModel):
|
||||||
topic: str = Field(min_length=1, max_length=100)
|
topic: str = Field(min_length=1, max_length=100)
|
||||||
format: FormatType
|
format: FormatType
|
||||||
instructions: str = Field(default="", max_length=2000)
|
instructions: str = Field(default="", max_length=2000)
|
||||||
reindex: bool = False
|
reindex: bool = False
|
||||||
|
provider: ProviderType = "claude"
|
||||||
|
|
||||||
|
|
||||||
class ProjectResponse(BaseModel):
|
class ProjectResponse(BaseModel):
|
||||||
@@ -22,8 +25,14 @@ class ProjectResponse(BaseModel):
|
|||||||
cached: bool
|
cached: bool
|
||||||
|
|
||||||
|
|
||||||
|
class ProviderInfo(BaseModel):
|
||||||
|
id: str
|
||||||
|
available: bool
|
||||||
|
|
||||||
|
|
||||||
class GuideReworkRequest(BaseModel):
|
class GuideReworkRequest(BaseModel):
|
||||||
instructions: str = Field(min_length=1, max_length=2000)
|
instructions: str = Field(min_length=1, max_length=2000)
|
||||||
|
provider: ProviderType = "claude"
|
||||||
|
|
||||||
|
|
||||||
class GuideResponse(BaseModel):
|
class GuideResponse(BaseModel):
|
||||||
@@ -41,10 +50,12 @@ class BausteinCreateRequest(BaseModel):
|
|||||||
topic: str = Field(min_length=1, max_length=100)
|
topic: str = Field(min_length=1, max_length=100)
|
||||||
title: str = Field(min_length=1, max_length=200)
|
title: str = Field(min_length=1, max_length=200)
|
||||||
instructions: str = Field(default="", max_length=2000)
|
instructions: str = Field(default="", max_length=2000)
|
||||||
|
provider: ProviderType = "claude"
|
||||||
|
|
||||||
|
|
||||||
class BausteinReworkRequest(BaseModel):
|
class BausteinReworkRequest(BaseModel):
|
||||||
instructions: str = Field(min_length=1, max_length=2000)
|
instructions: str = Field(min_length=1, max_length=2000)
|
||||||
|
provider: ProviderType = "claude"
|
||||||
|
|
||||||
|
|
||||||
class BausteinResponse(BaseModel):
|
class BausteinResponse(BaseModel):
|
||||||
@@ -61,6 +72,7 @@ class BausteinResponse(BaseModel):
|
|||||||
|
|
||||||
class BausteinSortRequest(BaseModel):
|
class BausteinSortRequest(BaseModel):
|
||||||
instructions: str = Field(default="", max_length=2000)
|
instructions: str = Field(default="", max_length=2000)
|
||||||
|
provider: ProviderType = "claude"
|
||||||
|
|
||||||
|
|
||||||
class SuggestionResponse(BaseModel):
|
class SuggestionResponse(BaseModel):
|
||||||
@@ -76,6 +88,7 @@ class SuggestionResponse(BaseModel):
|
|||||||
|
|
||||||
class TopicSuggestRequest(BaseModel):
|
class TopicSuggestRequest(BaseModel):
|
||||||
problem: str = Field(min_length=1, max_length=2000)
|
problem: str = Field(min_length=1, max_length=2000)
|
||||||
|
provider: ProviderType = "claude"
|
||||||
|
|
||||||
|
|
||||||
class TopicSuggestion(BaseModel):
|
class TopicSuggestion(BaseModel):
|
||||||
@@ -92,6 +105,7 @@ class GuideChatRequest(BaseModel):
|
|||||||
section: str = Field(default="", max_length=20000)
|
section: str = Field(default="", max_length=20000)
|
||||||
outline: str = Field(default="", max_length=8000)
|
outline: str = Field(default="", max_length=8000)
|
||||||
messages: list[ChatMessage] = Field(min_length=1)
|
messages: list[ChatMessage] = Field(min_length=1)
|
||||||
|
provider: ProviderType = "claude"
|
||||||
|
|
||||||
|
|
||||||
class GuideChatResponse(BaseModel):
|
class GuideChatResponse(BaseModel):
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ from datetime import datetime, timezone
|
|||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, HTTPException
|
||||||
from fastapi.responses import FileResponse
|
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 (
|
from database import (
|
||||||
create_guide, delete_guide, get_guide, list_guides,
|
create_guide, delete_guide, get_guide, list_guides,
|
||||||
create_baustein as db_create_baustein, list_bausteine, get_baustein, delete_baustein as db_delete_baustein,
|
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,
|
BausteinCreateRequest, BausteinReworkRequest, BausteinSortRequest, BausteinResponse, SuggestionResponse,
|
||||||
TopicSuggestRequest, TopicSuggestion,
|
TopicSuggestRequest, TopicSuggestion,
|
||||||
GuideChatRequest, GuideChatResponse,
|
GuideChatRequest, GuideChatResponse,
|
||||||
ProgressUpdate, ProgressResponse, ProjectResponse,
|
ProgressUpdate, ProgressResponse, ProjectResponse, ProviderInfo,
|
||||||
)
|
)
|
||||||
from paths import final_paths, project_dir, project_cache_path
|
from paths import final_paths, project_dir, project_cache_path
|
||||||
|
|
||||||
@@ -31,6 +32,11 @@ async def get_formats():
|
|||||||
return FORMAT_META
|
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:
|
def _safe_project_name(name: str) -> str:
|
||||||
if not name or "/" in name or "\\" in name or ".." in name or "\x00" in name:
|
if not name or "/" in name or "\\" in name or ".." in name or "\x00" in name:
|
||||||
raise HTTPException(400, "Ungültiger Projektname")
|
raise HTTPException(400, "Ungültiger Projektname")
|
||||||
@@ -63,7 +69,7 @@ async def remove_project(name: str):
|
|||||||
async def topic_suggestions(req: TopicSuggestRequest):
|
async def topic_suggestions(req: TopicSuggestRequest):
|
||||||
guides = await list_guides()
|
guides = await list_guides()
|
||||||
existing_topics = sorted({g["topic"] for g in 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)
|
@router.post("/guides", response_model=GuideResponse)
|
||||||
@@ -80,7 +86,7 @@ async def create(req: GuideCreateRequest):
|
|||||||
"updated_at": now,
|
"updated_at": now,
|
||||||
}
|
}
|
||||||
await create_guide(guide)
|
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
|
return guide
|
||||||
|
|
||||||
|
|
||||||
@@ -117,7 +123,7 @@ async def rework(guide_id: str, req: GuideReworkRequest):
|
|||||||
raise HTTPException(404, "Guide nicht gefunden")
|
raise HTTPException(404, "Guide nicht gefunden")
|
||||||
if guide["status"] != "done":
|
if guide["status"] != "done":
|
||||||
raise HTTPException(400, "Guide muss fertig sein")
|
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}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
@@ -129,6 +135,7 @@ async def guide_chat(guide_id: str, req: GuideChatRequest):
|
|||||||
reply = await chat_with_guide(
|
reply = await chat_with_guide(
|
||||||
guide["topic"], guide["format"], req.section, req.outline,
|
guide["topic"], guide["format"], req.section, req.outline,
|
||||||
[m.model_dump() for m in req.messages],
|
[m.model_dump() for m in req.messages],
|
||||||
|
provider=req.provider,
|
||||||
)
|
)
|
||||||
return {"reply": reply}
|
return {"reply": reply}
|
||||||
|
|
||||||
@@ -205,7 +212,7 @@ async def add_baustein(req: BausteinCreateRequest):
|
|||||||
"updated_at": now,
|
"updated_at": now,
|
||||||
}
|
}
|
||||||
await db_create_baustein(baustein)
|
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
|
return baustein
|
||||||
|
|
||||||
|
|
||||||
@@ -233,7 +240,7 @@ async def rework_baustein_route(baustein_id: str, req: BausteinReworkRequest):
|
|||||||
"purpose": b.get("purpose", ""),
|
"purpose": b.get("purpose", ""),
|
||||||
"examples": examples,
|
"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}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
@@ -244,7 +251,7 @@ async def sort_bausteine_route(topic: str, req: BausteinSortRequest):
|
|||||||
bausteine = await list_bausteine(topic)
|
bausteine = await list_bausteine(topic)
|
||||||
if not bausteine:
|
if not bausteine:
|
||||||
return {"ok": True}
|
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}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
@@ -261,7 +268,9 @@ async def get_suggestions(topic: str):
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/bausteine/suggestions/generate")
|
@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):
|
if is_suggestions_generating(topic):
|
||||||
return {"ok": True, "status": "already_generating"}
|
return {"ok": True, "status": "already_generating"}
|
||||||
guides = await list_guides()
|
guides = await list_guides()
|
||||||
@@ -271,7 +280,7 @@ async def trigger_suggestions(topic: str):
|
|||||||
html_path, _ = final_paths(g["topic"], g["format"])
|
html_path, _ = final_paths(g["topic"], g["format"])
|
||||||
if html_path.exists():
|
if html_path.exists():
|
||||||
html_paths.append(html_path)
|
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}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
77
dev-ops/opencode.json
Normal file
77
dev-ops/opencode.json
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
- CLAUDE_CODE_OAUTH_TOKEN=${CLAUDE_CODE_OAUTH_TOKEN:-}
|
- CLAUDE_CODE_OAUTH_TOKEN=${CLAUDE_CODE_OAUTH_TOKEN:-}
|
||||||
|
- MINIMAX_API_KEY=${MINIMAX_API_KEY:-}
|
||||||
networks:
|
networks:
|
||||||
- web
|
- web
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
|
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
|
||||||
import { fetchGuides, createGuide as apiCreate, deleteGuide, cancelGuide as apiCancel, reworkGuide as apiRework, createBaustein as apiCreateBaustein, fetchProjects, deleteProject as apiDeleteProject } from './api.js'
|
import { fetchGuides, createGuide as apiCreate, deleteGuide, cancelGuide as apiCancel, reworkGuide as apiRework, createBaustein as apiCreateBaustein, fetchProjects, deleteProject as apiDeleteProject, fetchProviders } from './api.js'
|
||||||
import TopicSidebar from './components/TopicSidebar.vue'
|
import TopicSidebar from './components/TopicSidebar.vue'
|
||||||
import TopicDetail from './components/TopicDetail.vue'
|
import TopicDetail from './components/TopicDetail.vue'
|
||||||
import BausteineView from './components/BausteineView.vue'
|
import BausteineView from './components/BausteineView.vue'
|
||||||
@@ -21,6 +21,26 @@ const darkMode = ref(
|
|||||||
? window.matchMedia('(prefers-color-scheme: dark)').matches
|
? window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||||
: localStorage.getItem('darkMode') === 'true',
|
: localStorage.getItem('darkMode') === 'true',
|
||||||
)
|
)
|
||||||
|
const provider = ref(localStorage.getItem('provider') || 'claude')
|
||||||
|
const providers = ref([])
|
||||||
|
|
||||||
|
function setProvider(id) {
|
||||||
|
provider.value = id
|
||||||
|
localStorage.setItem('provider', id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadProviders() {
|
||||||
|
try {
|
||||||
|
providers.value = await fetchProviders()
|
||||||
|
const current = providers.value.find((p) => p.id === provider.value)
|
||||||
|
if (current && !current.available) {
|
||||||
|
const fallback = providers.value.find((p) => p.available)
|
||||||
|
if (fallback) setProvider(fallback.id)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Fehler beim Laden der Provider:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
let pollTimer = null
|
let pollTimer = null
|
||||||
|
|
||||||
function applyTheme() {
|
function applyTheme() {
|
||||||
@@ -149,7 +169,7 @@ function onHelpSelect(title) {
|
|||||||
|
|
||||||
async function handleFormatClick({ format, instructions, reindex }) {
|
async function handleFormatClick({ format, instructions, reindex }) {
|
||||||
if (!selectedTopic.value) return
|
if (!selectedTopic.value) return
|
||||||
await apiCreate(selectedTopic.value, format, instructions, reindex || false)
|
await apiCreate(selectedTopic.value, format, instructions, reindex || false, provider.value)
|
||||||
await loadGuides()
|
await loadGuides()
|
||||||
startPolling()
|
startPolling()
|
||||||
}
|
}
|
||||||
@@ -164,7 +184,7 @@ async function handleDeleteProject(name) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleRework({ guideId, instructions }) {
|
async function handleRework({ guideId, instructions }) {
|
||||||
await apiRework(guideId, instructions)
|
await apiRework(guideId, instructions, provider.value)
|
||||||
await loadGuides()
|
await loadGuides()
|
||||||
startPolling()
|
startPolling()
|
||||||
}
|
}
|
||||||
@@ -181,7 +201,7 @@ function handleShowBausteine() {
|
|||||||
|
|
||||||
async function handleSidebarAddBaustein(title) {
|
async function handleSidebarAddBaustein(title) {
|
||||||
if (!selectedTopic.value) return
|
if (!selectedTopic.value) return
|
||||||
await apiCreateBaustein(selectedTopic.value, title)
|
await apiCreateBaustein(selectedTopic.value, title, '', provider.value)
|
||||||
bausteineRefreshKey.value++
|
bausteineRefreshKey.value++
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -236,7 +256,7 @@ function onVisibility() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await Promise.all([loadGuides(), loadProjects()])
|
await Promise.all([loadGuides(), loadProjects(), loadProviders()])
|
||||||
if (!selectedTopic.value && topics.value.length) {
|
if (!selectedTopic.value && topics.value.length) {
|
||||||
selectTopic(topics.value[0])
|
selectTopic(topics.value[0])
|
||||||
}
|
}
|
||||||
@@ -264,6 +284,9 @@ onUnmounted(() => {
|
|||||||
:bausteineActive="showBausteine"
|
:bausteineActive="showBausteine"
|
||||||
:pinned="sidebarPinned"
|
:pinned="sidebarPinned"
|
||||||
:dark="darkMode"
|
:dark="darkMode"
|
||||||
|
:provider="provider"
|
||||||
|
:providers="providers"
|
||||||
|
@setProvider="setProvider"
|
||||||
@toggleDark="toggleDark"
|
@toggleDark="toggleDark"
|
||||||
@select="selectTopic"
|
@select="selectTopic"
|
||||||
@create="createTopic"
|
@create="createTopic"
|
||||||
@@ -282,6 +305,7 @@ onUnmounted(() => {
|
|||||||
/>
|
/>
|
||||||
<HelpChat
|
<HelpChat
|
||||||
v-if="showHelp"
|
v-if="showHelp"
|
||||||
|
:provider="provider"
|
||||||
@close="showHelp = false"
|
@close="showHelp = false"
|
||||||
@selectTopic="onHelpSelect"
|
@selectTopic="onHelpSelect"
|
||||||
/>
|
/>
|
||||||
@@ -289,11 +313,13 @@ onUnmounted(() => {
|
|||||||
v-else-if="selectedTopic && showBausteine"
|
v-else-if="selectedTopic && showBausteine"
|
||||||
:topic="selectedTopic"
|
:topic="selectedTopic"
|
||||||
:refreshKey="bausteineRefreshKey"
|
:refreshKey="bausteineRefreshKey"
|
||||||
|
:provider="provider"
|
||||||
/>
|
/>
|
||||||
<TopicDetail
|
<TopicDetail
|
||||||
v-else-if="selectedTopic"
|
v-else-if="selectedTopic"
|
||||||
:previewGuide="previewGuide"
|
:previewGuide="previewGuide"
|
||||||
:dark="darkMode"
|
:dark="darkMode"
|
||||||
|
:provider="provider"
|
||||||
/>
|
/>
|
||||||
<div v-else class="empty-main">
|
<div v-else class="empty-main">
|
||||||
<p>Thema in der Sidebar anlegen oder auswählen.</p>
|
<p>Thema in der Sidebar anlegen oder auswählen.</p>
|
||||||
|
|||||||
@@ -10,15 +10,20 @@ export async function fetchGuide(id) {
|
|||||||
return res.json()
|
return res.json()
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createGuide(topic, format, instructions = '', reindex = false) {
|
export async function createGuide(topic, format, instructions = '', reindex = false, provider = 'claude') {
|
||||||
const res = await fetch(`${BASE}/guides`, {
|
const res = await fetch(`${BASE}/guides`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ topic, format, instructions, reindex }),
|
body: JSON.stringify({ topic, format, instructions, reindex, provider }),
|
||||||
})
|
})
|
||||||
return res.json()
|
return res.json()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchProviders() {
|
||||||
|
const res = await fetch(`${BASE}/providers`)
|
||||||
|
return res.json()
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchProjects() {
|
export async function fetchProjects() {
|
||||||
const res = await fetch(`${BASE}/projects`)
|
const res = await fetch(`${BASE}/projects`)
|
||||||
return res.json()
|
return res.json()
|
||||||
@@ -28,11 +33,11 @@ export async function deleteProject(name) {
|
|||||||
await fetch(`${BASE}/projects/${encodeURIComponent(name)}`, { method: 'DELETE' })
|
await fetch(`${BASE}/projects/${encodeURIComponent(name)}`, { method: 'DELETE' })
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function reworkGuide(id, instructions) {
|
export async function reworkGuide(id, instructions, provider = 'claude') {
|
||||||
const res = await fetch(`${BASE}/guides/${id}/rework`, {
|
const res = await fetch(`${BASE}/guides/${id}/rework`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ instructions }),
|
body: JSON.stringify({ instructions, provider }),
|
||||||
})
|
})
|
||||||
return res.json()
|
return res.json()
|
||||||
}
|
}
|
||||||
@@ -67,20 +72,20 @@ export async function setProgress(id, chapter, done) {
|
|||||||
return res.json()
|
return res.json()
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function chatGuide(id, { section, outline, messages }) {
|
export async function chatGuide(id, { section, outline, messages, provider = 'claude' }) {
|
||||||
const res = await fetch(`${BASE}/guides/${id}/chat`, {
|
const res = await fetch(`${BASE}/guides/${id}/chat`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ section, outline, messages }),
|
body: JSON.stringify({ section, outline, messages, provider }),
|
||||||
})
|
})
|
||||||
return res.json()
|
return res.json()
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function suggestTopics(problem) {
|
export async function suggestTopics(problem, provider = 'claude') {
|
||||||
const res = await fetch(`${BASE}/topic-suggestions`, {
|
const res = await fetch(`${BASE}/topic-suggestions`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ problem }),
|
body: JSON.stringify({ problem, provider }),
|
||||||
})
|
})
|
||||||
return res.json()
|
return res.json()
|
||||||
}
|
}
|
||||||
@@ -90,11 +95,11 @@ export async function fetchBausteine(topic) {
|
|||||||
return res.json()
|
return res.json()
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createBaustein(topic, title, instructions = '') {
|
export async function createBaustein(topic, title, instructions = '', provider = 'claude') {
|
||||||
const res = await fetch(`${BASE}/bausteine`, {
|
const res = await fetch(`${BASE}/bausteine`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ topic, title, instructions }),
|
body: JSON.stringify({ topic, title, instructions, provider }),
|
||||||
})
|
})
|
||||||
return res.json()
|
return res.json()
|
||||||
}
|
}
|
||||||
@@ -103,19 +108,19 @@ export async function deleteBaustein(id) {
|
|||||||
await fetch(`${BASE}/bausteine/${id}`, { method: 'DELETE' })
|
await fetch(`${BASE}/bausteine/${id}`, { method: 'DELETE' })
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function reworkBaustein(id, instructions) {
|
export async function reworkBaustein(id, instructions, provider = 'claude') {
|
||||||
await fetch(`${BASE}/bausteine/${id}/rework`, {
|
await fetch(`${BASE}/bausteine/${id}/rework`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ instructions }),
|
body: JSON.stringify({ instructions, provider }),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function sortBausteine(topic, instructions = '') {
|
export async function sortBausteine(topic, instructions = '', provider = 'claude') {
|
||||||
await fetch(`${BASE}/bausteine/sort?topic=${encodeURIComponent(topic)}`, {
|
await fetch(`${BASE}/bausteine/sort?topic=${encodeURIComponent(topic)}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ instructions }),
|
body: JSON.stringify({ instructions, provider }),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,8 +134,8 @@ export async function fetchSuggestions(topic) {
|
|||||||
return res.json()
|
return res.json()
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateSuggestions(topic) {
|
export async function generateSuggestions(topic, provider = 'claude') {
|
||||||
await fetch(`${BASE}/bausteine/suggestions/generate?topic=${encodeURIComponent(topic)}`, { method: 'POST' })
|
await fetch(`${BASE}/bausteine/suggestions/generate?topic=${encodeURIComponent(topic)}&provider=${encodeURIComponent(provider)}`, { method: 'POST' })
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchSuggestionsStatus(topic) {
|
export async function fetchSuggestionsStatus(topic) {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
topic: { type: String, required: true },
|
topic: { type: String, required: true },
|
||||||
refreshKey: { type: Number, default: 0 },
|
refreshKey: { type: Number, default: 0 },
|
||||||
|
provider: { type: String, default: 'claude' },
|
||||||
})
|
})
|
||||||
|
|
||||||
const bausteine = ref([])
|
const bausteine = ref([])
|
||||||
@@ -65,7 +66,7 @@ async function handleAdd() {
|
|||||||
const info = newInfo.value.trim()
|
const info = newInfo.value.trim()
|
||||||
newTitle.value = ''
|
newTitle.value = ''
|
||||||
newInfo.value = ''
|
newInfo.value = ''
|
||||||
const created = await createBaustein(props.topic, title, info)
|
const created = await createBaustein(props.topic, title, info, props.provider)
|
||||||
bausteine.value.push(created)
|
bausteine.value.push(created)
|
||||||
reworkingSnapshots.set(created.id, created.updated_at)
|
reworkingSnapshots.set(created.id, created.updated_at)
|
||||||
reworkingIds.value = new Set([...reworkingIds.value, created.id])
|
reworkingIds.value = new Set([...reworkingIds.value, created.id])
|
||||||
@@ -97,14 +98,14 @@ async function handleRestore(s) {
|
|||||||
|
|
||||||
async function handleRegenerate() {
|
async function handleRegenerate() {
|
||||||
suggestionsLoading.value = true
|
suggestionsLoading.value = true
|
||||||
await generateSuggestions(props.topic)
|
await generateSuggestions(props.topic, props.provider)
|
||||||
startPolling()
|
startPolling()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSort() {
|
async function handleSort() {
|
||||||
sortingActive.value = true
|
sortingActive.value = true
|
||||||
const info = sortInfo.value.trim()
|
const info = sortInfo.value.trim()
|
||||||
await sortBausteine(props.topic, info)
|
await sortBausteine(props.topic, info, props.provider)
|
||||||
startSortPolling()
|
startSortPolling()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,7 +128,7 @@ async function handleRework(b) {
|
|||||||
reworkingSnapshots.set(b.id, b.updated_at)
|
reworkingSnapshots.set(b.id, b.updated_at)
|
||||||
reworkingIds.value = new Set([...reworkingIds.value, b.id])
|
reworkingIds.value = new Set([...reworkingIds.value, b.id])
|
||||||
reworkInputs.value[b.id] = ''
|
reworkInputs.value[b.id] = ''
|
||||||
await reworkBaustein(b.id, instructions)
|
await reworkBaustein(b.id, instructions, props.provider)
|
||||||
startBausteinPolling()
|
startBausteinPolling()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,10 @@
|
|||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { suggestTopics } from '../api.js'
|
import { suggestTopics } from '../api.js'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
provider: { type: String, default: 'claude' },
|
||||||
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['close', 'selectTopic'])
|
const emit = defineEmits(['close', 'selectTopic'])
|
||||||
|
|
||||||
const problem = ref('')
|
const problem = ref('')
|
||||||
@@ -18,7 +22,7 @@ async function submit() {
|
|||||||
error.value = false
|
error.value = false
|
||||||
suggestions.value = []
|
suggestions.value = []
|
||||||
try {
|
try {
|
||||||
const result = await suggestTopics(text)
|
const result = await suggestTopics(text, props.provider)
|
||||||
suggestions.value = Array.isArray(result) ? result : []
|
suggestions.value = Array.isArray(result) ? result : []
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = true
|
error.value = true
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ function renderMarkdown(text) {
|
|||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
previewGuide: { type: Object, default: null },
|
previewGuide: { type: Object, default: null },
|
||||||
dark: { type: Boolean, default: false },
|
dark: { type: Boolean, default: false },
|
||||||
|
provider: { type: String, default: 'claude' },
|
||||||
})
|
})
|
||||||
|
|
||||||
const LANDSCAPE_FORMATS = ['OnePager', 'Cheatsheet']
|
const LANDSCAPE_FORMATS = ['OnePager', 'Cheatsheet']
|
||||||
@@ -312,6 +313,7 @@ async function send() {
|
|||||||
section,
|
section,
|
||||||
outline,
|
outline,
|
||||||
messages: messages.value,
|
messages: messages.value,
|
||||||
|
provider: props.provider,
|
||||||
})
|
})
|
||||||
messages.value.push({ role: 'assistant', content: res.reply || '…' })
|
messages.value.push({ role: 'assistant', content: res.reply || '…' })
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -12,9 +12,18 @@ const props = defineProps({
|
|||||||
bausteineActive: { type: Boolean, default: false },
|
bausteineActive: { type: Boolean, default: false },
|
||||||
pinned: { type: Boolean, default: true },
|
pinned: { type: Boolean, default: true },
|
||||||
dark: { type: Boolean, default: false },
|
dark: { type: Boolean, default: false },
|
||||||
|
provider: { type: String, default: 'claude' },
|
||||||
|
providers: { type: Array, default: () => [] },
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['select', 'create', 'formatClick', 'deleteTopic', 'deleteProject', 'cancelGuide', 'deleteGuide', 'preview', 'rework', 'showBausteine', 'addBaustein', 'togglePin', 'sidebarLeave', 'openHelp', 'toggleDark'])
|
const emit = defineEmits(['select', 'create', 'formatClick', 'deleteTopic', 'deleteProject', 'cancelGuide', 'deleteGuide', 'preview', 'rework', 'showBausteine', 'addBaustein', 'togglePin', 'sidebarLeave', 'openHelp', 'toggleDark', 'setProvider'])
|
||||||
|
|
||||||
|
function providerAvailable(id) {
|
||||||
|
const p = props.providers.find((x) => x.id === id)
|
||||||
|
return p ? p.available : true
|
||||||
|
}
|
||||||
|
|
||||||
|
const PROVIDER_LABELS = { claude: 'Claude', minimax: 'MiniMax' }
|
||||||
|
|
||||||
const reindex = ref(false)
|
const reindex = ref(false)
|
||||||
|
|
||||||
@@ -166,6 +175,16 @@ function confirmDeleteProject(name) {
|
|||||||
/>
|
/>
|
||||||
<button @click="submit" :disabled="!newTopic.trim()">+</button>
|
<button @click="submit" :disabled="!newTopic.trim()">+</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="provider-toggle" v-if="providers.length">
|
||||||
|
<button
|
||||||
|
v-for="p in providers"
|
||||||
|
:key="p.id"
|
||||||
|
:class="{ active: p.id === provider }"
|
||||||
|
:disabled="!p.available"
|
||||||
|
:title="p.available ? '' : 'Nicht konfiguriert (CLI/Key fehlt)'"
|
||||||
|
@click="emit('setProvider', p.id)"
|
||||||
|
>{{ PROVIDER_LABELS[p.id] || p.id }}</button>
|
||||||
|
</div>
|
||||||
<div class="format-section" v-if="selectedTopic">
|
<div class="format-section" v-if="selectedTopic">
|
||||||
<div class="progress-info" v-if="activeGenerations.length">
|
<div class="progress-info" v-if="activeGenerations.length">
|
||||||
<div v-for="(line, i) in activeGenerations" :key="i">{{ line }}</div>
|
<div v-for="(line, i) in activeGenerations" :key="i">{{ line }}</div>
|
||||||
@@ -349,6 +368,43 @@ function confirmDeleteProject(name) {
|
|||||||
border-color: var(--accent-border);
|
border-color: var(--accent-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.provider-toggle {
|
||||||
|
display: flex;
|
||||||
|
gap: 0;
|
||||||
|
padding: 0.5rem 0.75rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.provider-toggle button {
|
||||||
|
flex: 1;
|
||||||
|
padding: 5px 8px;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 600;
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.provider-toggle button:first-child {
|
||||||
|
border-radius: 6px 0 0 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.provider-toggle button:last-child {
|
||||||
|
border-radius: 0 6px 6px 0;
|
||||||
|
border-left: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.provider-toggle button.active {
|
||||||
|
background: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--on-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.provider-toggle button:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
.topic-list {
|
.topic-list {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
|||||||
Reference in New Issue
Block a user