AttributeError: 'function' object has no attribute 'study_queues'

Anki 24.11 (87ccd24e)  (ao)
Python 3.9.18 Qt 6.6.2 PyQt 6.6.1
Platform: macOS-12.7.6-arm64-arm-64bit


Traceback (most recent call last):
  File "aqt.taskman", line 144, in _on_closures_pending
  File "aqt.taskman", line 88, in <lambda>
  File "aqt.taskman", line 108, in wrapped_done
  File "aqt.operations", line 130, in wrapped_done
  File "aqt.operations", line 159, in on_op_finished
  File "_aqt.hooks", line 3840, in __call__
  File "aqt.main", line 845, in on_operation_did_execute
  File "aqt.deckbrowser", line 94, in op_executed
AttributeError: 'function' object has no attribute 'study_queues'

I wrote a plugin with Gemini 2.0 that uses AI to generate readings based on what I’ve learned today, and it generates the readings correctly, but after generating them there is always the error reported above that comes up. I’ve had the AI fix it several times, but it’s not getting any better. Here are the contents of the py file.

import json
from aqt import mw
from aqt.qt import *
from aqt.utils import showInfo, showText, checkInvalidFilename
from aqt.webview import AnkiWebView
import os
from anki.hooks import addHook
import requests
from typing import Any, Optional, Tuple, Union

# --- Compatibility Import ---
try:
    # Newer Anki versions (23.10+)
    from aqt.operations import QueryOp
    from aqt.operations import CollectionOp
except ImportError:
    # Older Anki versions
    from anki.scheduler.base import CollectionOp as co

# --- Result Wrapper ---
class SimpleResult:
    def __init__(self, result: Optional[dict] = None):
        self.result = result
        # Indicate changes if result contains "result" key
        self._changes = "result" in (result or {})

    def changes(self) -> bool:
        return self._changes

# --- 配置相关 ---
config_file = os.path.join(os.path.dirname(__file__), "config.json")

default_config = {
    "api_token": "YOUR_DEFAULT_API_TOKEN",
    "request_url": "YOUR_DEFAULT_REQUEST_URL",
    "model_name": "YOUR_DEFAULT_MODEL_NAME",
    "max_tokens": 4096,
    "field_name": "VocabKanji",
    "temperature": 0.7,
    "top_p": 0.7,
    "top_k": 50,
    "frequency_penalty": 0.5,
    "stop_sequences": [],
    "enable_cache": True,
    "prompt_template": "你是一名日本の小説家,请根据日语单词生成一篇小说,必须用日语进行回答,只生成简短的小说,不产生其他内容:\n\n{vocab_list}",
    "note_type_name": "Basic",
    "target_field_name": "Reading",
    "add_to_card": True,  # Option to add the generated article to a card
}

def load_config():
    try:
        with open(config_file, "r") as f:
            config = json.load(f)

        # 填充默认值
        for key, value in default_config.items():
            if key not in config:
                config[key] = value

        return config
    except FileNotFoundError:
        return default_config

def save_config(config):
    with open(config_file, "w") as f:
        json.dump(config, f, indent=4)

# --- 缓存 ---
cache = {}

def load_cache():
    global cache
    cache_file = os.path.join(os.path.dirname(__file__), "cache.json")
    try:
        with open(cache_file, "r") as f:
            cache = json.load(f)
    except FileNotFoundError:
        cache = {}

def save_cache():
    cache_file = os.path.join(os.path.dirname(__file__), "cache.json")
    with open(cache_file, "w") as f:
        json.dump(cache, f, indent=4)

# --- 配置界面 ---
class ConfigDialog(QDialog):
    def __init__(self, parent=None):
        super().__init__(parent)
        self.setWindowTitle("AI 服务配置")
        self.setMinimumWidth(500)
        form_layout = QFormLayout()

        self.api_token_edit = QLineEdit()
        form_layout.addRow("API 密钥:", self.api_token_edit)

        self.request_url_edit = QLineEdit()
        form_layout.addRow("请求 URL:", self.request_url_edit)

        self.model_name_edit = QLineEdit()
        form_layout.addRow("模型名称:", self.model_name_edit)

        self.max_tokens_edit = QSpinBox()
        self.max_tokens_edit.setRange(1, 100000)
        self.max_tokens_edit.setSingleStep(100)
        form_layout.addRow("最大 Token 数:", self.max_tokens_edit)

        self.field_name_edit = QLineEdit()
        form_layout.addRow("单词字段名:", self.field_name_edit)

        self.temperature_edit = QDoubleSpinBox()
        self.temperature_edit.setRange(0.0, 1.0)
        self.temperature_edit.setSingleStep(0.1)
        form_layout.addRow("Temperature:", self.temperature_edit)

        self.top_p_edit = QDoubleSpinBox()
        self.top_p_edit.setRange(0.0, 1.0)
        self.top_p_edit.setSingleStep(0.1)
        form_layout.addRow("Top P:", self.top_p_edit)

        self.top_k_edit = QSpinBox()
        self.top_k_edit.setRange(0, 100)
        self.top_k_edit.setSingleStep(1)
        form_layout.addRow("Top K:", self.top_k_edit)

        self.frequency_penalty_edit = QDoubleSpinBox()
        self.frequency_penalty_edit.setRange(0.0, 2.0)
        self.frequency_penalty_edit.setSingleStep(0.1)
        form_layout.addRow("Frequency Penalty:", self.frequency_penalty_edit)

        self.stop_sequences_edit = QLineEdit()
        form_layout.addRow("Stop Sequences (comma-separated):", self.stop_sequences_edit)

        self.enable_cache_checkbox = QCheckBox("启用缓存")
        form_layout.addRow(self.enable_cache_checkbox)

        self.prompt_template_edit = QPlainTextEdit()
        form_layout.addRow("Prompt Template:", self.prompt_template_edit)

        self.note_type_name_edit = QLineEdit()
        form_layout.addRow("笔记类型名称:", self.note_type_name_edit)

        self.target_field_name_edit = QLineEdit()
        form_layout.addRow("目标字段名称:", self.target_field_name_edit)

        self.add_to_card_checkbox = QCheckBox("将生成的文章添加到卡片")
        form_layout.addRow(self.add_to_card_checkbox)

        self.load_from_config()

        # 兼容不同版本的 PyQt
        if hasattr(QDialogButtonBox, 'StandardButton'):
            button_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Save | QDialogButtonBox.StandardButton.Cancel)
        else:
            button_box = QDialogButtonBox(QDialogButtonBox.Save | QDialogButtonBox.Cancel)

        button_box.accepted.connect(self.accept)
        button_box.rejected.connect(self.reject)

        main_layout = QVBoxLayout()
        main_layout.addLayout(form_layout)
        main_layout.addWidget(button_box)
        self.setLayout(main_layout)

    def load_from_config(self):
        config = load_config()
        self.api_token_edit.setText(config["api_token"])
        self.request_url_edit.setText(config["request_url"])
        self.model_name_edit.setText(config["model_name"])
        self.max_tokens_edit.setValue(config["max_tokens"])
        self.field_name_edit.setText(config["field_name"])
        self.temperature_edit.setValue(config["temperature"])
        self.top_p_edit.setValue(config["top_p"])
        self.top_k_edit.setValue(config["top_k"])
        self.frequency_penalty_edit.setValue(config["frequency_penalty"])
        self.stop_sequences_edit.setText(", ".join(config["stop_sequences"]))
        self.enable_cache_checkbox.setChecked(config["enable_cache"])
        self.prompt_template_edit.setPlainText(config["prompt_template"])
        self.note_type_name_edit.setText(config["note_type_name"])
        self.target_field_name_edit.setText(config["target_field_name"])
        self.add_to_card_checkbox.setChecked(config["add_to_card"])

    def save_to_config(self):
        config = {
            "api_token": self.api_token_edit.text(),
            "request_url": self.request_url_edit.text(),
            "model_name": self.model_name_edit.text(),
            "max_tokens": self.max_tokens_edit.value(),
            "field_name": self.field_name_edit.text(),
            "temperature": self.temperature_edit.value(),
            "top_p": self.top_p_edit.value(),
            "top_k": self.top_k_edit.value(),
            "frequency_penalty": self.frequency_penalty_edit.value(),
            "stop_sequences": [seq.strip() for seq in self.stop_sequences_edit.text().split(",") if seq.strip()],
            "enable_cache": self.enable_cache_checkbox.isChecked(),
            "prompt_template": self.prompt_template_edit.toPlainText(),
            "note_type_name": self.note_type_name_edit.text(),
            "target_field_name": self.target_field_name_edit.text(),
            "add_to_card": self.add_to_card_checkbox.isChecked(),
        }
        save_config(config)

    def accept(self):
        self.save_to_config()
        super().accept()

