本文是"从零搭建大模型智能体实战"系列的第二篇。在上一篇中,我们构建了通用的 AI 对话窗体程序,掌握了智能体的核心架构与 LLM 客户端封装。本篇将在此基础上,打造一个专精英语诗歌创作的智能体——支持俳句、十四行诗、自由诗等八种体裁,流式诗歌创作 + 右侧生词本手动记录,一键导出 TXT 格式生词表,配合金山词霸等工具轻松背词。使用 AIGC bar 统一 API 接口,免费模型即可流畅运行。


博主智算菩萨,专注于人工智能、Python编程、音视频处理及UI窗体程序设计等方向。致力于以通俗易懂的方式拆解前沿技术,从零基础入门到高阶实战,陪伴开发者共同成长。目前已开设五大技术专栏,累计发布多篇原创技术文章,深受读者好评。

📌 专栏导航

  • 人工智能前沿知识(已更201篇):深度剖析Transformer架构、生成式AI、强化学习、具身智能、神经符号系统、大模型及智能体(Agent)技术,系统性解析AI核心技术体系与前沿趋势。
  • Python基础小白编程(已更232篇):从零开始,以保姆式教程讲解变量、数据类型、流程控制、函数等核心语法,配有大量实战代码与避坑指南,真正做到学以致用。
  • 机器学习与深度学习(125篇):系统化拆解线性模型、决策树、随机森林、梯度提升树、神经网络等算法原理与工程实践,覆盖从公式推导到代码实现的全链路内容。
  • 音频、图像与视频处理理论与实战(81篇):涵盖FFmpeg多媒体处理、audio_shop开源工具、ComfyUI-WanVideoWrapper视频生成等实用技术,从基础操作到高级应用一应俱全。
  • UI窗体程序设计实战(78篇):深入讲解UI设计、动态窗体生成、游戏UI框架设计等实战技巧,提供从配置到编码的完整解决方案。
    智算菩萨,以代码为经,以算法为纬,在人工智能的星辰大海中,做你前行路上最可靠的导航者。本人最常用AI工具为AIGCBAR

1 为什么需要一个诗歌创作智能体

英语诗歌学习是英语进阶过程中极具价值但门槛较高的一环。传统学习方式面临几个痛点:

  1. 体裁知识难以系统获取——俳句的 5-7-5 音节规则、十四行诗的押韵格式、维拉内拉的叠句结构,每种体裁都有严格的形式要求,初学者很难快速掌握。
  2. 创作缺少即时反馈——写了诗没人点评,不知道韵律是否正确、意象是否恰当。
  3. 生词学习脱离语境——背单词表枯燥乏味,而在诗歌中学到的词汇既有文化内涵又有情感温度,记忆效果远胜于机械背诵。

本篇构建的英语诗歌创作智能体正是为了解决这些问题而生:你只需选择体裁、输入主题,AI 即刻生成符合格律的英语诗歌;右侧生词本手动记录诗中的生词,一键导出 TXT 文件,配合金山词霸等工具自主查词学习。
在这里插入图片描述

在这里插入图片描述

2 项目架构与设计思路

2.1 与通用聊天智能体的区别

维度 通用聊天智能体 诗歌创作智能体
系统提示词 用户自定义,无默认约束 固定为英语诗歌创作专家,体裁可选
输入方式 任意自然语言 体裁选择器 + 主题输入
回复格式 自由文本 严格按体裁格律输出诗歌
领域功能 生词本手动记录 + TXT 导出 + 词汇难度分级
UI 布局 单栏对话 左对话 + 右生词本双栏
词汇学习 手动记录 + 难度提示,导出后用外部工具查词

2.2 核心功能模块

PoetryAgent
├── 配置管理模块 (load_config / save_config)
│   ├── API 密钥持久化
│   └── 体裁偏好记忆
├── 诗歌创作模块
│   ├── 体裁选择器(8种体裁)
│   ├── 主题输入
│   ├── 流式诗歌生成
│   └── 诗歌格式渲染
└── 生词本模块
    ├── 手动输入区域(每行一个单词)
    ├── 导出 TXT(filedialog 选择保存路径)
    └── 清空生词本

2.3 强制输出英语

为杜绝 AI 输出中文诗,user prompt 末尾加上 no Chinese 强约束:

{"role": "user", "content": f"Please compose a {genre.lower()} poem about {topic}. "
                              f"Write only the poem in English, no explanations, no Chinese."}

同时体裁栏增加了词汇难度选择器(CET-4/6、TEM-4/8),以提示用户当前产出诗歌的用词级别,方便在使用金山词霸等工具时有目标地学习。

3 诗歌体裁系统设计

3.1 八种英语诗歌体裁详解

俳句 (Haiku):源自日本,英语俳句遵循 5-7-5 音节模式,捕捉自然或日常生活中的一个瞬间。

