├── .github
└── workflows
│ └── publish_action.yml
├── LICENSE
├── README.md
├── __init__.py
├── config
├── kontext_presets.json
└── tags.json
├── config_manager.py
├── js
├── assets
│ ├── icon-caption-en.svg
│ ├── icon-caption-zh.svg
│ ├── icon-expand.svg
│ ├── icon-history.svg
│ ├── icon-main.svg
│ ├── icon-redo.svg
│ ├── icon-remove.svg
│ ├── icon-resize-handle.svg
│ ├── icon-tag.svg
│ ├── icon-translate.svg
│ └── icon-undo.svg
├── css
│ ├── assistant.css
│ ├── common.css
│ ├── popup.css
│ └── settings.css
├── index.js
├── lib
│ └── Sortable.min.js
├── modules
│ ├── PromptAssistant.js
│ ├── apiConfigManager.js
│ ├── history.js
│ ├── imageCaption.js
│ ├── rulesConfigManager.js
│ ├── settings.js
│ ├── tag.js
│ └── tagConfigManager.js
├── services
│ ├── api.js
│ ├── btnMenu.js
│ ├── cache.js
│ ├── features.js
│ └── interceptor.js
└── utils
│ ├── UIToolkit.js
│ ├── eventManager.js
│ ├── logger.js
│ ├── popupManager.js
│ ├── promptFormatter.js
│ └── resourceManager.js
├── node
├── __init__.py
├── image_caption_node.py
├── kontext_preset_node.py
└── translate_node.py
├── pyproject.toml
├── requirements.txt
├── server.py
└── services
├── __init__.py
├── baidu.py
├── error_util.py
├── llm.py
└── vlm.py
/.github/workflows/publish_action.yml:
--------------------------------------------------------------------------------
1 | name: Publish to Comfy registry
2 | on:
3 | workflow_dispatch:
4 | push:
5 | branches:
6 | - main
7 | paths:
8 | - "pyproject.toml"
9 |
10 | permissions:
11 | issues: write
12 |
13 | jobs:
14 | publish-node:
15 | name: Publish Custom Node to registry
16 | runs-on: ubuntu-latest
17 | if: ${{ github.repository_owner == 'yawiii' }}
18 | steps:
19 | - name: Check out code
20 | uses: actions/checkout@v4
21 | - name: Publish Custom Node
22 | uses: Comfy-Org/publish-node-action@v1
23 | with:
24 | personal_access_token: ${{ secrets.REGISTRY_ACCESS_TOKEN }} ## Add your own personal access token to your Github Repository secrets and reference it here.
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | # ComfyUI Prompt Assistant✨提示词小助手
5 |
6 |
7 |
8 | > 使用教程请查看:👉
👈
9 |
10 |
11 | 🎀特别感谢以下朋友提出的宝贵方案!
12 |
13 | Cereza69、LAOGOU-666、H、小海、foryoung365、xu...
14 |
15 |
16 | ## ✨插件介绍
17 |
18 | 这是一个无需添加节点,即可实现提示词翻译、扩写、预设标签插入、图片反推提示词、历史记录功能等功能的comfyUI插件。
19 | > 📍手动安装请从右侧[Releases](https://github.com/yawiii/comfyui_prompt_assistant/releases)下载最新版本。
20 |
21 |
22 | ## 📣更新
23 |
24 | [2025-8-28]V1.1.3:
25 |
26 | - 优化小助手UI,实现自动避开滚动条,避免重叠误触
27 | - 修复标签弹窗无滚动条,内容显示不全的问题
28 |
29 |
30 |
31 | [2025-8-23]V1.1.2:
32 |
33 | - 重构节点,解决执行时产生多队列和重复执行的问题
34 | - API配置界面添加模型参数,某些报错可以尝试调整最大token数解决
35 | - 简化图像反推流程,提升反推速度
36 | - 修复了标签按需加载时,无法搜索到未加载的标签
37 |
38 |
39 |
40 | [2025-8-10]V1.1.1:
41 |
42 | -修复图像反推节点报错
43 |
44 |
45 |
46 | [2025-8-10]V1.1.0:
47 |
48 | - 修改了UI交互
49 | - 支持所有兼容OpenAI SDK API
50 | - 新增自定自定义规则
51 | - 新增自定义标签
52 | - 新增图像反推、Kontext预设、翻译节点节点
53 |
54 |
55 |
56 | [2025-6-24]V1.0.6:
57 |
58 | - 修复了一些界面bug
59 |
60 |
61 |
62 | [2025-6-24]V1.0.5:
63 |
64 | - 修复新版创建使用选择工具栏创建kontext节点时,出现小助手UI异常问题
65 |
66 | - 修复可能网络环境问题造成的智谱无法服务无法使用问题
67 |
68 | - 修复可能出现实例清除出错导致工作流无法加载问题
69 |
70 | - 修复AIGODLIKE-COMFYUI-TRANSLATION汉化插件导致标签弹窗打开卡住的问题
71 |
72 | - 新增标签面板可以调整大小
73 |
74 | - 优化UI资源加载机制
75 |
76 |
77 |
78 | [2025-6-24]V1.0.3:
79 |
80 | - 重构了api请求服务,避免apikey暴露在前端
81 |
82 | - 修改了配置的保存和读取机制,解决配置无法保存问题
83 |
84 | - 修复了少许bug
85 |
86 |
87 |
88 |
89 | [2025-6-21]V1.0.2:
90 |
91 | - 修复了少许bug
92 |
93 |
94 |
95 |
96 | [2025-6-15]V1.0.0:
97 |
98 | - 一键插入tag
99 |
100 | - 支持llm扩写
101 |
102 | - 支持百度翻译和llm翻译切换
103 |
104 | - 图片反推提示词
105 |
106 | - 历史、撤销、重做
107 |
108 |
109 | ## ✨ 功能介绍
110 |
111 | 
112 |
113 | 
114 |
115 | 
116 |
117 | 
118 |
119 | 
120 |
121 | ## 📦 安装方法
122 |
123 | #### 从ComfyUI Manager中安装
124 | 在Manager中输入“Prompt Assistant”或“提示词小助手”,点击Install,选择最新版本安装。
125 |
126 |
127 | 
128 |
129 |
130 | #### 手动安装
131 |
132 |
133 |
134 | 1. 从[克隆仓库](https://github.com/yawiii/comfyui_prompt_assistant/releases)中下载最新版本
135 | 解压缩到ComfyUI/custom_nodes目录下
136 |
137 |
138 | 2. 重启 ComfyUI
139 |
140 | ## ⚙️ 配置说明
141 | 目前小助手的翻译功能支持百度和智谱两种翻译服务,都是免费的。百度机翻速度快,智谱则是 AI翻译,更加准确。你可以根据自己的需求,进行切换 。而扩写和提示词反推则必须要使用智谱的服务来实现。
142 | 申请教程,可查看作者 B 站视频:
143 |
144 | 百度翻译申请入口:[通用文本翻译API链接](https://fanyi-api.baidu.com/product/11)
145 |
146 | 
147 |
148 | 智谱API申请入口:[智谱API申请](https://www.bigmodel.cn/invite?icode=Wz1tQAT40T9M8vwp%2F1db7nHEaazDlIZGj9HxftzTbt4%3D)
149 |
150 | 硅基流动 api申请入口:[硅基流动API申请](https://cloud.siliconflow.cn/i/FCDL2zBQ)
151 |
152 | 
153 |
154 |
155 |
156 | #### 填入App id 、密钥、大模型API key
157 |
158 | 
159 |
160 |
161 |
162 |
163 |
164 |
165 |
166 |
167 |
168 |
169 |
170 |
171 |
--------------------------------------------------------------------------------
/__init__.py:
--------------------------------------------------------------------------------
1 |
2 | import os
3 | import re
4 | from server import PromptServer
5 | from . import server
6 | from .node.translate_node import NODE_CLASS_MAPPINGS as TRANSLATE_NODE_CLASS_MAPPINGS
7 | from .node.translate_node import NODE_DISPLAY_NAME_MAPPINGS as TRANSLATE_NODE_DISPLAY_NAME_MAPPINGS
8 | from .node.image_caption_node import NODE_CLASS_MAPPINGS as IMAGE_CAPTION_NODE_CLASS_MAPPINGS
9 | from .node.image_caption_node import NODE_DISPLAY_NAME_MAPPINGS as IMAGE_CAPTION_NODE_DISPLAY_NAME_MAPPINGS
10 | from .node.kontext_preset_node import NODE_CLASS_MAPPINGS as KONTEXT_PRESET_NODE_CLASS_MAPPINGS
11 | from .node.kontext_preset_node import NODE_DISPLAY_NAME_MAPPINGS as KONTEXT_PRESET_NODE_DISPLAY_NAME_MAPPINGS
12 |
13 | # ANSI颜色常量
14 | GREEN = "\033[92m"
15 | RESET = "\033[0m"
16 |
17 | # 模块常量定义
18 | NODE_CLASS_MAPPINGS = {
19 | **IMAGE_CAPTION_NODE_CLASS_MAPPINGS,
20 | **KONTEXT_PRESET_NODE_CLASS_MAPPINGS,
21 | }
22 |
23 | NODE_DISPLAY_NAME_MAPPINGS = {
24 | **IMAGE_CAPTION_NODE_DISPLAY_NAME_MAPPINGS,
25 | **KONTEXT_PRESET_NODE_DISPLAY_NAME_MAPPINGS,
26 | }
27 | WEB_DIRECTORY = "./js"
28 |
29 | # 更新节点映射
30 | NODE_CLASS_MAPPINGS.update(TRANSLATE_NODE_CLASS_MAPPINGS)
31 | NODE_DISPLAY_NAME_MAPPINGS.update(TRANSLATE_NODE_DISPLAY_NAME_MAPPINGS)
32 |
33 | def get_version():
34 | """
35 | 从pyproject.toml文件中读取版本号
36 |
37 | Returns:
38 | str: 版本号字符串
39 |
40 | Raises:
41 | ValueError: 当无法找到版本号时抛出
42 | """
43 | try:
44 | toml_path = os.path.join(os.path.dirname(__file__), "pyproject.toml")
45 | with open(toml_path, "r", encoding='utf-8') as f:
46 | content = f.read()
47 | version_match = re.search(r'version\s*=\s*"([^"]+)"', content)
48 | if version_match:
49 | return version_match.group(1)
50 | raise ValueError("未在pyproject.toml中找到版本号")
51 | except Exception as e:
52 | print(f"读取版本号失败: {str(e)}")
53 | raise
54 |
55 | def inject_version_to_frontend():
56 | """
57 | 将版本号注入到前端全局变量
58 | """
59 | js_code = f"""
60 | window.PromptAssistant_Version = "{VERSION}";
61 | """
62 |
63 | js_dir = os.path.join(os.path.dirname(__file__), "js")
64 | if not os.path.exists(js_dir):
65 | os.makedirs(js_dir)
66 |
67 | version_file = os.path.join(js_dir, "version.js")
68 | with open(version_file, "w", encoding='utf-8') as f:
69 | f.write(js_code)
70 |
71 | # 初始化版本号
72 | VERSION = get_version()
73 |
74 | # 执行初始化操作
75 | inject_version_to_frontend()
76 |
77 | # 打印初始化信息
78 | print(f"✨提示词小助手 V{VERSION} {GREEN}已启动{RESET}")
79 |
80 |
81 |
82 |
--------------------------------------------------------------------------------
/config/kontext_presets.json:
--------------------------------------------------------------------------------
1 | {
2 | "kontext_prefix": "You are a creative prompt engineer. Your mission is to analyze the provided image and generate exactly 1 distinct image transformation *instructions*.",
3 | "kontext_suffix": "Your response must consist of concise instruction ready for the image editing AI. Do not add any conversational text, explanations, or deviations; only the instructions.",
4 | "kontext_presets": {
5 | "kontext_情境深度融合": {
6 | "name": "情境深度融合",
7 | "role": "system",
8 | "content": "The provided image is a composite with a head and body from drastically different contexts (lighting, style, condition). Your mission is to generate instructions for a complete narrative and physical transformation of the head to flawlessly match the body and scene. The instructions must guide the AI to: 1. **Cinematic Re-Lighting**: Describe in vivid detail the scene's light sources (color, direction, hardness) and how this light should sculpt the head's features with new, appropriate shadows and highlights. 2. **Contextual Storytelling**: Instruct to add physical evidence of the scene's story onto the head, such as grime from a battle, sweat from exertion, or rain droplets from a storm. 3. **Color Grading Unification**: Detail how to apply the scene's specific color grade (e.g., cool desaturated tones, warm golden hour hues) to the head. 4. **Asset & Hair Adaptation**: Command the modification or removal of out-of-place elements (like clean jewelry in a gritty scene) and the restyling of the hair to fit the environment (e.g., messy, windblown, wet). 5. **Flawless Final Integration**: As the final step, describe the process of blending the neckline to be completely invisible, ensuring a uniform film grain and texture across the entire person."
9 | },
10 | "kontext_无痕融合": {
11 | "name": "无痕融合",
12 | "role": "system",
13 | "content": "This image is a composite with minor inconsistencies between the head and body. Your task is to generate instructions for a subtle but master-level integration. Focus on creating a photorealistic and utterly convincing final image. The instructions should detail: 1. **Micro-Lighting Adjustment**: Fine-tune the lighting and shadows around the neck and jawline to create a perfect match. 2. **Skin Tone & Texture Unification**: Describe the process of unifying the skin tones for a seamless look, and more importantly, harmonizing the micro-textures like pores, fine hairs, and film grain across the blended area. 3. **Edge Blending Perfection**: Detail how to create an invisible transition at the neckline, making it appear as if it was never separate."
14 | },
15 | "kontext_场景传送": {
16 | "name": "场景传送",
17 | "role": "system",
18 | "content": "Imagine the main subject of the image is suddenly teleported to a completely different and unexpected environment, while maintaining their exact pose. Your instruction should describe this new, richly detailed scene. For example, a person in a business suit is now standing in the middle of an enchanted, glowing forest, or a beachgoer is now on a futuristic spaceship bridge. The instruction must detail how the new environment's lighting and atmosphere should realistically affect the subject."
19 | },
20 | "kontext_移动镜头": {
21 | "name": "移动镜头",
22 | "role": "system",
23 | "content": "Propose a dramatic and purposeful camera movement that reveals a new perspective or emotion in the scene. Instead of a simple change, describe the *type* of shot and its *narrative purpose*. For example, 'Change to a dramatic low-angle shot from the ground, making the subject appear heroic and monumental against the sky,' or 'Switch to a dizzying Dutch angle shot, tilting the horizon to create a sense of unease and disorientation'."
24 | },
25 | "kontext_重新布光": {
26 | "name": "重新布光",
27 | "role": "system",
28 | "content": "Completely transform the mood and story of the image by proposing a new, cinematic lighting scheme. Describe a specific lighting style linked to a genre or mood. For example: 'Relight the scene with Film Noir aesthetics: hard, single-source key light creating deep shadows and high contrast,' or 'Relight with the warmth of a magical Golden Hour: soft, golden light from a low angle, wrapping around the subject and creating long, gentle shadows.'"
29 | },
30 | "kontext_专业产品图": {
31 | "name": "专业产品图",
32 | "role": "system",
33 | "content": "Re-imagine this image as a high-end commercial product photograph for a luxury catalog. Describe a clean, professional setting. The instruction should specify: 1. **Studio Lighting**: Detail a sophisticated lighting setup (e.g., three-point lighting with a softbox key light, a rim light for separation, and a fill light to soften shadows). 2. **Composition**: Describe a clean, minimalist composition on a seamless background or in a luxury lifestyle setting. 3. **Product Focus**: Emphasize capturing crisp details, perfect textures, and an aspirational mood."
34 | },
35 | "kontext_画面缩放": {
36 | "name": "画面缩放",
37 | "role": "system",
38 | "content": "Describe a specific zoom action that serves a narrative purpose. Propose either: 1. **A dramatic 'push-in' (zoom in)**: 'Slowly push in on the subject's eyes to reveal a subtle, hidden emotion.' or 2. **A revealing 'pull-out' (zoom out)**: 'Pull the camera back to reveal a surprising or vast new element in the environment that re-contextualizes the subject's situation.'"
39 | },
40 | "kontext_图像上色": {
41 | "name": "图像上色",
42 | "role": "system",
43 | "content": "Describe a specific artistic style for colorizing a black and white image. Go beyond simple colorization. For example: 'Colorize this image with the vibrant, high-contrast, and slightly surreal palette of the Technicolor films from the 1950s,' or 'Apply a muted, melancholic color palette with desaturated blues and earthy tones, reminiscent of a modern independent film.'"
44 | },
45 | "kontext_电影海报": {
46 | "name": "电影海报",
47 | "role": "system",
48 | "content": "Transform the image into a compelling movie poster for a specific, imagined film genre. Describe the full poster concept: 1. **Genre & Title**: Invent a movie title and genre (e.g., Sci-Fi Thriller: 'ECHOES OF TOMORROW'). 2. **Visual Style**: Describe how to treat the image (e.g., 'apply a gritty, high-contrast filter'). 3. **Typography & Text**: Instruct to add a stylized title, a dramatic tagline (e.g., 'The future is listening.'), and other elements like actor names and release date."
49 | },
50 | "kontext_卡通漫画化": {
51 | "name": "卡通漫画化",
52 | "role": "system",
53 | "content": "Redraw the entire image in a specific, iconic animated or illustrated style. Be descriptive about the chosen style. For example: 'Convert the image into the style of a 1990s Japanese anime cel, characterized by sharp character outlines, expressive eyes, and hand-painted backgrounds,' or 'Re-imagine the scene in the visual language of a modern Pixar film, with soft, appealing shapes, detailed textures, and warm, bounce lighting.'"
54 | },
55 | "kontext_移除文字": {
56 | "name": "移除文字",
57 | "role": "system",
58 | "content": "Describe the task of removing all text from the image as a meticulous restoration project. 'Carefully and seamlessly remove all text, logos, and lettering from the image. The goal is to reconstruct the underlying surfaces and textures so perfectly that there is no hint that text ever existed there.'"
59 | },
60 | "kontext_更换发型": {
61 | "name": "更换发型",
62 | "role": "system",
63 | "content": "Describe a complete hair transformation for the subject that tells a story or embodies a new persona. Be specific about the style, color, and texture. For example: 'Give the subject a bold, punk-inspired pixie cut with a vibrant magenta color, featuring a choppy, textured finish,' or 'Transform her hair into long, elegant, Pre-Raphaelite waves with a deep auburn hue, appearing soft and voluminous.'"
64 | },
65 | "kontext_肌肉猛男化": {
66 | "name": "肌肉猛男化",
67 | "role": "system",
68 | "content": "Dramatically transform the subject into a hyper-realistic, massively muscled bodybuilder, as if they are a character from a fantasy action movie. Describe the transformation in detail: 'Exaggerate and define every muscle group—biceps, triceps, pectorals, and abdominals—to heroic proportions. The skin should be taut over the muscles, with visible veins. Modify the clothing, perhaps tearing it, to accommodate and reveal the new, powerful physique.'"
69 | },
70 | "kontext_清空家具": {
71 | "name": "清空家具",
72 | "role": "system",
73 | "content": "Imagine the room in the image has been completely emptied for a renovation. Instruct the AI to meticulously remove all furniture, appliances, decorations, carpets, and even light fixtures from the ceiling and walls. The instruction should state that the goal is to reveal the room's 'bare bones'—the empty floor, walls, and ceiling, paying close attention to realistically recreating the surfaces that were previously hidden."
74 | },
75 | "kontext_室内设计": {
76 | "name": "室内设计",
77 | "role": "system",
78 | "content": "You are a world-class interior designer tasked with redesigning this space in a specific, evocative style. While keeping the room's core structure (walls, windows, doors) intact, describe a complete redesign concept. For example: 'Redesign this room with a 'Japandi' (Japanese-Scandinavian) aesthetic: light wood furniture with clean lines, a neutral color palette of beige and gray, minimalist decor, and soft, diffused natural lighting.'"
79 | },
80 | "kontext_季节变换": {
81 | "name": "季节变换",
82 | "role": "system",
83 | "content": "Transform the entire scene to be convincingly set in a different season, focusing on atmospheric and sensory details. Propose a season and describe its effects. For example: 'Plunge the scene into a deep, quiet Autumn. Change all foliage to rich tones of crimson and gold. Litter the ground with fallen leaves. The air should feel crisp, and the light should have a low, golden quality. Adjust the subject's clothing to include a cozy sweater or light jacket.'"
84 | },
85 | "kontext_时光旅人": {
86 | "name": "时光旅人",
87 | "role": "system",
88 | "content": "Visibly and realistically age or de-age the main subject, as if we are seeing them at a different stage of their life. For an older version, describe 'adding fine lines around the eyes and mouth, silver strands woven into the hair, and the subtle wisdom in their expression.' For a younger version, describe 'smoothing the skin to a youthful glow, restoring the hair's original vibrant color, and capturing a sense of bright-eyed optimism'."
89 | },
90 | "kontext_材质置换": {
91 | "name": "材质置换",
92 | "role": "system",
93 | "content": "Re-imagine the main subject as a masterfully crafted sculpture made from an unexpected material, keeping their pose intact. Describe the new material's properties in detail. For example: 'Transform the subject into a statue carved from a single piece of dark, polished obsidian. Describe its glossy, reflective surface, how it catches the light, and the subtle, natural imperfections within the stone.'"
94 | },
95 | "kontext_微缩世界": {
96 | "name": "微缩世界",
97 | "role": "system",
98 | "content": "Convert the entire scene into a charming and highly detailed miniature model world, as if viewed through a tilt-shift lens. Describe the visual effects needed: 'Apply a very shallow depth of field to blur the top and bottom of the image, making the scene look tiny. Sharpen the details and boost the color saturation to enhance the artificial, toy-like appearance of all subjects and objects.'"
99 | },
100 | "kontext_幻想领域": {
101 | "name": "幻想领域",
102 | "role": "system",
103 | "content": "Transport the entire scene and its subject into a specific, richly detailed fantasy or sci-fi universe. Describe the complete aesthetic overhaul. For example: 'Re-imagine the scene in a Steampunk universe. All modern technology is replaced with intricate brass and copper machinery, full of gears and steam pipes. The subject's clothing is transformed into Victorian-era attire with leather and brass accessories.'"
104 | },
105 | "kontext_衣橱改造": {
106 | "name": "衣橱改造",
107 | "role": "system",
108 | "content": "Give the subject a complete fashion makeover into a specific, well-defined style, keeping the background and pose the same. Describe the entire outfit in detail. For example: 'Dress the subject in a 'Dark Academia' aesthetic: a tweed blazer, a dark turtleneck sweater, tailored trousers, and leather brogues. Add an accessory like a vintage leather satchel or a pair of classic spectacles.'"
109 | },
110 | "kontext_艺术风格模仿": {
111 | "name": "艺术风格模仿",
112 | "role": "system",
113 | "content": "Repaint the entire image in the unmistakable style of a famous art movement. Be descriptive and evocative. For example: 'Transform the image into a Post-Impressionist painting in the style of Van Gogh, using thick, swirling brushstrokes (impasto), vibrant, emotional colors, and a dynamic sense of energy and movement in every element.'"
114 | },
115 | "kontext_蓝图视角": {
116 | "name": "蓝图视角",
117 | "role": "system",
118 | "content": "Convert the image into a detailed and aesthetically pleasing technical blueprint. Describe the visual transformation: 'Change the background to a classic cyanotype-blue. Redraw the main subject and key objects using clean, white, schematic outlines. Overlay the image with fictional measurement lines, annotations, and technical callouts to complete the architectural drawing effect.'"
119 | },
120 | "kontext_添加倒影": {
121 | "name": "添加倒影",
122 | "role": "system",
123 | "content": "Introduce a new, reflective surface into the scene to create a more dynamic and compelling composition. Describe the placement and quality of the reflection. For example: 'After a rain shower, the street is now covered in a thin layer of water, creating a stunning, mirror-like reflection of the subject and the moody, illuminated sky above. The reflection should be slightly distorted by ripples in the water.'"
124 | },
125 | "kontext_像素艺术": {
126 | "name": "像素艺术",
127 | "role": "system",
128 | "content": "Deconstruct the image into the charming, retro aesthetic of 16-bit era pixel art. Describe the technical and artistic constraints: 'Reduce the entire image to a limited color palette of 64 colors. Redraw all shapes and subjects with sharp, aliased pixel edges. Use dithering patterns to create gradients and shading, capturing the authentic look and feel of a classic Super Nintendo game.'"
129 | },
130 | "kontext_铅笔手绘": {
131 | "name": "铅笔手绘",
132 | "role": "system",
133 | "content": "You are a master of pencil sketching. Write prompt words to transform the picture into a pencil sketch style based on the image content. The description needs to be precise and detailed, and avoid describing content unrelated to the task."
134 | },
135 | "kontext_油画风格": {
136 | "name": "油画风格",
137 | "role": "system",
138 | "content": "Transform the provided image into an oil painting style. Describe the brushwork, color palette, and overall mood to achieve an authentic oil - painting aesthetic. For example, use thick, impasto brushstrokes to add texture, choose a rich and vibrant color palette typical of oil paintings, and create a moody or vivid atmosphere according to the scene."
139 | }
140 | }
141 | }
--------------------------------------------------------------------------------
/js/assets/icon-caption-en.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/js/assets/icon-caption-zh.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/js/assets/icon-expand.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/js/assets/icon-history.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/js/assets/icon-main.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/js/assets/icon-redo.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/js/assets/icon-remove.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/js/assets/icon-resize-handle.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/js/assets/icon-tag.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/js/assets/icon-translate.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/js/assets/icon-undo.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/js/css/assistant.css:
--------------------------------------------------------------------------------
1 | /**
2 | * 提示词小助手 (PromptAssistant) 样式表
3 | * 包含所有组件样式和动画定义
4 | */
5 |
6 | /* ======== 提示词小助手样式 ======== */
7 |
8 | /* 提示词小助手容器 */
9 | .prompt-assistant-container {
10 | position: absolute;
11 | pointer-events: all;
12 | display: none;
13 | align-items: center;
14 | justify-content: flex-end;
15 | /* 始终右对齐 */
16 | padding: 2px;
17 | background-color: color-mix(in srgb, var(--bg-color), transparent 20%);
18 | border: 1px solid color-mix(in srgb, var(--p-panel-border-color), transparent 60%);
19 | border-radius: 6px;
20 | z-index: 9999;
21 | opacity: 0.95;
22 | backdrop-filter: blur(4px);
23 | transform-origin: bottom right;
24 | user-select: none;
25 | will-change: width, opacity, background-color, border-color, backdrop-filter, box-shadow, right, left;
26 | width: auto;
27 | height: 26px !important;
28 | max-width: fit-content;
29 | box-sizing: border-box !important;
30 | overflow: hidden;
31 | /* 防止内容溢出 */
32 | box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
33 | }
34 |
35 | /* 折叠状态的小助手容器 */
36 | .prompt-assistant-container.collapsed {
37 | width: 28px !important;
38 | overflow: hidden;
39 | justify-content: flex-end;
40 | pointer-events: none !important;
41 | /* 折叠时穿透鼠标事件 */
42 | background-color: transparent !important;
43 | border-color: transparent !important;
44 | backdrop-filter: none !important;
45 | box-shadow: none !important;
46 | }
47 |
48 | /* 折叠状态指示器图标 - 基础样式 */
49 | .prompt-assistant-indicator {
50 | position: absolute;
51 | top: 50%;
52 | right: 5px;
53 | transform: translateY(-50%) scale(0);
54 | width: 18px;
55 | height: 18px;
56 | opacity: 0;
57 | z-index: 2;
58 | pointer-events: none;
59 | }
60 |
61 | /* 指示器图标显示动画 */
62 | .prompt-assistant-container.collapsed .prompt-assistant-indicator {
63 | opacity: var(--assistant-icon-opacity, 0.3);
64 | transform: translateY(-50%) scale(0.8);
65 | filter: drop-shadow(0 0 2px rgba(255, 255, 255, 0.6)) drop-shadow(0 0 4px rgba(100, 200, 255, 0.5));
66 | transition: opacity 0.3s cubic-bezier(.49, 1.98, .67, .83) 0.25s,
67 | transform 0.4s cubic-bezier(.78, 1.76, .68, .96) 0.25s;
68 | }
69 |
70 | /* 指示器图标隐藏动画 */
71 | .prompt-assistant-container:not(.collapsed) .prompt-assistant-indicator {
72 | opacity: 0;
73 | transform: translateY(-50%) scale(0);
74 | transition: opacity 0s ease-in,
75 | transform 0.3s ease-in;
76 | }
77 |
78 | /* 内部内容容器 - 使用common.css中定义的固定内容样式 */
79 | .prompt-assistant-inner,
80 | .image-assistant-inner {
81 | /* 使用common.css中定义的固定内容样式 */
82 | display: flex;
83 | gap: 2px;
84 | position: relative;
85 | right: 0;
86 | justify-content: flex-end;
87 | width: auto;
88 | flex-grow: 1;
89 | transition: none;
90 | }
91 |
92 | /* 折叠状态下的内部容器 */
93 | .prompt-assistant-container.collapsed .prompt-assistant-inner {
94 | pointer-events: none !important;
95 | }
96 |
97 | /* 悬停区域 - 用于检测鼠标悬停 */
98 | .prompt-assistant-hover-area {
99 | position: absolute;
100 | top: 2px;
101 | right: 2px;
102 | width: 80%;
103 | height: 90%;
104 | z-index: 1;
105 | /* 降低z-index,确保在按钮下方 */
106 | cursor: pointer;
107 | pointer-events: auto;
108 | /* 初始状态隐藏 */
109 | display: none;
110 | background-color: transparent;
111 | }
112 |
113 | /* 折叠状态下的悬停区域 */
114 | .prompt-assistant-container.collapsed .prompt-assistant-hover-area {
115 | display: block;
116 | }
117 |
118 | /* ======== 图像小助手样式 ======== */
119 |
120 | /* 图像小助手容器 */
121 | .image-assistant-container {
122 | position: fixed;
123 | pointer-events: all;
124 | display: none;
125 | align-items: center;
126 | justify-content: flex-start;
127 | /* 改为左对齐 */
128 | padding: 2px;
129 | background-color: color-mix(in srgb, var(--bg-color), transparent 20%);
130 | border: 1px solid color-mix(in srgb, var(--p-panel-border-color), transparent 40%);
131 | border-radius: 8px;
132 | z-index: 9999;
133 | opacity: 0.95;
134 | backdrop-filter: blur(4px);
135 | transform-origin: bottom left;
136 | /* 改为左下角 */
137 | user-select: none;
138 | will-change: width, opacity, background-color, border-color, backdrop-filter, box-shadow;
139 | width: auto;
140 | height: 24px !important;
141 | max-width: fit-content;
142 | box-sizing: border-box !important;
143 | overflow: hidden;
144 | transform: scale(var(--assistant-scale, 1));
145 | box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
146 | }
147 |
148 | /* 折叠状态的图像小助手容器 */
149 | .image-assistant-container.collapsed {
150 | width: 28px !important;
151 | overflow: hidden;
152 | justify-content: flex-start;
153 | /* 改为左对齐 */
154 | background-color: transparent;
155 | border-color: transparent;
156 | backdrop-filter: blur(0px);
157 | box-shadow: none;
158 | }
159 |
160 | /* 折叠状态指示器图标 - 基础样式 */
161 | .image-assistant-indicator {
162 | position: absolute;
163 | top: 50%;
164 | left: 5px;
165 | /* 改为左侧 */
166 | transform: translateY(-50%) scale(0);
167 | width: 18px;
168 | height: 18px;
169 | opacity: 0;
170 | z-index: 2;
171 | pointer-events: none;
172 | }
173 |
174 | /* 指示器图标显示动画 */
175 | .image-assistant-container.collapsed .image-assistant-indicator {
176 | opacity: var(--assistant-icon-opacity, 0.3);
177 | transform: translateY(-50%) scale(0.8);
178 | filter: drop-shadow(0 0 2px rgba(255, 255, 255, 0.6)) drop-shadow(0 0 4px rgba(100, 200, 255, 0.5));
179 | transition: opacity 0.3s cubic-bezier(.49, 1.98, .67, .83) 0.25s,
180 | transform 0.4s cubic-bezier(.78, 1.76, .68, .96) 0.25s;
181 | }
182 |
183 | /* 指示器图标创建时的动画 */
184 | .image-assistant-container.collapsed .image-assistant-indicator.animate-creation {
185 | animation: indicator-creation 0.6s cubic-bezier(.49, 1.98, .67, .83);
186 | }
187 |
188 | /* 指示器创建动画 */
189 | @keyframes indicator-creation {
190 | 0% {
191 | opacity: 0;
192 | transform: translateY(-50%) scale(0);
193 | }
194 |
195 | 60% {
196 | opacity: 0.5;
197 | transform: translateY(-50%) scale(1.0);
198 | }
199 |
200 | 100% {
201 | opacity: 0.3;
202 | transform: translateY(-50%) scale(0.8);
203 | }
204 | }
205 |
206 | /* 指示器图标隐藏动画 */
207 | .image-assistant-container:not(.collapsed) .image-assistant-indicator {
208 | opacity: 0;
209 | transform: translateY(-50%) scale(0);
210 | transition: opacity 0s ease-in,
211 | transform 0.3s ease-in;
212 | }
213 |
214 | /* 悬停区域 - 用于检测鼠标悬停 */
215 | .image-assistant-hover-area {
216 | position: absolute;
217 | top: 0;
218 | left: 0;
219 | /* 改为左侧 */
220 | width: 28px;
221 | height: 100%;
222 | z-index: 1;
223 | cursor: pointer;
224 | pointer-events: auto;
225 | display: none;
226 | background-color: transparent;
227 | }
228 |
229 | /* 折叠状态下的悬停区域 */
230 | .image-assistant-container.collapsed .image-assistant-hover-area {
231 | display: block;
232 | }
233 |
234 | /* 折叠状态下按钮的透明度过渡 */
235 | .image-assistant-container.collapsed .image-assistant-button {
236 | transition: opacity 0.3s cubic-bezier(0.11, 0, 0.5, 0);
237 | opacity: 0;
238 | }
239 |
240 | /* 图像小助手过渡效果 */
241 | .image-assistant-transition {
242 | transition: width 0.3s cubic-bezier(0.25, 1, 0.5, 1),
243 | opacity 0.3s cubic-bezier(0.25, 1, 0.5, 1),
244 | border-color 0.5s ease-out,
245 | backdrop-filter 0.5s ease-out,
246 | box-shadow 0.2s ease-out,
247 | background-color 0.5s ease-out;
248 | }
249 |
250 | /* 图像小助手显示动画类 */
251 | .image-assistant-show {
252 | display: flex !important;
253 | box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
254 | }
255 |
256 | /* ======== 共用组件样式 ======== */
257 |
258 | /* 自动翻译指示器 - 黄色图标样式 */
259 | .prompt-assistant-container.auto-translate-enabled .prompt-assistant-indicator {
260 | color: rgb(246, 252, 163);
261 | filter: drop-shadow(0 0 2px rgba(247, 187, 6, 0.755)) drop-shadow(0 0 4px rgba(255, 255, 0, 0.7));
262 | }
263 |
264 | .prompt-assistant-container.auto-translate-enabled.collapsed .prompt-assistant-indicator {
265 | opacity: var(--assistant-icon-opacity, 0.5);
266 | }
267 |
268 | /* 按钮分割线样式 */
269 | .prompt-assistant-divider,
270 | .image-assistant-divider {
271 | width: 1px;
272 | height: 10px;
273 | background-color: var(--p-content-border-color);
274 | margin: 0 2px;
275 | align-self: center;
276 | flex-shrink: 0;
277 | transform: translateZ(0);
278 | border-radius: 0.5px;
279 | }
280 |
281 | /* 过渡效果类 */
282 | .prompt-assistant-transition {
283 | transition: width 0.3s cubic-bezier(0.25, 1, 0.5, 1),
284 | opacity 0.3s cubic-bezier(0.25, 1, 0.5, 1),
285 | border-color 0.5s ease-out,
286 | backdrop-filter 0.5s ease-out,
287 | box-shadow 0.2s ease-out,
288 | background-color 0.5s ease-out,
289 | right 0.2s ease-out,
290 | left 0.2s ease-out;
291 | }
292 |
293 | /* 显示动画类 */
294 | .assistant-show {
295 | display: flex !important;
296 | animation: clipFadeInDown 0.25s cubic-bezier(0.25, 0.46, 0.45, 0.94) forwards;
297 | box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
298 | }
299 |
300 | /* 状态提示的入场和退场动画类 */
301 | .statustip-show {
302 | animation: tipScaleIn 0.1s ease-out forwards !important;
303 | }
304 |
305 | .statustip-hide {
306 | animation: tipFloatUp 0.4s ease-out forwards !important;
307 | }
308 |
309 | /* 按钮样式 */
310 | .prompt-assistant-button,
311 | .image-assistant-button {
312 | display: inline-flex;
313 | align-items: center;
314 | justify-content: center;
315 | width: 18px;
316 | height: 18px;
317 | cursor: pointer;
318 | opacity: 0.8;
319 | border: none;
320 | padding: 0;
321 | margin: 0 1px;
322 | background-color: transparent;
323 | transition: transform 0.15s, opacity 0.2s, background-color 0.2s;
324 | flex-shrink: 0;
325 | border-radius: 3px;
326 | color: var(--p-dialog-color);
327 | position: relative;
328 | /* 确保按钮相对定位 */
329 | }
330 |
331 | /* 展开状态下恢复按钮的透明度 */
332 | .prompt-assistant-container:not(.collapsed) .prompt-assistant-button {
333 | opacity: 0.8;
334 | }
335 |
336 | /* 折叠状态下隐藏并禁用按钮 */
337 | .prompt-assistant-container.collapsed .prompt-assistant-button,
338 | .prompt-assistant-container.collapsed .prompt-assistant-button.button-active,
339 | .prompt-assistant-container.collapsed .prompt-assistant-button.button-processing {
340 | opacity: 0 !important;
341 | pointer-events: none !important;
342 | transition: opacity 0.4s ease-out, pointer-events 0s 0.4s;
343 | }
344 |
345 | /* SVG图标样式 */
346 | .svg-icon {
347 | display: flex;
348 | align-items: center;
349 | justify-content: center;
350 | width: 18px;
351 | height: 18px;
352 | position: relative;
353 | z-index: 1;
354 | }
355 |
356 | .svg-icon svg {
357 | width: 100%;
358 | height: 100%;
359 | fill: currentColor;
360 | color: inherit;
361 | transition: all 0.2s;
362 | vertical-align: middle;
363 | }
364 |
365 | /* 按钮内部图标样式 */
366 | .prompt-assistant-button .svg-icon,
367 | .image-assistant-button .svg-icon {
368 | width: 18px;
369 | height: 18px;
370 | color: inherit;
371 | transition: all 0.2s;
372 | position: relative;
373 | z-index: 1;
374 | vertical-align: middle;
375 | margin: 0;
376 | }
377 |
378 | /* 按钮交互效果 */
379 | .prompt-assistant-button:hover,
380 | .image-assistant-button:hover {
381 | opacity: 1;
382 | transform: scale(1.15);
383 | background-color: rgba(255, 255, 255, 0.1);
384 | box-shadow: 0 0 5px rgba(255, 255, 255, 0.2);
385 | }
386 |
387 | .prompt-assistant-button:active,
388 | .image-assistant-button:active {
389 | transform: scale(0.95);
390 | }
391 |
392 | /* 按钮状态类 */
393 | .button-processing {
394 | animation: buttonBreathing 1.5s infinite ease-in-out;
395 | background-color: rgba(100, 100, 255, 0.15) !important;
396 | position: relative;
397 | z-index: 1;
398 | opacity: 1 !important;
399 | }
400 |
401 | .button-active {
402 | opacity: 1;
403 | transform: scale(1.15);
404 | background-color: rgba(255, 255, 255, 0.1);
405 | box-shadow: 0 0 5px rgba(255, 255, 255, 0.2);
406 | color: var(--p-listbox-option-selected-color);
407 | }
408 |
409 | .button-disabled {
410 | opacity: 0.4 !important;
411 | filter: grayscale(60%);
412 | cursor: not-allowed !important;
413 | pointer-events: none;
414 | transform: none !important;
415 | color: var(--p-text-muted-color);
416 | }
417 |
418 | .button-disabled:hover {
419 | transform: none !important;
420 | background-color: transparent !important;
421 | box-shadow: none !important;
422 | }
423 |
424 | .button-disabled .svg-icon svg {
425 | filter: brightness(0.9) grayscale(100%);
426 | }
427 |
428 | /* 状态提示样式 */
429 | .statustip {
430 | transform-origin: center bottom;
431 | font-weight: bold;
432 | letter-spacing: 0.5px;
433 | position: absolute;
434 | padding: 4px 8px;
435 | border-radius: 8px;
436 | font-size: 14px;
437 | z-index: 10000;
438 | text-align: center;
439 | display: flex;
440 | align-items: center;
441 | justify-content: center;
442 | pointer-events: none;
443 | backdrop-filter: blur(8px);
444 | white-space: nowrap;
445 | will-change: transform, opacity;
446 | }
447 |
448 | /* 状态提示颜色 */
449 | .statustip.success {
450 | color: color-mix(in srgb, var(--p-inputtext-color), rgb(18, 255, 18) 40%) !important;
451 | background-color: color-mix(in srgb, var(--p-content-background), rgb(4, 58, 4)30%);
452 | }
453 |
454 | .statustip.error {
455 | color: color-mix(in srgb, var(--p-inputtext-color), rgb(255, 0, 0) 60%) !important;
456 | background-color: color-mix(in srgb, var(--p-content-background), rgb(74, 12, 12)30%);
457 | }
458 |
459 | .statustip.loading {
460 | color: color-mix(in srgb, var(--p-inputtext-color), rgb(249, 255, 69) 30%) !important;
461 | background-color: color-mix(in srgb, var(--p-content-background), rgb(114, 117, 22)30%);
462 | }
463 |
464 | .statustip.info {
465 | color: color-mix(in srgb, var(--p-inputtext-color), rgb(82, 70, 255) 60%) !important;
466 | background-color: color-mix(in srgb, var(--p-content-background), rgb(55, 8, 113)30%);
467 | }
468 |
469 | .prompt_assistant_container .icon_button:hover {
470 | background-color: color-mix(in srgb, var(--p-content-background), rgb(55, 8, 113)30%);
471 | }
--------------------------------------------------------------------------------
/js/css/common.css:
--------------------------------------------------------------------------------
1 | /**
2 | * 通用样式及动画库
3 | * 提供全局共享的动画效果和基础样式
4 | */
5 |
6 | /* ======== 动画定义 ======== */
7 |
8 | /* 输入框高亮动画 */
9 | @keyframes inputHighlight {
10 | 0% {
11 | background-color: transparent;
12 | }
13 |
14 | 20% {
15 | background-color: rgba(93, 243, 146, 0.07);
16 | }
17 |
18 | 100% {
19 | background-color: transparent;
20 | }
21 | }
22 |
23 | .input-highlight {
24 | animation: inputHighlight 0.2s ease-out;
25 | }
26 |
27 |
28 |
29 | /* 小助手折叠和展开过渡效果 */
30 | .prompt-assistant-transition {
31 | transition: width 0.3s cubic-bezier(0.25, 1, 0.5, 1),
32 | opacity 0.3s cubic-bezier(0.25, 1, 0.5, 1),
33 | border-color 0.2s ease-out,
34 | backdrop-filter 0.2s ease-out,
35 | box-shadow 0.2s ease-out,
36 | background-color 0.5s ease-out;
37 | }
38 |
39 | /* 确保内容不随容器宽度变化而移动的样式 */
40 | .prompt-assistant-content-fixed {
41 | display: flex;
42 | gap: 2px;
43 | position: relative;
44 | right: 0;
45 | justify-content: flex-end;
46 | width: auto;
47 | flex-grow: 1;
48 | transition: none;
49 | }
50 |
51 | /* 折叠状态下按钮的透明度过渡 */
52 | .prompt-assistant-container.collapsed .prompt-assistant-button {
53 | transition: opacity 0.3s cubic-bezier(0.11, 0, 0.5, 0);
54 | opacity: 0;
55 | }
56 |
57 | /* 折叠状态下容器的透明效果 */
58 | .prompt-assistant-container.collapsed {
59 | background-color: transparent;
60 | border-color: transparent;
61 | backdrop-filter: blur(0px);
62 | box-shadow: none;
63 | }
64 |
65 | /* 向上淡入动画 */
66 | @keyframes clipFadeInUp {
67 | from {
68 | opacity: 0;
69 | transform: translateY(-10px);
70 | }
71 |
72 | to {
73 | opacity: 1;
74 | transform: translateY(0);
75 | }
76 | }
77 |
78 | /* 向下淡入动画 */
79 | @keyframes clipFadeInDown {
80 | from {
81 | opacity: 0;
82 | transform: translateY(10px) scale(1);
83 | }
84 |
85 | to {
86 | opacity: 1;
87 | transform: translateY(0) scale(1);
88 | }
89 | }
90 |
91 | /* 向上淡出动画 */
92 | @keyframes clipFadeOutUp {
93 | from {
94 | opacity: 1;
95 | transform: translateY(0) scale(1);
96 | }
97 |
98 | to {
99 | opacity: 0;
100 | transform: translateY(10px) scale(1);
101 | }
102 | }
103 |
104 | /* 向下淡出动画 */
105 | @keyframes clipFadeOutDown {
106 | from {
107 | opacity: 1;
108 | transform: translateY(0) scale(1);
109 | }
110 |
111 | to {
112 | opacity: 0;
113 | transform: translateY(-10px) scale(1);
114 | }
115 | }
116 |
117 | /* 淡入动画 */
118 | @keyframes clipFadeIn {
119 | from {
120 | opacity: 0;
121 | }
122 |
123 | to {
124 | opacity: 1;
125 | }
126 | }
127 |
128 | /* 按钮呼吸缩放动画 */
129 | @keyframes buttonBreathing {
130 | 0% {
131 | transform: scale(1);
132 | box-shadow: 0 0 0 rgba(100, 100, 255, 0);
133 | }
134 |
135 | 50% {
136 | transform: scale(1.15);
137 | box-shadow: 0 0 8px rgba(100, 100, 255, 0.6);
138 | }
139 |
140 | 100% {
141 | transform: scale(1);
142 | box-shadow: 0 0 0 rgba(100, 100, 255, 0);
143 | }
144 | }
145 |
146 |
147 |
148 | /* 状态提示浮动效果 - 向上淡出 */
149 | @keyframes tipFloatUp {
150 | 0% {
151 | opacity: 1;
152 | transform: translate(-50%, -100%) translateY(-8px);
153 | }
154 |
155 | 20% {
156 | opacity: 0.9;
157 | transform: translate(-50%, -100%) translateY(-15px);
158 | }
159 |
160 | 100% {
161 | opacity: 0;
162 | transform: translate(-50%, -100%) translateY(-40px);
163 | }
164 | }
165 |
166 | /* 状态提示缩放出现动画 */
167 | @keyframes tipScaleIn {
168 | 0% {
169 | opacity: 0;
170 | transform: translate(-50%, -100%) translateY(-8px) scale(0.8);
171 | }
172 |
173 | 70% {
174 | opacity: 1;
175 | transform: translate(-50%, -100%) translateY(-8px) scale(1.05);
176 | }
177 |
178 | 100% {
179 | opacity: 1;
180 | transform: translate(-50%, -100%) translateY(-8px) scale(1);
181 | }
182 | }
183 |
184 | /* 标签内容渐显动画 */
185 | @keyframes tagContentFadeIn {
186 | from {
187 | opacity: 0;
188 | transform: translateY(-5px);
189 | }
190 |
191 | to {
192 | opacity: 1;
193 | transform: translateY(0);
194 | }
195 | }
196 |
197 | /* 标签内容包装器样式 */
198 | .tag_content_wrapper {
199 | width: 100%;
200 | height: 100%;
201 | opacity: 0;
202 | transition: opacity 0.2s ease-in-out, transform 0.2s ease-out;
203 | transform: translateY(-5px);
204 | }
205 |
206 | .tag_content_wrapper.visible {
207 | opacity: 1;
208 | transform: translateY(0);
209 | }
210 |
211 | /* ======== 图像反推复制对话框样式 ======== */
212 | .image-assistant-copy-dialog {
213 | position: fixed;
214 | top: 50%;
215 | left: 50%;
216 | transform: translate(-50%, -50%);
217 | background-color: var(--p-dialog-background);
218 | color: var(--p-text-muted-color);
219 | border-radius: 8px;
220 | box-shadow: 0 4px 20px rgba(0, 0, 0, 0.6);
221 | z-index: 9999;
222 | width: 400px;
223 | height: 300px;
224 | padding: 20px;
225 | font-family: var(--litegraph-font, Arial, sans-serif);
226 | position: relative;
227 | }
228 |
229 | /* 关闭按钮 */
230 | .image-assistant-copy-dialog-close {
231 | position: absolute;
232 | top: 12px;
233 | right: 12px;
234 | width: 24px;
235 | height: 24px;
236 | background: none;
237 | border: none;
238 | color: var(--p-button-text-secondary-color);
239 | cursor: pointer;
240 | display: flex;
241 | align-items: center;
242 | justify-content: center;
243 | border-radius: 12px;
244 | transition: all 0.2s ease;
245 | }
246 |
247 | .image-assistant-copy-dialog-close:hover {
248 | background-color: rgba(255, 255, 255, 0.1);
249 | color: var(--p-button-text-secondary-color);
250 | }
251 |
252 | .image-assistant-copy-dialog-close .svg-icon {
253 | width: 16px;
254 | height: 16px;
255 | }
256 |
257 | /* 标题文本 */
258 | .image-assistant-copy-dialog-title {
259 | font-size: 14px;
260 | font-weight: bold;
261 | line-height: 1.4;
262 | margin: 0 0 16px 0;
263 | padding-right: 30px;
264 | /* 为关闭按钮留出空间 */
265 | }
266 |
267 | /* 文本区域 */
268 | .image-assistant-copy-dialog-textarea {
269 | width: 100%;
270 | height: 160px;
271 | padding: 12px;
272 | border: 1px solid var(--p-content-border-color);
273 | border-radius: 6px;
274 | background-color: var(--comfy-input-bg);
275 | color: var(--input-text);
276 | font-family: var(--litegraph-font, Arial, sans-serif);
277 | font-size: 13px;
278 | line-height: 1.5;
279 | resize: none;
280 | outline: none;
281 | box-sizing: border-box;
282 | margin-bottom: 16px;
283 | }
284 |
285 | .image-assistant-copy-dialog-textarea:focus {
286 | border-color: var(--p-primary-hover-color);
287 | box-shadow: 0 0 0 2px rgba(74, 144, 226, 0.2);
288 | }
289 |
290 | /* 复制按钮 */
291 | .image-assistant-copy-dialog-copy-btn {
292 | display: block;
293 | margin: 0 auto;
294 | padding: 10px 24px;
295 | background-color: var(--p-primary-color);
296 | color: var(--p-button-primary-color);
297 | border: none;
298 | border-radius: 6px;
299 | cursor: pointer;
300 | font-size: 14px;
301 | font-weight: 500;
302 | transition: all 0.2s ease;
303 | min-width: 120px;
304 | }
305 |
306 | .image-assistant-copy-dialog-copy-btn:hover {
307 | background-color: var(--p-primary-hover-color);
308 | }
309 |
310 | .image-assistant-copy-dialog-copy-btn:active {
311 | background-color: var(--p-primary-hover-color);
312 | transform: translateY(0);
313 | box-shadow: 0 1px 4px rgba(74, 144, 226, 0.3);
314 | }
315 |
316 | /* 对话框入场动画 */
317 | @keyframes dialogFadeIn {
318 | from {
319 | opacity: 0;
320 | transform: translate(-50%, -50%) scale(0.9);
321 | }
322 |
323 | to {
324 | opacity: 1;
325 | transform: translate(-50%, -50%) scale(1);
326 | }
327 | }
328 |
329 | .image-assistant-copy-dialog {
330 | animation: dialogFadeIn 0.2s ease-out;
331 | }
--------------------------------------------------------------------------------
/js/index.js:
--------------------------------------------------------------------------------
1 | /**
2 | * 提示词小助手 (PromptAssistant) 主入口文件
3 | * 负责扩展初始化、节点检测和功能注入
4 | */
5 |
6 | import { app } from "../../../scripts/app.js";
7 | import { promptAssistant, PromptAssistant } from './modules/PromptAssistant.js';
8 | import { registerSettings } from './modules/settings.js';
9 | import { FEATURES as ASSISTANT_FEATURES, handleFeatureChange, setFeatureModuleDeps } from './services/features.js';
10 | import { EventManager } from './utils/eventManager.js';
11 | import { ResourceManager } from './utils/resourceManager.js';
12 | import { UIToolkit } from "./utils/UIToolkit.js";
13 | import { logger } from './utils/logger.js';
14 | import { HistoryCacheService, TagCacheService } from './services/cache.js';
15 | import { imageCaption, ImageCaption } from './modules/imageCaption.js';
16 |
17 | // import { ensureAutoTranslateInterceptorInstalled } from './services/interceptor.js'; // 导入自动翻译拦截器
18 |
19 | // ====================== 全局配置与状态 ======================
20 |
21 | // 设置全局对象供其他模块访问
22 | window.FEATURES = ASSISTANT_FEATURES;
23 |
24 | // 将实例添加到全局对象
25 | window.promptAssistant = promptAssistant;
26 | window.imageCaption = imageCaption;
27 |
28 | // 将实例添加到全局app对象
29 | app.promptAssistant = promptAssistant;
30 | app.imageCaption = imageCaption;
31 |
32 | // ====================== 扩展注册 ======================
33 |
34 | /**
35 | * 注册ComfyUI扩展
36 | */
37 | app.registerExtension({
38 | name: "Comfy.PromptAssistant",
39 |
40 | // ---扩展生命周期钩子---
41 | /**
42 | * 初始化扩展
43 | */
44 | async setup() {
45 | try {
46 | // 注册设置选项
47 | registerSettings();
48 |
49 | // 初始化自动翻译拦截器(独立于提示词小助手)
50 | // ensureAutoTranslateInterceptorInstalled();
51 |
52 | // 初始化提示词小助手(内部会处理版本号检查和总开关状态)
53 | await promptAssistant.initialize();
54 |
55 | // 初始化图像小助手(只初始化一次)
56 | if (!imageCaption.initialized) {
57 | await imageCaption.initialize();
58 | }
59 |
60 | // 清理旧引用
61 | if (app.canvas) {
62 | app.canvas.updateNodeAssistantsVisibility = null;
63 | app.canvas._onNodeSelectionChange = null;
64 | }
65 |
66 | // 将管理器添加到app对象,使其可以通过window.app访问
67 | app.EventManager = EventManager;
68 | app.ResourceManager = ResourceManager;
69 | app.UIToolkit = UIToolkit;
70 |
71 | // 先初始化 features.js 依赖
72 | setFeatureModuleDeps({
73 | promptAssistant,
74 | PromptAssistant,
75 | UIToolkit,
76 | HistoryCacheService,
77 | TagCacheService,
78 | imageCaption,
79 | ImageCaption
80 | });
81 |
82 | // 然后再自动注册服务功能
83 | if (window.FEATURES.enabled) {
84 | await promptAssistant.toggleGlobalFeature(true, true);
85 | // 避免重复初始化,只在必要时启用图像小助手功能
86 | if (window.FEATURES.imageCaption) {
87 | await imageCaption.toggleGlobalFeature(true, false);
88 | }
89 | }
90 |
91 | logger.log("扩展初始化完成");
92 | } catch (error) {
93 | logger.error(`扩展初始化失败: ${error.message}`);
94 | }
95 |
96 | // 仅保留工作流ID识别功能,不处理工作流切换事件
97 | try {
98 | const LGraph = app.graph.constructor;
99 | const origConfigure = LGraph.prototype.configure;
100 | LGraph.prototype.configure = function (data) {
101 | // 在图表对象上存储工作流ID
102 | this._workflow_id = data.id || LiteGraph.uuidv4();
103 |
104 | // 执行原始方法
105 | return origConfigure.apply(this, arguments);
106 | };
107 |
108 | // 添加工作流加载监听,只标记切换状态,不做特殊处理
109 | const origLoadGraphData = app.loadGraphData;
110 | app.loadGraphData = async function (data) {
111 | // 设置工作流切换标记,避免删除缓存
112 | window.PROMPT_ASSISTANT_WORKFLOW_SWITCHING = true;
113 |
114 | // 只在debug模式下打印工作流切换信息
115 | const workflowId = data?.id || (data?.extra?.workflow_id) || "未知工作流";
116 | logger.debug(`[工作流] 正在切换工作流: ${workflowId}`);
117 |
118 | try {
119 | // 调用原始加载方法
120 | const result = await origLoadGraphData.apply(this, arguments);
121 | return result;
122 | } finally {
123 | // 延迟重置工作流切换标记
124 | setTimeout(() => {
125 | window.PROMPT_ASSISTANT_WORKFLOW_SWITCHING = false;
126 | }, 500);
127 | }
128 | };
129 | } catch (e) {
130 | logger.error("[PromptAssistant] 注入 LGraph 设置工作流ID失败", e);
131 | }
132 | },
133 |
134 | // ---节点生命周期钩子---
135 | /**
136 | * 节点创建钩子
137 | * 在节点创建时初始化特定类型节点的小助手
138 | */
139 | async nodeCreated(node) {
140 | try {
141 | // 始终检查总开关状态
142 | if (!window.FEATURES.enabled) {
143 | return;
144 | }
145 |
146 | // 只在节点被选中时进行检测和初始化
147 | if (!node || !node.id || node.id === -1) {
148 | return;
149 | }
150 |
151 | // 检查是否为提示词节点
152 | if (PromptAssistant.isValidNode(node)) {
153 | if (!node._promptAssistantInitialized) {
154 | node._promptAssistantInitialized = true;
155 | promptAssistant.checkAndSetupNode(node);
156 | }
157 | return;
158 | }
159 |
160 | // 检查是否为图像节点,同时检查图像反推功能开关
161 | if (window.FEATURES.imageCaption && imageCaption.hasValidImage(node)) {
162 | if (!node._imageCaptionInitialized) {
163 | node._imageCaptionInitialized = true;
164 | imageCaption.checkAndSetupNode(node);
165 | }
166 | return;
167 | }
168 | } catch (error) {
169 | logger.error(`节点创建处理失败: ${error.message}`);
170 | }
171 | },
172 |
173 | /**
174 | * 节点移除钩子
175 | * 在节点被删除时清理对应的小助手实例
176 | */
177 | async nodeRemoved(node) {
178 | // 如果正在切换工作流,则不执行任何清理操作
179 | if (window.PROMPT_ASSISTANT_WORKFLOW_SWITCHING) {
180 | return;
181 | }
182 |
183 | try {
184 | if (!node || node.id === undefined || node.id === -1) return;
185 |
186 | // 安全获取节点ID,用于日志记录
187 | const nodeId = node.id;
188 |
189 | // 添加清理标记,避免重复清理
190 | node._promptAssistantCleaned = false;
191 | node._imageCaptionCleaned = false;
192 |
193 | // 清理提示词小助手
194 | if (node._promptAssistantInitialized) {
195 | promptAssistant.cleanup(nodeId);
196 | node._promptAssistantCleaned = true;
197 | logger.debug(`[节点移除钩子] 提示词小助手清理完成 | 节点ID: ${nodeId}`);
198 | }
199 |
200 | // 清理图像小助手
201 | if (node._imageCaptionInitialized) {
202 | imageCaption.cleanup(nodeId);
203 | node._imageCaptionCleaned = true;
204 | logger.debug(`[节点移除钩子] 图像小助手清理完成 | 节点ID: ${nodeId}`);
205 | }
206 | } catch (error) {
207 | const safeNodeId = node && node.id !== undefined ? node.id : "unknown";
208 | logger.error(`[节点移除钩子] 处理失败 | 节点ID: ${safeNodeId} | 错误: ${error.message}`);
209 | }
210 | },
211 |
212 | /**
213 | * 节点定义注册前钩子
214 | * 向所有节点类型注入小助手相关功能
215 | */
216 | async beforeRegisterNodeDef(nodeType, nodeData) {
217 | // 保存原始方法
218 | const origOnNodeCreated = nodeType.prototype.onNodeCreated;
219 | const origOnRemoved = nodeType.prototype.onRemoved;
220 | const origOnSelected = nodeType.prototype.onSelected;
221 |
222 | // 注入节点创建方法
223 | nodeType.prototype.onNodeCreated = function () {
224 | if (origOnNodeCreated) {
225 | origOnNodeCreated.apply(this, arguments);
226 | }
227 |
228 | // 始终检查最新的总开关状态
229 | const currentEnabled = app.ui.settings.getSettingValue("PromptAssistant.Features.Enabled");
230 | window.FEATURES.enabled = currentEnabled !== undefined ? currentEnabled : true;
231 |
232 | // 总开关关闭时,直接返回
233 | if (!window.FEATURES.enabled) {
234 | return;
235 | }
236 |
237 | // 设置未初始化标记,等待nodeCreated钩子处理
238 | this._promptAssistantInitialized = false;
239 | this._imageCaptionInitialized = false;
240 | };
241 |
242 | // 注入节点选择方法
243 | nodeType.prototype.onSelected = function () {
244 | if (origOnSelected) {
245 | origOnSelected.apply(this, arguments);
246 | }
247 |
248 | // 确保总开关开启
249 | if (!window.FEATURES.enabled) {
250 | return;
251 | }
252 |
253 | // 重置提示词小助手初始化标记,确保每次选择都重新检测节点状态
254 | this._promptAssistantInitialized = false;
255 | promptAssistant.checkAndSetupNode(this);
256 |
257 | // 确保选择事件能触发到图像小助手,同时检查图像反推功能开关
258 | if (window.FEATURES.imageCaption &&
259 | app.canvas && app.canvas._imageCaptionSelectionHandler) {
260 | // 重置初始化标记,确保每次选择都重新检测节点状态
261 | this._imageCaptionInitialized = false;
262 |
263 | const selected_nodes = {};
264 | selected_nodes[this.id] = this;
265 | app.canvas._imageCaptionSelectionHandler(selected_nodes);
266 | }
267 | };
268 |
269 | // 注入节点移除方法
270 | nodeType.prototype.onRemoved = function () {
271 | try {
272 | // 首先检查this和this.id是否存在和有效
273 | if (!this) {
274 | logger.debug("[onRemoved方法] 节点实例不存在,跳过清理");
275 | if (origOnRemoved) {
276 | origOnRemoved.apply(this, arguments);
277 | }
278 | return;
279 | }
280 |
281 | // 安全获取节点ID,如果不存在则使用占位符
282 | const nodeId = this.id !== undefined ? this.id : "unknown";
283 |
284 | // 清理提示词小助手(如果尚未清理)
285 | if (this._promptAssistantInitialized && !this._promptAssistantCleaned) {
286 | if (this.id !== undefined) {
287 | promptAssistant.cleanup(this.id);
288 | this._promptAssistantCleaned = true;
289 | }
290 | }
291 |
292 | // 清理图像小助手(如果尚未清理)
293 | if (this._imageCaptionInitialized && !this._imageCaptionCleaned) {
294 | if (this.id !== undefined) {
295 | imageCaption.cleanup(this.id);
296 | this._imageCaptionCleaned = true;
297 | }
298 | }
299 |
300 | // 即使没有初始化标记,也尝试清理(关键修复)
301 | // 这是为了处理可能的边缘情况,确保完全清理
302 | if (!this._promptAssistantCleaned && this.id !== undefined) {
303 | promptAssistant.cleanup(this.id, true);
304 | }
305 | if (!this._imageCaptionCleaned && this.id !== undefined) {
306 | imageCaption.cleanup(this.id, true);
307 | }
308 |
309 | // 只打印一次清理完成日志
310 | if (this._promptAssistantCleaned && this._imageCaptionCleaned) {
311 | logger.debug(`[onRemoved方法] 节点助手清理完成 | 节点ID: ${nodeId}`);
312 | } else if (this._promptAssistantCleaned) {
313 | logger.debug(`[onRemoved方法] 提示词小助手清理完成 | 节点ID: ${nodeId}`);
314 | } else if (this._imageCaptionCleaned) {
315 | logger.debug(`[onRemoved方法] 图像小助手清理完成 | 节点ID: ${nodeId}`);
316 | }
317 |
318 | if (origOnRemoved) {
319 | origOnRemoved.apply(this, arguments);
320 | }
321 | } catch (error) {
322 | // 安全获取节点ID,用于错误日志
323 | const safeNodeId = this && this.id !== undefined ? this.id : "unknown";
324 | logger.error(`[onRemoved方法] 清理失败 | 节点ID: ${safeNodeId} | 错误: ${error.message}`);
325 | // 确保原始方法仍然被调用
326 | if (origOnRemoved) {
327 | origOnRemoved.apply(this, arguments);
328 | }
329 | }
330 | };
331 | },
332 |
333 | /**
334 | * 扩展卸载钩子
335 | * 在扩展被卸载时清理所有资源
336 | */
337 | async beforeExtensionUnload() {
338 | promptAssistant.cleanup();
339 | imageCaption.cleanup();
340 | }
341 | });
342 |
343 | export { EventManager, UIToolkit };
--------------------------------------------------------------------------------
/js/services/api.js:
--------------------------------------------------------------------------------
1 | /**
2 | * API服务
3 | * 通过后端API代理调用第三方服务,保护API密钥安全
4 | */
5 |
6 | import { logger } from '../utils/logger.js';
7 |
8 | // 用于存储进行中的请求的AbortController
9 | const runningRequests = new Map();
10 |
11 | class APIService {
12 | /**
13 | * 构建完整的API URL
14 | */
15 | static getApiUrl(path) {
16 | // 获取当前域名和端口
17 | const baseUrl = window.location.origin;
18 | // 确保路径格式正确
19 | const formattedPath = path.startsWith('/') ? path : `/${path}`;
20 | const url = `${baseUrl}${formattedPath}`;
21 | logger.debug(`构建API URL: ${url}`);
22 | return url;
23 | }
24 |
25 | /**
26 | * 生成唯一请求ID
27 | */
28 | static generateRequestId() {
29 | return `req_${Date.now()}_${Math.random().toString(36).substr(2, 8)}`;
30 | }
31 |
32 | /**
33 | * 取消一个正在进行的请求
34 | */
35 | static async cancelRequest(requestId) {
36 | if (!requestId) return { success: false, error: "缺少requestId" };
37 |
38 | const controller = runningRequests.get(requestId);
39 |
40 | if (controller) {
41 | // 1. 中止前端的fetch请求
42 | controller.abort();
43 | runningRequests.delete(requestId);
44 | logger.debug(`前端请求已中止 | ID: ${requestId}`);
45 | }
46 |
47 | // 2. 通知后端取消任务
48 | try {
49 | const apiUrl = this.getApiUrl('/prompt_assistant/api/request/cancel');
50 | const response = await fetch(apiUrl, {
51 | method: 'POST',
52 | headers: { 'Content-Type': 'application/json' },
53 | body: JSON.stringify({ request_id: requestId })
54 | });
55 | const result = await response.json();
56 | logger.debug(`后端任务取消请求已发送 | ID: ${requestId} | 结果: ${JSON.stringify(result)}`);
57 | return result;
58 | } catch (error) {
59 | logger.error(`后端任务取消请求失败 | ID: ${requestId} | 错误: ${error.message}`);
60 | return { success: false, error: error.message };
61 | }
62 | }
63 |
64 | /**
65 | * 百度翻译API
66 | */
67 | static async baiduTranslate(text, from = 'auto', to = 'zh', request_id = null, is_auto = false) {
68 | // 生成请求ID
69 | if (!request_id) {
70 | request_id = `baidu_trans_${Date.now()}_${Math.random().toString(36).substr(2, 6)}`;
71 | }
72 |
73 | const controller = new AbortController();
74 | const signal = controller.signal;
75 | runningRequests.set(request_id, controller);
76 |
77 | try {
78 | if (!text || text.trim() === '') {
79 | throw new Error('待翻译文本不能为空');
80 | }
81 |
82 | // 获取API URL
83 | const apiUrl = this.getApiUrl('/prompt_assistant/api/baidu/translate');
84 |
85 | // 调用后端API
86 | const response = await fetch(apiUrl, {
87 | method: 'POST',
88 | headers: {
89 | 'Content-Type': 'application/json'
90 | },
91 | body: JSON.stringify({
92 | text,
93 | from,
94 | to,
95 | request_id,
96 | is_auto
97 | }),
98 | signal // 传递signal
99 | });
100 |
101 | const result = await response.json();
102 | return result;
103 | } catch (error) {
104 | if (error.name === 'AbortError') {
105 | logger.debug(`百度翻译请求被用户中止 | ID: ${request_id}`);
106 | return { success: false, error: '请求已取消', cancelled: true };
107 | }
108 | return {
109 | success: false,
110 | error: error.message
111 | };
112 | } finally {
113 | // 请求完成后从Map中移除
114 | if (runningRequests.has(request_id)) {
115 | runningRequests.delete(request_id);
116 | }
117 | }
118 | }
119 |
120 | /**
121 | * 批量翻译
122 | */
123 | static async batchBaiduTranslate(texts, from = 'auto', to = 'zh') {
124 | try {
125 | if (!Array.isArray(texts) || texts.length === 0) {
126 | throw new Error('待翻译文本数组不能为空');
127 | }
128 |
129 | // 串行处理每个文本的翻译
130 | const results = [];
131 | for (const text of texts) {
132 | const result = await this.baiduTranslate(text, from, to);
133 | results.push(result);
134 | }
135 |
136 | return results;
137 | } catch (error) {
138 | logger.error(`批量翻译 | 结果:失败 | 错误:${error.message}`);
139 | return [];
140 | }
141 | }
142 |
143 | /**
144 | * LLM扩写提示词
145 | */
146 | static async llmExpandPrompt(prompt, request_id = null) {
147 | // 生成请求ID
148 | if (!request_id) {
149 | request_id = `glm4_expand_${Date.now()}_${Math.random().toString(36).substr(2, 6)}`;
150 | }
151 |
152 | const controller = new AbortController();
153 | const signal = controller.signal;
154 | runningRequests.set(request_id, controller);
155 |
156 | try {
157 | if (!prompt || prompt.trim() === '') {
158 | throw new Error('请输入要扩写的内容');
159 | }
160 |
161 | logger.debug(`发起LLM扩写请求 | 请求ID:${request_id} | 原文:${prompt}`);
162 |
163 | // 调用后端API
164 | const apiUrl = this.getApiUrl('/prompt_assistant/api/llm/expand');
165 | logger.debug('LLM扩写API URL:', apiUrl);
166 |
167 | const response = await fetch(apiUrl, {
168 | method: 'POST',
169 | headers: {
170 | 'Content-Type': 'application/json'
171 | },
172 | body: JSON.stringify({
173 | prompt,
174 | request_id
175 | }),
176 | signal // 传递signal
177 | });
178 |
179 | const result = await response.json();
180 | logger.debug(`LLM扩写请求成功 | 请求ID:${request_id} | 结果:${JSON.stringify(result)}`);
181 |
182 | return result;
183 | } catch (error) {
184 | if (error.name === 'AbortError') {
185 | logger.debug(`LLM扩写请求被用户中止 | ID: ${request_id}`);
186 | return { success: false, error: '请求已取消', cancelled: true };
187 | }
188 | logger.error(`LLM扩写请求失败 | 请求ID:${request_id || 'unknown'} | 错误:${error.message}`);
189 | return {
190 | success: false,
191 | error: error.message
192 | };
193 | } finally {
194 | // 请求完成后从Map中移除
195 | if (runningRequests.has(request_id)) {
196 | runningRequests.delete(request_id);
197 | }
198 | }
199 | }
200 |
201 | /**
202 | * LLM翻译文本
203 | */
204 | static async llmTranslate(text, from = 'auto', to = 'zh', request_id = null, is_auto = false) {
205 | // 生成请求ID
206 | if (!request_id) {
207 | request_id = `llm_trans_${Date.now()}_${Math.random().toString(36).substr(2, 6)}`;
208 | }
209 |
210 | const controller = new AbortController();
211 | const signal = controller.signal;
212 | runningRequests.set(request_id, controller);
213 |
214 | try {
215 | if (!text || text.trim() === '') {
216 | throw new Error('请输入要翻译的内容');
217 | }
218 |
219 | // 调用后端API
220 | const apiUrl = this.getApiUrl('/prompt_assistant/api/llm/translate');
221 |
222 | const response = await fetch(apiUrl, {
223 | method: 'POST',
224 | headers: {
225 | 'Content-Type': 'application/json'
226 | },
227 | body: JSON.stringify({
228 | text,
229 | from,
230 | to,
231 | request_id,
232 | is_auto
233 | }),
234 | signal // 传递signal
235 | });
236 |
237 | const result = await response.json();
238 | return result;
239 | } catch (error) {
240 | if (error.name === 'AbortError') {
241 | logger.debug(`LLM翻译请求被用户中止 | ID: ${request_id}`);
242 | return { success: false, error: '请求已取消', cancelled: true };
243 | }
244 | return {
245 | success: false,
246 | error: error.message
247 | };
248 | } finally {
249 | // 请求完成后从Map中移除
250 | if (runningRequests.has(request_id)) {
251 | runningRequests.delete(request_id);
252 | }
253 | }
254 | }
255 |
256 | /**
257 | * 调用视觉模型分析图像
258 | */
259 | static async llmAnalyzeImage(imageData, prompt, request_id = null) {
260 | // 生成请求ID
261 | if (!request_id) {
262 | request_id = this.generateRequestId();
263 | }
264 |
265 | const controller = new AbortController();
266 | const signal = controller.signal;
267 | runningRequests.set(request_id, controller);
268 |
269 | try {
270 | if (!imageData) {
271 | throw new Error('未找到有效的图像');
272 | }
273 |
274 | logger.debug(`发起视觉分析请求 | 请求ID:${request_id}`);
275 |
276 | // 构建API URL
277 | const apiUrl = this.getApiUrl('/prompt_assistant/api/vlm/analyze');
278 | logger.debug('视觉分析API URL:', apiUrl);
279 |
280 | // 构建请求数据
281 | const requestData = {
282 | image: imageData,
283 | prompt: prompt, // 添加prompt
284 | request_id: request_id
285 | };
286 |
287 | // 发送请求
288 | const response = await fetch(apiUrl, {
289 | method: 'POST',
290 | headers: {
291 | 'Content-Type': 'application/json'
292 | },
293 | body: JSON.stringify(requestData),
294 | signal // 传递signal
295 | });
296 |
297 | // 解析响应
298 | const result = await response.json();
299 | logger.debug(`视觉分析请求完成 | 请求ID:${request_id}`);
300 |
301 | return result;
302 | } catch (error) {
303 | if (error.name === 'AbortError') {
304 | logger.debug(`视觉分析请求被用户中止 | ID: ${request_id}`);
305 | return { success: false, error: '请求已取消', cancelled: true };
306 | }
307 | logger.error(`视觉分析请求失败 | 请求ID:${request_id || 'unknown'} | 错误:${error.message}`);
308 | return {
309 | success: false,
310 | error: error.message || '请求失败'
311 | };
312 | } finally {
313 | // 请求完成后从Map中移除
314 | if (runningRequests.has(request_id)) {
315 | runningRequests.delete(request_id);
316 | }
317 | }
318 | }
319 |
320 | /**
321 | * 将图像转换为Base64
322 | */
323 | static async imageToBase64(img) {
324 | return new Promise((resolve, reject) => {
325 | try {
326 | if (!img) {
327 | reject('无效的图像');
328 | return;
329 | }
330 |
331 | // 如果已经是base64字符串,直接返回
332 | if (typeof img === 'string' && img.startsWith('data:image')) {
333 | resolve(img);
334 | return;
335 | }
336 |
337 | // 如果是Blob对象
338 | if (img instanceof Blob) {
339 | const reader = new FileReader();
340 | reader.onload = () => resolve(reader.result);
341 | reader.onerror = reject;
342 | reader.readAsDataURL(img);
343 | return;
344 | }
345 |
346 | // 如果是URL
347 | if (typeof img === 'string' && (img.startsWith('http') || img.startsWith('/'))) {
348 | const image = new Image();
349 | image.crossOrigin = 'Anonymous';
350 | image.onload = () => {
351 | const canvas = document.createElement('canvas');
352 | canvas.width = image.width;
353 | canvas.height = image.height;
354 | const ctx = canvas.getContext('2d');
355 | ctx.drawImage(image, 0, 0);
356 | resolve(canvas.toDataURL('image/jpeg'));
357 | };
358 | image.onerror = () => reject('图像加载失败');
359 | image.src = img;
360 | return;
361 | }
362 |
363 | // 处理ComfyUI图像对象
364 | if (img && typeof img === 'object' && img.src) {
365 | // 如果图像对象有src属性,使用它
366 | const image = new Image();
367 | image.crossOrigin = 'Anonymous';
368 | image.onload = () => {
369 | const canvas = document.createElement('canvas');
370 | canvas.width = image.width;
371 | canvas.height = image.height;
372 | const ctx = canvas.getContext('2d');
373 | ctx.drawImage(image, 0, 0);
374 | resolve(canvas.toDataURL('image/jpeg'));
375 | };
376 | image.onerror = (e) => {
377 | console.error('图像加载失败:', e);
378 | reject('图像加载失败');
379 | };
380 | image.src = img.src;
381 | return;
382 | }
383 |
384 | // 处理HTMLImageElement或类似的对象
385 | if (img && (img instanceof HTMLImageElement || (img.width && img.height && img.complete))) {
386 | try {
387 | const canvas = document.createElement('canvas');
388 | canvas.width = img.width;
389 | canvas.height = img.height;
390 | const ctx = canvas.getContext('2d');
391 | ctx.drawImage(img, 0, 0);
392 | resolve(canvas.toDataURL('image/jpeg'));
393 | return;
394 | } catch (e) {
395 | console.warn('使用canvas转换图像失败:', e);
396 | // 继续尝试其他方法
397 | }
398 | }
399 |
400 | // 处理ComfyUI的特殊格式 (dataURL缓存在node中)
401 | if (img && img.dataURL) {
402 | resolve(img.dataURL);
403 | return;
404 | }
405 |
406 | // 处理ComfyUI特殊的图像数据格式
407 | if (img && img.data && img.width && img.height) {
408 | try {
409 | const canvas = document.createElement('canvas');
410 | canvas.width = img.width;
411 | canvas.height = img.height;
412 | const ctx = canvas.getContext('2d');
413 | const imageData = new ImageData(
414 | new Uint8ClampedArray(img.data.buffer || img.data),
415 | img.width,
416 | img.height
417 | );
418 | ctx.putImageData(imageData, 0, 0);
419 | resolve(canvas.toDataURL('image/jpeg'));
420 | return;
421 | } catch (e) {
422 | console.error('处理图像数据失败:', e);
423 | }
424 | }
425 |
426 | console.error('不支持的图像格式', img);
427 | reject('不支持的图像格式');
428 | } catch (error) {
429 | console.error('转换图像出错:', error);
430 | reject(error);
431 | }
432 | });
433 | }
434 | }
435 |
436 | export { APIService };
--------------------------------------------------------------------------------
/js/services/features.js:
--------------------------------------------------------------------------------
1 | /**
2 | * 小助手功能特性管理模块
3 | * 负责管理所有功能开关、按钮可见性、功能状态变更等
4 | */
5 |
6 | import { logger } from '../utils/logger.js';
7 |
8 | // 外部注入的 promptAssistant 实例
9 | let promptAssistant = null;
10 | // 外部注入的 PromptAssistant 类
11 | let PromptAssistant = null;
12 | // 外部注入的 UIToolkit
13 | let UIToolkit = null;
14 | // 外部注入的 HistoryCacheService
15 | let HistoryCacheService = null;
16 | // 外部注入的 imageCaption 实例
17 | let imageCaption = null;
18 | // 外部注入的 ImageCaption 类
19 | let ImageCaption = null;
20 |
21 | /**
22 | * 注入依赖实例(由主入口调用)
23 | */
24 | export function setFeatureModuleDeps({ promptAssistant: pa, PromptAssistant: PAC, UIToolkit: ui, HistoryCacheService: hc, imageCaption: ic, ImageCaption: ICC }) {
25 | promptAssistant = pa;
26 | PromptAssistant = PAC;
27 | UIToolkit = ui;
28 | HistoryCacheService = hc;
29 | imageCaption = ic;
30 | ImageCaption = ICC;
31 | // 初始化时同步日志级别
32 | try {
33 | if (typeof window !== 'undefined' && window.FEATURES) {
34 | if (typeof window.FEATURES.logLevel === 'undefined') {
35 | window.FEATURES.logLevel = 0;
36 | }
37 | if (typeof logger.setLevel === 'function') {
38 | logger.setLevel(window.FEATURES.logLevel);
39 | }
40 | }
41 | } catch (e) { }
42 | }
43 |
44 | /**
45 | * 功能特性配置对象
46 | * 控制各个功能的启用状态
47 | */
48 | export const FEATURES = {
49 | // 基础功能开关
50 | enabled: true,
51 |
52 | // 具体功能开关
53 | history: true, // 历史功能(包含历史、撤销、重做)
54 | tag: true,
55 | expand: true,
56 | translate: true,
57 | autoTranslate: false, // 自动翻译功能
58 | imageCaption: true, // 图像反推提示词功能
59 |
60 | /**
61 | * 更新所有实例的按钮显示状态
62 | * 根据功能开关状态控制UI元素的显示和隐藏
63 | */
64 | updateButtonsVisibility() {
65 | if (!PromptAssistant) return;
66 | // 遍历所有助手实例
67 | PromptAssistant.instances.forEach((instance) => {
68 | if (instance.buttons) {
69 | // 历史相关按钮 - 由单一的history开关控制
70 | if (instance.buttons['history']) {
71 | instance.buttons['history'].style.display = this.history ? 'block' : 'none';
72 | }
73 | if (instance.buttons['undo']) {
74 | instance.buttons['undo'].style.display = this.history ? 'block' : 'none';
75 | }
76 | if (instance.buttons['redo']) {
77 | instance.buttons['redo'].style.display = this.history ? 'block' : 'none';
78 | }
79 |
80 | // 分隔线1 - 在历史功能之后
81 | if (instance.buttons['divider1']) {
82 | const hasHistoryFeature = this.history;
83 | const hasOtherFeatures = this.tag || this.expand || this.translate;
84 | const showDivider1 = hasHistoryFeature && hasOtherFeatures;
85 | instance.buttons['divider1'].style.display = showDivider1 ? 'block' : 'none';
86 | }
87 |
88 | // 其他功能按钮
89 | if (instance.buttons['tag']) {
90 | instance.buttons['tag'].style.display = this.tag ? 'block' : 'none';
91 | }
92 | if (instance.buttons['expand']) {
93 | instance.buttons['expand'].style.display = this.expand ? 'block' : 'none';
94 | }
95 | if (instance.buttons['translate']) {
96 | instance.buttons['translate'].style.display = this.translate ? 'block' : 'none';
97 | }
98 |
99 | // 记录日志
100 | logger.debug(`按钮更新 | 节点ID: ${instance.nodeId}`);
101 | }
102 | });
103 |
104 | // 处理图像小助手的按钮显示
105 | if (ImageCaption) {
106 | ImageCaption.instances.forEach((assistant) => {
107 | if (assistant.buttons) {
108 | // 图像反推按钮
109 | if (assistant.buttons['caption_zh']) {
110 | assistant.buttons['caption_zh'].style.display = this.imageCaption ? 'block' : 'none';
111 | }
112 | if (assistant.buttons['caption_en']) {
113 | assistant.buttons['caption_en'].style.display = this.imageCaption ? 'block' : 'none';
114 | }
115 |
116 | // 如果图像反推功能被禁用,隐藏整个小助手
117 | if (assistant.element) {
118 | if (!this.imageCaption) {
119 | assistant.element.style.display = 'none';
120 | } else {
121 | // 始终显示图像小助手
122 | assistant.element.style.display = 'flex';
123 | }
124 | }
125 | }
126 | });
127 | }
128 | }
129 | };
130 |
131 | /**
132 | * 处理功能开关状态变化
133 | */
134 | export function handleFeatureChange(featureName, value, oldValue) {
135 | if (!PromptAssistant || !promptAssistant) return;
136 | // 无论总开关状态如何,功能开关始终独立工作
137 | // 如果是从禁用变为启用,需要重新创建按钮
138 | if (value && !oldValue) {
139 | // 只有当小助手系统已初始化时才重建按钮
140 | if (PromptAssistant.instances.size > 0) {
141 | // 重新创建所有实例的按钮
142 | PromptAssistant.instances.forEach((instance) => {
143 | if (instance.element && instance.innerContent) {
144 | // 清空现有按钮容器
145 | instance.innerContent.innerHTML = '';
146 | instance.buttons = {};
147 | // 重新创建所有按钮
148 | promptAssistant.addFunctionButtons(instance);
149 | }
150 | });
151 | logger.debug(`功能重建 | 结果:完成 | 功能: ${featureName}`);
152 | }
153 |
154 | // 如果是图像反推功能被启用
155 | if (featureName === '图像反推' && imageCaption) {
156 | // 启用图像小助手功能
157 | if (imageCaption.initialized) {
158 | // 重置节点初始化标记
159 | if (app.canvas && app.canvas.graph) {
160 | const nodes = app.canvas.graph._nodes || [];
161 | nodes.forEach(node => {
162 | if (node) {
163 | node._imageCaptionInitialized = false;
164 | }
165 | });
166 | }
167 |
168 | // 如果有当前选中的节点,立即处理
169 | if (app.canvas && app.canvas.selected_nodes && Object.keys(app.canvas.selected_nodes).length > 0) {
170 | app.canvas._imageCaptionSelectionHandler(app.canvas.selected_nodes);
171 | }
172 | } else {
173 | // 如果图像小助手尚未初始化,则初始化它
174 | imageCaption.initialize().then(() => {
175 | // 初始化完成后处理当前选中的节点
176 | if (app.canvas && app.canvas.selected_nodes && Object.keys(app.canvas.selected_nodes).length > 0) {
177 | app.canvas._imageCaptionSelectionHandler(app.canvas.selected_nodes);
178 | }
179 | });
180 | }
181 | }
182 | } else {
183 | // 否则只更新显示状态
184 | FEATURES.updateButtonsVisibility();
185 |
186 | // 如果是图像反推功能被禁用
187 | if (featureName === '图像反推' && !value && imageCaption) {
188 | // 清理所有图像小助手实例
189 | imageCaption.cleanup();
190 | }
191 | }
192 | }
--------------------------------------------------------------------------------
/js/utils/eventManager.js:
--------------------------------------------------------------------------------
1 | /**
2 | * 事件管理器
3 | * 统一管理所有与事件相关的操作,包括DOM事件、鼠标事件等
4 | */
5 |
6 | import { logger } from './logger.js';
7 |
8 | class EventManager {
9 | // 事件存储 - 使用Map嵌套Map来存储事件和监听器
10 | static listeners = new Map();
11 |
12 | // 全局鼠标位置
13 | static mousePosition = { x: 0, y: 0 };
14 |
15 | // 初始化状态标记
16 | static initialized = false;
17 | static _mouseHandler = null;
18 |
19 | /**
20 | * 初始化事件管理器
21 | */
22 | static init() {
23 | // 严格检查避免重复初始化
24 | if (this.initialized) {
25 | return true;
26 | }
27 |
28 | try {
29 | // 设置全局鼠标跟踪
30 | this.setupGlobalMouseTracking();
31 |
32 | this.initialized = true;
33 | logger.log("事件管理器 | 初始化完成");
34 | return true;
35 | } catch (error) {
36 | logger.error(`事件管理器 | 初始化失败 | ${error.message}`);
37 | return false;
38 | }
39 | }
40 |
41 | /**
42 | * 设置全局鼠标位置跟踪
43 | */
44 | static setupGlobalMouseTracking() {
45 | // 移除可能存在的旧监听器
46 | if (this._mouseHandler) {
47 | document.removeEventListener('mousemove', this._mouseHandler);
48 | }
49 |
50 | // 创建新的鼠标处理函数
51 | this._mouseHandler = (e) => {
52 | // 更新鼠标位置
53 | this.mousePosition.x = e.clientX;
54 | this.mousePosition.y = e.clientY;
55 |
56 | // 触发自定义事件
57 | this.emit('global_mouse_move', e);
58 | };
59 |
60 | // 添加鼠标监听
61 | document.addEventListener('mousemove', this._mouseHandler);
62 | }
63 |
64 | /**
65 | * 获取当前鼠标位置
66 | */
67 | static getMousePosition() {
68 | return { ...this.mousePosition };
69 | }
70 |
71 | /**
72 | * 判断鼠标是否在元素上方
73 | */
74 | static isMouseOverElement(element) {
75 | if (!element) return false;
76 |
77 | try {
78 | const rect = element.getBoundingClientRect();
79 | return (
80 | this.mousePosition.x >= rect.left &&
81 | this.mousePosition.x <= rect.right &&
82 | this.mousePosition.y >= rect.top &&
83 | this.mousePosition.y <= rect.bottom
84 | );
85 | } catch {
86 | return false;
87 | }
88 | }
89 |
90 | /**
91 | * 添加事件监听器
92 | */
93 | static on(eventKey, id, callback) {
94 | // 参数验证
95 | if (!eventKey || !id || typeof callback !== 'function') {
96 | logger.error(`事件注册失败 | 无效参数 | 事件: ${eventKey}`);
97 | return false;
98 | }
99 |
100 | // 获取或创建事件监听器集合
101 | if (!this.listeners.has(eventKey)) {
102 | this.listeners.set(eventKey, new Map());
103 | }
104 |
105 | const listeners = this.listeners.get(eventKey);
106 |
107 | // 检查是否已存在相同ID的监听器
108 | if (listeners.has(id)) {
109 | return true; // 已存在,静默返回
110 | }
111 |
112 | // 添加新的监听器
113 | listeners.set(id, callback);
114 | return true;
115 | }
116 |
117 | /**
118 | * 移除事件监听器
119 | */
120 | static off(eventKey, id) {
121 | // 参数验证
122 | if (!eventKey || !id) return false;
123 |
124 | // 检查事件和监听器是否存在
125 | if (!this.listeners.has(eventKey)) return false;
126 |
127 | const listeners = this.listeners.get(eventKey);
128 | const removed = listeners.delete(id);
129 |
130 | // 如果该事件没有监听器了,则删除整个事件
131 | if (listeners.size === 0) {
132 | this.listeners.delete(eventKey);
133 | }
134 |
135 | return removed;
136 | }
137 |
138 | /**
139 | * 触发事件
140 | */
141 | static emit(eventKey, ...args) {
142 | if (!eventKey) return false;
143 |
144 | // 检查事件是否有监听器
145 | if (!this.listeners.has(eventKey)) return false;
146 |
147 | const listeners = this.listeners.get(eventKey);
148 | if (listeners.size === 0) return false;
149 |
150 | // 执行所有监听器
151 | for (const [id, callback] of listeners.entries()) {
152 | try {
153 | callback(...args);
154 | } catch (error) {
155 | logger.error(`事件处理错误 | 事件: ${eventKey}, ID: ${id} | 错误: ${error.message}`);
156 | }
157 | }
158 |
159 | return true;
160 | }
161 |
162 | /**
163 | * 添加DOM事件监听器
164 | * 简化的辅助方法,返回用于移除监听器的函数
165 | */
166 | static addDOMListener(element, event, handler, options = false) {
167 | if (!element || !event || typeof handler !== 'function') {
168 | return () => { };
169 | }
170 |
171 | element.addEventListener(event, handler, options);
172 |
173 | return () => {
174 | element.removeEventListener(event, handler, options);
175 | };
176 | }
177 |
178 | /**
179 | * 创建防抖函数
180 | * 限制函数调用频率
181 | */
182 | static debounce(func, wait = 100) {
183 | let timeout;
184 | return function (...args) {
185 | clearTimeout(timeout);
186 | timeout = setTimeout(() => func.apply(this, args), wait);
187 | };
188 | }
189 |
190 | /**
191 | * 注册元素的悬停事件(简化版本)
192 | */
193 | static registerHoverEvents(element, id, onEnter, onLeave) {
194 | if (!element) return () => { };
195 |
196 | const enterHandler = () => onEnter && onEnter();
197 | const leaveHandler = () => onLeave && onLeave();
198 |
199 | element.addEventListener('mouseenter', enterHandler);
200 | element.addEventListener('mouseleave', leaveHandler);
201 |
202 | return () => {
203 | element.removeEventListener('mouseenter', enterHandler);
204 | element.removeEventListener('mouseleave', leaveHandler);
205 | };
206 | }
207 |
208 | /**
209 | * 清理事件管理器
210 | */
211 | static cleanup(keepGlobalEvents = true) {
212 | if (keepGlobalEvents) {
213 | // 保留全局事件,清理其他事件
214 | const globalEvents = ['global_mouse_move'];
215 |
216 | for (const [eventKey, listeners] of this.listeners.entries()) {
217 | if (!globalEvents.includes(eventKey)) {
218 | this.listeners.delete(eventKey);
219 | }
220 | }
221 | } else {
222 | // 清理所有事件和监听器
223 | this.listeners.clear();
224 |
225 | // 移除全局鼠标监听
226 | if (this._mouseHandler) {
227 | document.removeEventListener('mousemove', this._mouseHandler);
228 | this._mouseHandler = null;
229 | }
230 |
231 | // 重置状态
232 | this.initialized = false;
233 | }
234 | }
235 | }
236 |
237 | export { EventManager };
--------------------------------------------------------------------------------
/js/utils/logger.js:
--------------------------------------------------------------------------------
1 | /**
2 | * 日志管理模块
3 | * 提供统一的日志记录和管理功能
4 | */
5 |
6 | // ====================== 日志级别常量 ======================
7 |
8 | /**
9 | * 日志级别常量
10 | * 用于控制日志输出的详细程度
11 | */
12 | export const LOG_LEVELS = {
13 | ERROR: 0, // 仅错误(生产环境)
14 | BASIC: 1, // 基础日志(监控环境)
15 | DEBUG: 2 // 详细日志(开发环境)
16 | };
17 |
18 | // ====================== 日志管理器 ======================
19 |
20 | /**
21 | * 统一日志管理器
22 | * 集中管理不同级别的日志记录
23 | */
24 | class Logger {
25 | constructor() {
26 | this.level = LOG_LEVELS.DEBUG; // 默认使用详细日志级别
27 | }
28 |
29 | log(message) {
30 | if (this.level >= LOG_LEVELS.BASIC) {
31 | console.log(`[PromptAssistant-系统] ${message}`);
32 | }
33 | }
34 |
35 | debug(message) {
36 | if (this.level >= LOG_LEVELS.DEBUG) {
37 | console.debug(`[PromptAssistant-调试] ${message}`);
38 | }
39 | }
40 |
41 | error(message) {
42 | console.error(`[PromptAssistant-错误] ${message}`);
43 | }
44 |
45 | warn(message) {
46 | console.warn(`[PromptAssistant-警告] ${message}`);
47 | }
48 |
49 | /**
50 | * 设置日志级别
51 | * @param {number} level - 日志级别 (0: ERROR, 1: BASIC, 2: DEBUG)
52 | */
53 | setLevel(level) {
54 | if (typeof level !== 'number' || level < 0 || level > 2) {
55 | console.error("[PromptAssistant-错误] 无效的日志级别:", level);
56 | return;
57 | }
58 | this.level = level;
59 | }
60 | }
61 |
62 | // 创建单例实例
63 | const logger = new Logger();
64 |
65 | // 导出日志管理器实例和日志级别常量
66 | export { logger };
--------------------------------------------------------------------------------
/js/utils/promptFormatter.js:
--------------------------------------------------------------------------------
1 | /**
2 | * 提示词格式化工具
3 | * 提供提示词相关的格式化方法
4 | */
5 |
6 | import { logger } from './logger.js';
7 |
8 | class PromptFormatter {
9 | // 定义特殊分隔符
10 | static SEPARATORS = {
11 | START: '【T:', // 标签开始标记
12 | END: ':T】', // 标签结束标记
13 | NEWLINE: '\n' // 换行符用于分隔多个标签
14 | };
15 |
16 | // 定义标点符号映射表
17 | static PUNCTUATION_MAP = {
18 | // 基础标点
19 | ',': ',',
20 | '。': '.',
21 | '、': ',',
22 | ';': ';',
23 | ':': ':',
24 | '?': '?',
25 | '!': '!',
26 |
27 | // 引号
28 | '\u201C': '"', // 中文左双引号
29 | '\u201D': '"', // 中文右双引号
30 | '\u2018': "'", // 中文左单引号
31 | '\u2019': "'", // 中文右单引号
32 |
33 | // 括号
34 | '(': '(',
35 | ')': ')',
36 | '【': '[',
37 | '】': ']',
38 | '「': '{',
39 | '」': '}',
40 | '『': '{',
41 | '』': '}',
42 | '〔': '[',
43 | '〕': ']',
44 | '[': '[',
45 | ']': ']',
46 | '{': '{',
47 | '}': '}',
48 | '《': '<',
49 | '》': '>',
50 | '〈': '<',
51 | '〉': '>',
52 |
53 | // 其他符号
54 | '~': '~',
55 | '|': '|',
56 | '·': '.',
57 | '…': '...',
58 | '━': '-',
59 | '—': '-',
60 | '──': '--',
61 | '-': '-',
62 | '_': '_',
63 | '+': '+',
64 | '×': '*',
65 | '÷': '/',
66 | '=': '=',
67 | '@': '@',
68 | '#': '#',
69 | '$': '$',
70 | '%': '%',
71 | '^': '^',
72 | '&': '&',
73 | '*': '*',
74 | };
75 |
76 | // 定义需要检测的标点符号集合
77 | static PUNCTUATION_SET = new Set([',', '.', ',', '。']);
78 |
79 | /**
80 | * 在文本中查找最近的标点符号
81 | */
82 | static findNearestPunctuation(text, searchForward = true) {
83 | if (!text) return false;
84 |
85 | // 移除开头和结尾的空格
86 | const cleanText = searchForward ? text.trimStart() : text.trimEnd();
87 | if (!cleanText) return false;
88 |
89 | // 搜索第一个非空格字符
90 | const char = searchForward ? cleanText[0] : cleanText[cleanText.length - 1];
91 | return this.PUNCTUATION_SET.has(char);
92 | }
93 |
94 | /**
95 | * 格式化标签,生成四种格式
96 | */
97 | static formatTag(tagValue) {
98 | try {
99 | // 生成四种格式
100 | const format1 = ` ${tagValue}`; // 空格+标签
101 | const format2 = ` ${tagValue},`; // 空格+标签+逗号
102 | const format3 = `, ${tagValue}`; // 逗号+空格+标签
103 | const format4 = `, ${tagValue},`; // 逗号+空格+标签+逗号
104 |
105 | logger.debug(`标签格式化 | 结果:成功 | 原始值:${tagValue} | 格式1:"${format1}" | 格式2:"${format2}" | 格式3:"${format3}" | 格式4:"${format4}"`);
106 |
107 | return {
108 | format1,
109 | format2,
110 | format3,
111 | format4,
112 | insertedFormat: null // 实际插入的格式,初始为null
113 | };
114 | } catch (error) {
115 | logger.error(`标签格式化 | 结果:异常 | 错误:${error.message}`);
116 | return {
117 | format1: ` ${tagValue}`,
118 | format2: ` ${tagValue},`,
119 | format3: `, ${tagValue}`,
120 | format4: `, ${tagValue},`,
121 | insertedFormat: null
122 | };
123 | }
124 | }
125 |
126 | /**
127 | * 确定应该使用哪种格式
128 | */
129 | static determineFormatType(beforeText, afterText) {
130 | try {
131 | // 判断是否为空输入框
132 | if (!beforeText && !afterText) {
133 | logger.debug('格式判断 | 结果:空输入框 | 使用格式:2');
134 | return 2; // 空输入框使用格式2(空格+标签+逗号)
135 | }
136 |
137 | // 检查前方文本是否只包含空格
138 | const hasTextBefore = beforeText.trim().length > 0;
139 |
140 | // 跨空格查找前后的标点符号
141 | const hasCommaBefore = this.findNearestPunctuation(beforeText, false);
142 | const hasCommaAfter = this.findNearestPunctuation(afterText, true);
143 |
144 | // 如果前方没有实际文本(只有空格),使用格式2
145 | if (!hasTextBefore) {
146 | logger.debug('格式判断 | 结果:前方无文本 | 使用格式:2');
147 | return 2; // 使用格式2(空格+标签+逗号)
148 | }
149 |
150 | // 根据前后标点符号情况决定使用哪种格式
151 | let formatType;
152 | if (hasCommaBefore && hasCommaAfter) {
153 | formatType = 1; // 前后都有标点,使用格式1(空格+标签)
154 | } else if (hasCommaBefore && !hasCommaAfter) {
155 | formatType = 2; // 前有标点后无标点,使用格式2(空格+标签+逗号)
156 | } else if (!hasCommaBefore && hasCommaAfter) {
157 | formatType = 3; // 前无标点后有标点,使用格式3(逗号+空格+标签)
158 | } else {
159 | formatType = 4; // 前后都无标点,使用格式4(逗号+空格+标签+逗号)
160 | }
161 |
162 | logger.debug(`格式判断 | 前标点:${hasCommaBefore} | 后标点:${hasCommaAfter} | 前方文本:${hasTextBefore} | 使用格式:${formatType}`);
163 | return formatType;
164 |
165 | } catch (error) {
166 | logger.error(`格式判断 | 结果:异常 | 错误:${error.message}`);
167 | return 2; // 发生错误时默认使用格式2
168 | }
169 | }
170 |
171 | /**
172 | * 格式化提示词用于API调用
173 | */
174 | static formatPromptForAPI(prompt) {
175 | // 暂时直接返回原始文本,不做格式化处理
176 | return {
177 | formattedText: prompt,
178 | extractedParts: [],
179 | originalText: prompt
180 | };
181 | }
182 |
183 | /**
184 | * 获取用于API调用的纯文本
185 | */
186 | static getAPIText(formatInfo) {
187 | // 直接返回原始文本
188 | return formatInfo?.formattedText || '';
189 | }
190 |
191 | /**
192 | * 将API返回的结果恢复为原始格式
193 | */
194 | static restorePromptFormat(apiResult, formatInfo) {
195 | // 直接返回API结果
196 | return apiResult;
197 | }
198 |
199 | /**
200 | * 格式化翻译后的文本
201 | * 将中文标点符号转换为英文标点符号
202 | * 注意:此方法负责处理所有翻译结果的格式(包括百度翻译和LLM翻译),
203 | * 后端不再进行任何格式预处理或后处理。
204 | */
205 | static formatTranslatedText(text) {
206 | try {
207 | if (!text) return '';
208 |
209 | // 记录原始文本用于日志
210 | const originalText = text;
211 |
212 | // 保留换行符,按行处理文本
213 | const lines = text.split('\n');
214 | const formattedLines = lines.map(line => {
215 | // 使用映射表替换所有中文标点
216 | let formattedLine = line;
217 | for (const [cnPunct, enPunct] of Object.entries(this.PUNCTUATION_MAP)) {
218 | formattedLine = formattedLine.split(cnPunct).join(enPunct);
219 | }
220 |
221 | // 处理连续的点号(超过3个的情况)
222 | formattedLine = formattedLine.replace(/\.{3,}/g, '...');
223 |
224 | // 处理多余的空格
225 | formattedLine = formattedLine
226 | .replace(/\s+/g, ' ') // 多个空格转换为单个空格
227 | .replace(/\s*,\s*/g, ', ') // 统一逗号后的空格
228 | .trim(); // 去除首尾空格
229 |
230 | return formattedLine;
231 | });
232 |
233 | // 合并处理后的行,保留原始换行符
234 | const formattedText = formattedLines.join('\n');
235 |
236 | // 记录日志
237 | if (originalText !== formattedText) {
238 | const logOriginal = originalText.length > 100 ?
239 | originalText.substring(0, 100) + '...' : originalText;
240 | const logFormatted = formattedText.length > 100 ?
241 | formattedText.substring(0, 100) + '...' : formattedText;
242 | logger.debug(`标点符号转换 | 结果:成功 | 原行数:${lines.length} | 转换后行数:${formattedLines.length} | 样例:"${logFormatted}"`);
243 | }
244 |
245 | return formattedText;
246 |
247 | } catch (error) {
248 | logger.error(`标点符号转换 | 结果:异常 | 错误:${error.message}`);
249 | return text; // 发生错误时返回原始文本
250 | }
251 | }
252 |
253 | /**
254 | * 判断文本的语言类型
255 | */
256 | static detectLanguage(text) {
257 | try {
258 | if (!text) {
259 | return {
260 | from: 'en',
261 | to: 'zh'
262 | };
263 | }
264 |
265 | // 检查是否包含中文字符
266 | const hasChineseChars = /[\u4e00-\u9fff]/.test(text);
267 | // 检查是否包含英文字符
268 | const hasEnglishChars = /[a-zA-Z]/.test(text);
269 |
270 | let from, to, type;
271 |
272 | if (hasChineseChars && !hasEnglishChars) {
273 | // 纯中文
274 | from = 'zh';
275 | to = 'en';
276 | type = '纯中文';
277 | } else if (!hasChineseChars && hasEnglishChars) {
278 | // 纯英文
279 | from = 'en';
280 | to = 'zh';
281 | type = '纯英文';
282 | } else {
283 | // 混合语言(包含中英文)或其他情况,默认中译英
284 | from = 'zh';
285 | to = 'en';
286 | type = '混合语言';
287 | }
288 |
289 | // 记录日志
290 | logger.debug(`语言检测 | 结果:${type} | 翻译方向:${from}→${to}`);
291 |
292 | return { from, to };
293 |
294 | } catch (error) {
295 | logger.error(`语言检测 | 结果:异常 | 错误:${error.message}`);
296 | return {
297 | from: 'en',
298 | to: 'zh'
299 | };
300 | }
301 | }
302 | }
303 |
304 | export { PromptFormatter };
--------------------------------------------------------------------------------
/js/utils/resourceManager.js:
--------------------------------------------------------------------------------
1 | /**
2 | * 资源管理器
3 | * 统一管理所有资源的加载、缓存和访问
4 | */
5 |
6 | import { logger } from './logger.js';
7 |
8 | class ResourceManager {
9 | // 资源缓存
10 | static #iconCache = new Map();
11 | static #styleCache = new Map();
12 | static #scriptCache = new Map();
13 | static #tagCache = null; // 修改为单一变量存储
14 | static #userTagCache = null; // 用户自定义标签缓存
15 |
16 | // 初始化状态
17 | static #initialized = false;
18 | static #initializing = false;
19 |
20 | /**
21 | * 初始化资源管理器
22 | */
23 | static init() {
24 | // 已经初始化过,直接返回
25 | if (this.#initialized) {
26 | return true;
27 | }
28 |
29 | // 正在初始化中,避免重复
30 | if (this.#initializing) {
31 | return false;
32 | }
33 |
34 | this.#initializing = true;
35 |
36 | try {
37 | // 加载所有资源
38 | this.#loadIcons();
39 | this.#loadStyles();
40 | this.#loadTagData();
41 | this.#loadUserTagData();
42 |
43 | this.#initialized = true;
44 | this.#initializing = false;
45 |
46 | logger.log("资源管理器 | 初始化完成");
47 | return true;
48 | } catch (error) {
49 | logger.error(`资源管理器 | 初始化失败 | ${error.message}`);
50 | this.#initializing = false;
51 | return false;
52 | }
53 | }
54 |
55 | /**
56 | * 获取资源的绝对URL
57 | */
58 | static getResourceUrl(relativePath) {
59 | return new URL(relativePath, import.meta.url).href;
60 | }
61 |
62 | /**
63 | * 获取CSS文件的URL
64 | */
65 | static getCssUrl(cssFileName) {
66 | return this.getResourceUrl(`../css/${cssFileName}`);
67 | }
68 |
69 | /**
70 | * 获取资源目录中的资源URL
71 | */
72 | static getAssetUrl(assetFileName) {
73 | return this.getResourceUrl(`../assets/${assetFileName}`);
74 | }
75 |
76 | /**
77 | * 获取库文件的URL
78 | */
79 | static getLibUrl(libFileName) {
80 | return this.getResourceUrl(`../lib/${libFileName}`);
81 | }
82 |
83 | // ====================== 图标管理 ======================
84 |
85 | /**
86 | * 加载所有图标
87 | * @private
88 | */
89 | static #loadIcons() {
90 | // 所有需要加载的图标列表
91 | const iconsToLoad = [
92 | 'icon-main.svg',
93 | 'icon-history.svg',
94 | 'icon-undo.svg',
95 | 'icon-redo.svg',
96 | 'icon-tag.svg',
97 | 'icon-expand.svg',
98 | 'icon-translate.svg',
99 | 'icon-caption-zh.svg',
100 | 'icon-caption-en.svg',
101 | 'icon-remove.svg',
102 | 'icon-resize-handle.svg',
103 | ];
104 |
105 | let loaded = 0;
106 | let failed = 0;
107 |
108 | // 逐个加载图标
109 | iconsToLoad.forEach(iconName => {
110 | const iconUrl = this.getAssetUrl(iconName);
111 |
112 | // 使用fetch加载SVG内容
113 | fetch(iconUrl)
114 | .then(response => {
115 | if (!response.ok) {
116 | throw new Error(`HTTP error! status: ${response.status}`);
117 | }
118 | return response.text();
119 | })
120 | .then(svgContent => {
121 | // 缓存SVG内容
122 | this.#iconCache.set(iconName, svgContent);
123 | loaded++;
124 |
125 | // 所有图标加载完成后输出日志
126 | if (loaded + failed === iconsToLoad.length) {
127 | logger.debug(`图标加载完成 | 成功:${loaded}个 | 失败:${failed}个`);
128 | }
129 | })
130 | .catch(error => {
131 | failed++;
132 | logger.warn(`图标加载失败 | ${iconName} | ${error.message}`);
133 |
134 | // 所有图标加载完成后输出日志
135 | if (loaded + failed === iconsToLoad.length) {
136 | logger.debug(`图标加载完成 | 成功:${loaded}个 | 失败:${failed}个`);
137 | }
138 | });
139 | });
140 | }
141 |
142 | /**
143 | * 获取缓存的图标
144 | */
145 | static getIcon(iconName) {
146 | const svgContent = this.#iconCache.get(iconName);
147 | if (!svgContent) {
148 | return null;
149 | }
150 |
151 | // 创建一个包含SVG的span元素
152 | const iconContainer = document.createElement('span');
153 | iconContainer.className = 'svg-icon';
154 | iconContainer.innerHTML = svgContent;
155 |
156 | // 获取SVG元素并添加样式
157 | const svgElement = iconContainer.querySelector('svg');
158 | if (svgElement) {
159 | // 添加样式以确保SVG可以通过color属性控制颜色
160 | svgElement.style.width = '100%';
161 | svgElement.style.height = '100%';
162 | svgElement.style.fill = 'currentColor';
163 |
164 | // 移除可能存在的固定颜色属性
165 | svgElement.querySelectorAll('*').forEach(el => {
166 | if (el.hasAttribute('fill') && el.getAttribute('fill') !== 'none') {
167 | el.setAttribute('fill', 'currentColor');
168 | }
169 | if (el.hasAttribute('stroke') && el.getAttribute('stroke') !== 'none') {
170 | el.setAttribute('stroke', 'currentColor');
171 | }
172 | });
173 | }
174 |
175 | return iconContainer;
176 | }
177 |
178 | // ====================== 样式管理 ======================
179 |
180 | /**
181 | * 加载所有样式表
182 | * @private
183 | */
184 | static #loadStyles() {
185 | const stylesToLoad = [
186 | { id: 'prompt-assistant-common-styles', file: 'common.css' },
187 | { id: 'prompt-assistant-styles', file: 'assistant.css' },
188 | { id: 'prompt-assistant-popup-styles', file: 'popup.css' },
189 | { id: 'prompt-assistant-settings-styles', file: 'settings.css' }
190 | ];
191 |
192 | stylesToLoad.forEach(style => {
193 | this.#loadStyle(style.id, style.file);
194 | });
195 | }
196 |
197 | /**
198 | * 加载单个样式表
199 | */
200 | static #loadStyle(id, file) {
201 | // 检查是否已存在
202 | if (document.getElementById(id)) {
203 | return;
204 | }
205 |
206 | const link = document.createElement("link");
207 | link.id = id;
208 | link.rel = "stylesheet";
209 | link.type = "text/css";
210 | link.href = this.getCssUrl(file);
211 |
212 | link.onload = () => {
213 | this.#styleCache.set(id, link);
214 | };
215 |
216 | link.onerror = () => {
217 | logger.error(`样式表加载失败 | ${id}`);
218 | };
219 |
220 | document.head.appendChild(link);
221 | }
222 |
223 | // ====================== 标签数据管理 ======================
224 |
225 | /**
226 | * 获取标签数据文件的URL
227 | */
228 | static getTagUrl() {
229 | // 使用API路由获取标签数据
230 | return '/prompt_assistant/api/config/tags';
231 | }
232 |
233 | /**
234 | * 获取用户自定义标签数据文件的URL
235 | */
236 | static getUserTagUrl() {
237 | // 使用API路由获取用户自定义标签数据
238 | return '/prompt_assistant/api/config/tags_user';
239 | }
240 |
241 | /**
242 | * 统计标签数据
243 | */
244 | static #getTagStats(data) {
245 | const stats = {
246 | categories: 0, // 所有分类数量(包括所有层级)
247 | tags: 0 // 叶子节点数量(实际标签数)
248 | };
249 |
250 | /**
251 | * 递归统计标签数据
252 | */
253 | const countRecursively = (obj) => {
254 | // 遍历当前层级的所有键
255 | for (const key in obj) {
256 | const value = obj[key];
257 |
258 | // 如果值是字符串,说明这是一个标签(叶子节点)
259 | if (typeof value === 'string') {
260 | stats.tags++;
261 | }
262 | // 如果值是对象,说明这是一个分类,需要继续递归
263 | else if (typeof value === 'object' && value !== null) {
264 | stats.categories++;
265 | countRecursively(value);
266 | }
267 | }
268 | };
269 |
270 | // 开始递归统计
271 | countRecursively(data);
272 |
273 | return stats;
274 | }
275 |
276 | /**
277 | * 刷新标签数据
278 | */
279 | static refreshTagData() {
280 | return new Promise((resolve, reject) => {
281 | const tagUrl = this.getTagUrl();
282 | logger.debug("开始重新加载标签数据...");
283 |
284 | fetch(tagUrl + '?t=' + new Date().getTime()) // 添加时间戳防止缓存
285 | .then(response => {
286 | if (!response.ok) {
287 | throw new Error(`HTTP error! status: ${response.status}`);
288 | }
289 | return response.json();
290 | })
291 | .then(data => {
292 | // 检查是否有错误信息
293 | if (data.error) {
294 | throw new Error(`API返回错误: ${data.error}`);
295 | }
296 |
297 | this.#tagCache = data;
298 | const stats = this.#getTagStats(data);
299 | logger.log(`标签数据刷新完成 | 分类数量: ${stats.categories} | 标签数量: ${stats.tags}`);
300 | resolve(data);
301 | })
302 | .catch(error => {
303 | logger.error(`标签数据刷新失败 | ${error.message}`);
304 | reject(error);
305 | });
306 | });
307 | }
308 |
309 | /**
310 | * 刷新用户自定义标签数据
311 | */
312 | static refreshUserTagData() {
313 | return new Promise((resolve, reject) => {
314 | const userTagUrl = this.getUserTagUrl();
315 | logger.debug("开始重新加载用户自定义标签数据...");
316 |
317 | fetch(userTagUrl + '?t=' + new Date().getTime()) // 添加时间戳防止缓存
318 | .then(response => {
319 | if (!response.ok) {
320 | throw new Error(`HTTP error! status: ${response.status}`);
321 | }
322 | return response.json();
323 | })
324 | .then(data => {
325 | // 检查是否有错误信息
326 | if (data.error) {
327 | throw new Error(`API返回错误: ${data.error}`);
328 | }
329 |
330 | this.#userTagCache = data;
331 | const stats = this.#getTagStats(data);
332 | logger.log(`用户标签数据刷新完成 | 分类数量: ${stats.categories} | 标签数量: ${stats.tags}`);
333 | resolve(data);
334 | })
335 | .catch(error => {
336 | logger.error(`用户标签数据刷新失败 | ${error.message}`);
337 | reject(error);
338 | });
339 | });
340 | }
341 |
342 | /**
343 | * 加载标签数据
344 | */
345 | static #loadTagData() {
346 | return this.refreshTagData();
347 | }
348 |
349 | /**
350 | * 加载用户自定义标签数据
351 | */
352 | static #loadUserTagData() {
353 | return this.refreshUserTagData();
354 | }
355 |
356 | /**
357 | * 获取标签数据
358 | */
359 | static async getTagData(refresh = false) {
360 | if (refresh || !this.#tagCache) {
361 | try {
362 | await this.refreshTagData();
363 | } catch (error) {
364 | logger.error(`获取标签数据失败 | ${error.message}`);
365 | return {};
366 | }
367 | }
368 | return this.#tagCache || {};
369 | }
370 |
371 | /**
372 | * 获取用户自定义标签数据
373 | */
374 | static async getUserTagData(refresh = false) {
375 | if (refresh || !this.#userTagCache) {
376 | try {
377 | await this.refreshUserTagData();
378 | } catch (error) {
379 | logger.error(`获取用户标签数据失败 | ${error.message}`);
380 | return {};
381 | }
382 | }
383 | return this.#userTagCache || {};
384 | }
385 |
386 | /**
387 | * 获取标签统计数据
388 | */
389 | static async getTagStats() {
390 | const tagData = await this.getTagData();
391 | const stats = this.#getTagStats(tagData);
392 | return stats.tags;
393 | }
394 |
395 | /**
396 | * 检查是否已初始化
397 | */
398 | static isInitialized() {
399 | return this.#initialized;
400 | }
401 |
402 | // ====================== 资源清理 ======================
403 |
404 | /**
405 | * 清理所有资源
406 | */
407 | static async cleanup() {
408 | // 清理图标缓存
409 | this.#iconCache.clear();
410 |
411 | // 移除样式表
412 | this.#styleCache.forEach((style) => {
413 | if (style && style.parentNode) {
414 | style.parentNode.removeChild(style);
415 | }
416 | });
417 | this.#styleCache.clear();
418 |
419 | // 清理脚本缓存
420 | this.#scriptCache.clear();
421 |
422 | // 清理标签数据
423 | this.#tagCache = null;
424 | this.#userTagCache = null;
425 |
426 | // 重置状态
427 | this.#initialized = false;
428 | this.#initializing = false;
429 |
430 | // 重新初始化资源
431 | await this.init();
432 |
433 | logger.log("资源管理器 | 资源已清理并重新初始化");
434 | }
435 |
436 | /**
437 | * 加载外部脚本
438 | */
439 | static async loadScript(url) {
440 | try {
441 | if (this.#scriptCache.has(url)) {
442 | return this.#scriptCache.get(url);
443 | }
444 |
445 | const promise = new Promise((resolve, reject) => {
446 | const script = document.createElement('script');
447 | script.src = url;
448 | script.async = true;
449 | script.onload = () => resolve();
450 | script.onerror = () => reject(new Error(`Failed to load script: ${url}`));
451 | document.head.appendChild(script);
452 | });
453 |
454 | this.#scriptCache.set(url, promise);
455 | await promise;
456 | logger.debug(`脚本加载成功 | URL:${url}`);
457 | return promise;
458 | } catch (error) {
459 | logger.error(`脚本加载失败 | URL:${url} | 错误:${error.message}`);
460 | throw error;
461 | }
462 | }
463 |
464 | /**
465 | * 获取 SortableJS 实例
466 | */
467 | static async getSortable() {
468 | try {
469 | if (window.Sortable) {
470 | return window.Sortable;
471 | }
472 |
473 | const sortableUrl = this.getLibUrl('Sortable.min.js');
474 | await this.loadScript(sortableUrl);
475 |
476 | if (!window.Sortable) {
477 | throw new Error('Sortable.js 加载后,window.Sortable 未定义');
478 | }
479 |
480 | logger.debug('Sortable.js 加载成功');
481 | return window.Sortable;
482 | } catch (error) {
483 | logger.error(`Sortable.js 获取失败 | 错误:${error.message}`);
484 | throw error;
485 | }
486 | }
487 |
488 | /**
489 | * 获取系统提示词配置文件的URL
490 | */
491 | static getSystemPromptsUrl() {
492 | return this.getResourceUrl('../config/system_prompts.json');
493 | }
494 |
495 | /**
496 | * 加载系统提示词配置
497 | */
498 | static async loadSystemPrompts(forceRefresh = true) {
499 | try {
500 | const url = this.getSystemPromptsUrl();
501 | // 添加时间戳或随机参数以防止缓存
502 | const finalUrl = forceRefresh ? `${url}?t=${Date.now()}` : url;
503 |
504 | logger.debug(`加载系统提示词配置 | URL: ${finalUrl}`);
505 |
506 | const response = await fetch(finalUrl);
507 | if (!response.ok) {
508 | throw new Error(`HTTP error! status: ${response.status}`);
509 | }
510 |
511 | const data = await response.json();
512 | logger.debug(`系统提示词配置加载成功`);
513 |
514 | // 验证数据结构
515 | if (!data.vision_prompts) {
516 | logger.warn(`系统提示词配置中缺少vision_prompts字段`);
517 | } else {
518 | logger.debug(`视觉提示词配置: ${Object.keys(data.vision_prompts).join(', ')}`);
519 | }
520 |
521 | return data;
522 | } catch (error) {
523 | logger.error(`系统提示词配置加载失败 | ${error.message}`);
524 | return null;
525 | }
526 | }
527 | }
528 |
529 | export { ResourceManager };
--------------------------------------------------------------------------------
/node/__init__.py:
--------------------------------------------------------------------------------
1 | # 节点包初始化文件
2 | # 确保node文件夹被识别为Python包
3 |
4 | # 将在这里注册的节点导出到顶级包
--------------------------------------------------------------------------------
/node/image_caption_node.py:
--------------------------------------------------------------------------------
1 | import asyncio
2 | import random
3 | import time
4 | import threading
5 | import hashlib
6 | import base64
7 | from io import BytesIO
8 | import os
9 | import json
10 |
11 | import torch
12 | import numpy as np
13 | from PIL import Image
14 | from comfy.model_management import InterruptProcessingException
15 |
16 | from ..services.vlm import VisionService
17 | from ..services.error_util import format_api_error
18 |
19 | # 定义ANSI颜色代码常量
20 | GREEN = "\033[92m"
21 | RESET = "\033[0m"
22 |
23 |
24 | class ImageCaptionNode:
25 | """
26 | 图像反推提示词节点
27 | 分析输入图像并生成描述性提示词
28 | """
29 | # 定义日志前缀(带绿色)
30 | LOG_PREFIX = f"{GREEN}[PromptAssistant]{RESET}"
31 |
32 | @classmethod
33 | def INPUT_TYPES(cls):
34 | # 从config_manager获取系统提示词配置
35 | from ..config_manager import config_manager
36 | system_prompts = config_manager.get_system_prompts()
37 |
38 | # 获取所有vision_prompts作为选项
39 | vision_prompts = {}
40 | if system_prompts and 'vision_prompts' in system_prompts:
41 | vision_prompts = system_prompts['vision_prompts']
42 |
43 | # 构建提示词模板选项
44 | prompt_template_options = []
45 | for key, value in vision_prompts.items():
46 | name = value.get('name', key)
47 | prompt_template_options.append(name)
48 |
49 | # 如果没有选项,添加一个默认选项
50 | if not prompt_template_options:
51 | prompt_template_options = ["默认中文反推提示词"]
52 |
53 | return {
54 | "required": {
55 | "图像": ("IMAGE",),
56 | "规则类型": (["规则模板", "手动输入"], {"default": "规则模板"}),
57 | "规则模板": (prompt_template_options, {"default": prompt_template_options[0] if prompt_template_options else "默认中文反推提示词"}),
58 | "临时规则内容": ("STRING", {"multiline": True, "default": "", "placeholder": "规则类型设置为\"手动输入\"后,输入框内容才会生效"}),
59 | "视觉服务": (["智谱", "硅基流动", "自定义"], {"default": "智谱"}),
60 | },
61 | }
62 |
63 | RETURN_TYPES = ("STRING",)
64 | RETURN_NAMES = ("提示词输出",)
65 | FUNCTION = "analyze_image"
66 | CATEGORY = "✨提示词小助手"
67 | OUTPUT_NODE = False
68 |
69 | @classmethod
70 | def IS_CHANGED(cls, 图像, 规则类型, 规则模板, 临时规则内容, 视觉服务):
71 | """
72 | 只在输入内容真正变化时才触发重新执行
73 | 使用输入参数的哈希值作为判断依据
74 | """
75 | # 计算图像的哈希值(只使用第一帧的部分数据,避免计算量过大)
76 | img_hash = ""
77 | if 图像 is not None:
78 | try:
79 | if len(图像.shape) == 4:
80 | # 取第一帧的中心区域作为哈希计算依据
81 | h, w = 图像.shape[1:3]
82 | center_h, center_w = h // 2, w // 2
83 | size = min(100, h // 4, w // 4) # 限制计算区域大小
84 | img_data = 图像[0,
85 | max(0, center_h - size):min(h, center_h + size),
86 | max(0, center_w - size):min(w, center_w + size),
87 | 0].cpu().numpy().tobytes()
88 | img_hash = hashlib.md5(img_data).hexdigest()
89 | else:
90 | # 如果不是4D张量,使用整个张量的哈希
91 | img_data = 图像.cpu().numpy().tobytes()
92 | img_hash = hashlib.md5(img_data).hexdigest()
93 | except Exception:
94 | img_hash = "0"
95 |
96 | # 组合所有输入的哈希值
97 | input_hash = hash((
98 | img_hash,
99 | 规则类型,
100 | 规则模板,
101 | 临时规则内容,
102 | 视觉服务
103 | ))
104 |
105 | return input_hash
106 |
107 | def analyze_image(self, 图像, 规则类型, 规则模板, 临时规则内容, 视觉服务):
108 | """
109 | 分析图像并生成提示词
110 |
111 | Args:
112 | 图像: 输入的图像数据
113 | 规则类型: 选择使用模板还是手动输入
114 | 规则模板: 选择的提示词模板
115 | 临时规则内容: 临时规则的内容
116 | 视觉服务: 选择的视觉服务
117 |
118 | Returns:
119 | tuple: 分析结果
120 | """
121 | try:
122 | # 检查输入
123 | if 图像 is None:
124 | raise ValueError("输入图像不能为空")
125 |
126 | # 将图像转换为base64编码
127 | image_data = self._image_to_base64(图像)
128 |
129 | # 获取提示词模板内容
130 | prompt_template = None
131 |
132 | if 规则类型 == "手动输入" and 临时规则内容:
133 | prompt_template = 临时规则内容
134 | print(f"{self.LOG_PREFIX} 图像反推: 使用手动输入规则")
135 | else:
136 | # 从config_manager获取系统提示词配置
137 | from ..config_manager import config_manager
138 | system_prompts = config_manager.get_system_prompts()
139 |
140 | # 获取vision_prompts
141 | vision_prompts = {}
142 | if system_prompts and 'vision_prompts' in system_prompts:
143 | vision_prompts = system_prompts['vision_prompts']
144 |
145 | # 查找选定的提示词模板
146 | template_found = False
147 | for key, value in vision_prompts.items():
148 | if value.get('name') == 规则模板:
149 | prompt_template = value.get('content')
150 | template_found = True
151 | print(f"{self.LOG_PREFIX} 图像反推: 使用模板 '{规则模板}'")
152 | break
153 |
154 | if not template_found:
155 | print(f"{self.LOG_PREFIX} 图像反推: 未找到模板 '{规则模板}',尝试直接匹配键名")
156 | # 尝试直接匹配键名
157 | for key, value in vision_prompts.items():
158 | if key == 规则模板:
159 | prompt_template = value.get('content')
160 | template_found = True
161 | print(f"{self.LOG_PREFIX} 图像反推: 使用模板 '{规则模板}'")
162 | break
163 |
164 | # 如果没有找到提示词模板,使用默认值
165 | if not prompt_template:
166 | prompt_template = "请详细描述这张图片的内容,包括主体、场景、风格、色彩等要素。"
167 | print(f"{self.LOG_PREFIX} 图像反推: 未找到模板 '{规则模板}',使用默认提示词")
168 |
169 | # 执行图像分析
170 | print(f"{self.LOG_PREFIX} 开始图像反推分析: {视觉服务}")
171 |
172 | # 映射视觉服务选项到provider
173 | provider_map = {
174 | "智谱": "zhipu",
175 | "硅基流动": "siliconflow",
176 | "自定义": "custom"
177 | }
178 |
179 | # 获取选定的provider
180 | selected_provider = provider_map.get(视觉服务)
181 | if not selected_provider:
182 | raise ValueError(f"不支持的视觉服务: {视觉服务}")
183 |
184 | # 获取对应provider的配置
185 | from ..config_manager import config_manager
186 | provider_config = self._get_provider_config(config_manager, selected_provider)
187 | if not provider_config:
188 | raise ValueError(f"未找到{视觉服务}的配置,请先完成API配置")
189 |
190 | print(f"{self.LOG_PREFIX} 图像反推: 使用{视觉服务}服务, API: {provider_config.get('model')}")
191 |
192 | # 执行图像分析
193 | result = self._analyze_with_vision_service(image_data, prompt_template, selected_provider, provider_config)
194 |
195 | if result and result.get('success'):
196 | description = result.get('data', {}).get('description', '').strip()
197 | if not description:
198 | error_msg = 'API返回结果为空,请检查API密钥、模型配置或网络连接'
199 | print(f"{self.LOG_PREFIX} 图像反推分析失败: {error_msg}")
200 | raise RuntimeError(f"分析失败: {error_msg}")
201 |
202 | print(f"{self.LOG_PREFIX} 图像反推分析完成,结果长度: {len(description)}")
203 | return (description,)
204 | else:
205 | error_msg = result.get('error', '分析失败,未知错误') if result else '分析服务未返回结果'
206 | print(f"{self.LOG_PREFIX} 图像反推分析失败: {error_msg}")
207 | raise RuntimeError(f"分析失败: {error_msg}")
208 |
209 | except InterruptProcessingException:
210 | print(f"{self.LOG_PREFIX} 图像反推任务被用户取消")
211 | raise
212 | except Exception as e:
213 | error_msg = format_api_error(e, 视觉服务)
214 | print(f"{self.LOG_PREFIX} 图像反推节点异常: {error_msg}")
215 | raise RuntimeError(f"分析异常: {error_msg}")
216 |
217 | def _analyze_with_vision_service(self, image_data, prompt_template, provider, provider_config):
218 | """使用视觉服务分析图像"""
219 | try:
220 | # 创建请求ID
221 | request_id = f"image_caption_{int(time.time())}_{random.randint(1000, 9999)}"
222 | result_container = {}
223 |
224 | # 在独立线程中运行图像分析
225 | thread = threading.Thread(
226 | target=self._run_async_vision_analysis,
227 | args=(image_data, prompt_template, request_id, result_container, provider, provider_config)
228 | )
229 | thread.start()
230 |
231 | # 等待分析完成,同时检查中断
232 | while thread.is_alive():
233 | # 检查是否被中断 - 这会抛出 InterruptProcessingException
234 | try:
235 | import nodes
236 | nodes.before_node_execution()
237 | except:
238 | # 如果检查中断时出现异常,说明被中断了
239 | print(f"{self.LOG_PREFIX} 检测到中断信号,正在终止分析任务...")
240 | # 设置结果容器为中断状态,让线程知道要停止
241 | result_container['interrupted'] = True
242 | # 等待线程结束或超时
243 | thread.join(timeout=1.0)
244 | if thread.is_alive():
245 | print(f"{self.LOG_PREFIX} 分析线程未能及时响应中断")
246 | raise InterruptProcessingException()
247 |
248 | time.sleep(0.1)
249 |
250 | return result_container.get('result')
251 |
252 | except Exception as e:
253 | return {"success": False, "error": str(e)}
254 |
255 | def _run_async_vision_analysis(self, image_data, prompt_template, request_id, result_container, provider, provider_config):
256 | """在独立线程中运行异步图像分析任务"""
257 | loop = asyncio.new_event_loop()
258 | asyncio.set_event_loop(loop)
259 | try:
260 | # 检查是否在开始前就被中断了
261 | if result_container.get('interrupted'):
262 | result_container['result'] = {"success": False, "error": "任务被中断"}
263 | return
264 |
265 | result = loop.run_until_complete(
266 | VisionService.analyze_image(image_data, request_id, None, prompt_template, provider, provider_config)
267 | )
268 |
269 | # 检查是否在执行过程中被中断了
270 | if result_container.get('interrupted'):
271 | result_container['result'] = {"success": False, "error": "任务被中断"}
272 | return
273 |
274 | result_container['result'] = result
275 | except Exception as e:
276 | if result_container.get('interrupted'):
277 | result_container['result'] = {"success": False, "error": "任务被中断"}
278 | else:
279 | result_container['result'] = {"success": False, "error": str(e)}
280 | finally:
281 | loop.close()
282 |
283 | def _get_provider_config(self, config_manager, provider):
284 | """获取指定provider的配置"""
285 | vision_config = config_manager.get_vision_config()
286 | if 'providers' in vision_config and provider in vision_config['providers']:
287 | return vision_config['providers'][provider]
288 | return None
289 |
290 | def _image_to_base64(self, image_tensor):
291 | """将图像张量转换为base64编码"""
292 | # 确保图像是正确的形状 [batch, height, width, channels]
293 | if len(image_tensor.shape) == 4:
294 | # 取第一张图片
295 | image_tensor = image_tensor[0]
296 |
297 | # 将图像转换为numpy数组并缩放到0-255范围
298 | image_np = (image_tensor.cpu().numpy() * 255).astype(np.uint8)
299 |
300 | # 创建PIL图像
301 | image = Image.fromarray(image_np)
302 |
303 | # 将图像转换为JPEG格式的字节流
304 | buffer = BytesIO()
305 | image.save(buffer, format="JPEG", quality=95)
306 |
307 | # 将字节流转换为base64编码
308 | encoded_image = base64.b64encode(buffer.getvalue()).decode('utf-8')
309 |
310 | # 返回带有MIME类型的data URL
311 | return f"data:image/jpeg;base64,{encoded_image}"
312 |
313 | # 节点映射,用于向ComfyUI注册节点
314 | NODE_CLASS_MAPPINGS = {
315 | "ImageCaptionNode": ImageCaptionNode,
316 | }
317 |
318 | # 节点显示名称映射
319 | NODE_DISPLAY_NAME_MAPPINGS = {
320 | "ImageCaptionNode": "✨图像反推提示词",
321 | }
--------------------------------------------------------------------------------
/node/kontext_preset_node.py:
--------------------------------------------------------------------------------
1 | import asyncio
2 | import random
3 | import time
4 | import threading
5 | import hashlib
6 | import base64
7 | from io import BytesIO
8 | import os
9 | import json
10 |
11 | import torch
12 | import numpy as np
13 | from PIL import Image
14 | from comfy.model_management import InterruptProcessingException
15 |
16 | from ..services.vlm import VisionService
17 | from ..services.error_util import format_api_error
18 |
19 | # 定义ANSI颜色代码常量
20 | GREEN = "\033[92m"
21 | RESET = "\033[0m"
22 |
23 |
24 | class KontextPresetNode:
25 | """
26 | Kontext预设助手节点
27 | 使用Kontext预设分析图像并生成创意转换指令
28 | """
29 | # 定义日志前缀(带绿色)
30 | LOG_PREFIX = f"{GREEN}[PromptAssistant]{RESET}"
31 |
32 | # 缓存配置数据,避免重复从文件系统读取
33 | _kontext_config = None
34 |
35 | @classmethod
36 | def _load_kontext_config(cls):
37 | """加载Kontext配置,使用缓存避免重复读取文件"""
38 | if cls._kontext_config is None:
39 | try:
40 | config_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "config")
41 | kontext_presets_path = os.path.join(config_dir, "kontext_presets.json")
42 | if os.path.exists(kontext_presets_path):
43 | with open(kontext_presets_path, "r", encoding="utf-8") as f:
44 | cls._kontext_config = json.load(f)
45 | else:
46 | cls._kontext_config = {}
47 | except Exception as e:
48 | print(f"{cls.LOG_PREFIX} 加载Kontext配置失败: {str(e)}")
49 | cls._kontext_config = {}
50 | return cls._kontext_config
51 |
52 | @classmethod
53 | def INPUT_TYPES(cls):
54 | # 获取kontext_presets
55 | kontext_presets = {}
56 | config_data = cls._load_kontext_config()
57 | if 'kontext_presets' in config_data:
58 | kontext_presets = config_data['kontext_presets']
59 |
60 | # 构建提示词模板选项
61 | prompt_template_options = []
62 | for key, value in kontext_presets.items():
63 | name = value.get('name', key)
64 | prompt_template_options.append(name)
65 |
66 | # 如果没有选项,添加一个默认选项
67 | if not prompt_template_options:
68 | prompt_template_options = ["情境深度融合"]
69 |
70 | return {
71 | "required": {
72 | "图像": ("IMAGE",),
73 | "Kontext预设": (prompt_template_options, {"default": prompt_template_options[0] if prompt_template_options else "情境深度融合"}),
74 | "视觉服务": (["智谱", "硅基流动", "自定义"], {"default": "智谱"}),
75 | },
76 | }
77 |
78 | RETURN_TYPES = ("STRING",)
79 | RETURN_NAMES = ("创意指令",)
80 | FUNCTION = "analyze_image"
81 | CATEGORY = "✨提示词小助手"
82 | OUTPUT_NODE = False
83 |
84 | @classmethod
85 | def IS_CHANGED(cls, 图像, Kontext预设, 视觉服务):
86 | """
87 | 只在输入内容真正变化时才触发重新执行
88 | 使用输入参数的哈希值作为判断依据
89 | """
90 | # 计算图像的哈希值(只使用第一帧的部分数据,避免计算量过大)
91 | img_hash = ""
92 | if 图像 is not None:
93 | try:
94 | if len(图像.shape) == 4:
95 | # 取第一帧的中心区域作为哈希计算依据
96 | h, w = 图像.shape[1:3]
97 | center_h, center_w = h // 2, w // 2
98 | size = min(100, h // 4, w // 4) # 限制计算区域大小
99 | img_data = 图像[0,
100 | max(0, center_h - size):min(h, center_h + size),
101 | max(0, center_w - size):min(w, center_w + size),
102 | 0].cpu().numpy().tobytes()
103 | img_hash = hashlib.md5(img_data).hexdigest()
104 | else:
105 | # 如果不是4D张量,使用整个张量的哈希
106 | img_data = 图像.cpu().numpy().tobytes()
107 | img_hash = hashlib.md5(img_data).hexdigest()
108 | except Exception:
109 | img_hash = "0"
110 |
111 | # 组合所有输入的哈希值
112 | input_hash = hash((
113 | img_hash,
114 | Kontext预设,
115 | 视觉服务
116 | ))
117 |
118 | return input_hash
119 |
120 | def analyze_image(self, 图像, Kontext预设, 视觉服务):
121 | """
122 | 使用Kontext预设分析图像并生成创意转换指令
123 |
124 | Args:
125 | 图像: 输入的图像数据
126 | Kontext预设: 选择的Kontext预设
127 | 视觉服务: 选择的视觉服务
128 |
129 | Returns:
130 | tuple: 分析结果
131 | """
132 | try:
133 | # 检查输入
134 | if 图像 is None:
135 | raise ValueError("输入图像不能为空")
136 |
137 | # 将图像转换为base64编码
138 | image_data = self._image_to_base64(图像)
139 |
140 | # 获取kontext配置
141 | config_data = self.__class__._load_kontext_config()
142 | kontext_prefix = config_data.get('kontext_prefix', "")
143 | kontext_suffix = config_data.get('kontext_suffix', "")
144 | kontext_presets = config_data.get('kontext_presets', {})
145 |
146 | # 获取提示词模板内容
147 | prompt_template = None
148 |
149 | # 查找选定的提示词模板
150 | template_found = False
151 | for key, value in kontext_presets.items():
152 | if value.get('name') == Kontext预设:
153 | prompt_template = value.get('content')
154 | template_found = True
155 | print(f"{self.LOG_PREFIX} Kontext预设: 使用预设 '{Kontext预设}'")
156 | break
157 |
158 | if not template_found:
159 | print(f"{self.LOG_PREFIX} Kontext预设: 未找到预设 '{Kontext预设}',尝试直接匹配键名")
160 | # 尝试直接匹配键名
161 | for key, value in kontext_presets.items():
162 | if key == Kontext预设 or key == f"kontext_{Kontext预设}":
163 | prompt_template = value.get('content')
164 | template_found = True
165 | print(f"{self.LOG_PREFIX} Kontext预设: 使用预设 '{Kontext预设}'")
166 | break
167 |
168 | # 如果没有找到提示词模板,使用默认值
169 | if not prompt_template:
170 | prompt_template = "Transform the image into a detailed pencil sketch with fine lines and careful shading."
171 | print(f"{self.LOG_PREFIX} Kontext预设: 未找到预设 '{Kontext预设}',使用默认提示词")
172 |
173 | # 构建最终提示词,添加前缀和后缀
174 | final_prompt = prompt_template
175 | if kontext_prefix and kontext_suffix:
176 | final_prompt = f"{kontext_prefix}\n\nThe Brief: {prompt_template}\n\n{kontext_suffix}"
177 | print(f"{self.LOG_PREFIX} Kontext预设: 添加前缀和后缀")
178 |
179 | # 执行图像分析
180 | print(f"{self.LOG_PREFIX} 开始Kontext预设分析: {视觉服务}")
181 |
182 | # 映射视觉服务选项到provider
183 | provider_map = {
184 | "智谱": "zhipu",
185 | "硅基流动": "siliconflow",
186 | "自定义": "custom"
187 | }
188 |
189 | # 获取选定的provider
190 | selected_provider = provider_map.get(视觉服务)
191 | if not selected_provider:
192 | raise ValueError(f"不支持的视觉服务: {视觉服务}")
193 |
194 | # 获取对应provider的配置
195 | from ..config_manager import config_manager
196 | provider_config = self._get_provider_config(config_manager, selected_provider)
197 | if not provider_config:
198 | raise ValueError(f"未找到{视觉服务}的配置,请先完成API配置")
199 |
200 | print(f"{self.LOG_PREFIX} Kontext预设: 使用{视觉服务}服务, API: {provider_config.get('model')}")
201 |
202 | # 执行图像分析
203 | result = self._analyze_with_vision_service(image_data, final_prompt, selected_provider, provider_config)
204 |
205 | if result and result.get('success'):
206 | description = result.get('data', {}).get('description', '').strip()
207 | if not description:
208 | error_msg = 'API返回结果为空,请检查API密钥、模型配置或网络连接'
209 | print(f"{self.LOG_PREFIX} Kontext预设分析失败: {error_msg}")
210 | raise RuntimeError(f"分析失败: {error_msg}")
211 |
212 | print(f"{self.LOG_PREFIX} Kontext预设分析完成,结果长度: {len(description)}")
213 | return (description,)
214 | else:
215 | error_msg = result.get('error', '分析失败,未知错误') if result else '分析服务未返回结果'
216 | print(f"{self.LOG_PREFIX} Kontext预设分析失败: {error_msg}")
217 | raise RuntimeError(f"分析失败: {error_msg}")
218 |
219 | except InterruptProcessingException:
220 | print(f"{self.LOG_PREFIX} Kontext预设任务被用户取消")
221 | raise
222 | except Exception as e:
223 | error_msg = format_api_error(e, 视觉服务)
224 | print(f"{self.LOG_PREFIX} Kontext预设节点异常: {error_msg}")
225 | raise RuntimeError(f"分析异常: {error_msg}")
226 |
227 | def _analyze_with_vision_service(self, image_data, prompt_template, provider, provider_config):
228 | """使用视觉服务分析图像"""
229 | try:
230 | # 创建请求ID
231 | request_id = f"kontext_preset_{int(time.time())}_{random.randint(1000, 9999)}"
232 | result_container = {}
233 |
234 | # 在独立线程中运行图像分析
235 | thread = threading.Thread(
236 | target=self._run_async_vision_analysis,
237 | args=(image_data, prompt_template, request_id, result_container, provider, provider_config)
238 | )
239 | thread.start()
240 |
241 | # 等待分析完成,同时检查中断
242 | while thread.is_alive():
243 | # 检查是否被中断 - 这会抛出 InterruptProcessingException
244 | try:
245 | import nodes
246 | nodes.before_node_execution()
247 | except:
248 | # 如果检查中断时出现异常,说明被中断了
249 | print(f"{self.LOG_PREFIX} 检测到中断信号,正在终止分析任务...")
250 | # 设置结果容器为中断状态,让线程知道要停止
251 | result_container['interrupted'] = True
252 | # 等待线程结束或超时
253 | thread.join(timeout=1.0)
254 | if thread.is_alive():
255 | print(f"{self.LOG_PREFIX} 分析线程未能及时响应中断")
256 | raise InterruptProcessingException()
257 |
258 | time.sleep(0.1)
259 |
260 | return result_container.get('result')
261 |
262 | except Exception as e:
263 | return {"success": False, "error": str(e)}
264 |
265 | def _run_async_vision_analysis(self, image_data, prompt_template, request_id, result_container, provider, provider_config):
266 | """在独立线程中运行异步图像分析任务"""
267 | loop = asyncio.new_event_loop()
268 | asyncio.set_event_loop(loop)
269 | try:
270 | # 检查是否在开始前就被中断了
271 | if result_container.get('interrupted'):
272 | result_container['result'] = {"success": False, "error": "任务被中断"}
273 | return
274 |
275 | result = loop.run_until_complete(
276 | VisionService.analyze_image(image_data, request_id, None, prompt_template, provider, provider_config)
277 | )
278 |
279 | # 检查是否在执行过程中被中断了
280 | if result_container.get('interrupted'):
281 | result_container['result'] = {"success": False, "error": "任务被中断"}
282 | return
283 |
284 | result_container['result'] = result
285 | except Exception as e:
286 | if result_container.get('interrupted'):
287 | result_container['result'] = {"success": False, "error": "任务被中断"}
288 | else:
289 | result_container['result'] = {"success": False, "error": str(e)}
290 | finally:
291 | loop.close()
292 |
293 | def _get_provider_config(self, config_manager, provider):
294 | """获取指定provider的配置"""
295 | vision_config = config_manager.get_vision_config()
296 | if 'providers' in vision_config and provider in vision_config['providers']:
297 | return vision_config['providers'][provider]
298 | return None
299 |
300 | def _image_to_base64(self, image_tensor):
301 | """将图像张量转换为base64编码"""
302 | # 确保图像是正确的形状 [batch, height, width, channels]
303 | if len(image_tensor.shape) == 4:
304 | # 取第一张图片
305 | image_tensor = image_tensor[0]
306 |
307 | # 将图像转换为numpy数组并缩放到0-255范围
308 | image_np = (image_tensor.cpu().numpy() * 255).astype(np.uint8)
309 |
310 | # 创建PIL图像
311 | image = Image.fromarray(image_np)
312 |
313 | # 将图像转换为JPEG格式的字节流
314 | buffer = BytesIO()
315 | image.save(buffer, format="JPEG", quality=95)
316 |
317 | # 将字节流转换为base64编码
318 | encoded_image = base64.b64encode(buffer.getvalue()).decode('utf-8')
319 |
320 | # 返回带有MIME类型的data URL
321 | return f"data:image/jpeg;base64,{encoded_image}"
322 |
323 | # 节点映射,用于向ComfyUI注册节点
324 | NODE_CLASS_MAPPINGS = {
325 | "KontextPresetNode": KontextPresetNode,
326 | }
327 |
328 | # 节点显示名称映射
329 | NODE_DISPLAY_NAME_MAPPINGS = {
330 | "KontextPresetNode": "✨Kontext预设",
331 | }
--------------------------------------------------------------------------------
/node/translate_node.py:
--------------------------------------------------------------------------------
1 | import asyncio
2 | import random
3 | import re
4 | import time
5 | import threading
6 | import hashlib
7 |
8 | import torch
9 | from comfy.model_management import InterruptProcessingException
10 |
11 | from ..services.llm import LLMService
12 | from ..services.baidu import BaiduTranslateService
13 | from ..services.error_util import format_api_error
14 |
15 |
16 | # 定义ANSI颜色代码常量
17 | GREEN = "\033[92m"
18 | RESET = "\033[0m"
19 |
20 |
21 | class PromptTranslate:
22 | """
23 | 文本翻译节点
24 | 自动识别输入语言并翻译成目标语言,支持多种翻译服务
25 | """
26 | # 定义日志前缀(带绿色)
27 | LOG_PREFIX = f"{GREEN}[PromptAssistant]{RESET}"
28 |
29 | @classmethod
30 | def INPUT_TYPES(cls):
31 | return {
32 | "required": {
33 | "原文": ("STRING", {"forceInput": True, "default": "", "multiline": True, "placeholder": "输入要翻译的文本..."}),
34 | "目标语言": (["英文", "中文"], {"default": "英文"}),
35 | "翻译服务": (["百度翻译", "智谱翻译", "硅基流动翻译", "自定义翻译"], {"default": "百度翻译"}),
36 | },
37 | }
38 |
39 | RETURN_TYPES = ("STRING",)
40 | RETURN_NAMES = ("翻译输出",)
41 | FUNCTION = "translate"
42 | CATEGORY = "✨提示词小助手"
43 | OUTPUT_NODE = False
44 |
45 | @classmethod
46 | def IS_CHANGED(cls, 原文, 目标语言, 翻译服务):
47 | """
48 | 只在输入内容真正变化时才触发重新执行
49 | 使用输入参数的哈希值作为判断依据
50 | """
51 | # 计算文本的哈希值
52 | text_hash = ""
53 | if 原文:
54 | # 使用hashlib计算文本的哈希值,更安全和一致
55 | text_hash = hashlib.md5(原文.encode('utf-8')).hexdigest()
56 |
57 | # 组合所有输入的哈希值
58 | input_hash = hash((
59 | text_hash,
60 | 目标语言,
61 | 翻译服务
62 | ))
63 |
64 | return input_hash
65 |
66 | def _contains_chinese(self, text: str) -> bool:
67 | """检查文本是否包含中文字符"""
68 | if not text:
69 | return False
70 | return bool(re.search('[\u4e00-\u9fa5]', text))
71 |
72 | def _detect_language(self, text: str) -> str:
73 | """自动检测文本语言"""
74 | if not text:
75 | return "auto"
76 |
77 | # 检查是否为纯英文 (只包含ASCII可打印字符)
78 | is_pure_english = bool(re.fullmatch(r'[ -~]+', text))
79 | # 检查是否包含中文字符
80 | contains_chinese = self._contains_chinese(text)
81 |
82 | if contains_chinese:
83 | return "zh"
84 | elif is_pure_english:
85 | return "en"
86 | else:
87 | return "auto"
88 |
89 | def translate(self, 原文, 目标语言, 翻译服务):
90 | """
91 | 翻译文本函数
92 |
93 | Args:
94 | 原文: 输入的文本
95 | 目标语言: 目标语言 ("英文", "中文")
96 | 翻译服务: 翻译服务 ("百度翻译", "智谱翻译")
97 |
98 | Returns:
99 | tuple: 翻译结果
100 | """
101 | try:
102 | # 检查输入
103 | if not 原文 or not 原文.strip():
104 | return ("",)
105 |
106 | # 自动检测源语言
107 | detected_lang = self._detect_language(原文)
108 | to_lang = "en" if 目标语言 == "英文" else "zh"
109 |
110 | # 智能跳过翻译逻辑
111 | skip_translation = False
112 | if to_lang == 'en' and detected_lang == 'en':
113 | print(f"{self.LOG_PREFIX} 检测到英文输入,目标为英文,无需翻译。")
114 | skip_translation = True
115 | elif to_lang == 'zh' and detected_lang == 'zh':
116 | print(f"{self.LOG_PREFIX} 检测到中文输入,目标为中文,无需翻译。")
117 | skip_translation = True
118 |
119 | if skip_translation:
120 | return (原文,)
121 |
122 | # 执行翻译
123 | print(f"{self.LOG_PREFIX} 开始翻译: {detected_lang} -> {to_lang}, 服务: {翻译服务}")
124 |
125 | if 翻译服务 == "百度翻译":
126 | result = self._translate_with_baidu(原文, detected_lang, to_lang)
127 | elif 翻译服务 == "智谱翻译":
128 | result = self._translate_with_llm(原文, detected_lang, to_lang, "zhipu")
129 | elif 翻译服务 == "硅基流动翻译":
130 | result = self._translate_with_llm(原文, detected_lang, to_lang, "siliconflow")
131 | elif 翻译服务 == "自定义翻译":
132 | result = self._translate_with_llm(原文, detected_lang, to_lang, "custom")
133 | else:
134 | raise ValueError(f"不支持的翻译服务: {翻译服务}")
135 |
136 | if result and result.get('success'):
137 | translated_text = result.get('data', {}).get('translated', '').strip()
138 | if not translated_text:
139 | error_msg = 'API返回结果为空,请检查API密钥、模型配置或网络连接'
140 | print(f"{self.LOG_PREFIX} 翻译失败: {error_msg}")
141 | raise RuntimeError(f"翻译失败: {error_msg}")
142 |
143 | print(f"{self.LOG_PREFIX} 翻译完成,结果长度: {len(translated_text)}")
144 | return (translated_text,)
145 | else:
146 | error_msg = result.get('error', '翻译失败,未知错误') if result else '翻译服务未返回结果'
147 | print(f"{self.LOG_PREFIX} 翻译失败: {error_msg}")
148 | raise RuntimeError(f"翻译失败: {error_msg}")
149 |
150 | except InterruptProcessingException:
151 | print(f"{self.LOG_PREFIX} 翻译任务被用户取消")
152 | raise
153 | except Exception as e:
154 | error_msg = format_api_error(e, 翻译服务)
155 | print(f"{self.LOG_PREFIX} 翻译异常: {error_msg}")
156 | raise RuntimeError(f"翻译异常: {error_msg}")
157 |
158 | def _translate_with_baidu(self, text, from_lang, to_lang):
159 | """使用百度翻译服务"""
160 | try:
161 | # 创建请求ID
162 | request_id = f"translate_{int(time.time())}_{random.randint(1000, 9999)}"
163 | result_container = {}
164 |
165 | # 在独立线程中运行异步翻译
166 | thread = threading.Thread(
167 | target=self._run_async_translation,
168 | args=(BaiduTranslateService.translate, text, from_lang, to_lang, request_id, result_container)
169 | )
170 | thread.start()
171 |
172 | # 等待翻译完成,同时检查中断
173 | while thread.is_alive():
174 | # 检查是否被中断 - 这会抛出 InterruptProcessingException
175 | try:
176 | import nodes
177 | nodes.before_node_execution()
178 | except:
179 | # 如果检查中断时出现异常,说明被中断了
180 | print(f"{self.LOG_PREFIX} 检测到中断信号,正在终止翻译任务...")
181 | # 设置结果容器为中断状态,让线程知道要停止
182 | result_container['interrupted'] = True
183 | # 等待线程结束或超时
184 | thread.join(timeout=1.0)
185 | if thread.is_alive():
186 | print(f"{self.LOG_PREFIX} 翻译线程未能及时响应中断")
187 | raise InterruptProcessingException()
188 |
189 | time.sleep(0.1)
190 |
191 | return result_container.get('result')
192 |
193 | except Exception as e:
194 | return {"success": False, "error": str(e)}
195 |
196 | def _translate_with_llm(self, text, from_lang, to_lang, provider):
197 | """使用LLM翻译服务"""
198 | try:
199 | # 获取配置
200 | from ..config_manager import config_manager
201 | provider_config = self._get_provider_config(config_manager, provider)
202 |
203 | if not provider_config:
204 | provider_display_map = {
205 | "zhipu": "智谱翻译",
206 | "siliconflow": "硅基流动翻译",
207 | "custom": "自定义翻译"
208 | }
209 | provider_display_name = provider_display_map.get(provider, provider)
210 | return {"success": False, "error": f"未找到{provider_display_name}的配置,请先完成API配置"}
211 |
212 | # 创建请求ID
213 | request_id = f"translate_{int(time.time())}_{random.randint(1000, 9999)}"
214 | result_container = {}
215 |
216 | # 在独立线程中运行LLM翻译
217 | thread = threading.Thread(
218 | target=self._run_llm_translation,
219 | args=(text, from_lang, to_lang, request_id, result_container, provider, provider_config)
220 | )
221 | thread.start()
222 |
223 | # 等待翻译完成,同时检查中断
224 | while thread.is_alive():
225 | # 检查是否被中断 - 这会抛出 InterruptProcessingException
226 | try:
227 | import nodes
228 | nodes.before_node_execution()
229 | except:
230 | # 如果检查中断时出现异常,说明被中断了
231 | print(f"{self.LOG_PREFIX} 检测到中断信号,正在终止翻译任务...")
232 | # 设置结果容器为中断状态,让线程知道要停止
233 | result_container['interrupted'] = True
234 | # 等待线程结束或超时
235 | thread.join(timeout=1.0)
236 | if thread.is_alive():
237 | print(f"{self.LOG_PREFIX} 翻译线程未能及时响应中断")
238 | raise InterruptProcessingException()
239 |
240 | time.sleep(0.1)
241 |
242 | return result_container.get('result')
243 |
244 | except Exception as e:
245 | return {"success": False, "error": str(e)}
246 |
247 | def _run_async_translation(self, service_func, text, from_lang, to_lang, request_id, result_container, **kwargs):
248 | """在独立线程中运行异步翻译任务"""
249 | loop = asyncio.new_event_loop()
250 | asyncio.set_event_loop(loop)
251 | try:
252 | # 检查是否在开始前就被中断了
253 | if result_container.get('interrupted'):
254 | result_container['result'] = {"success": False, "error": "任务被中断"}
255 | return
256 |
257 | result = loop.run_until_complete(
258 | service_func(text, from_lang, to_lang, request_id, **kwargs)
259 | )
260 |
261 | # 检查是否在执行过程中被中断了
262 | if result_container.get('interrupted'):
263 | result_container['result'] = {"success": False, "error": "任务被中断"}
264 | return
265 |
266 | result_container['result'] = result
267 | except Exception as e:
268 | if result_container.get('interrupted'):
269 | result_container['result'] = {"success": False, "error": "任务被中断"}
270 | else:
271 | result_container['result'] = {"success": False, "error": str(e)}
272 | finally:
273 | loop.close()
274 |
275 | def _get_provider_config(self, config_manager, provider):
276 | """获取指定provider的配置"""
277 | llm_config = config_manager.get_llm_config()
278 | if 'providers' in llm_config and provider in llm_config['providers']:
279 | return llm_config['providers'][provider]
280 | return None
281 |
282 | def _run_llm_translation(self, text, from_lang, to_lang, request_id, result_container, provider, provider_config):
283 | """在独立线程中运行LLM翻译"""
284 | loop = asyncio.new_event_loop()
285 | asyncio.set_event_loop(loop)
286 | try:
287 | # 检查是否在开始前就被中断了
288 | if result_container.get('interrupted'):
289 | result_container['result'] = {"success": False, "error": "任务被中断"}
290 | return
291 |
292 | # 获取API密钥和模型
293 | api_key = provider_config.get('api_key', '')
294 | model = provider_config.get('model', '')
295 |
296 | if not api_key or not model:
297 | result_container['result'] = {
298 | "success": False,
299 | "error": f"请先配置{provider}的API密钥和模型"
300 | }
301 | return
302 |
303 | # 创建临时客户端
304 | client = LLMService.get_openai_client(api_key, provider)
305 |
306 | # 加载系统提示词
307 | from ..config_manager import config_manager
308 | system_prompts = config_manager.get_system_prompts()
309 |
310 | if not system_prompts or 'translate_prompts' not in system_prompts or 'ZH' not in system_prompts['translate_prompts']:
311 | result_container['result'] = {
312 | "success": False,
313 | "error": "翻译系统提示词加载失败"
314 | }
315 | return
316 |
317 | system_message = system_prompts['translate_prompts']['ZH']
318 |
319 | # 动态替换提示词
320 | lang_map = {'zh': '中文', 'en': '英文', 'auto': '原文'}
321 | src_lang = lang_map.get(from_lang, from_lang)
322 | dst_lang = lang_map.get(to_lang, to_lang)
323 | sys_msg_content = system_message['content'].replace('{src_lang}', src_lang).replace('{dst_lang}', dst_lang)
324 | sys_msg = {"role": "system", "content": sys_msg_content}
325 |
326 | # 设置输出语言
327 | lang_message = {"role": "system", "content": "Please answer in English."} if to_lang == 'en' else {"role": "system", "content": "请用中文回答"}
328 |
329 | # 构建消息
330 | messages = [
331 | lang_message,
332 | sys_msg,
333 | {"role": "user", "content": text}
334 | ]
335 |
336 | print(f"{PromptTranslate.LOG_PREFIX} 调用{provider}翻译API | 模型:{model}")
337 |
338 | # 调用API
339 | stream = loop.run_until_complete(client.chat.completions.create(
340 | model=model,
341 | messages=[{"role": m["role"], "content": m["content"]} for m in messages],
342 | temperature=0.5,
343 | top_p=0.5,
344 | max_tokens=1500,
345 | stream=True,
346 | response_format={"type": "text"}
347 | ))
348 |
349 | full_content = ""
350 | async def process_stream():
351 | nonlocal full_content
352 | async for chunk in stream:
353 | # 检查是否被中断
354 | if result_container.get('interrupted'):
355 | break
356 | if chunk.choices[0].delta.content:
357 | content = chunk.choices[0].delta.content
358 | full_content += content
359 |
360 | loop.run_until_complete(process_stream())
361 |
362 | # 检查是否在执行过程中被中断了
363 | if result_container.get('interrupted'):
364 | result_container['result'] = {"success": False, "error": "任务被中断"}
365 | return
366 |
367 | result_container['result'] = {
368 | "success": True,
369 | "data": {
370 | "from": from_lang,
371 | "to": to_lang,
372 | "original": text,
373 | "translated": full_content
374 | }
375 | }
376 | print(f"{PromptTranslate.LOG_PREFIX} 翻译完成 | 服务:{provider} | 结果字符数:{len(full_content)}")
377 |
378 | except Exception as e:
379 | # 格式化错误信息
380 | provider_display_map = {
381 | "zhipu": "智谱翻译",
382 | "siliconflow": "硅基流动翻译",
383 | "custom": "自定义翻译"
384 | }
385 | provider_display_name = provider_display_map.get(provider, provider)
386 | error_message = format_api_error(e, provider_display_name)
387 |
388 | result_container['result'] = {
389 | "success": False,
390 | "error": error_message
391 | }
392 | print(f"{PromptTranslate.LOG_PREFIX} 翻译失败 | 服务:{provider} | 错误:{error_message}")
393 | finally:
394 | loop.close()
395 |
396 | # 节点映射,用于向ComfyUI注册节点
397 | NODE_CLASS_MAPPINGS = {
398 | "PromptTranslate": PromptTranslate,
399 | }
400 |
401 | # 节点显示名称映射
402 | NODE_DISPLAY_NAME_MAPPINGS = {
403 | "PromptTranslate": "✨提示词翻译",
404 | }
--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------
1 | [project]
2 | name = "prompt-assistant"
3 | version = "1.1.3"
4 | description = "提示词小助手可以一键调用智谱、硅基流动、gemini、本地ollama、百度等大语言模型服务,实现提示词翻译、润色扩写、图片反推。支持提示词预设实现一键插入、历史提示词查找等功能。是一个全能型提示词插件。The Prompt Assistant enables one-click access to LLMs/VLMs for prompt translation, expansion, and image aptioning. It also supports one-click preset insertion and historical prompt search."
5 | readme = "README.md"
6 | license = {text = "GNU General Public License v3"}
7 | classifiers = [
8 | "Operating System :: OS Independent"
9 | ]
10 |
11 | [project.urls]
12 | Repository = "https://github.com/yawiii/comfyui_prompt_assistant"
13 |
14 |
15 | [tool.setuptools.dynamic]
16 | dependencies = {file = ["requirements.txt"]}
17 |
18 | [tool.comfy]
19 |
20 | PublisherId = "yawiii"
21 | DisplayName = "Prompt Assistant"
22 | Icon = ""
23 |
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | openai>=1.12.0
2 | aiohttp>=3.8.0
3 |
--------------------------------------------------------------------------------
/services/__init__.py:
--------------------------------------------------------------------------------
1 | # 导出服务类
2 | from .baidu import BaiduTranslateService
3 | from .llm import LLMService
4 | from .vlm import VisionService
5 |
6 | __all__ = ['BaiduTranslateService', 'LLMService', 'VisionService']
--------------------------------------------------------------------------------
/services/baidu.py:
--------------------------------------------------------------------------------
1 | import random
2 | import aiohttp
3 | from hashlib import md5
4 | import json
5 | import time
6 | import re
7 | from typing import Optional, Dict, Any, List
8 | import asyncio
9 | from .error_util import BAIDU_ERROR_CODE_MESSAGES
10 |
11 | class BaiduTranslateService:
12 | @staticmethod
13 | def split_text_by_paragraphs(text, max_length=2000):
14 | """
15 | 按段落分割文本,处理长文本翻译
16 | 使用正则表达式匹配连续的换行符,与JavaScript版本保持一致
17 | """
18 | if not text:
19 | return []
20 |
21 | # 保留原始的换行符,用于后续恢复格式
22 | # 先将文本按换行符分割为行
23 | lines = text.split('\n')
24 | chunks = []
25 | current_chunk = ""
26 |
27 | for line in lines:
28 | # 如果当前行本身超过最大长度,需要再次分割
29 | if len(line) > max_length:
30 | # 如果current_chunk不为空,先添加到chunks
31 | if current_chunk:
32 | chunks.append(current_chunk)
33 | current_chunk = ""
34 |
35 | # 分割长行
36 | remaining_text = line
37 | while len(remaining_text) > 0:
38 | chunk_text = remaining_text[:max_length]
39 | chunks.append(chunk_text)
40 | remaining_text = remaining_text[max_length:]
41 | # 如果添加当前行会超出长度限制,先保存当前chunk
42 | elif current_chunk and (len(current_chunk) + len(line) + 1 > max_length):
43 | chunks.append(current_chunk)
44 | current_chunk = line
45 | # 否则,添加到当前chunk
46 | else:
47 | if current_chunk:
48 | current_chunk += "\n" + line
49 | else:
50 | current_chunk = line
51 |
52 | # 添加最后一个chunk
53 | if current_chunk:
54 | chunks.append(current_chunk)
55 |
56 | return chunks
57 |
58 | @staticmethod
59 | async def translate_chunk(session, chunk, app_id, secret_key, from_lang, to_lang, retry_count=1):
60 | """
61 | 异步翻译单个文本块,带重试机制
62 | """
63 | for attempt in range(retry_count):
64 | try:
65 | # 生成签名
66 | salt = random.randint(32768, 65536)
67 | sign = md5((app_id + chunk + str(salt) + secret_key).encode('utf-8')).hexdigest()
68 |
69 | # 构建请求
70 | url = 'https://fanyi-api.baidu.com/api/trans/vip/translate'
71 | params = {
72 | 'q': chunk,
73 | 'from': from_lang,
74 | 'to': to_lang,
75 | 'appid': app_id,
76 | 'salt': salt,
77 | 'sign': sign
78 | }
79 |
80 | # 发送异步请求
81 | async with session.post(url, data=params, timeout=10) as response:
82 | if response.status != 200:
83 | if attempt < retry_count - 1:
84 | await asyncio.sleep(1)
85 | continue
86 | raise Exception(f"百度: HTTP请求失败,状态码: {response.status}")
87 |
88 | result = await response.json()
89 |
90 | # 检查错误
91 | if 'error_code' in result:
92 | error_code = result['error_code']
93 | error_message = BAIDU_ERROR_CODE_MESSAGES.get(
94 | error_code,
95 | f"未知错误(错误码:{error_code})"
96 | )
97 |
98 | # 某些错误码可以重试
99 | if error_code in ['54003', '52001', '52002'] and attempt < retry_count - 1:
100 | await asyncio.sleep(1)
101 | continue
102 |
103 | raise Exception(f"百度: {error_message}")
104 |
105 | # 处理翻译结果
106 | if 'trans_result' in result and result['trans_result']:
107 | translated_parts = [item['dst'] for item in result['trans_result']]
108 | return '\n'.join(translated_parts)
109 | else:
110 | raise Exception("百度: 翻译结果为空")
111 |
112 | except (aiohttp.ClientError, asyncio.TimeoutError) as e:
113 | if attempt < retry_count - 1:
114 | print(f"百度翻译请求遇到网络错误,尝试重试 ({attempt+1}/{retry_count}): {e}")
115 | await asyncio.sleep(1)
116 | else:
117 | raise Exception(f"百度: 网络请求失败,请检查网络连接或稍后再试 ({type(e).__name__})")
118 | except Exception as e:
119 | if attempt < retry_count - 1:
120 | await asyncio.sleep(1)
121 | else:
122 | if str(e).startswith("百度:"):
123 | raise e
124 | else:
125 | raise Exception(f"百度: 翻译过程中发生未知错误 ({type(e).__name__})")
126 |
127 | raise Exception("百度: 超过最大重试次数")
128 |
129 | @staticmethod
130 | async def translate(text, from_lang='auto', to_lang='zh', request_id=None, is_auto=False):
131 | """
132 | 异步调用百度翻译API进行翻译
133 | """
134 | try:
135 | request_id = request_id or f"baidu_trans_{int(time.time())}_{random.randint(1000, 9999)}"
136 |
137 | if not text or text.strip() == '':
138 | return {"success": False, "error": "百度: 待翻译文本不能为空"}
139 |
140 | from ..config_manager import config_manager
141 | config = config_manager.get_baidu_translate_config()
142 |
143 | app_id = config.get('app_id')
144 | secret_key = config.get('secret_key')
145 |
146 | if not app_id or not secret_key:
147 | return {"success": False, "error": "百度: 请先配置百度翻译API的APP_ID和SECRET_KEY"}
148 |
149 | from ..server import PREFIX, AUTO_TRANSLATE_PREFIX
150 |
151 | prefix = AUTO_TRANSLATE_PREFIX if is_auto else PREFIX
152 | print(f"{prefix} {'工作流自动翻译' if is_auto else '翻译请求'} | 服务:百度翻译 | 请求ID:{request_id} | 原文长度:{len(text)} | 方向:{from_lang}->{to_lang}")
153 |
154 | text_chunks = BaiduTranslateService.split_text_by_paragraphs(text)
155 | if not text_chunks:
156 | text_chunks = [text]
157 |
158 | translated_parts = []
159 |
160 | # 创建一个禁用SSL证书验证的连接器
161 | connector = aiohttp.TCPConnector(ssl=False)
162 | # 创建 aiohttp.ClientSession,并禁用代理和SSL验证
163 | async with aiohttp.ClientSession(connector=connector, trust_env=False) as session:
164 | for i, chunk in enumerate(text_chunks):
165 | try:
166 | chunk_translation = await BaiduTranslateService.translate_chunk(
167 | session, chunk, app_id, secret_key, from_lang, to_lang
168 | )
169 | translated_parts.append(chunk_translation)
170 |
171 | if i < len(text_chunks) - 1:
172 | await asyncio.sleep(1)
173 |
174 | except Exception as chunk_error:
175 | return {"success": False, "error": str(chunk_error)}
176 |
177 | translated_text = '\n'.join(translated_parts)
178 | prefix = AUTO_TRANSLATE_PREFIX if is_auto else PREFIX
179 | print(f"{prefix} {'工作流翻译完成' if is_auto else '翻译完成'} | 服务:百度翻译 | 请求ID:{request_id} | 结果字符数:{len(translated_text)}")
180 |
181 | return {
182 | "success": True,
183 | "data": {
184 | "translated": translated_text,
185 | "from": from_lang,
186 | "to": to_lang,
187 | "original": text
188 | }
189 | }
190 |
191 | except Exception as e:
192 | return {"success": False, "error": str(e)}
193 |
194 | @staticmethod
195 | async def batch_translate(texts, from_lang='auto', to_lang='zh'):
196 | """
197 | 异步批量翻译文本
198 | """
199 | tasks = [BaiduTranslateService.translate(text, from_lang, to_lang) for text in texts]
200 | return await asyncio.gather(*tasks)
--------------------------------------------------------------------------------
/services/error_util.py:
--------------------------------------------------------------------------------
1 | import json
2 | from openai import APIStatusError
3 |
4 | # 定义HTTP状态码到中文错误信息的映射
5 | HTTP_STATUS_CODE_MESSAGES = {
6 | 400: "请求无效",
7 | 401: "身份验证失败-请检查您的API Key是否正确。",
8 | 403: "无权限访问-您没有权限访问此资源。",
9 | 404: "请求的资源不存",
10 | 429: "请求频率过高-您已超出速率限制,请稍后再试。",
11 | 500: "服务器内部错误- 服务提供商端发生未知问题。",
12 | 502: "网关错误",
13 | 503: "服务不可用- 服务器当前无法处理请求,请稍后重试。",
14 | 504: "网关超时",
15 | }
16 |
17 | # 定义百度翻译API的错误码映射
18 | BAIDU_ERROR_CODE_MESSAGES = {
19 | '52001': '请求超时,请重试',
20 | '52002': '系统错误,请重试',
21 | '52003': '未授权用户,请检查appid是否正确或服务是否开通',
22 | '54000': '必填参数为空,请检查是否少传参数',
23 | '54001': '签名错误,请检查appid和secret_key是否正确',
24 | '54003': '访问频率受限,请降低您的调用频率,或进行身份认证后切换为高级版/尊享版',
25 | '54004': '账户余额不足,请前往管理控制台充值',
26 | '54005': '长query请求频繁,请降低长query的发送频率,3s后再试',
27 | '58000': '客户端IP非法,检查个人资料里填写的IP地址是否正确,可前往开发者信息-基本信息修改',
28 | '58001': '译文语言方向不支持,检查译文语言是否在语言列表里',
29 | '58002': '服务当前已关闭,请前往百度管理控制台开启服务',
30 | '58003': '此IP已被封禁',
31 | '90107': '认证未通过或未生效,请前往我的认证查看认证进度',
32 | '20003': '请求内容存在安全风险',
33 | }
34 |
35 |
36 | def format_api_error(e: Exception, provider_display_name: str) -> str:
37 | """
38 | 格式化来自API的错误信息,提供更详细的上下文。
39 | """
40 | # 优先处理openai的API状态错误
41 | if isinstance(e, APIStatusError):
42 | status_code = e.status_code
43 | message = HTTP_STATUS_CODE_MESSAGES.get(status_code, f"未知HTTP错误")
44 |
45 | error_details_str = ""
46 | try:
47 | # 尝试解析响应体中的详细错误信息
48 | error_details = e.response.json()
49 | # 提取关键信息,兼容不同服务商的格式
50 | # 常见格式: {"error": {"message": "...", "type": "...", "code": "..."}}
51 | # 或: {"message": "..."}
52 | detail_msg = error_details.get("message", "")
53 | if isinstance(error_details.get("error"), dict):
54 | detail_msg = error_details["error"].get("message", detail_msg)
55 |
56 | if detail_msg:
57 | error_details_str = f" | 详情: {detail_msg}"
58 | except (json.JSONDecodeError, AttributeError):
59 | # 如果解析失败或没有响应体,使用原始响应文本
60 | try:
61 | if e.response and hasattr(e.response, 'text') and e.response.text:
62 | error_details_str = f" | 原始响应: {e.response.text}"
63 | except Exception:
64 | pass # 忽略获取原始响应的错误
65 |
66 | return f"{provider_display_name} API错误: {message} (状态码: {status_code}){error_details_str}"
67 |
68 | # 对于其他类型的异常,例如网络连接错误,返回其类型和基本信息
69 | return f"{provider_display_name} 服务请求异常: ({type(e).__name__}) {str(e)}"
70 |
71 | def format_baidu_translate_error(error_data: dict) -> str:
72 | """
73 | 格式化百度翻译API的错误信息。
74 | """
75 | if not isinstance(error_data, dict):
76 | return "未知的百度翻译错误格式"
77 |
78 | error_code = str(error_data.get('error_code'))
79 | if error_code in BAIDU_ERROR_CODE_MESSAGES:
80 | return f"百度翻译错误: {BAIDU_ERROR_CODE_MESSAGES[error_code]} (代码: {error_code})"
81 |
82 | error_msg = error_data.get('error_msg', '未知错误')
83 | return f"百度翻译错误: {error_msg} (代码: {error_code})"
--------------------------------------------------------------------------------
/services/llm.py:
--------------------------------------------------------------------------------
1 | import json
2 | import os
3 | import sys
4 | from typing import Optional, Dict, Any, List, Callable
5 | import asyncio
6 | from openai import AsyncOpenAI
7 | from openai.types.chat import ChatCompletion
8 | import httpx
9 | from .error_util import format_api_error
10 |
11 | class LLMService:
12 | _provider_base_urls = {
13 | 'openai': None, # 使用默认
14 | 'siliconflow': 'https://api.siliconflow.cn/v1',
15 | 'zhipu': 'https://open.bigmodel.cn/api/paas/v4',
16 | 'custom': None # 使用配置中的自定义URL
17 | }
18 |
19 | @classmethod
20 | def get_openai_client(cls, api_key: str, provider: str) -> AsyncOpenAI:
21 | """获取OpenAI客户端"""
22 | # 否则创建新客户端
23 | base_url = None
24 |
25 | # 如果是自定义提供商,从配置中获取base_url
26 | if provider == 'custom':
27 | from ..config_manager import config_manager
28 | config = config_manager.get_llm_config()
29 | if 'providers' in config and 'custom' in config['providers']:
30 | base_url = config['providers']['custom'].get('base_url')
31 | # 确保base_url不以/chat/completions结尾,避免路径重复
32 | if base_url and base_url.endswith('/chat/completions'):
33 | base_url = base_url.rstrip('/chat/completions')
34 | else:
35 | base_url = cls._provider_base_urls.get(provider)
36 |
37 | # 创建简化的httpx客户端,不使用HTTP/2,避免额外依赖
38 | http_client = httpx.AsyncClient(
39 | timeout=httpx.Timeout(15.0) # 设置超时时间,稍微增加以适应网络延迟
40 | )
41 |
42 | kwargs = {
43 | "api_key": api_key,
44 | "http_client": http_client,
45 | "max_retries": 2 # 设置最大重试次数
46 | }
47 | if base_url:
48 | # 确保base_url末尾没有斜杠
49 | base_url = base_url.rstrip('/')
50 | kwargs["base_url"] = base_url
51 |
52 | # 添加调试日志
53 | from ..server import PREFIX
54 | print(f"{PREFIX} 创建OpenAI客户端 | 提供商:{provider} | 基础URL:{base_url}")
55 |
56 | # 创建客户端并缓存
57 | client = AsyncOpenAI(**kwargs)
58 | return client
59 |
60 | @staticmethod
61 | def _get_config() -> Dict[str, Any]:
62 | """获取LLM配置"""
63 | from ..config_manager import config_manager
64 | config = config_manager.get_llm_config()
65 | current_provider = config.get('provider')
66 |
67 | # 获取实际配置
68 | if 'providers' in config and current_provider in config['providers']:
69 | provider_config = config['providers'][current_provider]
70 | return {
71 | 'provider': current_provider,
72 | 'model': provider_config.get('model', ''),
73 | 'base_url': provider_config.get('base_url', ''),
74 | 'api_key': provider_config.get('api_key', ''),
75 | 'temperature': provider_config.get('temperature', 0.7),
76 | 'top_p': provider_config.get('top_p', 0.9),
77 | 'max_tokens': provider_config.get('max_tokens', 2000)
78 | }
79 | else:
80 | # 兼容旧版配置格式
81 | return config
82 |
83 | @staticmethod
84 | async def expand_prompt(prompt: str, request_id: Optional[str] = None, stream_callback: Optional[Callable[[str], None]] = None) -> Dict[str, Any]:
85 | """
86 | 使用大语言模型扩写提示词,自动判断用户输入语言,并设置大语言模型回答语言。
87 | 支持流式输出以提高响应速度。
88 |
89 | 参数:
90 | prompt: 要扩写的提示词
91 | request_id: 请求ID
92 | stream_callback: 流式输出的回调函数
93 |
94 | 返回:
95 | 包含扩写结果的字典
96 | """
97 | try:
98 | # 获取配置
99 | config = LLMService._get_config()
100 | api_key = config.get('api_key')
101 | model = config.get('model')
102 | provider = config.get('provider', 'unknown')
103 | temperature = config.get('temperature', 0.7)
104 | top_p = config.get('top_p', 0.9)
105 | max_tokens = config.get('max_tokens', 2000)
106 |
107 | if not api_key:
108 | return {"success": False, "error": "请先配置大语言模型 API密钥"}
109 | if not model:
110 | return {"success": False, "error": "未配置模型名称"}
111 |
112 | # 从server.py导入颜色常量和前缀
113 | from ..server import PREFIX, ERROR_PREFIX
114 |
115 | # 获取提供商显示名称
116 | provider_display_name = {
117 | 'zhipu': '智谱',
118 | 'siliconflow': '硅基流动',
119 | 'openai': 'OpenAI',
120 | 'custom': '自定义'
121 | }.get(provider, provider)
122 |
123 | print(f"{PREFIX} LLM扩写请求 | 服务:{provider_display_name} | ID:{request_id} | 内容:{prompt[:30]}...")
124 |
125 | # 加载系统提示词
126 | from ..config_manager import config_manager
127 | system_prompts = config_manager.get_system_prompts()
128 |
129 | if not system_prompts or 'expand_prompts' not in system_prompts:
130 | return {"success": False, "error": "扩写系统提示词加载失败"}
131 |
132 | # 获取激活的提示词ID
133 | active_prompt_id = system_prompts.get('active_prompts', {}).get('expand', 'expand_default')
134 |
135 | # 获取对应的提示词
136 | if active_prompt_id not in system_prompts['expand_prompts']:
137 | # 如果找不到激活的提示词,尝试使用第一个可用的提示词
138 | if len(system_prompts['expand_prompts']) > 0:
139 | active_prompt_id = list(system_prompts['expand_prompts'].keys())[0]
140 | else:
141 | return {"success": False, "error": "未找到可用的扩写系统提示词"}
142 |
143 | system_message = system_prompts['expand_prompts'][active_prompt_id]
144 |
145 | # 输出使用的提示词名称
146 | prompt_name = system_message.get('name', active_prompt_id)
147 | print(f"{PREFIX} 使用扩写提示词: {prompt_name} | ID:{active_prompt_id}")
148 |
149 | # 判断用户输入语言
150 | def is_chinese(text):
151 | return any('\u4e00' <= char <= '\u9fff' for char in text)
152 | if is_chinese(prompt):
153 | lang_message = {"role": "system", "content": "请用中文回答"}
154 | else:
155 | lang_message = {"role": "system", "content": "Please answer in English."}
156 |
157 | # 构建消息
158 | messages = [
159 | lang_message,
160 | system_message,
161 | {"role": "user", "content": prompt}
162 | ]
163 |
164 | # 使用OpenAI SDK
165 | client = LLMService.get_openai_client(api_key, provider)
166 | try:
167 | # 添加调试信息
168 | print(f"{PREFIX} 调用LLM API | 服务:{provider_display_name} | 模型:{model}")
169 |
170 | # 使用配置中的参数
171 | stream = await client.chat.completions.create(
172 | model=model,
173 | messages=[{"role": m["role"], "content": m["content"]} for m in messages],
174 | temperature=temperature,
175 | top_p=top_p,
176 | max_tokens=max_tokens,
177 | stream=True,
178 | # 添加响应格式参数,减少不必要的token
179 | response_format={"type": "text"}
180 | )
181 |
182 | full_content = ""
183 | async for chunk in stream:
184 | if chunk.choices[0].delta.content:
185 | content = chunk.choices[0].delta.content
186 | full_content += content
187 | if stream_callback:
188 | stream_callback(content)
189 |
190 | # 输出结构化成功日志
191 | print(f"{PREFIX} LLM扩写成功 | 服务:{provider_display_name} | 请求ID:{request_id} | 结果字符数:{len(full_content)}")
192 |
193 | return {
194 | "success": True,
195 | "data": {
196 | "original": prompt,
197 | "expanded": full_content
198 | }
199 | }
200 | except asyncio.CancelledError:
201 | print(f"{PREFIX} LLM扩写任务在服务层被取消 | ID:{request_id}")
202 | return {"success": False, "error": "请求已取消", "cancelled": True}
203 | except Exception as e:
204 | return {"success": False, "error": format_api_error(e, provider_display_name)}
205 |
206 | except asyncio.CancelledError:
207 | from ..server import PREFIX
208 | print(f"{PREFIX} LLM扩写任务在服务层被取消 | ID:{request_id}")
209 | return {"success": False, "error": "请求已取消", "cancelled": True}
210 | except Exception as e:
211 | # 从server.py导入颜色常量和前缀
212 | from ..server import ERROR_PREFIX
213 | print(f"{ERROR_PREFIX} LLM扩写请求失败 | 错误:{str(e)}")
214 | return {"success": False, "error": str(e)}
215 |
216 | @staticmethod
217 | async def translate(text: str, from_lang: str = 'auto', to_lang: str = 'zh', request_id: Optional[str] = None, is_auto: bool = False, stream_callback: Optional[Callable[[str], None]] = None) -> Dict[str, Any]:
218 | """
219 | 使用大语言模型翻译文本,自动设置提示词语言和输出语言。
220 | 支持流式输出以提高响应速度。
221 |
222 | 参数:
223 | text: 要翻译的文本
224 | from_lang: 源语言 (默认为auto自动检测)
225 | to_lang: 目标语言 (默认为zh中文)
226 | request_id: 请求ID
227 | is_auto: 是否为工作流自动翻译
228 | stream_callback: 流式输出的回调函数
229 |
230 | 返回:
231 | 包含翻译结果的字典
232 | """
233 | try:
234 | # 获取配置
235 | config = LLMService._get_config()
236 | api_key = config.get('api_key')
237 | model = config.get('model')
238 | provider = config.get('provider', 'unknown')
239 | temperature = config.get('temperature', 0.7)
240 | top_p = config.get('top_p', 0.9)
241 | max_tokens = config.get('max_tokens', 2000)
242 |
243 | if not api_key:
244 | return {"success": False, "error": "请先配置大语言模型 API密钥"}
245 | if not model:
246 | return {"success": False, "error": "未配置模型名称"}
247 |
248 | # 从server.py导入颜色常量和前缀
249 | from ..server import PREFIX, AUTO_TRANSLATE_PREFIX
250 |
251 | # 获取提供商显示名称
252 | provider_display_name = {
253 | 'zhipu': '智谱',
254 | 'siliconflow': '硅基流动',
255 | 'openai': 'OpenAI',
256 | 'custom': '自定义'
257 | }.get(provider, provider)
258 |
259 | # 使用统一的前缀
260 | prefix = AUTO_TRANSLATE_PREFIX if is_auto else PREFIX
261 | print(f"{prefix} {'工作流自动翻译' if is_auto else '翻译请求'} | 服务:{provider_display_name}翻译 | 请求ID:{request_id} | 原文长度:{len(text)} | 方向:{from_lang}->{to_lang}")
262 |
263 | # 加载系统提示词
264 | from ..config_manager import config_manager
265 | system_prompts = config_manager.get_system_prompts()
266 |
267 | if not system_prompts or 'translate_prompts' not in system_prompts or 'ZH' not in system_prompts['translate_prompts']:
268 | return {"success": False, "error": "翻译系统提示词加载失败"}
269 |
270 | system_message = system_prompts['translate_prompts']['ZH']
271 |
272 | # 动态替换提示词中的{src_lang}和{dst_lang}
273 | lang_map = {'zh': '中文', 'en': '英文', 'auto': '原文'}
274 | src_lang = lang_map.get(from_lang, from_lang)
275 | dst_lang = lang_map.get(to_lang, to_lang)
276 | sys_msg_content = system_message['content'].replace('{src_lang}', src_lang).replace('{dst_lang}', dst_lang)
277 | sys_msg = {"role": "system", "content": sys_msg_content}
278 |
279 | # 设置输出语言
280 | if to_lang == 'en':
281 | lang_message = {"role": "system", "content": "Please answer in English."}
282 | else:
283 | lang_message = {"role": "system", "content": "请用中文回答"}
284 |
285 | # 构建消息
286 | messages = [
287 | lang_message,
288 | sys_msg,
289 | {"role": "user", "content": text}
290 | ]
291 |
292 | # 使用OpenAI SDK
293 | client = LLMService.get_openai_client(api_key, provider)
294 | try:
295 | # 添加调试信息
296 | print(f"{PREFIX} 调用LLM API | 服务:{provider_display_name} | 模型:{model}")
297 |
298 | # 使用配置中的参数
299 | stream = await client.chat.completions.create(
300 | model=model,
301 | messages=[{"role": m["role"], "content": m["content"]} for m in messages],
302 | temperature=temperature,
303 | top_p=top_p,
304 | max_tokens=max_tokens,
305 | stream=True,
306 | # 添加响应格式参数,减少不必要的token
307 | response_format={"type": "text"}
308 | )
309 |
310 | full_content = ""
311 | async for chunk in stream:
312 | if chunk.choices[0].delta.content:
313 | content = chunk.choices[0].delta.content
314 | full_content += content
315 | if stream_callback:
316 | stream_callback(content)
317 |
318 | # 输出结构化成功日志
319 | prefix = AUTO_TRANSLATE_PREFIX if is_auto else PREFIX
320 | print(f"{prefix} {'工作流翻译完成' if is_auto else '翻译完成'} | 服务:{provider_display_name}翻译 | 请求ID:{request_id} | 结果字符数:{len(full_content)}")
321 |
322 | return {
323 | "success": True,
324 | "data": {
325 | "from": from_lang,
326 | "to": to_lang,
327 | "original": text,
328 | "translated": full_content
329 | }
330 | }
331 | except asyncio.CancelledError:
332 | print(f"{prefix} {'工作流翻译' if is_auto else '翻译'}任务在服务层被取消 | ID:{request_id}")
333 | return {"success": False, "error": "请求已取消", "cancelled": True}
334 | except Exception as e:
335 | return {"success": False, "error": format_api_error(e, provider_display_name)}
336 |
337 | except asyncio.CancelledError:
338 | from ..server import PREFIX, AUTO_TRANSLATE_PREFIX
339 | prefix = AUTO_TRANSLATE_PREFIX if is_auto else PREFIX
340 | print(f"{prefix} {'工作流翻译' if is_auto else '翻译'}任务在服务层被取消 | ID:{request_id}")
341 | return {"success": False, "error": "请求已取消", "cancelled": True}
342 | except Exception as e:
343 | return {"success": False, "error": str(e)}
--------------------------------------------------------------------------------
/services/vlm.py:
--------------------------------------------------------------------------------
1 | import json
2 | import os
3 | import base64
4 | from io import BytesIO
5 | from PIL import Image
6 | from typing import Optional, Dict, Any, List, Callable
7 | import asyncio
8 | from openai import AsyncOpenAI
9 | import httpx
10 | from .error_util import format_api_error
11 |
12 | class VisionService:
13 | _provider_base_urls = {
14 | 'openai': None, # 使用默认
15 | 'siliconflow': 'https://api.siliconflow.cn/v1',
16 | 'zhipu': 'https://open.bigmodel.cn/api/paas/v4',
17 | 'custom': None # 使用配置中的自定义URL
18 | }
19 |
20 | @classmethod
21 | def get_openai_client(cls, api_key: str, provider: str) -> AsyncOpenAI:
22 | """获取OpenAI客户端"""
23 | # 否则创建新客户端
24 | base_url = None
25 |
26 | # 如果是自定义提供商,从配置中获取base_url
27 | if provider == 'custom':
28 | from ..config_manager import config_manager
29 | config = config_manager.get_vision_config()
30 | if 'providers' in config and 'custom' in config['providers']:
31 | base_url = config['providers']['custom'].get('base_url')
32 | # 确保base_url不以/chat/completions结尾,避免路径重复
33 | if base_url and base_url.endswith('/chat/completions'):
34 | base_url = base_url.rstrip('/chat/completions')
35 | else:
36 | base_url = cls._provider_base_urls.get(provider)
37 |
38 | # 创建简化的httpx客户端,不使用HTTP/2,避免额外依赖
39 | # 视觉模型需要较长的超时时间
40 | http_client = httpx.AsyncClient(
41 | timeout=httpx.Timeout(30.0) # 视觉模型需要更长的超时时间
42 | )
43 |
44 | kwargs = {
45 | "api_key": api_key,
46 | "http_client": http_client,
47 | "max_retries": 2 # 设置最大重试次数
48 | }
49 | if base_url:
50 | # 确保base_url末尾没有斜杠
51 | base_url = base_url.rstrip('/')
52 | kwargs["base_url"] = base_url
53 |
54 | # 添加调试日志
55 | from ..server import PREFIX
56 | print(f"{PREFIX} 创建OpenAI客户端 | 提供商:{provider} | 基础URL:{base_url}")
57 |
58 | # 创建客户端
59 | client = AsyncOpenAI(**kwargs)
60 | return client
61 |
62 | @staticmethod
63 | def _get_config() -> Dict[str, Any]:
64 | """获取视觉模型配置"""
65 | from ..config_manager import config_manager
66 | config = config_manager.get_vision_config()
67 | current_provider = config.get('provider')
68 |
69 | # 获取实际配置
70 | if 'providers' in config and current_provider in config['providers']:
71 | provider_config = config['providers'][current_provider]
72 | return {
73 | 'provider': current_provider,
74 | 'model': provider_config.get('model', ''),
75 | 'base_url': provider_config.get('base_url', ''),
76 | 'api_key': provider_config.get('api_key', ''),
77 | 'temperature': provider_config.get('temperature', 0.7),
78 | 'top_p': provider_config.get('top_p', 0.9),
79 | 'max_tokens': provider_config.get('max_tokens', 2000)
80 | }
81 | else:
82 | # 兼容旧版配置格式
83 | return config
84 |
85 | @staticmethod
86 | def preprocess_image(image_data: str, request_id: Optional[str] = None) -> str:
87 | """
88 | 预处理图像数据,包括压缩和调整大小
89 |
90 | 参数:
91 | image_data: 图像数据(Base64编码或URL)
92 | request_id: 请求ID,用于日志记录
93 |
94 | 返回:
95 | 处理后的图像数据
96 | """
97 | from ..server import PREFIX
98 |
99 | try:
100 | # 检查是否为base64编码的图像数据
101 | if image_data.startswith('data:image'):
102 | # 提取base64数据
103 | header, encoded = image_data.split(",", 1)
104 | image_bytes = base64.b64decode(encoded)
105 |
106 | # 打开图像
107 | img = Image.open(BytesIO(image_bytes))
108 | original_size = img.size
109 | original_format = img.format or 'JPEG'
110 | original_bytes = len(image_bytes)
111 |
112 | # 调整大小,保持纵横比
113 | max_size = 1024 # 最大尺寸设为1024px
114 | if max(img.size) > max_size:
115 | ratio = max_size / max(img.size)
116 | new_size = (int(img.size[0] * ratio), int(img.size[1] * ratio))
117 | img = img.resize(new_size, Image.LANCZOS)
118 |
119 | # 压缩图像
120 | buffer = BytesIO()
121 | save_format = 'JPEG' if original_format not in ['PNG', 'GIF'] else original_format
122 |
123 | # 根据格式选择保存参数
124 | if save_format == 'JPEG':
125 | img.save(buffer, format=save_format, quality=85, optimize=True)
126 | elif save_format == 'PNG':
127 | img.save(buffer, format=save_format, optimize=True, compress_level=7)
128 | else:
129 | img.save(buffer, format=save_format)
130 |
131 | compressed_bytes = buffer.getvalue()
132 |
133 | # 转回base64
134 | compressed_b64 = base64.b64encode(compressed_bytes).decode('utf-8')
135 | processed_image_data = f"{header},{compressed_b64}"
136 |
137 | # 计算压缩比例
138 | compressed_size = len(compressed_bytes)
139 | compression_ratio = (1 - compressed_size / original_bytes) * 100
140 |
141 | # 记录日志
142 | print(f"{PREFIX} 图像预处理 | 请求ID:{request_id} | 原始尺寸:{original_size} | "
143 | f"处理后尺寸:{img.size} | 压缩率:{compression_ratio:.1f}% | "
144 | f"原始大小:{original_bytes/1024:.1f}KB | 压缩后:{compressed_size/1024:.1f}KB")
145 |
146 | return processed_image_data
147 |
148 | # 如果不是base64编码的图像数据,直接返回
149 | return image_data
150 |
151 | except Exception as e:
152 | from ..server import WARN_PREFIX
153 | print(f"{WARN_PREFIX} 图像预处理失败 | 请求ID:{request_id} | 错误:{str(e)}")
154 | # 预处理失败时返回原始图像数据
155 | return image_data
156 |
157 | @staticmethod
158 | async def analyze_image(image_data: str, request_id: Optional[str] = None,
159 | stream_callback: Optional[Callable[[str], None]] = None,
160 | prompt_content: Optional[str] = None,
161 | custom_provider: Optional[str] = None,
162 | custom_provider_config: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
163 | """
164 | 使用视觉模型分析图像
165 |
166 | 参数:
167 | image_data: 图像数据(Base64编码)
168 | request_id: 请求ID
169 | lang: 分析语言,zh为中文,en为英文
170 | stream_callback: 流式输出的回调函数
171 | custom_prompt: 自定义提示词
172 | custom_provider: 自定义提供商
173 | custom_provider_config: 自定义提供商配置
174 |
175 | 返回:
176 | 包含分析结果的字典
177 | """
178 | from ..server import PREFIX, ERROR_PREFIX
179 | try:
180 | # 获取配置
181 | if custom_provider and custom_provider_config:
182 | # 使用自定义提供商和配置
183 | provider = custom_provider
184 | api_key = custom_provider_config.get('api_key', '')
185 | model = custom_provider_config.get('model', '')
186 | base_url = custom_provider_config.get('base_url', '')
187 | temperature = custom_provider_config.get('temperature', 0.7)
188 | top_p = custom_provider_config.get('top_p', 0.9)
189 | max_tokens = custom_provider_config.get('max_tokens', 2000)
190 | else:
191 | # 使用默认配置
192 | config = VisionService._get_config()
193 | api_key = config.get('api_key')
194 | model = config.get('model')
195 | provider = config.get('provider', 'unknown')
196 | temperature = config.get('temperature', 0.7)
197 | top_p = config.get('top_p', 0.9)
198 | max_tokens = config.get('max_tokens', 2000)
199 |
200 | if not api_key:
201 | return {"success": False, "error": "请先配置视觉模型API密钥"}
202 | if not model:
203 | return {"success": False, "error": "未配置视觉模型名称"}
204 |
205 | # 检查图片数据格式
206 | if not image_data:
207 | return {"success": False, "error": "未提供图像数据"}
208 |
209 | # 处理图像数据格式
210 | if not image_data.startswith('data:image'):
211 | # 尝试添加前缀
212 | image_data = f"data:image/jpeg;base64,{image_data}"
213 |
214 | # 预处理图像数据(压缩和调整大小)
215 | image_data = VisionService.preprocess_image(image_data, request_id)
216 |
217 | # 获取提供商显示名称
218 | provider_display_name = {
219 | 'zhipu': '智谱',
220 | 'siliconflow': '硅基流动',
221 | 'openai': 'OpenAI',
222 | 'custom': '自定义'
223 | }.get(provider, provider)
224 |
225 | # 直接使用传入的提示词内容
226 | system_prompt = prompt_content
227 | if not system_prompt:
228 | return {"success": False, "error": "未提供有效的提示词内容"}
229 |
230 | # 发送请求
231 | print(f"{PREFIX} 调用视觉模型 | 服务:{provider_display_name} | 请求ID:{request_id} | 模型:{model}")
232 |
233 | # 使用OpenAI SDK
234 | client = VisionService.get_openai_client(api_key, provider)
235 | try:
236 | # 添加调试信息
237 | print(f"{PREFIX} 调用视觉模型API | 服务:{provider_display_name} | 模型:{model}")
238 |
239 | # 使用配置中的参数
240 | response = await client.chat.completions.create(
241 | model=model,
242 | messages=[{
243 | "role": "user",
244 | "content": [
245 | {"type": "text", "text": system_prompt},
246 | {"type": "image_url", "image_url": {"url": image_data}}
247 | ]
248 | }],
249 | max_tokens=max_tokens,
250 | temperature=temperature,
251 | top_p=top_p,
252 | stream=True,
253 | # 添加响应格式参数,减少不必要的token
254 | response_format={"type": "text"}
255 | )
256 |
257 | full_content = ""
258 | async for chunk in response:
259 | if chunk.choices[0].delta.content:
260 | content = chunk.choices[0].delta.content
261 | full_content += content
262 | if stream_callback:
263 | stream_callback(content)
264 |
265 | # 输出结构化成功日志
266 | print(f"{PREFIX} 视觉模型分析成功 | 服务:{provider_display_name} | 请求ID:{request_id} | 结果字符数:{len(full_content)}")
267 |
268 | return {
269 | "success": True,
270 | "data": {
271 | "description": full_content
272 | }
273 | }
274 | except asyncio.CancelledError:
275 | print(f"{PREFIX} 视觉模型分析任务在服务层被取消 | ID:{request_id}")
276 | return {"success": False, "error": "请求已取消", "cancelled": True}
277 | except Exception as e:
278 | return {"success": False, "error": format_api_error(e, provider_display_name)}
279 |
280 | except asyncio.CancelledError:
281 | print(f"{PREFIX} 视觉分析任务在服务层被取消 | ID:{request_id}")
282 | return {"success": False, "error": "请求已取消", "cancelled": True}
283 | except Exception as e:
284 | print(f"{ERROR_PREFIX} 视觉分析过程异常 | 错误:{str(e)}")
285 | return {"success": False, "error": str(e)}
--------------------------------------------------------------------------------