def show_config_dialog():
    dialog = ConfigDialog(mw)
    dialog.exec()

# --- Anki 主程序 ---
def get_today_learned_cards_field(field_name):
    cids = mw.col.find_cards("rated:1")
    field_values = []
    for cid in cids:
        card = mw.col.get_card(cid)
        note = card.note()
        if field_name in note:
            field_values.append(note[field_name])
        else:
            return None
    return field_values

def generate_review_article_with_ai(vocab_list: list[str]) -> SimpleResult:
    """
    Generates a review article using AI.

    Args:
        vocab_list: A list of vocabulary words.

    Returns:
        A SimpleResult object containing the result.
    """
    config = load_config()
    api_token = config["api_token"]
    request_url = config["request_url"]
    model_name = config["model_name"]
    max_tokens = config["max_tokens"]
    temperature = config["temperature"]
    top_p = config["top_p"]
    top_k = config["top_k"]
    frequency_penalty = config["frequency_penalty"]
    stop_sequences = config["stop_sequences"]
    enable_cache = config["enable_cache"]

    vocab_str = ', '.join(vocab_list)
    prompt = config["prompt_template"].format(vocab_list=vocab_str)

    if enable_cache:
        load_cache()
        if vocab_str in cache:
            print("Using cached response")
            return SimpleResult({"result": cache[vocab_str]})

    payload = {
        "model": model_name,
        "messages": [
            {
                "role": "user",
                "content": [{"type": "text", "text": prompt}],
            }
        ],
        "stream": False,
        "max_tokens": max_tokens,
        "temperature": temperature,
        "top_p": top_p,
        "top_k": top_k,
        "frequency_penalty": frequency_penalty,
        "stop": stop_sequences,
        "n": 1,
        "response_format": {"type": "text"},
    }
    headers = {
        "Authorization": f"Bearer {api_token}",
        "Content-Type": "application/json",
    }

    try:
        response = requests.post(request_url, headers=headers, json=payload)
        response.raise_for_status()
        response_json = response.json()
        print(f"API 响应的完整 JSON: {response_json}")

        if 'choices' in response_json and response_json['choices']:
            generated_text = response_json['choices'][0]['message']['content']
            if enable_cache:
                cache[vocab_str] = generated_text
                save_cache()
            return SimpleResult({"result": generated_text})
        else:
            err_msg = "无法从 API 响应中提取文本。请检查响应结构。"
            return SimpleResult({"error": err_msg})

    except requests.exceptions.RequestException as e:
        err_msg = f"调用 AI 服务 API 出错: {e}"
        return SimpleResult({"error": err_msg})
    except (KeyError, IndexError) as e:
        err_msg = f"解析 AI 服务 API 响应出错: {e}"
        return SimpleResult({"error": err_msg})
    except json.JSONDecodeError as e:
        err_msg = f"JSON 解析错误: {e}"
        return SimpleResult({"error": err_msg})

def add_article_to_note(article):
    config = load_config()
    note_type_name = config["note_type_name"]
    target_field_name = config["target_field_name"]

    # Find the note type
    model = mw.col.models.byName(note_type_name)
    if not model:
        showInfo(f"Note type '{note_type_name}' not found.")
        return False

    # Create a new note
    note = mw.col.newNote(model['id'])
    note[target_field_name] = article

    # Add the note to the collection
    mw.col.addNote(note)
    return True # Indicate that the note was added

def on_success(simple_result: SimpleResult):
    if simple_result.result:
        if "result" in simple_result.result:
            article = simple_result.result["result"]
            showInfo(f"Generated review article:\n\n{article}")
            config = load_config()
            if config["add_to_card"] and config["note_type_name"] and config["target_field_name"]:
                if not mw.col.models.byName(config["note_type_name"]):
                    showInfo(f"Note type '{config['note_type_name']}' not found. Skipping adding article to note.")
                elif config["target_field_name"] not in mw.col.models.fieldNames(mw.col.models.byName(config["note_type_name"])):
                    showInfo(f"Field '{config['target_field_name']}' not found in note type '{config['note_type_name']}'. Skipping adding article to note.")
                else:
                    ret = showInfo("Add generated article to a new note?", type="yesno")
                    if ret == QMessageBox.Yes:
                        # Check if add_article_to_note was successful
                        success = add_article_to_note(article)
                        if success:
                          mw.reset()
        elif "error" in simple_result.result:
            # Handle the error case
            showText(f"Error: {simple_result.result['error']}")  # Display the error message
    else:
        showText("An unknown error occurred.")

def on_failure(exception):
    showText(f"Error: {exception}")

def generate_review_article_op(vocab_list) -> CollectionOp[SimpleResult]:
     return CollectionOp(
            parent=mw,
            op=lambda col: generate_review_article_with_ai(vocab_list)
        )

def test():
    config = load_config()
    vocab_kanji_values = get_today_learned_cards_field(config["field_name"])
    if vocab_kanji_values:
        showInfo("Generating article... Please wait.")
        op = generate_review_article_op(vocab_kanji_values)
        op.success(on_success).failure(on_failure).run_in_background()
    else:
        showInfo(f"No {config['field_name']} field found or no cards learned today.")

# --- 菜单项 ---
action = QAction("AI Reading Gen:生成文章", mw)
qconnect(action.triggered, test)
mw.form.menuTools.addAction(action)

# 添加配置菜单项
config_action = QAction("AI Reading Gen:AI 服务配置", mw)
qconnect(config_action.triggered, show_config_dialog)
mw.form.menuTools.addAction(config_action)

The code Gemini made is close to working. Try feeding it my response below. I’m curious to see if it’ll be able to generate a working addon with this (quite detailed) feedback. I’d like to believe giving it this much example code should let it figure out the rest.


Here’s the part in the source code that’s crashing:

It crashes because the changes argument provided is None.

Problem 1

The SimpleResult implementation in the code is incorrect. The changes property is never filled in. The code isn’t making a custom undo entry with undo_entry = mw.col.add_custom_undo_entry(undo_text).
The changes value is acquired as the return value from mw.col.merge_undo_entries(undo_entry). This is (quite vaguely) mentioned in the anki source here:

Problem 2

The code is performing database changing operations in the on_success function following the CollectionOp which is erroneous usage. In fact using CollectionOp for the AI call is unnecessary as it neither queries nor changes the Anki db. Database changes, like creating new notes in this case, are supposed to be done within a CollectionOp. So, add_article_to_note should be called within a CollectionOp. The on_success function’s job should be to just show a tooltip about the whether the op was succesful or not.

Suggested fixes

SimpleResult could be like below. It won’t be used in the CollectionOp.

class SimpleResult:
    def __init__(self, result: Optional[dict] = None):
        self.result = result

Performing the changes should look something like this:

from anki.collection import OpChanges

def generate_review_article_with_ai(article):
   #  no change

def add_article_to_note(article):
    undo_entry = mw.col.add_custom_undo_entry("Add new note from generated article")
    # previous code except
    mw.col.add_note(note)
    # return the OpChanges for op_executed to handle
    return mw.col.merge_undo_entries(undo_entry)

def on_success(op_changes: OpChanges):
    # show simple tooltip like "Adding note successful"

def on_failure(exception: Exception)
    # show tooltip with the exception

