diff --git a/README.md b/README.md index bc17f84..ae8149f 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ | | 解压 RAR | 🔧 unrar | | **文字识别** | 从图片识别文字 | 🔧 Tesseract | | | 提取 PDF 内嵌文字 | ✅ | -| **AI** | 提示词出图(OpenAI Images 兼容接口) | 🔧 API Key | +| **AI** | 提示词出图(OpenAI Images 兼容接口):31 个内置模板、风格增强、我的模板,可订阅 GitHub 开源提示词库或导入 JSON / CSV / Markdown | 🔧 API Key | | | 用自然语言描述任务、自动执行 | 🚧 | | **应用** | 全局功能搜索、最近操作记录、偏好设置 | ✅ | diff --git a/file-toolkit/core/prompt_image/modifiers.py b/file-toolkit/core/prompt_image/modifiers.py new file mode 100644 index 0000000..6a5e5ce --- /dev/null +++ b/file-toolkit/core/prompt_image/modifiers.py @@ -0,0 +1,64 @@ +"""风格增强 — 可叠加到任何模板上的风格 / 光影 / 镜头 / 画质修饰词。 + +UI 以分组标签(chip)展示,用户点选后由 apply_modifiers() 追加到提示词末尾。 +""" +from __future__ import annotations + +# (分组名, [(id, 中文标签, 英文修饰词), ...]) +MODIFIER_GROUPS: list[tuple[str, list[tuple[str, str, str]]]] = [ + ("画面风格", [ + ("photo", "写实摄影", "photorealistic photography"), + ("cinematic", "电影感", "cinematic film still"), + ("render3d", "3D 渲染", "high-end 3D render, octane render"), + ("anime", "日系动漫", "anime illustration, cel shading"), + ("watercolor", "水彩", "delicate watercolor painting"), + ("oil", "油画", "textured oil painting"), + ("flat", "扁平插画", "flat vector illustration"), + ("cyberpunk", "赛博朋克", "cyberpunk aesthetic"), + ("ink", "国风水墨", "traditional Chinese ink painting"), + ("pixel", "像素风", "pixel art"), + ("clay", "黏土", "claymation style"), + ]), + ("光影", [ + ("natural", "自然光", "soft natural light"), + ("golden", "黄金时刻", "golden hour lighting"), + ("studio", "影棚光", "professional studio lighting"), + ("neon", "霓虹", "neon glow lighting"), + ("rim", "轮廓光", "rim lighting"), + ("volumetric", "体积光", "volumetric light rays"), + ("dramatic", "戏剧明暗", "dramatic chiaroscuro lighting"), + ]), + ("镜头构图", [ + ("wide", "广角", "wide-angle shot"), + ("closeup", "特写", "close-up shot"), + ("macro", "微距", "macro photography"), + ("topdown", "俯拍", "top-down view"), + ("bokeh", "浅景深", "shallow depth of field, bokeh"), + ("symmetric", "对称构图", "symmetrical composition"), + ("thirds", "三分法", "rule of thirds composition"), + ("tiltshift", "移轴", "tilt-shift effect"), + ]), + ("画质", [ + ("detailed", "高细节", "highly detailed"), + ("8k", "8K 超清", "8K ultra high resolution"), + ("sharp", "锐利对焦", "sharp focus"), + ("grading", "专业调色", "professional color grading"), + ("grain", "胶片颗粒", "subtle film grain"), + ]), +] + +_BY_ID: dict[str, str] = {mid: text for _, items in MODIFIER_GROUPS for mid, _, text in items} + + +def apply_modifiers(prompt: str, modifier_ids: list[str] | tuple[str, ...] = (), + negative: str = "") -> str: + """把选中的修饰词和"避免出现"的内容追加到提示词末尾。""" + prompt = (prompt or "").rstrip() + extras = [_BY_ID[m] for m in modifier_ids if m in _BY_ID] + if extras: + sep = "" if not prompt or prompt.endswith((".", "。", ",", ",")) else "." + prompt = f"{prompt}{sep} {', '.join(extras)}.".strip() + negative = (negative or "").strip() + if negative: + prompt = f"{prompt}\nAvoid: {negative}." + return prompt diff --git a/file-toolkit/core/prompt_image/option_labels.py b/file-toolkit/core/prompt_image/option_labels.py new file mode 100644 index 0000000..152926d --- /dev/null +++ b/file-toolkit/core/prompt_image/option_labels.py @@ -0,0 +1,151 @@ +"""内置模板下拉选项的中文显示名。 + +提示词里写入英文选项值(生图模型理解更稳定),界面上显示这里的中文; +没登记的选项原样显示。 +""" +from __future__ import annotations + +OPTION_LABELS: dict[str, str] = { + # 风格 / 设计流派 + "Swiss International Style": "瑞士国际主义", "Japanese wabi-sabi": "日式侘寂", + "Scandinavian minimal": "北欧极简", "Bauhaus geometric": "包豪斯几何", + "sleek cyberpunk": "赛博朋克", "holographic glassmorphism": "全息玻璃拟态", + "dark premium tech": "暗黑高级科技", "deep space exploration": "深空探索", + "Pure studio packshot": "白底棚拍", "Lifestyle scene styling": "生活场景", + "Dark moody luxury look": "暗调奢华", "Bright fresh look": "明亮清新", + "Magazine editorial look": "杂志大片", "editorial magazine": "杂志大片", + "cozy home cooking": "家常温馨", "dark moody fine dining": "暗调高级餐厅", + "bright and airy": "明亮通透", "trendy cafe": "网红咖啡馆", + "Professional pet photography": "专业宠物摄影", "Studio portrait": "影棚肖像", + "Pixar-like 3D render": "皮克斯风 3D", "Watercolor illustration": "水彩插画", + "Studio Ghibli-inspired anime": "吉卜力风动画", "watercolor": "水彩", + "impasto oil painting": "厚涂油画", "pixel art": "像素艺术", "cel-shaded comic": "赛璐璐漫画", + "Chinese ink wash painting": "中国水墨", "concept art matte painting": "概念艺术绘景", + "watercolor and colored pencil": "水彩 + 彩铅", "gouache": "水粉", "crayon": "蜡笔", + "digital soft brush": "数字柔笔刷", + "xieyi freehand ink": "写意水墨", "gongbi fine-line": "工笔", "blue-green landscape": "青绿山水", + "splash ink": "泼墨", "pure ink only": "纯水墨", "light color washes": "淡彩", + "touches of vermilion": "点缀朱红", "mineral blue and green": "石青石绿", + "Kawaii chibi style": "日系 Q 版", "Flat vector style": "扁平矢量", "3D clay style": "3D 黏土", + "Hand-drawn doodle style": "手绘涂鸦", + "minimal line mark": "极简线条标", "geometric symbol": "几何图形", "lettermark": "字母组合", + "emblem badge": "徽章", "abstract symbol with wordmark": "抽象图形 + 文字", + "hand-lettered script": "手写字体", + "minimal modern": "现代极简", "warm organic": "温暖自然", "bold playful": "大胆活泼", + "luxury classic": "经典奢华", + "Blender low-poly": "低多边形", "soft clay render": "柔和黏土", "voxel style": "体素风", + "realistic miniature": "写实微缩", + "cute hand-drawn": "可爱手绘", "minimal magazine": "极简杂志", "bold contrast": "强对比", + "film photo collage": "胶片拼贴", + "clean tech style": "简洁科技", "vibrant pop style": "活力波普", "cinematic photo style": "电影感摄影", + "flat illustration style": "扁平插画", + "contemporary minimalist": "当代极简", "new Chinese": "新中式", "brutalist": "粗野主义", + "Japanese": "日式", "industrial": "工业风", "parametric futuristic": "参数化未来", + "Japandi": "日式北欧", "modern minimalist": "现代极简", "Scandinavian": "北欧", + "mid-century modern": "中古风", "wabi-sabi": "侘寂", "French cream style": "法式奶油", + "anime cel shading": "动漫赛璐璐", "semi-realistic game art": "半写实游戏", + "Western cartoon": "美式卡通", "3D stylized render": "3D 风格化", + "Pixar-like 3D": "皮克斯风 3D", "anime": "动漫", "flat vector": "扁平矢量", + "oil painting portrait": "油画肖像", "clay figure": "黏土人偶", + "hand-drawn sketchnote": "手绘笔记", "isometric 3D": "等距 3D", "minimal line art": "极简线稿", + "fluid gradient": "流体渐变", "geometric abstraction": "几何抽象", "organic shapes": "有机形态", + "glitch art": "故障艺术", "minimal lines": "极简线条", "3D glass shapes": "3D 玻璃形体", + "corporate minimal": "商务极简", "tech gradient": "科技渐变", "soft watercolor": "柔和水彩", + "geometric lines": "几何线条", + # 配色 + "black, white and one red accent": "黑白 + 一点红", "muted Morandi tones": "莫兰迪色", + "classic blue and white": "经典蓝白", "warm earth tones": "暖大地色", + "cool monochrome blue": "冷调单色蓝", "blue-violet gradient": "蓝紫渐变", "cyan on black": "青黑", + "neon multicolor": "霓虹多彩", "gold on black": "黑金", + "teal and orange": "青橙", "desaturated cold": "低饱和冷调", "warm vintage": "暖调复古", + "high-contrast noir": "高反差黑白", + "vermilion and gold": "朱红 + 金", "indigo and porcelain white": "靛蓝 + 瓷白", + "jade green and gold": "翡翠绿 + 金", "ink black and cinnabar": "墨黑 + 朱砂", + "deep gradient": "深色渐变", "bright solid color": "明亮纯色", "black": "黑色", "pastel": "马卡龙色", + "warm beige and gold": "暖米 + 金", "fresh mint and white": "薄荷 + 白", + "bold primary colors": "鲜明三原色", "pastel candy colors": "糖果色", + "warm tones": "暖色系", "cool tones": "冷色系", "neon": "霓虹", "Morandi muted": "莫兰迪", + "monochrome": "单色", "vivid rainbow": "彩虹", "warm cozy": "暖调温馨", "cool night": "冷调夜景", + "vivid": "鲜艳", "cream and pink": "奶油粉", "fresh green": "清新绿", "bright yellow": "明黄", + "black white red": "黑白红", "blue and teal": "蓝 + 青", "warm orange": "暖橙", + "pastel multicolor": "柔和多彩", "black and yellow": "黑黄", "navy and white": "藏青 + 白", + "blue and green": "蓝绿", "warm neutrals": "暖中性色", "black and gold": "黑金", + # 氛围 + "epic and powerful": "震撼", "mysterious": "神秘", "premium": "高端", "energetic": "活力", + "dreamy": "梦幻", "serene": "宁静", "epic": "壮丽", "cozy": "温馨", "dark": "暗黑", + # 电影类型 + "sci-fi thriller": "科幻惊悚", "romantic drama": "爱情", "fantasy adventure": "奇幻冒险", + "crime noir": "犯罪黑色", "animated family": "合家欢动画", "horror": "恐怖", + # 背景 / 台面 + "seamless pure white": "纯白无缝", "soft gradient backdrop": "柔和渐变", + "polished marble surface": "大理石台面", "natural wood tabletop": "原木桌面", + "colored paper set with geometric pedestals": "彩色背景纸 + 几何展台", + "rustic wood table": "复古木桌", "marble countertop": "大理石台面", + "solid color backdrop": "纯色背景", "restaurant setting": "餐厅环境", + "neutral gray": "中性灰", "warm beige": "暖米色", "deep navy": "深藏青", "pure white": "纯白", + "textured canvas": "肌理画布", "pure black": "纯黑", "light gray": "浅灰", "white": "白色", + "transparent-looking pale": "近透明浅色", + "light concrete surface": "浅色水泥", "linen fabric": "亚麻布", "warm wood desk": "暖色木桌", + "solid color paper": "纯色卡纸", "solid pastel": "纯色马卡龙", "gradient": "渐变", + "simple pattern": "简单图案", + # 灯光 + "large softbox, soft and even": "大柔光箱 · 柔和均匀", "hard light with crisp shadows": "硬光 · 清晰投影", + "rim light outlining the silhouette": "轮廓光", "natural window light": "自然窗光", + "soft window light": "柔和窗光", "warm tungsten light": "暖色钨丝灯", + "hard sunlight with shadows": "硬朗阳光", "large softbox": "大柔光箱", + "Rembrandt lighting": "伦勃朗光", "soft butterfly lighting": "蝴蝶光", + "clamshell beauty lighting": "蚌壳美颜光", "low-key dramatic lighting": "低调戏剧光", + # 角度 / 镜头 + "three-quarter view": "四分之三侧", "front view": "正面", "top-down view": "俯视", + "low hero angle": "低角度仰拍", "macro close-up": "微距特写", "45-degree angle": "45 度", + "overhead flat lay": "正俯拍平铺", "eye level": "平视", + "ultra wide-angle 16mm": "超广角 16mm", "telephoto compression 200mm": "长焦压缩 200mm", + "panoramic composition": "全景构图", + "rule of thirds": "三分法", "symmetrical": "对称", "leading lines": "引导线", + "frame within a frame": "框中框", "minimal negative space": "极简留白", + # 表情 + "confident gentle smile": "自信微笑", "serious and focused": "专注严肃", "candid laughing": "自然大笑", + "thoughtful side glance": "侧目沉思", + # 时间 / 天气 + "golden hour": "黄金时刻", "blue hour": "蓝调时刻", "overcast afternoon": "阴天午后", + "night with neon lights": "霓虹夜晚", "sunrise": "日出", "golden hour sunset": "日落", + "starry night with milky way": "银河星空", "blue hour with interior lights": "黄昏亮灯", + "bright noon": "正午", "early morning fog": "清晨薄雾", + "dramatic clouds": "戏剧云层", "light mist": "薄雾", "clear sky": "晴空", "aurora in the sky": "极光", + "after-rain clarity": "雨后通透", + # 胶片 + "Ilford HP5 black and white": "Ilford HP5 黑白", + # 飞溅 + "liquid splash": "液体飞溅", "milk crown splash": "牛奶皇冠", "powder explosion": "粉末爆炸", + "water ripples": "水波纹", "chocolate swirl": "巧克力漩涡", + # 菜系 + "Chinese": "中餐", "Western": "西餐", "dessert": "甜品", "beverage": "饮品", + "Southeast Asian": "东南亚", + # 行业 + "technology": "科技", "food and beverage": "餐饮", "education": "教育", "fashion": "时尚", + "finance": "金融", "healthcare": "医疗健康", "entertainment": "娱乐", + # 材质 + "glossy plastic": "亮面塑料", "frosted glass": "磨砂玻璃", "soft clay": "软黏土", + "brushed metal": "拉丝金属", "inflatable vinyl": "充气膜", "plush fabric": "毛绒", + # 包装 + "illustrated blind box packaging": "插画盲盒包装", "clear window display box": "透明开窗盒", + "no packaging": "无包装", + # 平台 + "WeChat article header (2.35:1)": "公众号头图 (2.35:1)", "YouTube thumbnail (16:9)": "YouTube 封面 (16:9)", + "Bilibili cover (16:9)": "B 站封面 (16:9)", + # 建筑 / 空间 + "modern villa": "现代别墅", "skyscraper": "摩天大楼", "Chinese courtyard house": "中式庭院", + "museum": "博物馆", "library": "图书馆", "boutique cafe": "精品咖啡馆", + "on a seaside cliff": "海边悬崖", "in a dense city center": "城市中心", "in a misty forest": "雾中森林", + "by a calm lake": "湖畔", "in the desert": "沙漠", + "living room": "客厅", "bedroom": "卧室", "kitchen": "厨房", "home office": "书房", + "cafe": "咖啡馆", "hotel lobby": "酒店大堂", "bathroom": "卫浴", + # 位置 / 设备 / 语言 + "top": "顶部", "bottom": "底部", "left side": "左侧", "right side": "右侧", + "edges": "四周", "corners": "四角", "desktop": "电脑", "phone": "手机", "tablet": "平板", + "English": "英文", +} + + +def option_label(value: str) -> str: + return OPTION_LABELS.get(value, value) diff --git a/file-toolkit/core/prompt_image/sources.py b/file-toolkit/core/prompt_image/sources.py new file mode 100644 index 0000000..ffaf4cb --- /dev/null +++ b/file-toolkit/core/prompt_image/sources.py @@ -0,0 +1,330 @@ +"""提示词源解析 — 把 JSON / CSV / Markdown 格式的提示词集合转换为统一模板结构。 + +支持的格式: +- JSON:`{"name": ..., "license": ..., "homepage": ..., "templates": [...]}` 或直接是列表。 + 每条至少包含 name/title 与 prompt/prompt_template,其余字段可选(见 templates.py)。 +- CSV:表头含 prompt 列,标题列可为 title / name / act,可选 category、tags、author。 +- Markdown:GitHub 上常见的 "awesome prompts" README —— 每个 ##/### 标题下的第一个 + 代码块视为一条提示词,标题里的 "(by @作者)" 作为署名,所在的 ## 标题作为分类。 + +提示词里的占位符会转换为可填写的变量: +- `{变量}`(本应用 / 自定义模板写法) +- `{argument name="变量" default="默认值"}`(部分 GPT Image 提示词库写法) +- `[主体]`(中文提示词库常见写法) +""" +from __future__ import annotations + +import csv +import io +import json +import re + +# 推荐的开源提示词源(只引用地址,运行时由用户自行下载,内容版权归原作者) +RECOMMENDED_SOURCES: list[dict] = [ + { + "name": "Awesome GPT-4o Images", + "url": "https://raw.githubusercontent.com/jamez-bondos/awesome-gpt4o-images/main/README.md", + "homepage": "https://github.com/jamez-bondos/awesome-gpt4o-images", + "license": "CC BY 4.0", + "description": "100+ 个中文 GPT-4o / gpt-image 精选案例,每条署名原作者", + }, + { + "name": "Awesome GPT Image 2 Prompts", + "url": "https://raw.githubusercontent.com/EvoLinkAI/awesome-gpt-image-2-API-and-Prompts/main/README_zh-CN.md", + "homepage": "https://github.com/EvoLinkAI/awesome-gpt-image-2-API-and-Prompts", + "license": "CC0-1.0", + "description": "数百条 GPT Image 2 提示词:电商、广告、人像、海报等分类", + }, +] + +MAX_VARIABLES = 8 + +_ARG_RE = re.compile(r'\{argument\s+name="([^"]{1,40})"(?:\s+default="([^"]*)")?\s*\}') +_CURLY_RE = re.compile(r"\{([^{}\"\n:=]{1,20})\}") +_BRACKET_RE = re.compile(r"\[([一-鿿A-Za-z][一-鿿A-Za-z //、]{0,11})\](?!\()") +_LINK_RE = re.compile(r"\[([^\]]*)\]\(([^)]*)\)") +_WEB_LINK_RE = re.compile(r"(? tuple[str, list[dict]]: + """识别提示词里的占位符,返回 (规范化后的提示词, 变量列表)。 + + 规范化:`{argument name="x" default="y"}` 与 `[x]` 统一改写为 `{x}`。 + """ + variables: list[dict] = [] + seen: set[str] = set() + + def add(label: str, default: str = "", required: bool = True) -> bool: + label = label.strip() + if not label or label in seen: + return label in seen + if len(variables) >= MAX_VARIABLES or label.isdigit(): + return False + seen.add(label) + variables.append({ + "name": label, "label": label, "type": "text", + "placeholder": default or f"填写{label}", "required": required and not default, + "default": default, + }) + return True + + def repl_arg(m: re.Match) -> str: + label, default = m.group(1), m.group(2) or "" + return f"{{{label.strip()}}}" if add(label, default) else (default or label) + + prompt = _ARG_RE.sub(repl_arg, prompt) + + for m in _CURLY_RE.finditer(prompt): + add(m.group(1)) + + if include_brackets: + def repl_bracket(m: re.Match) -> str: + return f"{{{m.group(1).strip()}}}" if add(m.group(1)) else m.group(0) + prompt = _BRACKET_RE.sub(repl_bracket, prompt) + + # 只保留真正被识别为变量的花括号,其余保持原样(assemble 时不会替换) + return prompt, variables + + +def guess_size(text: str) -> str: + t = text.lower() + if any(k in t for k in ("9:16", "3:4", "2:3", "vertical", "portrait format", "竖版", "竖屏")): + return "1024x1536" + if any(k in t for k in ("16:9", "4:3", "3:2", "horizontal", "landscape format", "横版", "横屏")): + return "1536x1024" + return "1024x1024" + + +def make_template(*, source_id: str, index: int, name: str, prompt: str, + category: str = "", description: str = "", tags: list[str] | None = None, + author: str = "", link: str = "", license_: str = "", + variables: list[dict] | None = None, default_size: str = "", + icon: str = "AUTO_AWESOME", include_brackets: bool = True) -> dict: + """组装一条标准模板。未提供 variables 时从提示词里自动识别。""" + prompt = prompt.strip() + if variables is None: + prompt, variables = detect_variables(prompt, include_brackets=include_brackets) + one_line = " ".join(prompt.split()) + return { + "id": f"{source_id}:{index}", + "name": (name or one_line[:24] or f"提示词 {index + 1}").strip()[:60], + "description": (description or one_line[:80]).strip(), + "category": _CATEGORY_ZH.get((category or "").strip(), (category or "未分类").strip()[:30]), + "icon": icon, + "tags": [t for t in (tags or []) if t][:4], + "prompt_template": prompt, + "variables": variables, + "default_size": default_size or guess_size(prompt), + "source": source_id, + "author": author, + "link": link, + "license": license_, + } + + +# ── 解析入口 ───────────────────────────────────────────────────────────── +def parse_source(text: str, source_id: str, *, hint: str = "", + default_license: str = "") -> tuple[dict, list[dict]]: + """解析提示词源文本,返回 (元信息, 模板列表)。 + + hint 是 URL 或文件名,用扩展名辅助判断格式。 + 元信息:{"name", "license", "homepage", "format"},缺失项为空字符串。 + """ + text = text.lstrip("") + stripped = text.lstrip() + lower_hint = hint.lower().split("?")[0] + if lower_hint.endswith(".json") or stripped.startswith(("{", "[")): + meta, items = _parse_json(text, source_id) + elif lower_hint.endswith((".csv", ".tsv")): + meta, items = _parse_csv(text, source_id, delimiter="\t" if lower_hint.endswith(".tsv") else ",") + else: + meta, items = {"format": "markdown"}, _parse_markdown(text, source_id) + meta.setdefault("name", "") + meta.setdefault("homepage", "") + meta["license"] = meta.get("license") or default_license + if default_license or meta["license"]: + for t in items: + t["license"] = t.get("license") or meta["license"] + if not items: + raise SourceParseError("没有找到可用的提示词,请确认链接内容是 JSON / CSV / Markdown 提示词集合") + return meta, items + + +def _parse_json(text: str, source_id: str) -> tuple[dict, list[dict]]: + try: + data = json.loads(text) + except json.JSONDecodeError as e: + raise SourceParseError(f"JSON 格式错误:{e}") from e + meta: dict = {"format": "json"} + if isinstance(data, dict): + meta.update({k: str(data.get(k) or "") for k in ("name", "license", "homepage")}) + raw = data.get("templates") or data.get("prompts") or data.get("items") or data.get("data") or [] + elif isinstance(data, list): + raw = data + else: + raw = [] + items: list[dict] = [] + for i, entry in enumerate(raw): + if isinstance(entry, str): + entry = {"prompt": entry} + if not isinstance(entry, dict): + continue + prompt = entry.get("prompt_template") or entry.get("prompt") or entry.get("content") or "" + if not isinstance(prompt, str) or not prompt.strip(): + continue + tags = entry.get("tags") or [] + if isinstance(tags, str): + tags = [t.strip() for t in re.split(r"[,,|]", tags)] + variables = entry.get("variables") + if not (isinstance(variables, list) and all(isinstance(v, dict) and v.get("name") for v in variables)): + variables = None + items.append(make_template( + source_id=source_id, index=i, + name=str(entry.get("name") or entry.get("title") or entry.get("act") or ""), + prompt=prompt, category=str(entry.get("category") or ""), + description=str(entry.get("description") or ""), tags=[str(t) for t in tags], + author=str(entry.get("author") or ""), link=str(entry.get("link") or entry.get("url") or ""), + license_=str(entry.get("license") or ""), variables=variables, + default_size=str(entry.get("default_size") or ""), + icon=str(entry.get("icon") or "AUTO_AWESOME"), + include_brackets=False, + )) + return meta, items + + +def _parse_csv(text: str, source_id: str, delimiter: str = ",") -> tuple[dict, list[dict]]: + reader = csv.DictReader(io.StringIO(text), delimiter=delimiter) + if not reader.fieldnames: + raise SourceParseError("CSV 没有表头") + fields = {f.strip().lower(): f for f in reader.fieldnames if f} + prompt_col = fields.get("prompt") or fields.get("prompt_template") or fields.get("content") + if not prompt_col: + raise SourceParseError("CSV 缺少 prompt 列") + title_col = fields.get("title") or fields.get("name") or fields.get("act") + items: list[dict] = [] + for i, row in enumerate(reader): + prompt = (row.get(prompt_col) or "").strip() + if not prompt: + continue + tags_raw = row.get(fields.get("tags", ""), "") or "" + items.append(make_template( + source_id=source_id, index=i, + name=(row.get(title_col) or "") if title_col else "", + prompt=prompt, + category=row.get(fields.get("category", ""), "") or "", + tags=[t.strip() for t in re.split(r"[,,|]", tags_raw)], + author=row.get(fields.get("author", ""), "") or "", + include_brackets=False, + )) + return {"format": "csv"}, items + + +def _clean_category(title: str) -> str: + title = _LINK_RE.sub(r"\1", title) + title = re.sub(r"^[^\w一-鿿]+", "", title).strip() + title = re.sub(r"\s*(cases|case|案例)$", "", title, flags=re.IGNORECASE).strip() + return "" if title.lower() in _GENERIC_CATEGORIES else title + + +def _parse_heading(title: str) -> tuple[str, str, str]: + """标题 → (名称, 作者, 链接)。""" + author = "" + m = _AUTHOR_RE.search(title) + if m: + author = m.group(1) + title = title[:m.start()] + link = "" + lm = _LINK_RE.search(title) + if lm: + link = lm.group(2) + title = _LINK_RE.sub(r"\1", title) + title = re.sub(r"[*_`]", "", title) + title = _TITLE_PREFIX_RE.sub("", title.strip()) + return title.strip(" -—::"), author, link + + +def _parse_markdown(text: str, source_id: str) -> list[dict]: + items: list[dict] = [] + category = "" + current: dict | None = None + in_code = False + fence = "" + code_lang = "" + code: list[str] = [] + + def flush_entry() -> None: + if current and current.get("prompt"): + items.append(make_template( + source_id=source_id, index=len(items), name=current["name"], + prompt=current["prompt"], category=current["category"], + author=current["author"], link=current["link"], + tags=[current["category"]] if current["category"] else [], + )) + + for line in text.splitlines(): + stripped = line.strip() + if in_code: + if stripped.startswith(fence) and stripped.strip("`~") == "": + in_code = False + body = "\n".join(code).strip() + if (current is not None and not current.get("prompt") and body + and code_lang not in _CODE_LANGS and len(body) >= 15): + current["prompt"] = body + code = [] + else: + code.append(line) + continue + if stripped.startswith(("```", "~~~")): + in_code = True + fence = stripped[:3] + code_lang = stripped[3:].strip().lower() + continue + m = _HEADING_RE.match(line) + if m: + flush_entry() + level, title = len(m.group(1)), m.group(2) + if level == 2: + category = _clean_category(title) + name, author, link = _parse_heading(title) + current = {"name": name, "author": author, "link": link, + "category": category or "精选", "prompt": ""} + continue + # 标题后紧跟的 "[原文链接](...)" 补充为来源链接 + if current is not None and not current["link"]: + lm = _WEB_LINK_RE.search(stripped) + if lm: + current["link"] = lm.group(2) + flush_entry() + return items + + +def to_export_json(name: str, templates: list[dict]) -> str: + """导出为本应用的 JSON 提示词源格式(可分享给他人订阅 / 导入)。""" + keep = ("name", "description", "category", "tags", "prompt_template", "variables", + "default_size", "author", "link", "license") + data = { + "name": name, + "templates": [{k: t.get(k) for k in keep if t.get(k)} for t in templates], + } + return json.dumps(data, ensure_ascii=False, indent=2) diff --git a/file-toolkit/core/prompt_image/templates.py b/file-toolkit/core/prompt_image/templates.py index 5fcdcc0..66f2a61 100644 --- a/file-toolkit/core/prompt_image/templates.py +++ b/file-toolkit/core/prompt_image/templates.py @@ -1,8 +1,8 @@ """ -提示词模板库 +内置提示词模板库 -每个模板包含: -- id: 唯一标识 +模板结构(内置、自定义、订阅源导入的模板共用): +- id: 唯一标识(订阅源 / 自定义模板会带前缀,如 "custom:xxx") - name: 显示名称 - description: 简短描述 - category: 分类 @@ -11,355 +11,612 @@ - prompt_template: 提示词模板({变量名} 为占位符) - variables: 变量定义列表,每个包含 name, label, type, options, placeholder, required, default - default_size: 默认图片尺寸 +- source / author / link / license: 来源信息(内置模板 source="builtin") + +提示词正文用英文书写(生图模型对英文描述理解最稳定),变量值可填中文。 """ from __future__ import annotations -# 分类列表(全部排在最前,用于 UI 过滤栏) -CATEGORIES = ["全部", "海报", "社交媒体", "Logo", "产品", "艺术", "其他"] +BUILTIN_SOURCE = "builtin" + + +def _text(name: str, label: str, placeholder: str = "", required: bool = True, + default: str = "") -> dict: + return {"name": name, "label": label, "type": "text", "placeholder": placeholder, + "required": required, "default": default} + + +def _select(name: str, label: str, options: list[str], default: str | None = None) -> dict: + return {"name": name, "label": label, "type": "select", "options": options, + "default": default or options[0]} + + +def _tpl(id_: str, name: str, description: str, category: str, icon: str, + tags: list[str], prompt: str, variables: list[dict], + size: str = "1024x1024") -> dict: + return { + "id": id_, "name": name, "description": description, "category": category, + "icon": icon, "tags": tags, "prompt_template": prompt, "variables": variables, + "default_size": size, "source": BUILTIN_SOURCE, "author": "", "link": "", + "license": "", + } + TEMPLATES: list[dict] = [ - { - "id": "poster_minimal", - "name": "简约海报", - "description": "极简风格的活动/宣传海报", - "category": "海报", - "icon": "ARTICLE", - "tags": ["热门", "海报"], - "prompt_template": ( - "Design a minimalist poster for '{theme}'. " - "Main title: '{title}', subtitle: '{subtitle}'. " - "Style: {style}. Color scheme: {color_scheme}. " - "Layout: clean, modern, with ample white space. " - "Typography should be elegant and readable. " - "No clutter, focus on visual hierarchy." - ), - "variables": [ - {"name": "theme", "label": "主题", "type": "text", - "placeholder": "如:2026春季音乐节", "required": True}, - {"name": "title", "label": "主标题", "type": "text", - "placeholder": "如:春之声", "required": True}, - {"name": "subtitle", "label": "副标题", "type": "text", - "placeholder": "如:2026.05.20 城市音乐厅", "required": False, "default": ""}, - {"name": "style", "label": "风格", "type": "select", - "options": ["极简主义", "瑞士平面设计", "日式侘寂", "北欧风格"], - "default": "极简主义"}, - {"name": "color_scheme", "label": "配色方案", "type": "select", - "options": ["黑白灰", "莫兰迪色系", "蓝白经典", "暖色调", "冷色调"], - "default": "黑白灰"}, + # ── 海报设计 ─────────────────────────────────────────────────────── + _tpl( + "poster_minimal", "简约海报", "极简留白的活动 / 宣传海报,层级清晰", + "海报设计", "ARTICLE", ["热门", "排版"], + "A minimalist event poster for \"{theme}\". Headline text \"{title}\" set in a " + "refined sans-serif, secondary line \"{subtitle}\" in smaller type. Style: {style}. " + "Color palette: {color_scheme}. Generous negative space, strict grid alignment, " + "one strong focal graphic, clear visual hierarchy, crisp print-ready typography, " + "all text spelled exactly as given.", + [ + _text("theme", "主题", "如:2026 春季音乐节"), + _text("title", "主标题", "如:春之声"), + _text("subtitle", "副标题", "如:2026.05.20 城市音乐厅", required=False), + _select("style", "风格", ["Swiss International Style", "Japanese wabi-sabi", + "Scandinavian minimal", "Bauhaus geometric"]), + _select("color_scheme", "配色", ["black, white and one red accent", + "muted Morandi tones", "classic blue and white", + "warm earth tones", "cool monochrome blue"]), + ], + "1024x1536", + ), + _tpl( + "poster_tech", "科技发布会海报", "未来感强烈的产品发布 / 峰会主视觉", + "海报设计", "ROCKET_LAUNCH", ["科技", "主视觉"], + "A futuristic key visual poster for \"{event_name}\", theme: {theme}. " + "Visual language: {style}, {color_scheme} palette, abstract light trails, particles " + "and holographic data streams converging on a glowing central object. Bold modern " + "sans-serif headline \"{event_name}\" with precise kerning. Mood: {atmosphere}. " + "High contrast, cinematic volumetric lighting, ultra sharp details.", + [ + _text("event_name", "活动 / 产品名称", "如:AI Summit 2026"), + _text("theme", "主题", "如:智能改变未来"), + _select("style", "视觉风格", ["sleek cyberpunk", "holographic glassmorphism", + "dark premium tech", "deep space exploration"]), + _select("color_scheme", "配色", ["blue-violet gradient", "cyan on black", + "neon multicolor", "gold on black"]), + _select("atmosphere", "氛围", ["epic and powerful", "mysterious", "premium", + "energetic"]), + ], + "1024x1536", + ), + _tpl( + "poster_film", "电影海报", "好莱坞院线风格的电影 / 剧集海报", + "海报设计", "MOVIE", ["电影", "叙事"], + "A theatrical movie poster for a {genre} film titled \"{title}\". Key art: {scene}. " + "Dramatic cinematic lighting, strong silhouette composition, subtle film grain, " + "{color_grade} color grading. Title in large custom lettering near the bottom, " + "tagline \"{tagline}\" above it, small billing block at the very bottom. " + "Professional one-sheet layout.", + [ + _text("title", "片名", "如:长夜将尽"), + _text("scene", "主画面", "如:雨夜霓虹街头,主角背影望向远方高楼"), + _select("genre", "类型", ["sci-fi thriller", "romantic drama", "fantasy adventure", + "crime noir", "animated family", "horror"]), + _select("color_grade", "调色", ["teal and orange", "desaturated cold", + "warm vintage", "high-contrast noir"]), + _text("tagline", "宣传语", "如:黎明之前,最黑暗", required=False), + ], + "1024x1536", + ), + _tpl( + "poster_chinese", "国潮海报", "新中式 / 国潮风格的节日与品牌海报", + "海报设计", "TEMPLE_BUDDHIST", ["国风", "节日"], + "A Chinese guochao style poster for {occasion}. Main subject: {subject}. " + "Blend traditional elements ({elements}) with modern flat graphic design, " + "rich {palette} palette, paper-cut layering and subtle gold foil texture. " + "Large Chinese calligraphy headline \"{headline}\" rendered accurately, " + "balanced asymmetric layout, festive yet refined.", + [ + _text("occasion", "场景 / 节日", "如:中秋节、品牌周年庆"), + _text("subject", "主体", "如:玉兔捧月饼"), + _text("headline", "标题文字", "如:月满中秋"), + _text("elements", "传统元素", "如:祥云、灯笼、山水", required=False, + default="auspicious clouds, lanterns, mountains"), + _select("palette", "配色", ["vermilion and gold", "indigo and porcelain white", + "jade green and gold", "ink black and cinnabar"]), + ], + "1024x1536", + ), + # ── 电商产品 ─────────────────────────────────────────────────────── + _tpl( + "product_hero", "电商主图", "白底 / 场景化的商品主图,适合详情页首图", + "电商产品", "SHOPPING_BAG", ["热门", "电商"], + "Professional e-commerce hero shot of {product}. {style}. Background: {background}. " + "Lighting: {lighting}. Product centered and perfectly in focus, {angle}, accurate " + "materials and reflections, clean edges, subtle contact shadow, premium " + "commercial photography, 8K detail. {props}", + [ + _text("product", "产品", "如:哑光黑无线降噪耳机"), + _select("style", "拍摄风格", ["Pure studio packshot", "Lifestyle scene styling", + "Dark moody luxury look", "Bright fresh look", + "Magazine editorial look"]), + _select("background", "背景", ["seamless pure white", "soft gradient backdrop", + "polished marble surface", "natural wood tabletop", + "colored paper set with geometric pedestals"]), + _select("lighting", "灯光", ["large softbox, soft and even", + "hard light with crisp shadows", + "rim light outlining the silhouette", + "natural window light"]), + _select("angle", "角度", ["three-quarter view", "front view", "top-down view", + "low hero angle", "macro close-up"]), + _text("props", "道具 / 补充", "如:绿植、水珠、丝绸", required=False), + ], + ), + _tpl( + "product_splash", "动感飞溅广告", "液体飞溅、食材悬浮的高冲击力广告图", + "电商产品", "WATER_DROP", ["广告", "动感"], + "High-speed commercial photograph of {product} surrounded by dynamic {splash} " + "frozen in mid-air, with {ingredients} floating around it. Dramatic studio lighting, " + "{background} background, crystal-clear droplets, shallow depth of field, " + "ultra-detailed textures, vibrant yet realistic colors, award-winning advertising shot.", + [ + _text("product", "产品", "如:气泡橙汁饮料瓶"), + _select("splash", "飞溅元素", ["liquid splash", "milk crown splash", "powder explosion", + "water ripples", "chocolate swirl"]), + _text("ingredients", "悬浮食材", "如:鲜橙切片、薄荷叶、冰块"), + _select("background", "背景色", ["deep gradient", "bright solid color", + "black", "pastel"]), + ], + ), + _tpl( + "product_miniature", "微缩场景广告", "小人国微缩世界与超大产品的创意组合", + "电商产品", "PRECISION_MANUFACTURING", ["创意", "微缩"], + "A hyper-realistic miniature diorama advertisement: an oversized {product} stands " + "on a circular platform while tiny figurines {activity} around it. Tilt-shift " + "photography, soft diffused studio light, {palette} palette, meticulous tiny " + "details (scaffolding, vehicles, props), clean seamless background, playful " + "and premium commercial CGI render.", + [ + _text("product", "产品", "如:护肤乳液瓶"), + _text("activity", "小人在做什么", "如:搭脚手架给瓶身刷漆、开吊车搬运"), + _select("palette", "色调", ["warm beige and gold", "fresh mint and white", + "bold primary colors", "pastel candy colors"]), + ], + ), + _tpl( + "food_photography", "美食摄影", "餐厅菜单、外卖与美食博主的出片", + "电商产品", "RESTAURANT", ["美食", "摄影"], + "Appetizing professional food photograph of {dish}, {cuisine} cuisine. " + "Styling: {style}. Surface: {background}. Lighting: {lighting}. " + "Visible steam and fresh garnish, glossy textures, {angle}, shallow depth of field, " + "magazine-quality food styling.", + [ + _text("dish", "菜品", "如:抹茶提拉米苏"), + _select("cuisine", "菜系", ["Chinese", "Japanese", "Western", "dessert", + "beverage", "Southeast Asian"]), + _select("style", "风格", ["editorial magazine", "cozy home cooking", + "dark moody fine dining", "bright and airy", + "trendy cafe"]), + _select("background", "台面", ["rustic wood table", "marble countertop", + "solid color backdrop", "restaurant setting"]), + _select("lighting", "光线", ["soft window light", "warm tungsten light", + "hard sunlight with shadows", "large softbox"]), + _select("angle", "角度", ["45-degree angle", "overhead flat lay", "eye level", + "macro close-up"]), ], - "default_size": "1024x1536", - }, - { - "id": "poster_tech", - "name": "科技海报", - "description": "未来感十足的科技/发布会海报", - "category": "海报", - "icon": "ROCKET_LAUNCH", - "tags": ["科技", "海报"], - "prompt_template": ( - "Create a futuristic tech poster for '{event_name}'. " - "Theme: {theme}. Style: {style} with {color_scheme} color palette. " - "Include abstract tech elements like circuits, particles, or data streams. " - "Typography: bold, modern sans-serif. " - "Atmosphere: {atmosphere}. High contrast, dramatic lighting." - ), - "variables": [ - {"name": "event_name", "label": "活动/产品名称", "type": "text", - "placeholder": "如:AI Summit 2026", "required": True}, - {"name": "theme", "label": "主题", "type": "text", - "placeholder": "如:人工智能改变未来", "required": True}, - {"name": "style", "label": "视觉风格", "type": "select", - "options": ["赛博朋克", "全息投影", "暗黑科技", "太空探索"], - "default": "赛博朋克"}, - {"name": "color_scheme", "label": "配色", "type": "select", - "options": ["蓝紫渐变", "青黑", "霓虹炫彩", "金色黑底"], - "default": "蓝紫渐变"}, - {"name": "atmosphere", "label": "氛围", "type": "select", - "options": ["震撼", "神秘", "高端", "活力"], "default": "震撼"}, + ), + # ── 摄影写真 ─────────────────────────────────────────────────────── + _tpl( + "portrait_studio", "影棚人像", "专业棚拍人像 / 形象照 / 头像", + "摄影写真", "PORTRAIT", ["人像", "形象照"], + "Professional studio portrait of {subject}, wearing {outfit}, {expression}. " + "{lighting}, {background} backdrop, shot on 85mm lens at f/1.8, natural skin texture, " + "sharp eyes, subtle retouching, editorial quality.", + [ + _text("subject", "人物", "如:30 岁亚洲女性产品经理,短发"), + _text("outfit", "服装", "如:米色西装外套", required=False, + default="smart casual clothing"), + _select("expression", "表情姿态", ["confident gentle smile", "serious and focused", + "candid laughing", "thoughtful side glance"]), + _select("lighting", "布光", ["Rembrandt lighting", "soft butterfly lighting", + "clamshell beauty lighting", "low-key dramatic lighting"]), + _select("background", "背景", ["neutral gray", "warm beige", "deep navy", + "pure white", "textured canvas"]), ], - "default_size": "1024x1536", - }, - { - "id": "social_media_cover", - "name": "社交媒体封面", - "description": "适用于 Instagram/微信/微博等平台的封面图", - "category": "社交媒体", - "icon": "SHARE", - "tags": ["社交", "封面"], - "prompt_template": ( - "Design a social media cover image for {platform}. " - "Content: {content_theme}. " - "Style: {style}. Color mood: {color_mood}. " - "Aspect ratio optimized for {platform}. " - "Eye-catching, scroll-stopping design with clear focal point. " - "Include subtle {element} as decorative elements." - ), - "variables": [ - {"name": "platform", "label": "平台", "type": "select", - "options": ["Instagram (1:1)", "Instagram Story (9:16)", - "微信公众号封面", "微博封面", "小红书封面"], - "default": "Instagram (1:1)"}, - {"name": "content_theme", "label": "内容主题", "type": "text", - "placeholder": "如:旅行日记、美食推荐、穿搭分享", "required": True}, - {"name": "style", "label": "风格", "type": "select", - "options": ["清新文艺", "潮流街头", "高级感", "可爱插画", "摄影风"], - "default": "清新文艺"}, - {"name": "color_mood", "label": "色彩氛围", "type": "select", - "options": ["明亮温暖", "冷淡高级", "粉嫩少女", "深沉质感", "彩虹活力"], - "default": "明亮温暖"}, - {"name": "element", "label": "装饰元素", "type": "text", - "placeholder": "如:花朵、几何图形、手绘线条", - "required": False, "default": "几何图形"}, + "1024x1536", + ), + _tpl( + "portrait_film", "胶片写真", "复古胶片质感的街拍 / 旅行写真", + "摄影写真", "CAMERA_ROLL", ["胶片", "氛围"], + "Candid film photograph of {subject} at {location}, {time}. Shot on {film}, " + "35mm lens, natural grain, slight halation, authentic color shifts, " + "unposed moment, nostalgic cinematic atmosphere.", + [ + _text("subject", "人物 / 主体", "如:穿白裙的女孩骑单车"), + _text("location", "地点", "如:海边公路、京都小巷"), + _select("time", "时间", ["golden hour", "blue hour", "overcast afternoon", + "night with neon lights"]), + _select("film", "胶片", ["Kodak Portra 400", "Fujifilm Superia 400", + "Kodak Gold 200", "CineStill 800T", "Ilford HP5 black and white"]), ], - "default_size": "1024x1024", - }, - { - "id": "logo_modern", - "name": "现代Logo", - "description": "简洁现代的品牌/公司 Logo 设计", - "category": "Logo", - "icon": "BRANDING_WATERMARK", - "tags": ["Logo", "品牌"], - "prompt_template": ( - "Design a modern, minimalist logo for '{brand_name}'. " - "Industry: {industry}. " - "Style: {style}. Colors: {colors}. " - "The logo should be simple, memorable, and scalable. " - "Clean lines, professional look. " - "Background: {background}. Vector-style rendering." - ), - "variables": [ - {"name": "brand_name", "label": "品牌名称", "type": "text", - "placeholder": "如:NovaTech", "required": True}, - {"name": "industry", "label": "行业", "type": "select", - "options": ["科技", "餐饮", "教育", "时尚", "金融", "健康", "娱乐", "其他"], - "default": "科技"}, - {"name": "style", "label": "风格", "type": "select", - "options": ["极简", "几何", "手写体", "字母组合", "图标+文字", "抽象符号"], - "default": "极简"}, - {"name": "colors", "label": "主色调", "type": "text", - "placeholder": "如:深蓝+白色、黑金", - "required": False, "default": "深蓝+白色"}, - {"name": "background", "label": "背景", "type": "select", - "options": ["纯白", "纯黑", "透明感", "渐变"], "default": "纯白"}, + "1024x1536", + ), + _tpl( + "landscape_photo", "风光摄影", "国家地理风格的自然风光大片", + "摄影写真", "LANDSCAPE", ["风景", "大片"], + "Breathtaking landscape photograph of {place}, {weather}, {time}. " + "Foreground {foreground} leading into the scene, layered depth, {lens}, " + "long exposure where appropriate, rich dynamic range, National Geographic quality.", + [ + _text("place", "地点", "如:冰岛黑沙滩、稻城亚丁"), + _select("weather", "天气", ["dramatic clouds", "light mist", "clear sky", + "aurora in the sky", "after-rain clarity"]), + _select("time", "时间", ["sunrise", "golden hour sunset", "blue hour", + "starry night with milky way"]), + _text("foreground", "前景", "如:岩石与溪流", required=False, + default="textured rocks"), + _select("lens", "镜头", ["ultra wide-angle 16mm", "telephoto compression 200mm", + "panoramic composition"]), ], - "default_size": "1024x1024", - }, - { - "id": "product_showcase", - "name": "产品展示", - "description": "电商/营销用的产品展示图", - "category": "产品", - "icon": "SHOPPING_BAG", - "tags": ["电商", "产品"], - "prompt_template": ( - "Create a professional product showcase image for '{product_name}'. " - "Category: {category}. Style: {style}. " - "Background: {background}. Lighting: {lighting}. " - "The product should be the clear focal point, {angle} angle. " - "High-end commercial photography feel. " - "Include subtle {props} as styling props." - ), - "variables": [ - {"name": "product_name", "label": "产品名称", "type": "text", - "placeholder": "如:无线降噪耳机", "required": True}, - {"name": "category", "label": "品类", "type": "select", - "options": ["电子产品", "美妆护肤", "食品饮料", "服装配饰", "家居用品", "其他"], - "default": "电子产品"}, - {"name": "style", "label": "拍摄风格", "type": "select", - "options": ["白底纯净", "场景化", "暗调质感", "活力明亮", "杂志大片"], - "default": "白底纯净"}, - {"name": "background", "label": "背景", "type": "select", - "options": ["纯白", "大理石纹", "木纹桌面", "渐变色", "生活场景"], - "default": "纯白"}, - {"name": "lighting", "label": "灯光", "type": "select", - "options": ["柔和自然光", "硬光对比", "环形光", "逆光轮廓"], - "default": "柔和自然光"}, - {"name": "angle", "label": "拍摄角度", "type": "select", - "options": ["正面", "45度俯拍", "平视", "仰拍", "特写"], - "default": "45度俯拍"}, - {"name": "props", "label": "搭配道具", "type": "text", - "placeholder": "如:绿植、咖啡杯", "required": False, "default": ""}, + "1536x1024", + ), + _tpl( + "pet_photo", "萌宠写真", "宠物肖像、表情包与创意宠物照", + "摄影写真", "PETS", ["宠物", "可爱"], + "Adorable photo of {pet}, {action}, in {scene}. {style}, eye-level shot, " + "sharp focus on the eyes, soft fur details, warm natural light, joyful mood.", + [ + _text("pet", "宠物", "如:橘色英短猫"), + _text("action", "动作", "如:戴着小厨师帽偷吃饼干"), + _text("scene", "场景", "如:温馨厨房"), + _select("style", "风格", ["Professional pet photography", "Studio portrait", + "Pixar-like 3D render", "Watercolor illustration"]), ], - "default_size": "1024x1024", - }, - { - "id": "art_digital", - "name": "数字艺术", - "description": "创意数字艺术/插画作品", - "category": "艺术", - "icon": "PALETTE", - "tags": ["艺术", "创意"], - "prompt_template": ( - "Create a digital artwork depicting '{scene}'. " - "Art style: {art_style}. Mood: {mood}. " - "Color palette: {color_palette}. " - "Composition: {composition}. " - "Highly detailed, artistic, visually stunning. " - "Inspired by {inspiration}." - ), - "variables": [ - {"name": "scene", "label": "画面场景", "type": "text", - "placeholder": "如:漂浮在云端的古城", "required": True}, - {"name": "art_style", "label": "艺术风格", "type": "select", - "options": ["赛博朋克", "水彩插画", "油画质感", "像素艺术", - "3D渲染", "中国风水墨", "日系动漫"], - "default": "水彩插画"}, - {"name": "mood", "label": "氛围", "type": "select", - "options": ["梦幻", "宁静", "壮丽", "神秘", "温馨", "暗黑"], - "default": "梦幻"}, - {"name": "color_palette", "label": "色彩", "type": "select", - "options": ["暖色系", "冷色系", "霓虹色", "莫兰迪", "单色", "彩虹"], - "default": "暖色系"}, - {"name": "composition", "label": "构图", "type": "select", - "options": ["对称构图", "三分法", "引导线", "框架构图", "极简留白"], - "default": "三分法"}, - {"name": "inspiration", "label": "灵感来源", "type": "text", - "placeholder": "如:宫崎骏、莫奈、AKIRA", - "required": False, "default": ""}, + ), + # ── 插画艺术 ─────────────────────────────────────────────────────── + _tpl( + "art_digital", "数字插画", "概念感强的数字艺术 / 插画作品", + "插画艺术", "PALETTE", ["艺术", "创意"], + "A digital illustration of {scene}. Art style: {art_style}. Mood: {mood}. " + "Color palette: {color_palette}. Composition: {composition}. " + "Highly detailed, painterly brushwork, cohesive lighting. {inspiration}", + [ + _text("scene", "画面场景", "如:漂浮在云端的古城"), + _select("art_style", "艺术风格", ["Studio Ghibli-inspired anime", "watercolor", + "impasto oil painting", "pixel art", + "cel-shaded comic", "Chinese ink wash painting", + "concept art matte painting"]), + _select("mood", "氛围", ["dreamy", "serene", "epic", "mysterious", "cozy", "dark"]), + _select("color_palette", "色彩", ["warm tones", "cool tones", "neon", + "Morandi muted", "monochrome", "vivid rainbow"]), + _select("composition", "构图", ["rule of thirds", "symmetrical", "leading lines", + "frame within a frame", "minimal negative space"]), + _text("inspiration", "补充描述", "如:受宫崎骏启发", required=False), ], - "default_size": "1024x1024", - }, - { - "id": "wallpaper_abstract", - "name": "抽象壁纸", - "description": "桌面/手机抽象艺术壁纸", - "category": "艺术", - "icon": "WALLPAPER", - "tags": ["壁纸", "抽象"], - "prompt_template": ( - "Design an abstract wallpaper for {device}. " - "Theme: {theme}. Style: {style}. " - "Colors: {colors}. " - "Flowing shapes, smooth gradients, modern aesthetic. " - "Resolution optimized for {device} screen. " - "Visually calming, suitable as a background wallpaper." - ), - "variables": [ - {"name": "device", "label": "设备", "type": "select", - "options": ["桌面电脑 (16:9)", "手机 (9:16)", "平板 (4:3)"], - "default": "桌面电脑 (16:9)"}, - {"name": "theme", "label": "主题", "type": "text", - "placeholder": "如:宇宙星云、海洋波浪、极光", "required": True}, - {"name": "style", "label": "风格", "type": "select", - "options": ["流体渐变", "几何抽象", "有机形态", "故障艺术", "极简线条"], - "default": "流体渐变"}, - {"name": "colors", "label": "配色", "type": "text", - "placeholder": "如:深紫+青蓝、粉橙渐变", - "required": False, "default": "深紫+青蓝"}, + ), + _tpl( + "children_book", "绘本插画", "温暖可爱的儿童绘本跨页", + "插画艺术", "MENU_BOOK", ["绘本", "儿童"], + "A children's picture book illustration: {story}. Soft {medium} texture, " + "rounded friendly characters with expressive faces, warm gentle palette, " + "storybook composition with room for text at the {text_area}, whimsical details, " + "heartwarming mood.", + [ + _text("story", "画面故事", "如:小狐狸和刺猬在森林里分享苹果"), + _select("medium", "画材", ["watercolor and colored pencil", "gouache", + "crayon", "digital soft brush"]), + _select("text_area", "留白位置", ["top", "bottom", "left side", "right side"]), ], - "default_size": "1024x1536", - }, - { - "id": "food_photography", - "name": "美食摄影", - "description": "餐厅/美食博主专用的食物摄影", - "category": "产品", - "icon": "RESTAURANT", - "tags": ["美食", "摄影"], - "prompt_template": ( - "Create an appetizing food photograph of '{dish_name}'. " - "Cuisine: {cuisine}. Style: {style}. " - "Background: {background}. Lighting: {lighting}. " - "Steam or fresh ingredients visible. " - "Shot from {angle} angle. " - "Professional food photography, magazine quality." - ), - "variables": [ - {"name": "dish_name", "label": "菜品名称", "type": "text", - "placeholder": "如:抹茶提拉米苏", "required": True}, - {"name": "cuisine", "label": "菜系", "type": "select", - "options": ["中餐", "日料", "西餐", "甜品", "饮品", "东南亚"], - "default": "甜品"}, - {"name": "style", "label": "风格", "type": "select", - "options": ["杂志大片", "家庭温馨", "暗调高级", "明亮清新", "ins网红风"], - "default": "杂志大片"}, - {"name": "background", "label": "背景", "type": "select", - "options": ["木桌", "大理石台面", "纯色背景", "餐厅环境", "户外自然"], - "default": "木桌"}, - {"name": "lighting", "label": "光线", "type": "select", - "options": ["自然窗光", "暖色灯光", "硬光投影", "柔光箱"], - "default": "自然窗光"}, - {"name": "angle", "label": "角度", "type": "select", - "options": ["45度俯拍", "正俯拍 (Flat Lay)", "平视", "特写"], - "default": "45度俯拍"}, + "1536x1024", + ), + _tpl( + "ink_painting", "水墨国画", "中国传统水墨 / 工笔意境画", + "插画艺术", "BRUSH", ["国风", "水墨"], + "Traditional Chinese {technique} painting of {subject}. Expressive brush strokes, " + "ink gradations from deep black to pale gray, {color}, rice paper texture, " + "poetic negative space, red seal stamp in the corner, elegant and timeless.", + [ + _text("subject", "题材", "如:烟雨江南小桥流水"), + _select("technique", "技法", ["xieyi freehand ink", "gongbi fine-line", + "blue-green landscape", "splash ink"]), + _select("color", "设色", ["pure ink only", "light color washes", + "touches of vermilion", "mineral blue and green"]), ], - "default_size": "1024x1024", - }, - { - "id": "architecture", - "name": "建筑设计", - "description": "建筑外观/室内设计效果图", - "category": "艺术", - "icon": "ARCHITECTURE", - "tags": ["建筑", "设计"], - "prompt_template": ( - "Visualize a {building_type} with {arch_style} architecture. " - "Setting: {setting}. Time of day: {time}. " - "Materials: {materials}. Mood: {mood}. " - "Photorealistic rendering, architectural visualization quality. " - "Professional composition with dramatic lighting." - ), - "variables": [ - {"name": "building_type", "label": "建筑类型", "type": "select", - "options": ["现代别墅", "摩天大楼", "中式庭院", "教堂/寺庙", - "商业空间", "图书馆", "咖啡馆"], - "default": "现代别墅"}, - {"name": "arch_style", "label": "建筑风格", "type": "select", - "options": ["现代极简", "新中式", "哥特式", "日式枯山水", - "工业风", "Art Deco"], - "default": "现代极简"}, - {"name": "setting", "label": "环境", "type": "select", - "options": ["城市中心", "海边悬崖", "山林之间", "沙漠绿洲", "湖畔"], - "default": "海边悬崖"}, - {"name": "time", "label": "时间", "type": "select", - "options": ["黄金时刻 (日落)", "蓝色时刻 (黄昏)", "正午", - "夜晚灯光", "清晨薄雾"], - "default": "黄金时刻 (日落)"}, - {"name": "materials", "label": "主要材质", "type": "text", - "placeholder": "如:玻璃+混凝土+木材", - "required": False, "default": "玻璃+混凝土"}, - {"name": "mood", "label": "氛围", "type": "select", - "options": ["宁静", "壮观", "温馨", "神秘", "未来感"], - "default": "宁静"}, + "1024x1536", + ), + _tpl( + "sticker_set", "贴纸表情包", "一组风格统一的贴纸 / 表情", + "插画艺术", "EMOJI_EMOTIONS", ["表情包", "贴纸"], + "A sticker sheet of {count} cute stickers featuring {character}, each showing a " + "different emotion or action ({emotions}). {style}, thick white die-cut border, " + "consistent character design, flat bright colors, evenly spaced grid on a plain " + "background.", + [ + _text("character", "角色", "如:一只戴眼镜的柴犬"), + _select("count", "数量", ["6", "9", "12", "16"], "9"), + _text("emotions", "表情 / 动作", "如:开心、生气、比心、加油、睡觉", + required=False, default="happy, angry, love, cheering, sleepy, surprised"), + _select("style", "风格", ["Kawaii chibi style", "Flat vector style", + "3D clay style", "Hand-drawn doodle style"]), ], - "default_size": "1536x1024", - }, - { - "id": "custom", - "name": "自由创作", - "description": "完全自定义你的生图提示词", - "category": "其他", - "icon": "EDIT_NOTE", - "tags": ["自定义"], - "prompt_template": "{custom_prompt}", - "variables": [ + ), + # ── 品牌 Logo ────────────────────────────────────────────────────── + _tpl( + "logo_modern", "现代 Logo", "简洁、可识别、可缩放的品牌标志", + "品牌 Logo", "BRANDING_WATERMARK", ["Logo", "品牌"], + "A modern logo design for \"{brand_name}\", a {industry} brand. Style: {style}. " + "Colors: {colors}. Simple, memorable, scalable mark with balanced negative space, " + "precise geometry, vector-style flat rendering, brand name set in a clean " + "typeface, centered on a {background} background. No mockup, no extra text.", + [ + _text("brand_name", "品牌名称", "如:NovaTech"), + _select("industry", "行业", ["technology", "food and beverage", "education", + "fashion", "finance", "healthcare", "entertainment"]), + _select("style", "风格", ["minimal line mark", "geometric symbol", "lettermark", + "emblem badge", "abstract symbol with wordmark", + "hand-lettered script"]), + _text("colors", "主色调", "如:深蓝 + 白色", required=False, + default="deep blue and white"), + _select("background", "背景", ["pure white", "pure black", "light gray"]), + ], + ), + _tpl( + "brand_vi", "品牌 VI 展示", "名片、包装、手提袋等品牌物料全家福", + "品牌 Logo", "STYLE", ["VI", "样机"], + "A brand identity presentation board for \"{brand_name}\" ({industry}). " + "Neatly arranged mockups: business cards, letterhead, packaging box, tote bag, " + "mobile app screen and signage, all using a consistent {style} visual system with " + "{colors} colors. Top-down flat lay on a {surface}, soft shadows, professional " + "design portfolio photography.", + [ + _text("brand_name", "品牌名称", "如:森野咖啡"), + _text("industry", "行业", "如:精品咖啡馆"), + _select("style", "视觉风格", ["minimal modern", "warm organic", "bold playful", + "luxury classic"]), + _text("colors", "品牌色", "如:森林绿 + 奶油白"), + _select("surface", "台面", ["light concrete surface", "linen fabric", + "warm wood desk", "solid color paper"]), + ], + "1536x1024", + ), + # ── 3D 与图标 ────────────────────────────────────────────────────── + _tpl( + "icon_3d", "3D 图标", "柔和材质的 3D 应用图标 / 功能图标", + "3D 与图标", "VIEW_IN_AR", ["图标", "3D"], + "A single 3D icon of {object}, {material} material, soft rounded shapes, " + "{palette} colors, gentle studio lighting with soft shadow, isometric " + "three-quarter view, centered on a plain {background} background, " + "clean high-quality render suitable for app UI.", + [ + _text("object", "图标内容", "如:带闪电的文件夹"), + _select("material", "材质", ["glossy plastic", "frosted glass", "soft clay", + "brushed metal", "inflatable vinyl", "plush fabric"]), + _text("palette", "配色", "如:蓝紫渐变", required=False, + default="blue and purple gradient"), + _select("background", "背景", ["white", "light gray", "transparent-looking pale"]), + ], + ), + _tpl( + "chibi_figure", "Q 版手办", "潮玩手办 / 盲盒公仔效果", + "3D 与图标", "TOYS", ["手办", "潮玩"], + "A chibi collectible vinyl figure of {character}, big head small body proportions, " + "{outfit}, holding {prop}. Glossy painted finish, standing on a round display base, " + "next to its {packaging}. Soft studio lighting, product photography, " + "pop mart blind box aesthetic.", + [ + _text("character", "角色", "如:穿汉服的女孩"), + _text("outfit", "服装", "如:红色斗篷", required=False, default="signature outfit"), + _text("prop", "手持道具", "如:糖葫芦", required=False, default="a small prop"), + _select("packaging", "包装", ["illustrated blind box packaging", + "clear window display box", "no packaging"]), + ], + ), + _tpl( + "isometric_room", "等距微缩场景", "2.5D 等距视角的房间 / 建筑小场景", + "3D 与图标", "HOLIDAY_VILLAGE", ["等距", "微缩"], + "A cute isometric 3D miniature of {scene}, cut-away diorama on a floating square " + "base, highly detailed tiny furniture and objects, {style}, soft global " + "illumination, {palette} palette, clean solid background.", + [ + _text("scene", "场景", "如:程序员的深夜书房"), + _select("style", "风格", ["Blender low-poly", "soft clay render", + "voxel style", "realistic miniature"]), + _select("palette", "色调", ["warm cozy", "pastel", "cool night", "vivid"]), + ], + ), + # ── 社交媒体 ─────────────────────────────────────────────────────── + _tpl( + "xiaohongshu_cover", "小红书封面", "吸睛的笔记封面,大字标题 + 主体图", + "社交媒体", "AUTO_STORIES", ["热门", "小红书"], + "A Xiaohongshu (RED) note cover image about {topic}. Large bold Chinese headline " + "\"{headline}\" rendered accurately, {style} design, eye-catching {palette} colors, " + "playful stickers and highlight marks, main visual: {visual}. Vertical 3:4 " + "layout, scroll-stopping, clean and trendy.", + [ + _text("topic", "笔记主题", "如:周末露营好物"), + _text("headline", "封面大字", "如:露营新手必看清单"), + _text("visual", "主体画面", "如:帐篷与咖啡壶", required=False, + default="a relevant product flat lay"), + _select("style", "风格", ["cute hand-drawn", "minimal magazine", + "bold contrast", "film photo collage"]), + _select("palette", "配色", ["cream and pink", "fresh green", "bright yellow", + "black white red"]), + ], + "1024x1536", + ), + _tpl( + "social_media_cover", "公众号 / 视频封面", "公众号头图、B 站 / YouTube 视频封面", + "社交媒体", "SMART_DISPLAY", ["封面", "视频"], + "A {platform} cover image about {topic}. Bold readable title \"{title}\" with " + "strong contrast, {style}, expressive focal subject, clear visual hierarchy, " + "high click-through thumbnail design.", + [ + _select("platform", "平台", ["WeChat article header (2.35:1)", + "YouTube thumbnail (16:9)", "Bilibili cover (16:9)"]), + _text("topic", "内容主题", "如:三分钟学会 PDF 合并"), + _text("title", "标题文字", "如:PDF 合并只要 3 秒"), + _select("style", "风格", ["clean tech style", "vibrant pop style", + "cinematic photo style", "flat illustration style"]), + ], + "1536x1024", + ), + # ── 建筑空间 ─────────────────────────────────────────────────────── + _tpl( + "architecture", "建筑效果图", "建筑外观的写实可视化渲染", + "建筑空间", "ARCHITECTURE", ["建筑", "渲染"], + "Architectural visualization of a {building_type} in {arch_style} style, located " + "{setting}, at {time}. Materials: {materials}. Photorealistic rendering, accurate " + "proportions, people for scale, lush landscaping, dramatic sky, award-winning " + "architecture photography.", + [ + _select("building_type", "建筑类型", ["modern villa", "skyscraper", + "Chinese courtyard house", "museum", + "library", "boutique cafe"]), + _select("arch_style", "建筑风格", ["contemporary minimalist", "new Chinese", + "brutalist", "Japanese", "industrial", + "parametric futuristic"]), + _select("setting", "环境", ["on a seaside cliff", "in a dense city center", + "in a misty forest", "by a calm lake", + "in the desert"]), + _select("time", "时间", ["golden hour", "blue hour with interior lights", + "bright noon", "early morning fog"]), + _text("materials", "主要材质", "如:玻璃 + 清水混凝土 + 木格栅", required=False, + default="glass, concrete and timber"), + ], + "1536x1024", + ), + _tpl( + "interior_design", "室内设计", "家装 / 商业空间室内效果图", + "建筑空间", "CHAIR", ["室内", "家装"], + "Interior design rendering of a {room} in {style} style. {palette} palette, " + "materials: {materials}. Natural daylight through large windows, carefully styled " + "decor, realistic textures, wide-angle architectural photography, " + "magazine-quality interior shot.", + [ + _select("room", "空间", ["living room", "bedroom", "kitchen", "home office", + "cafe", "hotel lobby", "bathroom"]), + _select("style", "风格", ["Japandi", "modern minimalist", "Scandinavian", + "mid-century modern", "new Chinese", "wabi-sabi", + "French cream style"]), + _text("palette", "配色", "如:原木 + 奶白", required=False, + default="warm neutral"), + _text("materials", "材质", "如:橡木地板、微水泥墙面", required=False, + default="oak wood, linen, stone"), + ], + "1536x1024", + ), + # ── 角色设计 ─────────────────────────────────────────────────────── + _tpl( + "character_sheet", "角色设定图", "游戏 / 动画角色三视图设定", + "角色设计", "PERSON", ["角色", "设定"], + "A professional character design sheet of {character}. Front, side and back " + "turnaround views plus three facial expressions, {style}, consistent " + "proportions, outfit details callouts, color palette swatches, clean light " + "background, concept art presentation.", + [ + _text("character", "角色描述", "如:银发少年剑士,身披蓝色风衣"), + _select("style", "风格", ["anime cel shading", "semi-realistic game art", + "Western cartoon", "3D stylized render"]), + ], + "1536x1024", + ), + _tpl( + "avatar", "个性头像", "社交头像,多种艺术风格", + "角色设计", "FACE", ["头像"], + "A profile avatar of {subject}, {style}, head and shoulders, centered, " + "{background} background, expressive and polished, high detail.", + [ + _text("subject", "人物 / 角色", "如:戴耳机的短发女孩"), + _select("style", "风格", ["Pixar-like 3D", "anime", "flat vector", + "oil painting portrait", "pixel art", "clay figure"]), + _select("background", "背景", ["solid pastel", "gradient", "simple pattern"]), + ], + ), + # ── 信息图 ───────────────────────────────────────────────────────── + _tpl( + "infographic", "信息图卡片", "知识科普、流程说明的信息图", + "信息图", "INSIGHTS", ["信息图", "知识"], + "A clean infographic card explaining \"{topic}\". Sections: {points}. " + "{style}, clear icons for each section, numbered flow, readable " + "{language} labels rendered accurately, {palette} palette, organized grid, " + "plenty of whitespace.", + [ + _text("topic", "主题", "如:如何保护眼睛"), + _text("points", "要点(逗号分隔)", "如:20-20-20 法则, 调亮度, 多眨眼, 定期检查"), + _select("style", "风格", ["flat vector", "hand-drawn sketchnote", + "isometric 3D", "minimal line art"]), + _select("language", "文字语言", ["Chinese", "English"]), + _select("palette", "配色", ["blue and teal", "warm orange", "pastel multicolor", + "black and yellow"]), + ], + "1024x1536", + ), + # ── 壁纸背景 ─────────────────────────────────────────────────────── + _tpl( + "wallpaper_abstract", "抽象壁纸", "桌面 / 手机抽象艺术壁纸", + "壁纸背景", "WALLPAPER", ["壁纸", "抽象"], + "An abstract wallpaper, theme: {theme}. Style: {style}. Colors: {colors}. " + "Smooth flowing forms, subtle grain, calm balanced composition with no text, " + "ultra high resolution, suitable as a {device} background.", + [ + _text("theme", "主题", "如:宇宙星云、海浪、极光"), + _select("style", "风格", ["fluid gradient", "geometric abstraction", + "organic shapes", "glitch art", "minimal lines", + "3D glass shapes"]), + _text("colors", "配色", "如:深紫 + 青蓝", required=False, + default="deep purple and cyan"), + _select("device", "设备", ["desktop", "phone", "tablet"]), + ], + "1536x1024", + ), + _tpl( + "ppt_background", "PPT 背景", "留白充足的演示文稿背景图", + "壁纸背景", "SLIDESHOW", ["办公", "背景"], + "A presentation slide background for a {topic} deck. {style}, {palette} palette, " + "decorative elements kept to the {side}, large clean empty area for text, " + "subtle and professional, no text.", + [ + _text("topic", "演示主题", "如:年度财务报告"), + _select("style", "风格", ["corporate minimal", "tech gradient", + "soft watercolor", "geometric lines"]), + _select("palette", "配色", ["navy and white", "blue and green", + "warm neutrals", "black and gold"]), + _select("side", "装饰位置", ["edges", "right side", "bottom", "corners"]), + ], + "1536x1024", + ), + # ── 自由创作 ─────────────────────────────────────────────────────── + _tpl( + "custom", "自由创作", "完全自定义你的生图提示词", + "自由创作", "EDIT_NOTE", ["自定义"], + "{custom_prompt}", + [ {"name": "custom_prompt", "label": "完整提示词", "type": "textarea", "placeholder": "直接输入你的生图提示词,支持中英文...", "required": True}, ], - "default_size": "1024x1024", - }, + ), ] +# 内置分类顺序("全部"排在最前,用于 UI 过滤栏) +CATEGORIES = ["全部"] + list(dict.fromkeys(t["category"] for t in TEMPLATES)) + -def get_templates(category: str = "全部", keyword: str = "") -> list[dict]: +def get_templates(category: str = "全部", keyword: str = "", + templates: list[dict] | None = None) -> list[dict]: """获取模板列表,支持分类过滤和关键词搜索。""" - result = TEMPLATES - if category != "全部": - result = [t for t in result if t["category"] == category] + result = TEMPLATES if templates is None else templates + if category and category != "全部": + result = [t for t in result if t.get("category") == category] if keyword: kw = keyword.lower() result = [ t for t in result if kw in t["name"].lower() - or kw in t["description"].lower() - or any(kw in tag.lower() for tag in t["tags"]) + or kw in (t.get("description") or "").lower() + or any(kw in tag.lower() for tag in t.get("tags", [])) + or kw in (t.get("author") or "").lower() + or kw in (t.get("prompt_template") or "").lower() ] return result def get_template_by_id(template_id: str) -> dict | None: - """根据 ID 获取模板。""" + """根据 ID 获取内置模板。""" for t in TEMPLATES: if t["id"] == template_id: return t @@ -371,10 +628,10 @@ def assemble_prompt(template: dict, values: dict) -> str: - 已填字段:替换 {name} → 值 - 未填必填字段:用 [标签] 占位,方便用户在预览区察觉缺失 - - 可选字段:用 default 兜底,空字符串直接清掉周边空格 + - 可选字段:用 default 兜底,空值时顺带清理多余空格 """ prompt = template["prompt_template"] - for var in template["variables"]: + for var in template.get("variables", []): name = var["name"] value = values.get(name, "") if value == "" or value is None: @@ -382,4 +639,6 @@ def assemble_prompt(template: dict, values: dict) -> str: if not value and var.get("required"): value = f"[{var['label']}]" prompt = prompt.replace(f"{{{name}}}", str(value)) - return prompt + # 可选字段留空后可能剩下 `""`、连续空格、孤立句点 + prompt = prompt.replace('""', "").replace(" ", " ").replace(" .", ".") + return prompt.strip() diff --git a/file-toolkit/services/prompt_library_service.py b/file-toolkit/services/prompt_library_service.py new file mode 100644 index 0000000..b26e65b --- /dev/null +++ b/file-toolkit/services/prompt_library_service.py @@ -0,0 +1,289 @@ +"""提示词库服务 — 汇总内置模板、我的模板、订阅源,并持久化收藏与生成历史。 + +数据都存在 settings 表(JSON 字符串),不新增表结构: +- prompt_lib_custom 我的模板(list[template]) +- prompt_lib_sources 订阅源元信息(list[{id, name, url, homepage, license, count, updated_at, enabled}]) +- prompt_lib_cache: 订阅源下载解析后的模板(list[template]) +- prompt_lib_favorites 收藏的模板 id(list[str]) +- prompt_image_history 最近生成记录(list[{path, prompt, template, size, created_at}]) +""" +from __future__ import annotations + +import json +import time +import uuid +from pathlib import Path + +import httpx + +from core.prompt_image import sources as src +from core.prompt_image import templates as tpl +from services import settings_service + +CUSTOM_SOURCE = "custom" +FAVORITES = "favorites" +ALL = "all" + +_KEY_CUSTOM = "prompt_lib_custom" +_KEY_SOURCES = "prompt_lib_sources" +_KEY_CACHE = "prompt_lib_cache:" +_KEY_FAVORITES = "prompt_lib_favorites" +_KEY_HISTORY = "prompt_image_history" + +HISTORY_LIMIT = 40 +MAX_SOURCE_BYTES = 8 * 1024 * 1024 + + +# ── JSON 读写 ──────────────────────────────────────────────────────────── +def _load(key: str, default): + raw = settings_service.get(key, "") + if not raw: + return default + try: + value = json.loads(raw) + except (json.JSONDecodeError, TypeError): + return default + return value if isinstance(value, type(default)) else default + + +def _save(key: str, value) -> None: + settings_service.set(key, json.dumps(value, ensure_ascii=False)) + + +# ── 来源列表 ───────────────────────────────────────────────────────────── +def list_sources() -> list[dict]: + """用户添加的订阅源(不含内置 / 我的模板)。""" + return _load(_KEY_SOURCES, []) + + +def get_source(source_id: str) -> dict | None: + return next((s for s in list_sources() if s.get("id") == source_id), None) + + +def source_label(source_id: str) -> str: + if source_id == tpl.BUILTIN_SOURCE: + return "内置模板" + if source_id == CUSTOM_SOURCE: + return "我的模板" + s = get_source(source_id) + return s["name"] if s else source_id + + +def source_options() -> list[tuple[str, str]]: + """来源下拉框选项:[(key, 显示名)]。""" + opts = [ + (ALL, "全部来源"), + (tpl.BUILTIN_SOURCE, f"内置模板 · {len(tpl.TEMPLATES)}"), + (CUSTOM_SOURCE, f"我的模板 · {len(list_custom())}"), + (FAVORITES, f"我的收藏 · {len(list_favorites())}"), + ] + for s in list_sources(): + if s.get("enabled", True): + opts.append((s["id"], f"{s['name']} · {s.get('count', 0)}")) + return opts + + +def templates_for(source_key: str) -> list[dict]: + """按来源取模板列表。""" + if source_key == tpl.BUILTIN_SOURCE: + return list(tpl.TEMPLATES) + if source_key == CUSTOM_SOURCE: + return list_custom() + if source_key == FAVORITES: + favs = list_favorites() + everything = {t["id"]: t for t in templates_for(ALL)} + return [everything[i] for i in favs if i in everything] + if source_key == ALL: + result = list_custom() + list(tpl.TEMPLATES) + for s in list_sources(): + if s.get("enabled", True): + result.extend(_load(_KEY_CACHE + s["id"], [])) + return result + return _load(_KEY_CACHE + source_key, []) + + +def categories_for(templates: list[dict]) -> list[str]: + cats = list(dict.fromkeys(t.get("category") or "未分类" for t in templates)) + return ["全部"] + cats + + +def find_template(template_id: str) -> dict | None: + return next((t for t in templates_for(ALL) if t["id"] == template_id), None) + + +# ── 订阅源:下载 / 导入 / 刷新 / 删除 ───────────────────────────────────── +async def fetch_text(url: str) -> str: + """下载提示词源文本(GitHub 页面地址自动换成 raw 地址)。""" + url = normalize_url(url) + async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as client: + resp = await client.get(url, headers={"User-Agent": "FileToolkit"}) + resp.raise_for_status() + if len(resp.content) > MAX_SOURCE_BYTES: + raise src.SourceParseError("文件过大(超过 8 MB)") + return resp.text + + +def normalize_url(url: str) -> str: + """github.com///blob// → raw.githubusercontent.com 地址。""" + url = url.strip() + prefix = "https://github.com/" + if url.startswith(prefix) and "/blob/" in url: + owner_repo, rest = url[len(prefix):].split("/blob/", 1) + return f"https://raw.githubusercontent.com/{owner_repo}/{rest}" + return url + + +def _store_source(meta: dict, items: list[dict], *, source_id: str, url: str, + fallback_name: str, homepage: str = "", license_: str = "", + local_path: str = "") -> dict: + record = { + "id": source_id, + "name": meta.get("name") or fallback_name, + "url": url, + "homepage": meta.get("homepage") or homepage, + "license": meta.get("license") or license_, + "count": len(items), + "updated_at": int(time.time()), + "enabled": True, + } + if local_path: + record["path"] = local_path + _save(_KEY_CACHE + source_id, items) + all_sources = [s for s in list_sources() if s.get("id") != source_id] + old = get_source(source_id) + if old: + # 刷新时保留用户改过的名称与启用状态 + record["name"] = old.get("name") or record["name"] + record["enabled"] = old.get("enabled", True) + all_sources = [record if s.get("id") == source_id else s for s in list_sources()] + else: + all_sources.append(record) + _save(_KEY_SOURCES, all_sources) + return record + + +async def add_source(url: str, name: str = "", homepage: str = "", license_: str = "") -> dict: + """下载并解析一个 URL 提示词源,返回保存后的来源记录。""" + url = normalize_url(url) + existing = next((s for s in list_sources() if s.get("url") == url), None) + source_id = existing["id"] if existing else f"src-{uuid.uuid4().hex[:8]}" + text = await fetch_text(url) + meta, items = src.parse_source(text, source_id, hint=url, default_license=license_) + fallback = name or url.rstrip("/").split("/")[-1] or "提示词源" + return _store_source(meta, items, source_id=source_id, url=url, fallback_name=fallback, + homepage=homepage, license_=license_) + + +def import_file(path: Path) -> dict: + """从本地 JSON / CSV / Markdown 文件导入为一个来源。""" + text = path.read_text(encoding="utf-8-sig", errors="replace") + url = path.resolve().as_uri() + existing = next((s for s in list_sources() if s.get("url") == url), None) + source_id = existing["id"] if existing else f"src-{uuid.uuid4().hex[:8]}" + meta, items = src.parse_source(text, source_id, hint=path.name) + return _store_source(meta, items, source_id=source_id, url=url, fallback_name=path.stem, + local_path=str(path.resolve())) + + +async def refresh_source(source_id: str) -> dict: + s = get_source(source_id) + if not s: + raise KeyError(source_id) + if s.get("path"): + return import_file(Path(s["path"])) + return await add_source(s["url"], s.get("name", ""), s.get("homepage", ""), s.get("license", "")) + + +def remove_source(source_id: str) -> None: + _save(_KEY_SOURCES, [s for s in list_sources() if s.get("id") != source_id]) + settings_service.set(_KEY_CACHE + source_id, "") + prefix = f"{source_id}:" + _save(_KEY_FAVORITES, [f for f in list_favorites() if not f.startswith(prefix)]) + + +def set_source_enabled(source_id: str, enabled: bool) -> None: + sources = list_sources() + for s in sources: + if s.get("id") == source_id: + s["enabled"] = enabled + _save(_KEY_SOURCES, sources) + + +# ── 我的模板 ───────────────────────────────────────────────────────────── +def list_custom() -> list[dict]: + return _load(_KEY_CUSTOM, []) + + +def save_custom(name: str, prompt: str, *, category: str = "我的模板", description: str = "", + default_size: str = "1024x1024", template_id: str = "") -> dict: + """保存(新建或覆盖)一个自定义模板。提示词里的 {变量} 自动变为可填写项。""" + prompt, variables = src.detect_variables(prompt.strip()) + template_id = template_id or f"{CUSTOM_SOURCE}:{uuid.uuid4().hex[:8]}" + one_line = " ".join(prompt.split()) + item = { + "id": template_id, + "name": name.strip() or one_line[:20] or "未命名模板", + "description": description.strip() or one_line[:80], + "category": category.strip() or "我的模板", + "icon": "BOOKMARK", + "tags": ["自定义"], + "prompt_template": prompt, + "variables": variables, + "default_size": default_size, + "source": CUSTOM_SOURCE, + "author": "", "link": "", "license": "", + } + items = list_custom() + for i, t in enumerate(items): + if t["id"] == template_id: + items[i] = item + break + else: + items.insert(0, item) + _save(_KEY_CUSTOM, items) + return item + + +def delete_custom(template_id: str) -> None: + _save(_KEY_CUSTOM, [t for t in list_custom() if t["id"] != template_id]) + _save(_KEY_FAVORITES, [f for f in list_favorites() if f != template_id]) + + +def export_custom_json() -> str: + return src.to_export_json("我的提示词模板", list_custom()) + + +# ── 收藏 ───────────────────────────────────────────────────────────────── +def list_favorites() -> list[str]: + return _load(_KEY_FAVORITES, []) + + +def is_favorite(template_id: str) -> bool: + return template_id in list_favorites() + + +def toggle_favorite(template_id: str) -> bool: + favs = list_favorites() + if template_id in favs: + favs.remove(template_id) + on = False + else: + favs.insert(0, template_id) + on = True + _save(_KEY_FAVORITES, favs) + return on + + +# ── 生成历史 ───────────────────────────────────────────────────────────── +def list_history() -> list[dict]: + """最近生成记录(新的在前),自动剔除已被删除的文件。""" + return [h for h in _load(_KEY_HISTORY, []) if h.get("path") and Path(h["path"]).exists()] + + +def add_history(path: Path, prompt: str, template_name: str, size: str) -> dict: + entry = {"path": str(path), "prompt": prompt, "template": template_name, "size": size, + "created_at": int(time.time())} + items = [h for h in _load(_KEY_HISTORY, []) if h.get("path") != str(path)] + items.insert(0, entry) + _save(_KEY_HISTORY, items[:HISTORY_LIMIT]) + return entry diff --git a/file-toolkit/tests/core/test_prompt_image.py b/file-toolkit/tests/core/test_prompt_image.py new file mode 100644 index 0000000..fa284f5 --- /dev/null +++ b/file-toolkit/tests/core/test_prompt_image.py @@ -0,0 +1,120 @@ +"""提示词模板、风格增强与提示词源解析。""" +import flet as ft +import pytest + +from core.prompt_image import modifiers as mods +from core.prompt_image import sources as src +from core.prompt_image import templates as tpl + + +def test_builtin_templates_are_well_formed(): + ids = [t["id"] for t in tpl.TEMPLATES] + assert len(ids) == len(set(ids)) >= 30 + for t in tpl.TEMPLATES: + assert hasattr(ft.Icons, t["icon"]), t["icon"] + names = {v["name"] for v in t["variables"]} + for name in names: + assert f"{{{name}}}" in t["prompt_template"], (t["id"], name) + for v in t["variables"]: + if v["type"] == "select": + assert v["default"] in v["options"] + # 填满所有变量后不应残留占位符 + values = {v["name"]: "x" for v in t["variables"]} + assert "{" not in tpl.assemble_prompt(t, values) + + +def test_assemble_marks_missing_required_and_drops_empty_optional(): + t = tpl.get_template_by_id("poster_minimal") + prompt = tpl.assemble_prompt(t, {"theme": "音乐节"}) + assert "[主标题]" in prompt + assert '""' not in prompt # 副标题留空后不留空引号 + assert "音乐节" in prompt + + +def test_get_templates_filters_by_category_and_keyword(): + assert all(t["category"] == "海报设计" for t in tpl.get_templates("海报设计")) + assert [t["id"] for t in tpl.get_templates(keyword="小红书")] == ["xiaohongshu_cover"] + + +def test_apply_modifiers_appends_selected_and_negative(): + out = mods.apply_modifiers("A cat", ["cinematic", "8k", "unknown"], "text, watermark") + assert out == "A cat. cinematic film still, 8K ultra high resolution.\nAvoid: text, watermark." + assert mods.apply_modifiers("A cat.", []) == "A cat." + + +def test_detect_variables_handles_all_placeholder_styles(): + prompt, variables = src.detect_variables( + 'A {argument name="product" default="mug"} on {背景}, style [风格]', + include_brackets=True, + ) + assert prompt == "A {product} on {背景}, style {风格}" + assert [(v["name"], v["default"], v["required"]) for v in variables] == [ + ("product", "mug", False), ("背景", "", True), ("风格", "", True)] + + +def test_parse_json_source(): + text = '{"name": "库", "license": "MIT", "templates": [' \ + '{"title": "猫", "prompt": "A {颜色} cat", "category": "动物", "tags": "a,b"},' \ + '{"prompt": ""}]}' + meta, items = src.parse_source(text, "s1") + assert meta["name"] == "库" and meta["license"] == "MIT" + assert len(items) == 1 + t = items[0] + assert t["id"] == "s1:0" and t["name"] == "猫" and t["category"] == "动物" + assert t["tags"] == ["a", "b"] and t["license"] == "MIT" + assert [v["name"] for v in t["variables"]] == ["颜色"] + + +def test_parse_csv_source(): + text = "act,prompt\nCat,A fluffy cat sitting\nDog,A happy dog running\n" + _meta, items = src.parse_source(text, "s2", hint="prompts.csv") + assert [t["name"] for t in items] == ["Cat", "Dog"] + + +def test_parse_markdown_source(): + text = """# Awesome +## 🛒 E-commerce Cases +### Case 1: [Skincare Ad](https://x.com/a/status/1) (by [@alice](https://x.com/alice)) +``` +A miniature diorama of a lotion bottle, vertical 9:16 poster +``` +### Case 2: No prompt here +Just text. +## Install +```bash +pip install something-long-enough +``` +### 案例 3:磨砂玻璃 (by [@bob](https://x.com/bob)) +[原文链接](https://x.com/bob/status/2) +``` +一张黑白照片,展示了一个[主体]在磨砂表面后的模糊剪影 +``` +""" + _meta, items = src.parse_source(text, "md", hint="README.md") + assert len(items) == 2 + a, b = items + assert (a["name"], a["author"], a["link"], a["category"]) == ( + "Skincare Ad", "@alice", "https://x.com/a/status/1", "电商") + assert a["default_size"] == "1024x1536" + assert (b["name"], b["author"], b["link"]) == ("磨砂玻璃", "@bob", "https://x.com/bob/status/2") + assert [v["name"] for v in b["variables"]] == ["主体"] + + +def test_parse_source_rejects_empty(): + with pytest.raises(src.SourceParseError): + src.parse_source("# nothing", "x") + + +def test_export_round_trip(): + t = tpl.get_template_by_id("logo_modern") + _meta, items = src.parse_source(src.to_export_json("导出", [t]), "rt") + assert items[0]["prompt_template"] == t["prompt_template"] + assert items[0]["variables"] == t["variables"] + + +def test_select_options_have_chinese_labels(): + from core.prompt_image.option_labels import OPTION_LABELS + missing = [o for t in tpl.TEMPLATES for v in t["variables"] if v["type"] == "select" + for o in v["options"] if o not in OPTION_LABELS + and not o.isdigit() and not o.startswith(("Kodak", "Fujifilm", "CineStill"))] + assert missing == [] diff --git a/file-toolkit/tests/services/test_prompt_library_service.py b/file-toolkit/tests/services/test_prompt_library_service.py new file mode 100644 index 0000000..fe50125 --- /dev/null +++ b/file-toolkit/tests/services/test_prompt_library_service.py @@ -0,0 +1,64 @@ +"""提示词库服务:我的模板、订阅源、收藏、生成历史。""" +from pathlib import Path + +import pytest + +from core.prompt_image import templates as tpl +from services import history_service, settings_service +from services import prompt_library_service as lib + + +@pytest.fixture(autouse=True) +def _settings(tmp_path: Path): + db = tmp_path / "app.db" + history_service.init_db(db) + settings_service.init_settings(db) + + +def test_custom_template_crud(): + item = lib.save_custom("我的海报", "A poster about {主题} in {风格}") + assert [v["name"] for v in item["variables"]] == ["主题", "风格"] + assert lib.templates_for(lib.CUSTOM_SOURCE)[0]["id"] == item["id"] + lib.save_custom("改名", "Only {主题}", template_id=item["id"]) + assert [t["name"] for t in lib.list_custom()] == ["改名"] + lib.toggle_favorite(item["id"]) + lib.delete_custom(item["id"]) + assert lib.list_custom() == [] and lib.list_favorites() == [] + + +def test_import_file_source_and_listing(tmp_path: Path): + f = tmp_path / "lib.json" + f.write_text('{"name": "本地库", "templates": [{"name": "A", "prompt": "A red fox"}]}', + encoding="utf-8") + record = lib.import_file(f) + assert record["name"] == "本地库" and record["count"] == 1 + assert any(k == record["id"] for k, _ in lib.source_options()) + everything = lib.templates_for(lib.ALL) + assert len(everything) == len(tpl.TEMPLATES) + 1 + # 重复导入同一个文件只更新,不新增来源 + assert lib.import_file(f)["id"] == record["id"] + assert len(lib.list_sources()) == 1 + lib.set_source_enabled(record["id"], False) + assert len(lib.templates_for(lib.ALL)) == len(tpl.TEMPLATES) + lib.remove_source(record["id"]) + assert lib.list_sources() == [] and lib.templates_for(record["id"]) == [] + + +def test_favorites_follow_templates(): + tid = tpl.TEMPLATES[3]["id"] + assert lib.toggle_favorite(tid) is True + assert [t["id"] for t in lib.templates_for(lib.FAVORITES)] == [tid] + assert lib.toggle_favorite(tid) is False + + +def test_history_skips_missing_files(tmp_path: Path): + img = tmp_path / "a.png" + img.write_bytes(b"x") + lib.add_history(img, "prompt", "模板", "1024x1024") + lib.add_history(tmp_path / "gone.png", "p2", "模板", "1024x1024") + assert [h["path"] for h in lib.list_history()] == [str(img)] + + +def test_normalize_github_blob_url(): + assert lib.normalize_url("https://github.com/o/r/blob/main/docs/p.json") == \ + "https://raw.githubusercontent.com/o/r/main/docs/p.json" diff --git a/file-toolkit/tests/ui/test_prompt_image_page.py b/file-toolkit/tests/ui/test_prompt_image_page.py new file mode 100644 index 0000000..9506242 --- /dev/null +++ b/file-toolkit/tests/ui/test_prompt_image_page.py @@ -0,0 +1,69 @@ +"""提示词出图页:构建、选模板、组装提示词、结果区状态。""" +from pathlib import Path + +import pytest + +from core.prompt_image import templates as tpl +from services import history_service, settings_service +from services import prompt_library_service as lib +from ui.pages.prompt_image_page import PromptImagePage + + +class _FakePage: + web = False + width = 1280 + height = 800 + + def run_task(self, *args, **kwargs): + pass + + def go(self, route): + pass + + +@pytest.fixture(autouse=True) +def _settings(tmp_path: Path): + db = tmp_path / "app.db" + history_service.init_db(db) + settings_service.init_settings(db) + + +def test_page_builds_and_assembles_prompt(): + page = PromptImagePage(_FakePage()) + assert page._current["id"] == tpl.TEMPLATES[0]["id"] + page._var_controls["theme"].value = "音乐节" + page._on_toggle_modifier("cinematic") + page._refresh_prompt() + assert "音乐节" in page._prompt_field.value + assert "cinematic film still" in page._prompt_field.value + + +def test_manual_edit_is_not_overwritten_until_restored(): + page = PromptImagePage(_FakePage()) + page._prompt_field.value = "my own prompt" + page._on_prompt_edited(None) + page._var_controls["theme"].value = "x" + page._refresh_prompt() + assert page._prompt_field.value == "my own prompt" + page._on_restore_prompt(None) + assert page._prompt_field.value != "my own prompt" + + +def test_last_template_and_source_are_remembered(): + item = lib.save_custom("我的", "A {物体}") + settings_service.set("prompt_image_last_source", lib.CUSTOM_SOURCE) + settings_service.set("prompt_image_last_template", item["id"]) + page = PromptImagePage(_FakePage()) + assert page._source == lib.CUSTOM_SOURCE + assert page._current["id"] == item["id"] + assert list(page._var_controls) == ["物体"] + + +def test_desktop_preview_loads_from_saved_file(tmp_path: Path): + img = tmp_path / "out.png" + img.write_bytes(b"\x89PNG") + page = PromptImagePage(_FakePage()) + assert page._image_control(b"bytes", img).src == str(img) + web = _FakePage() + web.web = True + assert PromptImagePage(web)._image_control(b"bytes", img).src == b"bytes" diff --git a/file-toolkit/ui/pages/ocr_page.py b/file-toolkit/ui/pages/ocr_page.py index 3582c7c..d7cc716 100644 --- a/file-toolkit/ui/pages/ocr_page.py +++ b/file-toolkit/ui/pages/ocr_page.py @@ -763,8 +763,15 @@ def _show_complete(self) -> None: def _copy_result(self, _) -> None: if self._result_text.value: - self._page.set_clipboard(self._result_text.value) + self._page.run_task(self._copy_async, self._result_text.value) + + async def _copy_async(self, text: str) -> None: + # Flet 0.84 已移除 page.set_clipboard,改用 Clipboard 服务 + try: + await ft.Clipboard().set(text) self._show_snack("已复制到剪贴板") + except Exception as e: + self._show_snack(f"复制失败:{e}") def _save_result(self, _) -> None: if not self._result_text.value or not self._input_file: diff --git a/file-toolkit/ui/pages/prompt_image_page.py b/file-toolkit/ui/pages/prompt_image_page.py index d16ee79..8a938c7 100644 --- a/file-toolkit/ui/pages/prompt_image_page.py +++ b/file-toolkit/ui/pages/prompt_image_page.py @@ -1,495 +1,749 @@ -"""提示词出图页 — 从模板选择 → 填表 → AI 生图 → 预览下载。 +"""提示词出图页 — 选模板 → 填写 / 增强 → 生成 → 预览。 -布局: - Header(SubPageHeader) - ├── 左侧 380px:搜索 + 分类标签 + 模板卡片网格(2 列) - └── 右侧 expand: - 表单(变量输入)→ 预览(组装后的完整 prompt)→ 生成按钮 → 结果图 +布局(三栏,各自滚动,页面本身不滚动): + Header:返回 + 标题 + 「提示词源」管理入口 + ├── 左栏 272px:来源下拉 + 搜索 + 分类 + 模板列表 + ├── 中栏 expand:模板信息 → 变量表单 → 风格增强 → 最终提示词(可手改) + │ 底部固定:尺寸 / 质量 + 生成按钮 + └── 右栏 expand:生成结果(常驻可见)→ 操作按钮 → 最近作品 -视觉: - 主色 #005F98 / 激活 #00A3FF,卡片 r=16,选中态 #F0F7FF + #005F98 边框。 +生成结果放在常驻的右栏里,生成完成后不需要滚动就能看到; +桌面端直接用保存好的本地文件加载预览,Web 端用图片字节。 """ from __future__ import annotations import asyncio -import subprocess -import sys +import io import time +from datetime import datetime from pathlib import Path import flet as ft +from core.prompt_image import modifiers as mods +from core.prompt_image import sources as src from core.prompt_image import templates as tpl +from core.prompt_image.option_labels import option_label from services import prompt_image_service, settings_service -from ui.components.sub_page_header import SubPageHeader +from services import prompt_library_service as lib from ui.palette import c -from ui.utils import show_toast +from ui.utils import open_folder, show_toast -_RESULT_KEY = "prompt-image-result" +_LIST_LIMIT = 300 # 左栏最多渲染的模板数,超出提示用户搜索 +_CHIP_CATEGORY_LIMIT = 14 # 分类不超过这个数用标签,否则改用下拉框 +_HISTORY_THUMBS = 12 +_KEY_LAST_SOURCE = "prompt_image_last_source" +_KEY_LAST_TEMPLATE = "prompt_image_last_template" +_KEY_MODIFIERS = "prompt_image_modifiers" +_FONT = "42dot Sans" + + +def _txt(value: str, size: int = 12, color: str = "#455c7f", **kw) -> ft.Text: + return ft.Text(value, size=size, color=c(color, "fg"), font_family=_FONT, **kw) + + +def _dropdown(**kw) -> ft.Dropdown: + """统一风格的下拉框:浅底无边框,聚焦时显示品牌色描边。""" + kw.setdefault("dense", True) + kw.setdefault("text_size", 13) + kw.setdefault("border_radius", 10) + kw.setdefault("filled", True) + kw.setdefault("fill_color", c("#f8fafc")) + kw.setdefault("border_color", "transparent") + kw.setdefault("focused_border_color", c("#005f98")) + kw.setdefault("content_padding", ft.padding.symmetric(horizontal=12, vertical=10)) + kw.setdefault("text_style", ft.TextStyle(color=c("#162f50", "fg"), size=13)) + return ft.Dropdown(**kw) + + +def _card(content: ft.Control, **kw) -> ft.Container: + return ft.Container( + content=content, + bgcolor=c("#ffffff"), + border_radius=16, + shadow=ft.BoxShadow(blur_radius=8, color=ft.Colors.with_opacity(0.05, c("#000000", "fg")), + offset=ft.Offset(0, 2)), + **kw, + ) class PromptImagePage(ft.Column): """提示词出图主页面。""" def __init__(self, page: ft.Page) -> None: - super().__init__(expand=True, scroll=ft.ScrollMode.AUTO, spacing=0) + super().__init__(expand=True, spacing=0) self._page = page - self._current_template: dict | None = None - self._current_category: str = "全部" - self._search_keyword: str = "" + self._mounted = False + + # ── 状态 ──────────────────────────────────────────────────────── + saved_source = settings_service.get(_KEY_LAST_SOURCE, tpl.BUILTIN_SOURCE) + valid_sources = {k for k, _ in lib.source_options()} + self._source: str = saved_source if saved_source in valid_sources else tpl.BUILTIN_SOURCE + self._category: str = "全部" + self._keyword: str = "" + self._templates: list[dict] = [] + self._current: dict | None = None self._var_controls: dict[str, ft.Control] = {} - self._generating: bool = False - self._last_image_bytes: bytes | None = None - self._last_image_path: Path | None = None - self._mounted: bool = False - - # 先构建各动态区域,后续事件回调直接访问 - self._search_field = ft.TextField( - hint_text="搜索模板(名称 / 标签)", + self._modifiers: list[str] = [ + m for m in settings_service.get(_KEY_MODIFIERS, "").split(",") if m + ] + self._manual_prompt = False + self._generating = False + self._gen_started = 0.0 + self._last_bytes: bytes | None = None + self._last_path: Path | None = None + self._last_prompt = "" + + # ── 左栏控件 ──────────────────────────────────────────────────── + self._source_dd = _dropdown( + value=self._source, options=[], dense=True, border_radius=10, expand=True, + text_size=13, on_select=self._on_source_change, + ) + self._search = ft.TextField( + hint_text="搜索名称 / 标签 / 提示词", hint_style=ft.TextStyle(color=c("#94a3b8", "fg"), size=13), - prefix_icon=ft.Icons.SEARCH, - border_radius=12, - bgcolor=c("#f8fafc"), - border_color="transparent", + prefix_icon=ft.Icons.SEARCH, dense=True, text_size=13, + border_radius=10, bgcolor=c("#f8fafc"), border_color="transparent", content_padding=ft.padding.symmetric(horizontal=12, vertical=10), on_change=self._on_search_change, ) - self._category_bar = ft.Row(spacing=8, wrap=True, run_spacing=8) - self._template_grid = ft.Column(spacing=12) + self._category_area = ft.Container() + self._count_text = _txt("", 11, "#94a3b8") + self._template_list = ft.ListView(expand=True, spacing=6, padding=ft.padding.only(right=6)) + # ── 中栏控件 ──────────────────────────────────────────────────── + self._tpl_header = ft.Column(spacing=6) self._form_area = ft.Column(spacing=12) - self._prompt_preview = ft.TextField( - value="", - read_only=True, - multiline=True, - min_lines=4, - max_lines=8, - text_style=ft.TextStyle( - font_family="JetBrains Mono", size=13, color=c("#162f50", "fg"), - ), - border_radius=12, - bgcolor=c("#f8fafc"), - border_color="transparent", + self._modifier_area = ft.Column(spacing=10, visible=bool(self._modifiers)) + self._modifier_toggle_icon = ft.Icon( + ft.Icons.EXPAND_LESS if self._modifiers else ft.Icons.EXPAND_MORE, + size=18, color=c("#455c7f", "fg"), + ) + self._modifier_count = _txt("", 11, "#005f98") + self._negative = ft.TextField( + hint_text="避免出现的内容(可选),如:文字水印、多余手指、模糊", + hint_style=ft.TextStyle(color=c("#94a3b8", "fg"), size=12), + dense=True, text_size=13, border_radius=10, bgcolor=c("#f8fafc"), + border_color="transparent", on_change=lambda _e: self._refresh_prompt(), + ) + self._prompt_state = _txt("自动生成", 11, "#94a3b8") + self._restore_btn = ft.TextButton( + "恢复自动", visible=False, on_click=self._on_restore_prompt, + style=ft.ButtonStyle(color=c("#005f98", "fg"), padding=ft.padding.all(4)), + ) + self._prompt_field = ft.TextField( + multiline=True, min_lines=4, max_lines=10, text_size=13, + text_style=ft.TextStyle(color=c("#162f50", "fg")), + border_radius=12, bgcolor=c("#f8fafc"), border_color="transparent", + on_change=self._on_prompt_edited, ) - self._size_dropdown = ft.Dropdown( - value="1024x1024", + self._size_dd = _dropdown( + value="1024x1024", dense=True, border_radius=10, expand=True, text_size=13, options=[ - ft.dropdown.Option("1024x1024", "1:1 方形 (1024×1024)"), - ft.dropdown.Option("1024x1536", "2:3 竖版 (1024×1536)"), - ft.dropdown.Option("1536x1024", "3:2 横版 (1536×1024)"), + ft.dropdown.Option("1024x1024", "1:1 方形"), + ft.dropdown.Option("1024x1536", "2:3 竖版"), + ft.dropdown.Option("1536x1024", "3:2 横版"), ft.dropdown.Option("auto", "自动"), ], - border_radius=12, - expand=True, ) - self._quality_dropdown = ft.Dropdown( - value="high", + self._quality_dd = _dropdown( + value="high", dense=True, border_radius=10, expand=True, text_size=13, options=[ - ft.dropdown.Option("low", "低(快速)"), - ft.dropdown.Option("medium", "中(平衡)"), - ft.dropdown.Option("high", "高(精细)"), + ft.dropdown.Option("low", "低 · 快速"), + ft.dropdown.Option("medium", "中 · 平衡"), + ft.dropdown.Option("high", "高 · 精细"), ft.dropdown.Option("auto", "自动"), ], - border_radius=12, - expand=True, ) - + self._generate_label = _txt("生成图片", 15, "#ffffff", weight=ft.FontWeight.W_600) self._generate_btn = self._build_generate_button() - self._loading_row = ft.Row( - controls=[ - ft.ProgressRing(width=20, height=20, stroke_width=2, color=c("#005f98", "fg")), - ft.Text("AI 正在创作中...", size=13, color=c("#455c7f", "fg"), - font_family="42dot Sans"), - ], - spacing=10, - visible=False, - vertical_alignment=ft.CrossAxisAlignment.CENTER, - ) self._config_hint = self._build_config_hint() - # 结果区 - # 每次出图都换一个新的 Image 放进这个容器,避免空 src 初始态和旧图缓存 - self._result_image = ft.Container(height=520, alignment=ft.Alignment(0, 0), visible=False) - self._result_meta = ft.Text("", size=12, color=c("#455c7f", "fg"), - font_family="42dot Sans") - self._result_actions = ft.Row(spacing=10, visible=False) - self._result_area = ft.Container( - content=ft.Column( + # ── 右栏控件 ──────────────────────────────────────────────────── + self._preview = ft.Container( + expand=True, border_radius=12, bgcolor=c("#f8fafc"), + border=ft.border.all(1, c("#e2e8f0")), alignment=ft.Alignment(0, 0), + padding=8, + ) + self._result_meta = _txt("", 11, "#94a3b8", max_lines=2, + overflow=ft.TextOverflow.ELLIPSIS) + self._result_actions = ft.Row(spacing=6, run_spacing=6, wrap=True, visible=False) + self._history_row = ft.Row(spacing=8, run_spacing=8, wrap=True) + self._history_section = ft.Column(spacing=8) + + self.controls = [self._build_header(), self._build_body()] + + self._show_empty() + self._render_history() + self._reload_library(select_id=settings_service.get(_KEY_LAST_TEMPLATE, "")) + + def did_mount(self) -> None: + self._mounted = True + + def will_unmount(self) -> None: + self._mounted = False + + def _update(self, *controls: ft.Control) -> None: + if not self._mounted: + return + try: + for ctrl in controls: + ctrl.update() + except RuntimeError: + pass + + # ═════════════════════════════════════════════════════════════════ + # 布局 + # ═════════════════════════════════════════════════════════════════ + def _build_header(self) -> ft.Control: + return ft.Container( + padding=ft.padding.only(left=24, top=16, right=32, bottom=12), + content=ft.Row( controls=[ - self._result_image, - self._result_meta, - self._result_actions, + ft.IconButton(ft.Icons.ARROW_BACK, icon_color=c("#455c7f", "fg"), + on_click=lambda _e: self._page.go("/")), + ft.Container( + content=ft.Icon(ft.Icons.AUTO_FIX_HIGH, color=c("#e11d48", "fg"), size=20), + width=40, height=40, bgcolor=c("#fff1f2"), border_radius=12, + alignment=ft.Alignment(0, 0), + ), + ft.Column( + controls=[ + _txt("提示词出图", 20, "#162f50", weight=ft.FontWeight.W_600), + _txt("挑一个模板,填几个关键词,AI 帮你出图", 12, "#455c7f"), + ], + spacing=0, tight=True, + ), + ft.Container(expand=True), + self._pill_button("提示词源", ft.Icons.HUB_OUTLINED, self._open_source_manager), + self._pill_button("新建模板", ft.Icons.ADD, lambda _e: self._open_template_editor()), ], spacing=12, - horizontal_alignment=ft.CrossAxisAlignment.STRETCH, + vertical_alignment=ft.CrossAxisAlignment.CENTER, ), - visible=False, ) - self._result_empty = ft.Container( - content=ft.Column( + + def _build_body(self) -> ft.Control: + left = _card( + ft.Column( controls=[ - ft.Icon(ft.Icons.AUTO_FIX_HIGH_OUTLINED, size=36, color=c("#94a3b8", "fg")), - ft.Text("尚未生成,点击上方按钮开始创作", - size=13, color=c("#94a3b8", "fg"), font_family="42dot Sans"), + ft.Row([self._source_dd], spacing=6), + self._search, + self._category_area, + self._count_text, + self._template_list, ], - horizontal_alignment=ft.CrossAxisAlignment.CENTER, - spacing=8, + spacing=10, + expand=True, ), - padding=ft.padding.symmetric(vertical=24), - alignment=ft.Alignment(0, 0), + width=272, padding=ft.padding.only(left=14, top=14, right=8, bottom=8), ) - self.controls = [ - SubPageHeader( - title="提示词出图", - icon=ft.Icons.AUTO_FIX_HIGH, - icon_color=c("#e11d48", "fg"), - icon_bg=c("#fff1f2"), - on_back=lambda: self._page.go("/"), - ), - ft.Container( - content=ft.Text( - "选择模板,填写关键信息,AI 自动生成精美图片", - size=14, color=c("#455c7f", "fg"), font_family="42dot Sans", + self._editor_scroll = editor_scroll = ft.Column( + controls=[ + self._tpl_header, + self._divider(), + self._section_title("填写内容", ft.Icons.EDIT_OUTLINED), + self._form_area, + self._divider(), + ft.Container( + content=ft.Row( + controls=[ + ft.Icon(ft.Icons.TUNE, size=16, color=c("#e11d48", "fg")), + _txt("风格增强", 14, "#162f50", weight=ft.FontWeight.W_600), + self._modifier_count, + ft.Container(expand=True), + self._modifier_toggle_icon, + ], + spacing=6, + vertical_alignment=ft.CrossAxisAlignment.CENTER, + ), + on_click=self._toggle_modifiers, + ink=True, border_radius=8, padding=ft.padding.symmetric(vertical=4), ), - padding=ft.padding.only(left=32, right=32, bottom=16), - ), - self._build_body(), - ] - - self._render_category_bar() - self._render_template_grid() - # 默认选中第一个模板 - if tpl.TEMPLATES: - self._select_template(tpl.TEMPLATES[0]) - - def did_mount(self) -> None: - self._mounted = True - - # ── 主体布局 ────────────────────────────────────────────────────── - def _build_body(self) -> ft.Control: - left = ft.Container( + self._modifier_area, + self._negative, + self._divider(), + ft.Row( + controls=[ + ft.Icon(ft.Icons.SUBJECT, size=16, color=c("#e11d48", "fg")), + _txt("最终提示词", 14, "#162f50", weight=ft.FontWeight.W_600), + self._prompt_state, + self._restore_btn, + ft.Container(expand=True), + ft.IconButton(ft.Icons.COPY_ALL_OUTLINED, icon_size=18, tooltip="复制提示词", + icon_color=c("#455c7f", "fg"), on_click=self._on_copy_prompt), + ft.IconButton(ft.Icons.BOOKMARK_ADD_OUTLINED, icon_size=18, + tooltip="存为我的模板", icon_color=c("#455c7f", "fg"), + on_click=self._on_save_as_template), + ], + spacing=6, + vertical_alignment=ft.CrossAxisAlignment.CENTER, + ), + self._prompt_field, + ], + spacing=12, + scroll=ft.ScrollMode.AUTO, + expand=True, + ) + bottom_bar = ft.Container( content=ft.Column( controls=[ - self._search_field, - self._category_bar, - self._template_grid, + self._config_hint, + ft.Row( + controls=[ + self._labeled("尺寸", self._size_dd), + self._labeled("质量", self._quality_dd), + ], + spacing=12, + ), + self._generate_btn, ], - spacing=16, - scroll=ft.ScrollMode.AUTO, - ), - width=380, - padding=ft.padding.all(16), - bgcolor=c("#ffffff"), - border_radius=16, - shadow=ft.BoxShadow( - blur_radius=8, - color=ft.Colors.with_opacity(0.05, c("#000000", "fg")), - offset=ft.Offset(0, 2), + spacing=10, + horizontal_alignment=ft.CrossAxisAlignment.STRETCH, ), + padding=ft.padding.only(top=12), + border=ft.border.only(top=ft.BorderSide(1, c("#e2e8f0"))), + ) + middle = _card( + ft.Column([ft.Container(editor_scroll, expand=True, padding=ft.padding.only(right=8)), + bottom_bar], spacing=0, expand=True), + expand=1, padding=ft.padding.only(left=20, top=16, right=12, bottom=16), ) - right = ft.Container( - content=ft.Column( + right = _card( + ft.Column( controls=[ - self._section("变量", self._form_area), - self._section("提示词预览", self._prompt_preview), - self._section( - "生成参数", - ft.Row( - controls=[ - self._labeled("图片尺寸", self._size_dropdown), - self._labeled("生成质量", self._quality_dropdown), - ], - spacing=16, - ), - ), - ft.Container( - content=ft.Column( - controls=[ - self._config_hint, - self._generate_btn, - self._loading_row, - ], - spacing=12, - horizontal_alignment=ft.CrossAxisAlignment.CENTER, - ), - ), - ft.Container( - key=ft.ScrollKey(_RESULT_KEY), - content=self._section("生成结果", ft.Column(controls=[ - self._result_empty, - self._result_area, - ], horizontal_alignment=ft.CrossAxisAlignment.STRETCH)), + ft.Row( + controls=[ + ft.Icon(ft.Icons.IMAGE_OUTLINED, size=16, color=c("#e11d48", "fg")), + _txt("生成结果", 14, "#162f50", weight=ft.FontWeight.W_600), + ], + spacing=6, ), + self._preview, + self._result_meta, + self._result_actions, + self._history_section, ], - spacing=24, + spacing=10, expand=True, ), - expand=True, - padding=ft.padding.all(24), - bgcolor=c("#ffffff"), - border_radius=16, - shadow=ft.BoxShadow( - blur_radius=8, - color=ft.Colors.with_opacity(0.05, c("#000000", "fg")), - offset=ft.Offset(0, 2), - ), + expand=1, padding=16, ) return ft.Container( content=ft.Row( - controls=[left, right], + controls=[left, middle, right], spacing=16, - vertical_alignment=ft.CrossAxisAlignment.START, + vertical_alignment=ft.CrossAxisAlignment.STRETCH, expand=True, ), - padding=ft.padding.only(left=32, right=32, bottom=32), + padding=ft.padding.only(left=24, right=24, bottom=24), expand=True, ) - def _section(self, title: str, body: ft.Control) -> ft.Control: - return ft.Column( - controls=[ - ft.Text( - title, size=14, weight=ft.FontWeight.W_600, - color=c("#162f50", "fg"), font_family="42dot Sans", - ), - body, - ], - spacing=8, + def _divider(self) -> ft.Control: + return ft.Divider(height=1, thickness=1, color=c("#e2e8f0")) + + def _section_title(self, title: str, icon: str) -> ft.Control: + return ft.Row( + controls=[ft.Icon(icon, size=16, color=c("#e11d48", "fg")), + _txt(title, 14, "#162f50", weight=ft.FontWeight.W_600)], + spacing=6, ) def _labeled(self, label: str, body: ft.Control) -> ft.Control: - return ft.Column( - controls=[ - ft.Text(label, size=12, color=c("#455c7f", "fg"), font_family="42dot Sans"), - body, - ], - spacing=4, - expand=True, + return ft.Column([_txt(label, 11), body], spacing=4, expand=True) + + def _pill_button(self, label: str, icon: str, on_click) -> ft.Control: + return ft.Container( + content=ft.Row( + controls=[ft.Icon(icon, size=16, color=c("#005f98", "fg")), + _txt(label, 13, "#005f98", weight=ft.FontWeight.W_500)], + spacing=6, tight=True, + ), + bgcolor=c("#ffffff"), border=ft.border.all(1, c("#d5e3ff")), border_radius=9999, + padding=ft.padding.symmetric(horizontal=14, vertical=8), on_click=on_click, ink=True, ) - # ── 分类栏 ──────────────────────────────────────────────────────── - def _render_category_bar(self) -> None: - self._category_bar.controls.clear() - for cat in tpl.CATEGORIES: - is_active = cat == self._current_category - chip = ft.Container( - content=ft.Text( - cat, size=12, - color=c("#ffffff", "fg") if is_active else c("#455c7f", "fg"), - font_family="42dot Sans", - weight=ft.FontWeight.W_500, - ), - bgcolor=c("#005f98") if is_active else c("#f1f5f9"), - border_radius=9999, - padding=ft.padding.symmetric(horizontal=12, vertical=6), - on_click=lambda e, c=cat: self._on_category_change(c), - ink=True, - ) - self._category_bar.controls.append(chip) - if self._mounted: - self._category_bar.update() + def _small_button(self, label: str, icon: str, on_click, primary: bool = False) -> ft.Control: + fg = "#ffffff" if primary else "#005f98" + return ft.Container( + content=ft.Row( + controls=[ft.Icon(icon, size=14, color=c(fg, "fg")), _txt(label, 12, fg)], + spacing=4, tight=True, + vertical_alignment=ft.CrossAxisAlignment.CENTER, + ), + bgcolor=c("#005f98") if primary else c("#ffffff"), + border=None if primary else ft.border.all(1, c("#d5e3ff")), + border_radius=8, padding=ft.padding.symmetric(horizontal=10, vertical=6), + on_click=on_click, ink=True, + ) - def _on_category_change(self, cat: str) -> None: - self._current_category = cat - self._render_category_bar() - self._render_template_grid() - - def _on_search_change(self, e: ft.ControlEvent) -> None: - self._search_keyword = (e.data or "").strip() - self._render_template_grid() - - # ── 模板网格 ────────────────────────────────────────────────────── - def _render_template_grid(self) -> None: - self._template_grid.controls.clear() - items = tpl.get_templates(self._current_category, self._search_keyword) - if not items: - self._template_grid.controls.append( - ft.Container( - content=ft.Text( - "没有匹配的模板", size=12, color=c("#94a3b8", "fg"), - font_family="42dot Sans", - ), - padding=ft.padding.symmetric(vertical=16), - alignment=ft.Alignment(0, 0), - ) - ) - else: - # 2 列网格 - for i in range(0, len(items), 2): - row_items = items[i:i + 2] - row = ft.Row( - controls=[self._build_template_card(t) for t in row_items], - spacing=10, - ) - # 单行不足 2 项时补空位保持对齐 - if len(row_items) == 1: - row.controls.append(ft.Container(expand=True)) - self._template_grid.controls.append(row) - if self._mounted: - self._template_grid.update() + # ═════════════════════════════════════════════════════════════════ + # 左栏:模板库 + # ═════════════════════════════════════════════════════════════════ + def _reload_library(self, select_id: str = "") -> None: + """来源 / 来源内容变化后重载下拉框、分类与列表。""" + options = lib.source_options() + keys = {k for k, _ in options} + if self._source not in keys: + self._source = tpl.BUILTIN_SOURCE + self._source_dd.options = [ft.dropdown.Option(k, label) for k, label in options] + self._source_dd.value = self._source + self._templates = lib.templates_for(self._source) + cats = lib.categories_for(self._templates) + if self._category not in cats: + self._category = "全部" + self._render_categories(cats) + self._render_list() - def _build_template_card(self, template: dict) -> ft.Control: - is_selected = (self._current_template is not None - and self._current_template["id"] == template["id"]) - icon_name = getattr(ft.Icons, template.get("icon", "IMAGE"), ft.Icons.IMAGE) + target = None + if select_id: + target = lib.find_template(select_id) + if target is None and self._current is not None: + target = lib.find_template(self._current["id"]) + if target is None and self._templates: + target = self._templates[0] + if target is None: + target = tpl.TEMPLATES[0] + if self._current is None or target["id"] != self._current["id"] or select_id: + self._select_template(target) + self._update(self._source_dd) - tag_row = ft.Row( - spacing=4, - controls=[ - ft.Container( - content=ft.Text( - tag, size=9, color=c("#005f98", "fg"), - font_family="42dot Sans", weight=ft.FontWeight.W_500, - ), - bgcolor=c("#dee9ff"), + def _on_source_change(self, e) -> None: + self._source = e.control.value or tpl.BUILTIN_SOURCE + settings_service.set(_KEY_LAST_SOURCE, self._source) + self._category = "全部" + self._templates = lib.templates_for(self._source) + self._render_categories(lib.categories_for(self._templates)) + self._render_list() + + def _on_search_change(self, e) -> None: + self._keyword = (e.control.value or "").strip() + self._render_list() + + def _render_categories(self, cats: list[str]) -> None: + if len(cats) <= 2: + self._category_area.content = None + elif len(cats) > _CHIP_CATEGORY_LIMIT: + self._category_area.content = ft.Row([_dropdown( + value=self._category, expand=True, + options=[ft.dropdown.Option(cat) for cat in cats], + on_select=lambda e: self._on_category_change(e.control.value or "全部"), + )]) + else: + chips = [] + for cat in cats: + active = cat == self._category + chips.append(ft.Container( + content=_txt(cat, 12, "#ffffff" if active else "#455c7f", + weight=ft.FontWeight.W_500), + bgcolor=c("#005f98") if active else c("#f1f5f9"), border_radius=9999, - padding=ft.padding.symmetric(horizontal=6, vertical=2), - ) - for tag in template.get("tags", [])[:2] - ], - wrap=True, - ) + padding=ft.padding.symmetric(horizontal=10, vertical=5), + on_click=lambda _e, cat=cat: self._on_category_change(cat), + ink=True, + )) + self._category_area.content = ft.Row(chips, spacing=6, run_spacing=6, wrap=True) + self._update(self._category_area) + + def _on_category_change(self, cat: str) -> None: + self._category = cat + self._render_categories(lib.categories_for(self._templates)) + self._render_list() + def _render_list(self) -> None: + items = tpl.get_templates(self._category, self._keyword, self._templates) + favs = set(lib.list_favorites()) + self._template_list.controls = [self._template_tile(t, t["id"] in favs) + for t in items[:_LIST_LIMIT]] + if not items: + hint = "还没有自己的模板,点右上角「新建模板」,或在提示词旁点书签保存" \ + if self._source == lib.CUSTOM_SOURCE and not self._keyword else "没有匹配的模板" + self._template_list.controls = [ft.Container( + content=_txt(hint, 12, "#94a3b8", text_align=ft.TextAlign.CENTER), + padding=ft.padding.symmetric(vertical=24, horizontal=8), + alignment=ft.Alignment(0, 0), + )] + count = f"共 {len(items)} 个模板" + if len(items) > _LIST_LIMIT: + count += f",显示前 {_LIST_LIMIT} 个,可搜索缩小范围" + self._count_text.value = count + self._update(self._template_list, self._count_text) + + def _template_tile(self, t: dict, favorite: bool) -> ft.Control: + selected = self._current is not None and self._current["id"] == t["id"] + icon = getattr(ft.Icons, t.get("icon") or "AUTO_AWESOME", ft.Icons.AUTO_AWESOME) + sub = t.get("author") or (lib.source_label(t["source"]) + if t.get("source") not in (tpl.BUILTIN_SOURCE, None) else "") + texts: list[ft.Control] = [ + ft.Row( + controls=[ + ft.Text(t["name"], size=13, weight=ft.FontWeight.W_600, + color=c("#162f50", "fg"), font_family=_FONT, expand=True, + max_lines=1, overflow=ft.TextOverflow.ELLIPSIS), + ft.Icon(ft.Icons.STAR, size=13, color=c("#d97706", "fg"), visible=favorite), + ], + spacing=4, + ), + _txt(t.get("description", ""), 11, "#61789c", max_lines=1, + overflow=ft.TextOverflow.ELLIPSIS), + ] + if sub: + texts.append(_txt(sub, 10, "#94a3b8", max_lines=1, overflow=ft.TextOverflow.ELLIPSIS)) return ft.Container( - content=ft.Column( + content=ft.Row( controls=[ ft.Container( - content=ft.Icon(icon_name, color=c("#e11d48", "fg"), size=22), - width=36, height=36, - bgcolor=c("#fff1f2"), - border_radius=10, + content=ft.Icon(icon, size=18, color=c("#e11d48", "fg")), + width=34, height=34, border_radius=10, bgcolor=c("#fff1f2"), alignment=ft.Alignment(0, 0), ), - ft.Text( - template["name"], size=13, - weight=ft.FontWeight.W_600, - color=c("#162f50", "fg"), font_family="42dot Sans", - max_lines=1, overflow=ft.TextOverflow.ELLIPSIS, - ), - ft.Text( - template["description"], size=11, color=c("#455c7f", "fg"), - font_family="42dot Sans", - max_lines=2, overflow=ft.TextOverflow.ELLIPSIS, - ), - tag_row, + ft.Column(texts, spacing=1, expand=True, tight=True), ], - spacing=6, - tight=True, - ), - bgcolor=c("#f0f7ff") if is_selected else c("#ffffff"), - border=ft.border.all( - 2 if is_selected else 1, - c("#005f98") if is_selected else c("#e2e8f0"), + spacing=10, + vertical_alignment=ft.CrossAxisAlignment.CENTER, ), + bgcolor=c("#f0f7ff") if selected else None, + border=ft.border.all(1, c("#005f98") if selected else "transparent"), border_radius=12, - padding=ft.padding.all(12), - on_click=lambda e, t=template: self._select_template(t), + padding=ft.padding.symmetric(horizontal=8, vertical=8), + on_click=lambda _e, t=t: self._select_template(t), ink=True, - expand=True, - animate=ft.Animation(150, ft.AnimationCurve.EASE_OUT), ) - # ── 模板选中 → 渲染表单 ────────────────────────────────────────── + # ═════════════════════════════════════════════════════════════════ + # 中栏:模板信息、表单、风格增强、最终提示词 + # ═════════════════════════════════════════════════════════════════ def _select_template(self, template: dict) -> None: - self._current_template = template + self._current = template + self._manual_prompt = False self._var_controls.clear() - # 用模板默认尺寸回填 size 下拉 - default_size = template.get("default_size", "1024x1024") - if default_size in [o.key for o in self._size_dropdown.options]: - self._size_dropdown.value = default_size + settings_service.set(_KEY_LAST_TEMPLATE, template["id"]) + size = template.get("default_size", "1024x1024") + if size in [o.key for o in self._size_dd.options]: + self._size_dd.value = size + self._render_template_header() self._render_form() - self._render_template_grid() - self._update_prompt_preview() + self._render_modifiers() + self._render_list() + self._refresh_prompt() + self._update(self._size_dd) if self._mounted: - self._size_dropdown.update() + self._page.run_task(self._scroll_editor_top) + + async def _scroll_editor_top(self) -> None: + try: + await self._editor_scroll.scroll_to(offset=0, duration=150) + except Exception: + pass + + def _render_template_header(self) -> None: + t = self._current + if not t: + return + fav = lib.is_favorite(t["id"]) + icon = getattr(ft.Icons, t.get("icon") or "AUTO_AWESOME", ft.Icons.AUTO_AWESOME) + actions: list[ft.Control] = [ + ft.IconButton( + ft.Icons.STAR if fav else ft.Icons.STAR_BORDER, icon_size=20, + icon_color=c("#d97706", "fg") if fav else c("#94a3b8", "fg"), + tooltip="取消收藏" if fav else "收藏", on_click=self._on_toggle_favorite, + ), + ] + if t.get("source") == lib.CUSTOM_SOURCE: + actions += [ + ft.IconButton(ft.Icons.EDIT_OUTLINED, icon_size=18, tooltip="编辑模板", + icon_color=c("#455c7f", "fg"), + on_click=lambda _e: self._open_template_editor(self._current)), + ft.IconButton(ft.Icons.DELETE_OUTLINE, icon_size=18, tooltip="删除模板", + icon_color=c("#b91c1c", "fg"), on_click=self._on_delete_custom), + ] + credit: list[ft.Control] = [ + self._badge(t.get("category") or "未分类", "#dee9ff", "#005f98"), + self._badge(lib.source_label(t.get("source") or tpl.BUILTIN_SOURCE), "#f1f5f9", "#455c7f"), + ] + if t.get("author"): + credit.append(_txt(f"作者 {t['author']}", 11, "#61789c")) + if t.get("license"): + credit.append(_txt(t["license"], 11, "#61789c")) + if t.get("link"): + credit.append(ft.Container( + content=ft.Row([ft.Icon(ft.Icons.OPEN_IN_NEW, size=12, color=c("#005f98", "fg")), + _txt("原文", 11, "#005f98")], spacing=2, tight=True), + on_click=lambda _e, url=t["link"]: self._launch(url), ink=True, border_radius=6, + padding=ft.padding.symmetric(horizontal=4, vertical=2), + )) + self._tpl_header.controls = [ + ft.Row( + controls=[ + ft.Container( + content=ft.Icon(icon, size=22, color=c("#e11d48", "fg")), + width=42, height=42, border_radius=12, bgcolor=c("#fff1f2"), + alignment=ft.Alignment(0, 0), + ), + ft.Column( + controls=[ + ft.Text(t["name"], size=17, weight=ft.FontWeight.W_600, + color=c("#162f50", "fg"), font_family=_FONT, + max_lines=2, overflow=ft.TextOverflow.ELLIPSIS), + _txt(t.get("description", ""), 12, "#455c7f", max_lines=2, + overflow=ft.TextOverflow.ELLIPSIS), + ], + spacing=2, expand=True, tight=True, + ), + *actions, + ], + spacing=12, + vertical_alignment=ft.CrossAxisAlignment.CENTER, + ), + ft.Row(credit, spacing=8, wrap=True, run_spacing=4, + vertical_alignment=ft.CrossAxisAlignment.CENTER), + ] + self._update(self._tpl_header) + + def _badge(self, text: str, bg: str, fg: str) -> ft.Control: + return ft.Container( + content=_txt(text, 10, fg, weight=ft.FontWeight.W_500), + bgcolor=c(bg), border_radius=9999, + padding=ft.padding.symmetric(horizontal=8, vertical=2), + ) def _render_form(self) -> None: self._form_area.controls.clear() - if not self._current_template: + t = self._current + if not t: return - - for var in self._current_template["variables"]: + variables = t.get("variables") or [] + if not variables: + self._form_area.controls.append(_txt( + "这个提示词没有需要填写的内容,可以直接生成,或在下方「最终提示词」里修改。", + 12, "#94a3b8")) + for var in variables: name = var["name"] var_type = var.get("type", "text") - label = var["label"] - required = var.get("required", False) default = var.get("default", "") - placeholder = var.get("placeholder", "") - - if var_type == "select": - ctrl = ft.Dropdown( - value=default or (var.get("options") or [""])[0], - options=[ft.dropdown.Option(o) for o in var.get("options", [])], - border_radius=12, - expand=True, - on_select=lambda _e: self._update_prompt_preview(), - ) - elif var_type == "textarea": - ctrl = ft.TextField( - value=default, - hint_text=placeholder, - multiline=True, - min_lines=3, max_lines=6, - border_radius=12, - bgcolor=c("#f8fafc"), - border_color="transparent", - on_change=lambda _e: self._update_prompt_preview(), + if var_type == "select" and var.get("options"): + ctrl: ft.Control = _dropdown( + value=default or var["options"][0], dense=True, text_size=13, + options=[ft.dropdown.Option(o, option_label(o)) for o in var["options"]], + border_radius=10, expand=True, + on_select=lambda _e: self._refresh_prompt(), ) - else: # text + else: + multiline = var_type == "textarea" ctrl = ft.TextField( - value=default, - hint_text=placeholder, - border_radius=12, - bgcolor=c("#f8fafc"), - border_color="transparent", - on_change=lambda _e: self._update_prompt_preview(), + value=default, hint_text=var.get("placeholder", ""), + hint_style=ft.TextStyle(color=c("#94a3b8", "fg"), size=12), + multiline=multiline, min_lines=4 if multiline else None, + max_lines=10 if multiline else 1, dense=not multiline, text_size=13, + border_radius=10, bgcolor=c("#f8fafc"), border_color="transparent", + on_change=lambda _e: self._refresh_prompt(), ) - self._var_controls[name] = ctrl - - label_text = f"{label}{' *' if required else ''}" - self._form_area.controls.append( - ft.Column( - controls=[ - ft.Text(label_text, size=12, color=c("#455c7f", "fg"), - font_family="42dot Sans"), - ctrl, - ], - spacing=4, - ) - ) - - if self._mounted: - self._form_area.update() + label = var.get("label") or name + self._form_area.controls.append(ft.Column( + controls=[ + ft.Row([_txt(label, 12), + _txt("*", 12, "#e11d48", visible=bool(var.get("required")))], spacing=2), + ctrl, + ], + spacing=4, + )) + self._update(self._form_area) def _collect_values(self) -> dict: values = {} for name, ctrl in self._var_controls.items(): - values[name] = (ctrl.value or "").strip() if isinstance(ctrl.value, str) else (ctrl.value or "") + v = ctrl.value + values[name] = v.strip() if isinstance(v, str) else (v or "") return values - def _update_prompt_preview(self) -> None: - if not self._current_template: + def _render_modifiers(self) -> None: + groups = [] + selected = set(self._modifiers) + for group, items in mods.MODIFIER_GROUPS: + chips = [] + for mid, label, _text in items: + on = mid in selected + chips.append(ft.Container( + content=_txt(label, 12, "#ffffff" if on else "#455c7f"), + bgcolor=c("#e11d48", "fg") if on else c("#f1f5f9"), + border_radius=9999, + padding=ft.padding.symmetric(horizontal=10, vertical=5), + on_click=lambda _e, mid=mid: self._on_toggle_modifier(mid), + ink=True, + )) + groups.append(ft.Column( + controls=[_txt(group, 11, "#94a3b8"), + ft.Row(chips, spacing=6, run_spacing=6, wrap=True)], + spacing=4, + )) + self._modifier_area.controls = groups + self._modifier_count.value = f"已选 {len(self._modifiers)} 项" if self._modifiers else "" + self._update(self._modifier_area, self._modifier_count) + + def _toggle_modifiers(self, _e) -> None: + self._modifier_area.visible = not self._modifier_area.visible + self._modifier_toggle_icon.icon = (ft.Icons.EXPAND_LESS if self._modifier_area.visible + else ft.Icons.EXPAND_MORE) + self._update(self._modifier_area, self._modifier_toggle_icon) + + def _on_toggle_modifier(self, mid: str) -> None: + if mid in self._modifiers: + self._modifiers.remove(mid) + else: + self._modifiers.append(mid) + settings_service.set(_KEY_MODIFIERS, ",".join(self._modifiers)) + self._render_modifiers() + self._refresh_prompt() + + def _build_prompt(self) -> str: + if not self._current: + return "" + base = tpl.assemble_prompt(self._current, self._collect_values()) + return mods.apply_modifiers(base, self._modifiers, self._negative.value or "") + + def _refresh_prompt(self) -> None: + """变量 / 增强项变化后刷新最终提示词(用户手改过则不覆盖)。""" + if self._manual_prompt: + self._prompt_state.value = "已手动修改,上方改动不会自动同步" + self._update(self._prompt_state) return - prompt = tpl.assemble_prompt(self._current_template, self._collect_values()) - self._prompt_preview.value = prompt - if self._mounted: - self._prompt_preview.update() + self._prompt_field.value = self._build_prompt() + self._prompt_state.value = "根据上方内容自动生成,可直接修改" + self._restore_btn.visible = False + self._update(self._prompt_field, self._prompt_state, self._restore_btn) + + def _on_prompt_edited(self, _e) -> None: + if not self._manual_prompt: + self._manual_prompt = True + self._prompt_state.value = "已手动修改" + self._restore_btn.visible = True + self._update(self._prompt_state, self._restore_btn) + + def _on_restore_prompt(self, _e) -> None: + self._manual_prompt = False + self._refresh_prompt() - # ── 生成按钮 & 配置提示 ────────────────────────────────────────── + def _on_toggle_favorite(self, _e) -> None: + if not self._current: + return + on = lib.toggle_favorite(self._current["id"]) + show_toast(self._page, "已收藏" if on else "已取消收藏", color="#047857" if on else None) + self._render_template_header() + self._reload_library() + + # ═════════════════════════════════════════════════════════════════ + # 生成 + # ═════════════════════════════════════════════════════════════════ def _build_generate_button(self) -> ft.Control: return ft.Container( content=ft.Row( - controls=[ - ft.Icon(ft.Icons.AUTO_FIX_HIGH, color=c("#ffffff", "fg"), size=18), - ft.Text( - "生成图片", size=15, color=c("#ffffff", "fg"), - font_family="42dot Sans", weight=ft.FontWeight.W_600, - ), - ], + controls=[ft.Icon(ft.Icons.AUTO_FIX_HIGH, color=c("#ffffff", "fg"), size=18), + self._generate_label], spacing=10, alignment=ft.MainAxisAlignment.CENTER, ), @@ -498,15 +752,13 @@ def _build_generate_button(self) -> ft.Control: colors=[c("#005f98"), c("#6b1ef3")], ), border_radius=12, - padding=ft.padding.symmetric(vertical=12, horizontal=40), - shadow=ft.BoxShadow( - blur_radius=15, spread_radius=-3, - color=ft.Colors.with_opacity(0.25, c("#005f98", "fg")), - offset=ft.Offset(0, 6), - ), + padding=ft.padding.symmetric(vertical=13), + shadow=ft.BoxShadow(blur_radius=15, spread_radius=-3, + color=ft.Colors.with_opacity(0.25, c("#005f98", "fg")), + offset=ft.Offset(0, 6)), on_click=self._on_generate, ink=True, - animate=ft.Animation(150, ft.AnimationCurve.EASE_OUT), + animate_opacity=150, ) def _build_config_hint(self) -> ft.Control: @@ -514,16 +766,9 @@ def _build_config_hint(self) -> ft.Control: content=ft.Row( controls=[ ft.Icon(ft.Icons.INFO_OUTLINE, color=c("#b45309", "fg"), size=16), - ft.Text( - "尚未配置 AI 生图 API Key,点击右侧按钮前往设置", - size=12, color=c("#92400e", "fg"), font_family="42dot Sans", - ), - ft.Container(expand=True), - ft.TextButton( - "去设置", - on_click=lambda _e: self._page.go("/settings"), - style=ft.ButtonStyle(color=c("#b45309", "fg")), - ), + _txt("尚未配置 AI 生图 API Key", 12, "#92400e", expand=True), + ft.TextButton("去设置", on_click=lambda _e: self._page.go("/settings"), + style=ft.ButtonStyle(color=c("#b45309", "fg"))), ], vertical_alignment=ft.CrossAxisAlignment.CENTER, spacing=8, @@ -531,204 +776,628 @@ def _build_config_hint(self) -> ft.Control: bgcolor=c("#fffbeb"), border=ft.border.all(1, c("#fcd34d")), border_radius=10, - padding=ft.padding.symmetric(horizontal=12, vertical=8), + padding=ft.padding.only(left=12, right=4, top=2, bottom=2), visible=not prompt_image_service.is_configured(), ) - # ── 生成流程 ────────────────────────────────────────────────────── - def _on_generate(self, _e) -> None: - if self._generating: + def _on_generate(self, _e=None) -> None: + if self._generating or not self._current: return - if not self._current_template: - show_toast(self._page, "请先选择一个模板") + if not self._manual_prompt: + values = self._collect_values() + missing = [v.get("label") or v["name"] for v in self._current.get("variables", []) + if v.get("required") and not values.get(v["name"])] + if missing: + show_toast(self._page, f"请填写:{'、'.join(missing)}", color="#b91c1c") + return + self._refresh_prompt() + prompt = (self._prompt_field.value or "").strip() + if not prompt: + show_toast(self._page, "提示词为空", color="#b91c1c") return - - # 必填校验 - values = self._collect_values() - missing = [ - v["label"] for v in self._current_template["variables"] - if v.get("required") and not values.get(v["name"]) - ] - if missing: - show_toast(self._page, f"请填写必填项:{', '.join(missing)}", color="#b91c1c") - return - if not prompt_image_service.is_configured(): self._config_hint.visible = True - if self._mounted: - self._config_hint.update() + self._update(self._config_hint) show_toast(self._page, "请先在设置中配置 AI 生图 API Key", color="#b45309") return - self._update_prompt_preview() - prompt = self._prompt_preview.value or "" - size = self._size_dropdown.value or "1024x1024" - quality = self._quality_dropdown.value or "high" - + size = self._size_dd.value or "1024x1024" + quality = self._quality_dd.value or "high" self._set_generating(True) self._page.run_task(self._generate_task, prompt, size, quality) async def _generate_task(self, prompt: str, size: str, quality: str) -> None: - started_at = time.time() - result = await prompt_image_service.generate_image( - prompt=prompt, size=size, quality=quality, - ) - elapsed = time.time() - started_at - + self._gen_started = time.time() + self._show_loading(prompt) + self._page.run_task(self._tick_loading) + try: + result = await prompt_image_service.generate_image(prompt=prompt, size=size, + quality=quality) + except Exception as e: # 服务层已兜底,这里防御未知异常,避免界面卡在生成中 + result = {"success": False, "error": str(e)} + elapsed = time.time() - self._gen_started self._set_generating(False) - if not result.get("success"): - show_toast( - self._page, - f"生成失败:{result.get('error', '未知错误')}", - color=c("#b91c1c", "fg"), duration=4000, - ) + if not result.get("success") or not result.get("image_bytes"): + self._show_error(result.get("error") or "未获取到图片数据") return - image_bytes = result.get("image_bytes") or b"" - if not image_bytes: - show_toast(self._page, "生成失败:未获取到图片数据", color="#b91c1c") - return - - # 保存到本地 + image_bytes = result["image_bytes"] + saved_path: Path | None = None try: saved_path = prompt_image_service.save_image(image_bytes) + lib.add_history(saved_path, prompt, self._current["name"] if self._current else "", + size) except Exception as e: - saved_path = None show_toast(self._page, f"保存失败:{e}", color="#b91c1c") - self._last_image_bytes = image_bytes - self._last_image_path = saved_path - self._show_result(image_bytes, saved_path, size, elapsed) - # 结果区在生成按钮下方,窗口不够高时看不到,自动滚过去 - try: - await asyncio.sleep(0.15) # 等结果区先完成布局,否则滚动距离按旧高度计算 - await self.scroll_to(scroll_key=_RESULT_KEY, duration=400) - except Exception: - pass + self._last_bytes = image_bytes + self._last_path = saved_path + self._last_prompt = prompt + meta = [f"{size}", f"耗时 {elapsed:.1f}s"] + if saved_path: + meta.append(str(saved_path)) + self._show_image(image_bytes, saved_path, " · ".join(meta)) + self._render_history() + + async def _tick_loading(self) -> None: + while self._generating: + if isinstance(self._preview.data, ft.Text): + self._preview.data.value = f"已等待 {int(time.time() - self._gen_started)} 秒" + self._update(self._preview.data) + await asyncio.sleep(1) def _set_generating(self, on: bool) -> None: self._generating = on - self._loading_row.visible = on self._generate_btn.disabled = on self._generate_btn.opacity = 0.6 if on else 1.0 - self._page.update() - - def _show_result( - self, - image_bytes: bytes, - saved_path: Path | None, - size: str, - elapsed: float, - ) -> None: - self._result_image.content = ft.Image( - src=image_bytes, - fit=ft.BoxFit.CONTAIN, - border_radius=12, - gapless_playback=True, - error_content=ft.Text("预览加载失败,图片已保存,可点“打开目录”查看", - size=12, color=c("#b91c1c", "fg")), + self._generate_label.value = "生成中…" if on else "生成图片" + self._update(self._generate_btn) + + # ── 右栏状态 ───────────────────────────────────────────────────── + def _preview_state(self, icon: str, title: str, detail: str = "", + color: str = "#94a3b8", extra: ft.Control | None = None) -> ft.Control: + controls: list[ft.Control] = [ + ft.Icon(icon, size=40, color=c(color, "fg")), + _txt(title, 13, "#455c7f", weight=ft.FontWeight.W_500, text_align=ft.TextAlign.CENTER), + ] + if detail: + controls.append(_txt(detail, 11, "#94a3b8", text_align=ft.TextAlign.CENTER)) + if extra: + controls.append(extra) + return ft.Column(controls, spacing=8, tight=True, + horizontal_alignment=ft.CrossAxisAlignment.CENTER) + + def _show_empty(self) -> None: + self._preview.data = None + self._preview.on_click = None + self._preview.content = self._preview_state( + ft.Icons.AUTO_FIX_HIGH_OUTLINED, "生成的图片会显示在这里", + "选好模板、填写内容后点「生成图片」") + self._result_actions.visible = False + self._result_meta.value = "" + self._update(self._preview, self._result_actions, self._result_meta) + + def _show_loading(self, prompt: str) -> None: + waited = _txt("已等待 0 秒", 11, "#94a3b8") + self._preview.data = waited + self._preview.on_click = None + self._preview.content = ft.Column( + controls=[ + ft.ProgressRing(width=36, height=36, stroke_width=3, color=c("#005f98", "fg")), + _txt("AI 正在创作中…", 13, "#455c7f", weight=ft.FontWeight.W_500), + waited, + ft.Container( + content=_txt(prompt, 11, "#94a3b8", max_lines=4, + overflow=ft.TextOverflow.ELLIPSIS, text_align=ft.TextAlign.CENTER), + padding=ft.padding.symmetric(horizontal=16), + ), + ], + spacing=10, tight=True, horizontal_alignment=ft.CrossAxisAlignment.CENTER, ) - self._result_image.visible = True - self._result_empty.visible = False - self._result_area.visible = True + self._result_actions.visible = False + self._result_meta.value = "高质量通常需要 20–60 秒" + self._update(self._preview, self._result_actions, self._result_meta) - meta_parts = [f"尺寸:{size}", f"耗时:{elapsed:.1f}s"] - if saved_path: - meta_parts.append(f"保存至:{saved_path}") - self._result_meta.value = " · ".join(meta_parts) + def _show_error(self, error: str) -> None: + self._preview.data = None + self._preview.on_click = None + self._preview.content = self._preview_state( + ft.Icons.ERROR_OUTLINE, "生成失败", error[:300], color="#b91c1c", + extra=self._small_button("重试", ft.Icons.REFRESH, self._on_generate, primary=True), + ) + self._result_meta.value = "" + self._result_actions.visible = False + self._update(self._preview, self._result_actions, self._result_meta) + + def _image_control(self, image_bytes: bytes | None, path: Path | None, **kw) -> ft.Image: + """桌面端优先用本地文件加载(不经过界面通道传输大块数据),Web 端用字节。""" + if path is not None and path.exists() and not self._page.web: + source: str | bytes = str(path) + elif image_bytes: + source = image_bytes + elif path is not None and path.exists(): + source = path.read_bytes() + else: + source = b"" + return ft.Image( + src=source, fit=ft.BoxFit.CONTAIN, border_radius=10, gapless_playback=True, + error_content=ft.Column( + controls=[ft.Icon(ft.Icons.BROKEN_IMAGE_OUTLINED, color=c("#b91c1c", "fg")), + _txt("预览加载失败,图片已保存,可点「打开图片」查看", 11, "#b91c1c")], + tight=True, horizontal_alignment=ft.CrossAxisAlignment.CENTER, + ), + **kw, + ) + def _show_image(self, image_bytes: bytes | None, path: Path | None, meta: str) -> None: + self._preview.data = None + self._preview.content = ft.Stack( + controls=[ + ft.Container(self._image_control(image_bytes, path), alignment=ft.Alignment(0, 0), + expand=True), + ft.Container( + content=ft.Icon(ft.Icons.ZOOM_OUT_MAP, size=16, color=c("#ffffff", "fg")), + bgcolor=ft.Colors.with_opacity(0.45, "#000000"), border_radius=8, padding=6, + right=6, top=6, tooltip="查看大图", + ), + ], + expand=True, + ) + self._preview.on_click = lambda _e: self._open_lightbox() + self._result_meta.value = meta self._result_actions.controls = [ - self._action_btn("下载", ft.Icons.DOWNLOAD, self._on_download), - self._action_btn("打开目录", ft.Icons.FOLDER_OPEN, self._on_open_dir), - self._action_btn("复制提示词", ft.Icons.COPY_ALL, self._on_copy_prompt), - self._action_btn("重新生成", ft.Icons.REFRESH, self._on_generate), + self._small_button("打开图片", ft.Icons.OPEN_IN_NEW, self._on_open_image), + self._small_button("打开目录", ft.Icons.FOLDER_OPEN, self._on_open_dir), + self._small_button("另存为", ft.Icons.DOWNLOAD, self._on_download), + self._small_button("复用提示词", ft.Icons.REPLAY, self._on_reuse_prompt), + self._small_button("再来一张", ft.Icons.REFRESH, self._on_generate, primary=True), ] self._result_actions.visible = True - self._page.update() + self._update(self._preview, self._result_meta, self._result_actions) + + # ── 最近作品 ───────────────────────────────────────────────────── + def _render_history(self) -> None: + history = lib.list_history()[:_HISTORY_THUMBS] + self._history_row.controls = [self._history_thumb(h) for h in history] + self._history_section.controls = [ + ft.Row([_txt("最近作品", 12, "#455c7f", weight=ft.FontWeight.W_500), + _txt(f"{len(history)}", 11, "#94a3b8")], spacing=6), + self._history_row, + ] if history else [] + self._update(self._history_section) - def _action_btn(self, label: str, icon: str, on_click) -> ft.Control: + def _thumb_source(self, path: Path) -> str | bytes: + if not self._page.web: + return str(path) + try: + from PIL import Image + with Image.open(path) as im: + im.thumbnail((128, 128)) + buf = io.BytesIO() + im.convert("RGB").save(buf, "JPEG", quality=80) + return buf.getvalue() + except Exception: + return b"" + + def _history_thumb(self, h: dict) -> ft.Control: + path = Path(h["path"]) + current = self._last_path is not None and path == self._last_path + stamp = datetime.fromtimestamp(h.get("created_at", 0)).strftime("%m-%d %H:%M") return ft.Container( - content=ft.Row( - controls=[ - ft.Icon(icon, color=c("#005f98", "fg"), size=14), - ft.Text(label, size=12, color=c("#005f98", "fg"), - font_family="42dot Sans"), - ], - spacing=6, - vertical_alignment=ft.CrossAxisAlignment.CENTER, - ), - bgcolor=c("#ffffff"), - border=ft.border.all(1, c("#d5e3ff")), - border_radius=10, - padding=ft.padding.symmetric(horizontal=12, vertical=8), - on_click=on_click, + content=ft.Image(src=self._thumb_source(path), width=56, height=56, + fit=ft.BoxFit.COVER, border_radius=8, cache_width=112, + error_content=ft.Icon(ft.Icons.IMAGE_OUTLINED, size=18)), + width=60, height=60, border_radius=10, padding=2, + border=ft.border.all(2, c("#e11d48", "fg") if current else "transparent"), + tooltip=f"{h.get('template') or '提示词出图'} · {stamp}", + on_click=lambda _e, h=h: self._show_history_item(h), ink=True, ) - # ── 结果区操作 ──────────────────────────────────────────────────── - def _on_download(self, _e) -> None: - if not self._last_image_bytes: + def _show_history_item(self, h: dict) -> None: + path = Path(h["path"]) + if not path.exists(): + show_toast(self._page, "图片文件已不存在", color="#b91c1c") + self._render_history() + return + self._last_path = path + self._last_bytes = None + self._last_prompt = h.get("prompt", "") + stamp = datetime.fromtimestamp(h.get("created_at", 0)).strftime("%Y-%m-%d %H:%M") + self._show_image(None, path, f"{h.get('template') or ''} · {h.get('size', '')} · {stamp}") + self._render_history() + + # ── 结果区操作 ─────────────────────────────────────────────────── + def _current_image_bytes(self) -> bytes | None: + if self._last_bytes: + return self._last_bytes + if self._last_path and self._last_path.exists(): + return self._last_path.read_bytes() + return None + + def _open_lightbox(self) -> None: + if not (self._last_path or self._last_bytes): + return + width = (self._page.width or 1280) * 0.82 + height = (self._page.height or 800) * 0.78 + dlg = ft.AlertDialog( + content=ft.Container( + self._image_control(self._last_bytes, self._last_path), + width=width, height=height, alignment=ft.Alignment(0, 0), + ), + content_padding=12, + actions=[ + ft.TextButton("打开目录", on_click=self._on_open_dir), + ft.TextButton("关闭", on_click=lambda _e: self._page.pop_dialog()), + ], + ) + self._page.show_dialog(dlg) + + def _on_open_image(self, _e) -> None: + if self._last_path and self._last_path.exists(): + open_folder(self._last_path) + + def _on_open_dir(self, _e) -> None: + if self._last_path: + open_folder(self._last_path.parent) + + def _on_reuse_prompt(self, _e) -> None: + if not self._last_prompt: return + self._manual_prompt = True + self._prompt_field.value = self._last_prompt + self._prompt_state.value = "已载入这张图的提示词" + self._restore_btn.visible = True + self._update(self._prompt_field, self._prompt_state, self._restore_btn) + show_toast(self._page, "已载入提示词,可修改后再次生成") + + def _on_download(self, _e) -> None: self._page.run_task(self._download_async) async def _download_async(self) -> None: - picker = ft.FilePicker() - self._page.overlay.append(picker) - self._page.update() + data = self._current_image_bytes() + if not data: + return + if not hasattr(self, "_save_picker"): + self._save_picker = ft.FilePicker() + ext = self._last_path.suffix.lstrip(".") if self._last_path else "png" try: - default_dir = settings_service.get("default_output_dir", "") or str(Path.home()) - try: - target = await picker.save_file_async( - dialog_title="保存图片", - file_name=f"prompt_image_{int(time.time())}.png", - initial_directory=default_dir, - ) - except AttributeError: - target = await picker.save_file( - dialog_title="保存图片", - file_name=f"prompt_image_{int(time.time())}.png", - initial_directory=default_dir, - ) + target = await self._save_picker.save_file( + dialog_title="保存图片", + file_name=f"prompt_image_{int(time.time())}.{ext or 'png'}", + initial_directory=settings_service.get("default_output_dir", "") or str(Path.home()), + ) except Exception as e: show_toast(self._page, f"无法打开保存对话框:{e}", color="#b91c1c") - target = None - finally: - try: - self._page.overlay.remove(picker) - self._page.update() - except Exception: - pass - + return if not target: return try: - Path(target).write_bytes(self._last_image_bytes or b"") + Path(target).write_bytes(data) show_toast(self._page, "已保存", color="#047857") - except Exception as e: + except OSError as e: show_toast(self._page, f"保存失败:{e}", color="#b91c1c") - def _on_open_dir(self, _e) -> None: - target = self._last_image_path - if not target: + def _on_copy_prompt(self, _e) -> None: + text = self._prompt_field.value or "" + if text: + self._page.run_task(self._copy_async, text) + + async def _copy_async(self, text: str) -> None: + try: + await ft.Clipboard().set(text) + show_toast(self._page, "提示词已复制", color="#047857") + except Exception as e: + show_toast(self._page, f"复制失败:{e}", color="#b91c1c") + + def _launch(self, url: str) -> None: + self._page.run_task(self._page.launch_url, url) + + # ═════════════════════════════════════════════════════════════════ + # 我的模板 + # ═════════════════════════════════════════════════════════════════ + def _on_save_as_template(self, _e) -> None: + prompt = self._prompt_field.value or "" + if self._current and not self._manual_prompt and self._current.get("variables"): + # 未手改时保存模板原文(保留 {变量}),再叠加当前的风格增强 + prompt = mods.apply_modifiers(self._current["prompt_template"], self._modifiers, + self._negative.value or "") + name = f"{self._current['name']}(我的)" if self._current else "" + self._open_template_editor({"name": name, "prompt_template": prompt, + "category": "我的模板", + "default_size": self._size_dd.value or "1024x1024"}) + + def _open_template_editor(self, template: dict | None = None) -> None: + template = template or {} + editing_id = template.get("id", "") if template.get("source") == lib.CUSTOM_SOURCE else "" + name = ft.TextField(label="模板名称", value=template.get("name", ""), dense=True, + border_radius=10, text_size=13) + category = ft.TextField(label="分类", value=template.get("category") or "我的模板", + dense=True, border_radius=10, text_size=13) + prompt = ft.TextField( + label="提示词", value=template.get("prompt_template", ""), multiline=True, + min_lines=6, max_lines=12, border_radius=10, text_size=13, + hint_text="例如:A cozy {房间} interior in {风格} style, warm light", + ) + detected = _txt("", 11, "#005f98") + + def refresh_detected(_e=None) -> None: + _p, variables = src.detect_variables(prompt.value or "") + detected.value = ("可填写项:" + "、".join(v["label"] for v in variables) + if variables else "用 {变量名} 标记需要每次填写的部分") + if _e is not None: + detected.update() + + prompt.on_change = refresh_detected + refresh_detected() + + def save(_e) -> None: + if not (prompt.value or "").strip(): + show_toast(self._page, "提示词不能为空", color="#b91c1c") + return + item = lib.save_custom(name.value or "", prompt.value or "", + category=category.value or "我的模板", + default_size=template.get("default_size", "1024x1024"), + template_id=editing_id) + self._page.pop_dialog() + show_toast(self._page, "模板已保存", color="#047857") + self._source = lib.CUSTOM_SOURCE + settings_service.set(_KEY_LAST_SOURCE, self._source) + self._category = "全部" + self._reload_library(select_id=item["id"]) + + dlg = ft.AlertDialog( + modal=True, + title=_txt("编辑模板" if editing_id else "保存为我的模板", 16, "#162f50", + weight=ft.FontWeight.W_600), + content=ft.Container( + ft.Column([name, category, prompt, detected], spacing=12, tight=True, + scroll=ft.ScrollMode.AUTO, + horizontal_alignment=ft.CrossAxisAlignment.STRETCH), + width=520, + ), + actions=[ + ft.TextButton("取消", on_click=lambda _e: self._page.pop_dialog()), + ft.FilledButton("保存", on_click=save), + ], + ) + self._page.show_dialog(dlg) + + def _on_delete_custom(self, _e) -> None: + t = self._current + if not t: + return + + def confirm(_e) -> None: + lib.delete_custom(t["id"]) + self._page.pop_dialog() + self._current = None + self._reload_library() + show_toast(self._page, "模板已删除") + + self._page.show_dialog(ft.AlertDialog( + modal=True, + title=_txt("删除模板", 16, "#162f50", weight=ft.FontWeight.W_600), + content=_txt(f"确定删除「{t['name']}」吗?此操作不可撤销。", 13), + actions=[ + ft.TextButton("取消", on_click=lambda _e: self._page.pop_dialog()), + ft.FilledButton("删除", on_click=confirm, + style=ft.ButtonStyle(bgcolor=c("#b91c1c", "fg"))), + ], + )) + + # ═════════════════════════════════════════════════════════════════ + # 提示词源管理 + # ═════════════════════════════════════════════════════════════════ + def _open_source_manager(self, _e=None) -> None: + self._source_body = ft.Column(spacing=14, tight=True, scroll=ft.ScrollMode.AUTO) + self._source_url = ft.TextField( + hint_text="粘贴 JSON / CSV / Markdown 提示词集合的链接(支持 GitHub 文件页地址)", + hint_style=ft.TextStyle(size=12, color=c("#94a3b8", "fg")), + dense=True, text_size=13, border_radius=10, expand=True, + ) + self._source_busy = ft.ProgressRing(width=16, height=16, stroke_width=2, visible=False) + self._render_source_manager() + dlg = ft.AlertDialog( + modal=True, + title=ft.Row([ + ft.Icon(ft.Icons.HUB_OUTLINED, color=c("#e11d48", "fg")), + _txt("提示词源", 16, "#162f50", weight=ft.FontWeight.W_600), + self._source_busy, + ], spacing=8), + content=ft.Container(self._source_body, width=600, height=520), + actions=[ft.TextButton("完成", on_click=self._close_source_manager)], + ) + self._page.show_dialog(dlg) + + def _close_source_manager(self, _e) -> None: + self._page.pop_dialog() + self._reload_library() + + def _render_source_manager(self) -> None: + sources = lib.list_sources() + added_urls = {s.get("url") for s in sources} + body: list[ft.Control] = [] + + body.append(_txt("已添加", 13, "#162f50", weight=ft.FontWeight.W_600)) + if not sources: + body.append(_txt("还没有添加外部提示词源。可从下方推荐里一键添加,或粘贴链接 / 导入本地文件。", + 12, "#94a3b8")) + for s in sources: + updated = datetime.fromtimestamp(s.get("updated_at", 0)).strftime("%Y-%m-%d") + info = f"{s.get('count', 0)} 条 · 更新于 {updated}" + if s.get("license"): + info += f" · {s['license']}" + body.append(ft.Container( + content=ft.Row( + controls=[ + ft.Column([ + _txt(s["name"], 13, "#162f50", weight=ft.FontWeight.W_500, + max_lines=1, overflow=ft.TextOverflow.ELLIPSIS), + _txt(info, 11, "#94a3b8"), + ], spacing=2, expand=True, tight=True), + ft.Switch(value=s.get("enabled", True), scale=0.8, tooltip="在模板库中显示", + on_change=lambda e, sid=s["id"]: self._on_source_enabled(sid, e)), + ft.IconButton(ft.Icons.REFRESH, icon_size=18, tooltip="重新下载", + on_click=lambda _e, sid=s["id"]: self._run_source_op( + lib.refresh_source(sid), "已更新")), + ft.IconButton(ft.Icons.OPEN_IN_NEW, icon_size=18, tooltip="打开主页", + visible=bool(s.get("homepage") or s["url"].startswith("http")), + on_click=lambda _e, u=s.get("homepage") or s["url"]: self._launch(u)), + ft.IconButton(ft.Icons.DELETE_OUTLINE, icon_size=18, tooltip="移除", + icon_color=c("#b91c1c", "fg"), + on_click=lambda _e, sid=s["id"]: self._on_remove_source(sid)), + ], + spacing=2, + vertical_alignment=ft.CrossAxisAlignment.CENTER, + ), + bgcolor=c("#f8fafc"), border_radius=10, + padding=ft.padding.only(left=12, right=4, top=4, bottom=4), + )) + + body.append(ft.Container(height=4)) + body.append(_txt("推荐的开源提示词库", 13, "#162f50", weight=ft.FontWeight.W_600)) + body.append(_txt("内容从 GitHub 下载到本机,版权归原作者,使用时请遵守对应许可证并保留署名。", + 11, "#94a3b8")) + for rec in src.RECOMMENDED_SOURCES: + added = rec["url"] in added_urls + body.append(ft.Container( + content=ft.Row( + controls=[ + ft.Column([ + ft.Row([_txt(rec["name"], 13, "#162f50", weight=ft.FontWeight.W_500), + self._badge(rec["license"], "#dcfce7", "#047857")], spacing=6), + _txt(rec["description"], 11, "#61789c"), + ], spacing=2, expand=True, tight=True), + ft.IconButton(ft.Icons.OPEN_IN_NEW, icon_size=18, tooltip="查看仓库", + on_click=lambda _e, u=rec["homepage"]: self._launch(u)), + ft.FilledButton( + "已添加" if added else "添加", disabled=added, + on_click=lambda _e, rec=rec: self._run_source_op( + lib.add_source(rec["url"], rec["name"], rec["homepage"], + rec["license"]), "已添加"), + ), + ], + spacing=4, + vertical_alignment=ft.CrossAxisAlignment.CENTER, + ), + border=ft.border.all(1, c("#e2e8f0")), border_radius=10, + padding=ft.padding.only(left=12, right=8, top=6, bottom=6), + )) + + body.append(ft.Container(height=4)) + body.append(_txt("添加其他来源", 13, "#162f50", weight=ft.FontWeight.W_600)) + body.append(ft.Row([ + self._source_url, + ft.FilledButton("添加", on_click=self._on_add_url), + ], spacing=8)) + body.append(ft.Row([ + self._small_button("从本地文件导入", ft.Icons.UPLOAD_FILE, self._on_import_file), + self._small_button("导出我的模板", ft.Icons.IOS_SHARE, self._on_export_custom), + ], spacing=8)) + body.append(_txt( + "支持格式:① JSON:{\"name\": \"库名\", \"templates\": [{\"name\": ..., \"prompt\": ..., " + "\"category\": ...}]};② CSV:含 prompt 列,可选 title / category / tags;" + "③ Markdown:每个标题下第一个代码块为一条提示词。提示词中的 {变量} 或 [变量] 会变成可填写项。", + 11, "#94a3b8")) + self._source_body.controls = body + self._update(self._source_body) + + def _set_source_busy(self, busy: bool) -> None: + self._source_busy.visible = busy + self._update(self._source_busy) + + def _run_source_op(self, coro, ok_text: str) -> None: + async def runner() -> None: + self._set_source_busy(True) + try: + record = await coro + show_toast(self._page, f"{ok_text}:{record['name']}({record['count']} 条)", + color="#047857") + except Exception as e: + show_toast(self._page, f"操作失败:{_friendly_error(e)}", color="#b91c1c", + duration=4000) + finally: + self._set_source_busy(False) + self._render_source_manager() + self._reload_library() + self._page.run_task(runner) + + def _on_add_url(self, _e) -> None: + url = (self._source_url.value or "").strip() + if not url.startswith(("http://", "https://")): + show_toast(self._page, "请输入以 http(s):// 开头的链接", color="#b91c1c") + return + self._source_url.value = "" + self._run_source_op(lib.add_source(url), "已添加") + + def _on_source_enabled(self, source_id: str, e) -> None: + lib.set_source_enabled(source_id, bool(e.control.value)) + self._reload_library() + + def _on_remove_source(self, source_id: str) -> None: + lib.remove_source(source_id) + self._render_source_manager() + self._reload_library() + + def _on_import_file(self, _e) -> None: + self._page.run_task(self._import_file_async) + + async def _import_file_async(self) -> None: + if not hasattr(self, "_import_picker"): + self._import_picker = ft.FilePicker() + try: + picked = await self._import_picker.pick_files( + dialog_title="选择提示词文件", file_type=ft.FilePickerFileType.CUSTOM, + allowed_extensions=["json", "csv", "tsv", "md", "markdown", "txt"], + ) + except Exception as e: + show_toast(self._page, f"无法打开文件选择器:{e}", color="#b91c1c") + return + if not picked or not picked[0].path: return - directory = str(target.parent) try: - if sys.platform == "win32": - subprocess.Popen(["explorer", directory]) - elif sys.platform == "darwin": - subprocess.Popen(["open", directory]) - else: - subprocess.Popen(["xdg-open", directory]) - except Exception: - show_toast(self._page, "无法打开目录", color="#b91c1c") + record = lib.import_file(Path(picked[0].path)) + show_toast(self._page, f"已导入:{record['name']}({record['count']} 条)", + color="#047857") + except Exception as e: + show_toast(self._page, f"导入失败:{_friendly_error(e)}", color="#b91c1c", duration=4000) + self._render_source_manager() + self._reload_library() - def _on_copy_prompt(self, _e) -> None: - text = self._prompt_preview.value or "" - if not text: + def _on_export_custom(self, _e) -> None: + if not lib.list_custom(): + show_toast(self._page, "还没有自己的模板") return + self._page.run_task(self._export_custom_async) + + async def _export_custom_async(self) -> None: + if not hasattr(self, "_export_picker"): + self._export_picker = ft.FilePicker() try: - self._page.set_clipboard(text) - show_toast(self._page, "提示词已复制", color="#047857") + target = await self._export_picker.save_file( + dialog_title="导出我的模板", file_name="my_prompts.json", + allowed_extensions=["json"], + ) except Exception as e: - show_toast(self._page, f"复制失败:{e}", color="#b91c1c") + show_toast(self._page, f"无法打开保存对话框:{e}", color="#b91c1c") + return + if not target: + return + try: + Path(target).write_text(lib.export_custom_json(), encoding="utf-8") + show_toast(self._page, "已导出,可分享给他人导入", color="#047857") + except OSError as e: + show_toast(self._page, f"导出失败:{e}", color="#b91c1c") + + +def _friendly_error(e: Exception) -> str: + import httpx + if isinstance(e, httpx.HTTPStatusError): + return f"服务器返回 {e.response.status_code}" + if isinstance(e, httpx.TimeoutException): + return "下载超时,请检查网络(GitHub 在部分网络下需要代理)" + if isinstance(e, httpx.HTTPError): + return "无法连接,请检查网络或链接(GitHub 在部分网络下需要代理)" + return str(e) or e.__class__.__name__