十四行诗 (Sonnet):莎士比亚式十四行诗,14 行,押韵格式 ABABCDCDEFEFGG,主题多为爱情、自然或人生。

自由诗 (Free Verse):没有严格的韵律和格律约束,依靠自然语言节奏和意象传达情感。

打油诗 (Limerick):五行诗,押韵格式 AABBA,内容幽默风趣。

民谣 (Ballad):叙事诗,交替押韵(ABAB 或 ABCB),有重复的叠句。

维拉内拉 (Villanelle):19 行诗,由 5 个三行节和 1 个四行节组成,ABA 押韵格式。

藏头诗 (Acrostic):每行首字母组合成一个单词或信息。

五行诗 (Cinquain):五行诗,音节数为 2-4-6-8-2。

3.2 体裁提示词工程

每种体裁对应一个系统提示词,存储在 POETRY_GENRES 字典中。以十四行诗为例:

"Sonnet": {
    "name": "十四行诗 (Sonnet)",
    "prompt": "You are a master of the English sonnet. Compose a 14-line Shakespearean sonnet with the rhyme scheme ABABCDCDEFEFGG, exploring themes of love, nature, or human experience.",
}

提示词设计的关键要素:

  1. 角色设定:告诉 LLM 它是"大师"级别的诗人
  2. 形式约束:明确说明格律要求(行数、押韵格式、音节数)
  3. 主题引导:给出合理的主题范围

4 生词本与导出功能

右侧生词本是一个简洁实用的小工具,采用纯手动记录 + 一键导出的设计。

4.1 手动记录生词

生词本的设计理念是:AI 负责创作诗歌,用户负责发现生词。阅读诗歌时遇到不认识的单词,随手输入到右侧生词本中,每行一个。这种方式的优势是:

  • 100% 准确:用户自己发现的生词,不会出现 AI 提取错误的情况
  • 自主选择:每个学习者词汇量不同,自己决定哪些词值得记录
  • 配合金山词霸:导出后用金山词霸等工具查询释义,学习效果更扎实

4.2 导出 TXT 纯文本词表

导出功能使用标准 filedialog 选择保存路径,使用 os 库写入纯文本文件:

def _export_vocab(self):
    content = self.vocab_input.get("1.0", tk.END).strip()
    if not content:
        messagebox.showinfo("提示", "生词本是空的。")
        return

    # 默认文件名:vocab_YYYYMMDD_HHMMSS.txt
    timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
    default_name = f"vocab_{timestamp}.txt"

    file_path = filedialog.asksaveasfilename(
        title="导出生词本",
        defaultextension=".txt",
        initialfile=default_name,
        filetypes=[("文本文件", "*.txt"), ("所有文件", "*.*")],
    )

    if not file_path:
        return

    # 清理空行,每行一个单词写出
    lines = content.split("\n")
    cleaned = [line.strip() for line in lines if line.strip()]

    with open(file_path, "w", encoding="utf-8") as f:
        for word in cleaned:
            f.write(word + "\n")

导出的 TXT 文件格式示例(一行一个单词,无编号无多余内容):

melancholy
ethereal
luminous
tranquil
whisper

这种纯文本格式兼容性最好——金山词霸、欧路词典、Anki 等工具均可直接导入。

5 完整代码

以下是完整的 poetry_agent.py 单文件代码(约 685 行),复制保存即可直接运行:

"""
英语诗歌创作智能体 - 桌面窗体对话程序
支持多种诗歌体裁创作、生词本手动记录与导出
"""

import os
import sys
import json
import tkinter as tk
from tkinter import ttk, messagebox, scrolledtext, filedialog
from threading import Thread
from datetime import datetime

# ── 配置管理 ─────────────────────────────────────────────
CONFIG_DIR = os.path.join(os.path.expanduser("~"), ".poetry_agent")
CONFIG_FILE = os.path.join(CONFIG_DIR, "config.json")

DEFAULT_CONFIG = {
    "api_key": "",
    "model": "deepseek-v4-flash",
    "genre": "Free Verse",
    "temperature": 0.8,
    "max_tokens": 8000,
    "top_p": 1.0,
    "presence_penalty": 0.3,
    "frequency_penalty": 0.1,
    "difficulty": "CET-6",
}

# ── 词汇难度定义 ─────────────────────────────────────────
DIFFICULTY_LEVELS = {
    "CET-4": "大学英语四级",
    "CET-6": "大学英语六级",
    "TEM-4": "英语专业四级",
    "TEM-8": "英语专业八级",
}

