From dcd215438fe287b4846bcff02e9ebd62820fc823 Mon Sep 17 00:00:00 2001
From: yawiii <77927734@qq.com>
Date: Sun, 21 Dec 2025 22:52:38 +0800
Subject: [PATCH] V2.0.0
---
__init__.py | 14 +-
config/config_template.json | 108 +
...ets.json => kontext_presets_template.json} | 55 +-
config/system_prompts_template.json | 186 +
config/{tags.json => tags_template.json} | 160 -
config_manager.py | 2120 +++++++---
js/css/assistant.css | 892 +++--
js/css/captionFrame.css | 458 +++
js/css/common.css | 295 +-
js/css/popup.css | 204 +-
js/css/settings.css | 1582 --------
js/css/uiComponents.css | 3533 +++++++++++++++++
js/index.js | 272 +-
js/modules/AssistantContainer.js | 451 +++
js/modules/PromptAssistant.js | 2343 ++++++-----
js/modules/apiConfigManager.js | 3352 +++++++++-------
js/modules/history.js | 17 +-
js/modules/imageCaption.js | 986 +++--
js/modules/nodeHelpTranslator.js | 637 +++
js/modules/rulesConfigManager.js | 1013 +++--
js/modules/settings.js | 1363 +++----
js/modules/tag.js | 2538 ++++++++++--
js/modules/tagConfigManager.js | 1005 -----
js/modules/uiComponents.js | 2472 ++++++++++++
js/node/captionFrame.js | 1468 +++++++
js/services/NodeMountService.js | 714 ++++
js/services/api.js | 220 +-
js/services/btnMenu.js | 300 +-
js/services/features.js | 78 +-
js/services/interceptor.js | 1211 ------
js/utils/UIToolkit.js | 246 +-
js/utils/logger.js | 19 +-
js/utils/markdownNoteTranslate.js | 157 +
js/utils/popupManager.js | 18 +
js/utils/promptFormatter.js | 34 +-
js/utils/resourceManager.js | 197 +-
locales/en/nodeDefs.json | 210 +
locales/zh-TW/nodeDefs.json | 210 +
locales/zh/nodeDefs.json | 210 +
node/base/__init__.py | 14 +
node/base/base_node.py | 235 ++
node/base/llm_node_base.py | 221 ++
node/base/vlm_node_base.py | 279 ++
node/expand_node.py | 398 +-
node/image_caption_node.py | 367 +-
node/kontext_preset_node.py | 346 +-
node/translate_node.py | 453 +--
node/video_caption_node.py | 541 +++
requirements.txt | 3 +-
server.py | 1113 +++++-
services/baidu.py | 158 +-
services/core.py | 120 +
services/error_util.py | 83 -
services/llm.py | 1344 ++-----
services/model_list.py | 592 +--
services/openai_base.py | 478 +++
services/thinking_control.py | 361 +-
services/vlm.py | 1253 +++---
utils/__init__.py | 5 +
utils/common.py | 841 ++++
utils/image.py | 80 +
utils/migration_tool.py | 1049 +++++
utils/video.py | 127 +
63 files changed, 28694 insertions(+), 13115 deletions(-)
create mode 100644 config/config_template.json
rename config/{kontext_presets.json => kontext_presets_template.json} (90%)
create mode 100644 config/system_prompts_template.json
rename config/{tags.json => tags_template.json} (92%)
create mode 100644 js/css/captionFrame.css
delete mode 100644 js/css/settings.css
create mode 100644 js/css/uiComponents.css
create mode 100644 js/modules/AssistantContainer.js
create mode 100644 js/modules/nodeHelpTranslator.js
delete mode 100644 js/modules/tagConfigManager.js
create mode 100644 js/modules/uiComponents.js
create mode 100644 js/node/captionFrame.js
create mode 100644 js/services/NodeMountService.js
delete mode 100644 js/services/interceptor.js
create mode 100644 js/utils/markdownNoteTranslate.js
create mode 100644 locales/en/nodeDefs.json
create mode 100644 locales/zh-TW/nodeDefs.json
create mode 100644 locales/zh/nodeDefs.json
create mode 100644 node/base/__init__.py
create mode 100644 node/base/base_node.py
create mode 100644 node/base/llm_node_base.py
create mode 100644 node/base/vlm_node_base.py
create mode 100644 node/video_caption_node.py
create mode 100644 services/core.py
delete mode 100644 services/error_util.py
create mode 100644 services/openai_base.py
create mode 100644 utils/__init__.py
create mode 100644 utils/common.py
create mode 100644 utils/image.py
create mode 100644 utils/migration_tool.py
create mode 100644 utils/video.py
diff --git a/__init__.py b/__init__.py
index b54fca1..875048f 100644
--- a/__init__.py
+++ b/__init__.py
@@ -11,10 +11,8 @@ from .node.kontext_preset_node import NODE_CLASS_MAPPINGS as KONTEXT_PRESET_NODE
from .node.kontext_preset_node import NODE_DISPLAY_NAME_MAPPINGS as KONTEXT_PRESET_NODE_DISPLAY_NAME_MAPPINGS
from .node.expand_node import NODE_CLASS_MAPPINGS as EXPAND_NODE_CLASS_MAPPINGS
from .node.expand_node import NODE_DISPLAY_NAME_MAPPINGS as EXPAND_NODE_DISPLAY_NAME_MAPPINGS
-
-# ANSI颜色常量
-GREEN = "\033[92m"
-RESET = "\033[0m"
+from .node.video_caption_node import NODE_CLASS_MAPPINGS as VIDEO_CAPTION_NODE_CLASS_MAPPINGS
+from .node.video_caption_node import NODE_DISPLAY_NAME_MAPPINGS as VIDEO_CAPTION_NODE_DISPLAY_NAME_MAPPINGS
# 模块常量定义
NODE_CLASS_MAPPINGS = {
@@ -22,6 +20,7 @@ NODE_CLASS_MAPPINGS = {
**KONTEXT_PRESET_NODE_CLASS_MAPPINGS,
**TRANSLATE_NODE_CLASS_MAPPINGS,
**EXPAND_NODE_CLASS_MAPPINGS,
+ **VIDEO_CAPTION_NODE_CLASS_MAPPINGS,
}
NODE_DISPLAY_NAME_MAPPINGS = {
@@ -29,6 +28,7 @@ NODE_DISPLAY_NAME_MAPPINGS = {
**KONTEXT_PRESET_NODE_DISPLAY_NAME_MAPPINGS,
**TRANSLATE_NODE_DISPLAY_NAME_MAPPINGS,
**EXPAND_NODE_DISPLAY_NAME_MAPPINGS,
+ **VIDEO_CAPTION_NODE_DISPLAY_NAME_MAPPINGS,
}
WEB_DIRECTORY = "./js"
@@ -80,8 +80,12 @@ VERSION = get_version()
# 执行初始化操作
inject_version_to_frontend()
+# 禁用httpx的详细日志,避免打断单行动态显示
+import logging
+logging.getLogger("httpx").setLevel(logging.WARNING)
+
# 打印初始化信息
-print(f"✨提示词小助手 V{VERSION} {GREEN}已启动{RESET}")
+print(f"✨提示词小助手 V{VERSION} 已启动")
diff --git a/config/config_template.json b/config/config_template.json
new file mode 100644
index 0000000..eb4551c
--- /dev/null
+++ b/config/config_template.json
@@ -0,0 +1,108 @@
+{
+ "__config_version": "2.0",
+ "__comment": "提示词小助手配置文件模板",
+ "baidu_translate": {
+ "app_id": "",
+ "secret_key": ""
+ },
+ "model_services": [
+ {
+ "id": "zhipu",
+ "type": "openai_compatible",
+ "name": "智谱",
+ "description": "免费GLM系列模型",
+ "base_url": "https://open.bigmodel.cn/api/paas/v4",
+ "api_key": "",
+ "disable_thinking": true,
+ "enable_advanced_params": true,
+ "filter_thinking_output": true,
+ "llm_models": [
+ {
+ "name": "glm-4-flash-250414",
+ "display_name": "",
+ "is_default": true,
+ "temperature": 0.7,
+ "max_tokens": 1000,
+ "top_p": 0.9
+ },
+ {
+ "name": "glm-4.5-flash",
+ "display_name": "",
+ "is_default": false,
+ "temperature": 0.7,
+ "max_tokens": 1000,
+ "top_p": 0.9
+ }
+ ],
+ "vlm_models": [
+ {
+ "name": "glm-4v-flash",
+ "display_name": "",
+ "is_default": true,
+ "temperature": 0.7,
+ "max_tokens": 500,
+ "top_p": 0.9
+ }
+ ]
+ },
+ {
+ "id": "xFlow",
+ "type": "openai_compatible",
+ "name": "xFlow-API聚合",
+ "description": "免魔法Gemini、Grok、chatGPT...",
+ "base_url": "https://api.xflow.cc/v1",
+ "api_key": "",
+ "disable_thinking": true,
+ "enable_advanced_params": true,
+ "filter_thinking_output": true,
+ "llm_models": [
+ {
+ "name": "gemini-2.5-pro-nothinking",
+ "display_name": "",
+ "is_default": true,
+ "temperature": 0.7,
+ "max_tokens": 1000,
+ "top_p": 0.9
+ }
+ ],
+ "vlm_models": [
+ {
+ "name": "grok-4-1-fast-non-reasoning",
+ "display_name": "",
+ "is_default": true,
+ "temperature": 0.7,
+ "max_tokens": 500,
+ "top_p": 0.9
+ }
+ ]
+ },
+ {
+ "id": "ollama",
+ "type": "ollama",
+ "name": "Ollama",
+ "description": "调用本地模型",
+ "base_url": "http://localhost:11434/v1",
+ "api_key": "ollama",
+ "auto_unload": true,
+ "disable_thinking": true,
+ "enable_advanced_params": true,
+ "filter_thinking_output": true,
+ "llm_models": [],
+ "vlm_models": []
+ }
+ ],
+ "current_services": {
+ "llm": {
+ "service": "zhipu",
+ "model": "glm-4.5-flash"
+ },
+ "vlm": {
+ "service": "zhipu",
+ "model": "glm-4v-flash"
+ },
+ "translate": {
+ "service": "baidu",
+ "model": ""
+ }
+ }
+}
\ No newline at end of file
diff --git a/config/kontext_presets.json b/config/kontext_presets_template.json
similarity index 90%
rename from config/kontext_presets.json
rename to config/kontext_presets_template.json
index 1dfd886..f3b0579 100644
--- a/config/kontext_presets.json
+++ b/config/kontext_presets_template.json
@@ -1,139 +1,140 @@
{
+ "__config_version": "2.0",
"kontext_prefix": "You are a creative prompt engineer. Your mission is to analyze the provided image and generate exactly 1 distinct image transformation *instructions*.",
"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.",
"kontext_presets": {
"kontext_情境深度融合": {
- "name": "情境深度融合",
+ "name": "Deep Contextual Fusion (情境深度融合)增量测试",
"role": "system",
"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."
},
"kontext_无痕融合": {
- "name": "无痕融合",
+ "name": "Seamless Integration (无痕融合)",
"role": "system",
"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."
},
"kontext_场景传送": {
- "name": "场景传送",
+ "name": "Scene Teleportation (场景传送)",
"role": "system",
"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."
},
"kontext_移动镜头": {
- "name": "移动镜头",
+ "name": "Camera Movement (移动镜头)",
"role": "system",
"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'."
},
"kontext_重新布光": {
- "name": "重新布光",
+ "name": "Relighting (重新布光)",
"role": "system",
"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.'"
},
"kontext_专业产品图": {
- "name": "专业产品图",
+ "name": "Professional Product Photo (专业产品图)",
"role": "system",
"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."
},
"kontext_画面缩放": {
- "name": "画面缩放",
+ "name": "Zoom (画面缩放)",
"role": "system",
"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.'"
},
"kontext_图像上色": {
- "name": "图像上色",
+ "name": "Colorization (图像上色)",
"role": "system",
"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.'"
},
"kontext_电影海报": {
- "name": "电影海报",
+ "name": "Movie Poster (电影海报)",
"role": "system",
"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."
},
"kontext_卡通漫画化": {
- "name": "卡通漫画化",
+ "name": "Cartoon Style (卡通漫画化)",
"role": "system",
"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.'"
},
"kontext_移除文字": {
- "name": "移除文字",
+ "name": "Text Removal (移除文字)",
"role": "system",
"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.'"
},
"kontext_更换发型": {
- "name": "更换发型",
+ "name": "Hairstyle Change (更换发型)",
"role": "system",
"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.'"
},
"kontext_肌肉猛男化": {
- "name": "肌肉猛男化",
+ "name": "Muscular Transformation (肌肉猛男化)",
"role": "system",
"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.'"
},
"kontext_清空家具": {
- "name": "清空家具",
+ "name": "Empty Room (清空家具)",
"role": "system",
"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."
},
"kontext_室内设计": {
- "name": "室内设计",
+ "name": "Interior Design (室内设计)",
"role": "system",
"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.'"
},
"kontext_季节变换": {
- "name": "季节变换",
+ "name": "Season Change (季节变换)",
"role": "system",
"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.'"
},
"kontext_时光旅人": {
- "name": "时光旅人",
+ "name": "Time Traveler (时光旅人)",
"role": "system",
"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'."
},
"kontext_材质置换": {
- "name": "材质置换",
+ "name": "Material Replacement (材质置换)",
"role": "system",
"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.'"
},
"kontext_微缩世界": {
- "name": "微缩世界",
+ "name": "Miniature World (微缩世界)",
"role": "system",
"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.'"
},
"kontext_幻想领域": {
- "name": "幻想领域",
+ "name": "Fantasy Realm (幻想领域)",
"role": "system",
"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.'"
},
"kontext_衣橱改造": {
- "name": "衣橱改造",
+ "name": "Wardrobe Makeover (衣橱改造)",
"role": "system",
"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.'"
},
"kontext_艺术风格模仿": {
- "name": "艺术风格模仿",
+ "name": "Art Style Imitation (艺术风格模仿)",
"role": "system",
"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.'"
},
"kontext_蓝图视角": {
- "name": "蓝图视角",
+ "name": "Blueprint View (蓝图视角)",
"role": "system",
"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.'"
},
"kontext_添加倒影": {
- "name": "添加倒影",
+ "name": "Add Reflection (添加倒影)",
"role": "system",
"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.'"
},
"kontext_像素艺术": {
- "name": "像素艺术",
+ "name": "Pixel Art (像素艺术)",
"role": "system",
"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.'"
},
"kontext_铅笔手绘": {
- "name": "铅笔手绘",
+ "name": "Pencil Sketch (铅笔手绘)",
"role": "system",
"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."
},
"kontext_油画风格": {
- "name": "油画风格",
+ "name": "Oil Painting Style (油画风格)",
"role": "system",
"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."
}
diff --git a/config/system_prompts_template.json b/config/system_prompts_template.json
new file mode 100644
index 0000000..d49ab7b
--- /dev/null
+++ b/config/system_prompts_template.json
@@ -0,0 +1,186 @@
+{
+ "__config_version": "2.0",
+ "expand_prompts": {
+ "expand_扩写-通用": {
+ "name": "扩写-通用",
+ "tags": [],
+ "category": "",
+ "showIn": [
+ "frontend",
+ "node"
+ ],
+ "role": "system",
+ "content": "Role\n你是一位拥有全学科视觉知识的图像生成提示词专家。你的核心能力是:精准识别用户输入关键词的侧重点,自动判定其所属领域(如写实摄影、工业设计、平面海报、二次元动漫、3D动画、数字艺术等),并调用该领域的专业术语进行像素级的深度扩写。\n\n最高指令 (Absolute Command)\n\n1.语言自适应:识别用户输入语言。用户用中文提问,你输出中文指令;用户用英文提问,你输出英文指令。\n2.格式绝对纯净:严禁输出 Markdown 符号(如星号、井号)、严禁中英对照括号、严禁输出任何解释或前缀。\n3.领域自适应:必须先判断输入内容的领域属性,严禁跨领域混用术语(例如:严禁在平面设计类提示词中加入焦距参数,严禁在二次元插画中加入皮肤毛孔描写)。\n4.语义忠实:严格保留用户所有原始关键词,严禁擅自增删核心主体。\n5.拒绝抽象词汇:禁止使用高质量、精美等模糊词,必须转化为可感知的物理细节或专业艺术术语。\n\n核心逻辑 (领域判定与定向扩写)\n\n第一步:领域侧重点判定 (Domain Recognition)\n\n分析用户关键词,自动进入以下对应的专业模式:\nA. 摄影模式 (Photography):侧重镜头焦段、光圈、胶片质感、真实皮肤/环境肌理。\nB. 工业/产品模式 (Product):侧重材质工艺(CNC、阳极氧化)、商业布光(轮廓光)、结构精密感。\nC. 平面/海报模式 (Graphic Design):侧重构图布局、负空间、排版占位感、矢量色彩。\nD. 二次元/漫画模式 (Anime/Manga):侧重线条精细度(Line art)、赛璐璐阴影(Cel shading)、网点纸(Screen tones)、夸张的眼神细节、特定的画风特征。\nE. 3D动画/CGI模式 (3D Animation):侧重次表面散射(SSS材质)、角色建模精度、电影级3D布光、渲染器风格(Pixar/Dreamworks风格)。\nF. 艺术/插画模式 (Art/Illustration):侧重笔触质感、媒介(水墨、油画、水彩)、流派特征。\n\n第二步:专业维度填充 (Directional Supplement)\n\n主体与质感:动漫类强调线稿与填色;3D类强调建模与光影反弹;产品类强调加工工艺。\n环境与背景:根据模式补充细节。摄影类补自然环境;3D类补置景;插画类补意境或笔触背景。\n专业技术参数:匹配该领域最专业的后缀(如摄影的 35mm,3D类的 Octane render,二次元的 Cel shaded)。\n\n输出规范\n\n结构顺序:深度扩写的专业描述, [领域特定参数], photorealistic(针对写实类) 或 stylized(针对非写实类), high fidelity, ultra-detailed texture, 8k resolution\n\n示例 (领域感知演示)\n输入中文:精密机械手表,微距\n输出:一张极其精密的机械手表机芯特写摄影。画面展示了复杂的齿轮组、红宝石轴承和游丝结构,金属齿轮表面带有细腻的拉丝纹理。每一个微小的零件都散发着淡淡的金属光泽,齿轮边缘有极细微的切削倒角痕迹。微距摄影视角,景深极浅,焦点完美对准在核心摆轮上,展现出极致的工艺美学和金属物理质感, 100mm macro lens, sharp focus, high fidelity, ultra-detailed texture, 8k resolution\n\n输入英文:Precision mechanical watch, macro\n输出:A close-up photograph of an extremely precise mechanical watch movement. The image showcases complex gear trains, ruby bearings, and balance spring structures, with the metal gears having a delicate brushed texture. Each tiny part exudes a subtle metallic luster, and the edges of the gears bear extremely fine cutting bevel marks. The macro photography perspective has an extremely shallow depth of field, with the focus perfectly aligned on the core balance wheel, displaying the ultimate craftsmanship aesthetics and the physical texture of the metal. 100mm macro lens, sharp focus, high fidelity, ultra-detailed texture, 8k resolution\n\n输入中文:日系风格,女孩,夏日\n输出:一张清新透明的日系夏日人像摄影。一位年轻女孩站在微风吹过的车站站台上,穿着轻薄的白色棉质短袖,皮肤白皙且带有通透的自然质感。背景是湛蓝的天空和几朵洁白的积雨云。光线明亮而柔和,呈现出典型的日系胶片青色调。自然抓拍视角,画面充满清冷的空气感和夏日氛围, Fujifilm color, 35mm lens, f/2.8, photorealistic, high fidelity, 8k resolution\n\n输入英文:Japanese style, girl, summer\n输出:A fresh and transparent Japanese summer portrait photography. A young girl standing on a station platform with a gentle breeze, wearing a thin white cotton T-shirt, with fair and translucent natural skin texture. The background is a bright blue sky and a few white cumulus clouds. The light is bright and soft, presenting a typical Japanese film cyan tone. A natural candid perspective, the image is filled with a sense of fresh cool air and summer atmosphere, Fujifilm color, 35mm lens, f/2.8, photorealistic, high fidelity, 8k resolution\n\n输入中文:二次元,美少女,魔法少女,施法\n输出:一张精美的二次元插画,采用赛璐璐上色风格。一位拥有璀璨紫色眼眸和飘逸长发的魔法少女正在施法,手中的法杖顶端绽放出华丽的五角星光效,粒子碎片随风飞散。线条清晰锐利,阴影边缘干净,色彩对比鲜明。背景是如梦似幻的星空和魔法阵,充满动态感。画面具有典型的高质量剧场版动画质感, anime style, cel shaded, line art, vibrant colors, high fidelity, 8k resolution\n\n输入英文:Two-dimensional, beautiful girl, magical girl, casting spells\n输出:A beautiful two-dimensional illustration in a cel-shaded coloring style. A magic girl with dazzling purple eyes and flowing long hair is casting a spell, with a magical staff at the top of her hand emitting a magnificent five-pointed starlight effect, particle fragments flying in the wind. The lines are clear and sharp, the shadows have clean edges, and the color contrast is vivid. The background is a dreamlike starry sky and magical runes, full of dynamism. The image has a typical high-quality movie theater animation quality, anime style, cel shaded, line art, vibrant colors, high fidelity, 8k resolution"
+ },
+ "expand_扩写-人像大师": {
+ "name": "扩写-人像大师",
+ "tags": [],
+ "category": "",
+ "showIn": [
+ "frontend",
+ "node"
+ ],
+ "role": "system",
+ "content": "Role\n你是一位追求自然真实感与极致细节的人像摄影提示词专家。你的核心任务是将用户简单的描述,通过显微镜式的观察与扩写,转化为画面感极强、细节丰富、且符合亚洲主流审美(干净自然)的高质量提示词。你的输出将直接用于绘图模型,因此格式必须绝对纯净。\n\n最高指令 (Absolute Command)\n\n1.语言自适应:识别用户输入语言。用户用中文提问,你输出中文指令;用户用英文提问,你输出英文指令。\n2.严禁使用Markdown符号:绝对禁止在关键词两侧添加星号或井号等符号。输出必须是没有任何格式标记的纯文本。\n3.严禁解释性翻译:绝对禁止在句子中使用括号进行中英文对照。如果用户输入中文,请直接使用精准的中文形容词。\n4.关键词神圣不可侵犯:用户输入的所有核心关键词必须完整保留,严禁遗漏或篡改。\n5.拒绝简短:禁止输出单薄的短句。最终结果应包含 3-5 个细节丰富的长句。对于每个名词,必须添加至少 2 个描述物理性质的修饰词。\n\n核心逻辑 (Core Logic)\n\n第一步:题材识别与基调设定\n根据用户输入,自动匹配以下模式:影楼写真、街拍潮流、手机自拍、古装文化、人文故事。\n\n第二步:五维细节填充\n\n皮肤与质感:默认追求干净、通透、有微小肌理的皮肤。人文模式下强调岁月的纹理感。\n构图与视角:明确描写视角(如平视、低角度仰拍、三分法构图)及景深虚化程度。\n服饰与细节:极度细致地描写布料材质(如透光的薄纱、粗糙的麻布、反光的皮革)。\n五官与情绪:描写眼神的焦点、具体的微表情(如欲言又止、欣喜、坚毅)。\n光影与色彩:描述光线的质感(柔光、硬光、丁达尔效应)及画面整体的色调。\n\n输出规范 (Output Rules)\n\n纯文本输出:只输出提示词正文,不要包含任何前缀或解释。\n语言规范:主体描述部分使用纯中文(若用户输入中文),结尾追加高质感视觉标签。\n结构顺序:深度扩写的中文描述, [光影/构图/焦段], photorealistic, ultra-detailed texture, sharp focus, 8k resolution\n\n示例 (Few-Shot Examples)\n\n输入中文:私房写真,女孩,蕾丝内衣,清晨\n输出:一张充满日系小清新风格的私房写真。一位18岁的女孩拥有白皙无瑕且富有通透感的肌肤,由于阳光的照射呈现出细腻的肌理。她穿着一套轻盈半透明的白色蕾丝内衣,面料上的花纹纹理清晰可见,阳光勾勒出她柔美的身体轮廓。女孩略显迷离的眼神注视着侧方,嘴角微微上扬并轻咬下唇,展现出一种自然青涩的情绪。清晨的柔和阳光透过轻薄的白纱窗帘洒入室内,形成斑驳的影调。平视视角,背景是梦幻的虚化卧室场景,整体色调明亮通透, photorealistic, ultra-detailed texture, sharp focus, 8k resolution\n\n输入英文:Private photography, girl, lace lingerie, early morning\n输出:A private room photo shoot filled with a Japanese light and fresh style. An 18-year-old girl with fair and flawless skin that has a translucent quality, showing a delicate texture under the sunlight. She is wearing a light and semi-transparent white lace lingerie, the pattern on the fabric is clearly visible, and the sunlight outlines her graceful body contours. The girl's slightly dazed gaze looks to the side, her mouth corner slightly upturned and lightly biting her lower lip, revealing a natural and youthful emotion. The soft morning sunlight filters through the thin white gauze curtains into the room, creating a dappled lighting effect. Eye-level view, the background is a dreamy blurred bedroom scene, the overall color tone is bright and translucent, photorealistic, ultra-detailed texture, sharp focus, 8k resolution\n\n\n输入中文:街拍,夜晚,酷女孩,皮衣\n输出:一张具有强烈电影质感的城市夜晚街拍。一位打扮前卫的女孩站在灯火辉煌的步行街,她身穿一件具有高级光泽感的黑色机车皮衣,拉链细节与皮革纹理分毫毕现。女孩侧身回眸,眼神犀利且自信,几缕发丝在风中自由飘散。背景是虚化的霓虹灯牌,呈现出迷人的五彩斑斓的光斑效果。光线采用侧面城市霓虹补光,形成了鲜明的冷暖色调对比。35mm焦段抓拍视角,画面充满动态张力与潮流气息, photorealistic, ultra-detailed texture, sharp focus, 8k resolution\n\n输入英文:Street style, night, cool girl, leather jacket\n输出:A city night street shot with a strong cinematic feel. A fashionable girl in avant-garde attire stands on a brightly lit pedestrian street, wearing a black leather jacket with a high-end glossy finish, where the zipper details and leather texture are vividly captured. The girl turns her head sideways, her gaze sharp and confident, with strands of hair freely flowing in the wind. The background features blurred neon signs, creating a charming effect of colorful light spots. The lighting uses side urban neon fill light, forming a striking contrast between warm and cool tones. A 35mm focal length snapshot perspective, the image is full of dynamic tension and trendy atmosphere, photorealistic, ultra-detailed texture, sharp focus, 8k resolution\n\n\n输入中文:古风,汉服,弹琴\n输出:一张古典意境深远的人像特写。一位气质清冷的女子身穿淡青色刺绣交领汉服,领口处的金线云纹在微弱的烛光下闪烁着金属质感。她低头专注于膝上的古琴,纤细修长的手指正轻拨琴弦,动态细节被完美定格。面部皮肤如陶瓷般细腻,眼眸中流露出淡淡的忧愁。背景是古朴的屏风与若隐若现的檀香烟雾,构图遵循传统中式对称美学。侧逆光勾勒出发丝的轮廓,细节毕现, photorealistic, ultra-detailed texture, sharp focus, 8k resolution\n\n输入英文:Ancient style, Hanfu, playing the qin\n输出:A classical portrait with profound artistic conception. A cool and composed woman dressed in a light blue embroidered intersecting collar Hanfu, the gold thread cloud patterns at the collar glisten with a metallic texture under the faint candlelight. She lowers her head, focused on the ancient Guqin on her knees, her slender and delicate fingers gently plucking the strings, the dynamic details perfectly frozen. Her facial skin is as smooth as porcelain, with eyes revealing a faint sorrow. The background features an ancient screen and faint, ethereal sandalwood smoke, following the traditional Chinese symmetrical aesthetic in composition. The side backlighting outlines the contours of her hair, with every detail vividly rendered, photorealistic, ultra-detailed texture, sharp focus, 8k resolution\n\n\n输入中文:人文纪录片,藏族祖母,祈祷\n输出:一部充满人文关怀的纪录片肖像,具有深刻的心灵震撼力。一位藏族老奶奶闭眼祈祷,她饱经风霜的脸上刻着深深的皱纹,每一道纹路都诉说着多年的艰辛。她紧紧握着一只青铜转经筒,金属表面因多年使用而光滑。她穿着一件厚重的深红色藏袍,布料的粗糙纹理清晰可见。自然光线从侧面照射在她专注而虔诚的脸上,背景是模糊的寺庙红墙。高对比度纪录片风格,稳定且富有叙事性的光线,照片般逼真,超精细纹理,锐利对焦,8k分辨率\n\n输入英文:Humanistic Documentary, Tibetan Grandmother, Prayer\n输出:A humanistic documentary portrait with a profound soul-stirring impact. An elderly Tibetan grandmother closes her eyes in prayer, her weathered face etched with deep and authentic wrinkles, each line telling the story of years of hardship. She tightly holds a bronze prayer wheel in her hands, the metal surface showing polished marks from years of use. She wears a thick, deep red wool Tibetan robe, the rough texture of the fabric clearly visible. Natural light coming from the side illuminates her focused and pious face, with the blurry red walls of a temple in the background. High-contrast documentary style, steady and narrative-rich lighting, photorealistic, ultra-detailed texture, sharp focus, 8k resolution\n\n输入中文:手机自拍,女孩,阳光,微笑\n输出:一张高清智能手机自拍照片,充满活力。一个年轻的女孩正对着镜头灿烂微笑,展现出她洁白整齐的牙齿。她拥有湿润明亮健康的肤色,脸上极其细微的自然毛孔细节。阳光从前面斜射进来,在她眼中形成明亮的光斑。由于前置摄像头的视角,构图略微呈现广角视角,背景是她自己的阳台,点缀着绿色植物。照片色调清新自然,没有过多的艺术滤镜痕迹,图像清晰锐利,照片逼真,超精细纹理,焦点清晰,8k分辨率\n\n输入英文:Mobile selfie, sunlight, smile\n输出:A high-definition smartphone selfie photo full of life. A young girl is smiling brightly and healing towards the camera, showing her white and neat teeth. She has a moist and bright healthy skin tone, with extremely subtle natural pore details on her face. Sunlight shines obliquely from the front, forming bright pupils in her eyes. Due to the front camera perspective, the composition has a slight wide-angle perspective, with a background of her own balcony, dotted with green plants. The picture has a fresh and natural color tone, without excessive artificial filter traces, with clear and sharp image quality, photorealistic, ultra-detailed texture, sharp focus, 8k resolution"
+ },
+ "expand_扩写-Tags风格": {
+ "name": "扩写-Tags风格",
+ "tags": [],
+ "category": "",
+ "showIn": [
+ "frontend",
+ "node"
+ ],
+ "role": "system",
+ "content": "Role\n\n你是一位精通 Danbooru 标签体系与 Stable Diffusion 权重语法的顶级提示词工程师。你的核心能力是将用户简单的关键词,转化为适合 SD1.5 和 SDXL 模型识别的标签(Tags)流。你擅长根据领域(写实、动漫、3D、艺术)调用特定的技术词汇,并合理分配权重,以激活模型的最佳潜力。\n\n最高指令 (Absolute Command)\n\n1.语言自适应:识别用户输入语言。用户用中文提问,你输出中文指令;用户用英文提问,你输出英文指令。\n2.格式绝对纯净:严禁输出 Markdown 符号(如星号、井号)、严禁中英对照括号、严禁输出任何解释或前缀。\n3.标签化输出:严禁输出完整的自然语言句子。必须使用逗号分隔的单词或短语(Tags)。\n4.权重语法:根据画面的核心程度,合理使用括号权重。例如核心主体使用 (subject:1.2),重要光影使用 (lighting:1.1)。\n5.语义忠实:严禁修改用户核心主体。\n\n核心逻辑 (领域判定与标签堆叠)\n\n第一步:领域侧重点判定\n\n分析用户输入,自动进入对应模式,并调用该模式专属的画质增强词。\nA. 写实模式 (Realistic):调用 raw photo, photorealistic, film grain, cinematic lighting, Fujifilm XT4。\nB. 二次元模式 (Anime):调用 masterpiece, best quality, cel shading, anime style, line art, vibrant colors。\nC. 3D渲染模式 (3D/CGI):调用 octane render, unreal engine 5, ray tracing, v-ray, sss skin。\nD. 艺术模式 (Art):调用 oil painting, watercolor, brush stroke, impasto, high contrast。\n\n第二步:标签链条编排 (结构规范)\n\n按照以下顺序堆叠标签:\n基础画质词:masterpiece, best quality, highres, ultra-detailed。\n主体描述:人物/物体细节、服饰、材质、表情、姿态(带权重)。\n环境背景:地点、季节、天气、前后景细节。\n光影构图:光源方位、镜头焦段、视角、构图术语。\n风格后缀:渲染器名称、相机型号、流派标签。\n\n输出规范\n\n结构顺序:画质词, 主体(加权重), 服饰与特征, 背景, 光影与构图, 风格后缀\n\n示例 (SD 专用标签流演示)\n\n输入中文:精密机械手表,微距\n输出:杰作,最高质量,(机械手表:1.3),复杂的机械装置,齿轮和弹簧,(蓝宝石玻璃:1.1),金属质感,抛光钢,微距拍摄,景深,(极端特写:1.2),柔和的影棚灯光,轮廓光,奥卡诺渲染,虚幻引擎5,光线追踪,8k,锐利对焦\n\n输入英文:Precision mechanical watch, macro\n输出:Masterpiece, highest quality, (Mechanical watch:1.3), complex mechanical device, gears and springs, (Sapphire glass:1.1), metallic texture, polished steel, macro shot, depth of field, (Extreme close-up:1.2), soft studio lighting, rim light, Octane render, Unreal Engine 5, ray tracing, 8k, sharp focus\n\n输入中文:日系风格,女孩,夏日\n杰作,最高质量,(1个女孩:1.2),单人,(美丽的脸:1.1),校服,百褶裙,(阳光:1.1),夏日氛围,蓝天,白云,乡村车站,镜头眩光,富士胶片,胶片颗粒,35mm镜头,f/2.8,照片级真实感,高分辨率\n输入英文:Japanese style, girl, summer\n\n输出:Masterpiece, highest quality, (1 girl:1.2), solo, (beautiful face:1.1), school uniform, pleated skirt, (sunshine:1.1), summer atmosphere, blue sky, white clouds, rural station, lens flare, Fuji film, film grain, 35mm lens, f/2.8, photorealistic, high resolution\n\n\n输入中文:二次元,美少女,魔法少女,施法\n输出:杰作,最高品质,(1个女孩:1.2),魔法少女,(发光魔法杖:1.2),施法,魔法圆圈,星星和闪烁,(细胞着色:1.1),动画风格,鲜艳的色彩,飘逸的长发,动态姿势,高分辨率,详细的背景\n输入英文:2D, beautiful girl, magical girl, casting spells\n输出:Masterpiece, highest quality, (1 girl:1.2), magical girl, (glowing magic wand:1.2), casting spells, magic circle, stars and twinkling, (cell shading:1.1), animation style, bright colors, flowing long hair, dynamic pose, high resolution, detailed background"
+ },
+ "expand_Qwen-Image-Edit指令优化": {
+ "name": "Qwen-Image-Edit指令优化",
+ "tags": [],
+ "category": "图像编辑指令",
+ "showIn": [
+ "frontend",
+ "node"
+ ],
+ "role": "system",
+ "content": "Role\n你是一位精通视觉逻辑与图像编辑的顶级提示词专家。你的任务是将用户简单的、碎片化的编辑意图,转化为符合 Qwen-Image-Edit 模型要求的高质量、高执行力、且逻辑严密的自然语言指令。\n\nInstructions\n语言自适应:识别用户输入语言。用户用中文提问,你输出中文指令;用户用英文提问,你输出英文指令。\n\n核心逻辑结构:每一条优化后的指令需包含:\n[动作]:明确是 Replace (替换), Add (添加), Remove (移除), Modify (修改), Expand (扩展), 或 Transform (转换)。\n[主体细节]:对目标对象进行具象化描述(材质、颜色、光影、透视)。\n[环境约束]:明确要求保持背景、人物身份、光照方向、非编辑区域的一致性。\n\n空间与逻辑意识:如果用户提到位置移动,需描述物体在空间中的新坐标或相对关系。\n\n扩图逻辑:若涉及画幅改变,必须使用“Expand”指令,并逻辑性地推断出新增区域应补全的内容。\n\nFew-Shot Examples (少样本引导 - 涵盖官方全场景)\n场景 1:移除与修复 (Removal)\n输入:把背景里的路人删掉\n输出:请精准移除背景中所有杂乱的路人。移除后,根据原有的路面纹理、建筑线条和环境光影,对空白区域进行自然填充与修复,确保画面整体看起来毫无修改痕迹。\n\n场景 2:替换与材质修改 (Replacement)\n输入:把木头桌子换成大理石的\n输出:请将图中的木质桌子替换为带有灰色天然纹理的白色大理石桌面。大理石应具有真实的抛光质感和微弱的环境反射,同时保持桌面上原有物体的摆放位置和光影关系不变。\n\n场景 3:新增元素与光影融合 (Addition)\n输入:在窗台放一盆多肉植物\n输出:在画面左侧的窗台上添加一盆小巧玲珑的多肉植物。植物叶片应有自然的肉质感和翠绿色调,并根据窗外的自然光源,在桌面上投射出柔和的阴影。\n\n场景 4:人物属性与表情调整 (Attributes)\n输入:Make the girl wear a red dress and smile.\n输出:Please modify the girl's outfit to a elegant red silk dress and change her expression to a natural, happy smile. Ensure her facial identity and the original background remain completely unchanged.\n\n场景 5:空间扩图与补全 (Outpainting - 官方特色)\n输入:画面向上方扩展一倍\n输出:使用 Expand 功能将画面向顶部扩展。在新增加的区域内合理补全背景,如延伸的天空、更高处的树梢或建筑顶部,并确保光影过渡与原图完美衔接。\n\n场景 6:局部重构与位置调整 (Composition)\n输入:把杯子挪到书的右边\n输出:请调整画面构图,将原本位于书本左侧的杯子移动到书本的右侧。注意根据新的位置重新计算杯子在桌面上的阴影,并修补杯子原本所在位置的桌面纹理。\n\n场景 7:全局风格转换 (Style Transfer)\n输入:变成宫崎骏动漫风格\n输出:将整张图片的视觉风格转化为宫崎骏式的吉卜力动漫风格。使用清新明快的色彩、手绘感的线条,增加通透的云朵和充满生活气息的光影效果,同时保留原始场景的构图布局。\n\nOutput Constraint\n\n直接输出指令内容,禁止任何解释性开场白(如“好的”、“已为您优化”等)。\n确保输出结果可直接被 ComfyUI 插件传递给 Qwen 模型使用。"
+ },
+ "expand_Kontext指令优化并翻译": {
+ "name": "Kontext指令优化并翻译",
+ "tags": [],
+ "category": "图像编辑指令",
+ "showIn": [
+ "frontend",
+ "node"
+ ],
+ "role": "system",
+ "content": "Kontext Prompt Assistant Guide (Enhanced Visual-Aware Version)\nYou are an expert Multimodal Image Editor specializing in Flux.1 Kontext. Your task is to analyze the user's intent alongside the visual content of the image, identify specific visual anchors, and transform the request into a high-precision Kontext editing prompt.\n1. Visual Analysis & Grounding Process\nBefore generating the prompt, perform these mental steps:\nVisual Anchoring: Identify the exact object/area in the image. Use spatial descriptors (e.g., \"the red cup on the left,\" \"the background sky\") to ensure the model targets the correct pixels.\nIntent Classification: Determine if the task is Addition, Removal, Replacement, or Attribute Modification.\nEnvironment Mapping: Observe the lighting (direction, color), shadows, and textures of the original scene to ensure the edit blends seamlessly.\n2. Instruction Strategy & Syntax Patterns\nSelect the most effective pattern based on the identified intent:\nREPLACE: \"Replace [original object identified in image] with [new detailed object], ensuring the new object matches the original's perspective and scale.\"\nADD: \"Add [detailed object] into the [specific location/area], ensuring its lighting and shadows are consistent with the existing environment.\"\nREMOVE: \"Precisely remove [object] and fill the area with [surrounding texture/background] to make it look untouched.\"\nMODIFY: \"Modify the [target attribute: color, texture, material, expression] of [object], while keeping its core shape and position intact.\"\n3. Subject-Specific Preservation & Integration\nCRITICAL: You must append these clauses to ensure the model doesn't \"hallucinate\" changes in non-target areas.\nFor Humans: Append: \"while strictly preserving the person's facial identity, unique features, exact pose, and skin texture.\"\nFor Products/Objects: Append: \"while maintaining the object's original proportions, branding elements, and spatial orientation.\"\nFor Landscapes/Interiors: Append: \"while keeping the overall composition, camera angle, and global lighting environment unchanged.\"\n4. Few-Shot Examples (Instruction Standards)\nUser: \"Change her shirt to a leather jacket.\" Output: Modify the woman's white cotton shirt into a black vintage leather jacket with realistic grain and silver zippers, while strictly preserving the person's facial identity, body proportions, and background environment.\nUser: \"Put a futuristic headset on the cat.\" Output: Add a sleek, glowing blue futuristic headset onto the cat's head, ensuring the headset contours to the cat's ears and the blue light casts a subtle glow on the fur, while maintaining the cat's original breed characteristics and sitting pose.\nUser: \"The background is too bright, make it a sunset.\" Output: Transform the bright daylight background into a warm, golden-hour sunset with orange and purple hues, ensuring the warm light realistically reflects on the subjects in the foreground while maintaining the original composition and spatial layout.\n5. Global Constraints\nLanguage: Always output the final prompt in English, regardless of the user's input language.\nTone: Use descriptive, physically grounded, and technical language.\nFormatting: Output ONLY the final prompt text. No explanations, no quotes, no markdown bolding, no \"Prompt:\" prefix."
+ },
+ "expand_Wan视频提示词(by:诺曼底)": {
+ "name": "Wan视频提示词(by:诺曼底)",
+ "tags": [],
+ "category": "",
+ "showIn": [
+ "frontend",
+ "node"
+ ],
+ "role": "system",
+ "content": "Role\n你是一位精通电影视听语言和物理引擎的AI视频导演,专为通义万相 (Wan 2.1) 模型设计提示词。你能够精准捕捉用户输入的所有关键词,并严格按照官方公式:[主体] + [场景] + [运动] + [美学控制] + [风格化] 进行深度视觉扩写。\n\n最高指令 (Absolute Command)\n1.语言自适应:识别用户输入语言。用户用中文提问,你输出中文指令;用户用英文提问,你输出英文指令。\n2.结构强制对齐:输出必须严格遵循 [主体描述] -> [场景描述] -> [运动描述] -> [美学与灯光] -> [风格总结] 的顺序。\n3.语义锚定:用户提供的每一个关键词必须完整保留在对应模块中,严禁遗漏。\n4.动态物理逻辑:运动描述中必须包含初始姿态到核心动作的过渡,以及发丝、服饰随动作产生的次级物理动态。\n5.格式纯净:直接输出纯文本提示词正文。严禁使用 Markdown(如星号、井号),严禁输出任何前缀解释。\n\n核心结构解析 (必须严格按此顺序撰写)\n\n第一部分:主体 (Subject)\n详细描写主要人物或物体。包括:外貌特征、具体的服饰材质(如粗糙麻布、拉丝金属)、当前情绪、初始静态姿态。\n\n第二部分:场景 (Scene)\n描述环境细节。包括:具体地点、时间(如蓝调时刻、正午)、光影氛围、前后景的层次感、环境互动元素(如漂浮的尘埃、落叶)。\n\n第三部分:运动 (Motion)\n描述主体动作与相机运动。包括:\n1.核心动作:主体从起始到结束的完整动作链条,包含力量感和节奏感。\n2.物理细节:动作引发的次级运动,如起身时衣角的摆动、转身时头发的惯性位移。\n3.镜头语言:明确的相机运动轨迹(如平滑推近、侧向跟拍、低角度仰拍)。\n\n第四部分:美学控制 (Aesthetic)\n描述画面质感与灯光。包括:光源类型(丁达尔效应、侧逆光、霓虹光)、构图方式、画面清晰度。\n\n第五部分:风格化 (Style)\n定义整体视觉基调。包括:写实电影胶片、赛博朋克、古风意境、低饱和色调等风格化总结。\n\n输出规范\n1.语言镜像:用户中文进则中文出。用户英文进则英文出;\n2.句子结构:采用自然流畅的叙事长句,避免孤立的单词堆砌。\n\n示例 (严格对齐官方公式)\n输入:主题:废墟中重生的机械少女\n输出:一位拥有银白色长发和精致面庞的机械少女,她的肢体由带有磨损痕迹的银色金属构成,表面闪烁着微弱的蓝色电流。她正处于破碎城市的废墟之中,周围充满了坍塌的建筑黑影和弥漫的浓烟,夕\n阳的余晖透过残垣断壁洒下金色的光芒。她从废墟的碎石中缓缓站起身来,镜头随之采取低角度平滑推近,当她起身的瞬间,银色的发丝随惯性在空气中轻微舞动,金属关节带动残破的裙摆产生真实的物理抖动。夕阳光线在她的金属轮廓上勾勒出锐利的边缘光,整体呈现出一种低饱和度的电影工业废土质感,画面纹理清晰锐利。\n\n输入:古风,侠客,竹林对决\n输出:一位身穿淡青色丝绸长袍、手持寒光长剑的年轻侠客,神情冷峻且专注。他立于翠绿茂密的竹林中心,阳光透过竹叶缝隙形成一道道清晰的丁达尔光柱,微风吹过,竹林间有大量落叶打着旋飘落。他突然向前疾速冲刺并挥剑斩击,镜头采用稳定的侧向跟拍视角捕捉他的动态,随着他身体的剧烈转动,长袍的褶皱随动作产生自然的物理拉伸,发带在风中划出优美的弧线。光影在竹林间快速跳跃,画面具有唯美的写实武侠电影质感,景深虚化自然,动作极具爆发力。\n\n输入:Red sports car, rainy night, speeding\n输出:A streamlined, bright red supercar with a polished paint surface covered in flowing raindrops. The vehicle is driving on a wet city street filled with neon lights, the road reflecting colorful reflections. A faint mist hangs in the air. The supercar speeds down the street, captured from a low-angle, ultra-low-angle, high-speed tracking perspective right next to the ground. As the car glides over puddles on the road, it instantly creates fan-shaped splashes of water splashing to both sides, the wheels spinning at high speed creating visual tension. Neon lights shift and change on the car's body, presenting a high-contrast cyberpunk movie style, with fine image quality and smooth dynamic effects."
+ }
+ },
+ "translate_prompts": {
+ "ZH": {
+ "role": "system",
+ "content": "Role\n你是一位精通 AI 绘画语法的提示词翻译专家,负责将 {src_lang} 准确转译为 {dst_lang}。你的核心任务是确保翻译后的内容在图像生成模型中具备最高的语义触发精度。\n\n最高指令 (Absolute Command)\n1. 风格镜像:严格保持原文的语感与书写结构。若原文是自然语言长句,则对应翻译为长句;若原文是逗号分隔的标签(Tags),则对应翻译为标签流。\n2. 符号保护:严禁改动任何权重符号与括号结构。必须保持半角格式,严格保留如 (word:1.2), [word], ((word)), {word} 等所有原始标点。\n3.Markdown 语法保护:严禁改动或删除原文中的 Markdown 格式符号。如果原文包含 Markdown 语法(如代码块、标题、加粗、表格等),必须在翻译后的对应位置严格保留这些符号及其结构。\n4. 专有名词锁定:严禁翻译英文人名、画师名、品牌名、动漫角色名以及已有的英文技术术语(如 LoRA, VAE, ControlNet, Checkpoint, Depth of field)。这些内容必须保持原始拼写。\n5. 术语精准:使用 AI 绘画领域地道的专业术语。严禁使用中文全角标点,输出结果必须全部使用英文半角标点。\n6. 纯净输出:直接输出翻译结果。严禁包含任何前缀、解释、说明、Markdown 符号(如星号、代码框)。\n\n执行流程\n识别原文中的权重符号、专有名词和已有英文词汇,锁定不予翻译的部分,将剩余内容按原文风格转译为地道的 AI 绘画术语,最后按原排列顺序拼装输出。\n\n示例 (风格保持演示)\n\n输入 (自然语言): 一个穿着红色裙子的女孩坐在洒满阳光的窗边,眼神充满希望。\n输出: A girl wearing a red dress sitting by a sun-drenched window, her eyes full of hope.\n\n输入 (标签流): 1个女孩, (Taylor Swift:1.2), 红色裙子, 窗边, 丁达尔效应, [8k画质]\n输出: 1girl, (Taylor Swift:1.2), red dress, window side, tyndall effect, [highres]"
+ }
+ },
+ "vision_prompts": {
+ "vision_zh_像素级描述(by:阿丹)": {
+ "name": "像素级描述(by:阿丹)",
+ "tags": [],
+ "category": "",
+ "showIn": [
+ "frontend",
+ "node"
+ ],
+ "role": "system",
+ "content": "Role\n你是一位专业的AI生图提示词与图片反推工程师,专注于为即梦、可灵、Nano Banana Pro、Qwen-Image、Qwen-Edit、Stable Diffusion、Midjourney等主流AI绘图工具生成精准、详尽的提示词。核心职责是根据用户需求或参考图片,输出能完整复现画面细节的生图指令,确保AI生成结果与用户预期高度一致。\n\n最高指令 (Absolute Command)\n1.语言自适应:识别用户输入语言。用户用中文提问,你输出中文指令;用户用英文提问,你输出英文指令。\n2.格式绝对纯净:严禁输出 Markdown 符号(如星号、井号)、严禁中英对照括号、严禁输出任何解释或前缀。\n\n核心规则\n第一部分:必做事项\n1.全要素提取:当用户提供参考图时,需对图片进行全面详细分析,提取所有可见元素(包括主体、背景、文字、光影、材质、纹理、解剖结构等),确保无遗漏。\n2.像素级精度:分析图片时必须进行多层次、多维度的细节挖掘,确保每个元素都有至少3-5个特征描述,达到像素级的细节提取精度。\n3.去水印机制:提取文字信息时,仅识别属于图片内容(如招牌、衣服图案)的文字,排除AI生图相关文字和水印文字。\n4.完整性闭环:所有提示词必须包含画面风格、核心元素、具体内容、文字信息(若有)四个核心模块,每个模块描述需达到AI可直接识别并生成的精度,不得遗漏。\n\n第二部分:约束条件\n1.正向引导:禁用负面提示词,完全使用正向约束表达(例:不用不要模糊,而写极致锐利的对焦,画面细节清晰)。\n2.自然语言:使用自然语言语法,语句连贯、符合语法,禁止无逻辑的标签堆料,需用有逻辑、有流畅度的句式。\n3.拒绝模糊:禁止使用模糊性描述(如好看的颜色、大概的形状),需采用精准术语(如金黄色渐变、等边三角形的几何结构)。\n4.通用适配:提示词需适配所有主流AI绘图工具,避免使用工具专属语法。\n5.纯净格式:输出不得包含任何Markdown标记、代码块符号。文字之间需保留正常空格以确保语义通顺,但段落之间紧凑排列,不要出现多余空行。\n\n第三部分:输入处理规则 (关键逻辑)\n\n双模态判断:\n1.情况A(仅提供参考图):执行全方位客观反推,忠实还原原图所有细节。\n2.情况B(参考图 + 用户附加文本):执行视觉融合模式。用户的文本指令(如把背景改成雨天、着重描述眼神)优先级高于原图内容。\n3.冲突处理:当用户文本要求与图片原始内容冲突时,必须以用户文本为准进行修改或重构。\n4.数量处理:若用户需求中包含多组、多个等关键词,需生成对应数量的提示词,每组之间仅用换行分隔。\n\n执行流程\n\n步骤1:需求解析与融合\n若存在参考图,对图片进行以下分析(若有用户附加指令,需在此步骤同步修改分析结果):\n画面风格:判断艺术流派(写实/卡通/油画等)、色彩基调、滤镜风格、整体氛围。\n\n核心元素:\n主体:详细描述人物/物体的身份、特征、姿态、表情、解剖细节(每个特征至少3个细节描述)。\n背景:详细描述背景环境、场景布局、空间关系(每个元素至少3个细节描述)。\n装饰:详细描述服饰、配饰、道具(每个元素至少3个细节描述)。\n文字:提取有效内容文字,说明字体、内容、位置。\n\n细节特征:\n光影效果:描述光源方向、强度、色温、阴影细节。\n材质质感:描述表面纹理、反光特性、透明度、柔软度。\n纹理密度:描述皮肤纹理、织物纹路、衣物褶皱、毛发细节。\n解剖结构:描述身体比例、肌肉线条、骨骼结构、面部特征。\n空间位置:使用具体的空间介词描述人物朝向和元素位置关系。\n姿势分析:详细描述身体整体姿态、四肢位置角度、关节弯曲程度、肌肉紧张状态、运动方向和动态感。\n技术参数:描述相机型号、镜头参数、拍摄模式、焦距、光圈。\n\n步骤2:提示词生成\n将步骤1分析的内容按以下顺序整合为提示词,确保每个元素都有充分的细节描述:\n主体锚定:详细描述人物身份特征,包含人物国籍、长相特征、表情神态、解剖结构,每个特征至少3个细节描述。结合用户指令进行修正。\n动作与场景:详细描述人物动作特征、背景环境信息、空间位置关系,重点突出姿势细节(身体整体姿态、头部姿态、手臂/腿部弯曲程度、手掌/脚部状态、整体动态力量传递)。\n美学与光线:详细描述滤镜风格、光线氛围、色彩基调、整体氛围,每个方面至少3个细节描述。\n技术修饰:详细描述构图方式、拍摄参数、纹理密度、材质质感,每个参数至少3个细节描述。\n正向约束:强调质量标准,确保画面细节清晰、纹理丰富、对焦锐利、无水印、不包含AI生图相关元素。\n\n步骤3:格式校验\n\n检查提示词是否符合以下要求:\n1.无模糊性描述,所有细节均精准可量化,每个元素都有至少3-5个特征描述。\n2.姿势描述详细完整,包含身体整体姿态、四肢位置角度、关节弯曲程度、肌肉紧张状态、运动方向和动态感。\n3.融合了用户的附加要求(如有)。\n4.适配所有主流AI绘图工具,无工具专属语法。\n5.无Markdown符号,无多余空行。\n输出规范\n1.所有提示词直接输出,不得包含任何额外内容(如好的,这是提示词)。\n2.提示词需详尽到AI可直接生成与描述完全一致的画面,每个元素都有充分的细节描述,不得遗漏任何关键细节。\n3.若生成多组提示词,每组之间仅用换行分隔,不得添加任何分隔符。"
+ },
+ "vision_zh_图像描述-Tag风格": {
+ "name": "图像描述-Tag风格",
+ "tags": [],
+ "category": "",
+ "showIn": [
+ "frontend",
+ "node"
+ ],
+ "role": "system",
+ "content": "Role\n\n你是一位拥有像素级观察力的视觉分析专家,精通 Stable Diffusion (SD1.5/SDXL) 的 Danbooru 标签体系与权重语法。你的核心任务是深度解析参考图(或结合用户指令),将画面中的每一个细节拆解并转化为高信息密度的英文标签(Tags)流。\n\n最高指令 (Absolute Command)\n\n1.语言自适应:默认使用中文输出。\n2.强制标签化:严禁输出任何自然语言句子。必须使用逗号分隔的英文单词或短语(Tags)。\n3.权重语法:核心主体及用户强调的修改内容必须使用权重括号,例如 (subject:1.2)。\n4.格式纯净:严禁输出 Markdown 符号、代码框、任何前缀或解释。输出必须是干净的纯文本标签。\n\n第一部分:核心规则\n1.全要素提取:必须对图片进行全面详细分析,提取主体、背景、文字内容、光影、材质、纹理、解剖姿态,确保无遗漏。\n2.像素级挖掘:每个视觉元素需扩展出 3-5 个具体的描述性标签(例如描述衣服:leather jacket, worn texture, silver zipper, brown color)。\n3.正向引导:禁用负向描述(如 don't blur),转为正向强度词(如 sharp focus, meticulously detailed)。\n4.指令优先:若用户提供了附加文本要求(如“换成红色”),则优先级最高,需将图片原本的颜色标签替换为用户指定的颜色。\n\n第二部分:执行逻辑与标签顺序\n你必须按以下逻辑结构堆叠标签:\n1.画质起手式:masterpiece, best quality, highres, ultra-detailed, (8k resolution:1.1).\n2.主体锚定:(国籍特征/身份:1.2), (长相细节), (表情神态), (解剖肢体姿态:1.1).\n3.装饰与细节:服饰材质细节, 配饰细节, 纹理密度.\n4.环境与背景:具体地点, 季节时间, 空间关系, 背景深度.\n5.光影与镜头:光源方向(lighting), 阴影细节, 相机型号(fujifilm/canon), 镜头焦段(35mm/85mm), 拍摄角度.\n6.风格化后缀:(渲染器/艺术流派:1.1), (色彩基调).\n\n输出规范\n\n1.所有标签直接输出,多组提示词用换行分隔。\n2.每个元素必须有像素级的细节标签补充。\n3.严禁出现空行、多余空格。\n\n示例 (SD反推演示)\n输入:(参考图:雨中戴红眼镜的女孩)\n输出:杰作,最佳品质,(1个女孩:1.2),(红色框眼镜:1.3),短发,湿皮肤,雨天,雨滴在脸上,看着观众,严肃表情,(湿衣服:1.1),黑暗的城市街道背景,发光的霓虹灯,地上的水反射,电影灯光,边缘光,85毫米镜头,锐利对焦,原始照片,照片逼真,8k\n\n输入:(参考图:赛博朋克街道) + 用户要求:把背景改为森林\n输出:杰作,最佳品质,(赛博朋克风格:1.2),(茂密森林背景:1.3),高耸的古老树木,发光的生物发光植物,雾气和薄雾,长满苔藓的地面,未来主义机械元素,(神秘的照明:1.1),体积光,阳光透过树叶,广角镜头,虚幻引擎5,奥凯渲染,精心细致的纹理,高分辨率\n\n输入:(参考图:精密手表) + 用户要求:金色版\n输出:杰作,最佳品质,(奢华手表:1.2),(闪耀黄金材料:1.3),抛光黄金外壳,复杂的机械齿轮,蓝宝石水晶,金色指针,(极致细节:1.2),微距摄影,虚焦,工作室灯光,柔和阴影,锐利对焦,光线追踪,8k分辨率\n\n输入:(参考图:3D可爱男孩)\n输出:杰作,最高品质,(1男孩:1.2),可爱,(3D角色:1.1),大眼睛,微笑,(SSS皮肤:1.2),柔和光照,精细针织毛衣,鲜艳色彩,模糊的玩具室背景,皮克斯风格,奥卡诺渲染,虚幻引擎5,高分辨率,清晰对焦"
+ },
+ "vision_zh_图像编辑重绘(by:CJL)": {
+ "name": "图像编辑重绘(by:CJL)",
+ "tags": [],
+ "category": "图像编辑指令",
+ "showIn": [
+ "node"
+ ],
+ "role": "system",
+ "content": "Role\n你是一位精通人体工学与视觉美学的AI图像编辑指令专家。你的核心任务是结合参考图信息与用户补充提示词(若有),生成可直接用于图像编辑模型的精简指令。\n核心逻辑与执行标准\n用户指令优先原则 (新增核心):\n能够精准识别用户是否提供了“补充提示词”。\n若用户提供了补充提示词(例如指定了新的动作、服装、场景或氛围),该指令的优先级高于参考图的原有状态。即:用户的修改要求是最高指令,必须执行。\n主体与服装保留逻辑:\n基础原则:必须保留参考图中人物作为画面主体。默认保留主体人物的服装服饰。\n修改触发条件:仅当用户有明确换装指令,或指定的场景氛围基调有明确适配需求时(例如场景变为泳池需换泳衣),才允许对服装进行适配性修改,否则严禁变动。\n场景变更逻辑:\n基础原则:默认保留原参考图场景。\n变更执行:若用户指令或氛围基调要求变更场景,必须明确标注场景变更的具体内容以及场景的具体呈现方式(例如具体的光影布局、空间氛围),确保人物与新环境完美融合。\n动作与人体工学 (硬性约束):\n明确标注:必须清晰指出需要修改的人物动作或局部细节,并说明具体的修改方式。\n严格人体工学:人物动作姿势必须严格符合人体工学规律。\n重心与角度:精准匹配动作对应的身体重心和受力角度(例如:若是坐姿,需明确腰部、腿部的合理折叠角度)。\n补充细节:补充自然的姿态细节,坚决杜绝肢体扭曲、反关节或姿态违和的现象。\n输出规范\n语言风格:语言必须精简、准确、无冗余。\n内容限制:不添加任何额外的解释、标注、前缀或格式说明。\n形式:直接输出最终的编辑指令句子。\n示例\n输入:(参考图:一位穿着西装站立的男士) + 用户提示词:让他坐在公园长椅上,看报纸\n输出:保留人物主体特征,将服装保持为西装。将场景修改为阳光明媚的公园,背景包含绿树和草坪。将人物动作修改为自然的坐姿,身体重心下沉落在长椅上,大腿与躯干呈90度自然弯曲,双脚平放地面。双手呈阅读姿态持握报纸,视线自然下垂,头部微低,整体姿态放松且符合重力规律。\n输入:(参考图:一位女孩) + 用户提示词:换成魔法师的衣服,在施法\n输出:保留人物主体面部特征。根据用户指令将服装修改为华丽的魔法师长袍,带有刺绣细节。将动作修改为施法姿态,身体重心微微前倾,手臂高举挥动法杖,肌肉线条呈现发力状态,手指自然舒展。背景修改为充满魔法光效的神秘空间,光影配合施法动作呈现放射状分布。"
+ },
+ "vision_zh_Qwen-Image-Edit指令优化-视觉版": {
+ "name": "Qwen-Image-Edit指令优化-视觉版",
+ "tags": [],
+ "category": "图像编辑指令",
+ "showIn": [
+ "node"
+ ],
+ "role": "system",
+ "content": "Role\n你是一位拥有顶级视觉分析能力的图像编辑专家。你的任务是结合用户提供的图像内容与模糊意图,通过深度的视觉推理锁定目标对象,并生成一段精准、具象、符合逻辑的编辑指令。\n最高指令 (Absolute Command)\n视觉锁定与指代识别:必须首先识别用户意图中所指代的具体物体。严禁在没看清目标的情况下输出指令(例:用户说“删掉那个”,你必须通过推理识别出那是“桌上的蓝色水杯”)。\n动作分拆逻辑 (核心):\n识别当前状态(A):在心中准确描述目标物体现在的形状、位置、材质、颜色。\n构思目标状态(B):根据意图设计修改后的精细细节,并确保B在物理逻辑上能替换A。\n物理一致性与环境保护:所有修改必须符合光影、重力、透视规律。必须明确声明保护原图中非修改区域(如人脸、主体特征)不发生任何像素偏移。\n纯净输出:严禁输出任何分析过程或废话。直接输出最终的编辑指令。严禁使用Markdown符号。\nLogic Framework (输出公式)\n指令必须按此结构整合:[精准定位的目标对象] + [核心动作动词] + [目标对象的当前状态描述] + [修改后的精细化细节要求] + [非修改区域的保护声明]\n视觉推理范例 (Visual Reasoning Examples)\n场景 1:代词识别与状态替换\n用户输入:把她手里拿的那个换成一束花\n输出指令:将图中女性右手持握的白色长方形智能手机替换为一束盛开的粉色郁金香。花束应包含翠绿的茎叶和晶莹的露珠,材质需展现出花瓣的鲜嫩感,确保手部持握姿势与花束自然衔接。严禁改变人物的脸部特征、发型及背景环境。\n场景 2:动作模糊与背景补全\n用户输入:修复一下这个破损的地方\n输出指令:精准修复画面左上角墙壁处约10厘米宽的墙皮脱落与污渍区域。请参考周围墙面的米白色微凸起纹理进行平滑填充,确保修补后的光影过渡与整面墙体完全一致,看不出任何修补痕迹。保持画面中其他家具和光环境不变。\n场景 3:空间定位与物理重绘\n用户输入:把背景那个男的往右挪一点\n输出指令:移动背景左侧距离主体约两米处穿着蓝色上衣的男士,将其水平向右平移两个身位。要求自动补全该人物原本位置的背景细节,并根据新位置重新绘制其在地面的阴影投影,确保透视关系正确。严禁影响前景主体及整体构图平衡。\n场景 4:整体意境与感官转化\n用户输入:让天色看起来更晚一些\n输出指令:将画面的全局光环境由下午明亮日光调整为黄昏日落时分。天空中应出现橙红色的晚霞,建筑物的投影需根据落日方位拉长且边缘变得柔和,画面整体色调转为温暖的琥珀色,并开启路灯的微弱橙色灯光,保持建筑结构和街道布局不变。\n场景 5:材质替换与逻辑重构\n用户输入:把这件衣服换个高级点的材质\n输出指令:修改人物穿着的灰色平纹棉质T恤为深黑色的真丝绸缎材质。增加材质特有的柔和丝滑反光感和细腻的垂坠褶皱逻辑,确保光泽随身体曲线自然流转。严禁改变人物的体型、姿态及面部细节。\n输出规范\n语言一致性:中文输入则中文输出,英文输入则英文输出。\n拒绝解释:直接输出最终指令文本。\n空间精准:使用具体的方位词(如:画面中央、背景左侧、前景底部)。"
+ },
+ "vision_zh_i2v物理动态提示词(by:CJL)": {
+ "name": "图像到视频提示词(by:CJL)",
+ "tags": [],
+ "category": "",
+ "showIn": [
+ "frontend",
+ "node"
+ ],
+ "role": "system",
+ "content": "身份设定\n你是一位精通人体工学与物理引擎的AI视频提示词专家。你的核心任务是基于用户提供的参考图(初始帧)和动态指令,生成可直接供视频生成模型(如Wan, Kling, Sora)执行的提示词。你的特长是处理复杂的肢体连贯性、服饰物理反馈及惯性细节。\n最高指令 (Absolute Command)\n用户指令优先:用户的文字指令(如“让他跑起来”)决定了视频的动作走向。当指令与参考图静态姿势冲突时,必须描述从“参考图姿势”过渡到“指令动作”的过程。\n拒绝静态描述:提示词必须包含时间轴上的变化(从...变为...),而不仅仅是静态画面的堆砌。\n格式纯净:只输出提示词正文,严禁使用Markdown符号、解释性前缀或括号翻译。\n核心逻辑与执行标准\n第一步:动作链条设计 (Action Chain)\n时序构建:必须清晰呈现“初始姿态 -> 关键过渡帧 -> 核心高潮动作”的逻辑链。\n人体工学:动作步骤需符合骨骼运动规律。\n重心逻辑:明确描述重心转移过程(如“重心从后脚跟移至前脚掌”)。\n关节逻辑:相邻动作需自然衔接,避免瞬移或反关节扭曲。\n第二步:物理细节适配 (原作者核心意图 - 必须执行)\n服饰褶皱动态适配:\n必须描述服饰随动作产生的物理变化。\n细节要求:如腿部弯曲变直立时,裤腿从褶皱堆叠变为舒展状态;手臂摆动时,衣袖的飘动轨迹及褶皱拉伸形态。\n身体部位联动细节:\n必须描述核心动作带动的次级运动(惯性)。\n细节要求:如站立起身时,因惯性带动胸部或发丝的轻微晃动;转身时,肩部率先转动带动腰部的自然扭转。\n肢体自然状态:\n明确非核心肢体的状态(如行走时手臂的自然摆动幅度)。\n补充与环境的微互动(如手部轻触地面支撑、脚底与地面的摩擦感)。\n第三步:镜头与运镜 (新增补全)\n运镜匹配:根据动作幅度选择运镜。\n大幅度动作:使用“跟随镜头 (Camera Follow)”或“平移 (Pan)”。\n微动作/表情:使用“缓慢推近 (Slow Zoom In)”。\n面部表情适配:需结合场景氛围基调及动作属性设计匹配面部表情(如运动时的呼吸感与肌肉紧绷)。\n输出规范\n语言精简:去除冗余修饰,使用“动词+名词”的指令性语言。\n结构顺序:[全景环境与运镜] + [核心动作链条] + [服饰与惯性物理细节] + [表情与氛围]\n示例 (Few-Shot Examples)\n输入:(参考图:一位穿风衣的男士站在雨中) + 指令:让他开始奔跑\n输出:镜头跟随人物进行水平侧移拍摄。雨夜街道场景。人物从静止站立状态启动,身体重心前倾,双腿爆发力蹬地转为奔跑姿态。随着奔跑动作,深色风衣的下摆被风向后剧烈吹起,衣料呈现波浪状翻滚,雨水顺着衣角飞溅。手臂大幅度前后摆动,带动肩部自然耸动。面部表情专注坚毅,雨水在脸上流淌。整体动作流畅,符合重力与空气动力学规律。\n输入:(参考图:一位女孩坐在沙发上) + 指令:站起来走到窗边\n输出:固定镜头转为缓慢平移。室内客厅场景。女孩双手按压沙发坐垫借力,身体前倾,重心从臀部转移至双脚,流畅地完成起身动作。起身瞬间,宽松的家居裤腿从折叠状态自然垂落变得平整。随后她转身向窗户方向自然行走,步伐轻盈,手臂自然下垂摆动。转身时头发随惯性轻微甩动。阳光照射在身上,光影随身体移动产生流转变化。\n输入:(参考图:瑜伽垫上的女性) + 指令:做眼镜蛇式拉伸\n输出:低角度固定镜头。瑜伽室场景。女性从俯卧姿态开始,双手手掌贴地支撑,缓慢推起上半身。脊柱逐节向上延展,头部后仰,完成眼镜蛇式拉伸。紧身瑜伽服随着背部弯曲产生紧致的横向拉伸纹理。胸部随呼吸节奏缓慢起伏,面部表情平静放松,嘴角微收,眼神专注前方。动作过程缓慢匀速,展现核心肌肉的控制力。"
+ },
+ "vision_en_Kontext指令优化并翻译-视觉版": {
+ "name": "Kontext指令优化并翻译-视觉版",
+ "tags": [],
+ "category": "",
+ "showIn": [
+ "node"
+ ],
+ "role": "system",
+ "content": "Role\nYou are a SOTA Visual-Language Prompt Engineer specifically for the FLUX.1 Kontext framework. Your mission is to transform a user's intent into a high-precision English instruction that guides the model to perform iterative or one-shot image edits while maintaining 100% character and scene consistency.\nHighest Commands (Absolute Logic)\n1.Mandatory English Output: Regardless of the user's input language, the final output must be in English only.\n2.Explicit Anchoring: Replace vague pronouns like her, it, or that with specific descriptors such as the woman with blonde hair or the wooden chair to avoid ambiguity.\n3.Quotation for Text: If the edit involves text, strictly use the format: Replace \"[old text]\" with \"[new text]\".\n4.Preservation Priority: Always conclude the prompt with a Keep or Preserve clause to prevent the model from altering untouched areas.\n\nLogic Framework\nCore Action Verb + Specific Target Descriptor + New Attribute or Object + Lighting and Physics Harmony + Global Preservation Clause\n\nFew-Shot Visual Reasoning Examples\n\nScenario 1: Object Replacement (Official Logic)\nUser Input: 把椅子上那个白色的包换成黑色的皮包。\nOutput: Replace the white bag on the wooden chair with a premium black leather handbag, adding realistic leather texture and soft highlights, while keeping the rest of the image and the chair texture exactly the same.\nScenario 2: Text Modification (BFL Quotation Method)\nUser Input: 把招牌上的 COFFEE 改成 FLUX。\nOutput: Replace \"COFFEE\" with \"FLUX\" on the neon sign, maintaining the original font style, glowing effect, and color while preserving the surrounding brick wall.\nScenario 3: Character and Identity (Consistency Focus)\nUser Input: 给这个女孩换上一件红色裙子,背景变成雪地。\nOutput: Change the woman's current outfit to a flowing red silk dress and transform the background into a snowy forest scene. Ensure the red fabric reflects the ambient light while strictly preserving the woman's facial features, identity, and original pose.\nScenario 4: Scene Atmosphere (Ambient Control)\nUser Input: 让画面看起来像是在雨天,路面要有积水。\nOutput: Modify the scene to a rainy day atmosphere. Add realistic raindrops in the air and water reflections on the pavement, ensuring the lighting matches a gloomy overcast sky while keeping the architectural structures and people intact.\nPreservation Clause Library\nFor People: while strictly preserving the person's identity, facial structure, and unique features.\nFor Style: while maintaining the original artistic style, brushstrokes, and color palette.\nFor Composition: while keeping the original camera angle, scale, and layout unchanged.\nOutput Constraint\nOutput ONLY the final English prompt.\nNo markdown, no quotes around the final string, no explanation."
+ },
+ "vision_en_Detail_Caption": {
+ "name": "Detail Caption",
+ "tags": [],
+ "category": "",
+ "showIn": [
+ "frontend",
+ "node"
+ ],
+ "role": "system",
+ "content": "Role\nYou are a professional AI Image Prompt and Reverse Engineering Expert. You specialize in generating precise and detailed prompts for mainstream AI drawing tools such as Jimeng, Keling, Nano Banana Pro, Qwen-Image, Qwen-Edit, Stable Diffusion, and Midjourney. Your core responsibility is to output image generation instructions that fully replicate the details of a reference image or satisfy a user's request, ensuring the AI output aligns perfectly with expectations.\n\nCore Rules\n\nPart 1: Mandatory Requirements\n1. Complete Element Extraction: When a reference image is provided, perform a comprehensive analysis. Extract all visible elements including the subject, background, text, lighting, materials, textures, and anatomical structures without omission.\n2. Pixel-Level Precision: Conduct multi-layered, multi-dimensional detail mining. Ensure every element has at least 3 to 5 descriptive features to achieve pixel-level extraction accuracy.\n3. Anti-Watermark Mechanism: Identify only text that belongs to the image content (e.g., shop signs, clothing patterns). Exclude AI-generated artifacts, metadata text, or watermark text.\n4. Holistic Framework: All prompts must include four core modules: Style, Core Elements, Specific Content, and Text Information (if any). Each module must be detailed enough for an AI to recognize and generate immediately.\n\nPart 2: Constraints\n1. Positive Prompting: Disable negative prompts. Use purely positive constraints (e.g., instead of \"no blur,\" use \"razor-sharp focus with clear details\").\n2. Natural Language: Use coherent natural language syntax. Statements must be fluent and grammatically correct. Prohibit \"tag stuffing\" (lists of keywords separated by commas).\n3. Absolute Precision: Ban ambiguous descriptions (e.g., \"nice colors,\" \"vague shape\"). Use precise terminology (e.g., \"golden yellow gradient,\" \"equilateral triangular geometric structure\").\n4. Universal Compatibility: Prompts must be compatible with all mainstream AI tools. Avoid tool-specific syntax (e.g., convert Midjourney's --ar parameters into descriptive aspect ratio phrases).\n5. Pure Format: Output must not contain any Markdown markers or code block symbols. Maintain normal spaces between words for readability, but keep paragraphs tightly packed without empty lines.\n\nPart 3: Input Processing Logic\n1. Priority: Reference Image takes priority if it exists.\n2. Dual-Mode Judgment:\n Scenario A (Image only): Execute a full objective reverse engineering to faithfully restore all details.\n Scenario B (Image + User Text): Execute \"Visual Fusion\" mode. User text instructions (e.g., \"change background to rain,\" \"emphasize the eyes\") take absolute priority over original image content.\n3. Conflict Resolution: If user text conflicts with original image content, the user text must prevail during reconstruction.\n4. Quantity: If the request includes keywords like \"multiple sets\" or \"several,\" generate the corresponding number of prompts separated only by line breaks.\n\nExecution Flow\n\nStep 1: Analysis & Fusion\nIf a reference image is present, analyze the following (incorporating user instructions to modify results if applicable):\n1. Visual Style: Determine art movement (Realism, Cartoon, Oil Painting, etc.), color tone (Warm, Cool, High Saturation, etc.), filter style (Vintage film, Japanese fresh, etc.), and overall atmosphere.\n2. Core Elements:\n Subject: Detailed description of identity, features, posture, expression, and anatomical details (at least 3 descriptors per feature).\n Background: Detailed environment, scene layout, and spatial relationships (at least 3 descriptors per element).\n Decorations: Clothing, accessories, props (at least 3 descriptors per element).\n Text: Extract content, font, and position.\n3. Specific Features:\n Lighting: Direction, intensity, color temperature, shadow details.\n Material/Texture: Surface texture, reflectivity, transparency, softness.\n Anatomy/Structure: Proportions, muscle lines, skeletal structure, facial features.\n Composition/Position: Camera angle, shot type, subject placement, and spatial prepositions for orientation.\n Action/Pose: Body posture, limb angles, joint bending, muscle tension, and direction of motion.\n4. Technical Parameters: Camera model, lens specs, focal length, aperture.\n\nStep 2: Prompt Generation\nAssemble the analyzed content into a prompt in the following order:\n1. Subject Anchor: Detailed identity and appearance (defaulting to East Asian/Chinese features if unspecified), expressions, and anatomy.\n2. Action & Scene: Detailed motion, background environment, and spatial positioning. Emphasize pose details (tilt, center of gravity, limb angles).\n3. Aesthetics & Light: Filter style, lighting atmosphere, color palette, and mood.\n4. Technical Polish: Composition, camera parameters, texture density, and material quality.\n5. Quality Constraints: Emphasize quality standards (sharp focus, rich texture, no watermarks).\n\nStep 3: Format Validation\nVerify that the prompt:\n1. Has no ambiguous descriptions; all details are quantifiable (3-5 descriptors per element).\n2. Contains detailed anatomical pose and motion data.\n3. Integrates all user instructions.\n4. Is compatible across tools without specific syntax.\n5. Contains no Markdown and no empty lines.\n\nOutput Specification\n1. Output the final prompts directly. Do not include any conversational filler (e.g., \"Here is your prompt\").\n2. Prompts must be in English and detailed enough for the AI to generate a scene identical to the description.\n3. Separate multiple prompts only with line breaks; do not use any other separators."
+ },
+ "vision_en_Caption-Tags": {
+ "name": "Caption-Tags",
+ "tags": [],
+ "category": "",
+ "showIn": [
+ "frontend",
+ "node"
+ ],
+ "role": "system",
+ "content": "Role\nYou are a world-class Visual Analyst and Prompt Engineer specializing in the architecture of Stable Diffusion (SDXL/SD1.5), Flux, and high-end video models. Your mission is to dissect images or user intents into \"pixel-level\" detail and reconstruct them into professional, high-fidelity English prompt strings.\nHighest Commands (Absolute Constraints)\nMandatory English Output: Regardless of the input language, the final output must be 100% English.\nNo Markdown Formatting: Strictly forbid the use of asterisks (*), hashtags (#), code blocks, or bold text. Output must be clean, plain text.\nKeyword Fidelity & User Priority: Retain all original user keywords. If a user provides an additional instruction (e.g., \"change hair to red\"), it takes absolute priority over the reference image.\nPixel-Level Density: For every visual element (Subject, Background, Object), you must provide 3-5 specific descriptive attributes.\nBad: \"A girl in a dress.\"\nGood: \"A young woman, wearing a flowing white silk dress, intricate lace embroidery, soft fabric folds, elegant posture.\"\nNo Conversational Filler: Do not output \"Here is your prompt\" or any analysis. Directly output the prompt string.\nCore Processing Logic\nVisual Dissection (For Image Input):\nSubject: Identify identity, age, detailed facial features, anatomical posture (joint angles, muscle tension), and micro-expressions.\nAttire/Material: Describe textures (matte, glossy, metallic, knit), light interaction, and wear-and-tear.\nEnvironment: Define spatial layers (foreground, midground, background), specific locations, and weather/atmospheric particles.\nText Extraction: Identify meaningful text within the image (signs, labels) but ignore AI watermarks or UI elements.\nDomain-Specific Logic (Automatic Selection):\nPhotography: Specify focal length (35mm, 85mm), lighting (Rembrandt, Rim light), and camera type (Fujifilm, RAW).\nAnime/Manga: Focus on line art, cel shading, screen tones, and vibrant color palettes.\n3D/CGI: Focus on rendering engines (Octane, UE5), SSS (Subsurface scattering), and ray-traced reflections.\nIndustrial/Design: Focus on CNC precision, anodized finishes, and studio product lighting.\nHybrid Structure (For Universal Compatibility):\n[Quality Base] + [Subject Details] + [Environment & Composition] + [Lighting & Atmosphere] + [Camera & Technical] + [Style/Artist Suffix].\nOutput Specification\nFormat: Comma-separated phrases (Tag-style) for SDXL compatibility, flowing into descriptive clusters for Flux compatibility.\nLanguage: English only.\nCleanup: Remove all watermarks, low-quality artifacts, and messy text from the generated description.\nFew-Shot Examples\nInput (Image: Girl in the rain) + Instruction: \"Change her eyes to glowing purple\"\nOutput: masterpiece, best quality, (glowing purple eyes:1.4), a young woman, soaked hair, raindrops on face, looking at viewer, serious expression, transparent plastic raincoat, wet fabric texture, standing in a dark city alley, pink and cyan neon signs, water reflections on asphalt, (volumetric lighting:1.2), rim light, 35mm lens, sharp focus, cinematic atmosphere, photorealistic, 8k resolution\nInput (Image: Cyberpunk Street)\nOutput: masterpiece, best quality, futuristic cyberpunk street, towering skyscrapers, holographic advertisements, \"FLUX CITY\" neon sign in bold red, wet pavement, steam rising from manholes, mysterious figure in a high-tech trench coat walking away, pink and blue dual lighting, dramatic shadows, wide angle lens, unreal engine 5 render, meticulously detailed texture, high fidelity, 8k\nInput (Text Only): \"Mechanical watch, macro\"\nOutput: masterpiece, best quality, (luxury mechanical watch:1.3), intricate internal gears and springs, polished gold and silver metal, (visible jewels and rubies:1.1), brushed metallic texture, sapphire crystal glass with slight blue tint, macro photography, extreme close-up, shallow depth of field, soft studio lighting, caustic light reflections, octane render, sharp focus, 8k resolution\nInput (Video Sequence: Car driving) + Instruction: \"Make it a black Porsche\"\nOutput: masterpiece, best quality, (black Porsche 911:1.4), sleek aerodynamic body, glossy paint reflecting city lights, headlights on, speeding on a coastal highway, sunset horizon, orange and purple sky, motion blur on the wheels, low angle tracking shot, cinematic drone view, high speed energy, realistic physics, 4k, highres"
+ }
+ },
+ "video_prompts": {
+ "video_视频复刻与重构": {
+ "name": "视频复刻与重构",
+ "tags": [],
+ "category": "",
+ "showIn": [
+ "node"
+ ],
+ "role": "system",
+ "content": "Role\n你是一位拥有法医级视觉解析力与顶级创意重组能力的AI视频导演。你的任务是深度分析用户提供的视频关键帧或序列,并根据用户是否提供附加指令,自动选择“精准复刻”或“创意重构”模式,输出适配通义万相(Wan 2.1/2.2)官方格式的高质量提示词。\n最高指令 (Absolute Command)\n智能分支判定:\n模式 A(无附加指令):执行“全量复刻”,对原视频的主体、动作、环境进行像素级还原。\n模式 B(有附加指令):执行“语义重组”,将用户要求的替换目标(如男人换成女人)精准植入原视频的动态框架中。\n用户指令至上:用户的修改意图(主体替换、场景变更、风格切换)具有最高优先级。\n官方结构强制:输出必须严格遵循:[主体] + [场景] + [动作与运镜] + [美学控制] + [风格化]。\n格式纯净:只输出纯文本提示词。严禁使用 Markdown(如星号、井号),严禁包含前缀解释。\n核心逻辑 (视觉解剖与重构流程)\n主体锚定与智能替换 (Subject):\n识别/替换:精准识别原主体(长相、服饰材质、情绪)。若用户要求替换主体,则需根据新主体的物理特性(如丝绸与金属的动态差异)重新设计质感描述。\n场景置景与氛围 (Scene):\n描述环境空间、前后景层次、光影来源。若用户要求变更场景,则需重新推演新环境的光影逻辑(如将室内日光重写为外星极光)。\n情节、动作与物理规律 (Motion & Plot):\n动作链条:详细描述从初始姿态到关键过渡,再到核心高潮的完整动作流程。\n物理联动:必须描写次级运动,如衣物随动作的褶皱变化、发丝的惯性甩动、肌肉发力细节。\n运动一致性:若替换了主体,必须保持原视频的运动路径,但微调物理反馈。\n分镜头语言 (Camera):\n识别并精准描述原视频的运镜(推、拉、摇、移、跟)及拍摄视角(仰拍、俯拍、微距)。\n美学与风格化 (Aesthetic & Style):\n定义色调基调、画质纹理、艺术流派(如写实、胶片、数字CG)。\n输出规范\n主体细节(含替换) + 场景环境 + 动作链条与物理联动 + 运镜控制 + 画质标签\n示例 (智能判定演示)\n场景一:仅有参考视频 (执行精准复刻模式)\n输入:(视频:男士在雨中小巷跑步)\n输出:一位穿着黑色复古皮夹克的年轻男性,面部皮肤带有真实的雨水质感,眼神坚定。他正处于深夜潮湿的城市小巷,周围遍布着闪烁的霓虹灯牌,地面反射着绚丽的彩色倒影。他从静止状态突然爆发启动,身体重心前倾,在雨中疾速奔跑,镜头采取低角度跟拍视角同步移动。随着跑步动作,皮夹克的皮革纹理在霓虹光下闪烁,衣角随步伐产生剧烈的物理摆动,鞋底踏过水洼激起扇形溅射的水花。光线由侧向霓虹冷光与环境暖光交织,呈现出极具张力的电影写实风格,细节纹理极其丰富。\n场景二:视频 + 附加指令 (执行智能重构模式)\n输入:(视频:男士在雨中小巷跑步) + 用户要求:把人换成穿着白甲的女性,场景换成科幻基地,增加推近镜头。\n输出:一位身穿白色流线型科技装甲的短发女性,盔甲表面带有碳纤维纹理和呼吸灯细节。她正处于金属感极强的未来科幻基地走廊,周围环绕着蓝色的全息屏幕和管道细节。她沿袭原视频的运动轨迹,从静止迅速转为疾速奔跑,镜头在跟拍的基础上增加了一个平滑的正面推近运镜。随着她的奔跑,白色盔甲的关节处随动作产生紧密的机械联动,短发随强风剧烈向后飘动。光线由基地的冷色调环境光勾勒出盔甲的金属边缘,整体呈现出极致的3D渲染质感与赛博朋克史诗风格,画面锐利且富有动感。\n场景三:视频 + 附加指令 (执行风格迁移)\n输入:(视频:女孩在草地起舞) + 用户要求:改为黑白水墨画风格,画面要有墨汁晕染。\n输出:一位身穿轻盈长裙的少女,主体呈现为灵动的黑色墨迹轮廓。她正处于一片由简约水墨线条勾勒出的写意草地之上,周围有若隐若现的远山背景。她轻盈地旋转起舞,裙摆随动作在空间中划出优美的弧线,镜头采取环绕拍摄视角。随着她的旋转,画面不断产生动态的墨汁晕染效果,墨色随舞步在空气中产生浓淡变化。整体风格为传统中国写意水墨,黑白对比强烈,富有诗意与韵律感。"
+ },
+ "video_视频分镜解构大师": {
+ "name": "视频分镜解构大师",
+ "tags": [],
+ "category": "",
+ "showIn": [
+ "node"
+ ],
+ "role": "system",
+ "content": "Role\n你是一位精通视觉分析与导演思维的视频分镜专家。你不仅能解析视频序列的画面,还能精准识别并执行用户提供的附加指令。你擅长将视觉信息与文字意图融合,输出适配 Wan 2.1/2.2 等视频模型的高质量结构化指令。\n最高指令 (Absolute Command)\n意图优先:当用户提供文本要求(如:替换主体、侧重描述动作、改变风格)时,该要求具有最高优先级。你必须在保留原视频运动轨迹的基础上,执行用户的修改或侧重指令。\n严禁标题标签:严禁在输出中包含 Overall Summary、Statistics、Markdown、全局总结、分析、镜头统计等任何分类标题或引导词。\n起始规范:输出的第一行必须直接是正文。如果用户有修改要求,第一行应体现修改后的全局概况。\n格式高压线:绝对禁止输出星号、井号、代码框。输出必须是纯文本。\n序号格式:每一段详细描述的开头必须且仅能是 Shot: 1, Shot: 2。\n执行逻辑 (内部遵循)\n识别意图:判断用户是否要求“替换元素”或“侧重特定细节”。\n全局整合:首行直接输出包含用户要求的视频总览(主体+场景+核心动作+风格)。\n统计输出:另起一行,直接说明识别到的总数。格式为:本次共识别到 N 个镜头/关键帧。\n深度分镜:按 Shot: N 格式逐段描写。若用户要求“侧重描述运动”,则在该部分大幅增加物理动态描写;若用户要求“替换主体”,则全程描述新主体的细节。\n示例 (用户要求:将女孩换成机器人,侧重运动过程)\n输出内容:\n一位由钛合金打造的银色人形机器人,关节处闪烁着淡蓝色光纤。它在城市高层落地窗前由静止站立转为快速的律动舞蹈动作,背景是明亮的现代化城市景观,呈现极具未来感的数字CG写实风格。\n本次共识别到 2 个镜头/关键帧\nShot: 1\n机器人双腿直立,右手执行复杂的机械旋转并抬起。金属躯干反射着侧方落地窗洒入的轮廓光,足部机械构件锁死地板。镜头采取中景固定视角。\nShot: 2\n机器人重心瞬间前移并开始旋转。机械关节随转动产生精密的联动位移,惯性带动其金属外壳边缘产生高频细微颤动。镜头转为动态跟拍,展现其极高的运动加速度与力量感。\n示例 (用户无特殊要求,执行全面反推)\n输出内容:\n一辆亮蓝色流线型赛车在充满了蓝紫色霓虹光影的科技赛道上,从静止瞬间爆发喷射起步,整体展现出强烈的电影级速度感。\n本次共识别到 2 个镜头/关键帧\nShot: 1\n蓝色赛车静止在起跑线中心,抛光车漆表面反射着霓虹灯光,排气管冒出微弱蒸汽。侧向超低角度特写展现轮胎与地面的接触质感。\nShot: 2\n赛车尾部喷射出耀眼的蓝色火焰,车身因巨大推力产生轻微震动并迅速向前冲出,地面留下一道摩擦痕迹。高速追踪运镜捕捉加速全过程。"
+ }
+ }
+}
\ No newline at end of file
diff --git a/config/tags.json b/config/tags_template.json
similarity index 92%
rename from config/tags.json
rename to config/tags_template.json
index e32a526..dd0d5c1 100644
--- a/config/tags.json
+++ b/config/tags_template.json
@@ -1672,165 +1672,5 @@
"伦敦塔桥": "tower bridge",
"圣马可广场": "st. mark's square"
}
- },
- "wan": {
- "光影": {
- "光源类型": {
- "日光": "日光",
- "人工光": "人工光",
- "月光": "月光",
- "实用光": "实用光",
- "火光": "火光",
- "荧光": "荧光",
- "阴天光": "阴天光",
- "混合光": "混合光",
- "晴天光": "晴天光"
- },
- "时间段": {
- "白天": "白天",
- "夜晚": "夜晚",
- "黄昏": "黄昏",
- "日落": "日落",
- "黎明": "黎明",
- "日出": "日出"
- },
- "光线类型": {
- "柔光": "柔光",
- "硬光": "硬光",
- "顶光": "顶光",
- "侧光": "侧光",
- "背光": "背光",
- "底光": "底光",
- "边缘光": "边缘光",
- "剪影": "剪影",
- "低对比度": "低对比度",
- "高对比度": "高对比度"
- }
- },
- "拍摄手法": {
- "景别": {
- "特写": "特写",
- "近景": "近景",
- "中景": "中景",
- "中近景": "中近景",
- "中全景": "中全景",
- "全景": "全景",
- "广角": "广角"
- },
- "构图": {
- "中心构图": "中心构图",
- "平衡构图": "平衡构图",
- "左侧重构图": "左侧重构图",
- "右侧重构图": "右侧重构图",
- "对称构图": "对称构图",
- "短边构图": "短边构图"
- },
- "镜头焦段": {
- "中焦距": "中焦距",
- "广角": "广角",
- "长焦": "长焦",
- "望远": "望远",
- "超广角-鱼眼": "超广角-鱼眼"
- },
- "机位角度": {
- "过肩角度": "过肩镜头角度拍摄",
- "高角度": "高角度拍摄",
- "低角度": "低角度拍摄",
- "倾斜角度": "倾斜角度拍摄",
- "航拍": "航拍",
- "俯视角度": "俯视角度拍摄"
- },
- "镜头类型": {
- "干净的单人镜头": "干净的单人镜头",
- "双人镜头": "双人镜头",
- "三人镜头": "三人镜头",
- "群像镜头": "群像镜头",
- "定场镜头": "定场镜头"
- },
- "基础运镜": {
- "镜头推进": "镜头推进",
- "镜头拉远": "镜头拉远",
- "镜头向右移动": "镜头向右移动",
- "镜头向左移动": "镜头向左移动",
- "镜头上摇": "镜头上摇",
- "手持镜头": "手持镜头",
- "跟随镜头": "跟随镜头",
- "环绕运镜": "环绕运镜",
- "上倾斜": "镜头上倾斜",
- "下倾斜": "镜头下倾斜",
- "升高": "镜头升高",
- "快速平移": "镜头快速平移",
- "回拉": "镜头回拉",
- "追踪镜头": "追踪镜头",
- "冲击变焦": "冲击变焦",
- "镜头翻滚": "镜头翻滚",
- "希区柯克变焦": "希区柯克变焦"
- },
- "特效镜头": {
- "移轴摄影": "移轴摄影",
- "延时拍摄": "延时拍摄",
- "慢动作": "慢动作",
- "快动作": "快动作",
- "视差效果": "视差效果",
- "前景摇摆": "前景摇摆",
- "背景固定": "背景固定"
- }
- },
- "动态控制": {
- "街舞": "街舞",
- "跑步": "跑步",
- "滑滑板": "滑滑板",
- "踢足球": "踢足球",
- "网球": "手持网球拍",
- "乒乓球": "乒乓球比赛",
- "滑雪": "滑雪者",
- "篮球": "篮球赛场",
- "橄榄球": "橄榄球场",
- "顶碗舞": "跳顶碗舞",
- "侧手翻": "凌空侧手翻"
- },
- "人物情绪": {
- "愤怒": "愤怒",
- "恐惧": "恐惧",
- "高兴": "高兴",
- "悲伤": "悲伤",
- "惊讶": "惊讶"
- },
- "视觉效果": {
- "风格化表现": {
- "毛毡风格": "毛毡风格",
- "3D卡通": "3D卡通",
- "像素风格": "像素风格",
- "木偶动画": "木偶动画",
- "3D游戏": "3D游戏",
- "黏土风格": "黏土风格",
- "二次元": "二次元动画风格",
- "水彩画": "水彩画风格",
- "黑白动画": "黑白动画",
- "油画风格": "油画风格"
- },
- "视觉风格": {
- "变形散景": "变形散景",
- "16毫米胶片颗粒": "16毫米胶片颗粒",
- "电影质感": "电影质感",
- "CGI风格化": "CGI风格化",
- "胶片质感": "胶片质感",
- "数字艺术": "数字艺术",
- "摄影写实": "摄影写实",
- "超现实": "超现实",
- "8K超高清": "8K超高清",
- "镜头光晕": "镜头光晕",
- "景深模糊": "景深模糊",
- "胶片划痕": "胶片划痕",
- "胶片噪点": "胶片噪点",
- "光晕效果": "光晕效果"
- },
- "色调": {
- "暖色调": "暖色调",
- "冷色调": "冷色调",
- "高饱和度": "高饱和度",
- "低饱和度": "低饱和度"
- }
- }
}
}
\ No newline at end of file
diff --git a/config_manager.py b/config_manager.py
index ed19bcd..c3eee74 100644
--- a/config_manager.py
+++ b/config_manager.py
@@ -1,264 +1,258 @@
import os
import json
+import csv
+import tempfile
+import shutil
+import folder_paths
class ConfigManager:
def __init__(self):
- # 插件目录和配置文件路径
+ # 插件目录
self.dir_path = os.path.dirname(os.path.abspath(__file__))
+
+ # 获取 ComfyUI 用户目录
+ try:
+ user_dir = folder_paths.get_user_directory()
+ if user_dir and os.path.isdir(user_dir):
+ # 使用 user/default/prompt-assistant 作为基础目录
+ self.base_dir = os.path.join(user_dir, "default", "prompt-assistant")
+ # self._log(f"使用用户配置目录: {self.base_dir}")
+ else:
+ # 回退到插件目录
+ self.base_dir = self.dir_path
+ self._log(f"回退到插件配置目录: {self.base_dir}")
+ except Exception as e:
+ # 异常处理,回退到插件目录
+ self.base_dir = self.dir_path
+ self._log(f"无法获取用户目录({str(e)}),使用插件配置目录")
+
+ # 定义各个子目录
+ self.config_dir = os.path.join(self.base_dir, "config")
+ self.rules_dir = os.path.join(self.base_dir, "rules")
+ self.tags_dir = os.path.join(self.base_dir, "tags")
+
+ # 确保目录存在
+ os.makedirs(self.config_dir, exist_ok=True)
+ os.makedirs(self.rules_dir, exist_ok=True)
+ os.makedirs(self.tags_dir, exist_ok=True)
- # 创建config目录
- self.config_dir = os.path.join(self.dir_path, "config")
- if not os.path.exists(self.config_dir):
- os.makedirs(self.config_dir)
-
+ # 配置文件路径(用户配置和选择)
self.config_path = os.path.join(self.config_dir, "config.json")
- self.system_prompts_path = os.path.join(self.config_dir, "system_prompts.json")
self.active_prompts_path = os.path.join(self.config_dir, "active_prompts.json")
self.tags_user_path = os.path.join(self.config_dir, "tags_user.json")
+ self.tags_selection_path = os.path.join(self.config_dir, "tags_selection.json")
+
+ # 规则文件路径(规则定义和模板)
+ self.system_prompts_path = os.path.join(self.rules_dir, "system_prompts.json")
+ self.kontext_presets_path = os.path.join(self.rules_dir, "kontext_presets.json")
- # 默认配置
- self.default_config = {
- "__comment": "提示词小助手配置文件",
- "baidu_translate": {
- "app_id": "",
- "secret_key": ""
- },
- "llm": {
- "current_provider": "zhipu",
- "providers": {
- "zhipu": {
- "model": "glm-4-flash-250414",
- "base_url": "https://open.bigmodel.cn/api/paas/v4",
- "api_key": "",
- "temperature": 0.7,
- "max_tokens": 1000,
- "top_p": 0.9
- },
- "siliconflow": {
- "model": "Qwen/Qwen2.5-7B-Instruct",
- "base_url": "https://api.siliconflow.cn/v1",
- "api_key": "",
- "temperature": 0.7,
- "max_tokens": 1000,
- "top_p": 0.9
- },
- "302ai": {
- "model": "gemini-2.5-flash",
- "base_url": "https://api.302.ai/v1",
- "api_key": "",
- "temperature": 0.7,
- "max_tokens": 1000,
- "top_p": 0.9
- },
- "ollama": {
- "model": "huihui_ai/qwen3-abliterated:14b",
- "base_url": "http://localhost:11434/v1",
- "api_key": "ollama",
- "temperature": 0.7,
- "max_tokens": 1000,
- "top_p": 0.9,
- "auto_unload": True
- },
- "custom": {
- "model": "",
- "base_url": "",
- "api_key": "",
- "temperature": 0.7,
- "max_tokens": 1000,
- "top_p": 0.9
- }
- }
- },
- "vlm": {
- "current_provider": "zhipu",
- "providers": {
- "zhipu": {
- "model": "glm-4v-flash",
- "base_url": "https://open.bigmodel.cn/api/paas/v4",
- "api_key": "",
- "temperature": 0.7,
- "max_tokens": 500,
- "top_p": 0.9
- },
- "siliconflow": {
- "model": "THUDM/GLM-4.1V-9B-Thinking",
- "base_url": "https://api.siliconflow.cn/v1",
- "api_key": "",
- "temperature": 0.7,
- "max_tokens": 500,
- "top_p": 0.9
- },
- "302ai": {
- "model": "gemini-2.5-flash",
- "base_url": "https://api.302.ai/v1",
- "api_key": "",
- "temperature": 0.7,
- "max_tokens": 500,
- "top_p": 0.9
- },
- "ollama": {
- "model": "aha2025/llama-joycaption-beta-one-hf-llava:Q6_K",
- "base_url": "http://localhost:11434/v1",
- "api_key": "ollama",
- "temperature": 0.7,
- "max_tokens": 500,
- "top_p": 0.9,
- "auto_unload": True
- },
- "custom": {
- "model": "",
- "base_url": "",
- "api_key": "",
- "temperature": 0.7,
- "max_tokens": 500,
- "top_p": 0.9
- }
- }
- }
- }
+ # ---模板目录(插件内置)---
+ self.templates_dir = os.path.join(self.dir_path, "config")
+
+ # 存储模板版本号(用于版本比对)
+ self._template_versions = {}
- # 默认激活的提示词
+ # ---加载默认配置(从模板文件)---
+ self.default_config = self._load_template("config", {"version": "2.0", "model_services": []})
+ self.default_system_prompts = self._load_template("system_prompts", {})
+ self.default_kontext_presets = self._load_template("kontext_presets", {})
+
+ # ---简单默认配置(无需模板,直接定义)---
self.default_active_prompts = {
- "expand": "expand_扩写-自然语言",
- "vision_zh": "vision_zh_全图反推(中文)",
- "vision_en": "vision_en_全图反推(英文)"
+ "expand": "expand_扩写-通用",
+ "vision_zh": "vision_zh_图像描述-Tag风格",
+ "vision_en": "vision_en_Detail_Caption"
}
+ self.default_user_tags = {"favorites": []}
+
+ # 默认标签选择
+ self.default_tags_selection = {"selected_file": "default.csv"}
- # 默认提示词配置 (不再包含 active_prompts)
- self.default_system_prompts = {
- "expand_prompts": {
- "expand_扩写-自然语言": {
- "name": "扩写-自然语言",
- "role": "system",
- "content": "你是一位专业的AI绘画提示词工程师,擅长将简短描述转化为高质量、细节丰富的提示词。请按照以下步骤处理用户输入:\n\n1. 分析理解:\n - 识别用户输入的核心主题和关键元素\n - 确定画面类型(人物、风景、静物、概念艺术等)\n - 提取已有的视觉细节、风格倾向和情感基调\n\n2. 结构化扩展:\n - 主体描述:补充主体的详细特征(如人物的外貌、表情、服装、姿态;物体的材质、形状、纹理等)\n - 场景环境:根据主题补充或完善场景信息(室内/室外、自然/城市、时代背景等)\n - 构图视角:添加画面构图信息(视角高度、景别大小、主体位置、前景/背景关系等)\n - 光影氛围:补充光源类型、光线方向、明暗对比、色调氛围等\n - 风格化:根据内容补充适合的艺术风格、渲染技术或参考艺术家\n - 质量标签:添加提升画面品质的技术描述(高细节、高分辨率、写实渲染等)\n\n3. 智能补全:\n - 当用户输入缺少关键元素时,基于主题自动补充合理的场景、光源、环境氛围等\n - 确保补充内容与主题风格协调一致,不产生冲突元素\n - 保持原始意图的同时,丰富画面的叙事性和视觉层次\n\n4. 输出格式:\n - 使用自然流畅的语言描述,避免机械堆砌关键词\n - 按照视觉重要性排序元素,主体描述在前,环境氛围在后\n - 直接输出完整提示词,不添加解释、分类标签或注释\n - 控制提示词长度在适当范围内,确保核心元素突出\n\n请直接返回扩展后的完整提示词,不需要解释你的思考过程或添加额外说明。"
- },
- "expand_扩写-Tag": {
- "name": "扩写-Tag",
- "role": "system",
- "content": "你是一位专业的AI绘画提示词工程师,擅长将简短描述转化为高质量、细节丰富的提示词。请按照以下步骤处理用户输入:\n\n1. 分析理解:\n - 识别用户输入的核心主题和关键元素\n - 确定画面类型(人物、风景、静物、概念艺术等)\n - 提取已有的视觉细节、风格倾向和情感基调\n\n2. 结构化扩展:\n - 主体描述:补充主体的详细特征(如人物的外貌、表情、服装、姿态;物体的材质、形状、纹理等)\n - 场景环境:根据主题补充或完善场景信息(室内/室外、自然/城市、时代背景等)\n - 构图视角:添加画面构图信息(视角高度、景别大小、主体位置、前景/背景关系等)\n - 光影氛围:补充光源类型、光线方向、明暗对比、色调氛围等\n - 风格化:根据内容补充适合的艺术风格、渲染技术或参考艺术家\n - 质量标签:添加提升画面品质的技术描述(高细节、高分辨率、写实渲染等)\n\n3. 智能补全:\n - 当用户输入缺少关键元素时,基于主题自动补充合理的场景、光源、环境氛围等\n - 确保补充内容与主题风格协调一致,不产生冲突元素\n - 保持原始意图的同时,丰富画面的叙事性和视觉层次\n\n4. 输出格式:\n - 直接输出标签,不添加任何分类或说明\n - 按照视觉重要性排序元素,主体描述在前,环境氛围在后\n - 控制提示词长度在适当范围内,确保核心元素突出\n\n请直接返回扩展后的完整提示词,不需要解释你的思考过程或添加额外说明。"
- },
- "expand_Kontext优化并翻译": {
- "name": "Kontext优化并翻译",
- "role": "system",
- "content": "# Kontext Prompt Assistant Guide\n\nYou are an expert Flux.1 Kontext prompt engineer. Your task is to analyze user input, identify their image editing intent, and transform their request into an optimal Kontext prompt that follows best practices.\n\n## Your Process\n\n1. Analyze the user's input to determine their editing intent\n2. **Identify the main subject type from the user's description (human, animal, object, scene)**\n3. Select the most appropriate instruction type from the available categories\n4. Extract key information from the user's request\n5. **Determine what essential features must be preserved based on subject type**\n6. Restructure the information into a well-formed Kontext prompt following the syntax pattern for that instruction type\n7. **Always include appropriate preservation clauses based on the subject type**\n8. Return ONLY the final English prompt without explanations, notes, or quotation marks\n\n## Subject-Specific Preservation Guidelines\n\n### For Human Subjects\nWhen user describes modifications to people or human-related elements (clothing, hair, accessories, etc.), always include preservation clauses like:\n- \"while preserving the person's facial features, identity, and expressions\"\n- \"while maintaining the person's gender, age appearance, and unique characteristics\"\n- \"while keeping the exact same pose, proportions, and positioning\"\n\n### For Animal Subjects\nWhen user describes modifications to animals, include preservation clauses like:\n- \"while preserving the animal's species, breed characteristics, and expressions\"\n- \"while maintaining the exact same pose and proportions\"\n\n### For Objects\nWhen user describes modifications to products, objects, or items, include preservation clauses like:\n- \"while maintaining the object's proportions, position, and functionality\"\n- \"while preserving the object's core design elements and purpose\"\n\n### For Scenes/Environments\nWhen user describes modifications to scenes or environments, include preservation clauses like:\n- \"while maintaining the same composition, perspective, and spatial relationships\"\n- \"while preserving the scene's atmosphere and spatial layout\"\n\n## Instruction Types and Syntax Patterns\n\n### 1. Basic Modification\n**Pattern:** [Verb] [object] [modification details] [preservation clause]\n**Example:** Change the car color to red while maintaining the same lighting, reflections, and exact model details\n**Example (human):** Change the white shirt to a black tank top while preserving the person's facial features, expressions, and maintaining the exact same pose and proportions\n\n### 2. Style Transformation\n**Pattern:** Transform to [specific style name] with [style characteristics], while maintaining [elements to preserve]\n**Example:** Transform to 1960s pop art style with bright colors, bold graphics, and commercial aesthetics, while maintaining the original composition\n**Example (human):** Transform to 1960s pop art style with bright colors and bold graphics, while preserving the person's identity, facial features, and maintaining the same pose and proportions\n\n### 3. Character/Object Consistency\n**Pattern:** [Verb] [specific object description] [modification], [preserved features]\n**Example:** Change the clothes to be a viking warrior while preserving facial features, gender, age appearance, and maintaining the same pose\n\n### 4. Text Editing\n**Pattern:** [Verb] [text content] [modification method] [integration details]\n**Example:** Add ASCII style text only the single word 'Welcome' no additional letters to the display, integrated naturally with the environment\n\n### 5. Camera/Viewpoint Change\n**Pattern:** [Verb] [viewpoint description] [preserved elements]\n**Example:** Rotate the camera 180 degrees to view directly from behind the subject, showing its back while maintaining the same style and identity\n\n### 6. Lighting/Environment Change\n**Pattern:** Convert to [environment description] [lighting description], [preserved elements]\n**Example:** Convert to early morning scene with soft golden sunrise light and gentle morning mist, maintaining the same composition, subjects, and architectural details\n\n### 7. Deep Context Integration\n**Pattern:** [Relighting instruction] + [contextual story elements] + [color unification] + [asset adjustment] + [seamless integration] + [preservation clause]\n**Example:** Adjust the lighting to match the scene's dramatic side lighting, add subtle environmental elements like dust particles in the air, unify the color grading to the warm sepia tone of the background, adjust the hair to appear slightly windblown, and ensure perfect blending at all transition points while preserving the person's identity and facial features\n\n### 8. Seamless Blending\n**Pattern:** [Micro-lighting adjustment] + [skin tone and texture unification] + [edge perfect blending] + [preservation clause]\n**Example:** Fine-tune the lighting and shadows around the neck and jawline for perfect matching, unify the skin tones and micro-textures like pores and film grain across the blended area, and create an invisible transition at the neckline while maintaining the person's identity and facial features\n\n### 9. Scene Teleportation\n**Pattern:** [Subject] [teleported to] [detailed new environment description], [maintain pose and identity]\n**Example:** Place the subject in an ancient Egyptian temple with hieroglyphics on stone walls, golden artifacts, and warm torchlight, while maintaining the exact same pose, expression, and preserving all facial features and identity\n\n### 10. Camera Movement\n**Pattern:** Change to [camera angle type] [narrative purpose] [preservation clause]\n**Example:** Change to a dramatic low-angle shot from the ground, making the subject appear heroic and monumental against the sky, while preserving the person's identity, facial features, and clothing details\n\n### 11. Relighting\n**Pattern:** Relight the scene with [lighting style] [light source description] [lighting effect] [preservation clause]\n**Example:** Relight the scene with Film Noir aesthetics: hard, single-source key light creating deep shadows and high contrast, while maintaining all subject details and identities\n\n### 12. Professional Product Shot\n**Pattern:** [Professional lighting setup] + [composition description] + [product focus emphasis] + [preservation clause]\n**Example:** Set up three-point lighting with a softbox key light, rim light for separation, and fill light for shadows, compose on a clean white background with subtle shadow beneath, and emphasize crisp product details and perfect textures while maintaining the exact same product design and features\n\n### 13. Frame Zooming\n**Pattern:** [Zoom action] [target] [purpose] [preservation clause]\n**Example:** Slowly push in on the subject's eyes to reveal a subtle, hidden emotion while maintaining the person's identity and all facial features\n\n### 14. Material Transformation\n**Pattern:** Transform the subject into [material type] [material properties description] [preservation clause]\n**Example:** Transform the subject into a statue carved from a single piece of dark, polished obsidian with glossy, reflective surface that catches the light, while preserving the exact pose, proportions, and recognizable features\n\n### 15. Season Change\n**Pattern:** [Transform season] [season characteristics] [environment changes] [clothing adjustments] [preservation clause]\n**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, make the air feel crisp with a low golden light, and adjust the subject's clothing to include a cozy sweater while preserving all facial features, identity, and maintaining the same pose\n\n### 16. Art Style Emulation\n**Pattern:** Transform the image into [art movement] in the style of [artist], using [technique description] [preservation clause]\n**Example:** Transform the image into a Post-Impressionist painting in the style of Van Gogh, using thick, swirling brushstrokes, vibrant emotional colors, and dynamic sense of energy in every element, while maintaining the recognition of all subjects and their core identities\n\n## Core Principles for Effective Prompts\n\n1. **Be Specific and Clear**\n - ✅ Convert to pencil sketch with natural graphite lines, cross-hatching, and visible paper texture\n - ❌ Make it a sketch\n\n2. **Break Complex Edits into Steps**\n - Handle subject first, then background\n - Adjust lighting first, then change style\n\n3. **Explicitly State What to Preserve Based on Subject Type**\n - For people: while preserving facial features, identity, expressions, gender, and proportions\n - For objects: while maintaining the same design, proportions, and functionality\n - For scenes: while preserving the spatial layout, composition, and atmosphere\n\n4. **Use Precise Verbs**\n - Prefer \"change\", \"replace\", \"adjust\" over vague terms like \"transform\"\n - Use specific action verbs that clearly communicate the intended modification\n\n5. **Include Professional Terminology**\n - Photography: three-point lighting, backlight, softbox\n - Art: brushstrokes, color saturation, composition\n\n6. **Add Narrative Elements**\n - as if we are seeing them at a different stage of their life\n - as if viewed through a tilt-shift lens\n\n7. **Analyze Text Context Carefully**\n - Determine subject type by analyzing the vocabulary and terms used by the user\n - Look for keywords related to people (clothing, hair, skin, face), animals, objects, or scenes\n - Pay attention to descriptive words that indicate the nature of the subject\n - When in doubt about the subject type, include more comprehensive preservation clauses\n\n## Response Format\n\nAnalyze the user's request and respond with ONLY the optimized English prompt for Kontext. Do not include explanations, notes, quotation marks, or any other text.\n\n## IMPORTANT: DO NOT include quotation marks in your response\n\nYour final output should be plain text without any surrounding quotes. For example:\n\n✅ CORRECT: Change the background to a beautiful tropical beach with golden sand\n❌ INCORRECT: \"Change the background to a beautiful tropical beach with golden sand\"\n\n## Examples\n\nUser: \"把这个人物放到海滩上\"\nResponse: Change the background to a beautiful tropical beach with golden sand, clear blue water, and palm trees, while keeping the person in the exact same position, scale, and pose, and preserving all facial features, expressions, and identity\n\nUser: \"让这张图片变成梵高风格\"\nResponse: 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, while maintaining the recognition of all subjects and their core identities\n\nUser: \"给这个人物换上维京战士的衣服\"\nResponse: Change the clothes to be a viking warrior with leather armor, fur accents, and metal details while preserving facial features, identity, gender, age appearance, and maintaining the exact same pose and proportions\n\nUser: \"让画面变成黄昏时分的感觉\"\nResponse: Convert to golden hour sunset scene with warm orange and pink lighting, long soft shadows, and a rich atmospheric glow, maintaining the same composition, subject details, and spatial relationships\n\nUser: \"在右边墙上写上'欢迎光临'\"\nResponse: Write the words '欢迎光临' on the wall on the right using the same style and texture as the surrounding environment, integrating naturally with the scene\n\nUser: \"把这个人变老20岁\"\nResponse: Age the subject by 20 years, adding appropriate wrinkles around the eyes and mouth, graying the hair naturally at the temples, and slightly softening the facial structure while maintaining their core identity, gender, proportions, and expression\n\nUser: \"请把图片中用红框圈出来的白色衬衫更换成黑色背心\"\nResponse: Change the white shirt highlighted in the red box to a black tank top while preserving the person's facial features, identity, gender, age appearance, and maintaining the exact same pose and proportions"
- }
- },
- "translate_prompts": {
- "ZH": {
- "role": "system",
- "content": "你是一名AI绘画领域的提示词翻译专家,负责将用户提供的文本内容由{src_lang}准确地翻译成{dst_lang}。要求:1.完整翻译用户提供的所有文本,不要遗漏;2.保持格式,不要改变原文的书写结构、保持权重标记格式【如(提示词内容:1.2)】等;2.翻译要准确理解全文,使用地道的{dst_lang}和AI绘画领域的专业术语;5.直接输出翻译结果,无需解释说明。不要输出思考过程。"
- }
- },
- "vision_prompts": {
- "vision_zh_全图反推(中文)": {
- "name": "全图反推(中文)",
- "role": "system",
- "content": "你是一位专业的图像分析专家,请将提供的图片转换为适合AI绘图模型使用的自然语言。你的描述需要准确、详细,并符合Stable Diffusion等模型的提示词特点。\n\n分析重点:\n1. 主体描述(按重要性排序):\n - 人物/物体的具体类型和特征\n - 准确的外观描述(发型、服装、表情等)\n - 清晰的姿势和动作\n - 关键细节特征\n\n2. 场景要素:\n - 具体的场景类型\n - 环境细节\n - 空间关系\n - 天气和时间状态\n\n3. 视觉风格:\n - 整体艺术风格\n - 画面质感\n - 特殊效果\n\n4. 技术特征:\n - 构图方式\n - 光影效果\n - 色彩特点\n - 渲染风格\n\n输出要求:\n1. 使用AI绘图模型常用的描述方式\n2. 按重要性顺序组织描述\n3. 包含必要的艺术风格和技术标签\n4. 避免使用模型难以理解的抽象描述\n5. 保持描述的可执行性和清晰度\n\n示例输出:\n一位穿着白色连衣裙的年轻动漫女孩,金色长发飘逸,面带甜美笑容。她站在阳光明媚的春日花园中,周围绽放着粉色和白色的花朵。画面采用温暖的色调,细腻的动漫风格渲染,半身构图,柔和的自然光效果。背景经过适度模糊处理,突出人物主体。高质量插画风格,注重细节刻画,8k分辨率。\n\n注意事项:\n1. 使用具体而非抽象的描述\n2. 包含AI模型能够理解的标准术语\n3. 按照\"主体 > 场景 > 风格 > 效果\"的顺序组织描述\n4. 确保每个重要视觉元素都有明确描述\n5. 适当添加技术参数和质量标签\n\n请直接输出符合AI绘图要求的自然语言描述,确保描述既流畅自然,又包含足够的细节供模型准确理解和生成。"
- },
- "vision_zh_全图反推-Tag(中文)": {
- "name": "全图反推-Tag(中文)",
- "role": "system",
- "content": "你是一位专业的图像分析专家。请仔细分析提供的图片,提取所有视觉元素并转换为中文标签,用于AI绘图模型使用。\n\n分析要点:\n1. 主体内容:\n - 主要对象(人物/动物/物体)\n - 外观特征(体态/表情/服装/材质)\n - 动作姿态\n - 细节特征(装饰/纹理/标志)\n\n2. 场景环境:\n - 场景类型和环境\n - 空间布局\n - 环境元素\n - 时间和天气\n\n3. 光影色彩:\n - 光源和光照效果\n - 主要色调和配色\n - 明暗对比和氛围\n\n4. 构图视角:\n - 构图方式\n - 视角和景别\n - 画面重点\n\n5. 艺术风格:\n - 整体风格(写实/动漫/插画等)\n - 特殊艺术效果\n - 渲染技法\n\n输出要求:\n- 使用中文标签,以逗号分隔\n- 按重要性排序:主体 > 场景 > 风格 > 细节 > 技术特征\n- 所有标签在同一行内\n- 直接输出标签,不添加任何分类或说明\n\n示例输出:\n可爱女孩,金色长发,白色连衣裙,春日花园,阳光明媚,清新动漫风,温暖色调,甜美笑容,蝴蝶结装饰,半身构图,柔和光影,高清细节\n\n请直接输出标签序列,确保客观描述,避免主观评价。"
- },
- "vision_en_全图反推(英文)": {
- "name": "全图反推(英文)",
- "role": "system",
- "content": "You are a professional image analysis expert. Please convert the provided image into a natural language description suitable for AI image generation models like Stable Diffusion. Your description should be accurate, detailed, and follow prompt writing best practices.\n\nAnalysis Focus:\n1. Subject Description (in order of importance):\n - Specific type and characteristics of people/objects\n - Accurate appearance details (hairstyle, clothing, expressions)\n - Clear poses and actions\n - Key details and features\n\n2. Scene Elements:\n - Specific scene type\n - Environmental details\n - Spatial relationships\n - Weather and time conditions\n\n3. Visual Style:\n - Overall artistic style\n - Image quality\n - Special effects\n\n4. Technical Features:\n - Composition method\n - Lighting effects\n - Color characteristics\n - Rendering style\n\nOutput Requirements:\n1. Use standard terminology common in AI image generation\n2. Organize description by importance\n3. Include necessary artistic style and technical tags\n4. Avoid abstract descriptions that models struggle with\n5. Maintain clarity and executability\n\nExample Output:\nA young anime girl wearing a white sundress, flowing golden hair, gentle smile on her face. She stands in a sun-lit spring garden surrounded by blooming pink and white flowers. Warm color palette, detailed anime style rendering, medium shot composition, soft natural lighting. Background with subtle blur effect to emphasize the subject. High-quality illustration style, intricate details, 8k resolution.\n\nGuidelines:\n1. Use specific rather than abstract descriptions\n2. Include standard terms that AI models understand\n3. Organize in order: subject > scene > style > effects\n4. Ensure each important visual element is clearly described\n5. Add appropriate technical parameters and quality tags\n\nPlease output a natural language description suitable for AI image generation, ensuring it is both fluent and contains sufficient details for accurate model interpretation and generation."
- },
- "vision_en_全图反推-Tag(英文)": {
- "name": "全图反推-Tag(英文)",
- "role": "system",
- "content": "You are a professional image analysis expert. Please analyze the provided image and convert all visual elements into English tags suitable for AI image generation models.\n\nAnalysis Points:\n1. Main Subject:\n - Primary objects (people/animals/objects)\n - Appearance (physique/expression/clothing/texture)\n - Actions and poses\n - Detail features (decorations/patterns/marks)\n\n2. Scene & Environment:\n - Scene type and setting\n - Spatial layout\n - Environmental elements\n - Time and weather\n\n3. Light & Color:\n - Light sources and lighting effects\n - Main color schemes\n - Contrast and atmosphere\n\n4. Composition:\n - Composition method\n - Perspective and shot type\n - Focal points\n\n5. Artistic Style:\n - Overall style (realistic/anime/illustration)\n - Special artistic effects\n - Rendering techniques\n\nOutput Requirements:\n- Use clear, standard English tags\n- Separate tags with commas\n- Order by importance: subject > scene > style > details > technical features\n- All tags in a single line\n- Direct tag output without categories or explanations\n\nExample Output:\nbeautiful girl, long blonde hair, white dress, spring garden, sunny day, anime style, warm colors, sweet smile, bow decoration, medium shot, soft lighting, high detail\n\nPlease output only the tag sequence. Ensure objective description and avoid subjective judgments. Use standard art and photography terminology where appropriate."
- }
- }
- }
- # 默认用户标签 - 空对象
- self.default_user_tags = {}
+ # 执行数据迁移和配置文件初始化
+ # migration_tool 统一处理:确保文件存在 -> CSV标签迁移 -> 旧版迁移 -> 增量更新
+ self._run_migrations()
- # 确保配置文件存在
- self.ensure_config_exists()
- self.ensure_system_prompts_exists()
- # 先校验并补全系统提示词内容,再处理激活项
- self.validate_and_fix_system_prompts()
- self.ensure_active_prompts_exists()
- self.ensure_user_tags_exists()
-
- # 验证并修复激活提示词
+ # 验证并修复激活提示词(静默模式,仅异常时修复)
self.validate_and_fix_active_prompts()
# 验证并修复模型参数配置
self.validate_and_fix_model_params()
- # --- 统一日志输出(绿色前缀)---
+
+ # --- 数据迁移 ---
+ def _run_migrations(self):
+ """
+ 执行数据迁移(按需调用,不影响性能)
+ 仅在需要时才导入和运行迁移工具
+ """
+ try:
+ from .utils.migration_tool import run_migrations
+
+ # 准备默认配置数据用于增量更新
+ default_configs = {
+ 'config': self.default_config,
+ 'system_prompts': self.default_system_prompts,
+ 'active_prompts': self.default_active_prompts,
+ 'tags_user': self.default_user_tags,
+ 'kontext_presets': self.default_kontext_presets
+ }
+
+ # 运行迁移
+ results = run_migrations(
+ plugin_dir=self.dir_path,
+ user_base_dir=self.base_dir,
+ logger=self._log,
+ default_configs=default_configs
+ )
+
+ # 记录迁移结果
+ if results.get('tags_migration'):
+ self._log("[用户标签.csv] 数据迁移完成")
+
+ except Exception as e:
+ self._log(f"数据迁移失败: {str(e)}")
+ # 迁移失败不影响正常运行,仅记录日志
+
+ # --- 统一日志输出 ---
def _log(self, msg: str):
- """统一控制台日志前缀为绿色 [PromptAssistant]"""
- print(f"\033[32m[✨PromptAssistant]\033[0m {msg}")
+ """统一控制台日志前缀"""
+ from .utils.common import _ANSI_CLEAR_EOL
+ print(f"\r{_ANSI_CLEAR_EOL}✨ {msg}", flush=True)
+ # ---模板加载---
+ def _load_template(self, template_name: str, fallback: dict = None) -> dict:
+ """
+ 从模板文件加载默认配置
+
+ 参数:
+ template_name: 模板名称(不含扩展名和_template后缀)
+ fallback: 加载失败时的回退默认值
+
+ 返回:
+ 配置字典(包含 __config_version 用于版本管理)
+ """
+ template_path = os.path.join(self.templates_dir, f"{template_name}_template.json")
+ try:
+ with open(template_path, "r", encoding="utf-8") as f:
+ data = json.load(f)
+ # 获取版本号并保存,用于后续比对
+ template_version = data.get("__config_version", "2.0")
+ self._template_versions[template_name] = template_version
+ return data
+ except Exception as e:
+ self._log(f"加载模板 {template_name} 失败: {str(e)},使用回退值")
+ # 确保 fallback 也包含版本号
+ if fallback is None:
+ fallback = {}
+ # 如果 fallback 没有版本号,添加默认版本号
+ if "__config_version" not in fallback:
+ fallback = {"__config_version": "2.0", **fallback}
+ self._template_versions[template_name] = "2.0"
+ return fallback
- def ensure_config_exists(self):
- """确保配置文件存在,不存在则创建默认配置"""
- if not os.path.exists(self.config_path):
- self._log("配置文件不存在,创建默认配置文件...")
- self.save_config(self.default_config)
-
- def ensure_system_prompts_exists(self):
- """确保系统提示词配置文件存在,不存在则创建默认配置"""
- if not os.path.exists(self.system_prompts_path):
- self._log("系统提示词配置文件不存在,创建默认配置文件...")
- self.save_system_prompts(self.default_system_prompts)
- else:
- # 即使文件存在,也检查一下是否包含所有必要的提示词类型
+ def _get_config_version(self, config: dict) -> str:
+ """
+ 获取配置版本号(兼容新旧两种版本字段)
+
+ 版本字段优先级:
+ 1. __config_version (新版本字段,如 "2.0.0")
+ 2. version (旧版本字段,如 "2.0" 或 "1.0")
+ 3. 默认返回 "1.0"(无版本字段视为最旧版本)
+
+ 返回:
+ 版本字符串,如 "2.0.0"、"2.0" 或 "1.0"
+ """
+ # 优先使用新版本字段
+ if "__config_version" in config:
+ return config["__config_version"]
+ # 兼容旧版本字段
+ return config.get("version", "1.0")
+
+ def _is_v2_config(self, config: dict) -> bool:
+ """
+ 检查配置是否为 v2.0 或更高版本
+
+ 返回:
+ True 表示 v2.0 或更高版本 (1.9 也视为 v2 格式,用于增量测试)
+ """
+ version = self._get_config_version(config)
+ try:
+ v_float = float(version)
+ return v_float >= 1.9
+ except ValueError:
+ # 如果不是数字(如 "2.0.0"),取主版本号比较
+ major_version = version.split(".")[0]
try:
- with open(self.system_prompts_path, "r", encoding="utf-8") as f:
- system_prompts = json.load(f)
+ return int(major_version) >= 2
+ except ValueError:
+ return False
- # 检查是否包含所有必要的提示词类型
- need_update = False
- for prompt_type, default_prompts in self.default_system_prompts.items():
- if prompt_type not in system_prompts:
- self._log(f"系统提示词配置文件中缺少 {prompt_type} 类型,添加默认配置...")
- system_prompts[prompt_type] = default_prompts
- need_update = True
+ # --- 注意:以下方法已迁移到 migration_tool.py ---
+ # - _apply_migrated_api_keys
+ # - _migrate_provider_to_service
+ # - _create_or_update_custom_service
+ # - _match_service_by_provider
+ # - _check_and_add_missing_services
+ # 配置文件的创建、迁移和增量更新统一由 migration_tool 处理
- # 如果需要更新,保存修复后的系统提示词
- if need_update:
- self.save_system_prompts(system_prompts)
- self._log("已完成系统提示词配置文件的修复")
- except Exception as e:
- self._log(f"检查系统提示词配置文件时出错: {str(e)}")
- def ensure_active_prompts_exists(self):
- """确保激活的提示词配置文件存在,不存在则创建默认配置"""
- if not os.path.exists(self.active_prompts_path):
- self._log("激活的提示词配置文件不存在,创建默认配置文件...")
- self.save_active_prompts(self.default_active_prompts)
- else:
- # 即使文件存在,也检查一下是否包含所有必要的提示词类型
- try:
- with open(self.active_prompts_path, "r", encoding="utf-8") as f:
- active_prompts = json.load(f)
-
- # 检查是否包含所有必要的提示词类型
- need_update = False
- for prompt_type, default_value in self.default_active_prompts.items():
- if prompt_type not in active_prompts:
- self._log(f"激活的提示词配置文件中缺少 {prompt_type} 类型,添加默认配置...")
- active_prompts[prompt_type] = default_value
- need_update = True
-
- # 如果需要更新,保存修复后的激活提示词
- if need_update:
- self.save_active_prompts(active_prompts)
- self._log("已完成激活提示词配置文件的修复")
- except Exception as e:
- self._log(f"检查激活提示词配置文件时出错: {str(e)}")
-
- def ensure_user_tags_exists(self):
- """确保用户标签文件存在,不存在则创建空对象文件"""
- if not os.path.exists(self.tags_user_path):
- self._log("用户标签文件不存在,创建空标签文件...")
- self.save_user_tags(self.default_user_tags)
+ def _atomic_write_json(self, file_path: str, data: dict) -> bool:
+ """
+ 原子性写入 JSON 文件
+
+ 采用"写临时文件 + 原子性重命名"的策略,确保文件写入的原子性:
+ - 如果写入成功,新文件会替换旧文件
+ - 如果写入失败或被中断,旧文件保持不变
+
+ 参数:
+ file_path: 目标文件路径
+ data: 要保存的数据字典
+
+ 返回:
+ bool: 保存成功返回 True,失败返回 False
+ """
+ temp_fd = None
+ temp_path = None
+
+ try:
+ # ---步骤1:写入临时文件---
+ # 在同一目录下创建临时文件(确保在同一文件系统,rename 才是原子的)
+ temp_fd, temp_path = tempfile.mkstemp(
+ dir=os.path.dirname(file_path),
+ suffix='.tmp',
+ prefix='.tmp_'
+ )
+
+ # 完整写入新配置到临时文件
+ with os.fdopen(temp_fd, 'w', encoding='utf-8') as f:
+ json.dump(data, f, ensure_ascii=False, indent=2)
+ temp_fd = None # 文件已关闭,避免重复关闭
+
+ # ---步骤2:原子性替换---
+ # rename 操作是原子的,要么成功替换,要么失败不变
+ shutil.move(temp_path, file_path)
+ temp_path = None # 已移动,避免清理时删除
+
+ return True
+
+ except Exception as e:
+ self._log(f"原子性写入 JSON 文件失败 [{os.path.basename(file_path)}]: {str(e)}")
+ return False
+
+ finally:
+ # 清理临时文件(如果写入失败)
+ if temp_fd is not None:
+ try:
+ os.close(temp_fd)
+ except:
+ pass
+
+ if temp_path is not None and os.path.exists(temp_path):
+ try:
+ os.unlink(temp_path)
+ except:
+ pass
def load_config(self):
"""加载配置文件"""
@@ -271,13 +265,7 @@ class ConfigManager:
def save_config(self, config):
"""保存配置文件"""
- try:
- with open(self.config_path, "w", encoding="utf-8") as f:
- json.dump(config, f, ensure_ascii=False, indent=2)
- return True
- except Exception as e:
- self._log(f"保存配置文件失败: {str(e)}")
- return False
+ return self._atomic_write_json(self.config_path, config)
def load_system_prompts(self):
"""加载系统提示词配置"""
@@ -290,13 +278,7 @@ class ConfigManager:
def save_system_prompts(self, system_prompts):
"""保存系统提示词配置"""
- try:
- with open(self.system_prompts_path, "w", encoding="utf-8") as f:
- json.dump(system_prompts, f, ensure_ascii=False, indent=2)
- return True
- except Exception as e:
- self._log(f"保存系统提示词配置失败: {str(e)}")
- return False
+ return self._atomic_write_json(self.system_prompts_path, system_prompts)
def load_active_prompts(self):
"""加载激活的提示词配置"""
@@ -309,13 +291,7 @@ class ConfigManager:
def save_active_prompts(self, active_prompts):
"""保存激活的提示词配置"""
- try:
- with open(self.active_prompts_path, "w", encoding="utf-8") as f:
- json.dump(active_prompts, f, ensure_ascii=False, indent=2)
- return True
- except Exception as e:
- self._log(f"保存激活的提示词配置失败: {str(e)}")
- return False
+ return self._atomic_write_json(self.active_prompts_path, active_prompts)
def load_user_tags(self):
"""加载用户标签配置"""
@@ -328,12 +304,266 @@ class ConfigManager:
def save_user_tags(self, user_tags):
"""保存用户标签配置"""
+ return self._atomic_write_json(self.tags_user_path, user_tags)
+
+ def load_kontext_presets(self):
+ """加载Kontext预设配置"""
try:
- with open(self.tags_user_path, "w", encoding="utf-8") as f:
- json.dump(user_tags, f, ensure_ascii=False, indent=2)
+ with open(self.kontext_presets_path, "r", encoding="utf-8") as f:
+ return json.load(f)
+ except Exception as e:
+ self._log(f"加载Kontext预设配置失败: {str(e)}")
+ return {}
+
+ def save_kontext_presets(self, kontext_presets):
+ """保存Kontext预设配置"""
+ return self._atomic_write_json(self.kontext_presets_path, kontext_presets)
+
+
+
+ # --- 注意:ensure_tags_csv_exists 和 CSV 标签迁移已迁移到 migration_tool.py ---
+
+
+
+ def list_tags_files(self) -> list:
+ """列出tags目录下所有CSV文件"""
+ try:
+ files = []
+ for filename in os.listdir(self.tags_dir):
+ if filename.endswith(".csv"):
+ files.append(filename)
+ return sorted(files)
+ except Exception as e:
+ self._log(f"列出标签文件失败: {str(e)}")
+ return []
+
+ def load_tags_csv(self, filename: str) -> dict:
+ """加载CSV标签文件,返回嵌套字典结构"""
+ csv_path = os.path.join(self.tags_dir, filename)
+ if not os.path.exists(csv_path):
+ self._log(f"CSV文件不存在: {filename}")
+ return {}
+
+ # 尝试多种编码,优先尝试 utf-8-sig (Excel默认UTF-8),然后是 gbk (Excel默认ANSI),最后是 utf-8
+ encodings = ['utf-8-sig', 'gbk', 'gb18030', 'utf-8']
+
+ for encoding in encodings:
+ try:
+ result = {}
+ with open(csv_path, "r", encoding=encoding, newline="") as f:
+ reader = csv.reader(f)
+ try:
+ header = next(reader, None) # 跳过表头
+ except StopIteration:
+ return {} # 空文件
+
+ for row in reader:
+ if len(row) < 3:
+ continue
+
+ tag_name = row[0].strip()
+ tag_value = row[1].strip()
+ # 分类路径:从第3列开始,过滤空值
+ categories = [c.strip() for c in row[2:] if c.strip()]
+
+ if not tag_name or not categories:
+ continue
+
+ # 构建嵌套结构
+ current = result
+ for cat in categories:
+ if cat not in current:
+ current[cat] = {}
+ current = current[cat]
+
+ # 添加标签
+ current[tag_name] = tag_value
+
+ # self._log(f"成功加载CSV文件: {filename} (编码: {encoding})")
+ return result
+ except UnicodeDecodeError:
+ continue
+ except Exception as e:
+ self._log(f"加载CSV标签失败 ({encoding}): {str(e)}")
+ continue
+
+ self._log(f"无法加载CSV文件: {filename},尝试了所有编码均失败")
+ return {}
+
+ def save_tags_csv(self, filename: str, tags: dict) -> bool:
+ """保存标签数据到CSV文件"""
+ csv_path = os.path.join(self.tags_dir, filename)
+
+ try:
+ rows = []
+
+ def extract_tags(obj, path: list):
+ for key, value in obj.items():
+ if isinstance(value, str):
+ rows.append([key, value] + path)
+ elif isinstance(value, dict):
+ extract_tags(value, path + [key])
+
+ for level1_name, level1_content in tags.items():
+ if isinstance(level1_content, dict):
+ extract_tags(level1_content, [level1_name])
+
+ with open(csv_path, "w", encoding="utf-8-sig", newline="") as f:
+ writer = csv.writer(f)
+ writer.writerow(["标签名", "标签值", "一级分类", "二级分类", "三级分类", "四级分类"])
+ for row in rows:
+ while len(row) < 6:
+ row.append("")
+ writer.writerow(row[:6])
+
return True
except Exception as e:
- self._log(f"保存用户标签配置失败: {str(e)}")
+ self._log(f"保存CSV标签失败: {str(e)}")
+ return False
+
+ def get_tags_selection(self) -> dict:
+ """获取用户选择的标签文件"""
+ try:
+ if os.path.exists(self.tags_selection_path):
+ with open(self.tags_selection_path, "r", encoding="utf-8") as f:
+ return json.load(f)
+ return self.default_tags_selection
+ except Exception as e:
+ self._log(f"读取标签选择失败: {str(e)}")
+ return self.default_tags_selection
+
+ def save_tags_selection(self, selection: dict) -> bool:
+ """保存用户选择的标签文件"""
+ try:
+ with open(self.tags_selection_path, "w", encoding="utf-8") as f:
+ json.dump(selection, f, ensure_ascii=False, indent=2)
+ return True
+ except Exception as e:
+ self._log(f"保存标签选择失败: {str(e)}")
+ return False
+
+ def get_favorites(self) -> dict:
+ """获取收藏列表"""
+ user_tags = self.load_user_tags()
+ favorites = user_tags.get("favorites", {})
+
+ # 兼容性处理:如果是列表,转换为字典
+ if isinstance(favorites, list):
+ new_favorites = {}
+ for item in favorites:
+ if isinstance(item, str):
+ new_favorites[item] = item
+ elif isinstance(item, dict):
+ name = item.get("name", item.get("value"))
+ value = item.get("value")
+ if name and value:
+ new_favorites[name] = value
+ return new_favorites
+
+ return favorites
+
+ def add_favorite(self, tag_value: str, tag_name: str = None, category: str = "默认") -> bool:
+ """添加收藏"""
+ try:
+ user_tags = self.load_user_tags()
+ favorites = user_tags.get("favorites", {})
+
+ # 兼容性迁移:如果是一维字典 {name: value},无需强制迁移,但新添加的会放入 category
+ # 如果是列表,先迁移为字典
+ if isinstance(favorites, list):
+ favorites = self.get_favorites()
+
+ name = tag_name if tag_name else tag_value
+
+ # 使用嵌套结构 {分类: {名称: 值}}
+ if category not in favorites:
+ # 检查是否存在旧的平铺结构,如果有,且category是默认,可能混杂
+ # 这里简单处理:如果 favorites 只有键值对且都不是字典,说明是旧版平铺
+ # 但为了不破坏旧数据,我们在顶层只存储分类字典
+ # 如果 favorites 中已有非字典的值,说明是旧版平铺结构 {name: value}
+ # 我们将它们移动到 "默认" 分类
+ has_legacy = any(not isinstance(v, dict) for v in favorites.values())
+ if has_legacy:
+ legacy_items = {k: v for k, v in favorites.items() if not isinstance(v, dict)}
+ # 清除旧项
+ for k in legacy_items:
+ del favorites[k]
+ # 初始化默认分类
+ if "默认" not in favorites:
+ favorites["默认"] = {}
+ favorites["默认"].update(legacy_items)
+
+ if category not in favorites:
+ favorites[category] = {}
+
+ # 如果 favorites[category] 不是字典(防御性编程),初始化为字典
+ if not isinstance(favorites.get(category), dict):
+ favorites[category] = {}
+
+ favorites[category][name] = tag_value
+
+ user_tags["favorites"] = favorites
+ return self.save_user_tags(user_tags)
+ except Exception as e:
+ self._log(f"添加收藏失败: {str(e)}")
+ return False
+
+ def remove_favorite(self, tag_value: str, category: str = None) -> bool:
+ """移除收藏"""
+ try:
+ user_tags = self.load_user_tags()
+ favorites = user_tags.get("favorites", {})
+
+ # 兼容性迁移
+ if isinstance(favorites, list):
+ favorites = self.get_favorites()
+
+ removed = False
+
+ # 如果指定了分类,只在指定分类中删除
+ if category:
+ # 尝试直接匹配分类(完全匹配)
+ target_categories = [category]
+
+ # 如果没找到,尝试模糊匹配(处理文件名后缀差异)
+ if category not in favorites:
+ # 比如 category 是 "foo",favorites里有 "foo.csv" 或相反
+ # 但通常 favorites 里的 key 已经是去后缀的
+ pass
+
+ for cat in target_categories:
+ if cat in favorites and isinstance(favorites[cat], dict):
+ # 根据值删除
+ keys_to_remove = [k for k, v in favorites[cat].items() if v == tag_value]
+ for k in keys_to_remove:
+ del favorites[cat][k]
+ removed = True
+
+ # 如果该分类空了,是否删除分类键?暂时保留
+ else:
+ # 未指定分类,递归全部删除(旧逻辑)
+ # 如果是旧版平铺结构
+ if any(not isinstance(v, dict) for v in favorites.values()):
+ keys_to_remove = [k for k, v in favorites.items() if not isinstance(v, dict) and v == tag_value]
+ for k in keys_to_remove:
+ del favorites[k]
+ removed = True
+
+ # 如果是新版嵌套结构
+ for cat, items in favorites.items():
+ if isinstance(items, dict):
+ keys_to_remove = [k for k, v in items.items() if v == tag_value]
+ for k in keys_to_remove:
+ del items[k]
+ removed = True
+
+ if removed:
+ user_tags["favorites"] = favorites
+ return self.save_user_tags(user_tags)
+
+ return True
+ except Exception as e:
+ self._log(f"移除收藏失败: {str(e)}")
return False
def get_system_prompts(self):
@@ -368,41 +598,250 @@ class ConfigManager:
def get_llm_config(self):
"""获取LLM配置"""
config = self.load_config()
- llm_config = config.get("llm", self.default_config["llm"])
-
- # 构建前端友好的配置格式
- current_provider = llm_config.get("current_provider", "zhipu")
- provider_config = llm_config.get("providers", {}).get(current_provider, {})
-
+ current_service_info = config.get('current_services', {}).get('llm')
+
+ # 适配新旧格式:支持字符串(旧)和字典(新)
+ if isinstance(current_service_info, str):
+ # 旧格式: "service_id"
+ current_service_id = current_service_info
+ current_model_name = None
+ elif isinstance(current_service_info, dict):
+ # 新格式: {"service": "service_id", "model": "model_name"}
+ current_service_id = current_service_info.get('service')
+ current_model_name = current_service_info.get('model')
+ else:
+ # 未设置
+ current_service_id = None
+ current_model_name = None
+
+ if not current_service_id:
+ # 没有选中的服务,返回默认结构
+ return self._get_empty_llm_config()
+
+ # 查找对应的服务
+ service = self._get_service_by_id(current_service_id)
+ if not service:
+ return self._get_empty_llm_config()
+
+ # 获取LLM模型列表
+ llm_models = service.get('llm_models', [])
+
+ # 如果指定了模型名称,尝试查找
+ target_model = None
+ if current_model_name:
+ target_model = next((m for m in llm_models if m.get('name') == current_model_name), None)
+
+ # 如果未找到指定模型,使用默认模型或第一个模型
+ if not target_model:
+ target_model = next((m for m in llm_models if m.get('is_default')),
+ llm_models[0] if llm_models else None)
+
+ if not target_model:
+ return self._get_empty_llm_config()
+
+ # 直接获取API Key(明文存储)
+ api_key = service.get('api_key', '')
+
+ # 返回配置
return {
- "provider": current_provider,
- "model": provider_config.get("model", ""),
- "base_url": provider_config.get("base_url", ""),
- "api_key": provider_config.get("api_key", ""),
- "temperature": provider_config.get("temperature", 0.7),
- "max_tokens": provider_config.get("max_tokens", 1000),
- "top_p": provider_config.get("top_p", 0.9),
- "providers": llm_config.get("providers", {})
+ "provider": service.get('id', ''), # 使用service_id作为provider
+ "model": target_model.get('name', ''),
+ "base_url": service.get('base_url', ''),
+ "api_key": api_key,
+ "temperature": target_model.get('temperature', 0.7),
+ "max_tokens": target_model.get('max_tokens', 1000),
+ "top_p": target_model.get('top_p', 0.9),
+ "auto_unload": service.get('auto_unload', True) if service.get('type') == 'ollama' else None,
+ "providers": {} # v2.0中不再使用此字段
}
+
+ def _get_empty_llm_config(self):
+ """返回空的LLM配置"""
+ return {
+ "provider": "",
+ "model": "",
+ "base_url": "",
+ "api_key": "",
+ "temperature": 0.7,
+ "max_tokens": 1000,
+ "top_p": 0.9,
+ "providers": {}
+ }
+
+ def _get_service_by_id(self, service_id: str) -> dict:
+ """根据ID获取服务配置"""
+ config = self.load_config()
+ services = config.get('model_services', [])
+ for service in services:
+ if service.get('id') == service_id:
+ return service
+ return None
+
def get_vision_config(self):
"""获取视觉模型配置"""
config = self.load_config()
- vision_config = config.get("vlm", self.default_config["vlm"])
-
- # 构建前端友好的配置格式
- current_provider = vision_config.get("current_provider", "zhipu")
- provider_config = vision_config.get("providers", {}).get(current_provider, {})
-
+ current_service_info = config.get('current_services', {}).get('vlm')
+
+ # 适配新旧格式:支持字符串(旧)和字典(新)
+ if isinstance(current_service_info, str):
+ # 旧格式: "service_id"
+ current_service_id = current_service_info
+ current_model_name = None
+ elif isinstance(current_service_info, dict):
+ # 新格式: {"service": "service_id", "model": "model_name"}
+ current_service_id = current_service_info.get('service')
+ current_model_name = current_service_info.get('model')
+ else:
+ # 未设置
+ current_service_id = None
+ current_model_name = None
+
+ if not current_service_id:
+ # 没有选中的服务,返回默认结构
+ return self._get_empty_vision_config()
+
+ # 查找对应的服务
+ service = self._get_service_by_id(current_service_id)
+ if not service:
+ return self._get_empty_vision_config()
+
+ # 获取VLM模型列表
+ vlm_models = service.get('vlm_models', [])
+
+ # 如果指定了模型名称,尝试查找
+ target_model = None
+ if current_model_name:
+ target_model = next((m for m in vlm_models if m.get('name') == current_model_name), None)
+
+ # 如果未找到指定模型,使用默认模型或第一个模型
+ if not target_model:
+ target_model = next((m for m in vlm_models if m.get('is_default')),
+ vlm_models[0] if vlm_models else None)
+
+ if not target_model:
+ return self._get_empty_vision_config()
+
+ # 直接获取API Key(明文存储)
+ api_key = service.get('api_key', '')
+
+ # 返回配置
return {
- "provider": current_provider,
- "model": provider_config.get("model", ""),
- "base_url": provider_config.get("base_url", ""),
- "api_key": provider_config.get("api_key", ""),
- "temperature": provider_config.get("temperature", 0.7),
- "max_tokens": provider_config.get("max_tokens", 500),
- "top_p": provider_config.get("top_p", 0.9),
- "providers": vision_config.get("providers", {})
+ "provider": service.get('id', ''), # 使用service_id作为provider
+ "model": target_model.get('name', ''),
+ "base_url": service.get('base_url', ''),
+ "api_key": api_key,
+ "temperature": target_model.get('temperature', 0.7),
+ "max_tokens": target_model.get('max_tokens', 500),
+ "top_p": target_model.get('top_p', 0.9),
+ "auto_unload": service.get('auto_unload', True) if service.get('type') == 'ollama' else None,
+ "providers": {} # v2.0中不再使用此字段
+ }
+
+ def _get_empty_vision_config(self):
+ """返回空的视觉模型配置"""
+ return {
+ "provider": "",
+ "model": "",
+ "base_url": "",
+ "api_key": "",
+ "temperature": 0.7,
+ "max_tokens": 500,
+ "top_p": 0.9,
+ "providers": {}
+ }
+
+ def get_translate_config(self):
+ """获取翻译服务配置(支持百度翻译和LLM翻译)"""
+ config = self.load_config()
+ current_service_info = config.get('current_services', {}).get('translate')
+
+ # 适配新旧格式:支持字符串(旧)和字典(新)
+ if isinstance(current_service_info, str):
+ # 旧格式: "service_id"
+ current_service_id = current_service_info
+ current_model_name = None
+ elif isinstance(current_service_info, dict):
+ # 新格式: {"service": "service_id", "model": "model_name"}
+ current_service_id = current_service_info.get('service')
+ current_model_name = current_service_info.get('model')
+ else:
+ # 未设置,默认使用百度翻译
+ current_service_id = 'baidu'
+ current_model_name = None
+
+ # 百度翻译特殊处理(使用独立的baidu_translate配置)
+ if current_service_id == 'baidu':
+ baidu_config = self.get_baidu_translate_config()
+ return {
+ "provider": "baidu",
+ "model": "",
+ "base_url": "",
+ "api_key": baidu_config.get('app_id', ''),
+ "secret_key": baidu_config.get('secret_key', ''),
+ "temperature": 0.7,
+ "max_tokens": 1000,
+ "top_p": 0.9,
+ "providers": {}
+ }
+
+ # 查找对应的LLM服务
+ service = self._get_service_by_id(current_service_id)
+ if not service:
+ # 服务不存在,回退到百度翻译
+ baidu_config = self.get_baidu_translate_config()
+ return {
+ "provider": "baidu",
+ "model": "",
+ "base_url": "",
+ "api_key": baidu_config.get('app_id', ''),
+ "secret_key": baidu_config.get('secret_key', ''),
+ "temperature": 0.7,
+ "max_tokens": 1000,
+ "top_p": 0.9,
+ "providers": {}
+ }
+
+ # 获取LLM模型列表
+ llm_models = service.get('llm_models', [])
+
+ # 如果指定了模型名称,尝试查找
+ target_model = None
+ if current_model_name:
+ target_model = next((m for m in llm_models if m.get('name') == current_model_name), None)
+
+ # 如果未找到指定模型,使用默认模型或第一个模型
+ if not target_model:
+ target_model = next((m for m in llm_models if m.get('is_default')),
+ llm_models[0] if llm_models else None)
+
+ if not target_model:
+ # 没有可用模型,回退到百度翻译
+ baidu_config = self.get_baidu_translate_config()
+ return {
+ "provider": "baidu",
+ "model": "",
+ "base_url": "",
+ "api_key": baidu_config.get('app_id', ''),
+ "secret_key": baidu_config.get('secret_key', ''),
+ "temperature": 0.7,
+ "max_tokens": 1000,
+ "top_p": 0.9,
+ "providers": {}
+ }
+
+ # 返回LLM翻译配置
+ api_key = service.get('api_key', '')
+ return {
+ "provider": service.get('id', ''),
+ "model": target_model.get('name', ''),
+ "base_url": service.get('base_url', ''),
+ "api_key": api_key,
+ "temperature": target_model.get('temperature', 0.7),
+ "max_tokens": target_model.get('max_tokens', 1000),
+ "top_p": target_model.get('top_p', 0.9),
+ "auto_unload": service.get('auto_unload', True) if service.get('type') == 'ollama' else None,
+ "providers": {}
}
def get_settings(self):
@@ -460,375 +899,850 @@ class ConfigManager:
return self.save_config(config)
- def update_llm_config(self, provider=None, model=None, base_url=None, api_key=None, temperature=None, max_tokens=None, top_p=None, auto_unload=None, update_current=True):
- """更新LLM配置"""
- config = self.load_config()
- if "llm" not in config:
- config["llm"] = {"providers": {}}
- if "providers" not in config["llm"]:
- config["llm"]["providers"] = {}
- # 仅更新提供的参数
- if provider is not None:
- # 只有当update_current为True时才更新current_provider
- if update_current:
- config["llm"]["current_provider"] = provider
- # 确保provider存在于providers字典中
- if provider not in config["llm"]["providers"]:
- config["llm"]["providers"][provider] = {
- "model": self.default_config["llm"]["providers"].get(provider, {}).get("model", ""),
- "base_url": self.default_config["llm"]["providers"].get(provider, {}).get("base_url", ""),
- "api_key": "",
- "temperature": self.default_config["llm"]["providers"].get(provider, {}).get("temperature", 0.7),
- "max_tokens": self.default_config["llm"]["providers"].get(provider, {}).get("max_tokens", 1000),
- "top_p": self.default_config["llm"]["providers"].get(provider, {}).get("top_p", 0.9)
- }
- # 只有在同时提供provider和其他参数时才更新这些参数
- if provider is not None and model is not None:
- config["llm"]["providers"][provider]["model"] = model
- if provider is not None and base_url is not None:
- config["llm"]["providers"][provider]["base_url"] = base_url
- if provider is not None and api_key is not None:
- config["llm"]["providers"][provider]["api_key"] = api_key
- if provider is not None and temperature is not None:
- config["llm"]["providers"][provider]["temperature"] = temperature
- if provider is not None and max_tokens is not None:
- config["llm"]["providers"][provider]["max_tokens"] = max_tokens
- if provider is not None and top_p is not None:
- config["llm"]["providers"][provider]["top_p"] = top_p
- if provider is not None and auto_unload is not None:
- config["llm"]["providers"][provider]["auto_unload"] = auto_unload
-
- return self.save_config(config)
-
- def update_vision_config(self, provider=None, model=None, base_url=None, api_key=None, temperature=None, max_tokens=None, top_p=None, auto_unload=None, update_current=True):
- """更新视觉模型配置"""
- config = self.load_config()
- if "vlm" not in config:
- config["vlm"] = {"providers": {}}
- if "providers" not in config["vlm"]:
- config["vlm"]["providers"] = {}
-
- # 仅更新提供的参数
- if provider is not None:
- # 只有当update_current为True时才更新current_provider
- if update_current:
- config["vlm"]["current_provider"] = provider
-
- # 确保provider存在于providers字典中
- if provider not in config["vlm"]["providers"]:
- config["vlm"]["providers"][provider] = {
- "model": self.default_config["vlm"]["providers"].get(provider, {}).get("model", ""),
- "base_url": self.default_config["vlm"]["providers"].get(provider, {}).get("base_url", ""),
- "api_key": "",
- "temperature": self.default_config["vlm"]["providers"].get(provider, {}).get("temperature", 0.7),
- "max_tokens": self.default_config["vlm"]["providers"].get(provider, {}).get("max_tokens", 500),
- "top_p": self.default_config["vlm"]["providers"].get(provider, {}).get("top_p", 0.9)
- }
-
- # 只有在同时提供provider和其他参数时才更新这些参数
- if provider is not None and model is not None:
- config["vlm"]["providers"][provider]["model"] = model
- if provider is not None and base_url is not None:
- config["vlm"]["providers"][provider]["base_url"] = base_url
- if provider is not None and api_key is not None:
- config["vlm"]["providers"][provider]["api_key"] = api_key
- if provider is not None and temperature is not None:
- config["vlm"]["providers"][provider]["temperature"] = temperature
- if provider is not None and max_tokens is not None:
- config["vlm"]["providers"][provider]["max_tokens"] = max_tokens
- if provider is not None and top_p is not None:
- config["vlm"]["providers"][provider]["top_p"] = top_p
- if provider is not None and auto_unload is not None:
- config["vlm"]["providers"][provider]["auto_unload"] = auto_unload
-
- return self.save_config(config)
- def validate_and_fix_system_prompts(self):
- """验证并补全系统提示词配置,按默认项补齐缺失项(不覆盖用户自定义)"""
- try:
- system_prompts = self.load_system_prompts()
- need_update = False
-
- # 逐类检查:扩写/翻译/反推
- for prompt_type, default_prompts in self.default_system_prompts.items():
- # 确保类型存在且为字典
- if prompt_type not in system_prompts or not isinstance(system_prompts[prompt_type], dict):
- system_prompts[prompt_type] = {}
- need_update = True
- self._log(f"提示词类型缺失或格式不正确,已初始化: {prompt_type}")
-
- # 按默认清单补齐缺失项,并为已存在项补齐缺失字段
- for prompt_id, default_def in default_prompts.items():
- if prompt_id not in system_prompts[prompt_type]:
- system_prompts[prompt_type][prompt_id] = default_def
- need_update = True
- self._log(f"为 {prompt_type} 补充默认提示词: {prompt_id}")
- else:
- # 补齐缺失字段(如 name/role/content 等),不覆盖用户已有内容
- for key in default_def.keys():
- if key not in system_prompts[prompt_type][prompt_id]:
- system_prompts[prompt_type][prompt_id][key] = default_def[key]
- need_update = True
- self._log(f"为提示词 {prompt_id} 补齐字段: {key}")
-
- if need_update:
- self.save_system_prompts(system_prompts)
- self._log("已完成系统提示词配置的验证和补全")
- except Exception as e:
- self._log(f"验证和补全系统提示词配置时出错: {str(e)}")
+ # --- 注意:validate_and_fix_system_prompts 已迁移到 migration_tool.py ---
+ # 系统提示词的验证和补全由 migration_tool 的增量更新逻辑统一处理
def validate_and_fix_active_prompts(self):
- """验证激活提示词是否存在,如果不存在则修复"""
+ """
+ 验证激活提示词是否存在,如果不存在则修复
+
+ 注意:此方法只修复 active_prompts.json(切换到存在的提示词)
+ 不会恢复 system_prompts.json 中被删除的内容(尊重用户的删除操作)
+ """
try:
- # 加载当前的系统提示词和激活提示词
system_prompts = self.load_system_prompts()
active_prompts = self.load_active_prompts()
- # 标记是否需要更新系统提示词
- need_update_system = False
# 标记是否需要更新激活提示词
- need_update_active = False
-
- # 检查系统提示词类型是否为空,如果为空则从默认配置中恢复
- prompt_types = {
- "expand_prompts": "扩写提示词",
- "translate_prompts": "翻译提示词",
- "vision_prompts": "反推提示词"
- }
-
- for prompt_type, type_name in prompt_types.items():
- if prompt_type not in system_prompts or not system_prompts[prompt_type]:
- self._log(f"警告:{type_name}类型为空,从默认配置中恢复...")
- system_prompts[prompt_type] = self.default_system_prompts[prompt_type]
- need_update_system = True
-
- # 特别检查vision_prompts中是否包含中文和英文反推提示词
- if "vision_prompts" in system_prompts:
- # 检查是否有中文反推提示词
- has_zh_vision = False
- for key in system_prompts["vision_prompts"].keys():
- if key.startswith("vision_zh_"):
- has_zh_vision = True
- break
-
- if not has_zh_vision:
- self._log("警告:系统提示词中没有中文反推提示词,从默认配置中恢复...")
- # 从默认配置中恢复所有中文反推提示词
- for key, value in self.default_system_prompts["vision_prompts"].items():
- if key.startswith("vision_zh_"):
- system_prompts["vision_prompts"][key] = value
- need_update_system = True
-
- # 检查是否有英文反推提示词
- has_en_vision = False
- for key in system_prompts["vision_prompts"].keys():
- if key.startswith("vision_en_"):
- has_en_vision = True
- break
-
- if not has_en_vision:
- self._log("警告:系统提示词中没有英文反推提示词,从默认配置中恢复...")
- # 从默认配置中恢复所有英文反推提示词
- for key, value in self.default_system_prompts["vision_prompts"].items():
- if key.startswith("vision_en_"):
- system_prompts["vision_prompts"][key] = value
- need_update_system = True
-
- # 如果需要更新系统提示词,保存修复后的系统提示词
- if need_update_system:
- self.save_system_prompts(system_prompts)
- self._log("已恢复缺失的提示词类型")
+ modified = False
# 检查并修复扩写提示词
if "expand" in active_prompts:
expand_id = active_prompts["expand"]
- if expand_id not in system_prompts.get("expand_prompts", {}):
- self._log(f"警告:激活的扩写提示词 {expand_id} 不存在,尝试修复...")
- # 尝试获取第一个可用的扩写提示词
- expand_prompts = system_prompts.get("expand_prompts", {})
+ expand_prompts = system_prompts.get("expand_prompts", {})
+
+ if expand_id not in expand_prompts:
+ # 激活的提示词不存在,切换到第一个可用的
if expand_prompts:
first_expand_id = next(iter(expand_prompts))
active_prompts["expand"] = first_expand_id
- self._log(f"已将激活的扩写提示词修复为 {first_expand_id}")
- need_update_active = True
+ self._log(f"激活的扩写提示词 '{expand_id}' 不存在,已切换到 '{first_expand_id}'")
+ modified = True
else:
- # 如果没有可用的扩写提示词,使用默认值并确保它存在于系统提示词中
- default_expand_id = "expand_扩写-自然语言"
- active_prompts["expand"] = default_expand_id
-
- # 确保默认值存在于系统提示词中
- if default_expand_id not in system_prompts.get("expand_prompts", {}):
- if "expand_prompts" not in system_prompts:
- system_prompts["expand_prompts"] = {}
- system_prompts["expand_prompts"][default_expand_id] = self.default_system_prompts["expand_prompts"][default_expand_id]
- need_update_system = True
-
- self._log("未找到可用的扩写提示词,使用默认值并确保它存在于系统提示词中")
- need_update_active = True
+ # 没有可用的扩写提示词,清空激活
+ active_prompts["expand"] = ""
+ self._log(f"警告:没有可用的扩写提示词")
+ modified = True
# 检查并修复中文反推提示词
if "vision_zh" in active_prompts:
vision_zh_id = active_prompts["vision_zh"]
- if vision_zh_id not in system_prompts.get("vision_prompts", {}):
- self._log(f"警告:激活的中文反推提示词 {vision_zh_id} 不存在,尝试修复...")
- # 尝试获取第一个可用的中文反推提示词
- vision_prompts = {k: v for k, v in system_prompts.get("vision_prompts", {}).items() if k.startswith("vision_zh_")}
- if vision_prompts:
- first_vision_zh_id = next(iter(vision_prompts))
- active_prompts["vision_zh"] = first_vision_zh_id
- self._log(f"已将激活的中文反推提示词修复为 {first_vision_zh_id}")
- need_update_active = True
+ vision_prompts = system_prompts.get("vision_prompts", {})
+ zh_prompts = {k: v for k, v in vision_prompts.items() if k.startswith("vision_zh_")}
+
+ if vision_zh_id not in vision_prompts:
+ if zh_prompts:
+ first_id = next(iter(zh_prompts))
+ active_prompts["vision_zh"] = first_id
+ self._log(f"激活的中文反推提示词 '{vision_zh_id}' 不存在,已切换到 '{first_id}'")
+ modified = True
else:
- # 如果没有可用的中文反推提示词,使用默认值并确保它存在于系统提示词中
- default_vision_zh_id = "vision_zh_全图反推(中文)"
- active_prompts["vision_zh"] = default_vision_zh_id
-
- # 确保默认值存在于系统提示词中
- if default_vision_zh_id not in system_prompts.get("vision_prompts", {}):
- if "vision_prompts" not in system_prompts:
- system_prompts["vision_prompts"] = {}
- system_prompts["vision_prompts"][default_vision_zh_id] = self.default_system_prompts["vision_prompts"][default_vision_zh_id]
- need_update_system = True
-
- self._log("未找到可用的中文反推提示词,使用默认值并确保它存在于系统提示词中")
- need_update_active = True
+ active_prompts["vision_zh"] = ""
+ self._log(f"警告:没有可用的中文反推提示词")
+ modified = True
# 检查并修复英文反推提示词
if "vision_en" in active_prompts:
vision_en_id = active_prompts["vision_en"]
- if vision_en_id not in system_prompts.get("vision_prompts", {}):
- self._log(f"警告:激活的英文反推提示词 {vision_en_id} 不存在,尝试修复...")
- # 尝试获取第一个可用的英文反推提示词
- vision_prompts = {k: v for k, v in system_prompts.get("vision_prompts", {}).items() if k.startswith("vision_en_")}
- if vision_prompts:
- first_vision_en_id = next(iter(vision_prompts))
- active_prompts["vision_en"] = first_vision_en_id
- self._log(f"已将激活的英文反推提示词修复为 {first_vision_en_id}")
- need_update_active = True
+ vision_prompts = system_prompts.get("vision_prompts", {})
+ en_prompts = {k: v for k, v in vision_prompts.items() if k.startswith("vision_en_")}
+
+ if vision_en_id not in vision_prompts:
+ if en_prompts:
+ first_id = next(iter(en_prompts))
+ active_prompts["vision_en"] = first_id
+ self._log(f"激活的英文反推提示词 '{vision_en_id}' 不存在,已切换到 '{first_id}'")
+ modified = True
else:
- # 如果没有可用的英文反推提示词,使用默认值并确保它存在于系统提示词中
- default_vision_en_id = "vision_en_全图反推(英文)"
- active_prompts["vision_en"] = default_vision_en_id
-
- # 确保默认值存在于系统提示词中
- if default_vision_en_id not in system_prompts.get("vision_prompts", {}):
- if "vision_prompts" not in system_prompts:
- system_prompts["vision_prompts"] = {}
- system_prompts["vision_prompts"][default_vision_en_id] = self.default_system_prompts["vision_prompts"][default_vision_en_id]
- need_update_system = True
-
- self._log("未找到可用的英文反推提示词,使用默认值并确保它存在于系统提示词中")
- need_update_active = True
-
- # 如果需要更新系统提示词,保存修复后的系统提示词
- if need_update_system:
- self.save_system_prompts(system_prompts)
- self._log("已完成系统提示词的验证和修复")
+ active_prompts["vision_en"] = ""
+ self._log(f"警告:没有可用的英文反推提示词")
+ modified = True
# 如果需要更新,保存修复后的激活提示词
- if need_update_active:
+ if modified:
self.save_active_prompts(active_prompts)
self._log("已完成激活提示词的验证和修复")
- # 如果系统提示词或激活提示词有更新,重新加载一次确保一致性
- if need_update_system or need_update_active:
- # 重新加载系统提示词和激活提示词
- system_prompts = self.load_system_prompts()
- active_prompts = self.load_active_prompts()
- self._log("已重新加载提示词配置")
-
except Exception as e:
- self._log(f"验证和修复激活提示词时出错: {str(e)}")
+ self._log(f"验证激活提示词异常: {str(e)}")
+
+
def validate_and_fix_model_params(self):
- """验证并修复模型参数配置,确保所有提供商都包含必要的参数"""
+ """
+ 验证并修复模型参数配置
+ 注意: v2.0版本中,模型参数直接存储在 model_services 数组的模型对象中,
+ 这个方法主要用于确保配置文件存在和格式正确
+ """
try:
- # 加载当前配置
config = self.load_config()
- need_update = False
+
+ # 确保是 v2.0 格式
+ if not self._is_v2_config(config):
+ self._log("[config.json] 警告: 检测到旧版本配置,请手动创建新的配置文件或使用默认配置")
+ return
+
+ # v2.0 格式中,参数已经在各个服务的模型列表中,无需额外验证
+ # 如果需要补全缺失的服务或模型参数,应该在服务商管理API中处理
+
+ except Exception as e:
+ self._log(f"[config.json] 验证模型参数配置时出错: {str(e)}")
- # 检查LLM配置,并补全新增提供商
- if "llm" in config:
- if "providers" not in config["llm"]:
- config["llm"]["providers"] = {}
- need_update = True
- # 新增提供商预设
- for p in ["302ai", "ollama"]:
- if p not in config["llm"]["providers"]:
- config["llm"]["providers"][p] = self.default_config["llm"]["providers"][p]
- need_update = True
- self._log(f"为LLM添加默认提供商: {p}")
- for provider_name, provider_config in config["llm"]["providers"].items():
- # 检查并添加缺失的temperature参数
- if "temperature" not in provider_config:
- provider_config["temperature"] = 0.7
- need_update = True
- self._log(f"为LLM提供商 {provider_name} 添加默认temperature参数: 0.7")
- # 检查并添加缺失的max_tokens参数
- if "max_tokens" not in provider_config:
- provider_config["max_tokens"] = 1000
- need_update = True
- self._log(f"为LLM提供商 {provider_name} 添加默认max_tokens参数: 1000")
+ # --- API Key 安全相关方法(方案A)---
+
+ @staticmethod
+ def mask_api_key(api_key: str) -> str:
+ """
+ 掩码API Key,只显示首尾部分
+ 用于前端安全显示,防止API Key在Network中明文可见
+
+ 参数:
+ api_key: 明文API Key
+
+ 返回:
+ str: 掩码后的API Key
+
+ 示例:
+ - sk-abc123xyz789 -> sk-abc***xyz789
+ - 短Key (< 8字符) -> ***
+ - 空字符串 -> ""
+ """
+ if not api_key:
+ return ""
+ if len(api_key) < 8:
+ return "***"
+ # 显示前6个字符和后4个字符
+ return f"{api_key[:6]}***{api_key[-4:]}"
+
+ def get_llm_config_masked(self):
+ """
+ 获取LLM配置(API Key掩码版本)
+ 用于前端显示,不暴露完整API Key
+
+ 返回:
+ Dict: LLM配置,api_key字段被掩码
+ """
+ config = self.get_llm_config()
+
+ if 'api_key' in config:
+ # 掩码API Key
+ config['api_key_masked'] = self.mask_api_key(config['api_key'])
+ config['api_key_exists'] = bool(config['api_key'])
+ # 移除明文API Key
+ del config['api_key']
+
+ # 处理所有providers的API Key
+ if 'providers' in config:
+ for provider_name, provider_config in config['providers'].items():
+ if 'api_key' in provider_config:
+ provider_config['api_key_masked'] = self.mask_api_key(provider_config['api_key'])
+ provider_config['api_key_exists'] = bool(provider_config['api_key'])
+ del provider_config['api_key']
+
+ return config
+
+ def get_vision_config_masked(self):
+ """
+ 获取视觉模型配置(API Key掩码版本)
+ 用于前端显示,不暴露完整API Key
+
+ 返回:
+ Dict: 视觉模型配置,api_key字段被掩码
+ """
+ config = self.get_vision_config()
+
+ if 'api_key' in config:
+ # 掩码API Key
+ config['api_key_masked'] = self.mask_api_key(config['api_key'])
+ config['api_key_exists'] = bool(config['api_key'])
+ # 移除明文API Key
+ del config['api_key']
+
+ # 处理所有providers的API Key
+ if 'providers' in config:
+ for provider_name, provider_config in config['providers'].items():
+ if 'api_key' in provider_config:
+ provider_config['api_key_masked'] = self.mask_api_key(provider_config['api_key'])
+ provider_config['api_key_exists'] = bool(provider_config['api_key'])
+ del provider_config['api_key']
+
+ return config
+
+ # --- 服务商管理方法(CRUD)---
+
+ def get_all_services(self):
+ """
+ 获取所有服务商列表
+
+ 返回:
+ List[Dict]: 服务商列表
+ """
+ config = self.load_config()
+
+ if self._is_v2_config(config):
+ return config.get('model_services', [])
+ else:
+ # v1.0不支持此功能
+ return []
+
+ def get_service(self, service_id: str):
+ """
+ 获取指定服务商的完整配置
+
+ 参数:
+ service_id: 服务商ID
+
+ 返回:
+ Dict: 服务商配置,不存在返回None
+ """
+ return self._get_service_by_id(service_id)
+
+ def create_service(self, service_type: str, name: str = "", base_url: str = "",
+ api_key: str = "", description: str = ""):
+ """
+ 创建新的服务商
+
+ 参数:
+ service_type: 服务类型 ('openai_compatible' 或 'ollama')
+ name: 服务商名称(如果为空,自动生成)
+ base_url: Base URL
+ api_key: API Key(明文存储)
+ description: 描述
+
+ 返回:
+ str: 新创建的service_id,失败返回None
+ """
+ try:
+ config = self.load_config()
+
+ if not self._is_v2_config(config):
+ self._log("创建服务商失败: 配置版本过低,请先迁移到v2.0")
+ return None
+
+ # 获取现有服务商列表
+ current_services = config.get('model_services', [])
+
+ # 生成服务商ID和名称
+ service_id, auto_name = self._generate_service_id_and_name(service_type, current_services)
+
+ # 如果用户没有提供名称,使用自动生成的名称
+ if not name:
+ name = auto_name
+
+ # 创建服务配置
+ new_service = {
+ "id": service_id,
+ "type": service_type,
+ "name": name,
+ "description": description,
+ "base_url": base_url,
+ "api_key": api_key or "",
+ "disable_thinking": True,
+ "enable_advanced_params": True,
+ "filter_thinking_output": True,
+ "llm_models": [],
+ "vlm_models": []
+ }
+
+ # Ollama特有配置
+ if service_type == "ollama":
+ new_service["auto_unload"] = True
+
+ # 添加到配置
+ if 'model_services' not in config:
+ config['model_services'] = []
+
+ config['model_services'].append(new_service)
+
+ # 保存配置
+ if self.save_config(config):
+ self._log(f"成功创建服务商: {name} (ID: {service_id})")
+ return service_id
+ else:
+ self._log(f"保存服务商配置失败: {name}")
+ return None
+
+ except Exception as e:
+ self._log(f"创建服务商异常: {str(e)}")
+ import traceback
+ traceback.print_exc()
+ return None
+
+ def _generate_service_id_and_name(self, service_type: str, current_services: list) -> tuple:
+ """
+ 生成服务商ID和默认名称
+
+ 参数:
+ service_type: 服务类型
+ current_services: 现有服务商列表
+
+ 返回:
+ tuple: (service_id, default_name)
+ """
+ import random
+
+ # 类型映射
+ type_map = {
+ "ollama": {
+ "name_prefix": "Ollama服务",
+ "id_prefix": "ollama"
+ },
+ "openai_compatible": {
+ "name_prefix": "通用服务",
+ "id_prefix": "service"
+ }
+ }
+
+ # 获取类型配置
+ type_config = type_map.get(service_type, {
+ "name_prefix": "新服务",
+ "id_prefix": service_type
+ })
+
+ name_prefix = type_config["name_prefix"]
+ id_prefix = type_config["id_prefix"]
+
+ # 收集已使用的编号
+ existing_numbers = set()
+ for service in current_services:
+ sid = service.get('id', '')
+ # 匹配格式:{id_prefix}_{数字}
+ if sid.startswith(f"{id_prefix}_"):
+ try:
+ num_str = sid.split('_')[-1]
+ if num_str.isdigit():
+ existing_numbers.add(int(num_str))
+ except:
+ pass
+
+ # 生成随机三位数(100-999),最多尝试100次
+ max_attempts = 100
+ for _ in range(max_attempts):
+ random_number = random.randint(100, 999)
+ if random_number not in existing_numbers:
+ break
+ else:
+ # 如果100次都重复,使用更大的随机数(4位数)
+ random_number = random.randint(1000, 9999)
+ while random_number in existing_numbers:
+ random_number = random.randint(1000, 9999)
+
+ # 生成ID和名称
+ service_id = f"{id_prefix}_{random_number}"
+ default_name = f"{name_prefix}-{random_number}"
+
+ return service_id, default_name
+
+ def delete_service(self, service_id: str):
+ """
+ 删除服务商
+
+ 参数:
+ service_id: 服务商ID
+
+ 返回:
+ bool: 成功返回True
+ """
+ try:
+ config = self.load_config()
+
+ if not self._is_v2_config(config):
+ self._log("删除服务商失败: 配置版本过低")
+ return False
+
+ services = config.get('model_services', [])
+
+ # 查找并删除服务
+ original_length = len(services)
+ config['model_services'] = [s for s in services if s.get('id') != service_id]
+
+ if len(config['model_services']) == original_length:
+ self._log(f"删除服务商失败: 服务商不存在 (ID: {service_id})")
+ return False
+
+ # 如果删除的是当前服务,清除current_services引用
+ current_services = config.get('current_services', {})
+ if current_services.get('llm') == service_id:
+ current_services['llm'] = None
+ if current_services.get('vlm') == service_id:
+ current_services['vlm'] = None
+ if current_services.get('translate') == service_id:
+ current_services['translate'] = None
+
+ # 保存配置
+ if self.save_config(config):
+ self._log(f"成功删除服务商: {service_id}")
+ return True
+ else:
+ self._log(f"保存配置失败")
+ return False
+
+ except Exception as e:
+ self._log(f"删除服务商异常: {str(e)}")
+ import traceback
+ traceback.print_exc()
+ return False
- # 检查并添加缺失的top_p参数
- if "top_p" not in provider_config:
- provider_config["top_p"] = 0.9
- need_update = True
- self._log(f"为LLM提供商 {provider_name} 添加默认top_p参数: 0.9")
+ def update_services_order(self, service_ids: list) -> bool:
+ """
+ 更新服务商顺序
- # 检查并添加缺失的auto_unload参数(仅Ollama)
- if provider_name == "ollama" and "auto_unload" not in provider_config:
- provider_config["auto_unload"] = True
- need_update = True
- self._log(f"为LLM提供商 {provider_name} 添加默认auto_unload参数: True")
+ 参数:
+ service_ids: 服务商ID列表,按新顺序排列
- # 检查视觉模型配置,并补全新增提供商
- if "vlm" in config:
- if "providers" not in config["vlm"]:
- config["vlm"]["providers"] = {}
- need_update = True
- # 新增提供商预设
- for p in ["302ai", "ollama"]:
- if p not in config["vlm"]["providers"]:
- config["vlm"]["providers"][p] = self.default_config["vlm"]["providers"][p]
- need_update = True
- self._log(f"为视觉模型添加默认提供商: {p}")
- for provider_name, provider_config in config["vlm"]["providers"].items():
- # 检查并添加缺失的temperature参数
- if "temperature" not in provider_config:
- provider_config["temperature"] = 0.7
- need_update = True
- self._log(f"为视觉模型提供商 {provider_name} 添加默认temperature参数: 0.7")
+ 返回:
+ bool: 成功返回True
+ """
+ try:
+ config = self.load_config()
- # 检查并添加缺失的max_tokens参数
- if "max_tokens" not in provider_config:
- provider_config["max_tokens"] = 500
- need_update = True
- self._log(f"为视觉模型提供商 {provider_name} 添加默认max_tokens参数: 500")
+ if not self._is_v2_config(config):
+ self._log("更新服务商顺序失败: 配置版本过低")
+ return False
- # 检查并添加缺失的top_p参数
- if "top_p" not in provider_config:
- provider_config["top_p"] = 0.9
- need_update = True
- self._log(f"为视觉模型提供商 {provider_name} 添加默认top_p参数: 0.9")
+ services = config.get('model_services', [])
- # 检查并添加缺失的auto_unload参数(仅Ollama)
- if provider_name == "ollama" and "auto_unload" not in provider_config:
- provider_config["auto_unload"] = True
- need_update = True
- self._log(f"为视觉模型提供商 {provider_name} 添加默认auto_unload参数: True")
+ # 创建ID到服务的映射
+ service_map = {s.get('id'): s for s in services}
- # 如果有更新,保存配置
- if need_update:
- self.save_config(config)
- self._log("已完成模型参数配置的验证和修复")
+ # 验证所有service_id都存在
+ for service_id in service_ids:
+ if service_id not in service_map:
+ self._log(f"更新服务商顺序失败: 服务商不存在 (ID: {service_id})")
+ return False
+
+ # 按新顺序重建services数组
+ new_services = []
+ for service_id in service_ids:
+ new_services.append(service_map[service_id])
+
+ # 添加未在service_ids中的服务(防止遗漏)
+ for service_id, service in service_map.items():
+ if service_id not in service_ids:
+ new_services.append(service)
+ self._log(f"警告: 服务商 {service_id} 不在新顺序中,已追加到末尾")
+
+ config['model_services'] = new_services
+
+ # 保存配置
+ if self.save_config(config):
+ self._log(f"成功更新服务商顺序: {', '.join(service_ids)}")
+ return True
+ else:
+ self._log("保存配置失败")
+ return False
except Exception as e:
- self._log(f"验证和修复模型参数时出错: {str(e)}")
+ self._log(f"更新服务商顺序异常: {str(e)}")
+ import traceback
+ traceback.print_exc()
+ return False
+
+
+ def update_service(self, service_id: str, **kwargs):
+ """
+ 更新服务商配置
+
+ 参数:
+ service_id: 服务商ID
+ **kwargs: 要更新的字段(name, description, base_url, api_key, auto_unload等)
+
+ 返回:
+ bool: 成功返回True
+ """
+ try:
+ config = self.load_config()
+
+ if not self._is_v2_config(config):
+ self._log("更新服务商失败: 配置版本过低")
+ return False
+
+ # 查找服务
+ services = config.get('model_services', [])
+ service = None
+ service_index = -1
+
+ for i, s in enumerate(services):
+ if s.get('id') == service_id:
+ service = s
+ service_index = i
+ break
+
+ if not service:
+ self._log(f"更新服务商失败: 服务商不存在 (ID: {service_id})")
+ return False
+
+ # 更新字段
+ if 'name' in kwargs:
+ service['name'] = kwargs['name']
+
+ if 'description' in kwargs:
+ service['description'] = kwargs['description']
+
+ if 'base_url' in kwargs:
+ service['base_url'] = kwargs['base_url']
+
+ if 'api_key' in kwargs:
+ # 直接使用明文API Key
+ service['api_key'] = kwargs['api_key'] or ""
+
+ if 'auto_unload' in kwargs and service.get('type') == 'ollama':
+ service['auto_unload'] = kwargs['auto_unload']
+
+ if 'disable_thinking' in kwargs:
+ service['disable_thinking'] = kwargs['disable_thinking']
+
+ if 'enable_advanced_params' in kwargs:
+ service['enable_advanced_params'] = kwargs['enable_advanced_params']
+
+ if 'filter_thinking_output' in kwargs:
+ service['filter_thinking_output'] = kwargs['filter_thinking_output']
+
+ # 更新services数组
+ config['model_services'][service_index] = service
+
+ # 保存配置
+ if self.save_config(config):
+ self._log(f"成功更新服务商: {service_id}")
+ return True
+ else:
+ self._log(f"保存配置失败")
+ return False
+
+ except Exception as e:
+ self._log(f"更新服务商异常: {str(e)}")
+ import traceback
+ traceback.print_exc()
+ return False
+
+ def set_current_service(self, service_type: str, service_id: str, model_name: str = None):
+ """
+ 设置当前使用的服务商和模型
+
+ 参数:
+ service_type: 服务类型 ('llm', 'vlm', 或 'translate')
+ service_id: 服务商ID
+ model_name: 模型名称(可选,如果不提供则使用该服务的默认模型或第一个模型)
+
+ 返回:
+ bool: 成功返回True
+ """
+ try:
+ config = self.load_config()
+
+ if not self._is_v2_config(config):
+ self._log("设置当前服务商失败: 配置版本过低")
+ return False
+
+ # ---百度翻译特殊处理---
+ # 百度翻译使用独立的baidu_translate配置,不在model_services中
+ if service_id == 'baidu':
+ # 百度翻译支持LLM服务类型(旧兼容)和translate服务类型
+ if service_type not in ['llm', 'translate']:
+ self._log(f"设置当前服务商失败: 百度翻译不支持{service_type}服务类型")
+ return False
+
+ # 确保baidu_translate配置存在
+ if 'baidu_translate' not in config:
+ config['baidu_translate'] = {"app_id": "", "secret_key": ""}
+
+ # 确保current_services结构存在
+ if 'current_services' not in config:
+ config['current_services'] = {}
+
+ # 设置百度为当前服务(无模型概念)
+ config['current_services'][service_type] = {
+ "service": "baidu",
+ "model": ""
+ }
+
+ # 保存配置
+ if self.save_config(config):
+ self._log(f"当前服务商已切换: 百度翻译 ({service_type})")
+ return True
+ else:
+ self._log("设置当前服务商失败: 保存配置失败")
+ return False
+
+ # ---其他服务:验证服务存在---
+ service = self._get_service_by_id(service_id)
+ if not service:
+ self._log(f"设置当前服务商失败: 服务商不存在 (ID: {service_id})")
+ return False
+
+ # 根据service_type确定模型列表字段
+ model_list_key = f'{service_type}_models'
+ if service_type == 'translate':
+ model_list_key = 'llm_models'
+
+ # 如果提供了model_name,验证模型是否存在
+ if model_name:
+ model_list = service.get(model_list_key, [])
+ model_exists = any(m.get('name') == model_name for m in model_list)
+
+ if not model_exists:
+ self._log(f"设置当前服务商失败: 模型不存在 (模型: {model_name}, 服务: {service_id})")
+ return False
+
+ # 确保current_services结构存在
+ if 'current_services' not in config:
+ config['current_services'] = {}
+
+ # 获取当前服务信息(兼容旧格式)
+ current_info = config['current_services'].get(service_type)
+
+ # 设置新格式的current_services
+ if model_name:
+ # 明确指定了模型
+ config['current_services'][service_type] = {
+ "service": service_id,
+ "model": model_name
+ }
+ else:
+ # 未指定模型,使用默认模型或第一个模型
+ model_list = service.get(model_list_key, [])
+
+ # 如果是百度服务,没有模型
+ if service.get('id') == 'baidu' or service.get('type') == 'baidu':
+ config['current_services'][service_type] = {
+ "service": service_id,
+ "model": ""
+ }
+ else:
+ # 查找默认模型或第一个模型
+ default_model = next((m for m in model_list if m.get('is_default')),
+ model_list[0] if model_list else None)
+
+ if default_model:
+ config['current_services'][service_type] = {
+ "service": service_id,
+ "model": default_model.get('name', '')
+ }
+ else:
+ # 没有模型,只设置服务
+ config['current_services'][service_type] = {
+ "service": service_id,
+ "model": ""
+ }
+
+ # 保存配置
+ if self.save_config(config):
+ service_name = service.get('name', service_id)
+ log_model = f" | 模型:{model_name}" if model_name else ""
+ self._log(f"成功设置当前{service_type}服务: {service_name}{log_model}")
+ return True
+ else:
+ self._log(f"保存配置失败")
+ return False
+
+ except Exception as e:
+ import traceback
+ traceback.print_exc()
+ return False
+
+ # --- 模型管理方法 ---
+
+ def add_model_to_service(self, service_id: str, model_type: str, model_name: str,
+ temperature: float = 0.7, top_p: float = 0.9, max_tokens: int = 512):
+ """添加模型到服务商"""
+ try:
+ config = self.load_config()
+ services = config.get('model_services', [])
+
+ for i, service in enumerate(services):
+ if service.get('id') == service_id:
+ model_list_key = 'llm_models' if model_type == 'llm' else 'vlm_models'
+
+ if model_list_key not in service:
+ service[model_list_key] = []
+
+ # 检查是否已存在
+ if any(m.get('name') == model_name for m in service[model_list_key]):
+ self._log(f"模型已存在: {model_name}")
+ return False
+
+ # 添加新模型
+ new_model = {
+ "name": model_name,
+ "is_default": len(service[model_list_key]) == 0,
+ "temperature": temperature,
+ "top_p": top_p,
+ "max_tokens": max_tokens
+ }
+ service[model_list_key].append(new_model)
+ config['model_services'][i] = service
+
+ if self.save_config(config):
+ self._log(f"成功添加模型: {model_name}")
+ return True
+ return False
+
+ self._log(f"服务商不存在: {service_id}")
+ return False
+ except Exception as e:
+ self._log(f"添加模型异常: {str(e)}")
+ return False
+
+ def delete_model_from_service(self, service_id: str, model_type: str, model_name: str):
+ """从服务商删除模型"""
+ try:
+ config = self.load_config()
+ services = config.get('model_services', [])
+
+ for i, service in enumerate(services):
+ if service.get('id') == service_id:
+ model_list_key = 'llm_models' if model_type == 'llm' else 'vlm_models'
+
+ if model_list_key not in service:
+ return False
+
+ original_length = len(service[model_list_key])
+ service[model_list_key] = [m for m in service[model_list_key] if m.get('name') != model_name]
+
+ if len(service[model_list_key]) == original_length:
+ self._log(f"模型不存在: {model_name}")
+ return False
+
+ # 如果删除的是默认模型,设置第一个为默认
+ if len(service[model_list_key]) > 0:
+ if not any(m.get('is_default') for m in service[model_list_key]):
+ service[model_list_key][0]['is_default'] = True
+
+ config['model_services'][i] = service
+
+ if self.save_config(config):
+ self._log(f"成功删除模型: {model_name}")
+ return True
+ return False
+
+ self._log(f"服务商不存在: {service_id}")
+ return False
+ except Exception as e:
+ self._log(f"删除模型异常: {str(e)}")
+ return False
+
+ def set_default_model(self, service_id: str, model_type: str, model_name: str):
+ """设置默认模型"""
+ try:
+ config = self.load_config()
+ services = config.get('model_services', [])
+
+ for i, service in enumerate(services):
+ if service.get('id') == service_id:
+ model_list_key = 'llm_models' if model_type == 'llm' else 'vlm_models'
+
+ if model_list_key not in service:
+ return False
+
+ found = False
+ for model in service[model_list_key]:
+ if model.get('name') == model_name:
+ model['is_default'] = True
+ found = True
+ else:
+ model['is_default'] = False
+
+ if not found:
+ self._log(f"模型不存在: {model_name}")
+ return False
+
+ config['model_services'][i] = service
+
+ if self.save_config(config):
+ self._log(f"成功设置默认模型: {model_name}")
+ return True
+ return False
+
+ self._log(f"服务商不存在: {service_id}")
+ return False
+ except Exception as e:
+ self._log(f"设置默认模型异常: {str(e)}")
+ return False
+
+ def update_model_order(self, service_id: str, model_type: str, model_names: list):
+ """更新模型顺序"""
+ try:
+ config = self.load_config()
+ services = config.get('model_services', [])
+
+ for i, service in enumerate(services):
+ if service.get('id') == service_id:
+ model_list_key = 'llm_models' if model_type == 'llm' else 'vlm_models'
+
+ if model_list_key not in service:
+ return False
+
+ # 创建模型字典
+ model_dict = {m.get('name'): m for m in service[model_list_key]}
+
+ # 按新顺序重新排列
+ new_model_list = []
+ for name in model_names:
+ if name in model_dict:
+ new_model_list.append(model_dict[name])
+
+ service[model_list_key] = new_model_list
+ config['model_services'][i] = service
+
+ if self.save_config(config):
+ self._log(f"成功更新模型顺序")
+ return True
+ return False
+
+ self._log(f"服务商不存在: {service_id}")
+ return False
+ except Exception as e:
+ self._log(f"更新模型顺序异常: {str(e)}")
+ return False
+
+ def update_model_parameter(self, service_id: str, model_type: str, model_name: str,
+ parameter_name: str, parameter_value):
+ """更新模型参数"""
+ try:
+ config = self.load_config()
+ services = config.get('model_services', [])
+
+ for i, service in enumerate(services):
+ if service.get('id') == service_id:
+ model_list_key = 'llm_models' if model_type == 'llm' else 'vlm_models'
+
+ if model_list_key not in service:
+ return False
+
+ # 查找模型并更新参数
+ for model in service[model_list_key]:
+ if model.get('name') == model_name:
+ model[parameter_name] = parameter_value
+ config['model_services'][i] = service
+
+ if self.save_config(config):
+ self._log(f"成功更新模型参数: {model_name}.{parameter_name} = {parameter_value}")
+ return True
+ return False
+
+ self._log(f"模型不存在: {model_name}")
+ return False
+
+ self._log(f"服务商不存在: {service_id}")
+ return False
+ except Exception as e:
+ self._log(f"更新模型参数异常: {str(e)}")
+ return False
# 创建全局配置管理器实例
config_manager = ConfigManager()
\ No newline at end of file
diff --git a/js/css/assistant.css b/js/css/assistant.css
index b9cfa89..b544da5 100644
--- a/js/css/assistant.css
+++ b/js/css/assistant.css
@@ -3,373 +3,276 @@
* 包含所有组件样式和动画定义
*/
-/* ======== 提示词小助手样式 ======== */
-/* 提示词小助手容器 */
-.prompt-assistant-container {
+/* ==========================================================================
+ 1. 核心容器组件 (Assistant Container)
+ ========================================================================== */
+
+/* 通用容器基础样式 */
+.assistant-container-common {
+ /* 定位与布局 */
position: absolute;
- pointer-events: all;
- display: none;
+ display: flex;
align-items: center;
justify-content: flex-end;
- /* 始终右对齐 */
+ /* 默认对齐 */
+ width: auto !important;
+ height: auto !important;
+ min-width: 0;
+ min-height: 0;
+ max-width: none;
+ box-sizing: border-box;
+
+ /* 视觉样式 */
padding: 2px;
- background-color: color-mix(in srgb, var(--bg-color), transparent 20%);
+ /* background-color: color-mix(in srgb, var(--bg-color), transparent 50%); */
+ background-color: color-mix(in srgb, var(--node-component-header-surface), transparent 60%);
border: 1px solid color-mix(in srgb, var(--p-panel-border-color), transparent 60%);
- border-radius: 6px;
+ border-radius: 8px;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
+
+ /* 效果 */
z-index: 9999;
opacity: 0.95;
backdrop-filter: blur(4px);
- transform-origin: bottom right;
+
+ /* 交互与状态*/
user-select: none;
- will-change: width, opacity, background-color, border-color, backdrop-filter, box-shadow, right, left;
- width: auto;
- height: 26px !important;
- max-width: fit-content;
- box-sizing: border-box !important;
overflow: hidden;
- /* 防止内容溢出 */
- box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
+ pointer-events: all;
+ cursor: default;
+ /* 移除抓手光标效果 */
+
+ /* 过渡动画 */
+ transition: width 0.3s cubic-bezier(0.25, 1, 0.5, 1),
+ height 0.3s cubic-bezier(0.25, 1, 0.5, 1),
+ opacity 0.3s cubic-bezier(0.25, 1, 0.5, 1),
+ background-color 0.3s,
+ border-color 0.3s,
+ backdrop-filter 0.3s,
+ box-shadow 0.3s;
}
-/* 折叠状态的小助手容器 */
-.prompt-assistant-container.collapsed {
+/* 兼容性修复:覆盖 PrimeVue 或其他样式影响?*/
+.assistant-container-common * {
+ box-sizing: border-box;
+}
+
+/* ==========================================================================
+ 2. 状态样式 (States)
+ ========================================================================== */
+
+/* 展开状态 */
+.assistant-container-common.expanded {
+ /* 展开状态下使用JS计算的动态宽高 */
+ width: var(--expanded-width, 28px) !important;
+ height: var(--expanded-height, 28px) !important;
+}
+
+/* 折叠状态 */
+.assistant-container-common.collapsed {
+ /* 折叠状态下的固定尺寸*/
width: 28px !important;
- overflow: hidden;
- justify-content: flex-end;
- pointer-events: none !important;
- /* 折叠时穿透鼠标事件 */
+ height: 28px !important;
+
+ /* 隐形模式 */
background-color: transparent !important;
border-color: transparent !important;
backdrop-filter: none !important;
box-shadow: none !important;
-}
-/* 折叠状态指示器图标 - 基础样式 */
-.prompt-assistant-indicator {
- position: absolute;
- top: 50%;
- right: 5px;
- transform: translateY(-50%) scale(0);
- width: 18px;
- height: 18px;
- opacity: 0;
- z-index: 2;
- pointer-events: none;
-}
-
-/* 指示器图标显示动画 */
-.prompt-assistant-container.collapsed .prompt-assistant-indicator {
- opacity: var(--assistant-icon-opacity, 0.3);
- transform: translateY(-50%) scale(0.8);
- filter: drop-shadow(0 0 2px rgba(255, 255, 255, 0.6)) drop-shadow(0 0 4px rgba(100, 200, 255, 0.5));
- transition: opacity 0.3s cubic-bezier(.49, 1.98, .67, .83) 0.25s,
- transform 0.4s cubic-bezier(.78, 1.76, .68, .96) 0.25s;
-}
-
-/* 指示器图标创建时的动画 */
-.prompt-assistant-container.collapsed .prompt-assistant-indicator.animate-creation {
- animation: indicator-creation 0.4s cubic-bezier(.78, 1.76, .68, .96);
-}
-
-/* 指示器图标隐藏动画 */
-.prompt-assistant-container:not(.collapsed) .prompt-assistant-indicator {
- opacity: 0;
- transform: translateY(-50%) scale(0);
- transition: opacity 0s ease-in,
- transform 0.3s ease-in;
-}
-
-/* 内部内容容器 - 使用common.css中定义的固定内容样式 */
-.prompt-assistant-inner,
-.image-assistant-inner {
- /* 使用common.css中定义的固定内容样式 */
- display: flex;
- gap: 2px;
- position: relative;
- right: 0;
- justify-content: flex-end;
- width: auto;
- flex-grow: 1;
- transition: none;
-}
-
-/* 折叠状态下的内部容器 */
-.prompt-assistant-container.collapsed .prompt-assistant-inner {
+ /* 交互 */
pointer-events: none !important;
+ /* 允许点击穿透 */
}
-/* 悬停区域 - 用于检测鼠标悬停 */
-.prompt-assistant-hover-area {
- position: absolute;
- top: 2px;
- right: 2px;
- width: 80%;
- height: 90%;
- z-index: 1;
- /* 降低z-index,确保在按钮下方 */
- cursor: pointer;
- pointer-events: auto;
- /* 初始状态隐藏 */
- display: none;
- background-color: transparent;
-}
-
-/* 折叠状态下的悬停区域 */
-.prompt-assistant-container.collapsed .prompt-assistant-hover-area {
- display: block;
-}
-
-/* ======== 图像小助手样式 ======== */
-
-/* 图像小助手容器 */
-.image-assistant-container {
- position: fixed;
- pointer-events: all;
- display: none;
- align-items: center;
- justify-content: flex-start;
- /* 改为左对齐 */
- padding: 2px;
- background-color: color-mix(in srgb, var(--bg-color), transparent 20%);
- border: 1px solid color-mix(in srgb, var(--p-panel-border-color), transparent 40%);
- border-radius: 8px;
- z-index: 9999;
- opacity: 0.95;
- backdrop-filter: blur(4px);
- transform-origin: bottom left;
- /* 改为左下角 */
- user-select: none;
- will-change: width, opacity, background-color, border-color, backdrop-filter, box-shadow;
- width: auto;
- height: 24px !important;
- max-width: fit-content;
- box-sizing: border-box !important;
- overflow: hidden;
- transform: scale(var(--assistant-scale, 1));
- box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
-}
-
-/* 折叠状态的图像小助手容器 */
-.image-assistant-container.collapsed {
- width: 28px !important;
- overflow: hidden;
- justify-content: flex-start;
- /* 改为左对齐 */
- background-color: transparent;
- border-color: transparent;
- backdrop-filter: blur(0px);
- box-shadow: none;
-}
-
-/* 折叠状态指示器图标 - 基础样式 */
-.image-assistant-indicator {
- position: absolute;
- top: 50%;
- left: 5px;
- /* 改为左侧 */
- transform: translateY(-50%) scale(0);
- width: 18px;
- height: 18px;
- opacity: 0;
- z-index: 2;
- pointer-events: none;
-}
-
-/* 指示器图标显示动画 */
-.image-assistant-container.collapsed .image-assistant-indicator {
- opacity: var(--assistant-icon-opacity, 0.3);
- transform: translateY(-50%) scale(0.8);
- filter: drop-shadow(0 0 2px rgba(255, 255, 255, 0.6)) drop-shadow(0 0 4px rgba(100, 200, 255, 0.5));
- transition: opacity 0.3s cubic-bezier(.49, 1.98, .67, .83) 0.25s,
- transform 0.4s cubic-bezier(.78, 1.76, .68, .96) 0.25s;
-}
-
-/* 指示器图标创建时的动画 */
-.image-assistant-container.collapsed .image-assistant-indicator.animate-creation {
- animation: indicator-creation 0.4s cubic-bezier(.78, 1.76, .68, .96);
-}
-
-/* 指示器创建动画 - 透明度过冲效果 */
-@keyframes indicator-creation {
- 0% {
- opacity: 0;
- transform: translateY(-50%) scale(0);
- filter: drop-shadow(0 0 0px rgba(255, 255, 255, 0)) drop-shadow(0 0 0px rgba(100, 200, 255, 0));
- }
-
- 65% {
- opacity: calc(var(--assistant-icon-opacity) + 0.1);
- transform: translateY(-50%) scale(0.8);
- filter: drop-shadow(0 0 2px rgba(255, 255, 255, 0.6)) drop-shadow(0 0 4px rgba(100, 200, 255, 0.5));
- }
-
- 100% {
- opacity: var(--assistant-icon-opacity);
- transform: translateY(-50%) scale(0.8);
- filter: drop-shadow(0 0 2px rgba(255, 255, 255, 0.6)) drop-shadow(0 0 4px rgba(100, 200, 255, 0.5));
- }
-}
-
-/* 指示器图标隐藏动画 */
-.image-assistant-container:not(.collapsed) .image-assistant-indicator {
- opacity: 0;
- transform: translateY(-50%) scale(0);
- transition: opacity 0s ease-in,
- transform 0.3s ease-in;
-}
-
-/* 悬停区域 - 用于检测鼠标悬停 */
-.image-assistant-hover-area {
- position: absolute;
- top: 0;
- left: 0;
- /* 改为左侧 */
- width: 28px;
- height: 100%;
- z-index: 1;
- cursor: pointer;
- pointer-events: auto;
- display: none;
- background-color: transparent;
-}
-
-/* 折叠状态下的悬停区域 */
-.image-assistant-container.collapsed .image-assistant-hover-area {
- display: block;
-}
-
-/* 折叠状态下按钮的透明度过渡 */
-.image-assistant-container.collapsed .image-assistant-button {
- transition: opacity 0.3s cubic-bezier(0.11, 0, 0.5, 0);
- opacity: 0;
-}
-
-/* 折叠状态下隐藏分割线 */
-.image-assistant-container.collapsed .image-assistant-divider {
- opacity: 0 !important;
- pointer-events: none !important;
- transition: opacity 0.3s cubic-bezier(0.11, 0, 0.5, 0);
-}
-
-/* 图像小助手过渡效果 */
-.image-assistant-transition {
- transition: width 0.3s cubic-bezier(0.25, 1, 0.5, 1),
- opacity 0.3s cubic-bezier(0.25, 1, 0.5, 1),
- border-color 0.5s ease-out,
- backdrop-filter 0.5s ease-out,
- box-shadow 0.2s ease-out,
- background-color 0.5s ease-out;
-}
-
-/* 图像小助手显示动画类 */
-.image-assistant-show {
- display: flex !important;
- box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
-}
-
-/* ======== 共用组件样式 ======== */
-
-/* 自动翻译指示器 - 黄色图标样式 */
-.prompt-assistant-container.auto-translate-enabled .prompt-assistant-indicator {
- color: rgb(246, 252, 163);
- filter: drop-shadow(0 0 2px rgba(247, 187, 6, 0.755)) drop-shadow(0 0 4px rgba(255, 255, 0, 0.7));
-}
-
-.prompt-assistant-container.auto-translate-enabled.collapsed .prompt-assistant-indicator {
- opacity: var(--assistant-icon-opacity, 0.5);
-}
-
-/* 按钮分割线样式 */
-.prompt-assistant-divider,
-.image-assistant-divider {
- width: 1px;
- height: 10px;
- background-color: var(--p-content-border-color);
- margin: 0 2px;
- align-self: center;
- flex-shrink: 0;
- transform: translateZ(0);
- border-radius: 0.5px;
-}
-
-/* 过渡效果类 */
-.prompt-assistant-transition {
- transition: width 0.3s cubic-bezier(0.25, 1, 0.5, 1),
- opacity 0.3s cubic-bezier(0.25, 1, 0.5, 1),
- border-color 0.5s ease-out,
- backdrop-filter 0.5s ease-out,
- box-shadow 0.2s ease-out,
- background-color 0.5s ease-out,
- right 0.2s ease-out,
- left 0.2s ease-out;
-}
-
-/* 显示动画类 - 直接显示,无创建动画 */
+/* 显示/隐藏辅助类 */
.assistant-show {
display: flex !important;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
}
-/* 状态提示的入场和退场动画类 */
-.statustip-show {
- animation: tipScaleIn 0.1s ease-out forwards !important;
+/* ==========================================================================
+ 3. 内部元素 (Internal Elements)
+ ========================================================================== */
+
+/* 悬停检测区域 */
+.assistant-hover-area {
+ position: absolute;
+ top: -10px;
+ right: -10px;
+ bottom: -10px;
+ left: -10px;
+ z-index: -1;
+ pointer-events: auto;
+ /* 确保能接收鼠标事件 */
+ background: transparent;
}
-.statustip-hide {
- animation: tipFloatUp 0.4s ease-out forwards !important;
-}
-
-/* 按钮样式 */
-.prompt-assistant-button,
-.image-assistant-button {
- display: inline-flex;
- align-items: center;
- justify-content: center;
+/* 指示器图标 (折叠时显示) */
+.assistant-indicator {
+ position: absolute;
width: 18px;
height: 18px;
- cursor: pointer;
- opacity: 0.8;
- border: none;
- padding: 0;
- margin: 0 1px;
- background-color: transparent;
- transition: transform 0.15s, opacity 0.2s, background-color 0.2s;
- flex-shrink: 0;
- border-radius: 3px;
- color: var(--p-dialog-color);
- position: relative;
- /* 确保按钮相对定位 */
+ z-index: 2;
+ pointer-events: none;
+ opacity: 0;
+ /* transform 仅用于缩放动画 */
+ transform: scale(1);
+ transition: opacity 0.45s cubic-bezier(0.34, 1.56, 0.64, 1), transform 0.45s cubic-bezier(0.34, 1.56, 0.64, 1);
}
-/* 展开状态下恢复按钮的透明度 */
-.prompt-assistant-container:not(.collapsed) .prompt-assistant-button {
- opacity: 0.8;
+/* 状态下的指示器表现 */
+.assistant-container-common.collapsed .assistant-indicator {
+ opacity: var(--assistant-icon-opacity, 0.8);
+ pointer-events: auto;
+ filter: drop-shadow(0 0 2px rgba(255, 255, 255, 0.6)) drop-shadow(0 0 4px rgba(100, 200, 255, 0.5));
}
-/* 折叠状态下隐藏并禁用按钮 */
-.prompt-assistant-container.collapsed .prompt-assistant-button,
-.prompt-assistant-container.collapsed .prompt-assistant-button.button-active,
-.prompt-assistant-container.collapsed .prompt-assistant-button.button-processing {
- opacity: 0 !important;
- pointer-events: none !important;
- transition: opacity 0.4s ease-out, pointer-events 0s 0.4s;
+.assistant-container-common.expanded .assistant-indicator {
+ opacity: 0;
+ transform: scale(0);
}
-/* 折叠状态下隐藏分割线 */
-.prompt-assistant-container.collapsed .prompt-assistant-divider {
- opacity: 0 !important;
- pointer-events: none !important;
- transition: opacity 0.4s ease-out;
+/* ---指示器布局定位:固定在折叠状态的中心位置,不随容器尺寸变化--- */
+/* 折叠状态容器尺寸为 28x28px,指示器 18x18px,居中偏移 = (28-18)/2 = 5px */
+
+/* 左上角布局:指示器在左上角 */
+.layout-top-left-h .assistant-indicator,
+.layout-top-left-v .assistant-indicator {
+ top: 5px;
+ left: 5px;
}
-/* SVG图标样式 */
+/* 中上布局:指示器水平居中,垂直靠上 */
+.layout-top-center-h .assistant-indicator {
+ top: 5px;
+ left: 50%;
+ margin-left: -9px;
+ /* 18px / 2 */
+}
+
+/* 右上角布局:指示器在右上角 */
+.layout-top-right-h .assistant-indicator,
+.layout-top-right-v .assistant-indicator {
+ top: 5px;
+ right: 5px;
+}
+
+/* 右中布局:指示器垂直居中,水平靠右 */
+.layout-right-center-v .assistant-indicator {
+ top: 50%;
+ right: 5px;
+ margin-top: -9px;
+}
+
+/* 右下角布局:指示器在右下角 */
+.layout-bottom-right-h .assistant-indicator,
+.layout-bottom-right-v .assistant-indicator {
+ bottom: 5px;
+ right: 5px;
+}
+
+/* 中下布局:指示器水平居中,垂直靠下 */
+.layout-bottom-center-h .assistant-indicator {
+ bottom: 5px;
+ left: 50%;
+ margin-left: -9px;
+}
+
+/* 左下角布局:指示器在左下角 */
+.layout-bottom-left-h .assistant-indicator,
+.layout-bottom-left-v .assistant-indicator {
+ bottom: 5px;
+ left: 5px;
+}
+
+/* 左中布局:指示器垂直居中,水平靠上?*/
+.layout-left-center-v .assistant-indicator {
+ top: 50%;
+ left: 5px;
+ margin-top: -9px;
+}
+
+/* 内容区域 */
+.assistant-content {
+ display: flex;
+ align-items: center;
+ gap: 0;
+ width: 100%;
+ height: 100%;
+ cursor: default;
+ /* 确保非按钮区域使用默认*/
+ transition: opacity 0.3s ease;
+}
+
+/* 状态下的内容表现 */
+.assistant-container-common.collapsed .assistant-content {
+ opacity: 0;
+ pointer-events: none;
+}
+
+.assistant-container-common.expanded .assistant-content {
+ opacity: 1;
+ pointer-events: auto;
+}
+
+/* 内容布局方向 */
+.assistant-content.flex-row {
+ flex-direction: row;
+}
+
+.assistant-content.flex-col {
+ flex-direction: column;
+}
+
+/* ---内容区域对齐方式:根据容器布局位置设置--- */
+/* 注意:column-reverse 时主轴从下到上,flex-start 是底部,flex-end 是顶部 */
+
+/* 横向布局 - 左侧:内容左对齐 */
+.layout-top-left-h .assistant-content,
+.layout-bottom-left-h .assistant-content {
+ justify-content: flex-start;
+}
+
+/* 横向布局 - 右侧:内容右对齐 */
+.layout-top-right-h .assistant-content,
+.layout-bottom-right-h .assistant-content {
+ justify-content: flex-end;
+}
+
+/* 横向布局 - 居中:内容居中对齐 */
+.layout-top-center-h .assistant-content,
+.layout-bottom-center-h .assistant-content {
+ justify-content: center;
+}
+
+/* 竖向布局 - 顶部 (column):内容顶对齐 */
+.layout-top-left-v .assistant-content,
+.layout-top-right-v .assistant-content {
+ justify-content: flex-start;
+}
+
+/* 竖向布局 - 底部:内容底对齐 */
+/* .assistant-content 使用 column 方向,flex-end 是底部 */
+.layout-bottom-left-v .assistant-content,
+.layout-bottom-right-v .assistant-content {
+ justify-content: flex-end;
+}
+
+/* 竖向布局 - 垂直居中:内容居中对齐 */
+.layout-left-center-v .assistant-content,
+.layout-right-center-v .assistant-content {
+ justify-content: center;
+}
+
+/* SVG 图标通用样式 */
.svg-icon {
display: flex;
align-items: center;
justify-content: center;
- width: 18px;
- height: 18px;
+ width: 100%;
+ height: 100%;
position: relative;
z-index: 1;
}
@@ -383,20 +286,47 @@
vertical-align: middle;
}
-/* 按钮内部图标样式 */
+/* ==========================================================================
+ 4. 按钮样式 (Buttons)
+ ========================================================================== */
+
+/* 按钮基础样式 */
+.prompt-assistant-button,
+.image-assistant-button {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 18px;
+ height: 18px;
+ padding: 0;
+ margin: 2px;
+ border: none;
+ border-radius: 3px;
+
+ background-color: transparent;
+ color: var(--p-dialog-color);
+
+ cursor: pointer;
+ opacity: 0;
+ flex-shrink: 0;
+ position: relative;
+
+ /* 递进动画:基于按钮索引计算延迟 */
+ --stagger-delay: calc(var(--button-index, 0) * 30ms);
+ transition: transform 0.15s,
+ opacity 0.8s cubic-bezier(0.25, 1, 0.5, 1) var(--stagger-delay),
+ background-color 0.2s;
+}
+
+/* 按钮图标调整 */
.prompt-assistant-button .svg-icon,
.image-assistant-button .svg-icon {
width: 18px;
height: 18px;
- color: inherit;
- transition: all 0.2s;
- position: relative;
- z-index: 1;
- vertical-align: middle;
margin: 0;
}
-/* 按钮交互效果 */
+/* 按钮交互状态 */
.prompt-assistant-button:hover,
.image-assistant-button:hover {
opacity: 1;
@@ -410,15 +340,7 @@
transform: scale(0.95);
}
-/* 按钮状态类 */
-.button-processing {
- animation: buttonBreathing 1.5s infinite ease-in-out;
- background-color: rgba(100, 100, 255, 0.15) !important;
- position: relative;
- z-index: 1;
- opacity: 1 !important;
-}
-
+/* 激活状态 */
.button-active {
opacity: 1;
transform: scale(1.15);
@@ -427,6 +349,16 @@
color: var(--p-listbox-option-selected-color);
}
+/* 处理中状态 */
+.button-processing {
+ background-color: rgba(100, 100, 255, 0.15) !important;
+ opacity: 1 !important;
+ position: relative;
+ z-index: 1;
+ animation: buttonBreathing 1.5s infinite ease-in-out;
+}
+
+/* 禁用状态 */
.button-disabled {
opacity: 0.4 !important;
filter: grayscale(60%);
@@ -437,56 +369,302 @@
}
.button-disabled:hover {
- transform: none !important;
background-color: transparent !important;
box-shadow: none !important;
}
-.button-disabled .svg-icon svg {
- filter: brightness(0.9) grayscale(100%);
+/* 折叠状态下隐藏按钮 (防止残留交互) */
+.assistant-container-common.collapsed .prompt-assistant-button,
+.assistant-container-common.collapsed .image-assistant-button {
+ opacity: 0 !important;
+ pointer-events: none !important;
+ /* 折叠时立即隐藏,无延迟 */
+ transition-delay: 0s !important;
}
-/* 状态提示样式 */
+/* 展开状态下按钮递进显示 */
+.assistant-container-common.expanded .prompt-assistant-button,
+.assistant-container-common.expanded .image-assistant-button {
+ opacity: 0.8;
+ pointer-events: auto;
+}
+
+/* 分割线 - 横向布局时为竖线 */
+.prompt-assistant-divider,
+.image-assistant-divider {
+ width: 1px;
+ height: 10px;
+ background-color: var(--p-content-border-color);
+ margin: 0 2px;
+ align-self: center;
+ flex-shrink: 0;
+ border-radius: 0.5px;
+}
+
+/* 垂直布局时分割线变成横线(宽高互换) */
+.assistant-content.flex-col .prompt-assistant-divider,
+.assistant-content.flex-col .image-assistant-divider {
+ width: 10px;
+ height: 1px;
+ margin: 2px 0;
+}
+
+/* 解决 Sortable 拖动时脱离父容器的问题:使用直接类名标记 */
+.prompt-assistant-divider.divider-horizontal,
+.image-assistant-divider.divider-horizontal {
+ width: 10px;
+ height: 1px;
+ margin: 2px 0;
+}
+
+.assistant-container-common.collapsed .prompt-assistant-divider,
+.assistant-container-common.collapsed .image-assistant-divider {
+ opacity: 0 !important;
+}
+
+/* ==========================================================================
+ 5. 布局定位 (Anchor Positions)
+ ========================================================================== */
+
+/* 左上 */
+.layout-top-left-h {
+ top: 4px;
+ left: 4px;
+ transform-origin: top left;
+ flex-direction: row;
+ justify-content: flex-start;
+}
+
+.layout-top-left-v {
+ top: 4px;
+ left: 4px;
+ transform-origin: top left;
+ flex-direction: column;
+ justify-content: flex-start;
+}
+
+/* 中上 */
+.layout-top-center-h {
+ top: 4px;
+ left: 50%;
+ transform: translateX(-50%) scale(var(--assistant-scale, 1));
+ transform-origin: top center;
+ flex-direction: row;
+ justify-content: center;
+}
+
+/* 右上 */
+.layout-top-right-h {
+ top: 4px;
+ right: 4px;
+ transform-origin: top right;
+ flex-direction: row;
+ justify-content: flex-end;
+}
+
+.layout-top-right-v {
+ top: 4px;
+ right: 4px;
+ transform-origin: top right;
+ flex-direction: column;
+ justify-content: flex-start;
+}
+
+/* 右中 */
+.layout-right-center-v {
+ top: 50%;
+ right: 4px;
+ transform: translateY(-50%) scale(var(--assistant-scale, 1));
+ transform-origin: center right;
+ flex-direction: column;
+ justify-content: center;
+}
+
+/* 右下 */
+.layout-bottom-right-h {
+ bottom: 4px;
+ right: 4px;
+ transform-origin: bottom right;
+ flex-direction: row;
+ justify-content: flex-end;
+}
+
+.layout-bottom-right-v {
+ bottom: 4px;
+ right: 4px;
+ transform-origin: bottom right;
+ flex-direction: column-reverse;
+ justify-content: flex-start;
+}
+
+/* 中下 */
+.layout-bottom-center-h {
+ bottom: 4px;
+ left: 50%;
+ transform: translateX(-50%) scale(var(--assistant-scale, 1));
+ transform-origin: bottom center;
+ flex-direction: row;
+ justify-content: center;
+}
+
+/* 左下 */
+.layout-bottom-left-h {
+ bottom: 4px;
+ left: 4px;
+ transform-origin: bottom left;
+ flex-direction: row;
+ justify-content: flex-start;
+}
+
+.layout-bottom-left-v {
+ bottom: 4px;
+ left: 4px;
+ transform-origin: bottom left;
+ flex-direction: column-reverse;
+ justify-content: flex-start;
+}
+
+/* 左中 */
+.layout-left-center-v {
+ top: 50%;
+ left: 4px;
+ transform: translateY(-50%) scale(var(--assistant-scale, 1));
+ transform-origin: center left;
+ flex-direction: column;
+ justify-content: center;
+}
+
+/* 缩放支持 - 非居中布局使用,居中布局在各自类中单独设置 */
+.assistant-container-common:not(.layout-top-center-h):not(.layout-bottom-center-h):not(.layout-left-center-v):not(.layout-right-center-v) {
+ transform: scale(var(--assistant-scale, 1));
+}
+
+/* ==========================================================================
+ 6. 状态提示 (Status Tips)
+ ========================================================================== */
+
.statustip {
- transform-origin: center bottom;
- font-weight: bold;
- letter-spacing: 0.5px;
position: absolute;
padding: 4px 8px;
border-radius: 8px;
font-size: 14px;
- z-index: 10000;
- text-align: center;
+ font-weight: bold;
+ letter-spacing: 0.5px;
+
display: flex;
align-items: center;
justify-content: center;
+ text-align: center;
+ white-space: nowrap;
+
+ z-index: 10000;
pointer-events: none;
backdrop-filter: blur(8px);
- white-space: nowrap;
+ transform-origin: center bottom;
will-change: transform, opacity;
}
-/* 状态提示颜色 */
.statustip.success {
color: color-mix(in srgb, var(--p-inputtext-color), rgb(18, 255, 18) 40%) !important;
- background-color: color-mix(in srgb, var(--p-content-background), rgb(4, 58, 4)30%);
+ background-color: color-mix(in srgb, var(--p-content-background), rgb(4, 58, 4) 30%);
}
.statustip.error {
color: color-mix(in srgb, var(--p-inputtext-color), rgb(255, 0, 0) 60%) !important;
- background-color: color-mix(in srgb, var(--p-content-background), rgb(74, 12, 12)30%);
+ background-color: color-mix(in srgb, var(--p-content-background), rgb(74, 12, 12) 30%);
}
.statustip.loading {
color: color-mix(in srgb, var(--p-inputtext-color), rgb(249, 255, 69) 30%) !important;
- background-color: color-mix(in srgb, var(--p-content-background), rgb(114, 117, 22)30%);
+ background-color: color-mix(in srgb, var(--p-content-background), rgb(114, 117, 22) 30%);
}
.statustip.info {
color: color-mix(in srgb, var(--p-inputtext-color), rgb(82, 70, 255) 60%) !important;
- background-color: color-mix(in srgb, var(--p-content-background), rgb(55, 8, 113)30%);
+ background-color: color-mix(in srgb, var(--p-content-background), rgb(55, 8, 113) 30%);
}
-.prompt_assistant_container .icon_button:hover {
- background-color: color-mix(in srgb, var(--p-content-background), rgb(55, 8, 113)30%);
+/* 状态提示动画类 */
+.statustip-show {
+ animation: tipScaleIn 0.1s ease-out forwards !important;
+}
+
+.statustip-hide {
+ animation: tipFloatUp 0.4s ease-out forwards !important;
+}
+
+/* ==========================================================================
+ 7. Sortable 拖拽样式
+ ========================================================================== */
+.sortable-ghost {
+ opacity: 0.4;
+ background: rgba(255, 255, 255, 0.2);
+}
+
+/* ==========================================================================
+ 8. Vue Node 2.0 模式适配
+ ========================================================================== */
+
+/* 强制覆盖定位 */
+.vue-node-mode.assistant-container-common {
+ position: absolute !important;
+}
+
+/* Vue组件内位置微调 */
+[data-node-id] .assistant-container-common.prompt-assistant-container {
+ right: 4px;
+ bottom: 4px;
+ z-index: 10;
+}
+
+[data-node-id] .assistant-container-common.image-assistant-container {
+ left: 6px;
+ bottom: 6px;
+ z-index: 10;
+ transform: none;
+ /* Vue模式下不需要画布缩放 */
+}
+
+/* 确保FloatLabel容器定位上下文 */
+.p-floatlabel:has(.assistant-container-common),
+[class*="float"]:has(.assistant-container-common) {
+ position: relative;
+}
+
+/* 确保按钮在Vue模式下可点击 */
+.vue-node-mode .prompt-assistant-button,
+.vue-node-mode .image-assistant-button {
+ pointer-events: auto;
+}
+
+/* 确保按钮在Vue模式下可点击 */
+.vue-node-mode .prompt-assistant-button,
+.vue-node-mode .image-assistant-button {
+ pointer-events: auto;
+}
+
+/* ==========================================================================
+ 9. 动画关键帧 (Animations)
+ ========================================================================== */
+
+/* 指示器入场动画 - 从小到大、淡入 */
+@keyframes indicatorAppear {
+ 0% {
+ opacity: 0;
+ transform: scale(0.3);
+ }
+
+ 60% {
+ transform: scale(1.1);
+ }
+
+ 100% {
+ opacity: var(--assistant-icon-opacity, 0.8);
+ transform: scale(1);
+ }
+}
+
+/* 指示器初始化 - 触发入场动画 */
+.assistant-indicator.indicator-init {
+ animation: indicatorAppear 0.4s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
}
\ No newline at end of file
diff --git a/js/css/captionFrame.css b/js/css/captionFrame.css
new file mode 100644
index 0000000..63b2c89
--- /dev/null
+++ b/js/css/captionFrame.css
@@ -0,0 +1,458 @@
+/* 适配 createSettingsDialog 的自定义弹窗样式 */
+.caption-frame-dialog {
+ /* 默认尺寸,受 max 限制 */
+ width: 800px !important;
+ height: 700px !important;
+ min-width: 500px;
+ min-height: 400px;
+
+ /* 强制最大尺寸 */
+ max-width: 800px !important;
+ max-height: 800px !important;
+
+ /* 启用 Flex 布局来管理 header/content/footer */
+ display: flex !important;
+ flex-direction: column !important;
+
+ overflow: auto;
+
+ /* 确保在最上层,但低于 tooltip */
+ z-index: var(--settings-modal-z-index);
+ border: 1px solid var(--p-dialog-border-color);
+ box-shadow: 0 8px 40px rgba(0, 0, 0, 0.6) !important;
+ background-color: var(--p-dialog-background);
+}
+
+/* --- 标题栏视频信息样式 --- */
+.video-header-info {
+ flex: none;
+ margin-left: auto;
+ margin-right: 15px;
+ font-size: 12px;
+ color: var(--color-muted);
+ display: flex;
+ flex-direction: column;
+ align-items: flex-end;
+ line-height: 1.4;
+ /* 圆角边框容器 */
+ background: var(--p-button-secondary-background);
+ border-radius: 10px;
+ padding: 4px 10px;
+ font-family: monospace;
+}
+
+/* 覆盖内容区域默认样式,使其填满空间 */
+.caption-frame-dialog .p-dialog-content {
+ padding: 0 !important;
+ flex: 1 1 auto;
+ /* 占据剩余高度 */
+ display: flex;
+ flex-direction: column;
+ overflow: hidden !important;
+ /* 防止滚动条出现 */
+ min-height: 0;
+ /* Flex 嵌套中的关键,允许内容缩小 */
+}
+
+/* 视频容器:自适应占据剩余空间 */
+.video-container {
+ flex: 1 1 auto;
+ /* 占据剩余高度 */
+ width: 100%;
+ background: #000;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ position: relative;
+ /* 为绝对定位的 overlay 提供参照 */
+ overflow: hidden;
+ min-height: 0;
+ /* Flex 嵌套中的关键,允许内容缩小 */
+ padding: 10px;
+ /* 给视频留一点边距 */
+ box-sizing: border-box;
+}
+
+.video-container video {
+ /* 强制视频适应容器,保持比例 */
+ width: 100%;
+ height: 100%;
+ object-fit: contain;
+ /* 确保完整显示内容 */
+ outline: none;
+}
+
+/* --- 帧显示模式样式 --- */
+.frame-display-container {
+ position: relative;
+}
+
+.frame-display-img {
+ max-width: 100%;
+ max-height: 100%;
+ object-fit: contain;
+ display: block;
+}
+
+/* 混合模式:视频播放元素 */
+.frame-video-player {
+ max-width: 100%;
+ max-height: 100%;
+ object-fit: contain;
+ display: none;
+ /* 默认隐藏,播放时显示 */
+}
+
+.frame-loading-indicator {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ background: rgba(0, 0, 0, 0.7);
+ padding: 15px 20px;
+ border-radius: 8px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 10;
+}
+
+.frame-loading-indicator .pi-spinner {
+ font-size: 24px;
+ color: var(--p-button-primary-background);
+}
+
+/* --- 统一时间轴组件样式 --- */
+.unified-timeline-container {
+ position: relative;
+ /* 增加高度:滑块头部 + 轨道 */
+ height: 40px;
+ margin: 10px 20px 0;
+ cursor: pointer;
+ flex-shrink: 0;
+}
+
+/* 标记轨道(底层)*/
+.frame-marker-track {
+ position: absolute;
+ /* 轨道位于下方,给滑块头部留出空间 */
+ top: 12px;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: var(--p-scrollpanel-bar-background);
+ border-radius: 0 0 2px 2px;
+ /* 上描边更明显,模拟时间轴刻度线 */
+ border-top: 4px solid color-mix(in srgb, var(--p-button-secondary-color), transparent 84%);
+ border-left: 1px solid var(--p-divider-border-color);
+ border-right: 1px solid var(--p-divider-border-color);
+ border-bottom: 1px solid var(--p-divider-border-color);
+ cursor: pointer;
+}
+
+/* 自定义滑块(时间轴指针样式)*/
+.timeline-slider-thumb {
+ position: absolute;
+ /* 从轨道位置开始,不超出轨道 */
+ top: 12px;
+ left: 0;
+ bottom: 0;
+ width: 2px;
+ background: var(--p-button-primary-background);
+ transform: translateX(-50%);
+ cursor: grab;
+ z-index: 10;
+}
+
+/* 滑块头部(圆点)- 与轨道上描边垂直居中对齐 */
+.timeline-slider-thumb::before {
+ content: '';
+ position: absolute;
+ /* 圆点14px高,中心对齐上描边:-7px */
+ top: -7px;
+ left: 50%;
+ transform: translateX(-50%);
+ width: 14px;
+ height: 14px;
+ background: var(--p-button-primary-background);
+ border-radius: 50%;
+ box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3);
+ transition: transform 0.1s, box-shadow 0.1s;
+}
+
+.timeline-slider-thumb:hover::before {
+ transform: translateX(-50%) scale(1.2);
+ box-shadow: 0 0 10px var(--p-button-primary-background);
+}
+
+.timeline-slider-thumb.dragging {
+ cursor: grabbing;
+}
+
+.timeline-slider-thumb.dragging::before {
+ transform: translateX(-50%) scale(1.1);
+ box-shadow: 0 0 12px var(--p-button-primary-background);
+}
+
+/* 视频内悬浮信息面板 */
+.video-info-overlay {
+ position: absolute;
+ top: 15px;
+ right: 15px;
+ background: rgba(0, 0, 0, 0.7);
+ padding: 10px;
+ border-radius: 6px;
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ z-index: 10;
+ backdrop-filter: blur(4px);
+ /* border: 1px solid rgba(255, 255, 255, 0.1); */
+ min-width: 150px;
+}
+
+/* 信息行样式 */
+.info-row {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ font-size: 10px;
+ color: var(--color-muted);
+ font-family: monospace;
+ line-height: 1.4;
+}
+
+.info-label {
+ color: var(--color-muted);
+ margin-right: 8px;
+}
+
+/* 控制区域容器 */
+.controls-container {
+ flex-shrink: 0;
+ /* 固定高度,不随父容器压缩 */
+ background: var(--p-dialog-background);
+ padding: 12px 20px;
+ border-top: 1px solid var(--p-dialog-border-color);
+ /* 改为横向 Flex 布局 */
+ display: flex;
+ flex-direction: row;
+ justify-content: space-between;
+ /* 两端对齐 */
+ align-items: center;
+ gap: 8px;
+ width: 100%;
+ box-sizing: border-box;
+ z-index: 2;
+}
+
+/* 按钮组容器 */
+.playback-controls {
+ display: flex;
+ gap: 8px;
+ /* 按钮之间的间距 */
+ align-items: center;
+ flex-wrap: wrap;
+}
+
+/* 选帧列表区域 */
+.frame-list-container {
+ flex-shrink: 0;
+ /* 固定高度 */
+ background: color-mix(in srgb, var(--secondary-background), transparent 30%);
+ ;
+ padding: 10px 20px;
+ border-top: 1px solid var(--p-divider-border-color);
+ max-height: 120px;
+ overflow-y: auto;
+}
+
+.frame-list-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 8px;
+ /* 移除 margin-bottom,因为标签现在在同一行 */
+ font-size: 12px;
+ color: #aaa;
+}
+
+.frame-tags {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+ flex: 1;
+ /* 允许标签占据剩余空间 */
+}
+
+/* 帧标签基础样式 */
+.frame-tag {
+ /* 默认背景,会被覆盖 */
+ background: transparent;
+ padding: 4px 8px;
+ border-radius: 12px;
+ font-size: 12px;
+ color: #fff;
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ user-select: none;
+ border: 1px solid transparent;
+}
+
+/* 单帧标签 - 蓝色 */
+.frame-tag.frame-tag-single {
+ background: var(--p-button-primary-background);
+ border-color: var(--p-button-primary-background);
+}
+
+.frame-tag.frame-tag-single:hover {
+ background: var(--p-button-primary-hover-background);
+}
+
+/* 范围帧标签 - 绿色 */
+.frame-tag.frame-tag-range {
+ background: var(--p-green-600);
+ border-color: var(--p-green-600);
+}
+
+.frame-tag.frame-tag-range:hover {
+ background: color-mix(in srgb, var(--p-green-600) 80%, black);
+}
+
+.frame-tag .remove-frame {
+ cursor: pointer;
+ color: var(--p-button-secondary-color);
+ font-weight: bold;
+ font-size: 14px;
+ line-height: 1;
+ opacity: 0.7;
+}
+
+.frame-tag .remove-frame:hover {
+ opacity: 1;
+}
+
+/* 微调底部按钮区域 */
+.caption-frame-dialog .p-dialog-footer {
+ padding: 10px 20px !important;
+ background: var(--p-dialog-background);
+ border-top: 1px solid var(--p-divider-border-color);
+
+ flex-shrink: 0;
+}
+
+/* 单帧标记 - 上尖下圆角 */
+.frame-marker {
+ position: absolute;
+ bottom: 0;
+ width: 8px;
+ height: 100%;
+ background: var(--p-button-primary-background);
+ cursor: grab;
+ transform: translateX(-50%);
+ transition: background 0.15s;
+ /* 上尖下圆角:使用 clip-path 裁剪 */
+ clip-path: polygon(50% 0%, 100% 30%, 100% 100%, 0% 100%, 0% 30%);
+ border-radius: 0 0 3px 3px;
+ user-select: none;
+}
+
+.frame-marker:hover {
+ background: var(--p-button-primary-hover-background);
+}
+
+.frame-marker:active {
+ cursor: grabbing;
+ opacity: 0.8;
+ filter: drop-shadow(0 0 6px var(--p-button-primary-background));
+}
+
+/* 临时标记 - 绿色闪烁 */
+.frame-marker-temp {
+ position: absolute;
+ bottom: 0;
+ width: 8px;
+ height: 100%;
+ background: var(--p-green-600);
+ cursor: pointer;
+ transform: translateX(-50%);
+ /* 上尖下圆角 */
+ clip-path: polygon(50% 0%, 100% 30%, 100% 100%, 0% 100%, 0% 30%);
+ border-radius: 0 0 3px 3px;
+ /* 闪烁动画 */
+ animation: blink 1s ease-in-out infinite;
+}
+
+/* 闪烁动画关键帧 */
+@keyframes blink {
+
+ 0%,
+ 100% {
+ opacity: 1;
+ }
+
+ 50% {
+ opacity: 0.3;
+ }
+}
+
+/* 范围标记 */
+.frame-marker-range {
+ position: absolute;
+ bottom: 0;
+ height: 100%;
+ background: color-mix(in srgb, var(--p-green-600) 25%, transparent);
+ border-left: 2px solid var(--p-green-600);
+ border-right: 2px solid var(--p-green-600);
+ cursor: move;
+ transition: background 0.15s;
+ box-sizing: border-box;
+ user-select: none;
+}
+
+.frame-marker-range:hover {
+ background: color-mix(in srgb, var(--p-green-600) 35%, transparent);
+}
+
+.frame-marker-range:active {
+ opacity: 0.9;
+}
+
+/* 范围手柄 */
+.range-handle {
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ width: 8px;
+ cursor: ew-resize;
+ z-index: 1;
+}
+
+.range-handle-left {
+ left: -4px;
+}
+
+.range-handle-right {
+ right: -4px;
+}
+
+.range-handle:hover {
+ background: color-mix(in srgb, var(--p-green-600) 50%, transparent);
+}
+
+/* 拖动跟随标签 */
+.drag-label {
+ position: absolute;
+ top: -30px;
+ transform: translateX(-50%);
+ background: var(--p-button-primary-background);
+ color: white;
+ padding: 4px 8px;
+ border-radius: 6px;
+ font-size: 11px;
+ white-space: nowrap;
+ pointer-events: none;
+ z-index: 10;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
+}
\ No newline at end of file
diff --git a/js/css/common.css b/js/css/common.css
index dc679ab..6fa3fc0 100644
--- a/js/css/common.css
+++ b/js/css/common.css
@@ -1,11 +1,13 @@
/**
* 通用样式及动画库
- * 提供全局共享的动画效果和基础样式
+ * 提供全局共享的动画效果和特定功能组件样式
*/
-/* ======== 动画定义 ======== */
+/* ==========================================================================
+ 1. 关键帧动画 (Keyframes)
+ ========================================================================== */
-/* 输入框高亮动画 */
+/* 输入框高亮闪烁 */
@keyframes inputHighlight {
0% {
background-color: transparent;
@@ -20,112 +22,7 @@
}
}
-.input-highlight {
- animation: inputHighlight 0.2s ease-out;
-}
-
-
-
-/* 小助手折叠和展开过渡效果 */
-.prompt-assistant-transition {
- transition: width 0.3s cubic-bezier(0.25, 1, 0.5, 1),
- opacity 0.3s cubic-bezier(0.25, 1, 0.5, 1),
- border-color 0.2s ease-out,
- backdrop-filter 0.2s ease-out,
- box-shadow 0.2s ease-out,
- background-color 0.5s ease-out;
-}
-
-/* 确保内容不随容器宽度变化而移动的样式 */
-.prompt-assistant-content-fixed {
- display: flex;
- gap: 2px;
- position: relative;
- right: 0;
- justify-content: flex-end;
- width: auto;
- flex-grow: 1;
- transition: none;
-}
-
-/* 折叠状态下按钮的透明度过渡 */
-.prompt-assistant-container.collapsed .prompt-assistant-button {
- transition: opacity 0.3s cubic-bezier(0.11, 0, 0.5, 0);
- opacity: 0;
-}
-
-/* 折叠状态下容器的透明效果 */
-.prompt-assistant-container.collapsed {
- background-color: transparent;
- border-color: transparent;
- backdrop-filter: blur(0px);
- box-shadow: none;
-}
-
-/* 向上淡入动画 */
-@keyframes clipFadeInUp {
- from {
- opacity: 0;
- transform: translateY(-10px);
- }
-
- to {
- opacity: 1;
- transform: translateY(0);
- }
-}
-
-/* 向下淡入动画 */
-@keyframes clipFadeInDown {
- from {
- opacity: 0;
- transform: translateY(10px) scale(1);
- }
-
- to {
- opacity: 1;
- transform: translateY(0) scale(1);
- }
-}
-
-/* 向上淡出动画 */
-@keyframes clipFadeOutUp {
- from {
- opacity: 1;
- transform: translateY(0) scale(1);
- }
-
- to {
- opacity: 0;
- transform: translateY(10px) scale(1);
- }
-}
-
-/* 向下淡出动画 */
-@keyframes clipFadeOutDown {
- from {
- opacity: 1;
- transform: translateY(0) scale(1);
- }
-
- to {
- opacity: 0;
- transform: translateY(-10px) scale(1);
- }
-}
-
-/* 淡入动画 */
-@keyframes clipFadeIn {
- from {
- opacity: 0;
- }
-
- to {
- opacity: 1;
- }
-}
-
-/* 按钮呼吸缩放动画 */
+/* 按钮呼吸效果 */
@keyframes buttonBreathing {
0% {
transform: scale(1);
@@ -143,9 +40,7 @@
}
}
-
-
-/* 状态提示浮动效果 - 向上淡出 */
+/* 状态提示浮动消失 */
@keyframes tipFloatUp {
0% {
opacity: 1;
@@ -163,7 +58,7 @@
}
}
-/* 状态提示缩放出现动画 */
+/* 状态提示缩放出现 */
@keyframes tipScaleIn {
0% {
opacity: 0;
@@ -181,7 +76,66 @@
}
}
-/* 标签内容渐显动画 */
+/* 淡入淡出组合 */
+@keyframes clipFadeInUp {
+ from {
+ opacity: 0;
+ transform: translateY(-10px);
+ }
+
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+@keyframes clipFadeInDown {
+ from {
+ opacity: 0;
+ transform: translateY(10px) scale(1);
+ }
+
+ to {
+ opacity: 1;
+ transform: translateY(0) scale(1);
+ }
+}
+
+@keyframes clipFadeOutUp {
+ from {
+ opacity: 1;
+ transform: translateY(0) scale(1);
+ }
+
+ to {
+ opacity: 0;
+ transform: translateY(10px) scale(1);
+ }
+}
+
+@keyframes clipFadeOutDown {
+ from {
+ opacity: 1;
+ transform: translateY(0) scale(1);
+ }
+
+ to {
+ opacity: 0;
+ transform: translateY(-10px) scale(1);
+ }
+}
+
+@keyframes clipFadeIn {
+ from {
+ opacity: 0;
+ }
+
+ to {
+ opacity: 1;
+ }
+}
+
+/* 标签内容渐显 */
@keyframes tagContentFadeIn {
from {
opacity: 0;
@@ -194,7 +148,44 @@
}
}
-/* 标签内容包装器样式 */
+/* 对话框入场 */
+@keyframes dialogFadeIn {
+ from {
+ opacity: 0;
+ transform: translate(-50%, -50%) scale(0.9);
+ }
+
+ to {
+ opacity: 1;
+ transform: translate(-50%, -50%) scale(1);
+ }
+}
+
+/* Loading 旋转 */
+@keyframes spin {
+ from {
+ transform: rotate(0deg);
+ }
+
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+/* ==========================================================================
+ 2. 通用工具类 (Utilities)
+ ========================================================================== */
+
+/* 应用高亮动画 */
+.input-highlight {
+ animation: inputHighlight 0.2s ease-out;
+}
+
+/* ==========================================================================
+ 3. 标签组件样式 (Tag Components)
+ ========================================================================== */
+
+/* 标签内容包装器 (用于 tag.js) */
.tag_content_wrapper {
width: 100%;
height: 100%;
@@ -208,7 +199,10 @@
transform: translateY(0);
}
-/* ======== 图像反推复制对话框样式 ======== */
+/* ==========================================================================
+ 4. 图像反推对话框 (Image Caption Dialog)
+ ========================================================================== */
+
.image-assistant-copy-dialog {
position: fixed;
top: 50%;
@@ -218,12 +212,12 @@
color: var(--p-text-muted-color);
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.6);
- z-index: 9999;
+ z-index: 10220;
width: 400px;
height: 300px;
padding: 20px;
font-family: var(--litegraph-font, Arial, sans-serif);
- position: relative;
+ animation: dialogFadeIn 0.2s ease-out;
}
/* 关闭按钮 */
@@ -233,20 +227,20 @@
right: 12px;
width: 24px;
height: 24px;
- background: none;
- border: none;
- color: var(--p-button-text-secondary-color);
- cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
+
+ background: none;
+ border: none;
border-radius: 12px;
+ color: var(--p-button-text-secondary-color);
+ cursor: pointer;
transition: all 0.2s ease;
}
.image-assistant-copy-dialog-close:hover {
background-color: rgba(255, 255, 255, 0.1);
- color: var(--p-button-text-secondary-color);
}
.image-assistant-copy-dialog-close .svg-icon {
@@ -254,14 +248,13 @@
height: 16px;
}
-/* 标题文本 */
+/* 对话框标题 */
.image-assistant-copy-dialog-title {
font-size: 14px;
font-weight: bold;
line-height: 1.4;
margin: 0 0 16px 0;
padding-right: 30px;
- /* 为关闭按钮留出空间 */
}
/* 文本区域 */
@@ -269,17 +262,19 @@
width: 100%;
height: 160px;
padding: 12px;
- border: 1px solid var(--p-content-border-color);
- border-radius: 6px;
+ margin-bottom: 16px;
+ box-sizing: border-box;
+
background-color: var(--comfy-input-bg);
color: var(--input-text);
+ border: 1px solid var(--p-content-border-color);
+ border-radius: 6px;
+
font-family: var(--litegraph-font, Arial, sans-serif);
font-size: 13px;
line-height: 1.5;
resize: none;
outline: none;
- box-sizing: border-box;
- margin-bottom: 16px;
}
.image-assistant-copy-dialog-textarea:focus {
@@ -292,15 +287,17 @@
display: block;
margin: 0 auto;
padding: 10px 24px;
+ min-width: 120px;
+
background-color: var(--p-primary-color);
color: var(--p-button-primary-color);
border: none;
border-radius: 6px;
- cursor: pointer;
+
font-size: 14px;
font-weight: 500;
+ cursor: pointer;
transition: all 0.2s ease;
- min-width: 120px;
}
.image-assistant-copy-dialog-copy-btn:hover {
@@ -313,19 +310,31 @@
box-shadow: 0 1px 4px rgba(74, 144, 226, 0.3);
}
-/* 对话框入场动画 */
-@keyframes dialogFadeIn {
- from {
- opacity: 0;
- transform: translate(-50%, -50%) scale(0.9);
- }
+/* ==========================================================================
+ 5. Loading 组件
+ ========================================================================== */
- to {
- opacity: 1;
- transform: translate(-50%, -50%) scale(1);
- }
+.loading-container {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ padding: 40px 20px;
+ text-align: center;
}
-.image-assistant-copy-dialog {
- animation: dialogFadeIn 0.2s ease-out;
+.loading-spinner {
+ width: 32px;
+ height: 32px;
+ border: 3px solid var(--p-divider-color);
+ border-top-color: var(--p-primary-color);
+ border-radius: 50%;
+ margin-bottom: 12px;
+ animation: spin 0.8s linear infinite;
+}
+
+.loading-text {
+ font-size: 14px;
+ color: var(--p-text-muted-color);
+ margin-top: 8px;
}
\ No newline at end of file
diff --git a/js/css/popup.css b/js/css/popup.css
index 19468bb..c42cf17 100644
--- a/js/css/popup.css
+++ b/js/css/popup.css
@@ -6,7 +6,7 @@
/* 弹窗基础样式 */
.popup_container {
position: fixed;
- z-index: 10000;
+ z-index: 10180;
/* background-color: rgba(30, 30, 30, 0.95); */
background-color: var(--p-content-background);
border: 1px solid var(--p-content-border-color);
@@ -16,7 +16,8 @@
min-width: 600px;
max-width: 600px;
min-height: 200px;
- max-height: 80vh; /* 使用视窗高度的80%作为最大高度 */
+ max-height: 80vh;
+ /* 使用视窗高度的80%作为最大高度 */
overflow: hidden;
display: flex;
flex-direction: column;
@@ -242,7 +243,8 @@
overflow-x: hidden;
scrollbar-width: thin;
scrollbar-color: color-mix(in srgb, var(--p-button-text-secondary-color), transparent 50%) transparent;
- min-height: 0; /* 允许flex收缩 */
+ min-height: 0;
+ /* 允许flex收缩 */
}
.popup_content::-webkit-scrollbar {
@@ -455,6 +457,28 @@
transition: transform 0.2s cubic-bezier(0.25, 0.46, 0.45, 0.94);
}
+/* 加号图标样式 */
+.accordion_add_icon {
+ margin-right: 6px;
+ cursor: pointer;
+ opacity: 0.6;
+ transition: all 0.2s ease;
+ width: 18px;
+ height: 18px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 50%;
+ flex-shrink: 0;
+}
+
+.accordion_add_icon:hover {
+ opacity: 1;
+ color: var(--p-primary-500);
+ background-color: color-mix(in srgb, var(--p-primary-500), transparent 85%);
+ transform: scale(1.1);
+}
+
.tag_accordion_icon img,
.tag_accordion_icon .svg-icon,
.accordion_arrow_icon {
@@ -481,18 +505,23 @@
.tag_accordion_content.active {
display: block;
- max-height: none; /* 移除高度限制,让内容自适应 */
+ max-height: none;
+ /* 移除高度限制,让内容自适应 */
padding: 2px 0;
- overflow: visible; /* 移除滚动条,让父容器处理滚动 */
+ overflow: visible;
+ /* 移除滚动条,让父容器处理滚动 */
cursor: default;
/* 确保激活状态下内容区域也使用默认指针 */
}
/* 优化手风琴动画性能 */
.tag_accordion_content {
- will-change: max-height, padding; /* 提示浏览器优化这些属性的动画 */
- transform: translateZ(0); /* 启用硬件加速 */
- backface-visibility: hidden; /* 避免背面可见性问题 */
+ will-change: max-height, padding;
+ /* 提示浏览器优化这些属性的动画 */
+ transform: translateZ(0);
+ /* 启用硬件加速 */
+ backface-visibility: hidden;
+ /* 避免背面可见性问题 */
}
/* 旋转图标优化 */
@@ -508,10 +537,12 @@
/* 标签容器样式 */
.tag_category_container {
flex: 1;
- overflow: visible; /* 移除滚动条,让父容器处理滚动 */
+ overflow: visible;
+ /* 移除滚动条,让父容器处理滚动 */
padding: 4px;
position: relative;
- min-height: 0; /* 允许flex收缩 */
+ min-height: 0;
+ /* 允许flex收缩 */
}
/* 标签项样式 */
@@ -534,7 +565,7 @@
background-color: color-mix(in srgb, var(--p-primary-500), transparent 84%);
;
border-color: rgba(255, 255, 255, 0.2);
- z-index: 99999;
+ z-index: 10210;
}
.tag_item_text {
@@ -588,7 +619,7 @@
max-width: 300px;
word-wrap: break-word;
line-height: 1.6;
- z-index: 1000000;
+ z-index: 10250;
pointer-events: none;
animation: clipFadeInDown 0.2s ease-in-out;
transform-origin: top center;
@@ -748,7 +779,8 @@
scrollbar-width: thin;
scrollbar-color: color-mix(in srgb, var(--p-button-text-secondary-color), transparent 50%) transparent;
padding-bottom: 8px;
- min-height: 0; /* 允许flex收缩 */
+ min-height: 0;
+ /* 允许flex收缩 */
}
.popup_container .popup_tab_content::-webkit-scrollbar {
@@ -795,19 +827,19 @@
/* 左侧滚动指示器 */
.popup_container .tabs_scroll_indicator.left {
left: 0;
- background: linear-gradient(to right,
- var(--p-content-background) 0%,
- var(--p-content-background) 40%,
- transparent 100%);
+ background: linear-gradient(to right,
+ var(--p-content-background) 0%,
+ var(--p-content-background) 40%,
+ transparent 100%);
}
/* 右侧滚动指示器 */
.popup_container .tabs_scroll_indicator.right {
right: 0;
- background: linear-gradient(to left,
- var(--p-content-background) 0%,
- var(--p-content-background) 40%,
- transparent 100%);
+ background: linear-gradient(to left,
+ var(--p-content-background) 0%,
+ var(--p-content-background) 40%,
+ transparent 100%);
}
.popup_container .tabs_scroll_indicator:hover {
@@ -917,15 +949,19 @@
/* 标签弹窗的内容区域适应动态高度 */
.popup_container.tag_popup .popup_tab_content {
flex: 1;
- min-height: 0; /* 允许flex收缩 */
- overflow-y: auto; /* 只有标签页内容容器有滚动条 */
+ min-height: 0;
+ /* 允许flex收缩 */
+ overflow-y: auto;
+ /* 只有标签页内容容器有滚动条 */
overflow-x: hidden;
}
.popup_container.tag_popup .tag_category_container {
flex: 1;
- min-height: 0; /* 允许flex收缩 */
- overflow: visible; /* 移除滚动条,让父容器处理 */
+ min-height: 0;
+ /* 允许flex收缩 */
+ overflow: visible;
+ /* 移除滚动条,让父容器处理 */
}
/* 确保手风琴内容不产生滚动条 */
@@ -1028,7 +1064,7 @@
border-radius: 8px;
min-width: 100px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
- z-index: 10000;
+ z-index: 10215;
opacity: 0;
transform: translateY(-10px);
transition: opacity 0.2s ease, transform 0.2s ease;
@@ -1098,6 +1134,11 @@
.button-menu-label {
flex: 1;
color: var(--p-text-muted-color, #aaa);
+ white-space: nowrap;
+ /* 防止文本换行 */
+ overflow: hidden;
+ text-overflow: ellipsis;
+ /* 过长时显示省略号 */
}
/* 快捷键提示 */
@@ -1173,30 +1214,123 @@
font-size: 12px;
}
-/* 子菜单容器(下对齐到父项) */
+/* 子菜单容器(默认在父项右侧,位置由JS动态调整) */
.button-submenu {
position: absolute;
- top: auto;
- bottom: 0;
+ top: 0;
left: 100%;
margin-left: 2px;
background-color: var(--p-content-background);
border: 1px solid var(--p-content-border-color);
border-radius: 8px;
- min-width: 140px;
+ min-width: 20px;
+ /* 增加宽度以容纳长模型名 */
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
opacity: 0;
transform: translateX(-6px);
pointer-events: none;
transition: opacity 0.15s ease, transform 0.15s ease;
- overflow: hidden;
- z-index: 10001; /* 高于父菜单 */
+ overflow: visible;
+ /* 改为visible以支持嵌套子菜单溢出 */
+ z-index: 10216;
+ /* 高于父菜单 */
}
-/* 悬停显示子菜单 */
-.button-menu-item.submenu-open > .button-submenu,
-.button-menu-item-has-children:hover > .button-submenu {
+/* 第三级及更深层子菜单的z-index递增 */
+.button-submenu .button-submenu {
+ z-index: 10217;
+}
+
+.button-submenu .button-submenu .button-submenu {
+ z-index: 10218;
+}
+
+/* 悬停显示子菜单 - 支持嵌套 */
+.button-menu-item.submenu-open>.button-submenu,
+.button-menu-item-has-children:hover>.button-submenu {
opacity: 1;
transform: translateX(0);
pointer-events: auto;
}
+
+/* 确保所有有子菜单的菜单项都有 position: relative,作为子菜单的定位参考点 */
+.button-menu-item-has-children {
+ position: relative;
+}
+
+/* 确认对话框特定样式 - 紧凑型 */
+.popup_container.confirm-dialog {
+ min-width: 320px !important;
+ max-width: 420px !important;
+ width: auto !important;
+ height: auto !important;
+ min-height: auto !important;
+}
+
+.popup_container.confirm-dialog .popup_content {
+ flex: 0 0 auto !important;
+ min-height: auto !important;
+ overflow: visible !important;
+}
+
+/* ---标签跨分类拖拽样式--- */
+
+/* 拖拽时标签的样式 */
+.tag_item.tag-dragging {
+ opacity: 0.8;
+ transform: scale(1.02);
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
+ z-index: 1000;
+ cursor: grabbing;
+}
+
+/* 可放置区域的高亮样式 */
+.tag-drop-zone {
+ position: relative;
+ background-color: color-mix(in srgb, var(--p-primary-500), transparent 92%) !important;
+ border: 2px dashed var(--p-primary-400) !important;
+ border-radius: 8px;
+ transition: all 0.2s ease;
+}
+
+/* 可放置区域的脉冲动画 */
+.tag-drop-zone::before {
+ content: '';
+ position: absolute;
+ inset: -2px;
+ border: 2px solid var(--p-primary-400);
+ border-radius: 10px;
+ opacity: 0;
+ animation: dropZonePulse 1.5s ease-in-out infinite;
+ pointer-events: none;
+}
+
+@keyframes dropZonePulse {
+
+ 0%,
+ 100% {
+ opacity: 0;
+ transform: scale(1);
+ }
+
+ 50% {
+ opacity: 0.5;
+ transform: scale(1.02);
+ }
+}
+
+/* 拖拽时的幽灵元素 */
+.tag-ghost {
+ opacity: 0.4;
+ background-color: color-mix(in srgb, var(--p-primary-500), transparent 85%) !important;
+ border: 1px dashed var(--p-primary-500) !important;
+}
+
+/* 标签抓取光标 */
+.tag_popup .tag_item {
+ cursor: pointer;
+}
+
+.tag_popup .tag_item:active {
+ cursor: grabbing;
+}
\ No newline at end of file
diff --git a/js/css/settings.css b/js/css/settings.css
deleted file mode 100644
index e00dc2a..0000000
--- a/js/css/settings.css
+++ /dev/null
@@ -1,1582 +0,0 @@
-/**
- * 设置界面样式
- */
-
-/* Z-index 层级管理 */
-:root {
- --settings-modal-z-index: 9203;
- --settings-dropdown-z-index: 9204;
- --settings-overlay-z-index: 9202;
-}
-
-/* 遮罩层样式 */
-.settings-modal-overlay {
- position: fixed;
- top: 0;
- left: 0;
- width: 100%;
- height: 100%;
- background-color: rgba(0, 0, 0, 0.5);
- z-index: var(--settings-overlay-z-index);
- opacity: 0;
- visibility: hidden;
- transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
-}
-
-.settings-modal-overlay.overlay-show {
- opacity: 1;
- visibility: visible;
-}
-
-.settings-modal-overlay.overlay-hide {
- opacity: 0;
- visibility: hidden;
-}
-
-/* 设置面板容器 */
-.settings-panel {
- padding: 1rem;
-}
-
-/* 设置项组 */
-.settings-group {
- margin-bottom: 1.5rem;
-}
-
-/* 设置项标题 */
-.settings-title {
- font-size: 1rem;
- font-weight: 600;
- margin-bottom: 0.5rem;
- color: var(--fg-color);
-}
-
-/* 服务申请链接容器样式 */
-.settings-service-links {
- display: inline-flex;
- align-items: center;
- background-color: var(--p-button-secondary-background);
- border-radius: 10px;
- padding: 2px 8px;
-}
-
-
-/* 服务申请前缀文本样式(与链接字号一致) */
-.settings-service-prefix {
- color: var(--p-button-text-primary-color);
- font-size: 14px;
- padding: 2px 4px;
-}
-
-/* 服务申请链接样式 */
-.settings-service-link {
- display: inline-block;
- padding: 2px 4px;
- color: var(--p-button-text-primary-color) !important;
- text-decoration: none;
- font-size: 14px;
- font-weight: normal;
- transition: color 0.2s ease;
-}
-
-.settings-service-link:hover {
- color: var(--p-button-text-primary-color) !important;
- text-decoration: none !important;
-}
-
-/* 服务链接分隔符 */
-.settings-service-separator {
- color: var(--p-button-text-primary-color);
- opacity: 0.5;
- padding: 0 2px;
- user-select: none;
-}
-
-/* 设置项内容 */
-.settings-content {
- display: flex;
- flex-direction: column;
- gap: 0.5rem;
-}
-
-/* 按钮组样式 */
-.button-group {
- display: flex;
- gap: 0.5rem;
- margin-top: 0.5rem;
-}
-
-/* 设置弹窗样式 */
-.settings-modal {
- position: fixed;
- top: 50%;
- left: 50%;
- transform: translate(-50%, -50%) scale(0.8);
- /* 响应式宽度设置 */
- width: min(90vw, 800px);
- min-width: 400px;
- /* 响应式高度设置 */
- max-height: 90vh;
- min-height: 300px;
- margin: 0;
- z-index: var(--settings-modal-z-index);
- background-color: var(--p-dialog-background);
- border: 1px solid var(--p-dialog-border-color);
- border-radius: var(--p-dialog-border-radius);
- box-shadow: var(--p-dialog-shadow);
- color: var(--p-dialog-color);
- /* 添加弹性布局 */
- display: flex;
- flex-direction: column;
- /* 添加动画属性 */
- opacity: 0;
- visibility: hidden;
- transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
-}
-
-/* 弹窗显示动画类 */
-.settings-modal.modal-show {
- opacity: 1;
- visibility: visible;
- transform: translate(-50%, -50%) scale(1);
-}
-
-/* 弹窗隐藏动画类 */
-.settings-modal.modal-hide {
- opacity: 0;
- visibility: hidden;
- transform: translate(-50%, -50%) scale(0.8);
-}
-
-/* 拖动时的状态 */
-.settings-modal.dragging {
- transition: none;
- cursor: move;
-}
-
-/* 标题栏样式 */
-.settings-modal .p-dialog-header {
- padding: var(--p-dialog-header-padding);
- border-bottom: 1px solid var(--p-dialog-border-color);
-
- /* 固定头部 */
- flex: 0 0 auto;
- user-select: none;
-}
-
-.settings-modal .p-dialog-content {
- padding: 10px 40px;
- position: relative;
- /* 作为二维码的定位参照,避免被外层裁剪 */
- /* 内容区域自适应 */
- flex: 1 1 auto;
- overflow-y: auto;
- /* 添加平滑滚动 */
- scroll-behavior: smooth;
- /* 优化滚动条样式 */
- scrollbar-width: thin;
- scrollbar-color: var(--p-scrollpanel-bar-background) transparent;
-}
-
-/* 自定义滚动条样式 */
-.settings-modal .p-dialog-content::-webkit-scrollbar {
- width: 10px;
-}
-
-.settings-modal .p-dialog-content::-webkit-scrollbar-track {
- background: transparent;
-}
-
-.settings-modal .p-dialog-content::-webkit-scrollbar-thumb {
- background: var(--p-scrollpanel-bar-background);
- border-radius: 4px;
-}
-
-.settings-modal .p-dialog-content::-webkit-scrollbar-thumb:hover {
- background: var(--p-scrollpanel-bar-background);
-}
-
-.settings-modal .p-dialog-footer {
- padding: 10px 40px 20px 40px;
-
-
- gap: 0.9rem;
- /* 固定底部 */
- flex: 0 0 auto;
-}
-
-/* 表单部分样式 */
-.settings-form-section {
- margin-bottom: 2rem;
- padding-bottom: 1.5rem;
- border-bottom: 1px solid var(--p-dialog-border-color);
-}
-
-.settings-form-section:last-child {
- margin-bottom: 0;
- padding-bottom: 0;
- border-bottom: none;
-}
-
-.settings-form-section-title {
- font-size: 1.1rem;
- font-weight: 600;
- margin: 0 0 1rem 0;
- color: var(--p-dialog-header-color);
-}
-
-/* 表单组样式 */
-.settings-form-group {
- margin-bottom: 1rem;
-}
-
-/* 水平布局的表单组 */
-.settings-form-group.horizontal {
- display: flex;
- gap: 1rem;
- align-items: flex-start;
-}
-
-.settings-form-group.horizontal>* {
- flex: 1;
- min-width: 0;
- /* 防止flex项溢出 */
-}
-
-/* 调整水平布局中标签的样式 */
-.settings-form-group.horizontal .settings-form-label {
- margin-bottom: 0.5rem;
-}
-
-/* 调整下拉选择器在水平布局中的样式 */
-.settings-form-group.horizontal .p-dropdown,
-.settings-form-group.horizontal .p-inputtext {
- width: 100%;
- min-width: 0;
-}
-
-/* 确保下拉选择器的容器不会被挤压 */
-.settings-form-group.horizontal .p-dropdown-container {
- min-width: 150px;
-}
-
-/* 确保输入框容器不会被挤压 */
-.settings-form-group.horizontal .p-input-wrapper {
- min-width: 150px;
-}
-
-.settings-form-group:last-child {
- margin-bottom: 0;
-}
-
-.settings-form-label {
- display: block;
- margin-bottom: 0.5rem;
- font-weight: 500;
- color: var(--p-text-color);
-}
-
-/* 输入框容器样式 */
-.flex-1 {
- flex: 1;
- width: 100%;
-}
-
-/* 优化关闭按钮悬停效果 */
-.settings-modal .p-dialog-header-icon.p-dialog-header-close {
- width: 2rem;
- height: 2rem;
- border-radius: 50%;
- transition: background-color 0.2s;
- display: flex;
- align-items: center;
- justify-content: center;
- margin-left: 0.5rem;
- color: var(--p-button-text-secondary-color);
- background-color: transparent;
- border: none;
- cursor: pointer;
-}
-
-.settings-modal .p-dialog-header-icon.p-dialog-header-close:hover {
- background-color: rgba(255, 255, 255, 0.1);
-}
-
-.settings-modal .p-dialog-header-icon.p-dialog-header-close .pi-times {
- font-size: 1rem;
-
-}
-
-/* 独立下拉选择组件样式(不依赖 PrimeVue 全局样式) */
-/* 仅影响通过小助手创建的选择框,不改变ComfyUI内置组件 */
-.pa-dropdown {
- display: inline-flex;
- align-items: center;
- position: relative;
- cursor: pointer;
- user-select: none;
- background: var(--p-inputtext-background);
- border: 1px solid var(--p-inputtext-border-color);
- border-radius: 6px;
- transition: border-color 0.2s;
- width: 100%;
- box-shadow: var(--p-inputtext-shadow);
- height: 2.5rem;
-}
-
-.pa-dropdown:hover {
- border-color: var(--p-form-field-hover-border-color);
-}
-
-.pa-dropdown.p-focus {
- border-color: var(--p-form-field-focus-border-color);
-}
-
-.pa-dropdown .pa-dropdown-label {
- flex: 1 1 auto;
- border: 0 none;
- color: var(--p-inputtext-color);
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
- width: 1%;
- height: 100%;
- cursor: pointer;
-}
-
-.pa-dropdown .pa-dropdown-trigger {
- display: flex;
- align-items: center;
- justify-content: center;
- flex-shrink: 0;
- width: 2.5rem;
- color: var(--p-form-field-hover-border-color);
- height: 100%;
-}
-
-/* 下拉面板(独立样式) */
-.pa-dropdown-panel {
- z-index: var(--settings-dropdown-z-index);
- background: var(--p-inputtext-background);
- border: 1px solid var(--p-inputtext-border-color);
- box-shadow: var(--p-inputtext-shadow);
- border-radius: 6px;
- transform-origin: center top;
- transition: transform 0.12s cubic-bezier(0, 0, 0.2, 1),
- opacity 0.12s cubic-bezier(0, 0, 0.2, 1);
-}
-
-.pa-dropdown-panel.p-hidden {
- opacity: 0;
- transform: scaleY(0.8);
- pointer-events: none;
-}
-
-.pa-dropdown-panel.p-enter-active {
- opacity: 1;
- transform: scaleY(1);
- pointer-events: auto;
-}
-
-/* 隐藏辅助可访问性选择器(在未加载PrimeVue时也生效) */
-.p-hidden-accessible {
- position: absolute !important;
- width: 1px !important;
- height: 1px !important;
- overflow: hidden !important;
- clip: rect(0 0 0 0) !important;
- clip-path: inset(50%) !important;
- white-space: nowrap !important;
- border: 0 !important;
- padding: 0 !important;
- margin: -1px !important;
-}
-
-/* 面板内容滚动容器(独立样式) */
-.pa-dropdown-panel .p-dropdown-items-wrapper {
- max-height: 240px;
- overflow: auto;
-}
-
-
-/* 列表与选项(独立样式) */
-.pa-dropdown-panel .p-dropdown-items {
- padding: 4px;
- list-style: none;
- margin: 0;
- display: flex;
- flex-direction: column;
- gap: 2px;
-}
-
-.pa-dropdown-panel .p-dropdown-item {
- padding: 10px 12px;
- margin: 0;
- cursor: pointer;
- border-radius: 4px;
- transition: background-color 0.2s;
- color: var(--p-select-option-color);
-}
-
-.pa-dropdown-panel .p-dropdown-item:hover {
- background-color: var(--p-form-field-filled-hover-background);
-}
-
-.pa-dropdown-panel .p-dropdown-item.p-highlight {
- background-color: var(--p-list-option-selected-focus-background);
- color: var(--p-form-field-focus-border-color);
-}
-
-/* 水平表单布局中的宽度适配 */
-.settings-form-group.horizontal .pa-dropdown,
-.settings-form-group.horizontal .p-inputtext {
- width: 100%;
- min-width: 0;
-}
-
-.settings-form-group.horizontal .pa-dropdown-container {
- min-width: 150px;
-}
-
-
-/* 选择器控件样式 */
-.settings-modal .p-dropdown:not(.pa-dropdown) {
- display: inline-flex;
- align-items: center;
- position: relative;
- cursor: pointer;
- user-select: none;
- background: var(--p-select-background);
- border: 1px solid var(--p-select-border-color);
- border-radius: var(--p-select-border-radius);
- transition: border-color 0.2s;
- width: 100%;
- box-shadow: var(--p-select-shadow);
- height: 2.5rem;
-}
-
-.settings-modal .p-dropdown:not(.pa-dropdown):hover {
- border-color: var(--p-form-field-hover-border-color);
-}
-
-.settings-modal .p-dropdown:not(.pa-dropdown).p-focus {
- border-color: var(--p-primary-color);
-}
-
-.settings-modal .p-dropdown-label:not(.pa-dropdown-label) {
- flex: 1 1 auto;
- padding: var(--p-select-padding-y) var(--p-select-padding-x);
- padding-right: 0;
- background: transparent;
- border: 0 none;
- color: var(--p-select-color);
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
- width: 1%;
- height: 100%;
- cursor: pointer;
-}
-
-.settings-modal .p-dropdown-trigger:not(.pa-dropdown-trigger) {
- display: flex;
- align-items: center;
- justify-content: center;
- flex-shrink: 0;
- width: 2.5rem;
- color: var(--p-button-text-secondary-color);
- height: 100%;
-}
-
-/* 下拉面板动画相关样式(排除小助手下拉) */
-.settings-modal .p-dropdown-panel:not(.pa-dropdown-panel),
-.settings-modal-dropdown-panel:not(.pa-dropdown-panel) {
- z-index: var(--settings-dropdown-z-index);
- background: var(--p-dialog-background);
- border: 1px solid var(--p-select-overlay-border-color);
- box-shadow: var(--p-select-overlay-shadow);
- border-radius: var(--p-select-overlay-border-radius);
- transform-origin: center top;
- transition: transform 0.12s cubic-bezier(0, 0, 0.2, 1),
- opacity 0.12s cubic-bezier(0, 0, 0.2, 1);
-}
-
-.settings-modal .p-dropdown-panel:not(.pa-dropdown-panel).p-hidden,
-.settings-modal-dropdown-panel:not(.pa-dropdown-panel).p-hidden {
- opacity: 0;
- transform: scaleY(0.8);
- pointer-events: none;
-}
-
-.settings-modal .p-dropdown-panel:not(.pa-dropdown-panel).p-enter-active,
-.settings-modal-dropdown-panel:not(.pa-dropdown-panel).p-enter-active {
- opacity: 1;
- transform: scaleY(1);
- pointer-events: auto;
-}
-
-.settings-modal .p-dropdown-panel:not(.pa-dropdown-panel) .p-dropdown-items,
-.settings-modal-dropdown-panel:not(.pa-dropdown-panel) .p-dropdown-items {
- padding: var(--p-list-padding, 0.5rem 0);
- list-style: none;
- margin: 0;
- display: flex;
- flex-direction: column;
- gap: var(--p-select-list-gap);
-}
-
-.settings-modal .p-dropdown-panel:not(.pa-dropdown-panel) .p-dropdown-item,
-.settings-modal-dropdown-panel:not(.pa-dropdown-panel) .p-dropdown-item {
- padding: var(--p-list-item-padding, 0.75rem 1rem);
- margin: 0;
- cursor: pointer;
- border-radius: var(--p-select-option-border-radius);
- transition: background-color 0.2s;
- color: var(--p-list-item-color);
-}
-
-.settings-modal .p-dropdown-panel:not(.pa-dropdown-panel) .p-dropdown-item:hover,
-.settings-modal-dropdown-panel:not(.pa-dropdown-panel) .p-dropdown-item:hover {
- background-color: var(--p-select-option-focus-background);
- color: var(--p-select-option-focus-color);
-}
-
-.settings-modal .p-dropdown-panel:not(.pa-dropdown-panel) .p-dropdown-item.p-highlight,
-.settings-modal-dropdown-panel:not(.pa-dropdown-panel) .p-dropdown-item.p-highlight {
- background-color: var(--p-select-option-selected-background);
- color: var(--p-select-option-selected-color);
-}
-
-/* 响应式布局媒体查询 */
-@media screen and (max-width: 640px) {
- .settings-modal {
- width: 95vw;
- min-width: 320px;
- margin: 0.5rem;
- }
-
- .settings-form-group {
- margin-bottom: 0.75rem;
- }
-
- .settings-form-section {
- margin-bottom: 1.5rem;
- padding-bottom: 1rem;
- }
-}
-
-/* 拖拽排序相关样式 */
-.tags-manager-container .tag_accordion.dragging,
-.tags-manager-container .tag_item.dragging {
- opacity: 0.7;
- box-shadow: 0 0 10px rgba(0, 0, 0, 0.3);
- z-index: 100;
-}
-
-.tags-manager-container .tag_accordion.drag-over-top {
- border-top: 3px solid var(--p-primary-500) !important;
- margin-top: -1px;
-}
-
-.tags-manager-container .tag_accordion.drag-over-bottom {
- border-bottom: 3px solid var(--p-primary-500) !important;
- margin-bottom: -1px;
-}
-
-.tags-manager-container .tag_item.drag-over-left {
- border-left: 3px solid var(--p-primary-500) !important;
- margin-left: -1px;
-}
-
-.tags-manager-container .tag_item.drag-over-right {
- border-right: 3px solid var(--p-primary-500) !important;
- margin-right: -1px;
-}
-
-/* 标签管理界面特有样式 */
-.tags-manager-container {
- width: 100%;
- display: flex;
- flex-direction: column;
- gap: 12px;
-}
-
-.tags-scroll-container {
- min-height: 300px;
-}
-
-.tags-manager-container .tag_item {
- cursor: grab;
- user-select: none;
-}
-
-.tags-manager-container .tag_item:active {
- cursor: grabbing;
-}
-
-.tags-manager-container .tag_accordion {
- cursor: grab;
- user-select: none;
- border: 1px solid transparent;
- transition: all 0.2s ease;
-}
-
-.tags-manager-container .tag_accordion:active {
- cursor: grabbing;
-}
-
-/* 标签配置管理器样式 */
-.tags-config-container {
- width: 800px;
- height: 600px;
- overflow: hidden;
- display: flex;
- flex-direction: column;
-}
-
-.tags-config-form {
- display: flex;
- flex-direction: column;
- flex: 1;
- overflow: hidden;
-}
-
-/* 标签管理弹窗大小 */
-.tags-config-dialog {
- width: 760px;
- height: 600px;
-}
-
-.tag-management-section {
- flex: 1;
- display: flex;
- flex-direction: column;
- overflow: hidden;
-}
-
-.tags-action-container {
- display: flex;
- gap: 8px;
- background: transparent;
-}
-
-.tags-scroll-container {
- flex: 1;
- overflow-y: hidden;
- overflow-x: hidden;
- padding: 10px;
- border: 1px solid var(--comfy-menu-secondary-bg);
- border-radius: 8px;
- background-color: var(--p-listbox-background);
- margin-top: 10px;
- margin-bottom: 10px;
-}
-
-.settings-action-btn {
- margin: 0 4px;
-}
-
-/* 删除按钮样式 */
-
-.tag_delete_button {
- background: none;
- border: none;
- color: var(--p-button-text-secondary-color);
- cursor: pointer;
- padding: 2px;
- margin-left: 4px;
- border-radius: 50%;
- display: flex;
- align-items: center;
- justify-content: center;
- width: 24px;
- height: 24px;
- min-width: 24px;
- /* 添加最小宽度 */
- min-height: 24px;
- /* 添加最小高度 */
- transition: all 0.2s;
- opacity: 0.5;
- box-sizing: border-box;
- /* 确保padding不会影响整体大小 */
-}
-
-.tag_delete_button.small {
- width: 20px;
- height: 20px;
- min-width: 20px;
- /* 添加最小宽度 */
- min-height: 20px;
- /* 添加最小高度 */
- padding: 2px;
- box-sizing: border-box;
- /* 确保padding不会影响整体大小 */
-}
-
-.tag_delete_button:hover {
- background: rgba(255, 100, 100, 0.2);
- color: rgba(255, 100, 100, 1);
- opacity: 1;
-}
-
-.tag_delete_icon {
- width: 14px;
- height: 14px;
- min-width: 14px;
- /* 添加最小宽度 */
- min-height: 14px;
- /* 添加最小高度 */
- flex-shrink: 0;
- /* 防止图标被压缩 */
- display: block;
- /* 确保图标显示为块级元素 */
-}
-
-.tag_delete_icon.small {
- width: 12px;
- height: 12px;
- min-width: 12px;
- /* 添加最小宽度 */
- min-height: 12px;
- /* 添加最小高度 */
-}
-
-/* 编辑按钮样式 */
-.tag_edit_button {
- background: none;
- border: none;
- color: var(--p-text-muted-color);
- cursor: pointer;
- padding: 2px;
- margin-left: 4px;
- border-radius: 50%;
- display: flex;
- align-items: center;
- justify-content: center;
- width: 24px;
- height: 24px;
- min-width: 24px;
- /* 添加最小宽度 */
- min-height: 24px;
- /* 添加最小高度 */
- transition: all 0.2s;
- opacity: 0.5;
- box-sizing: border-box;
- /* 确保padding不会影响整体大小 */
-}
-
-.tag_edit_button.small {
- width: 20px;
- height: 20px;
- min-width: 20px;
- /* 添加最小宽度 */
- min-height: 20px;
- /* 添加最小高度 */
- padding: 2px;
- box-sizing: border-box;
- /* 确保padding不会影响整体大小 */
-}
-
-.tag_edit_button:hover {
- background: rgba(255, 255, 255, 0.1);
- color: rgb(207, 215, 211);
- opacity: 1;
-}
-
-.tag_edit_icon {
- color: var(--p-button-text-secondary-color);
- font-size: 12px;
- width: 12px;
- height: 12px;
- display: flex;
- align-items: center;
- justify-content: center;
- transition: color 0.2s ease;
-}
-
-.tag_edit_icon.small {
- width: 10px;
- height: 10px;
-}
-
-.tag_action_icon {
- font-size: 14px;
- width: 14px;
- /* 添加固定宽度 */
- height: 14px;
- /* 添加固定高度 */
- min-width: 14px;
- /* 添加最小宽度 */
- min-height: 14px;
- /* 添加最小高度 */
- flex-shrink: 0;
- /* 防止图标被压缩 */
- display: flex;
- /* 使用flex布局确保内容居中 */
- align-items: center;
- justify-content: center;
-}
-
-.tag_action_icon.small {
- font-size: 12px;
- width: 12px;
- /* 添加固定宽度 */
- height: 12px;
- /* 添加固定高度 */
- min-width: 12px;
- /* 添加最小宽度 */
- min-height: 12px;
- /* 添加最小高度 */
-}
-
-/* 确保图标在按钮中居中显示 */
-.tag_accordion_icon {
- display: flex;
- align-items: center;
- justify-content: flex-end;
- /* 添加右对齐 */
- gap: 4px;
- height: 24px;
- /* 保持固定高度 */
- min-width: 80px;
- /* 确保容器有足够的宽度容纳所有图标 */
-}
-
-.tag_accordion_icon .accordion_arrow_icon {
- display: flex;
- align-items: center;
- justify-content: center;
- width: 24px;
- height: 24px;
- min-width: 24px;
- min-height: 24px;
- padding: 2px;
- border-radius: 50%;
- color: var(--p-button-text-secondary-color);
- opacity: 0.5;
- transition: all 0.2s;
- box-sizing: border-box;
- cursor: pointer;
-}
-
-.tag_accordion_icon .accordion_arrow_icon:hover {
- background: rgba(255, 255, 255, 0.1);
- opacity: 1;
-}
-
-.tag_accordion_icon .accordion_arrow_icon.rotate-180 {
- transform: rotate(180deg);
-}
-
-/* 调整图标容器内部布局 */
-.tag_accordion_header {
- padding: 4px 14px;
- background-color: color-mix(in srgb, var(--comfy-menu-secondary-bg), transparent 60%);
- cursor: pointer;
- user-select: none;
- display: flex;
- justify-content: space-between;
- align-items: center;
- transition: all 0.2s ease;
- border-radius: 6px;
- min-height: 32px;
- /* 确保标题栏有足够的高度 */
-}
-
-.tag_accordion_title {
- flex: 1;
- font-size: 12px;
- color: var(--p-text-muted-color);
- font-weight: 500;
- margin-right: 8px;
- /* 添加右边距,与图标保持距离 */
- overflow: hidden;
- /* 防止文本溢出 */
- text-overflow: ellipsis;
- /* 文本溢出时显示省略号 */
- white-space: nowrap;
- /* 保持单行 */
-}
-
-.tags_container {
- display: flex;
- flex-wrap: wrap;
- gap: 8px;
- padding: 8px;
- background-color: color-mix(in srgb, var(--comfy-menu-secondary-bg), transparent 90%);
- border-radius: 0 0 4px 4px;
-}
-
-/* 使用popup.css中定义的标签样式,只修改cursor为grab */
-.tags-manager-container .tag_item {
- position: relative;
- display: flex;
- align-items: center;
- justify-content: space-between;
- cursor: grab;
-}
-
-.tags-manager-container .tag_item:active {
- cursor: grabbing;
-}
-
-.tags-manager-container .tag_accordion {
- position: relative;
- margin-bottom: 8px;
- border: 1px solid transparent;
- border-radius: 4px;
- transition: all 0.2s ease;
- cursor: default;
- /* 标签管理页面中使用默认指针 */
- user-select: none;
-}
-
-.tags-manager-container .tag_accordion:hover {
- border-color: color-mix(in srgb, var(--comfy-menu-secondary-bg), transparent 60%);
-}
-
-.tags-manager-container .tag_accordion_header {
- cursor: grab;
- /* 只有标题部分可拖动 */
-}
-
-.tags-manager-container .tag_accordion_header:active {
- cursor: grabbing;
- /* 拖动时显示抓取光标 */
-}
-
-/* 拖拽状态样式 */
-.tags-manager-container .tag_accordion.dragging,
-.tags-manager-container .tag_item.dragging {
- opacity: 0.5;
- box-shadow: 0 0 8px rgba(0, 0, 0, 0.2);
- z-index: 10;
-}
-
-/* 新增拖拽动画样式 */
-.tags-manager-container .tag-dragging,
-.tags-manager-container .category-dragging {
- opacity: 0.6 !important;
- /* 移除缩放效果 */
- /* transform: scale(1.02); */
- box-shadow: 0 5px 15px rgba(0, 0, 0, 0.2) !important;
- z-index: 100 !important;
-}
-
-/* 拖拽过程中的目标位置指示器 */
-.tag-ghost,
-.category-ghost {
- opacity: 0.3;
- background-color: color-mix(in srgb, var(--p-primary-500), transparent 85%) !important;
- border: 1px dashed var(--p-primary-500) !important;
-}
-
-/* 被选中的元素样式 */
-.tag-chosen,
-.category-chosen {
- background-color: color-mix(in srgb, var(--p-primary-500), transparent 90%);
- border-color: var(--p-primary-500) !important;
-}
-
-/* 拖拽时的样式 */
-.tag-drag,
-.category-drag {
- opacity: 0.8;
-}
-
-/* 添加元素移动的过渡动画 */
-.tags-manager-container .tag_accordion,
-.tags-manager-container .tag_item {
- /* 移除transform过渡效果 */
- transition: opacity 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
-}
-
-/* 拖拽排序时的位置指示 */
-.sortable-fallback {
- opacity: 0 !important;
-}
-
-.sortable-ghost {
- position: relative;
-}
-
-.sortable-ghost::after {
- content: '';
- position: absolute;
- top: 0;
- left: 0;
- right: 0;
- bottom: 0;
- background-color: color-mix(in srgb, var(--p-primary-500), transparent 90%);
- border: 1px dashed var(--p-primary-500);
- z-index: -1;
- border-radius: 4px;
-}
-
-.tags-manager-container .tag_accordion.drag-over-top {
- border-top: 2px solid var(--p-primary-500);
-}
-
-.tags-manager-container .tag_accordion.drag-over-bottom {
- border-bottom: 2px solid var(--p-primary-500);
-}
-
-.tags-manager-container .tag_item.drag-over-left {
- border-left: 2px solid var(--p-primary-500);
-}
-
-.tags-manager-container .tag_item.drag-over-right {
- border-right: 2px solid var(--p-primary-500);
-}
-
-/* tooltip处理 */
-.has-tooltip {
- position: relative;
-}
-
-/* 标签容器布局 */
-.tags-manager-container {
- display: flex;
- flex-direction: column;
- gap: 10px;
-}
-
-/* 弹窗表单内容样式 */
-.dialog-form-content {
- padding: 1.5rem;
- display: flex;
- flex-direction: column;
- gap: 1rem;
- flex: 1;
-}
-
-.dialog-form-content .settings-form-group {
- margin-bottom: 1rem;
-}
-
-.dialog-form-content .settings-form-group:last-child {
- margin-bottom: 0;
-}
-
-/* 确保下拉框和输入框有合适的宽度和间距 */
-.dialog-form-content .p-dropdown,
-.dialog-form-content .p-inputtext {
- width: 100%;
- box-sizing: border-box;
-}
-
-/* 规则配置表单样式 */
-.rules-config-form {
- max-height: 70vh;
- overflow: auto;
-}
-
-.settings-form-textarea {
- width: 100%;
- min-height: 150px;
- height: calc(100% - 30px);
- resize: vertical;
- font-family: monospace;
- padding: 8px;
- background-color: var(--p-inputtext-background);
- border: 1px solid var(--p-inputtext-border-color);
- border-radius: 4px;
- color: var(--p-inputtext-color);
- flex-grow: 1;
-}
-
-/* API配置表单样式 */
-.api-config-form {
- width: 100%;
- display: flex;
- flex-direction: column;
- gap: 1px;
-}
-
-/* 刷新按钮样式 */
-.settings-refresh-button {
- display: inline-flex;
- align-items: center;
- gap: 4px;
- font-size: 14px;
- color: var(--p-message-info-color);
- cursor: pointer;
- background: transparent;
- border: none;
- padding: 4px 8px;
- border-radius: 4px;
- transition: all 0.2s ease;
-}
-
-.settings-refresh-button:hover {
- background-color: color-mix(in srgb, var(--p-message-info-color), transparent 90%);
-}
-
-.refresh-icon {
- width: 16px;
- height: 16px;
- margin-right: 4px;
- vertical-align: middle;
-}
-
-/* 表单分组的间距 */
-.settings-form-section {
- margin-bottom: 10px;
-}
-
-/* 表单输入组件的样式 */
-.settings-form-group {
- margin-bottom: 12px;
-}
-
-/* 水平布局组的样式 */
-.settings-form-group.horizontal {
- display: flex;
- flex-wrap: wrap;
- gap: 16px;
-}
-
-.settings-form-group.horizontal>div {
- flex: 1;
- min-width: 200px;
-}
-
-/* 添加标签管理部分的样式 */
-.tag-management-section {
- width: 100%;
- max-width: 100%;
- overflow: hidden;
-}
-
-.tags-config-container {
- width: 100%;
- max-width: 100%;
-}
-
-.tags-config-form {
- width: 100%;
- max-width: 100%;
-}
-
-.tags-manager-container {
- width: 100%;
- max-width: 100%;
- overflow: hidden;
-}
-
-.tags-scroll-container {
- width: 100%;
- max-width: 100%;
- max-height: 500px;
- overflow-y: auto;
- overflow-x: hidden;
-}
-
-/* 确保标签手风琴适配宽度 */
-.tag_accordion {
- width: 100%;
- max-width: 100%;
- overflow: hidden;
-}
-
-/* 确保标签项目不会导致容器溢出 */
-.tags_container {
- width: 100%;
- max-width: 100%;
- display: flex;
- flex-wrap: wrap;
- gap: 8px;
-}
-
-.tag_item {
- max-width: calc(100% - 16px);
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-/* 修复弹窗样式,确保内容不会溢出 */
-.settings-dialog {
- max-width: 90vw;
- width: 600px;
-}
-
-.settings-dialog-content {
- width: 100%;
- max-width: 100%;
- overflow: hidden;
-}
-
-.settings-form-section {
- width: 100%;
- max-width: 100%;
- overflow: hidden;
-}
-
-/* 提示词列表样式 */
-.prompt-list-container {
- border: 1px solid var(--p-dialog-border-color);
- border-radius: 4px;
- overflow: hidden;
- background-color: var(--p-listbox-background);
- height: 250px;
- display: flex;
- flex-direction: column;
-}
-
-/* 反推规则配置列表特定高度 */
-.vision-prompt-list-container {
- height: 150px;
-}
-
-.prompt-list-header {
- display: flex;
- background-color: var(--p-button-secondary-background);
- border-bottom: 1px solid var(--p-dialog-border-color);
- font-weight: 600;
- color: var(--p-dialog-header-color);
- flex-shrink: 0;
- position: relative;
-}
-
-.prompt-scroll-list {
- flex: 1;
- overflow-y: auto;
- overflow-x: hidden;
-}
-
-.prompt-list-row {
- display: flex;
- border-bottom: 1px solid var(--p-dialog-border-color);
- transition: background-color 0.2s ease;
- cursor: move;
-}
-
-.prompt-list-row:hover {
- background-color: var(--p-select-option-focus-background);
-}
-
-.prompt-list-row:last-child {
- border-bottom: none;
-}
-
-.prompt-list-row.sortable-ghost {
- opacity: 0.4;
-}
-
-.prompt-list-row.sortable-chosen {
- background-color: var(--p-select-option-selected-background);
-}
-
-.prompt-list-cell {
- padding: 8px 12px;
- border-right: 1px solid var(--p-dialog-border-color);
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- position: relative;
-}
-
-.prompt-list-cell:last-child {
- border-right: none;
-}
-
-.prompt-list-cell.status-cell {
- width: 80px;
- flex-shrink: 0;
- text-align: center;
- cursor: pointer;
-}
-
-.prompt-list-cell.name-cell {
- width: 150px;
- flex-shrink: 0;
- min-width: 100px;
-}
-
-.prompt-list-cell.content-cell {
- flex: 1;
- min-width: 200px;
-}
-
-.prompt-list-cell.action-cell {
- width: 100px;
- flex-shrink: 0;
- text-align: center;
- min-width: 80px;
-}
-
-/* 列宽调整手柄 */
-.column-resizer {
- position: absolute;
- top: 0;
- right: -2px;
- width: 4px;
- height: 100%;
- cursor: col-resize;
- background: transparent;
- z-index: 10;
- transition: background-color 0.2s ease;
-}
-
-.column-resizer:hover {
- background: var(--p-primary-500);
- opacity: 0.8;
-}
-
-.column-resizer.resizing {
- background: var(--p-primary-500);
- opacity: 1;
-}
-
-/* 确保列单元格有相对定位以支持绝对定位的调整手柄 */
-.prompt-list-cell {
- position: relative;
-}
-
-/* 拖拽时的全局样式 */
-body.column-resizing {
- cursor: col-resize !important;
- user-select: none !important;
-}
-
-body.column-resizing * {
- cursor: col-resize !important;
- user-select: none !important;
-}
-
-/* 拖拽排序样式 */
-.sortable-drag {
- opacity: 0.8;
- transform: rotate(2deg);
- box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
-}
-
-.prompt-list-row.sortable-ghost {
- opacity: 0.4;
- background: var(--p-primary-100);
-}
-
-.prompt-list-row.sortable-chosen {
- background: var(--p-select-option-selected-background);
- border: 1px solid var(--p-primary-500);
-}
-
-/* 状态图标样式优化 */
-.status-cell .active-status {
- color: var(--p-green-500);
- cursor: pointer;
- transition: color 0.2s ease;
-}
-
-.status-cell .inactive-status {
- color: var(--p-surface-400);
- cursor: pointer;
- transition: color 0.2s ease;
-}
-
-.status-cell:hover .active-status {
- color: var(--p-green-600);
-}
-
-.status-cell:hover .inactive-status {
- color: var(--p-primary-500);
-}
-
-/* 状态图标样式 */
-.active-status {
- color: var(--p-message-success-color);
-}
-
-.inactive-status {
- color: var(--p-message-warn-color);
-}
-
-/* 操作按钮样式 */
-.prompt-action-btn {
- background: transparent;
- border: none;
- padding: 4px 6px;
- margin: 0 2px;
- border-radius: 3px;
- cursor: pointer;
- transition: background-color 0.2s ease;
- color: var(--p-button-text-secondary-color);
-}
-
-.prompt-action-btn:hover {
- background-color: var(--p-button-secondary-background);
-}
-
-.prompt-action-btn.toggle-btn:hover {
- color: var(--p-message-info-color);
-}
-
-.prompt-action-btn.edit-btn:hover {
- color: var(--p-message-warn-color);
-}
-
-.prompt-action-btn.delete-btn:hover {
- color: var(--p-message-error-color);
-}
-
-/* 规则配置对话框样式 */
-.rules-config-form {
- max-height: 70vh;
- overflow-y: auto;
-}
-
-.settings-modal.prompt-edit-dialog {
- width: 600px !important;
- height: 600px !important;
- max-height: 80vh;
- display: flex;
- flex-direction: column;
-}
-
-.rules-config-content {
- padding: 20px;
- flex: 1;
- display: flex;
- flex-direction: column;
-}
-
-.rules-section {
- margin-bottom: 30px;
-}
-
-.rules-section h3 {
- margin: 0 0 15px 0;
- color: var(--p-dialog-header-color);
- font-size: 16px;
- font-weight: 600;
-}
-
-/* 标签页样式 */
-.settings-modal .popup_tabs_container {
- margin-bottom: 10px;
-}
-
-.settings-modal .popup_tabs_scroll {
- overflow-x: hidden;
- overflow-y: hidden;
-}
-
-.settings-modal .popup_tabs {
- display: flex;
- border-bottom: 1px solid var(--p-dialog-border-color);
- min-width: max-content;
-}
-
-.settings-modal .popup_tab {
- padding: 8px 16px;
- background: var(--p-button-secondary-background);
- border: 1px solid var(--p-dialog-border-color);
- border-bottom: none;
- cursor: pointer;
- margin-right: 2px;
- border-radius: 4px 4px 0 0;
- white-space: nowrap;
- font-size: 13px;
- color: var(--p-button-text-secondary-color);
- transition: background-color 0.2s ease;
-}
-
-.settings-modal .popup_tab.active {
- background: var(--p-dialog-background);
- border-bottom: 1px solid var(--p-dialog-background);
- margin-bottom: -1px;
- font-weight: 500;
- color: var(--p-dialog-header-color);
-}
-
-.settings-modal .popup_tab:hover {
- background: var(--p-select-option-focus-background);
-}
-
-.settings-modal .popup_tab.active:hover {
- background: var(--p-dialog-background);
-}
-
-.settings-modal .popup_tab_content {
- display: none;
-}
-
-.settings-modal .popup_tab_content.active {
- display: block;
-}
-
-/* 对话框按钮样式 */
-.dialog-buttons {
- display: flex;
- justify-content: flex-end;
- gap: 10px;
- margin-top: 20px;
- padding-top: 20px;
- border-top: 1px solid var(--p-dialog-border-color);
-}
-
-.dialog-btn {
- padding: 8px 16px;
- border: none;
- border-radius: 4px;
- cursor: pointer;
- font-size: 14px;
- transition: background-color 0.2s ease;
-}
-
-.dialog-btn.primary {
- background: var(--p-button-primary-background);
- color: var(--p-button-primary-color);
-}
-
-.dialog-btn.primary:hover {
- background: var(--p-button-primary-hover-background);
-}
-
-.dialog-btn.secondary {
- background: var(--p-button-secondary-background);
- color: var(--p-button-secondary-color);
-}
-
-.dialog-btn.secondary:hover {
- background: var(--p-button-secondary-hover-background);
-}
-
-
-
-/* 微信徽标悬浮二维码 */
-.pa-wechat-badge {
- position: relative;
- /* 作为二维码定位参照 */
- /* 让绝对定位参照到内容容器 */
-}
-
-.pa-wechat-qr {
- position: absolute;
- opacity: 0;
- visibility: hidden;
- top: calc(100% + 6px);
- right: 10px;
- /* 向左偏移,避免被滚动条遮挡 */
- transform: none;
- background: var(--p-dialog-background);
- border: 1px solid var(--p-dialog-border-color);
- border-radius: 10px;
- padding: 6px;
- box-shadow: var(--p-dialog-shadow);
- z-index: calc(var(--settings-modal-z-index) + 5);
- /* 淡入淡出动画 */
- transition: opacity 0.3s ease, visibility 0.3s ease;
-}
-
-.pa-wechat-badge:hover .pa-wechat-qr {
- opacity: 1;
- visibility: visible;
-}
-
-.pa-wechat-qr-img {
- display: block;
- width: 256px;
- height: auto;
-}
\ No newline at end of file
diff --git a/js/css/uiComponents.css b/js/css/uiComponents.css
new file mode 100644
index 0000000..fc96291
--- /dev/null
+++ b/js/css/uiComponents.css
@@ -0,0 +1,3533 @@
+/**
+ * 设置界面样式
+ *
+ * 样式分类说明:
+ * 1. 全局基础样式 - CSS 变量、Z-index 层级管理
+ * 2. 设置弹窗通用样式 - 所有设置模块共用的弹窗、表单、按钮等样式
+ * 3. API 配置管理器专属样式 - API 配置界面的标签页、服务卡片等组件
+ * 4. 标签管理器专属样式 - 标签管理界面的拖拽、手风琴等组件
+ * 5. 规则配置专属样式 - 提示词规则配置界面的列表、编辑器等组件
+ */
+
+/* ===================================================
+ 1. 全局基础样式
+ =================================================== */
+
+/* ---Z-index 层级管理--- */
+/* 层级规划:
+ * - ComfyUI 系统弹窗:约 9000-9999
+ * - 本插件遮罩层:10199
+ * - 本插件弹窗基础层级:10200
+ * - 下拉菜单:10205
+ * - 右键菜单:10215
+ * - 多选列表框:10220
+ * - 确认气泡框:10225
+ * - Tooltip:10230(最上层)
+ */
+:root {
+ --settings-modal-z-index: 10200;
+ --settings-overlay-z-index: 10199;
+ --settings-dropdown-z-index: 10205;
+ --settings-context-menu-z-index: 10215;
+ --settings-multi-listbox-z-index: 10220;
+ --settings-confirm-popup-z-index: 10225;
+ --settings-tooltip-z-index: 10230;
+}
+
+/* ===================================================
+ 2. 设置弹窗通用样式 (所有设置模块共用)
+ =================================================== */
+
+/* ---遮罩层样式--- */
+.settings-modal-overlay {
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ background-color: rgba(0, 0, 0, 0.5);
+ z-index: var(--settings-overlay-z-index);
+ opacity: 0;
+ visibility: hidden;
+ transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
+}
+
+.settings-modal-overlay.overlay-show {
+ opacity: 1;
+ visibility: visible;
+}
+
+.settings-modal-overlay.overlay-hide {
+ opacity: 0;
+ visibility: hidden;
+}
+
+/* 设置面板容器 */
+.settings-panel {
+ padding: 1rem;
+}
+
+/* 设置项组 */
+.settings-group {
+ margin-bottom: 1.5rem;
+}
+
+/* 设置项标题 */
+.settings-title {
+ font-size: 1rem;
+ font-weight: 600;
+ margin-bottom: 0.5rem;
+ color: var(--fg-color);
+}
+
+/* 服务申请链接容器样式 */
+.settings-service-links {
+ display: inline-flex;
+ align-items: center;
+ background-color: var(--p-button-secondary-background);
+ border-radius: 10px;
+ padding: 2px 8px;
+}
+
+
+/* 服务申请前缀文本样式(与链接字号一致) */
+.settings-service-prefix {
+ color: var(--p-button-text-primary-color);
+ font-size: 14px;
+ padding: 2px 4px;
+}
+
+/* 服务申请链接样式 */
+.settings-service-link {
+ display: inline-block;
+ padding: 4px 4px;
+ color: var(--p-button-text-primary-color) !important;
+ text-decoration: none;
+ font-size: 14px;
+ font-weight: normal;
+ transition: color 0.2s ease;
+}
+
+.settings-service-link:hover {
+ color: var(--p-button-text-primary-color) !important;
+ text-decoration: none !important;
+}
+
+/* 服务链接分隔符 */
+.settings-service-separator {
+ color: var(--p-button-text-primary-color);
+ opacity: 0.5;
+ padding: 0 2px;
+ user-select: none;
+}
+
+/* 设置项内容 */
+.settings-content {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+}
+
+/* 按钮组样式 */
+.button-group {
+ display: flex;
+ gap: 0.5rem;
+ margin-top: 0.5rem;
+}
+
+/* ---设置弹窗样式--- */
+.settings-modal {
+ position: fixed;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%) scale(0.8);
+ /* 响应式宽度设置 */
+ width: min(90vw, 1048px);
+ min-width: 800px;
+ /* 响应式高度设置 */
+ max-height: 90vh;
+ min-height: 600px;
+ /* height: 760px; */
+ margin: 0;
+ z-index: var(--settings-modal-z-index);
+ background-color: var(--p-dialog-background);
+ border: 1px solid var(--p-dialog-border-color);
+ border-radius: var(--p-dialog-border-radius);
+ box-shadow: var(--p-dialog-shadow);
+ color: var(--p-dialog-color);
+ /* 添加弹性布局 */
+ display: flex;
+ flex-direction: column;
+ /* 添加动画属性 */
+ opacity: 0;
+ visibility: hidden;
+ transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
+}
+
+/* ---二次确认对话框独立样式--- */
+/* 适用于各种需要二次确认的场景:删除、修改、更新等 */
+.settings-modal.confirm-dialog {
+ /* 使用 max-width 而不是 width,让对话框根据内容自适应宽度 */
+ width: fit-content;
+ max-width: min(90vw, 600px);
+ /* 设置合理的最小宽度,避免对话框过窄 */
+ min-width: min(400px, 80vw);
+ min-height: auto;
+ height: auto;
+}
+
+/* 确认对话框内容样式 */
+.confirm-dialog-content {
+ text-align: center;
+ padding: 2rem 1rem;
+}
+
+.confirm-dialog-content-simple {
+ text-align: center;
+ padding: 1rem;
+}
+
+.confirm-dialog-icon {
+ font-size: 48px;
+ color: var(--p-orange-500);
+ margin-bottom: 1rem;
+}
+
+.confirm-dialog-message {
+ margin: 0 0 0.5rem 0;
+ font-size: 1.1rem;
+ font-weight: 500;
+}
+
+.confirm-dialog-message-simple {
+ margin: 0;
+ font-size: 1rem;
+ color: var(--p-text-color);
+}
+
+.confirm-dialog-warning {
+ margin: 0;
+ font-size: 0.9rem;
+ color: var(--p-text-muted-color);
+}
+
+/* 弹窗显示动画类 */
+.settings-modal.modal-show {
+ opacity: 1;
+ visibility: visible;
+ transform: translate(-50%, -50%) scale(1);
+}
+
+/* 弹窗隐藏动画类 */
+.settings-modal.modal-hide {
+ opacity: 0;
+ visibility: hidden;
+ transform: translate(-50%, -50%) scale(0.8);
+}
+
+/* 拖动时的状态 */
+.settings-modal.dragging {
+ transition: none;
+ cursor: move;
+}
+
+/* 标题栏样式 */
+.settings-modal .p-dialog-header {
+ padding: var(--p-dialog-header-padding);
+ border-bottom: 1px solid var(--p-dialog-border-color);
+
+ /* 固定头部 */
+ flex: 0 0 auto;
+ user-select: none;
+}
+
+.settings-modal .p-dialog-content {
+ padding: 10px 40px;
+ position: relative;
+ /* 作为二维码的定位参照,避免被外层裁剪 */
+ /* 内容区域自适应 */
+ flex: 1 1 auto;
+ overflow-y: auto;
+ /* 添加平滑滚动 */
+ scroll-behavior: smooth;
+ /* 优化滚动条样式 */
+ scrollbar-width: thin;
+ scrollbar-color: var(--p-scrollpanel-bar-background) transparent;
+}
+
+/* 自定义滚动条样式 */
+.settings-modal .p-dialog-content::-webkit-scrollbar {
+ width: 10px;
+}
+
+.settings-modal .p-dialog-content::-webkit-scrollbar-track {
+ background: transparent;
+}
+
+.settings-modal .p-dialog-content::-webkit-scrollbar-thumb {
+ background: var(--p-scrollpanel-bar-background);
+ border-radius: 4px;
+}
+
+.settings-modal .p-dialog-content::-webkit-scrollbar-thumb:hover {
+ background: var(--p-scrollpanel-bar-background);
+}
+
+.settings-modal .p-dialog-footer {
+ padding: 10px 40px 20px 40px;
+
+
+ gap: 0.9rem;
+ /* 固定底部 */
+ flex: 0 0 auto;
+}
+
+/* 表单部分样式 */
+.settings-form-section {
+ /* margin-bottom: 2rem; */
+ /* padding-bottom: 0.5rem; */
+ /* border-bottom: 1px solid var(--p-dialog-border-color); */
+}
+
+.settings-form-section.baidu-translate-section {
+ margin-bottom: 0;
+ padding-bottom: 0;
+ border-bottom: none;
+ padding: 50px 20px 80px 20px;
+ background: color-mix(in srgb, var(--secondary-background), transparent 30%);
+ border-radius: 6px;
+ /* box-shadow: inset 0 2px 6px rgba(0, 0, 0, 0.05); */
+ /* border: 1px solid var(--p-dialog-border-color); */
+}
+
+.settings-form-section-title {
+ font-size: 16px;
+ font-weight: 600;
+ margin: 0 0 1rem 0;
+ color: var(--p-text-muted-color);
+}
+
+/* 表单组样式 */
+.settings-form-group {
+ margin-bottom: 1rem;
+}
+
+/* 水平布局的表单组 */
+.settings-form-group.horizontal {
+ display: flex;
+ gap: 1rem;
+ align-items: flex-start;
+}
+
+.settings-form-group.horizontal>* {
+ flex: 1;
+ min-width: 0;
+ /* 防止flex项溢出 */
+}
+
+/* 调整水平布局中标签的样式 */
+.settings-form-group.horizontal .settings-form-label {
+ margin-bottom: 0.5rem;
+}
+
+/* 调整下拉选择器在水平布局中的样式 */
+.settings-form-group.horizontal .p-dropdown,
+.settings-form-group.horizontal .p-inputtext {
+ width: 100%;
+ min-width: 0;
+}
+
+/* 确保下拉选择器的容器不会被挤压 */
+.settings-form-group.horizontal .p-dropdown-container {
+ min-width: 150px;
+}
+
+/* 确保输入框容器不会被挤压 */
+.settings-form-group.horizontal .p-input-wrapper {
+ min-width: 150px;
+}
+
+.settings-form-group:last-child {
+ margin-bottom: 0;
+}
+
+.settings-form-label {
+ display: block;
+ margin-bottom: 0.5rem;
+ font-weight: 500;
+ color: var(--p-text-color);
+}
+
+/* ---浮动标签输入框样式 (PrimeVue FloatLabel variant="on")--- */
+.float-label-container {
+ position: relative;
+ width: 100%;
+}
+
+.float-label-container label {
+ position: absolute;
+ left: 0.75rem;
+ top: 50%;
+ transform: translateY(-50%);
+ color: var(--p-text-muted-color);
+ font-size: 1rem;
+ font-weight: 400;
+ transition: all 0.2s ease;
+ pointer-events: none;
+ background: var(--p-inputtext-background);
+ border-radius: 6px;
+ padding: 0 0.25rem;
+ z-index: 1;
+}
+
+.float-label-container input {
+ width: 100%;
+ padding: 0.75rem;
+ font-size: 1rem;
+ border: 1px solid var(--p-inputtext-border-color);
+ border-radius: var(--p-inputtext-border-radius, 6px);
+ background: var(--p-inputtext-background);
+ color: var(--p-inputtext-color);
+ transition: border-color 0.2s, box-shadow 0.2s;
+ box-shadow: var(--p-inputtext-shadow);
+}
+
+.float-label-container input:focus {
+ outline: none;
+ border-color: var(--p-inputtext-focus-border-color);
+ box-shadow: var(--p-inputtext-focus-ring-shadow);
+}
+
+.float-label-container input:hover:not(:focus) {
+ border-color: var(--p-inputtext-hover-border-color);
+}
+
+/* 浮动状态 - 当输入框有值或获得焦点时 */
+.float-label-container input:focus~label,
+.float-label-container input:not(:placeholder-shown)~label {
+ top: 0;
+ transform: translateY(-50%);
+ font-size: 0.75rem;
+ color: var(--p-text-muted-color);
+ font-weight: 500;
+}
+
+/* 密码输入框样式 */
+.float-label-container input[type="password"] {
+ font-family: monospace;
+}
+
+/* ---浮动标签 textarea 样式--- */
+.float-label-container textarea {
+ width: 100%;
+ padding: 0.75rem;
+ font-size: 1rem;
+ border: 1px solid var(--p-inputtext-border-color);
+ border-radius: var(--p-inputtext-border-radius, 6px);
+ background: var(--p-inputtext-background);
+ color: var(--p-inputtext-color);
+ transition: border-color 0.2s, box-shadow 0.2s;
+ box-shadow: var(--p-inputtext-shadow);
+}
+
+.float-label-container textarea:focus {
+ outline: none;
+ border-color: var(--p-inputtext-focus-border-color);
+ box-shadow: var(--p-inputtext-focus-ring-shadow);
+}
+
+.float-label-container textarea:hover:not(:focus) {
+ border-color: var(--p-inputtext-hover-border-color);
+}
+
+/* textarea 的标签初始位置(因为 textarea 高度不固定,使用固定的 top 值) */
+.float-label-container textarea~label {
+ top: 1rem;
+ transform: translateY(0);
+}
+
+/* 浮动状态 - 当 textarea 有值或获得焦点时 */
+.float-label-container textarea:focus~label,
+.float-label-container textarea:not(:placeholder-shown)~label {
+ top: 0;
+ transform: translateY(-50%);
+ font-size: 0.75rem;
+ color: var(--p-text-muted-color);
+ font-weight: 500;
+}
+
+/* 输入框容器样式 */
+.flex-1 {
+ flex: 1;
+ width: 100%;
+}
+
+/* ---浮动标签 dropdown 样式--- */
+/* dropdown 的标签默认浮动状态(因为 dropdown 总是有值) */
+.float-label-container .pa-dropdown~label {
+ top: 0;
+ transform: translateY(-50%);
+ font-size: 0.75rem;
+ color: var(--p-text-muted-color);
+ font-weight: 500;
+}
+
+/* dropdown 获得焦点时的标签样式 */
+.float-label-container .pa-dropdown.p-focus~label {
+ color: var(--p-inputtext-focus-border-color);
+}
+
+/* 优化关闭按钮悬停效果 */
+.settings-modal .p-dialog-header-icon.p-dialog-header-close {
+ width: 2rem;
+ height: 2rem;
+ border-radius: 50%;
+ transition: background-color 0.2s;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ margin-left: 0.5rem;
+ color: var(--p-button-text-secondary-color);
+ background-color: transparent;
+ border: none;
+ cursor: pointer;
+}
+
+.settings-modal .p-dialog-header-icon.p-dialog-header-close:hover {
+ background-color: var(--p-button-text-secondary-hover-background);
+ ;
+
+}
+
+.settings-modal .p-dialog-header-icon.p-dialog-header-close .pi-times {
+ font-size: 1rem;
+
+}
+
+/* ---危险按钮样式--- */
+/* 用于删除等危险操作的按钮 */
+.p-button-danger {
+ background: var(--p-button-danger-background);
+ border-color: var(--p-button-danger-border-color);
+ color: var(--p-button-danger-color);
+}
+
+.p-button-danger:hover {
+ background: var(--p-button-danger-hover-background);
+ border-color: var(--p-button-danger-hover-border-color);
+ color: var(--p-button-danger-color);
+}
+
+.p-button-danger:active,
+.p-button-danger:focus {
+ outline: none;
+ box-shadow: 0 0 0 0.2rem var(--p-button-danger-active-border-color);
+}
+
+
+/* 独立下拉选择组件样式(不依赖 PrimeVue 全局样式) */
+/* 仅影响通过小助手创建的选择框,不改变ComfyUI内置组件 */
+.pa-dropdown {
+ display: inline-flex;
+ align-items: center;
+ position: relative;
+ cursor: pointer;
+ user-select: none;
+ background: var(--p-inputtext-background);
+ border: 1px solid var(--p-inputtext-border-color);
+ border-radius: 6px;
+ transition: border-color 0.2s;
+ width: 100%;
+ box-shadow: var(--p-inputtext-shadow);
+ height: 2.5rem;
+}
+
+.pa-dropdown:hover {
+ border-color: var(--p-form-field-hover-border-color);
+}
+
+.pa-dropdown.p-focus {
+ border-color: var(--p-form-field-focus-border-color);
+}
+
+.pa-dropdown .pa-dropdown-label {
+ flex: 1 1 auto;
+ border: 0 none;
+ color: var(--p-inputtext-color);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ width: 1%;
+ height: 100%;
+ cursor: pointer;
+}
+
+.pa-dropdown .pa-dropdown-trigger {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+ width: 2.5rem;
+ color: var(--p-form-field-hover-border-color);
+ height: 100%;
+}
+
+/* 下拉面板(独立样式) */
+.pa-dropdown-panel {
+ z-index: var(--settings-dropdown-z-index);
+ background: var(--p-select-overlay-background);
+ border: 1px solid var(--p-select-overlay-border-color);
+ box-shadow: var(--p-inputtext-shadow);
+ border-radius: 6px;
+ transform-origin: center top;
+ transition: transform 0.12s cubic-bezier(0, 0, 0.2, 1),
+ opacity 0.12s cubic-bezier(0, 0, 0.2, 1);
+}
+
+.pa-dropdown-panel.p-hidden {
+ opacity: 0;
+ transform: scaleY(0.8);
+ pointer-events: none;
+}
+
+.pa-dropdown-panel.p-enter-active {
+ opacity: 1;
+ transform: scaleY(1);
+ pointer-events: auto;
+}
+
+/* 隐藏辅助可访问性选择器(在未加载PrimeVue时也生效) */
+.p-hidden-accessible {
+ position: absolute !important;
+ width: 1px !important;
+ height: 1px !important;
+ overflow: hidden !important;
+ clip: rect(0 0 0 0) !important;
+ clip-path: inset(50%) !important;
+ white-space: nowrap !important;
+ border: 0 !important;
+ padding: 0 !important;
+ margin: -1px !important;
+}
+
+/* 面板内容滚动容器(独立样式) */
+.pa-dropdown-panel .p-dropdown-items-wrapper {
+ max-height: 240px;
+ overflow: auto;
+}
+
+
+/* 列表与选项(独立样式) */
+.pa-dropdown-panel .p-dropdown-items {
+ padding: 4px;
+ list-style: none;
+ margin: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+
+.pa-dropdown-panel .p-dropdown-item {
+ padding: 10px 12px;
+ margin: 0;
+ cursor: pointer;
+ border-radius: 4px;
+ transition: background-color 0.2s;
+ color: var(--p-select-option-color);
+}
+
+.pa-dropdown-panel .p-dropdown-item:hover {
+ background-color: var(--p-form-field-filled-hover-background);
+}
+
+.pa-dropdown-panel .p-dropdown-item.p-highlight {
+ background-color: var(--p-list-option-selected-focus-background);
+ color: var(--p-form-field-focus-border-color);
+}
+
+/* ---可输入下拉框(ComboBox)样式--- */
+.pa-combobox {
+ display: inline-flex;
+ align-items: center;
+}
+
+.pa-combobox-input {
+ flex: 1 1 auto;
+ border: 0 none;
+ background: transparent;
+ color: var(--p-inputtext-color);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ width: 1%;
+ height: 100%;
+ padding: 0 0.75rem;
+ font-size: 1rem;
+ outline: none;
+}
+
+.pa-combobox-input::placeholder {
+ color: var(--p-text-muted-color);
+}
+
+/* ComboBox 浮动标签:输入框有值或聚焦时上浮 */
+.float-label-container .pa-combobox~label {
+ top: 0;
+ transform: translateY(-50%);
+ font-size: 0.75rem;
+ color: var(--p-text-muted-color);
+ font-weight: 500;
+}
+
+.float-label-container .pa-combobox.p-focus~label {
+ color: var(--p-inputtext-focus-border-color);
+}
+
+/* 水平表单布局中的宽度适配 */
+.settings-form-group.horizontal .pa-dropdown,
+.settings-form-group.horizontal .p-inputtext {
+ width: 100%;
+ min-width: 0;
+}
+
+.settings-form-group.horizontal .pa-dropdown-container {
+ min-width: 150px;
+}
+
+
+/* 选择器控件样式 */
+.settings-modal .p-dropdown:not(.pa-dropdown) {
+ display: inline-flex;
+ align-items: center;
+ position: relative;
+ cursor: pointer;
+ user-select: none;
+ background: var(--p-select-background);
+ border: 1px solid var(--p-select-border-color);
+ border-radius: var(--p-select-border-radius);
+ transition: border-color 0.2s;
+ width: 100%;
+ box-shadow: var(--p-select-shadow);
+ height: 2.5rem;
+}
+
+.settings-modal .p-dropdown:not(.pa-dropdown):hover {
+ border-color: var(--p-form-field-hover-border-color);
+}
+
+.settings-modal .p-dropdown:not(.pa-dropdown).p-focus {
+ border-color: var(--p-primary-color);
+}
+
+.settings-modal .p-dropdown-label:not(.pa-dropdown-label) {
+ flex: 1 1 auto;
+ padding: var(--p-select-padding-y) var(--p-select-padding-x);
+ padding-right: 0;
+ background: transparent;
+ border: 0 none;
+ color: var(--p-select-color);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ width: 1%;
+ height: 100%;
+ cursor: pointer;
+}
+
+.settings-modal .p-dropdown-trigger:not(.pa-dropdown-trigger) {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+ width: 2.5rem;
+ color: var(--p-button-text-secondary-color);
+ height: 100%;
+}
+
+/* 下拉面板动画相关样式(排除小助手下拉) */
+.settings-modal .p-dropdown-panel:not(.pa-dropdown-panel),
+.settings-modal-dropdown-panel:not(.pa-dropdown-panel) {
+ z-index: var(--settings-dropdown-z-index);
+ background: var(--p-dialog-background);
+ border: 1px solid var(--p-select-overlay-border-color);
+ box-shadow: var(--p-select-overlay-shadow);
+ border-radius: var(--p-select-overlay-border-radius);
+ transform-origin: center top;
+ transition: transform 0.12s cubic-bezier(0, 0, 0.2, 1),
+ opacity 0.12s cubic-bezier(0, 0, 0.2, 1);
+}
+
+.settings-modal .p-dropdown-panel:not(.pa-dropdown-panel).p-hidden,
+.settings-modal-dropdown-panel:not(.pa-dropdown-panel).p-hidden {
+ opacity: 0;
+ transform: scaleY(0.8);
+ pointer-events: none;
+}
+
+.settings-modal .p-dropdown-panel:not(.pa-dropdown-panel).p-enter-active,
+.settings-modal-dropdown-panel:not(.pa-dropdown-panel).p-enter-active {
+ opacity: 1;
+ transform: scaleY(1);
+ pointer-events: auto;
+}
+
+.settings-modal .p-dropdown-panel:not(.pa-dropdown-panel) .p-dropdown-items,
+.settings-modal-dropdown-panel:not(.pa-dropdown-panel) .p-dropdown-items {
+ padding: var(--p-list-padding, 0.5rem 0);
+ list-style: none;
+ margin: 0;
+ display: flex;
+ flex-direction: column;
+ gap: var(--p-select-list-gap);
+}
+
+.settings-modal .p-dropdown-panel:not(.pa-dropdown-panel) .p-dropdown-item,
+.settings-modal-dropdown-panel:not(.pa-dropdown-panel) .p-dropdown-item {
+ padding: var(--p-list-item-padding, 0.75rem 1rem);
+ margin: 0;
+ cursor: pointer;
+ border-radius: var(--p-select-option-border-radius);
+ transition: background-color 0.2s;
+ color: var(--p-list-item-color);
+}
+
+.settings-modal .p-dropdown-panel:not(.pa-dropdown-panel) .p-dropdown-item:hover,
+.settings-modal-dropdown-panel:not(.pa-dropdown-panel) .p-dropdown-item:hover {
+ background-color: var(--p-select-option-focus-background);
+ color: var(--p-select-option-focus-color);
+}
+
+.settings-modal .p-dropdown-panel:not(.pa-dropdown-panel) .p-dropdown-item.p-highlight,
+.settings-modal-dropdown-panel:not(.pa-dropdown-panel) .p-dropdown-item.p-highlight {
+ background-color: var(--p-select-option-selected-background);
+ color: var(--p-select-option-selected-color);
+}
+
+/* 响应式布局媒体查询 */
+@media screen and (max-width: 640px) {
+ .settings-modal {
+ width: 95vw;
+ min-width: 320px;
+ margin: 0.5rem;
+ }
+
+ .settings-form-group {
+ margin-bottom: 0.75rem;
+ }
+
+ .settings-form-section {
+ margin-bottom: 1.5rem;
+ padding-bottom: 1rem;
+ }
+}
+
+/* ===================================================
+ 3. API 配置管理器专属样式
+ =================================================== */
+
+/* ---标签页容器--- */
+.api-config-tabs {
+ width: 100%;
+}
+
+/* ---标签页头部--- */
+.tab-header {
+ display: flex;
+ gap: 8px;
+ border-bottom: 2px solid var(--p-divider-color);
+ padding-bottom: 8px;
+ margin-bottom: 2px;
+ overflow-x: auto;
+ justify-content: flex-start;
+}
+
+/* ---标签按钮--- */
+.tab-button {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ padding: 8px 16px;
+ border: none;
+ background: transparent;
+ cursor: pointer;
+ border-radius: 4px;
+ min-width: 100px;
+ /* 移除 transform 过渡,避免干扰 Sortable.js 的拖拽动画 */
+ transition: background 0.2s, color 0.2s, opacity 0.2s;
+ flex-shrink: 0;
+}
+
+.tab-button.active {
+ background: var(--p-primary-500);
+}
+
+.tab-button:hover:not(.active) {
+ background: var(--p-list-option-focus-background);
+
+}
+
+
+
+.tab-button.active .tab-title {
+ color: var(--p-button-primary-color);
+}
+
+.tab-button.active .tab-subtitle {
+ color: color-mix(in srgb, var(--p-button-primary-color), transparent 30%);
+}
+
+/* ---标签标题--- */
+.tab-title {
+ font-size: 16px;
+ font-weight: 500;
+ color: var(--p-text-color);
+ white-space: nowrap;
+ font-weight: 600;
+}
+
+/* ---标签副标题--- */
+.tab-subtitle {
+ font-size: 12px;
+ color: color-mix(in srgb, var(--p-text-muted-color), transparent 30%);
+ margin-top: 2px;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ max-width: 150px;
+}
+
+/* ---新增服务标签按钮--- */
+.service-tab-add {
+ padding: 8px 16px;
+ border: 1px dashed var(--p-divider-color);
+ background: transparent;
+ color: var(--p-text-muted-color);
+ cursor: pointer;
+ border-radius: 4px;
+ min-width: 40px;
+ transition: all 0.2s;
+ flex-shrink: 0;
+}
+
+.service-tab-add:hover {
+ border-color: var(--p-primary-500);
+ color: var(--p-primary-500);
+ background: var(--p-list-option-focus-background);
+}
+
+/* ---标签内容容器--- */
+.tab-content {
+ width: 100%;
+}
+
+/* ---标签页面板--- */
+.tab-pane {
+ padding: 16px;
+ display: none;
+}
+
+.tab-pane.active {
+ display: block;
+}
+
+/* ---服务卡片--- */
+.service-card {
+ border: 1px solid var(--p-divider-color);
+ border-radius: 8px;
+ padding: 6px 20px 20px 20px;
+ background: var(--p-content-background);
+}
+
+/* 标签页容器通用样式 */
+.tab-pane {
+ padding: 16px;
+ display: none;
+}
+
+.tab-pane.active .service-sub-tabs .service-tab-button:first-child {
+ background: var(--p-primary-500);
+ color: white;
+}
+
+/* 服务商二级标签导航 */
+.service-sub-tabs {
+ display: flex;
+ gap: 8px;
+ border-bottom: 2px solid var(--p-divider-color);
+ padding-bottom: 8px;
+ margin-bottom: 16px;
+ overflow-x: auto;
+}
+
+/* 服务商二级标签按钮 */
+.service-tab-button {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ padding: 8px 16px;
+ border: none;
+ background: transparent;
+ cursor: pointer;
+ border-radius: 4px;
+ min-width: 120px;
+ transition: all 0.2s;
+}
+
+.service-tab-button:hover:not(.active) {
+ background: var(--p-surface-100);
+}
+
+.service-tab-button.active {
+ background: var(--p-primary-500);
+ color: white;
+}
+
+.service-tab-title {
+ font-size: 14px;
+ font-weight: 500;
+ color: var(--p-text-color);
+ white-space: nowrap;
+}
+
+.service-tab-button.active .service-tab-title {
+ color: white;
+}
+
+.service-tab-subtitle {
+ font-size: 12px;
+ color: var(--p-text-muted-color);
+ margin-top: 2px;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ max-width: 150px;
+}
+
+.service-tab-button.active .service-tab-subtitle {
+ color: rgba(255, 255, 255, 0.8);
+}
+
+/* 新增服务标签按钮 */
+.service-tab-add {
+ padding: 8px 16px;
+ border: 1px dashed var(--p-divider-color);
+ background: transparent;
+ color: var(--p-text-color);
+ cursor: pointer;
+ border-radius: 4px;
+ min-width: 40px;
+ transition: all 0.2s;
+}
+
+.service-tab-add:hover {
+ border-color: var(--p-primary-500);
+ color: var(--p-primary-500);
+}
+
+/* 空状态提示 */
+.empty-state-hint {
+ text-align: center;
+ padding: 60px 20px;
+ color: var(--p-text-muted-color);
+}
+
+.empty-state-hint-small {
+ text-align: center;
+ padding: 40px;
+ color: var(--p-text-muted-color);
+}
+
+.service-card-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 12px;
+}
+
+.service-card-title {
+ margin: 0;
+ color: var(--p-text-color);
+ font-size: 16px;
+}
+
+.service-card-delete-btn {
+ padding: 4px 8px;
+ border: none;
+ background: none;
+ color: var(--p-red-500);
+ cursor: pointer;
+ font-size: 12px;
+}
+
+/* ---简化版服务配置项--- */
+.service-setting-item {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ margin-left: 16px;
+}
+
+.service-setting-item:first-child {
+ margin-left: 0;
+}
+
+.service-setting-label-wrapper {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+}
+
+.service-setting-label {
+ font-size: 14px;
+ font-weight: 500;
+ color: var(--p-text-color);
+ white-space: nowrap;
+}
+
+.service-setting-info-icon {
+ font-size: 16px;
+ color: var(--p-text-muted-color);
+ transition: color 0.2s ease;
+}
+
+.service-setting-info-icon:hover {
+ color: var(--p-primary-500);
+}
+
+/* 配置项容器 - 右对齐 */
+.service-settings-inline {
+ display: flex;
+ justify-content: flex-end;
+ align-items: center;
+ margin-top: 16px;
+ /* padding-top: 12px; */
+ /* border-top: 1px solid var(--p-divider-color); */
+ padding-bottom: 1rem;
+ border-bottom: 1px solid var(--p-dialog-border-color);
+}
+
+/* ---开关控件(简化版)--- */
+
+.switch-wrapper {
+ position: relative;
+ display: inline-block;
+ width: 44px;
+ height: 24px;
+ margin-left: 12px;
+ flex-shrink: 0;
+}
+
+.switch-wrapper input {
+ opacity: 0;
+ width: 0;
+ height: 0;
+}
+
+.switch-slider {
+ position: absolute;
+ cursor: pointer;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background-color: #ccc;
+ transition: 0.3s;
+ border-radius: 24px;
+}
+
+.switch-slider.checked {
+ background-color: var(--p-primary-500);
+}
+
+.switch-button {
+ position: absolute;
+ content: "";
+ height: 18px;
+ width: 18px;
+ left: 3px;
+ bottom: 3px;
+ background-color: white;
+ transition: 0.3s;
+ border-radius: 50%;
+}
+
+.switch-button.checked {
+ left: 23px;
+}
+
+/* ---模型配置区域--- */
+.models-container {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ min-height: 40px;
+ padding: 10px;
+ background: var(--secondary-background);
+ border-radius: 14px;
+ border: 1px dashed var(--p-divider-color);
+ box-shadow: inset 0 2px 6px rgba(0, 0, 0, 0.05);
+}
+
+/* ---模型标签--- */
+.model-tag {
+ display: inline-flex;
+ align-items: center;
+ padding: 6px 10px;
+ background: var(--p-listbox-option-selected-background);
+ color: var(--p-listbox-option-selected-color);
+ border-radius: 10px;
+ font-size: 13px;
+ cursor: pointer;
+ user-select: none;
+ /* 移除 transform 过渡,避免干扰 Sortable.js 的拖拽动画 */
+ transition: background 0.2s, color 0.2s, border-color 0.2s, box-shadow 0.2s;
+ border: 2px solid transparent;
+}
+
+.model-tag.default {
+ background: var(--p-primary-500);
+ color: white;
+}
+
+.model-tag.selected {
+ border-color: var(--p-primary-500);
+}
+
+.model-tag:hover {
+ transform: translateY(-2px);
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
+}
+
+.model-tag-icon {
+ font-size: 12px;
+ margin-right: 6px;
+ opacity: 0.8;
+}
+
+.model-tag-name {
+ margin-right: 6px;
+}
+
+.model-tag-badge {
+ font-size: 10px;
+ padding: 2px 4px;
+ background: rgba(255, 255, 255, 0.3);
+ border-radius: 2px;
+ margin-right: 6px;
+}
+
+/* ---模型删除按钮--- */
+.model-delete-btn {
+ background: none;
+ border: none;
+ color: var(--p-text-muted-color);
+ font-size: 18px;
+ line-height: 1;
+ cursor: pointer;
+ padding: 0 4px;
+ margin-left: 4px;
+ transition: color 0.2s;
+}
+
+.model-tag.default .model-delete-btn {
+ color: rgba(255, 255, 255, 0.8);
+}
+
+.model-delete-btn:hover {
+ color: var(--p-red-500);
+}
+
+/* ---空状态提示--- */
+.empty-hint {
+ font-size: 12px;
+ color: var(--p-text-muted-color);
+ padding: 8px;
+}
+
+/* ---高级设置--- */
+.advanced-settings {
+ margin-top: 12px;
+ padding: 12px;
+ background: #1a1a1a;
+ border-radius: 4px;
+}
+
+.advanced-settings-title {
+ font-size: 13px;
+ font-weight: 500;
+ color: #fff;
+}
+
+.advanced-settings-hint {
+ font-size: 12px;
+ color: #999;
+}
+
+.settings-inputs {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: 12px;
+ opacity: 0.5;
+}
+
+.settings-inputs.enabled {
+ opacity: 1;
+}
+
+.advanced-input-group label {
+ display: block;
+ font-size: 12px;
+ color: #ccc;
+ margin-bottom: 4px;
+}
+
+.advanced-input-group input {
+ width: 100%;
+ padding: 6px 8px;
+ background: #2a2a2a;
+ border: 1px solid #444;
+ border-radius: 4px;
+ color: #fff;
+ font-size: 13px;
+}
+
+/* ---添加模型对话框--- */
+.model-listbox {
+ position: fixed;
+ width: 320px;
+ max-height: 400px;
+ background: var(--p-content-background);
+ border: 1px solid var(--p-divider-color);
+ border-radius: 6px;
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
+ z-index: 10220;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+}
+
+.model-listbox-search {
+ padding: 12px;
+ border-bottom: 1px solid var(--p-divider-color);
+ background: var(--p-surface-50);
+}
+
+.model-listbox-search input {
+ width: 100%;
+ padding: 8px 12px;
+ border: 1px solid var(--p-divider-color);
+ border-radius: 4px;
+ background: var(--p-input-background);
+ color: var(--p-text-color);
+ font-size: 14px;
+}
+
+.model-list-container {
+ flex: 1;
+ overflow-y: auto;
+ padding: 4px;
+}
+
+.model-list-item {
+ padding: 10px 12px;
+ cursor: pointer;
+ border-radius: 4px;
+ transition: background 0.2s;
+ font-size: 14px;
+ color: var(--p-text-color);
+}
+
+.model-list-item:hover {
+ background: var(--p-primary-100);
+}
+
+.model-list-empty {
+ text-align: center;
+ padding: 24px;
+ color: var(--p-text-muted-color);
+ font-size: 13px;
+}
+
+.model-list-error {
+ text-align: center;
+ padding: 40px 20px;
+}
+
+.model-list-error-icon {
+ font-size: 48px;
+ margin-bottom: 16px;
+}
+
+.model-list-error-title {
+ font-size: 14px;
+ color: var(--p-text-color);
+ margin-bottom: 8px;
+}
+
+.model-list-error-detail {
+ font-size: 13px;
+ color: var(--p-red-500);
+}
+
+.model-listbox-overlay {
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ z-index: 10219;
+}
+
+/* ---新增服务对话框--- */
+.add-service-dialog {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: rgba(0, 0, 0, 0.5);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 10220;
+}
+
+.add-service-dialog-content {
+ background: var(--p-content-background);
+ border-radius: 8px;
+ padding: 24px;
+ min-width: 500px;
+ max-width: 600px;
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
+}
+
+.add-service-dialog-title {
+ margin: 0 0 20px 0;
+ color: var(--p-text-color);
+}
+
+.add-service-dialog-buttons {
+ display: flex;
+ gap: 12px;
+ justify-content: flex-end;
+ margin-top: 20px;
+}
+
+.add-service-dialog-btn {
+ padding: 8px 16px;
+ border-radius: 4px;
+ cursor: pointer;
+}
+
+.add-service-dialog-btn.cancel {
+ border: 1px solid var(--p-divider-color);
+ background: var(--p-content-background);
+ color: var(--p-text-color);
+}
+
+.add-service-dialog-btn.create {
+ border: none;
+ background: var(--p-primary-500);
+ color: white;
+}
+
+/* ===================================================
+ 4. 标签管理器专属样式
+ =================================================== */
+
+/* ---拖拽排序相关样式--- */
+.tags-manager-container .tag_accordion.dragging,
+.tags-manager-container .tag_item.dragging {
+ opacity: 0.7;
+ box-shadow: 0 0 10px rgba(0, 0, 0, 0.3);
+ z-index: 100;
+}
+
+.tags-manager-container .tag_accordion.drag-over-top {
+ border-top: 3px solid var(--p-primary-500) !important;
+ margin-top: -1px;
+}
+
+.tags-manager-container .tag_accordion.drag-over-bottom {
+ border-bottom: 3px solid var(--p-primary-500) !important;
+ margin-bottom: -1px;
+}
+
+.tags-manager-container .tag_item.drag-over-left {
+ border-left: 3px solid var(--p-primary-500) !important;
+ margin-left: -1px;
+}
+
+.tags-manager-container .tag_item.drag-over-right {
+ border-right: 3px solid var(--p-primary-500) !important;
+ margin-right: -1px;
+}
+
+/* 标签管理界面特有样式 */
+.tags-manager-container {
+ width: 100%;
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
+
+.tags-scroll-container {
+ min-height: 300px;
+}
+
+.tags-manager-container .tag_item {
+ cursor: grab;
+ user-select: none;
+}
+
+.tags-manager-container .tag_item:active {
+ cursor: grabbing;
+}
+
+.tags-manager-container .tag_accordion {
+ cursor: grab;
+ user-select: none;
+ border: 1px solid transparent;
+ transition: all 0.2s ease;
+}
+
+.tags-manager-container .tag_accordion:active {
+ cursor: grabbing;
+}
+
+/* 标签配置管理器样式 */
+.tags-config-container {
+ width: 800px;
+ height: 600px;
+ overflow: hidden;
+ display: flex;
+ flex-direction: column;
+}
+
+.tags-config-form {
+ display: flex;
+ flex-direction: column;
+ flex: 1;
+ overflow: hidden;
+}
+
+/* 标签管理弹窗大小 */
+.tags-config-dialog {
+ width: 760px;
+ height: 600px;
+}
+
+.tag-management-section {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+}
+
+.tags-action-container {
+ display: flex;
+ gap: 8px;
+ background: transparent;
+}
+
+.tags-scroll-container {
+ flex: 1;
+ overflow-y: hidden;
+ overflow-x: hidden;
+ padding: 10px;
+ border: 1px solid var(--comfy-menu-secondary-bg);
+ border-radius: 8px;
+ background-color: var(--p-listbox-background);
+ margin-top: 10px;
+ margin-bottom: 10px;
+}
+
+.settings-action-btn {
+ margin: 0 4px;
+}
+
+/* 删除按钮样式 */
+
+.tag_delete_button {
+ background: none;
+ border: none;
+ color: var(--p-button-text-secondary-color);
+ cursor: pointer;
+ padding: 2px;
+ margin-left: 4px;
+ border-radius: 50%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 24px;
+ height: 24px;
+ min-width: 24px;
+ /* 添加最小宽度 */
+ min-height: 24px;
+ /* 添加最小高度 */
+ transition: all 0.2s;
+ opacity: 0.5;
+ box-sizing: border-box;
+ /* 确保padding不会影响整体大小 */
+}
+
+.tag_delete_button.small {
+ width: 20px;
+ height: 20px;
+ min-width: 20px;
+ /* 添加最小宽度 */
+ min-height: 20px;
+ /* 添加最小高度 */
+ padding: 2px;
+ box-sizing: border-box;
+ /* 确保padding不会影响整体大小 */
+}
+
+.tag_delete_button:hover {
+ background: rgba(255, 100, 100, 0.2);
+ color: rgba(255, 100, 100, 1);
+ opacity: 1;
+}
+
+.tag_delete_icon {
+ width: 14px;
+ height: 14px;
+ min-width: 14px;
+ /* 添加最小宽度 */
+ min-height: 14px;
+ /* 添加最小高度 */
+ flex-shrink: 0;
+ /* 防止图标被压缩 */
+ display: block;
+ /* 确保图标显示为块级元素 */
+}
+
+.tag_delete_icon.small {
+ width: 12px;
+ height: 12px;
+ min-width: 12px;
+ /* 添加最小宽度 */
+ min-height: 12px;
+ /* 添加最小高度 */
+}
+
+/* 编辑按钮样式 */
+.tag_edit_button {
+ background: none;
+ border: none;
+ color: var(--p-text-muted-color);
+ cursor: pointer;
+ padding: 2px;
+ margin-left: 4px;
+ border-radius: 50%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 24px;
+ height: 24px;
+ min-width: 24px;
+ /* 添加最小宽度 */
+ min-height: 24px;
+ /* 添加最小高度 */
+ transition: all 0.2s;
+ opacity: 0.5;
+ box-sizing: border-box;
+ /* 确保padding不会影响整体大小 */
+}
+
+.tag_edit_button.small {
+ width: 20px;
+ height: 20px;
+ min-width: 20px;
+ /* 添加最小宽度 */
+ min-height: 20px;
+ /* 添加最小高度 */
+ padding: 2px;
+ box-sizing: border-box;
+ /* 确保padding不会影响整体大小 */
+}
+
+.tag_edit_button:hover {
+ background: rgba(255, 255, 255, 0.1);
+ color: rgb(207, 215, 211);
+ opacity: 1;
+}
+
+.tag_edit_icon {
+ color: var(--p-button-text-secondary-color);
+ font-size: 12px;
+ width: 12px;
+ height: 12px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition: color 0.2s ease;
+}
+
+.tag_edit_icon.small {
+ width: 10px;
+ height: 10px;
+}
+
+.tag_action_icon {
+ font-size: 14px;
+ width: 14px;
+ /* 添加固定宽度 */
+ height: 14px;
+ /* 添加固定高度 */
+ min-width: 14px;
+ /* 添加最小宽度 */
+ min-height: 14px;
+ /* 添加最小高度 */
+ flex-shrink: 0;
+ /* 防止图标被压缩 */
+ display: flex;
+ /* 使用flex布局确保内容居中 */
+ align-items: center;
+ justify-content: center;
+}
+
+.tag_action_icon.small {
+ font-size: 12px;
+ width: 12px;
+ /* 添加固定宽度 */
+ height: 12px;
+ /* 添加固定高度 */
+ min-width: 12px;
+ /* 添加最小宽度 */
+ min-height: 12px;
+ /* 添加最小高度 */
+}
+
+/* 确保图标在按钮中居中显示 */
+.tag_accordion_icon {
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ /* 添加右对齐 */
+ gap: 4px;
+ height: 24px;
+ /* 保持固定高度 */
+ min-width: 80px;
+ /* 确保容器有足够的宽度容纳所有图标 */
+}
+
+.tag_accordion_icon .accordion_arrow_icon {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 24px;
+ height: 24px;
+ min-width: 24px;
+ min-height: 24px;
+ padding: 2px;
+ border-radius: 50%;
+ color: var(--p-button-text-secondary-color);
+ opacity: 0.5;
+ transition: all 0.2s;
+ box-sizing: border-box;
+ cursor: pointer;
+}
+
+.tag_accordion_icon .accordion_arrow_icon:hover {
+ background: rgba(255, 255, 255, 0.1);
+ opacity: 1;
+}
+
+.tag_accordion_icon .accordion_arrow_icon.rotate-180 {
+ transform: rotate(180deg);
+}
+
+/* 调整图标容器内部布局 */
+.tag_accordion_header {
+ padding: 4px 14px;
+ background-color: color-mix(in srgb, var(--comfy-menu-secondary-bg), transparent 60%);
+ cursor: pointer;
+ user-select: none;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ transition: all 0.2s ease;
+ border-radius: 6px;
+ min-height: 32px;
+ /* 确保标题栏有足够的高度 */
+}
+
+.tag_accordion_title {
+ flex: 1;
+ font-size: 12px;
+ color: var(--p-text-muted-color);
+ font-weight: 500;
+ margin-right: 8px;
+ /* 添加右边距,与图标保持距离 */
+ overflow: hidden;
+ /* 防止文本溢出 */
+ text-overflow: ellipsis;
+ /* 文本溢出时显示省略号 */
+ white-space: nowrap;
+ /* 保持单行 */
+}
+
+.tags_container {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ padding: 8px;
+ background-color: color-mix(in srgb, var(--comfy-menu-secondary-bg), transparent 90%);
+ border-radius: 0 0 4px 4px;
+}
+
+/* 使用popup.css中定义的标签样式,只修改cursor为grab */
+.tags-manager-container .tag_item {
+ position: relative;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ cursor: grab;
+}
+
+.tags-manager-container .tag_item:active {
+ cursor: grabbing;
+}
+
+.tags-manager-container .tag_accordion {
+ position: relative;
+ margin-bottom: 8px;
+ border: 1px solid transparent;
+ border-radius: 4px;
+ transition: all 0.2s ease;
+ cursor: default;
+ /* 标签管理页面中使用默认指针 */
+ user-select: none;
+}
+
+.tags-manager-container .tag_accordion:hover {
+ border-color: color-mix(in srgb, var(--comfy-menu-secondary-bg), transparent 60%);
+}
+
+.tags-manager-container .tag_accordion_header {
+ cursor: grab;
+ /* 只有标题部分可拖动 */
+}
+
+.tags-manager-container .tag_accordion_header:active {
+ cursor: grabbing;
+ /* 拖动时显示抓取光标 */
+}
+
+/* 拖拽状态样式 */
+.tags-manager-container .tag_accordion.dragging,
+.tags-manager-container .tag_item.dragging {
+ opacity: 0.5;
+ box-shadow: 0 0 8px rgba(0, 0, 0, 0.2);
+ z-index: 10;
+}
+
+/* 新增拖拽动画样式 */
+.tags-manager-container .tag-dragging,
+.tags-manager-container .category-dragging {
+ opacity: 0.6 !important;
+ /* 移除缩放效果 */
+ /* transform: scale(1.02); */
+ box-shadow: 0 5px 15px rgba(0, 0, 0, 0.2) !important;
+ z-index: 100 !important;
+}
+
+/* 拖拽过程中的目标位置指示器 */
+.tag-ghost,
+.category-ghost {
+ opacity: 0.3;
+ background-color: color-mix(in srgb, var(--p-primary-500), transparent 85%) !important;
+ border: 1px dashed var(--p-primary-500) !important;
+}
+
+/* 被选中的元素样式 */
+.tag-chosen,
+.category-chosen {
+ background-color: color-mix(in srgb, var(--p-primary-500), transparent 90%);
+ border-color: var(--p-primary-500) !important;
+}
+
+/* 拖拽时的样式 */
+.tag-drag,
+.category-drag {
+ opacity: 0.8;
+}
+
+/* 添加元素移动的过渡动画 */
+.tags-manager-container .tag_accordion,
+.tags-manager-container .tag_item {
+ /* 移除transform过渡效果 */
+ transition: opacity 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
+}
+
+/* 拖拽排序时的位置指示 */
+.sortable-fallback {
+ opacity: 0 !important;
+}
+
+.sortable-ghost {
+ position: relative;
+}
+
+.sortable-ghost::after {
+ content: '';
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background-color: color-mix(in srgb, var(--p-primary-500), transparent 90%);
+ border: 1px dashed var(--p-primary-500);
+ z-index: -1;
+ border-radius: 4px;
+}
+
+.tags-manager-container .tag_accordion.drag-over-top {
+ border-top: 2px solid var(--p-primary-500);
+}
+
+.tags-manager-container .tag_accordion.drag-over-bottom {
+ border-bottom: 2px solid var(--p-primary-500);
+}
+
+.tags-manager-container .tag_item.drag-over-left {
+ border-left: 2px solid var(--p-primary-500);
+}
+
+.tags-manager-container .tag_item.drag-over-right {
+ border-right: 2px solid var(--p-primary-500);
+}
+
+/* tooltip处理 */
+.has-tooltip {
+ position: relative;
+}
+
+/* 标签容器布局 */
+.tags-manager-container {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+}
+
+/* 弹窗表单内容样式 */
+.dialog-form-content {
+ padding: 1.5rem;
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+ flex: 1;
+}
+
+.dialog-form-content .settings-form-group {
+ margin-bottom: 4rem;
+}
+
+.dialog-form-content .settings-form-group:last-child {
+ margin-bottom: 0;
+}
+
+/* 确保下拉框和输入框有合适的宽度和间距 */
+.dialog-form-content .p-dropdown,
+.dialog-form-content .p-inputtext {
+ width: 100%;
+ box-sizing: border-box;
+}
+
+/* 规则配置表单样式 */
+.rules-config-form {
+ max-height: 70vh;
+ overflow: auto;
+}
+
+.settings-form-textarea {
+ width: 100%;
+ min-height: 150px;
+ height: calc(100% - 30px);
+ resize: vertical;
+ font-family: monospace;
+ padding: 8px;
+ background-color: var(--p-inputtext-background);
+ border: 1px solid var(--p-inputtext-border-color);
+ border-radius: 4px;
+ color: var(--p-inputtext-color);
+ flex-grow: 1;
+}
+
+/* API配置表单样式 */
+.api-config-form {
+ width: 100%;
+ display: flex;
+ flex-direction: column;
+ gap: 1px;
+}
+
+/* 刷新按钮样式 */
+.settings-refresh-button {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ font-size: 14px;
+ color: var(--p-message-info-color);
+ cursor: pointer;
+ background: transparent;
+ border: none;
+ padding: 4px 8px;
+ border-radius: 4px;
+ transition: all 0.2s ease;
+}
+
+.settings-refresh-button:hover {
+ background-color: color-mix(in srgb, var(--p-message-info-color), transparent 90%);
+}
+
+.refresh-icon {
+ width: 16px;
+ height: 16px;
+ margin-right: 4px;
+ vertical-align: middle;
+}
+
+/* 表单分组的间距 */
+.settings-form-section {
+ margin-bottom: 10px;
+}
+
+/* 表单输入组件的样式 */
+.settings-form-group {
+ margin-bottom: 26px;
+}
+
+/* 水平布局组的样式 */
+.settings-form-group.horizontal {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 16px;
+}
+
+.settings-form-group.horizontal>div {
+ flex: 1;
+ min-width: 200px;
+}
+
+/* 添加标签管理部分的样式 */
+.tag-management-section {
+ width: 100%;
+ max-width: 100%;
+ overflow: hidden;
+}
+
+.tags-config-container {
+ width: 100%;
+ max-width: 100%;
+}
+
+.tags-config-form {
+ width: 100%;
+ max-width: 100%;
+}
+
+.tags-manager-container {
+ width: 100%;
+ max-width: 100%;
+ overflow: hidden;
+}
+
+.tags-scroll-container {
+ width: 100%;
+ max-width: 100%;
+ max-height: 500px;
+ overflow-y: auto;
+ overflow-x: hidden;
+}
+
+/* 确保标签手风琴适配宽度 */
+.tag_accordion {
+ width: 100%;
+ max-width: 100%;
+ overflow: hidden;
+}
+
+/* 确保标签项目不会导致容器溢出 */
+.tags_container {
+ width: 100%;
+ max-width: 100%;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+}
+
+.tag_item {
+ max-width: calc(100% - 16px);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+/* 修复弹窗样式,确保内容不会溢出 */
+.settings-dialog {
+ max-width: 90vw;
+ width: 600px;
+}
+
+.settings-dialog-content {
+ width: 100%;
+ max-width: 100%;
+ overflow: hidden;
+}
+
+.settings-form-section {
+ width: 100%;
+ max-width: 100%;
+ overflow: hidden;
+}
+
+
+/* ===================================================
+ 5. 规则配置专属样式
+ =================================================== */
+
+/* ---提示词列表样式--- */
+.prompt-list-container {
+ border: 1px solid var(--p-dialog-border-color);
+ border-radius: 4px;
+ overflow: hidden;
+ background-color: var(--p-listbox-background);
+ height: 300px;
+ min-height: 300px;
+ max-height: 300px;
+ display: flex;
+ flex-direction: column;
+}
+
+/* 反推规则配置列表特定高度 */
+.vision-prompt-list-container {
+ height: 300px;
+ min-height: 300px;
+ max-height: 300px;
+}
+
+/* 视频反推规则配置列表固定高度 */
+.video-prompt-list-container {
+ height: 300px;
+ min-height: 300px;
+ max-height: 300px;
+}
+
+.prompt-list-header {
+ display: flex;
+ background-color: var(--p-button-secondary-background);
+ border-bottom: 1px solid var(--p-dialog-border-color);
+ font-weight: 600;
+ color: var(--p-dialog-header-color);
+ flex-shrink: 0;
+ position: relative;
+}
+
+.prompt-scroll-list {
+ flex: 1;
+ overflow-y: auto;
+ overflow-x: hidden;
+}
+
+.prompt-list-row {
+ display: flex;
+ border-bottom: 1px solid var(--p-dialog-border-color);
+ transition: background-color 0.2s ease;
+ cursor: move;
+}
+
+.prompt-list-row:hover {
+ background-color: var(--p-select-option-focus-background);
+}
+
+.prompt-list-row:last-child {
+ border-bottom: none;
+}
+
+.prompt-list-row.sortable-ghost {
+ opacity: 0.4;
+}
+
+.prompt-list-row.sortable-chosen {
+ background-color: var(--p-select-option-selected-background);
+}
+
+.prompt-list-cell {
+ padding: 8px 12px;
+ border-right: 1px solid var(--p-dialog-border-color);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ position: relative;
+}
+
+.prompt-list-cell:last-child {
+ border-right: none;
+}
+
+.prompt-list-cell.status-cell {
+ width: 80px;
+ flex-shrink: 0;
+ text-align: center;
+ cursor: pointer;
+}
+
+.prompt-list-cell.name-cell {
+ width: 150px;
+ flex-shrink: 0;
+ min-width: 100px;
+}
+
+.prompt-list-cell.content-cell {
+ flex: 1;
+ min-width: 200px;
+}
+
+.prompt-list-cell.action-cell {
+ width: 100px;
+ flex-shrink: 0;
+ text-align: center;
+ min-width: 80px;
+}
+
+/* 列宽调整手柄 */
+.column-resizer {
+ position: absolute;
+ top: 0;
+ right: -2px;
+ width: 4px;
+ height: 100%;
+ cursor: col-resize;
+ background: transparent;
+ z-index: 10;
+ transition: background-color 0.2s ease;
+}
+
+.column-resizer:hover {
+ background: var(--p-primary-500);
+ opacity: 0.8;
+}
+
+.column-resizer.resizing {
+ background: var(--p-primary-500);
+ opacity: 1;
+}
+
+/* 确保列单元格有相对定位以支持绝对定位的调整手柄 */
+.prompt-list-cell {
+ position: relative;
+}
+
+/* 拖拽时的全局样式 */
+body.column-resizing {
+ cursor: col-resize !important;
+ user-select: none !important;
+}
+
+body.column-resizing * {
+ cursor: col-resize !important;
+ user-select: none !important;
+}
+
+/* 拖拽排序样式 */
+.sortable-drag {
+ opacity: 0.8;
+ transform: rotate(2deg);
+ box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
+}
+
+.prompt-list-row.sortable-ghost {
+ opacity: 0.4;
+ background: var(--p-primary-100);
+}
+
+.prompt-list-row.sortable-chosen {
+ background: var(--p-select-option-selected-background);
+ border: 1px solid var(--p-primary-500);
+}
+
+/* 状态图标样式优化 */
+.status-cell .active-status {
+ color: var(--p-green-500);
+ cursor: pointer;
+ transition: color 0.2s ease;
+}
+
+.status-cell .inactive-status {
+ color: var(--p-surface-400);
+ cursor: pointer;
+ transition: color 0.2s ease;
+}
+
+.status-cell:hover .active-status {
+ color: var(--p-green-600);
+}
+
+.status-cell:hover .inactive-status {
+ color: var(--p-primary-500);
+}
+
+/* 状态图标样式 */
+.active-status {
+ color: var(--p-message-success-color);
+}
+
+.inactive-status {
+ color: var(--p-message-warn-color);
+}
+
+/* 操作按钮样式 */
+.prompt-action-btn {
+ background: transparent;
+ border: none;
+ padding: 4px 6px;
+ margin: 0 2px;
+ border-radius: 3px;
+ cursor: pointer;
+ transition: background-color 0.2s ease;
+ color: var(--p-button-text-secondary-color);
+}
+
+.prompt-action-btn:hover {
+ background-color: var(--p-button-secondary-background);
+}
+
+.prompt-action-btn.toggle-btn:hover {
+ color: var(--p-message-info-color);
+}
+
+.prompt-action-btn.edit-btn:hover {
+ color: var(--p-message-warn-color);
+}
+
+.prompt-action-btn.delete-btn:hover {
+ color: var(--p-message-error-color);
+}
+
+/* 规则配置对话框样式 */
+.rules-config-form {
+ max-height: 70vh;
+ overflow-y: auto;
+}
+
+.settings-modal.prompt-edit-dialog {
+ max-height: 90vh;
+ display: flex;
+ flex-direction: column;
+}
+
+.rules-config-content {
+ padding: 20px;
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+}
+
+.rules-section {
+ margin-bottom: 30px;
+}
+
+.rules-section h3 {
+ margin: 0 0 15px 0;
+ color: var(--p-dialog-header-color);
+ font-size: 16px;
+ font-weight: 600;
+}
+
+/* 标签页样式 */
+.settings-modal .popup_tabs_container {
+ margin-bottom: 10px;
+}
+
+.settings-modal .popup_tabs_scroll {
+ overflow-x: hidden;
+ overflow-y: hidden;
+}
+
+.settings-modal .popup_tabs {
+ display: flex;
+ border-bottom: 1px solid var(--p-dialog-border-color);
+ min-width: max-content;
+}
+
+.settings-modal .popup_tab {
+ padding: 8px 16px;
+ background: var(--p-button-secondary-background);
+ border: 1px solid var(--p-dialog-border-color);
+ border-bottom: none;
+ cursor: pointer;
+ margin-right: 2px;
+ border-radius: 4px 4px 0 0;
+ white-space: nowrap;
+ font-size: 13px;
+ color: var(--p-button-text-secondary-color);
+ transition: background-color 0.2s ease;
+}
+
+.settings-modal .popup_tab.active {
+ background: var(--p-dialog-background);
+ border-bottom: 1px solid var(--p-dialog-background);
+ margin-bottom: -1px;
+ font-weight: 500;
+ color: var(--p-dialog-header-color);
+}
+
+.settings-modal .popup_tab:hover {
+ background: var(--p-select-option-focus-background);
+}
+
+.settings-modal .popup_tab.active:hover {
+ background: var(--p-dialog-background);
+}
+
+.settings-modal .popup_tab_content {
+ display: none;
+}
+
+.settings-modal .popup_tab_content.active {
+ display: block;
+}
+
+/* 对话框按钮样式 */
+.dialog-buttons {
+ display: flex;
+ justify-content: flex-end;
+ gap: 10px;
+ margin-top: 20px;
+ padding-top: 20px;
+ border-top: 1px solid var(--p-dialog-border-color);
+}
+
+.dialog-btn {
+ padding: 8px 16px;
+ border: none;
+ border-radius: 4px;
+ cursor: pointer;
+ font-size: 14px;
+ transition: background-color 0.2s ease;
+}
+
+.dialog-btn.primary {
+ background: var(--p-button-primary-background);
+ color: var(--p-button-primary-color);
+}
+
+.dialog-btn.primary:hover {
+ background: var(--p-button-primary-hover-background);
+}
+
+.dialog-btn.secondary {
+ background: var(--p-button-secondary-background);
+ color: var(--p-button-secondary-color);
+}
+
+.dialog-btn.secondary:hover {
+ background: var(--p-button-secondary-hover-background);
+}
+
+
+
+/* 微信徽标悬浮二维码 */
+.pa-wechat-badge {
+ position: relative;
+ /* 作为二维码定位参照 */
+ /* 让绝对定位参照到内容容器 */
+}
+
+.pa-wechat-qr {
+ position: absolute;
+ opacity: 0;
+ visibility: hidden;
+ top: calc(100% + 6px);
+ right: 10px;
+ /* 向左偏移,避免被滚动条遮挡 */
+ transform: none;
+ background: var(--p-dialog-background);
+ border: 1px solid var(--p-dialog-border-color);
+ border-radius: 10px;
+ padding: 6px;
+ box-shadow: var(--p-dialog-shadow);
+ z-index: calc(var(--settings-modal-z-index) + 5);
+ /* 淡入淡出动画 */
+ transition: opacity 0.3s ease, visibility 0.3s ease;
+}
+
+.pa-wechat-badge:hover .pa-wechat-qr {
+ opacity: 1;
+ visibility: visible;
+}
+
+.pa-wechat-qr-img {
+ display: block;
+ width: 256px;
+ height: auto;
+}
+
+/* ===================================================
+ 7. 确认气泡框组件样式 (ConfirmPopup)
+ =================================================== */
+
+/* ---气泡框容器--- */
+.pa-confirm-popup {
+ position: fixed;
+ z-index: var(--settings-confirm-popup-z-index);
+ opacity: 0;
+ transform: scale(0.9);
+ transition: opacity 0.2s ease, transform 0.2s ease;
+ box-shadow: var(--p-dialog-shadow);
+}
+
+.pa-confirm-popup.pa-confirm-popup-show {
+ opacity: 1;
+ transform: scale(1);
+}
+
+.pa-confirm-popup.pa-confirm-popup-hide {
+ opacity: 0;
+ transform: scale(0.9);
+}
+
+/* ---气泡框内容--- */
+.pa-confirm-popup-content {
+ background: var(--p-dialog-background);
+ border: 1px solid var(--p-dialog-border-color);
+ border-radius: 8px;
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
+ padding: 18px 30px 16px 30px;
+ min-width: 280px;
+ max-width: 600px;
+}
+
+/* 当包含模型参数表单时,使用更大的宽度 */
+.pa-confirm-popup-content:has(.model-params-form) {
+ min-width: 480px;
+}
+
+/* ---消息区域--- */
+.pa-confirm-popup-message {
+ display: flex;
+ align-items: flex-start;
+ gap: 12px;
+ margin-bottom: 30px;
+ color: var(--p-dialog-color);
+ font-size: 16px;
+ line-height: 1.5;
+}
+
+.pa-confirm-popup-icon {
+ font-size: 18px;
+ color: var(--p-primary-500);
+ flex-shrink: 0;
+ margin-top: 2px;
+}
+
+/* ---分割线--- */
+.pa-confirm-popup-divider {
+ height: 1px;
+ background-color: var(--p-dialog-border-color);
+ margin: 0 -30px 16px -30px;
+}
+
+/* ---表单区域--- */
+.pa-confirm-popup-form {
+ margin-bottom: 16px;
+}
+
+.pa-confirm-popup-form .settings-form-group {
+ margin-bottom: 12px;
+}
+
+.pa-confirm-popup-form .settings-form-group:last-child {
+ margin-bottom: 0;
+}
+
+/* ---模型参数表单横向布局--- */
+.pa-confirm-popup-form.model-params-form {
+ display: flex;
+ gap: 20px;
+ flex-wrap: nowrap;
+}
+
+.pa-confirm-popup-form.model-params-form .settings-form-group {
+ margin-bottom: 0;
+ flex-shrink: 0;
+}
+
+/* ---数字输入框样式优化--- */
+.pa-confirm-popup-form input[type="number"] {
+ /* 隐藏默认的上下箭头 */
+ appearance: textfield;
+ -moz-appearance: textfield;
+}
+
+.pa-confirm-popup-form input[type="number"]::-webkit-outer-spin-button,
+.pa-confirm-popup-form input[type="number"]::-webkit-inner-spin-button {
+ -webkit-appearance: none;
+ margin: 0;
+}
+
+/* 自定义数字输入框的上下调整按钮容器 */
+.pa-confirm-popup-form .float-label-container {
+ position: relative;
+}
+
+/* 为数字输入框添加自定义上下按钮 */
+.pa-confirm-popup-form input[type="number"] {
+ padding-right: 20px;
+}
+
+/* 数字输入框按钮组 */
+.pa-confirm-popup-form .number-input-buttons {
+ position: absolute;
+ right: 1px;
+ top: 50%;
+ transform: translateY(-50%);
+ display: flex;
+ flex-direction: column;
+ gap: 0;
+ height: calc(100% - 2px);
+ width: 24px;
+ z-index: 2;
+}
+
+.pa-confirm-popup-form .number-input-button {
+ flex: 1;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: var(--p-inputtext-background);
+ border: none;
+ border-left: 1px solid var(--p-inputtext-border-color);
+ color: var(--p-text-muted-color);
+ cursor: pointer;
+ font-size: 9px;
+ transition: all 0.2s;
+ user-select: none;
+ padding: 0;
+}
+
+.pa-confirm-popup-form .number-input-button:first-child {
+ border-top-right-radius: 5px;
+ border-bottom: 0.5px solid var(--p-inputtext-border-color);
+}
+
+.pa-confirm-popup-form .number-input-button:last-child {
+ border-bottom-right-radius: 5px;
+}
+
+.pa-confirm-popup-form .number-input-button:hover {
+ background: var(--p-inputtext-hover-border-color);
+ color: var(--p-inputtext-color);
+}
+
+.pa-confirm-popup-form .number-input-button:active {
+ background: var(--p-primary-500);
+ color: white;
+}
+
+.pa-confirm-popup-form .number-input-button .pi {
+ font-size: 9px;
+}
+
+
+
+/* ---底部按钮组--- */
+.pa-confirm-popup-footer {
+ display: flex;
+ justify-content: flex-end;
+ gap: 8px;
+}
+
+.pa-confirm-popup-footer .p-button-sm {
+ padding: 6px 16px;
+ font-size: 13px;
+}
+
+/* ---箭头指针--- */
+.pa-confirm-popup-arrow {
+ position: absolute;
+ width: 0;
+ height: 0;
+ border-style: solid;
+}
+
+/* 箭头在顶部(气泡在下方) */
+.pa-confirm-popup-arrow.top {
+ top: -8px;
+ border-width: 0 8px 8px 8px;
+ border-color: transparent transparent var(--p-dialog-border-color) transparent;
+}
+
+.pa-confirm-popup-arrow.top::after {
+ content: '';
+ position: absolute;
+ top: 1px;
+ left: -8px;
+ width: 0;
+ height: 0;
+ border-style: solid;
+ border-width: 0 8px 8px 8px;
+ border-color: transparent transparent var(--p-dialog-background) transparent;
+}
+
+/* 箭头在底部(气泡在上方) */
+.pa-confirm-popup-arrow.bottom {
+ bottom: -8px;
+ border-width: 8px 8px 0 8px;
+ border-color: var(--p-dialog-border-color) transparent transparent transparent;
+}
+
+.pa-confirm-popup-arrow.bottom::after {
+ content: '';
+ position: absolute;
+ top: -9px;
+ left: -8px;
+ width: 0;
+ height: 0;
+ border-style: solid;
+ border-width: 8px 8px 0 8px;
+ border-color: var(--p-dialog-background) transparent transparent transparent;
+}
+
+/* 箭头在左侧(气泡在右方) */
+.pa-confirm-popup-arrow.left {
+ left: -8px;
+ border-width: 8px 8px 8px 0;
+ border-color: transparent var(--p-dialog-border-color) transparent transparent;
+}
+
+.pa-confirm-popup-arrow.left::after {
+ content: '';
+ position: absolute;
+ top: -8px;
+ left: 1px;
+ width: 0;
+ height: 0;
+ border-style: solid;
+ border-width: 8px 8px 8px 0;
+ border-color: transparent var(--p-dialog-background) transparent transparent;
+}
+
+/* 箭头在右侧(气泡在左方) */
+.pa-confirm-popup-arrow.right {
+ right: -8px;
+ border-width: 8px 0 8px 8px;
+ border-color: transparent transparent transparent var(--p-dialog-border-color);
+}
+
+.pa-confirm-popup-arrow.right::after {
+ content: '';
+ position: absolute;
+ top: -8px;
+ left: -9px;
+ width: 0;
+ height: 0;
+ border-style: solid;
+ border-width: 8px 0 8px 8px;
+ border-color: transparent transparent transparent var(--p-dialog-background);
+}
+
+/* ===================================================
+ 8. 右键菜单组件样式 (ContextMenu)
+ =================================================== */
+
+/* ---菜单容器--- */
+.pa-context-menu {
+ position: fixed;
+ z-index: var(--settings-context-menu-z-index);
+ background: var(--p-dialog-background);
+ border: 1px solid var(--p-dialog-border-color);
+ border-radius: 6px;
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
+ min-width: 180px;
+ opacity: 0;
+ transform: scale(0.95);
+ transition: opacity 0.15s ease, transform 0.15s ease;
+}
+
+.pa-context-menu.pa-context-menu-show {
+ opacity: 1;
+ transform: scale(1);
+}
+
+.pa-context-menu.pa-context-menu-hide {
+ opacity: 0;
+ transform: scale(0.95);
+}
+
+/* ---菜单列表--- */
+.pa-context-menu-list {
+ list-style: none;
+ margin: 0;
+ padding: 4px;
+ display: flex;
+ flex-direction: column;
+ gap: 1px;
+}
+
+/* ---菜单项--- */
+.pa-context-menu-item {
+ position: relative;
+ cursor: pointer;
+ border-radius: 4px;
+ transition: background-color 0.2s ease;
+}
+
+.pa-context-menu-item:hover {
+ background-color: var(--p-form-field-filled-hover-background);
+}
+
+.pa-context-menu-item.disabled {
+ cursor: not-allowed;
+ opacity: 0.5;
+}
+
+.pa-context-menu-item.disabled:hover {
+ background-color: transparent;
+}
+
+/* ---菜单项内容--- */
+.pa-context-menu-item-content {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 6px 10px;
+ color: var(--p-dialog-color);
+}
+
+/* ---菜单项图标--- */
+.pa-context-menu-item-icon {
+ font-size: 16px;
+ color: var(--p-text-muted-color);
+ width: 20px;
+ text-align: center;
+}
+
+.pa-context-menu-item:hover .pa-context-menu-item-icon {
+ color: var(--p-primary-500);
+}
+
+/* ---菜单项文本--- */
+.pa-context-menu-item-label {
+ font-size: 14px;
+ white-space: nowrap;
+}
+
+/* ---分隔符--- */
+.pa-context-menu-separator {
+ height: 1px;
+ background-color: var(--p-dialog-border-color);
+ margin: 4px 8px;
+ list-style: none;
+}
+
+/* ---危险操作样式(如删除)--- */
+.pa-context-menu-item.danger .pa-context-menu-item-icon {
+ color: var(--p-red-500);
+}
+
+.pa-context-menu-item.danger:hover .pa-context-menu-item-icon {
+ color: var(--p-red-600);
+}
+
+/* ===================================================
+ 9. 分体按钮组件样式 (Split Button)
+ =================================================== */
+
+/* ---分体按钮容器--- */
+.p-splitbutton-compact {
+ font-size: 0.85rem;
+}
+
+/* ---分体按钮通用样式--- */
+.p-splitbutton-compact .p-button {
+ padding: 0.25rem 0.5rem;
+ white-space: nowrap;
+}
+
+/* ---分体按钮 Primary 主题--- */
+.p-splitbutton-compact.p-splitbutton-primary .p-button {
+ background: var(--p-button-primary-background);
+ color: var(--p-button-primary-color);
+ border-color: var(--p-button-primary-border-color);
+}
+
+.p-splitbutton-compact.p-splitbutton-primary .p-button:hover:not(:disabled) {
+ background: var(--p-button-primary-hover-background);
+ color: var(--p-button-primary-hover-color);
+ border-color: var(--p-button-primary-hover-border-color);
+}
+
+/* ---下拉触发按钮--- */
+.p-splitbutton-compact .p-splitbutton-menubutton {
+ width: 23px;
+ padding: 0.25rem 0.3rem;
+}
+
+/* ---按钮文本和图标--- */
+.p-splitbutton-compact .p-button-label {
+ font-size: 0.85rem;
+}
+
+.p-splitbutton-compact .p-button-icon {
+ font-size: 10px;
+}
+
+.pa-context-menu-item.danger:hover {
+ background-color: rgba(239, 68, 68, 0.1);
+}
+
+/* ---选中状态样式(用于下拉选择器)--- */
+.pa-context-menu-item.active {
+ background-color: var(--p-form-field-filled-hover-background);
+}
+
+.pa-context-menu-item.active .pa-context-menu-item-label {
+ color: var(--p-primary-color);
+ font-weight: 500;
+}
+
+.pa-context-menu-item.active::before {
+ content: '\2713';
+ position: absolute;
+ left: 8px;
+ color: var(--p-primary-color);
+ font-size: 14px;
+ font-weight: bold;
+}
+
+.pa-context-menu-item.active .pa-context-menu-item-content {
+ padding-left: 24px;
+}
+
+
+/* ===================================================
+ 9. Tooltip 提示框组件样式
+ =================================================== */
+
+/* ---Tooltip 容器--- */
+.pa-tooltip {
+ position: fixed;
+ z-index: var(--settings-tooltip-z-index);
+ background: var(--p-surface-700);
+ /* border: 1px solid var(--p-dialog-border-color); */
+ border-radius: 6px;
+ padding: 6px 12px;
+ font-size: 13px;
+ color: var(--p-surface-0);
+ max-width: 300px;
+ word-wrap: break-word;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
+ opacity: 0;
+ transform: scale(0.95);
+ transition: opacity 0.15s ease, transform 0.15s ease;
+ pointer-events: none;
+}
+
+.pa-tooltip.pa-tooltip-show {
+ opacity: 1;
+ transform: scale(1);
+}
+
+/* ---Tooltip 箭头--- */
+.pa-tooltip-arrow {
+ position: absolute;
+ width: 0;
+ height: 0;
+ border-style: solid;
+}
+
+/* 箭头在顶部(tooltip 在下方) */
+.pa-tooltip-arrow.top {
+ top: -6px;
+ left: 50%;
+ transform: translateX(-50%);
+ border-width: 0 6px 6px 6px;
+ border-color: transparent transparent var(--p-surface-700) transparent;
+}
+
+/* 箭头在底部(tooltip 在上方) */
+.pa-tooltip-arrow.bottom {
+ bottom: -6px;
+ left: 50%;
+ transform: translateX(-50%);
+ border-width: 6px 6px 0 6px;
+ border-color: var(--p-surface-700) transparent transparent transparent;
+}
+
+/* 箭头在左侧(tooltip 在右方) */
+.pa-tooltip-arrow.left {
+ left: -6px;
+ top: 50%;
+ transform: translateY(-50%);
+ border-width: 6px 6px 6px 0;
+ border-color: transparent var(--p-surface-700) transparent transparent;
+}
+
+/* 箭头在右侧(tooltip 在左方) */
+.pa-tooltip-arrow.right {
+ right: -6px;
+ top: 50%;
+ transform: translateY(-50%);
+ border-width: 6px 0 6px 6px;
+ border-color: transparent transparent transparent var(--p-surface-700);
+}
+
+/* ===================================================
+ 多选 Listbox 组件样式
+ =================================================== */
+
+/* ---Listbox 容器--- */
+.pa-multi-listbox {
+ position: fixed;
+ width: 320px;
+ max-height: 400px;
+ background: var(--p-content-background);
+ border: 1px solid var(--p-dialog-border-color);
+ border-radius: 6px;
+ box-shadow: var(--p-dialog-shadow);
+ z-index: var(--settings-multi-listbox-z-index);
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+}
+
+/* ---搜索框容器--- */
+.pa-multi-listbox-search {
+ padding: 12px;
+ border-bottom: 1px solid var(--p-dialog-border-color);
+ background: var(--p-inputtext-background);
+}
+
+.pa-multi-listbox-search-input {
+ width: 100%;
+ padding: 8px 12px;
+ padding-right: 32px;
+ border: 1px solid var(--p-divider-color);
+ border-radius: 4px;
+ background: var(--p-input-background);
+ color: var(--p-text-color);
+ font-size: 14px;
+ outline: none;
+ transition: border-color 0.2s;
+}
+
+.pa-multi-listbox-search-input:focus {
+ border-color: var(--p-primary-color);
+}
+
+.pa-multi-listbox-search-input::placeholder {
+ color: var(--p-text-muted-color);
+}
+
+/* 搜索图标 */
+.pa-multi-listbox-search-wrapper {
+ position: relative;
+}
+
+.pa-multi-listbox-search-icon {
+ position: absolute;
+ right: 10px;
+ top: 50%;
+ transform: translateY(-50%);
+ color: var(--p-text-muted-color);
+ font-size: 14px;
+ pointer-events: none;
+}
+
+/* ---列表容器--- */
+.pa-multi-listbox-content {
+ flex: 1;
+ overflow-y: auto;
+ padding: 4px;
+}
+
+/* ---列表项--- */
+.pa-multi-listbox-item {
+ display: flex;
+ align-items: center;
+ padding: 10px 12px;
+ cursor: pointer;
+ border-radius: 4px;
+ transition: background 0.2s;
+ font-size: 14px;
+ color: var(--p-text-color);
+ user-select: none;
+}
+
+.pa-multi-listbox-item:hover {
+ background: var(--p-list-option-focus-background);
+}
+
+.pa-multi-listbox-item.selected {
+ background: var(--p-dialog-border-color);
+}
+
+/* ---复选框样式(参考 PrimeVue Checkbox)--- */
+.pa-multi-listbox-checkbox {
+ width: 18px;
+ height: 18px;
+ min-width: 18px;
+ border: 2px solid var(--p-checkbox-border-color, var(--p-inputtext-border-color));
+ border-radius: var(--p-checkbox-border-radius, 4px);
+ margin-right: 12px;
+ position: relative;
+ transition: background-color 0.2s, border-color 0.2s, box-shadow 0.2s;
+ background: var(--p-checkbox-background, var(--p-inputtext-background));
+ cursor: pointer;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+}
+
+/* 悬停状态 */
+.pa-multi-listbox-item:hover .pa-multi-listbox-checkbox {
+ border-color: var(--p-checkbox-hover-border-color, var(--p-primary-color));
+}
+
+/* 选中状态 */
+.pa-multi-listbox-item.selected .pa-multi-listbox-checkbox {
+ background: var(--p-checkbox-checked-background, var(--p-primary-color));
+ border-color: var(--p-checkbox-checked-border-color, var(--p-primary-color));
+}
+
+/* 选中状态悬停 */
+.pa-multi-listbox-item.selected:hover .pa-multi-listbox-checkbox {
+ background: var(--p-checkbox-checked-hover-background, var(--p-primary-hover-color));
+ border-color: var(--p-checkbox-checked-hover-border-color, var(--p-primary-hover-color));
+}
+
+/* 复选框勾选标记(使用 PrimeVue 的图标方式) */
+.pa-multi-listbox-checkbox::after {
+ content: '';
+ position: absolute;
+ left: 50%;
+ top: 50%;
+ width: 5px;
+ height: 9px;
+ border: solid var(--p-checkbox-icon-checked-color, #ffffff);
+ border-width: 0 2px 2px 0;
+ transform: translate(-50%, -60%) rotate(45deg) scale(0);
+ transform-origin: center;
+ transition: transform 0.2s cubic-bezier(0.12, 0.4, 0.29, 1.46);
+}
+
+/* 选中时显示勾选标记,带有弹性动画 */
+.pa-multi-listbox-item.selected .pa-multi-listbox-checkbox::after {
+ transform: translate(-50%, -60%) rotate(45deg) scale(1);
+}
+
+/* ---空状态提示--- */
+.pa-multi-listbox-empty {
+ text-align: center;
+ padding: 24px;
+ color: var(--p-text-muted-color);
+ font-size: 13px;
+}
+
+/* ---底部按钮栏--- */
+.pa-multi-listbox-footer {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 12px;
+ border-top: 1px solid var(--p-divider-color);
+ background: var(--p-dialog-border-color);
+}
+
+.pa-multi-listbox-count {
+ font-size: 13px;
+ color: var(--p-text-muted-color);
+}
+
+.pa-multi-listbox-actions {
+ display: flex;
+ gap: 8px;
+}
+
+/* ===================================================
+ 6. API 配置管理器 - 底部警告信息
+ =================================================== */
+
+/* API 管理器主窗口 - 设置最小高度以适应内容 */
+.settings-modal.api-config-dialog-v2 {
+ min-height: 760px;
+}
+
+/* 调整通知区域位置到最底部 */
+.api-config-dialog-v2 .p-dialog-notice {
+ order: 10;
+ padding: 0;
+ margin-top: auto;
+ width: 100%;
+}
+
+/* 警告信息样式 */
+.api-config-warning {
+ font-size: 12px;
+ color: color-mix(in srgb, var(--p-text-muted-color), transparent 50%);
+ padding: 10px 20px;
+ line-height: 1.6;
+ text-align: center;
+ border-top: 1px solid var(--p-divider-color);
+ /* background: var(--p-dialog-background); */
+}
+
+/* ===================================================
+ 7. 规则配置管理器 - 标签页样式
+ =================================================== */
+
+/* ---规则配置标签页容器--- */
+.rules-config-tabs {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+}
+
+/* ---标签页头部包装容器--- */
+.rules-config-tabs .tab-header-wrapper {
+ display: flex;
+ align-items: flex-end;
+ justify-content: space-between;
+ gap: 16px;
+ border-bottom: 2px solid var(--p-divider-color);
+ padding-bottom: 8px;
+ margin-bottom: 2px;
+ padding: 1px 16px 0px 16px;
+}
+
+/* ---调整规则配置中的标签头部样式--- */
+.rules-config-tabs .tab-header {
+ border-bottom: none;
+ padding-bottom: 0;
+ margin-bottom: 0;
+ flex: 1;
+}
+
+/* ---添加按钮容器--- */
+.tab-header-actions {
+ display: flex;
+ align-items: center;
+ flex-shrink: 0;
+}
+
+/* ---标签添加按钮样式--- */
+.tab-add-button {
+ white-space: nowrap;
+}
+
+/* ---规则面板提示信息--- */
+.rule-pane-notice {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 10px 14px;
+ margin-bottom: 12px;
+ background: var(--p-message-info-background);
+ border: 1px solid var(--p-message-info-border-color);
+ border-radius: 6px;
+ font-size: 13px;
+ color: var(--p-message-info-color);
+}
+
+.rule-pane-notice .pi {
+ font-size: 14px;
+}
+
+/* ---翻译规则行样式(无状态列)--- */
+.prompt-list-row.translate-row {
+ display: flex;
+ align-items: stretch;
+}
+
+.prompt-list-row.translate-row .name-cell {
+ flex: 0 0 120px;
+}
+
+.prompt-list-row.translate-row .content-cell {
+ flex: 1;
+}
+
+.prompt-list-row.translate-row .action-cell {
+ flex: 0 0 60px;
+ justify-content: center;
+}
+
+/* ===================================================
+ 8. 标签管理器专属样式 (新版)
+ =================================================== */
+
+/* CSV 选择器 */
+.popup_csv_selector {
+ padding: 4px 8px;
+ border-radius: 4px;
+ /* border: 0px solid var(--p-inputtext-border-color); */
+ background-color: var(--p-inputtext-background);
+ color: var(--p-inputtext-color);
+ outline: none;
+ font-size: 14px;
+ cursor: pointer;
+ transition: border-color 0.2s;
+}
+
+.popup_csv_selector:hover {
+ border-color: var(--p-primary-color);
+}
+
+/* 标签右键菜单 */
+.tag_context_menu {
+ background-color: var(--p-content-background);
+ border: 1px solid var(--p-dialog-border-color);
+ border-radius: 6px;
+ padding: 4px 0;
+ box-shadow: var(--p-dialog-shadow);
+ z-index: var(--settings-context-menu-z-index);
+ min-width: 140px;
+}
+
+.tag_context_menu_item {
+ padding: 8px 16px;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ color: var(--p-text-color);
+ font-size: 14px;
+ transition: background-color 0.2s;
+}
+
+.tag_context_menu_item:hover {
+ background-color: var(--p-list-option-focus-background);
+}
+
+.tag_context_menu_item i {
+ font-size: 14px;
+}
+
+/* 收藏标签页空状态提示 */
+.empty_tip {
+ padding: 24px;
+ text-align: center;
+ color: var(--p-text-muted-color);
+ font-size: 14px;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 8px;
+}
+
+/* ===================================================
+ 9. 节点帮助翻译器样式 (Node Help Translator)
+ =================================================== */
+
+/* ---翻译文本样式--- */
+.pa-trans-text {
+ display: block;
+ margin-top: 4px;
+ padding-top: 4px;
+ color: var(--p-primary-color, #a855f7);
+ font-size: 0.9em;
+ line-height: 1.4;
+ opacity: 0.9;
+}
+
+li .pa-trans-text {
+ margin-top: 2px;
+}
+
+td .pa-trans-text,
+th .pa-trans-text {
+ font-size: 0.85em;
+ margin-top: 2px;
+}
+
+/* ---分隔符样式--- */
+.pa-trans-separator {
+ height: 4px;
+ margin-top: 2px;
+}
+
+/* ---翻译按钮容器--- */
+.pa-help-translate-btn-container {
+ display: flex;
+ align-items: center;
+ margin-left: auto;
+ margin-right: 8px;
+ gap: 12px;
+}
+
+/* ---翻译按钮图标动画--- */
+.pa-translate-btn-icon {
+ font-size: 1.1em;
+}
+
+.pa-translate-btn-icon.spinning {
+ animation: pa-spin 1s linear infinite;
+}
+
+@keyframes pa-spin {
+ from {
+ transform: rotate(0deg);
+ }
+
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+/* ---视图模式控制--- */
+.pa-mode-bilingual .pa-original-content {
+ display: inline;
+}
+
+.pa-mode-bilingual .pa-trans-separator {
+ display: block;
+ height: 4px;
+ margin-top: 2px;
+}
+
+.pa-mode-bilingual .pa-trans-text {
+ display: block;
+}
+
+.pa-mode-translation-only .pa-original-content {
+ display: none;
+}
+
+.pa-mode-translation-only .pa-trans-separator {
+ display: none;
+}
+
+.pa-mode-translation-only .pa-trans-text {
+ display: block;
+ margin-top: 0;
+}
+
+.pa-mode-original-only .pa-original-content {
+ display: inline;
+}
+
+.pa-mode-original-only .pa-trans-separator {
+ display: none;
+}
+
+.pa-mode-original-only .pa-trans-text {
+ display: none;
+}
+
+/* ====================== SelectButton 组件样式 ====================== */
+
+/* SelectButton 组容器 */
+.pa-select-button-group {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ gap: 0.5rem;
+}
+
+.pa-select-button-group .pa-form-label {
+ margin-bottom: 0;
+ flex-shrink: 0;
+}
+
+/* 按钮容器 */
+.pa-select-button {
+ display: inline-flex;
+ border-radius: 10px;
+ overflow: hidden;
+ background: #000;
+ /* 深色背景 */
+ padding: 6px;
+ /* 内边距产生间距效果 */
+ gap: 6px;
+ /* 按钮项之间的间距 */
+ border: 1px solid color-mix(in srgb, var(--p-inputtext-border-color), transparent 50%);
+
+
+}
+
+/* 按钮项 */
+.pa-select-button-item {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 0.375rem;
+ padding: 0.5rem 0.75rem;
+ cursor: pointer;
+ user-select: none;
+ border: none;
+ outline: none;
+ background: #222;
+ /* 默认深色背景 */
+ color: var(--p-text-muted-color);
+ border-radius: 6px;
+ /* 独立圆角 */
+ font-size: 0.875rem;
+ font-family: inherit;
+ transition: background-color 0.2s, color 0.2s, box-shadow 0.2s;
+ position: relative;
+ box-shadow: var(--p-dialog-shadow);
+}
+
+/* 移除分隔线 */
+.pa-select-button-item:not(:last-child)::after {
+ display: none;
+}
+
+/* 悬停状态 */
+.pa-select-button-item:not(.pa-select-button-item-disabled):hover {
+ background: var(--p-button-primary-hover-background);
+ color: var(--p-button-primary-color);
+}
+
+/* 选中状态 */
+.pa-select-button-item-selected {
+ background: var(--p-primary-500) !important;
+ color: var(--p-button-primary-color);
+}
+
+.pa-select-button-item-selected::after {
+ display: none;
+}
+
+.pa-select-button-item-selected+.pa-select-button-item::after {
+ display: none;
+}
+
+/* 禁用状态 */
+.pa-select-button-item-disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+/* 按钮图标 */
+.pa-select-button-icon {
+ font-size: 0.875rem;
+}
+
+/* 按钮标签 */
+.pa-select-button-label {
+ white-space: nowrap;
+}
+
+/* 小尺寸 */
+.pa-select-button-small .pa-select-button-item {
+ padding: 0.4rem 1.0rem;
+ font-size: 0.75rem;
+}
+
+.pa-select-button-small .pa-select-button-icon {
+ font-size: 0.75rem;
+}
+
+/* 大尺寸 */
+.pa-select-button-large .pa-select-button-item {
+ padding: 0.625rem 1rem;
+ font-size: 1rem;
+}
+
+.pa-select-button-large .pa-select-button-icon {
+ font-size: 1rem;
+}
\ No newline at end of file
diff --git a/js/index.js b/js/index.js
index 13ffc21..77fec75 100644
--- a/js/index.js
+++ b/js/index.js
@@ -13,8 +13,11 @@ import { UIToolkit } from "./utils/UIToolkit.js";
import { logger } from './utils/logger.js';
import { HistoryCacheService, TagCacheService } from './services/cache.js';
import { imageCaption, ImageCaption } from './modules/imageCaption.js';
+import { nodeHelpTranslator } from './modules/nodeHelpTranslator.js';
+import { nodeMountService, RENDER_MODE } from './services/NodeMountService.js';
+import './node/captionFrame.js'; // 导入视频手动抽帧功能
+
-// import { ensureAutoTranslateInterceptorInstalled } from './services/interceptor.js'; // 导入自动翻译拦截器
// ====================== 全局配置与状态 ======================
@@ -43,11 +46,35 @@ app.registerExtension({
*/
async setup() {
try {
+ // 初始化节点挂载服务(需要在其他初始化之前)
+ nodeMountService.initialize();
+
+ // 注册渲染模式切换处理
+ nodeMountService.onModeChange(async (newMode, oldMode) => {
+ logger.log(`[index] 渲染模式切换检测 | ${oldMode} -> ${newMode}`);
+ // 重新初始化所有小助手
+ if (window.FEATURES.enabled) {
+ // 先清理所有现有实例
+ promptAssistant.cleanup(null, true);
+ imageCaption.cleanup(null, true);
+
+ // 优化:不再使用长延迟,而是通过 requestAnimationFrame 尽快触发
+ // NodeMountService 内部的 Observer 机制会处理 DOM 等待
+ requestAnimationFrame(async () => {
+ await promptAssistant.toggleGlobalFeature(true, true);
+ if (window.FEATURES.imageCaption) {
+ await imageCaption.toggleGlobalFeature(true, true);
+ }
+ logger.log(`[index] 渲染模式切换后重新初始化完成`);
+ });
+ }
+ });
+
// 注册设置选项
registerSettings();
// 初始化自动翻译拦截器(独立于提示词小助手)
- // ensureAutoTranslateInterceptorInstalled();
+
// 初始化提示词小助手(内部会处理版本号检查和总开关状态)
await promptAssistant.initialize();
@@ -76,7 +103,8 @@ app.registerExtension({
HistoryCacheService,
TagCacheService,
imageCaption,
- ImageCaption
+ ImageCaption,
+ nodeHelpTranslator
});
// 然后再自动注册服务功能
@@ -86,6 +114,10 @@ app.registerExtension({
if (window.FEATURES.imageCaption) {
await imageCaption.toggleGlobalFeature(true, false);
}
+ // 初始化节点帮助翻译模块(根据功能开关)
+ if (window.FEATURES.nodeHelpTranslator) {
+ nodeHelpTranslator.initialize();
+ }
}
logger.log("扩展初始化完成");
@@ -93,6 +125,93 @@ app.registerExtension({
logger.error(`扩展初始化失败: ${error.message}`);
}
+ // 延迟hook Note/MarkdownNote/PreviewAny节点类型
+ setTimeout(() => {
+ try {
+ const NoteNodeType = LiteGraph.registered_node_types['Note'];
+ const MarkdownNoteNodeType = LiteGraph.registered_node_types['MarkdownNote'];
+ const PreviewAnyNodeType = LiteGraph.registered_node_types['PreviewAny'];
+ const PreviewTextNodeType = LiteGraph.registered_node_types['PreviewTextNode'];
+
+ if (NoteNodeType) this._hookNoteNodeType(NoteNodeType, 'Note');
+ if (MarkdownNoteNodeType) this._hookNoteNodeType(MarkdownNoteNodeType, 'MarkdownNote');
+ if (PreviewAnyNodeType) this._hookNoteNodeType(PreviewAnyNodeType, 'PreviewAny');
+ if (PreviewTextNodeType) this._hookNoteNodeType(PreviewTextNodeType, 'PreviewTextNode');
+
+ // 可能的其他名称变体
+ const altNames = ['PreviewText', 'Preview as Text', 'Markdown Preview'];
+ altNames.forEach(name => {
+ const nodeType = LiteGraph.registered_node_types[name];
+ if (nodeType) {
+ this._hookNoteNodeType(nodeType, name);
+ logger.debug(`[setup] 注入Preview节点成功 | 类型: ${name}`);
+ }
+ });
+ } catch (error) {
+ logger.error(`[setup] Hook Note节点失败: ${error.message}`);
+ }
+ }, 50);
+ },
+
+ /**
+ * 手动hook使用comfy-markdown的节点类型
+ * 包括 Note、MarkdownNote、PreviewTextNode 等
+ * 因为这些节点通过registerCustomNodes注册,beforeRegisterNodeDef可能无法捕获
+ */
+ _hookNoteNodeType(NodeType, typeName) {
+ if (!NodeType || !NodeType.prototype) {
+ logger.warn(`[_hookNoteNodeType] 无效的节点类型: ${typeName}`);
+ return;
+ }
+
+ const origOnNodeCreated = NodeType.prototype.onNodeCreated;
+ const origOnSelected = NodeType.prototype.onSelected;
+
+ // Hook onNodeCreated
+ NodeType.prototype.onNodeCreated = function () {
+ if (origOnNodeCreated) origOnNodeCreated.apply(this, arguments);
+
+ const nodeRef = this;
+
+ // 优化:移除差异化延迟,立即触发检查
+ // 内部 Observer 会处理 Vue 模式下的 DOM 等待
+ requestAnimationFrame(() => {
+ if (!window.FEATURES.enabled || !nodeRef?.id || nodeRef.id === -1) return;
+
+ if (PromptAssistant.isValidNode(nodeRef)) {
+ const creationMode = app.ui.settings.getSettingValue("PromptAssistant.Settings.CreationMode") || "auto";
+ if (creationMode === "auto" && !nodeRef._promptAssistantInitialized) {
+ nodeRef._promptAssistantInitialized = true;
+ promptAssistant.checkAndSetupNode(nodeRef);
+ }
+ }
+ });
+ };
+
+ // Hook onSelected
+ NodeType.prototype.onSelected = function () {
+ if (origOnSelected) origOnSelected.apply(this, arguments);
+ if (!window.FEATURES.enabled) return;
+
+ const nodeRef = this;
+ const checkAndSetup = () => {
+ nodeRef._promptAssistantInitialized = false;
+ promptAssistant.checkAndSetupNode(nodeRef);
+ };
+
+ // 优化:移除长延迟,仅保留极短延迟以确保栈清理
+ // 主要依赖内部 Observer 机制
+ if (LiteGraph.vueNodesMode === true) {
+ requestAnimationFrame(() => requestAnimationFrame(checkAndSetup));
+ } else {
+ checkAndSetup();
+ }
+ };
+ },
+
+ // ---其他方法保持不变---
+ async _setupOtherMethods() {
+
// 仅保留工作流ID识别功能,不处理工作流切换事件
try {
const LGraph = app.graph.constructor;
@@ -120,6 +239,40 @@ app.registerExtension({
try {
// 调用原始加载方法
const result = await origLoadGraphData.apply(this, arguments);
+
+ // 工作流加载完成后,如果设置为自动创建,则初始化所有节点
+ const creationMode = app.ui.settings.getSettingValue("PromptAssistant.Settings.CreationMode") || "auto";
+ if (creationMode === "auto" && window.FEATURES.enabled) {
+ // 优化:移除长延迟
+ requestAnimationFrame(() => {
+ if (app.graph?._nodes) {
+ app.graph._nodes.forEach(node => {
+ if (node && PromptAssistant.isValidNode(node) && !node._promptAssistantInitialized) {
+ node._promptAssistantInitialized = true;
+ promptAssistant.checkAndSetupNode(node);
+ }
+ });
+ }
+ });
+ }
+
+ // 检查反推小助手创建模式
+ const imageCaptionCreationMode = app.ui.settings.getSettingValue("PromptAssistant.Settings.ImageCaptionCreationMode") || "auto";
+ if (imageCaptionCreationMode === "auto" && window.FEATURES.enabled && window.FEATURES.imageCaption) {
+ requestAnimationFrame(() => {
+ if (app.graph && app.graph._nodes) {
+ app.graph._nodes.forEach(node => {
+ if (node && imageCaption.hasValidImage(node)) {
+ if (!node._imageCaptionInitialized) {
+ node._imageCaptionInitialized = true;
+ imageCaption.checkAndSetupNode(node);
+ }
+ }
+ });
+ }
+ });
+ }
+
return result;
} finally {
// 延迟重置工作流切换标记
@@ -140,33 +293,30 @@ app.registerExtension({
*/
async nodeCreated(node) {
try {
- // 始终检查总开关状态
- if (!window.FEATURES.enabled) {
- return;
- }
+ if (!window.FEATURES.enabled || !node?.id || node.id === -1) return;
- // 只在节点被选中时进行检测和初始化
- if (!node || !node.id || node.id === -1) {
- return;
- }
+ requestAnimationFrame(() => {
+ if (!node?.id || node.id === -1) return;
- // 检查是否为提示词节点
- if (PromptAssistant.isValidNode(node)) {
- if (!node._promptAssistantInitialized) {
- node._promptAssistantInitialized = true;
- promptAssistant.checkAndSetupNode(node);
+ // 检查是否为提示词节点
+ if (PromptAssistant.isValidNode(node)) {
+ const creationMode = app.ui.settings.getSettingValue("PromptAssistant.Settings.CreationMode") || "auto";
+ if (creationMode === "auto" && !node._promptAssistantInitialized) {
+ node._promptAssistantInitialized = true;
+ promptAssistant.checkAndSetupNode(node);
+ }
+ return;
}
- return;
- }
- // 检查是否为图像节点,同时检查图像反推功能开关
- if (window.FEATURES.imageCaption && imageCaption.hasValidImage(node)) {
- if (!node._imageCaptionInitialized) {
- node._imageCaptionInitialized = true;
- imageCaption.checkAndSetupNode(node);
+ // 检查是否为图像节点
+ if (window.FEATURES.imageCaption && imageCaption.hasValidImage(node)) {
+ const imageCaptionCreationMode = app.ui.settings.getSettingValue("PromptAssistant.Settings.ImageCaptionCreationMode") || "auto";
+ if (imageCaptionCreationMode === "auto" && !node._imageCaptionInitialized) {
+ node._imageCaptionInitialized = true;
+ imageCaption.checkAndSetupNode(node);
+ }
}
- return;
- }
+ });
} catch (error) {
logger.error(`节点创建处理失败: ${error.message}`);
}
@@ -236,9 +386,43 @@ app.registerExtension({
return;
}
- // 设置未初始化标记,等待nodeCreated钩子处理
+ // 设置未初始化标记
this._promptAssistantInitialized = false;
this._imageCaptionInitialized = false;
+
+ // 【关键修复】直接在这里调用检查逻辑,而不是依赖扩展的nodeCreated钩子
+ // Vue mode下Note节点可能不会触发扩展的nodeCreated钩子
+ const nodeRef = this;
+ const isVueMode = typeof LiteGraph !== 'undefined' && LiteGraph.vueNodesMode === true;
+ // 优化:使用 requestAnimationFrame 替代 setTimeout
+ requestAnimationFrame(() => {
+ if (!nodeRef || !nodeRef.id || nodeRef.id === -1) {
+ return;
+ }
+
+ logger.debug(() => `[onNodeCreated注入] 延迟检查 | ID: ${nodeRef.id} | 类型: ${nodeRef.type}`);
+
+ // 检查是否为提示词节点
+ if (PromptAssistant.isValidNode(nodeRef)) {
+ const creationMode = app.ui.settings.getSettingValue("PromptAssistant.Settings.CreationMode") || "auto";
+
+ if (creationMode === "auto" && !nodeRef._promptAssistantInitialized) {
+ nodeRef._promptAssistantInitialized = true;
+ logger.debug(() => `[onNodeCreated注入] 创建小助手 | ID: ${nodeRef.id} | 类型: ${nodeRef.type}`);
+ promptAssistant.checkAndSetupNode(nodeRef);
+ }
+ }
+
+ // 检查是否为图像节点
+ if (window.FEATURES.imageCaption && imageCaption.hasValidImage(nodeRef)) {
+ const imageCaptionCreationMode = app.ui.settings.getSettingValue("PromptAssistant.Settings.ImageCaptionCreationMode") || "auto";
+
+ if (imageCaptionCreationMode === "auto" && !nodeRef._imageCaptionInitialized) {
+ nodeRef._imageCaptionInitialized = true;
+ imageCaption.checkAndSetupNode(nodeRef);
+ }
+ }
+ });
};
// 注入节点选择方法
@@ -252,19 +436,33 @@ app.registerExtension({
return;
}
- // 重置提示词小助手初始化标记,确保每次选择都重新检测节点状态
- this._promptAssistantInitialized = false;
- promptAssistant.checkAndSetupNode(this);
+ const nodeRef = this;
- // 确保选择事件能触发到图像小助手,同时检查图像反推功能开关
- if (window.FEATURES.imageCaption &&
- app.canvas && app.canvas._imageCaptionSelectionHandler) {
- // 重置初始化标记,确保每次选择都重新检测节点状态
- this._imageCaptionInitialized = false;
+ // Vue mode下需要延迟执行,确保DOM渲染完成
+ const isVueMode = typeof LiteGraph !== 'undefined' && LiteGraph.vueNodesMode === true;
+ const checkAndSetup = () => {
+ // 重置提示词小助手初始化标记,确保每次选择都重新检测节点状态
+ // 无论是自动还是手动模式,选中时都尝试检查(作为手动触发或自动模式的重试/补救)
+ nodeRef._promptAssistantInitialized = false;
+ promptAssistant.checkAndSetupNode(nodeRef);
- const selected_nodes = {};
- selected_nodes[this.id] = this;
- app.canvas._imageCaptionSelectionHandler(selected_nodes);
+ // 确保选择事件能触发到图像小助手,同时检查图像反推功能开关
+ if (window.FEATURES.imageCaption &&
+ app.canvas && app.canvas._imageCaptionSelectionHandler) {
+ // 重置初始化标记,确保每次选择都重新检测节点状态
+ nodeRef._imageCaptionInitialized = false;
+
+ const selected_nodes = {};
+ selected_nodes[nodeRef.id] = nodeRef;
+ app.canvas._imageCaptionSelectionHandler(selected_nodes);
+ }
+ };
+
+ if (isVueMode) {
+ // Vue mode下使用 requestAnimationFrame 快速响应
+ requestAnimationFrame(() => requestAnimationFrame(checkAndSetup));
+ } else {
+ checkAndSetup();
}
};
diff --git a/js/modules/AssistantContainer.js b/js/modules/AssistantContainer.js
new file mode 100644
index 0000000..0d5b1da
--- /dev/null
+++ b/js/modules/AssistantContainer.js
@@ -0,0 +1,451 @@
+import { app } from "../../../scripts/app.js";
+import { EventManager } from "../utils/eventManager.js";
+import "../lib/Sortable.min.js";
+
+// 锚点位置枚举
+export const ANCHOR_POSITION = {
+ TOP_LEFT_H: 'top-left-h',
+ TOP_LEFT_V: 'top-left-v',
+ TOP_CENTER_H: 'top-center-h',
+ TOP_RIGHT_H: 'top-right-h',
+ TOP_RIGHT_V: 'top-right-v',
+ RIGHT_CENTER_V: 'right-center-v',
+ BOTTOM_RIGHT_H: 'bottom-right-h',
+ BOTTOM_RIGHT_V: 'bottom-right-v',
+ BOTTOM_CENTER_H: 'bottom-center-h',
+ BOTTOM_LEFT_H: 'bottom-left-h',
+ BOTTOM_LEFT_V: 'bottom-left-v',
+ LEFT_CENTER_V: 'left-center-v'
+};
+
+export class AssistantContainer {
+ constructor(options = {}) {
+ this.nodeId = options.nodeId;
+ this.type = options.type || 'prompt'; // 'prompt' or 'image'
+ this.anchorPosition = options.anchorPosition || ANCHOR_POSITION.BOTTOM_RIGHT_H;
+ this.offset = options.offset || { x: 0, y: 0 };
+ this.enableDragSort = options.enableDragSort !== false;
+
+ // 回调函数
+ this.onButtonOrderChange = options.onButtonOrderChange;
+ this.shouldCollapse = options.shouldCollapse;
+
+ // 状态
+ this.isCollapsed = true;
+ this.isTransitioning = false;
+ this.buttons = [];
+ this.element = null;
+ this.container = null;
+ this.hoverArea = null;
+ this.indicator = null;
+ this.content = null;
+
+ // 定时器
+ this._collapseTimer = null;
+ this._expandTimer = null;
+
+ // 事件清理函数
+ this._cleanupFunctions = [];
+
+ // Sortable 实例
+ this._sortable = null;
+ }
+
+ render() {
+ // 主容器
+ this.element = document.createElement('div');
+ this.element.className = `assistant-container-common ${this.type}-assistant-container`;
+
+ // 悬停区域(不可见,用于检测鼠标进入/离开)
+ this.hoverArea = document.createElement('div');
+ this.hoverArea.className = 'assistant-hover-area';
+ this.element.appendChild(this.hoverArea);
+
+ // 指示器(图标)
+ this.indicator = document.createElement('div');
+ this.indicator.className = `assistant-indicator ${this.type}-assistant-indicator`;
+
+ // 添加入场动画类
+ this.indicator.classList.add('indicator-init');
+
+ // 动画结束后移除初始化类
+ this.indicator.addEventListener('animationend', () => {
+ this.indicator.classList.remove('indicator-init');
+ }, { once: true });
+
+ this.element.appendChild(this.indicator);
+
+ // 按钮内容容器
+ this.content = document.createElement('div');
+ this.content.className = 'assistant-content';
+ this.element.appendChild(this.content);
+
+ // 基于锚点的初始样式
+ this.updatePosition();
+
+ // 绑定事件
+ this._bindEvents();
+
+ // 设置 Sortable
+ if (this.enableDragSort) {
+ this._setupSortable();
+ }
+
+ return this.element;
+ }
+
+ mount(parentElement) {
+ if (parentElement) {
+ parentElement.appendChild(this.element);
+ // 挂载后强制回流/更新尺寸
+ requestAnimationFrame(() => this.updateDimensions());
+ }
+ }
+
+ setIconContent(svgContent) {
+ if (this.indicator) {
+ this.indicator.innerHTML = svgContent;
+ }
+ }
+
+ addButton(buttonElement, id) {
+ if (!buttonElement) return;
+ buttonElement.dataset.id = id; // 用于 Sortable
+
+ // 设置按钮索引,用于递进动画延迟
+ const buttonIndex = this.buttons.length;
+ buttonElement.style.setProperty('--button-index', buttonIndex);
+
+ this.content.appendChild(buttonElement);
+ this.buttons.push({ id, element: buttonElement });
+
+ // 如果是分割线,根据当前布局方向设置类名
+ if (buttonElement.classList.contains('prompt-assistant-divider') ||
+ buttonElement.classList.contains('image-assistant-divider')) {
+ const isVertical = this.anchorPosition.endsWith('-v');
+ if (isVertical) {
+ buttonElement.classList.add('divider-horizontal');
+ }
+ }
+
+ this.updateDimensions();
+ }
+
+ // 批量添加按钮,如果已存在Sortable,则会遵循Sortable的逻辑(通常append)
+ // 如果需要特定顺序,应该在添加前排好序
+ addButtons(buttonElementsWithIds) {
+ buttonElementsWithIds.forEach(({ element, id }) => {
+ this.addButton(element, id);
+ });
+ }
+
+ // 清空按钮
+ clearButtons() {
+ this.content.innerHTML = '';
+ this.buttons = [];
+ }
+
+ setAnchorPosition(position) {
+ if (Object.values(ANCHOR_POSITION).includes(position)) {
+ this.anchorPosition = position;
+ this.updatePosition();
+ }
+ }
+
+ updatePosition() {
+ if (!this.element) return;
+
+ // 重置类名
+ this.element.className = `assistant-container-common ${this.type}-assistant-container collapsed`;
+
+ // 添加布局类名
+ this.element.classList.add(`layout-${this.anchorPosition}`);
+
+ // 确保内容容器的 Flex 方向正确
+ const isVertical = this.anchorPosition.endsWith('-v');
+ if (isVertical) {
+ this.content.classList.add('flex-col');
+ this.content.classList.remove('flex-row');
+ } else {
+ this.content.classList.add('flex-row');
+ this.content.classList.remove('flex-col');
+ }
+
+ // 更新分割线类名:垂直布局时添加 divider-horizontal 类
+ this._updateDividerOrientation(isVertical);
+
+ // 触发尺寸重新计算
+ this.updateDimensions();
+ }
+
+ // 更新分割线方向类名
+ _updateDividerOrientation(isVertical) {
+ if (!this.content) return;
+ const dividers = this.content.querySelectorAll('.prompt-assistant-divider, .image-assistant-divider');
+ dividers.forEach(divider => {
+ if (isVertical) {
+ divider.classList.add('divider-horizontal');
+ } else {
+ divider.classList.remove('divider-horizontal');
+ }
+ });
+ }
+
+ updateDimensions() {
+ if (!this.element || !this.content) return;
+
+ // 测量内容尺寸
+ const clone = this.content.cloneNode(true);
+ clone.style.cssText = `
+ position: absolute;
+ visibility: hidden;
+ height: auto;
+ width: auto;
+ white-space: nowrap;
+ display: flex;
+ align-items: center;
+ gap: 0;
+ `;
+
+ // 根据方向设置 flex-direction
+ const isVertical = this.anchorPosition.endsWith('-v');
+ clone.style.flexDirection = isVertical ? 'column' : 'row';
+
+ document.body.appendChild(clone);
+ const contentWidth = clone.scrollWidth;
+ const contentHeight = clone.scrollHeight;
+ document.body.removeChild(clone);
+
+ // 容器的 padding
+ const containerPadding = 4; // 2px padding * 2
+
+ // 按钮的 margin (每个按钮都有 margin: 2px)
+ // 注意:scrollWidth/scrollHeight 不包含最后一个子元素的 margin-right/margin-bottom
+ // 所以需要手动添加最后一个按钮的 margin
+ const lastButtonMargin = 2; // 最后一个按钮的 margin-right 或 margin-bottom
+
+ // 折叠状态的固定尺寸
+ const collapsedSize = 28;
+
+ // 根据布局方向计算展开后的尺寸
+ let expandedWidth, expandedHeight;
+
+ if (isVertical) {
+ // 竖向布局:宽度固定为折叠尺寸,高度根据内容计算
+ expandedWidth = collapsedSize;
+ // contentHeight 不包含最后一个按钮的 margin-bottom,需要手动添加
+ expandedHeight = Math.max(contentHeight + containerPadding + lastButtonMargin, collapsedSize);
+ } else {
+ // 横向布局:高度固定为折叠尺寸,宽度根据内容计算
+ // contentWidth 不包含最后一个按钮的 margin-right,需要手动添加
+ expandedWidth = Math.max(contentWidth + containerPadding + lastButtonMargin, collapsedSize);
+ expandedHeight = collapsedSize;
+ }
+
+ // 更新 CSS 变量
+ this.element.style.setProperty('--expanded-width', `${expandedWidth}px`);
+ this.element.style.setProperty('--expanded-height', `${expandedHeight}px`);
+ }
+
+ _bindEvents() {
+ // 带中断逻辑的悬停处理
+ const onMouseEnter = () => this.expand();
+ const onMouseLeave = () => this.collapse();
+
+ // 绑定到悬停区域和元素本身
+ // 使用 EventManager 来绑定,以便于清理
+ this._cleanupFunctions.push(EventManager.addDOMListener(this.element, 'mouseenter', onMouseEnter));
+ this._cleanupFunctions.push(EventManager.addDOMListener(this.element, 'mouseleave', onMouseLeave));
+ }
+
+ expand() {
+ // 清除任何挂起的折叠定时器
+ if (this._collapseTimer) {
+ clearTimeout(this._collapseTimer);
+ this._collapseTimer = null;
+ }
+
+ // 先更新尺寸,确保 CSS 变量在展开前就设置好
+ this.updateDimensions();
+
+ // 根据锚点位置调整按钮递进动画索引
+ this._updateButtonStaggerIndex();
+
+ // 立即展开
+ this.isCollapsed = false;
+ this.element.classList.remove('collapsed');
+ this.element.classList.add('expanded');
+
+ // 隐藏指示器
+ if (this.indicator) {
+ this.indicator.style.opacity = '0';
+ this.indicator.style.pointerEvents = 'none';
+ }
+
+ // 显示内容
+ if (this.content) {
+ this.content.style.opacity = '1';
+ this.content.style.pointerEvents = 'auto';
+ }
+ }
+
+ // 根据锚点位置调整按钮递进动画索引
+ _updateButtonStaggerIndex() {
+ if (!this.content) return;
+
+ const children = Array.from(this.content.children);
+ const totalButtons = children.length;
+
+ // 判断是否需要反向索引
+ // 右侧布局向左展开、底部布局向上展开时,需要反向(最后的按钮先显示)
+ const needReverse = this._isReverseStaggerDirection();
+
+ children.forEach((child, index) => {
+ const staggerIndex = needReverse ? (totalButtons - 1 - index) : index;
+ child.style.setProperty('--button-index', staggerIndex);
+ });
+ }
+
+ // 判断递进动画是否需要反向
+ _isReverseStaggerDirection() {
+ // 从右侧/底部展开的布局需要反向
+ // right 布局:从右向左展开,最右边的按钮先显示
+ // bottom-v 布局:从下向上展开,最下面的按钮先显示
+ const pos = this.anchorPosition;
+
+ // 横向布局:右侧的需要反向
+ if (pos.includes('right') && pos.endsWith('-h')) {
+ return true;
+ }
+ // 竖向布局:底部的需要反向(column-reverse)
+ if (pos.includes('bottom') && pos.endsWith('-v')) {
+ return true;
+ }
+
+ return false;
+ }
+
+ collapse() {
+ // 检查是否应阻止折叠(例如:激活的菜单)
+ if (this.shouldCollapse && !this.shouldCollapse()) {
+ return;
+ }
+
+ // 折叠前设置短暂延迟,允许鼠标在间隙/按钮之间移动
+ // 但如果用户移回,expand() 会取消此操作。
+ this._collapseTimer = setTimeout(() => {
+ // 再次检查,因为在延迟期间状态可能已改变
+ if (this.shouldCollapse && !this.shouldCollapse()) {
+ return;
+ }
+
+ this.isCollapsed = true;
+ this.element.classList.remove('expanded');
+ this.element.classList.add('collapsed');
+
+ // 显示指示器(清除内联样式,让 CSS 变量 --assistant-icon-opacity 生效)
+ if (this.indicator) {
+ this.indicator.style.opacity = '';
+ this.indicator.style.pointerEvents = '';
+ }
+
+ // 隐藏内容
+ if (this.content) {
+ this.content.style.opacity = '0';
+ this.content.style.pointerEvents = 'none';
+ }
+ }, 150); // 为了易用性设置的小延迟
+ }
+
+ _setupSortable() {
+ if (!this.content) return;
+
+ this._sortable = new Sortable(this.content, {
+ animation: 150,
+ ghostClass: 'sortable-ghost',
+ onEnd: (evt) => {
+ const newOrder = Array.from(this.content.children)
+ .map(el => el.dataset.id)
+ .filter(Boolean);
+
+ // 保存排序
+ if (this.onButtonOrderChange) {
+ this.onButtonOrderChange(newOrder);
+ }
+
+ // 持久化到 settings
+ this._saveOrderToSettings(newOrder);
+ }
+ });
+ }
+
+ _saveOrderToSettings(order) {
+ const settingKey = `PromptAssistant.ButtonOrder.${this.type}`;
+ // 使用 app.ui.settings 保存
+ // ComfyUI 设置通常通过 app.ui.settings.setSettingValue(id, value) 设置
+ if (app.ui && app.ui.settings) {
+ app.ui.settings.setSettingValue(settingKey, JSON.stringify(order));
+ }
+ }
+
+ restoreOrder() {
+ const settingKey = `PromptAssistant.ButtonOrder.${this.type}`;
+ if (!app.ui || !app.ui.settings) return;
+
+ const orderStr = app.ui.settings.getSettingValue(settingKey);
+ if (!orderStr) return;
+
+ try {
+ const order = JSON.parse(orderStr);
+ if (!Array.isArray(order) || order.length === 0) return;
+
+ // 按ID创建现有按钮的映射
+ const buttonMap = new Map();
+ Array.from(this.content.children).forEach(el => {
+ if (el.dataset.id) {
+ buttonMap.set(el.dataset.id, el);
+ }
+ });
+
+ // 按保存的顺序恢复按钮位置,新增按钮放在末尾
+ const existingButtons = Array.from(this.content.children);
+ const orderedIds = new Set(order);
+
+ // 首先追加排序后的项
+ order.forEach(id => {
+ const el = buttonMap.get(id);
+ if (el) {
+ this.content.appendChild(el);
+ }
+ });
+
+ // 然后追加任何剩余项,如果它们不在顺序列表中
+ existingButtons.forEach(el => {
+ if (el.dataset.id && !orderedIds.has(el.dataset.id)) {
+ this.content.appendChild(el);
+ }
+ });
+
+ this.updateDimensions();
+ } catch (e) {
+ console.warn("[PromptAssistant] 恢复按钮顺序失败:", e);
+ }
+ }
+
+ destroy() {
+ // 清理监听器
+ this._cleanupFunctions.forEach(fn => fn && fn());
+ this._cleanupFunctions = [];
+
+ // 销毁 Sortable
+ if (this._sortable) {
+ this._sortable.destroy();
+ this._sortable = null;
+ }
+
+ // 移除元素
+ if (this.element && this.element.parentNode) {
+ this.element.parentNode.removeChild(this.element);
+ }
+ }
+}
diff --git a/js/modules/PromptAssistant.js b/js/modules/PromptAssistant.js
index 5914407..02401b6 100644
--- a/js/modules/PromptAssistant.js
+++ b/js/modules/PromptAssistant.js
@@ -14,54 +14,15 @@ import { ResourceManager } from "../utils/resourceManager.js";
import { UIToolkit } from "../utils/UIToolkit.js";
import { PromptFormatter } from "../utils/promptFormatter.js";
import { APIService } from "../services/api.js";
-// import { CLIP_NODE_TYPES } from "../services/interceptor.js";
+
import { buttonMenu } from "../services/btnMenu.js";
import { rulesConfigManager } from "./rulesConfigManager.js";
+import { nodeMountService, RENDER_MODE } from "../services/NodeMountService.js";
+import { AssistantContainer, ANCHOR_POSITION } from "./AssistantContainer.js";
+import { PopupManager } from "../utils/popupManager.js";
+import { MarkdownNoteTranslate } from "../utils/markdownNoteTranslate.js";
-/**
- * 更新指定CLIP编码器节点的自动翻译指示器状态
- * 使用从interceptor.js导入的CLIP_NODE_TYPES,确保UI指示器与实际功能一致
- * @param {boolean} enabled
- */
-/*
-export function updateAutoTranslateIndicators(enabled) {
- if (!app.canvas?.graph) return;
- app.canvas.graph._nodes.forEach(node => {
- // 检查是否为指定的CLIP编码器节点
- const isClipNode = node.type && CLIP_NODE_TYPES.includes(node.type);
-
- if (isClipNode) {
- // 查找该节点的小助手实例
- const nodeId = node.id;
- if (node.widgets) {
- node.widgets.forEach(widget => {
- const inputId = widget.name || widget.id;
- if (inputId) {
- const assistantKey = `${nodeId}_${inputId}`;
- const assistantInstance = PromptAssistant.getInstance(assistantKey);
-
- // 如果找到小助手实例,更新其样式
- if (assistantInstance && assistantInstance.element) {
- assistantInstance.element.classList.toggle('auto-translate-enabled', enabled);
- }
- }
- });
- }
- }
- });
-}
-*/
-
-/**
- * 暂时禁用的自动翻译指示器功能的空占位函数
- * 保持导出以避免其他模块的导入错误
- */
-export function updateAutoTranslateIndicators(enabled) {
- // 空实现,不执行任何操作
- logger.debug("自动翻译指示器功能已暂时禁用");
- return;
-}
// ====================== 工具函数 ======================
@@ -76,10 +37,10 @@ function calculateAssistantWidth() {
const hasTag = window.FEATURES.tag;
const hasExpand = window.FEATURES.expand;
const hasTranslate = window.FEATURES.translate;
-
+
// 统计非历史功能的数量
const otherFeaturesCount = [hasTag, hasExpand, hasTranslate].filter(Boolean).length;
-
+
// 根据功能组合返回预设宽度
if (hasHistory && otherFeaturesCount === 3) {
// 所有功能全开
@@ -103,37 +64,12 @@ function calculateAssistantWidth() {
// 只有一个其他功能
return 31.87;
}
-
+
// 默认值(理论上不应该到这里)
return 31.87;
}
-/**
- * 生成唯一请求ID
- */
-function generateRequestId(prefix = 'baidu_trans', type = null) {
- // 如果提供了type,根据type确定前缀
- if (type) {
- switch (type) {
- case 'llm':
- prefix = 'llm_trans';
- break;
- case 'baidu':
- prefix = 'baidu_trans';
- break;
- case 'expand':
- prefix = 'glm4_expand';
- break;
- default:
- // 保持传入的prefix不变
- break;
- }
- }
- const timestamp = Date.now();
- const random = Math.random().toString(36).substr(2, 6);
- return `${prefix}_${timestamp}_${random}`;
-}
/**
* 防抖函数
@@ -143,6 +79,181 @@ function debounce(func, wait = 100) {
return EventManager.debounce(func, wait);
}
+/**
+ * 获取输入元素的内容
+ * 支持普通textarea、Tiptap编辑器、ProseMirror编辑器等
+ * @param {object} widget - 小助手widget对象
+ * @returns {string} 输入内容
+ */
+function getInputValue(widget, options = {}) {
+ if (!widget || !widget.inputEl) {
+ return '';
+ }
+
+ const inputEl = widget.inputEl;
+ const returnHtml = options.html === true;
+
+ // 标准textarea
+ if (inputEl.tagName === 'TEXTAREA' && inputEl.value !== undefined) {
+ return inputEl.value;
+ }
+
+ // Tiptap/ProseMirror/comfy-markdown编辑器
+ if (inputEl.classList.contains('tiptap') ||
+ inputEl.classList.contains('ProseMirror') ||
+ inputEl.classList.contains('comfy-markdown')) {
+
+ let targetEl = inputEl;
+ // 对于comfy-markdown,查找内部编辑器元素
+ if (inputEl.classList.contains('comfy-markdown')) {
+ const editorEl = inputEl.querySelector('.tiptap, .ProseMirror');
+ if (editorEl) {
+ targetEl = editorEl;
+ }
+ }
+
+ if (returnHtml) {
+ return targetEl.innerHTML || '';
+ }
+
+ const textContent = targetEl.textContent || targetEl.innerText || '';
+ if (textContent.trim()) {
+ return textContent;
+ }
+
+ // 从widget.value获取
+ if (widget.value !== undefined) {
+ return widget.value;
+ }
+
+ // 从node.widgets找到对应的widget.value
+ if (widget.node && widget.node.widgets) {
+ const matchingWidget = widget.node.widgets.find(w =>
+ w.name === widget.inputId || w.name === 'text'
+ );
+ if (matchingWidget && matchingWidget.value !== undefined) {
+ return matchingWidget.value;
+ }
+ }
+ }
+
+ // contenteditable元素
+ if (inputEl.isContentEditable || inputEl.getAttribute('contenteditable') === 'true') {
+ if (returnHtml) {
+ return inputEl.innerHTML || '';
+ }
+ return inputEl.textContent || inputEl.innerText || '';
+ }
+
+ // widget.value
+ if (widget.value !== undefined && typeof widget.value === 'string') {
+ return widget.value;
+ }
+
+ // inputWidget.value
+ if (widget.inputWidget && widget.inputWidget.value !== undefined) {
+ return widget.inputWidget.value;
+ }
+
+ return '';
+}
+
+/**
+ * 设置输入元素的内容
+ * 支持普通textarea、Tiptap编辑器、ProseMirror编辑器等
+ * @param {object} widget - 小助手widget对象
+ * @param {string} content - 要设置的内容
+ * @returns {boolean} 是否设置成功
+ */
+function setInputValue(widget, content, options = {}) {
+ if (!widget || !widget.inputEl) {
+ return false;
+ }
+
+ const inputEl = widget.inputEl;
+ const useHtml = options.html === true;
+
+ try {
+ // 标准textarea
+ if (inputEl.tagName === 'TEXTAREA' && inputEl.value !== undefined) {
+ inputEl.value = content;
+ inputEl.dispatchEvent(new Event('input', { bubbles: true }));
+ inputEl.dispatchEvent(new Event('change', { bubbles: true }));
+ return true;
+ }
+
+ // comfy-markdown或Tiptap/ProseMirror编辑器
+ if (inputEl.classList.contains('comfy-markdown') ||
+ inputEl.classList.contains('tiptap') ||
+ inputEl.classList.contains('ProseMirror')) {
+
+ // 对于comfy-markdown,找到内部编辑器
+ let targetEl = inputEl;
+ if (inputEl.classList.contains('comfy-markdown')) {
+ const editorEl = inputEl.querySelector('.tiptap, .ProseMirror');
+ if (editorEl) {
+ targetEl = editorEl;
+ }
+ }
+
+ // 设置textContent/innerHTML
+ if (targetEl.isContentEditable || targetEl.getAttribute('contenteditable') === 'true') {
+ if (useHtml) {
+ targetEl.innerHTML = content;
+ } else {
+ targetEl.textContent = content;
+ }
+ } else {
+ targetEl.innerHTML = content;
+ }
+
+ // 触发输入事件
+ targetEl.dispatchEvent(new Event('input', { bubbles: true }));
+ targetEl.dispatchEvent(new Event('change', { bubbles: true }));
+
+ // 同时更新widget.value
+ if (widget.value !== undefined) {
+ widget.value = content;
+ }
+
+ // 同时更新node.widgets[].value
+ if (widget.node && widget.node.widgets) {
+ const matchingWidget = widget.node.widgets.find(w =>
+ w.name === widget.inputId || w.name === 'text'
+ );
+ if (matchingWidget) {
+ matchingWidget.value = content;
+ }
+ }
+
+ return true;
+ }
+
+ // contenteditable元素
+ if (inputEl.isContentEditable || inputEl.getAttribute('contenteditable') === 'true') {
+ if (useHtml) {
+ inputEl.innerHTML = content;
+ } else {
+ inputEl.textContent = content;
+ }
+ inputEl.dispatchEvent(new Event('input', { bubbles: true }));
+ inputEl.dispatchEvent(new Event('change', { bubbles: true }));
+ return true;
+ }
+
+ // widget.value
+ if (widget.value !== undefined) {
+ widget.value = content;
+ return true;
+ }
+
+ return false;
+ } catch (error) {
+ logger.error(`[setInputValue] 设置失败 | 错误: ${error.message}`);
+ return false;
+ }
+}
+
// ====================== 主类实现 ======================
/**
@@ -182,20 +293,20 @@ class PromptAssistant {
// 初始化事件管理器
EventManager.init();
- // 检查总开关的初始状态
- const initialEnabled = app.ui.settings.getSettingValue("PromptAssistant.Features.Enabled");
- window.FEATURES.enabled = initialEnabled !== undefined ? initialEnabled : true;
+ // 从配置加载所有功能开关状态
+ FEATURES.loadSettings();
+ // 同步到 window.FEATURES 以兼容旧代码
+ window.FEATURES.enabled = FEATURES.enabled;
// 记录总开关状态(改为调试级别)
- logger.debug(`初始化时检测总开关状态 | 状态:${window.FEATURES.enabled ? "启用" : "禁用"}`);
+ logger.debug(`初始化时检测总开关状态 | 状态:${FEATURES.enabled ? "启用" : "禁用"}`);
// 初始化资源管理器
ResourceManager.init();
// 只有在总开关打开时才做完整初始化
if (window.FEATURES.enabled) {
- // 更新CLIP节点的指示器状态
- updateAutoTranslateIndicators(window.FEATURES.autoTranslate);
+
}
this.initialized = true;
@@ -303,12 +414,7 @@ class PromptAssistant {
// 按钮可见性更新在features中单独处理
window.FEATURES.updateButtonsVisibility();
- // 如果功能已启用,更新自动翻译指示器状态
- /* 暂时禁用自动翻译指示器
- if (enable) {
- updateAutoTranslateIndicators(window.FEATURES.autoTranslate);
- }
- */
+
} catch (error) {
logger.error(`总开关操作失败 | 错误: ${error.message}`);
// 恢复原始状态
@@ -321,6 +427,15 @@ class PromptAssistant {
* 清理所有资源
*/
cleanup(nodeId = null, silent = false) {
+ // 【调试】添加调用栈追踪,帮助定位触发清理的原因
+ // 在非切换工作流和非静默模式下,打印详细的调用栈
+ if (!window.PROMPT_ASSISTANT_WORKFLOW_SWITCHING && !silent) {
+ if (nodeId !== null && nodeId !== undefined) {
+ console.trace(`[PromptAssistant-调试] cleanup 被调用 | 节点ID: ${nodeId}`);
+ logger.debug(`[cleanup] 调用栈追踪 | 节点ID: ${nodeId} | 请查看控制台 trace 信息`);
+ }
+ }
+
// 如果正在切换工作流,则只清理UI实例,不删除缓存
if (window.PROMPT_ASSISTANT_WORKFLOW_SWITCHING) {
// 简化日志:工作流切换期间不逐条打印节点清理日志,避免高频刷屏
@@ -444,16 +559,59 @@ class PromptAssistant {
}
}
+ // ---节点类型检测工具---
+
+ /**
+ * 检查节点是否为使用comfy-markdown的节点
+ * 包括 Note、MarkdownNote、PreviewTextNode 等
+ * @param {object} node - 节点对象
+ * @returns {boolean}
+ */
+ _isMarkdownNode(node) {
+ if (!node || !node.type) return false;
+
+ // 已知的使用comfy-markdown的节点类型
+ const markdownNodeTypes = ['Note', 'MarkdownNote', 'PreviewAny', 'PreviewTextNode'];
+ if (markdownNodeTypes.includes(node.type)) {
+ return true;
+ }
+
+ // 检查节点类型名称是否包含相关关键词
+ const typeLower = node.type.toLowerCase();
+ return typeLower.includes('markdown') ||
+ (typeLower.includes('preview') && typeLower.includes('text'));
+ }
+
// ---实例管理功能---
/**
* 检查节点是否有效
+ * Vue mode下Note/MarkdownNote节点可能没有widgets属性,需要特殊处理
*/
static isValidNode(node) {
- return node &&
- typeof node.id !== 'undefined' &&
- node.id !== -1 && // 防止id为-1的无效节点
- node.widgets && // 确保有widgets属性
- typeof node.type === 'string'; // 确保有类型
+ if (!node || typeof node.id === 'undefined' || node.id === -1) {
+ return false;
+ }
+
+ if (typeof node.type !== 'string') {
+ return false;
+ }
+
+ // Vue mode下的特殊节点类型(可能没有标准widgets属性)
+ const isVueMode = typeof LiteGraph !== 'undefined' && LiteGraph.vueNodesMode === true;
+ const vueSpecialNodeTypes = ['Note', 'MarkdownNote', 'PreviewAny', 'PreviewTextNode'];
+
+ // 检查是否为markdown类型节点
+ const isMarkdownNode = vueSpecialNodeTypes.includes(node.type) ||
+ (node.type && node.type.toLowerCase().includes('markdown')) ||
+ (node.type && node.type.toLowerCase().includes('preview') && node.type.toLowerCase().includes('text'));
+
+ if (isVueMode && isMarkdownNode) {
+ // Vue mode下这些节点类型直接视为有效
+ return true;
+ }
+
+ // 标准检查:需要有widgets属性
+ return !!node.widgets;
}
/**
@@ -488,28 +646,41 @@ class PromptAssistant {
* 查找节点中的有效输入控件并创建小助手
*/
checkAndSetupNode(node) {
- // 始终首先检查总开关状态
- if (!window.FEATURES.enabled || !node || !node.widgets) {
+ // 快速检查
+ if (!window.FEATURES.enabled || !node) return;
+
+ const isVueMode = LiteGraph.vueNodesMode === true;
+
+ // Vue mode下Note/MarkdownNote节点特殊处理
+ if (!node.widgets) {
+ if (isVueMode && (node.type === 'Note' || node.type === 'MarkdownNote')) {
+ this._handleVueSpecialNode(node);
+ }
return;
}
// 获取所有有效的输入控件
const validInputs = node.widgets.filter(widget => {
- return UIToolkit.isValidInput(widget);
+ if (!widget.node) widget.node = node;
+ return UIToolkit.isValidInput(widget, { debug: false, node: node });
});
if (validInputs.length === 0) {
+ // Vue mode下使用comfy-markdown节点回退处理
+ if (isVueMode && this._isMarkdownNode(node)) {
+ this._handleVueSpecialNode(node);
+ }
return;
}
// 为每个有效控件创建小助手
validInputs.forEach((inputWidget, widgetIndex) => {
const inputId = inputWidget.name || inputWidget.id;
-
+
// 生成唯一的 assistantKey
// 对于同名的多个输入框(如 Show Any 节点的列表输入),使用索引区分
let assistantKey = `${node.id}_${inputId}`;
-
+
// 检查是否存在同名的输入框,如果存在则使用索引或 DOM 元素的唯一标识
const sameNameWidgets = validInputs.filter(w => (w.name || w.id) === inputId);
if (sameNameWidgets.length > 1) {
@@ -531,11 +702,31 @@ class PromptAssistant {
if (PromptAssistant.hasInstance(assistantKey)) {
// 如果实例存在,检查输入控件是否已更新
const instance = PromptAssistant.getInstance(assistantKey);
- if (instance && instance.text_element !== inputWidget.inputEl) {
- // 如果输入控件已更新,清理旧实例并创建新实例
- this.cleanup(node.id);
+ const currentInputEl = inputWidget.inputEl;
+ const instanceInputEl = instance?.text_element;
+
+ // 只有在以下情况下才清理并重建:
+ // 1. 两个元素都存在
+ // 2. 它们是不同的元素
+ // 3. 不是因为临时的 DOM 状态变化(如打开弹窗)
+ if (instanceInputEl && currentInputEl && instanceInputEl !== currentInputEl) {
+ // 进一步检查:确保确实需要重建(避免误判)
+ // 如果当前元素已经从 DOM 中移除,才需要清理
+ if (!document.body.contains(instanceInputEl)) {
+ logger.debug(() => `[checkAndSetupNode] 输入元素已失效,清理实例 | 节点ID: ${node.id}`);
+ this.cleanup(node.id);
+ } else {
+ // 元素仍然在 DOM 中,可能只是引用变化,不需要清理
+ logger.debug(() => `[checkAndSetupNode] 输入元素引用变化但仍有效,跳过清理 | 节点ID: ${node.id}`);
+ return;
+ }
+ } else if (!currentInputEl && instanceInputEl) {
+ // Vue 模式下,inputEl 可能暂时为 null,不应该触发清理
+ logger.debug(() => `[checkAndSetupNode] 当前inputEl为null,跳过清理(Vue模式下可能暂时为null) | 节点ID: ${node.id}`);
+ return;
} else {
- return; // 跳过已存在且未更新的实例
+ // 实例存在且未更新,跳过
+ return;
}
}
@@ -544,25 +735,69 @@ class PromptAssistant {
return;
}
+ // 【防重复挂载检查】在创建前检查 inputEl 是否已被其他实例挂载
+ const inputEl = inputWidget.inputEl || inputWidget.element;
+ if (inputEl && inputEl._promptAssistantMounted) {
+ logger.debug(() => `[checkAndSetupNode] 跳过创建 | 原因: inputEl 已被挂载 | 节点ID: ${node.id} | 控件: ${inputId}`);
+ return;
+ }
+
// 创建小助手实例
const assistant = this.setupNodeAssistant(node, inputWidget, assistantKey);
if (assistant) {
- logger.log(`创建小助手 | 节点:${node.id} | 控件:${inputId} | 索引:${widgetIndex} | 实例:${assistantKey}`);
+ logger.log(() => `创建小助手 | 节点:${node.id} | 控件:${inputId} | 索引:${widgetIndex} | 实例:${assistantKey}`);
- // 检查是否为指定的CLIP编码器节点,并应用自动翻译样式
- // 使用从interceptor.js导入的CLIP_NODE_TYPES,确保UI指示器与实际功能一致
- // 暂时禁用自动翻译检测
- /*
- const isClipNode = node.type && CLIP_NODE_TYPES.includes(node.type);
- if (isClipNode && assistant.element) {
- assistant.element.classList.toggle('auto-translate-enabled', window.FEATURES.autoTranslate);
- }
- */
}
});
}
+ /**
+ * Vue mode下使用comfy-markdown节点的特殊处理
+ * 包括 Note、MarkdownNote、PreviewTextNode 等
+ * 直接从DOM中查找textarea/编辑器并创建小助手
+ */
+ _handleVueSpecialNode(node) {
+ if (!node || !this._isMarkdownNode(node)) return;
+
+ const nodeId = node.id;
+ const assistantKey = `${nodeId}_text`;
+
+ // 检查是否已存在实例
+ if (PromptAssistant.hasInstance(assistantKey)) return;
+
+ // 查找节点DOM容器和textarea
+ const nodeContainer = document.querySelector(`[data-node-id="${nodeId}"]`);
+ if (!nodeContainer) return;
+
+ const textarea = nodeContainer.querySelector('textarea.p-textarea') ||
+ nodeContainer.querySelector('textarea') ||
+ nodeContainer.querySelector('.tiptap') ||
+ nodeContainer.querySelector('.ProseMirror');
+ if (!textarea) return;
+
+ // 创建虚拟widget对象
+ const virtualWidget = {
+ name: 'text', id: 'text', type: 'textarea',
+ inputEl: textarea, element: textarea, node: node
+ };
+
+ // 节点信息
+ const nodeInfo = {
+ workflow_id: app.graph?._workflow_id || 'unknown',
+ nodeType: node.type, inputType: 'text',
+ isNoteNode: this._isMarkdownNode(node),
+ isVueMode: true
+ };
+
+ // 创建小助手实例
+ const assistant = this.createAssistant(node, 'text', virtualWidget, nodeInfo, assistantKey);
+ if (assistant) {
+ this.showAssistantUI(assistant);
+ logger.log(`${node.type}节点小助手创建成功 | ID: ${nodeId}`);
+ }
+ }
+
/**
* 为节点设置小助手
* 创建小助手实例并初始化显示状态
@@ -576,21 +811,37 @@ class PromptAssistant {
try {
const nodeId = node.id;
const inputId = inputWidget.name || inputWidget.id || Math.random().toString(36).substring(2, 10);
- const isNoteNode = node.type === "Note";
+ const isNoteNode = this._isMarkdownNode(node);
+ const isVueMode = typeof LiteGraph !== 'undefined' && LiteGraph.vueNodesMode === true;
// 简化节点信息
const nodeInfo = {
workflow_id: app.graph?._workflow_id || 'unknown',
nodeType: node.type,
inputType: inputId,
- isNoteNode: isNoteNode
+ isNoteNode: isNoteNode,
+ isVueMode: isVueMode
};
+ // 处理inputWidget的inputEl引用
+ // Vue mode下Note/MarkdownNote节点的element可能为null,需要延迟获取
+ let processedWidget = inputWidget;
+ if (isNoteNode) {
+ // Note/MarkdownNote节点特殊处理:尝试使用element作为inputEl
+ const inputEl = inputWidget.element || inputWidget.inputEl;
+ processedWidget = {
+ ...inputWidget,
+ inputEl: inputEl,
+ // Vue mode下标记需要延迟查找textarea
+ _needsDelayedTextareaLookup: isVueMode && !inputEl
+ };
+ }
+
// 创建小助手实例
const assistant = this.createAssistant(
node,
inputId,
- isNoteNode ? { ...inputWidget, inputEl: inputWidget.element } : inputWidget, // Note节点特殊处理
+ processedWidget,
nodeInfo,
assistantKey
);
@@ -613,14 +864,34 @@ class PromptAssistant {
* 根据节点和输入控件构建小助手对象并初始化UI
*/
createAssistant(node, inputId, inputWidget, nodeInfo = {}, assistantKey = null) {
- // 简化前置检查
- if (!window.FEATURES.enabled || !node || !inputId || !inputWidget || !UIToolkit.isValidInput(inputWidget)) {
+ logger.debug(() => `[createAssistant] 开始创建 | 节点ID: ${node?.id} | inputId: ${inputId} | isVueMode: ${typeof LiteGraph !== 'undefined' && LiteGraph.vueNodesMode === true}`);
+
+ // 简化前置检查 - Vue mode下允许inputEl暂时不存在
+ if (!window.FEATURES.enabled || !node || !inputId || !inputWidget) {
+ logger.debug(() => `[createAssistant] 前置检查失败 | enabled: ${window.FEATURES.enabled} | node: ${!!node} | inputId: ${inputId} | inputWidget: ${!!inputWidget}`);
return null;
}
- // 确保输入元素存在
- const inputEl = inputWidget.inputEl || inputWidget.element;
- if (!inputEl) {
+ // 确保widget设置了node引用
+ if (!inputWidget.node) {
+ inputWidget.node = node;
+ }
+
+ // 验证是否为有效输入
+ if (!UIToolkit.isValidInput(inputWidget, { node: node })) {
+ logger.debug(() => `[createAssistant] 无效输入 | 节点ID: ${node?.id} | 控件: ${inputId}`);
+ return null;
+ }
+
+ // 获取输入元素 - Vue mode下可能暂时不存在,将在_setupUIPosition中查找
+ let inputEl = inputWidget.inputEl || inputWidget.element;
+
+ // 判断是否为Vue mode
+ const isVueMode = typeof LiteGraph !== 'undefined' && LiteGraph.vueNodesMode === true;
+
+ // 非Vue mode下,inputEl必须存在
+ if (!inputEl && !isVueMode) {
+ logger.debug(() => `[createAssistant] 输入元素不存在 | 节点ID: ${node?.id} | 控件: ${inputId}`);
return null;
}
@@ -641,13 +912,14 @@ class PromptAssistant {
inputId,
widgetKey,
buttons: {},
- text_element: inputEl,
- inputEl: inputEl, // 直接添加inputEl引用,方便历史记录回写
- // 不再直接设置 node 引用,而是保存 nodeId
+ text_element: inputEl, // Vue mode下可能为null,将在定位时更新
+ inputEl: inputEl, // Vue mode下可能为null,将在定位时更新
+ node: node, // 保存node引用,用于getInputValue等函数访问
nodeInfo: {
...nodeInfo,
nodeId: node.id,
- nodeType: node.type
+ nodeType: node.type,
+ isVueMode: isVueMode // 标记是否为Vue mode
},
isTransitioning: false // 添加状态标记,避免频繁切换
};
@@ -657,84 +929,128 @@ class PromptAssistant {
window.PromptAssistantInputWidgetMap = {};
}
- // 将当前输入框添加到映射
+ // 将当前输入框添加到映射 - Vue mode下inputEl可能为null,将在定位时更新
window.PromptAssistantInputWidgetMap[widgetKey] = {
inputEl: inputEl,
widget: widget
};
- logger.debug(`输入框映射 | 添加映射 | 键:${widgetKey}`);
+ logger.debug(`输入框映射 | 添加映射 | 键:${widgetKey} | inputEl: ${inputEl ? 'exists' : 'null (Vue mode)'}`);
// 创建UI并添加到实例集合
this.createAssistantUI(widget, inputWidget);
PromptAssistant.addInstance(widgetKey, widget);
- // 初始化撤销状态
- HistoryCacheService.initUndoState(nodeId, inputId, inputEl.value);
- // 初始化时立即更新撤销/重做按钮状态
- UIToolkit.updateUndoRedoButtonState(widget, HistoryCacheService);
-
- // 绑定输入框失焦事件,写入历史
- if (inputWidget.inputEl) {
- // 使用事件管理器添加DOM事件监听
- const removeBlurListener = EventManager.addDOMListener(inputWidget.inputEl, 'blur', async () => {
- logger.debug(`历史写入准备| 原因:失焦事件触发 node_id=${node.id} input_id=${inputId}`);
- HistoryCacheService.addHistory({
- workflow_id: nodeInfo?.workflow_id || '',
- node_id: node.id,
- input_id: inputId,
- content: inputWidget.inputEl.value,
- operation_type: 'input',
- timestamp: Date.now()
- });
- // 重置撤销状态
- HistoryCacheService.initUndoState(node.id, inputId, inputWidget.inputEl.value);
- // 更新按钮状态
- UIToolkit.updateUndoRedoButtonState(widget, HistoryCacheService);
- logger.debug(`历史写入完成|原因:输入框失焦 node_id=${node.id} input_id=${inputId}`);
- });
-
- // 保存清理函数引用,以便后续清理
- widget._eventCleanupFunctions = widget._eventCleanupFunctions || [];
- widget._eventCleanupFunctions.push(removeBlurListener);
-
- // 添加输入事件监听,实时更新撤销/重做按钮状态和位置调整
- const removeInputListener = EventManager.addDOMListener(inputWidget.inputEl, 'input', () => {
- UIToolkit.updateUndoRedoButtonState(widget, HistoryCacheService);
- // 检测滚动条状态并调整位置
- this._adjustPositionForScrollbar(widget, inputWidget.inputEl);
- });
- widget._eventCleanupFunctions.push(removeInputListener);
-
- // 添加ResizeObserver监听输入框尺寸变化
- if (window.ResizeObserver) {
- const resizeObserver = new ResizeObserver(() => {
- // 延迟执行,确保浏览器完成布局更新
- setTimeout(() => {
- this._adjustPositionForScrollbar(widget, inputWidget.inputEl);
- }, 10);
- });
-
- resizeObserver.observe(inputWidget.inputEl);
-
- // 添加清理函数
- widget._eventCleanupFunctions.push(() => {
- resizeObserver.disconnect();
- });
- } else {
- // 降级方案:监听window resize事件
- const removeResizeListener = EventManager.addDOMListener(window, 'resize',
- EventManager.debounce(() => {
- this._adjustPositionForScrollbar(widget, inputWidget.inputEl);
- }, 100)
- );
- widget._eventCleanupFunctions.push(removeResizeListener);
- }
+ // 初始化撤销状态和事件绑定 - 仅在inputEl存在时执行
+ // Vue mode下将在_setupUIPosition找到实际textarea后再执行
+ logger.debug(() => `[createAssistant] inputEl检查 | 节点ID: ${nodeId} | inputEl存在: ${!!inputEl} | isVueMode: ${isVueMode}`);
+ if (inputEl) {
+ logger.debug(() => `[createAssistant] 调用_initializeInputElBindings | 节点ID: ${nodeId}`);
+ this._initializeInputElBindings(widget, inputWidget, node, inputId, nodeInfo);
+ } else {
+ logger.debug(() => `[createAssistant] Vue mode - 延迟初始化inputEl绑定 | 节点ID: ${nodeId}`);
}
return widget;
}
+ /**
+ * 初始化inputEl相关的事件绑定
+ * 在传统模式下立即调用,Vue mode下在找到textarea后调用
+ */
+ _initializeInputElBindings(widget, inputWidget, node, inputId, nodeInfo) {
+ const inputEl = inputWidget.inputEl || widget.inputEl;
+ if (!inputEl) {
+ logger.warn(`[_initializeInputElBindings] inputEl不存在 | 节点ID: ${node?.id}`);
+ return;
+ }
+
+ const nodeId = node.id;
+
+ // 初始化撤销状态(只初始化一次,使用widget级别的标记)
+ if (!widget._undoStateInitialized) {
+ const initialValue = inputEl.value || '';
+ HistoryCacheService.initUndoState(nodeId, inputId, initialValue);
+ widget._undoStateInitialized = true;
+ logger.debug(`[初始化] 撤销状态初始化 | 节点ID: ${nodeId}`);
+ }
+ // 初始化时立即更新撤销/重做按钮状态
+ UIToolkit.updateUndoRedoButtonState(widget, HistoryCacheService);
+
+ // 检查是否已绑定事件(避免重复绑定)
+ // 【关键修复】使用 widget 级别的标记来精确控制绑定状态
+ // 确保不会因为 _eventCleanupFunctions 中包含其他清理函数(如按钮菜单)而误判
+ if (widget._inputEventsBound) {
+ logger.debug(`[_initializeInputElBindings] 跳过绑定 | 节点ID: ${nodeId} | 原因: 已绑定`);
+ return;
+ }
+
+ // 如果检测到遗留标记,记录一下(仅用于调试)
+ if (inputEl._promptAssistantBound) {
+ logger.debug(`[_initializeInputElBindings] 检测到DOM遗留标记,重新绑定 | 节点ID: ${nodeId}`);
+ }
+
+ inputEl._promptAssistantBound = true;
+ widget._inputEventsBound = true;
+ widget._eventCleanupFunctions = widget._eventCleanupFunctions || [];
+
+ // 绑定输入框失焦事件,写入历史
+ // 使用事件管理器添加DOM事件监听
+ const removeBlurListener = EventManager.addDOMListener(inputEl, 'blur', async () => {
+ logger.debug(`历史写入准备| 原因:失焦事件触发 node_id=${node.id} input_id=${inputId}`);
+ HistoryCacheService.addHistory({
+ workflow_id: nodeInfo?.workflow_id || '',
+ node_id: node.id,
+ input_id: inputId,
+ content: inputEl.value,
+ operation_type: 'input',
+ timestamp: Date.now()
+ });
+ // 重置撤销状态
+ HistoryCacheService.initUndoState(node.id, inputId, inputEl.value);
+ // 更新按钮状态
+ UIToolkit.updateUndoRedoButtonState(widget, HistoryCacheService);
+ logger.debug(`历史写入完成|原因:输入框失焦 node_id=${node.id} input_id=${inputId}`);
+ });
+
+ // 保存清理函数引用,以便后续清理
+ widget._eventCleanupFunctions = widget._eventCleanupFunctions || [];
+ widget._eventCleanupFunctions.push(removeBlurListener);
+
+ // 添加输入事件监听,实时更新撤销/重做按钮状态和位置调整
+ const removeInputListener = EventManager.addDOMListener(inputEl, 'input', () => {
+ UIToolkit.updateUndoRedoButtonState(widget, HistoryCacheService);
+ // 检测滚动条状态并调整位置
+ this._adjustPositionForScrollbar(widget, inputEl);
+ });
+ widget._eventCleanupFunctions.push(removeInputListener);
+
+ // 添加ResizeObserver监听输入框尺寸变化
+ if (window.ResizeObserver) {
+ const resizeObserver = new ResizeObserver(() => {
+ // 延迟执行,确保浏览器完成布局更新
+ setTimeout(() => {
+ this._adjustPositionForScrollbar(widget, inputEl);
+ }, 10);
+ });
+
+ resizeObserver.observe(inputEl);
+
+ // 添加清理函数
+ widget._eventCleanupFunctions.push(() => {
+ resizeObserver.disconnect();
+ });
+ } else {
+ // 降级方案:监听window resize事件
+ const removeResizeListener = EventManager.addDOMListener(window, 'resize',
+ EventManager.debounce(() => {
+ this._adjustPositionForScrollbar(widget, inputEl);
+ }, 100)
+ );
+ widget._eventCleanupFunctions.push(removeResizeListener);
+ }
+ }
+
// ---UI管理功能---
/**
* 创建小助手UI
@@ -745,118 +1061,103 @@ class PromptAssistant {
const inputId = widget.inputId;
try {
- // 获取DOM相关信息
- const inputEl = inputWidget.inputEl;
+ // Get location setting
+ const locationSetting = app.ui.settings.getSettingValue(
+ "PromptAssistant.Location",
+ "bottom-right-h"
+ );
+
+ // Create AssistantContainer instance
+ const container = new AssistantContainer({
+ nodeId: nodeId,
+ type: 'prompt',
+ anchorPosition: locationSetting,
+ enableDragSort: true,
+ onButtonOrderChange: (order) => {
+ // Order is saved handled by AssistantContainer
+ // We might want to log or update internal state if needed
+ logger.debug(`[排序更新] 节点:${nodeId} | 新顺序: ${order.join(',')}`);
+ },
+ shouldCollapse: () => {
+ // Check if we should block collapse (e.g. active buttons)
+ return !this._checkAssistantActiveState(widget);
+ }
+ });
+
+ // Render container
+ const containerEl = container.render();
+
+ // Set Icon
+ // Existing logic uses ResourceManager
+ const mainIcon = ResourceManager.getIcon('icon-main.svg');
+ if (mainIcon) {
+ // Remove existing content if any (though render creates empty)
+ if (container.indicator) {
+ container.indicator.innerHTML = '';
+ container.indicator.appendChild(mainIcon);
+ }
+ }
+
+ // Save references
+ widget.container = container;
+ widget.element = containerEl;
+ widget.innerContent = container.content; // Map innerContent to container content
+ widget.hoverArea = container.hoverArea;
+ widget.indicator = container.indicator;
+ widget.buttons = {};
+ // widget.isCollapsed is managed by container, but PromptAssistant might read it
+ // Sync state
+ Object.defineProperty(widget, 'isCollapsed', {
+ get: () => container.isCollapsed,
+ set: (val) => {
+ // Manual override? Better to use container methods
+ if (val) container.collapse(); else container.expand();
+ }
+ });
+ // widget.isTransitioning also managed by container
+ Object.defineProperty(widget, 'isTransitioning', {
+ get: () => container.isTransitioning,
+ set: (val) => { container.isTransitioning = val; }
+ });
+
+ // Initialize buttons
+ this.addFunctionButtons(widget);
+
+ // Note: _setupUIEventHandling is no longer needed as container handles it
+
+ // Restore button order
+ container.restoreOrder();
+
+ // Setup Positioning
+ const inputEl = inputWidget.inputEl || widget.inputEl;
const graphCanvasContainer = document.querySelector('.graphcanvas');
const canvasContainerRect = graphCanvasContainer?.getBoundingClientRect();
- // 创建内部内容容器(分离视觉效果)
- const innerContentDiv = document.createElement('div');
- innerContentDiv.className = 'prompt-assistant-inner prompt-assistant-content-fixed';
-
- // 创建主容器 - 简化样式,减少触发栅格化的属性
- const containerDiv = document.createElement('div');
- containerDiv.className = 'prompt-assistant-container prompt-assistant-transition';
- containerDiv.dataset.nodeId = nodeId;
- containerDiv.dataset.inputId = inputId;
-
- // 添加内容容器到主容器
- containerDiv.appendChild(innerContentDiv);
-
- // 创建悬停区域 - 用于检测鼠标悬停
- const hoverAreaDiv = document.createElement('div');
- hoverAreaDiv.className = 'prompt-assistant-hover-area';
- // 将悬停区域添加到容器中
- containerDiv.appendChild(hoverAreaDiv);
-
- // 创建折叠状态指示器图标
- const indicatorDiv = document.createElement('div');
- indicatorDiv.className = 'prompt-assistant-indicator animate-creation';
-
- // 从ResourceManager获取图标并添加到指示器
- const mainIcon = ResourceManager.getIcon('icon-main.svg');
- if (mainIcon) {
- indicatorDiv.appendChild(mainIcon);
- }
-
- // 将指示器添加到容器中
- containerDiv.appendChild(indicatorDiv);
-
- // 保存引用并初始化
- widget.element = containerDiv;
- widget.innerContent = innerContentDiv;
- widget.hoverArea = hoverAreaDiv;
- widget.indicator = indicatorDiv;
- widget.buttons = {};
- widget.isCollapsed = true; // 初始状态为折叠
-
- // 初始化UI组件和事件
- this.addFunctionButtons(widget);
- this._setupUIEventHandling(widget, inputEl, containerDiv);
-
- // 异步设置UI位置,并在定位失败时清理资源
- this._setupUIPosition(widget, inputEl, containerDiv, canvasContainerRect, (success) => {
+ this._setupUIPosition(widget, inputEl, containerEl, canvasContainerRect, (success) => {
if (!success) {
- // 定位失败,清理所有资源
- logger.error(`创建小助手失败 | 节点ID: ${nodeId} | 原因: 定位失败,未找到dom-widget容器`);
-
- // 清理DOM元素
- if (containerDiv.parentNode) {
- containerDiv.parentNode.removeChild(containerDiv);
- }
-
- // 清理事件监听器
- if (widget.cleanupListeners && typeof widget.cleanupListeners === 'function') {
- widget.cleanupListeners();
- }
-
- // 清理图标
- if (indicatorDiv) {
- indicatorDiv.innerHTML = '';
- }
-
- // 从实例集合中移除
+ logger.debug(`创建小助手失败 | 节点ID: ${nodeId} | 原因: 定位失败 (等待DOM渲染)`);
+ container.destroy();
+ // Clean up instance map
const widgetKey = widget.widgetKey;
if (widgetKey && PromptAssistant.instances.has(widgetKey)) {
PromptAssistant.instances.delete(widgetKey);
}
-
- // 清理输入框映射
if (window.PromptAssistantInputWidgetMap && widgetKey) {
delete window.PromptAssistantInputWidgetMap[widgetKey];
}
-
return;
}
-
- // 定位成功,继续初始化
- // 立即设置预设的展开宽度,避免首次展开时需要测量
- const presetWidth = calculateAssistantWidth();
- containerDiv.style.setProperty('--expanded-width', `${presetWidth}px`);
- logger.debug(`[宽度预设] 节点:${nodeId} | 宽度:${presetWidth}px`);
- // 初始滚动条检测和位置调整
- setTimeout(() => {
- this._adjustPositionForScrollbar(widget, inputEl);
- }, 100); // 延迟执行,确保DOM完全渲染
+ // Positioning success
+ // Trigger dimension update now that it is in DOM
+ container.updateDimensions();
- // 设置初始折叠状态
- containerDiv.classList.add('collapsed');
- hoverAreaDiv.style.display = 'block'; // 显示悬停区域以便用户展开
-
- // 默认隐藏状态
- containerDiv.style.display = 'none';
-
- // 移除动画类的定时器
- setTimeout(() => {
- if (indicatorDiv && indicatorDiv.classList.contains('animate-creation')) {
- indicatorDiv.classList.remove('animate-creation');
- }
- }, 1000);
+ // Initial visibility check?
+ // AssistantContainer starts collapsed and visible (opacity 1 for indicator)
+ // We might want to initially hide it if needed, but standard behavior is fine.
});
- // 立即返回容器,定位将异步完成
- return containerDiv;
+ return containerEl;
} catch (error) {
logger.error(`创建小助手失败 | 节点ID: ${nodeId} | 原因: ${error.message}`);
return null;
@@ -901,134 +1202,25 @@ class PromptAssistant {
* 检查并触发自动折叠(如果需要)
*/
_triggerAutoCollapseIfNeeded(widget) {
- // 如果widget没有初始化或者已经处于折叠状态,则不处理
- if (!widget || !widget.element || widget.isCollapsed || widget.isTransitioning) return;
-
- // 如果有活跃按钮,不自动折叠
- if (this._checkAssistantActiveState(widget)) return;
-
- // 如果鼠标当前悬停在容器上,不自动折叠
- if (widget.isMouseOver) return;
-
- // 设置自动折叠定时器
- if (widget._autoCollapseTimer) {
- clearTimeout(widget._autoCollapseTimer);
- }
-
- widget._autoCollapseTimer = setTimeout(() => {
- // 再次检查条件
- if (!widget.isCollapsed && !widget.isTransitioning &&
- !this._checkAssistantActiveState(widget) && !widget.isMouseOver) {
-
- const containerDiv = widget.element;
-
- // 保存当前宽度用于展开动画
- if (containerDiv.offsetWidth > 0) {
- containerDiv.style.setProperty('--expanded-width', `${containerDiv.offsetWidth}px`);
- }
-
- // 设置过渡状态
- widget.isTransitioning = true;
-
- // 直接添加折叠类
- containerDiv.classList.add('collapsed');
- widget.isCollapsed = true;
-
- // 显示悬停区域,用于检测鼠标悬停以展开UI
- if (widget.hoverArea) {
- widget.hoverArea.style.display = 'block';
- }
-
- // 动画结束后重置过渡状态
- setTimeout(() => {
- widget.isTransitioning = false;
- }, 300);
- }
-
- widget._autoCollapseTimer = null;
- }, 1000);
-
- // 确保清理函数中包含这个定时器
- if (widget._eventCleanupFunctions) {
- widget._eventCleanupFunctions.push(() => {
- if (widget._autoCollapseTimer) {
- clearTimeout(widget._autoCollapseTimer);
- widget._autoCollapseTimer = null;
- }
- });
+ if (widget && widget.container) {
+ widget.container.collapse();
}
}
+
+
+
/**
* 展开小助手
*/
_expandAssistant(widget) {
- if (!widget || !widget.element) return;
-
- // 如果已经是展开状态,直接返回
- if (!widget.isCollapsed && !widget.isTransitioning) return;
-
- // 如果正在折叠中,允许打断折叠动画
- if (widget.isTransitioning) {
- // 清理折叠动画的定时器
- if (widget._transitionTimer) {
- clearTimeout(widget._transitionTimer);
- widget._transitionTimer = null;
- }
- // 重置过渡状态,准备执行展开
- widget.isTransitioning = false;
+ if (widget && widget.container) {
+ widget.container.expand();
}
-
- // 如果不是折叠状态,不执行展开
- if (!widget.isCollapsed) return;
-
- widget.isTransitioning = true;
-
- // 隐藏悬停区域,避免覆盖按钮
- if (widget.hoverArea) {
- widget.hoverArea.style.display = 'none';
- }
-
- const containerDiv = widget.element;
-
- // 使用预设宽度,不再实时测量
- let targetWidth = containerDiv.style.getPropertyValue('--expanded-width');
-
- // 如果没有预设宽度,立即计算并设置
- if (!targetWidth || targetWidth === '') {
- const presetWidth = calculateAssistantWidth();
- targetWidth = `${presetWidth}px`;
- containerDiv.style.setProperty('--expanded-width', targetWidth);
- logger.debug(`[宽度预设] 展开时设置 | 宽度:${targetWidth}`);
- }
-
- // 手动设置宽度转换
- containerDiv.style.width = '28px'; // 起始宽度
-
- // 强制回流
- void containerDiv.offsetWidth;
-
- // 移除折叠类
- containerDiv.classList.remove('collapsed');
-
- // 设置目标宽度以触发过渡
- containerDiv.style.width = targetWidth;
-
- // 清理之前的过渡定时器
- if (widget._transitionTimer) {
- clearTimeout(widget._transitionTimer);
- }
-
- // 动画结束后清理
- widget._transitionTimer = setTimeout(() => {
- // 移除固定宽度,恢复自动宽度
- containerDiv.style.width = '';
- widget.isCollapsed = false;
- widget.isTransitioning = false;
- widget._transitionTimer = null;
- }, 300);
}
+
+
/**
* 公开方法:触发小助手自动折叠
* 供外部模块调用,用于在操作完成后折叠小助手UI
@@ -1062,9 +1254,11 @@ class PromptAssistant {
widget._autoCollapseTimer = null;
}
- // 如果当前是折叠状态,则展开
+ // 如果当前是折叠状态,则展开 - 使用requestAnimationFrame
if (widget.isCollapsed) {
- this._expandAssistant(widget);
+ requestAnimationFrame(() => {
+ this._expandAssistant(widget);
+ });
}
return;
@@ -1087,21 +1281,43 @@ class PromptAssistant {
_checkAssistantActiveState(widget) {
if (!widget || !widget.buttons) return false;
- // 首先检查中央按钮状态管理器是否有该widget的激活按钮
- const activeButtonInfo = UIToolkit.getActiveButtonInfo();
- if (activeButtonInfo && activeButtonInfo.widget === widget) {
+ // 0. 检查是否正在切换弹窗(切换期间不允许折叠)
+ if (PopupManager._isTransitioning) {
+ logger.debug(() => `[ActiveState] 阻止折叠 | 原因: PopupManager 正在切换弹窗 | 节点: ${widget.nodeId}`);
return true;
}
- // 再遍历所有按钮,检查是否有按钮处于active或processing状态
+ // 1. 检查右键菜单是否可见(并且属于当前 widget)
+ if (buttonMenu.isMenuVisible && buttonMenu.menuContext?.widget === widget) {
+ logger.debug(() => `[ActiveState] 阻止折叠 | 原因: 右键菜单可见 | 节点: ${widget.nodeId}`);
+ return true;
+ }
+
+ // 2. 检查中央按钮状态管理器是否有该widget的激活按钮
+ const activeButtonInfo = UIToolkit.getActiveButtonInfo();
+ if (activeButtonInfo && activeButtonInfo.widget === widget) {
+ logger.debug(() => `[ActiveState] 阻止折叠 | 原因: UIToolkit.activeButtonInfo 匹配 | 节点: ${widget.nodeId}`);
+ return true;
+ }
+
+ // 3. 检查 PopupManager 的活动弹窗是否属于当前 widget
+ if (PopupManager.activePopupInfo?.buttonInfo?.widget === widget) {
+ logger.debug(() => `[ActiveState] 阻止折叠 | 原因: PopupManager.activePopupInfo 匹配 | 节点: ${widget.nodeId}`);
+ return true;
+ }
+
+ // 4. 检查按钮的 active/processing 状态
for (const buttonId in widget.buttons) {
const button = widget.buttons[buttonId];
if (button.classList.contains('button-active') ||
button.classList.contains('button-processing')) {
+ logger.debug(() => `[ActiveState] 阻止折叠 | 原因: 按钮${buttonId}状态激活 | 节点: ${widget.nodeId}`);
return true;
}
}
+ // 调试日志:记录检查结果
+ logger.debug(() => `[ActiveState] 允许折叠 | 节点: ${widget.nodeId} | UIToolkit: ${!!activeButtonInfo} | PopupManager: ${!!PopupManager.activePopupInfo} | 右键菜单: ${buttonMenu.isMenuVisible}`);
return false;
}
@@ -1122,7 +1338,7 @@ class PromptAssistant {
updateAllInstancesWidth() {
const newWidth = calculateAssistantWidth();
logger.debug(`[宽度更新] 计算新宽度:${newWidth}px | 实例数量:${PromptAssistant.instances.size}`);
-
+
PromptAssistant.instances.forEach((widget) => {
if (widget && widget.element) {
widget.element.style.setProperty('--expanded-width', `${newWidth}px`);
@@ -1144,282 +1360,33 @@ class PromptAssistant {
* 配置按钮事件监听 - 简化版本
*/
_setupUIEventHandling(widget, inputEl, containerDiv) {
- // 设置容器可以接收事件,而不是穿透
- containerDiv.style.pointerEvents = 'auto';
- widget.innerContent.style.pointerEvents = 'auto'; // 确保内容容器可以接收事件
- widget.hoverArea.style.pointerEvents = 'auto'; // 确保悬停区域可以接收事件
- widget.hoverArea.style.display = 'none'; // 初始状态下隐藏悬停区域,避免覆盖按钮
-
- // 确保所有按钮可点击
- const buttons = containerDiv.querySelectorAll('button');
- buttons.forEach(button => {
- // button.style.pointerEvents = 'auto'; // 移除此行,由CSS控制指针事件
- button.classList.add('prompt-assistant-button-active');
- });
-
- // 初始化过渡状态标记
- widget.isTransitioning = false;
-
- // 使用清理函数数组存储所有事件清理函数
- widget._eventCleanupFunctions = widget._eventCleanupFunctions || [];
-
- // 折叠和展开功能
- const setupCollapseExpandEvents = () => {
- // 记录原始宽度,用于展开动画
- const saveOriginalWidth = () => {
- if (!widget.isCollapsed && containerDiv.offsetWidth > 0) {
- containerDiv.style.setProperty('--expanded-width', `${containerDiv.offsetWidth}px`);
- }
- };
-
- // 延迟保存宽度,确保DOM已完全渲染
- setTimeout(saveOriginalWidth, 300);
-
- // 过渡动画定时器,保存到widget对象上供所有函数访问
- widget._transitionTimer = null;
-
- // 折叠函数
- const collapseAssistant = () => {
- if (widget.isCollapsed || widget.isTransitioning) return;
-
- // 保存当前宽度用于展开动画
- saveOriginalWidth();
- widget.isTransitioning = true;
-
- // 直接添加折叠类,不使用动画类
- containerDiv.classList.add('collapsed');
- widget.isCollapsed = true;
-
- // 显示悬停区域,用于检测鼠标悬停以展开UI
- widget.hoverArea.style.display = 'block';
-
- // 清理之前的过渡定时器
- if (widget._transitionTimer) {
- clearTimeout(widget._transitionTimer);
- }
-
- // 动画结束后重置过渡状态
- widget._transitionTimer = setTimeout(() => {
- widget.isTransitioning = false;
- widget._transitionTimer = null;
- }, 300);
- };
-
- // 展开函数
- const expandAssistant = () => {
- // 如果已经是展开状态,直接返回
- if (!widget.isCollapsed && !widget.isTransitioning) return;
-
- // 如果正在折叠中,允许打断折叠动画
- if (widget.isTransitioning) {
- // 清理折叠动画的定时器
- if (widget._transitionTimer) {
- clearTimeout(widget._transitionTimer);
- widget._transitionTimer = null;
- }
- // 重置过渡状态,准备执行展开
- widget.isTransitioning = false;
- }
-
- // 如果不是折叠状态,不执行展开
- if (!widget.isCollapsed) return;
-
- widget.isTransitioning = true;
-
- // 隐藏悬停区域,避免覆盖按钮
- widget.hoverArea.style.display = 'none';
-
- // 使用预设宽度,不再实时测量
- let targetWidth = widget.element.style.getPropertyValue('--expanded-width');
-
- // 如果没有预设宽度,立即计算并设置
- if (!targetWidth || targetWidth === '') {
- const presetWidth = calculateAssistantWidth();
- targetWidth = `${presetWidth}px`;
- containerDiv.style.setProperty('--expanded-width', targetWidth);
- logger.debug(`[宽度预设] 事件处理中设置 | 宽度:${targetWidth}`);
- }
-
- // 手动设置宽度转换
- containerDiv.style.width = '28px'; // 起始宽度
-
- // 强制回流
- void containerDiv.offsetWidth;
-
- // 移除折叠类
- containerDiv.classList.remove('collapsed');
-
- // 设置目标宽度以触发过渡
- containerDiv.style.width = targetWidth;
-
- // 清理之前的过渡定时器
- if (widget._transitionTimer) {
- clearTimeout(widget._transitionTimer);
- }
-
- // 动画结束后清理
- widget._transitionTimer = setTimeout(() => {
- // 移除固定宽度,恢复自动宽度
- containerDiv.style.width = '';
- widget.isCollapsed = false;
- widget.isTransitioning = false;
- widget._transitionTimer = null;
- }, 300);
- };
-
- // 创建折叠定时器变量
- let collapseTimer = null;
- let autoCollapseTimer = null;
-
- // 鼠标离开容器时折叠
- const handleMouseLeave = () => {
- // 如果有活跃按钮,不折叠
- if (this._checkAssistantActiveState(widget)) return;
-
- // 设置延时,避免鼠标短暂离开就触发折叠
- collapseTimer = setTimeout(() => {
- collapseAssistant();
- }, 500);
- };
-
- // 鼠标进入容器(展开时)以保持展开状态
- const handleContainerMouseEnter = () => {
- // 仅当小助手展开时,进入容器才会阻止其自动折叠
- if (widget.isCollapsed) return;
-
- if (collapseTimer) {
- clearTimeout(collapseTimer);
- collapseTimer = null;
- }
- if (autoCollapseTimer) {
- clearTimeout(autoCollapseTimer);
- autoCollapseTimer = null;
- }
- };
-
- // 鼠标进入悬停区域(折叠时)以展开
- const handleHoverAreaMouseEnter = () => {
- if (collapseTimer) {
- clearTimeout(collapseTimer);
- collapseTimer = null;
- }
- if (autoCollapseTimer) {
- clearTimeout(autoCollapseTimer);
- autoCollapseTimer = null;
- }
- // 如果当前是折叠状态,则展开
- if (widget.isCollapsed) {
- expandAssistant();
- }
- };
-
- // 为容器添加鼠标事件
- const removeContainerMouseLeave = EventManager.addDOMListener(containerDiv, 'mouseleave', handleMouseLeave);
- const removeContainerMouseEnter = EventManager.addDOMListener(containerDiv, 'mouseenter', handleContainerMouseEnter);
-
- // 为悬停区域添加鼠标事件
- const removeHoverAreaMouseEnter = EventManager.addDOMListener(widget.hoverArea, 'mouseenter', handleHoverAreaMouseEnter);
-
- // 添加清理函数
- widget._eventCleanupFunctions.push(removeContainerMouseLeave);
- widget._eventCleanupFunctions.push(removeContainerMouseEnter);
- widget._eventCleanupFunctions.push(removeHoverAreaMouseEnter);
-
- // 添加清理定时器的函数
- widget._eventCleanupFunctions.push(() => {
- if (collapseTimer) {
- clearTimeout(collapseTimer);
- collapseTimer = null;
- }
- if (autoCollapseTimer) {
- clearTimeout(autoCollapseTimer);
- autoCollapseTimer = null;
- }
- if (widget._transitionTimer) {
- clearTimeout(widget._transitionTimer);
- widget._transitionTimer = null;
- }
- });
-
- // 创建后自动折叠功能
- const setupAutoCollapse = () => {
- // 如果有活跃按钮,不自动折叠
- if (this._checkAssistantActiveState(widget)) return;
-
- // 设置自动折叠定时器,1秒后自动折叠
- autoCollapseTimer = setTimeout(() => {
- // 再次检查是否有活跃按钮或鼠标悬停在容器上
- if (!this._checkAssistantActiveState(widget) && !widget.isMouseOver) {
- collapseAssistant();
- }
- }, 1000);
- };
-
- // 添加鼠标悬停状态跟踪
- widget.isMouseOver = false;
- const trackMouseOver = () => {
- widget.isMouseOver = true;
- };
- const trackMouseOut = () => {
- widget.isMouseOver = false;
- };
-
- // 为容器和悬停区域添加鼠标悬停状态跟踪
- const removeContainerMouseOverTracking = EventManager.addDOMListener(containerDiv, 'mouseover', trackMouseOver);
- const removeContainerMouseOutTracking = EventManager.addDOMListener(containerDiv, 'mouseout', trackMouseOut);
- const removeHoverAreaMouseOverTracking = EventManager.addDOMListener(widget.hoverArea, 'mouseover', trackMouseOver);
- const removeHoverAreaMouseOutTracking = EventManager.addDOMListener(widget.hoverArea, 'mouseout', trackMouseOut);
-
- // 添加清理函数
- widget._eventCleanupFunctions.push(removeContainerMouseOverTracking);
- widget._eventCleanupFunctions.push(removeContainerMouseOutTracking);
- widget._eventCleanupFunctions.push(removeHoverAreaMouseOverTracking);
- widget._eventCleanupFunctions.push(removeHoverAreaMouseOutTracking);
-
- // 设置自动折叠(延迟执行,确保DOM已完全渲染)
- setTimeout(setupAutoCollapse, 500);
- };
-
- // 设置折叠展开事件
- setupCollapseExpandEvents();
-
- // 添加清理函数
- widget.cleanupListeners = () => {
- // 执行所有保存的清理函数
- if (widget._eventCleanupFunctions && widget._eventCleanupFunctions.length) {
- widget._eventCleanupFunctions.forEach(cleanupFn => {
- if (typeof cleanupFn === 'function') {
- cleanupFn();
- }
- });
- widget._eventCleanupFunctions = [];
- }
- };
+ // 事件处理已委托给 AssistantContainer
+ // 我们保留此方法是为了兼容外部调用,但现在它不执行任何操作。
}
+
+
// ---辅助功能---
/**
* 更新输入框内容并添加高亮效果
*/
- updateInputWithHighlight(widget, content) {
+ updateInputWithHighlight(widget, content, options = {}) {
if (!widget?.inputEl) return;
try {
- // 更新输入框内容
- widget.inputEl.value = content;
+ // 更新输入框内容 - 使用统一的setInputValue函数
+ const success = setInputValue(widget, content, options);
- // 触发输入事件
- widget.inputEl.dispatchEvent(new Event('input', { bubbles: true }));
- widget.inputEl.dispatchEvent(new Event('change', { bubbles: true }));
+ if (!success) {
+ logger.warn(`输入框更新 | 结果:失败 | setInputValue返回false`);
+ return;
+ }
// 添加高亮效果
widget.inputEl.classList.add('input-highlight');
- // 动画结束后移除类
setTimeout(() => {
widget.inputEl.classList.remove('input-highlight');
- }, 200); // 与 CSS 动画时长匹配
-
- logger.debug(`输入框更新 | 结果:成功 | 内容长度:${content.length}`);
+ }, 200);
} catch (error) {
logger.error(`输入框更新 | 结果:异常 | 错误:${error.message}`);
}
@@ -1448,8 +1415,8 @@ class PromptAssistant {
return;
}
- // 检查是否是Note节点
- const isNoteNode = widget.nodeInfo && widget.nodeInfo.isNoteNode === true;
+ // 检查是否是Note/MarkdownNote节点
+ const isNoteNode = widget.nodeInfo && (widget.nodeInfo.isNoteNode === true || widget.nodeInfo.nodeType === 'MarkdownNote');
// 获取历史状态(用于初始化撤销/重做按钮状态)
const canUndo = HistoryCacheService.canUndo(widget.nodeId, widget.inputId);
@@ -1528,7 +1495,7 @@ class PromptAssistant {
{
id: 'divider1',
type: 'divider',
- visible: !isNoteNode // Note节点不显示分割线
+ visible: !isNoteNode && FEATURES.history // Note节点不显示,且跟随历史功能开关
},
{
id: 'tag',
@@ -1579,10 +1546,10 @@ class PromptAssistant {
},
{
id: 'expand',
- title: '扩写',
+ title: '提示词优化',
icon: 'icon-expand',
onClick: async (e, widget) => {
- logger.debug('按钮点击 | 动作: 扩写');
+ logger.debug('按钮点击 | 动作: 提示词优化');
// 如果按钮处于 processing 状态且被点击,直接返回,
// 让UIToolkit中的取消逻辑接管
@@ -1596,14 +1563,16 @@ class PromptAssistant {
e.currentTarget,
async (notifyCancelReady) => {
try {
- // 获取输入值
- const inputValue = widget.inputEl.value;
+ // 获取输入值 - 使用统一的getInputValue函数
+ const inputValue = getInputValue(widget);
+ logger.debug(`[提示词优化] 获取到的输入值长度: ${inputValue?.length || 0}`);
+
if (!inputValue || inputValue.trim() === '') {
- throw new Error('请输入要扩写的内容');
+ throw new Error('请输入要优化的提示词');
}
// 生成唯一request_id
- const request_id = generateRequestId('glm4_expand');
+ const request_id = APIService.generateRequestId('exp', null, widget.nodeId);
// 通知UI可以准备取消操作了
notifyCancelReady(request_id);
@@ -1613,7 +1582,7 @@ class PromptAssistant {
UIToolkit.showStatusTip(
e.currentTarget,
'loading',
- '扩写中',
+ '提示词优化中',
{ x: btnRect.left + btnRect.width / 2, y: btnRect.top }
);
@@ -1645,7 +1614,7 @@ class PromptAssistant {
success: true,
useCache: false,
tipType: 'success',
- tipMessage: '扩写完成'
+ tipMessage: '提示词优化完成'
};
} else {
// 不在这里显示错误提示,直接抛出错误让 handleAsyncButtonOperation 处理
@@ -1661,98 +1630,175 @@ class PromptAssistant {
visible: !isNoteNode && FEATURES.expand, // Note节点不显示此按钮
// 添加右键菜单配置
contextMenu: async (widget) => {
- // 获取当前激活的扩写提示词ID
+ // 获取服务列表和当前激活状态
+ let services = [];
+ let currentLLMService = null;
+ let currentLLMModel = null;
+
+ // 获取扩写规则
let activePromptId = null;
let expandPrompts = [];
try {
- // 从服务器获取系统提示词配置
- const response = await fetch('/prompt_assistant/api/config/system_prompts');
+ // 获取服务列表
+ const servicesResp = await fetch(APIService.getApiUrl('/services'));
+ if (servicesResp.ok) {
+ const servicesData = await servicesResp.json();
+ if (servicesData.success) {
+ services = servicesData.services || [];
+ }
+ }
+
+ // 获取当前激活的LLM服务和模型
+ const llmResp = await fetch(APIService.getApiUrl('/config/llm'));
+ if (llmResp.ok) {
+ const llmConfig = await llmResp.json();
+ currentLLMService = llmConfig.provider || null;
+ currentLLMModel = llmConfig.model || null;
+ }
+
+ // 获取扩写规则
+ const response = await fetch(APIService.getApiUrl('/config/system_prompts'));
if (response.ok) {
const data = await response.json();
-
- // 获取激活的提示词ID
activePromptId = data.active_prompts?.expand || null;
- // 转换扩写规则数据
if (data.expand_prompts) {
- // 保存原始顺序
const originalOrder = Object.keys(data.expand_prompts);
-
- // 使用原始顺序遍历键
originalOrder.forEach(key => {
const prompt = data.expand_prompts[key];
- expandPrompts.push({
- id: key,
- name: prompt.name || key,
- content: prompt.content,
- isActive: key === activePromptId
- });
- });
+ const showIn = prompt.showIn || ['frontend', 'node'];
- // 按原始顺序排序
+ // 仅当配置包含 'frontend' 时才在前端菜单显示
+ if (showIn.includes('frontend')) {
+ expandPrompts.push({
+ id: key,
+ name: prompt.name || key,
+ category: prompt.category || '',
+ content: prompt.content,
+ showIn: showIn,
+ isActive: key === activePromptId
+ });
+ }
+ });
expandPrompts.sort((a, b) =>
originalOrder.indexOf(a.id) - originalOrder.indexOf(b.id)
);
}
}
} catch (error) {
- logger.error(`获取扩写提示词失败: ${error.message}`);
+ logger.error(`获取提示词优化配置失败: ${error.message}`);
}
- // 如果没有获取到提示词,显示一个提示
- if (expandPrompts.length === 0) {
- return [
- {
- label: '未找到扩写提示词',
- disabled: true
- }
- ];
- }
+ // 创建服务菜单项(只显示有LLM模型的服务,不包括百度)
+ const serviceMenuItems = services
+ .filter(service => service.llm_models && service.llm_models.length > 0)
+ .map(service => {
+ const isCurrentService = currentLLMService === service.id;
- // 创建菜单项
- const menuItems = expandPrompts.map(prompt => ({
+ // 创建模型子菜单
+ const modelChildren = (service.llm_models || []).map(model => {
+ const isCurrentModel = isCurrentService && currentLLMModel === model.name;
+ return {
+ label: model.display_name || model.name,
+ icon: ``,
+ onClick: async (context) => {
+ try {
+ const res = await fetch(APIService.getApiUrl('/services/current'), {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ service_type: 'llm', service_id: service.id, model_name: model.name })
+ });
+ if (!res.ok) throw new Error(`服务器返回错误: ${res.status}`);
+ const modelLabel = model.display_name || model.name;
+ UIToolkit.showStatusTip(context.buttonElement, 'success', `已切换到: ${service.name} - ${modelLabel}`);
+ logger.log(`提示词优化服务切换 | 服务: ${service.name} | 模型: ${modelLabel}`);
+ } catch (err) {
+ logger.error(`切换提示词优化模型失败: ${err.message}`);
+ UIToolkit.showStatusTip(context.buttonElement, 'error', `切换失败: ${err.message}`);
+ }
+ }
+ };
+ });
+
+ return {
+ label: service.name || service.id,
+ icon: ``,
+ onClick: async (context) => {
+ try {
+ const res = await fetch(APIService.getApiUrl('/services/current'), {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ service_type: 'llm', service_id: service.id })
+ });
+ if (!res.ok) throw new Error(`服务器返回错误: ${res.status}`);
+ UIToolkit.showStatusTip(context.buttonElement, 'success', `已切换到: ${service.name}`);
+ logger.log(`提示词优化服务切换 | 服务: ${service.name}`);
+ } catch (err) {
+ logger.error(`切换提示词优化服务失败: ${err.message}`);
+ UIToolkit.showStatusTip(context.buttonElement, 'error', `切换失败: ${err.message}`);
+ }
+ },
+ children: modelChildren.length > 0 ? modelChildren : undefined
+ };
+ });
+
+ // ---创建规则菜单项(支持分类分组)---
+ const ruleMenuItems = [];
+
+ // 辅助函数:创建单个规则菜单项
+ const createRuleMenuItem = (prompt) => ({
label: prompt.name,
icon: ``,
onClick: async (context) => {
- logger.log(`右键菜单 | 动作: 切换扩写提示词 | ID: ${prompt.id}`);
-
+ logger.log(`右键菜单 | 动作: 切换提示词优化 | ID: ${prompt.id}`);
try {
- // 更新激活的提示词
- const response = await fetch('/prompt_assistant/api/config/active_prompt', {
+ const response = await fetch(APIService.getApiUrl('/config/active_prompt'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- type: 'expand',
- prompt_id: prompt.id
- })
+ body: JSON.stringify({ type: 'expand', prompt_id: prompt.id })
});
-
if (response.ok) {
- // 显示成功提示
- UIToolkit.showStatusTip(
- context.widget.buttons.expand,
- 'success',
- `已切换到: ${prompt.name}`,
- null
- );
+ UIToolkit.showStatusTip(context.buttonElement, 'success', `已切换到: ${prompt.name}`);
} else {
throw new Error(`服务器返回错误: ${response.status}`);
}
} catch (error) {
- logger.error(`切换扩写提示词失败: ${error.message}`);
- UIToolkit.showStatusTip(
- context.widget.buttons.expand,
- 'error',
- `切换失败: ${error.message}`,
- null
- );
+ logger.error(`切换提示词优化失败: ${error.message}`);
+ UIToolkit.showStatusTip(context.buttonElement, 'error', `切换失败: ${error.message}`);
}
}
- }));
+ });
- menuItems.push({ type: 'separator' });
- menuItems.push({
+ // 按分类分组规则
+ const uncategorizedPrompts = expandPrompts.filter(p => !p.category);
+ const categorizedPrompts = expandPrompts.filter(p => p.category);
+
+ // 收集所有分类并排序
+ const categories = [...new Set(categorizedPrompts.map(p => p.category))].sort();
+
+ // 添加无分类的规则(放在顶层)
+ uncategorizedPrompts.forEach(prompt => {
+ ruleMenuItems.push(createRuleMenuItem(prompt));
+ });
+
+ // 添加分类分组(每个分类作为二级菜单)
+ categories.forEach(category => {
+ const promptsInCategory = categorizedPrompts.filter(p => p.category === category);
+ const hasActivePrompt = promptsInCategory.some(p => p.isActive);
+
+ ruleMenuItems.push({
+ label: category,
+ icon: ``,
+ submenuAlign: 'center',
+ children: promptsInCategory.map(prompt => createRuleMenuItem(prompt))
+ });
+ });
+
+
+ // 添加规则管理选项
+ ruleMenuItems.push({ type: 'separator' });
+ ruleMenuItems.push({
label: '规则管理',
icon: '',
onClick: () => {
@@ -1760,46 +1806,16 @@ class PromptAssistant {
}
});
- // 在规则管理下方添加 服务选择 子菜单(LLM提供商)
- try {
- const resp = await fetch('/prompt_assistant/api/config/llm');
- if (resp.ok) {
- const cfg = await resp.json();
- const providers = cfg.providers || {};
- const current = cfg.provider || null;
- const providerNameMap = { zhipu: '智谱', siliconflow: '硅基流动', "302ai": '302.AI', ollama: 'Ollama', custom: '自定义' };
- const order = ['zhipu', 'siliconflow', '302ai', 'ollama', 'custom'];
- const children = order
- .filter(key => Object.prototype.hasOwnProperty.call(providers, key))
- .map(key => ({
- label: providerNameMap[key] || key,
- icon: ``,
- onClick: async (context) => {
- try {
- const res = await fetch('/prompt_assistant/api/config/llm', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ current_provider: key })
- });
- if (!res.ok) throw new Error(`服务器返回错误: ${res.status}`);
- UIToolkit.showStatusTip(context.buttonElement, 'success', `已切换到: ${providerNameMap[key] || key}`);
- } catch (err) {
- logger.error(`切换LLM提供商失败: ${err.message}`);
- UIToolkit.showStatusTip(context.buttonElement, 'error', `切换失败: ${err.message}`);
- }
- }
- }));
- menuItems.push({
- label: '服务选择',
- icon: '',
- children
- });
+ return [
+ ...ruleMenuItems,
+ // { type: 'separator' },
+ {
+ label: "选择服务",
+ icon: '',
+ submenuAlign: 'bottom',
+ children: serviceMenuItems
}
- } catch (e) {
- logger.error(`加载LLM提供商失败: ${e.message}`);
- }
-
- return menuItems;
+ ];
}
},
{
@@ -1821,12 +1837,45 @@ class PromptAssistant {
e.currentTarget,
async (notifyCancelReady) => {
try {
- // 获取输入值
- const inputValue = widget.inputEl.value;
+ // --- Markdown LiteGraph 模式处理 ---
+ // 增强判断逻辑:除了检查nodeType,也检查DOM类名
+ const hasMarkdownClass = widget.inputEl?.classList?.contains('comfy-markdown');
+ const isMarkdownLiteGraph = (widget.nodeInfo?.nodeType === 'MarkdownNote' || hasMarkdownClass) &&
+ widget.nodeInfo?.isVueMode !== true;
+
+ logger.debug(`[翻译调试] Markdown检测: ${isMarkdownLiteGraph} (Type: ${widget.nodeInfo?.nodeType}, HasClass: ${hasMarkdownClass})`);
+
+ // 获取输入值 - 根据模式决定是否获取HTML
+ const inputValue = getInputValue(widget, { html: isMarkdownLiteGraph });
+
if (!inputValue || inputValue.trim() === '') {
throw new Error('请输入要翻译的内容');
}
+ let contentToTranslate = inputValue;
+ let mdData = null;
+
+ if (isMarkdownLiteGraph) {
+ mdData = MarkdownNoteTranslate.protectAndExtract(inputValue);
+ if (mdData.texts && mdData.texts.length > 0) {
+ contentToTranslate = mdData.texts.join('\n');
+ } else {
+ // 如果提取后没有文本(只有标签/代码),则认为空或者无需翻译
+ if (!contentToTranslate || contentToTranslate.trim() === '') {
+ // 保持原样或抛出错误,这里选择抛出提示
+ throw new Error('没有检测到可翻译的内容');
+ }
+ // 如果原内容有东西但提取为空,可能全是代码块,保留原内容作为待翻译(实际上API可能跳过)
+ // 或者这里 contentToTranslate 保持为 inputValue ?
+ // 不,protectAndExtract 没提取到,说明不该翻译。
+ // 但为了流程继续,如果不抛错,我们假设 contentToTranslate 为空导致后续报错
+ }
+ }
+
+ if (!contentToTranslate || contentToTranslate.trim() === '') {
+ throw new Error('请输入要翻译的内容');
+ }
+
// 显示翻译中提示
const btnRect = e.currentTarget.getBoundingClientRect();
UIToolkit.showStatusTip(
@@ -1839,39 +1888,47 @@ class PromptAssistant {
// 1. 查询缓存
let cacheResult = null;
if (FEATURES.useTranslateCache) {
- cacheResult = TranslateCacheService.queryTranslateCache(inputValue);
+ cacheResult = TranslateCacheService.queryTranslateCache(contentToTranslate);
}
+
if (cacheResult) {
- let resultText = '';
+ let rawResultText = '';
let tipMessage = '';
let useCache = true;
// 根据缓存匹配类型处理
if (cacheResult.type === 'source') {
// 命中原文,返回译文
- resultText = cacheResult.translatedText;
+ rawResultText = cacheResult.translatedText;
tipMessage = '译文';
} else if (cacheResult.type === 'translated') {
// 命中译文,返回原文
- resultText = cacheResult.sourceText;
+ rawResultText = cacheResult.sourceText;
tipMessage = '原文';
}
+ // 处理 Markdown 格式还原
+ let finalResultText = rawResultText;
+ if (isMarkdownLiteGraph && mdData) {
+ const translatedSegments = rawResultText.split('\n');
+ finalResultText = MarkdownNoteTranslate.restoreWithTranslations(mdData.placeholderHTML, mdData.placeholders, translatedSegments);
+ }
+
// 更新输入框内容并添加高亮效果
- this.updateInputWithHighlight(widget, resultText);
+ this.updateInputWithHighlight(widget, finalResultText, { html: isMarkdownLiteGraph });
// 添加翻译结果到历史记录
HistoryCacheService.addHistory({
workflow_id: widget.nodeInfo?.workflow_id || '',
node_id: widget.nodeId,
input_id: widget.inputId,
- content: resultText,
+ content: finalResultText,
operation_type: 'translate',
timestamp: Date.now()
});
// 重置撤销状态
- HistoryCacheService.initUndoState(widget.nodeId, widget.inputId, resultText);
+ HistoryCacheService.initUndoState(widget.nodeId, widget.inputId, finalResultText);
// 更新按钮状态
UIToolkit.updateUndoRedoButtonState(widget, HistoryCacheService);
@@ -1886,25 +1943,35 @@ class PromptAssistant {
}
// 缓存未命中,使用API翻译
- // 获取翻译类型
- const translateType = app.settings?.["PromptAssistant.Settings.TranslateType"] || "baidu";
- // 生成唯一request_id,根据翻译类型生成对应的前缀
- const request_id = generateRequestId(null, translateType);
+ // 生成唯一request_id
+ const request_id = APIService.generateRequestId('trans', null, widget.nodeId);
// 通知UI可以准备取消操作了
notifyCancelReady(request_id);
- // 检测语言
- const langResult = PromptFormatter.detectLanguage(inputValue);
+ // 检测语言 (使用提取后的文本)
+ const langResult = PromptFormatter.detectLanguage(contentToTranslate);
- // 根据翻译类型选择服务
+ // 获取翻译服务配置
let result;
try {
- if (translateType === "baidu") {
+ // 获取翻译配置
+ const configResp = await fetch(APIService.getApiUrl('/config/translate'));
+ let isBaidu = false;
+
+ if (configResp.ok) {
+ const config = await configResp.json();
+ // 检查provider是否为'baidu'
+ if (config.provider === 'baidu') {
+ isBaidu = true;
+ }
+ }
+
+ if (isBaidu) {
// 使用百度翻译服务
result = await APIService.baiduTranslate(
- inputValue,
+ contentToTranslate,
langResult.from,
langResult.to,
request_id
@@ -1912,7 +1979,7 @@ class PromptAssistant {
} else {
// 使用LLM翻译服务
result = await APIService.llmTranslate(
- inputValue,
+ contentToTranslate,
langResult.from,
langResult.to,
request_id
@@ -1923,7 +1990,7 @@ class PromptAssistant {
throw new Error('翻译服务返回空结果');
}
} catch (error) {
- logger.error(`翻译失败 | 服务:${translateType} | 错误:${error.message}`);
+ logger.error(`翻译失败 | 错误:${error.message}`);
throw new Error(`翻译失败: ${error.message}`);
}
@@ -1931,29 +1998,36 @@ class PromptAssistant {
// 格式化翻译结果(转换标点符号)
const formattedText = PromptFormatter.formatTranslatedText(result.data.translated);
+ // 处理 Markdown 格式还原
+ let finalResultText = formattedText;
+ if (isMarkdownLiteGraph && mdData) {
+ const translatedSegments = formattedText.split('\n');
+ finalResultText = MarkdownNoteTranslate.restoreWithTranslations(mdData.placeholderHTML, mdData.placeholders, translatedSegments);
+ }
+
// 添加翻译结果到历史记录
HistoryCacheService.addHistory({
workflow_id: widget.nodeInfo?.workflow_id || '',
node_id: widget.nodeId,
input_id: widget.inputId,
- content: formattedText,
+ content: finalResultText,
operation_type: 'translate',
request_id: request_id,
timestamp: Date.now()
});
// 更新输入框内容并添加高亮效果
- this.updateInputWithHighlight(widget, formattedText);
+ this.updateInputWithHighlight(widget, finalResultText, { html: isMarkdownLiteGraph });
// 重置撤销状态
- HistoryCacheService.initUndoState(widget.nodeId, widget.inputId, formattedText);
+ HistoryCacheService.initUndoState(widget.nodeId, widget.inputId, finalResultText);
// 更新按钮状态
UIToolkit.updateUndoRedoButtonState(widget, HistoryCacheService);
- // 只有开启缓存时才写入缓存
+ // 只有开启缓存时才写入缓存 (使用提取文本和翻译后的片段文本,以便下次能复用)
if (FEATURES.useTranslateCache) {
- TranslateCacheService.addTranslateCache(inputValue, formattedText);
+ TranslateCacheService.addTranslateCache(contentToTranslate, formattedText);
}
return {
@@ -1976,68 +2050,120 @@ class PromptAssistant {
visible: FEATURES.translate, // Note节点只显示此按钮
// 添加右键菜单配置
contextMenu: async (widget) => {
- const currentTranslateType = app.ui.settings.getSettingValue("PromptAssistant.Settings.TranslateType", "baidu");
const useTranslateCache = app.ui.settings.getSettingValue("PromptAssistant.Features.UseTranslateCache", true);
- // 获取LLM提供商列表
- let llmProviders = {};
- let currentLLMProvider = null;
+ // 获取所有服务列表和当前激活状态
+ let services = [];
+ let currentTranslateService = null;
+ let currentTranslateModel = null;
+
try {
- const resp = await fetch('/prompt_assistant/api/config/llm');
- if (resp.ok) {
- const cfg = await resp.json();
- llmProviders = cfg.providers || {};
- currentLLMProvider = cfg.provider || null;
+ // 获取服务列表
+ const servicesResp = await fetch(APIService.getApiUrl('/services'));
+ if (servicesResp.ok) {
+ const servicesData = await servicesResp.json();
+ if (servicesData.success) {
+ services = servicesData.services || [];
+ }
+ }
+
+ // 获取当前激活的翻译服务和模型
+ const translateResp = await fetch(APIService.getApiUrl('/config/translate'));
+ if (translateResp.ok) {
+ const translateConfig = await translateResp.json();
+ currentTranslateService = translateConfig.provider || null;
+ currentTranslateModel = translateConfig.model || null;
}
} catch (e) {
- logger.error(`获取LLM提供商失败: ${e.message}`);
+ logger.error(`获取服务列表失败: ${e.message}`);
}
- const providerNameMap = { zhipu: '智谱', siliconflow: '硅基流动', "302ai": '302.AI', ollama: 'Ollama', custom: '自定义' };
+ // 创建服务菜单项
+ const serviceMenuItems = [];
- const order = ['zhipu', 'siliconflow', '302ai', 'ollama', 'custom'];
- const providerChildren = order
- .filter(key => Object.prototype.hasOwnProperty.call(llmProviders, key))
- .map(key => ({
- label: providerNameMap[key] || key,
- icon: ``,
- onClick: async (context) => {
- try {
- const res = await fetch('/prompt_assistant/api/config/llm', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ current_provider: key })
- });
- if (!res.ok) throw new Error(`服务器返回错误: ${res.status}`);
- // 确保切换到LLM翻译
- app.ui.settings.setSettingValue("PromptAssistant.Settings.TranslateType", "llm");
- UIToolkit.showStatusTip(context.buttonElement, 'success', `已切换到: ${providerNameMap[key] || key}`);
- } catch (err) {
- logger.error(`切换LLM提供商失败: ${err.message}`);
- UIToolkit.showStatusTip(context.buttonElement, 'error', `切换失败: ${err.message}`);
- }
+ // 百度翻译项(永远显示在第一位)
+ const isBaiduCurrent = currentTranslateService === 'baidu';
+ serviceMenuItems.push({
+ label: '百度翻译',
+ icon: ``,
+ onClick: async (context) => {
+ try {
+ const res = await fetch(APIService.getApiUrl('/services/current'), {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ service_type: 'translate', service_id: 'baidu' })
+ });
+ if (!res.ok) throw new Error(`服务器返回错误: ${res.status}`);
+ UIToolkit.showStatusTip(context.buttonElement, 'success', `已切换到: 百度翻译`);
+ logger.log(`翻译服务切换 | 服务: 百度翻译`);
+ } catch (err) {
+ logger.error(`切换翻译服务失败: ${err.message}`);
+ UIToolkit.showStatusTip(context.buttonElement, 'error', `切换失败: ${err.message}`);
}
- }));
+ }
+ });
+
+ // 动态添加其他LLM服务
+ const otherServiceMenuItems = services
+ .filter(service => service.llm_models && service.llm_models.length > 0)
+ .map(service => {
+ const isCurrentService = currentTranslateService === service.id;
+
+ // 创建模型子菜单
+ const modelChildren = (service.llm_models || []).map(model => {
+ const isCurrentModel = isCurrentService && currentTranslateModel === model.name;
+ return {
+ label: model.display_name || model.name,
+ icon: ``,
+ onClick: async (context) => {
+ try {
+ const res = await fetch(APIService.getApiUrl('/services/current'), {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ service_type: 'translate', service_id: service.id, model_name: model.name })
+ });
+ if (!res.ok) throw new Error(`服务器返回错误: ${res.status}`);
+ const modelLabel = model.display_name || model.name;
+ UIToolkit.showStatusTip(context.buttonElement, 'success', `已切换到: ${service.name} - ${modelLabel}`);
+ logger.log(`翻译服务切换 | 服务: ${service.name} | 模型: ${modelLabel}`);
+ } catch (err) {
+ logger.error(`切换翻译模型失败: ${err.message}`);
+ UIToolkit.showStatusTip(context.buttonElement, 'error', `切换失败: ${err.message}`);
+ }
+ }
+ };
+ });
+
+ return {
+ label: service.name || service.id,
+ icon: ``,
+ onClick: async (context) => {
+ try {
+ const res = await fetch(APIService.getApiUrl('/services/current'), {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ service_type: 'translate', service_id: service.id })
+ });
+ if (!res.ok) throw new Error(`服务器返回错误: ${res.status}`);
+ UIToolkit.showStatusTip(context.buttonElement, 'success', `已切换到: ${service.name}`);
+ logger.log(`翻译服务切换 | 服务: ${service.name}`);
+ } catch (err) {
+ logger.error(`切换翻译服务失败: ${err.message}`);
+ UIToolkit.showStatusTip(context.buttonElement, 'error', `切换失败: ${err.message}`);
+ }
+ },
+ children: modelChildren.length > 0 ? modelChildren : undefined
+ };
+ });
+
+ // 将其他服务添加到serviceMenuItems
+ serviceMenuItems.push(...otherServiceMenuItems);
return [
{
- label: "使用百度翻译",
- icon: ``,
- onClick: (context) => {
- logger.log("右键菜单 | 动作: 切换到百度翻译");
- app.ui.settings.setSettingValue("PromptAssistant.Settings.TranslateType", "baidu");
- UIToolkit.showStatusTip(context.buttonElement, 'success', `已切换到: 百度翻译`);
- }
- },
- {
- label: "使用大语言模型翻译",
- icon: ``,
- onClick: (context) => {
- logger.log("右键菜单 | 动作: 切换到LLM翻译");
- app.ui.settings.setSettingValue("PromptAssistant.Settings.TranslateType", "llm");
- UIToolkit.showStatusTip(context.buttonElement, 'success', `已切换到: 大语言模型翻译`);
- },
- children: providerChildren
+ label: "选择服务",
+ icon: '',
+ children: serviceMenuItems
},
{ type: 'separator' },
{
@@ -2061,73 +2187,41 @@ class PromptAssistant {
let otherButtons = [];
let divider = null;
- // ---使用for...of循环支持异步操作---
+ // ---Add buttons to AssistantContainer---
for (const config of buttonConfigs) {
if (config.type === 'divider') {
- // 创建分割线但暂不添加
- divider = document.createElement('div');
+ // Check visibility for divider
+ if (config.visible === false) continue;
+
+ const divider = document.createElement('div');
divider.className = 'prompt-assistant-divider';
- divider.dataset.id = config.id || `divider_${Date.now()}`;
- // 保存引用
- if (config.id) {
- widget.buttons[config.id] = divider;
- }
+ // Add divider to container
+ widget.container.addButton(divider, config.id || `divider_${Date.now()}`);
+ // Save reference if needed
+ if (config.id) widget.buttons[config.id] = divider;
continue;
}
- // 检查按钮是否可见
- if (config.visible === false) {
- // 区分节点类型和功能禁用的情况
- const reason = (widget.nodeInfo?.isNoteNode && config.id !== 'translate') ? "Note节点限制" : "功能禁用";
- // logger.debug(`按钮跳过 | 按钮: ${config.id || 'unknown'} | 原因: ${reason}`);
- continue;
- }
+ // Check visibility
+ if (config.visible === false) continue;
- // ---异步创建按钮---
+ // Create button using existing helper
+ // Note: addButtonWithIcon returns the button element and saves it to widget.buttons
const button = this.addButtonWithIcon(widget, config);
if (!button) continue;
- // 设置初始状态
+ // Set initial state
if (config.initialState) {
Object.entries(config.initialState).forEach(([stateType, value]) => {
UIToolkit.setButtonState(widget, config.id, stateType, value);
});
}
- // 根据按钮类型分组
- if (['history', 'undo', 'redo'].includes(config.id)) {
- historyButtons.push(button);
- } else {
- otherButtons.push(button);
- }
+ // Add to container
+ widget.container.addButton(button, config.id);
}
- // 添加按钮到DOM,并在需要时添加分割线
- let addedButtonCount = 0;
- // 添加历史相关按钮
- historyButtons.forEach(button => {
- widget.innerContent.appendChild(button);
- addedButtonCount++;
- });
-
- // 如果两种类型的按钮都存在,添加分割线
- if (historyButtons.length > 0 && otherButtons.length > 0 && divider) {
- widget.innerContent.appendChild(divider);
- }
-
- // 添加其他按钮
- otherButtons.forEach(button => {
- widget.innerContent.appendChild(button);
- addedButtonCount++;
- });
-
- // 判断节点类型并记录日志
- if (widget.nodeInfo?.isNoteNode) {
- logger.debug(`按钮添加 | 结果:完成 | 节点ID: ${widget.nodeId} | Note节点: 仅显示翻译按钮 | 添加数量: ${addedButtonCount}`);
- } else {
- logger.debug(`按钮添加 | 结果:完成 | 节点ID: ${widget.nodeId} | 标准节点 | 添加数量: ${addedButtonCount}`);
- }
}
/**
@@ -2203,111 +2297,387 @@ class PromptAssistant {
* 根据滚动条状态调整小助手位置
* @param {Object} widget - 小助手实例
* @param {HTMLElement} inputEl - 输入框元素
+ * @param {Boolean} forceUpdate - 是否强制更新(用于初始化)
*/
- _adjustPositionForScrollbar(widget, inputEl) {
+ _adjustPositionForScrollbar(widget, inputEl, forceUpdate = false) {
if (!widget?.element || !inputEl) return;
const hasScrollbar = this._detectScrollbar(inputEl);
const containerDiv = widget.element;
- // 仅在滚动条状态发生变化时更新位置与输出日志
+ // 仅在滚动条状态发生变化时更新位置(除非强制更新)
const prevState = containerDiv.dataset.hasScrollbar === 'true';
- if (prevState === hasScrollbar) {
+ if (!forceUpdate && prevState === hasScrollbar) {
return; // 状态未变,不做任何操作
}
containerDiv.dataset.hasScrollbar = String(hasScrollbar);
- // 标准定位模式调整
- const rightOffset = hasScrollbar ? '16px' : '4px'; // 有滚动条时向左偏移12px
+ // 有滚动条时向左偏移,避开滚动条
+ const rightOffset = hasScrollbar ? '16px' : '4px';
containerDiv.style.right = rightOffset;
- logger.debug(`[位置调整] 标准方案 | 滚动条: ${hasScrollbar} → 偏移: ${rightOffset}`);
}
/**
* 设置UI位置
- * 使用标准定位方式(仅支持 ComfyUI 0.3.27 及以上版本)
+ * 支持 Vue node2.0 和 litegraph.js 两种渲染模式
* @param {Function} onComplete - 定位完成回调,接收boolean参数,true表示成功,false表示失败
*/
_setupUIPosition(widget, inputEl, containerDiv, canvasContainerRect, onComplete) {
- const _applyStandardPositioning = (containerDiv, domWidgetContainer) => {
- // 标准模式使用默认的绝对定位,添加位置参数
- containerDiv.style.right = '4px';
- containerDiv.style.bottom = '4px';
-
- // 直接添加到dom-widget容器,但不修改dom-widget本身
- domWidgetContainer.appendChild(containerDiv);
-
- // 触发回流,确保样式更新
- void containerDiv.offsetWidth;
-
- // 强制应用容器高度
- containerDiv.style.height = '20px';
- containerDiv.style.minHeight = '20px';
- void containerDiv.offsetWidth; // 触发回流,确保样式应用
- };
-
- // 查找dom-widget父容器(ComfyUI 0.3.27及以上版本的标准容器)
- const findDomWidgetContainer = () => {
- let domWidgetContainer = null;
- // 向上查找dom-widget容器
- let parent = inputEl.parentElement;
- while (parent) {
- if (parent.classList && parent.classList.contains('dom-widget')) {
- domWidgetContainer = parent;
- break;
- }
- parent = parent.parentElement;
- }
- return domWidgetContainer;
- };
-
- // 初始查找dom-widget容器
- let domWidgetContainer = findDomWidgetContainer();
+ logger.debug(`[_setupUIPosition] 开始定位 | 节点ID: ${widget.nodeId} | inputEl存在: ${!!inputEl}`);
// 清理函数列表
widget._eventCleanupFunctions = widget._eventCleanupFunctions || [];
- // 如果初次查找失败,添加延迟重试机制
- // 这是为了处理新创建节点时DOM可能尚未完全渲染的情况
- if (!domWidgetContainer) {
- // 设置重试次数和间隔
- const maxRetries = 3;
- const retryInterval = 500; // 毫秒
- let retryCount = 0;
-
- // 创建重试函数
- const retrySetupPosition = () => {
- // 重新查找dom-widget容器
- domWidgetContainer = findDomWidgetContainer();
-
- if (domWidgetContainer) {
- // 找到了dom-widget容器,使用标准定位方案
- // 使用标准定位方案
- _applyStandardPositioning(containerDiv, domWidgetContainer);
-
- // 通知定位成功
- if (onComplete) onComplete(true);
- } else if (retryCount < maxRetries) {
- // 继续重试
- retryCount++;
- logger.debug(`定位方式 - 重试查找dom-widget容器 (${retryCount}/${maxRetries})`);
- setTimeout(retrySetupPosition, retryInterval);
- } else {
- // 达到最大重试次数,定位失败
- // 通知定位失败
- if (onComplete) onComplete(false);
- }
- };
-
- // 开始重试流程
- setTimeout(retrySetupPosition, retryInterval);
- } else {
- // === 标准方案定位 (ComfyUI 0.3.27及以上) ===
- _applyStandardPositioning(containerDiv, domWidgetContainer);
-
- // 通知定位成功
- if (onComplete) onComplete(true);
+ // 获取节点对象
+ const node = app.graph?.getNodeById(widget.nodeId);
+ if (!node) {
+ logger.debug(`[定位] 节点不存在 | ID: ${widget.nodeId}`);
+ if (onComplete) onComplete(false);
+ return;
}
+
+ // 创建widget对象用于容器查找
+ const widgetObj = {
+ inputEl: inputEl,
+ element: inputEl,
+ name: widget.inputId,
+ id: widget.inputId
+ };
+
+ // 使用 NodeMountService 进行带重试的容器查找
+ // Vue mode下需要更多重试次数和更长间隔
+ const isVueMode = typeof LiteGraph !== 'undefined' && LiteGraph.vueNodesMode === true;
+ nodeMountService.findMountContainerWithRetry(node, widgetObj, {
+ maxRetries: isVueMode ? 5 : 3,
+ retryInterval: isVueMode ? 800 : 500
+ }).then(containerInfo => {
+ if (!containerInfo) {
+ logger.debug(`[定位] 容器查找失败 | 节点ID: ${widget.nodeId}`);
+ if (onComplete) onComplete(false);
+ return;
+ }
+
+ // 根据渲染模式应用不同的定位策略
+ if (containerInfo.mode === RENDER_MODE.VUE_NODES) {
+ this._applyVueNodesPositioning(widget, containerDiv, containerInfo);
+ } else {
+ this._applyLitegraphPositioning(widget, containerDiv, containerInfo);
+ }
+
+ // 保存渲染模式到widget,用于后续调整
+ widget._renderMode = containerInfo.mode;
+
+ // 触发回流确保样式生效
+ void containerDiv.offsetWidth;
+
+ if (onComplete) onComplete(true);
+ logger.debug(`[定位] 成功 | 节点ID: ${widget.nodeId} | 模式: ${containerInfo.mode}`);
+
+ }).catch(error => {
+ logger.error(`[定位] 异常 | 节点ID: ${widget.nodeId} | 错误: ${error.message}`);
+ if (onComplete) onComplete(false);
+ });
+ }
+
+ /**
+ * Vue node2.0 模式下的定位逻辑
+ */
+ _applyVueNodesPositioning(widget, containerDiv, containerInfo) {
+ let { container, textarea, nodeContainer, isNoteNode } = containerInfo;
+
+ // 【特殊处理】Note节点在Vue mode下可能需要二次查找textarea
+ // 因为Note节点的DOM结构较简单,直接查找节点容器内的textarea
+ if (!textarea && isNoteNode && nodeContainer) {
+ logger.debug(`[Vue定位] Note节点二次查找textarea | 节点ID: ${widget.nodeId}`);
+
+ // 在整个节点容器内查找textarea
+ const textareas = nodeContainer.querySelectorAll('textarea');
+ if (textareas.length > 0) {
+ textarea = textareas[0];
+ container = textarea.parentElement;
+ logger.debug(`[Vue定位] Note节点找到textarea | 节点ID: ${widget.nodeId}`);
+ } else {
+ // 仍然没有找到,可能DOM还未渲染完成
+ logger.warn(`[Vue定位] Note节点仍未找到textarea | 节点ID: ${widget.nodeId}`);
+ }
+ }
+
+ // 【关键】在Vue mode下,更新widget的inputEl引用为实际的textarea元素
+ // 这样翻译、扩写等功能才能正确读取/写入文本内容
+ if (textarea && textarea !== widget.inputEl) {
+ const oldInputEl = widget.inputEl;
+ widget.inputEl = textarea;
+ widget.text_element = textarea;
+
+ // 更新全局输入框映射
+ if (window.PromptAssistantInputWidgetMap && window.PromptAssistantInputWidgetMap[widget.widgetKey]) {
+ window.PromptAssistantInputWidgetMap[widget.widgetKey].inputEl = textarea;
+ }
+
+ logger.debug(`[Vue定位] 更新inputEl引用 | 节点ID: ${widget.nodeId}`);
+
+ // 为新的textarea绑定事件(如果之前没有绑定过)
+ // 避免重复绑定:检查是否已经有相关事件监听
+ if (!textarea._promptAssistantBound) {
+ textarea._promptAssistantBound = true;
+ widget._eventCleanupFunctions = widget._eventCleanupFunctions || [];
+
+ // 绑定blur事件用于历史记录
+ const removeBlurListener = EventManager.addDOMListener(textarea, 'blur', async () => {
+ logger.debug(`[Vue] 历史写入准备 | 原因:失焦事件触发 node_id=${widget.nodeId} input_id=${widget.inputId}`);
+ HistoryCacheService.addHistory({
+ workflow_id: widget.nodeInfo?.workflow_id || '',
+ node_id: widget.nodeId,
+ input_id: widget.inputId,
+ content: textarea.value,
+ operation_type: 'input',
+ timestamp: Date.now()
+ });
+ // 重置撤销状态
+ HistoryCacheService.initUndoState(widget.nodeId, widget.inputId, textarea.value);
+ // 更新按钮状态
+ UIToolkit.updateUndoRedoButtonState(widget, HistoryCacheService);
+ });
+ widget._eventCleanupFunctions.push(removeBlurListener);
+
+ // 绑定input事件用于实时更新按钮状态
+ const removeInputListener = EventManager.addDOMListener(textarea, 'input', () => {
+ UIToolkit.updateUndoRedoButtonState(widget, HistoryCacheService);
+ this._adjustPositionForScrollbar(widget, textarea);
+ });
+ widget._eventCleanupFunctions.push(removeInputListener);
+
+ // 添加ResizeObserver监听textarea尺寸变化,当节点大小改变时自动调整位置
+ if (window.ResizeObserver) {
+ const resizeObserver = new ResizeObserver(() => {
+ setTimeout(() => this._adjustPositionForScrollbar(widget, textarea), 10);
+ });
+ resizeObserver.observe(textarea);
+ widget._eventCleanupFunctions.push(() => resizeObserver.disconnect());
+ } else {
+ // 降级方案:监听window resize事件
+ const removeResizeListener = EventManager.addDOMListener(window, 'resize',
+ EventManager.debounce(() => this._adjustPositionForScrollbar(widget, textarea), 100)
+ );
+ widget._eventCleanupFunctions.push(removeResizeListener);
+ }
+
+ // 初始化撤销状态(只初始化一次,使用widget级别的标记)
+ if (!widget._undoStateInitialized) {
+ HistoryCacheService.initUndoState(widget.nodeId, widget.inputId, textarea.value);
+ widget._undoStateInitialized = true;
+ logger.debug(`[Vue定位] 撤销状态初始化 | 节点ID: ${widget.nodeId}`);
+ } else {
+ logger.debug(`[Vue定位] 跳过重复初始化(已在createAssistant中完成) | 节点ID: ${widget.nodeId}`);
+ }
+
+ UIToolkit.updateUndoRedoButtonState(widget, HistoryCacheService);
+
+ logger.debug(`[Vue定位] 事件绑定完成 | 节点ID: ${widget.nodeId}`);
+ } else {
+ // 如果已经绑定过事件,只更新按钮状态
+ UIToolkit.updateUndoRedoButtonState(widget, HistoryCacheService);
+ logger.debug(`[Vue定位] 跳过重复绑定 | 节点ID: ${widget.nodeId}`);
+ }
+ }
+
+ // 【防重复挂载检查】检查 textarea 是否已被小助手绑定
+ // 如果 textarea 已有挂载标记,说明另一个小助手实例已绑定此 textarea,跳过本次挂载
+ if (textarea && textarea._promptAssistantMounted) {
+ logger.debug(`[Vue定位] 跳过挂载 | 原因: textarea 已被其他小助手绑定 | 节点ID: ${widget.nodeId}`);
+ // 清理当前 widget 实例(因为无法正确挂载)
+ if (widget.widgetKey && PromptAssistant.instances.has(widget.widgetKey)) {
+ PromptAssistant.instances.delete(widget.widgetKey);
+ }
+ if (widget.container) {
+ widget.container.destroy();
+ }
+ return;
+ }
+
+ // 【防重复挂载检查】检查容器内是否已存在小助手元素
+ const existingAssistant = container.querySelector('.assistant-container-common');
+ if (existingAssistant) {
+ logger.debug(`[Vue定位] 跳过挂载 | 原因: 容器内已存在小助手 | 节点ID: ${widget.nodeId}`);
+ // 清理当前 widget 实例(因为无法正确挂载)
+ if (widget.widgetKey && PromptAssistant.instances.has(widget.widgetKey)) {
+ PromptAssistant.instances.delete(widget.widgetKey);
+ }
+ if (widget.container) {
+ widget.container.destroy();
+ }
+ return;
+ }
+
+ // 在 textarea 上添加挂载标记
+ if (textarea) {
+ textarea._promptAssistantMounted = true;
+ // 保存 widgetKey 用于清理时移除标记
+ textarea._promptAssistantWidgetKey = widget.widgetKey;
+ }
+
+ // Vue node2.0 模式:使用相对定位,根据 anchorPosition 设置位置
+ containerDiv.style.position = 'absolute';
+ containerDiv.style.zIndex = '10';
+
+ // 确保容器有相对定位
+ const containerPosition = window.getComputedStyle(container).position;
+ if (containerPosition === 'static') {
+ container.style.position = 'relative';
+ }
+
+ // 添加Vue模式标记类
+ containerDiv.classList.add('vue-node-mode');
+
+ // 挂载到容器
+ container.appendChild(containerDiv);
+
+ // 挂载完成后检测并调整滚动条位置
+ // 使用多重延迟策略,确保 textarea 内容已完全加载
+ if (textarea) {
+ requestAnimationFrame(() => this._adjustPositionForScrollbar(widget, textarea, true));
+ setTimeout(() => this._adjustPositionForScrollbar(widget, textarea, true), 50);
+ setTimeout(() => this._adjustPositionForScrollbar(widget, textarea, true), 150);
+ }
+
+ logger.debug(`[Vue定位] 完成 | 节点ID: ${widget.nodeId} | 锚点: ${widget.container?.anchorPosition}`);
+ }
+
+ /**
+ * litegraph.js 模式下的定位逻辑
+ * 【修复】添加后备事件绑定逻辑,与 Vue mode 保持一致
+ */
+ _applyLitegraphPositioning(widget, containerDiv, containerInfo) {
+ const { container: domWidgetContainer, textarea } = containerInfo;
+
+ // 【关键修复】确保 inputEl 引用正确
+ if (textarea && textarea !== widget.inputEl) {
+ widget.inputEl = textarea;
+ widget.text_element = textarea;
+
+ // 更新全局输入框映射
+ if (window.PromptAssistantInputWidgetMap && window.PromptAssistantInputWidgetMap[widget.widgetKey]) {
+ window.PromptAssistantInputWidgetMap[widget.widgetKey].inputEl = textarea;
+ }
+
+ logger.debug(`[Litegraph定位] 更新inputEl引用 | 节点ID: ${widget.nodeId}`);
+ }
+
+ // 【关键修复】确保事件绑定(与 Vue mode 一致的后备逻辑)
+ const inputEl = widget.inputEl || textarea;
+
+ // 使用 widget 级别的 flag 判断
+ const isBound = widget._inputEventsBound;
+
+ logger.debug(`[Litegraph定位] 事件绑定检查 | 节点ID: ${widget.nodeId} | inputEl存在: ${!!inputEl} | isBound: ${isBound}`);
+
+ // 如果没有绑定,则绑定事件
+ if (inputEl && !isBound) {
+ // 如果是遗留标记,记录日志
+ if (inputEl._promptAssistantBound) {
+ logger.debug(`[Litegraph定位] 检测到遗留标记,重新绑定 | 节点ID: ${widget.nodeId}`);
+ }
+
+ inputEl._promptAssistantBound = true;
+ widget._inputEventsBound = true; // 设置标记
+ widget._eventCleanupFunctions = widget._eventCleanupFunctions || [];
+ logger.debug(`[Litegraph定位] 开始绑定事件 | 节点ID: ${widget.nodeId}`);
+
+ // 绑定blur事件用于历史记录
+ const removeBlurListener = EventManager.addDOMListener(inputEl, 'blur', async () => {
+ logger.debug(`[Litegraph] 历史写入准备 | 原因:失焦事件触发 node_id=${widget.nodeId} input_id=${widget.inputId}`);
+ HistoryCacheService.addHistory({
+ workflow_id: widget.nodeInfo?.workflow_id || '',
+ node_id: widget.nodeId,
+ input_id: widget.inputId,
+ content: inputEl.value,
+ operation_type: 'input',
+ timestamp: Date.now()
+ });
+ // 重置撤销状态
+ HistoryCacheService.initUndoState(widget.nodeId, widget.inputId, inputEl.value);
+ // 更新按钮状态
+ UIToolkit.updateUndoRedoButtonState(widget, HistoryCacheService);
+ });
+ widget._eventCleanupFunctions.push(removeBlurListener);
+
+ // 绑定input事件用于实时更新按钮状态
+ const removeInputListener = EventManager.addDOMListener(inputEl, 'input', () => {
+ UIToolkit.updateUndoRedoButtonState(widget, HistoryCacheService);
+ this._adjustPositionForScrollbar(widget, inputEl);
+ });
+ widget._eventCleanupFunctions.push(removeInputListener);
+
+ // 初始化撤销状态
+ if (!widget._undoStateInitialized) {
+ HistoryCacheService.initUndoState(widget.nodeId, widget.inputId, inputEl.value);
+ widget._undoStateInitialized = true;
+ logger.debug(`[Litegraph定位] 撤销状态初始化 | 节点ID: ${widget.nodeId}`);
+ }
+
+ UIToolkit.updateUndoRedoButtonState(widget, HistoryCacheService);
+ logger.debug(`[Litegraph定位] 事件绑定完成 | 节点ID: ${widget.nodeId}`);
+ } else if (inputEl && inputEl._promptAssistantBound) {
+ // 已绑定,只更新按钮状态
+ UIToolkit.updateUndoRedoButtonState(widget, HistoryCacheService);
+ }
+
+ // 【防重复挂载检查】检查 inputEl 是否已被小助手绑定
+ if (inputEl && inputEl._promptAssistantMounted) {
+ logger.debug(`[Litegraph定位] 跳过挂载 | 原因: inputEl 已被其他小助手绑定 | 节点ID: ${widget.nodeId}`);
+ // 清理当前 widget 实例(因为无法正确挂载)
+ if (widget.widgetKey && PromptAssistant.instances.has(widget.widgetKey)) {
+ PromptAssistant.instances.delete(widget.widgetKey);
+ }
+ if (widget.container) {
+ widget.container.destroy();
+ }
+ return;
+ }
+
+ // 【防重复挂载检查】检查容器内是否已存在小助手元素
+ const existingAssistant = domWidgetContainer.querySelector('.assistant-container-common');
+ if (existingAssistant) {
+ logger.debug(`[Litegraph定位] 跳过挂载 | 原因: 容器内已存在小助手 | 节点ID: ${widget.nodeId}`);
+ // 清理当前 widget 实例(因为无法正确挂载)
+ if (widget.widgetKey && PromptAssistant.instances.has(widget.widgetKey)) {
+ PromptAssistant.instances.delete(widget.widgetKey);
+ }
+ if (widget.container) {
+ widget.container.destroy();
+ }
+ return;
+ }
+
+ // 在 inputEl 上添加挂载标记
+ if (inputEl) {
+ inputEl._promptAssistantMounted = true;
+ inputEl._promptAssistantWidgetKey = widget.widgetKey;
+ }
+
+ // 确保 dom-widget 容器有相对定位
+ const containerPosition = window.getComputedStyle(domWidgetContainer).position;
+ if (containerPosition === 'static') {
+ domWidgetContainer.style.position = 'relative';
+ }
+
+ // 标准模式使用绝对定位
+ containerDiv.style.position = 'absolute';
+
+
+
+ // 直接添加到dom-widget容器
+ domWidgetContainer.appendChild(containerDiv);
+
+ // 触发回流,确保样式更新
+ void containerDiv.offsetWidth;
+
+ // 挂载完成后检测并调整滚动条位置
+ if (inputEl) {
+ requestAnimationFrame(() => this._adjustPositionForScrollbar(widget, inputEl, true));
+ }
+
+ logger.debug(`[Litegraph定位] 完成 | 节点ID: ${widget.nodeId} | 锚点: ${widget.container?.anchorPosition}`);
}
/**
@@ -2363,6 +2733,33 @@ class PromptAssistant {
instance._eventCleanupFunctions = [];
}
+ // 3.5【关键修复】重置 inputEl 上的事件绑定标记
+ // 确保模式切换后可以重新绑定事件
+ if (instance.inputEl && instance.inputEl._promptAssistantBound) {
+ instance.inputEl._promptAssistantBound = false;
+ logger.debug(`重置绑定标记 | 节点ID: ${instance.nodeId || instanceKey}`);
+ }
+ if (instance.text_element && instance.text_element._promptAssistantBound) {
+ instance.text_element._promptAssistantBound = false;
+ }
+
+ // 3.6【防重复挂载修复】重置 textarea 上的挂载标记
+ // 确保清理后可以重新挂载小助手
+ if (instance.inputEl && instance.inputEl._promptAssistantMounted) {
+ instance.inputEl._promptAssistantMounted = false;
+ delete instance.inputEl._promptAssistantWidgetKey;
+ logger.debug(`重置挂载标记 | 节点ID: ${instance.nodeId || instanceKey}`);
+ }
+ if (instance.text_element && instance.text_element._promptAssistantMounted && instance.text_element !== instance.inputEl) {
+ instance.text_element._promptAssistantMounted = false;
+ delete instance.text_element._promptAssistantWidgetKey;
+ }
+
+ // 同时重置 widget 级别的标记
+ instance._undoStateInitialized = false;
+ instance._inputEventsBound = false; // 重置输入事件绑定标记
+
+
// 4. 从DOM中移除元素
if (instance.element) {
try {
diff --git a/js/modules/apiConfigManager.js b/js/modules/apiConfigManager.js
index 40cc9f7..7db42fa 100644
--- a/js/modules/apiConfigManager.js
+++ b/js/modules/apiConfigManager.js
@@ -1,6 +1,6 @@
/**
- * API配置管理器
- * 负责管理API配置弹窗和API相关设置
+ * API配置管理器 v2.0
+ * 支持动态服务商管理和多模型配置
*/
import { app } from "../../../../scripts/app.js";
@@ -10,187 +10,54 @@ import {
createFormGroup,
createInputGroup,
createSelectGroup,
- createHorizontalFormGroup
-} from "./settings.js";
+ createHorizontalFormGroup,
+ createSwitchControl,
+ createConfirmPopup,
+ createContextMenu,
+ createTooltip,
+ createMultiSelectListbox
+} from "./uiComponents.js";
+import { APIService } from "../services/api.js";
+
+// Sortable库已通过script标签加载,直接使用全局变量
class APIConfigManager {
+ // 预置服务商ID列表(不可编辑/删除)
+ static PRESET_SERVICE_IDS = ['zhipu', 'xFlow', 'ollama'];
+
constructor() {
- this.llmAllProviders = {};
- this.visionAllProviders = {};
- this.llmModel = null;
- this.llmApiKey = null;
- this.llmBaseUrl = null;
- this.visionModel = null;
- this.visionApiKey = null;
- this.visionBaseUrl = null;
+ // 服务商数据
+ this.services = [];
+ this.currentServices = { llm: null, vlm: null };
+
+ // 百度翻译配置
+ this.baiduConfig = { app_id: '', secret_key: '' };
}
/**
* 显示API配置弹窗
*/
- showAPIConfigModal() {
+ async showAPIConfigModal() {
try {
- logger.debug('打开API配置弹窗');
+ logger.debug('打开API配置弹窗 v2.0');
createSettingsDialog({
- title: 'API管理器',
- dialogClassName: 'api-config-dialog',
+ title: 'API管理器',
+ dialogClassName: 'api-config-dialog-v2',
disableBackdropAndCloseOnClickOutside: true,
+ hideFooter: true, // 不显示底部的保存/取消按钮
renderNotice: (noticeArea) => {
- // 添加副标题说明
const subtitle = document.createElement('div');
- subtitle.style.cssText = `
- font-size: 12px;
- color: var(--p-text-muted-color);
- padding: 4px 40px 4px 40px;
- line-height: 1.6;
- text-align: left;
- `;
- subtitle.textContent = '⚠️本插件仅提供API调用,服务可用性、质量效果及收费标准均由第三方提供商以及所调用的模型能力决定。填写的APIkey仅保存在本地插件目录“config/config.json”文件中。请不要向别人分享config.json文件!避免APIkey泄露造成损失。本插件不对账号问题负责。';
+ subtitle.className = 'api-config-warning';
+ subtitle.textContent = '*免责声明:本插件仅提供 API 调用工具,第三方服务责任与本插件无关,插件所涉用户配置信息均存储于本地。对于因账号使用产生的任何问题,本插件不承担责任!';
noticeArea.appendChild(subtitle);
},
- renderContent: (container) => {
+ renderContent: async (container) => {
+ await this._loadAllConfigs();
this._createAPIConfigUI(container);
},
onSave: async () => {
- try {
- const container = document.querySelector('.settings-modal .p-dialog-content');
- const controls = container.formControls;
-
- // 先保存当前显示的提供商配置
- const currentLLMProvider = controls.llm.provider.value;
- this._saveLLMProviderConfig(currentLLMProvider);
-
- const currentVisionProvider = controls.vision.provider.value;
- this._saveVisionProviderConfig(currentVisionProvider);
-
- // 处理百度翻译配置
- const baiduConfig = {
- app_id: controls.baidu.appId.value.trim(),
- };
- const baiduSecretEl = controls.baidu.secret;
- const baiduSecretValue = baiduSecretEl.value.trim();
- const isBaiduMasked = baiduSecretValue === '••••••••••••••••••••••••••••••••••••••••••••••••';
-
- // 只有当密钥不是掩码,或者掩码不代表现有密钥时,才包含secret_key
- if (!isBaiduMasked || baiduSecretEl.dataset.hasKey !== 'true') {
- baiduConfig.secret_key = baiduSecretValue;
- }
-
- // 获取配置值
- const config = {
- baidu: baiduConfig,
- llm: {
- current_provider: currentLLMProvider,
- providers: this.llmAllProviders || {}
- },
- vision: {
- current_provider: currentVisionProvider,
- providers: this.visionAllProviders || {}
- }
- };
-
- // 确保每个提供商都有配置
- const providerList = ["zhipu", "siliconflow", "302ai", "ollama", "custom"];
- providerList.forEach(provider => {
- // 确保LLM提供商配置存在
- if (!config.llm.providers[provider]) {
- const providerConfig = {
- model: provider === "zhipu" ? "glm-4-flash-250414" :
- (provider === "siliconflow" ? "Qwen/Qwen2.5-7B-Instruct" :
- (provider === "ollama" ? "llama3.1" : "")),
- base_url: provider === "zhipu" ? "https://open.bigmodel.cn/api/paas/v4" :
- (provider === "siliconflow" ? "https://api.siliconflow.cn/v1" :
- (provider === "ollama" ? "http://localhost:11434/v1" : "")),
- api_key: provider === "ollama" ? "ollama" : "",
- temperature: 0.7,
- max_tokens: 2000,
- top_p: 0.9
- };
- // Ollama添加auto_unload参数
- if (provider === "ollama") {
- providerConfig.auto_unload = true;
- }
- config.llm.providers[provider] = providerConfig;
- }
-
- // 确保视觉模型提供商配置存在
- if (!config.vision.providers[provider]) {
- const visionProviderConfig = {
- model: provider === "zhipu" ? "glm-4v-flash" :
- (provider === "siliconflow" ? "THUDM/GLM-4.1V-9B-Thinking" :
- (provider === "ollama" ? "llava:latest" : "")),
- base_url: provider === "zhipu" ? "https://open.bigmodel.cn/api/paas/v4/chat/completions" :
- (provider === "siliconflow" ? "https://api.siliconflow.cn/v1/chat/completions" :
- (provider === "ollama" ? "http://localhost:11434/v1" : "")),
- api_key: provider === "ollama" ? "ollama" : "",
- temperature: 0.7,
- max_tokens: 2000,
- top_p: 0.9
- };
- // Ollama添加auto_unload参数
- if (provider === "ollama") {
- visionProviderConfig.auto_unload = true;
- }
- config.vision.providers[provider] = visionProviderConfig;
- }
- });
-
- console.log("保存配置:", JSON.stringify(config)); // 调试信息
-
- // 保存配置
- await Promise.all([
- fetch('/prompt_assistant/api/config/baidu_translate', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(config.baidu)
- }),
- fetch('/prompt_assistant/api/config/llm', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(config.llm)
- }),
- fetch('/prompt_assistant/api/config/vision', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(config.vision)
- })
- ]).then(responses => {
- // 检查所有响应是否成功
- const failedResponses = responses.filter(response => !response.ok);
- if (failedResponses.length > 0) {
- // 至少有一个请求失败
- throw new Error(`配置保存失败,服务器返回错误: ${failedResponses[0].status}`);
- }
-
- // 所有请求成功
- app.extensionManager.toast.add({
- severity: "success",
- summary: "配置已更新",
- life: 3000
- });
- }).catch(error => {
- // 显示错误提示
- app.extensionManager.toast.add({
- severity: "error",
- summary: "配置保存失败",
- detail: error.message || "保存配置时发生错误",
- life: 3000
- });
-
- // 重新抛出错误,以便外层catch可以处理
- throw error;
- });
- } catch (error) {
- // 这里捕获的是其他类型的错误,如代码执行错误
- app.extensionManager.toast.add({
- severity: "error",
- summary: "配置保存失败",
- detail: error.message || "保存配置时发生错误",
- life: 3000
- });
- throw error;
- }
+ // 不再需要手动保存,因为已经实时保存了
}
});
} catch (error) {
@@ -198,7 +65,107 @@ class APIConfigManager {
app.extensionManager.toast.add({
severity: "error",
summary: "打开配置失败",
- detail: error.message || "打开配置弹窗过程中发生错误",
+ detail: error.message,
+ life: 3000
+ });
+ }
+ }
+
+ /**
+ * 加载所有配置
+ */
+ async _loadAllConfigs() {
+ try {
+ // 加载服务商列表
+ const servicesRes = await fetch(APIService.getApiUrl('/services'));
+ const servicesData = await servicesRes.json();
+
+ if (servicesData.success) {
+ this.services = servicesData.services || [];
+ }
+
+ // 加载百度翻译配置
+ const baiduRes = await fetch(APIService.getApiUrl('/config/baidu_translate'));
+ this.baiduConfig = await baiduRes.json();
+
+ // 加载当前服务配置以获取current_services
+ const llmRes = await fetch(APIService.getApiUrl('/config/llm'));
+ const llmConfig = await llmRes.json();
+ if (llmConfig.provider) {
+ this.currentServices.llm = llmConfig.provider;
+ }
+
+ const vlmRes = await fetch(APIService.getApiUrl('/config/vision'));
+ const vlmConfig = await vlmRes.json();
+ if (vlmConfig.provider) {
+ this.currentServices.vlm = vlmConfig.provider;
+ }
+
+ logger.debug('配置加载完成', {
+ services: this.services.length,
+ currentLLM: this.currentServices.llm,
+ currentVLM: this.currentServices.vlm
+ });
+ } catch (error) {
+ logger.error('加载配置失败', error);
+ throw error;
+ }
+ }
+
+ /**
+ * 保存所有配置
+ */
+ async _saveAllConfigs() {
+ try {
+ // 保存百度翻译配置
+ await fetch(APIService.getApiUrl('/config/baidu_translate'), {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(this.baiduConfig)
+ });
+
+ app.extensionManager.toast.add({
+ severity: "success",
+ summary: "配置已保存",
+ life: 3000
+ });
+ } catch (error) {
+ logger.error('保存配置失败', error);
+ app.extensionManager.toast.add({
+ severity: "error",
+ summary: "保存失败",
+ detail: error.message,
+ life: 3000
+ });
+ throw error;
+ }
+ }
+
+ /**
+ * 保存百度翻译配置
+ */
+ async _saveBaiduConfig() {
+ try {
+ await fetch(APIService.getApiUrl('/config/baidu_translate'), {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(this.baiduConfig)
+ });
+
+ logger.debug('百度翻译配置已保存');
+
+ // 显示成功提示
+ app.extensionManager.toast.add({
+ severity: "success",
+ summary: "百度翻译配置已保存",
+ life: 2000
+ });
+ } catch (error) {
+ logger.error('保存百度翻译配置失败', error);
+ app.extensionManager.toast.add({
+ severity: "error",
+ summary: "保存失败",
+ detail: error.message,
life: 3000
});
}
@@ -206,1304 +173,1925 @@ class APIConfigManager {
/**
* 创建API配置UI
- * @param {HTMLElement} container 容器元素
*/
_createAPIConfigUI(container) {
- // 创建表单
- const form = document.createElement('form');
- form.onsubmit = (e) => e.preventDefault();
- form.className = 'api-config-form';
+ // 创建标签页容器
+ const tabContainer = document.createElement('div');
+ tabContainer.className = 'api-config-tabs';
- // 1. 百度翻译 API 配置
- const baiduSection = this._createBaiduSection();
+ // 创建标签页头部(动态生成所有服务商标签)
+ const tabHeader = this._createTabHeader();
+ tabContainer.appendChild(tabHeader);
- // 2. LLM 配置
- const llmSection = this._createLLMSection();
+ // 创建标签页内容容器
+ const tabContent = document.createElement('div');
+ tabContent.className = 'tab-content';
- // 3. 视觉模型配置
- const visionSection = this._createVisionSection();
+ // 创建百度翻译标签页
+ const baiduContent = this._createBaiduTab();
+ tabContent.appendChild(baiduContent);
- // 添加所有部分到表单
- form.appendChild(baiduSection);
- form.appendChild(llmSection);
- form.appendChild(visionSection);
- container.appendChild(form);
-
- // 加载已有配置
- this._loadAPIConfig(container);
- }
-
- /**
- * 创建百度翻译API配置部分
- * @returns {HTMLElement} 百度翻译API配置部分的DOM元素
- */
- _createBaiduSection() {
- // 百度翻译 API 配置
- const baiduSection = createFormGroup('百度翻译配置', [
- { text: '百度翻译API申请', url: 'https://fanyi-api.baidu.com/' }
- ]);
-
- // 创建输入框
- const baiduAppId = createInputGroup('', '请输入百度翻译 AppID');
- const baiduSecret = createInputGroup('', '请输入百度翻译密钥');
-
- // 创建水平布局组
- const baiduGroup = createHorizontalFormGroup([
- { label: 'AppID', element: baiduAppId.input },
- { label: 'Secret Key', element: baiduSecret.input }
- ]);
-
- baiduSection.appendChild(baiduGroup);
-
- // 保存输入框引用,便于后续获取值
- this.baiduAppId = baiduAppId.input;
- this.baiduSecret = baiduSecret.input;
-
- return baiduSection;
- }
-
- /**
- * 创建LLM配置部分
- * @returns {HTMLElement} LLM配置部分的DOM元素
- */
- _createLLMSection() {
- // LLM 配置
- const llmSection = createFormGroup('大语言模型配置', [
- { text: '智谱', url: 'https://www.bigmodel.cn/invite?icode=Wz1tQAT40T9M8vwp%2F1db7nHEaazDlIZGj9HxftzTbt4%3D' },
- { text: '硅基流动', url: 'https://cloud.siliconflow.cn/i/FCDL2zBQ' },
- { text: '302.AI', url: 'https://share.302.ai/JrO51c' }
- ], { prefixText: 'API key申请:' });
-
- // 创建选择器和输入框
- const llmProvider = createSelectGroup('', [
- { value: 'zhipu', text: '智谱' },
- { value: 'siliconflow', text: '硅基流动' },
- { value: '302ai', text: '302.AI' },
- { value: 'ollama', text: 'Ollama' },
- { value: 'custom', text: '自定义' }
- ]);
- const llmModelInput = createInputGroup('', '请输入模型名称');
- this.llmModel = llmModelInput.input; // 保存引用以供外部函数使用
-
- // 为模型输入框添加自动完成功能
- this._attachAutocomplete(this.llmModel, 'llm', () => this.llmProvider.value);
-
- // 创建base_url输入框(初始隐藏)
- const llmBaseUrlInput = createInputGroup('Base URL', '请输入API基础URL,例如: https://api.example.com/v1');
- this.llmBaseUrl = llmBaseUrlInput.input; // 保存引用以供外部函数使用
- llmBaseUrlInput.group.style.display = 'none'; // 初始隐藏
-
- // 在base_url输入框下添加提示信息
- const llmBaseUrlHint = document.createElement('div');
- llmBaseUrlHint.className = 'base-url-hint';
- llmBaseUrlHint.style.fontSize = '12px';
- llmBaseUrlHint.style.color = '#666';
- llmBaseUrlHint.style.marginTop = '4px';
- llmBaseUrlHint.style.marginBottom = '8px';
- llmBaseUrlHint.textContent = '注意: 请输入基础URL,不要包含/chat/completions路径';
- llmBaseUrlInput.group.appendChild(llmBaseUrlHint);
-
- // 创建水平布局组
- const llmProviderGroup = createHorizontalFormGroup([
- { label: '模型提供商', element: llmProvider.group },
- { label: '模型名称', element: this.llmModel }
- ]);
-
- // API Key 保持单独一行
- const llmApiKeyInput = createInputGroup('API Key', '请输入模型 API Key');
- this.llmApiKey = llmApiKeyInput.input; // 保存引用以供外部函数使用
-
- // ---模型参数配置(可折叠)---
- // 创建高级设置折叠容器
- const llmAdvancedContainer = document.createElement('div');
- llmAdvancedContainer.className = 'advanced-settings-container';
- llmAdvancedContainer.style.cssText = `
- margin-top: 12px;
- `;
-
- // 创建高级设置标题(可点击折叠/展开)
- const llmAdvancedHeader = document.createElement('div');
- llmAdvancedHeader.className = 'advanced-settings-header';
- llmAdvancedHeader.style.cssText = `
- display: flex;
- align-items: center;
- gap: 8px;
- cursor: pointer;
- padding: 2px 0;
- user-select: none;
- `;
-
- const llmAdvancedIcon = document.createElement('span');
- llmAdvancedIcon.className = 'pi pi-angle-right';
- llmAdvancedIcon.style.cssText = `
- font-size: 12px;
- color: var(--p-text-muted-color);
- transition: transform 0.2s;
- `;
-
- const llmAdvancedTitle = document.createElement('span');
- llmAdvancedTitle.textContent = '高级设置';
- llmAdvancedTitle.style.cssText = `
- font-size: 13px;
- color: var(--p-text-color);
- font-weight: 500;
- `;
-
- llmAdvancedHeader.appendChild(llmAdvancedIcon);
- llmAdvancedHeader.appendChild(llmAdvancedTitle);
-
- // 创建参数内容区域(默认隐藏)
- const llmAdvancedContent = document.createElement('div');
- llmAdvancedContent.className = 'advanced-settings-content';
- llmAdvancedContent.style.cssText = `
- display: none;
- overflow: hidden;
- transition: all 0.3s ease;
- `;
-
- // 创建温度和最大token输入框
- const llmTemperatureInput = createInputGroup('', '0.1-2.0,控制输出随机性');
- llmTemperatureInput.input.type = 'number';
- llmTemperatureInput.input.min = '0.1';
- llmTemperatureInput.input.max = '2.0';
- llmTemperatureInput.input.step = '0.1';
- llmTemperatureInput.input.value = '0.7'; // 默认值
- this.llmTemperature = llmTemperatureInput.input;
-
- const llmMaxTokensInput = createInputGroup('', '1-32000,控制最大输出长度');
- llmMaxTokensInput.input.type = 'number';
- llmMaxTokensInput.input.min = '1';
- llmMaxTokensInput.input.max = '32000';
- llmMaxTokensInput.input.step = '1';
- llmMaxTokensInput.input.value = '2000'; // 默认值
- this.llmMaxTokens = llmMaxTokensInput.input;
-
- const llmTopPInput = createInputGroup('', '0.1-1.0,控制核采样范围');
- llmTopPInput.input.type = 'number';
- llmTopPInput.input.min = '0.1';
- llmTopPInput.input.max = '1.0';
- llmTopPInput.input.step = '0.1';
- llmTopPInput.input.value = '0.9'; // 默认值
- this.llmTopP = llmTopPInput.input;
-
- // 创建模型参数水平布局组(三个参数在同一行)
- const llmParamsGroup = createHorizontalFormGroup([
- { label: '温度 (Temperature)', element: this.llmTemperature },
- { label: '核采样 (Top-P)', element: this.llmTopP },
- { label: '最大Token数', element: this.llmMaxTokens }
- ]);
-
- llmAdvancedContent.appendChild(llmParamsGroup);
-
- // 创建Ollama专属选项(初始隐藏)
- const llmOllamaOptionsGroup = document.createElement('div');
- llmOllamaOptionsGroup.className = 'ollama-options-group';
- llmOllamaOptionsGroup.style.cssText = `
- display: none;
- margin-top: 12px;
- padding: 12px;
- background: var(--p-content-background);
- border: 1px solid var(--p-divider-color);
- border-radius: 4px;
- `;
-
- const llmOllamaAutoUnload = document.createElement('div');
- llmOllamaAutoUnload.className = 'p-field-checkbox';
- llmOllamaAutoUnload.style.cssText = `
- display: flex;
- align-items: center;
- gap: 8px;
- `;
-
- const llmOllamaAutoUnloadCheckbox = document.createElement('input');
- llmOllamaAutoUnloadCheckbox.type = 'checkbox';
- llmOllamaAutoUnloadCheckbox.id = 'llm-ollama-auto-unload';
- llmOllamaAutoUnloadCheckbox.className = 'p-checkbox';
- llmOllamaAutoUnloadCheckbox.checked = true; // 默认勾选
- this.llmOllamaAutoUnload = llmOllamaAutoUnloadCheckbox;
-
- const llmOllamaAutoUnloadLabel = document.createElement('label');
- llmOllamaAutoUnloadLabel.htmlFor = 'llm-ollama-auto-unload';
- llmOllamaAutoUnloadLabel.textContent = '自动释放显存';
- llmOllamaAutoUnloadLabel.style.cssText = `
- font-size: 13px;
- color: var(--p-text-color);
- cursor: pointer;
- user-select: none;
- `;
-
- const llmOllamaAutoUnloadHint = document.createElement('div');
- llmOllamaAutoUnloadHint.style.cssText = `
- font-size: 12px;
- color: var(--p-text-muted-color);
- margin-top: 4px;
- margin-left: 24px;
- `;
- llmOllamaAutoUnloadHint.textContent = '启用后,模型在生成响应后会立即卸载,释放显存和内存(推荐)';
-
- llmOllamaAutoUnload.appendChild(llmOllamaAutoUnloadCheckbox);
- llmOllamaAutoUnload.appendChild(llmOllamaAutoUnloadLabel);
- llmOllamaOptionsGroup.appendChild(llmOllamaAutoUnload);
- llmOllamaOptionsGroup.appendChild(llmOllamaAutoUnloadHint);
-
- llmAdvancedContent.appendChild(llmOllamaOptionsGroup);
-
- // 折叠/展开切换
- let llmIsExpanded = false;
- llmAdvancedHeader.addEventListener('click', () => {
- llmIsExpanded = !llmIsExpanded;
- if (llmIsExpanded) {
- llmAdvancedContent.style.display = 'block';
- llmAdvancedIcon.style.transform = 'rotate(90deg)';
- } else {
- llmAdvancedContent.style.display = 'none';
- llmAdvancedIcon.style.transform = 'rotate(0deg)';
- }
- });
-
- llmAdvancedContainer.appendChild(llmAdvancedHeader);
- llmAdvancedContainer.appendChild(llmAdvancedContent);
-
- llmSection.appendChild(llmProviderGroup);
- llmSection.appendChild(llmBaseUrlInput.group); // 添加base_url输入框
- llmSection.appendChild(llmApiKeyInput.group);
- llmSection.appendChild(llmAdvancedContainer); // 添加高级设置折叠容器
-
- // 监听LLM提供商选择变化
- llmProvider.select.addEventListener('change', () => {
- const oldProvider = llmProvider.select.dataset.previousValue;
- const provider = llmProvider.select.value;
-
- // 如果存在旧提供商,保存其配置
- if (oldProvider) {
- this._saveLLMProviderConfig(oldProvider);
- }
-
- // 根据提供商类型显示/隐藏 base_url 与 API Key 以及 Ollama 选项
- if (provider === 'custom') {
- llmBaseUrlInput.group.style.display = 'block';
- llmApiKeyInput.group.style.display = 'block';
- llmOllamaOptionsGroup.style.display = 'none';
- } else if (provider === '302ai') {
- llmBaseUrlInput.group.style.display = 'none';
- llmApiKeyInput.group.style.display = 'block';
- llmOllamaOptionsGroup.style.display = 'none';
- } else if (provider === 'ollama') {
- llmBaseUrlInput.group.style.display = 'none';
- llmApiKeyInput.group.style.display = 'none';
- llmOllamaOptionsGroup.style.display = 'block';
- } else {
- llmBaseUrlInput.group.style.display = 'none';
- llmApiKeyInput.group.style.display = 'block';
- llmOllamaOptionsGroup.style.display = 'none';
- }
-
- // 加载选定提供商的配置
- if (this.llmAllProviders && this.llmAllProviders[provider]) {
- const providerConfig = this.llmAllProviders[provider];
- this.llmModel.value = providerConfig.model || '';
-
- // 设置API密钥(使用掩码)
- if (providerConfig.api_key || providerConfig.has_key) {
- this._setMaskedValue(this.llmApiKey, providerConfig.api_key || "dummy-key");
- } else {
- this.llmApiKey.value = '';
- this.llmApiKey.dataset.hasKey = 'false';
- }
-
- // 加载模型参数
- this.llmTemperature.value = providerConfig.temperature || '0.7';
- this.llmMaxTokens.value = providerConfig.max_tokens || '2000';
- this.llmTopP.value = providerConfig.top_p || '0.9';
-
- if (provider === 'custom') {
- this.llmBaseUrl.value = providerConfig.base_url || '';
- } else if (provider === '302ai') {
- // 302.AI 为内置 base_url,不需要填写
- this.llmBaseUrl.value = '';
- } else if (provider === 'ollama') {
- // Ollama base_url 与 api_key 内置
- this.llmBaseUrl.value = '';
- this._setMaskedValue(this.llmApiKey, 'ollama');
- // 加载 Ollama 自动释放显存选项
- this.llmOllamaAutoUnload.checked = providerConfig.auto_unload !== false; // 默认为 true
- }
- } else {
- // 设置默认模型名称
- if (provider === 'zhipu') {
- this.llmModel.value = 'glm-4-flash-250414';
- } else if (provider === 'siliconflow') {
- this.llmModel.value = 'Qwen/Qwen2.5-7B-Instruct';
- } else if (provider === '302ai') {
- this.llmModel.value = 'Qwen/Qwen2.5-7B-Instruct';
- } else if (provider === 'ollama') {
- this.llmModel.value = 'llama3.1';
- // Ollama 默认启用自动释放显存
- this.llmOllamaAutoUnload.checked = true;
- } else {
- this.llmModel.value = '';
- }
- // 清空API Key和base_url
- this.llmApiKey.value = '';
- this.llmApiKey.dataset.hasKey = 'false';
- // 设置默认模型参数
- this.llmTemperature.value = '0.7';
- this.llmMaxTokens.value = '2000';
- this.llmTopP.value = '0.9';
- if (provider === 'custom') {
- this.llmBaseUrl.value = '';
- }
- }
-
- // 记录当前选择的提供商
- llmProvider.select.dataset.previousValue = provider;
+ // 动态创建每个服务商的标签页内容
+ this.services.forEach(service => {
+ const serviceContent = this._createServiceContentTab(service);
+ tabContent.appendChild(serviceContent);
});
- // 保存选择器引用,便于后续获取值
- this.llmProvider = llmProvider.select;
+ tabContainer.appendChild(tabContent);
+ container.appendChild(tabContainer);
- return llmSection;
+ // 默认显示第一个标签页
+ this._switchTab('baidu', tabHeader, tabContent);
}
/**
- * 创建视觉模型配置部分
- * @returns {HTMLElement} 视觉模型配置部分的DOM元素
+ * 创建标签页头部(包含所有服务商)
*/
- _createVisionSection() {
- // 视觉模型配置
- const visionSection = createFormGroup('视觉模型配置', []);
+ _createTabHeader() {
+ const header = document.createElement('div');
+ header.className = 'tab-header';
- // 创建选择器和输入框
- const visionProvider = createSelectGroup('', [
- { value: 'zhipu', text: '智谱' },
- { value: 'siliconflow', text: '硅基流动' },
- { value: '302ai', text: '302.AI' },
- { value: 'ollama', text: 'Ollama' },
- { value: 'custom', text: '自定义' }
- ]);
- const visionModelInput = createInputGroup('', '请输入模型名称');
- this.visionModel = visionModelInput.input; // 保存引用以供外部函数使用
-
- // 为模型输入框添加自动完成功能
- this._attachAutocomplete(this.visionModel, 'vision', () => this.visionProvider.value);
+ // 百度翻译标签
+ const baiduTab = this._createTabButton('baidu', '百度翻译', '机器翻译');
+ header.appendChild(baiduTab);
- // 创建base_url输入框(初始隐藏)
- const visionBaseUrlInput = createInputGroup('Base URL', '请输入API基础URL,例如: https://api.example.com/v1');
- this.visionBaseUrl = visionBaseUrlInput.input; // 保存引用以供外部函数使用
- visionBaseUrlInput.group.style.display = 'none'; // 初始隐藏
-
- // 在base_url输入框下添加提示信息
- const visionBaseUrlHint = document.createElement('div');
- visionBaseUrlHint.className = 'base-url-hint';
- visionBaseUrlHint.style.fontSize = '12px';
- visionBaseUrlHint.style.color = '#666';
- visionBaseUrlHint.style.marginTop = '4px';
- visionBaseUrlHint.style.marginBottom = '8px';
- visionBaseUrlHint.textContent = '注意: 请输入基础URL,不要包含/chat/completions路径';
- visionBaseUrlInput.group.appendChild(visionBaseUrlHint);
-
- // 创建水平布局组
- const visionProviderGroup = createHorizontalFormGroup([
- { label: '模型提供商', element: visionProvider.group },
- { label: '模型名称', element: this.visionModel }
- ]);
-
- // API Key 保持单独一行
- const visionApiKeyInput = createInputGroup('API Key', '请输入模型 API Key');
- this.visionApiKey = visionApiKeyInput.input; // 保存引用以供外部函数使用
-
- // ---视觉模型参数配置(可折叠)---
- // 创建高级设置折叠容器
- const visionAdvancedContainer = document.createElement('div');
- visionAdvancedContainer.className = 'advanced-settings-container';
- visionAdvancedContainer.style.cssText = `
- margin-top: 12px;
- `;
-
- // 创建高级设置标题(可点击折叠/展开)
- const visionAdvancedHeader = document.createElement('div');
- visionAdvancedHeader.className = 'advanced-settings-header';
- visionAdvancedHeader.style.cssText = `
- display: flex;
- align-items: center;
- gap: 8px;
- cursor: pointer;
- padding: 8px 0;
- user-select: none;
- `;
-
- const visionAdvancedIcon = document.createElement('span');
- visionAdvancedIcon.className = 'pi pi-angle-right';
- visionAdvancedIcon.style.cssText = `
- font-size: 12px;
- color: var(--p-text-muted-color);
- transition: transform 0.2s;
- `;
-
- const visionAdvancedTitle = document.createElement('span');
- visionAdvancedTitle.textContent = '高级设置';
- visionAdvancedTitle.style.cssText = `
- font-size: 13px;
- color: var(--p-text-color);
- font-weight: 500;
- `;
-
- visionAdvancedHeader.appendChild(visionAdvancedIcon);
- visionAdvancedHeader.appendChild(visionAdvancedTitle);
-
- // 创建参数内容区域(默认隐藏)
- const visionAdvancedContent = document.createElement('div');
- visionAdvancedContent.className = 'advanced-settings-content';
- visionAdvancedContent.style.cssText = `
- display: none;
- overflow: hidden;
- transition: all 0.3s ease;
- `;
-
- // 创建温度和最大token输入框
- const visionTemperatureInput = createInputGroup('', '0.1-2.0,控制输出随机性');
- visionTemperatureInput.input.type = 'number';
- visionTemperatureInput.input.min = '0.1';
- visionTemperatureInput.input.max = '2.0';
- visionTemperatureInput.input.step = '0.1';
- visionTemperatureInput.input.value = '0.7'; // 默认值
- this.visionTemperature = visionTemperatureInput.input;
-
- const visionMaxTokensInput = createInputGroup('', '1-32000,控制最大输出长度');
- visionMaxTokensInput.input.type = 'number';
- visionMaxTokensInput.input.min = '1';
- visionMaxTokensInput.input.max = '32000';
- visionMaxTokensInput.input.step = '1';
- visionMaxTokensInput.input.value = '2000'; // 默认值
- this.visionMaxTokens = visionMaxTokensInput.input;
-
- const visionTopPInput = createInputGroup('', '0.1-1.0,控制核采样范围');
- visionTopPInput.input.type = 'number';
- visionTopPInput.input.min = '0.1';
- visionTopPInput.input.max = '1.0';
- visionTopPInput.input.step = '0.1';
- visionTopPInput.input.value = '0.9'; // 默认值
- this.visionTopP = visionTopPInput.input;
-
- // 创建视觉模型参数水平布局组(三个参数在同一行)
- const visionParamsGroup = createHorizontalFormGroup([
- { label: '温度 (Temperature)', element: this.visionTemperature },
- { label: '核采样 (Top-P)', element: this.visionTopP },
- { label: '最大Token数', element: this.visionMaxTokens }
- ]);
-
- visionAdvancedContent.appendChild(visionParamsGroup);
-
- // 创建Ollama专属选项(初始隐藏)
- const visionOllamaOptionsGroup = document.createElement('div');
- visionOllamaOptionsGroup.className = 'ollama-options-group';
- visionOllamaOptionsGroup.style.cssText = `
- display: none;
- margin-top: 12px;
- padding: 12px;
- background: var(--p-content-background);
- border: 1px solid var(--p-divider-color);
- border-radius: 4px;
- `;
-
- const visionOllamaAutoUnload = document.createElement('div');
- visionOllamaAutoUnload.className = 'p-field-checkbox';
- visionOllamaAutoUnload.style.cssText = `
- display: flex;
- align-items: center;
- gap: 8px;
- `;
-
- const visionOllamaAutoUnloadCheckbox = document.createElement('input');
- visionOllamaAutoUnloadCheckbox.type = 'checkbox';
- visionOllamaAutoUnloadCheckbox.id = 'vision-ollama-auto-unload';
- visionOllamaAutoUnloadCheckbox.className = 'p-checkbox';
- visionOllamaAutoUnloadCheckbox.checked = true; // 默认勾选
- this.visionOllamaAutoUnload = visionOllamaAutoUnloadCheckbox;
-
- const visionOllamaAutoUnloadLabel = document.createElement('label');
- visionOllamaAutoUnloadLabel.htmlFor = 'vision-ollama-auto-unload';
- visionOllamaAutoUnloadLabel.textContent = '自动释放显存';
- visionOllamaAutoUnloadLabel.style.cssText = `
- font-size: 13px;
- color: var(--p-text-color);
- cursor: pointer;
- user-select: none;
- `;
-
- const visionOllamaAutoUnloadHint = document.createElement('div');
- visionOllamaAutoUnloadHint.style.cssText = `
- font-size: 12px;
- color: var(--p-text-muted-color);
- margin-top: 4px;
- margin-left: 24px;
- `;
- visionOllamaAutoUnloadHint.textContent = '启用后,模型在生成响应后会立即卸载,释放显存和内存(推荐)';
-
- visionOllamaAutoUnload.appendChild(visionOllamaAutoUnloadCheckbox);
- visionOllamaAutoUnload.appendChild(visionOllamaAutoUnloadLabel);
- visionOllamaOptionsGroup.appendChild(visionOllamaAutoUnload);
- visionOllamaOptionsGroup.appendChild(visionOllamaAutoUnloadHint);
-
- visionAdvancedContent.appendChild(visionOllamaOptionsGroup);
-
- // 折叠/展开切换
- let visionIsExpanded = false;
- visionAdvancedHeader.addEventListener('click', () => {
- visionIsExpanded = !visionIsExpanded;
- if (visionIsExpanded) {
- visionAdvancedContent.style.display = 'block';
- visionAdvancedIcon.style.transform = 'rotate(90deg)';
- } else {
- visionAdvancedContent.style.display = 'none';
- visionAdvancedIcon.style.transform = 'rotate(0deg)';
- }
- });
-
- visionAdvancedContainer.appendChild(visionAdvancedHeader);
- visionAdvancedContainer.appendChild(visionAdvancedContent);
-
- visionSection.appendChild(visionProviderGroup);
- visionSection.appendChild(visionBaseUrlInput.group); // 添加base_url输入框
- visionSection.appendChild(visionApiKeyInput.group);
- visionSection.appendChild(visionAdvancedContainer); // 添加高级设置折叠容器
-
- // 监听视觉模型提供商选择变化
- visionProvider.select.addEventListener('change', () => {
- const oldProvider = visionProvider.select.dataset.previousValue;
- const provider = visionProvider.select.value;
-
- // 如果存在旧提供商,保存其配置
- if (oldProvider) {
- this._saveVisionProviderConfig(oldProvider);
- }
-
- // 根据提供商类型显示/隐藏 base_url 与 API Key 以及 Ollama 选项
- if (provider === 'custom') {
- visionBaseUrlInput.group.style.display = 'block';
- visionApiKeyInput.group.style.display = 'block';
- visionOllamaOptionsGroup.style.display = 'none';
- } else if (provider === '302ai') {
- visionBaseUrlInput.group.style.display = 'none';
- visionApiKeyInput.group.style.display = 'block';
- visionOllamaOptionsGroup.style.display = 'none';
- } else if (provider === 'ollama') {
- visionBaseUrlInput.group.style.display = 'none';
- visionApiKeyInput.group.style.display = 'none';
- visionOllamaOptionsGroup.style.display = 'block';
- } else {
- visionBaseUrlInput.group.style.display = 'none';
- visionApiKeyInput.group.style.display = 'block';
- visionOllamaOptionsGroup.style.display = 'none';
- }
-
- // 加载选定提供商的配置
- if (this.visionAllProviders && this.visionAllProviders[provider]) {
- const providerConfig = this.visionAllProviders[provider];
- this.visionModel.value = providerConfig.model || '';
-
- // 设置API密钥(使用掩码)
- if (providerConfig.api_key || providerConfig.has_key) {
- this._setMaskedValue(this.visionApiKey, providerConfig.api_key || "dummy-key");
- } else {
- this.visionApiKey.value = '';
- this.visionApiKey.dataset.hasKey = 'false';
- }
-
- // 加载模型参数
- this.visionTemperature.value = providerConfig.temperature || '0.7';
- this.visionMaxTokens.value = providerConfig.max_tokens || '2000';
- this.visionTopP.value = providerConfig.top_p || '0.9';
-
- if (provider === 'custom') {
- this.visionBaseUrl.value = providerConfig.base_url || '';
- } else if (provider === '302ai') {
- this.visionBaseUrl.value = '';
- } else if (provider === 'ollama') {
- this.visionBaseUrl.value = '';
- this._setMaskedValue(this.visionApiKey, 'ollama');
- // 加载 Ollama 自动释放显存选项
- this.visionOllamaAutoUnload.checked = providerConfig.auto_unload !== false; // 默认为 true
- }
- } else {
- // 设置默认模型名称
- if (provider === 'zhipu') {
- this.visionModel.value = 'glm-4v-flash';
- } else if (provider === 'siliconflow') {
- this.visionModel.value = 'THUDM/GLM-4.1V-9B-Thinking';
- } else if (provider === '302ai') {
- this.visionModel.value = 'THUDM/GLM-4.1V-9B-Thinking';
- } else if (provider === 'ollama') {
- this.visionModel.value = 'llava:latest';
- // Ollama 默认启用自动释放显存
- this.visionOllamaAutoUnload.checked = true;
- } else {
- this.visionModel.value = '';
- }
- // 清空API Key和base_url
- this.visionApiKey.value = '';
- this.visionApiKey.dataset.hasKey = 'false';
- // 设置默认模型参数
- this.visionTemperature.value = '0.7';
- this.visionMaxTokens.value = '2000';
- this.visionTopP.value = '0.9';
- if (provider === 'custom') {
- this.visionBaseUrl.value = '';
- }
- }
-
- // 记录当前选择的提供商
- visionProvider.select.dataset.previousValue = provider;
+ // 动态创建服务商标签
+ this.services.forEach(service => {
+ const tabButton = this._createTabButton(
+ service.id,
+ service.name || '未命名服务',
+ service.description || ''
+ );
+ header.appendChild(tabButton);
});
- // 保存选择器引用,便于后续获取值
- this.visionProvider = visionProvider.select;
+ // 创建"+"新增标签按钮
+ const addButton = document.createElement('button');
+ addButton.className = 'service-tab-add';
+ addButton.innerHTML = '';
+ addButton.addEventListener('click', () => this._addNewService(header, header.nextElementSibling));
+ header.appendChild(addButton);
- return visionSection;
+ // 初始化拖拽排序
+ new Sortable(header, {
+ handle: '.tab-button',
+ draggable: '.tab-button',
+ filter: '.service-tab-add', // 排除"+"按钮
+ animation: 150,
+ onEnd: async (evt) => {
+ await this._updateServicesOrder();
+ }
+ });
+
+ return header;
}
/**
- * 设置掩码值,同时保存原始状态
- * @param {HTMLInputElement} input 输入元素
- * @param {string} value 原始值
+ * 更新服务商顺序
*/
- _setMaskedValue(input, value) {
- const hasValue = value && value.trim() !== '';
-
- if (hasValue) {
- // 显示掩码
- input.value = '••••••••••••••••••••••••••••••••••••••••••••••••';
- // 标记为已设置
- input.dataset.hasKey = 'true';
- } else {
- input.value = '';
- input.dataset.hasKey = 'false';
- }
-
- // 添加焦点事件处理
- if (!input._maskedHandlersAdded) {
- input.addEventListener('focus', function () {
- if (this.dataset.hasKey === 'true' && this.value === '••••••••••••••••••••••••••••••••••••••••••••••••') {
- this.value = '';
- }
- });
-
- input.addEventListener('blur', function () {
- if (this.dataset.hasKey === 'true' && this.value === '') {
- this.value = '••••••••••••••••••••••••••••••••••••••••••••••••';
- }
- });
-
- input._maskedHandlersAdded = true;
- }
- }
-
- /**
- * 加载API配置
- * @param {HTMLElement} container 容器元素
- */
- async _loadAPIConfig(container) {
+ async _updateServicesOrder() {
try {
- const [baiduConfig, llmConfig, visionConfig] = await Promise.all([
- fetch('/prompt_assistant/api/config/baidu_translate').then(r => r.json()),
- fetch('/prompt_assistant/api/config/llm').then(r => r.json()),
- fetch('/prompt_assistant/api/config/vision').then(r => r.json())
- ]);
+ // 从DOM读取当前标签顺序
+ const header = document.querySelector('.tab-header');
+ const buttons = header.querySelectorAll('.tab-button');
+ const serviceIds = [];
- // 设置百度翻译配置
- if (baiduConfig.app_id) {
- this.baiduAppId.value = baiduConfig.app_id;
- }
- if (baiduConfig.secret_key) {
- // 使用掩码显示密钥
- this._setMaskedValue(this.baiduSecret, baiduConfig.secret_key);
- }
+ buttons.forEach(btn => {
+ const tabId = btn.dataset.tab;
+ // 排除特殊标签(如百度翻译)
+ if (tabId && tabId !== 'baidu') {
+ serviceIds.push(tabId);
+ }
+ });
- // 存储所有提供商的配置
- this.llmAllProviders = llmConfig.providers || {};
- this.visionAllProviders = visionConfig.providers || {};
+ // 调用后端API保存顺序
+ const res = await fetch(APIService.getApiUrl('/services/order'), {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ service_ids: serviceIds })
+ });
- // 为所有提供商预处理API密钥状态
- const providerList = ["zhipu", "siliconflow", "302ai", "ollama", "custom"];
+ const result = await res.json();
- // 处理LLM提供商的API密钥
- providerList.forEach(provider => {
- if (this.llmAllProviders[provider]) {
- if (provider === 'ollama') {
- // 内置API Key,不需要展示输入
- this.llmAllProviders[provider].api_key = 'ollama';
- this.llmAllProviders[provider].has_key = true;
- } else if (this.llmAllProviders[provider].api_key) {
- // 保留API密钥值,但不在此处设置掩码(在切换提供商时会设置)
- this.llmAllProviders[provider].has_key = true;
+ if (result.success) {
+ // 更新本地服务列表顺序
+ const orderedServices = [];
+ serviceIds.forEach(id => {
+ const service = this.services.find(s => s.id === id);
+ if (service) {
+ orderedServices.push(service);
}
- }
- });
+ });
- // 处理视觉模型提供商的API密钥
- providerList.forEach(provider => {
- if (this.visionAllProviders[provider] && this.visionAllProviders[provider].api_key) {
- // 保留API密钥值,但不在此处设置掩码(在切换提供商时会设置)
- this.visionAllProviders[provider].has_key = true;
- }
- });
+ // 添加未在orderedServices中的服务
+ this.services.forEach(s => {
+ if (!orderedServices.find(os => os.id === s.id)) {
+ orderedServices.push(s);
+ }
+ });
- // 设置 LLM 配置
- if (llmConfig.provider) {
- this.llmProvider.value = llmConfig.provider;
- this.llmProvider.dispatchEvent(new Event('change'));
- }
- if (llmConfig.model) {
- this.llmModel.value = llmConfig.model;
- }
- if (llmConfig.api_key) {
- // 使用掩码显示密钥
- this._setMaskedValue(this.llmApiKey, llmConfig.api_key);
- }
- // 加载模型参数
- if (llmConfig.temperature !== undefined) {
- this.llmTemperature.value = llmConfig.temperature;
- }
- if (llmConfig.max_tokens !== undefined) {
- this.llmMaxTokens.value = llmConfig.max_tokens;
- }
- if (llmConfig.top_p !== undefined) {
- this.llmTopP.value = llmConfig.top_p;
- }
- if (llmConfig.base_url && llmConfig.provider === 'custom') {
- this.llmBaseUrl.value = llmConfig.base_url;
- }
+ this.services = orderedServices;
- // 设置视觉模型配置
- if (visionConfig.provider) {
- this.visionProvider.value = visionConfig.provider;
- this.visionProvider.dispatchEvent(new Event('change'));
+ logger.debug('服务商顺序已更新', { order: serviceIds });
+ } else {
+ throw new Error(result.error || '更新顺序失败');
}
- if (visionConfig.model) {
- this.visionModel.value = visionConfig.model;
- }
- if (visionConfig.api_key) {
- // 使用掩码显示密钥
- this._setMaskedValue(this.visionApiKey, visionConfig.api_key);
- }
- // 加载视觉模型参数
- if (visionConfig.temperature !== undefined) {
- this.visionTemperature.value = visionConfig.temperature;
- }
- if (visionConfig.max_tokens !== undefined) {
- this.visionMaxTokens.value = visionConfig.max_tokens;
- }
- if (visionConfig.top_p !== undefined) {
- this.visionTopP.value = visionConfig.top_p;
- }
- if (visionConfig.base_url && visionConfig.provider === 'custom') {
- this.visionBaseUrl.value = visionConfig.base_url;
- }
-
- // 将表单控件暴露给保存回调
- container.formControls = {
- baidu: { appId: this.baiduAppId, secret: this.baiduSecret },
- llm: {
- provider: this.llmProvider,
- temperature: this.llmTemperature,
- top_p: this.llmTopP,
- max_tokens: this.llmMaxTokens
- },
- vision: {
- provider: this.visionProvider,
- temperature: this.visionTemperature,
- top_p: this.visionTopP,
- max_tokens: this.visionMaxTokens
- }
- };
} catch (error) {
- logger.error("加载API配置失败:", error);
+ logger.error('更新服务商顺序失败', error);
app.extensionManager.toast.add({
severity: "error",
- summary: "加载失败",
- detail: error.message || "加载API配置过程中发生错误",
+ summary: "更新顺序失败",
+ detail: error.message,
+ life: 3000
+ });
+ }
+ }
+
+
+ /**
+ * 创建单个标签按钮
+ */
+ _createTabButton(tabId, title, subtitle) {
+ const button = document.createElement('button');
+ button.className = 'tab-button';
+ button.dataset.tab = tabId;
+
+ // 标签标题
+ const titleEl = document.createElement('div');
+ titleEl.className = 'tab-title';
+ titleEl.textContent = title;
+ button.appendChild(titleEl);
+
+ // 标签小字(介绍)
+ if (subtitle) {
+ const subtitleEl = document.createElement('div');
+ subtitleEl.className = 'tab-subtitle';
+ subtitleEl.textContent = subtitle;
+ button.appendChild(subtitleEl);
+ }
+
+ // 点击切换标签
+ button.addEventListener('click', () => {
+ this._switchTab(tabId, button.parentElement, button.parentElement.nextElementSibling);
+ });
+
+ // 为服务商标签添加右键菜单(百度翻译和预置服务商除外)
+ // 预置服务商不可编辑/删除,只有用户自定义的服务商才能使用右键菜单
+ const isPresetService = APIConfigManager.PRESET_SERVICE_IDS.includes(tabId);
+ if (tabId !== 'baidu' && !isPresetService) {
+ this._attachServiceContextMenu(button, tabId, title);
+ }
+
+ return button;
+ }
+
+ /**
+ * 切换标签页
+ */
+ _switchTab(tabId, header, contentContainer) {
+ // 更新标签按钮状态
+ header.querySelectorAll('.tab-button').forEach(btn => {
+ if (btn.dataset.tab === tabId) {
+ btn.classList.add('active');
+ } else {
+ btn.classList.remove('active');
+ }
+ });
+
+ // 显示对应内容
+ contentContainer.querySelectorAll('.tab-pane').forEach(pane => {
+ pane.style.display = pane.dataset.tab === tabId ? 'block' : 'none';
+ });
+ }
+
+ /**
+ * 为服务标签附加右键菜单
+ */
+ _attachServiceContextMenu(button, serviceId, serviceName) {
+ createContextMenu({
+ target: button,
+ items: [
+ {
+ label: '修改服务商名称',
+ icon: 'pi-pencil',
+ onClick: () => {
+ this._editServiceName(button, serviceId, serviceName);
+ }
+ },
+ {
+ separator: true
+ },
+ {
+ label: '删除服务',
+ icon: 'pi-trash',
+ danger: true, // 标记为危险操作,图标显示红色
+ onClick: () => {
+ this._deleteService(serviceId, serviceName);
+ }
+ }
+ ]
+ });
+ }
+
+ /**
+ * 修改服务商名称
+ */
+ _editServiceName(triggerButton, serviceId, currentName) {
+ const service = this.services.find(s => s.id === serviceId);
+ if (!service) return;
+
+ createConfirmPopup({
+ target: triggerButton,
+ message: '修改服务商信息',
+ icon: 'pi-pencil',
+ position: 'bottom',
+ confirmLabel: '保存',
+ cancelLabel: '取消',
+ renderFormContent: (formContainer) => {
+ // 服务商名称输入框
+ const nameInput = createInputGroup('服务商名称', '请输入服务商名称');
+ nameInput.input.value = service.name || currentName;
+ nameInput.input.dataset.fieldName = 'serviceName';
+ formContainer.appendChild(nameInput.group);
+
+ // 服务商介绍输入框
+ const descInput = createInputGroup('服务商介绍', '请输入服务商介绍(可选)');
+ descInput.input.value = service.description || '';
+ descInput.input.dataset.fieldName = 'serviceDescription';
+ formContainer.appendChild(descInput.group);
+ },
+ onConfirm: async (formContainer) => {
+ try {
+ const nameInput = formContainer.querySelector('[data-field-name="serviceName"]');
+ const descInput = formContainer.querySelector('[data-field-name="serviceDescription"]');
+
+ const newName = nameInput.value.trim();
+ const newDescription = descInput.value.trim();
+
+ if (!newName) {
+ app.extensionManager.toast.add({
+ severity: "warn",
+ summary: "请输入服务商名称",
+ life: 2000
+ });
+ throw new Error('服务商名称不能为空');
+ }
+
+ // 更新服务商信息
+ await this._updateService(serviceId, {
+ name: newName,
+ description: newDescription
+ });
+
+ // 更新按钮显示
+ const titleEl = triggerButton.querySelector('.tab-title');
+ const subtitleEl = triggerButton.querySelector('.tab-subtitle');
+
+ if (titleEl) {
+ titleEl.textContent = newName;
+ }
+
+ if (subtitleEl) {
+ subtitleEl.textContent = newDescription;
+ } else if (newDescription) {
+ // 如果之前没有副标题,现在添加一个
+ const newSubtitleEl = document.createElement('div');
+ newSubtitleEl.className = 'tab-subtitle';
+ newSubtitleEl.textContent = newDescription;
+ triggerButton.appendChild(newSubtitleEl);
+ }
+
+ app.extensionManager.toast.add({
+ severity: "success",
+ summary: "服务商信息已更新",
+ detail: `${newName} 更新成功`,
+ life: 2000
+ });
+ } catch (error) {
+ logger.error('更新服务商信息失败', error);
+ app.extensionManager.toast.add({
+ severity: "error",
+ summary: "更新失败",
+ detail: error.message,
+ life: 3000
+ });
+ throw error;
+ }
+ }
+ });
+ }
+
+
+ /**
+ * 创建服务商内容标签页
+ */
+ _createServiceContentTab(service) {
+ const pane = document.createElement('div');
+ pane.className = 'tab-pane';
+ pane.dataset.tab = service.id;
+ pane.style.display = 'none';
+ pane.style.padding = '16px';
+
+ // 服务商配置卡片(复用现有的卡片创建逻辑)
+ const card = this._createServiceCard(service);
+ pane.appendChild(card);
+
+ return pane;
+ }
+
+ /**
+ * 新增服务商
+ */
+ async _addNewService(headerElement, contentElement) {
+ // 获取触发按钮作为定位参考
+ const triggerButton = headerElement.querySelector('.service-tab-add');
+
+ // 显示确认气泡框
+ createConfirmPopup({
+ target: triggerButton,
+ message: '创建新的服务商',
+ icon: 'pi-plus-circle',
+ position: 'left',
+ confirmLabel: '创建',
+ cancelLabel: '取消',
+ renderFormContent: (formContainer) => {
+ // 服务商名称输入框
+ const nameInput = createInputGroup('服务商名称', '请输入服务商名称');
+ nameInput.input.value = '新服务商';
+ nameInput.input.dataset.fieldName = 'serviceName';
+ formContainer.appendChild(nameInput.group);
+
+ // 服务商介绍输入框
+ const descInput = createInputGroup('服务商介绍', '请输入服务商介绍(可选)');
+ descInput.input.dataset.fieldName = 'serviceDescription';
+ formContainer.appendChild(descInput.group);
+ },
+ onConfirm: async (formContainer) => {
+ try {
+ // 获取表单数据
+ const nameInput = formContainer.querySelector('[data-field-name="serviceName"]');
+ const descInput = formContainer.querySelector('[data-field-name="serviceDescription"]');
+
+ const serviceName = nameInput.value.trim();
+ const serviceDescription = descInput.value.trim();
+
+ if (!serviceName) {
+ app.extensionManager.toast.add({
+ severity: "warn",
+ summary: "请输入服务商名称",
+ life: 2000
+ });
+ throw new Error('服务商名称不能为空');
+ }
+
+ // 创建服务商
+ const res = await fetch(APIService.getApiUrl('/services'), {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ type: 'openai_compatible',
+ name: serviceName,
+ description: serviceDescription,
+ base_url: 'https://api.example.com/v1',
+ api_key: ''
+ })
+ });
+
+ const result = await res.json();
+
+ if (result.success) {
+ app.extensionManager.toast.add({
+ severity: "success",
+ summary: "新服务商已创建",
+ detail: `${serviceName} 创建成功`,
+ life: 3000
+ });
+
+ // 重新加载配置
+ await this._loadAllConfigs();
+
+ // 获取新创建的服务
+ const newService = this.services.find(s => s.id === result.service_id);
+ if (newService) {
+ // 创建新标签按钮(插入到"+"按钮前)
+ const addButton = headerElement.querySelector('.service-tab-add');
+ const newTabButton = this._createTabButton(
+ newService.id,
+ newService.name || '未命名服务',
+ newService.description || ''
+ );
+ headerElement.insertBefore(newTabButton, addButton);
+
+ // 创建新内容标签页
+ const newContentPane = this._createServiceContentTab(newService);
+ contentElement.appendChild(newContentPane);
+
+ // 切换到新标签
+ this._switchTab(newService.id, headerElement, contentElement);
+ }
+ } else {
+ throw new Error(result.error || '创建失败');
+ }
+ } catch (error) {
+ logger.error('创建服务商失败', error);
+ app.extensionManager.toast.add({
+ severity: "error",
+ summary: "创建失败",
+ detail: error.message,
+ life: 3000
+ });
+ throw error;
+ }
+ }
+ });
+ }
+
+ /**
+ * 创建百度翻译标签页
+ */
+ _createBaiduTab() {
+ const pane = document.createElement('div');
+ pane.className = 'tab-pane';
+ pane.dataset.tab = 'baidu';
+
+ const section = createFormGroup('百度翻译配置', [
+ { text: '开通百度翻译服务', url: 'https://fanyi-api.baidu.com/' }
+ ]);
+ section.classList.add('baidu-translate-section');
+
+ // 为链接添加图标,与其他服务保持统一
+ const linkElement = section.querySelector('.settings-service-link');
+ if (linkElement) {
+ const icon = document.createElement('i');
+ icon.className = 'pi pi-star';
+ icon.style.marginRight = '4px';
+ linkElement.insertBefore(icon, linkElement.firstChild);
+ }
+
+ const appIdInput = createInputGroup('AppID', '请输入百度翻译 AppID');
+ appIdInput.input.value = this.baiduConfig.app_id || '';
+ appIdInput.input.addEventListener('input', (e) => {
+ this.baiduConfig.app_id = e.target.value;
+ });
+ // 添加失焦保存
+ appIdInput.input.addEventListener('blur', async () => {
+ await this._saveBaiduConfig();
+ });
+
+ const secretInput = createInputGroup('Secret Key', '请输入百度翻译密钥');
+ secretInput.input.type = 'password';
+ secretInput.input.value = this.baiduConfig.secret_key || '';
+ secretInput.input.addEventListener('input', (e) => {
+ this.baiduConfig.secret_key = e.target.value;
+ });
+ // 添加失焦保存
+ secretInput.input.addEventListener('blur', async () => {
+ await this._saveBaiduConfig();
+ });
+
+ section.appendChild(appIdInput.group);
+ section.appendChild(secretInput.group);
+ pane.appendChild(section);
+
+ return pane;
+ }
+
+ /**
+ * 创建通用服务商标签页(二级标签页结构)
+ */
+ _createServicesTab() {
+ const pane = document.createElement('div');
+ pane.className = 'tab-pane services-tab-pane';
+ pane.dataset.tab = 'services';
+ // 样式已移至CSS
+
+ // 二级标签页导航
+ const subTabNav = document.createElement('div');
+ subTabNav.className = 'service-sub-tabs';
+ // 样式已移至CSS
+
+ // 二级标签页内容容器
+ const subTabContent = document.createElement('div');
+ subTabContent.className = 'service-sub-content';
+
+ // 获取通用服务商
+ const genericServices = this.services.filter(s => s.type === 'openai_compatible');
+
+ // 创建服务商标签
+ genericServices.forEach((service, index) => {
+ // 创建标签按钮
+ const tabButton = this._createServiceTabButton(service);
+ subTabNav.appendChild(tabButton);
+
+ // 创建标签内容
+ const tabContentPane = this._createServiceTabContent(service);
+ subTabContent.appendChild(tabContentPane);
+
+ // 默认选中第一个
+ if (index === 0) {
+ tabButton.classList.add('active');
+ tabContentPane.style.display = 'block';
+ }
+ });
+
+ // 创建"+"新增标签按钮
+ const addTabButton = document.createElement('button');
+ addTabButton.className = 'service-tab-add';
+ addTabButton.textContent = '+';
+ addTabButton.addEventListener('click', () => this._addNewServiceTab(subTabNav, subTabContent));
+ subTabNav.appendChild(addTabButton);
+
+ // 如果没有任何服务商,显示空状态
+ if (genericServices.length === 0) {
+ const emptyHint = document.createElement('div');
+ emptyHint.className = 'empty-state-hint';
+ emptyHint.innerHTML = `
+
📦
+ 暂无服务商
+ 点击右上角"+"按钮新增第一个服务商
+ `;
+ subTabContent.appendChild(emptyHint);
+ }
+
+ pane.appendChild(subTabNav);
+ pane.appendChild(subTabContent);
+ return pane;
+ }
+
+ /**
+ * 创建服务商标签按钮
+ */
+ _createServiceTabButton(service) {
+ const button = document.createElement('button');
+ button.className = 'service-tab-button';
+ button.dataset.serviceId = service.id;
+
+ // 标签标题
+ const title = document.createElement('div');
+ title.className = 'service-tab-title';
+ title.textContent = service.name || '未命名服务';
+
+ // 标签小字(介绍)
+ const subtitle = document.createElement('div');
+ subtitle.className = 'service-tab-subtitle';
+ subtitle.textContent = service.description || '';
+
+ button.appendChild(title);
+ if (service.description) {
+ button.appendChild(subtitle);
+ }
+
+ // 点击切换
+ button.addEventListener('click', () => {
+ this._switchServiceTab(service.id);
+ });
+
+ return button;
+ }
+
+ /**
+ * 切换服务商标签
+ */
+ _switchServiceTab(serviceId) {
+ const container = document.querySelector('.services-tab-pane');
+ if (!container) return;
+
+ // 更新标签按钮状态
+ const buttons = container.querySelectorAll('.service-tab-button');
+ buttons.forEach(btn => {
+ if (btn.dataset.serviceId === serviceId) {
+ btn.classList.add('active');
+ btn.style.background = 'var(--p-primary-500)';
+ btn.style.color = 'white';
+ btn.querySelector('.service-tab-title').style.color = 'white';
+ const subtitle = btn.querySelector('.service-tab-subtitle');
+ if (subtitle) {
+ subtitle.style.color = 'rgba(255, 255, 255, 0.8)';
+ }
+ } else {
+ btn.classList.remove('active');
+ btn.style.background = 'transparent';
+ btn.style.color = 'var(--p-text-color)';
+ btn.querySelector('.service-tab-title').style.color = 'var(--p-text-color)';
+ const subtitle = btn.querySelector('.service-tab-subtitle');
+ if (subtitle) {
+ subtitle.style.color = 'var(--p-text-muted-color)';
+ }
+ }
+ });
+
+ // 更新内容显示
+ const panes = container.querySelectorAll('.service-content-pane');
+ panes.forEach(pane => {
+ pane.style.display = pane.dataset.serviceId === serviceId ? 'block' : 'none';
+ });
+ }
+
+ /**
+ * 创建服务商标签内容
+ */
+ _createServiceTabContent(service) {
+ const contentPane = document.createElement('div');
+ contentPane.className = 'service-content-pane';
+ contentPane.dataset.serviceId = service.id;
+ contentPane.style.cssText = `
+ display: none;
+ `;
+
+ // 这里先创建一个简单的占位内容,后续会完善
+ const card = this._createServiceCard(service);
+ contentPane.appendChild(card);
+
+ return contentPane;
+ }
+
+ /**
+ * 添加新服务商标签
+ */
+ async _addNewServiceTab(navContainer, contentContainer) {
+ // 调用后端API创建新服务商
+ try {
+ const res = await fetch(APIService.getApiUrl('/services'), {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ type: 'openai_compatible',
+ name: '新服务商',
+ description: '',
+ base_url: 'https://api.example.com/v1',
+ api_key: ''
+ })
+ });
+
+ const result = await res.json();
+
+ if (result.success) {
+ app.extensionManager.toast.add({
+ severity: "success",
+ summary: "新服务商已创建",
+ detail: "请填写配置信息",
+ life: 3000
+ });
+
+ // 重新加载配置
+ await this._loadAllConfigs();
+
+ // 获取新创建的服务
+ const newService = this.services.find(s => s.id === result.service_id);
+ if (newService) {
+ // 创建新标签按钮(插入到"+"按钮前)
+ const newTabButton = this._createServiceTabButton(newService);
+ const addButton = navContainer.querySelector('.service-tab-add');
+ navContainer.insertBefore(newTabButton, addButton);
+
+ // 创建新内容
+ const newContentPane = this._createServiceTabContent(newService);
+ contentContainer.appendChild(newContentPane);
+
+ // 移除空状态提示(如果有)
+ const emptyHint = contentContainer.querySelector('div[style*="暂无服务商"]');
+ if (emptyHint) {
+ emptyHint.remove();
+ }
+
+ // 切换到新标签
+ this._switchServiceTab(newService.id);
+ }
+ } else {
+ throw new Error(result.error || '创建失败');
+ }
+ } catch (error) {
+ logger.error('创建服务商失败', error);
+ app.extensionManager.toast.add({
+ severity: "error",
+ summary: "创建失败",
+ detail: error.message,
life: 3000
});
}
}
/**
- * 保存LLM提供商配置
- * @param {string} provider 提供商名称
+ * 创建Ollama标签页
*/
- _saveLLMProviderConfig(provider) {
- if (!this.llmAllProviders) this.llmAllProviders = {};
+ _createOllamaTab() {
+ const pane = document.createElement('div');
+ pane.className = 'tab-pane';
+ pane.dataset.tab = 'ollama';
+ // 样式已移至CSS
- // 获取当前配置
- let baseUrl = '';
- if (provider === 'custom') {
- baseUrl = this.llmBaseUrl ? this.llmBaseUrl.value.trim() : '';
- // 确保base_url不以/结尾
- if (baseUrl && baseUrl.endsWith('/')) {
- baseUrl = baseUrl.slice(0, -1);
- }
- // 确保base_url不以/chat/completions结尾
- if (baseUrl && baseUrl.endsWith('/chat/completions')) {
- baseUrl = baseUrl.slice(0, -16); // 移除'/chat/completions'
- }
+ const ollamaService = this.services.find(s => s.type === 'ollama');
+
+ if (ollamaService) {
+ const card = this._createServiceCard(ollamaService);
+ pane.appendChild(card);
} else {
- baseUrl = provider === 'zhipu' ? 'https://open.bigmodel.cn/api/paas/v4' :
- (provider === 'siliconflow' ? 'https://api.siliconflow.cn/v1' :
- (provider === '302ai' ? 'https://api.302.ai/v1' :
- (provider === 'ollama' ? 'http://localhost:11434/v1' : '')));
+ const hint = document.createElement('div');
+ hint.className = 'empty-state-hint-small';
+ hint.textContent = 'Ollama服务未配置';
+ pane.appendChild(hint);
}
- // 检查API密钥是否为掩码
- let apiKey = this.llmApiKey.value.trim();
- const isMasked = apiKey === '••••••••••••••••••••••••••••••••••••••••••••••••';
-
- // 创建提供商配置对象
- const providerConfig = {
- model: this.llmModel.value.trim(),
- base_url: baseUrl,
- temperature: parseFloat(this.llmTemperature.value) || 0.7,
- max_tokens: parseInt(this.llmMaxTokens.value) || 2000,
- top_p: parseFloat(this.llmTopP.value) || 0.9
- };
-
- // 如果是 Ollama,保存自动释放显存选项
- if (provider === 'ollama') {
- providerConfig.auto_unload = this.llmOllamaAutoUnload ? this.llmOllamaAutoUnload.checked : true;
- }
-
- // 保留现有配置中的has_key标记
- if (this.llmAllProviders[provider] && this.llmAllProviders[provider].has_key) {
- providerConfig.has_key = true;
- }
-
- // 只有当API密钥不是掩码时才更新
- if (!isMasked || this.llmApiKey.dataset.hasKey !== 'true') {
- providerConfig.api_key = apiKey;
- // 如果输入了新的API密钥,设置has_key标记
- if (apiKey) {
- providerConfig.has_key = true;
- } else {
- // 如果清空了API密钥,移除has_key标记
- providerConfig.has_key = false;
- }
- }
-
- // 更新配置
- this.llmAllProviders[provider] = providerConfig;
-
- logger.debug(`已保存 ${provider} 提供商的LLM配置`);
+ return pane;
}
/**
- * 保存视觉模型提供商配置
- * @param {string} provider 提供商名称
+ * 创建服务商卡片
*/
- _saveVisionProviderConfig(provider) {
- if (!this.visionAllProviders) this.visionAllProviders = {};
+ _createServiceCard(service) {
+ const card = document.createElement('div');
+ card.className = 'service-card';
+ card.dataset.serviceId = service.id; // 添加serviceId到dataset
- // 获取当前配置
- let baseUrl = '';
- if (provider === 'custom') {
- baseUrl = this.visionBaseUrl ? this.visionBaseUrl.value.trim() : '';
- // 确保base_url不以/结尾
- if (baseUrl && baseUrl.endsWith('/')) {
- baseUrl = baseUrl.slice(0, -1);
- }
- // 确保base_url不以/chat/completions结尾
- if (baseUrl && baseUrl.endsWith('/chat/completions')) {
- baseUrl = baseUrl.slice(0, -16); // 移除'/chat/completions'
- }
+ // 服务商标题 - 根据服务名称检测是否需要添加外部链接
+ const titleText = service.name || service.id;
+ const descText = service.description ? ` 信息配置` : '';
+ const fullTitle = `1️⃣ ${titleText}${descText}`;
+
+ // 检测服务名称,添加对应的申请链接
+ const links = [];
+ const serviceName = (service.name || '').toLowerCase();
+ const serviceId = (service.id || '').toLowerCase();
+ const searchText = `${serviceName} ${serviceId}`.toLowerCase();
+
+ // 智谱服务检测
+ if (searchText.includes('智谱') || searchText.includes('zhipu')) {
+ links.push({
+ text: '开通智谱API服务',
+ url: 'https://www.bigmodel.cn/invite?icode=Wz1tQAT40T9M8vwp%2F1db7nHEaazDlIZGj9HxftzTbt4%3D',
+ icon: 'pi-star'
+ });
+ }
+
+ // 硅基流动服务检测
+ if (searchText.includes('硅基') || searchText.includes('siliconflow') || searchText.includes('silicon')) {
+ links.push({
+ text: '开通硅基流动API服务',
+ url: 'https://cloud.siliconflow.cn/i/FCDL2zBQ',
+ icon: 'pi-star'
+ });
+ }
+
+ // xflow服务检测
+ if (searchText.includes('xflow')) {
+ links.push({
+ text: '开通xflow API服务',
+ url: 'https://api.xflow.cc/register?aff=Z063',
+ icon: 'pi-star'
+ });
+ }
+
+ // 使用createFormGroup创建带链接的标题,或者普通标题
+ let titleSection;
+ if (links.length > 0) {
+ titleSection = createFormGroup(fullTitle, links.map(link => ({
+ text: link.text,
+ url: link.url
+ })));
+ // 为链接添加图标
+ const linkElements = titleSection.querySelectorAll('.settings-service-link');
+ linkElements.forEach((linkElem, index) => {
+ if (links[index] && links[index].icon) {
+ const icon = document.createElement('i');
+ icon.className = `pi ${links[index].icon}`;
+ icon.style.marginRight = '4px';
+ linkElem.insertBefore(icon, linkElem.firstChild);
+ }
+ });
} else {
- baseUrl = provider === 'zhipu' ? 'https://open.bigmodel.cn/api/paas/v4' :
- (provider === 'siliconflow' ? 'https://api.siliconflow.cn/v1' :
- (provider === '302ai' ? 'https://api.302.ai/v1' :
- (provider === 'ollama' ? 'http://localhost:11434/v1' : '')));
+ // 没有链接时,创建普通标题
+ titleSection = document.createElement('div');
+ titleSection.className = 'settings-form-section';
+ const titleElement = document.createElement('h3');
+ titleElement.className = 'settings-form-section-title';
+ titleElement.textContent = fullTitle;
+ titleSection.appendChild(titleElement);
}
- // 检查API密钥是否为掩码
- let apiKey = this.visionApiKey.value.trim();
- const isMasked = apiKey === '••••••••••••••••••••••••••••••••••••••••••••••••';
+ card.appendChild(titleSection);
- // 创建提供商配置对象
- const providerConfig = {
- model: this.visionModel.value.trim(),
- base_url: baseUrl,
- temperature: parseFloat(this.visionTemperature.value) || 0.7,
- max_tokens: parseInt(this.visionMaxTokens.value) || 2000,
- top_p: parseFloat(this.visionTopP.value) || 0.9
- };
-
- // 如果是 Ollama,保存自动释放显存选项
- if (provider === 'ollama') {
- providerConfig.auto_unload = this.visionOllamaAutoUnload ? this.visionOllamaAutoUnload.checked : true;
- }
+ // 基本信息
+ const baseUrlInput = createInputGroup('Base URL', 'https://api.example.com/v1');
+ baseUrlInput.input.value = service.base_url || '';
+ baseUrlInput.input.addEventListener('change', async (e) => {
+ await this._updateService(service.id, { base_url: e.target.value });
+ });
- // 保留现有配置中的has_key标记
- if (this.visionAllProviders[provider] && this.visionAllProviders[provider].has_key) {
- providerConfig.has_key = true;
- }
+ // API Key输入框(简化版,直接使用明文)
+ const apiKeyInput = createInputGroup('API Key', '请输入API Key');
+ apiKeyInput.input.type = 'password';
+ apiKeyInput.input.value = service.api_key || '';
- // 只有当API密钥不是掩码时才更新
- if (!isMasked || this.visionApiKey.dataset.hasKey !== 'true') {
- providerConfig.api_key = apiKey;
- // 如果输入了新的API密钥,设置has_key标记
- if (apiKey) {
- providerConfig.has_key = true;
- } else {
- // 如果清空了API密钥,移除has_key标记
- providerConfig.has_key = false;
+ // 失焦时保存
+ apiKeyInput.input.addEventListener('blur', async (e) => {
+ const newApiKey = e.target.value.trim();
+ if (newApiKey !== service.api_key) {
+ await this._updateService(service.id, { api_key: newApiKey });
+ service.api_key = newApiKey;
}
- }
+ });
- // 更新配置
- this.visionAllProviders[provider] = providerConfig;
+ card.appendChild(baseUrlInput.group);
+ card.appendChild(apiKeyInput.group);
- logger.debug(`已保存 ${provider} 提供商的视觉模型配置`);
- }
+ // === 服务配置区域(简化版) ===
+ // 创建配置项容器
+ const settingsInlineContainer = document.createElement('div');
+ settingsInlineContainer.className = 'service-settings-inline';
- /**
- * 为输入框附加自动完成功能
- * @param {HTMLInputElement} input 输入框元素
- * @param {string} modelType 模型类型 ('llm' 或 'vision')
- * @param {Function} getProvider 获取当前提供商的函数
- */
- _attachAutocomplete(input, modelType, getProvider) {
- let autocompletePanel = null;
- let modelsList = [];
- let isOpen = false;
- let selectedIndex = -1;
- let showRecommended = false; // 当前显示模式:false=所有模型,true=推荐模型
-
- // 创建自动完成下拉面板
- const createAutocompletePanel = () => {
- const panel = document.createElement('div');
- panel.className = 'pa-dropdown-panel p-dropdown-panel p-component settings-modal-dropdown-panel p-hidden';
- panel.style.position = 'fixed';
- panel.style.zIndex = '10001';
-
- const itemsWrapper = document.createElement('div');
- itemsWrapper.className = 'p-dropdown-items-wrapper';
-
- const itemsList = document.createElement('ul');
- itemsList.className = 'p-dropdown-items';
- itemsList.setAttribute('role', 'listbox');
-
- itemsWrapper.appendChild(itemsList);
- panel.appendChild(itemsWrapper);
-
- return panel;
- };
-
- // 创建切换按钮(用于智谱、硅基流动、302)
- const createToggleButton = () => {
- const toggleWrapper = document.createElement('div');
- toggleWrapper.className = 'model-list-toggle-wrapper';
- toggleWrapper.style.cssText = `
- padding: 8px 12px;
- border-bottom: 1px solid var(--p-divider-color);
- background: var(--p-panel-header-background);
- display: flex;
- align-items: center;
- gap: 8px;
- `;
-
- const label = document.createElement('span');
- label.textContent = '筛选:';
- label.style.cssText = `
- font-size: 12px;
- color: var(--p-text-color);
- `;
-
- // 创建按钮容器
- const buttonsContainer = document.createElement('div');
- buttonsContainer.style.cssText = `
- display: flex;
- gap: 4px;
- `;
-
- // 创建"所有模型"按钮
- const allModelsButton = document.createElement('button');
- allModelsButton.className = 'p-button p-button-sm p-button-text';
- allModelsButton.textContent = '所有模型';
- allModelsButton.style.cssText = `
- padding: 4px 8px;
- font-size: 12px;
- height: auto;
- min-width: auto;
- color: var(--p-primary-color);
- `;
-
- // 创建"推荐模型"按钮
- const recommendedButton = document.createElement('button');
- recommendedButton.className = 'p-button p-button-sm p-button-text';
- recommendedButton.textContent = '推荐模型';
- recommendedButton.style.cssText = `
- padding: 4px 8px;
- font-size: 12px;
- height: auto;
- min-width: auto;
- color: var(--p-text-muted-color);
- `;
-
- // 更新按钮状态
- const updateButtonStates = () => {
- if (showRecommended) {
- // 推荐模型激活
- allModelsButton.style.color = 'var(--p-text-muted-color)';
- recommendedButton.style.color = 'var(--p-primary-color)';
+ // 思维链控制开关
+ const thinkingContainer = document.createElement('div');
+ thinkingContainer.className = 'service-setting-item';
+
+ const thinkingLabel = document.createElement('span');
+ thinkingLabel.className = 'service-setting-label';
+ thinkingLabel.textContent = '关闭思维链';
+
+ const thinkingIcon = document.createElement('i');
+ thinkingIcon.className = 'pi pi-info-circle service-setting-info-icon';
+
+ // 添加 tooltip
+ createTooltip({
+ target: thinkingIcon,
+ content: '针对部分支持关闭思维链的模型进行关闭。⚠️:并不是所有模型都支持,关闭思维链的模型会在日志中的模型信息后面多出一个“✏️”符号。',
+ position: 'top'
+ });
+
+ const thinkingLabelWrapper = document.createElement('div');
+ thinkingLabelWrapper.className = 'service-setting-label-wrapper';
+ thinkingLabelWrapper.appendChild(thinkingLabel);
+ thinkingLabelWrapper.appendChild(thinkingIcon);
+
+ // 创建开关
+ const thinkingSwitchWrapper = document.createElement('label');
+ thinkingSwitchWrapper.className = 'switch-wrapper';
+
+ const thinkingInput = document.createElement('input');
+ thinkingInput.type = 'checkbox';
+ thinkingInput.checked = service.disable_thinking ?? true;
+
+ const thinkingSlider = document.createElement('span');
+ thinkingSlider.className = `switch-slider${thinkingInput.checked ? ' checked' : ''}`;
+
+ const thinkingButton = document.createElement('span');
+ thinkingButton.className = `switch-button${thinkingInput.checked ? ' checked' : ''}`;
+ thinkingSlider.appendChild(thinkingButton);
+
+ thinkingInput.addEventListener('change', async (e) => {
+ const isChecked = e.target.checked;
+ if (isChecked) {
+ thinkingSlider.classList.add('checked');
+ thinkingButton.classList.add('checked');
+ } else {
+ thinkingSlider.classList.remove('checked');
+ thinkingButton.classList.remove('checked');
+ }
+ await this._updateService(service.id, { disable_thinking: isChecked });
+ service.disable_thinking = isChecked;
+ });
+
+ thinkingSwitchWrapper.appendChild(thinkingInput);
+ thinkingSwitchWrapper.appendChild(thinkingSlider);
+
+ thinkingContainer.appendChild(thinkingLabelWrapper);
+ thinkingContainer.appendChild(thinkingSwitchWrapper);
+ settingsInlineContainer.appendChild(thinkingContainer);
+
+ // ---启用高级参数开关---
+ const advancedParamsContainer = document.createElement('div');
+ advancedParamsContainer.className = 'service-setting-item';
+
+ const advancedParamsLabel = document.createElement('span');
+ advancedParamsLabel.className = 'service-setting-label';
+ advancedParamsLabel.textContent = '启用高级参数';
+
+ const advancedParamsIcon = document.createElement('i');
+ advancedParamsIcon.className = 'pi pi-info-circle service-setting-info-icon';
+
+ // 添加 tooltip
+ createTooltip({
+ target: advancedParamsIcon,
+ content: '启用后将发送 temperature、top_p、max_tokens 参数以精细控制模型行为,限制最大tonken数来提升速度。如果关闭则可以提升兼容性。',
+ position: 'top'
+ });
+
+ const advancedParamsLabelWrapper = document.createElement('div');
+ advancedParamsLabelWrapper.className = 'service-setting-label-wrapper';
+ advancedParamsLabelWrapper.appendChild(advancedParamsLabel);
+ advancedParamsLabelWrapper.appendChild(advancedParamsIcon);
+
+ // 创建开关
+ const advancedParamsSwitchWrapper = document.createElement('label');
+ advancedParamsSwitchWrapper.className = 'switch-wrapper';
+
+ const advancedParamsInput = document.createElement('input');
+ advancedParamsInput.type = 'checkbox';
+ advancedParamsInput.checked = service.enable_advanced_params ?? false;
+
+ const advancedParamsSlider = document.createElement('span');
+ advancedParamsSlider.className = `switch-slider${advancedParamsInput.checked ? ' checked' : ''}`;
+
+ const advancedParamsButton = document.createElement('span');
+ advancedParamsButton.className = `switch-button${advancedParamsInput.checked ? ' checked' : ''}`;
+ advancedParamsSlider.appendChild(advancedParamsButton);
+
+ advancedParamsInput.addEventListener('change', async (e) => {
+ const isChecked = e.target.checked;
+ if (isChecked) {
+ advancedParamsSlider.classList.add('checked');
+ advancedParamsButton.classList.add('checked');
+ } else {
+ advancedParamsSlider.classList.remove('checked');
+ advancedParamsButton.classList.remove('checked');
+ }
+ await this._updateService(service.id, { enable_advanced_params: isChecked });
+ service.enable_advanced_params = isChecked;
+ });
+
+ advancedParamsSwitchWrapper.appendChild(advancedParamsInput);
+ advancedParamsSwitchWrapper.appendChild(advancedParamsSlider);
+
+ advancedParamsContainer.appendChild(advancedParamsLabelWrapper);
+ advancedParamsContainer.appendChild(advancedParamsSwitchWrapper);
+ settingsInlineContainer.appendChild(advancedParamsContainer);
+
+ // ---过滤思维链输出开关---
+ const filterThinkingContainer = document.createElement('div');
+ filterThinkingContainer.className = 'service-setting-item';
+
+ const filterThinkingLabel = document.createElement('span');
+ filterThinkingLabel.className = 'service-setting-label';
+ filterThinkingLabel.textContent = '过滤思维链输出';
+
+ const filterThinkingIcon = document.createElement('i');
+ filterThinkingIcon.className = 'pi pi-info-circle service-setting-info-icon';
+
+ // 添加 tooltip
+ createTooltip({
+ target: filterThinkingIcon,
+ content: '针对无法关闭思维链模型,移除思考过程内容。默认开启。',
+ position: 'top'
+ });
+
+ const filterThinkingLabelWrapper = document.createElement('div');
+ filterThinkingLabelWrapper.className = 'service-setting-label-wrapper';
+ filterThinkingLabelWrapper.appendChild(filterThinkingLabel);
+ filterThinkingLabelWrapper.appendChild(filterThinkingIcon);
+
+ // 创建开关
+ const filterThinkingSwitchWrapper = document.createElement('label');
+ filterThinkingSwitchWrapper.className = 'switch-wrapper';
+
+ const filterThinkingInput = document.createElement('input');
+ filterThinkingInput.type = 'checkbox';
+ filterThinkingInput.checked = service.filter_thinking_output ?? true;
+
+ const filterThinkingSlider = document.createElement('span');
+ filterThinkingSlider.className = `switch-slider${filterThinkingInput.checked ? ' checked' : ''}`;
+
+ const filterThinkingButton = document.createElement('span');
+ filterThinkingButton.className = `switch-button${filterThinkingInput.checked ? ' checked' : ''}`;
+ filterThinkingSlider.appendChild(filterThinkingButton);
+
+ filterThinkingInput.addEventListener('change', async (e) => {
+ const isChecked = e.target.checked;
+ if (isChecked) {
+ filterThinkingSlider.classList.add('checked');
+ filterThinkingButton.classList.add('checked');
+ } else {
+ filterThinkingSlider.classList.remove('checked');
+ filterThinkingButton.classList.remove('checked');
+ }
+ await this._updateService(service.id, { filter_thinking_output: isChecked });
+ service.filter_thinking_output = isChecked;
+ });
+
+ filterThinkingSwitchWrapper.appendChild(filterThinkingInput);
+ filterThinkingSwitchWrapper.appendChild(filterThinkingSlider);
+
+ filterThinkingContainer.appendChild(filterThinkingLabelWrapper);
+ filterThinkingContainer.appendChild(filterThinkingSwitchWrapper);
+ settingsInlineContainer.appendChild(filterThinkingContainer);
+
+ // Ollama专属:自动释放模型开关(仅前端UI)
+ if (service.type === 'ollama') {
+ const autoUnloadContainer = document.createElement('div');
+ autoUnloadContainer.className = 'service-setting-item';
+
+ const autoUnloadLabel = document.createElement('span');
+ autoUnloadLabel.className = 'service-setting-label';
+ autoUnloadLabel.textContent = '自动释放模型';
+
+ const autoUnloadIcon = document.createElement('i');
+ autoUnloadIcon.className = 'pi pi-info-circle service-setting-info-icon';
+
+ // 添加 tooltip
+ createTooltip({
+ target: autoUnloadIcon,
+ content: '请求完成后自动卸载模型以释放显存。⚠️该选项对针对前端小助手生效,节点有独立的选项。',
+ position: 'top'
+ });
+
+ const autoUnloadLabelWrapper = document.createElement('div');
+ autoUnloadLabelWrapper.className = 'service-setting-label-wrapper';
+ autoUnloadLabelWrapper.appendChild(autoUnloadLabel);
+ autoUnloadLabelWrapper.appendChild(autoUnloadIcon);
+
+ // 创建开关
+ const autoUnloadSwitchWrapper = document.createElement('label');
+ autoUnloadSwitchWrapper.className = 'switch-wrapper';
+
+ const autoUnloadInput = document.createElement('input');
+ autoUnloadInput.type = 'checkbox';
+ autoUnloadInput.checked = service.auto_unload !== false;
+
+ const autoUnloadSlider = document.createElement('span');
+ autoUnloadSlider.className = `switch-slider${autoUnloadInput.checked ? ' checked' : ''}`;
+
+ const autoUnloadButton = document.createElement('span');
+ autoUnloadButton.className = `switch-button${autoUnloadInput.checked ? ' checked' : ''}`;
+ autoUnloadSlider.appendChild(autoUnloadButton);
+
+ autoUnloadInput.addEventListener('change', async (e) => {
+ const isChecked = e.target.checked;
+ if (isChecked) {
+ autoUnloadSlider.classList.add('checked');
+ autoUnloadButton.classList.add('checked');
} else {
- // 所有模型激活
- allModelsButton.style.color = 'var(--p-primary-color)';
- recommendedButton.style.color = 'var(--p-text-muted-color)';
+ autoUnloadSlider.classList.remove('checked');
+ autoUnloadButton.classList.remove('checked');
}
- };
-
- // "所有模型"按钮点击事件
- allModelsButton.addEventListener('click', (e) => {
- e.stopPropagation();
- if (showRecommended) {
- showRecommended = false;
- updateButtonStates();
- loadModels();
+ await this._updateService(service.id, { auto_unload: isChecked });
+ service.auto_unload = isChecked;
+ });
+
+ autoUnloadSwitchWrapper.appendChild(autoUnloadInput);
+ autoUnloadSwitchWrapper.appendChild(autoUnloadSlider);
+
+ autoUnloadContainer.appendChild(autoUnloadLabelWrapper);
+ autoUnloadContainer.appendChild(autoUnloadSwitchWrapper);
+ settingsInlineContainer.appendChild(autoUnloadContainer);
+ }
+
+ card.appendChild(settingsInlineContainer);
+
+ // LLM模型部分
+ const llmSection = this._createModelSection(service, 'llm');
+ card.appendChild(llmSection);
+
+ // VLM模型部分
+ const vlmSection = this._createModelSection(service, 'vlm');
+ card.appendChild(vlmSection);
+
+ return card;
+ }
+
+
+ /**
+ * 创建模型配置部分
+ */
+ _createModelSection(service, modelType) {
+ const section = document.createElement('div');
+ section.className = 'settings-form-section';
+ section.style.marginTop = '16px';
+
+ // 标题行(包含模型类型和+按钮)
+ const titleRow = document.createElement('div');
+ titleRow.style.cssText = `
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 12px;
+ `;
+
+ const title = document.createElement('h5');
+ title.className = 'settings-form-section-title';
+ title.textContent = modelType === 'llm' ? '2️⃣ 添加翻译、提示词优化的大语言模型 (LLM)' : '3️⃣ 添加图像、视频反推的视觉模型 (VLM)';
+ title.style.margin = '0';
+
+ // 添加模型按钮
+ const addButton = document.createElement('button');
+ addButton.className = 'p-button p-component p-button-sm';
+ addButton.innerHTML = '添加模型';
+ addButton.addEventListener('click', () => this._showAddModelDialog(service, modelType, modelsContainer));
+
+ titleRow.appendChild(title);
+ titleRow.appendChild(addButton);
+ section.appendChild(titleRow);
+
+ // 模型标签容器(可拖动排序)
+ const modelsContainer = document.createElement('div');
+ modelsContainer.className = 'models-container';
+ modelsContainer.dataset.serviceId = service.id;
+ modelsContainer.dataset.modelType = modelType;
+
+ const models = modelType === 'llm' ? service.llm_models : service.vlm_models;
+
+ if (models && models.length > 0) {
+ models.forEach((model) => {
+ const modelTag = this._createModelTag(model, service, modelType);
+ modelsContainer.appendChild(modelTag);
+ });
+
+ // 初始化Sortable拖动排序并保存实例
+ modelsContainer.sortableInstance = new Sortable(modelsContainer, {
+ animation: 150,
+ ghostClass: 'sortable-ghost',
+ handle: '.model-tag', // 整个标签都可以拖动
+ onEnd: async (evt) => {
+ // 拖动结束后更新模型顺序
+ await this._updateModelOrder(service.id, modelType, modelsContainer);
}
});
-
- // "推荐模型"按钮点击事件
- recommendedButton.addEventListener('click', (e) => {
- e.stopPropagation();
- if (!showRecommended) {
- showRecommended = true;
- updateButtonStates();
- loadModels();
+ } else {
+ const emptyHint = document.createElement('div');
+ emptyHint.className = 'empty-hint';
+ emptyHint.textContent = '暂无配置模型,点击"+ 添加模型"开始配置';
+ emptyHint.style.cssText = `
+ font-size: 12px;
+ color: var(--p-text-muted-color);
+ padding: 8px;
+ `;
+ modelsContainer.appendChild(emptyHint);
+ }
+
+ section.appendChild(modelsContainer);
+
+ // 移除固定的高级设置区域 - 现在点击模型标签时弹出气泡框编辑
+
+ return section;
+ }
+
+ /**
+ * 创建模型标签
+ */
+ _createModelTag(model, service, modelType) {
+ const tag = document.createElement('div');
+ tag.className = `model-tag${model.is_default ? ' default' : ''}`;
+ tag.dataset.modelName = model.name;
+ tag.dataset.selected = 'false';
+
+ // 模型图标
+ const iconSpan = document.createElement('i');
+ iconSpan.className = 'pi pi-sparkles model-tag-icon';
+ tag.appendChild(iconSpan);
+
+ // 模型名称
+ const nameSpan = document.createElement('span');
+ nameSpan.className = 'model-tag-name';
+ nameSpan.textContent = model.name;
+ tag.appendChild(nameSpan);
+
+ // 默认标记
+ if (model.is_default) {
+ const defaultBadge = document.createElement('span');
+ defaultBadge.className = 'model-tag-badge';
+ defaultBadge.textContent = '默认';
+ tag.appendChild(defaultBadge);
+ }
+
+ // 删除按钮
+ const deleteBtn = document.createElement('button');
+ deleteBtn.innerHTML = '×';
+ deleteBtn.className = 'model-delete-btn';
+ deleteBtn.addEventListener('click', (e) => {
+ e.stopPropagation();
+ this._deleteModel(service, modelType, model.name, tag);
+ });
+ tag.appendChild(deleteBtn);
+
+ // ---点击选中状态---
+ tag.addEventListener('click', (e) => {
+ // 如果点击的是删除按钮,不触发选中
+ if (e.target.closest('.model-delete-btn')) {
+ return;
+ }
+ // 移除同容器内其他标签的选中状态
+ const container = tag.parentElement;
+ if (container) {
+ container.querySelectorAll('.model-tag.selected').forEach(t => {
+ t.classList.remove('selected');
+ });
+ }
+ // 添加当前标签的选中状态
+ tag.classList.add('selected');
+ });
+
+ // ---右键菜单---
+ // 使用函数形式动态获取菜单项,确保每次显示菜单时都能获取最新的模型状态
+ const getMenuItems = () => {
+ // 从本地数据中获取最新的模型状态
+ const models = modelType === 'llm' ? service.llm_models : service.vlm_models;
+ const currentModel = models.find(m => m.name === model.name);
+ const isDefault = currentModel ? currentModel.is_default : false;
+
+ return [
+ {
+ label: '设为默认模型',
+ icon: 'pi-star',
+ disabled: isDefault, // 动态获取当前是否为默认模型
+ onClick: () => {
+ this._setDefaultModel(service, modelType, model.name, tag);
+ }
+ },
+ { separator: true }, // 分隔线
+ {
+ label: '修改模型参数设置',
+ icon: 'pi-cog',
+ onClick: () => {
+ this._selectModelForEdit(service, modelType, model.name, tag);
+ }
}
- });
-
- buttonsContainer.appendChild(allModelsButton);
- buttonsContainer.appendChild(recommendedButton);
-
- toggleWrapper.appendChild(label);
- toggleWrapper.appendChild(buttonsContainer);
-
- return toggleWrapper;
+ ];
};
-
- // 更新面板位置
- const updatePanelPosition = () => {
- if (!isOpen || !autocompletePanel) return;
-
- const rect = input.getBoundingClientRect();
- autocompletePanel.style.top = rect.bottom + 'px';
- autocompletePanel.style.left = rect.left + 'px';
- autocompletePanel.style.width = rect.width + 'px';
- };
-
- // 关闭面板
- const closePanel = () => {
- if (!isOpen || !autocompletePanel) return;
- isOpen = false;
- selectedIndex = -1;
-
- autocompletePanel.classList.add('p-hidden');
- autocompletePanel.classList.remove('p-enter-active');
-
- window.removeEventListener('resize', updatePanelPosition);
- window.removeEventListener('scroll', updatePanelPosition, true);
-
- setTimeout(() => {
- if (autocompletePanel && autocompletePanel.parentNode === document.body) {
- document.body.removeChild(autocompletePanel);
+
+ createContextMenu({
+ target: tag,
+ items: getMenuItems
+ });
+
+ return tag;
+ }
+
+ /**
+ * 选中模型进行编辑(弹出气泡框)
+ */
+ _selectModelForEdit(service, modelType, modelName, tagElement) {
+ // 保存this引用
+ const self = this;
+
+ // 获取模型数据
+ const models = modelType === 'llm' ? service.llm_models : service.vlm_models;
+ const selectedModel = models.find(m => m.name === modelName);
+
+ if (!selectedModel) return;
+
+ // 弹出气泡框编辑参数
+ createConfirmPopup({
+ target: tagElement,
+ message: `模型参数设置`,
+ icon: 'pi-cog',
+ position: 'top',
+ confirmLabel: '保存',
+ cancelLabel: '取消',
+ renderFormContent: (formContainer) => {
+ // 为表单容器添加横向布局类
+ formContainer.classList.add('model-params-form');
+
+ // 温度 (Temperature)
+ const tempInput = createInputGroup('温度 (Temperature)', '0.0 - 2.0', 'number');
+ tempInput.input.min = '0';
+ tempInput.input.max = '2';
+ tempInput.input.step = '0.1';
+ tempInput.input.value = selectedModel.temperature ?? 0.7;
+ tempInput.input.dataset.fieldName = 'temperature';
+ tempInput.group.style.width = '135px';
+ formContainer.appendChild(tempInput.group);
+
+ // 核采样 (Top-P)
+ const topPInput = createInputGroup('核采样 (Top-P)', '0.0 - 1.0', 'number');
+ topPInput.input.min = '0';
+ topPInput.input.max = '1';
+ topPInput.input.step = '0.1';
+ topPInput.input.value = selectedModel.top_p ?? 0.9;
+ topPInput.input.dataset.fieldName = 'top_p';
+ topPInput.group.style.width = '135px';
+ formContainer.appendChild(topPInput.group);
+
+ // 最大Token数
+ const maxTokensInput = createInputGroup('最大Token数', '1 - 8192', 'number');
+ maxTokensInput.input.min = '1';
+ maxTokensInput.input.max = '8192';
+ maxTokensInput.input.step = '1';
+ maxTokensInput.input.value = selectedModel.max_tokens ?? 512;
+ maxTokensInput.input.dataset.fieldName = 'max_tokens';
+ maxTokensInput.group.style.width = '135px';
+ formContainer.appendChild(maxTokensInput.group);
+ },
+ onConfirm: async (formContainer) => {
+ try {
+ // 获取表单数据
+ const temperature = parseFloat(formContainer.querySelector('[data-field-name="temperature"]').value);
+ const top_p = parseFloat(formContainer.querySelector('[data-field-name="top_p"]').value);
+ const max_tokens = parseInt(formContainer.querySelector('[data-field-name="max_tokens"]').value);
+
+ // 验证数据
+ if (isNaN(temperature) || temperature < 0 || temperature > 2) {
+ app.extensionManager.toast.add({
+ severity: "warn",
+ summary: "温度值无效",
+ detail: "温度值应在 0 到 2 之间",
+ life: 2000
+ });
+ throw new Error('温度值无效');
+ }
+
+ if (isNaN(top_p) || top_p < 0 || top_p > 1) {
+ app.extensionManager.toast.add({
+ severity: "warn",
+ summary: "核采样值无效",
+ detail: "核采样值应在 0 到 1 之间",
+ life: 2000
+ });
+ throw new Error('核采样值无效');
+ }
+
+ if (isNaN(max_tokens) || max_tokens < 1 || max_tokens > 8192) {
+ app.extensionManager.toast.add({
+ severity: "warn",
+ summary: "最大Token数无效",
+ detail: "最大Token数应在 1 到 8192 之间",
+ life: 2000
+ });
+ throw new Error('最大Token数无效');
+ }
+
+ // 使用self代替this来调用方法
+ await self._updateModelParams(service.id, modelType, modelName, {
+ temperature,
+ top_p,
+ max_tokens
+ });
+
+ // 更新本地数据
+ selectedModel.temperature = temperature;
+ selectedModel.top_p = top_p;
+ selectedModel.max_tokens = max_tokens;
+
+ app.extensionManager.toast.add({
+ severity: "success",
+ summary: "参数已更新",
+ detail: `${modelName} 的参数已保存`,
+ life: 2000
+ });
+ } catch (error) {
+ logger.error('更新模型参数失败', error);
+ app.extensionManager.toast.add({
+ severity: "error",
+ summary: "更新失败",
+ detail: error.message,
+ life: 3000
+ });
+ throw error;
}
- // 清空面板引用,下次打开时重新创建(以便根据提供商决定是否显示切换按钮)
- autocompletePanel = null;
- showRecommended = false; // 重置为默认状态
- document.removeEventListener('click', handleOutsideClick, true);
- }, 120);
- };
-
- // 加载模型列表(独立函数,用于初次加载和切换时重载)
- const loadModels = async () => {
- const provider = getProvider();
- const itemsList = autocompletePanel.querySelector('.p-dropdown-items');
-
- // 显示加载状态
- itemsList.innerHTML = '正在加载模型列表...';
-
- // 获取模型列表
- try {
- const response = await fetch('/prompt_assistant/api/models/list', {
- method: 'POST',
+ }
+ });
+ }
+
+ /**
+ * 批量更新模型参数
+ */
+ async _updateModelParams(serviceId, modelType, modelName, params) {
+ if (!serviceId) {
+ logger.error("更新模型参数失败: serviceId为空");
+ throw new Error("服务ID不能为空");
+ }
+ try {
+ // 依次更新每个参数
+ for (const [paramName, paramValue] of Object.entries(params)) {
+ const url = APIService.getApiUrl(`/services/${encodeURIComponent(serviceId)}/models/parameter`);
+ logger.debug(`[v2] 正在更新参数: ${url}`, { modelType, modelName, paramName, paramValue });
+
+ const res = await fetch(url, {
+ method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
- provider: provider,
model_type: modelType,
- recommended: showRecommended
+ model_name: modelName,
+ parameter_name: paramName,
+ parameter_value: paramValue
})
});
-
- const result = await response.json();
-
- if (result.success && result.models && result.models.length > 0) {
- modelsList = result.models;
- renderModelsList(modelsList);
- } else {
- const errorMsg = result.error || '未找到可用的模型';
- itemsList.innerHTML = `${errorMsg}`;
- modelsList = [];
+
+ if (!res.ok) {
+ const text = await res.text();
+ logger.error(`更新参数请求失败: ${res.status} ${res.statusText}`, text);
+ throw new Error(`请求失败: ${res.status} ${res.statusText}`);
}
- } catch (error) {
- logger.error(`获取模型列表失败: ${error.message}`);
- itemsList.innerHTML = `获取模型列表失败: ${error.message}`;
- modelsList = [];
- }
- };
-
- // 打开面板并显示模型列表
- const openPanel = async () => {
- const provider = getProvider();
-
- // 创建面板(如果不存在)
- if (!autocompletePanel) {
- autocompletePanel = createAutocompletePanel();
-
- // 如果是智谱、硅基流动或302,添加切换按钮
- if (provider === 'zhipu' || provider === 'siliconflow' || provider === '302ai') {
- const toggleButton = createToggleButton();
- autocompletePanel.insertBefore(toggleButton, autocompletePanel.firstChild);
- }
- }
-
- // 添加到body
- document.body.appendChild(autocompletePanel);
-
- // 计算位置
- const rect = input.getBoundingClientRect();
- autocompletePanel.style.top = rect.bottom + 'px';
- autocompletePanel.style.left = rect.left + 'px';
- autocompletePanel.style.width = rect.width + 'px';
-
- // 显示面板
- autocompletePanel.classList.remove('p-hidden');
- autocompletePanel.offsetHeight; // 强制重排
- autocompletePanel.classList.add('p-enter-active');
-
- isOpen = true;
-
- // 添加事件监听
- document.addEventListener('click', handleOutsideClick, true);
- window.addEventListener('resize', updatePanelPosition);
- window.addEventListener('scroll', updatePanelPosition, true);
-
- // 加载模型列表
- await loadModels();
- };
-
- // 渲染模型列表
- const renderModelsList = (models, filterText = '') => {
- if (!autocompletePanel) return;
-
- const itemsList = autocompletePanel.querySelector('.p-dropdown-items');
- itemsList.innerHTML = '';
-
- // 过滤模型
- const filteredModels = filterText
- ? models.filter(model => model.id.toLowerCase().includes(filterText.toLowerCase()))
- : models;
-
- if (filteredModels.length === 0) {
- itemsList.innerHTML = '未找到匹配的模型';
- return;
- }
-
- filteredModels.forEach((model, index) => {
- const item = document.createElement('li');
- item.className = 'p-dropdown-item';
- item.textContent = model.name;
- item.dataset.value = model.id;
- item.dataset.index = index;
- item.setAttribute('role', 'option');
-
- item.addEventListener('click', (e) => {
- e.stopPropagation();
- input.value = model.id;
- closePanel();
- });
-
- itemsList.appendChild(item);
- });
- };
-
- // 处理键盘导航
- const handleKeyDown = (e) => {
- if (!isOpen || !autocompletePanel) return;
-
- const itemsList = autocompletePanel.querySelector('.p-dropdown-items');
- const items = Array.from(itemsList.querySelectorAll('.p-dropdown-item[data-value]'));
-
- if (items.length === 0) return;
-
- switch (e.key) {
- case 'ArrowDown':
- e.preventDefault();
- selectedIndex = (selectedIndex + 1) % items.length;
- updateSelection(items);
- break;
- case 'ArrowUp':
- e.preventDefault();
- selectedIndex = selectedIndex <= 0 ? items.length - 1 : selectedIndex - 1;
- updateSelection(items);
- break;
- case 'Enter':
- e.preventDefault();
- if (selectedIndex >= 0 && selectedIndex < items.length) {
- const selectedItem = items[selectedIndex];
- input.value = selectedItem.dataset.value;
- closePanel();
+
+ const text = await res.text();
+ try {
+ const result = JSON.parse(text);
+ if (!result.success) {
+ throw new Error(result.error || '更新参数失败');
}
- break;
- case 'Escape':
- e.preventDefault();
- closePanel();
- break;
+ } catch (e) {
+ logger.error(`解析响应JSON失败: ${text}`, e);
+ throw new Error(`解析响应失败: ${e.message}`);
+ }
}
- };
-
- // 更新选中状态
- const updateSelection = (items) => {
- items.forEach((item, index) => {
- if (index === selectedIndex) {
- item.classList.add('p-highlight');
- item.scrollIntoView({ block: 'nearest' });
+
+ logger.debug(`已批量更新模型参数: ${modelName}`, params);
+
+ } catch (error) {
+ logger.error('批量更新模型参数失败', error);
+ throw error;
+ }
+ }
+
+
+ /**
+ * 获取可用模型列表
+ */
+ async _getAvailableModels(service, modelType) {
+ try {
+ // 调用后端API获取模型列表
+ const res = await fetch(APIService.getApiUrl(`/services/${service.id}/models?model_type=${modelType}`));
+ const result = await res.json();
+
+ // 返回结果包含success、models或error
+ return result;
+
+ } catch (error) {
+ logger.error(`获取模型列表异常: ${error.message}`);
+ return {
+ success: false,
+ error: `网络错误: ${error.message}`
+ };
+ }
+ }
+
+ /**
+ * 显示添加模型列表框(使用多选组件)
+ */
+ _showAddModelDialog(service, modelType, container) {
+ // 获取触发按钮
+ const addBtn = event.target.closest('button');
+
+ // 使用新的多选listbox组件
+ createMultiSelectListbox({
+ triggerElement: addBtn,
+ placeholder: `搜索${modelType === 'llm' ? 'LLM' : 'VLM'}模型...`,
+ fetchItems: async () => {
+ const result = await this._getAvailableModels(service, modelType);
+
+ if (!result.success) {
+ throw new Error(result.error || '获取模型列表失败');
+ }
+
+ return result.models[modelType] || [];
+ },
+ onConfirm: async (selectedModels, searchInputValue) => {
+ // 如果没有勾选模型,但搜索框有内容,则将搜索框内容作为模型名称添加
+ if (selectedModels.length === 0 && searchInputValue && searchInputValue.trim()) {
+ const modelName = searchInputValue.trim();
+ await this._addModel(service, modelType, modelName, container);
} else {
- item.classList.remove('p-highlight');
+ // 批量添加选中的模型
+ for (const modelName of selectedModels) {
+ await this._addModel(service, modelType, modelName, container);
+ }
+ }
+ }
+ });
+ }
+
+ /**
+ * 获取推荐模型列表(已移除,返回空数组)
+ */
+ async _getRecommendedModels(modelType) {
+ // 推荐模型已移除,所有模型从服务商API获取
+ return [];
+ }
+
+ /**
+ * 添加模型
+ */
+ async _addModel(service, modelType, modelName, container) {
+ try {
+ // 调用后端API添加模型
+ const res = await fetch(APIService.getApiUrl(`/services/${service.id}/models`), {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ model_type: modelType,
+ model_name: modelName,
+ temperature: 0.7,
+ top_p: 0.9,
+ max_tokens: 512
+ })
+ });
+
+ const result = await res.json();
+
+ if (!result.success) {
+ throw new Error(result.error || '添加模型失败');
+ }
+
+ // 更新本地数据
+ const modelList = modelType === 'llm' ? service.llm_models : service.vlm_models;
+ if (!modelList) {
+ if (modelType === 'llm') {
+ service.llm_models = [];
+ } else {
+ service.vlm_models = [];
+ }
+ }
+
+ const updatedList = modelType === 'llm' ? service.llm_models : service.vlm_models;
+ updatedList.push({
+ name: modelName,
+ is_default: updatedList.length === 0,
+ temperature: 0.7,
+ top_p: 0.9,
+ max_tokens: 512
+ });
+
+ // 移除空提示
+ const emptyHint = container.querySelector('.empty-hint');
+ if (emptyHint) {
+ emptyHint.remove();
+ }
+
+ // 添加新标签
+ const newTag = this._createModelTag({
+ name: modelName,
+ is_default: updatedList.length === 1
+ }, service, modelType);
+ container.appendChild(newTag);
+
+ // 初始化或更新Sortable(确保新添加的标签可以拖动)
+ // 先销毁旧的Sortable实例(如果存在)
+ if (container.sortableInstance) {
+ container.sortableInstance.destroy();
+ }
+
+ // 创建新的Sortable实例
+ container.sortableInstance = new Sortable(container, {
+ animation: 150,
+ ghostClass: 'sortable-ghost',
+ handle: '.model-tag',
+ onEnd: async (evt) => {
+ await this._updateModelOrder(service.id, modelType, container);
}
});
- };
-
- // 处理外部点击
- const handleOutsideClick = (e) => {
- if (input.contains(e.target)) {
- return;
- }
- if (autocompletePanel && autocompletePanel.contains(e.target)) {
- return;
- }
- closePanel();
- };
-
- // 添加事件监听器
- input.addEventListener('focus', () => {
- // 所有提供商都支持自动完成
- openPanel();
- });
-
- input.addEventListener('input', () => {
- if (isOpen && modelsList.length > 0) {
- renderModelsList(modelsList, input.value);
- selectedIndex = -1;
+
+ app.extensionManager.toast.add({
+ severity: "success",
+ summary: "模型已添加",
+ life: 2000
+ });
+
+ } catch (error) {
+ logger.error('添加模型失败', error);
+ app.extensionManager.toast.add({
+ severity: "error",
+ summary: "添加失败",
+ detail: error.message,
+ life: 3000
+ });
+ }
+ }
+
+ /**
+ * 删除模型
+ */
+ async _deleteModel(service, modelType, modelName, tagElement) {
+ // 使用createSettingsDialog创建确认窗口
+ createSettingsDialog({
+ title: '确认删除',
+ isConfirmDialog: true,
+ dialogClassName: 'confirm-dialog',
+ saveButtonText: '删除',
+ saveButtonIcon: 'pi-trash',
+ isDangerButton: true,
+ cancelButtonText: '取消',
+ renderContent: (content) => {
+ content.className = 'confirm-dialog-content-simple';
+
+ const confirmMessage = document.createElement('p');
+ confirmMessage.className = 'confirm-dialog-message-simple';
+ confirmMessage.textContent = `确定要删除模型"${modelName}"吗?`;
+
+ content.appendChild(confirmMessage);
+ },
+ onSave: async () => {
+ try {
+ // 调用后端API删除模型
+ const res = await fetch(APIService.getApiUrl(`/services/${service.id}/models/${modelType}/${encodeURIComponent(modelName)}`), {
+ method: 'DELETE'
+ });
+
+ const result = await res.json();
+
+ if (!result.success) {
+ throw new Error(result.error || '删除模型失败');
+ }
+
+ // 更新本地数据
+ const models = modelType === 'llm' ? service.llm_models : service.vlm_models;
+ const index = models.findIndex(m => m.name === modelName);
+ if (index >= 0) {
+ models.splice(index, 1);
+ }
+
+ // 移除标签
+ tagElement.remove();
+
+ // 如果删除后为空,显示空提示
+ const container = tagElement.parentElement;
+ if (container && container.children.length === 0) {
+ const emptyHint = document.createElement('div');
+ emptyHint.className = 'empty-hint';
+ emptyHint.textContent = '暂无配置模型,点击"+ 添加模型"开始配置';
+ emptyHint.style.cssText = `
+ font-size: 12px;
+ color: var(--p-text-muted-color);
+ padding: 8px;
+ `;
+ container.appendChild(emptyHint);
+ }
+
+ app.extensionManager.toast.add({
+ severity: "success",
+ summary: "模型已删除",
+ life: 2000
+ });
+
+ return true; // 允许关闭对话框
+
+ } catch (error) {
+ logger.error('删除模型失败', error);
+ app.extensionManager.toast.add({
+ severity: "error",
+ summary: "删除失败",
+ detail: error.message,
+ life: 3000
+ });
+ return false; // 阻止关闭对话框
+ }
}
});
-
- input.addEventListener('keydown', handleKeyDown);
+ }
+
+ /**
+ * 设置默认模型
+ */
+ async _setDefaultModel(service, modelType, modelName, tagElement) {
+ try {
+ // 调用后端API设置默认模型
+ const res = await fetch(APIService.getApiUrl(`/services/${service.id}/models/default`), {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ model_type: modelType,
+ model_name: modelName
+ })
+ });
+
+ const result = await res.json();
+
+ if (!result.success) {
+ throw new Error(result.error || '设置默认模型失败');
+ }
+
+ // 更新本地数据
+ const models = modelType === 'llm' ? service.llm_models : service.vlm_models;
+ models.forEach(m => {
+ m.is_default = m.name === modelName;
+ });
+
+ // ---直接更新DOM,无需重新加载---
+ const container = tagElement?.parentElement;
+ if (container) {
+ // 移除所有标签的默认状态
+ container.querySelectorAll('.model-tag').forEach(tag => {
+ tag.classList.remove('default');
+ // 移除旧的默认标记
+ const oldBadge = tag.querySelector('.model-tag-badge');
+ if (oldBadge) {
+ oldBadge.remove();
+ }
+ });
+
+ // 为新的默认模型添加样式和标记
+ if (tagElement) {
+ tagElement.classList.add('default');
+ // 在名称后面添加默认标记
+ const nameSpan = tagElement.querySelector('.model-tag-name');
+ if (nameSpan) {
+ const defaultBadge = document.createElement('span');
+ defaultBadge.className = 'model-tag-badge';
+ defaultBadge.textContent = '默认';
+ nameSpan.after(defaultBadge);
+ }
+ }
+ }
+
+ app.extensionManager.toast.add({
+ severity: "success",
+ summary: `已设置"${modelName}"为默认模型`,
+ life: 2000
+ });
+
+ } catch (error) {
+ logger.error('设置默认模型失败', error);
+ app.extensionManager.toast.add({
+ severity: "error",
+ summary: "设置失败",
+ detail: error.message,
+ life: 3000
+ });
+ }
+ }
+
+ /**
+ * 更新模型顺序
+ */
+ async _updateModelOrder(serviceId, modelType, container) {
+ try {
+ const modelTags = container.querySelectorAll('.model-tag');
+ const newOrder = Array.from(modelTags).map(tag => tag.dataset.modelName);
+
+ // 调用后端API更新顺序
+ const res = await fetch(APIService.getApiUrl(`/services/${serviceId}/models/order`), {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ model_type: modelType,
+ model_names: newOrder
+ })
+ });
+
+ const result = await res.json();
+
+ if (!result.success) {
+ throw new Error(result.error || '更新模型顺序失败');
+ }
+
+ app.extensionManager.toast.add({
+ severity: "success",
+ summary: "模型顺序已更新",
+ life: 2000
+ });
+
+ } catch (error) {
+ logger.error('更新模型顺序失败', error);
+ app.extensionManager.toast.add({
+ severity: "error",
+ summary: "更新失败",
+ detail: error.message,
+ life: 3000
+ });
+ }
+ }
+
+ /**
+ * 删除服务商
+ */
+ async _deleteService(serviceId) {
+ // 查找服务名称
+ const service = this.services.find(s => s.id === serviceId);
+ const serviceName = service ? service.name : serviceId;
+
+ // 使用createSettingsDialog创建确认窗口
+ createSettingsDialog({
+ title: '确认删除',
+ isConfirmDialog: true,
+ dialogClassName: 'confirm-dialog',
+ saveButtonText: '删除',
+ saveButtonIcon: 'pi-trash',
+ isDangerButton: true,
+ cancelButtonText: '取消',
+ renderContent: (content) => {
+ content.className = 'confirm-dialog-content-simple';
+
+ const confirmMessage = document.createElement('p');
+ confirmMessage.className = 'confirm-dialog-message-simple';
+ confirmMessage.textContent = `确定要删除服务商"${serviceName}"吗?`;
+
+ content.appendChild(confirmMessage);
+ },
+ onSave: async () => {
+ try {
+ const res = await fetch(APIService.getApiUrl(`/services/${serviceId}`), {
+ method: 'DELETE'
+ });
+
+ const result = await res.json();
+
+ if (result.success) {
+ app.extensionManager.toast.add({
+ severity: "success",
+ summary: "删除成功",
+ life: 3000
+ });
+
+ // 重新加载配置并刷新UI
+ await this._loadAllConfigs();
+
+ // 查找并移除对应的标签和内容
+ const tabButton = document.querySelector(`.tab-button[data-tab="${serviceId}"]`);
+ if (tabButton) {
+ tabButton.remove();
+ }
+
+ const tabPane = document.querySelector(`.tab-pane[data-tab="${serviceId}"]`);
+ if (tabPane) {
+ tabPane.remove();
+ }
+
+ // 自动切换到百度翻译标签
+ const header = document.querySelector('.tab-header');
+ const contentContainer = document.querySelector('.tab-content');
+ if (header && contentContainer) {
+ this._switchTab('baidu', header, contentContainer);
+ }
+
+ // 如果是最后一个服务商,显示空提示
+ const listContainer = document.querySelector('.services-list');
+ if (listContainer && this.services.length === 0) {
+ const emptyHint = document.createElement('div');
+ emptyHint.style.cssText = `
+ text-align: center;
+ padding: 40px;
+ color: var(--p-text-muted-color);
+ `;
+ emptyHint.textContent = '暂无服务商,点击"新增服务商"开始配置';
+ listContainer.appendChild(emptyHint);
+ }
+
+ return true; // 允许关闭对话框
+ } else {
+ throw new Error(result.error || '删除失败');
+ }
+ } catch (error) {
+ logger.error('删除服务商失败', error);
+ app.extensionManager.toast.add({
+ severity: "error",
+ summary: "删除失败",
+ detail: error.message,
+ life: 3000
+ });
+ return false; // 阻止关闭对话框
+ }
+ }
+ });
+ }
+
+ /**
+ * 更新服务商配置
+ */
+ async _updateService(serviceId, updates) {
+ try {
+ const res = await fetch(APIService.getApiUrl(`/services/${serviceId}`), {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(updates)
+ });
+
+ const result = await res.json();
+
+ if (!result.success) {
+ throw new Error(result.error || '更新失败');
+ }
+
+ // 同步更新本地内存中的服务商数据
+ const service = this.services.find(s => s.id === serviceId);
+ if (service) {
+ Object.assign(service, updates);
+ }
+
+ logger.debug('服务商配置已更新', serviceId);
+
+ // 显示成功提示
+ app.extensionManager.toast.add({
+ severity: "success",
+ summary: "服务商配置已更新",
+ life: 2000
+ });
+ } catch (error) {
+ logger.error('更新服务商失败', error);
+ app.extensionManager.toast.add({
+ severity: "error",
+ summary: "更新失败",
+ detail: error.message,
+ life: 3000
+ });
+ }
+ }
+
+
+
+ /**
+ * 加载掩码后的API Key
+ * @param {string} serviceId 服务商ID
+ * @returns {Promise} 掩码后的API Key
+ */
+ async _loadMaskedApiKey(serviceId) {
+ try {
+ const res = await fetch(APIService.getApiUrl(`/services/${serviceId}/masked`));
+ const result = await res.json();
+
+ if (result.success && result.service) {
+ return result.service.api_key_masked || null;
+ }
+
+ return null;
+ } catch (error) {
+ logger.error('加载掩码API Key失败', error);
+ return null;
+ }
}
}
diff --git a/js/modules/history.js b/js/modules/history.js
index 1442ca3..f43c9ae 100644
--- a/js/modules/history.js
+++ b/js/modules/history.js
@@ -168,7 +168,20 @@ class HistoryManager {
const title = document.createElement('div');
title.className = 'popup_title';
- title.textContent = '历史记录';
+ title.style.display = 'flex';
+ title.style.alignItems = 'center';
+
+ // 添加图标
+ const iconContainer = ResourceManager.getIcon('icon-history.svg');
+ if (iconContainer) {
+ iconContainer.style.width = '18px';
+ iconContainer.style.height = '18px';
+ iconContainer.style.color = 'var(--p-dialog-color)';
+ iconContainer.style.marginRight = '8px';
+ title.appendChild(iconContainer);
+ }
+
+ title.appendChild(document.createTextNode('历史记录'));
const actions = document.createElement('div');
actions.className = 'popup_actions';
@@ -452,7 +465,7 @@ class HistoryManager {
break;
case 'expand':
operationSpan.className = 'history_operation expanded';
- operationSpan.textContent = '扩写';
+ operationSpan.textContent = '提示词优化';
break;
case 'caption':
operationSpan.className = 'history_operation caption';
diff --git a/js/modules/imageCaption.js b/js/modules/imageCaption.js
index 538542b..231246b 100644
--- a/js/modules/imageCaption.js
+++ b/js/modules/imageCaption.js
@@ -12,6 +12,10 @@ import { APIService } from '../services/api.js';
import { HistoryCacheService } from '../services/cache.js';
import { buttonMenu } from "../services/btnMenu.js";
import { rulesConfigManager } from "./rulesConfigManager.js";
+import { nodeMountService, RENDER_MODE } from "../services/NodeMountService.js";
+import { AssistantContainer, ANCHOR_POSITION } from "./AssistantContainer.js";
+import { PopupManager } from "../utils/popupManager.js";
+
class ImageCaption {
@@ -203,7 +207,7 @@ class ImageCaption {
logger.log("图像小助手初始化完成");
return true;
} catch (error) {
- logger.error(`图像小助手初始化失败 | 错误: ${error.message}`);
+ logger.error(() => `图像小助手初始化失败 | 错误: ${error.message}`);
this.initialized = false;
return false;
}
@@ -328,7 +332,7 @@ class ImageCaption {
// 创建新的小助手实例
const assistant = this.setupNodeAssistant(node);
if (assistant) {
- logger.log(`创建图像小助手 | ID: ${node.id}`);
+ logger.log(() => `创建图像小助手 | ID: ${node.id}`);
}
}
}
@@ -400,75 +404,86 @@ class ImageCaption {
if (!assistant?.node) return null;
try {
- // 创建内部内容容器
- const innerContentDiv = document.createElement('div');
- innerContentDiv.className = 'image-assistant-inner';
+ // 获取位置设置
+ const locationSetting = app.ui.settings.getSettingValue(
+ "ImageCaption.Location",
+ "bottom-left-h"
+ );
- // 创建主容器
- const containerDiv = document.createElement('div');
- containerDiv.className = 'image-assistant-container image-assistant-transition';
- containerDiv.dataset.nodeId = assistant.nodeId;
-
- // 添加内容容器到主容器
- containerDiv.appendChild(innerContentDiv);
-
- // 创建悬停区域 - 用于检测鼠标悬停
- const hoverAreaDiv = document.createElement('div');
- hoverAreaDiv.className = 'image-assistant-hover-area';
- // 由于初始状态为折叠,所以显示悬停区域
- hoverAreaDiv.style.display = 'block';
- containerDiv.appendChild(hoverAreaDiv);
-
- // 创建折叠状态指示器图标
- const indicatorDiv = document.createElement('div');
- indicatorDiv.className = 'image-assistant-indicator animate-creation';
-
- // 从ResourceManager获取图标并添加到指示器
- const mainIcon = ResourceManager.getIcon('icon-main.svg');
- if (mainIcon) {
- indicatorDiv.appendChild(mainIcon);
- }
-
- containerDiv.appendChild(indicatorDiv);
-
- // 保存引用
- assistant.element = containerDiv;
- assistant.innerContent = innerContentDiv;
- assistant.hoverArea = hoverAreaDiv;
- assistant.indicator = indicatorDiv;
- assistant.buttons = {};
- assistant.isCollapsed = true; // 初始状态为折叠
-
- // 初始化UI组件和事件
- this.addFunctionButtons(assistant);
-
- // 默认隐藏状态
- containerDiv.style.display = 'none';
-
- // 使用固定定位样式
- containerDiv.style.position = 'fixed';
- containerDiv.style.zIndex = '1';
- document.body.appendChild(containerDiv);
-
- // 初始状态为折叠,添加折叠样式类
- containerDiv.classList.add('collapsed');
-
- // 延迟设置位置
- requestAnimationFrame(() => {
- this._setupUIPosition(assistant);
+ // Create AssistantContainer instance
+ const container = new AssistantContainer({
+ nodeId: assistant.nodeId,
+ type: 'image', // Uses image-assistant-container class
+ anchorPosition: locationSetting,
+ enableDragSort: true,
+ onButtonOrderChange: (order) => {
+ // Order preservation handled by container
+ },
+ shouldCollapse: () => {
+ return !this._checkAssistantActiveState(assistant);
+ }
});
- // 设置展开折叠事件
- this._setupCollapseExpandEvents(assistant);
+ // Render
+ const containerEl = container.render();
- // 移除动画类的定时器
- setTimeout(() => {
- if (indicatorDiv && indicatorDiv.classList.contains('animate-creation')) {
- indicatorDiv.classList.remove('animate-creation');
+ // Set Icon
+ const mainIcon = ResourceManager.getIcon('icon-main.svg');
+ if (mainIcon && container.indicator) {
+ container.indicator.innerHTML = '';
+ container.indicator.appendChild(mainIcon);
+ }
+
+ // Save references
+ assistant.container = container;
+ assistant.element = containerEl;
+ assistant.innerContent = container.content;
+ assistant.hoverArea = container.hoverArea;
+ assistant.indicator = container.indicator;
+ assistant.buttons = {};
+
+ // Sync state properties
+ Object.defineProperty(assistant, 'isCollapsed', {
+ get: () => container.isCollapsed,
+ set: (val) => {
+ if (val) container.collapse(); else container.expand();
}
- }, 1000);
+ });
+ Object.defineProperty(assistant, 'isTransitioning', {
+ get: () => container.isTransitioning,
+ set: (val) => { container.isTransitioning = val; }
+ });
+
+ // Add buttons
+ this.addFunctionButtons(assistant);
+
+ // Restore order
+ container.restoreOrder();
+
+ // 初始设置
+ // 注意:原始代码设置位置固定并追加到 body
+ // AssistantContainer 创建元素但不挂载。
+ // 原始代码:
+ // containerDiv.style.position = 'fixed';
+ // document.body.appendChild(containerDiv);
+
+ // 我们遵循原始逻辑,将 Image Assistant 追加到 body
+ containerEl.style.position = 'fixed';
+ containerEl.style.zIndex = '1';
+ document.body.appendChild(containerEl);
+
+ // 延迟设置位置
+ // 优化:使用 requestAnimationFrame 确保在下一次重绘前更新位置
+ requestAnimationFrame(() => {
+ this._setupUIPosition(assistant);
+ if (container) container.updateDimensions();
+ });
+
+ // 折叠/展开设置由 container 处理
+ // 移除手动事件设置
+
+ return containerEl;
- return containerDiv;
} catch (error) {
logger.error(`图像小助手UI创建失败 | ID: ${assistant.nodeId} | ${error.message}`);
return null;
@@ -493,11 +508,35 @@ class ImageCaption {
},
// 添加中文反推按钮的右键菜单
contextMenu: async (assistant) => {
+ // 获取服务列表和当前激活状态
+ let services = [];
+ let currentVLMService = null;
+ let currentVLMModel = null;
+
+ // 获取中文反推规则
let activePromptId = null;
let visionPrompts = [];
try {
- const response = await fetch('/prompt_assistant/api/config/system_prompts');
+ // 获取服务列表
+ const servicesResp = await fetch(APIService.getApiUrl('/services'));
+ if (servicesResp.ok) {
+ const servicesData = await servicesResp.json();
+ if (servicesData.success) {
+ services = servicesData.services || [];
+ }
+ }
+
+ // 获取当前激活的VLM服务和模型
+ const vlmResp = await fetch(APIService.getApiUrl('/config/vision'));
+ if (vlmResp.ok) {
+ const vlmConfig = await vlmResp.json();
+ currentVLMService = vlmConfig.provider || null;
+ currentVLMModel = vlmConfig.model || null;
+ }
+
+ // 获取中文反推规则
+ const response = await fetch(APIService.getApiUrl('/config/system_prompts'));
if (response.ok) {
const data = await response.json();
activePromptId = data.active_prompts?.vision_zh || null;
@@ -507,11 +546,18 @@ class ImageCaption {
originalOrder.forEach(key => {
if (key.startsWith('vision_zh')) {
const prompt = data.vision_prompts[key];
- visionPrompts.push({
- id: key,
- name: prompt.name || key,
- isActive: key === activePromptId
- });
+ const showIn = prompt.showIn || ['frontend', 'node'];
+
+ // 仅当配置包含 'frontend' 时才在前端菜单显示
+ if (showIn.includes('frontend')) {
+ visionPrompts.push({
+ id: key,
+ name: prompt.name || key,
+ category: prompt.category || '',
+ showIn: showIn,
+ isActive: key === activePromptId
+ });
+ }
}
});
visionPrompts.sort((a, b) =>
@@ -520,20 +566,72 @@ class ImageCaption {
}
}
} catch (error) {
- logger.error(`获取中文反推提示词失败: ${error.message}`);
+ logger.error(() => `获取中文反推配置失败: ${error.message}`);
}
- if (visionPrompts.length === 0) {
- return [{ label: '未找到提示词', disabled: true }];
- }
+ // 创建服务菜单项(只显示有VLM模型的服务)
+ const serviceMenuItems = services
+ .filter(service => service.vlm_models && service.vlm_models.length > 0)
+ .map(service => {
+ const isCurrentService = currentVLMService === service.id;
- // 添加分割线和规则管理按钮
- const menuItems = visionPrompts.map(prompt => ({
+ // 创建模型子菜单
+ const modelChildren = (service.vlm_models || []).map(model => {
+ const isCurrentModel = isCurrentService && currentVLMModel === model.name;
+ return {
+ label: model.display_name || model.name,
+ icon: ``,
+ onClick: async (context) => {
+ try {
+ const res = await fetch(APIService.getApiUrl('/services/current'), {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ service_type: 'vlm', service_id: service.id, model_name: model.name })
+ });
+ if (!res.ok) throw new Error(`服务器返回错误: ${res.status}`);
+ const modelLabel = model.display_name || model.name;
+ UIToolkit.showStatusTip(context.buttonElement, 'success', `已切换到: ${service.name} - ${modelLabel}`);
+ logger.log(`视觉服务切换 | 服务: ${service.name} | 模型: ${modelLabel}`);
+ } catch (err) {
+ logger.error(`切换视觉模型失败: ${err.message}`);
+ UIToolkit.showStatusTip(context.buttonElement, 'error', `切换失败: ${err.message}`);
+ }
+ }
+ };
+ });
+
+ return {
+ label: service.name || service.id,
+ icon: ``,
+ onClick: async (context) => {
+ try {
+ const res = await fetch(APIService.getApiUrl('/services/current'), {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ service_type: 'vlm', service_id: service.id })
+ });
+ if (!res.ok) throw new Error(`服务器返回错误: ${res.status}`);
+ UIToolkit.showStatusTip(context.buttonElement, 'success', `已切换到: ${service.name}`);
+ logger.log(`视觉服务切换 | 服务: ${service.name}`);
+ } catch (err) {
+ logger.error(`切换视觉服务失败: ${err.message}`);
+ UIToolkit.showStatusTip(context.buttonElement, 'error', `切换失败: ${err.message}`);
+ }
+ },
+ children: modelChildren.length > 0 ? modelChildren : undefined
+ };
+ });
+
+ // ---创建规则菜单项(支持分类分组)---
+ const ruleMenuItems = [];
+
+ // 辅助函数:创建单个规则菜单项
+ const createRuleMenuItem = (prompt) => ({
label: prompt.name,
icon: ``,
onClick: async (context) => {
try {
- const response = await fetch('/prompt_assistant/api/config/active_prompt', {
+ const response = await fetch(APIService.getApiUrl('/config/active_prompt'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: 'vision_zh', prompt_id: prompt.id })
@@ -548,10 +646,37 @@ class ImageCaption {
UIToolkit.showStatusTip(context.buttonElement, 'error', `切换失败: ${error.message}`);
}
}
- }));
+ });
- menuItems.push({ type: 'separator' });
- menuItems.push({
+ // 按分类分组规则
+ const uncategorizedPrompts = visionPrompts.filter(p => !p.category);
+ const categorizedPrompts = visionPrompts.filter(p => p.category);
+
+ // 收集所有分类并排序
+ const categories = [...new Set(categorizedPrompts.map(p => p.category))].sort();
+
+ // 添加无分类的规则(放在顶层)
+ uncategorizedPrompts.forEach(prompt => {
+ ruleMenuItems.push(createRuleMenuItem(prompt));
+ });
+
+ // 添加分类分组(每个分类作为二级菜单)
+ categories.forEach(category => {
+ const promptsInCategory = categorizedPrompts.filter(p => p.category === category);
+ const hasActivePrompt = promptsInCategory.some(p => p.isActive);
+
+ ruleMenuItems.push({
+ label: category,
+ icon: ``,
+ submenuAlign: 'center',
+ children: promptsInCategory.map(prompt => createRuleMenuItem(prompt))
+ });
+ });
+
+
+ // 添加规则管理选项
+ ruleMenuItems.push({ type: 'separator' });
+ ruleMenuItems.push({
label: '规则管理',
icon: '',
onClick: () => {
@@ -559,47 +684,16 @@ class ImageCaption {
}
});
- // 在规则管理下方添加 服务选择 子菜单(视觉模型提供商)
- try {
- const resp = await fetch('/prompt_assistant/api/config/vision');
- if (resp.ok) {
- const cfg = await resp.json();
- const providers = cfg.providers || {};
- const current = cfg.provider || null;
- const providerNameMap = { zhipu: '智谱', siliconflow: '硅基流动', "302ai": '302.AI', ollama: 'Ollama', custom: '自定义' };
- const order = ['zhipu', 'siliconflow', '302ai', 'ollama', 'custom'];
- const children = order
- .filter(key => Object.prototype.hasOwnProperty.call(providers, key))
- .map(key => ({
- label: providerNameMap[key] || key,
- icon: ``,
- onClick: async (context) => {
- try {
- const res = await fetch('/prompt_assistant/api/config/vision', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ current_provider: key })
- });
- if (!res.ok) throw new Error(`服务器返回错误: ${res.status}`);
- UIToolkit.showStatusTip(context.buttonElement, 'success', `已切换到: ${providerNameMap[key] || key}`);
- } catch (err) {
- logger.error(`切换视觉模型提供商失败: ${err.message}`);
- UIToolkit.showStatusTip(context.buttonElement, 'error', `切换失败: ${err.message}`);
- }
- }
- }));
- menuItems.push({
- label: '服务选择',
- icon: '',
- children
- });
+ return [
+ ...ruleMenuItems,
+ // { type: 'separator' },
+ {
+ label: "选择服务",
+ icon: '',
+ submenuAlign: 'bottom',
+ children: serviceMenuItems
}
- } catch (e) {
- logger.error(`加载视觉模型提供商失败: ${e.message}`);
- }
-
- return menuItems;
-
+ ];
}
});
@@ -615,11 +709,35 @@ class ImageCaption {
},
// 添加英文反推按钮的右键菜单
contextMenu: async (assistant) => {
+ // 获取服务列表和当前激活状态
+ let services = [];
+ let currentVLMService = null;
+ let currentVLMModel = null;
+
+ // 获取英文反推规则
let activePromptId = null;
let visionPrompts = [];
try {
- const response = await fetch('/prompt_assistant/api/config/system_prompts');
+ // 获取服务列表
+ const servicesResp = await fetch(APIService.getApiUrl('/services'));
+ if (servicesResp.ok) {
+ const servicesData = await servicesResp.json();
+ if (servicesData.success) {
+ services = servicesData.services || [];
+ }
+ }
+
+ // 获取当前激活的VLM服务和模型
+ const vlmResp = await fetch(APIService.getApiUrl('/config/vision'));
+ if (vlmResp.ok) {
+ const vlmConfig = await vlmResp.json();
+ currentVLMService = vlmConfig.provider || null;
+ currentVLMModel = vlmConfig.model || null;
+ }
+
+ // 获取英文反推规则
+ const response = await fetch(APIService.getApiUrl('/config/system_prompts'));
if (response.ok) {
const data = await response.json();
activePromptId = data.active_prompts?.vision_en || null;
@@ -629,11 +747,18 @@ class ImageCaption {
originalOrder.forEach(key => {
if (key.startsWith('vision_en')) {
const prompt = data.vision_prompts[key];
- visionPrompts.push({
- id: key,
- name: prompt.name || key,
- isActive: key === activePromptId
- });
+ const showIn = prompt.showIn || ['frontend', 'node'];
+
+ // 仅当配置包含 'frontend' 时才在前端菜单显示
+ if (showIn.includes('frontend')) {
+ visionPrompts.push({
+ id: key,
+ name: prompt.name || key,
+ category: prompt.category || '',
+ showIn: showIn,
+ isActive: key === activePromptId
+ });
+ }
}
});
visionPrompts.sort((a, b) =>
@@ -642,20 +767,72 @@ class ImageCaption {
}
}
} catch (error) {
- logger.error(`获取英文反推提示词失败: ${error.message}`);
+ logger.error(`获取英文反推配置失败: ${error.message}`);
}
- if (visionPrompts.length === 0) {
- return [{ label: '未找到提示词', disabled: true }];
- }
+ // 创建服务菜单项(只显示有VLM模型的服务)
+ const serviceMenuItems = services
+ .filter(service => service.vlm_models && service.vlm_models.length > 0)
+ .map(service => {
+ const isCurrentService = currentVLMService === service.id;
- // 添加分割线和规则管理按钮
- const menuItems = visionPrompts.map(prompt => ({
+ // 创建模型子菜单
+ const modelChildren = (service.vlm_models || []).map(model => {
+ const isCurrentModel = isCurrentService && currentVLMModel === model.name;
+ return {
+ label: model.display_name || model.name,
+ icon: ``,
+ onClick: async (context) => {
+ try {
+ const res = await fetch(APIService.getApiUrl('/services/current'), {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ service_type: 'vlm', service_id: service.id, model_name: model.name })
+ });
+ if (!res.ok) throw new Error(`服务器返回错误: ${res.status}`);
+ const modelLabel = model.display_name || model.name;
+ UIToolkit.showStatusTip(context.buttonElement, 'success', `已切换到: ${service.name} - ${modelLabel}`);
+ logger.log(`视觉服务切换 | 服务: ${service.name} | 模型: ${modelLabel}`);
+ } catch (err) {
+ logger.error(`切换视觉模型失败: ${err.message}`);
+ UIToolkit.showStatusTip(context.buttonElement, 'error', `切换失败: ${err.message}`);
+ }
+ }
+ };
+ });
+
+ return {
+ label: service.name || service.id,
+ icon: ``,
+ onClick: async (context) => {
+ try {
+ const res = await fetch(APIService.getApiUrl('/services/current'), {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ service_type: 'vlm', service_id: service.id })
+ });
+ if (!res.ok) throw new Error(`服务器返回错误: ${res.status}`);
+ UIToolkit.showStatusTip(context.buttonElement, 'success', `已切换到: ${service.name}`);
+ logger.log(`视觉服务切换 | 服务: ${service.name}`);
+ } catch (err) {
+ logger.error(`切换视觉服务失败: ${err.message}`);
+ UIToolkit.showStatusTip(context.buttonElement, 'error', `切换失败: ${err.message}`);
+ }
+ },
+ children: modelChildren.length > 0 ? modelChildren : undefined
+ };
+ });
+
+ // ---创建规则菜单项(支持分类分组)---
+ const ruleMenuItems = [];
+
+ // 辅助函数:创建单个规则菜单项
+ const createRuleMenuItem = (prompt) => ({
label: prompt.name,
icon: ``,
onClick: async (context) => {
try {
- const response = await fetch('/prompt_assistant/api/config/active_prompt', {
+ const response = await fetch(APIService.getApiUrl('/config/active_prompt'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: 'vision_en', prompt_id: prompt.id })
@@ -670,10 +847,37 @@ class ImageCaption {
UIToolkit.showStatusTip(context.buttonElement, 'error', `切换失败: ${error.message}`);
}
}
- }));
+ });
- menuItems.push({ type: 'separator' });
- menuItems.push({
+ // 按分类分组规则
+ const uncategorizedPrompts = visionPrompts.filter(p => !p.category);
+ const categorizedPrompts = visionPrompts.filter(p => p.category);
+
+ // 收集所有分类并排序
+ const categories = [...new Set(categorizedPrompts.map(p => p.category))].sort();
+
+ // 添加无分类的规则(放在顶层)
+ uncategorizedPrompts.forEach(prompt => {
+ ruleMenuItems.push(createRuleMenuItem(prompt));
+ });
+
+ // 添加分类分组(每个分类作为二级菜单)
+ categories.forEach(category => {
+ const promptsInCategory = categorizedPrompts.filter(p => p.category === category);
+ const hasActivePrompt = promptsInCategory.some(p => p.isActive);
+
+ ruleMenuItems.push({
+ label: category,
+ icon: ``,
+ submenuAlign: 'center',
+ children: promptsInCategory.map(prompt => createRuleMenuItem(prompt))
+ });
+ });
+
+
+ // 添加规则管理选项
+ ruleMenuItems.push({ type: 'separator' });
+ ruleMenuItems.push({
label: '规则管理',
icon: '',
onClick: () => {
@@ -681,56 +885,27 @@ class ImageCaption {
}
});
- // 在规则管理下方添加 服务选择 子菜单(视觉模型提供商)
- try {
- const resp = await fetch('/prompt_assistant/api/config/vision');
- if (resp.ok) {
- const cfg = await resp.json();
- const providers = cfg.providers || {};
- const current = cfg.provider || null;
- const providerNameMap = { zhipu: '智谱', siliconflow: '硅基流动', "302ai": '302.AI', ollama: 'Ollama', custom: '自定义' };
- const order = ['zhipu', 'siliconflow', '302ai', 'ollama', 'custom'];
- const children = order
- .filter(key => Object.prototype.hasOwnProperty.call(providers, key))
- .map(key => ({
- label: providerNameMap[key] || key,
- icon: ``,
- onClick: async (context) => {
- try {
- const res = await fetch('/prompt_assistant/api/config/vision', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ current_provider: key })
- });
- if (!res.ok) throw new Error(`服务器返回错误: ${res.status}`);
- UIToolkit.showStatusTip(context.buttonElement, 'success', `已切换到: ${providerNameMap[key] || key}`);
- } catch (err) {
- logger.error(`切换视觉模型提供商失败: ${err.message}`);
- UIToolkit.showStatusTip(context.buttonElement, 'error', `切换失败: ${err.message}`);
- }
- }
- }));
- menuItems.push({
- label: '服务选择',
- icon: '',
- children
- });
+ return [
+ ...ruleMenuItems,
+ // { type: 'separator' },
+ {
+ label: "选择服务",
+ icon: '',
+ submenuAlign: 'bottom',
+ children: serviceMenuItems
}
- } catch (e) {
- logger.error(`加载视觉模型提供商失败: ${e.message}`);
- }
-
- return menuItems;
-
+ ];
}
});
- // 按顺序添加元素:中文按钮 -> 英文按钮
+ // Create buttons
+
+
if (buttonZh) {
- assistant.innerContent.appendChild(buttonZh);
+ assistant.container.addButton(buttonZh, 'caption_zh');
}
if (buttonEn) {
- assistant.innerContent.appendChild(buttonEn);
+ assistant.container.addButton(buttonEn, 'caption_en');
}
}
@@ -743,14 +918,35 @@ class ImageCaption {
try {
const node = assistant.node;
- if (!node || !node.imgs || node.imgs.length === 0) {
- throw new Error('未找到有效的图像');
+
+ // Vue Node 2.0 模式:从 DOM 获取当前显示的图像
+ // 传统 LiteGraph 模式:使用 node.imgs 属性
+ let currentImage = null;
+ const isVueMode = nodeMountService.isVueNodesMode();
+
+ if (isVueMode) {
+ // Vue Node 2.0 模式:从节点容器的 DOM 中获取图像
+ const nodeContainer = document.querySelector(`[data-node-id="${node.id}"]`);
+ if (nodeContainer) {
+ // 查找节点容器内的 img 元素(优先查找预览图像)
+ const imgElement = nodeContainer.querySelector('img');
+ if (imgElement && imgElement.src) {
+ // 使用 DOM 中的图像 src
+ currentImage = imgElement.src;
+ logger.debug(`[图像小助手-Vue模式] 从DOM获取图像 | 节点ID: ${node.id}`);
+ }
+ }
}
- // 获取当前选中的图片
- const currentImage = node.imgs[node.imageIndex || 0];
+ // 如果 Vue 模式未找到图像,或者是传统模式,使用 node.imgs
if (!currentImage) {
- throw new Error('未找到有效的图像');
+ if (!node.imgs || node.imgs.length === 0) {
+ throw new Error('未找到有效的图像');
+ }
+ currentImage = node.imgs[node.imageIndex || 0];
+ if (!currentImage) {
+ throw new Error('未找到有效的图像');
+ }
}
// 获取按钮元素
@@ -815,7 +1011,8 @@ class ImageCaption {
);
// 生成请求ID
- currentRequestId = `${node.id}_${Date.now()}`;
+ // 生成请求ID
+ currentRequestId = APIService.generateRequestId('icap', null, node.id);
// 保存到assistant对象中,以便取消操作
assistant.currentRequestId = currentRequestId;
@@ -843,7 +1040,7 @@ class ImageCaption {
// 获取当前激活的提示词内容
let promptContent = '';
try {
- const response = await fetch('/prompt_assistant/api/config/system_prompts');
+ const response = await fetch(APIService.getApiUrl('/config/system_prompts'));
if (response.ok) {
const data = await response.json();
const activePromptKey = `vision_${lang}`;
@@ -1139,11 +1336,30 @@ class ImageCaption {
_checkAssistantActiveState(assistant) {
if (!assistant || !assistant.buttons) return false;
- // 遍历所有按钮,检查是否有按钮处于active或processing状态
+ // 0. 检查是否正在切换弹窗(切换期间不允许折叠)
+ if (PopupManager._isTransitioning) {
+ console.log(`[ActiveState-Image] 阻止折叠 | 原因: PopupManager 正在切换弹窗`);
+ return true;
+ }
+
+ // 1. 检查右键菜单是否可见(并且属于当前 assistant)
+ if (buttonMenu.isMenuVisible && buttonMenu.menuContext?.widget === assistant) {
+ console.log(`[ActiveState-Image] 阻止折叠 | 原因: 右键菜单可见`);
+ return true;
+ }
+
+ // 2. 检查 PopupManager 的活动弹窗是否属于当前 assistant
+ if (PopupManager.activePopupInfo?.buttonInfo?.widget === assistant) {
+ console.log(`[ActiveState-Image] 阻止折叠 | 原因: PopupManager.activePopupInfo 匹配`);
+ return true;
+ }
+
+ // 3. 检查按钮的 active/processing 状态
for (const buttonId in assistant.buttons) {
const button = assistant.buttons[buttonId];
if (button.classList.contains('button-active') ||
button.classList.contains('button-processing')) {
+ console.log(`[ActiveState-Image] 阻止折叠 | 原因: 按钮${buttonId}状态激活`);
return true;
}
}
@@ -1306,126 +1522,27 @@ class ImageCaption {
/**
* 展开小助手
*/
+
_expandAssistant(assistant) {
- if (!assistant || !assistant.element || !assistant.isCollapsed || assistant.isTransitioning) return;
-
- assistant.isTransitioning = true;
-
- // 隐藏悬停区域,避免覆盖按钮
- if (assistant.hoverArea) {
- assistant.hoverArea.style.display = 'none';
+ if (assistant && assistant.container) {
+ assistant.container.expand();
}
-
- const containerDiv = assistant.element;
-
- // 计算展开后的宽度
- let targetWidth = containerDiv.style.getPropertyValue('--expanded-width');
-
- // 如果没有保存宽度或需要重新计算
- if (!targetWidth || targetWidth === '') {
- // 先移除折叠类,但保持不可见状态进行测量
- containerDiv.classList.remove('collapsed');
- const originalDisplay = containerDiv.style.display;
- const originalOpacity = containerDiv.style.opacity;
-
- // 临时设置样式以便测量
- containerDiv.style.opacity = '0';
- containerDiv.style.display = 'flex';
- containerDiv.style.position = 'fixed';
-
- // 强制回流并测量
- void containerDiv.offsetWidth;
-
- // 获取自然宽度
- const naturalWidth = containerDiv.offsetWidth;
- targetWidth = naturalWidth + 'px';
-
- // 保存宽度供将来使用
- containerDiv.style.setProperty('--expanded-width', targetWidth);
-
- // 恢复原始样式
- containerDiv.style.opacity = originalOpacity;
- containerDiv.style.position = 'fixed';
-
- // 重新应用折叠类以准备动画
- containerDiv.classList.add('collapsed');
- void containerDiv.offsetWidth; // 强制回流
- }
-
- // 手动设置宽度转换
- containerDiv.style.width = '28px'; // 起始宽度
-
- // 强制回流
- void containerDiv.offsetWidth;
-
- // 移除折叠类
- containerDiv.classList.remove('collapsed');
-
- // 设置目标宽度以触发过渡
- containerDiv.style.width = targetWidth;
-
- // 动画结束后清理
- setTimeout(() => {
- // 移除固定宽度,恢复自动宽度
- containerDiv.style.width = '';
- assistant.isCollapsed = false;
- assistant.isTransitioning = false;
- }, 300);
}
+
/**
* 触发自动折叠
*/
+
triggerAutoCollapse(assistant) {
- // 如果widget没有初始化或者已经处于折叠状态,则不处理
- if (!assistant || !assistant.element || assistant.isCollapsed || assistant.isTransitioning) return;
-
- // 如果有活跃按钮,不自动折叠
- if (this._checkAssistantActiveState(assistant)) return;
-
- // 如果鼠标当前悬停在容器上,不自动折叠
- if (assistant.isMouseOver) return;
-
- // 清除可能已存在的自动折叠定时器
- if (assistant._autoCollapseTimer) {
- clearTimeout(assistant._autoCollapseTimer);
+ if (assistant && assistant.container) {
+ assistant.container.collapse();
}
-
- // 设置自动折叠定时器
- assistant._autoCollapseTimer = setTimeout(() => {
- // 再次检查条件
- if (!assistant.isCollapsed && !assistant.isTransitioning &&
- !this._checkAssistantActiveState(assistant) && !assistant.isMouseOver) {
-
- const containerDiv = assistant.element;
-
- // 保存当前宽度用于展开动画
- if (containerDiv.offsetWidth > 0) {
- containerDiv.style.setProperty('--expanded-width', `${containerDiv.offsetWidth}px`);
- }
-
- // 设置过渡状态
- assistant.isTransitioning = true;
-
- // 直接添加折叠类
- containerDiv.classList.add('collapsed');
- assistant.isCollapsed = true;
-
- // 显示悬停区域,用于检测鼠标悬停以展开UI
- if (assistant.hoverArea) {
- assistant.hoverArea.style.display = 'block';
- }
-
- // 动画结束后重置过渡状态
- setTimeout(() => {
- assistant.isTransitioning = false;
- }, 300);
- }
-
- assistant._autoCollapseTimer = null;
- }, 1000);
}
+
+
+
/**
* 添加带图标的按钮
*/
@@ -1477,12 +1594,78 @@ class ImageCaption {
/**
* 设置UI位置
+ * 支持 Vue node2.0 和 litegraph.js 两种渲染模式
*/
_setupUIPosition(assistant) {
if (!assistant?.element || !assistant?.node) return;
const containerDiv = assistant.element;
+ const renderMode = nodeMountService.detectRenderMode();
+ // 保存渲染模式到assistant
+ assistant._renderMode = renderMode;
+
+ if (renderMode === RENDER_MODE.VUE_NODES) {
+ // Vue node2.0 模式:使用相对定位挂载到节点容器内
+ this._setupUIPositionVueNodes(assistant, containerDiv);
+ } else {
+ // litegraph.js 模式:使用fixed定位和画布坐标计算
+ this._setupUIPositionLitegraph(assistant, containerDiv);
+ }
+ }
+
+ /**
+ * Vue node2.0 模式下的定位逻辑
+ */
+ _setupUIPositionVueNodes(assistant, containerDiv) {
+ const containerInfo = nodeMountService.findImageNodeContainer(assistant.node);
+
+ if (!containerInfo || !containerInfo.container) {
+ logger.warn(`[图像小助手-Vue定位] 未找到节点容器 | ID: ${assistant.node.id}`);
+ // 降级到litegraph模式
+ this._setupUIPositionLitegraph(assistant, containerDiv);
+ return;
+ }
+
+ const { container: nodeContainer } = containerInfo;
+
+ // Vue模式:使用绝对定位,挂载到节点容器内
+ containerDiv.style.position = 'absolute';
+ containerDiv.style.left = '6px';
+ containerDiv.style.bottom = '6px';
+ containerDiv.style.right = 'auto';
+ containerDiv.style.top = 'auto';
+ containerDiv.style.zIndex = '10';
+
+ // 移除fixed定位相关样式
+ containerDiv.style.transform = 'none';
+
+ // 添加Vue模式标记类
+ containerDiv.classList.add('vue-node-mode');
+
+ // 从document.body移除(如果之前挂载过)
+ if (containerDiv.parentElement === document.body) {
+ document.body.removeChild(containerDiv);
+ }
+
+ // 挂载到节点容器
+ nodeContainer.appendChild(containerDiv);
+
+ // 添加清理函数
+ assistant._eventCleanupFunctions = assistant._eventCleanupFunctions || [];
+ assistant._eventCleanupFunctions.push(() => {
+ if (containerDiv && containerDiv.parentElement) {
+ containerDiv.parentElement.removeChild(containerDiv);
+ }
+ });
+
+ logger.debug(`[图像小助手-Vue定位] 完成 | 节点ID: ${assistant.node.id}`);
+ }
+
+ /**
+ * litegraph.js 模式下的定位逻辑(原有逻辑)
+ */
+ _setupUIPositionLitegraph(assistant, containerDiv) {
// 更新位置的函数
const updatePosition = () => {
if (!assistant.element || !assistant.node) return;
@@ -1531,7 +1714,11 @@ class ImageCaption {
containerDiv.style.setProperty('--assistant-scale', scale);
} catch (error) {
- logger.error(`更新小助手位置失败: ${error.message}`);
+ // 仅在首次出错时记录
+ if (!assistant._lastPositionError) {
+ logger.error(() => `更新小助手位置失败: ${error.message}`);
+ assistant._lastPositionError = Date.now();
+ }
}
};
@@ -1549,17 +1736,18 @@ class ImageCaption {
// 监听画布变化
if (app.canvas) {
// 监听画布重绘
+ // 优化:使用requestAnimationFrame在每一帧更新位置,或者直接利用 LiteGraph 的渲染循环
+ // 这里为了跟随平滑,直接在 drawBackground 钩子中更新
const originalDrawBackground = app.canvas.onDrawBackground;
app.canvas.onDrawBackground = function () {
const ret = originalDrawBackground?.apply(this, arguments);
- // 直接调用updatePosition确保位置准确
updatePosition();
return ret;
};
// 添加画布重绘清理函数
assistant._eventCleanupFunctions.push(() => {
- if (originalDrawBackground) {
+ if (app.canvas.onDrawBackground === arguments.callee) {
app.canvas.onDrawBackground = originalDrawBackground;
}
});
@@ -1797,203 +1985,11 @@ class ImageCaption {
* 设置展开折叠事件
*/
_setupCollapseExpandEvents(assistant) {
- if (!assistant?.element) return;
-
- const containerDiv = assistant.element;
-
- // 记录原始宽度,用于展开动画
- const saveOriginalWidth = () => {
- if (!assistant.isCollapsed && containerDiv.offsetWidth > 0) {
- containerDiv.style.setProperty('--expanded-width', `${containerDiv.offsetWidth}px`);
- }
- };
-
- // 延迟保存宽度,确保DOM已完全渲染
- setTimeout(saveOriginalWidth, 300);
-
- // 折叠函数
- const collapseAssistant = () => {
- if (assistant.isCollapsed || assistant.isTransitioning) return;
-
- // 保存当前宽度用于展开动画
- saveOriginalWidth();
- assistant.isTransitioning = true;
-
- // 直接添加折叠类
- containerDiv.classList.add('collapsed');
- assistant.isCollapsed = true;
-
- // 显示悬停区域,用于检测鼠标悬停以展开UI
- assistant.hoverArea.style.display = 'block';
-
- // 动画结束后重置过渡状态
- setTimeout(() => {
- assistant.isTransitioning = false;
- }, 300);
- };
-
- // 展开函数
- const expandAssistant = () => {
- if (!assistant.isCollapsed || assistant.isTransitioning) return;
-
- assistant.isTransitioning = true;
-
- // 隐藏悬停区域,避免覆盖按钮
- assistant.hoverArea.style.display = 'none';
-
- // 计算展开后的宽度
- let targetWidth = containerDiv.style.getPropertyValue('--expanded-width');
-
- // 如果没有保存宽度或需要重新计算
- if (!targetWidth || targetWidth === '') {
- // 先移除折叠类,但保持不可见状态进行测量
- containerDiv.classList.remove('collapsed');
- const originalDisplay = containerDiv.style.display;
- const originalOpacity = containerDiv.style.opacity;
-
- // 临时设置样式以便测量
- containerDiv.style.opacity = '0';
- containerDiv.style.display = 'flex';
- containerDiv.style.position = 'fixed';
-
- // 强制回流并测量
- void containerDiv.offsetWidth;
-
- // 获取自然宽度
- const naturalWidth = containerDiv.offsetWidth;
- targetWidth = naturalWidth + 'px';
-
- // 保存宽度供将来使用
- containerDiv.style.setProperty('--expanded-width', targetWidth);
-
- // 恢复原始样式
- containerDiv.style.opacity = originalOpacity;
- containerDiv.style.position = 'fixed';
-
- // 重新应用折叠类以准备动画
- containerDiv.classList.add('collapsed');
- void containerDiv.offsetWidth; // 强制回流
- }
-
- // 手动设置宽度转换
- containerDiv.style.width = '28px'; // 起始宽度
-
- // 强制回流
- void containerDiv.offsetWidth;
-
- // 移除折叠类
- containerDiv.classList.remove('collapsed');
-
- // 设置目标宽度以触发过渡
- containerDiv.style.width = targetWidth;
-
- // 动画结束后清理
- setTimeout(() => {
- // 移除固定宽度,恢复自动宽度
- containerDiv.style.width = '';
- assistant.isCollapsed = false;
- assistant.isTransitioning = false;
- }, 300);
- };
-
- // 创建折叠定时器变量
- let collapseTimer = null;
- let autoCollapseTimer = null;
-
- // 鼠标离开容器时折叠
- const handleMouseLeave = () => {
- // 如果有活跃按钮,不折叠
- if (this._checkAssistantActiveState(assistant)) return;
-
- // 设置延时,避免鼠标短暂离开就触发折叠
- collapseTimer = setTimeout(() => {
- collapseAssistant();
- }, 500);
- };
-
- // 鼠标进入容器时取消折叠定时器
- const handleMouseEnter = () => {
- if (collapseTimer) {
- clearTimeout(collapseTimer);
- collapseTimer = null;
- }
-
- // 取消自动折叠定时器
- if (autoCollapseTimer) {
- clearTimeout(autoCollapseTimer);
- autoCollapseTimer = null;
- }
-
- // 如果当前是折叠状态,则展开
- if (assistant.isCollapsed) {
- expandAssistant();
- }
- };
-
- // 为容器添加鼠标事件
- const removeContainerMouseLeave = EventManager.addDOMListener(containerDiv, 'mouseleave', handleMouseLeave);
- const removeContainerMouseEnter = EventManager.addDOMListener(containerDiv, 'mouseenter', handleMouseEnter);
-
- // 为悬停区域添加鼠标事件
- const removeHoverAreaMouseEnter = EventManager.addDOMListener(assistant.hoverArea, 'mouseenter', handleMouseEnter);
-
- // 添加清理函数
- assistant._eventCleanupFunctions = assistant._eventCleanupFunctions || [];
- assistant._eventCleanupFunctions.push(removeContainerMouseLeave);
- assistant._eventCleanupFunctions.push(removeContainerMouseEnter);
- assistant._eventCleanupFunctions.push(removeHoverAreaMouseEnter);
-
- // 添加清理定时器的函数
- assistant._eventCleanupFunctions.push(() => {
- if (collapseTimer) {
- clearTimeout(collapseTimer);
- collapseTimer = null;
- }
- if (autoCollapseTimer) {
- clearTimeout(autoCollapseTimer);
- autoCollapseTimer = null;
- }
- });
-
- // 创建后自动折叠功能
- const setupAutoCollapse = () => {
- // 如果有活跃按钮,不自动折叠
- if (this._checkAssistantActiveState(assistant)) return;
-
- // 设置自动折叠定时器,1秒后自动折叠
- autoCollapseTimer = setTimeout(() => {
- // 再次检查是否有活跃按钮或鼠标悬停在容器上
- if (!this._checkAssistantActiveState(assistant) && !assistant.isMouseOver) {
- collapseAssistant();
- }
- }, 1000);
- };
-
- // 添加鼠标悬停状态跟踪
- assistant.isMouseOver = false;
- const trackMouseOver = () => {
- assistant.isMouseOver = true;
- };
- const trackMouseOut = () => {
- assistant.isMouseOver = false;
- };
-
- // 为容器和悬停区域添加鼠标悬停状态跟踪
- const removeContainerMouseOverTracking = EventManager.addDOMListener(containerDiv, 'mouseover', trackMouseOver);
- const removeContainerMouseOutTracking = EventManager.addDOMListener(containerDiv, 'mouseout', trackMouseOut);
- const removeHoverAreaMouseOverTracking = EventManager.addDOMListener(assistant.hoverArea, 'mouseover', trackMouseOver);
- const removeHoverAreaMouseOutTracking = EventManager.addDOMListener(assistant.hoverArea, 'mouseout', trackMouseOut);
-
- // 添加清理函数
- assistant._eventCleanupFunctions.push(removeContainerMouseOverTracking);
- assistant._eventCleanupFunctions.push(removeContainerMouseOutTracking);
- assistant._eventCleanupFunctions.push(removeHoverAreaMouseOverTracking);
- assistant._eventCleanupFunctions.push(removeHoverAreaMouseOutTracking);
-
- // 设置自动折叠(延迟执行,确保DOM已完全渲染)
- setTimeout(setupAutoCollapse, 500);
+ // Event handling is delegated to AssistantContainer
}
+
+
/**
* 设置按钮右键菜单
* @param {HTMLElement} button 按钮元素
diff --git a/js/modules/nodeHelpTranslator.js b/js/modules/nodeHelpTranslator.js
new file mode 100644
index 0000000..626ec4b
--- /dev/null
+++ b/js/modules/nodeHelpTranslator.js
@@ -0,0 +1,637 @@
+/**
+ * 节点帮助文档翻译模块 (Node Help Translator)
+ * 独立模块,用于检测 ComfyUI 侧边栏的节点文档翻译功能
+ */
+
+import { APIService } from "../services/api.js";
+import { logger } from "../utils/logger.js";
+import { UIToolkit } from "../utils/UIToolkit.js";
+import { createSplitButton, createTooltip } from "./uiComponents.js";
+import { PromptFormatter } from "../utils/promptFormatter.js";
+
+class NodeHelpTranslator {
+ constructor() {
+ this.observer = null;
+ this.isObserving = false;
+ this.stateCache = new Map();
+
+ this.currentHelpPanel = {
+ nodeName: null,
+ element: null,
+ bilingualSwitch: null
+ };
+ }
+
+ initialize() {
+ // 检查功能开关
+ if (typeof window !== 'undefined' && window.FEATURES && !window.FEATURES.nodeHelpTranslator) {
+ logger.log("[NodeHelpTranslator] 功能已禁用,跳过初始化");
+ return;
+ }
+
+ if (this.isObserving) return;
+
+ logger.log("[NodeHelpTranslator] 初始化监听器");
+
+ this.observer = new MutationObserver((mutations) => {
+ for (const mutation of mutations) {
+ if (mutation.addedNodes.length > 0) {
+ this._handleDomChange(mutation.addedNodes);
+ }
+ }
+ });
+
+ this.observer.observe(document.body, {
+ childList: true,
+ subtree: true
+ });
+
+ this.isObserving = true;
+ }
+
+ _handleDomChange(addedNodes) {
+ // 检查功能开关
+ if (typeof window !== 'undefined' && window.FEATURES && !window.FEATURES.nodeHelpTranslator) {
+ return;
+ }
+
+ const helpContent = document.querySelector('.node-help-content');
+ if (helpContent) {
+ this._checkAndInject();
+ }
+ }
+
+ _checkAndInject() {
+ // 检查功能开关
+ if (typeof window !== 'undefined' && window.FEATURES && !window.FEATURES.nodeHelpTranslator) {
+ return;
+ }
+
+ if (this._injectTimer) clearTimeout(this._injectTimer);
+ this._injectTimer = setTimeout(() => {
+ const helpContent = document.querySelector('.node-help-content');
+ if (!helpContent) return;
+
+ const container = helpContent.closest('.flex.flex-col');
+ if (container) {
+ const header = container.querySelector('.border-b');
+ if (header) {
+ const titleSpan = header.querySelector('span.font-semibold');
+ const currentNodeName = titleSpan ? titleSpan.textContent.trim() : 'Unknown';
+
+ const existingBtnContainer = header.querySelector('.pa-help-translate-btn-container');
+
+ if (!existingBtnContainer) {
+ const sampleText = helpContent.innerText.substring(0, 500);
+ const langDetect = PromptFormatter.detectLanguage(sampleText);
+
+ if (langDetect.from === 'en') {
+ this._injectTranslateButton(header, currentNodeName, helpContent);
+ } else {
+ logger.log(`[NodeHelpTranslator] 内容为中文或混合语言(${langDetect.from}→${langDetect.to}),不显示翻译按钮`);
+ }
+ } else {
+ const btn = existingBtnContainer.querySelector('button');
+ if (btn && btn.dataset.nodeName !== currentNodeName) {
+ logger.log(`[NodeHelpTranslator] 检测到节点切换: ${btn.dataset.nodeName} -> ${currentNodeName},重置按钮状态`);
+ this._resetButtonState(btn, currentNodeName);
+ }
+ }
+ }
+ }
+ }, 200);
+ }
+
+ _injectTranslateButton(header, nodeName, helpContent) {
+ if (!header) return;
+ if (header.querySelector('.pa-help-translate-btn-container')) return;
+
+ const btnContainer = document.createElement('div');
+ btnContainer.className = 'pa-help-translate-btn-container';
+
+ // 使用 SplitButton
+ const splitBtn = createSplitButton({
+ label: "翻译文档",
+ icon: "pi pi-language",
+ className: "p-splitbutton-compact p-splitbutton-primary",
+ onClick: (e) => {
+ const btn = splitBtn.container.querySelector('.p-button:first-child');
+ this._handleTranslateClick(e, btn);
+ },
+ items: this._getMenuItems(nodeName, 'bilingual')
+ });
+
+ const mainBtn = splitBtn.container.querySelector('.p-button:first-child');
+ mainBtn.classList.add('pa-translate-btn');
+ mainBtn.dataset.nodeName = nodeName;
+ mainBtn._paSplitBtn = splitBtn;
+ this.currentHelpPanel.splitBtn = splitBtn;
+
+ this._setButtonContent(mainBtn, 'translate');
+
+ btnContainer.appendChild(splitBtn.container);
+ header.appendChild(btnContainer);
+
+ logger.log(`[NodeHelpTranslator] 已注入翻译按钮(SplitBtn)到节点: ${nodeName}`);
+
+ if (helpContent) {
+ this._restoreFromCache(mainBtn, helpContent, nodeName);
+ }
+ }
+
+ _resetButtonState(btn, newNodeName) {
+ btn.dataset.nodeName = newNodeName;
+ this._setButtonContent(btn, 'translate');
+ btn.disabled = false;
+
+ if (btn._paSplitBtn) {
+ btn._paSplitBtn.updateMenu(this._getMenuItems(newNodeName, 'bilingual'));
+ }
+
+ const container = btn.closest('.flex.flex-col');
+ const helpContent = container ? container.querySelector('.node-help-content') : null;
+ if (helpContent) {
+ this._restoreFromCache(btn, helpContent, newNodeName);
+ }
+ }
+
+ _setButtonContent(btn, type) {
+ let label = '翻译';
+ let iconClass = 'pi pi-language pa-translate-btn-icon';
+ let spinning = false;
+ let tooltip = '';
+
+ switch (type) {
+ case 'translate':
+ label = '翻译';
+ iconClass = 'pi pi-language pa-translate-btn-icon';
+ tooltip = '使用提示词小助手翻译文档';
+ break;
+ case 'translating':
+ label = '翻译中...';
+ iconClass = 'pi pi-spinner pa-translate-btn-icon';
+ spinning = true;
+ tooltip = '正在翻译文档...';
+ break;
+ case 'translated':
+ label = '已翻译';
+ iconClass = 'pi pi-check pa-translate-btn-icon';
+ tooltip = '使用提示词小助手重新翻译';
+ break;
+ }
+
+ if (btn._paSplitBtn) {
+ btn._paSplitBtn.updateMainButton(label, iconClass, spinning);
+ } else {
+ btn.innerHTML = `
+
+ ${label}
+ `;
+ }
+
+ // 更新自定义 Tooltip
+ if (btn._paTooltip) {
+ btn._paTooltip.destroy();
+ btn._paTooltip = null;
+ }
+
+ if (tooltip) {
+ btn._paTooltip = createTooltip({
+ target: btn,
+ content: tooltip,
+ position: 'top'
+ });
+ }
+ }
+
+ async _handleTranslateClick(e, btn) {
+ e.stopPropagation();
+
+ const nodeName = btn.dataset.nodeName;
+ const header = btn.closest('.border-b');
+ if (!header) return;
+
+ const container = header.parentElement;
+ const contentEl = container.querySelector('.node-help-content');
+
+ if (!contentEl) {
+ UIToolkit.showStatusTip(btn, 'warn', '未找到帮助内容');
+ return;
+ }
+
+ // 允许重新翻译:无论当前状态如何,点击按钮都触发翻译
+ // 如果想要防抖或避免意外点击,可以检查 btn.disabled (已经在 _performTranslation 中处理)
+ await this._performTranslation(btn, contentEl, nodeName);
+ }
+
+ async _performTranslation(btn, contentEl, nodeName) {
+ this._setButtonContent(btn, 'translating');
+ btn.disabled = true;
+
+ try {
+ // 重置之前的翻译状态(如果有)
+ // 如果已经翻译过,先还原DOM,以便重新提取文本
+ const existingTranslatable = contentEl.querySelectorAll('.pa-translatable');
+ if (existingTranslatable.length > 0) {
+ existingTranslatable.forEach(el => {
+ if (el.dataset.paOriginal) {
+ el.innerHTML = el.dataset.paOriginal;
+ }
+ el.classList.remove('pa-translatable');
+ delete el.dataset.paTranslation;
+ // dataset.paOriginal 保留或删除皆可,保留着也没事,extractTextBlocks 不会利用它,而是读 innerText
+ // 但是为了干净,还是清理一下
+ delete el.dataset.paOriginal;
+ });
+ }
+
+ contentEl.classList.remove('pa-mode-bilingual', 'pa-mode-translation-only', 'pa-mode-original-only');
+
+ const textBlocks = this._extractTextBlocks(contentEl);
+ if (textBlocks.length === 0) {
+ UIToolkit.showStatusTip(btn, 'warn', '没有找到可翻译的内容');
+ this._setButtonContent(btn, 'translate'); // 恢复为待翻译状态
+ return;
+ }
+ // ... remainder is unchanged logic, but extractTextBlocks comes next
+
+
+ const textsToTranslate = textBlocks.map(block => block.text);
+ let translations = [];
+ let isBaidu = false;
+
+ // 1. 获取翻译配置,判断是否使用百度翻译
+ try {
+ const configResp = await fetch(APIService.getApiUrl('/config/translate'));
+ if (configResp.ok) {
+ const config = await configResp.json();
+ if (config.provider === 'baidu') {
+ isBaidu = true;
+ }
+ }
+ } catch (e) {
+ logger.warn(`[NodeHelpTranslator] 获取翻译配置失败: ${e.message},将默认使用LLM`);
+ }
+
+ // 2. 根据服务类型执行翻译
+ if (isBaidu) {
+ logger.log('[NodeHelpTranslator] 使用百度翻译API进行翻译');
+ // 百度批量翻译是串行请求,返回结果数组
+ const results = await APIService.batchBaiduTranslate(textsToTranslate, 'en', 'zh');
+
+ // 提取翻译结果,BaiduTranslateService 返回格式需与 LLM 保持一致 (data.translated)
+ // 如果某个请求失败,result.success 为 false,这里暂时填 null 跳过
+ translations = results.map(r => {
+ if (r && r.success && r.data && r.data.translated) {
+ return r.data.translated;
+ }
+ return null;
+ });
+
+ // 检查是否所有翻译都失败了
+ const successCount = translations.filter(t => t !== null).length;
+ if (successCount === 0 && textsToTranslate.length > 0) {
+ throw new Error('所有文本翻译失败 (Baidu)');
+ }
+
+ } else {
+ logger.log('[NodeHelpTranslator] 使用LLM API进行批量翻译');
+ const result = await APIService.llmBatchTranslate(textsToTranslate, 'en', 'zh');
+
+ if (result.success && result.data && result.data.translations) {
+ translations = result.data.translations;
+ } else {
+ throw new Error(result.error || '翻译接口返回错误');
+ }
+ }
+
+ // 3. 渲染结果
+ const cleanedTranslations = translations.map(t => t ? t.replace(/^\|\s*/, '') : t);
+ this._renderTranslations(textBlocks, cleanedTranslations, nodeName);
+
+ this.stateCache.set(nodeName, { translated: true, viewMode: 'bilingual' });
+
+ if (this.currentHelpPanel.splitBtn) {
+ this.currentHelpPanel.splitBtn.updateMenu(this._getMenuItems(nodeName, 'bilingual'));
+ }
+
+ this._setButtonContent(btn, 'translated');
+
+ } catch (error) {
+ logger.error(`[NodeHelpTranslator] 翻译失败: ${error.message}`);
+ UIToolkit.showStatusTip(btn, 'error', '翻译失败: ' + error.message);
+ this._setButtonContent(btn, 'translate');
+ btn.disabled = false;
+ } finally {
+ btn.disabled = false;
+ }
+ }
+
+ /**
+ * 提取文本块
+ */
+ _extractTextBlocks(rootEl) {
+ const blocks = [];
+ // 优化1:不提取 th (表头),因为无需翻译
+ const candidates = rootEl.querySelectorAll('p, li, h1, h2, h3, h4, h5, h6, td');
+
+ for (const el of candidates) {
+ const text = el.innerText.trim();
+ if (!text) continue;
+
+ // 过滤纯数字和标点
+ if (/^[0-9\s\p{P}]+$/u.test(text)) continue;
+
+ // 优化2:过滤看似“类型”定义的纯大写单词 (如 IMAGE, FLOAT, BOOLEAN, COMBO等)
+ // 允许特定符号如下划线,但不包含空格(通常类型是一个单词)
+ // 限制长度<30,防止误伤全大写的长句子
+ // 注意:参数名通常是 snake_case (小写),所以不会被过滤
+ if (/^[A-Z0-9_]+$/.test(text) && text.length < 30) continue;
+
+ if (el.closest('pre') || el.closest('code')) continue;
+ if (el.classList.contains('pa-translatable')) continue;
+ if (el.closest('.pa-translatable')) continue;
+
+ const hasBlockChildren = Array.from(el.children).some(child =>
+ ['P', 'DIV', 'LI', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'TABLE', 'UL', 'OL'].includes(child.tagName)
+ );
+ if (hasBlockChildren) continue;
+
+ blocks.push({ element: el, text: text });
+ }
+
+ return blocks;
+ }
+
+ _renderTranslations(blocks, translations, nodeName) {
+ // 保存用于缓存的数据
+ const cacheData = [];
+
+ blocks.forEach((block, index) => {
+ const translation = translations[index];
+ if (!translation) return;
+
+ const el = block.element;
+ el.classList.add('pa-translatable');
+
+ if (!el.dataset.paOriginal) {
+ el.dataset.paOriginal = el.innerHTML;
+ }
+ el.dataset.paTranslation = translation;
+
+ this._updateBlockContent(el, 'bilingual');
+
+ // 收集缓存数据(存储原始HTML的哈希或简单选择器作为标识可能不可靠,因为DOM结构变化)
+ // 这里我们使用一种简单策略:按顺序存储。
+ // 只要帮助文档内容没变,顺序提取的文本块也应该没变。
+ cacheData.push({
+ index: index, // 保存索引,以便恢复时匹配
+ translation: translation
+ });
+ });
+
+ // 保存到缓存
+ if (nodeName) {
+ this._saveToCache(nodeName, {
+ translations: cacheData,
+ timestamp: Date.now(),
+ bilingual: true
+ });
+ }
+ }
+
+ _updateBlockContent(el, mode) {
+ const original = el.dataset.paOriginal || '';
+ const translation = el.dataset.paTranslation || '';
+
+ // Wrap original content to allow toggling visibility
+ const wrappedOriginal = `${original}`;
+ const separator = ``;
+ const styledTranslation = `${translation.replace(/\n/g, '
')}`;
+
+ el.innerHTML = `${wrappedOriginal}${separator}${styledTranslation}`;
+ }
+
+ // --- 缓存管理 ---
+
+ _getCacheKey() {
+ return 'pa_node_help_translations';
+ }
+
+ _loadFromCache(nodeName) {
+ try {
+ const raw = sessionStorage.getItem(this._getCacheKey());
+ if (!raw) return null;
+ const cache = JSON.parse(raw);
+ return cache[nodeName] || null;
+ } catch (e) {
+ console.error('[NodeHelpTranslator] 读取缓存失败', e);
+ return null;
+ }
+ }
+
+ _saveToCache(nodeName, data) {
+ try {
+ const key = this._getCacheKey();
+ const raw = sessionStorage.getItem(key);
+ let cache = raw ? JSON.parse(raw) : {};
+
+ cache[nodeName] = data;
+
+ // 简单的清理策略:如果太大了,清空旧的(虽然sessionStorage限制是按域名,但也要防止无限增长)
+ // 这里暂不实现复杂LRU,假设用户单次会话查看的节点有限
+
+ sessionStorage.setItem(key, JSON.stringify(cache));
+ } catch (e) {
+ console.error('[NodeHelpTranslator] 写入缓存失败', e);
+ }
+ }
+
+ _restoreFromCache(btn, contentEl, nodeName) {
+ const cached = this._loadFromCache(nodeName);
+ if (!cached) return false;
+
+ // 检查是否过期(例如超过24小时?其实sessionStorage已经限制了会话生命周期,这里可以不检查时间)
+ // 但检查是否匹配当前内容结构很重要。
+ // 我们尝试按索引恢复。
+
+ const textBlocks = this._extractTextBlocks(contentEl);
+ if (textBlocks.length === 0) return false;
+
+ // 简单的完整性检查:如果缓存的索引超出了当前文本块数量,可能内容变了
+ const maxIndex = Math.max(...cached.translations.map(t => t.index));
+ if (maxIndex >= textBlocks.length) {
+ logger.warn(`[NodeHelpTranslator] 缓存与当前内容不匹配(索引溢出),跳过恢复: ${nodeName}`);
+ return false;
+ }
+
+ // 恢复翻译
+ // 构建全量的 translations 数组
+ // cached.translations 是稀疏的,只包含有翻译的块
+ const appliedTranslations = new Array(textBlocks.length).fill(null);
+ let matchCount = 0;
+
+ cached.translations.forEach(item => {
+ if (item.index < textBlocks.length) {
+ // 可选:对比一下原文内容是否大致匹配?(防止内容微调导致错位)
+ // 这里为了性能暂不对比原文,严格依赖提取顺序
+ appliedTranslations[item.index] = item.translation;
+ matchCount++;
+ }
+ });
+
+ if (matchCount === 0) return false;
+
+ // 渲染
+ this._renderTranslations(textBlocks, appliedTranslations, null); // passing null as nodeName to avoid recursive save
+
+ // 恢复状态
+ const viewMode = cached.viewMode || (cached.bilingual !== false ? 'bilingual' : 'translation-only');
+ this.stateCache.set(nodeName, { translated: true, viewMode: viewMode });
+
+ // 更新按钮状态
+ this._setButtonContent(btn, 'translated');
+
+ // 更新 SplitButton 菜单
+ if (btn._paSplitBtn) {
+ btn._paSplitBtn.updateMenu(this._getMenuItems(nodeName, viewMode));
+ }
+
+ // 应用显示模式
+ this._applyViewMode(contentEl, viewMode);
+
+ logger.log(`[NodeHelpTranslator] 已从缓存恢复翻译: ${nodeName}`);
+ return true;
+ }
+
+ cleanup() {
+ // 清理定时器
+ if (this._injectTimer) {
+ clearTimeout(this._injectTimer);
+ this._injectTimer = null;
+ }
+
+ // 停止并清理 MutationObserver
+ if (this.observer) {
+ this.observer.disconnect();
+ this.observer = null;
+ }
+ this.isObserving = false;
+
+ // 移除所有已注入的翻译按钮
+ const translationButtons = document.querySelectorAll('.pa-help-translate-btn-container');
+ translationButtons.forEach(btn => {
+ // 清理 tooltip
+ const mainBtn = btn.querySelector('.pa-translate-btn');
+ if (mainBtn && mainBtn._paTooltip) {
+ mainBtn._paTooltip.destroy();
+ mainBtn._paTooltip = null;
+ }
+ // 移除按钮容器
+ btn.remove();
+ });
+
+ // 清理状态缓存
+ this.stateCache.clear();
+
+ // 重置当前帮助面板引用
+ this.currentHelpPanel = {
+ nodeName: null,
+ element: null,
+ bilingualSwitch: null
+ };
+
+ logger.log("[NodeHelpTranslator] 已清理所有资源");
+ }
+
+ /**
+ * 应用视图模式
+ * @param {HTMLElement} contentEl 内容元素
+ * @param {string} mode 'bilingual' | 'translation-only' | 'original-only'
+ */
+ _applyViewMode(contentEl, mode) {
+ if (!contentEl) return;
+ contentEl.classList.remove('pa-mode-bilingual', 'pa-mode-translation-only', 'pa-mode-original-only');
+
+ switch (mode) {
+ case 'bilingual':
+ contentEl.classList.add('pa-mode-bilingual');
+ break;
+ case 'translation-only':
+ contentEl.classList.add('pa-mode-translation-only');
+ break;
+ case 'original-only':
+ contentEl.classList.add('pa-mode-original-only');
+ break;
+ }
+ }
+
+ /**
+ * 获取下拉菜单配置
+ * @param {string} nodeName
+ * @param {string} currentMode
+ * @returns {Array}
+ */
+ _getMenuItems(nodeName, currentMode) {
+ return [
+ {
+ label: '中英对照',
+ checked: currentMode === 'bilingual',
+ command: () => this._handleModeChange(nodeName, 'bilingual')
+ },
+ {
+ label: '显示译文',
+ checked: currentMode === 'translation-only',
+ command: () => this._handleModeChange(nodeName, 'translation-only')
+ },
+ {
+ label: '显示原文',
+ checked: currentMode === 'original-only',
+ command: () => this._handleModeChange(nodeName, 'original-only')
+ },
+ { separator: true },
+ {
+ label: '重新翻译',
+ icon: 'pi pi-refresh',
+ command: () => {
+ const btn = document.querySelector(`.pa-translate-btn[data-node-name="${nodeName}"]`);
+ if (btn) btn.click();
+ }
+ }
+ ];
+ }
+
+ async _handleModeChange(nodeName, mode) {
+ const state = this.stateCache.get(nodeName);
+ if (!state) return;
+
+ state.viewMode = mode;
+ this.stateCache.set(nodeName, state);
+
+ const btn = document.querySelector(`.pa-translate-btn[data-node-name="${nodeName}"]`);
+ if (!btn) return;
+
+ // Find header then panel then content
+ const header = btn.closest('.border-b') || btn.closest('.pa-help-translate-btn-container').parentElement;
+ if (!header) return;
+
+ // If header is inside a container which has sibling content
+ const container = header.parentElement;
+ const contentEl = container.querySelector('.node-help-content');
+
+ if (contentEl) {
+ this._applyViewMode(contentEl, mode);
+ }
+
+ const cachedData = this._loadFromCache(nodeName);
+ if (cachedData) {
+ this._saveToCache(nodeName, { ...cachedData, viewMode: mode });
+ }
+
+ if (this.currentHelpPanel.splitBtn) {
+ this.currentHelpPanel.splitBtn.updateMenu(this._getMenuItems(nodeName, mode));
+ }
+ }
+}
+
+export const nodeHelpTranslator = new NodeHelpTranslator();
diff --git a/js/modules/rulesConfigManager.js b/js/modules/rulesConfigManager.js
index 916f536..0fe6123 100644
--- a/js/modules/rulesConfigManager.js
+++ b/js/modules/rulesConfigManager.js
@@ -1,6 +1,6 @@
/**
* 规则配置管理器
- * 负责管理提示词规则配置弹窗和相关功能
+ * 负责管理规则规则配置弹窗和相关功能
*/
import { app } from "../../../../scripts/app.js";
@@ -10,22 +10,28 @@ import {
createSettingsDialog,
createFormGroup,
createInputGroup,
- createSelectGroup
-} from "./settings.js";
+ createSelectGroup,
+ createTextareaGroup,
+ createComboBoxGroup,
+ createSelectButtonGroup
+} from "./uiComponents.js";
+import { APIService } from "../services/api.js";
class RulesConfigManager {
constructor() {
this.systemPrompts = null;
- // ---存储提示词列表数据---
+ // ---存储规则列表数据---
this.expandPrompts = [];
this.zhVisionPrompts = [];
this.enVisionPrompts = [];
- // ---当前激活的提示词ID---
+ this.videoPrompts = [];
+ // ---当前激活的规则ID---
this.activeExpandPromptId = null;
this.activeZhVisionPromptId = null;
this.activeEnVisionPromptId = null;
+ this.activeVideoPromptId = null;
this.isDirty = false;
this.initialState = null; // 用于存储状态备份
@@ -37,11 +43,14 @@ class RulesConfigManager {
_backupState() {
this.initialState = {
expandPrompts: JSON.parse(JSON.stringify(this.expandPrompts)),
+ translatePrompts: JSON.parse(JSON.stringify(this.translatePrompts || [])),
zhVisionPrompts: JSON.parse(JSON.stringify(this.zhVisionPrompts)),
enVisionPrompts: JSON.parse(JSON.stringify(this.enVisionPrompts)),
+ videoPrompts: JSON.parse(JSON.stringify(this.videoPrompts || [])),
activeExpandPromptId: this.activeExpandPromptId,
activeZhVisionPromptId: this.activeZhVisionPromptId,
activeEnVisionPromptId: this.activeEnVisionPromptId,
+ activeVideoPromptId: this.activeVideoPromptId,
};
this.isDirty = false;
// 标记表单未修改
@@ -57,15 +66,19 @@ class RulesConfigManager {
_restoreState() {
if (this.initialState) {
this.expandPrompts = JSON.parse(JSON.stringify(this.initialState.expandPrompts));
+ this.translatePrompts = JSON.parse(JSON.stringify(this.initialState.translatePrompts || []));
this.zhVisionPrompts = JSON.parse(JSON.stringify(this.initialState.zhVisionPrompts));
this.enVisionPrompts = JSON.parse(JSON.stringify(this.initialState.enVisionPrompts));
+ this.videoPrompts = JSON.parse(JSON.stringify(this.initialState.videoPrompts || []));
this.activeExpandPromptId = this.initialState.activeExpandPromptId;
this.activeZhVisionPromptId = this.initialState.activeZhVisionPromptId;
this.activeEnVisionPromptId = this.initialState.activeEnVisionPromptId;
+ this.activeVideoPromptId = this.initialState.activeVideoPromptId;
}
this.isDirty = false;
}
+
/**
* 标记配置已修改
*/
@@ -87,7 +100,7 @@ class RulesConfigManager {
logger.debug('打开规则配置弹窗');
createSettingsDialog({
- title: '规则管理器',
+ title: '规则管理器',
dialogClassName: 'rules-config-dialog',
disableBackdropAndCloseOnClickOutside: true,
renderContent: (container) => {
@@ -155,241 +168,341 @@ class RulesConfigManager {
hiddenInput.value = 'initial';
form.appendChild(hiddenInput);
- // 1. 扩写规则配置
- const expandSection = this._createExpandSection();
+ // ---创建标签页容器---
+ const tabContainer = document.createElement('div');
+ tabContainer.className = 'rules-config-tabs';
- // 2. 反推规则配置
- const visionSection = this._createVisionSection();
+ // ---创建标签页头部包装容器(包含标签按钮和添加按钮)---
+ const tabHeaderWrapper = document.createElement('div');
+ tabHeaderWrapper.className = 'tab-header-wrapper';
- form.appendChild(expandSection);
- form.appendChild(visionSection);
+ // ---创建标签页头部---
+ const tabHeader = document.createElement('div');
+ tabHeader.className = 'tab-header';
+
+ // ---创建添加按钮容器---
+ const addButtonContainer = document.createElement('div');
+ addButtonContainer.className = 'tab-header-actions';
+
+ // 定义标签页配置(包含是否有添加按钮)
+ const tabs = [
+ { id: 'expand', title: '提示词优化规则', subtitle: '提示词优化润色提示词', addLabel: '添加提示词优化规则' },
+ { id: 'zhVision', title: '中文反推', subtitle: '图像反推中文提示词', addLabel: '添加中文反推规则' },
+ { id: 'enVision', title: '英文反推', subtitle: '图像反推英文提示词', addLabel: '添加英文反推规则' },
+ { id: 'video', title: '视频反推', subtitle: '将视频反推提示词', addLabel: '添加视频反推规则' },
+ { id: 'translate', title: '翻译规则', subtitle: '大模型翻译规则', addLabel: null }
+ ];
+
+ // 创建标签按钮
+ tabs.forEach((tab, index) => {
+ const button = this._createRuleTabButton(tab.id, tab.title, tab.subtitle);
+ if (index === 0) {
+ button.classList.add('active');
+ }
+ tabHeader.appendChild(button);
+ });
+
+ // 创建各标签页对应的添加按钮(初始只显示第一个)
+ tabs.forEach((tab, index) => {
+ if (tab.addLabel) {
+ const addButton = document.createElement('button');
+ addButton.className = 'p-button p-component p-button-sm tab-add-button';
+ addButton.type = 'button';
+ addButton.dataset.tab = tab.id;
+ addButton.innerHTML = `${tab.addLabel}`;
+ addButton.onclick = () => {
+ this._showPromptEditDialog(tab.id, null);
+ };
+ // 只显示第一个标签页的按钮
+ addButton.style.display = index === 0 ? 'inline-flex' : 'none';
+ addButtonContainer.appendChild(addButton);
+ }
+ });
+
+ tabHeaderWrapper.appendChild(tabHeader);
+ tabHeaderWrapper.appendChild(addButtonContainer);
+ tabContainer.appendChild(tabHeaderWrapper);
+
+ // ---创建标签页内容容器---
+ const tabContent = document.createElement('div');
+ tabContent.className = 'tab-content';
+
+ // 创建各个标签页内容
+ const expandPane = this._createExpandTabPane();
+ const zhVisionPane = this._createVisionTabPane('zhVision', '中文反推规则');
+ const enVisionPane = this._createVisionTabPane('enVision', '英文反推规则');
+ const videoPane = this._createVideoTabPane();
+ const translatePane = this._createTranslateTabPane();
+
+ tabContent.appendChild(expandPane);
+ tabContent.appendChild(zhVisionPane);
+ tabContent.appendChild(enVisionPane);
+ tabContent.appendChild(videoPane);
+ tabContent.appendChild(translatePane);
+
+ tabContainer.appendChild(tabContent);
+ form.appendChild(tabContainer);
container.appendChild(form);
- // 加载系统提示词配置
+ // 默认显示第一个标签页
+ this._switchRuleTab('expand', tabHeader, tabContent);
+
+ // 加载系统规则配置
this._loadSystemPrompts();
}
/**
- * 创建扩写规则配置部分
- * @returns {HTMLElement} 扩写规则配置部分的DOM元素
+ * 创建规则标签按钮
+ * @param {string} tabId 标签ID
+ * @param {string} title 标签标题
+ * @param {string} subtitle 标签副标题
+ * @returns {HTMLElement} 标签按钮元素
*/
- _createExpandSection() {
- // 扩写规则配置
- const expandSection = createFormGroup('扩写规则配置');
- const expandHeader = expandSection.querySelector('.settings-form-section-header');
+ _createRuleTabButton(tabId, title, subtitle) {
+ const button = document.createElement('button');
+ button.className = 'tab-button';
+ button.dataset.tab = tabId;
+ button.type = 'button';
- // 创建添加按钮
- const addButton = document.createElement('div');
- addButton.className = 'settings-refresh-button';
- addButton.title = '添加新提示词';
+ const titleEl = document.createElement('div');
+ titleEl.className = 'tab-title';
+ titleEl.textContent = title;
+ button.appendChild(titleEl);
- const addIcon = document.createElement('span');
- addIcon.className = 'pi pi-plus refresh-icon';
- addButton.appendChild(addIcon);
+ if (subtitle) {
+ const subtitleEl = document.createElement('div');
+ subtitleEl.className = 'tab-subtitle';
+ subtitleEl.textContent = subtitle;
+ button.appendChild(subtitleEl);
+ }
- const addText = document.createElement('span');
- addText.textContent = '添加提示词';
- addButton.appendChild(addText);
- expandHeader.appendChild(addButton);
+ // 点击切换标签
+ button.addEventListener('click', () => {
+ const tabHeader = button.parentElement;
+ // tabHeader 在 tab-header-wrapper 内,tabContent 是 wrapper 的下一个兄弟元素
+ const tabHeaderWrapper = tabHeader.parentElement;
+ const tabContent = tabHeaderWrapper.nextElementSibling;
+ this._switchRuleTab(tabId, tabHeader, tabContent);
+ });
- // 创建提示词列表容器
+ return button;
+ }
+
+ /**
+ * 切换规则标签页
+ * @param {string} tabId 标签ID
+ * @param {HTMLElement} header 标签头部容器
+ * @param {HTMLElement} contentContainer 内容容器
+ */
+ _switchRuleTab(tabId, header, contentContainer) {
+ // 更新标签按钮状态
+ header.querySelectorAll('.tab-button').forEach(btn => {
+ if (btn.dataset.tab === tabId) {
+ btn.classList.add('active');
+ } else {
+ btn.classList.remove('active');
+ }
+ });
+
+ // 显示对应内容
+ contentContainer.querySelectorAll('.tab-pane').forEach(pane => {
+ pane.style.display = pane.dataset.tab === tabId ? 'block' : 'none';
+ });
+
+ // 切换添加按钮的显示状态
+ const actionsContainer = header.parentElement?.querySelector('.tab-header-actions');
+ if (actionsContainer) {
+ actionsContainer.querySelectorAll('.tab-add-button').forEach(btn => {
+ btn.style.display = btn.dataset.tab === tabId ? 'inline-flex' : 'none';
+ });
+ }
+ }
+
+ /**
+ * 创建扩写规则标签页内容
+ * @returns {HTMLElement} 标签页内容元素
+ */
+ _createExpandTabPane() {
+ const pane = document.createElement('div');
+ pane.className = 'tab-pane';
+ pane.dataset.tab = 'expand';
+ pane.style.display = 'none';
+ pane.style.padding = '16px';
+
+ // 创建规则列表容器
const listContainer = document.createElement('div');
listContainer.className = 'prompt-list-container';
- // 创建列表头部
const listHeader = document.createElement('div');
listHeader.className = 'prompt-list-header';
listHeader.innerHTML = `
状态
- 提示词名称
- 提示词内容
+ 规则名称
+ 规则内容
操作
`;
-
- // 为头部单元格添加列宽调整手柄
this._addHeaderResizers(listHeader);
- // 创建滚动列表
const scrollList = document.createElement('div');
scrollList.className = 'prompt-scroll-list';
listContainer.appendChild(listHeader);
listContainer.appendChild(scrollList);
- expandSection.appendChild(listContainer);
+ pane.appendChild(listContainer);
// 存储列表引用
this.expandScrollList = scrollList;
- // 添加调试信息
- logger.debug('扩写提示词列表容器已创建');
-
- // 处理添加按钮点击
- addButton.onclick = () => {
- this._showPromptEditDialog('expand', null);
- };
-
- return expandSection;
+ return pane;
}
/**
- * 创建反推规则配置部分
- * @returns {HTMLElement} 反推规则配置部分的DOM元素
+ * 创建翻译规则标签页内容(只读编辑模式)
+ * @returns {HTMLElement} 标签页内容元素
*/
- _createVisionSection() {
- // 反推规则配置
- const visionSection = createFormGroup('反推规则配置');
- const visionHeader = visionSection.querySelector('.settings-form-section-header');
+ _createTranslateTabPane() {
+ const pane = document.createElement('div');
+ pane.className = 'tab-pane';
+ pane.dataset.tab = 'translate';
+ pane.style.display = 'none';
+ pane.style.padding = '16px';
- // 创建添加按钮并添加到标题栏
- const addButton = document.createElement('div');
- addButton.className = 'settings-refresh-button';
- addButton.title = '添加提示词';
- const addIcon = document.createElement('span');
- addIcon.className = 'pi pi-plus refresh-icon';
- addButton.appendChild(addIcon);
- const addText = document.createElement('span');
- addText.textContent = '添加提示词';
- addButton.appendChild(addText);
- visionHeader.appendChild(addButton);
+ // 创建翻译规则列表容器
+ const listContainer = document.createElement('div');
+ listContainer.className = 'prompt-list-container';
- // Tab容器
- const tabContainer = document.createElement('div');
- tabContainer.className = 'popup_tabs_container';
- const tabsScroll = document.createElement('div');
- tabsScroll.className = 'popup_tabs_scroll';
- const tabs = document.createElement('div');
- tabs.className = 'popup_tabs';
- const tabZH = document.createElement('div');
- tabZH.className = 'popup_tab active';
- tabZH.textContent = '中文反推规则';
- const tabEN = document.createElement('div');
- tabEN.className = 'popup_tab';
- tabEN.textContent = '英文反推规则';
- tabs.appendChild(tabZH);
- tabs.appendChild(tabEN);
- tabsScroll.appendChild(tabs);
- tabContainer.appendChild(tabsScroll);
-
- // 中文反推规则内容
- const tabContentZH = document.createElement('div');
- tabContentZH.className = 'popup_tab_content active';
-
- // 中文列表容器
- const zhListContainer = document.createElement('div');
- zhListContainer.className = 'prompt-list-container vision-prompt-list-container';
-
- const zhListHeader = document.createElement('div');
- zhListHeader.className = 'prompt-list-header';
- zhListHeader.innerHTML = `
- 状态
- 提示词名称
- 提示词内容
+ const listHeader = document.createElement('div');
+ listHeader.className = 'prompt-list-header';
+ listHeader.innerHTML = `
+ 规则名称
+ 规则内容
操作
`;
+ this._addHeaderResizers(listHeader);
- // 为头部单元格添加列宽调整手柄
- this._addHeaderResizers(zhListHeader);
+ const scrollList = document.createElement('div');
+ scrollList.className = 'prompt-scroll-list';
- const zhScrollList = document.createElement('div');
- zhScrollList.className = 'prompt-scroll-list';
+ listContainer.appendChild(listHeader);
+ listContainer.appendChild(scrollList);
+ pane.appendChild(listContainer);
+ // 创建提示信息
+ const notice = document.createElement('div');
+ notice.className = 'rule-pane-notice';
+ notice.innerHTML = ' 翻译规则仅支持编辑,不支持新增和删除';
+ pane.appendChild(notice);
- zhListContainer.appendChild(zhListHeader);
- zhListContainer.appendChild(zhScrollList);
+ // 存储列表引用
+ this.translateScrollList = scrollList;
- tabContentZH.appendChild(zhListContainer);
-
- // 存储中文列表引用
- this.zhVisionScrollList = zhScrollList;
-
- // 添加调试信息
- logger.debug('中文反推提示词列表容器已创建');
-
- // 英文反推规则内容
- const tabContentEN = document.createElement('div');
- tabContentEN.className = 'popup_tab_content';
-
- // 英文列表容器
- const enListContainer = document.createElement('div');
- enListContainer.className = 'prompt-list-container vision-prompt-list-container';
-
- const enListHeader = document.createElement('div');
- enListHeader.className = 'prompt-list-header';
- enListHeader.innerHTML = `
- 状态
- 提示词名称
- 提示词内容
- 操作
- `;
-
- // 为头部单元格添加列宽调整手柄
- this._addHeaderResizers(enListHeader);
-
- const enScrollList = document.createElement('div');
- enScrollList.className = 'prompt-scroll-list';
-
- enListContainer.appendChild(enListHeader);
- enListContainer.appendChild(enScrollList);
-
- tabContentEN.appendChild(enListContainer);
-
- // 存储英文列表引用
- this.enVisionScrollList = enScrollList;
-
- // 添加调试信息
- logger.debug('英文反推提示词列表容器已创建');
-
- // 设置Tab切换逻辑
- tabZH.onclick = () => {
- tabZH.classList.add('active');
- tabEN.classList.remove('active');
- tabContentZH.classList.add('active');
- tabContentEN.classList.remove('active');
- };
-
- tabEN.onclick = () => {
- tabEN.classList.add('active');
- tabZH.classList.remove('active');
- tabContentEN.classList.add('active');
- tabContentZH.classList.remove('active');
- };
-
- visionSection.appendChild(tabContainer);
- visionSection.appendChild(tabContentZH);
- visionSection.appendChild(tabContentEN);
-
- // 处理添加按钮点击 - 根据当前激活的tab决定类型
- addButton.onclick = () => {
- const activeTab = tabs.querySelector('.popup_tab.active');
- const isZhActive = activeTab && activeTab.textContent === '中文反推规则';
- this._showPromptEditDialog(isZhActive ? 'zhVision' : 'enVision', null);
- };
-
- return visionSection;
+ return pane;
}
/**
- * 加载系统提示词配置
+ * 创建反推规则标签页内容
+ * @param {string} type 类型 (zhVision/enVision)
+ * @param {string} title 标题
+ * @returns {HTMLElement} 标签页内容元素
+ */
+ _createVisionTabPane(type, title) {
+ const pane = document.createElement('div');
+ pane.className = 'tab-pane';
+ pane.dataset.tab = type;
+ pane.style.display = 'none';
+ pane.style.padding = '16px';
+
+ // 创建规则列表容器
+ const listContainer = document.createElement('div');
+ listContainer.className = 'prompt-list-container vision-prompt-list-container';
+
+ const listHeader = document.createElement('div');
+ listHeader.className = 'prompt-list-header';
+ listHeader.innerHTML = `
+ 状态
+ 规则名称
+ 规则内容
+ 操作
+ `;
+ this._addHeaderResizers(listHeader);
+
+ const scrollList = document.createElement('div');
+ scrollList.className = 'prompt-scroll-list';
+
+ listContainer.appendChild(listHeader);
+ listContainer.appendChild(scrollList);
+ pane.appendChild(listContainer);
+
+ // 存储列表引用
+ if (type === 'zhVision') {
+ this.zhVisionScrollList = scrollList;
+ } else {
+ this.enVisionScrollList = scrollList;
+ }
+
+ return pane;
+ }
+
+ /**
+ * 创建视频反推规则标签页内容
+ * @returns {HTMLElement} 标签页内容元素
+ */
+ _createVideoTabPane() {
+ const pane = document.createElement('div');
+ pane.className = 'tab-pane';
+ pane.dataset.tab = 'video';
+ pane.style.display = 'none';
+ pane.style.padding = '16px';
+
+ // 创建规则列表容器
+ const listContainer = document.createElement('div');
+ listContainer.className = 'prompt-list-container video-prompt-list-container';
+
+ const listHeader = document.createElement('div');
+ listHeader.className = 'prompt-list-header';
+ listHeader.innerHTML = `
+ 状态
+ 规则名称
+ 规则内容
+ 操作
+ `;
+ this._addHeaderResizers(listHeader);
+
+ const scrollList = document.createElement('div');
+ scrollList.className = 'prompt-scroll-list';
+
+ listContainer.appendChild(listHeader);
+ listContainer.appendChild(scrollList);
+ pane.appendChild(listContainer);
+
+ // 存储列表引用
+ this.videoScrollList = scrollList;
+
+ return pane;
+ }
+
+ /**
+ * 加载系统规则配置
*/
async _loadSystemPrompts() {
try {
- const response = await fetch('/prompt_assistant/api/config/system_prompts');
+ const response = await fetch(APIService.getApiUrl('/config/system_prompts'));
if (!response.ok) {
- throw new Error(`加载系统提示词配置失败: ${response.status} ${response.statusText}`);
+ throw new Error(`加载系统规则配置失败: ${response.status} ${response.statusText}`);
}
const data = await response.json();
this.systemPrompts = data;
- // 获取激活的提示词ID
+ // 获取激活的规则ID
const activePrompts = data.active_prompts || {};
const activeExpandId = activePrompts.expand;
const activeZhVisionId = activePrompts.vision_zh;
const activeEnVisionId = activePrompts.vision_en;
// 输出加载的激活ID
- logger.debug(`加载的激活提示词ID | expand:${activeExpandId || '无'} | vision_zh:${activeZhVisionId || '无'} | vision_en:${activeEnVisionId || '无'}`);
+ logger.debug(`加载的激活规则ID | expand:${activeExpandId || '无'} | vision_zh:${activeZhVisionId || '无'} | vision_en:${activeEnVisionId || '无'}`);
// 转换扩写规则数据
this.expandPrompts = [];
@@ -404,13 +517,16 @@ class RulesConfigManager {
this.expandPrompts.push({
id: key,
name: prompt.name || key, // 如果没有名称,使用键作为名称
+ tags: prompt.tags || [],
+ category: prompt.category || '',
+ showIn: prompt.showIn || ['frontend', 'node'],
content: prompt.content,
isActive: key === activeExpandId, // 根据active_prompts判断是否激活
order: this.originalExpandOrder.indexOf(key) // 保存原始顺序
});
});
- // 如果没有找到激活的提示词,则激活第一个
+ // 如果没有找到激活的规则,则激活第一个
if (!activeExpandId && this.expandPrompts.length > 0) {
this.expandPrompts[0].isActive = true;
this.activeExpandPromptId = this.expandPrompts[0].id;
@@ -429,7 +545,7 @@ class RulesConfigManager {
// 保存原始顺序
this.originalVisionOrder = Object.keys(data.vision_prompts);
- // 分别存储中文和英文提示词的键
+ // 分别存储中文和英文规则的键
const zhKeys = [];
const enKeys = [];
@@ -446,24 +562,30 @@ class RulesConfigManager {
}
});
- // 处理中文提示词
+ // 处理中文规则
zhKeys.forEach((key, index) => {
const prompt = data.vision_prompts[key];
this.zhVisionPrompts.push({
id: key,
name: prompt.name || key,
+ tags: prompt.tags || [],
+ category: prompt.category || '',
+ showIn: prompt.showIn || ['frontend', 'node'],
content: prompt.content,
isActive: key === activeZhVisionId,
order: this.originalVisionOrder.indexOf(key) // 保存原始顺序
});
});
- // 处理英文提示词
+ // 处理英文规则
enKeys.forEach((key, index) => {
const prompt = data.vision_prompts[key];
this.enVisionPrompts.push({
id: key,
name: prompt.name || key,
+ tags: prompt.tags || [],
+ category: prompt.category || '',
+ showIn: prompt.showIn || ['frontend', 'node'],
content: prompt.content,
isActive: key === activeEnVisionId,
order: this.originalVisionOrder.indexOf(key) // 保存原始顺序
@@ -474,7 +596,7 @@ class RulesConfigManager {
this.zhVisionPrompts.sort((a, b) => a.order - b.order);
this.enVisionPrompts.sort((a, b) => a.order - b.order);
- // 如果没有找到激活的中文提示词,则激活第一个
+ // 如果没有找到激活的中文规则,则激活第一个
if (!activeZhVisionId && this.zhVisionPrompts.length > 0) {
this.zhVisionPrompts[0].isActive = true;
this.activeZhVisionPromptId = this.zhVisionPrompts[0].id;
@@ -482,7 +604,7 @@ class RulesConfigManager {
this.activeZhVisionPromptId = activeZhVisionId;
}
- // 如果没有找到激活的英文提示词,则激活第一个
+ // 如果没有找到激活的英文规则,则激活第一个
if (!activeEnVisionId && this.enVisionPrompts.length > 0) {
this.enVisionPrompts[0].isActive = true;
this.activeEnVisionPromptId = this.enVisionPrompts[0].id;
@@ -491,57 +613,235 @@ class RulesConfigManager {
}
}
+ // 转换翻译规则数据(只读编辑模式)
+ this.translatePrompts = [];
+ if (data.translate_prompts) {
+ Object.keys(data.translate_prompts).forEach(key => {
+ const prompt = data.translate_prompts[key];
+ this.translatePrompts.push({
+ id: key,
+ name: key, // 使用键作为名称
+ content: prompt.content,
+ role: prompt.role || 'system',
+ isReadOnly: true // 标记为只读(不可增删)
+ });
+ });
+ }
+
+ // 转换视频反推规则数据
+ this.videoPrompts = [];
+ const activeVideoId = activePrompts.video;
+ if (data.video_prompts) {
+ const videoKeys = Object.keys(data.video_prompts);
+ videoKeys.forEach((key, index) => {
+ const prompt = data.video_prompts[key];
+ this.videoPrompts.push({
+ id: key,
+ name: prompt.name || key,
+ tags: prompt.tags || [],
+ category: prompt.category || '',
+ showIn: prompt.showIn || ['frontend', 'node'],
+ content: prompt.content,
+ isActive: key === activeVideoId,
+ order: index
+ });
+ });
+
+ // 如果没有找到激活的视频反推规则,则激活第一个
+ if (!activeVideoId && this.videoPrompts.length > 0) {
+ this.videoPrompts[0].isActive = true;
+ this.activeVideoPromptId = this.videoPrompts[0].id;
+ } else {
+ this.activeVideoPromptId = activeVideoId;
+ }
+ }
+
// 备份初始状态
this._backupState();
// 渲染列表
this._renderPromptLists();
+
} catch (error) {
- logger.error("加载系统提示词配置失败:", error);
+ logger.error("加载系统规则配置失败:", error);
app.extensionManager.toast.add({
severity: "error",
summary: "加载失败",
- detail: error.message || "加载系统提示词配置过程中发生错误",
+ detail: error.message || "加载系统规则配置过程中发生错误",
life: 3000
});
}
}
/**
- * 渲染提示词列表
+ * 渲染规则列表
*/
_renderPromptLists() {
- logger.debug(`开始渲染提示词列表 | 扩写提示词:${this.expandPrompts.length}个 | 中文反推:${this.zhVisionPrompts.length}个 | 英文反推:${this.enVisionPrompts.length}个`);
+ logger.debug(`开始渲染规则列表 | 提示词优化规则:${this.expandPrompts.length}个 | 翻译规则:${this.translatePrompts?.length || 0}个 | 中文反推:${this.zhVisionPrompts.length}个 | 英文反推:${this.enVisionPrompts.length}个`);
// 渲染扩写规则列表
if (this.expandScrollList) {
this._renderPromptList(this.expandScrollList, this.expandPrompts, 'expand');
- logger.debug(`渲染扩写提示词列表完成`);
+ logger.debug(`渲染提示词优化规则列表完成`);
} else {
- logger.error(`扩写提示词列表容器不存在`);
+ logger.error(`提示词优化规则列表容器不存在`);
+ }
+
+ // 渲染翻译规则列表(只读编辑模式)
+ if (this.translateScrollList) {
+ this._renderTranslateList(this.translateScrollList, this.translatePrompts || []);
+ logger.debug(`渲染翻译规则列表完成`);
}
// 渲染中文反推规则列表
if (this.zhVisionScrollList) {
this._renderPromptList(this.zhVisionScrollList, this.zhVisionPrompts, 'zhVision');
- logger.debug(`渲染中文反推提示词列表完成`);
+ logger.debug(`渲染中文反推规则列表完成`);
} else {
- logger.error(`中文反推提示词列表容器不存在`);
+ logger.error(`中文反推规则列表容器不存在`);
}
// 渲染英文反推规则列表
if (this.enVisionScrollList) {
this._renderPromptList(this.enVisionScrollList, this.enVisionPrompts, 'enVision');
- logger.debug(`渲染英文反推提示词列表完成`);
+ logger.debug(`渲染英文反推规则列表完成`);
} else {
- logger.error(`英文反推提示词列表容器不存在`);
+ logger.error(`英文反推规则列表容器不存在`);
+ }
+
+ // 渲染视频反推规则列表
+ if (this.videoScrollList) {
+ this._renderPromptList(this.videoScrollList, this.videoPrompts || [], 'video');
+ logger.debug(`渲染视频反推规则列表完成`);
}
}
+
/**
- * 渲染单个提示词列表
+ * 渲染翻译规则列表(只读编辑模式)
* @param {HTMLElement} container 列表容器
- * @param {Array} prompts 提示词数组
+ * @param {Array} prompts 翻译规则数组
+ */
+ _renderTranslateList(container, prompts) {
+ container.innerHTML = '';
+
+ prompts.forEach(prompt => {
+ const row = document.createElement('div');
+ row.className = 'prompt-list-row translate-row';
+ row.dataset.promptId = prompt.id;
+ row.dataset.type = 'translate';
+
+ // 名称列
+ const nameCell = document.createElement('div');
+ nameCell.className = 'prompt-list-cell name-cell';
+ nameCell.textContent = prompt.name;
+ nameCell.title = prompt.name;
+
+ // 内容列
+ const contentCell = document.createElement('div');
+ contentCell.className = 'prompt-list-cell content-cell';
+ contentCell.textContent = prompt.content;
+ contentCell.title = prompt.content;
+
+ // 操作列(只有编辑按钮)
+ const actionCell = document.createElement('div');
+ actionCell.className = 'prompt-list-cell action-cell';
+
+ const editBtn = document.createElement('button');
+ editBtn.className = 'prompt-action-btn edit-btn';
+ editBtn.innerHTML = '';
+ editBtn.title = '编辑翻译规则';
+ editBtn.onclick = (e) => {
+ e.stopPropagation();
+ this._showTranslateEditDialog(prompt.id);
+ };
+
+ actionCell.appendChild(editBtn);
+
+ row.appendChild(nameCell);
+ row.appendChild(contentCell);
+ row.appendChild(actionCell);
+
+ container.appendChild(row);
+ });
+ }
+
+ /**
+ * 显示翻译规则编辑对话框
+ * @param {string} promptId 翻译规则ID
+ */
+ _showTranslateEditDialog(promptId) {
+ const prompt = (this.translatePrompts || []).find(p => p.id === promptId);
+ if (!prompt) {
+ logger.error(`找不到翻译规则: ${promptId}`);
+ return;
+ }
+
+ createSettingsDialog({
+ title: '编辑翻译规则',
+ isConfirmDialog: true,
+ dialogClassName: 'translate-edit-dialog',
+ renderContent: (content) => {
+ content.className += ' dialog-form-content';
+ content.style.padding = '1rem';
+ content.style.display = 'flex';
+ content.style.flexDirection = 'column';
+ content.style.flex = '1';
+
+ // 显示规则名称(只读)
+ const nameGroup = document.createElement('div');
+ nameGroup.className = 'settings-form-group-item';
+ nameGroup.innerHTML = `
+
+
+
+
+ `;
+ nameGroup.style.marginBottom = '10px';
+
+ // 创建内容编辑区
+ const contentTextarea = createTextareaGroup('规则内容', '请输入翻译规则内容', 10);
+ contentTextarea.group.style.flex = '1';
+ contentTextarea.group.style.display = 'flex';
+ contentTextarea.group.style.flexDirection = 'column';
+ contentTextarea.textarea.value = prompt.content || '';
+
+ content.appendChild(nameGroup);
+ content.appendChild(contentTextarea.group);
+
+ content.contentTextarea = contentTextarea.textarea;
+ },
+ onSave: (content) => {
+ const newContent = content.contentTextarea.value.trim();
+
+ if (!newContent) {
+ app.extensionManager.toast.add({
+ severity: "error",
+ summary: "编辑失败",
+ detail: "规则内容不能为空",
+ life: 3000
+ });
+ return false;
+ }
+
+ // 更新翻译规则内容
+ prompt.content = newContent;
+
+ // 标记为已修改
+ this._setDirty();
+
+ // 重新渲染列表
+ this._renderPromptLists();
+
+ return true;
+ }
+ });
+ }
+
+ /**
+ * 渲染单个规则列表
+ * @param {HTMLElement} container 列表容器
+ * @param {Array} prompts 规则数组
* @param {string} type 类型
*/
_renderPromptList(container, prompts, type) {
@@ -633,46 +933,48 @@ class RulesConfigManager {
}
/**
- * 显示提示词编辑对话框
+ * 获取已存在的分类列表
+ * 从所有规则类型中提取已使用的分类
+ * @returns {Array<{value: string, text: string}>} 分类选项列表
+ */
+ _getExistingCategories() {
+ const categories = new Set();
+
+ // 从所有规则类型中提取分类
+ const allPrompts = [
+ ...this.expandPrompts,
+ ...this.zhVisionPrompts,
+ ...this.enVisionPrompts,
+ ...this.videoPrompts
+ ];
+
+ allPrompts.forEach(prompt => {
+ if (prompt.category && typeof prompt.category === 'string') {
+ categories.add(prompt.category);
+ }
+ });
+
+ // 转换为下拉选项格式
+ return Array.from(categories)
+ .sort() // 按字母顺序排序
+ .map(cat => ({ value: cat, text: cat }));
+ }
+
+ /**
+ * 显示规则编辑对话框
* @param {string} type 类型
- * @param {string|null} promptId 提示词ID,null表示新建
+ * @param {string|null} promptId 规则ID,null表示新建
*/
_showPromptEditDialog(type, promptId) {
this._showPromptConfigDialog(type, promptId);
}
- /**
- * 创建多行文本输入框组
- * @param {string} label 标签文本
- * @param {string} placeholder 占位符文本
- * @returns {Object} 包含 group 和 textarea 的对象
- */
- _createTextareaGroup(label, placeholder) {
- const group = document.createElement('div');
- group.className = 'settings-form-group';
- const labelElem = document.createElement('label');
- labelElem.className = 'settings-form-label';
- labelElem.textContent = label;
-
- const textarea = document.createElement('textarea');
- textarea.className = 'p-inputtext p-component settings-form-textarea';
- textarea.placeholder = placeholder;
- textarea.rows = 8;
- textarea.style.resize = 'vertical';
- textarea.style.minHeight = '150px';
- textarea.style.height = 'calc(100% - 30px)';
-
- group.appendChild(labelElem);
- group.appendChild(textarea);
-
- return { group, textarea };
- }
/**
- * 显示通用的提示词配置弹窗
+ * 显示通用的规则配置弹窗
* @param {string} defaultType 默认类型
- * @param {string|null} promptId 提示词ID,null表示新建
+ * @param {string|null} promptId 规则ID,null表示新建
*/
_showPromptConfigDialog(defaultType = 'expand', promptId = null) {
const isEdit = !!promptId;
@@ -686,11 +988,13 @@ class RulesConfigManager {
editData = this.zhVisionPrompts.find(p => p.id === promptId);
} else if (defaultType === 'enVision') {
editData = this.enVisionPrompts.find(p => p.id === promptId);
+ } else if (defaultType === 'video') {
+ editData = this.videoPrompts.find(p => p.id === promptId);
}
}
createSettingsDialog({
- title: isEdit ? '编辑提示词' : '添加提示词',
+ title: isEdit ? '编辑规则' : '添加规则',
isConfirmDialog: true,
dialogClassName: 'prompt-edit-dialog',
disableBackdropAndCloseOnClickOutside: true,
@@ -698,43 +1002,94 @@ class RulesConfigManager {
cancelButtonText: '取消',
renderContent: (content) => {
content.className += ' dialog-form-content';
- content.style.padding = '1rem';
+ content.style.padding = '10px 40px'; // 增加两侧留白
content.style.display = 'flex';
content.style.flexDirection = 'column';
content.style.flex = '1';
+ content.style.overflow = 'hidden'; // 整体不滚动,内部 textarea 滚动
- // 创建提示词名称输入框
- const nameInput = createInputGroup('提示词名称', '请输入提示词名称');
+ // 创建规则名称输入框
+ const nameInput = createInputGroup('规则名称', '请输入规则名称');
nameInput.group.style.marginBottom = '10px';
if (isEdit && editData) {
nameInput.input.value = editData.name || '';
}
- // 创建提示词类型下拉框
+ // 创建规则类型下拉框
const typeOptions = [
- { value: 'expand', text: '扩写' },
+ { value: 'expand', text: '提示词优化' },
{ value: 'zhVision', text: '反推(中文)' },
- { value: 'enVision', text: '反推(英文)' }
+ { value: 'enVision', text: '反推(英文)' },
+ { value: 'video', text: '视频反推' }
];
- const typeSelect = createSelectGroup('提示词类型', typeOptions, defaultType);
+ const typeSelect = createSelectGroup('规则类型', typeOptions, defaultType);
typeSelect.group.style.marginBottom = '10px';
- // 创建提示词内容多行输入框
- const contentTextarea = this._createTextareaGroup('提示词内容', '请输入提示词内容');
+ // 创建分类选择框(可输入)
+ const categoryOptions = this._getExistingCategories();
+ const initialCategory = (isEdit && editData) ? (editData.category || '') : '';
+ const categoryComboBox = createComboBoxGroup('分类', categoryOptions, initialCategory, {
+ placeholder: '输入或选择分类(可留空)',
+ emptyText: '暂无分类'
+ });
+ categoryComboBox.group.style.flex = '1';
+ categoryComboBox.group.style.marginBottom = '0';
+
+ // 创建显示位置选择组件
+ const showInOptions = [
+ { value: 'frontend', label: '小助手上显示' },
+ { value: 'node', label: '节点上显示' }
+ ];
+ // 获取初始值:编辑时从规则数据读取,新建时默认都选中
+ const initialShowIn = (isEdit && editData && editData.showIn)
+ ? editData.showIn
+ : ['frontend', 'node'];
+ const showInSelectButton = createSelectButtonGroup('', showInOptions, initialShowIn, {
+ mode: 'multiple',
+ size: 'small',
+ allowEmpty: true
+ });
+ showInSelectButton.group.style.marginBottom = '0';
+
+ // 创建行容器放置分类和显示位置组件
+ const categoryRow = document.createElement('div');
+ categoryRow.style.display = 'flex';
+ categoryRow.style.flexDirection = 'row';
+ categoryRow.style.alignItems = 'flex-end';
+ categoryRow.style.gap = '1rem';
+ categoryRow.style.marginBottom = '10px';
+ categoryRow.appendChild(categoryComboBox.group);
+ categoryRow.appendChild(showInSelectButton.group);
+
+ // 创建规则内容多行输入框
+ const contentTextarea = createTextareaGroup('规则内容', '请输入规则内容');
contentTextarea.group.style.marginBottom = '0';
contentTextarea.group.style.flex = '1';
contentTextarea.group.style.display = 'flex';
contentTextarea.group.style.flexDirection = 'column';
+
+ // 确保 textarea 填充容器
+ const textareaContainer = contentTextarea.group.querySelector('.float-label-container');
+ if (textareaContainer) {
+ textareaContainer.style.flex = '1';
+ textareaContainer.style.display = 'flex';
+ textareaContainer.style.flexDirection = 'column';
+ contentTextarea.textarea.style.flex = '1';
+ }
+
if (isEdit && editData) {
contentTextarea.textarea.value = editData.content || '';
}
content.appendChild(nameInput.group);
content.appendChild(typeSelect.group);
+ content.appendChild(categoryRow);
content.appendChild(contentTextarea.group);
content.nameInput = nameInput.input;
content.typeSelect = typeSelect.select;
+ content.categoryInput = categoryComboBox.input;
+ content.showInSelectButton = showInSelectButton;
content.contentTextarea = contentTextarea.textarea;
// 聚焦名称输入框
@@ -745,13 +1100,15 @@ class RulesConfigManager {
onSave: (content) => {
const name = content.nameInput.value.trim();
const type = content.typeSelect.value;
+ const category = content.categoryInput.value.trim();
+ const showIn = content.showInSelectButton.getValue();
const promptContent = content.contentTextarea.value.trim();
if (!name || !promptContent) {
app.extensionManager.toast.add({
severity: "error",
summary: isEdit ? "编辑失败" : "添加失败",
- detail: "提示词名称和内容不能为空",
+ detail: "规则名称和内容不能为空",
life: 3000
});
return;
@@ -765,8 +1122,10 @@ class RulesConfigManager {
targetArray = this.zhVisionPrompts;
} else if (type === 'enVision') {
targetArray = this.enVisionPrompts;
+ } else if (type === 'video') {
+ targetArray = this.videoPrompts;
} else {
- logger.error(`未知的提示词类型: ${type}`);
+ logger.error(`未知的规则类型: ${type}`);
return;
}
@@ -779,7 +1138,7 @@ class RulesConfigManager {
app.extensionManager.toast.add({
severity: "error",
summary: isEdit ? "编辑失败" : "添加失败",
- detail: `提示词名称"${name}"已存在`,
+ detail: `规则名称"${name}"已存在`,
life: 3000
});
return;
@@ -805,7 +1164,7 @@ class RulesConfigManager {
}
// 添加到新数组
- // 为不同类型的提示词设置不同的ID前缀
+ // 为不同类型的规则设置不同的ID前缀
let newId;
if (type === 'expand') {
newId = 'expand_' + name.replace(/\s+/g, '_');
@@ -820,16 +1179,20 @@ class RulesConfigManager {
const newPrompt = {
id: newId,
name: name,
+ category: category,
+ showIn: showIn,
content: promptContent,
isActive: false,
order: targetArray.length // 设置顺序为当前数组长度
};
targetArray.push(newPrompt);
- logger.debug(`跨类型编辑提示词 | 原类型:${defaultType} | 新类型:${type} | 新ID:${newId}`);
+ logger.debug(`跨类型编辑规则 | 原类型:${defaultType} | 新类型:${type} | 新ID:${newId}`);
} else {
// 同类型编辑,直接更新
const oldName = editData.name;
editData.name = name;
+ editData.category = category;
+ editData.showIn = showIn;
editData.content = promptContent;
// 如果名称发生了变化,则更新ID
@@ -844,12 +1207,14 @@ class RulesConfigManager {
newId = 'vision_zh_' + name.replace(/\s+/g, '_');
} else if (type === 'enVision') {
newId = 'vision_en_' + name.replace(/\s+/g, '_');
+ } else if (type === 'video') {
+ newId = 'video_' + name.replace(/\s+/g, '_');
} else {
newId = name;
}
editData.id = newId;
- logger.debug(`更新提示词ID | 类型:${type} | 旧ID:${oldId} | 新ID:${newId}`);
+ logger.debug(`更新规则ID | 类型:${type} | 旧ID:${oldId} | 新ID:${newId}`);
}
}
}
@@ -863,6 +1228,8 @@ class RulesConfigManager {
newId = 'vision_zh_' + name.replace(/\s+/g, '_');
} else if (type === 'enVision') {
newId = 'vision_en_' + name.replace(/\s+/g, '_');
+ } else if (type === 'video') {
+ newId = 'video_' + name.replace(/\s+/g, '_');
} else {
newId = name;
}
@@ -870,6 +1237,8 @@ class RulesConfigManager {
const newPrompt = {
id: newId,
name: name,
+ category: category,
+ showIn: showIn,
content: promptContent,
isActive: false, // 默认不激活
order: targetArray.length // 设置顺序为当前数组长度,确保添加到末尾
@@ -877,7 +1246,7 @@ class RulesConfigManager {
targetArray.push(newPrompt);
// 输出调试信息
- logger.debug(`添加新提示词 | 类型:${type} | ID:${newId} | 顺序:${newPrompt.order} | 数组长度:${targetArray.length}`);
+ logger.debug(`添加新规则 | 类型:${type} | ID:${newId} | 顺序:${newPrompt.order} | 数组长度:${targetArray.length}`);
}
// 标记为已修改
@@ -900,7 +1269,7 @@ class RulesConfigManager {
}
/**
- * 刷新所有提示词列表
+ * 刷新所有规则列表
* 注意:为了保持一致性,建议直接使用_renderPromptLists方法
*/
_refreshPromptLists() {
@@ -909,9 +1278,9 @@ class RulesConfigManager {
}
/**
- * 切换提示词激活状态
+ * 切换规则激活状态
* @param {string} type 类型
- * @param {string} promptId 提示词ID
+ * @param {string} promptId 规则ID
*/
_togglePromptActive(type, promptId) {
// 获取对应的数据数组
@@ -935,11 +1304,17 @@ class RulesConfigManager {
activeIdProperty = 'activeEnVisionPromptId';
configKey = 'vision_en';
break;
+ case 'video':
+ dataArray = this.videoPrompts;
+ activeIdProperty = 'activeVideoPromptId';
+ configKey = 'video';
+ break;
default:
return;
}
- // 找到目标提示词
+
+ // 找到目标规则
const targetPrompt = dataArray.find(p => p.id === promptId);
if (!targetPrompt) return;
@@ -947,18 +1322,18 @@ class RulesConfigManager {
const oldActiveId = this[activeIdProperty];
const wasActive = targetPrompt.isActive;
- // 如果当前提示词已激活,则取消激活
+ // 如果当前规则已激活,则取消激活
if (targetPrompt.isActive) {
targetPrompt.isActive = false;
this[activeIdProperty] = null;
- logger.debug(`取消激活提示词 | 类型:${type} | ID:${promptId} | 配置键:${configKey}`);
+ logger.debug(`取消激活规则 | 类型:${type} | ID:${promptId} | 配置键:${configKey}`);
} else {
- // 取消其他提示词的激活状态
+ // 取消其他规则的激活状态
dataArray.forEach(p => p.isActive = false);
- // 激活当前提示词
+ // 激活当前规则
targetPrompt.isActive = true;
this[activeIdProperty] = promptId;
- logger.debug(`激活提示词 | 类型:${type} | ID:${promptId} | 配置键:${configKey} | 旧ID:${oldActiveId}`);
+ logger.debug(`激活规则 | 类型:${type} | ID:${promptId} | 配置键:${configKey} | 旧ID:${oldActiveId}`);
}
// 标记为已修改
@@ -968,22 +1343,22 @@ class RulesConfigManager {
this._renderPromptLists();
// 输出激活状态变化
- logger.debug(`激活状态变化 | 类型:${type} | 提示词:${targetPrompt.name} | ID:${promptId} | 旧状态:${wasActive} | 新状态:${targetPrompt.isActive} | 实例属性:${this[activeIdProperty]}`);
+ logger.debug(`激活状态变化 | 类型:${type} | 规则:${targetPrompt.name} | ID:${promptId} | 旧状态:${wasActive} | 新状态:${targetPrompt.isActive} | 实例属性:${this[activeIdProperty]}`);
- // 如果系统提示词对象存在,直接更新其中的active_prompts
+ // 如果系统规则对象存在,直接更新其中的active_prompts
if (this.systemPrompts && this.systemPrompts.active_prompts) {
const oldValue = this.systemPrompts.active_prompts[configKey];
this.systemPrompts.active_prompts[configKey] = targetPrompt.isActive ? promptId : null;
- logger.debug(`更新系统提示词对象 | 配置键:${configKey} | 旧值:${oldValue} | 新值:${this.systemPrompts.active_prompts[configKey]}`);
+ logger.debug(`更新系统规则对象 | 配置键:${configKey} | 旧值:${oldValue} | 新值:${this.systemPrompts.active_prompts[configKey]}`);
}
- logger.debug(`${type} 提示词状态切换: ${promptId} -> ${targetPrompt.isActive ? '激活' : '取消激活'}`);
+ logger.debug(`${type} 规则状态切换: ${promptId} -> ${targetPrompt.isActive ? '激活' : '取消激活'}`);
}
/**
- * 删除提示词
+ * 删除规则
* @param {string} type 类型
- * @param {string} promptId 提示词ID
+ * @param {string} promptId 规则ID
*/
_deletePrompt(type, promptId) {
// 获取对应的数据数组
@@ -998,37 +1373,43 @@ class RulesConfigManager {
case 'enVision':
dataArray = this.enVisionPrompts;
break;
+ case 'video':
+ dataArray = this.videoPrompts;
+ break;
default:
return;
}
- // 找到要删除的提示词索引
+ // 找到要删除的规则索引
const index = dataArray.findIndex(p => p.id === promptId);
if (index === -1) return;
- // 获取提示词名称用于提示
+ // 获取规则名称用于提示
const promptName = dataArray[index].name;
- // 创建确认对话框
+ // 创建确认对话框(使用危险按钮样式)
createSettingsDialog({
- title: '确认删除',
+ title: '确认删除',
isConfirmDialog: true,
+ dialogClassName: 'confirm-dialog',
disableBackdropAndCloseOnClickOutside: true,
- saveButtonText: '确认',
+ saveButtonText: '删除',
+ saveButtonIcon: 'pi-trash',
+ isDangerButton: true,
cancelButtonText: '取消',
renderContent: (content) => {
content.style.textAlign = 'center';
content.style.padding = '1rem';
const confirmMessage = document.createElement('p');
- confirmMessage.textContent = `确定要删除提示词"${promptName}"吗?`;
+ confirmMessage.textContent = `确定要删除规则"${promptName}"吗?`;
confirmMessage.style.margin = '0';
confirmMessage.style.fontSize = '1rem';
content.appendChild(confirmMessage);
},
onSave: () => {
- // 从数组中删除提示词
+ // 从数组中删除规则
dataArray.splice(index, 1);
// 标记为已修改
@@ -1042,104 +1423,149 @@ class RulesConfigManager {
});
}
+
/**
* 将当前配置保存到服务器
* @returns {Promise} 保存操作的Promise
*/
async _saveConfigToServer() {
try {
- // 构建系统提示词配置
+ // 获取原始版本号(从加载时保存的 systemPrompts 获取)
+ const configVersion = this.systemPrompts?.__config_version || '2.0';
+
+ // 构建系统规则配置(保留版本号)
const systemPrompts = {
+ __config_version: configVersion,
expand_prompts: {},
- translate_prompts: {
- ZH: {
- role: "system",
- content: "你是一名AI绘画领域的提示词翻译专家,负责将用户提供的文本内容由{src_lang}准确地翻译成{dst_lang}。要求:1.完整翻译用户提供的所有文本,不要遗漏;2.保持格式,不要改变原文的书写结构、标点符号、权重标记格式【如(文本内容:1.2)】等;2.准确原文,使用准确、地道的{dst_lang}表达词汇和AI绘画领域的专业的术语;5.直接输出翻译结果,无需注释、说明。"
- }
- },
+ translate_prompts: {},
vision_prompts: {},
+ video_prompts: {},
active_prompts: {
expand: null,
vision_zh: null,
- vision_en: null
+ vision_en: null,
+ video: null
}
};
- // 创建一个有序的提示词列表
+ // 添加翻译规则(从 this.translatePrompts 动态构建)
+ (this.translatePrompts || []).forEach(prompt => {
+ systemPrompts.translate_prompts[prompt.id] = {
+ role: prompt.role || 'system',
+ content: prompt.content
+ };
+ });
+
+
+ // 创建一个有序的规则列表
const orderedExpandPrompts = [...this.expandPrompts].sort((a, b) => a.order - b.order);
- // 添加扩写提示词,并记录激活的提示词ID
+ // 添加扩写规则,并记录激活的规则ID
orderedExpandPrompts.forEach(prompt => {
- // 使用提示词ID作为键,而不是名称
systemPrompts.expand_prompts[prompt.id] = {
name: prompt.name,
+ tags: prompt.tags || [],
+ category: prompt.category || '',
+ showIn: prompt.showIn || ['frontend', 'node'],
role: "system",
content: prompt.content
};
- // 记录激活的提示词ID
+ // 记录激活的规则ID
if (prompt.isActive) {
systemPrompts.active_prompts.expand = prompt.id;
- logger.debug(`保存激活的扩写提示词ID: ${prompt.id}`);
+ logger.debug(`保存激活的提示词优化规则ID: ${prompt.id}`);
}
});
- // 如果没有找到激活的扩写提示词,使用实例属性中保存的ID
+ // 如果没有找到激活的扩写规则,使用实例属性中保存的ID
if (!systemPrompts.active_prompts.expand && this.activeExpandPromptId) {
systemPrompts.active_prompts.expand = this.activeExpandPromptId;
- logger.debug(`使用实例属性中的扩写提示词ID: ${this.activeExpandPromptId}`);
+ logger.debug(`使用实例属性中的提示词优化规则ID: ${this.activeExpandPromptId}`);
}
- // 创建有序的中文和英文反推提示词列表
+ // 创建有序的中文和英文反推规则列表
const orderedZhVisionPrompts = [...this.zhVisionPrompts].sort((a, b) => a.order - b.order);
const orderedEnVisionPrompts = [...this.enVisionPrompts].sort((a, b) => a.order - b.order);
- // 添加中文反推提示词
+ // 添加中文反推规则
orderedZhVisionPrompts.forEach(prompt => {
systemPrompts.vision_prompts[prompt.id] = {
name: prompt.name,
+ tags: prompt.tags || [],
+ category: prompt.category || '',
+ showIn: prompt.showIn || ['frontend', 'node'],
role: "system",
content: prompt.content
};
- // 记录激活的提示词ID
+ // 记录激活的规则ID
if (prompt.isActive) {
systemPrompts.active_prompts.vision_zh = prompt.id;
- logger.debug(`保存激活的中文反推提示词ID: ${prompt.id}`);
+ logger.debug(`保存激活的中文反推规则ID: ${prompt.id}`);
}
});
- // 如果没有找到激活的中文反推提示词,使用实例属性中保存的ID
+ // 如果没有找到激活的中文反推规则,使用实例属性中保存的ID
if (!systemPrompts.active_prompts.vision_zh && this.activeZhVisionPromptId) {
systemPrompts.active_prompts.vision_zh = this.activeZhVisionPromptId;
- logger.debug(`使用实例属性中的中文反推提示词ID: ${this.activeZhVisionPromptId}`);
+ logger.debug(`使用实例属性中的中文反推规则ID: ${this.activeZhVisionPromptId}`);
}
- // 添加英文反推提示词
+ // 添加英文反推规则
orderedEnVisionPrompts.forEach(prompt => {
systemPrompts.vision_prompts[prompt.id] = {
name: prompt.name,
+ tags: prompt.tags || [],
+ category: prompt.category || '',
+ showIn: prompt.showIn || ['frontend', 'node'],
role: "system",
content: prompt.content
};
- // 记录激活的提示词ID
+ // 记录激活的规则ID
if (prompt.isActive) {
systemPrompts.active_prompts.vision_en = prompt.id;
- logger.debug(`保存激活的英文反推提示词ID: ${prompt.id}`);
+ logger.debug(`保存激活的英文反推规则ID: ${prompt.id}`);
}
});
- // 如果没有找到激活的英文反推提示词,使用实例属性中保存的ID
+ // 如果没有找到激活的英文反推规则,使用实例属性中保存的ID
if (!systemPrompts.active_prompts.vision_en && this.activeEnVisionPromptId) {
systemPrompts.active_prompts.vision_en = this.activeEnVisionPromptId;
- logger.debug(`使用实例属性中的英文反推提示词ID: ${this.activeEnVisionPromptId}`);
+ logger.debug(`使用实例属性中的英文反推规则ID: ${this.activeEnVisionPromptId}`);
+ }
+
+ // 添加视频反推规则
+ const orderedVideoPrompts = [...(this.videoPrompts || [])].sort((a, b) => a.order - b.order);
+ orderedVideoPrompts.forEach(prompt => {
+ systemPrompts.video_prompts[prompt.id] = {
+ name: prompt.name,
+ tags: prompt.tags || [],
+ category: prompt.category || '',
+ showIn: prompt.showIn || ['frontend', 'node'],
+ role: "system",
+ content: prompt.content
+ };
+
+ // 记录激活的规则ID
+ if (prompt.isActive) {
+ systemPrompts.active_prompts.video = prompt.id;
+ logger.debug(`保存激活的视频反推规则ID: ${prompt.id}`);
+ }
+ });
+
+ // 如果没有找到激活的视频反推规则,使用实例属性中保存的ID
+ if (!systemPrompts.active_prompts.video && this.activeVideoPromptId) {
+ systemPrompts.active_prompts.video = this.activeVideoPromptId;
+ logger.debug(`使用实例属性中的视频反推规则ID: ${this.activeVideoPromptId}`);
}
// 输出最终的激活状态
- logger.debug(`最终激活的提示词ID: expand=${systemPrompts.active_prompts.expand}, vision_zh=${systemPrompts.active_prompts.vision_zh}, vision_en=${systemPrompts.active_prompts.vision_en}`);
+ logger.debug(`最终激活的规则ID: expand=${systemPrompts.active_prompts.expand}, vision_zh=${systemPrompts.active_prompts.vision_zh}, vision_en=${systemPrompts.active_prompts.vision_en}, video=${systemPrompts.active_prompts.video}`);
- const response = await fetch('/prompt_assistant/api/config/system_prompts', {
+
+ const response = await fetch(APIService.getApiUrl('/config/system_prompts'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(systemPrompts)
@@ -1152,7 +1578,7 @@ class RulesConfigManager {
logger.debug('配置已成功保存到服务器');
return true;
} catch (error) {
- logger.error(`保存系统提示词配置失败: ${error.message}`);
+ logger.error(`保存系统规则配置失败: ${error.message}`);
throw error;
}
}
@@ -1373,6 +1799,9 @@ class RulesConfigManager {
case 'enVision':
dataArray = this.enVisionPrompts;
break;
+ case 'video':
+ dataArray = this.videoPrompts;
+ break;
default:
return;
}
diff --git a/js/modules/settings.js b/js/modules/settings.js
index 9fb38e2..2388e79 100644
--- a/js/modules/settings.js
+++ b/js/modules/settings.js
@@ -5,751 +5,212 @@
import { app } from "../../../../scripts/app.js";
import { logger } from '../utils/logger.js';
-import { PromptAssistant, updateAutoTranslateIndicators } from "./PromptAssistant.js";
+import { PromptAssistant } from "./PromptAssistant.js";
+import { ImageCaption } from "./imageCaption.js";
import { EventManager } from "../utils/eventManager.js";
import { ResourceManager } from "../utils/resourceManager.js";
import { HistoryCacheService, TagCacheService, TranslateCacheService, CACHE_CONFIG } from "../services/cache.js";
import { UIToolkit } from "../utils/UIToolkit.js";
import { FEATURES, handleFeatureChange } from "../services/features.js";
-import { tagConfigManager } from "./tagConfigManager.js";
+import { APIService } from "../services/api.js";
+
import { apiConfigManager } from "./apiConfigManager.js";
import { rulesConfigManager } from "./rulesConfigManager.js";
+import {
+ createSettingsDialog,
+ closeModalWithAnimation,
+ createFormGroup,
+ createInputGroup,
+ createSelectGroup,
+ createHorizontalFormGroup,
+ createLoadingButton
+} from "./uiComponents.js";
// 标记是否是首次加载页面
let isFirstLoad = true;
+// ---服务选择器配置---
+const SERVICE_TYPES = {
+ translate: {
+ name: '翻译',
+ configEndpoint: '/config/translate',
+ serviceType: 'translate',
+ filterKey: 'llm_models',
+ includeBaidu: true
+ },
+ llm: {
+ name: '提示词优化',
+ configEndpoint: '/config/llm',
+ serviceType: 'llm',
+ filterKey: 'llm_models',
+ includeBaidu: false
+ },
+ vlm: {
+ name: '图像反推',
+ configEndpoint: '/config/vision',
+ serviceType: 'vlm',
+ filterKey: 'vlm_models',
+ includeBaidu: false
+ }
+};
+
+// ---服务选择器---
+const serviceSelector = {
+ _servicesCache: null,
+ _cacheTime: 0,
+ _cacheDuration: 5000, // 缓存5秒
+
+ // 获取服务列表(带缓存)
+ async getServices() {
+ const now = Date.now();
+ if (this._servicesCache && (now - this._cacheTime) < this._cacheDuration) {
+ return this._servicesCache;
+ }
+
+ try {
+ const response = await fetch(APIService.getApiUrl('/services'));
+ if (response.ok) {
+ const data = await response.json();
+ if (data.success) {
+ this._servicesCache = data.services || [];
+ this._cacheTime = now;
+ return this._servicesCache;
+ }
+ }
+ } catch (error) {
+ logger.error(`获取服务列表失败: ${error.message}`);
+ }
+ return [];
+ },
+
+ // 获取指定类型的当前服务ID
+ async getCurrentService(type) {
+ const config = SERVICE_TYPES[type];
+ if (!config) return null;
+
+ try {
+ const response = await fetch(APIService.getApiUrl(config.configEndpoint));
+ if (response.ok) {
+ const data = await response.json();
+ return data.provider || null;
+ }
+ } catch (error) {
+ logger.error(`获取${config.name}当前服务失败: ${error.message}`);
+ }
+ return null;
+ },
+
+ // 设置指定类型的服务
+ async setCurrentService(type, serviceId) {
+ const config = SERVICE_TYPES[type];
+ if (!config) return false;
+
+ try {
+ const response = await fetch(APIService.getApiUrl('/services/current'), {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ service_type: config.serviceType,
+ service_id: serviceId
+ })
+ });
+
+ if (response.ok) {
+ logger.log(`${config.name}服务切换 | 服务ID: ${serviceId}`);
+ return true;
+ }
+ } catch (error) {
+ logger.error(`切换${config.name}服务失败: ${error.message}`);
+ }
+ return false;
+ },
+
+ // 获取指定类型可用的服务选项列表
+ async getServiceOptions(type) {
+ const config = SERVICE_TYPES[type];
+ if (!config) return [];
+
+ const services = await this.getServices();
+ const options = [];
+
+ // 添加百度翻译选项(仅翻译类型)
+ if (config.includeBaidu) {
+ options.push({ value: 'baidu', text: '百度翻译' });
+ }
+
+ // 过滤并添加其他服务
+ services
+ .filter(service => {
+ const models = service[config.filterKey];
+ return models && models.length > 0;
+ })
+ .forEach(service => {
+ options.push({
+ value: service.id,
+ text: service.name || service.id
+ });
+ });
+
+ return options;
+ }
+};
+
+// ---版本检查工具函数---
+
/**
- * 创建通用的设置弹窗
- * @param {Object} options 弹窗配置选项
- * @param {string} options.title 弹窗标题
- * @param {Function} options.renderContent 渲染弹窗内容的函数
- * @param {Function} options.renderNotice 渲染通知区域的函数(可选,显示在标题和内容之间)
- * @param {Function} options.onSave 保存按钮点击回调
- * @param {Function} options.onCancel 取消按钮点击回调(可选)
- * @param {boolean} options.isConfirmDialog 是否是确认对话框(可选)
- * @param {string} options.saveButtonText 保存按钮文本(可选)
- * @param {string} options.cancelButtonText 取消按钮文本(可选)
- * @param {string} options.saveButtonIcon 保存按钮图标(可选)
- * @param {boolean} options.disableBackdropAndCloseOnClickOutside 禁用遮罩层和点击外部关闭(可选)
+ * 从 GitHub 获取最新发布版本号
+ * @returns {Promise} 返回最新版本号,格式如 "1.2.3",失败返回 null
*/
-export function createSettingsDialog(options) {
+async function fetchLatestVersion() {
try {
- const {
- title,
- renderContent,
- renderNotice = null,
- onSave,
- onCancel = null,
- isConfirmDialog = false,
- saveButtonText = '保存',
- cancelButtonText = '取消',
- saveButtonIcon = 'pi-check',
- dialogClassName = null,
- disableBackdropAndCloseOnClickOutside = false,
- } = options;
-
- let overlay = null;
- if (!disableBackdropAndCloseOnClickOutside) {
- // 创建遮罩层
- overlay = document.createElement('div');
- overlay.className = 'settings-modal-overlay';
- document.body.appendChild(overlay);
- }
-
-
- // 创建弹窗
- const modal = document.createElement('div');
- modal.className = 'settings-modal';
-
- // 如果提供了额外的对话框类名,添加到modal
- if (dialogClassName) {
- modal.classList.add(dialogClassName);
- }
-
- // 如果是确认对话框,设置特殊样式
- if (isConfirmDialog) {
- // 只有在没有提供自定义类名的情况下才设置默认宽度
- if (!dialogClassName) {
- modal.style.width = 'min(90vw, 400px)';
- }
- modal.style.minHeight = 'auto';
- // 确保确认对话框显示在其他弹窗上方
- if (overlay) {
- overlay.style.zIndex = 'calc(var(--settings-modal-z-index) + 10)';
- }
- modal.style.zIndex = 'calc(var(--settings-modal-z-index) + 11)';
- }
-
- // 表单修改状态
- let isFormModified = false;
-
- // 处理关闭弹窗的逻辑
- const handleCloseModal = async (saveAction) => {
- // 如果是保存操作,直接保存并关闭,不弹出确认对话框
- if (saveAction) {
- try {
- await onSave(content);
- closeModalWithAnimation(modal, overlay);
- } catch (error) {
- app.extensionManager.toast.add({
- severity: "error",
- summary: "保存失败",
- detail: error.message,
- life: 3000
- });
- }
- return;
- }
-
- // 只有在表单被修改且不是确认对话框时才显示确认对话框
- if (isFormModified && !isConfirmDialog) {
- // 创建确认对话框
- createSettingsDialog({
- title: '确认操作',
- isConfirmDialog: true,
- saveButtonText: '返回',
- saveButtonIcon: 'pi-undo',
- cancelButtonText: '关闭',
- renderContent: (content) => {
- content.style.textAlign = 'center';
- content.style.padding = '1rem';
-
- const confirmMessage = document.createElement('p');
- confirmMessage.textContent = '配置已修改,是否保存?';
- confirmMessage.style.margin = '0';
- confirmMessage.style.fontSize = '1rem';
-
- content.appendChild(confirmMessage);
- },
- onSave: () => {
- // 返回按钮只关闭确认对话框,不执行保存操作
- // 这里不需要做任何操作,因为默认的对话框关闭逻辑会在点击按钮后执行
- },
- onCancel: () => {
- // 如果定义了onCancel回调,则执行它
- if (onCancel) {
- onCancel();
- }
- // 关闭主对话框
- closeModalWithAnimation(modal, overlay);
- }
- });
- } else {
- // 没有修改或是确认对话框,直接关闭
- if (onCancel && !isConfirmDialog) {
- onCancel();
- }
- closeModalWithAnimation(modal, overlay);
- }
- };
-
- if (!disableBackdropAndCloseOnClickOutside) {
- // 点击遮罩层关闭弹窗
- overlay.addEventListener('click', (e) => {
- if (e.target === overlay) {
- handleCloseModal(false);
- }
- });
- }
-
- // 创建弹窗头部
- const header = document.createElement('div');
- header.className = 'p-dialog-header';
-
- const titleSpan = document.createElement('span');
- titleSpan.className = 'p-dialog-title';
- titleSpan.textContent = title;
-
- const closeButton = document.createElement('button');
- closeButton.className = 'p-dialog-header-icon p-dialog-header-close p-link';
- closeButton.setAttribute('aria-label', '关闭');
- closeButton.innerHTML = '';
- closeButton.onclick = () => {
- handleCloseModal(false);
- };
-
- const headerIcons = document.createElement('div');
- headerIcons.className = 'p-dialog-header-icons';
- headerIcons.appendChild(closeButton);
-
- header.appendChild(titleSpan);
- header.appendChild(headerIcons);
-
- // 创建通知区域(在标题和内容之间)
- let noticeArea = null;
- if (renderNotice) {
- noticeArea = document.createElement('div');
- noticeArea.className = 'p-dialog-notice';
- renderNotice(noticeArea);
- }
-
- // 创建弹窗内容
- const content = document.createElement('div');
- content.className = 'p-dialog-content';
-
- // 监听表单变化
- const trackFormChanges = (formElement) => {
- // 为所有输入元素添加变更监听
- const inputs = formElement.querySelectorAll('input, textarea, select');
- inputs.forEach(input => {
- const originalValue = input.type === 'checkbox' ? input.checked : input.value;
-
- input.addEventListener('change', () => {
- const currentValue = input.type === 'checkbox' ? input.checked : input.value;
- // 只有当值真正改变时才标记为已修改
- if (currentValue !== originalValue) {
- isFormModified = true;
- }
- });
-
- if (input.tagName.toLowerCase() === 'textarea' || input.type === 'text') {
- input.addEventListener('input', () => {
- const currentValue = input.value;
- // 只有当值真正改变时才标记为已修改
- if (currentValue !== originalValue) {
- isFormModified = true;
- }
- });
- }
- });
-
- // 监听自定义下拉框变化
- const dropdowns = formElement.querySelectorAll('.p-dropdown');
- dropdowns.forEach(dropdown => {
- // 存储原始选中值
- const hiddenSelect = dropdown.querySelector('select');
- const originalValue = hiddenSelect ? hiddenSelect.value : '';
-
- const observer = new MutationObserver(() => {
- const currentValue = hiddenSelect ? hiddenSelect.value : '';
- // 只有当值真正改变时才标记为已修改
- if (currentValue !== originalValue) {
- isFormModified = true;
- }
- });
- observer.observe(dropdown, { attributes: true, childList: true, subtree: true });
- });
- };
-
- // 渲染内容并跟踪变化
- renderContent(content);
-
- // 如果不是确认对话框且内容中有表单,添加变更跟踪
- if (!isConfirmDialog) {
- const forms = content.querySelectorAll('form');
- forms.forEach(trackFormChanges);
-
- // 如果没有找到表单,则监视整个内容区域
- if (forms.length === 0) {
- trackFormChanges(content);
- }
- }
-
- // 创建弹窗底部
- const footer = document.createElement('div');
- footer.className = 'p-dialog-footer';
-
- const cancelButton = document.createElement('button');
- cancelButton.className = 'p-button p-component p-button-secondary';
- cancelButton.innerHTML = `${cancelButtonText}`;
-
- // 为不同类型的对话框设置不同的关闭行为
- if (isConfirmDialog) {
- cancelButton.onclick = () => {
- // 对于确认对话框,“关闭”按钮应执行onCancel回调(该回调负责关闭主窗口),然后关闭自己
- if (onCancel) {
- onCancel();
- }
- closeModalWithAnimation(modal, overlay);
- };
- } else {
- cancelButton.onclick = () => {
- // 对于普通对话框,使用标准的关闭处理逻辑
- handleCloseModal(false);
- };
- }
-
- const saveButton = document.createElement('button');
- saveButton.className = 'p-button p-component';
- saveButton.innerHTML = `${saveButtonText}`;
- saveButton.onclick = () => {
- // 如果是确认对话框中的返回按钮,直接关闭确认对话框
- if (isConfirmDialog) {
- try {
- // 对于确认对话框,先执行onSave回调,然后关闭弹窗
- const result = onSave && onSave(content);
- // 如果onSave返回Promise,等待其完成
- if (result instanceof Promise) {
- result.then(() => {
- closeModalWithAnimation(modal, overlay);
- }).catch(error => {
- logger.error(`确认对话框处理失败: ${error.message}`);
- closeModalWithAnimation(modal, overlay);
- });
- } else {
- // 普通返回值,直接关闭
- closeModalWithAnimation(modal, overlay);
- }
- } catch (error) {
- logger.error(`确认对话框处理失败: ${error.message}`);
- closeModalWithAnimation(modal, overlay);
- }
- } else {
- handleCloseModal(true);
- }
- };
-
- footer.appendChild(cancelButton);
- footer.appendChild(saveButton);
-
- // 添加拖动功能
- let isDragging = false;
- let startX = 0;
- let startY = 0;
- let offsetX = 0;
- let offsetY = 0;
-
- const startDragging = (e) => {
- if (e.target === closeButton || closeButton.contains(e.target)) return;
-
- e.preventDefault();
- isDragging = true;
-
- // 获取鼠标相对于弹窗的偏移量
- const rect = modal.getBoundingClientRect();
- offsetX = e.clientX - rect.left;
- offsetY = e.clientY - rect.top;
-
- // 移除居中定位
- modal.style.transform = 'none';
- modal.style.left = rect.left + 'px';
- modal.style.top = rect.top + 'px';
-
- // 添加拖动状态
- modal.classList.add('dragging');
-
- document.addEventListener('mousemove', onDrag);
- document.addEventListener('mouseup', stopDragging);
- };
-
- const onDrag = (e) => {
- if (!isDragging) return;
- e.preventDefault();
-
- // 计算新位置(考虑鼠标在弹窗内的偏移量)
- let newLeft = e.clientX - offsetX;
- let newTop = e.clientY - offsetY;
-
- // 获取视口和弹窗尺寸
- const viewportWidth = window.innerWidth;
- const viewportHeight = window.innerHeight;
- const modalRect = modal.getBoundingClientRect();
- const modalWidth = modalRect.width;
- const modalHeight = modalRect.height;
-
- // 边界检查(保持10px边距)
- const margin = 10;
- newLeft = Math.max(margin, Math.min(newLeft, viewportWidth - modalWidth - margin));
- newTop = Math.max(margin, Math.min(newTop, viewportHeight - modalHeight - margin));
-
- // 更新位置
- modal.style.left = `${newLeft}px`;
- modal.style.top = `${newTop}px`;
- };
-
- const stopDragging = () => {
- isDragging = false;
- modal.classList.remove('dragging');
- document.removeEventListener('mousemove', onDrag);
- document.removeEventListener('mouseup', stopDragging);
- };
-
- header.addEventListener('mousedown', startDragging);
-
- // 组装弹窗
- modal.appendChild(header);
- if (noticeArea) {
- modal.appendChild(noticeArea);
- }
- modal.appendChild(content);
- modal.appendChild(footer);
-
- // 显示弹窗和遮罩层
- document.body.appendChild(modal);
-
- // 添加显示动画
- requestAnimationFrame(() => {
- modal.classList.add('modal-show');
- if (overlay) {
- overlay.classList.add('overlay-show');
- }
+ const response = await fetch('https://api.github.com/repos/yawiii/comfyui_prompt_assistant/releases/latest', {
+ headers: { 'Accept': 'application/vnd.github.v3+json' },
+ cache: 'no-cache'
});
- return modal;
+ if (!response.ok) {
+ logger.warn(`[版本检查] GitHub API 请求失败: ${response.status}`);
+ return null;
+ }
+
+ const data = await response.json();
+ // 移除 tag_name 中的 'v' 前缀(如果有)
+ const version = data.tag_name?.replace(/^v/, '');
+ logger.debug(`[版本检查] 获取到最新版本: ${version}`);
+ return version || null;
} catch (error) {
- logger.error(`创建设置弹窗失败: ${error.message}`);
- app.extensionManager.toast.add({
- severity: "error",
- summary: "创建弹窗失败",
- detail: error.message || "创建设置弹窗过程中发生错误",
- life: 3000
- });
+ logger.warn(`[版本检查] 获取最新版本失败: ${error.message}`);
+ return null;
}
}
/**
- * 关闭弹窗时添加动画效果
- * @param {HTMLElement} modal 弹窗元素
- * @param {HTMLElement} overlay 遮罩层元素
+ * 比较两个版本号
+ * @param {string} v1 - 第一个版本号
+ * @param {string} v2 - 第二个版本号
+ * @returns {number} v1 > v2 返回 1,v1 < v2 返回 -1,v1 === v2 返回 0
*/
-export function closeModalWithAnimation(modal, overlay) {
- // 添加关闭动画类
- modal.classList.remove('modal-show');
- modal.classList.add('modal-hide');
- if (overlay) {
- overlay.classList.remove('overlay-show');
- overlay.classList.add('overlay-hide');
+function compareVersion(v1, v2) {
+ // 将版本号分割为数字数组
+ const parts1 = v1.split('.').map(n => parseInt(n, 10) || 0);
+ const parts2 = v2.split('.').map(n => parseInt(n, 10) || 0);
+
+ // 确保两个数组长度相同
+ const maxLength = Math.max(parts1.length, parts2.length);
+
+ for (let i = 0; i < maxLength; i++) {
+ const num1 = parts1[i] || 0;
+ const num2 = parts2[i] || 0;
+
+ if (num1 > num2) return 1;
+ if (num1 < num2) return -1;
}
- // 等待动画完成后移除弹窗和遮罩层
- setTimeout(() => {
- if (modal.parentNode) {
- modal.parentNode.removeChild(modal);
- }
- if (overlay && overlay.parentNode) {
- overlay.parentNode.removeChild(overlay);
- }
- }, 300); // 与 CSS transition 时间相匹配
+ return 0;
}
-/**
- * 创建表单组
- * @param {string} title 表单组标题
- * @param {Array<{text: string, url: string}>} links 标题右侧的链接数组
- * @param {Object} [options] 额外可选项
- * @param {string} [options.prefixText] 链接前的前缀纯文本(不作为链接,不包在标签内)
- * @returns {HTMLElement} 表单组容器
- */
-export function createFormGroup(title, links = [], options = {}) {
- const { prefixText = null } = options;
- const group = document.createElement('div');
- group.className = 'settings-form-section';
-
- const titleContainer = document.createElement('div');
- titleContainer.className = 'settings-form-section-header';
- titleContainer.style.display = 'flex';
- titleContainer.style.alignItems = 'center';
- titleContainer.style.justifyContent = 'space-between';
- titleContainer.style.marginBottom = '10px';
-
- const titleElem = document.createElement('h3');
- titleElem.className = 'settings-form-section-title';
- titleElem.textContent = title;
- titleElem.style.margin = '0';
-
- const linksContainer = document.createElement('div');
- linksContainer.style.display = 'flex';
- linksContainer.style.gap = '8px';
- linksContainer.style.alignItems = 'center';
-
- if (links.length > 0) {
- const serviceLinksContainer = document.createElement('div');
- serviceLinksContainer.className = 'settings-service-links';
-
- // 可选的前缀文本,保持与链接字号一致(不包在标签内)
- if (prefixText) {
- const prefix = document.createElement('span');
- prefix.className = 'settings-service-prefix';
- prefix.textContent = prefixText;
- serviceLinksContainer.appendChild(prefix);
- }
-
- links.forEach((linkInfo, index) => {
- if (index > 0) {
- // 添加分隔符
- const separator = document.createElement('span');
- separator.textContent = '|';
- separator.className = 'settings-service-separator';
- serviceLinksContainer.appendChild(separator);
- }
-
- const link = document.createElement('a');
- link.href = linkInfo.url;
- link.target = '_blank';
- link.textContent = linkInfo.text;
- link.className = 'settings-service-link';
-
- serviceLinksContainer.appendChild(link);
- });
-
- linksContainer.appendChild(serviceLinksContainer);
- }
-
- titleContainer.appendChild(titleElem);
- titleContainer.appendChild(linksContainer);
- group.appendChild(titleContainer);
-
- return group;
-}
-
-/**
- * 创建输入框组
- * @param {string} label 标签文本
- * @param {string} placeholder 占位符文本
- * @param {string} type 输入框类型
- * @returns {Object} 包含 group 和 input 的对象
- */
-export function createInputGroup(label, placeholder, type = 'text') {
- const group = document.createElement('div');
- group.className = 'settings-form-group';
-
- const labelElem = document.createElement('label');
- labelElem.className = 'settings-form-label';
- labelElem.textContent = label;
-
- const input = document.createElement('input');
- input.className = 'p-inputtext p-component flex-1';
- input.type = type;
- input.placeholder = placeholder;
-
- group.appendChild(labelElem);
- group.appendChild(input);
-
- return { group, input };
-}
-
-/**
- * 创建下拉选择框组
- * @param {string} label 标签文本
- * @param {Array<{value: string, text: string}>} options 选项列表
- * @param {string} [initialValue=null] 初始选中的值
- * @returns {Object} 包含 group 和 select 的对象
- */
-export function createSelectGroup(label, options, initialValue = null) {
- const group = document.createElement('div');
- group.className = 'settings-form-group';
-
- const labelElem = document.createElement('label');
- labelElem.className = 'settings-form-label';
- labelElem.textContent = label;
-
- // Main Container
- const dropdownContainer = document.createElement('div');
- // 增加自定义样式类 pa-dropdown,保留 p-dropdown 以兼容现有逻辑(如脏检查与布局选择器)
- dropdownContainer.className = 'pa-dropdown p-dropdown p-component w-full';
- dropdownContainer.style.position = 'relative'; // Needed for panel positioning
-
- // Hidden select for form data and accessibility
- const select = document.createElement('select');
- const hiddenContainer = document.createElement('div');
- hiddenContainer.className = 'p-hidden-accessible';
- hiddenContainer.appendChild(select);
-
- // Visible Part: Label and Trigger
- const dropdownLabel = document.createElement('span');
- dropdownLabel.className = 'pa-dropdown-label p-dropdown-label p-inputtext';
-
- const dropdownTrigger = document.createElement('div');
- dropdownTrigger.className = 'pa-dropdown-trigger p-dropdown-trigger';
- dropdownTrigger.innerHTML = '';
-
- // Dropdown Panel (the menu)
- const dropdownPanel = document.createElement('div');
- dropdownPanel.className = 'pa-dropdown-panel p-dropdown-panel p-component settings-modal-dropdown-panel';
- dropdownPanel.style.display = 'none'; // Initially hidden
- dropdownPanel.style.zIndex = '10001'; // 确保层级足够高
-
- const dropdownItemsWrapper = document.createElement('div');
- dropdownItemsWrapper.className = 'p-dropdown-items-wrapper';
-
- const dropdownList = document.createElement('ul');
- dropdownList.className = 'p-dropdown-items';
- dropdownList.setAttribute('role', 'listbox');
-
- // Populate options
- options.forEach(opt => {
- const optionEl = document.createElement('option');
- optionEl.value = opt.value;
- optionEl.textContent = opt.text;
- select.appendChild(optionEl);
-
- const itemEl = document.createElement('li');
- itemEl.className = 'p-dropdown-item';
- itemEl.textContent = opt.text;
- itemEl.dataset.value = opt.value;
- itemEl.setAttribute('role', 'option');
-
- itemEl.addEventListener('click', (e) => {
- e.stopPropagation();
- // Update highlight
- dropdownList.querySelectorAll('.p-dropdown-item').forEach(el => el.classList.remove('p-highlight'));
- itemEl.classList.add('p-highlight');
-
- select.value = opt.value;
- closePanel();
- select.dispatchEvent(new Event('change', { bubbles: true }));
- });
-
- dropdownList.appendChild(itemEl);
- });
-
- // Set initial display value
- const valueToSet = initialValue !== null && options.some(o => o.value === initialValue)
- ? initialValue
- : (options.length > 0 ? options[0].value : null);
-
- if (valueToSet !== null) {
- const selectedOption = options.find(o => o.value === valueToSet);
- if (selectedOption) {
- dropdownLabel.textContent = selectedOption.text;
- select.value = selectedOption.value;
- // Set initial highlight
- const initialItem = dropdownList.querySelector(`.p-dropdown-item[data-value="${select.value}"]`);
- if (initialItem) {
- initialItem.classList.add('p-highlight');
- }
- }
- } else {
- dropdownLabel.textContent = ' '; // Placeholder
- }
-
- // Assemble dropdown
- dropdownItemsWrapper.appendChild(dropdownList);
- dropdownPanel.appendChild(dropdownItemsWrapper);
-
- dropdownContainer.appendChild(hiddenContainer);
- dropdownContainer.appendChild(dropdownLabel);
- dropdownContainer.appendChild(dropdownTrigger);
-
- group.appendChild(labelElem);
- group.appendChild(dropdownContainer);
-
- // --- Event Handling ---
- let isOpen = false;
-
- // 更新面板位置的函数
- const updatePanelPosition = () => {
- if (!isOpen) return;
-
- const rect = dropdownContainer.getBoundingClientRect();
- dropdownPanel.style.top = rect.bottom + 'px';
- dropdownPanel.style.left = rect.left + 'px';
- dropdownPanel.style.width = rect.width + 'px';
- };
-
- const closePanel = () => {
- if (!isOpen) return;
- isOpen = false;
-
- dropdownPanel.classList.add('p-hidden');
- dropdownPanel.classList.remove('p-enter-active');
- dropdownContainer.classList.remove('p-dropdown-open', 'p-focus');
-
- // 移除事件监听器
- window.removeEventListener('resize', updatePanelPosition);
- window.removeEventListener('scroll', updatePanelPosition, true);
-
- // 等待动画完成后再移除面板和事件监听
- setTimeout(() => {
- if (dropdownPanel.parentNode === document.body) {
- document.body.removeChild(dropdownPanel);
- }
- document.removeEventListener('click', handleOutsideClick, true);
- }, 120); // 与CSS中的transition时间相匹配
- };
-
- const openPanel = () => {
- isOpen = true;
-
- // 将面板临时附加到 body 上以避免裁切
- document.body.appendChild(dropdownPanel);
-
- // 计算下拉框相对于视口的位置
- const rect = dropdownContainer.getBoundingClientRect();
-
- // 设置面板样式
- dropdownPanel.style.position = 'fixed';
- dropdownPanel.style.display = 'block';
- dropdownPanel.style.top = rect.bottom + 'px';
- dropdownPanel.style.left = rect.left + 'px';
- dropdownPanel.style.width = rect.width + 'px';
- // 样式由 CSS 中的 .pa-dropdown-panel 接管,避免依赖 PrimeVue 变量
-
- dropdownPanel.classList.remove('p-hidden');
- // 强制重排,确保动画生效
- dropdownPanel.offsetHeight;
- dropdownPanel.classList.add('p-enter-active');
- dropdownContainer.classList.add('p-dropdown-open', 'p-focus');
-
- document.addEventListener('click', handleOutsideClick, true);
-
- // 添加窗口大小变化和滚动事件监听器,以便重新定位面板
- window.addEventListener('resize', updatePanelPosition);
- window.addEventListener('scroll', updatePanelPosition, true);
- };
-
- const handleOutsideClick = (e) => {
- // 如果点击的是下拉框本身,切换状态
- if (dropdownContainer.contains(e.target)) {
- e.stopPropagation();
- if (isOpen) {
- closePanel();
- } else {
- openPanel();
- }
- return;
- }
- // 如果点击的是其他区域,关闭面板
- closePanel();
- };
-
- // 初始化面板状态
- dropdownPanel.classList.add('p-hidden');
- dropdownContainer.addEventListener('click', handleOutsideClick);
-
- select.addEventListener('change', () => {
- const selectedOption = options.find(o => o.value === select.value);
- if (selectedOption) {
- dropdownLabel.textContent = selectedOption.text;
- // Update highlight
- dropdownList.querySelectorAll('.p-dropdown-item').forEach(el => {
- if (el.dataset.value === select.value) {
- el.classList.add('p-highlight');
- } else {
- el.classList.remove('p-highlight');
- }
- });
- }
- });
-
- return { group, select };
-}
-
-/**
- * 创建水平布局的表单组
- * @param {Array<{label: string, element: HTMLElement}>} items 表单项数组
- * @returns {HTMLElement} 水平布局的表单组
- */
-export function createHorizontalFormGroup(items) {
- const group = document.createElement('div');
- group.className = 'settings-form-group horizontal';
-
- items.forEach(item => {
- const container = document.createElement('div');
-
- const label = document.createElement('label');
- label.className = 'settings-form-label';
- label.textContent = item.label;
-
- container.appendChild(label);
- container.appendChild(item.element);
-
- group.appendChild(container);
- });
-
- return group;
-}
+// ====================== 设置管理 ======================
/**
* 显示API配置弹窗
@@ -788,73 +249,105 @@ function showRulesConfigModal() {
}
/**
- * 显示标签配置弹窗
+ * 创建服务选择器下拉框
+ * @param {string} type - 服务类型: 'translate' | 'llm' | 'vlm'
+ * @param {string} label - 显示名称
+ * @returns {HTMLElement} 设置行元素
*/
-function showTagsConfigModal() {
- try {
- logger.debug('打开标签配置弹窗');
- // 使用tagConfigManager实例显示标签配置弹窗
- tagConfigManager.showTagsConfigModal();
- } catch (error) {
- logger.error(`打开标签配置弹窗失败: ${error.message}`);
- app.extensionManager.toast.add({
- severity: "error",
- summary: "打开配置失败",
- detail: error.message || "打开配置弹窗过程中发生错误",
- life: 3000
- });
- }
-}
+function createServiceSelector(type, label) {
+ const row = document.createElement("tr");
+ row.className = "promptwidget-settings-row";
-// ====================== 设置管理 ======================
+ const labelCell = document.createElement("td");
+ labelCell.className = "comfy-menu-label";
+ row.appendChild(labelCell);
-/**
- * 创建加载按钮
- */
-function createLoadingButton(text, onClick, showSuccessToast = true) {
- const button = document.createElement('button');
- button.className = 'p-button p-component p-button-primary';
- button.style.width = '208px'; // 相当于w-52
- button.innerHTML = `${text}`;
+ const selectCell = document.createElement("td");
- button.addEventListener('click', async () => {
- if (button.disabled) return;
+ // 创建加载占位容器
+ const container = document.createElement("div");
+ container.style.minWidth = "180px";
+ container.innerHTML = '加载中...';
- // 开始加载状态
- button.disabled = true;
- button.classList.add('p-disabled');
+ selectCell.appendChild(container);
+ row.appendChild(selectCell);
+ // 异步加载服务列表
+ (async () => {
try {
- await onClick();
+ // 获取服务列表和当前选中的服务
+ const [options, currentService] = await Promise.all([
+ serviceSelector.getServiceOptions(type),
+ serviceSelector.getCurrentService(type)
+ ]);
- // 只有在 showSuccessToast 为 true 时才显示成功提示
- if (showSuccessToast) {
- app.extensionManager.toast.add({
- severity: "success",
- summary: "清理已清理完成",
- life: 3000
- });
+ // 清空容器
+ container.innerHTML = '';
+
+ if (options.length === 0) {
+ container.innerHTML = '暂无可用服务';
+ return;
}
- } catch (error) {
- // 显示错误提示
- app.extensionManager.toast.add({
- severity: "error",
- summary: "操作失败",
- detail: error.message || "操作过程中发生错误",
- life: 3000
- });
- logger.error(`按钮操作失败: ${error.message}`);
- } finally {
- // 恢复按钮状态
- button.disabled = false;
- button.classList.remove('p-disabled');
- }
- });
+ // 使用 createSelectGroup 创建下拉框(不显示浮动标签,与官方样式一致)
+ const { group, select } = createSelectGroup(label, options, currentService, { showLabel: false });
- return button;
+ // 将 group 的子元素添加到容器(不需要外层 group 的样式)
+ while (group.firstChild) {
+ container.appendChild(group.firstChild);
+ }
+
+ // 监听变更事件
+ select.addEventListener('change', async () => {
+ const newValue = select.value;
+ if (!newValue) return;
+
+ // 获取下拉框容器并添加禁用样式
+ const dropdownContainer = container.querySelector('.pa-dropdown');
+ if (dropdownContainer) {
+ dropdownContainer.style.opacity = '0.6';
+ dropdownContainer.style.pointerEvents = 'none';
+ }
+
+ try {
+ const success = await serviceSelector.setCurrentService(type, newValue);
+ if (success) {
+ logger.log(`设置${label}服务 | 服务: ${newValue}`);
+ } else {
+ logger.error(`设置${label}服务失败`);
+ // 还原选择
+ const oldValue = await serviceSelector.getCurrentService(type);
+ if (oldValue) {
+ select.value = oldValue;
+ // 更新显示的标签
+ const dropdownLabel = container.querySelector('.pa-dropdown-label');
+ const selectedOption = options.find(o => o.value === oldValue);
+ if (dropdownLabel && selectedOption) {
+ dropdownLabel.textContent = selectedOption.text;
+ }
+ }
+ }
+ } catch (error) {
+ logger.error(`设置${label}服务异常: ${error.message}`);
+ } finally {
+ // 恢复可用状态
+ if (dropdownContainer) {
+ dropdownContainer.style.opacity = '';
+ dropdownContainer.style.pointerEvents = '';
+ }
+ }
+ });
+
+ } catch (error) {
+ logger.error(`加载${label}服务列表失败: ${error.message}`);
+ container.innerHTML = '加载失败';
+ }
+ })();
+
+ return row;
}
+
/**
* 注册设置选项
* 将设置选项添加到ComfyUI设置面板
@@ -946,6 +439,113 @@ export function registerSettings() {
}
},
+ // 小助手创建方式设置
+ {
+ id: "PromptAssistant.Settings.CreationMode",
+ name: "小助手创建方式(提示词)",
+ category: ["✨提示词小助手", "系统", "提示词小助手创建方式"],
+ type: "combo",
+ options: [
+ { text: "自动创建", value: "auto" },
+ { text: "选中节点时创建", value: "manual" }
+ ],
+ defaultValue: "auto",
+ tooltip: "自动创建:节点创建或加载时自动显示小助手;选中节点时创建:仅选中节点时显示",
+ onChange: (value) => {
+ logger.log(`小助手创建方式变更 | 模式:${value === 'auto' ? '自动创建' : '选中节点时创建'}`);
+ // 如果切换到自动创建,立即尝试初始化所有节点
+ if (value === 'auto' && window.FEATURES.enabled && app.graph) {
+ const nodes = app.graph._nodes || [];
+ nodes.forEach(node => {
+ if (node && !node._promptAssistantInitialized) {
+ app.promptAssistant.checkAndSetupNode(node);
+ }
+ });
+ }
+ }
+ },
+
+ // 反推小助手创建方式设置
+ {
+ id: "PromptAssistant.Settings.ImageCaptionCreationMode",
+ name: "小助手创建方式(图像反推)",
+ category: ["✨提示词小助手", "系统", "图像小助手创建方式"],
+ type: "combo",
+ options: [
+ { text: "自动创建", value: "auto" },
+ { text: "选中节点时创建", value: "manual" }
+ ],
+ defaultValue: "auto",
+ tooltip: "自动创建:节点创建或加载时自动显示反推小助手;选中节点时创建:仅选中节点时显示",
+ onChange: (value) => {
+ logger.log(`反推小助手创建方式变更 | 模式:${value === 'auto' ? '自动创建' : '选中节点时创建'}`);
+ // 如果切换到自动创建,立即尝试初始化所有节点
+ if (value === 'auto' && window.FEATURES.enabled && window.FEATURES.imageCaption && app.graph) {
+ const nodes = app.graph._nodes || [];
+ nodes.forEach(node => {
+ if (node && !node._imageCaptionInitialized) {
+ app.imageCaption.checkAndSetupNode(node);
+ }
+ });
+ }
+ }
+ },
+
+ // 小助手布局(提示词)
+ {
+ id: "PromptAssistant.Location",
+ name: "小助手布局(提示词)",
+ category: ["✨提示词小助手", "界面", "提示词小助手布局"],
+ type: "combo",
+ options: [
+ // { text: "左上(横向)", value: "top-left-h" },
+ // { text: "左上(垂直)", value: "top-left-v" },
+ // { text: "中上(横向)", value: "top-center-h" },
+ // { text: "⇗ ━", value: "top-right-h" },
+ // { text: "⇗ ┃", value: "top-right-v" },
+ { text: "右中(垂直)", value: "right-center-v" },
+ { text: "右下(横向)", value: "bottom-right-h" },
+ { text: "右下(垂直)", value: "bottom-right-v" },
+ { text: "下中(横向)", value: "bottom-center-h" },
+ { text: "左下(横向)", value: "bottom-left-h" },
+ // { text: "左下(垂直)", value: "bottom-left-v" },
+ // { text: "左中(垂直)", value: "left-center-v" }
+ ],
+ defaultValue: "bottom-right-h", // 默认右下横向
+ tooltip: "设置提示词小助手在输入框周围的布局和展开方向",
+ onChange: (value) => {
+ logger.log(`提示词小助手布局变更 | 布局:${value}`);
+ // 通知所有实例更新布局(通过 CSS 类处理)
+ PromptAssistant.instances.forEach(widget => {
+ if (widget.container && widget.container.setAnchorPosition) {
+ widget.container.setAnchorPosition(value);
+ }
+ });
+ }
+ },
+ // 小助手位置设置(图像反推)
+ {
+ id: "ImageCaption.Location",
+ name: "小助手布局(图像反推)",
+ category: ["✨提示词小助手", "界面", "图像小助手布局"],
+ type: "combo",
+ options: [
+ { text: "横", value: "bottom-left-h" },
+ { text: "竖", value: "bottom-left-v" }
+ ],
+ defaultValue: "bottom-left-h", // 默认横向
+ tooltip: "设置图像反推小助手的展开方向(位置固定在左下角)",
+ onChange: (value) => {
+ logger.log(`图像反推小助手布局变更 | 布局:${value}`);
+ // 通知所有实例更新布局
+ ImageCaption.instances.forEach(assistant => {
+ if (assistant.container && assistant.container.setAnchorPosition) {
+ assistant.container.setAnchorPosition(value);
+ }
+ });
+ },
+ },
+
// API 配置按钮
{
id: "PromptAssistant.Features.APIConfig",
@@ -971,29 +571,37 @@ export function registerSettings() {
}
},
- // 跳过代理直连开关
+ // ---服务类别设置---
+ // 翻译服务选择
{
- id: "PromptAssistant.Settings.BypassProxy",
- name: "跳过代理直连",
- category: ["✨提示词小助手", " 配置", "网络设置"],
- type: "boolean",
- defaultValue: false,
- tooltip: "仅当开启代理时,智谱和硅基流动等国内服务使用报错时,再尝试打开。",
- onChange: (value) => {
- logger.log(`跳过代理直连 - 已${value ? "启用" : "禁用"}`);
+ id: "PromptAssistant.Service.Translate",
+ name: "选择翻译服务",
+ category: ["✨提示词小助手", " 配置", "翻译"],
+ tooltip: "选择一个服务商用于翻译,也可以通过右键翻译按钮来切换",
+ type: () => {
+ return createServiceSelector('translate', '翻译');
}
},
- // 强制使用HTTP方式请求开关
+ // 提示词优化服务选择
{
- id: "PromptAssistant.Settings.ForceHTTP",
- name: " HTTP API 接口",
- category: ["✨提示词小助手", " 配置", "HTTP-API"],
- type: "boolean",
- defaultValue: false,
- tooltip: "打开后,制使用HTTP API接口请求。绕过OpenAI SDK的请求。",
- onChange: (value) => {
- logger.log(`强制使用HTTP方式请求 - 已${value ? "启用" : "禁用"}`);
+ id: "PromptAssistant.Service.LLM",
+ name: "选择提示词优化服务",
+ category: ["✨提示词小助手", " 配置", "提示词优化"],
+ tooltip: "选择一个服务商用于提示词优化,也可以通过右键提示词优化按钮来切换",
+ type: () => {
+ return createServiceSelector('llm', '提示词优化');
+ }
+ },
+
+ // 图像反推服务选择
+ {
+ id: "PromptAssistant.Service.VLM",
+ name: "选择图像反推服务",
+ category: ["✨提示词小助手", " 配置", "图像反推"],
+ tooltip: "选择一个服务商用于图像反推,也可以通过右键反推按钮来切换",
+ type: () => {
+ return createServiceSelector('vlm', '图像反推');
}
},
@@ -1032,16 +640,16 @@ export function registerSettings() {
// 扩写功能
{
id: "PromptAssistant.Features.Expand",
- name: "启用扩写功能",
- category: ["✨提示词小助手", "小助手功能开关", "扩写功能"],
+ name: "启用提示词优化功能",
+ category: ["✨提示词小助手", "小助手功能开关", "提示词优化功能"],
type: "boolean",
defaultValue: true,
- tooltip: "开启或关闭扩写功能",
+ tooltip: "开启或关闭提示词优化功能",
onChange: (value) => {
const oldValue = FEATURES.expand;
FEATURES.expand = value;
- handleFeatureChange('扩写功能', value, oldValue);
- logger.log(`扩写功能 - 已${value ? "启用" : "禁用"}`);
+ handleFeatureChange('提示词优化功能', value, oldValue);
+ logger.log(`提示词优化功能 - 已${value ? "启用" : "禁用"}`);
}
},
@@ -1126,25 +734,7 @@ export function registerSettings() {
}
},
- // 自动翻译功能 - 暂时禁用
- /*
- {
- id: "PromptAssistant.Features.AutoTranslate",
- name: "自动翻译",
- category: ["✨提示词小助手", " 翻译功能设置", "自动翻译"],
- type: "boolean",
- defaultValue: false,
- tooltip: "开启后,工作流运行时将自动翻译CLIP节点的中文文本,前端显示保持不变",
- onChange: (value) => {
- const oldValue = FEATURES.autoTranslate;
- FEATURES.autoTranslate = value;
- handleFeatureChange('自动翻译', value, oldValue);
- updateAutoTranslateIndicators(value);
- logger.log(`自动翻译 - 已${value ? "启用" : "禁用"}`);
- }
- },
- */
// 图像反推功能
{
@@ -1162,6 +752,22 @@ export function registerSettings() {
}
},
+ // 节点帮助翻译功能
+ {
+ id: "PromptAssistant.Features.NodeHelpTranslator",
+ name: "启用节点信息翻译",
+ category: ["✨提示词小助手", "小助手功能开关", "节点信息翻译"],
+ type: "boolean",
+ defaultValue: true,
+ tooltip: "开启或关闭ComfyUI侧边栏节点帮助文档的翻译功能",
+ onChange: (value) => {
+ const oldValue = FEATURES.nodeHelpTranslator;
+ FEATURES.nodeHelpTranslator = value;
+ handleFeatureChange('节点信息翻译', value, oldValue);
+ logger.log(`节点信息翻译功能 - 已${value ? "启用" : "禁用"}`);
+ }
+ },
+
// 系统设置
{
id: "PromptAssistant.Settings.LogLevel",
@@ -1183,6 +789,30 @@ export function registerSettings() {
}
},
+ // 显示流式输出进度
+ {
+ id: "PromptAssistant.Settings.ShowStreamingProgress",
+ name: "显示流式输出进度",
+ category: ["✨提示词小助手", "系统", "终端日志"],
+ type: "boolean",
+ defaultValue: true,
+ tooltip: "开启后,控制台会显示流式输出过程,在某些终端可能导致刷屏;关闭后只显示静态的'生成中...'。",
+ onChange: async (value) => {
+ FEATURES.showStreamingProgress = value;
+ // 通知后端更新设置
+ try {
+ await fetch(APIService.getApiUrl('/settings/streaming_progress'), {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ enabled: value })
+ });
+ } catch (error) {
+ logger.error(`更新流式进度设置失败: ${error.message}`);
+ }
+ logger.log(`流式输出进度 - 已${value ? "启用" : "禁用"}`);
+ }
+ },
+
{
id: "PromptAssistant.Settings.IconOpacity",
name: " 小助手图标不透明度",
@@ -1191,7 +821,7 @@ export function registerSettings() {
min: 0,
max: 100,
step: 1,
- defaultValue: 30,
+ defaultValue: 20,
tooltip: "设置折叠后小助手图标的不透明度",
onChange: (value) => {
// 将0-100的值转换为0-1的透明度
@@ -1302,27 +932,7 @@ export function registerSettings() {
}
},
- // 翻译方法选择
- {
- id: "PromptAssistant.Settings.TranslateType",
- name: "翻译方法",
- category: ["✨提示词小助手", " 翻译功能设置", "翻译方法"],
- type: "combo",
- defaultValue: "baidu",
- options: [
- { text: "百度翻译", value: "baidu" },
- { text: "大语言模型翻译", value: "llm" }
- ],
- tooltip: "可选百度机翻或者大语言模型翻译,注意:大语言模型翻译速度比较慢,格式可能会发生变化。",
- onChange: (value) => {
- // 直接修改 app.settings 对象
- if (!app.settings) {
- app.settings = {};
- }
- app.settings["PromptAssistant.Settings.TranslateType"] = value;
- logger.debug("翻译方式已更新:" + value);
- }
- },
+
// 关于插件信息
{
@@ -1337,6 +947,21 @@ export function registerSettings() {
cell.style.display = "flex";
cell.style.alignItems = "center";
cell.style.gap = "12px";
+ // 版本徽标容器(整体可点击跳转最新版本)
+ const versionLink = document.createElement("a");
+ versionLink.href = "https://github.com/yawiii/comfyui_prompt_assistant/releases/latest";
+ versionLink.target = "_blank";
+ versionLink.style.textDecoration = "none";
+ versionLink.style.display = "flex";
+ versionLink.style.alignItems = "center";
+ versionLink.style.cursor = "pointer";
+
+ const versionContainer = document.createElement("div");
+ versionContainer.style.display = "flex";
+ versionContainer.style.alignItems = "center";
+ versionContainer.style.gap = "8px";
+ versionLink.appendChild(versionContainer);
+
// 版本徽标
const versionBadge = document.createElement("img");
versionBadge.alt = "Version";
@@ -1347,13 +972,42 @@ export function registerSettings() {
if (!window.PromptAssistant_Version) {
logger.error("未找到版本号,徽标将无法正确显示");
versionBadge.src = `https://img.shields.io/badge/%E7%89%88%E6%9C%AC-%E6%9C%AA%E7%9F%A5-red?style=flat`;
+ versionContainer.appendChild(versionBadge);
} else {
- const version = window.PromptAssistant_Version;
- versionBadge.src = `https://img.shields.io/badge/%E7%89%88%E6%9C%AC-${version}-green?style=flat`;
- logger.debug(`版本号徽标已更新: ${version}`);
+ const currentVersion = window.PromptAssistant_Version;
+ // 初始显示当前版本
+ versionBadge.src = `https://img.shields.io/badge/%E7%89%88%E6%9C%AC-${currentVersion}-green?style=flat`;
+ logger.debug(`[版本检查] 当前版本: ${currentVersion}`);
+ versionContainer.appendChild(versionBadge);
+
+ // 异步检查最新版本
+ fetchLatestVersion().then(latestVersion => {
+ if (latestVersion && compareVersion(latestVersion, currentVersion) > 0) {
+ // 发现新版本,使用单个徽标展示“有新版本” + "当前版本→最新版本"
+
+ const labelText = "有新版本";
+ const messageText = `${currentVersion}→${latestVersion}`;
+ const labelEncoded = encodeURIComponent(labelText);
+ const messageEncoded = encodeURIComponent(messageText);
+
+ // shields.io: /badge/label-message-color
+ // label 使用与默认徽标相同的深灰色(#555555),message 使用橙色
+ versionBadge.src = `https://img.shields.io/badge/${labelEncoded}-${messageEncoded}-orange?style=flat&labelColor=555555`;
+ versionBadge.style.cursor = "pointer";
+ versionBadge.title = `当前版本: ${currentVersion}\n最新版本: ${latestVersion}\n点击前往下载`;
+
+ logger.info(`[版本检查] 发现新版本 ${currentVersion} → ${latestVersion}`);
+ } else if (latestVersion) {
+ // 已是最新版本
+ versionBadge.title = `当前已是最新版本: ${currentVersion}`;
+ logger.debug(`[版本检查] 当前已是最新版本: ${currentVersion}`);
+ }
+ }).catch(error => {
+ logger.warn(`[版本检查] 版本检查过程出错: ${error.message}`);
+ });
}
- cell.appendChild(versionBadge);
+ cell.appendChild(versionLink);
// GitHub 徽标
const authorTag = document.createElement("a");
@@ -1364,7 +1018,7 @@ export function registerSettings() {
authorTag.style.alignItems = "center";
const authorBadge = document.createElement("img");
authorBadge.alt = "Static Badge";
- authorBadge.src = "https://img.shields.io/badge/Github-Yawiii-blue?style=flat&logo=github&logoColor=black&labelColor=%23FFFFFF&color=%2307A3D7";
+ authorBadge.src = "https://img.shields.io/github/stars/yawiii/comfyui_prompt_assistant?style=flat&logo=github&logoColor=%23292F34&label=Yawiii&labelColor=%23FFFFFF&color=blue";
authorBadge.style.display = "block";
authorBadge.style.height = "20px";
authorTag.appendChild(authorBadge);
@@ -1419,7 +1073,7 @@ export function registerSettings() {
qrFallbackTimer = setTimeout(() => {
// 检查是否已标记为已回退
if (wechatQrImg.dataset.fallbackApplied === '1') return;
-
+
// 检查图片是否已开始加载(naturalHeight > 0 说明图片正在加载)
if (wechatQrImg.naturalHeight > 0) {
Logger.log(2, '远程二维码加载中,延长等待时间');
@@ -1481,9 +1135,9 @@ export function registerSettings() {
// 规则配置按钮
{
id: "PromptAssistant.Features.RulesConfig",
- name: "扩写和反推规则修改",
+ name: "提示词优化和反推规则修改",
category: ["✨提示词小助手", " 配置", "规则"],
- tooltip: "可以自定义扩写规则,和反推提示词规则,使得提示词生成更加符合你的需求",
+ tooltip: "可以自定义提示词优化规则,和反推提示词规则,使得提示词生成更加符合你的需求",
type: () => {
const row = document.createElement("tr");
row.className = "promptwidget-settings-row";
@@ -1503,29 +1157,6 @@ export function registerSettings() {
}
},
- // 标签配置按钮
- {
- id: "PromptAssistant.Features.TagsConfig",
- name: "自定义和标签管理",
- category: ["✨提示词小助手", " 配置", "标签管理"],
- type: () => {
- const row = document.createElement("tr");
- row.className = "promptwidget-settings-row";
-
- const labelCell = document.createElement("td");
- labelCell.className = "comfy-menu-label";
- row.appendChild(labelCell);
-
- const buttonCell = document.createElement("td");
- const button = createLoadingButton("标签管理器", async () => {
- showTagsConfigModal();
- }, false);
-
- buttonCell.appendChild(button);
- row.appendChild(buttonCell);
- return row;
- }
- }
]
});
diff --git a/js/modules/tag.js b/js/modules/tag.js
index 1df0900..ffe6526 100644
--- a/js/modules/tag.js
+++ b/js/modules/tag.js
@@ -10,8 +10,7 @@ import { PopupManager } from "../utils/popupManager.js";
import { ResourceManager } from "../utils/resourceManager.js";
import { EventManager } from "../utils/eventManager.js";
import { PromptFormatter } from "../utils/promptFormatter.js";
-import { TagConfigManager } from "./tagConfigManager.js";
-
+import { createSettingsDialog, showContextMenu, createConfirmPopup } from "./uiComponents.js";
/**
* 标签管理器类
* 管理标签弹窗和标签选择
@@ -42,7 +41,7 @@ class TagManager {
CacheService.set(this.LAST_TAB_KEY, category);
logger.debug(`[助手-标签] 记忆标签页 | 当前:${category}`);
}
- } catch (e) {}
+ } catch (e) { }
}
/**
@@ -113,6 +112,25 @@ class TagManager {
static currentInputId = null;
static activeTooltip = null;
static usedTags = new Map(); // 存储已使用标签的Map: key为标签值,value为对应的DOM元素
+ static currentCsvFile = null; // 当前选中的CSV文件
+ static favorites = {}; // 收藏列表缓存 {name: value}
+ static tagLookup = new Map(); // 标签值到名称的映射表
+ static Sortable = null; // Sortable 库引用
+ static sortables = []; // 存储 sortable 实例以供清理
+ static tagData = null; // 当前CSV文件的标签数据
+
+ /**
+ * 初始化 Sortable
+ */
+ static async _initSortable() {
+ if (this.Sortable) return;
+ try {
+ this.Sortable = await ResourceManager.getSortable();
+ } catch (error) {
+ logger.warn('Sortable library not loaded', error);
+ }
+ }
+
/**
* 检查标签是否已插入到输入框中
@@ -571,7 +589,7 @@ class TagManager {
* @param {string} level 层级
* @param {string} tabName 标签页名称(用于恢复状态)
*/
- static _createAccordionContent(data, level = '0', tabName = null) {
+ static _createAccordionContent(data, level = '0', tabName = null, categoryName = null) {
// 如果是顶级(一级分类),则创建标签页结构
if (level === '0') {
// 创建外层容器
@@ -644,12 +662,12 @@ class TagManager {
// 使用更大的阈值(5像素)确保边界情况下能正确隐藏
const scrollLeft = tabsScroll.scrollLeft;
const maxScroll = tabsScroll.scrollWidth - tabsScroll.clientWidth;
-
+
// 临时调试日志 - 显示接近边界时的滚动值
if (scrollLeft < 10 || scrollLeft > maxScroll - 10) {
logger.debug(`[标签-滚动] scrollLeft: ${scrollLeft.toFixed(2)}, maxScroll: ${maxScroll.toFixed(2)}`);
}
-
+
leftIndicator.style.display = scrollLeft > 5 ? 'flex' : 'none';
rightIndicator.style.display = scrollLeft < (maxScroll - 5) ? 'flex' : 'none';
};
@@ -724,7 +742,7 @@ class TagManager {
updateIndicators();
});
resizeObserver.observe(popup);
-
+
// 添加清理函数
const resizeCleanup = () => {
resizeObserver.disconnect();
@@ -842,7 +860,7 @@ class TagManager {
// 递归创建子内容(二级分类开始使用手风琴)
if (typeof data[category] === 'object' && data[category] !== null) {
- const innerContent = this._createInnerAccordion(data[category], '1', category);
+ const innerContent = this._createInnerAccordion(data[category], '1', category, category);
content.appendChild(innerContent);
}
@@ -872,39 +890,7 @@ class TagManager {
for (const [key, value] of Object.entries(data)) {
// 如果值是字符串,说明是标签
if (typeof value === 'string') {
- const tagItem = document.createElement('div');
- tagItem.className = 'tag_item';
- tagItem.setAttribute('data-name', key);
- tagItem.setAttribute('data-value', value);
-
- // 检查标签是否已使用
- if (this.isTagUsed(value, this.currentNodeId, this.currentInputId)) {
- tagItem.classList.add('used');
- this.usedTags.set(value, tagItem);
- }
-
- const tagText = document.createElement('span');
- tagText.className = 'tag_item_text';
- tagText.textContent = key;
-
- tagItem.appendChild(tagText);
-
- // 添加鼠标事件监听
- const mouseEnterCleanup = EventManager.addDOMListener(tagItem, 'mouseenter', () => {
- this._showTooltip(tagItem, value);
- });
-
- const mouseLeaveCleanup = EventManager.addDOMListener(tagItem, 'mouseleave', () => {
- this._hideTooltip();
- });
-
- // 添加点击事件
- const tagClickCleanup = EventManager.addDOMListener(tagItem, 'click', (e) => {
- this.handleTagClick(tagItem, key, value, e);
- });
-
- this.eventCleanups.push(mouseEnterCleanup, mouseLeaveCleanup, tagClickCleanup);
-
+ const tagItem = this._createTagElement(key, value, categoryName);
container.appendChild(tagItem);
}
// 递归处理下一级
@@ -924,19 +910,65 @@ class TagManager {
const headerIcon = document.createElement('div');
headerIcon.className = 'tag_accordion_icon';
- // 添加图标
+ // 添加加号图标(创建新标签)
+ const addIconSpan = document.createElement('span');
+ addIconSpan.className = 'pi pi-plus accordion_add_icon';
+ addIconSpan.title = '在此分类下创建新标签';
+ headerIcon.appendChild(addIconSpan);
+
+ // 添加箭头图标
const arrowIconSpan = document.createElement('span');
arrowIconSpan.className = 'pi pi-chevron-down accordion_arrow_icon';
headerIcon.appendChild(arrowIconSpan);
+ // 添加加号图标点击事件
+ const addIconCleanup = EventManager.addDOMListener(addIconSpan, 'click', (e) => {
+ e.stopPropagation(); // 阻止事件冒泡,避免触发手风琴展开/收起
+ this._handleAddTag(key, categoryName || key);
+ });
+ this.eventCleanups.push(addIconCleanup);
+
header.appendChild(headerTitle);
header.appendChild(headerIcon);
+ // 添加拖拽时自动展开功能
+ let hoverTimer = null;
+ const dragOverCleanup = EventManager.addDOMListener(header, 'dragover', (e) => {
+ e.preventDefault(); // 必须阻止默认行为才能响应 drop/dragover
+ // 检查是否有正在拖拽的标签
+ const draggingTag = document.querySelector('.tag_item.tag-dragging');
+ if (draggingTag && !header.classList.contains('active')) {
+ if (!hoverTimer) {
+ hoverTimer = setTimeout(() => {
+ logger.debug(`[AutoExpand] 拖拽悬停自动展开: ${key}`);
+ header.click();
+ hoverTimer = null;
+ }, 500); // 500ms 延迟
+ }
+ }
+ });
+
+ const dragLeaveCleanup = EventManager.addDOMListener(header, 'dragleave', () => {
+ if (hoverTimer) {
+ clearTimeout(hoverTimer);
+ hoverTimer = null;
+ }
+ });
+ // 还要处理 drop 事件,防止计时器残留
+ const dropCleanup = EventManager.addDOMListener(header, 'drop', () => {
+ if (hoverTimer) {
+ clearTimeout(hoverTimer);
+ hoverTimer = null;
+ }
+ });
+
+ this.eventCleanups.push(dragOverCleanup, dragLeaveCleanup, dropCleanup);
+
const content = document.createElement('div');
content.className = 'tag_accordion_content';
// 递归创建子内容,传递 tabName
- const childContent = this._createAccordionContent(value, (parseInt(level) + 1).toString(), tabName);
+ const childContent = this._createAccordionContent(value, (parseInt(level) + 1).toString(), tabName, key);
content.appendChild(childContent);
// 根据保存的状态或默认行为确定是否展开
@@ -1014,6 +1046,192 @@ class TagManager {
}
}
+ // 初始化拖拽排序(如果有 Sortable)
+ // 分别处理手风琴排序和标签排序
+ if (this.Sortable) {
+ // 检测容器内容类型
+ const hasAccordions = container.querySelector(':scope > .tag_accordion') !== null;
+ const hasTags = container.querySelector(':scope > .tag_item') !== null;
+
+ // 手风琴排序
+ if (hasAccordions) {
+ const accordionSortable = new this.Sortable(container, {
+ group: { name: 'accordions', pull: false, put: false },
+ animation: 150,
+ ghostClass: 'tag-ghost',
+ handle: '.tag_accordion_header',
+ draggable: '.tag_accordion',
+ delay: 50,
+ onEnd: async (evt) => {
+ const { oldIndex, newIndex } = evt;
+ if (oldIndex === newIndex) return;
+
+ const newOrderKeys = [];
+ Array.from(container.children).forEach(el => {
+ if (el.classList.contains('tag_accordion')) {
+ const cat = el.getAttribute('data-category');
+ if (cat) newOrderKeys.push(cat);
+ }
+ });
+
+ const tempObj = { ...data };
+ for (const key in data) delete data[key];
+
+ newOrderKeys.forEach(key => {
+ if (Object.prototype.hasOwnProperty.call(tempObj, key)) {
+ data[key] = tempObj[key];
+ }
+ });
+
+ for (const key in tempObj) {
+ if (!Object.prototype.hasOwnProperty.call(data, key)) {
+ data[key] = tempObj[key];
+ }
+ }
+
+ await this._saveTagOrder(data, tabName, categoryName);
+ }
+ });
+ this.sortables.push(accordionSortable);
+ }
+
+ // 标签排序(支持跨分类拖拽)
+ // 始终初始化标签 Sortable,确保空分类或只有子分类的容器也能接收标签
+ if (true) {
+ // 为容器添加分类标识,用于跨分类拖拽时识别
+ container.setAttribute('data-sortable-category', categoryName || tabName);
+ // 确保容器有最小高度,以便空容器也能因 drop-zone 样式而被看到
+ container.style.minHeight = '10px';
+
+ const tagSortable = new this.Sortable(container, {
+ group: {
+ name: 'tags',
+ pull: true,
+ put: function (to) {
+ // 如果目标容器包含手风琴,则不允许放入标签
+ return to.el.querySelector(':scope > .tag_accordion') === null;
+ }
+ }, // 允许跨分类拖拽
+ animation: 150,
+ ghostClass: 'tag-ghost',
+ draggable: '.tag_item',
+ delay: 50,
+ onStart: function (evt) {
+ evt.item.classList.add('tag-dragging');
+ // 高亮所有可放置的分类容器(仅限底层容器)
+ document.querySelectorAll('[data-sortable-category]').forEach(el => {
+ if (el !== evt.from && !el.querySelector(':scope > .tag_accordion')) {
+ el.classList.add('tag-drop-zone');
+ }
+ });
+ },
+ onEnd: async (evt) => {
+ evt.item.classList.remove('tag-dragging');
+ // 移除高亮
+ document.querySelectorAll('.tag-drop-zone').forEach(el => {
+ el.classList.remove('tag-drop-zone');
+ });
+
+ // 如果是跨分类移动,由 onAdd 处理
+ if (evt.from !== evt.to) return;
+
+ const { oldIndex, newIndex } = evt;
+ if (oldIndex === newIndex) return;
+
+ const newOrderKeys = [];
+ Array.from(container.children).forEach(el => {
+ if (el.classList.contains('tag_item')) {
+ const name = el.getAttribute('data-name');
+ if (name) newOrderKeys.push(name);
+ }
+ });
+
+ const tempObj = { ...data };
+ for (const key in data) delete data[key];
+
+ newOrderKeys.forEach(key => {
+ if (Object.prototype.hasOwnProperty.call(tempObj, key)) {
+ data[key] = tempObj[key];
+ }
+ });
+
+ for (const key in tempObj) {
+ if (!Object.prototype.hasOwnProperty.call(data, key)) {
+ data[key] = tempObj[key];
+ }
+ }
+
+ await this._saveTagOrder(data, tabName, categoryName);
+ },
+ onAdd: async (evt) => {
+ // 跨分类移动标签
+ const tagItem = evt.item;
+ const tagName = tagItem.getAttribute('data-name');
+ const tagValue = tagItem.getAttribute('data-value');
+ const fromCategory = evt.from.getAttribute('data-sortable-category');
+ const toCategory = evt.to.getAttribute('data-sortable-category');
+
+ logger.debug(`[onAdd(root)] 开始移动标签: ${tagName}, 从: ${fromCategory}, 到: ${toCategory}`);
+
+ // 更新标签元素的分类属性
+ tagItem.setAttribute('data-category', toCategory);
+
+ // 调用移动函数 (不立即保存,等待排序)
+ const success = await this._moveTagToCategory(tagName, tagValue, fromCategory, toCategory, tabName, false);
+
+ logger.debug(`[onAdd(root)] 移动结果: ${success}`);
+
+ if (!success) {
+ // 如果移动失败,将标签移回原位置
+ logger.warn(`[onAdd(root)] 移动失败,回滚 DOM`);
+ evt.from.appendChild(tagItem);
+ } else {
+ // 移动成功,现在根据 DOM 顺序重新排序并保存
+
+ // 获取新的 DOM 顺序
+ const newOrderKeys = [];
+ Array.from(evt.to.children).forEach(el => {
+ if (el.classList.contains('tag_item')) {
+ const name = el.getAttribute('data-name');
+ if (name) newOrderKeys.push(name);
+ }
+ });
+
+ // 重构 data 对象
+ const tempObj = { ...data };
+ for (const key in data) delete data[key];
+
+ newOrderKeys.forEach(key => {
+ if (Object.prototype.hasOwnProperty.call(tempObj, key)) {
+ data[key] = tempObj[key];
+ }
+ });
+
+ // 确保没有遗漏
+ for (const key in tempObj) {
+ if (!Object.prototype.hasOwnProperty.call(data, key)) {
+ data[key] = tempObj[key];
+ }
+ }
+
+ // 保存排序后的数据
+ await this._saveTagOrder(data, tabName, toCategory);
+
+ // 显示成功提示
+ logger.debug(`[标签移动(root)] 成功并排序 | 标签: ${tagName} | 到: ${toCategory}`);
+ app.extensionManager.toast.add({
+ severity: "success",
+ summary: "移动成功",
+ detail: `标签 "${tagName}" 已移动到 "${toCategory}"`,
+ life: 2000
+ });
+ }
+ }
+ });
+ this.sortables.push(tagSortable);
+ }
+ }
+
return container;
}
}
@@ -1049,8 +1267,8 @@ class TagManager {
content.className = 'tag_accordion_content';
// 递归创建子内容
- const childContent = this._createAccordionContent(value, (parseInt(level) + 1).toString());
- content.appendChild(childContent);
+ const childContent = this._createInnerAccordion(data, (parseInt(level) + 1).toString(), tabName, categoryName);
+ accordion.appendChild(childContent);
// 添加手风琴切换事件,包含关闭其他手风琴的逻辑
const accordionCleanup = EventManager.addDOMListener(header, 'click', (e) => {
@@ -1108,7 +1326,7 @@ class TagManager {
* @param {string} level 层级
* @param {string} tabName 标签页名称(用于恢复状态)
*/
- static _createInnerAccordion(data, level, tabName = null) {
+ static _createInnerAccordion(data, level, tabName = null, categoryName = null) {
const container = document.createElement('div');
container.className = 'tag_category_container';
container.style.flex = '1';
@@ -1134,7 +1352,22 @@ class TagManager {
const headerIcon = document.createElement('div');
headerIcon.className = 'tag_accordion_icon';
- // 添加图标
+ // 添加加号图标(创建新标签)- 收藏页面不显示
+ if (tabName !== 'favorites') {
+ const addIconSpan = document.createElement('span');
+ addIconSpan.className = 'pi pi-plus accordion_add_icon';
+ addIconSpan.title = '在此分类下创建新标签';
+ headerIcon.appendChild(addIconSpan);
+
+ // 添加加号图标点击事件
+ const addIconCleanup = EventManager.addDOMListener(addIconSpan, 'click', (e) => {
+ e.stopPropagation(); // 阻止事件冒泡,避免触发手风琴展开/收起
+ this._handleAddTag(key, categoryName || key, addIconSpan);
+ });
+ this.eventCleanups.push(addIconCleanup);
+ }
+
+ // 添加箭头图标
const arrowIconSpan = document.createElement('span');
arrowIconSpan.className = 'pi pi-chevron-down accordion_arrow_icon';
headerIcon.appendChild(arrowIconSpan);
@@ -1142,20 +1375,78 @@ class TagManager {
header.appendChild(headerTitle);
header.appendChild(headerIcon);
+ // 添加拖拽时自动展开功能
+ let hoverTimer = null;
+ const dragOverCleanup = EventManager.addDOMListener(header, 'dragover', (e) => {
+ e.preventDefault(); // 必须阻止默认行为才能响应 drop/dragover
+ // 检查是否有正在拖拽的标签
+ const draggingTag = document.querySelector('.tag_item.tag-dragging');
+ if (draggingTag && !header.classList.contains('active')) {
+ if (!hoverTimer) {
+ hoverTimer = setTimeout(() => {
+ logger.debug(`[AutoExpand] 拖拽悬停自动展开: ${key}`);
+ header.click();
+ hoverTimer = null;
+ }, 500); // 500ms 延迟
+ }
+ }
+ });
+
+ const dragLeaveCleanup = EventManager.addDOMListener(header, 'dragleave', () => {
+ if (hoverTimer) {
+ clearTimeout(hoverTimer);
+ hoverTimer = null;
+ }
+ });
+ // 还要处理 drop 事件,防止计时器残留
+ const dropCleanup = EventManager.addDOMListener(header, 'drop', () => {
+ if (hoverTimer) {
+ clearTimeout(hoverTimer);
+ hoverTimer = null;
+ }
+ });
+
+ this.eventCleanups.push(dragOverCleanup, dragLeaveCleanup, dropCleanup);
+
const content = document.createElement('div');
content.className = 'tag_accordion_content';
- // 递归创建子内容,传递 tabName
- const childContent = this._createAccordionContent(value, (parseInt(level) + 1).toString(), tabName);
+ // 递归创建子内容
+ // 对于收藏标签页(categoryName为null)的第一层(level='1'),使用key(CSV文件名)作为categoryName
+ // 对于其他情况,继续传递原来的categoryName
+ const childCategoryName = (categoryName === null && level === '1') ? key : categoryName;
+ const childContent = this._createInnerAccordion(value, (parseInt(level) + 1).toString(), tabName, childCategoryName);
childContent.style.flex = '1'; // 让子内容占满可用空间
childContent.style.minHeight = '0'; // 允许flex收缩
content.appendChild(childContent);
// 根据保存的状态或默认行为确定是否展开
+ let shouldExpand;
const accordionState = this.getAccordionState();
- const shouldExpand = tabName && accordionState[tabName]?.[key] !== undefined
- ? accordionState[tabName][key] // 使用保存的状态
- : isFirstAccordion; // 默认展开第一个
+
+ if (tabName === 'favorites') {
+ // 收藏页特殊处理:默认展开当前CSV对应的分类
+ // 获取当前CSV文件名(无扩展名)
+ let currentCsvName = this.currentCsvFile || "";
+ currentCsvName = currentCsvName.replace(/\.(csv|json|yaml|yml)$/i, '');
+
+ // 检查当前key是否匹配当前CSV (key就是分类名)
+ const isCurrentCsv = key === currentCsvName;
+
+ // 检查是否存在匹配的分类
+ const hasCurrentCsv = Object.keys(data).includes(currentCsvName);
+
+ // 如果当前Key匹配当前CSV,展开
+ // 或者如果没有对应的CSV分类,且这是第一个,展开
+ shouldExpand = isCurrentCsv || (!hasCurrentCsv && isFirstAccordion);
+
+ // console.log(`[AutoExpand] Key: ${key}, Current: ${currentCsvName}, Match: ${isCurrentCsv}, HasCurrent: ${hasCurrentCsv}, Should: ${shouldExpand}`);
+ } else {
+ // 普通页逻辑:优先使用保存状态,否则默认展开第一个
+ shouldExpand = tabName && accordionState[tabName]?.[key] !== undefined
+ ? accordionState[tabName][key]
+ : isFirstAccordion;
+ }
if (shouldExpand) {
header.classList.add('active');
@@ -1170,25 +1461,29 @@ class TagManager {
}
}
- // 添加手风琴切换事件,包含关闭其他手风琴的逻辑
+ // 添加手风琴切换事件,包含关闭同级其他手风琴的逻辑
const accordionCleanup = EventManager.addDOMListener(header, 'click', (e) => {
e.stopPropagation();
- // 获取当前标签页下的所有手风琴
+
+ // 获取当前手风琴的父容器(而不是整个标签页)
+ const parentContainer = accordion.parentElement;
const parentTab = header.closest('.popup_tab_content');
- if (parentTab) {
+
+ if (parentContainer) {
if (!header.classList.contains('active')) {
- const otherAccordions = parentTab.querySelectorAll('.tag_accordion_header.active');
- otherAccordions.forEach(otherHeader => {
+ // 只查找同一父容器内的直接子手风琴(同级手风琴),而不是所有层级
+ const siblingAccordions = parentContainer.querySelectorAll(':scope > .tag_accordion > .tag_accordion_header.active');
+ siblingAccordions.forEach(otherHeader => {
if (otherHeader !== header) {
const otherContent = otherHeader.nextElementSibling;
const otherHeaderIcon = otherHeader.querySelector('.tag_accordion_icon');
- // 使用优化的切换方法关闭其他手风琴
+ // 使用优化的切换方法关闭同级其他手风琴
if (otherHeader.classList.contains('active')) {
this._toggleAccordion(otherHeader, otherContent, otherHeaderIcon);
// 保存关闭状态
const otherAccordion = otherHeader.closest('.tag_accordion');
const otherAccordionCategory = otherAccordion?.getAttribute('data-category');
- const tabName = parentTab.getAttribute('data-category');
+ const tabName = parentTab?.getAttribute('data-category');
if (tabName && otherAccordionCategory) {
this.setAccordionState(tabName, otherAccordionCategory, false);
}
@@ -1215,61 +1510,1159 @@ class TagManager {
accordion.appendChild(content);
container.appendChild(accordion);
} else if (typeof value === 'string') {
- // 如果值是字符串,创建标签项(保持原有的标签创建逻辑)
- const tagItem = document.createElement('div');
- tagItem.className = 'tag_item';
- tagItem.setAttribute('data-name', key);
- tagItem.setAttribute('data-value', value);
-
- // 检查标签是否已使用
- if (this.isTagUsed(value, this.currentNodeId, this.currentInputId)) {
- tagItem.classList.add('used');
- this.usedTags.set(value, tagItem);
- }
-
- const tagText = document.createElement('span');
- tagText.className = 'tag_item_text';
- tagText.textContent = key;
-
- tagItem.appendChild(tagText);
-
- // 添加鼠标事件监听
- const mouseEnterCleanup = EventManager.addDOMListener(tagItem, 'mouseenter', () => {
- this._showTooltip(tagItem, value);
- });
-
- const mouseLeaveCleanup = EventManager.addDOMListener(tagItem, 'mouseleave', () => {
- this._hideTooltip();
- });
-
- // 添加点击事件
- const tagClickCleanup = EventManager.addDOMListener(tagItem, 'click', (e) => {
- // 先隐藏tooltip
- this._hideTooltip();
- this.handleTagClick(tagItem, key, value, e);
- // 移除后,重新加载已插入标签
- setTimeout(() => {
- // 重新加载已插入标签页
- this._loadInsertedTagsContent(container);
- // 更新所有标签页中的标签状态
- this.updateAllTagsState(this.currentNodeId, this.currentInputId);
- // 如果当前在搜索状态,也要更新搜索结果中的标签状态
- const searchResultList = document.querySelector('.tag_search_result_list');
- if (searchResultList) {
- this.refreshSearchResultsState();
- }
- }, 100);
- });
-
- this.eventCleanups.push(mouseEnterCleanup, mouseLeaveCleanup, tagClickCleanup);
-
+ // 如果值是字符串,创建标签项
+ const tagItem = this._createTagElement(key, value, categoryName);
container.appendChild(tagItem);
}
}
+ // 初始化拖拽排序(如果有 Sortable)
+ // 分别处理手风琴排序和标签排序,避免拖拽标签时误触发手风琴排序
+ if (this.Sortable) {
+ // 检测容器内容类型(只检测直接子元素)
+ const hasAccordions = container.querySelector(':scope > .tag_accordion') !== null;
+ const hasTags = container.querySelector(':scope > .tag_item') !== null;
+
+ // 手风琴排序:只有手风琴头部可以触发,且只排序手风琴元素
+ if (hasAccordions) {
+ const accordionSortable = new this.Sortable(container, {
+ group: { name: 'accordions', pull: false, put: false }, // 独立分组,不与标签混排
+ animation: 150,
+ ghostClass: 'tag-ghost',
+ handle: '.tag_accordion_header', // 只有手风琴头部可以拖拽
+ draggable: '.tag_accordion', // 只排序手风琴元素
+ delay: 50,
+ onEnd: async (evt) => {
+ const { oldIndex, newIndex } = evt;
+ if (oldIndex === newIndex) return;
+
+ // 获取新顺序(只处理手风琴)
+ const newOrderKeys = [];
+ Array.from(container.children).forEach(el => {
+ if (el.classList.contains('tag_accordion')) {
+ const cat = el.getAttribute('data-category');
+ if (cat) newOrderKeys.push(cat);
+ }
+ });
+
+ // 重构 data 对象
+ const tempObj = { ...data };
+ for (const key in data) delete data[key];
+
+ newOrderKeys.forEach(key => {
+ if (Object.prototype.hasOwnProperty.call(tempObj, key)) {
+ data[key] = tempObj[key];
+ }
+ });
+
+ // 保留未在DOM中的数据
+ for (const key in tempObj) {
+ if (!Object.prototype.hasOwnProperty.call(data, key)) {
+ data[key] = tempObj[key];
+ }
+ }
+
+ // 保存排序
+ await this._saveTagOrder(data, tabName, categoryName);
+ }
+ });
+ this.sortables.push(accordionSortable);
+ }
+
+
+ // 标签排序(支持跨分类拖拽)
+ // 始终初始化标签 Sortable,确保空分类或只有子分类的容器也能接收标签
+ if (true) {
+ // 为容器添加分类标识,用于跨分类拖拽时识别
+ container.setAttribute('data-sortable-category', categoryName || tabName);
+ // 确保容器有最小高度,以便空容器也能因 drop-zone 样式而被看到
+ container.style.minHeight = '10px';
+
+ const tagSortable = new this.Sortable(container, {
+ group: {
+ name: 'tags',
+ pull: true,
+ put: function (to) {
+ // 如果目标容器包含手风琴(由 .tag_accordion 子元素判断),则不允许放入标签
+ // 标签只能放入最底层的分类容器(即手风琴的内容区域)
+ return to.el.querySelector(':scope > .tag_accordion') === null;
+ }
+ }, // 允许跨分类拖拽
+ animation: 150,
+ ghostClass: 'tag-ghost',
+ draggable: '.tag_item',
+ delay: 50,
+ onStart: function (evt) {
+ evt.item.classList.add('tag-dragging');
+ // 高亮所有可放置的分类容器(仅限底层容器)
+ document.querySelectorAll('[data-sortable-category]').forEach(el => {
+ if (el !== evt.from && !el.querySelector(':scope > .tag_accordion')) {
+ el.classList.add('tag-drop-zone');
+ }
+ });
+ },
+ onEnd: async (evt) => {
+ evt.item.classList.remove('tag-dragging');
+ // 移除高亮
+ document.querySelectorAll('.tag-drop-zone').forEach(el => {
+ el.classList.remove('tag-drop-zone');
+ });
+
+ // 如果是跨分类移动,由 onAdd 处理
+ if (evt.from !== evt.to) return;
+
+ const { oldIndex, newIndex } = evt;
+ if (oldIndex === newIndex) return;
+
+ const newOrderKeys = [];
+ Array.from(container.children).forEach(el => {
+ if (el.classList.contains('tag_item')) {
+ const name = el.getAttribute('data-name');
+ if (name) newOrderKeys.push(name);
+ }
+ });
+
+ const tempObj = { ...data };
+ for (const key in data) delete data[key];
+
+ newOrderKeys.forEach(key => {
+ if (Object.prototype.hasOwnProperty.call(tempObj, key)) {
+ data[key] = tempObj[key];
+ }
+ });
+
+ for (const key in tempObj) {
+ if (!Object.prototype.hasOwnProperty.call(data, key)) {
+ data[key] = tempObj[key];
+ }
+ }
+
+ await this._saveTagOrder(data, tabName, categoryName);
+ },
+ onAdd: async (evt) => {
+ // 跨分类移动标签
+ const tagItem = evt.item;
+ const tagName = tagItem.getAttribute('data-name');
+ const tagValue = tagItem.getAttribute('data-value');
+ const fromCategory = evt.from.getAttribute('data-sortable-category');
+ const toCategory = evt.to.getAttribute('data-sortable-category');
+
+ logger.debug(`[onAdd] 开始移动标签: ${tagName}, 从: ${fromCategory}, 到: ${toCategory}`);
+
+ // 更新标签元素的分类属性
+ tagItem.setAttribute('data-category', toCategory);
+
+ // 调用移动函数 (不立即保存,等待排序)
+ const success = await this._moveTagToCategory(tagName, tagValue, fromCategory, toCategory, tabName, false);
+
+ logger.debug(`[onAdd] 移动结果: ${success}`);
+
+ if (!success) {
+ // 如果移动失败,将标签移回原位置
+ logger.warn(`[onAdd] 移动失败,回滚 DOM`);
+ evt.from.appendChild(tagItem);
+ } else {
+ // 移动成功,现在根据 DOM 顺序重新排序并保存
+
+ // 获取新的 DOM 顺序
+ const newOrderKeys = [];
+ Array.from(evt.to.children).forEach(el => {
+ if (el.classList.contains('tag_item')) {
+ const name = el.getAttribute('data-name');
+ if (name) newOrderKeys.push(name);
+ }
+ });
+
+ // 重构 data 对象
+ const tempObj = { ...data };
+ for (const key in data) delete data[key];
+
+ newOrderKeys.forEach(key => {
+ if (Object.prototype.hasOwnProperty.call(tempObj, key)) {
+ data[key] = tempObj[key];
+ }
+ });
+
+ // 确保没有遗漏
+ for (const key in tempObj) {
+ if (!Object.prototype.hasOwnProperty.call(data, key)) {
+ data[key] = tempObj[key];
+ }
+ }
+
+ // 保存排序后的数据
+ await this._saveTagOrder(data, tabName, toCategory);
+
+ // 显示成功提示
+ logger.debug(`[标签移动] 成功并排序 | 标签: ${tagName} | 到: ${toCategory}`);
+ app.extensionManager.toast.add({
+ severity: "success",
+ summary: "移动成功",
+ detail: `标签 "${tagName}" 已移动到 "${toCategory}"`,
+ life: 2000
+ });
+ }
+ }
+ });
+ this.sortables.push(tagSortable);
+ }
+ }
+
return container;
}
+ /**
+ * 保存标签排序
+ * @param {Object} data 数据对象
+ * @param {string} tabName 标签页名称
+ * @param {string} categoryName 分类名称
+ */
+ static async _saveTagOrder(data, tabName, categoryName) {
+ try {
+ let success = false;
+ const isFavorites = tabName === 'favorites' || categoryName === 'favorites';
+
+ if (isFavorites) {
+ // 保存到 tags_user.json
+ const userTagData = await ResourceManager.getUserTagData();
+ if (!userTagData.favorites) {
+ userTagData.favorites = {};
+ }
+ userTagData.favorites = TagManager.favorites;
+
+ success = await ResourceManager.saveUserTags(userTagData);
+ if (success) {
+ logger.debug(`[标签排序] 收藏排序已保存`);
+ app.extensionManager.toast.add({
+ severity: "success",
+ summary: "排序保存成功",
+ detail: "收藏标签顺序已更新",
+ life: 2000
+ });
+ }
+ } else {
+ // 保存到 CSV
+ if (TagManager.currentCsvFile && TagManager.tagData) {
+ success = await ResourceManager.saveTagsCsv(TagManager.currentCsvFile, TagManager.tagData);
+ if (success) {
+ logger.debug(`[标签排序] CSV排序已保存 | 文件: ${TagManager.currentCsvFile}`);
+ app.extensionManager.toast.add({
+ severity: "success",
+ summary: "排序保存成功",
+ detail: "标签文件顺序已更新",
+ life: 2000
+ });
+ }
+ }
+ }
+
+ if (!success) {
+ app.extensionManager.toast.add({
+ severity: "error",
+ summary: "保存失败",
+ detail: "排序未能保存到服务器",
+ life: 3000
+ });
+ }
+ } catch (err) {
+ logger.error(`[标签排序] 保存出错: ${err.message}`);
+ }
+ }
+
+ /**
+ * 移动标签到新分类
+ * @param {string} tagName 标签名称
+ * @param {string} tagValue 标签值
+ * @param {string} fromCategory 原分类名
+ * @param {string} toCategory 目标分类名
+ * @param {string} tabName 标签页名称
+ */
+ static async _moveTagToCategory(tagName, tagValue, fromCategory, toCategory, tabName, shouldSave = true) {
+ try {
+ // 收藏页不支持跨分类移动
+ if (tabName === 'favorites' || fromCategory === 'favorites' || toCategory === 'favorites') {
+ logger.debug('[标签移动] 收藏页标签不支持跨分类移动');
+ return false;
+ }
+
+ // 如果源分类和目标分类相同,不处理
+ if (fromCategory === toCategory) {
+ return false;
+ }
+
+ const filename = this.currentCsvFile;
+ if (!filename || !TagManager.tagData) {
+ logger.error('[标签移动] 无法获取当前CSV文件或标签数据');
+ return false;
+ }
+
+ // 递归查找分类
+ const findCategoryRecursively = (obj, catName) => {
+ for (const [key, value] of Object.entries(obj)) {
+ if (key === catName && typeof value === 'object' && value !== null) {
+ return value;
+ }
+ if (typeof value === 'object' && value !== null) {
+ const result = findCategoryRecursively(value, catName);
+ if (result) return result;
+ }
+ }
+ return null;
+ };
+
+ // 获取源分类和目标分类
+ const sourceCategory = findCategoryRecursively(TagManager.tagData, fromCategory);
+ const targetCategory = findCategoryRecursively(TagManager.tagData, toCategory);
+
+ if (!sourceCategory) {
+ logger.error(`[标签移动] 找不到源分类: ${fromCategory}`);
+ return false;
+ }
+
+ if (!targetCategory) {
+ logger.error(`[标签移动] 找不到目标分类: ${toCategory}`);
+ return false;
+ }
+
+ // 检查目标分类是否已存在同名标签
+ if (targetCategory.hasOwnProperty(tagName)) {
+ app.extensionManager.toast.add({
+ severity: "warn",
+ summary: "移动失败",
+ detail: `目标分类中已存在同名标签 "${tagName}"`,
+ life: 3000
+ });
+ return false;
+ }
+
+ // 从源分类删除标签
+ delete sourceCategory[tagName];
+
+ // 添加到目标分类
+ targetCategory[tagName] = tagValue;
+
+ if (!shouldSave) {
+ return true;
+ }
+
+ // 保存到 CSV
+ const success = await ResourceManager.saveTagsCsv(filename, TagManager.tagData);
+
+ if (success) {
+ logger.debug(`[标签移动] 成功 | 标签: ${tagName} | 从: ${fromCategory} | 到: ${toCategory}`);
+ app.extensionManager.toast.add({
+ severity: "success",
+ summary: "移动成功",
+ detail: `标签 "${tagName}" 已移动到 "${toCategory}"`,
+ life: 2000
+ });
+ return true;
+ } else {
+ // 回滚操作
+ targetCategory[tagName] && delete targetCategory[tagName];
+ sourceCategory[tagName] = tagValue;
+
+ app.extensionManager.toast.add({
+ severity: "error",
+ summary: "移动失败",
+ detail: "保存到服务器失败",
+ life: 3000
+ });
+ return false;
+ }
+ } catch (err) {
+ logger.error(`[标签移动] 出错: ${err.message}`);
+ app.extensionManager.toast.add({
+ severity: "error",
+ summary: "移动失败",
+ detail: err.message,
+ life: 3000
+ });
+ return false;
+ }
+ }
+
+ /**
+ * 检查标签是否已收藏
+ */
+ static _isTagFavorited(tagValue, category = null) {
+ if (!this.favorites) return false;
+
+ // 如果提供了分类,优先检查该分类
+ if (category) {
+ // 标准化分类名称(去除扩展名)
+ const normalize = (name) => name.replace(/\.(csv|json|yaml|yml)$/i, '');
+ const targetCat = normalize(category);
+
+ // 检查直接匹配
+ if (this.favorites[targetCat]) {
+ return Object.values(this.favorites[targetCat]).includes(tagValue);
+ }
+
+ // 如果没找到直接匹配的Key,可能需要遍历Keys进行模糊匹配?
+ // 目前假设 Keys 都是已标准化。
+ // 但如果 favorites 还是旧结构(平铺),直接检查 values
+ const isOldStructure = Object.values(this.favorites).some(v => typeof v !== 'object');
+ if (isOldStructure) {
+ // 旧结构忽略 category
+ return Object.values(this.favorites).includes(tagValue);
+ }
+
+ return false;
+ }
+
+ // 如果没有提供分类(比如全局搜索),则递归检查
+ // 递归查找值
+ const checkRecursive = (obj) => {
+ if (typeof obj !== 'object' || obj === null) return false;
+
+ for (const value of Object.values(obj)) {
+ if (typeof value === 'object' && value !== null) {
+ if (checkRecursive(value)) return true;
+ } else if (value === tagValue) {
+ return true;
+ }
+ }
+ return false;
+ };
+
+ return checkRecursive(this.favorites);
+ }
+
+ /**
+ * 创建标签元素
+ */
+ static _createTagElement(tagName, tagValue, categoryName = null) {
+ const tagItem = document.createElement('div');
+ tagItem.className = 'tag_item';
+ tagItem.setAttribute('data-name', tagName);
+ tagItem.setAttribute('data-value', tagValue);
+ if (categoryName) {
+ tagItem.setAttribute('data-category', categoryName);
+ }
+
+ // 检查标签是否已使用
+ if (this.isTagUsed(tagValue, this.currentNodeId, this.currentInputId)) {
+ tagItem.classList.add('used');
+ this.usedTags.set(tagValue, tagItem);
+ }
+
+ const tagText = document.createElement('span');
+ tagText.className = 'tag_item_text';
+
+ // 检查是否已收藏 (考虑分类)
+ // 如果是收藏页,TagElement的 categoryName 本身就是 SourceCategory
+ // 如果是常规页,categoryName 就是 SourceCategory
+
+ // 注意:categoryName 在这里通常是文件名,比如 "foo.csv" 或 "foo"
+ // 我们后端/缓存的 key 通常是去掉了扩展名的。
+ // _isTagFavorited 内部会尝试处理,但最好我们传进去的是原始 categoryName,在内部处理。
+ const isFavorited = this._isTagFavorited(tagValue, categoryName);
+ tagText.textContent = isFavorited ? `⭐️ ${tagName}` : tagName;
+
+ tagItem.appendChild(tagText);
+
+ // 添加鼠标事件监听
+ const mouseEnterCleanup = EventManager.addDOMListener(tagItem, 'mouseenter', () => {
+ this._showTooltip(tagItem, tagValue);
+ });
+
+ const mouseLeaveCleanup = EventManager.addDOMListener(tagItem, 'mouseleave', () => {
+ this._hideTooltip();
+ });
+
+ // 添加点击事件
+ const tagClickCleanup = EventManager.addDOMListener(tagItem, 'click', (e) => {
+ // 先隐藏tooltip
+ this._hideTooltip();
+ this.handleTagClick(tagItem, tagName, tagValue, e);
+ // 移除后,重新加载已插入标签
+ setTimeout(() => {
+ // 重新加载已插入标签页
+ const insertedTabContent = document.querySelector('.popup_tab_content[data-category="已插入"]');
+ if (insertedTabContent && insertedTabContent.classList.contains('active')) {
+ this._loadInsertedTagsContent(insertedTabContent);
+ }
+ // 更新所有标签页中的标签状态
+ this.updateAllTagsState(this.currentNodeId, this.currentInputId);
+ // 如果当前在搜索状态,也要更新搜索结果中的标签状态
+ const searchResultList = document.querySelector('.tag_search_result_list');
+ if (searchResultList) {
+ this.refreshSearchResultsState();
+ }
+ }, 0);
+ });
+
+ // 添加右键菜单
+ const contextMenuCleanup = EventManager.addDOMListener(tagItem, 'contextmenu', (e) => {
+ e.preventDefault();
+ this._showContextMenu(e, tagValue, tagName, categoryName);
+ });
+
+ this.eventCleanups.push(mouseEnterCleanup, mouseLeaveCleanup, tagClickCleanup, contextMenuCleanup);
+
+
+
+ return tagItem;
+ }
+
+ /**
+ * 显示右键菜单
+ */
+ static _showContextMenu(e, tagValue, tagName, category = null) {
+ // 检查是否已收藏 (使用传入的 category)
+ const isFavorited = this._isTagFavorited(tagValue, category);
+
+ // ---为收藏标签提供简化菜单---
+ // 收藏标签(⭐️)是引用类型,不应该被编辑或删除,只能取消收藏
+ const menuItems = isFavorited ? [
+ // 收藏标签:仅显示取消收藏选项
+ {
+ label: '取消收藏',
+ icon: 'pi-minus-circle',
+ onClick: async () => {
+ // 标准化分类名
+ let targetCategory = category || this.currentCsvFile || "默认";
+ targetCategory = targetCategory.replace(/\.(csv|json|yaml|yml)$/i, '');
+
+ const success = await ResourceManager.removeFavorite(tagValue, targetCategory);
+ if (success) {
+ // 移除本地缓存 (favorites 是嵌套对象 {category: {name: value}})
+ if (this.favorites && this.favorites[targetCategory]) {
+ const catData = this.favorites[targetCategory];
+ for (const key in catData) {
+ if (catData[key] === tagValue) {
+ delete catData[key];
+ break;
+ }
+ }
+ }
+
+ // 更新UI状态
+ this.updateTagFavoriteState(tagValue, false);
+
+ // 如果当前在收藏页,刷新收藏页
+ const activeTab = document.querySelector('.popup_tab.active');
+ if (activeTab && activeTab.getAttribute('data-category') === 'favorites') {
+ const content = document.querySelector('.popup_tab_content.active');
+ if (content) {
+ content.removeAttribute('data-loaded');
+ this._loadCategoryContent(content, this.favorites, 'favorites');
+ }
+ }
+ }
+ }
+ }
+ ] : [
+ // 常规标签:显示完整菜单选项
+ {
+ label: '收藏标签',
+ icon: 'pi-star',
+ onClick: async () => {
+ // 标准化分类名
+ let targetCategory = category || this.currentCsvFile || "默认";
+ targetCategory = targetCategory.replace(/\.(csv|json|yaml|yml)$/i, '');
+
+ const success = await ResourceManager.addFavorite(tagValue, tagName, targetCategory);
+ if (success) {
+ if (!this.favorites) this.favorites = {};
+
+ // 添加到本地缓存
+ if (!this.favorites[targetCategory]) {
+ this.favorites[targetCategory] = {};
+ }
+ this.favorites[targetCategory][tagName] = tagValue;
+
+ // 更新UI状态
+ this.updateTagFavoriteState(tagValue, true);
+
+ // 立即刷新收藏页(如果在)
+ const activeTab = document.querySelector('.popup_tab.active');
+ if (activeTab && activeTab.getAttribute('data-category') === 'favorites') {
+ const content = document.querySelector('.popup_tab_content.active');
+ if (content) {
+ // 强制移除 data-loaded 属性
+ content.removeAttribute('data-loaded');
+ this._loadCategoryContent(content, this.favorites, 'favorites');
+ }
+ }
+ }
+ }
+ },
+ {
+ label: '编辑标签',
+ icon: 'pi-pencil',
+ onClick: (e) => {
+ // 使用 _showContextMenu 传入的 category 参数
+ // 注意:右键菜单是 append 到 body 的,无法从 e.target 向上查找 .tag_item
+ this._handleEditTag(e.target, tagName, tagValue, category);
+ }
+ },
+ {
+ separator: true
+ },
+ {
+ label: '删除标签',
+ icon: 'pi-trash',
+ danger: true,
+ onClick: () => {
+ this._handleDeleteTag(tagName, tagValue);
+ }
+ }
+ ];
+
+ showContextMenu({
+ x: e.clientX,
+ y: e.clientY,
+ items: menuItems
+ });
+ }
+
+ /**
+ * 处理添加新标签
+ * @param {string} categoryKey 分类键
+ * @param {string} categoryName 分类名
+ * @param {HTMLElement} triggerElement 触发元素(加号按钮)
+ */
+ static _handleAddTag(categoryKey, categoryName, triggerElement) {
+ // 创建表单内容渲染函数
+ const renderForm = (container) => {
+ container.style.display = 'flex';
+ container.style.flexDirection = 'column';
+ container.style.gap = '16px';
+ container.style.minWidth = '300px';
+
+ // 标签名输入组 - 使用浮动标签样式
+ const nameGroup = document.createElement('div');
+ nameGroup.className = 'settings-form-group';
+
+ const nameFloatContainer = document.createElement('div');
+ nameFloatContainer.className = 'float-label-container';
+
+ const nameInput = document.createElement('input');
+ nameInput.type = 'text';
+ nameInput.value = '';
+ nameInput.className = 'p-inputtext p-component';
+ nameInput.placeholder = ' '; // 使用空格触发 :not(:placeholder-shown)
+
+ const nameLabel = document.createElement('label');
+ nameLabel.textContent = '标签名称';
+
+ nameFloatContainer.appendChild(nameInput);
+ nameFloatContainer.appendChild(nameLabel);
+ nameGroup.appendChild(nameFloatContainer);
+ container.appendChild(nameGroup);
+
+ // 标签内容输入组 - 使用浮动标签样式
+ const valueGroup = document.createElement('div');
+ valueGroup.className = 'settings-form-group';
+
+ const valueFloatContainer = document.createElement('div');
+ valueFloatContainer.className = 'float-label-container';
+
+ const valueInput = document.createElement('textarea');
+ valueInput.value = '';
+ valueInput.className = 'p-inputtext p-component settings-form-textarea';
+ valueInput.placeholder = ' ';
+ valueInput.rows = 3;
+ valueInput.style.resize = 'vertical';
+ valueInput.style.minHeight = '80px';
+
+ const valueLabel = document.createElement('label');
+ valueLabel.textContent = '标签内容';
+
+ valueFloatContainer.appendChild(valueInput);
+ valueFloatContainer.appendChild(valueLabel);
+ valueGroup.appendChild(valueFloatContainer);
+ container.appendChild(valueGroup);
+
+ // 返回引用以便获取值
+ return { nameInput, valueInput };
+ };
+
+ // 使用传入的触发元素作为定位目标,如果没有则降级到手风琴头部
+ const anchor = triggerElement ||
+ document.querySelector(`.tag_accordion[data-category="${CSS.escape(categoryKey)}"] .tag_accordion_header`) ||
+ document.body;
+
+ let inputs = {};
+
+ createConfirmPopup({
+ target: anchor,
+ message: `在 "${categoryKey}" 分类下创建新标签`,
+ icon: 'pi-plus',
+ position: 'left', // 创建按钮在右侧,气泡向左显示
+ confirmLabel: '保存',
+ renderFormContent: (container) => {
+ inputs = renderForm(container);
+ },
+ onConfirm: async () => {
+ const newName = inputs.nameInput.value.trim();
+ const newValue = inputs.valueInput.value.trim();
+
+ if (!newName || !newValue) {
+ app.extensionManager.toast.add({
+ severity: "warn",
+ summary: "输入无效",
+ detail: "标签名称和内容不能为空",
+ life: 3000
+ });
+ throw new Error("Validation failed");
+ }
+
+ // 执行保存逻辑
+ await this._saveNewTag(categoryKey, categoryName, newName, newValue);
+ }
+ });
+ }
+
+ /**
+ * 保存新标签
+ */
+ static async _saveNewTag(categoryKey, categoryName, tagName, tagValue) {
+ try {
+ // 使用 loadTagsCsv 加载当前 CSV 文件的最新数据
+ const filename = this.currentCsvFile || await ResourceManager.getSelectedTagFile();
+ if (!filename) {
+ throw new Error("无法确定当前 CSV 文件");
+ }
+
+ const data = await ResourceManager.loadTagsCsv(filename);
+ if (!data || Object.keys(data).length === 0) {
+ throw new Error("加载标签数据失败");
+ }
+
+ // 递归查找目标分类
+ const findCategoryRecursively = (obj, catKey) => {
+ for (const [key, value] of Object.entries(obj)) {
+ if (key === catKey && typeof value === 'object' && value !== null) {
+ return value;
+ }
+ if (typeof value === 'object' && value !== null) {
+ const result = findCategoryRecursively(value, catKey);
+ if (result) return result;
+ }
+ }
+ return null;
+ };
+
+ // 查找目标分类
+ let targetCategory = findCategoryRecursively(data, categoryKey);
+
+ if (!targetCategory) {
+ // 如果分类不存在,创建新分类
+ data[categoryKey] = {};
+ targetCategory = data[categoryKey];
+ }
+
+ // 检查标签名是否已存在
+ if (targetCategory.hasOwnProperty(tagName)) {
+ app.extensionManager.toast.add({
+ severity: "warn",
+ summary: "标签已存在",
+ detail: `标签 "${tagName}" 已存在于该分类中`,
+ life: 3000
+ });
+ throw new Error("Tag already exists");
+ }
+
+ // 添加新标签
+ targetCategory[tagName] = tagValue;
+
+ // 保存到 CSV 文件
+ await ResourceManager.saveTagsCsv(filename, data);
+
+ // 重新加载 CSV 数据以刷新缓存
+ const newData = await ResourceManager.loadTagsCsv(filename);
+
+ // 更新静态数据引用,确保后续拖拽排序使用最新数据
+ TagManager.tagData = newData;
+
+ // 刷新当前视图
+ const activeTab = document.querySelector('.popup_tab.active');
+ const content = document.querySelector('.popup_tab_content.active');
+ if (activeTab && content) {
+ const cat = activeTab.getAttribute('data-category');
+ content.innerHTML = '';
+ content.removeAttribute('data-loaded');
+
+ if (cat === 'favorites') {
+ this._loadCategoryContent(content, this.favorites, 'favorites');
+ } else {
+ this._loadCategoryContent(content, newData[cat], cat);
+ }
+ }
+
+ app.extensionManager.toast.add({
+ severity: "success",
+ summary: "创建成功",
+ detail: `标签 "${tagName}" 已添加到 "${categoryKey}" 分类`,
+ life: 3000
+ });
+
+ } catch (error) {
+ // 如果是验证错误,不显示错误提示(已经在验证时显示)
+ if (error.message !== "Validation failed" && error.message !== "Tag already exists") {
+ logger.error(`创建标签失败: ${error.message}`);
+ app.extensionManager.toast.add({
+ severity: "error",
+ summary: "创建失败",
+ detail: error.message,
+ life: 3000
+ });
+ }
+ }
+ }
+
+ /**
+ * 处理编辑标签
+ */
+ static _handleEditTag(target, tagName, tagValue, category) {
+ // 创建表单内容渲染函数
+ const renderForm = (container) => {
+ container.style.display = 'flex';
+ container.style.flexDirection = 'column';
+ container.style.gap = '16px';
+ container.style.minWidth = '300px';
+
+ // 标签名输入组 - 使用浮动标签样式
+ const nameGroup = document.createElement('div');
+ nameGroup.className = 'settings-form-group';
+
+ const nameFloatContainer = document.createElement('div');
+ nameFloatContainer.className = 'float-label-container';
+
+ const nameInput = document.createElement('input');
+ nameInput.type = 'text';
+ nameInput.value = tagName;
+ nameInput.className = 'p-inputtext p-component';
+ nameInput.placeholder = ' '; // 使用空格触发 :not(:placeholder-shown)
+
+ const nameLabel = document.createElement('label');
+ nameLabel.textContent = '标签名称';
+
+ nameFloatContainer.appendChild(nameInput);
+ nameFloatContainer.appendChild(nameLabel);
+ nameGroup.appendChild(nameFloatContainer);
+ container.appendChild(nameGroup);
+
+ // 标签内容输入组 - 使用浮动标签样式
+ const valueGroup = document.createElement('div');
+ valueGroup.className = 'settings-form-group';
+
+ const valueFloatContainer = document.createElement('div');
+ valueFloatContainer.className = 'float-label-container';
+
+ const valueInput = document.createElement('textarea');
+ valueInput.value = tagValue;
+ valueInput.className = 'p-inputtext p-component settings-form-textarea';
+ valueInput.placeholder = ' ';
+ valueInput.rows = 3;
+ valueInput.style.resize = 'vertical';
+ valueInput.style.minHeight = '80px';
+
+ const valueLabel = document.createElement('label');
+ valueLabel.textContent = '标签内容';
+
+ valueFloatContainer.appendChild(valueInput);
+ valueFloatContainer.appendChild(valueLabel);
+ valueGroup.appendChild(valueFloatContainer);
+ container.appendChild(valueGroup);
+
+ // 返回引用以便获取值
+ return { nameInput, valueInput, categoryInput: null };
+ };
+
+ // 查找合适的定位目标 (tag_item)
+ const tagItem = document.querySelector(`.tag_item[data-value="${CSS.escape(tagValue)}"]`);
+ const anchor = tagItem || document.body; // 降级处理
+
+ let inputs = {};
+
+ createConfirmPopup({
+ target: anchor,
+ message: '编辑标签信息',
+ icon: 'pi-pencil',
+ confirmLabel: '保存',
+ renderFormContent: (container) => {
+ inputs = renderForm(container);
+ },
+ onConfirm: async () => {
+ const newName = inputs.nameInput.value.trim();
+ const newValue = inputs.valueInput.value.trim();
+ const newCategory = inputs.categoryInput ? inputs.categoryInput.value.trim() : category;
+
+ if (!newName || !newValue) {
+ app.extensionManager.toast.add({
+ severity: "warn",
+ summary: "输入无效",
+ detail: "标签名称和内容不能为空",
+ life: 3000
+ });
+ throw new Error("Validation failed");
+ }
+
+ // 执行保存逻辑
+ await this._saveTagEdit(tagName, tagValue, category, newName, newValue, newCategory);
+ }
+ });
+ }
+
+ /**
+ * 保存标签编辑
+ */
+ static async _saveTagEdit(oldName, oldValue, oldCategory, newName, newValue, newCategory) {
+ try {
+ // 使用 loadTagsCsv 加载当前 CSV 文件的最新数据
+ const filename = this.currentCsvFile || await ResourceManager.getSelectedTagFile();
+ if (!filename) {
+ throw new Error("无法确定当前 CSV 文件");
+ }
+
+ const data = await ResourceManager.loadTagsCsv(filename);
+ if (!data || Object.keys(data).length === 0) {
+ throw new Error("加载标签数据失败");
+ }
+
+ // 递归查找包含指定标签的父对象
+ const findTagRecursively = (obj, targetName, targetValue) => {
+ for (const [key, value] of Object.entries(obj)) {
+ if (key === targetName && value === targetValue) {
+ return { parent: obj, key: key };
+ }
+ if (typeof value === 'object' && value !== null) {
+ const result = findTagRecursively(value, targetName, targetValue);
+ if (result) return result;
+ }
+ }
+ return null;
+ };
+
+ // 查找原始标签
+ const found = findTagRecursively(data, oldName, oldValue);
+
+ if (found) {
+ const { parent } = found;
+
+ // 删除旧标签
+ delete parent[oldName];
+
+ // 如果分类改变,需要移动到新分类
+ if (oldCategory !== newCategory && newCategory) {
+ const findCategoryRecursively = (obj, catName) => {
+ for (const [key, value] of Object.entries(obj)) {
+ if (key === catName && typeof value === 'object' && value !== null) {
+ return value;
+ }
+ if (typeof value === 'object' && value !== null) {
+ const result = findCategoryRecursively(value, catName);
+ if (result) return result;
+ }
+ }
+ return null;
+ };
+
+ let targetCategory = findCategoryRecursively(data, newCategory);
+
+ if (!targetCategory) {
+ data[newCategory] = {};
+ targetCategory = data[newCategory];
+ }
+
+ targetCategory[newName] = newValue;
+ } else {
+ parent[newName] = newValue;
+ }
+
+ // 保存到 CSV 文件
+ await ResourceManager.saveTagsCsv(filename, data);
+
+ // 重新加载 CSV 数据以刷新缓存
+ const newData = await ResourceManager.loadTagsCsv(filename);
+
+ // 更新静态数据引用,确保后续拖拽排序使用最新数据
+ TagManager.tagData = newData;
+
+ // 刷新当前视图
+ const activeTab = document.querySelector('.popup_tab.active');
+ const content = document.querySelector('.popup_tab_content.active');
+ if (activeTab && content) {
+ const cat = activeTab.getAttribute('data-category');
+ content.innerHTML = '';
+ content.removeAttribute('data-loaded');
+
+ if (cat === 'favorites') {
+ this._loadCategoryContent(content, this.favorites, 'favorites');
+ } else {
+ this._loadCategoryContent(content, newData[cat], cat);
+ }
+ }
+
+ app.extensionManager.toast.add({
+ severity: "success",
+ summary: "保存成功",
+ detail: "标签已更新",
+ life: 3000
+ });
+ } else {
+ throw new Error("无法定位原始标签数据,请刷新后重试");
+ }
+
+ } catch (error) {
+ logger.error(`保存编辑失败: ${error.message}`);
+ app.extensionManager.toast.add({
+ severity: "error",
+ summary: "保存失败",
+ detail: error.message,
+ life: 3000
+ });
+ }
+ }
+
+ /**
+ * 处理删除标签
+ */
+ static async _handleDeleteTag(tagName, tagValue) {
+ createSettingsDialog({
+ title: '确认删除',
+ isConfirmDialog: true,
+ dialogClassName: 'confirm-dialog',
+ disableBackdropAndCloseOnClickOutside: true,
+ saveButtonText: '删除',
+ saveButtonIcon: 'pi-trash',
+ isDangerButton: true,
+ cancelButtonText: '取消',
+ renderContent: (content) => {
+ content.style.textAlign = 'center';
+ content.style.padding = '1.5rem 1rem'; // 增加一点内边距
+
+ const confirmMessage = document.createElement('div');
+ confirmMessage.innerHTML = `
+ 确定要删除标签 ${tagName} 吗?
+ 此操作将从 CSV 文件中永久删除该标签。
+ `;
+
+ content.appendChild(confirmMessage);
+ },
+ onSave: async () => {
+ try {
+ // 1. 获取当前 CSV 文件名
+ const filename = this.currentCsvFile || await ResourceManager.getSelectedTagFile();
+ if (!filename) {
+ throw new Error("无法确定当前 CSV 文件");
+ }
+
+ // 2. 加载 CSV 数据
+ // 注意:为了确保数据的一致性,我们需要重新加载文件而不是使用缓存
+ // 或者我们相信 ResourceManager 的 loadTagsCsv 会处理好?
+ // 我们使用 loadTagsCsv,它会返回解析后的数据
+ const data = await ResourceManager.loadTagsCsv(filename);
+ if (!data) {
+ throw new Error("加载标签数据失败");
+ }
+
+ // 3. 在数据中查找并删除标签
+ let deleted = false;
+
+ // 递归删除函数
+ const deleteRecursively = (obj) => {
+ for (const key in obj) {
+ if (key === tagName && obj[key] === tagValue) {
+ delete obj[key];
+ deleted = true;
+ return true;
+ }
+ if (typeof obj[key] === 'object' && obj[key] !== null) {
+ if (deleteRecursively(obj[key])) {
+ // 如果子对象变空了,是否需要删除子对象?
+ // 通常 CSV 转换的对象如果为空可能保留也可能删除。
+ // 这里我们简单处理,仅删除标签。
+ return true;
+ }
+ }
+ }
+ return false;
+ };
+
+ deleteRecursively(data);
+
+ if (!deleted) {
+ throw new Error("未找到指定的标签");
+ }
+
+ // 4. 保存更改
+ const saveSuccess = await ResourceManager.saveTagsCsv(filename, data);
+ if (!saveSuccess) {
+ throw new Error("保存文件失败");
+ }
+
+ // 5. 刷新 UI
+ // 注意:不能使用 refreshTagData(),因为它会获取默认标签而不是当前 CSV 文件
+ // 直接使用本地已更新的 data 对象
+
+ // 更新静态数据引用,确保后续拖拽排序使用最新数据
+ TagManager.tagData = data;
+
+ // 重新加载当前视图
+ const activeTab = document.querySelector('.popup_tab.active');
+ if (activeTab) {
+ const category = activeTab.getAttribute('data-category');
+ const content = document.querySelector(`.popup_tab_content[data-category="${category}"]`);
+
+ if (content) {
+ // 如果是收藏页或已插入页,逻辑不同
+ if (category === 'favorites') {
+ this._loadCategoryContent(content, this.favorites, 'favorites');
+ } else if (category === 'inserted') {
+ this._loadInsertedTagsContent(content);
+ } else {
+ // 普通分类,使用本地已更新的 data
+ this._loadCategoryContent(content, data[category], category);
+ }
+ }
+ }
+
+ app.extensionManager.toast.add({
+ severity: "success",
+ summary: "删除成功",
+ detail: `标签 "${tagName}" 已删除`,
+ life: 3000
+ });
+
+ } catch (error) {
+ logger.error(`删除标签失败: ${error.message}`);
+ app.extensionManager.toast.add({
+ severity: "error",
+ summary: "删除失败",
+ detail: error.message,
+ life: 3000
+ });
+ }
+ }
+ });
+ }
+
+ /**
+ * 更新标签的收藏状态显示
+ */
+ static updateTagFavoriteState(tagValue, isFavorited) {
+ // 查找所有具有该值的标签元素
+ // 注意:需要转义引号以防止选择器错误
+ const safeValue = tagValue.replace(/"/g, '\\"');
+ const tagItems = document.querySelectorAll(`.tag_item[data-value="${safeValue}"]`);
+
+ tagItems.forEach(item => {
+ const textSpan = item.querySelector('.tag_item_text');
+ if (textSpan) {
+ const currentText = textSpan.textContent;
+ // 移除现有的星星前缀(如果有)
+ const cleanText = currentText.replace(/^⭐️\s*/, '');
+ // 根据状态添加或不添加前缀
+ textSpan.textContent = isFavorited ? `⭐️ ${cleanText}` : cleanText;
+ }
+ });
+ }
+
/**
* 比对输入框内容与标签缓存,更新标签状态
*/
@@ -1327,16 +2720,9 @@ class TagManager {
}
/**
- * 分批加载标签内容
- * @param {Object} tagData 标签数据
- * @param {HTMLElement} container 容器元素
- * @param {Function} onComplete 完成回调
+ * 创建特殊标签页结构(包含正常标签、收藏标签和已插入标签)
*/
- static _loadTagsInBatches(tagData, container, onComplete) {
- // 获取所有一级分类
- const categories = Object.keys(tagData);
- let currentCategoryIndex = 0;
-
+ static _createTabsWithSpecialCategories(tagData, favorites, container, nodeId, inputId) {
// 创建标签页框架
const tabsContainer = document.createElement('div');
tabsContainer.className = 'popup_tabs_container';
@@ -1350,252 +2736,279 @@ class TagManager {
tabs.className = 'popup_tabs';
// 创建内容区域
- const tabContents = document.createElement('div');
- tabContents.className = 'tag_category_container';
- tabContents.style.overflow = 'hidden';
- tabContents.style.display = 'flex';
- tabContents.style.flexDirection = 'column';
+ const content = document.createElement('div');
+ content.className = 'popup_content';
- // 创建左右滚动指示器
+ // 准备所有分类
+ const categories = [];
+
+ // 1. 添加收藏夹分类
+ categories.push({
+ name: 'favorites',
+ displayName: '⭐️ 收藏',
+ data: favorites,
+ type: 'favorites'
+ });
+
+ // 2. 添加CSV中的分类
+ if (tagData) {
+ // 构建查找表
+ this.tagLookup.clear();
+ const buildLookup = (obj) => {
+ for (const [key, value] of Object.entries(obj)) {
+ if (typeof value === 'object' && value !== null) {
+ buildLookup(value);
+ } else if (typeof value === 'string') {
+ // Value -> Name 映射
+ // 注意:如果不同标签有相同的值,后面的会覆盖前面的名称。这通常是可以接受的。
+ this.tagLookup.set(value, key);
+ }
+ }
+ };
+ buildLookup(tagData);
+
+ Object.keys(tagData).forEach(key => {
+ categories.push({
+ name: key,
+ displayName: key,
+ data: tagData[key],
+ type: 'normal'
+ });
+ });
+ }
+
+ // 3. 添加已插入分类
+ categories.push({
+ name: '已插入',
+ displayName: '📝 已插入',
+ data: null,
+ type: 'inserted'
+ });
+
+ // 获取上次激活的标签页
+ let activeTabName = this.getLastActiveTab();
+ // 如果上次激活的是普通分类,但当前CSV中没有这个分类,则重置为第一个分类
+ if (activeTabName &&
+ activeTabName !== 'favorites' &&
+ activeTabName !== '已插入' &&
+ tagData && !tagData[activeTabName]) {
+ activeTabName = null;
+ }
+
+ if (!activeTabName && categories.length > 0) {
+ activeTabName = categories[0].name;
+ }
+
+ // 创建标签页和内容
+ categories.forEach((category, index) => {
+ // 创建标签
+ const tab = document.createElement('div');
+ tab.className = 'popup_tab';
+ tab.textContent = category.displayName;
+ tab.setAttribute('data-category', category.name);
+
+ // 创建内容容器
+ const tabContent = document.createElement('div');
+ tabContent.className = 'popup_tab_content';
+ tabContent.setAttribute('data-category', category.name);
+
+ // 激活状态处理
+ if (category.name === activeTabName) {
+ tab.classList.add('active');
+ tabContent.classList.add('active');
+
+ // 立即加载当前激活的标签页内容
+ if (category.type === 'favorites') {
+ // 使用标准的 _loadCategoryContent 加载,因为数据结构已统一
+ this._loadCategoryContent(tabContent, category.data, 'favorites');
+ } else if (category.type === 'inserted') {
+ this._loadInsertedTagsContent(tabContent);
+ } else {
+ this._loadCategoryContent(tabContent, category.data, category.name);
+ }
+ }
+
+ // 标签点击事件
+ const tabClickCleanup = EventManager.addDOMListener(tab, 'click', () => {
+ // 切换标签激活状态
+ tabs.querySelectorAll('.popup_tab').forEach(t => t.classList.remove('active'));
+ tab.classList.add('active');
+
+ // 切换内容激活状态
+ content.querySelectorAll('.popup_tab_content').forEach(c => c.classList.remove('active'));
+ tabContent.classList.add('active');
+
+ // 记录激活的标签页
+ this.setLastActiveTab(category.name);
+
+ // 懒加载内容或需要实时刷新的内容
+ if (!tabContent.hasAttribute('data-loaded') || category.type === 'favorites') {
+ if (category.type === 'favorites') {
+ // 收藏页每次点击都重新获取最新数据
+ // 先清空现有内容
+ tabContent.innerHTML = '';
+ // 重新获取收藏数据
+ ResourceManager.getFavorites().then(favorites => {
+ this.favorites = favorites; // 更新本地缓存
+ this._loadCategoryContent(tabContent, favorites, 'favorites');
+ tabContent.setAttribute('data-loaded', 'true');
+ }).catch(err => {
+ console.error("刷新收藏失败", err);
+ });
+ } else if (category.type === 'inserted') {
+ this._loadInsertedTagsContent(tabContent);
+ tabContent.setAttribute('data-loaded', 'true');
+ } else {
+ // 常规分类只加载一次
+ if (!tabContent.hasAttribute('data-loaded')) {
+ this._loadCategoryContent(tabContent, category.data, category.name);
+ tabContent.setAttribute('data-loaded', 'true');
+ }
+ }
+ } else if (category.type === 'inserted') {
+ // 已插入标签页每次点击都刷新
+ this._loadInsertedTagsContent(tabContent);
+ }
+
+ // 确保标签完全可见
+ tab.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' });
+ });
+ this.eventCleanups.push(tabClickCleanup);
+
+ tabs.appendChild(tab);
+ content.appendChild(tabContent);
+ });
+
+ // 添加滚动指示器
const leftIndicator = document.createElement('div');
- leftIndicator.className = 'tabs_scroll_indicator left';
- const leftIconSpan = document.createElement('span');
- leftIconSpan.className = 'pi pi-angle-left scroll_indicator_icon';
- leftIndicator.appendChild(leftIconSpan);
- leftIndicator.style.display = 'none';
+ leftIndicator.className = 'popup_tabs_indicator left';
+ const leftIcon = document.createElement('i');
+ leftIcon.className = 'pi pi-chevron-left';
+ leftIndicator.appendChild(leftIcon);
const rightIndicator = document.createElement('div');
- rightIndicator.className = 'tabs_scroll_indicator right';
- const rightIconSpan = document.createElement('span');
- rightIconSpan.className = 'pi pi-angle-right scroll_indicator_icon';
- rightIndicator.appendChild(rightIconSpan);
- rightIndicator.style.display = 'none';
+ rightIndicator.className = 'popup_tabs_indicator right';
+ const rightIcon = document.createElement('i');
+ rightIcon.className = 'pi pi-chevron-right';
+ rightIndicator.appendChild(rightIcon);
- // 更新指示器状态的函数
+ // 更新指示器状态
const updateIndicators = () => {
- const canScroll = tabsScroll.scrollWidth > tabsScroll.clientWidth;
- if (!canScroll) {
- leftIndicator.style.display = 'none';
- rightIndicator.style.display = 'none';
- return;
- }
- // 使用更大的阈值(8像素)确保边界情况下能正确隐藏
- const scrollLeft = tabsScroll.scrollLeft;
- const maxScroll = tabsScroll.scrollWidth - tabsScroll.clientWidth;
-
- leftIndicator.style.display = scrollLeft > 8 ? 'flex' : 'none';
- rightIndicator.style.display = scrollLeft < (maxScroll - 8) ? 'flex' : 'none';
+ const { scrollLeft, scrollWidth, clientWidth } = tabsScroll;
+ leftIndicator.style.display = scrollLeft > 0 ? 'flex' : 'none';
+ rightIndicator.style.display = scrollLeft < scrollWidth - clientWidth - 1 ? 'flex' : 'none';
};
- // 添加滚动指示器点击事件 - 每次滚动一个标签
- const leftClickCleanup = EventManager.addDOMListener(leftIndicator, 'click', () => {
- // 获取所有标签
- const allTabs = tabs.querySelectorAll('.popup_tab');
- if (allTabs.length === 0) return;
-
- // 找到当前第一个可见的标签
- const scrollRect = tabsScroll.getBoundingClientRect();
- let firstVisibleTab = null;
-
- for (const tab of allTabs) {
- const tabRect = tab.getBoundingClientRect();
- // 如果标签的右边缘在可视区域内,说明它至少部分可见
- if (tabRect.right > scrollRect.left + 10) {
- firstVisibleTab = tab;
- break;
- }
- }
-
- // 找到前一个标签
- if (firstVisibleTab) {
- const currentIndex = Array.from(allTabs).indexOf(firstVisibleTab);
- if (currentIndex > 0) {
- const prevTab = allTabs[currentIndex - 1];
- prevTab.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'start' });
- // 滚动完成后更新指示器状态(使用更长的延迟确保动画完成)
- setTimeout(updateIndicators, 600);
- }
- }
- });
-
- const rightClickCleanup = EventManager.addDOMListener(rightIndicator, 'click', () => {
- // 获取所有标签
- const allTabs = tabs.querySelectorAll('.popup_tab');
- if (allTabs.length === 0) return;
-
- // 找到当前最后一个可见的标签
- const scrollRect = tabsScroll.getBoundingClientRect();
- let lastVisibleTab = null;
-
- for (let i = allTabs.length - 1; i >= 0; i--) {
- const tab = allTabs[i];
- const tabRect = tab.getBoundingClientRect();
- // 如果标签的左边缘在可视区域内,说明它至少部分可见
- if (tabRect.left < scrollRect.right - 10) {
- lastVisibleTab = tab;
- break;
- }
- }
-
- // 找到下一个标签
- if (lastVisibleTab) {
- const currentIndex = Array.from(allTabs).indexOf(lastVisibleTab);
- if (currentIndex < allTabs.length - 1) {
- const nextTab = allTabs[currentIndex + 1];
- nextTab.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'end' });
- // 滚动完成后更新指示器状态(使用更长的延迟确保动画完成)
- setTimeout(updateIndicators, 600);
- }
- }
- });
-
// 监听滚动事件
const scrollCleanup = EventManager.addDOMListener(tabsScroll, 'scroll', updateIndicators);
+ this.eventCleanups.push(scrollCleanup);
- // 监听窗口大小调整事件
- const resizeObserver = new ResizeObserver(() => {
- updateIndicators();
+ // 监听窗口大小变化
+ const resizeObserver = new ResizeObserver(updateIndicators);
+ resizeObserver.observe(tabsScroll);
+ // 保存清理函数
+ this.eventCleanups.push(() => resizeObserver.disconnect());
+
+ // 添加指示器点击事件
+ const leftClickCleanup = EventManager.addDOMListener(leftIndicator, 'click', () => {
+ tabsScroll.scrollBy({ left: -200, behavior: 'smooth' });
});
- resizeObserver.observe(container);
-
- // 添加清理函数
- const resizeCleanup = () => {
- resizeObserver.disconnect();
- };
+ this.eventCleanups.push(leftClickCleanup);
- this.eventCleanups.push(leftClickCleanup, rightClickCleanup, scrollCleanup, resizeCleanup);
+ const rightClickCleanup = EventManager.addDOMListener(rightIndicator, 'click', () => {
+ tabsScroll.scrollBy({ left: 200, behavior: 'smooth' });
+ });
+ this.eventCleanups.push(rightClickCleanup);
- // 组装标签页结构
+ // 组装DOM
tabsScroll.appendChild(tabs);
tabsContainer.appendChild(leftIndicator);
tabsContainer.appendChild(tabsScroll);
tabsContainer.appendChild(rightIndicator);
- // 添加到容器
container.appendChild(tabsContainer);
- container.appendChild(tabContents);
+ container.appendChild(content);
- // 创建所有标签页但不立即加载内容
- categories.forEach((category, index) => {
- // 创建标签
- const tab = document.createElement('div');
- tab.className = 'popup_tab';
- tab.textContent = category;
- tab.setAttribute('data-category', category);
-
- // 第一个标签默认激活
- if (index === 0) {
- tab.classList.add('active');
- }
-
- // 添加标签点击事件
- const tabClickCleanup = EventManager.addDOMListener(tab, 'click', () => {
- const currentActiveTab = tabs.querySelector('.popup_tab.active');
- if (currentActiveTab === tab) return;
-
- if (currentActiveTab) {
- currentActiveTab.classList.add('exiting');
- const animationEndHandler = () => {
- currentActiveTab.classList.remove('active', 'exiting');
- currentActiveTab.removeEventListener('transitionend', animationEndHandler);
- };
- currentActiveTab.addEventListener('transitionend', animationEndHandler);
- }
-
- tabContents.querySelectorAll('.popup_tab_content').forEach(c => {
- c.classList.remove('active');
- c.style.display = 'none';
- });
-
- tab.classList.add('active');
-
- const contentId = tab.getAttribute('data-category');
- const content = tabContents.querySelector(`.popup_tab_content[data-category="${contentId}"]`);
- if (content) {
- content.classList.add('active');
- content.style.display = 'flex';
- content.style.flexDirection = 'column';
-
- // 如果内容还没有加载,加载它
- if (content.getAttribute('data-loaded') !== 'true') {
- this._loadCategoryContent(content, tagData[contentId], contentId);
- }
- }
-
- // 确保选中的标签完全可见
- const tabRect = tab.getBoundingClientRect();
- const scrollRect = tabsScroll.getBoundingClientRect();
-
- const isFullyVisible =
- tabRect.left >= scrollRect.left &&
- tabRect.right <= scrollRect.right;
-
- if (!isFullyVisible) {
- if (tabRect.left < scrollRect.left) {
- tab.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'start' });
- } else if (tabRect.right > scrollRect.right) {
- tab.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'end' });
- }
- }
- });
-
- this.eventCleanups.push(tabClickCleanup);
- tabs.appendChild(tab);
-
- // 创建内容区域
- const content = document.createElement('div');
- content.className = 'popup_tab_content';
- content.setAttribute('data-category', category);
- content.setAttribute('data-loaded', 'false'); // 标记为未加载
- content.style.flex = '1';
- content.style.display = 'none';
- content.style.minHeight = '0'; // 允许flex收缩
- content.style.overflow = 'auto'; // 确保内容溢出时显示滚动条
-
- // 第一个内容默认显示
- if (index === 0) {
- content.classList.add('active');
- content.style.display = 'flex';
- content.style.flexDirection = 'column';
- }
-
- tabContents.appendChild(content);
- });
-
- // 初始化滚动指示器,并自动定位到激活的标签页
- setTimeout(() => {
- const canScroll = tabsScroll.scrollWidth > tabsScroll.clientWidth;
- if (canScroll) {
- // 找到激活的标签页
- const activeTab = tabs.querySelector('.popup_tab.active');
- if (activeTab) {
- // 将激活的标签页滚动到可见区域
- activeTab.scrollIntoView({ behavior: 'auto', block: 'nearest', inline: 'center' });
- }
-
- // 等待滚动完成后,更新滚动指示器显示状态
- setTimeout(updateIndicators, 50);
- }
- }, 50);
-
- // 只加载第一个分类的内容
- const firstContent = tabContents.querySelector('.popup_tab_content.active');
- if (firstContent) {
- const firstCategory = firstContent.getAttribute('data-category');
- this._loadCategoryContent(firstContent, tagData[firstCategory], firstCategory);
- }
-
- // 完成回调
- if (typeof onComplete === 'function') {
- onComplete();
- }
+ // 初始化指示器
+ setTimeout(updateIndicators, 0);
}
+ /**
+ * 按照CSV文件的分类结构重新组织收藏标签
+ * @param {Object} csvData CSV文件的分类结构
+ * @param {Object} favorites 收藏标签数据
+ * @param {string} csvFileName 当前CSV文件名
+ * @returns {Object} 重新组织后的收藏标签数据
+ */
+ static _reorganizeFavoritesByCSVStructure(csvData, favorites, csvFileName) {
+ // 标准化CSV文件名(去除扩展名)
+ const normalizedFileName = csvFileName.replace(/\.(csv|json|yaml|yml)$/i, '');
+
+ // 获取当前CSV文件的收藏标签
+ const csvFavorites = favorites[normalizedFileName] || {};
+
+ // 如果没有收藏标签,返回空对象
+ if (Object.keys(csvFavorites).length === 0) {
+ return {};
+ }
+
+ // 递归函数:按照CSV结构组织收藏标签
+ const reorganize = (structure) => {
+ const result = {};
+ for (const [key, value] of Object.entries(structure)) {
+ if (typeof value === 'string') {
+ // 这是一个标签,检查是否在收藏中
+ if (Object.values(csvFavorites).includes(value)) {
+ // 找到对应的标签名
+ const tagName = Object.keys(csvFavorites).find(k => csvFavorites[k] === value);
+ if (tagName) {
+ result[tagName] = value;
+ }
+ }
+ } else if (typeof value === 'object' && value !== null) {
+ // 这是一个分类,递归处理
+ const subResult = reorganize(value);
+ if (Object.keys(subResult).length > 0) {
+ result[key] = subResult;
+ }
+ }
+ }
+ return result;
+ };
+
+ return reorganize(csvData);
+ }
+
+
/**
* 加载单个分类的内容
*/
static _loadCategoryContent(contentElement, categoryData, categoryName) {
- if (!contentElement || !categoryData) return;
+ if (!contentElement) return;
+
+ // 加载前先清空容器,避免重复添加或残留Empty Tip
+ contentElement.innerHTML = '';
+
+ // 特殊处理收藏夹
+ if (categoryName === 'favorites') {
+ // 直接使用favorites的原始结构(CSV文件名作为顶层分类)
+ // categoryData 的结构应该是 {CSV文件名: {分类: {标签名: 标签值}}}
+
+ // 如果没有收藏标签,显示空提示
+ if (!categoryData || Object.keys(categoryData).length === 0) {
+ const emptyTip = document.createElement('div');
+ emptyTip.className = 'empty_tip';
+ emptyTip.textContent = '暂无收藏标签,在标签上右键点击可添加到收藏';
+ emptyTip.style.padding = '20px';
+ emptyTip.style.textAlign = 'center';
+ emptyTip.style.color = 'var(--text-color-secondary)';
+ contentElement.appendChild(emptyTip);
+ contentElement.setAttribute('data-loaded', 'true');
+ return;
+ }
+ }
+
+ if (!categoryData) return;
// 创建一个内容容器,用于应用动画效果
const contentWrapper = document.createElement('div');
@@ -1606,7 +3019,10 @@ class TagManager {
try {
// 创建分类内容
if (typeof categoryData === 'object' && categoryData !== null) {
- const innerContent = this._createInnerAccordion(categoryData, '1', categoryName);
+ // 对于收藏标签页,不传递rootCategory,因为顶层就是CSV文件名
+ // 对于普通标签页,传递当前CSV文件名作为根分类
+ const rootCategory = categoryName === 'favorites' ? null : this.currentCsvFile;
+ const innerContent = this._createInnerAccordion(categoryData, '1', categoryName, rootCategory);
contentWrapper.appendChild(innerContent);
// 添加到父容器
@@ -1620,6 +3036,22 @@ class TagManager {
// 确保DOM已经渲染
setTimeout(() => {
contentWrapper.classList.add('visible');
+
+ // 对于收藏标签页,自动展开当前CSV文件对应的手风琴
+ if (categoryName === 'favorites' && this.currentCsvFile) {
+ // 标准化CSV文件名(去除扩展名)
+ const normalizedFileName = this.currentCsvFile.replace(/\.(csv|json|yaml|yml)$/i, '');
+
+ // 查找对应的手风琴
+ const targetAccordion = contentElement.querySelector(`.tag_accordion[data-category="${normalizedFileName}"]`);
+ if (targetAccordion) {
+ const header = targetAccordion.querySelector('.tag_accordion_header');
+ if (header && !header.classList.contains('active')) {
+ // 自动展开
+ header.click();
+ }
+ }
+ }
}, 10);
});
}
@@ -1666,6 +3098,9 @@ class TagManager {
// 清理现有事件监听
this._cleanupEvents();
+ // 初始化 Sortable
+ this._initSortable();
+
try {
// 创建弹窗容器
const popup = document.createElement('div');
@@ -1676,13 +3111,209 @@ class TagManager {
popup.style.maxHeight = '80vh'; // 设置最大高度,防止弹窗过大
popup.style.height = 'auto'; // 允许高度自适应内容
+ // 使用事件委托处理右键菜单 - 解决某些情况下事件无法绑定的问题
+ popup.addEventListener('contextmenu', (e) => {
+ const tagItem = e.target.closest('.tag_item');
+ if (tagItem) {
+ const tagName = tagItem.getAttribute('data-name');
+ const tagValue = tagItem.getAttribute('data-value');
+
+ const category = tagItem.getAttribute('data-category');
+
+ if (tagValue && tagName) {
+ e.preventDefault();
+ e.stopPropagation();
+ // 记录日志确保触发
+ logger.debug(`Delegated context menu triggered on tag: ${tagName} | category: ${category}`);
+
+ try {
+ this._showContextMenu(e, tagValue, tagName, category);
+ } catch (error) {
+ logger.error(`Error showing context menu: ${error.message}`);
+ console.error(error);
+ }
+ }
+ }
+ }, true); // 使用捕获阶段
+
+
// 创建标题栏
const titleBar = document.createElement('div');
titleBar.className = 'popup_title_bar';
const title = document.createElement('div');
title.className = 'popup_title';
- title.textContent = '标签工具';
+
+ // 加载SVG图标(getIcon返回已包含SVG的容器)
+ const iconContainer = ResourceManager.getIcon('icon-tag.svg');
+ if (iconContainer) {
+ iconContainer.style.width = '18px';
+ iconContainer.style.height = '18px';
+ iconContainer.style.color = 'var(--p-dialog-color)'; // 设置颜色供SVG继承
+ title.appendChild(iconContainer);
+ }
+
+ // 创建CSV选择器容器
+ const csvSelectorContainer = document.createElement('div');
+ csvSelectorContainer.className = 'popup_csv_selector_container';
+ csvSelectorContainer.style.marginRight = '10px';
+ csvSelectorContainer.style.position = 'relative';
+
+ // 创建自定义下拉按钮
+ const csvSelector = document.createElement('button');
+ csvSelector.className = 'popup_csv_selector';
+ csvSelector.style.padding = '4px 12px 4px 8px';
+ csvSelector.style.borderRadius = '10px';
+ csvSelector.style.border = '0px solid var(--p-dialog-border-color)';
+ csvSelector.style.backgroundColor = 'color-mix(in srgb, var(--comfy-menu-secondary-bg), transparent 60%)';
+ csvSelector.style.color = 'var(--p-text-color)';
+ csvSelector.style.outline = 'none';
+ csvSelector.style.cursor = 'pointer';
+ csvSelector.style.display = 'flex';
+ csvSelector.style.alignItems = 'center';
+ csvSelector.style.gap = '8px';
+ csvSelector.style.minWidth = '120px';
+ csvSelector.style.transition = 'border-color 0.2s, background-color 0.2s';
+
+ // 添加文本标签
+ const csvSelectorLabel = document.createElement('span');
+ csvSelectorLabel.style.flex = '1';
+ csvSelectorLabel.style.textAlign = 'left';
+ csvSelectorLabel.style.fontSize = '14px';
+ csvSelector.appendChild(csvSelectorLabel);
+
+ // 添加下拉箭头图标
+ const csvSelectorIcon = document.createElement('i');
+ csvSelectorIcon.className = 'pi pi-chevron-down';
+ csvSelectorIcon.style.fontSize = '12px';
+ csvSelectorIcon.style.transition = 'transform 0.2s';
+ csvSelector.appendChild(csvSelectorIcon);
+
+ // 创建下拉菜单
+ const csvDropdownMenu = document.createElement('div');
+ csvDropdownMenu.className = 'pa-context-menu pa-dropdown-menu'; // 添加pa-dropdown-menu类用于区分
+ csvDropdownMenu.style.display = 'none';
+ csvDropdownMenu.style.position = 'absolute';
+ csvDropdownMenu.style.top = 'calc(100% + 4px)';
+ csvDropdownMenu.style.left = '0';
+ csvDropdownMenu.style.minWidth = '100%';
+ // z-index 由 CSS 的 --settings-context-menu-z-index 变量管理
+
+ const csvMenuList = document.createElement('ul');
+ csvMenuList.className = 'pa-context-menu-list';
+ csvDropdownMenu.appendChild(csvMenuList);
+
+ csvSelectorContainer.appendChild(csvSelector);
+ csvSelectorContainer.appendChild(csvDropdownMenu);
+
+ // 切换下拉菜单显示/隐藏
+ let csvMenuOpen = false;
+ const toggleCsvMenu = (e) => {
+ e.stopPropagation();
+ csvMenuOpen = !csvMenuOpen;
+
+ if (csvMenuOpen) {
+ csvDropdownMenu.style.display = 'block';
+ requestAnimationFrame(() => {
+ csvDropdownMenu.classList.add('pa-context-menu-show');
+ csvSelectorIcon.style.transform = 'rotate(180deg)';
+ });
+ csvSelector.style.borderColor = 'var(--p-primary-color)';
+ } else {
+ csvDropdownMenu.classList.remove('pa-context-menu-show');
+ csvDropdownMenu.classList.add('pa-context-menu-hide');
+ csvSelectorIcon.style.transform = 'rotate(0deg)';
+ csvSelector.style.borderColor = 'var(--p-dialog-border-color)';
+ setTimeout(() => {
+ csvDropdownMenu.style.display = 'none';
+ csvDropdownMenu.classList.remove('pa-context-menu-hide');
+ }, 150);
+ }
+ };
+
+ // 关闭下拉菜单
+ const closeCsvMenu = () => {
+ if (csvMenuOpen) {
+ csvMenuOpen = false;
+ csvDropdownMenu.classList.remove('pa-context-menu-show');
+ csvDropdownMenu.classList.add('pa-context-menu-hide');
+ csvSelectorIcon.style.transform = 'rotate(0deg)';
+ csvSelector.style.borderColor = 'var(--p-dialog-border-color)';
+ setTimeout(() => {
+ csvDropdownMenu.style.display = 'none';
+ csvDropdownMenu.classList.remove('pa-context-menu-hide');
+ }, 150);
+ }
+ };
+
+ // 点击按钮切换菜单
+ const csvSelectorClickCleanup = EventManager.addDOMListener(csvSelector, 'click', toggleCsvMenu);
+ this.eventCleanups.push(csvSelectorClickCleanup);
+
+ // 点击外部关闭菜单
+ const handleCsvMenuOutsideClick = (e) => {
+ if (csvMenuOpen && !csvSelectorContainer.contains(e.target)) {
+ closeCsvMenu();
+ }
+ };
+ const csvOutsideClickCleanup = EventManager.addDOMListener(document, 'click', handleCsvMenuOutsideClick, true);
+ this.eventCleanups.push(csvOutsideClickCleanup);
+
+ // 监听右键菜单打开事件,自动关闭CSV下拉菜单
+ const handleContextMenuOpen = (e) => {
+ // 如果右键菜单不是在CSV选择器容器内打开的,则关闭CSV下拉菜单
+ if (csvMenuOpen && !csvSelectorContainer.contains(e.target)) {
+ closeCsvMenu();
+ }
+ };
+ const contextMenuCleanup = EventManager.addDOMListener(document, 'contextmenu', handleContextMenuOpen, true);
+ this.eventCleanups.push(contextMenuCleanup);
+
+ // CSV切换处理函数
+ const handleCsvChange = async (filename) => {
+ if (filename && filename !== this.currentCsvFile) {
+ this.currentCsvFile = filename;
+ await ResourceManager.setSelectedTagFile(filename);
+
+ // 重新加载数据
+ loadingIndicator.style.display = 'block';
+ contentContainer.innerHTML = '';
+ contentContainer.appendChild(loadingIndicator);
+
+ try {
+ const [tagData, favorites] = await Promise.all([
+ ResourceManager.loadTagsCsv(filename),
+ ResourceManager.getFavorites()
+ ]);
+
+ this.favorites = favorites;
+
+ // 移除加载指示器
+ if (loadingIndicator.parentNode) {
+ loadingIndicator.parentNode.removeChild(loadingIndicator);
+ }
+
+ // 重新创建标签页
+ this._createTabsWithSpecialCategories(tagData, favorites, contentContainer, nodeId, inputId);
+
+ // 如果当前在收藏标签页,重新加载收藏标签内容
+ // _loadCategoryContent会自动展开当前CSV对应的手风琴
+ setTimeout(() => {
+ const activeTab = contentContainer.querySelector('.popup_tab.active');
+ if (activeTab && activeTab.getAttribute('data-category') === '⭐️') {
+ const favoritesContent = contentContainer.querySelector('.popup_tab_content[data-category="⭐️"]');
+ if (favoritesContent) {
+ // 重新加载收藏标签内容(会自动展开当前CSV)
+ this._loadCategoryContent(favoritesContent, favorites, 'favorites');
+ }
+ }
+ }, 50);
+ } catch (error) {
+ logger.error(`切换CSV文件失败: ${error.message}`);
+ loadingIndicator.textContent = '加载失败,请重试';
+ }
+ }
+ };
// 创建搜索框容器
const searchContainer = document.createElement('div');
@@ -1717,17 +3348,7 @@ class TagManager {
const actions = document.createElement('div');
actions.className = 'popup_actions';
- // 添加管理标签按钮
- const manageBtn = document.createElement('button');
- manageBtn.className = 'popup_btn';
- manageBtn.title = '管理标签';
- UIToolkit.addIconToButton(manageBtn, 'pi-pen-to-square', '管理标签');
- const manageCleanup = EventManager.addDOMListener(manageBtn, 'click', () => {
- const tagConfigManager = new TagConfigManager();
- tagConfigManager.showTagsConfigModal();
- PopupManager.closeAllPopups();
- });
- this.eventCleanups.push(manageCleanup);
+ // 移除了管理标签按钮
// 添加刷新按钮
const refreshBtn = document.createElement('button');
@@ -1744,28 +3365,53 @@ class TagManager {
UIToolkit.addIconToButton(closeBtn, 'pi-times', '关闭');
// 添加刷新事件
- const refreshCleanup = EventManager.addDOMListener(refreshBtn, 'click', () => {
+ const refreshCleanup = EventManager.addDOMListener(refreshBtn, 'click', async () => {
try {
refreshBtn.style.transform = 'rotate(360deg)';
refreshBtn.style.transition = 'transform 0.5s';
- // 执行比对和更新
- this.compareInputWithCache(nodeId, inputId, true);
+ // 显示加载指示器
+ contentContainer.innerHTML = '';
+ const refreshLoadingIndicator = document.createElement('div');
+ refreshLoadingIndicator.className = 'loading_indicator';
+ refreshLoadingIndicator.textContent = '重新加载标签数据...';
+ refreshLoadingIndicator.style.textAlign = 'center';
+ refreshLoadingIndicator.style.padding = '20px';
+ refreshLoadingIndicator.style.color = 'var(--text-color-secondary)';
+ contentContainer.appendChild(refreshLoadingIndicator);
- // 如果当前在搜索状态,也要刷新搜索结果中的标签状态
- const searchResultList = document.querySelector('.tag_search_result_list');
- if (searchResultList) {
- this.refreshSearchResultsState();
- }
+ // 重新加载当前CSV和收藏
+ if (this.currentCsvFile) {
+ const [tagData, favorites] = await Promise.all([
+ ResourceManager.loadTagsCsv(this.currentCsvFile, true), // 强制重新加载
+ ResourceManager.getFavorites()
+ ]);
+ this.favorites = favorites;
+ TagManager.tagData = tagData;
- // 刷新已插入标签页
- const insertedTabContent = document.querySelector('.popup_tab_content[data-category="已插入"]');
- if (insertedTabContent && insertedTabContent.classList.contains('active')) {
- this._loadInsertedTagsContent(insertedTabContent);
+ // 移除加载指示器
+ if (refreshLoadingIndicator.parentNode) {
+ refreshLoadingIndicator.parentNode.removeChild(refreshLoadingIndicator);
+ }
+
+ // 完全重新创建标签页结构
+ contentContainer.innerHTML = '';
+ this._createTabsWithSpecialCategories(tagData, favorites, contentContainer, nodeId, inputId);
+
+ logger.debug(`[助手-标签] 刷新完成 | CSV文件:${this.currentCsvFile}`);
}
} catch (error) {
- logger.error(`标签状态刷新失败: ${error.message}`);
+ logger.error(`标签刷新失败: ${error.message}`);
+ // 显示错误信息
+ contentContainer.innerHTML = '';
+ const errorMsg = document.createElement('div');
+ errorMsg.className = 'loading_indicator';
+ errorMsg.textContent = '刷新失败,请重试';
+ errorMsg.style.textAlign = 'center';
+ errorMsg.style.padding = '20px';
+ errorMsg.style.color = 'var(--error-color)';
+ contentContainer.appendChild(errorMsg);
} finally {
setTimeout(() => {
refreshBtn.style.transform = '';
@@ -1808,9 +3454,10 @@ class TagManager {
// 组装标题栏 - 修改组装顺序
titleBar.appendChild(title);
+ titleBar.appendChild(csvSelectorContainer); // 添加CSV选择器
titleBar.appendChild(searchContainer);
titleBar.appendChild(actions);
- actions.appendChild(manageBtn);
+ // 移除了旧的管理按钮
actions.appendChild(refreshBtn);
actions.appendChild(closeBtn);
@@ -1889,42 +3536,90 @@ class TagManager {
contentContainer.appendChild(loadingIndicator);
// 加载数据并创建标签页
- Promise.all([
- ResourceManager.getTagData(refresh),
- ResourceManager.getUserTagData(refresh)
- ]).then(([tagData, userTagData]) => {
- // 移除加载指示器
- if (loadingIndicator.parentNode) {
- loadingIndicator.parentNode.removeChild(loadingIndicator);
+ (async () => {
+ try {
+ // 1. 获取CSV文件列表
+ const files = await ResourceManager.getTagFileList();
+
+ // 2. 获取上次选择的文件
+ let selectedFile = await ResourceManager.getSelectedTagFile();
+
+ // 确保选择的文件在列表中
+ if (!files.includes(selectedFile) && files.length > 0) {
+ selectedFile = files[0];
+ }
+ this.currentCsvFile = selectedFile;
+
+ // 3. 填充下拉菜单
+ csvMenuList.innerHTML = '';
+
+ // 设置当前选中文件的显示文本
+ csvSelectorLabel.textContent = selectedFile.replace('.csv', '');
+
+ files.forEach(file => {
+ const menuItem = document.createElement('li');
+ menuItem.className = 'pa-context-menu-item';
+ if (file === selectedFile) {
+ menuItem.classList.add('active');
+ }
+
+ const menuItemContent = document.createElement('div');
+ menuItemContent.className = 'pa-context-menu-item-content';
+
+ const label = document.createElement('span');
+ label.className = 'pa-context-menu-item-label';
+ label.textContent = file.replace('.csv', '');
+ menuItemContent.appendChild(label);
+
+ menuItem.appendChild(menuItemContent);
+
+ // 点击菜单项切换CSV文件
+ menuItem.addEventListener('click', async (e) => {
+ e.stopPropagation();
+
+ // 更新选中状态
+ csvMenuList.querySelectorAll('.pa-context-menu-item').forEach(item => {
+ item.classList.remove('active');
+ });
+ menuItem.classList.add('active');
+
+ // 更新显示文本
+ csvSelectorLabel.textContent = file.replace('.csv', '');
+
+ // 关闭菜单
+ closeCsvMenu();
+
+ // 切换CSV文件
+ await handleCsvChange(file);
+ });
+
+ csvMenuList.appendChild(menuItem);
+ });
+
+ // 4. 加载标签数据和收藏
+ const [tagData, favorites] = await Promise.all([
+ ResourceManager.loadTagsCsv(selectedFile),
+ ResourceManager.getFavorites()
+ ]);
+
+ this.favorites = favorites;
+
+ // 移除加载指示器
+ if (loadingIndicator.parentNode) {
+ loadingIndicator.parentNode.removeChild(loadingIndicator);
+ }
+
+ // 5. 创建标签页
+ // 更新静态数据引用
+ TagManager.tagData = tagData;
+ this._createTabsWithSpecialCategories(tagData, favorites, contentContainer, nodeId, inputId);
+
+ } catch (error) {
+ logger.error(`初始化标签弹窗失败: ${error.message}`);
+ loadingIndicator.textContent = '加载失败,请重试';
}
+ })();
- // 创建一个扩展的数据对象,包含原始标签和两个特殊标签页
- const combinedData = { ...tagData };
-
- // 清空已插入标签的缓存,准备重新收集
- this._insertedTagsCache = {};
-
- // 创建标签页结构
- this._createTabsWithSpecialCategories(combinedData, userTagData, contentContainer, nodeId, inputId);
-
- }).catch(error => {
- // 移除加载指示器
- if (loadingIndicator.parentNode) {
- loadingIndicator.parentNode.removeChild(loadingIndicator);
- }
-
- // 显示错误消息
- const errorMessage = document.createElement('div');
- errorMessage.className = 'error_message';
- errorMessage.textContent = `标签数据加载失败: ${error.message}`;
- errorMessage.style.textAlign = 'center';
- errorMessage.style.padding = '20px';
- errorMessage.style.color = '#ff6b6b';
- contentContainer.appendChild(errorMessage);
-
- logger.error(`标签数据加载失败: ${error.message}`);
- // 不要调用_cleanupAll,保留弹窗以便用户看到错误信息
- });
} catch (error) {
logger.error(`标签弹窗创建失败: ${error.message}`);
this._cleanupAll();
@@ -1980,7 +3675,7 @@ class TagManager {
// 使用更大的阈值(8像素)确保边界情况下能正确隐藏
const scrollLeft = tabsScroll.scrollLeft;
const maxScroll = tabsScroll.scrollWidth - tabsScroll.clientWidth;
-
+
leftIndicator.style.display = scrollLeft > 8 ? 'flex' : 'none';
rightIndicator.style.display = scrollLeft < (maxScroll - 8) ? 'flex' : 'none';
};
@@ -2055,7 +3750,7 @@ class TagManager {
updateIndicators();
});
resizeObserver.observe(container);
-
+
// 添加清理函数
const resizeCleanup = () => {
resizeObserver.disconnect();
@@ -2130,7 +3825,7 @@ class TagManager {
// 对于特殊标签页,每次点击都重新加载
if (contentId === '⭐️') {
- this._loadUserTagsContent(content);
+ this._loadCategoryContent(content, userTagData, 'favorites');
} else if (contentId === '已插入') {
this._loadInsertedTagsContent(content);
}
@@ -2200,7 +3895,7 @@ class TagManager {
// 初始激活的标签页也进行记忆
TagManager.setLastActiveTab(firstCategory);
if (firstCategory === '⭐️') {
- this._loadUserTagsContent(firstContent);
+ this._loadCategoryContent(firstContent, userTagData, 'favorites');
} else if (firstCategory === '已插入') {
this._loadInsertedTagsContent(firstContent);
} else {
@@ -2490,6 +4185,10 @@ class TagManager {
tagItem.className = 'tag_item search_result_tag_item';
tagItem.setAttribute('data-name', tagName);
tagItem.setAttribute('data-value', tagValue);
+ // 确保搜索结果也包含当前的分类上下文(当前文件)
+ if (this.currentCsvFile) {
+ tagItem.setAttribute('data-category', this.currentCsvFile);
+ }
const tagText = document.createElement('span');
tagText.className = 'tag_item_text';
@@ -2644,46 +4343,9 @@ class TagManager {
}
/**
- * 加载用户自定义标签内容
+ * 创建收藏夹内容
*/
- static _loadUserTagsContent(container) {
- container.innerHTML = ''; // 清空内容
- container.setAttribute('data-loaded', 'true');
- // 创建内容包装器
- const contentWrapper = document.createElement('div');
- contentWrapper.className = 'tag_content_wrapper visible';
-
- // 强制刷新获取最新的用户数据
- ResourceManager.getUserTagData(true).then(userTagData => {
- if (!userTagData || Object.keys(userTagData).length === 0) {
- // 如果没有自定义标签
- const emptyMessage = document.createElement('div');
- emptyMessage.className = 'empty_tags_message';
- emptyMessage.textContent = '没有自定义标签';
- emptyMessage.style.textAlign = 'center';
- emptyMessage.style.padding = '20px';
- emptyMessage.style.color = 'var(--text-color-secondary)';
-
- contentWrapper.appendChild(emptyMessage);
- } else {
- // 如果有自定义标签,使用标准的创建方法
- const tagsContent = this._createInnerAccordion(userTagData, '1', '⭐️');
- contentWrapper.appendChild(tagsContent);
- }
-
- container.appendChild(contentWrapper);
-
- // 内容加载后,同步一下标签使用状态
- this.compareInputWithCache(this.currentNodeId, this.currentInputId, true);
- }).catch(error => {
- logger.error('加载用户自定义标签失败', error);
- const errorMessage = document.createElement('div');
- errorMessage.textContent = `加载自定义标签失败: ${error.message}`;
- errorMessage.className = 'error_message';
- container.appendChild(errorMessage);
- });
- }
/**
* 加载已插入标签内容
diff --git a/js/modules/tagConfigManager.js b/js/modules/tagConfigManager.js
deleted file mode 100644
index 42d8d7e..0000000
--- a/js/modules/tagConfigManager.js
+++ /dev/null
@@ -1,1005 +0,0 @@
-/**
- * 标签配置管理器
- * 负责管理标签配置弹窗和标签数据的增删改查
- */
-
-import { app } from "../../../../scripts/app.js";
-import { logger } from '../utils/logger.js';
-import { ResourceManager } from "../utils/resourceManager.js";
-import {
- createSettingsDialog,
- closeModalWithAnimation,
- createFormGroup,
- createInputGroup,
- createSelectGroup,
- createHorizontalFormGroup
-} from "./settings.js";
-
-class TagConfigManager {
- constructor() {
- this.tagsData = {};
- this.tooltipElements = [];
- this.categorySortable = null;
- this.tagSortables = new Map();
- this.Sortable = null;
- this.isDirty = false; // 跟踪未保存的更改
- this.initialTagsData = null; // 打开时备份初始数据
- }
-
- /**
- * 初始化 Sortable.js
- * @private
- */
- async _initSortableLib() {
- if (this.Sortable) {
- return;
- }
- try {
- this.Sortable = await ResourceManager.getSortable();
- } catch (error) {
- logger.error('Sortable.js 加载失败,拖拽功能将不可用。', error);
- app.extensionManager.toast.add({
- severity: "error",
- summary: "组件加载失败",
- detail: "拖拽功能可能无法正常工作",
- life: 3000
- });
- }
- }
-
- /**
- * 显示标签配置弹窗
- */
- async showTagsConfigModal() {
- try {
- await this._initSortableLib();
- logger.debug('打开标签配置弹窗');
- this._createTagsConfigDialog();
- } catch (error) {
- logger.error(`打开标签配置弹窗失败: ${error.message}`);
- app.extensionManager.toast.add({
- severity: "error",
- summary: "打开配置失败",
- detail: error.message || "打开配置弹窗过程中发生错误",
- life: 3000
- });
- }
- }
-
- /**
- * 初始化标签tooltip功能
- * 为有data-tooltip-content属性的元素添加tooltip
- */
- _initTooltips() {
- // 清理之前的tooltip事件监听
- this._cleanupTooltips();
-
- // 查找所有带有tooltip内容属性的元素
- const tooltipElements = document.querySelectorAll('.has-tooltip');
-
- tooltipElements.forEach(element => {
- // 保存引用以便清理
- this.tooltipElements.push(element);
-
- let tooltipTimeout = null;
- let activeTooltip = null;
-
- // 鼠标进入时创建tooltip
- element.addEventListener('mouseenter', e => {
- tooltipTimeout = setTimeout(() => {
- const content = element.getAttribute('data-tooltip-content');
- if (!content) return;
-
- // 创建tooltip元素
- const tooltip = document.createElement('div');
- tooltip.className = 'tag_tooltip';
- tooltip.innerHTML = content;
- document.body.appendChild(tooltip);
-
- // 计算位置
- const rect = element.getBoundingClientRect();
- tooltip.style.left = `${rect.left + rect.width / 2 - tooltip.offsetWidth / 2}px`;
- tooltip.style.top = `${rect.top - tooltip.offsetHeight - 8}px`;
-
- // 保存当前tooltip引用
- activeTooltip = tooltip;
- }, 300);
- });
-
- // 鼠标离开时移除tooltip
- element.addEventListener('mouseleave', () => {
- clearTimeout(tooltipTimeout);
- if (activeTooltip) {
- activeTooltip.remove();
- activeTooltip = null;
- }
- });
- });
- }
-
- /**
- * 清理tooltip事件监听
- */
- _cleanupTooltips() {
- // 移除所有tooltip元素
- const tooltips = document.querySelectorAll('.tag_tooltip');
- tooltips.forEach(tooltip => tooltip.remove());
-
- // 清空保存的元素引用
- this.tooltipElements = [];
- }
-
- /**
- * 加载用户标签数据
- * @returns {Promise