Compare commits
3 commits
0a618c5608
...
ba8b0e3bf6
| Author | SHA1 | Date | |
|---|---|---|---|
| ba8b0e3bf6 | |||
| fca1f8c1c6 | |||
| 33038978cc |
6 changed files with 197 additions and 5 deletions
|
|
@ -200,6 +200,61 @@ class ConfigComboBox(QComboBox):
|
|||
]))
|
||||
|
||||
|
||||
class ConfigMenu(QMenu):
|
||||
"""Popup menu with the same fully opaque surface as config dropdowns."""
|
||||
|
||||
_BACKGROUND = QColor("#353539")
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setStyleSheet("""
|
||||
QMenu {
|
||||
background-color: #353539;
|
||||
color: #f2f2f2;
|
||||
border: 1px solid #56565b;
|
||||
padding: 2px;
|
||||
}
|
||||
QMenu::item {
|
||||
background-color: #353539;
|
||||
color: #f2f2f2;
|
||||
min-height: 26px;
|
||||
padding: 3px 9px;
|
||||
}
|
||||
QMenu::item:selected {
|
||||
background-color: #007acc;
|
||||
color: #ffffff;
|
||||
}
|
||||
""")
|
||||
self._apply_opaque_background()
|
||||
|
||||
def _apply_opaque_background(self):
|
||||
self.setWindowOpacity(1.0)
|
||||
self.setAttribute(Qt.WA_TranslucentBackground, False)
|
||||
self.setAttribute(Qt.WA_StyledBackground, True)
|
||||
self.setAttribute(Qt.WA_OpaquePaintEvent, True)
|
||||
self.setAutoFillBackground(True)
|
||||
palette = self.palette()
|
||||
palette.setColor(QPalette.Window, self._BACKGROUND)
|
||||
palette.setColor(QPalette.Base, self._BACKGROUND)
|
||||
palette.setColor(QPalette.Button, self._BACKGROUND)
|
||||
palette.setColor(QPalette.Text, QColor("#f2f2f2"))
|
||||
palette.setColor(QPalette.ButtonText, QColor("#f2f2f2"))
|
||||
palette.setColor(QPalette.Highlight, QColor("#007acc"))
|
||||
palette.setColor(QPalette.HighlightedText, QColor("#ffffff"))
|
||||
self.setPalette(palette)
|
||||
|
||||
def showEvent(self, event):
|
||||
self._apply_opaque_background()
|
||||
super().showEvent(event)
|
||||
self._apply_opaque_background()
|
||||
|
||||
def paintEvent(self, event):
|
||||
painter = QPainter(self)
|
||||
painter.fillRect(self.rect(), self._BACKGROUND)
|
||||
painter.end()
|
||||
super().paintEvent(event)
|
||||
|
||||
|
||||
class ApiKeyEditDialog(QDialog):
|
||||
"""Dialog to create or overwrite a named API key (secret entered once)."""
|
||||
|
||||
|
|
@ -256,7 +311,7 @@ class ApiKeyEditDialog(QDialog):
|
|||
endpoint_preset_btn = QToolButton()
|
||||
endpoint_preset_btn.setText("Presets ▾")
|
||||
endpoint_preset_btn.setPopupMode(QToolButton.InstantPopup)
|
||||
endpoint_menu = QMenu(endpoint_preset_btn)
|
||||
endpoint_menu = ConfigMenu(endpoint_preset_btn)
|
||||
for label, url in (
|
||||
("OpenAI", "https://api.openai.com/v1"),
|
||||
("Claude (Anthropic)", "https://api.anthropic.com/v1"),
|
||||
|
|
@ -663,7 +718,7 @@ class ConfigTab(QWidget):
|
|||
self.api_url_preset_btn.setPopupMode(QToolButton.InstantPopup)
|
||||
self.api_url_preset_btn.setStyleSheet(btn_style)
|
||||
|
||||
api_url_menu = QMenu(self.api_url_preset_btn)
|
||||
api_url_menu = ConfigMenu(self.api_url_preset_btn)
|
||||
for name, url in (
|
||||
("OpenAI", "https://api.openai.com/v1"),
|
||||
("Claude (Anthropic)", "https://api.anthropic.com/v1"),
|
||||
|
|
|
|||
20
gui/main.py
20
gui/main.py
|
|
@ -131,6 +131,20 @@ class UpdateThread(QThread):
|
|||
return False
|
||||
return True
|
||||
|
||||
@classmethod
|
||||
def should_install_to_root(cls, rel: Path, root: Path) -> bool:
|
||||
"""Return True when *rel* may be copied into the selected install root.
|
||||
|
||||
Git expands ``.git_archival.txt`` while creating a source archive. Keep
|
||||
that expanded file in archive-only installs, but do not copy it back
|
||||
into a live checkout where it would dirty the worktree on every update.
|
||||
"""
|
||||
if not cls.should_install(rel):
|
||||
return False
|
||||
if rel.as_posix() == ".git_archival.txt" and (root / ".git").exists():
|
||||
return False
|
||||
return True
|
||||
|
||||
@classmethod
|
||||
def resolve_archive_root(cls, extract_dir: Path) -> Path:
|
||||
"""Locate the single top-level folder inside an extracted archive.
|
||||
|
|
@ -281,7 +295,11 @@ class UpdateThread(QThread):
|
|||
install_files = [
|
||||
src
|
||||
for src in extracted.rglob("*")
|
||||
if src.is_file() and self.should_install(src.relative_to(extracted))
|
||||
if src.is_file()
|
||||
and self.should_install_to_root(
|
||||
src.relative_to(extracted),
|
||||
root,
|
||||
)
|
||||
]
|
||||
if not install_files:
|
||||
raise RuntimeError(
|
||||
|
|
|
|||
36
tests/test_anthropic_content_text.py
Normal file
36
tests/test_anthropic_content_text.py
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Tests for Anthropic content-block text extraction."""
|
||||
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
|
||||
from util.translation import _anthropic_content_text
|
||||
|
||||
|
||||
class AnthropicContentTextTests(unittest.TestCase):
|
||||
def test_empty_content(self):
|
||||
self.assertEqual(_anthropic_content_text(None), "")
|
||||
self.assertEqual(_anthropic_content_text([]), "")
|
||||
|
||||
def test_text_block_only(self):
|
||||
content = [SimpleNamespace(type="text", text='{"0":"Hello"}')]
|
||||
self.assertEqual(_anthropic_content_text(content), '{"0":"Hello"}')
|
||||
|
||||
def test_skips_thinking_block_without_text(self):
|
||||
# Mirrors Anthropic ThinkingBlock: has .thinking, no .text.
|
||||
thinking = SimpleNamespace(type="thinking", thinking="internal notes")
|
||||
text = SimpleNamespace(type="text", text='{"0":"Alice"}')
|
||||
self.assertEqual(_anthropic_content_text([thinking, text]), '{"0":"Alice"}')
|
||||
|
||||
def test_accessing_first_block_text_would_fail(self):
|
||||
thinking = SimpleNamespace(type="thinking", thinking="notes")
|
||||
text = SimpleNamespace(type="text", text="ok")
|
||||
content = [thinking, text]
|
||||
with self.assertRaises(AttributeError):
|
||||
_ = content[0].text
|
||||
self.assertEqual(_anthropic_content_text(content), "ok")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
@ -68,6 +68,51 @@ class UpdateThreadInstallFilterTests(unittest.TestCase):
|
|||
with self.subTest(rel=rel):
|
||||
self.assertFalse(UpdateThread.should_install(Path(rel)))
|
||||
|
||||
def test_preserves_archive_metadata_in_git_checkout(self):
|
||||
from gui.main import UpdateThread
|
||||
|
||||
with tempfile.TemporaryDirectory() as raw:
|
||||
root = Path(raw)
|
||||
(root / ".git").mkdir()
|
||||
|
||||
self.assertFalse(
|
||||
UpdateThread.should_install_to_root(
|
||||
Path(".git_archival.txt"),
|
||||
root,
|
||||
)
|
||||
)
|
||||
self.assertTrue(
|
||||
UpdateThread.should_install_to_root(Path("gui/main.py"), root)
|
||||
)
|
||||
|
||||
def test_preserves_archive_metadata_with_git_file(self):
|
||||
"""Linked worktrees and submodules use a .git file, not a directory."""
|
||||
from gui.main import UpdateThread
|
||||
|
||||
with tempfile.TemporaryDirectory() as raw:
|
||||
root = Path(raw)
|
||||
(root / ".git").write_text("gitdir: elsewhere\n", encoding="utf-8")
|
||||
|
||||
self.assertFalse(
|
||||
UpdateThread.should_install_to_root(
|
||||
Path(".git_archival.txt"),
|
||||
root,
|
||||
)
|
||||
)
|
||||
|
||||
def test_installs_archive_metadata_outside_git_checkout(self):
|
||||
from gui.main import UpdateThread
|
||||
|
||||
with tempfile.TemporaryDirectory() as raw:
|
||||
root = Path(raw)
|
||||
|
||||
self.assertTrue(
|
||||
UpdateThread.should_install_to_root(
|
||||
Path(".git_archival.txt"),
|
||||
root,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class UpdateThreadArchiveRootTests(unittest.TestCase):
|
||||
"""Gitea zips nest files under a single top folder (repo display name)."""
|
||||
|
|
|
|||
|
|
@ -279,6 +279,33 @@ class ConfigTabRegressionTests(unittest.TestCase):
|
|||
self.assertFalse(popup.testAttribute(Qt.WA_TranslucentBackground))
|
||||
tab.model_combo.hidePopup()
|
||||
|
||||
def test_presets_menu_is_opaque(self) -> None:
|
||||
tab = self.make_tab()
|
||||
window = QMainWindow()
|
||||
self._windows.append(window)
|
||||
window.setCentralWidget(tab)
|
||||
window.resize(1280, 760)
|
||||
window.show()
|
||||
for _ in range(3):
|
||||
self._app.processEvents()
|
||||
|
||||
menu = tab.api_url_preset_btn.menu()
|
||||
menu.popup(
|
||||
tab.api_url_preset_btn.mapToGlobal(
|
||||
tab.api_url_preset_btn.rect().bottomLeft()
|
||||
)
|
||||
)
|
||||
for _ in range(3):
|
||||
self._app.processEvents()
|
||||
|
||||
self.assertTrue(menu.autoFillBackground())
|
||||
self.assertEqual(menu.palette().color(QPalette.Window).name(), "#353539")
|
||||
self.assertEqual(menu.palette().color(QPalette.Base).name(), "#353539")
|
||||
self.assertEqual(menu.windowOpacity(), 1.0)
|
||||
self.assertFalse(menu.testAttribute(Qt.WA_TranslucentBackground))
|
||||
self.assertTrue(menu.testAttribute(Qt.WA_OpaquePaintEvent))
|
||||
menu.hide()
|
||||
|
||||
def test_save_and_reload_round_trip_for_every_option(self) -> None:
|
||||
tab = self.make_tab()
|
||||
tab.disconnect_auto_save()
|
||||
|
|
|
|||
|
|
@ -1187,7 +1187,7 @@ def fetchTranslationBatches(batches=None):
|
|||
errored.append((r.custom_id, detail))
|
||||
continue
|
||||
msg = res.message
|
||||
text = "".join(getattr(b, "text", "") or "" for b in msg.content)
|
||||
text = _anthropic_content_text(msg.content)
|
||||
u = msg.usage
|
||||
cr = getattr(u, "cache_read_input_tokens", 0) or 0
|
||||
cw = getattr(u, "cache_creation_input_tokens", 0) or 0
|
||||
|
|
@ -1998,6 +1998,17 @@ def createTranslationSchema(numLines):
|
|||
}
|
||||
|
||||
|
||||
def _anthropic_content_text(content) -> str:
|
||||
"""Join text from Anthropic message content blocks.
|
||||
|
||||
Newer Claude models often return a ThinkingBlock (no ``.text``) before the
|
||||
TextBlock. Only blocks that expose ``.text`` contribute to the result.
|
||||
"""
|
||||
if not content:
|
||||
return ""
|
||||
return "".join(getattr(b, "text", "") or "" for b in content)
|
||||
|
||||
|
||||
class _AnthropicCompat:
|
||||
"""OpenAI-shaped wrapper around an Anthropic response (text + usage)."""
|
||||
class _Usage:
|
||||
|
|
@ -2232,7 +2243,7 @@ def translateText(system, user, history, penalty, formatType, model, numLines=No
|
|||
except Exception as e:
|
||||
raise Exception(f"Anthropic API error: {e}")
|
||||
|
||||
_ant_text = ant_resp.content[0].text if ant_resp.content else ""
|
||||
_ant_text = _anthropic_content_text(ant_resp.content)
|
||||
_u = ant_resp.usage
|
||||
_cr = getattr(_u, "cache_read_input_tokens", 0) or 0
|
||||
_cw = getattr(_u, "cache_creation_input_tokens", 0) or 0
|
||||
|
|
|
|||
Loading…
Reference in a new issue