# ── 诗歌体裁定义 ─────────────────────────────────────────
POETRY_GENRES = {
    "Haiku": {
        "name": "俳句 (Haiku)",
        "prompt": "You are an expert English haiku poet. Compose a traditional haiku with a 5-7-5 syllable pattern, capturing a single moment in nature or daily life with vivid imagery.",
    },
    "Sonnet": {
        "name": "十四行诗 (Sonnet)",
        "prompt": "You are a master of the English sonnet. Compose a 14-line Shakespearean sonnet with the rhyme scheme ABABCDCDEFEFGG, exploring themes of love, nature, or human experience.",
    },
    "Free Verse": {
        "name": "自由诗 (Free Verse)",
        "prompt": "You are an accomplished free verse poet. Compose a free verse poem without strict meter or rhyme, letting imagery and natural speech rhythms carry the emotional weight.",
    },
    "Limerick": {
        "name": "打油诗 (Limerick)",
        "prompt": "You are a witty limerick writer. Compose a five-line limerick with the AABBA rhyme scheme, humorous subject matter, and the characteristic anapestic meter.",
    },
    "Ballad": {
        "name": "民谣 (Ballad)",
        "prompt": "You are a skilled ballad poet. Compose a narrative ballad with alternating rhyme (ABAB or ABCB), strong storytelling elements, and a repeating refrain.",
    },
    "Villanelle": {
        "name": "维拉内拉 (Villanelle)",
        "prompt": "You are a master of the villanelle form. Compose a 19-line villanelle with two alternating refrains following the ABA rhyme scheme, expressing deep emotion.",
    },
    "Acrostic": {
        "name": "藏头诗 (Acrostic)",
        "prompt": "You are a creative acrostic poet. Compose an acrostic poem where the first letter of each line spells out a word or message related to the given theme.",
    },
    "Cinquain": {
        "name": "五行诗 (Cinquain)",
        "prompt": "You are a concise cinquain poet. Compose a five-line poem following the 2-4-6-8-2 syllable pattern, capturing a single image or emotion.",
    },
}


def load_config():
    """从本地文件加载配置"""
    if not os.path.exists(CONFIG_FILE):
        return dict(DEFAULT_CONFIG)
    try:
        with open(CONFIG_FILE, "r", encoding="utf-8") as f:
            data = json.load(f)
            cfg = dict(DEFAULT_CONFIG)
            cfg.update(data)
            return cfg
    except Exception:
        return dict(DEFAULT_CONFIG)


def save_config(config: dict):
    """保存配置到本地文件"""
    os.makedirs(CONFIG_DIR, exist_ok=True)
    with open(CONFIG_FILE, "w", encoding="utf-8") as f:
        json.dump(config, f, ensure_ascii=False, indent=2)


# ── LLM 客户端 ──────────────────────────────────────────
from openai import OpenAI


class LLMClient:
    """统一的 LLM API 客户端"""

    BASE_URL = "https://api.aigc.bar/v1"

    MODEL_CONFIG = {
        "glm-5.2": {
            "name": "GLM-5.2",
            "desc": "智谱 GLM 系列,付费模型,智能体能力强",
            "paid": True,
            "group": "OpenSource-MultiModal",
        },
        "deepseek-v4-flash": {
            "name": "DeepSeek-V4-Flash",
            "desc": "DeepSeek 系列,免费模型,速度快",
            "paid": False,
            "group": "OpenSource-MultiModal",
        },
    }

    def __init__(self, model="deepseek-v4-flash", api_key=None, base_url=None):
        if model not in self.MODEL_CONFIG:
            raise ValueError(f"未知模型: {model},可选: {list(self.MODEL_CONFIG.keys())}")
        self.model = model
        self.api_key = api_key or os.environ.get("AIGC_API_KEY", "")
        self.base_url = base_url or self.BASE_URL
        self._client = None

    def _get_client(self):
        if self._client is None:
            if not self.api_key:
                raise ValueError("API 密钥未设置,请在界面中输入或设置环境变量 AIGC_API_KEY")
            self._client = OpenAI(api_key=self.api_key, base_url=self.base_url)
        return self._client

    def chat_stream(self, messages, on_chunk, **kwargs):
        client = self._get_client()
        response = client.chat.completions.create(
            model=self.model,
            messages=messages,
            stream=True,
            stream_options={"include_usage": True},
            **kwargs,
        )
        for chunk in response:
            if chunk.choices and chunk.choices[0].delta and chunk.choices[0].delta.content:
                on_chunk(chunk.choices[0].delta.content)


