247 lines
8.3 KiB
Python
247 lines
8.3 KiB
Python
"""Skills Tab - edit tool-level skills and translation contexts under data/."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from pathlib import Path
|
|
|
|
from PyQt5.QtCore import Qt
|
|
from PyQt5.QtGui import QFont
|
|
from PyQt5.QtWidgets import (
|
|
QHBoxLayout,
|
|
QLabel,
|
|
QMessageBox,
|
|
QPushButton,
|
|
QTabWidget,
|
|
QTextEdit,
|
|
QVBoxLayout,
|
|
QWidget,
|
|
)
|
|
|
|
from util.paths import PROMPT_PATH, SKILLS_DIR, TRANSLATION_CONTEXTS_PATH
|
|
from util.skills.contexts import reload_contexts
|
|
|
|
|
|
class _PlainPasteTextEdit(QTextEdit):
|
|
"""QTextEdit that always pastes as plain text."""
|
|
|
|
def insertFromMimeData(self, source): # noqa: N802
|
|
self.insertPlainText(source.text())
|
|
|
|
|
|
_EDITOR_STYLE = (
|
|
"QTextEdit{background-color:#1e1e1e;color:#d4d4d4;"
|
|
"border:1px solid #3c3c3c;border-radius:4px;padding:10px;"
|
|
"selection-background-color:#264f78;}"
|
|
)
|
|
|
|
_HINT_STYLE = "color:#9d9d9d;font-size:13px;"
|
|
_PATH_STYLE = "color:#569cd6;font-size:12px;font-family:Consolas,monospace;"
|
|
|
|
|
|
class SkillsTab(QWidget):
|
|
"""Edit ``data/skills/*.md`` and ``data/translation_contexts.json``."""
|
|
|
|
def __init__(self, parent=None):
|
|
super().__init__(parent)
|
|
self._pages: dict[str, dict] = {}
|
|
self._build_ui()
|
|
self.reload_all()
|
|
|
|
def _build_ui(self):
|
|
root = QVBoxLayout(self)
|
|
root.setContentsMargins(20, 16, 20, 16)
|
|
root.setSpacing(10)
|
|
|
|
title = QLabel("Skills")
|
|
title.setStyleSheet("color:#ffffff;font-size:18px;font-weight:bold;")
|
|
root.addWidget(title)
|
|
|
|
intro = QLabel(
|
|
"Tool-level AI instructions live here. "
|
|
"Per-game quirks, the game IDE skill, and optional custom skills are edited "
|
|
"in Workflow Step 2 (they travel with the game folder)."
|
|
)
|
|
intro.setWordWrap(True)
|
|
intro.setStyleSheet(_HINT_STYLE)
|
|
root.addWidget(intro)
|
|
|
|
self.tabs = QTabWidget()
|
|
self.tabs.setStyleSheet(
|
|
"QTabWidget::pane{border:1px solid #3c3c3c;border-radius:4px;top:-1px;}"
|
|
"QTabBar::tab{background:#2d2d30;color:#cccccc;padding:8px 14px;"
|
|
"border:1px solid #3c3c3c;border-bottom:none;margin-right:2px;}"
|
|
"QTabBar::tab:selected{background:#1e1e1e;color:#ffffff;"
|
|
"border-top:2px solid #007acc;}"
|
|
)
|
|
root.addWidget(self.tabs, 1)
|
|
|
|
self._add_file_page(
|
|
key="system",
|
|
tab_title="System",
|
|
path=PROMPT_PATH,
|
|
hint=(
|
|
"Runtime system prompt for every translation API call. "
|
|
"Honorifics, formatting, and universal quality rules live here. "
|
|
"Do not put game-specific voice quirks here - use <game>/skills/quirks.md."
|
|
),
|
|
is_json=False,
|
|
)
|
|
self._add_file_page(
|
|
key="project_setup",
|
|
tab_title="Project Setup",
|
|
path=SKILLS_DIR / "project_setup.md",
|
|
hint=(
|
|
"Clipboard skill copied from Workflow. "
|
|
"Instructs the IDE to emit glossary, speakers, quirks, and game_skill blocks."
|
|
),
|
|
is_json=False,
|
|
)
|
|
self._add_file_page(
|
|
key="contexts",
|
|
tab_title="Contexts",
|
|
path=TRANSLATION_CONTEXTS_PATH,
|
|
hint=(
|
|
"Per-call history templates used by RPG Maker / WolfDawn "
|
|
"(names.npc, database.item, events.label_108, …). "
|
|
"Edit carefully - keys must match what the engine modules look up."
|
|
),
|
|
is_json=True,
|
|
)
|
|
|
|
btn_row = QHBoxLayout()
|
|
reload_btn = QPushButton("↺ Reload All")
|
|
reload_btn.setFixedWidth(140)
|
|
reload_btn.clicked.connect(self.reload_all)
|
|
btn_row.addWidget(reload_btn)
|
|
btn_row.addStretch()
|
|
open_btn = QPushButton("Open skills folder")
|
|
open_btn.setFixedWidth(160)
|
|
open_btn.clicked.connect(self._open_skills_folder)
|
|
btn_row.addWidget(open_btn)
|
|
root.addLayout(btn_row)
|
|
|
|
def _add_file_page(
|
|
self,
|
|
*,
|
|
key: str,
|
|
tab_title: str,
|
|
path: Path,
|
|
hint: str,
|
|
is_json: bool,
|
|
):
|
|
page = QWidget()
|
|
layout = QVBoxLayout(page)
|
|
layout.setContentsMargins(12, 12, 12, 12)
|
|
layout.setSpacing(8)
|
|
|
|
path_lbl = QLabel(str(path))
|
|
path_lbl.setStyleSheet(_PATH_STYLE)
|
|
path_lbl.setTextInteractionFlags(Qt.TextSelectableByMouse)
|
|
layout.addWidget(path_lbl)
|
|
|
|
hint_lbl = QLabel(hint)
|
|
hint_lbl.setWordWrap(True)
|
|
hint_lbl.setStyleSheet(_HINT_STYLE)
|
|
layout.addWidget(hint_lbl)
|
|
|
|
editor = _PlainPasteTextEdit()
|
|
editor.setFont(QFont("Consolas", 10))
|
|
editor.setStyleSheet(_EDITOR_STYLE)
|
|
layout.addWidget(editor, 1)
|
|
|
|
row = QHBoxLayout()
|
|
save_btn = QPushButton("💾 Save")
|
|
save_btn.setFixedWidth(120)
|
|
save_btn.clicked.connect(lambda _=False, k=key: self._save_page(k))
|
|
row.addWidget(save_btn)
|
|
|
|
reload_btn = QPushButton("↺ Reload")
|
|
reload_btn.setFixedWidth(120)
|
|
reload_btn.clicked.connect(lambda _=False, k=key: self._reload_page(k))
|
|
row.addWidget(reload_btn)
|
|
|
|
status = QLabel("")
|
|
status.setStyleSheet("color:#6a9955;font-size:12px;")
|
|
row.addWidget(status)
|
|
row.addStretch()
|
|
layout.addLayout(row)
|
|
|
|
self.tabs.addTab(page, tab_title)
|
|
self._pages[key] = {
|
|
"path": path,
|
|
"editor": editor,
|
|
"status": status,
|
|
"is_json": is_json,
|
|
}
|
|
|
|
def reload_all(self):
|
|
for key in self._pages:
|
|
self._reload_page(key)
|
|
|
|
def _reload_page(self, key: str):
|
|
page = self._pages[key]
|
|
path: Path = page["path"]
|
|
editor: QTextEdit = page["editor"]
|
|
status: QLabel = page["status"]
|
|
try:
|
|
if not path.is_file():
|
|
editor.setPlainText("")
|
|
status.setText(f"Missing: {path.name}")
|
|
status.setStyleSheet("color:#f0ad4e;font-size:12px;")
|
|
return
|
|
editor.setPlainText(path.read_text(encoding="utf-8"))
|
|
status.setText("Loaded")
|
|
status.setStyleSheet("color:#6a9955;font-size:12px;")
|
|
except Exception as exc:
|
|
status.setText(f"Load failed: {exc}")
|
|
status.setStyleSheet("color:#f44747;font-size:12px;")
|
|
|
|
def _save_page(self, key: str):
|
|
page = self._pages[key]
|
|
path: Path = page["path"]
|
|
editor: QTextEdit = page["editor"]
|
|
status: QLabel = page["status"]
|
|
text = editor.toPlainText()
|
|
if page["is_json"]:
|
|
try:
|
|
parsed = json.loads(text)
|
|
text = json.dumps(parsed, ensure_ascii=False, indent=2) + "\n"
|
|
editor.setPlainText(text.rstrip("\n") + "\n")
|
|
except json.JSONDecodeError as exc:
|
|
QMessageBox.warning(
|
|
self,
|
|
"Invalid JSON",
|
|
f"Cannot save {path.name}:\n{exc}",
|
|
)
|
|
status.setText("Invalid JSON")
|
|
status.setStyleSheet("color:#f44747;font-size:12px;")
|
|
return
|
|
try:
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text(text if text.endswith("\n") else text + "\n", encoding="utf-8")
|
|
if key == "contexts":
|
|
reload_contexts()
|
|
status.setText(f"Saved {path.name}")
|
|
status.setStyleSheet("color:#6a9955;font-size:12px;")
|
|
except Exception as exc:
|
|
QMessageBox.warning(self, "Save failed", str(exc))
|
|
status.setText(f"Save failed: {exc}")
|
|
status.setStyleSheet("color:#f44747;font-size:12px;")
|
|
|
|
def _open_skills_folder(self):
|
|
folder = SKILLS_DIR
|
|
folder.mkdir(parents=True, exist_ok=True)
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
|
|
try:
|
|
if sys.platform.startswith("win"):
|
|
os.startfile(str(folder)) # type: ignore[attr-defined]
|
|
elif sys.platform == "darwin":
|
|
subprocess.run(["open", str(folder)], check=False)
|
|
else:
|
|
subprocess.run(["xdg-open", str(folder)], check=False)
|
|
except Exception as exc:
|
|
QMessageBox.information(self, "Skills folder", f"{folder}\n\n{exc}")
|