def generate_review_article_op(vocab_list) -> CollectionOp[OpChanges]:
     simple_result = generate_review_article_with_ai(vocab_list)
     # code from previous on_success used to handle simple_result here
     # but change the part that calls add_article_to_note
                    if ret == QMessageBox.Yes:
                        op = lambda col: add_article_to_note(article)
                        return CollectionOp(
                            op=op
                        ).success(on_success).failure(on_failure).run_in_background()
        elif "error" in simple_result.result:
            # same as before

def test():
    config = load_config()
    vocab_kanji_values = get_today_learned_cards_field(config["field_name"])
    if vocab_kanji_values:
        showInfo("Generating article... Please wait.")
        return generate_review_article_op(vocab_kanji_values)
    else:
        showInfo(f"No {config['field_name']} field found or no cards learned today.")

2 Likes

Thanks to your answer, the above problem no longer occurs. But there is a new problem, after trying to generate articles, anki crashes, I tried to get Gemini to fix it, but Gemini introduces modules that don’t exist in aqt, here is Gemini’s analysis of the crash report:

1. Application and System Overview:

  • Process: anki [17408] (crashed process name and ID)
  • Path: /Applications/Anki.app/Contents/MacOS/anki (application path)
  • Identifier: net.ankiweb.dtop (application bundle identifier)
  • Version: 24.11 (???) (application version)
  • Code Type: ARM-64 (Native) (application architecture)
  • Date/Time: 2024-12-25 11:27:51.1433 +0900 (crash timestamp)
  • OS Version: macOS 12.7.6 (21H1320) (operating system version)
  • Report Version: 12 (crash report format version)
  • Model: MacBookAir10,1 (device model)

2. Crash Specifics:

  • Crashed Thread: 30 (thread that caused the crash)
  • Exception Type: EXC_CRASH (SIGABRT) (exception type: abnormal termination)
  • Exception Codes: 0x0000000000000000, 0x0000000000000000 (exception codes)
  • Exception Note: EXC_CORPSE_NOTIFY (indicates a crash report was generated)
  • Application Specific Information: abort() called (application-specific details: abort() function was invoked)

3. Root Cause Analysis (Key Insights):

  • Application Specific Backtrace: This backtrace provides a chain of function calls leading to the crash. Key highlights:
    • abort() was called (frame 2 in the crashed thread).
    • objc_exception_throw was called (frame 9 in the crashed thread).
    • -[NSException raise] was called (frame 10 in the crashed thread).
    • -[NSWindow(NSWindow_Theme) _postWindowNeedsToResetDragMarginsUnlessPostingDisabled] was called (frame 11 in the crashed thread), suggesting a potential issue related to window drag margins.
    • Functions related to NSWindow, NSPanel, NSAlert, QMessageBox, and QDialog are involved, indicating a potential problem with UI component creation or display .
    • Involvement of QtWidgets.abi3.so and libqcocoa.dylib points to a possible issue with the Qt framework .
  • Thread 30 (Crashed): The backtrace of this thread shows the function call stack at the time of the crash.
  • Thread State (Thread 30 Crashed): Register states provide low-level details about the crash. pc: 0x000000019d07ed38 points to __pthread_kill in libsystem_kernel.dylib, indicating the thread was terminated.

4. Other Threads:

  • The report lists multiple other threads, including the main thread (Thread 0) and several threads related to QtWebEngine (e.g., Thread 9, 12, 18, 19, 23, 24).
  • Most of these threads are in a waiting state (e.g., __psynch_cvwait, __select, kevent64), suggesting they were not directly involved in the crash.

5. Summary and Conclusion:

The crash report indicates that the Anki application crashed due to an abort() call. Based on the function call chain, the issue likely stems from UI component creation or display , potentially related to the Qt framework’s implementation on macOS. Specifically, the invocation of -[NSWindow(NSWindow_Theme) _postWindowNeedsToResetDragMarginsUnlessPostingDisabled] suggests a possible error concerning window drag margins.

6. Recommended Next Steps:

  • Contact Anki Developers: Submit this report to the Anki development team. They can leverage this information for further investigation and resolution.
  • Investigate Qt Version: Check if the Qt version used by Anki has known UI-related issues on macOS 12.7.6.
  • Update/Downgrade: Try updating Anki to the latest version or reverting to an earlier version to see if the problem persists.
  • Check Add-ons: If Anki add-ons are in use, try disabling them to determine if they contribute to the crash.

In essence, this crash report provides valuable clues about the cause of the Anki application crash, pinpointing UI elements and the Qt framework as potential culprits. The provided recommendations offer a path towards resolving the issue.

Here’s the code.

import json
from aqt import mw
from aqt.qt import *
from aqt.utils import showInfo, showText, checkInvalidFilename
from aqt.webview import AnkiWebView
import os
from anki.hooks import addHook
import requests
from typing import Any, Optional, Tuple, Union

# --- Compatibility Import ---
try:
    # Newer Anki versions (23.10+)
    from aqt.operations import QueryOp
    from aqt.operations import CollectionOp
    from anki.collection import OpChanges
except ImportError:
    # Older Anki versions
    from anki.scheduler.base import CollectionOp as co
    from anki.collection import Collection as co

# --- Result Wrapper (Simplified) ---
class SimpleResult:
    def __init__(self, result: Optional[dict] = None):
        self.result = result

# --- Configuration ---
config_file = os.path.join(os.path.dirname(__file__), "config.json")

default_config = {
    "api_token": "YOUR_DEFAULT_API_TOKEN",
    "request_url": "YOUR_DEFAULT_REQUEST_URL",
    "model_name": "YOUR_DEFAULT_MODEL_NAME",
    "max_tokens": 4096,
    "field_name": "VocabKanji",
    "temperature": 0.7,
    "top_p": 0.7,
    "top_k": 50,
    "frequency_penalty": 0.5,
    "stop_sequences": [],
    "enable_cache": True,
    "prompt_template": "你是一名日本の小説家,请根据日语单词生成一篇小说,必须用日语进行回答,只生成简短的小说,不产生其他内容:\n\n{vocab_list}",
    "note_type_name": "Basic",
    "target_field_name": "Reading",
    "add_to_card": True,  # Option to add the generated article to a card
}

def load_config():
    try:
        with open(config_file, "r") as f:
            config = json.load(f)

        # Fill in default values
        for key, value in default_config.items():
            if key not in config:
                config[key] = value

        return config
    except FileNotFoundError:
        return default_config

def save_config(config):
    with open(config_file, "w") as f:
        json.dump(config, f, indent=4)

# --- Cache ---
cache = {}

def load_cache():
    global cache
    cache_file = os.path.join(os.path.dirname(__file__), "cache.json")
    try:
        with open(cache_file, "r") as f:
            cache = json.load(f)
    except FileNotFoundError:
        cache = {}

def save_cache():
    cache_file = os.path.join(os.path.dirname(__file__), "cache.json")
    with open(cache_file, "w") as f:
        json.dump(cache, f, indent=4)