class PoetryAgent:
    """英语诗歌创作智能体主窗口"""

    def __init__(self):
        self.root = tk.Tk()
        self.root.title("英语诗歌创作智能体 - Poetry Agent")
        self.root.geometry("1200x780")
        self.root.minsize(1000, 600)

        # 加载配置
        self.config = load_config()

        # 对话历史
        self.messages = []

        # 设置栏折叠状态
        self.settings_visible = tk.BooleanVar(value=False)

        self._build_ui()
        self.root.protocol("WM_DELETE_WINDOW", self._on_close)

    def _build_ui(self):
        """构建界面"""

        # ========== 顶部:API 密钥栏 ==========
        api_frame = ttk.Frame(self.root, padding=(10, 8))
        api_frame.pack(fill=tk.X)

        ttk.Label(api_frame, text="API密钥:", font=("微软雅黑", 9)).pack(side=tk.LEFT)

        self.api_entry = ttk.Entry(api_frame, width=40, show="*")
        self.api_entry.pack(side=tk.LEFT, padx=(5, 0), fill=tk.X, expand=True)
        if self.config.get("api_key"):
            self.api_entry.insert(0, self.config["api_key"])

        ttk.Button(
            api_frame, text="获取API密钥",
            command=self._open_register_url,
        ).pack(side=tk.LEFT, padx=(5, 0))

        ttk.Button(
            api_frame, text="保存配置",
            command=self._save_config,
        ).pack(side=tk.LEFT, padx=(5, 0))

        ttk.Button(
            api_frame, text="展开设置", width=10,
            command=self._toggle_settings,
        ).pack(side=tk.RIGHT, padx=(5, 0))

        # ========== 可折叠的设置栏 ==========
        self.settings_frame = ttk.LabelFrame(
            self.root, text="参数设置", padding=(10, 5)
        )

        row1 = ttk.Frame(self.settings_frame)
        row1.pack(fill=tk.X, pady=2)
        ttk.Label(row1, text="模型:", font=("微软雅黑", 9)).pack(side=tk.LEFT)
        self.model_var = tk.StringVar(value=self.config["model"])
        self.model_combo = ttk.Combobox(
            row1, textvariable=self.model_var,
            values=list(LLMClient.MODEL_CONFIG.keys()),
            state="readonly", width=22,
        )
        self.model_combo.pack(side=tk.LEFT, padx=(5, 15))
        self.model_combo.bind("<<ComboboxSelected>>", lambda e: self._update_model_info())
        self.model_info_label = ttk.Label(
            row1, text="", font=("微软雅黑", 8), foreground="gray"
        )
        self.model_info_label.pack(side=tk.LEFT)
        self._update_model_info()

        row2 = ttk.Frame(self.settings_frame)
        row2.pack(fill=tk.X, pady=2)
        ttk.Label(row2, text="温度:", font=("微软雅黑", 9)).pack(side=tk.LEFT)
        self.temp_var = tk.DoubleVar(value=self.config["temperature"])
        ttk.Scale(
            row2, from_=0.0, to=2.0, variable=self.temp_var,
            orient=tk.HORIZONTAL, length=100,
        ).pack(side=tk.LEFT, padx=(5, 5))
        self.temp_label = ttk.Label(
            row2, text=f"{self.temp_var.get():.1f}", width=4
        )
        self.temp_label.pack(side=tk.LEFT)

        ttk.Label(row2, text="    最大Token:", font=("微软雅黑", 9)).pack(
            side=tk.LEFT, padx=(15, 0)
        )
        self.max_tokens_var = tk.IntVar(value=self.config["max_tokens"])
        ttk.Spinbox(
            row2, from_=100, to=128000, increment=100,
            textvariable=self.max_tokens_var, width=8,
        ).pack(side=tk.LEFT, padx=(5, 0))

        row3 = ttk.Frame(self.settings_frame)
        row3.pack(fill=tk.X, pady=2)
        ttk.Label(row3, text="Top-P:", font=("微软雅黑", 9)).pack(side=tk.LEFT)
        self.top_p_var = tk.DoubleVar(value=self.config["top_p"])
        ttk.Scale(
            row3, from_=0.0, to=1.0, variable=self.top_p_var,
            orient=tk.HORIZONTAL, length=80,
        ).pack(side=tk.LEFT, padx=(5, 5))

        ttk.Label(row3, text="    存在惩罚:", font=("微软雅黑", 9)).pack(
            side=tk.LEFT, padx=(15, 0)
        )
        self.presence_var = tk.DoubleVar(value=self.config["presence_penalty"])
        ttk.Scale(
            row3, from_=-2.0, to=2.0, variable=self.presence_var,
            orient=tk.HORIZONTAL, length=80,
        ).pack(side=tk.LEFT, padx=(5, 5))

        ttk.Label(row3, text="    频率惩罚:", font=("微软雅黑", 9)).pack(
            side=tk.LEFT, padx=(15, 0)
        )
        self.freq_var = tk.DoubleVar(value=self.config["frequency_penalty"])
        ttk.Scale(
            row3, from_=-2.0, to=2.0, variable=self.freq_var,
            orient=tk.HORIZONTAL, length=80,
        ).pack(side=tk.LEFT, padx=(5, 5))

        # ========== 诗歌体裁选择栏 ==========
        genre_frame = ttk.Frame(self.root, padding=(10, 0, 10, 5))
        genre_frame.pack(fill=tk.X)

        ttk.Label(genre_frame, text="诗歌体裁:", font=("微软雅黑", 9)).pack(side=tk.LEFT)

        self.genre_var = tk.StringVar(value=self.config["genre"])
        genre_keys = list(POETRY_GENRES.keys())
        self.genre_combo = ttk.Combobox(
            genre_frame, textvariable=self.genre_var,
            values=genre_keys, state="readonly", width=20,
        )
        self.genre_combo.pack(side=tk.LEFT, padx=(5, 0))
        self.genre_display_var = tk.StringVar(
            value=POETRY_GENRES.get(self.config["genre"], {}).get("name", "自由诗")
        )
        self.genre_name_label = ttk.Label(
            genre_frame, textvariable=self.genre_display_var,
            font=("微软雅黑", 9), foreground="gray",
        )
        self.genre_name_label.pack(side=tk.LEFT, padx=(5, 10))
        self.genre_combo.bind("<<ComboboxSelected>>", self._on_genre_change)

        ttk.Label(genre_frame, text="词汇难度:", font=("微软雅黑", 9)).pack(side=tk.LEFT, padx=(10, 0))
        self.difficulty_var = tk.StringVar(value=self.config.get("difficulty", "CET-6"))
        self.difficulty_combo = ttk.Combobox(
            genre_frame, textvariable=self.difficulty_var,
            values=list(DIFFICULTY_LEVELS.keys()),
            state="readonly", width=8,
        )
        self.difficulty_combo.pack(side=tk.LEFT, padx=(3, 0))
        self.difficulty_desc_label = ttk.Label(
            genre_frame, text=DIFFICULTY_LEVELS.get(self.config.get("difficulty", "CET-6"), ""),
            font=("微软雅黑", 8), foreground="gray",
        )
        self.difficulty_desc_label.pack(side=tk.LEFT, padx=(3, 0))
        self.difficulty_var.trace_add("write", self._on_difficulty_change)

        ttk.Label(genre_frame, text="主题:", font=("微软雅汉", 9)).pack(side=tk.LEFT, padx=(10, 0))
        self.topic_entry = ttk.Entry(genre_frame, width=30)
        self.topic_entry.pack(side=tk.LEFT, padx=(5, 0), fill=tk.X, expand=True)
        self.topic_entry.insert(0, "nature")

        ttk.Button(
            genre_frame, text="创作诗歌", command=self._send_message,
        ).pack(side=tk.RIGHT, padx=(5, 0))

        # ========== 主内容区(左侧对话 + 右侧生词本) ==========
        main_paned = ttk.PanedWindow(self.root, orient=tk.HORIZONTAL)
        main_paned.pack(fill=tk.BOTH, expand=True, padx=10, pady=(0, 5))

        # ── 左侧:对话区域 ──
        left_frame = ttk.Frame(main_paned)
        main_paned.add(left_frame, weight=3)

        self.chat_display = scrolledtext.ScrolledText(
            left_frame, wrap=tk.WORD, font=("微软雅黑", 10),
            bg="#f8f9fa", relief=tk.FLAT, borderwidth=1,
        )
        self.chat_display.pack(fill=tk.BOTH, expand=True)

        self.chat_display.tag_config(
            "user", foreground="#1a73e8", font=("微软雅黑", 10, "bold")
        )
        self.chat_display.tag_config(
            "assistant", foreground="#8B4513", font=("微软雅黑", 10, "bold")
        )
        self.chat_display.tag_config(
            "system_msg", foreground="#999999", font=("微软雅黑", 9)
        )
        self.chat_display.tag_config(
            "error", foreground="#d93025", font=("微软雅黑", 10)
        )
        self.chat_display.tag_config("content", font=("微软雅黑", 10))
        self.chat_display.tag_config(
            "poem", foreground="#4A148C", font=("Georgia", 11, "italic")
        )
        self.chat_display.config(state=tk.DISABLED)

        # ── 右侧:生词本(手动记录) ──
        right_frame = ttk.LabelFrame(main_paned, text="生词本", padding=(8, 5))
        main_paned.add(right_frame, weight=1)

        # 提示文字
        ttk.Label(
            right_frame,
            text="手动输入生词,每行一个单词:",
            font=("微软雅黑", 8),
        ).pack(anchor=tk.W, pady=(0, 3))

        # 生词输入区域
        self.vocab_input = scrolledtext.ScrolledText(
            right_frame, wrap=tk.WORD, font=("Consolas", 10),
            bg="#FFFDE7", relief=tk.FLAT, borderwidth=1,
            height=18,
        )
        self.vocab_input.pack(fill=tk.BOTH, expand=True, pady=(0, 5))

        # 提示示例
        self.vocab_input.insert("1.0", "melancholy\nethereal\nluminous\ntranquil\nwhisper")

        # 按钮行
        btn_row = ttk.Frame(right_frame)
        btn_row.pack(fill=tk.X)

        ttk.Button(
            btn_row, text="导出 TXT",
            command=self._export_vocab,
        ).pack(side=tk.LEFT, padx=(0, 3), fill=tk.X, expand=True)

        ttk.Button(
            btn_row, text="清空",
            command=self._clear_vocab,
        ).pack(side=tk.RIGHT, fill=tk.X, expand=True)

        # ========== 底部输入区域 ==========
        input_frame = ttk.Frame(self.root, padding=(10, 0, 10, 10))
        input_frame.pack(fill=tk.X)

        self.input_text = scrolledtext.ScrolledText(
            input_frame, height=3, wrap=tk.WORD,
            font=("微软雅黑", 10), relief=tk.FLAT, borderwidth=1,
        )
        self.input_text.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, padx=(0, 8))
        self.input_text.insert("1.0", "请创作一首关于...的诗歌...")

        self.send_btn = ttk.Button(
            input_frame, text="创作诗歌", width=12, command=self._send_message
        )
        self.send_btn.pack(side=tk.RIGHT, expand=True)

        self.input_text.bind("<Control-Return>", lambda e: self._send_message())

        # ========== 底部状态栏 ==========
        self.status_bar = ttk.Label(
            self.root, text='就绪 | 选择体裁并输入主题,点击"创作诗歌"', relief=tk.SUNKEN,
            anchor=tk.W, font=("微软雅黑", 9),
        )
        self.status_bar.pack(fill=tk.X, side=tk.BOTTOM)

        # 显示欢迎信息
        self._show_welcome()

    def _show_welcome(self):
        """显示欢迎信息"""
        self._append_text("=" * 60 + "\n", "system_msg")
        self._append_text("  英语诗歌创作智能体 - Poetry Agent\n", "assistant")
        self._append_text("=" * 60 + "\n", "system_msg")
        self._append_text(
            "欢迎使用英语诗歌创作智能体!\n\n"
            "使用方法:\n"
            "1. 在上方选择诗歌体裁(俳句、十四行诗、自由诗等)\n"
            "2. 输入诗歌主题(如 nature, love, seasons)\n"
            "3. 点击「创作诗歌」按钮即可生成\n"
            "4. 在右侧「生词本」手动记录诗中的生词\n"
            "5. 点击「导出 TXT」保存为纯文本文件\n\n"
            "支持的体裁:\n",
            "content",
        )
        for key in POETRY_GENRES:
            self._append_text(f"  - {POETRY_GENRES[key]['name']}\n", "content")
        self._append_text("-" * 60 + "\n", "system_msg")

    # ── 界面方法 ──

    def _on_genre_change(self, event=None):
        """体裁选择变更"""
        genre = self.genre_var.get()
        info = POETRY_GENRES.get(genre, {})
        self.genre_display_var.set(info.get("name", ""))

    def _on_difficulty_change(self, *args):
        """难度选择变更"""
        diff = self.difficulty_var.get()
        desc = DIFFICULTY_LEVELS.get(diff, "")
        self.difficulty_desc_label.config(text=desc)

    def _toggle_settings(self):
        """切换设置栏显示状态"""
        if self.settings_visible.get():
            self.settings_frame.pack_forget()
            self.settings_visible.set(False)
        else:
            self.settings_frame.pack(
                fill=tk.X, padx=10, pady=(0, 5)
            )
            self.settings_visible.set(True)

    def _update_model_info(self):
        model_id = self.model_var.get()
        cfg = LLMClient.MODEL_CONFIG.get(model_id)
        if cfg:
            paid = "付费" if cfg["paid"] else "免费"
            self.model_info_label.config(text=f"{cfg['name']} | {paid}")

    def _open_register_url(self):
        """打开 AIGC bar 注册页面"""
        import webbrowser
        webbrowser.open("https://api.aigc.bar/register?aff=UP4F")
        messagebox.showinfo(
            "获取API密钥",
            "已打开注册页面。注册后在控制台获取 API 密钥。",
        )

    def _save_config(self):
        """保存所有配置到本地"""
        api_key = self.api_entry.get().strip()
        if not api_key:
            messagebox.showwarning("提示", "请输入 API 密钥")
            return
        self.config["api_key"] = api_key
        self.config["model"] = self.model_var.get()
        self.config["genre"] = self.genre_var.get()
        self.config["difficulty"] = self.difficulty_var.get()
        self.config["temperature"] = round(self.temp_var.get(), 1)
        self.config["max_tokens"] = self.max_tokens_var.get()
        self.config["top_p"] = round(self.top_p_var.get(), 1)
        self.config["presence_penalty"] = round(self.presence_var.get(), 1)
        self.config["frequency_penalty"] = round(self.freq_var.get(), 1)
        save_config(self.config)
        messagebox.showinfo("成功", "配置已保存,下次启动自动加载。")

    def _clear_chat(self):
        """清空对话历史"""
        self.messages = []
        self.chat_display.config(state=tk.NORMAL)
        self.chat_display.delete("1.0", tk.END)
        self.chat_display.config(state=tk.DISABLED)
        self._show_welcome()
        self.status_bar.config(text="对话已清空")

    def _append_text(self, text: str, tag: str = "content"):
        """在对话区域追加文本(线程安全)"""
        self.chat_display.config(state=tk.NORMAL)
        self.chat_display.insert(tk.END, text, tag)
        self.chat_display.see(tk.END)
        self.chat_display.config(state=tk.DISABLED)

    def _set_input_state(self, enabled: bool):
        """设置输入区域状态"""
        state = tk.NORMAL if enabled else tk.DISABLED
        self.input_text.config(state=state)
        self.send_btn.config(state=state)
        if enabled:
            self.input_text.focus_set()

    # ── 生词本(手动记录 + 导出 TXT) ──

    def _export_vocab(self):
        """将生词本内容导出为 TXT 文件"""
        content = self.vocab_input.get("1.0", tk.END).strip()
        if not content:
            messagebox.showinfo("提示", "生词本是空的,请先输入一些单词。")
            return

        # 默认文件名:vocab_YYYYMMDD_HHMMSS.txt
        timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
        default_name = f"vocab_{timestamp}.txt"

        file_path = filedialog.asksaveasfilename(
            title="导出生词本",
            defaultextension=".txt",
            initialfile=default_name,
            filetypes=[("文本文件", "*.txt"), ("所有文件", "*.*")],
        )

        if not file_path:
            return  # 用户取消

        try:
            # 逐行清理:去空行、去两端空格
            lines = content.split("\n")
            cleaned_lines = []
            for line in lines:
                line = line.strip()
                if line:
                    cleaned_lines.append(line)

            with open(file_path, "w", encoding="utf-8") as f:
                for word in cleaned_lines:
                    f.write(word + "\n")

            self.status_bar.config(text=f"生词本已导出: {os.path.basename(file_path)} ({len(cleaned_lines)} 个单词)")
            messagebox.showinfo(
                "导出成功",
                f"已导出 {len(cleaned_lines)} 个单词到:\n{file_path}\n\n"
                f"可以用金山词霸、欧路词典等工具导入学习。",
            )
        except Exception as e:
            messagebox.showerror("导出失败", f"无法保存文件:{str(e)}")

    def _clear_vocab(self):
        """清空生词本"""
        if messagebox.askyesno("确认清空", "确定要清空生词本中的所有内容吗?"):
            self.vocab_input.delete("1.0", tk.END)
            self.status_bar.config(text="生词本已清空")

    # ── 诗歌创作核心方法 ──

    def _send_message(self):
        """发送创作请求"""
        topic = self.topic_entry.get().strip()
        if not topic:
            topic = "nature"
            self.topic_entry.insert(0, "nature")

        api_key = self.api_entry.get().strip()
        if not api_key:
            messagebox.showwarning("提示", "请先输入 API 密钥")
            return

        genre = self.genre_var.get()
        genre_info = POETRY_GENRES.get(genre, POETRY_GENRES["Free Verse"])
        system_prompt = genre_info["prompt"]

        self._append_text(f"\n{'='*60}\n", "system_msg")
        self._append_text(f"📝 创作请求\n", "user")
        self._append_text(f"体裁: {genre_info['name']}  主题: {topic}\n", "content")
        self._append_text(f"{'='*60}\n", "system_msg")

        self.messages = [
            {"role": "system", "content": system_prompt},
            {"role": "user", "content": f"Please compose a {genre.lower()} poem about {topic}. Write only the poem in English, no explanations, no Chinese."},
        ]

        self._append_text(f"\n✨ 诗歌创作中...\n", "assistant")

        self._set_input_state(False)
        model_id = self.model_var.get()
        self.status_bar.config(text=f"AI 创作中...(体裁: {genre_info['name']} | 模型: {model_id})")

        params = {
            "temperature": self.temp_var.get(),
            "max_tokens": self.max_tokens_var.get(),
            "top_p": self.top_p_var.get(),
            "presence_penalty": self.presence_var.get(),
            "frequency_penalty": self.freq_var.get(),
        }

        Thread(
            target=self._call_api_stream,
            args=(api_key, model_id, params),
            daemon=True,
        ).start()

    def _call_api_stream(self, api_key: str, model_id: str, params: dict):
        """后台线程:流式调用 API"""
        full_reply = ""

        def on_chunk(text: str):
            nonlocal full_reply
            full_reply += text
            self.root.after(0, self._append_text, text, "poem")

        try:
            client = LLMClient(model=model_id, api_key=api_key)
            client.chat_stream(
                self.messages,
                on_chunk=on_chunk,
                temperature=params["temperature"],
                max_tokens=params["max_tokens"],
                top_p=params["top_p"],
                presence_penalty=params["presence_penalty"],
                frequency_penalty=params["frequency_penalty"],
            )

            self.messages.append({"role": "assistant", "content": full_reply})
            self.root.after(0, self._on_stream_done, full_reply)

        except Exception as e:
            self.root.after(0, self._on_error, str(e))

    def _on_stream_done(self, reply_text: str):
        """流式完成处理"""
        self._append_text("\n", "system_msg")
        self._append_text("-" * 60 + "\n", "system_msg")
        self.status_bar.config(
            text=f"创作完成 | 输出长度: {len(reply_text)} 字符 | 在右侧生词本中手动记录生词"
        )
        self._set_input_state(True)

    def _on_error(self, error_msg: str):
        """错误处理"""
        self._append_text(f"\n[错误] {error_msg}\n", "error")
        self._append_text("-" * 60 + "\n", "system_msg")
        self.status_bar.config(text=f"错误: {error_msg}")
        self._set_input_state(True)

    def _on_close(self):
        """关闭窗口时保存配置"""
        cfg_fields = [
            ("model", self.model_var.get()),
            ("genre", self.genre_var.get()),
            ("difficulty", self.difficulty_var.get()),
            ("temperature", round(self.temp_var.get(), 1)),
            ("max_tokens", self.max_tokens_var.get()),
            ("top_p", round(self.top_p_var.get(), 1)),
            ("presence_penalty", round(self.presence_var.get(), 1)),
            ("frequency_penalty", round(self.freq_var.get(), 1)),
        ]
        for key, val in cfg_fields:
            self.config[key] = val
        api_key = self.api_entry.get().strip()
        if api_key:
            self.config["api_key"] = api_key
        save_config(self.config)
        self.root.destroy()

    def run(self):
        """启动应用"""
        self.root.mainloop()