# --- Configuration Dialog ---
class ConfigDialog(QDialog):
    def __init__(self, parent=None):
        super().__init__(parent)
        self.setWindowTitle("AI 服务配置")
        self.setMinimumWidth(500)
        form_layout = QFormLayout()

        self.api_token_edit = QLineEdit()
        form_layout.addRow("API 密钥:", self.api_token_edit)

        self.request_url_edit = QLineEdit()
        form_layout.addRow("请求 URL:", self.request_url_edit)

        self.model_name_edit = QLineEdit()
        form_layout.addRow("模型名称:", self.model_name_edit)

        self.max_tokens_edit = QSpinBox()
        self.max_tokens_edit.setRange(1, 100000)
        self.max_tokens_edit.setSingleStep(100)
        form_layout.addRow("最大 Token 数:", self.max_tokens_edit)

        self.field_name_edit = QLineEdit()
        form_layout.addRow("单词字段名:", self.field_name_edit)

        self.temperature_edit = QDoubleSpinBox()
        self.temperature_edit.setRange(0.0, 1.0)
        self.temperature_edit.setSingleStep(0.1)
        form_layout.addRow("Temperature:", self.temperature_edit)

        self.top_p_edit = QDoubleSpinBox()
        self.top_p_edit.setRange(0.0, 1.0)
        self.top_p_edit.setSingleStep(0.1)
        form_layout.addRow("Top P:", self.top_p_edit)

        self.top_k_edit = QSpinBox()
        self.top_k_edit.setRange(0, 100)
        self.top_k_edit.setSingleStep(1)
        form_layout.addRow("Top K:", self.top_k_edit)

        self.frequency_penalty_edit = QDoubleSpinBox()
        self.frequency_penalty_edit.setRange(0.0, 2.0)
        self.frequency_penalty_edit.setSingleStep(0.1)
        form_layout.addRow("Frequency Penalty:", self.frequency_penalty_edit)

        self.stop_sequences_edit = QLineEdit()
        form_layout.addRow("Stop Sequences (comma-separated):", self.stop_sequences_edit)

        self.enable_cache_checkbox = QCheckBox("启用缓存")
        form_layout.addRow(self.enable_cache_checkbox)

        self.prompt_template_edit = QPlainTextEdit()
        form_layout.addRow("Prompt Template:", self.prompt_template_edit)

        self.note_type_name_edit = QLineEdit()
        form_layout.addRow("笔记类型名称:", self.note_type_name_edit)

        self.target_field_name_edit = QLineEdit()
        form_layout.addRow("目标字段名称:", self.target_field_name_edit)

        self.add_to_card_checkbox = QCheckBox("将生成的文章添加到卡片")
        form_layout.addRow(self.add_to_card_checkbox)

        self.load_from_config()

        # Compatible with different versions of PyQt
        if hasattr(QDialogButtonBox, 'StandardButton'):
            button_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Save | QDialogButtonBox.StandardButton.Cancel)
        else:
            button_box = QDialogButtonBox(QDialogButtonBox.Save | QDialogButtonBox.Cancel)

        button_box.accepted.connect(self.accept)
        button_box.rejected.connect(self.reject)

        main_layout = QVBoxLayout()
        main_layout.addLayout(form_layout)
        main_layout.addWidget(button_box)
        self.setLayout(main_layout)

    def load_from_config(self):
        config = load_config()
        self.api_token_edit.setText(config["api_token"])
        self.request_url_edit.setText(config["request_url"])
        self.model_name_edit.setText(config["model_name"])
        self.max_tokens_edit.setValue(config["max_tokens"])
        self.field_name_edit.setText(config["field_name"])
        self.temperature_edit.setValue(config["temperature"])
        self.top_p_edit.setValue(config["top_p"])
        self.top_k_edit.setValue(config["top_k"])
        self.frequency_penalty_edit.setValue(config["frequency_penalty"])
        self.stop_sequences_edit.setText(", ".join(config["stop_sequences"]))
        self.enable_cache_checkbox.setChecked(config["enable_cache"])
        self.prompt_template_edit.setPlainText(config["prompt_template"])
        self.note_type_name_edit.setText(config["note_type_name"])
        self.target_field_name_edit.setText(config["target_field_name"])
        self.add_to_card_checkbox.setChecked(config["add_to_card"])

    def save_to_config(self):
        config = {
            "api_token": self.api_token_edit.text(),
            "request_url": self.request_url_edit.text(),
            "model_name": self.model_name_edit.text(),
            "max_tokens": self.max_tokens_edit.value(),
            "field_name": self.field_name_edit.text(),
            "temperature": self.temperature_edit.value(),
            "top_p": self.top_p_edit.value(),
            "top_k": self.top_k_edit.value(),
            "frequency_penalty": self.frequency_penalty_edit.value(),
            "stop_sequences": [seq.strip() for seq in self.stop_sequences_edit.text().split(",") if seq.strip()],
            "enable_cache": self.enable_cache_checkbox.isChecked(),
            "prompt_template": self.prompt_template_edit.toPlainText(),
            "note_type_name": self.note_type_name_edit.text(),
            "target_field_name": self.target_field_name_edit.text(),
            "add_to_card": self.add_to_card_checkbox.isChecked(),
        }
        save_config(config)

    def accept(self):
        self.save_to_config()
        super().accept()

def show_config_dialog():
    dialog = ConfigDialog(mw)
    dialog.exec()

# --- Anki Main Logic ---

def get_today_learned_cards_field(field_name):
    cids = mw.col.find_cards("rated:1")
    field_values = []
    for cid in cids:
        card = mw.col.get_card(cid)
        note = card.note()
        if field_name in note:
            field_values.append(note[field_name])
        else:
            return None
    return field_values

def generate_review_article_with_ai(vocab_list: list[str]) -> SimpleResult:
    """
    Generates a review article using AI. This function now only handles the AI call
    and returns a SimpleResult (no database changes here).
    """
    config = load_config()
    api_token = config["api_token"]
    request_url = config["request_url"]
    model_name = config["model_name"]
    max_tokens = config["max_tokens"]
    temperature = config["temperature"]
    top_p = config["top_p"]
    top_k = config["top_k"]
    frequency_penalty = config["frequency_penalty"]
    stop_sequences = config["stop_sequences"]
    enable_cache = config["enable_cache"]

    vocab_str = ', '.join(vocab_list)
    prompt = config["prompt_template"].format(vocab_list=vocab_str)

    if enable_cache:
        load_cache()
        if vocab_str in cache:
            print("Using cached response")
            return SimpleResult({"result": cache[vocab_str]})

    payload = {
        "model": model_name,
        "messages": [
            {
                "role": "user",
                "content": [{"type": "text", "text": prompt}],
            }
        ],
        "stream": False,
        "max_tokens": max_tokens,
        "temperature": temperature,
        "top_p": top_p,
        "top_k": top_k,
        "frequency_penalty": frequency_penalty,
        "stop": stop_sequences,
        "n": 1,
        "response_format": {"type": "text"},
    }
    headers = {
        "Authorization": f"Bearer {api_token}",
        "Content-Type": "application/json",
    }

    try:
        response = requests.post(request_url, headers=headers, json=payload)
        response.raise_for_status()
        response_json = response.json()
        print(f"API 响应的完整 JSON: {response_json}")

        if 'choices' in response_json and response_json['choices']:
            generated_text = response_json['choices'][0]['message']['content']
            if enable_cache:
                cache[vocab_str] = generated_text
                save_cache()
            return SimpleResult({"result": generated_text})
        else:
            err_msg = "无法从 API 响应中提取文本。请检查响应结构。"
            return SimpleResult({"error": err_msg})

    except requests.exceptions.RequestException as e:
        err_msg = f"调用 AI 服务 API 出错: {e}"
        return SimpleResult({"error": err_msg})
    except (KeyError, IndexError) as e:
        err_msg = f"解析 AI 服务 API 响应出错: {e}"
        return SimpleResult({"error": err_msg})
    except json.JSONDecodeError as e:
        err_msg = f"JSON 解析错误: {e}"
        return SimpleResult({"error": err_msg})

def add_article_to_note_op(article: str) -> CollectionOp[OpChanges]:
    """
    CollectionOp to add the generated article to a new note. This now handles
    database changes and undo/changes tracking.
    """

    def add_article_to_note(col) -> OpChanges:
        config = load_config()
        note_type_name = config["note_type_name"]
        target_field_name = config["target_field_name"]

        # Find the note type
        model = col.models.byName(note_type_name)
        if not model:
            showInfo(f"Note type '{note_type_name}' not found.")
            return OpChanges()  # No changes if note type not found

        # Create a new note
        note = col.newNote(model['id'])
        note[target_field_name] = article

        # Add the note to the collection and track changes
        undo_entry = col.add_custom_undo_entry("Add new note from generated article")
        col.add_note(note)
        return col.merge_undo_entries(undo_entry)

    return CollectionOp(parent=mw, op=add_article_to_note)

def on_ai_generation_success(simple_result: SimpleResult):
    """
    Handles the result of the AI generation (non-database operation).
    Asks the user if they want to add the article to a note and, if so,
    triggers the add_article_to_note_op.
    """
    if simple_result.result:
        if "result" in simple_result.result:
            article = simple_result.result["result"]
            showInfo(f"Generated review article:\n\n{article}")
            config = load_config()
            if config["add_to_card"] and config["note_type_name"] and config["target_field_name"]:
                if not mw.col.models.byName(config["note_type_name"]):
                    showInfo(f"Note type '{config['note_type_name']}' not found. Skipping adding article to note.")
                elif config["target_field_name"] not in mw.col.models.fieldNames(mw.col.models.byName(config["note_type_name"])):
                    showInfo(f"Field '{config['target_field_name']}' not found in note type '{config['note_type_name']}'. Skipping adding article to note.")
                else:
                    ret = showInfo("Add generated article to a new note?", type="yesno")
                    if ret == QMessageBox.Yes:
                        add_article_to_note_op(article).success(on_add_note_success).failure(on_failure).run_in_background()
        elif "error" in simple_result.result:
            showText(f"Error: {simple_result.result['error']}")
    else:
        showText("An unknown error occurred.")

def on_add_note_success(op_changes: OpChanges):
    """
    Handles the successful completion of the add_article_to_note_op.
    """
    mw.reset()  # Refresh the main window to show the new note
    showInfo("Article added to new note successfully!")

def on_failure(exception: Exception):
    """
    Handles any failure in CollectionOps.
    """
    showText(f"Error: {exception}")

def generate_review_article_background(vocab_list):
    """
    Starts the background task to generate the review article using AI.
    This does not involve database changes, so it doesn't need to be a CollectionOp.
    """
    simple_result = generate_review_article_with_ai(vocab_list)
    on_ai_generation_success(simple_result)

def test():
    config = load_config()
    vocab_values = get_today_learned_cards_field(config["field_name"])
    if vocab_values:
        showInfo("Generating article... Please wait.")
        mw.taskman.run_in_background(lambda: generate_review_article_background(vocab_values))
    else:
        showInfo(f"No {config['field_name']} field found or no cards learned today.")

# --- Menu Actions ---
action = QAction("AI Reading Gen:Generate Articles", mw)
qconnect(action.triggered, test)
mw.form.menuTools.addAction(action)

config_action = QAction("AI Reading Gen:AI service configuration", mw)
qconnect(config_action.triggered, show_config_dialog)
mw.form.menuTools.addAction(config_action)

I solved this problem with Claude sonnet 3.5. Here is the code:

import json
from aqt import mw
from aqt.qt import *
from aqt.utils import showInfo, showText, checkInvalidFilename
from aqt.webview import AnkiWebView
import os
from anki.hooks import addHook
import requests
from typing import Any, Optional, Tuple, Union

# --- Compatibility Import ---
try:
    # Newer Anki versions (23.10+)
    from aqt.operations import QueryOp
    from aqt.operations import CollectionOp
    from anki.collection import OpChanges
except ImportError:
    # Older Anki versions
    from anki.scheduler.base import CollectionOp as co
    from anki.collection import Collection as co

# --- Result Wrapper (Simplified) ---
class SimpleResult:
    def __init__(self, result: Optional[dict] = None):
        self.result = result

# --- Safe UI Operations ---
def safe_show_info(message, type=None):
    """Safely show info dialog on main thread"""
    def show():
        return showInfo(message, type=type)
    return mw.taskman.run_on_main(show)

def safe_show_text(message):
    """Safely show text dialog on main thread"""
    def show():
        return showText(message)
    return mw.taskman.run_on_main(show)

# --- Configuration ---
config_file = os.path.join(os.path.dirname(__file__), "config.json")

default_config = {
    "api_token": "YOUR_DEFAULT_API_TOKEN",
    "request_url": "YOUR_DEFAULT_REQUEST_URL",
    "model_name": "YOUR_DEFAULT_MODEL_NAME",
    "max_tokens": 4096,
    "field_name": "VocabKanji",
    "temperature": 0.7,
    "top_p": 0.7,
    "top_k": 50,
    "frequency_penalty": 0.5,
    "stop_sequences": [],
    "enable_cache": True,
    "prompt_template": "你是一名日本の小説家,请根据日语单词生成一篇小说,必须用日语进行回答,只生成简短的小说,不产生其他内容:\n\n{vocab_list}",
    "note_type_name": "Basic",
    "target_field_name": "Reading",
    "add_to_card": True,
}

def load_config():
    try:
        with open(config_file, "r", encoding='utf-8') as f:
            config = json.load(f)
        # Fill in default values
        for key, value in default_config.items():
            if key not in config:
                config[key] = value
        return config
    except (FileNotFoundError, json.JSONDecodeError) as e:
        safe_show_text(f"Error loading config: {str(e)}\nUsing default configuration.")
        return default_config

def save_config(config):
    try:
        with open(config_file, "w", encoding='utf-8') as f:
            json.dump(config, f, indent=4, ensure_ascii=False)
    except Exception as e:
        safe_show_text(f"Error saving config: {str(e)}")

# --- Cache ---
cache = {}

def load_cache():
    global cache
    cache_file = os.path.join(os.path.dirname(__file__), "cache.json")
    try:
        with open(cache_file, "r", encoding='utf-8') as f:
            cache = json.load(f)
    except (FileNotFoundError, json.JSONDecodeError):
        cache = {}

def save_cache():
    cache_file = os.path.join(os.path.dirname(__file__), "cache.json")
    try:
        with open(cache_file, "w", encoding='utf-8') as f:
            json.dump(cache, f, indent=4, ensure_ascii=False)
    except Exception as e:
        safe_show_text(f"Error saving cache: {str(e)}")