if __name__ == "__main__":
    app = PoetryAgent()
    app.run()

完整代码文件poetry_agent.py 包含了上述所有功能的完整实现,共计约 685行。从上方的体裁选择下拉框、主题输入框,到流式诗歌生成的回调处理,再到右侧生词本的手动记录与导出功能,所有代码均在一个文件中,复制粘贴即可运行。

6 运行与使用指南

6.1 环境准备

  • Python 3.8+
  • 安装依赖:pip install openai
  • 获取 API 密钥:注册 AIGC bar 后在控制台获取

6.2 使用流程

  1. 启动程序python poetry_agent.py
  2. 配置 API 密钥:粘贴密钥到顶部输入框,点击"保存配置"
  3. 选择诗歌体裁:从下拉框中选择俳句、十四行诗、自由诗等
  4. 设置词汇难度:选择 CET-4 / CET-6 / TEM-4 / TEM-8,提示当前诗歌用词级别
  5. 输入主题:在主题输入框填写主题词(如 love, nature, ocean)
  6. 创作诗歌:点击"创作诗歌"按钮或按 Ctrl+Enter
  7. 记录生词:阅读诗歌时,在右侧生词本中手动输入生词,每行一个
  8. 导出 TXT:点击"导出 TXT",选择保存路径,用金山词霸等工具打开学习