class ConfigDialog(QDialog):
    def __init__(self, parent=None):
        try:
            super().__init__(parent)
            self.setup_ui()
            self.load_from_config()
        except Exception as e:
            safe_show_text(f"Error initializing config dialog: {str(e)}")
            self.reject()

    def setup_ui(self):
        self.setWindowTitle("AI 服务配置")
        self.setMinimumWidth(500)
        
        layout = QVBoxLayout()
        form_layout = QFormLayout()

        # Create and add all input fields
        self.api_token_edit = QLineEdit()
        form_layout.addRow("API 密钥:", self.api_token_edit)

        self.request_url_edit = QLineEdit()
        form_layout.addRow("请求 URL:", self.request_url_edit)

        self.model_name_edit = QLineEdit()
        form_layout.addRow("模型名称:", self.model_name_edit)

        self.max_tokens_edit = QSpinBox()
        self.max_tokens_edit.setRange(1, 100000)
        self.max_tokens_edit.setSingleStep(100)
        form_layout.addRow("最大 Token 数:", self.max_tokens_edit)

        self.field_name_edit = QLineEdit()
        form_layout.addRow("单词字段名:", self.field_name_edit)

        self.temperature_edit = QDoubleSpinBox()
        self.temperature_edit.setRange(0.0, 1.0)
        self.temperature_edit.setSingleStep(0.1)
        form_layout.addRow("Temperature:", self.temperature_edit)

        self.top_p_edit = QDoubleSpinBox()
        self.top_p_edit.setRange(0.0, 1.0)
        self.top_p_edit.setSingleStep(0.1)
        form_layout.addRow("Top P:", self.top_p_edit)

        self.top_k_edit = QSpinBox()
        self.top_k_edit.setRange(0, 100)
        self.top_k_edit.setSingleStep(1)
        form_layout.addRow("Top K:", self.top_k_edit)

        self.frequency_penalty_edit = QDoubleSpinBox()
        self.frequency_penalty_edit.setRange(0.0, 2.0)
        self.frequency_penalty_edit.setSingleStep(0.1)
        form_layout.addRow("Frequency Penalty:", self.frequency_penalty_edit)

        self.stop_sequences_edit = QLineEdit()
        form_layout.addRow("Stop Sequences (comma-separated):", self.stop_sequences_edit)

        self.enable_cache_checkbox = QCheckBox("启用缓存")
        form_layout.addRow(self.enable_cache_checkbox)

        self.prompt_template_edit = QPlainTextEdit()
        form_layout.addRow("Prompt Template:", self.prompt_template_edit)

        self.note_type_name_edit = QLineEdit()
        form_layout.addRow("笔记类型名称:", self.note_type_name_edit)

        self.target_field_name_edit = QLineEdit()
        form_layout.addRow("目标字段名称:", self.target_field_name_edit)

        self.add_to_card_checkbox = QCheckBox("将生成的文章添加到卡片")
        form_layout.addRow(self.add_to_card_checkbox)

        layout.addLayout(form_layout)

        # Create buttons layout
        button_layout = QHBoxLayout()
        
        # Create Save and Cancel buttons
        save_button = QPushButton("保存")
        save_button.clicked.connect(self.accept)
        
        cancel_button = QPushButton("取消")
        cancel_button.clicked.connect(self.reject)
        
        button_layout.addStretch()
        button_layout.addWidget(save_button)
        button_layout.addWidget(cancel_button)
        
        layout.addLayout(button_layout)

        self.setLayout(layout)

    def load_from_config(self):
        try:
            config = load_config()
            self.api_token_edit.setText(config["api_token"])
            self.request_url_edit.setText(config["request_url"])
            self.model_name_edit.setText(config["model_name"])
            self.max_tokens_edit.setValue(config["max_tokens"])
            self.field_name_edit.setText(config["field_name"])
            self.temperature_edit.setValue(config["temperature"])
            self.top_p_edit.setValue(config["top_p"])
            self.top_k_edit.setValue(config["top_k"])
            self.frequency_penalty_edit.setValue(config["frequency_penalty"])
            self.stop_sequences_edit.setText(", ".join(config["stop_sequences"]))
            self.enable_cache_checkbox.setChecked(config["enable_cache"])
            self.prompt_template_edit.setPlainText(config["prompt_template"])
            self.note_type_name_edit.setText(config["note_type_name"])
            self.target_field_name_edit.setText(config["target_field_name"])
            self.add_to_card_checkbox.setChecked(config["add_to_card"])
        except Exception as e:
            safe_show_text(f"Error loading configuration: {str(e)}")

    def save_to_config(self):
        try:
            config = {
                "api_token": self.api_token_edit.text(),
                "request_url": self.request_url_edit.text(),
                "model_name": self.model_name_edit.text(),
                "max_tokens": self.max_tokens_edit.value(),
                "field_name": self.field_name_edit.text(),
                "temperature": self.temperature_edit.value(),
                "top_p": self.top_p_edit.value(),
                "top_k": self.top_k_edit.value(),
                "frequency_penalty": self.frequency_penalty_edit.value(),
                "stop_sequences": [seq.strip() for seq in self.stop_sequences_edit.text().split(",") if seq.strip()],
                "enable_cache": self.enable_cache_checkbox.isChecked(),
                "prompt_template": self.prompt_template_edit.toPlainText(),
                "note_type_name": self.note_type_name_edit.text(),
                "target_field_name": self.target_field_name_edit.text(),
                "add_to_card": self.add_to_card_checkbox.isChecked(),
            }
            save_config(config)
        except Exception as e:
            safe_show_text(f"Error saving configuration: {str(e)}")

    def accept(self):
        self.save_to_config()
        super().accept()

def show_config_dialog():
    try:
        dialog = ConfigDialog(mw)
        dialog.finished.connect(lambda: dialog.deleteLater())
        dialog.exec()
    except Exception as e:
        safe_show_text(f"Error showing config dialog: {str(e)}")

# --- Anki Main Logic ---
def get_today_learned_cards_field(field_name):
    try:
        cids = mw.col.find_cards("rated:1")
        field_values = []
        for cid in cids:
            card = mw.col.get_card(cid)
            note = card.note()
            if field_name in note:
                field_values.append(note[field_name])
        return field_values if field_values else None
    except Exception as e:
        safe_show_text(f"Error getting today's learned cards: {str(e)}")
        return None

def generate_review_article_with_ai(vocab_list: list[str]) -> SimpleResult:
    """
    Generates a review article using AI.
    """
    try:
        config = load_config()
        
        vocab_str = ', '.join(vocab_list)
        prompt = config["prompt_template"].format(vocab_list=vocab_str)

        if config["enable_cache"]:
            load_cache()
            if vocab_str in cache:
                return SimpleResult({"result": cache[vocab_str]})

        payload = {
            "model": config["model_name"],
            "messages": [
                {
                    "role": "user",
                    "content": [{"type": "text", "text": prompt}],
                }
            ],
            "stream": False,
            "max_tokens": config["max_tokens"],
            "temperature": config["temperature"],
            "top_p": config["top_p"],
            "top_k": config["top_k"],
            "frequency_penalty": config["frequency_penalty"],
            "stop": config["stop_sequences"],
            "n": 1,
            "response_format": {"type": "text"},
        }
        
        headers = {
            "Authorization": f"Bearer {config['api_token']}",
            "Content-Type": "application/json",
        }

        response = requests.post(config["request_url"], headers=headers, json=payload)
        response.raise_for_status()
        response_json = response.json()

        if 'choices' in response_json and response_json['choices']:
            generated_text = response_json['choices'][0]['message']['content']
            if config["enable_cache"]:
                cache[vocab_str] = generated_text
                save_cache()
            return SimpleResult({"result": generated_text})
        else:
            return SimpleResult({"error": "无法从 API 响应中提取文本"})

    except requests.exceptions.RequestException as e:
        return SimpleResult({"error": f"API 请求错误: {str(e)}"})
    except Exception as e:
        return SimpleResult({"error": f"生成文章时发生错误: {str(e)}"})

def add_article_to_note_op(article: str) -> CollectionOp[OpChanges]:
    """
    CollectionOp to add the generated article to a new note.
    """
    def add_article_to_note(col) -> OpChanges:
        config = load_config()
        note_type_name = config["note_type_name"]
        target_field_name = config["target_field_name"]

        model = col.models.byName(note_type_name)
        if not model:
            raise Exception(f"Note type '{note_type_name}' not found")

        note = col.newNote(model['id'])
        note[target_field_name] = article

        undo_entry = col.add_custom_undo_entry("Add new note from generated article")
        col.add_note(note)
        return col.merge_undo_entries(undo_entry)

    return CollectionOp(parent=mw, op=add_article_to_note)

def on_add_note_success(op_changes: OpChanges):
    """
    Handles successful note addition.
    """
    mw.reset()
    safe_show_info("Article added to new note successfully!")

def on_failure(exception: Exception):
    """
    Handles operation failures.
    """
    safe_show_text(f"Operation failed: {str(exception)}")

def on_ai_generation_success(simple_result: SimpleResult):
    """
    Handles the result of AI generation.
    """
    def handle_result():
        if simple_result.result:
            if "result" in simple_result.result:
                article = simple_result.result["result"]
                safe_show_info(f"Generated review article:\n\n{article}")
                
                config = load_config()
                if config["add_to_card"] and config["note_type_name"] and config["target_field_name"]:
                    if not mw.col.models.byName(config["note_type_name"]):
                        safe_show_info(f"Note type '{config['note_type_name']}' not found.")
                        return
                        
                    if config["target_field_name"] not in mw.col.models.fieldNames(mw.col.models.byName(config["note_type_name"])):
                        safe_show_info(f"Field '{config['target_field_name']}' not found.")
                        return
                        
                    ret = safe_show_info("Add generated article to a new note?", type="yesno")
                    if ret == QMessageBox.Yes:
                        add_article_to_note_op(article).success(on_add_note_success).failure(on_failure).run_in_background()
            elif "error" in simple_result.result:
                safe_show_text(f"Error: {simple_result.result['error']}")
        else:
            safe_show_text("An unknown error occurred during article generation.")
    
    mw.taskman.run_on_main(handle_result)

def generate_review_article_background(vocab_list):
    """
    Starts the background task to generate the review article using AI.
    """
    try:
        simple_result = generate_review_article_with_ai(vocab_list)
        on_ai_generation_success(simple_result)
    except Exception as e:
        safe_show_text(f"Error in background task: {str(e)}")

def test():
    """
    Main function to test the add-on functionality.
    """
    try:
        config = load_config()
        vocab_values = get_today_learned_cards_field(config["field_name"])
        
        if not vocab_values:
            safe_show_info(f"No {config['field_name']} field found or no cards learned today.")
            return
            
        safe_show_info("Generating article... Please wait.")
        mw.taskman.run_in_background(
            lambda: generate_review_article_background(vocab_values)
        )
    except Exception as e:
        safe_show_text(f"Error during test: {str(e)}")

# --- Menu Actions ---
def setup_menu():
    """
    Sets up the menu items in Anki.
    """
    try:
        # Create and add the main action
        action = QAction("AI Reading Gen:Generate Articles", mw)
        qconnect(action.triggered, test)
        mw.form.menuTools.addAction(action)

        # Create and add the configuration action
        config_action = QAction("AI Reading Gen:AI service configuration", mw)
        qconnect(config_action.triggered, show_config_dialog)
        mw.form.menuTools.addAction(config_action)
    except Exception as e:
        safe_show_text(f"Error setting up menu: {str(e)}")

# Initialize the add-on
setup_menu()

I think this is the last issue, the function to add the generated article to a new card doesn’t work, whether I use Ankiconnect or not! The code:

import json
from aqt import mw
from aqt.qt import *
from aqt.utils import showInfo, showText
import os
from anki.hooks import addHook
import requests
from typing import Any, Optional, Tuple, Union

# --- Result Wrapper (Simplified) ---
class AIResult:
    def __init__(self, result: Optional[dict] = None):
        self.result = result

# --- Safe UI Operations ---
def safe_show_info(message, type=None):
    """Safely show info dialog on main thread"""
    def show():
        return showInfo(message, type=type)
    return mw.taskman.run_on_main(show)

def safe_show_text(message):
    """Safely show text dialog on main thread"""
    def show():
        return showText(message)
    return mw.taskman.run_on_main(show)

# --- Configuration ---
config_file = os.path.join(os.path.dirname(__file__), "config.json")

default_config = {
    "api_token": "YOUR_API_TOKEN",
    "request_url": "YOUR_REQUEST_URL",
    "model_name": "YOUR_MODEL_NAME",
    "max_tokens": 4096,
    "input_field_name": "VocabKanji",
    "temperature": 0.7,
    "top_p": 0.7,
    "top_k": 50,
    "frequency_penalty": 0.5,
    "stop_sequences": [],
    "enable_cache": True,
    "prompt_template": "你是一名日本の小説家,请根据日语单词生成一篇小说,必须用日语进行回答,只生成简短的小说,不产生其他内容:\n\n{vocab_list}",
    "note_type_name": "Basic",
    "output_field_name": "Reading",
    "add_to_card": True,
    "deck_name": "Default",
}

def load_config():
    try:
        with open(config_file, "r", encoding='utf-8') as f:
            config = json.load(f)
        # Fill in default values
        for key, value in default_config.items():
            if key not in config:
                config[key] = value
        return config
    except (FileNotFoundError, json.JSONDecodeError) as e:
        safe_show_text(f"Error loading config: {str(e)}\nUsing default configuration.")
        return default_config

def save_config(config):
    try:
        with open(config_file, "w", encoding='utf-8') as f:
            json.dump(config, f, indent=4, ensure_ascii=False)
    except Exception as e:
        safe_show_text(f"Error saving config: {str(e)}")

# --- Cache ---
cache = {}

def load_cache():
    global cache
    cache_file = os.path.join(os.path.dirname(__file__), "cache.json")
    try:
        with open(cache_file, "r", encoding='utf-8') as f:
            cache = json.load(f)
    except (FileNotFoundError, json.JSONDecodeError):
        cache = {}

def save_cache():
    cache_file = os.path.join(os.path.dirname(__file__), "cache.json")
    try:
        with open(cache_file, "w", encoding='utf-8') as f:
            json.dump(cache, f, indent=4, ensure_ascii=False)
    except Exception as e:
        safe_show_text(f"Error saving cache: {str(e)}")

class ConfigDialog(QDialog):
    def __init__(self, parent=None):
        try:
            super().__init__(parent)
            self.setup_ui()
            self.load_from_config()
        except Exception as e:
            safe_show_text(f"Error initializing config dialog: {str(e)}")
            self.reject()

    def setup_ui(self):
        self.setWindowTitle("AI service configuration")
        self.setMinimumWidth(500)
        
        layout = QVBoxLayout()
        form_layout = QFormLayout()

        self.api_token_edit = QLineEdit()
        form_layout.addRow("API key:", self.api_token_edit)

        self.request_url_edit = QLineEdit()
        form_layout.addRow("Request URL:", self.request_url_edit)

        self.model_name_edit = QLineEdit()
        form_layout.addRow("AI model name:", self.model_name_edit)

        self.max_tokens_edit = QSpinBox()
        self.max_tokens_edit.setRange(1, 100000)
        self.max_tokens_edit.setSingleStep(100)
        form_layout.addRow("Maximum Token Count:", self.max_tokens_edit)

        self.input_field_name_edit = QLineEdit()
        form_layout.addRow("Input Field Name:", self.input_field_name_edit)

        self.temperature_edit = QDoubleSpinBox()
        self.temperature_edit.setRange(0.0, 1.0)
        self.temperature_edit.setSingleStep(0.1)
        form_layout.addRow("Temperature:", self.temperature_edit)

        self.top_p_edit = QDoubleSpinBox()
        self.top_p_edit.setRange(0.0, 1.0)
        self.top_p_edit.setSingleStep(0.1)
        form_layout.addRow("Top P:", self.top_p_edit)

        self.top_k_edit = QSpinBox()
        self.top_k_edit.setRange(0, 100)
        self.top_k_edit.setSingleStep(1)
        form_layout.addRow("Top K:", self.top_k_edit)

        self.frequency_penalty_edit = QDoubleSpinBox()
        self.frequency_penalty_edit.setRange(0.0, 2.0)
        self.frequency_penalty_edit.setSingleStep(0.1)
        form_layout.addRow("Frequency Penalty:", self.frequency_penalty_edit)

        self.stop_sequences_edit = QLineEdit()
        form_layout.addRow("Stop Sequences (comma-separated):", self.stop_sequences_edit)

        self.enable_cache_checkbox = QCheckBox("启用缓存")
        form_layout.addRow(self.enable_cache_checkbox)

        self.prompt_template_edit = QPlainTextEdit()
        form_layout.addRow("Prompt Template:", self.prompt_template_edit)

        self.add_to_card_checkbox = QCheckBox("Add the generated article to the card")
        form_layout.addRow(self.add_to_card_checkbox)

        self.note_type_name_edit = QLineEdit()
        form_layout.addRow("Note Type Name:", self.note_type_name_edit)

        self.output_field_name_edit = QLineEdit()
        form_layout.addRow("Output Field Name:", self.output_field_name_edit)

        self.deck_name_edit = QLineEdit()
        form_layout.addRow("Deck Name:", self.deck_name_edit)

        layout.addLayout(form_layout)

        button_layout = QHBoxLayout()
        
        save_button = QPushButton("Save")
        save_button.clicked.connect(self.accept)
        
        cancel_button = QPushButton("Cancel")
        cancel_button.clicked.connect(self.reject)
        
        button_layout.addStretch()
        button_layout.addWidget(save_button)
        button_layout.addWidget(cancel_button)
        
        layout.addLayout(button_layout)

        self.setLayout(layout)

    def load_from_config(self):
        try:
            config = load_config()
            self.api_token_edit.setText(config["api_token"])
            self.request_url_edit.setText(config["request_url"])
            self.model_name_edit.setText(config["model_name"])
            self.max_tokens_edit.setValue(config["max_tokens"])
            self.input_field_name_edit.setText(config["input_field_name"])
            self.temperature_edit.setValue(config["temperature"])
            self.top_p_edit.setValue(config["top_p"])
            self.top_k_edit.setValue(config["top_k"])
            self.frequency_penalty_edit.setValue(config["frequency_penalty"])
            self.stop_sequences_edit.setText(", ".join(config["stop_sequences"]))
            self.enable_cache_checkbox.setChecked(config["enable_cache"])
            self.prompt_template_edit.setPlainText(config["prompt_template"])
            self.note_type_name_edit.setText(config["note_type_name"])
            self.output_field_name_edit.setText(config["output_field_name"])
            self.add_to_card_checkbox.setChecked(config["add_to_card"])
            self.deck_name_edit.setText(config.get("deck_name", ""))
        except Exception as e:
            safe_show_text(f"Error loading configuration: {str(e)}")

    def save_to_config(self):
        try:
            config = {
                "api_token": self.api_token_edit.text(),
                "request_url": self.request_url_edit.text(),
                "model_name": self.model_name_edit.text(),
                "max_tokens": self.max_tokens_edit.value(),
                "input_field_name": self.input_field_name_edit.text(),
                "temperature": self.temperature_edit.value(),
                "top_p": self.top_p_edit.value(),
                "top_k": self.top_k_edit.value(),
                "frequency_penalty": self.frequency_penalty_edit.value(),
                "stop_sequences": [seq.strip() for seq in self.stop_sequences_edit.text().split(",") if seq.strip()],
                "enable_cache": self.enable_cache_checkbox.isChecked(),
                "prompt_template": self.prompt_template_edit.toPlainText(),
                "note_type_name": self.note_type_name_edit.text(),
                "output_field_name": self.output_field_name_edit.text(),
                "add_to_card": self.add_to_card_checkbox.isChecked(),
                "deck_name": self.deck_name_edit.text(),
            }
            save_config(config)
        except Exception as e:
            safe_show_text(f"Error saving configuration: {str(e)}")

    def accept(self):
        self.save_to_config()
        super().accept()