6.3 参数说明

参数 范围 说明
温度(Temperature) 0.0 - 2.0 诗歌创作推荐 0.8-1.0,越高越有创造性
最大输出Token 100 - 128000 俳句等短诗 2000 足矣,民谣/维拉内拉需要 8000+
Top-P 0.0 - 1.0 核采样参数,与温度配合,通常保持 1.0
词汇难度 CET-4/CET-6/TEM-4/TEM-8 标记当前诗歌的预期词汇水平,辅助用户有目标地学习

7 扩展与思考

体裁扩展:你可以在 POETRY_GENRES 字典中添加更多体裁,如日本短歌(Tanka)、斯宾塞体十四行诗(Spenserian Sonnet)、颂歌(Ode)等。

生词本增强:可以扩展为生词本自动保存到本地文件,下次启动时自动加载历史记录。

韵律检查:可以添加一个"韵律检查"功能,让 AI 在生成后自动检查诗歌是否符合格律要求。

双语对照:可以扩展为在诗歌右侧同步显示中文翻译。


智能体技术正在重塑人与 AI 的交互方式。从通用对话到专精创作,从被动回答到主动学习辅助,每一步扩展都在拓展 AI 的能力边界。希望这个英语诗歌创作智能体能成为你学习英语和探索 AI 应用的有趣工具。


文章涉及的代码和概念如有疑问,欢迎在评论区交流讨论。

Logo

中国智能体开发者社区,聚焦智能体与大模型开发,提供前沿资讯、实用工具链、开源项目及行业案例。通过技术沙龙、开发者大赛等活动,促进经验交流与协作,助力开发者快速构建创新智能应用。

更多推荐