def show_config_dialog():
    try:
        dialog = ConfigDialog(mw)
        dialog.finished.connect(lambda: dialog.deleteLater())
        dialog.exec()
    except Exception as e:
        safe_show_text(f"Error showing config dialog: {str(e)}")

# --- Anki Main Logic ---
def get_today_learned_cards_field(input_field_name):
    try:
        cids = mw.col.find_cards("rated:1")
        field_values = []
        for cid in cids:
            card = mw.col.get_card(cid)
            note = card.note()
            if input_field_name in note:
                field_values.append(note[input_field_name])
        return field_values if field_values else None
    except Exception as e:
        safe_show_text(f"Error getting today's learned cards: {str(e)}")
        return None

def generate_review_article_with_ai(vocab_list: list[str]) -> AIResult:
    """
    Generates a review article using AI.
    """
    try:
        config = load_config()
        
        vocab_str = ', '.join(vocab_list)
        prompt = config["prompt_template"].format(vocab_list=vocab_str)

        if config["enable_cache"]:
            load_cache()
            if vocab_str in cache:
                return AIResult({"result": cache[vocab_str]})

        payload = {
            "model": config["model_name"],
            "messages": [
                {
                    "role": "user",
                    "content": [{"type": "text", "text": prompt}],
                }
            ],
            "stream": False,
            "max_tokens": config["max_tokens"],
            "temperature": config["temperature"],
            "top_p": config["top_p"],
            "top_k": config["top_k"],
            "frequency_penalty": config["frequency_penalty"],
            "stop": config["stop_sequences"],
            "n": 1,
            "response_format": {"type": "text"},
        }
        
        headers = {
            "Authorization": f"Bearer {config['api_token']}",
            "Content-Type": "application/json",
        }

        response = requests.post(config["request_url"], headers=headers, json=payload)
        response.raise_for_status()
        response_json = response.json()

        if 'choices' in response_json and response_json['choices']:
            generated_text = response_json['choices'][0]['message']['content']
            if config["enable_cache"]:
                cache[vocab_str] = generated_text
                save_cache()
            return AIResult({"result": generated_text})
        else:
            return AIResult({"error": "无法从 API 响应中提取文本"})

    except requests.exceptions.RequestException as e:
        return AIResult({"error": f"API 请求错误: {str(e)}"})
    except Exception as e:
        return AIResult({"error": f"生成文章时发生错误: {str(e)}"})

def invoke_ankiconnect(action, **params):
    """
    Helper function to invoke AnkiConnect actions.
    """
    request_json = json.dumps({'action': action, 'params': params, 'version': 6})
    try:
        response = requests.post('http://localhost:8765', data=request_json)
        response.raise_for_status()
        return response.json()
    except requests.exceptions.ConnectionError as e:
        safe_show_text(f"Failed to connect to AnkiConnect: {e}")
        raise

def add_article_to_note_with_ankiconnect(article: str):
    """
    Adds the generated article to a new note using AnkiConnect.
    """
    config = load_config()
    note_type_name = config["note_type_name"]
    output_field_name = config["output_field_name"]
    deck_name = config.get("deck_name", "Default")

    note = {
        'deckName': deck_name,
        'modelName': note_type_name,
        'fields': {
            output_field_name: article
        },
        'options': {
            'allowDuplicate': False,
        },
        'tags': [
            'ai_reading_gen',
        ],
    }

    try:
        result = invoke_ankiconnect('addNote', note=note)
        if result.get('error'):
            safe_show_text(f"AnkiConnect Error: {result['error']}")
            return False
        else:
            safe_show_info("Article added to new note successfully via AnkiConnect!")
            return True
    except Exception as e:
        safe_show_text(f"Error adding note via AnkiConnect: {str(e)}")
        return False

def on_add_note_success(result: bool):
    """
    Handles the result of adding a note via AnkiConnect.
    """
    if result:
        # Refresh the main window if the note was added successfully
        mw.reset()

def on_failure(exception: Exception):
    """
    Handles operation failures.
    """
    safe_show_text(f"Operation failed: {str(exception)}")

def on_ai_generation_success(ai_result: AIResult):
    """
    Handles the result of AI generation.
    """
    def handle_result():
        if ai_result.result:
            if "result" in ai_result.result:
                article = ai_result.result["result"]
                safe_show_info(f"Generated review article:\n\n{article}")
                
                config = load_config()
                if config["add_to_card"] and config["note_type_name"] and config["output_field_name"]:
                    if not mw.col.models.byName(config["note_type_name"]):
                        safe_show_info(f"Note type '{config['note_type_name']}' not found.")
                        return
                        
                    if config["output_field_name"] not in mw.col.models.fieldNames(mw.col.models.byName(config["note_type_name"])):
                        safe_show_info(f"Field '{config['output_field_name']}' not found.")
                        return
                        
                    ret = safe_show_info("Add generated article to a new note?", type="yesno")
                    if ret == QMessageBox.StandardButton.Yes:
                        print(f"Article about to be added: {article}")

                        add_article_to_note_with_ankiconnect(article)
            elif "error" in ai_result.result:
                safe_show_text(f"Error: {ai_result.result['error']}")
        else:
            safe_show_text("An unknown error occurred during article generation.")
    
    mw.taskman.run_on_main(handle_result)

def generate_review_article_background(vocab_list):
    """
    Starts the background task to generate the review article using AI.
    """
    try:
        ai_result = generate_review_article_with_ai(vocab_list)
        on_ai_generation_success(ai_result)
    except Exception as e:
        safe_show_text(f"Error in background task: {str(e)}")

def test():
    """
    Main function to test the add-on functionality.
    """
    try:
        config = load_config()
        vocab_values = get_today_learned_cards_field(config["input_field_name"])
        
        if not vocab_values:
            safe_show_info(f"No {config['input_field_name']} field found or no cards learned today.")
            return
            
        safe_show_info("Generating article... Please wait.")
        mw.taskman.run_in_background(
            lambda: generate_review_article_background(vocab_values)
        )
    except Exception as e:
        safe_show_text(f"Error during test: {str(e)}")

# --- Menu Actions ---
def setup_menu():
    """
    Sets up the menu items in Anki.
    """
    try:
        # Create and add the main action
        action = QAction("AI Reading Gen:Generate Articles", mw)
        qconnect(action.triggered, test)
        mw.form.menuTools.addAction(action)

        # Create and add the configuration action
        config_action = QAction("AI Reading Gen:AI service configuration", mw)
        qconnect(config_action.triggered, show_config_dialog)
        mw.form.menuTools.addAction(config_action)
    except Exception as e:
        safe_show_text(f"Error setting up menu: {str(e)}")

# Initialize the add-on
setup_menu()