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} 用户标签数据 - */ - async _loadTagsData() { - try { - const response = await fetch('/prompt_assistant/api/config/tags_user'); - if (!response.ok) { - throw new Error(`加载用户标签配置失败: ${response.status} ${response.statusText}`); - } - this.tagsData = await response.json(); - // 确保至少有一个默认分类 - if (Object.keys(this.tagsData).length === 0) { - this.tagsData = { - "默认分类": {} - }; - } - // 备份初始数据并重置脏状态 - this.initialTagsData = JSON.parse(JSON.stringify(this.tagsData)); - this.isDirty = false; - return this.tagsData; - } catch (error) { - logger.error("加载用户标签配置失败:", error); - app.extensionManager.toast.add({ - severity: "error", - summary: "加载失败", - detail: error.message || "加载用户标签配置过程中发生错误", - life: 3000 - }); - // 返回默认分类 - this.tagsData = { - "默认分类": {} - }; - // 备份初始数据并重置脏状态 - this.initialTagsData = JSON.parse(JSON.stringify(this.tagsData)); - this.isDirty = false; - return this.tagsData; - } - } - - /** - * 保存用户标签数据 - * @param {Object} data 要保存的标签数据 - * @returns {Promise} 是否保存成功 - */ - async _saveTagsData(data) { - try { - const response = await fetch('/prompt_assistant/api/config/tags_user', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(data) - }); - if (!response.ok) { - throw new Error(`保存用户标签配置失败: ${response.status} ${response.statusText}`); - } - return true; - } catch (error) { - logger.error("保存用户标签配置失败:", error); - app.extensionManager.toast.add({ - severity: "error", - summary: "保存失败", - detail: error.message || "保存用户标签配置过程中发生错误", - life: 3000 - }); - return false; - } - } - - /** - * 初始化拖拽排序 - * @param {HTMLElement} container 容器元素 - * @param {HTMLElement} tagContainer 标签容器 - * @param {string} categoryName 分类名称 - * @private - */ - _initSortable(container, tagContainer, categoryName) { - if (!this.Sortable) { - return; - } - - const tagSortable = new this.Sortable(tagContainer, { - group: 'tags', - animation: 250, // 增加动画时长,使过渡更平滑 - easing: 'cubic-bezier(0.25, 1, 0.5, 1)', // 调整缓动函数,避免缩放效果 - ghostClass: 'tag-ghost', - chosenClass: 'tag-chosen', - dragClass: 'tag-drag', - // 添加拖拽时的延迟,防止意外触发 - delay: 50, - delayOnTouchOnly: true, - // 添加动画捕获状态,确保动画正确执行 - onChoose: function (evt) { - // 不再修改transition,避免影响缩放 - }, - onUnchoose: function (evt) { - // 不再修改transition,避免影响缩放 - }, - // 添加动画相关事件处理 - onStart: function (evt) { - // 拖动开始时添加效果 - evt.item.classList.add('tag-dragging'); - }, - onEnd: (evt) => { - // 拖动结束时移除效果 - evt.item.classList.remove('tag-dragging'); - - const { from, to } = evt; - const fromCategory = from.closest('.tag_accordion').getAttribute('data-category'); - const toCategory = to.closest('.tag_accordion').getAttribute('data-category'); - const tagName = evt.item.getAttribute('data-name'); - - // 从旧分类中移除(内存中) - const tagData = this.tagsData[fromCategory][tagName]; - delete this.tagsData[fromCategory][tagName]; - - // 添加到新分类(内存中),并根据DOM顺序重构 - const newTags = {}; - Array.from(to.children).forEach(tag => { - const name = tag.getAttribute('data-name'); - const value = tag.getAttribute('data-value'); - newTags[name] = this.tagsData[toCategory]?.[name] || tagData; - }); - - this.tagsData[toCategory] = newTags; - this.isDirty = true; - } - }); - - this.tagSortables.set(categoryName, tagSortable); - } - - /** - * 初始化分类排序 - * @param {HTMLElement} container 容器元素 - * @private - */ - _initCategorySortable(container) { - if (!this.Sortable) { - return; - } - - if (this.categorySortable) { - this.categorySortable.destroy(); - } - - this.categorySortable = new this.Sortable(container, { - animation: 250, // 增加动画时长,使过渡更平滑 - easing: 'cubic-bezier(0.25, 1, 0.5, 1)', // 调整缓动函数,避免缩放效果 - handle: '.tag_accordion_header', - ghostClass: 'category-ghost', - chosenClass: 'category-chosen', - dragClass: 'category-drag', - // 添加拖拽时的延迟,防止意外触发 - delay: 50, - delayOnTouchOnly: true, - // 添加动画捕获状态,确保动画正确执行 - onChoose: function (evt) { - // 不再修改transition,避免影响缩放 - }, - onUnchoose: function (evt) { - // 不再修改transition,避免影响缩放 - }, - // 添加动画相关事件处理 - onStart: function (evt) { - // 拖动开始时添加效果 - evt.item.classList.add('category-dragging'); - }, - onEnd: (evt) => { - // 拖动结束时移除效果 - evt.item.classList.remove('category-dragging'); - - // 根据DOM顺序重构数据 - const newData = {}; - Array.from(container.children).forEach(accordion => { - const categoryName = accordion.getAttribute('data-category'); - newData[categoryName] = { ...this.tagsData[categoryName] }; - }); - - this.tagsData = newData; - this.isDirty = true; - } - }); - } - - /** - * 创建标签手风琴 - * @param {Object} tagsData 标签数据 - * @param {HTMLElement} container 容器元素 - */ - _createTagAccordion(tagsData, container) { - // 清空容器 - container.innerHTML = ''; - - // 遍历所有分类,使用Object.entries保持原始顺序 - Object.entries(tagsData).forEach(([categoryName, categoryTags]) => { - // 创建分类手风琴 - const accordion = document.createElement('div'); - accordion.className = 'tag_accordion'; - accordion.setAttribute('data-category', categoryName); - - // 创建头部 - const header = document.createElement('div'); - header.className = 'tag_accordion_header active'; // 默认设置为active状态 - - // 创建标题 - const title = document.createElement('div'); - title.className = 'tag_accordion_title'; - title.textContent = categoryName; - - // 创建图标容器 - const iconContainer = document.createElement('div'); - iconContainer.className = 'tag_accordion_icon'; - - // 添加编辑按钮 - 为所有分类添加 - const editButton = document.createElement('button'); - editButton.className = 'tag_edit_button small'; // 使用新的编辑按钮样式 - editButton.title = '编辑分类'; - - const editIcon = document.createElement('span'); - editIcon.className = 'pi pi-pencil tag_edit_icon small'; // 使用新的编辑图标样式 - editButton.appendChild(editIcon); - - editButton.addEventListener('click', (e) => { - e.stopPropagation(); - this._showCategoryDialog(container, categoryName); - }); - iconContainer.appendChild(editButton); - - // 添加删除按钮 - const deleteButton = document.createElement('button'); - deleteButton.className = 'tag_delete_button small'; // 添加small类名 - deleteButton.title = '删除分类'; - - // 添加删除图标 - const deleteIcon = document.createElement('span'); - deleteIcon.className = 'pi pi-times tag_action_icon small'; - deleteButton.appendChild(deleteIcon); - - // 添加删除事件 - deleteButton.addEventListener('click', (e) => { - e.stopPropagation(); - - // 创建确认对话框 - createSettingsDialog({ - title: '确认删除', - isConfirmDialog: true, - disableBackdropAndCloseOnClickOutside: true, - saveButtonText: '确认', - cancelButtonText: '取消', - renderContent: (content) => { - content.style.textAlign = 'center'; - content.style.padding = '1rem'; - - const confirmMessage = document.createElement('p'); - confirmMessage.textContent = `确定要删除分类"${categoryName}"吗?`; - confirmMessage.style.margin = '0'; - confirmMessage.style.fontSize = '1rem'; - - content.appendChild(confirmMessage); - }, - onSave: () => { - // 删除分类 - logger.debug(`正在尝试删除分类: "${categoryName}"`); - if (this.tagsData[categoryName]) { - delete this.tagsData[categoryName]; - this.isDirty = true; - - logger.debug(`分类"${categoryName}"已删除,重新渲染界面`); - - // 确保重新渲染使用最新的数据 - setTimeout(() => { - this._createTagAccordion(this.tagsData, container); - this._initTooltips(); // 重新初始化tooltips - }, 0); - } else { - logger.warn(`无法找到分类: ${categoryName}`); - } - return true; // 确保弹窗关闭 - } - }); - }); - - iconContainer.appendChild(deleteButton); - - // 添加箭头图标(移到最后) - const arrowIcon = document.createElement('span'); - arrowIcon.className = 'pi pi-chevron-down accordion_arrow_icon rotate-180'; // 默认旋转箭头 - arrowIcon.style.fontSize = '12px'; // 设置图标大小 - iconContainer.appendChild(arrowIcon); - - header.appendChild(title); - header.appendChild(iconContainer); - - // 添加内容区域 - const content = document.createElement('div'); - content.className = 'tag_accordion_content active'; // 默认设置为active状态 - content.style.maxHeight = 'none'; // 默认展开 - - // 添加标签列表 - const tagContainer = document.createElement('div'); - tagContainer.className = 'tags_container'; - - // 添加标签 - for (const [tagName, tagValue] of Object.entries(categoryTags)) { - const tagItem = document.createElement('div'); - tagItem.className = 'tag_item'; - tagItem.setAttribute('data-name', tagName); - tagItem.setAttribute('data-value', tagValue); - - // 添加标签文本 - const tagText = document.createElement('span'); - tagText.className = 'tag_item_text'; - tagText.textContent = tagName; - - // 添加编辑按钮 - const editButton = document.createElement('button'); - editButton.className = 'tag_edit_button small'; // 使用新的编辑按钮样式 - editButton.title = '编辑标签'; - const editIcon = document.createElement('span'); - editIcon.className = 'pi pi-pencil tag_edit_icon small'; // 使用新的编辑图标样式 - editButton.appendChild(editIcon); - - editButton.addEventListener('click', (e) => { - e.stopPropagation(); - this._showTagDialog(container, categoryName, tagName); - }); - - // 添加删除按钮 - const deleteButton = document.createElement('button'); - deleteButton.className = 'tag_delete_button small'; - deleteButton.title = '删除标签'; - - // 添加删除图标 - const deleteIcon = document.createElement('span'); - deleteIcon.className = 'pi pi-times tag_action_icon small'; - deleteButton.appendChild(deleteIcon); - - // 添加删除事件 - deleteButton.addEventListener('click', (e) => { - e.stopPropagation(); - - const thisName = tagName; // 保存当前的tagName,避免在闭包中引用循环变量 - const thisCategory = categoryName; // 保存当前的categoryName - - // 创建确认对话框 - createSettingsDialog({ - title: '确认删除', - isConfirmDialog: true, - disableBackdropAndCloseOnClickOutside: true, - saveButtonText: '确认', - cancelButtonText: '取消', - renderContent: (content) => { - content.style.textAlign = 'center'; - content.style.padding = '1rem'; - - const confirmMessage = document.createElement('p'); - confirmMessage.textContent = `确定要删除标签"${thisName}"吗?`; - confirmMessage.style.margin = '0'; - confirmMessage.style.fontSize = '1rem'; - - content.appendChild(confirmMessage); - }, - onSave: () => { - // 删除标签 - logger.debug(`正在尝试删除标签: "${thisName}" (分类: "${thisCategory}")`); - if (this.tagsData[thisCategory] && this.tagsData[thisCategory][thisName]) { - delete this.tagsData[thisCategory][thisName]; - this.isDirty = true; - - logger.debug(`标签 "${thisName}" 已从分类 "${thisCategory}" 中删除,重新渲染界面`); - - // 确保重新渲染使用最新的数据 - setTimeout(() => { - this._createTagAccordion(this.tagsData, container); - this._initTooltips(); // 重新初始化tooltips - }, 0); - } else { - logger.warn(`无法找到标签: ${thisName} 在分类 ${thisCategory} 中`); - } - return true; // 确保弹窗关闭 - } - }); - }); - - // 添加tooltip功能 - tagItem.title = tagValue; - tagItem.setAttribute('data-tooltip-content', `${tagValue}`); - tagItem.classList.add('has-tooltip'); - - tagItem.appendChild(tagText); - tagItem.appendChild(editButton); - tagItem.appendChild(deleteButton); - tagContainer.appendChild(tagItem); - } - - content.appendChild(tagContainer); - - // 添加手风琴切换事件 - header.addEventListener('click', (e) => { - if (e.target.closest('.tag_delete_button')) return; - - const isActive = !header.classList.contains('active'); - header.classList.toggle('active'); - content.classList.toggle('active'); - - // 添加过渡效果 - if (isActive) { - // 先设置一个固定高度,确保动画效果 - content.style.maxHeight = content.scrollHeight + 'px'; - arrowIcon.classList.add('rotate-180'); - - // 处理滚动容器定位,确保展开内容可见 - setTimeout(() => { - // 计算手风琴的位置 - const accordionTop = accordion.offsetTop; - const scrollTop = container.closest('.tags-scroll-container').scrollTop; - const containerHeight = container.closest('.tags-scroll-container').clientHeight; - - // 如果手风琴底部超出可视区域,滚动到合适位置 - const accordionBottom = accordionTop + header.offsetHeight + content.scrollHeight - scrollTop; - if (accordionBottom > containerHeight) { - // 确保手风琴标题仍在可视区域顶部 - container.closest('.tags-scroll-container').scrollTo({ - top: Math.max(accordionTop - 20, 0), - behavior: 'smooth' - }); - } - }, 50); - } else { - content.style.maxHeight = '0'; - arrowIcon.classList.remove('rotate-180'); - } - }); - - // 默认所有手风琴都是展开状态 - header.classList.add('active'); - content.classList.add('active'); - content.style.maxHeight = 'none'; // 默认不限制高度,完全展开 - content.style.overflow = 'visible'; // 确保内容可见 - arrowIcon.classList.add('rotate-180'); // 箭头朝上表示展开状态 - - accordion.appendChild(header); - accordion.appendChild(content); - container.appendChild(accordion); - - // 初始化标签排序 - this._initSortable(container, tagContainer, categoryName); - }); - - // 初始化分类排序 - this._initCategorySortable(container); - } - - /** - * 创建标签配置弹窗 - * @private - */ - _createTagsConfigDialog() { - // 创建一个变量来存储原始的isDirty值 - const originalIsDirty = this.isDirty; - // 创建一个变量来存储表单修改状态设置函数 - let setFormModified = null; - - const dialog = createSettingsDialog({ - title: '标签管理器', - dialogClassName: 'tags-config-dialog', - disableBackdropAndCloseOnClickOutside: true, - renderContent: (container) => { - // 弹窗容器的整体设置 - container.className += ' tags-config-container'; - container.style.width = '100%'; - - // 创建表单 - const form = document.createElement('form'); - form.onsubmit = (e) => e.preventDefault(); - form.className = 'tags-config-form'; - form.style.width = '100%'; - - // 创建一个隐藏的输入字段,用于标记表单修改状态 - const hiddenInput = document.createElement('input'); - hiddenInput.type = 'hidden'; - hiddenInput.name = 'modified_marker'; - hiddenInput.value = 'initial'; - form.appendChild(hiddenInput); - - // 定义表单修改状态设置函数 - setFormModified = () => { - // 修改隐藏字段的值,这会触发表单变化检测 - hiddenInput.value = 'modified_' + Date.now(); - // 触发change事件 - hiddenInput.dispatchEvent(new Event('change', { bubbles: true })); - }; - - // 覆盖isDirty的setter,在设置为true时触发表单修改状态 - Object.defineProperty(this, 'isDirty', { - get: function () { - return this._isDirty; - }, - set: function (value) { - // 保存值 - this._isDirty = value; - // 如果设置为true,则标记表单已修改 - if (value && setFormModified) { - setFormModified(); - } - } - }); - // 初始化_isDirty - this._isDirty = originalIsDirty; - - // 创建标签管理部分 - const tagsSection = createFormGroup('标签管理'); - tagsSection.className += ' tag-management-section'; - tagsSection.style.width = '100%'; - - const tagsHeader = tagsSection.querySelector('.settings-form-section-header'); - - // 添加操作按钮容器 - 使用透明容器 - const actionContainer = document.createElement('div'); - actionContainer.className = 'tags-action-container'; - actionContainer.style.display = 'flex'; - actionContainer.style.gap = '8px'; - - // 添加分类按钮 - const addCategoryButton = document.createElement('button'); - addCategoryButton.className = 'p-button p-component p-button-secondary p-button-sm settings-action-btn'; - addCategoryButton.title = '添加分类'; - addCategoryButton.innerHTML = '添加分类'; - addCategoryButton.onclick = () => this._showCategoryDialog(tagsContainer); - - // 添加标签按钮 - const addTagButton = document.createElement('button'); - addTagButton.className = 'p-button p-component p-button-secondary p-button-sm settings-action-btn'; - addTagButton.title = '添加标签'; - addTagButton.innerHTML = '添加标签'; - addTagButton.onclick = () => this._showTagDialog(tagsContainer); - - // 添加按钮到容器 - actionContainer.appendChild(addCategoryButton); - actionContainer.appendChild(addTagButton); - tagsHeader.appendChild(actionContainer); - - // 创建标签容器 - const tagsContainer = document.createElement('div'); - tagsContainer.className = 'tags-manager-container'; - tagsContainer.style.width = '100%'; - - // 创建一个固定高度的滚动容器 - const scrollContainer = document.createElement('div'); - scrollContainer.className = 'tags-scroll-container'; - scrollContainer.style.width = '100%'; - scrollContainer.appendChild(tagsContainer); - - tagsSection.appendChild(scrollContainer); - - // 加载标签数据并渲染 - this._loadTagsData().then(data => { - this._createTagAccordion(data, tagsContainer); - // 初始化tooltip功能 - setTimeout(() => this._initTooltips(), 100); - }); - - form.appendChild(tagsSection); - container.appendChild(form); - }, - onSave: async (content) => { - // 如果没有更改,直接关闭 - if (!this.isDirty) { - return true; - } - - // 保存标签数据 - try { - const success = await this._saveTagsData(this.tagsData); - if (success) { - // 保存成功后,强制刷新用户标签缓存 - ResourceManager.getUserTagData(true); - - // 更新初始数据并重置脏状态 - this.initialTagsData = JSON.parse(JSON.stringify(this.tagsData)); - this.isDirty = false; - app.extensionManager.toast.add({ - severity: "success", - summary: "标签配置已更新", - detail: "用户自定义标签已保存", - life: 3000 - }); - // 恢复原始属性定义 - if (Object.getOwnPropertyDescriptor(this, 'isDirty').set) { - delete this.isDirty; - this.isDirty = this._isDirty; - delete this._isDirty; - } - return true; - } else { - throw new Error("保存失败"); - } - } catch (error) { - app.extensionManager.toast.add({ - severity: "error", - summary: "保存失败", - detail: error.message || "标签保存过程中发生错误", - life: 3000 - }); - throw error; - } - }, - onCancel: () => { - // 如果有未保存的更改,通用确认对话框会自动处理 - if (this.isDirty) { - // 恢复初始数据 - this.tagsData = JSON.parse(JSON.stringify(this.initialTagsData)); - this.isDirty = false; - } - - // 清理tooltips资源 - this._cleanupTooltips(); - - // 恢复原始属性定义 - if (Object.getOwnPropertyDescriptor(this, 'isDirty').set) { - delete this.isDirty; - this.isDirty = this._isDirty; - delete this._isDirty; - } - - return true; // 允许关闭 - } - }); - - return dialog; - } - - /** - * 显示分类对话框(添加/编辑) - * @param {HTMLElement} container 标签容器 - * @param {string} [oldCategoryName] 如果是编辑模式,则传入旧的分类名 - */ - _showCategoryDialog(container, oldCategoryName = null) { - const isEdit = !!oldCategoryName; - createSettingsDialog({ - title: isEdit ? '编辑分类' : '添加分类', - isConfirmDialog: true, - disableBackdropAndCloseOnClickOutside: true, - saveButtonText: isEdit ? '保存' : '添加', - cancelButtonText: '取消', - renderContent: (content) => { - content.className += ' dialog-form-content'; - content.style.padding = '1rem'; - - const categoryInput = createInputGroup('分类名称', '请输入分类名称'); - categoryInput.group.style.marginBottom = '0'; - - if (isEdit) { - categoryInput.input.value = oldCategoryName; - } - - content.appendChild(categoryInput.group); - - // 聚焦输入框 - setTimeout(() => { - categoryInput.input.focus(); - }, 100); - - content.categoryInput = categoryInput.input; - }, - onSave: (content) => { - const categoryName = content.categoryInput.value.trim(); - if (!categoryName) { - app.extensionManager.toast.add({ - severity: "error", - summary: isEdit ? "编辑失败" : "添加失败", - detail: "分类名称不能为空", - life: 3000 - }); - return; - } - - // 检查是否已存在(排除编辑时的自身) - if (!isEdit && this.tagsData[categoryName] || - (isEdit && categoryName !== oldCategoryName && this.tagsData[categoryName])) { - app.extensionManager.toast.add({ - severity: "error", - summary: isEdit ? "编辑失败" : "添加失败", - detail: "该分类已存在", - life: 3000 - }); - return; - } - - if (isEdit && categoryName !== oldCategoryName) { - // 创建一个新的对象来保持顺序 - const newData = {}; - // 遍历现有数据,在适当的位置替换分类名 - Object.entries(this.tagsData).forEach(([name, content]) => { - if (name === oldCategoryName) { - newData[categoryName] = content; - } else { - newData[name] = content; - } - }); - this.tagsData = newData; - } else if (!isEdit) { - // 添加新分类 - this.tagsData[categoryName] = {}; - } - - this.isDirty = true; - this._createTagAccordion(this.tagsData, container); - - app.extensionManager.toast.add({ - severity: "success", - summary: isEdit ? "编辑成功" : "添加成功", - detail: isEdit ? `已更新分类"${categoryName}"` : `已添加分类"${categoryName}"`, - life: 3000 - }); - - return true; - } - }); - } - - /** - * 显示标签对话框(添加/编辑) - * @param {HTMLElement} container 标签容器 - * @param {string} [categoryName] 如果是编辑模式,则传入分类名 - * @param {string} [oldTagName] 如果是编辑模式,则传入旧的标签名 - */ - _showTagDialog(container, categoryName = null, oldTagName = null) { - const isEdit = !!oldTagName; - const oldTagValue = isEdit ? this.tagsData[categoryName][oldTagName] : ''; - - createSettingsDialog({ - title: isEdit ? '编辑标签' : '添加标签', - isConfirmDialog: true, - disableBackdropAndCloseOnClickOutside: true, - saveButtonText: isEdit ? '保存' : '添加', - cancelButtonText: '取消', - renderContent: (content) => { - content.className += ' dialog-form-content'; - content.style.padding = '1rem'; - - // 创建分类选择器(编辑和添加模式都显示) - const categories = Object.keys(this.tagsData).map(cat => ({ value: cat, text: cat })); - const categorySelect = createSelectGroup('选择分类', categories, isEdit ? categoryName : null); - categorySelect.group.style.marginBottom = '10px'; - content.appendChild(categorySelect.group); - content.categorySelect = categorySelect.select; - - // 创建标签名称输入框 - const tagNameInput = createInputGroup('标签名称', '请输入标签显示名称'); - tagNameInput.group.style.marginBottom = '10px'; - if (isEdit) { - tagNameInput.input.value = oldTagName; - } - - // 创建标签内容输入框 - const tagValueInput = createInputGroup('标签内容', '请输入标签插入内容'); - tagValueInput.group.style.marginBottom = '0'; - if (isEdit) { - tagValueInput.input.value = oldTagValue; - } - - content.appendChild(tagNameInput.group); - content.appendChild(tagValueInput.group); - - content.tagNameInput = tagNameInput.input; - content.tagValueInput = tagValueInput.input; - - // 聚焦标签名称输入框 - setTimeout(() => { - tagNameInput.input.focus(); - }, 100); - }, - onSave: (content) => { - const targetCategory = content.categorySelect.value; - const tagName = content.tagNameInput.value.trim(); - const tagValue = content.tagValueInput.value.trim(); - - if (!targetCategory || !tagName || !tagValue) { - app.extensionManager.toast.add({ - severity: "error", - summary: isEdit ? "编辑失败" : "添加失败", - detail: "分类、标签名称和标签内容不能为空", - life: 3000 - }); - return; - } - - // 检查标签是否已存在 - const isSameCategory = isEdit && targetCategory === categoryName; - const isTagNameChanged = isEdit && tagName !== oldTagName; - - // 在以下情况下检查重复: - // 1. 添加新标签时 - // 2. 编辑时改变了标签名(在同一分类内) - // 3. 移动到其他分类且目标分类已有同名标签 - if ((!isEdit && this.tagsData[targetCategory][tagName]) || - (isEdit && ( - (isSameCategory && isTagNameChanged && this.tagsData[targetCategory][tagName]) || - (!isSameCategory && this.tagsData[targetCategory][tagName]) - ))) { - app.extensionManager.toast.add({ - severity: "error", - summary: isEdit ? "编辑失败" : "添加失败", - detail: `标签"${tagName}"已存在于分类"${targetCategory}"中`, - life: 3000 - }); - return; - } - - if (isEdit) { - // 如果分类发生了更改,我们需要从旧分类中移除标签 - if (targetCategory !== categoryName) { - const originalCategoryTags = {}; - Object.entries(this.tagsData[categoryName]).forEach(([name, value]) => { - if (name !== oldTagName) { - originalCategoryTags[name] = value; - } - }); - this.tagsData[categoryName] = originalCategoryTags; - } - - // 更新目标分类 - const newTargetTags = {}; - const tagsToIterate = (targetCategory === categoryName) - ? this.tagsData[targetCategory] - : (this.tagsData[targetCategory] || {}); - - Object.entries(tagsToIterate).forEach(([name, value]) => { - // 在同一个分类中重命名时,要跳过旧的标签名 - if (targetCategory === categoryName && name === oldTagName) { - newTargetTags[tagName] = tagValue; - } else { - newTargetTags[name] = value; - } - }); - - // 如果是移动到新分类,在末尾添加标签 - if (targetCategory !== categoryName) { - newTargetTags[tagName] = tagValue; - } - - this.tagsData[targetCategory] = newTargetTags; - - } else { - // 添加新标签 - this.tagsData[targetCategory][tagName] = tagValue; - } - - this.isDirty = true; - this._createTagAccordion(this.tagsData, container); - - app.extensionManager.toast.add({ - severity: "success", - summary: isEdit ? "编辑成功" : "添加成功", - detail: isEdit ? - (targetCategory === categoryName ? - `已更新标签"${tagName}"` : - `已将标签"${tagName}"移动到分类"${targetCategory}"`) : - `已添加标签"${tagName}"到分类"${targetCategory}"中`, - life: 3000 - }); - - return true; - } - }); - } -} - -// 导出实例 -const tagConfigManager = new TagConfigManager(); -export { TagConfigManager, tagConfigManager }; \ No newline at end of file diff --git a/js/modules/uiComponents.js b/js/modules/uiComponents.js new file mode 100644 index 0000000..a8b289f --- /dev/null +++ b/js/modules/uiComponents.js @@ -0,0 +1,2472 @@ +/** + * 通用UI组件库 + * 提供项目中可复用的UI组件 + */ + +import { app } from "../../../../scripts/app.js"; +import { logger } from '../utils/logger.js'; + +/** + * 创建通用的设置弹窗 + * @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.isDangerButton 保存按钮是否为危险按钮样式(可选,红色背景) + * @param {boolean} options.disableBackdropAndCloseOnClickOutside 禁用遮罩层和点击外部关闭(可选) + */ +export function createSettingsDialog(options) { + try { + const { + title, + renderContent, + renderNotice = null, + onSave, + onCancel = null, + onClose = null, // 关闭回调(无论保存还是取消都会调用) + isConfirmDialog = false, + saveButtonText = '保存', + cancelButtonText = '取消', + saveButtonIcon = 'pi-check', + isDangerButton = false, // 是否为危险按钮 + dialogClassName = null, + disableBackdropAndCloseOnClickOutside = false, + hideFooter = 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'; + // z-index 由 CSS 的 .settings-modal 和 .settings-modal-overlay 统一管理,不需要在 JS 中设置 + } + + // 表单修改状态 + let isFormModified = false; + + // 内部关闭函数:关闭弹窗并调用 onClose 回调 + const closeDialog = () => { + closeModalWithAnimation(modal, overlay); + // 在动画完成后调用 onClose 回调 + if (onClose) { + setTimeout(() => { + onClose(); + }, 300); // 与动画时间匹配 + } + }; + + // 处理关闭弹窗的逻辑 + const handleCloseModal = async (saveAction) => { + // 如果是保存操作,直接保存并关闭,不弹出确认对话框 + if (saveAction) { + try { + await onSave(content); + closeDialog(); + } catch (error) { + app.extensionManager.toast.add({ + severity: "error", + summary: "保存失败", + detail: error.message, + life: 3000 + }); + } + return; + } + + // 只有在表单被修改、不是确认对话框、且未提供 onCancel 时才显示确认对话框 + // 如果提供了 onCancel,表示调用者选择跳过二次确认 + if (isFormModified && !isConfirmDialog && !onCancel) { + // 创建确认对话框 + 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: () => { + // 关闭主对话框 + closeDialog(); + } + }); + } else { + // 没有修改或是确认对话框或有 onCancel,直接关闭 + if (onCancel && !isConfirmDialog) { + onCancel(); + } + closeDialog(); + } + }; + + 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.innerHTML = 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, header); + + // 如果不是确认对话框且内容中有表单,添加变更跟踪 + 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'); + // 根据isDangerButton参数设置按钮样式 + saveButton.className = isDangerButton + ? 'p-button p-component p-button-danger' + : '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); + + // 只有在 hideFooter 为 false 时才添加底部按钮 + if (!hideFooter) { + modal.appendChild(footer); + } + + // 显示弹窗和遮罩层 + document.body.appendChild(modal); + + // 添加显示动画 + requestAnimationFrame(() => { + modal.classList.add('modal-show'); + if (overlay) { + overlay.classList.add('overlay-show'); + } + }); + + return modal; + } catch (error) { + logger.error(`创建设置弹窗失败: ${error.message}`); + app.extensionManager.toast.add({ + severity: "error", + summary: "创建弹窗失败", + detail: error.message || "创建设置弹窗过程中发生错误", + life: 3000 + }); + } +} + +/** + * 关闭弹窗时添加动画效果 + * @param {HTMLElement} modal 弹窗元素 + * @param {HTMLElement} overlay 遮罩层元素 + */ +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'); + } + + // 等待动画完成后移除弹窗和遮罩层 + setTimeout(() => { + if (modal.parentNode) { + modal.parentNode.removeChild(modal); + } + if (overlay && overlay.parentNode) { + overlay.parentNode.removeChild(overlay); + } + }, 300); // 与 CSS transition 时间相匹配 +} + +/** + * 创建表单组 + * @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; +} + +/** + * 创建浮动标签输入框组 (PrimeVue FloatLabel variant="on" 风格) + * @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 floatContainer = document.createElement('div'); + floatContainer.className = 'float-label-container'; + + // 创建输入框 + const input = document.createElement('input'); + input.className = 'p-inputtext p-component'; + input.type = type; + // 使用空格作为 placeholder 以触发 :not(:placeholder-shown) 选择器 + input.placeholder = ' '; + + // 创建浮动标签 + const floatLabel = document.createElement('label'); + floatLabel.textContent = label; + + // 组装结构: input 在前, label 在后 (使用 ~ 选择器) + floatContainer.appendChild(input); + floatContainer.appendChild(floatLabel); + + // 如果是数字输入框,添加自定义上下调整按钮 + if (type === 'number') { + const buttonsContainer = document.createElement('div'); + buttonsContainer.className = 'number-input-buttons'; + + // 增加按钮 + const increaseBtn = document.createElement('button'); + increaseBtn.className = 'number-input-button'; + increaseBtn.type = 'button'; + increaseBtn.innerHTML = ''; + increaseBtn.addEventListener('click', () => { + const step = parseFloat(input.step) || 1; + const max = input.max ? parseFloat(input.max) : Infinity; + const currentValue = parseFloat(input.value) || 0; + const newValue = Math.min(currentValue + step, max); + input.value = newValue; + // 触发change事件以保存数据 + input.dispatchEvent(new Event('change', { bubbles: true })); + }); + + // 减少按钮 + const decreaseBtn = document.createElement('button'); + decreaseBtn.className = 'number-input-button'; + decreaseBtn.type = 'button'; + decreaseBtn.innerHTML = ''; + decreaseBtn.addEventListener('click', () => { + const step = parseFloat(input.step) || 1; + const min = input.min ? parseFloat(input.min) : -Infinity; + const currentValue = parseFloat(input.value) || 0; + const newValue = Math.max(currentValue - step, min); + input.value = newValue; + // 触发change事件以保存数据 + input.dispatchEvent(new Event('change', { bubbles: true })); + }); + + buttonsContainer.appendChild(increaseBtn); + buttonsContainer.appendChild(decreaseBtn); + floatContainer.appendChild(buttonsContainer); + } + + group.appendChild(floatContainer); + + return { group, input }; +} + +/** + * 创建下拉选择框组 (PrimeVue FloatLabel variant="on" 风格) + * @param {string} label 标签文本 + * @param {Array<{value: string, text: string}>} options 选项列表 + * @param {string} [initialValue=null] 初始选中的值 + * @param {Object} [config={}] 配置选项 + * @param {boolean} [config.showLabel=true] 是否显示浮动标签 + * @returns {Object} 包含 group 和 select 的对象 + */ +export function createSelectGroup(label, options, initialValue = null, config = {}) { + const { showLabel = true } = config; + + const group = document.createElement('div'); + group.className = 'settings-form-group'; + + // 创建浮动标签容器 + const floatContainer = document.createElement('div'); + floatContainer.className = 'float-label-container'; + + // 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 + // z-index 由 CSS 的 --settings-dropdown-z-index 变量管理 + + 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; + dropdownLabel.textContent = opt.text; + 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); + + // 根据 showLabel 决定是否创建浮动标签 + if (showLabel) { + // 创建浮动标签 + const floatLabel = document.createElement('label'); + floatLabel.textContent = label; + + // 组装浮动标签结构: dropdown 在前, label 在后 + floatContainer.appendChild(dropdownContainer); + floatContainer.appendChild(floatLabel); + } else { + // 不显示浮动标签,直接添加下拉框 + floatContainer.appendChild(dropdownContainer); + } + + group.appendChild(floatContainer); + + // --- 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 }; +} + +/** + * 创建可输入下拉框组(Combo Box) + * 支持从下拉列表选择或自定义输入 + * @param {string} label 标签文本 + * @param {Array<{value: string, text: string}>} options 选项列表(动态传入) + * @param {string} [initialValue=''] 初始值 + * @param {Object} [config={}] 配置选项 + * @param {string} [config.placeholder=''] 输入框占位符 + * @param {string} [config.emptyText='暂无选项'] 无选项时的提示文本 + * @param {boolean} [config.showLabel=true] 是否显示浮动标签 + * @returns {Object} 包含 group, input, setValue, getValue, updateOptions 的对象 + */ +export function createComboBoxGroup(label, options = [], initialValue = '', config = {}) { + const { + placeholder = '', + emptyText = '暂无选项', + showLabel = true + } = config; + + const group = document.createElement('div'); + group.className = 'settings-form-group'; + + // 创建浮动标签容器 + const floatContainer = document.createElement('div'); + floatContainer.className = 'float-label-container'; + + // ---主容器--- + const comboContainer = document.createElement('div'); + comboContainer.className = 'pa-combobox pa-dropdown p-dropdown p-component w-full'; + comboContainer.style.position = 'relative'; + + // ---输入框(可见且可编辑)--- + const input = document.createElement('input'); + input.type = 'text'; + input.className = 'pa-combobox-input pa-dropdown-label p-dropdown-label p-inputtext'; + input.placeholder = placeholder || ' '; + input.value = initialValue; + + // ---下拉触发器(箭头图标)--- + const dropdownTrigger = document.createElement('div'); + dropdownTrigger.className = 'pa-dropdown-trigger p-dropdown-trigger'; + dropdownTrigger.innerHTML = ''; + + // ---下拉面板--- + const dropdownPanel = document.createElement('div'); + dropdownPanel.className = 'pa-dropdown-panel pa-combobox-panel p-dropdown-panel p-component settings-modal-dropdown-panel'; + dropdownPanel.style.display = 'none'; + + 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'); + + // ---状态变量--- + let isOpen = false; + let currentOptions = [...options]; + + // ---渲染选项列表--- + const renderOptions = (optionsList) => { + dropdownList.innerHTML = ''; + + if (optionsList.length === 0) { + // 显示空状态提示 + const emptyItem = document.createElement('li'); + emptyItem.className = 'p-dropdown-item pa-combobox-empty'; + emptyItem.textContent = emptyText; + emptyItem.style.color = 'var(--p-text-muted-color)'; + emptyItem.style.fontStyle = 'italic'; + emptyItem.style.pointerEvents = 'none'; + dropdownList.appendChild(emptyItem); + return; + } + + optionsList.forEach(opt => { + const itemEl = document.createElement('li'); + itemEl.className = 'p-dropdown-item'; + itemEl.textContent = opt.text; + itemEl.dataset.value = opt.value; + itemEl.setAttribute('role', 'option'); + + // 高亮当前选中项 + if (input.value === opt.value) { + itemEl.classList.add('p-highlight'); + } + + itemEl.addEventListener('click', (e) => { + e.stopPropagation(); + // 更新输入框值 + input.value = opt.value; + // 更新高亮 + dropdownList.querySelectorAll('.p-dropdown-item').forEach(el => el.classList.remove('p-highlight')); + itemEl.classList.add('p-highlight'); + // 关闭面板 + closePanel(); + // 触发 change 事件 + input.dispatchEvent(new Event('change', { bubbles: true })); + input.dispatchEvent(new Event('input', { bubbles: true })); + }); + + dropdownList.appendChild(itemEl); + }); + }; + + // 初始渲染 + renderOptions(currentOptions); + + // ---组装下拉面板--- + dropdownItemsWrapper.appendChild(dropdownList); + dropdownPanel.appendChild(dropdownItemsWrapper); + + // ---组装主容器--- + comboContainer.appendChild(input); + comboContainer.appendChild(dropdownTrigger); + + // ---根据 showLabel 决定是否创建浮动标签--- + if (showLabel) { + const floatLabel = document.createElement('label'); + floatLabel.textContent = label; + floatContainer.appendChild(comboContainer); + floatContainer.appendChild(floatLabel); + } else { + floatContainer.appendChild(comboContainer); + } + + group.appendChild(floatContainer); + + // ---更新面板位置--- + const updatePanelPosition = () => { + if (!isOpen) return; + const rect = comboContainer.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'); + comboContainer.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); + }; + + // ---打开面板--- + const openPanel = () => { + if (isOpen) return; + isOpen = true; + + document.body.appendChild(dropdownPanel); + + const rect = comboContainer.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'; + + dropdownPanel.classList.remove('p-hidden'); + dropdownPanel.offsetHeight; // 强制重排 + dropdownPanel.classList.add('p-enter-active'); + comboContainer.classList.add('p-dropdown-open', 'p-focus'); + + document.addEventListener('click', handleOutsideClick, true); + window.addEventListener('resize', updatePanelPosition); + window.addEventListener('scroll', updatePanelPosition, true); + + // 更新高亮状态 + dropdownList.querySelectorAll('.p-dropdown-item').forEach(el => { + if (el.dataset.value === input.value) { + el.classList.add('p-highlight'); + } else { + el.classList.remove('p-highlight'); + } + }); + }; + + // ---处理外部点击--- + const handleOutsideClick = (e) => { + // 点击面板内部不关闭 + if (dropdownPanel.contains(e.target)) { + return; + } + // 点击输入框或触发器时切换状态 + if (comboContainer.contains(e.target)) { + return; // 由输入框或触发器的事件处理 + } + closePanel(); + }; + + // ---输入框事件--- + input.addEventListener('focus', () => { + openPanel(); + comboContainer.classList.add('p-focus'); + }); + + input.addEventListener('blur', () => { + // 延迟关闭,允许点击选项 + setTimeout(() => { + if (!dropdownPanel.contains(document.activeElement)) { + comboContainer.classList.remove('p-focus'); + } + }, 150); + }); + + // ---触发器点击事件--- + dropdownTrigger.addEventListener('click', (e) => { + e.stopPropagation(); + if (isOpen) { + closePanel(); + } else { + openPanel(); + input.focus(); + } + }); + + // ---初始化面板状态--- + dropdownPanel.classList.add('p-hidden'); + + // ---公开方法--- + const setValue = (value) => { + input.value = value; + // 更新高亮 + dropdownList.querySelectorAll('.p-dropdown-item').forEach(el => { + if (el.dataset.value === value) { + el.classList.add('p-highlight'); + } else { + el.classList.remove('p-highlight'); + } + }); + }; + + const getValue = () => { + return input.value; + }; + + const updateOptions = (newOptions) => { + currentOptions = [...newOptions]; + renderOptions(currentOptions); + }; + + return { group, input, setValue, getValue, updateOptions }; +} + +/** + * 创建水平布局的表单组 + * @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; +} + +/** + * 创建多行文本输入框组 (PrimeVue FloatLabel variant="on" 风格) + * @param {string} label 标签文本 + * @param {string} placeholder 占位符文本(未使用,保留参数以兼容旧代码) + * @param {number} rows 默认行数 + * @returns {Object} 包含 group 和 textarea 的对象 + */ +export function createTextareaGroup(label, placeholder, rows = 8) { + const group = document.createElement('div'); + group.className = 'settings-form-group'; + + // 创建浮动标签容器 + const floatContainer = document.createElement('div'); + floatContainer.className = 'float-label-container'; + + // 创建文本域 + const textarea = document.createElement('textarea'); + textarea.className = 'p-inputtext p-component settings-form-textarea'; + // 使用空格作为 placeholder 以触发 :not(:placeholder-shown) 选择器 + textarea.placeholder = ' '; + textarea.rows = rows; + textarea.style.resize = 'vertical'; + textarea.style.minHeight = '150px'; + + // 创建浮动标签 + const floatLabel = document.createElement('label'); + floatLabel.textContent = label; + + // 组装结构: textarea 在前, label 在后 (使用 ~ 选择器) + floatContainer.appendChild(textarea); + floatContainer.appendChild(floatLabel); + + group.appendChild(floatContainer); + + return { group, textarea }; +} + +/** + * 创建开关控制组件 + * @param {string} label 标签文本 + * @param {string} description 描述文本 + * @param {boolean} defaultChecked 默认选中状态 + * @param {Function} onChange 变化回调函数 + * @returns {HTMLElement} 开关容器元素 + */ +export function createSwitchControl(label, description, defaultChecked, onChange) { + const container = document.createElement('div'); + container.className = 'switch-control-container'; + + const textContainer = document.createElement('div'); + textContainer.className = 'switch-control-text'; + + const labelEl = document.createElement('div'); + labelEl.className = 'switch-control-label'; + labelEl.textContent = label; + textContainer.appendChild(labelEl); + + if (description) { + const descEl = document.createElement('div'); + descEl.className = 'switch-control-desc'; + descEl.textContent = description; + textContainer.appendChild(descEl); + } + + container.appendChild(textContainer); + + // 创建开关 + const switchWrapper = document.createElement('label'); + switchWrapper.className = 'switch-wrapper'; + + const input = document.createElement('input'); + input.type = 'checkbox'; + input.checked = defaultChecked; + + const slider = document.createElement('span'); + slider.className = `switch-slider${defaultChecked ? ' checked' : ''}`; + + const sliderButton = document.createElement('span'); + sliderButton.className = `switch-button${defaultChecked ? ' checked' : ''}`; + slider.appendChild(sliderButton); + + input.addEventListener('change', async (e) => { + const isChecked = e.target.checked; + if (isChecked) { + slider.classList.add('checked'); + sliderButton.classList.add('checked'); + } else { + slider.classList.remove('checked'); + sliderButton.classList.remove('checked'); + } + if (onChange) { + await onChange(isChecked); + } + }); + + switchWrapper.appendChild(input); + switchWrapper.appendChild(slider); + container.appendChild(switchWrapper); + + return container; +} + +/** + * 创建加载按钮 + * @param {string} text 按钮文本 + * @param {Function} onClick 点击回调函数 + * @param {boolean} showSuccessToast 是否显示成功提示 + * @returns {HTMLElement} 按钮元素 + */ +export 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}`; + + button.addEventListener('click', async () => { + if (button.disabled) return; + + // 开始加载状态 + button.disabled = true; + button.classList.add('p-disabled'); + + try { + await onClick(); + + // 只有在 showSuccessToast 为 true 时才显示成功提示 + if (showSuccessToast) { + app.extensionManager.toast.add({ + severity: "success", + summary: "清理已清理完成", + life: 3000 + }); + } + + } 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'); + } + }); + + return button; +} + +/** + * 创建确认气泡框 (参考 PrimeVue ConfirmPopup) + * @param {Object} options 配置选项 + * @param {HTMLElement} options.target 触发元素,气泡框的指针会指向它 + * @param {string} options.message 确认消息文本 + * @param {string} [options.icon] 消息图标类名(可选,默认为 'pi-info-circle') + * @param {Function} options.renderFormContent 渲染表单内容的函数(可选) + * @param {Function} options.onConfirm 确认回调函数 + * @param {Function} [options.onCancel] 取消回调函数(可选) + * @param {string} [options.confirmLabel='确认'] 确认按钮文本 + * @param {string} [options.cancelLabel='取消'] 取消按钮文本 + * @param {string} [options.position='bottom'] 气泡相对于触发元素的位置 ('top', 'bottom', 'left', 'right') + * @param {boolean} [options.autoPosition=true] 是否自动计算最佳弹出位置(基于元素在窗口中的位置) + * @param {boolean} [options.singleButton=false] 是否只显示单个确认按钮(适用于信息提示场景) + * @returns {Object} 包含 popup 元素和 close 方法的对象 + */ +export function createConfirmPopup(options) { + const { + target, + message, + icon = 'pi-info-circle', + renderFormContent = null, + onConfirm, + onCancel = null, + confirmLabel = '确认', + cancelLabel = '取消', + position = 'bottom', + autoPosition = true, + singleButton = false + } = options; + + // 创建气泡框容器 + const popup = document.createElement('div'); + popup.className = 'pa-confirm-popup'; + + // 创建气泡框内容 + const content = document.createElement('div'); + content.className = 'pa-confirm-popup-content'; + + // 创建消息区域 + const messageContainer = document.createElement('div'); + messageContainer.className = 'pa-confirm-popup-message'; + + const iconElement = document.createElement('i'); + iconElement.className = `pi ${icon} pa-confirm-popup-icon`; + messageContainer.appendChild(iconElement); + + const messageText = document.createElement('span'); + messageText.textContent = message; + messageContainer.appendChild(messageText); + + content.appendChild(messageContainer); + + // 如果提供了表单渲染函数,创建表单区域 + let formContainer = null; + if (renderFormContent) { + // 调整消息区域的下边距 + messageContainer.style.marginBottom = '12px'; + + // 添加分割线 + const divider = document.createElement('div'); + divider.className = 'pa-confirm-popup-divider'; + content.appendChild(divider); + + formContainer = document.createElement('div'); + formContainer.className = 'pa-confirm-popup-form'; + renderFormContent(formContainer); + content.appendChild(formContainer); + } + + // 创建按钮组 + const footer = document.createElement('div'); + footer.className = 'pa-confirm-popup-footer'; + + // 单按钮模式:只显示确认按钮 + if (singleButton) { + const confirmButton = document.createElement('button'); + confirmButton.className = 'p-button p-component p-button-sm'; + confirmButton.innerHTML = `${confirmLabel}`; + confirmButton.onclick = async () => { + try { + if (onConfirm) { + await onConfirm(formContainer); + } + closePopup(); + } catch (error) { + logger.error('确认操作失败', error); + // 如果确认操作失败,不关闭气泡框 + } + }; + footer.appendChild(confirmButton); + } else { + // 双按钮模式:显示取消和确认按钮 + const cancelButton = document.createElement('button'); + cancelButton.className = 'p-button p-component p-button-secondary p-button-sm'; + cancelButton.innerHTML = `${cancelLabel}`; + cancelButton.onclick = () => { + if (onCancel) { + onCancel(); + } + closePopup(); + }; + + const confirmButton = document.createElement('button'); + confirmButton.className = 'p-button p-component p-button-sm'; + confirmButton.innerHTML = `${confirmLabel}`; + confirmButton.onclick = async () => { + try { + // 将表单容器传递给 onConfirm 回调 + await onConfirm(formContainer); + closePopup(); + } catch (error) { + logger.error('确认操作失败', error); + // 如果确认操作失败,不关闭气泡框 + } + }; + + footer.appendChild(cancelButton); + footer.appendChild(confirmButton); + } + content.appendChild(footer); + + // 创建指针(箭头) + const arrow = document.createElement('div'); + arrow.className = 'pa-confirm-popup-arrow'; + + popup.appendChild(arrow); + popup.appendChild(content); + + // 关闭气泡框的函数 + const closePopup = () => { + popup.classList.add('pa-confirm-popup-hide'); + setTimeout(() => { + if (popup.parentNode) { + popup.parentNode.removeChild(popup); + } + // 移除点击外部关闭的事件监听 + document.removeEventListener('click', handleOutsideClick, true); + }, 200); + }; + + // 点击外部关闭 + const handleOutsideClick = (e) => { + if (!popup.contains(e.target) && !target.contains(e.target)) { + if (onCancel) { + onCancel(); + } + closePopup(); + } + }; + + // ---定位气泡框--- + const positionPopup = () => { + const targetRect = target.getBoundingClientRect(); + const popupRect = popup.getBoundingClientRect(); + const margin = 10; + const gap = 12; // 气泡与目标之间的间隙 + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + + let top, left; + let arrowPosition = ''; + let finalPosition = position; + + // 如果开启自动定位,根据目标元素位置计算最佳方向 + if (autoPosition) { + // 计算各方向可用空间 + const spaceTop = targetRect.top - margin; + const spaceBottom = viewportHeight - targetRect.bottom - margin; + const spaceLeft = targetRect.left - margin; + const spaceRight = viewportWidth - targetRect.right - margin; + + // 判断各方向是否能容纳气泡 + const canFitTop = spaceTop >= popupRect.height + gap; + const canFitBottom = spaceBottom >= popupRect.height + gap; + const canFitLeft = spaceLeft >= popupRect.width + gap; + const canFitRight = spaceRight >= popupRect.width + gap; + + // 根据目标元素位置选择最佳方向 + // 优先级:用户指定的方向 > 相反方向 > 垂直/水平替代方向 + const preferHorizontal = (position === 'left' || position === 'right'); + + if (preferHorizontal) { + // 用户偏好水平方向 + if (position === 'left' && canFitLeft) { + finalPosition = 'left'; + } else if (position === 'right' && canFitRight) { + finalPosition = 'right'; + } else if (canFitRight) { + finalPosition = 'right'; + } else if (canFitLeft) { + finalPosition = 'left'; + } else if (canFitBottom) { + finalPosition = 'bottom'; + } else if (canFitTop) { + finalPosition = 'top'; + } else { + // 空间都不够,选择垂直空间最大的方向 + finalPosition = spaceBottom >= spaceTop ? 'bottom' : 'top'; + } + } else { + // 用户偏好垂直方向(默认) + if (position === 'bottom' && canFitBottom) { + finalPosition = 'bottom'; + } else if (position === 'top' && canFitTop) { + finalPosition = 'top'; + } else if (canFitBottom) { + finalPosition = 'bottom'; + } else if (canFitTop) { + finalPosition = 'top'; + } else if (canFitRight) { + finalPosition = 'right'; + } else if (canFitLeft) { + finalPosition = 'left'; + } else { + // 空间都不够,选择垂直空间最大的方向 + finalPosition = spaceBottom >= spaceTop ? 'bottom' : 'top'; + } + } + } + + // 根据最终方向计算位置 + switch (finalPosition) { + case 'top': + top = targetRect.top - popupRect.height - gap; + left = targetRect.left + (targetRect.width / 2) - (popupRect.width / 2); + arrowPosition = 'bottom'; + break; + case 'left': + top = targetRect.top + (targetRect.height / 2) - (popupRect.height / 2); + left = targetRect.left - popupRect.width - gap; + arrowPosition = 'right'; + break; + case 'right': + top = targetRect.top + (targetRect.height / 2) - (popupRect.height / 2); + left = targetRect.right + gap; + arrowPosition = 'left'; + break; + case 'bottom': + default: + top = targetRect.bottom + gap; + left = targetRect.left + (targetRect.width / 2) - (popupRect.width / 2); + arrowPosition = 'top'; + break; + } + + // 水平边界检查(确保气泡不超出视口) + if (left < margin) { + left = margin; + } else if (left + popupRect.width > viewportWidth - margin) { + left = viewportWidth - popupRect.width - margin; + } + + // 垂直边界检查 + if (top < margin) { + top = margin; + } else if (top + popupRect.height > viewportHeight - margin) { + top = viewportHeight - popupRect.height - margin; + } + + popup.style.top = `${top}px`; + popup.style.left = `${left}px`; + + // 设置箭头位置 + arrow.className = `pa-confirm-popup-arrow ${arrowPosition}`; + + // 计算箭头的偏移,使其指向触发元素的中心 + if (arrowPosition === 'top' || arrowPosition === 'bottom') { + let arrowLeft = targetRect.left + (targetRect.width / 2) - left; + // 确保箭头不超出气泡边界 + arrowLeft = Math.max(16, Math.min(arrowLeft, popupRect.width - 16)); + arrow.style.left = `${arrowLeft}px`; + arrow.style.top = ''; + } else if (arrowPosition === 'left' || arrowPosition === 'right') { + let arrowTop = targetRect.top + (targetRect.height / 2) - top; + // 确保箭头不超出气泡边界 + arrowTop = Math.max(16, Math.min(arrowTop, popupRect.height - 16)); + arrow.style.top = `${arrowTop}px`; + arrow.style.left = ''; + } + }; + + // 将气泡框添加到 body + document.body.appendChild(popup); + + // 定位气泡框 + requestAnimationFrame(() => { + positionPopup(); + popup.classList.add('pa-confirm-popup-show'); + + // 延迟添加外部点击监听,防止立即触发 + setTimeout(() => { + document.addEventListener('click', handleOutsideClick, true); + }, 100); + }); + + return { + popup, + close: closePopup + }; +} + +/** + * 创建右键菜单组件 (参考 PrimeVue Menu) + * @param {Object} options 配置选项 + * @param {HTMLElement} options.target 触发元素,右键菜单会在此元素上触发 + * @param {Array} options.items 菜单项列表 + * @param {string} options.items[].label 菜单项文本 + * @param {string} [options.items[].icon] 菜单项图标类名(可选) + * @param {Function} options.items[].onClick 菜单项点击回调 + * @param {boolean} [options.items[].separator] 是否为分隔符(可选) + * @param {boolean} [options.items[].disabled] 是否禁用(可选) + * @returns {Object} 包含 destroy 方法的对象 + */ +/** + * 显示右键菜单 (动态显示,不需要绑定特定元素) + * @param {Object} options 配置选项 + * @param {number} options.x 显示位置 X 坐标 + * @param {number} options.y 显示位置 Y 坐标 + * @param {Array} options.items 菜单项列表 + * @param {Function} [options.onClose] 菜单关闭回调 + */ +export function showContextMenu(options) { + const { + x, + y, + items = [], + onClose + } = options; + + // 移除现有的右键菜单(但不移除 Split Button 的菜单和下拉菜单) + const existingMenu = document.querySelector('.pa-context-menu:not(.pa-split-button-menu):not(.pa-dropdown-menu)'); + if (existingMenu) { + existingMenu.remove(); + } + + const menu = document.createElement('div'); + menu.className = 'pa-context-menu pa-context-menu-show'; // 直接显示 + + // 我们需要手动设置样式以确保它立即可见但可以被定位 + menu.style.display = 'block'; + + const menuList = document.createElement('ul'); + menuList.className = 'pa-context-menu-list'; + + const closeMenu = () => { + menu.classList.remove('pa-context-menu-show'); + menu.classList.add('pa-context-menu-hide'); + + setTimeout(() => { + if (menu && menu.parentNode) { + menu.parentNode.removeChild(menu); + } + }, 150); + + document.removeEventListener('click', handleOutsideClick, true); + document.removeEventListener('contextmenu', handleOutsideClick, true); + + if (onClose) { + onClose(); + } + }; + + items.forEach(item => { + if (item.separator) { + const separator = document.createElement('li'); + separator.className = 'pa-context-menu-separator'; + menuList.appendChild(separator); + } else { + const menuItem = document.createElement('li'); + menuItem.className = 'pa-context-menu-item'; + + if (item.disabled) { + menuItem.classList.add('disabled'); + } + + if (item.danger) { + menuItem.classList.add('danger'); + } + + const menuItemContent = document.createElement('div'); + menuItemContent.className = 'pa-context-menu-item-content'; + + if (item.icon) { + const icon = document.createElement('i'); + icon.className = `pi ${item.icon} pa-context-menu-item-icon`; + menuItemContent.appendChild(icon); + } + + const label = document.createElement('span'); + label.className = 'pa-context-menu-item-label'; + label.textContent = item.label; + menuItemContent.appendChild(label); + + menuItem.appendChild(menuItemContent); + + if (!item.disabled && item.onClick) { + menuItem.addEventListener('click', (e) => { + e.stopPropagation(); + item.onClick(e); + closeMenu(); + }); + } + + menuList.appendChild(menuItem); + } + }); + + menu.appendChild(menuList); + document.body.appendChild(menu); + + // 定位菜单 + const menuRect = menu.getBoundingClientRect(); + let left = x; + let top = y; + + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + + if (left + menuRect.width > viewportWidth) { + left = viewportWidth - menuRect.width - 10; + } + + if (top + menuRect.height > viewportHeight) { + top = viewportHeight - menuRect.height - 10; + } + + menu.style.left = `${left}px`; + menu.style.top = `${top}px`; + + // 点击外部关闭 + const handleOutsideClick = (e) => { + if (menu && !menu.contains(e.target)) { + closeMenu(); + } + }; + + // 延迟添加外部点击监听 + setTimeout(() => { + document.addEventListener('click', handleOutsideClick, true); + document.addEventListener('contextmenu', handleOutsideClick, true); + }, 100); + + return { + close: closeMenu + }; +} + +/** + * 创建右键菜单组件 (参考 PrimeVue Menu) + * @param {Object} options 配置选项 + * @param {HTMLElement} options.target 触发元素,右键菜单会在此元素上触发 + * @param {Array|Function} options.items 菜单项列表或返回菜单项列表的函数 + * @returns {Object} 包含 destroy 方法的对象 + */ +export function createContextMenu(options) { + const { + target, + items = [] + } = options; + + const handleContextMenu = (e) => { + e.preventDefault(); + e.stopPropagation(); + + // 如果 items 是函数,则调用它获取最新的菜单项列表 + const menuItems = typeof items === 'function' ? items() : items; + + showContextMenu({ + x: e.clientX, + y: e.clientY, + items: menuItems + }); + }; + + target.addEventListener('contextmenu', handleContextMenu); + + const destroy = () => { + target.removeEventListener('contextmenu', handleContextMenu); + }; + + return { + destroy + }; +} + +/** + * 创建 Tooltip 提示框 (参考 PrimeVue Tooltip) + * @param {Object} options 配置选项 + * @param {HTMLElement} options.target 触发元素 + * @param {string} options.content 提示内容 + * @param {string} [options.position='top'] 提示框位置 ('top', 'bottom', 'left', 'right') + * @returns {Object} 包含 destroy 方法的对象 + */ +export function createTooltip(options) { + const { + target, + content, + position = 'top' + } = options; + + let tooltip = null; + let showTimeout = null; + let hideTimeout = null; + + // 创建 tooltip 元素 + const createTooltipElement = () => { + tooltip = document.createElement('div'); + tooltip.className = 'pa-tooltip'; + tooltip.textContent = content; + + // 创建箭头 + const arrow = document.createElement('div'); + arrow.className = 'pa-tooltip-arrow'; + tooltip.appendChild(arrow); + + document.body.appendChild(tooltip); + }; + + // 定位 tooltip + const positionTooltip = () => { + if (!tooltip) return; + + const targetRect = target.getBoundingClientRect(); + const tooltipRect = tooltip.getBoundingClientRect(); + + let top, left; + let arrowClass = ''; + + switch (position) { + case 'top': + top = targetRect.top - tooltipRect.height - 8; + left = targetRect.left + (targetRect.width / 2) - (tooltipRect.width / 2); + arrowClass = 'bottom'; + break; + case 'bottom': + top = targetRect.bottom + 8; + left = targetRect.left + (targetRect.width / 2) - (tooltipRect.width / 2); + arrowClass = 'top'; + break; + case 'left': + top = targetRect.top + (targetRect.height / 2) - (tooltipRect.height / 2); + left = targetRect.left - tooltipRect.width - 8; + arrowClass = 'right'; + break; + case 'right': + top = targetRect.top + (targetRect.height / 2) - (tooltipRect.height / 2); + left = targetRect.right + 8; + arrowClass = 'left'; + break; + } + + // 边界检查 + const margin = 10; + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + + if (left < margin) { + left = margin; + } else if (left + tooltipRect.width > viewportWidth - margin) { + left = viewportWidth - tooltipRect.width - margin; + } + + if (top < margin) { + top = margin; + } else if (top + tooltipRect.height > viewportHeight - margin) { + top = viewportHeight - tooltipRect.height - margin; + } + + tooltip.style.top = `${top}px`; + tooltip.style.left = `${left}px`; + + const arrow = tooltip.querySelector('.pa-tooltip-arrow'); + if (arrow) { + arrow.className = `pa-tooltip-arrow ${arrowClass}`; + } + }; + + // 显示 tooltip + const showTooltip = () => { + clearTimeout(hideTimeout); + showTimeout = setTimeout(() => { + if (!tooltip) { + createTooltipElement(); + } + positionTooltip(); + requestAnimationFrame(() => { + tooltip.classList.add('pa-tooltip-show'); + }); + }, 200); // 延迟 200ms 显示 + }; + + // 隐藏 tooltip + const hideTooltip = () => { + clearTimeout(showTimeout); + if (tooltip) { + tooltip.classList.remove('pa-tooltip-show'); + hideTimeout = setTimeout(() => { + if (tooltip && tooltip.parentNode) { + tooltip.parentNode.removeChild(tooltip); + tooltip = null; + } + }, 150); + } + }; + + // 绑定事件 + target.addEventListener('mouseenter', showTooltip); + target.addEventListener('mouseleave', hideTooltip); + + // 销毁方法 + const destroy = () => { + clearTimeout(showTimeout); + clearTimeout(hideTimeout); + target.removeEventListener('mouseenter', showTooltip); + target.removeEventListener('mouseleave', hideTooltip); + if (tooltip && tooltip.parentNode) { + tooltip.parentNode.removeChild(tooltip); + } + tooltip = null; + }; + + return { + destroy + }; +} + +/** + * 创建多选 Listbox 组件 + * @param {Object} options 配置选项 + * @param {HTMLElement} options.triggerElement 触发元素(按钮),用于定位 + * @param {string} options.placeholder 搜索框占位符 + * @param {Function} options.fetchItems 异步获取数据的函数,返回 Promise + * @param {Function} options.onConfirm 确认选择回调,参数为选中项数组 + * @param {Function} options.onCancel 取消回调(可选) + * @returns {void} + */ +export function createMultiSelectListbox(options) { + const { + triggerElement, + placeholder = '搜索...', + fetchItems, + onConfirm, + onCancel = null + } = options; + + // 获取触发按钮的位置 + const btnRect = triggerElement.getBoundingClientRect(); + + // 创建listbox容器 + const listbox = document.createElement('div'); + listbox.className = 'pa-multi-listbox'; + + // 创建搜索框容器 + const searchContainer = document.createElement('div'); + searchContainer.className = 'pa-multi-listbox-search'; + + const searchWrapper = document.createElement('div'); + searchWrapper.className = 'pa-multi-listbox-search-wrapper'; + + const searchInput = document.createElement('input'); + searchInput.type = 'text'; + searchInput.placeholder = placeholder; + searchInput.className = 'pa-multi-listbox-search-input'; + + const searchIcon = document.createElement('i'); + searchIcon.className = 'pi pi-search pa-multi-listbox-search-icon'; + + searchWrapper.appendChild(searchInput); + searchWrapper.appendChild(searchIcon); + searchContainer.appendChild(searchWrapper); + listbox.appendChild(searchContainer); + + // 创建列表容器 + const listContainer = document.createElement('div'); + listContainer.className = 'pa-multi-listbox-content'; + listbox.appendChild(listContainer); + + // 创建底部操作栏 + const footer = document.createElement('div'); + footer.className = 'pa-multi-listbox-footer'; + + const countLabel = document.createElement('span'); + countLabel.className = 'pa-multi-listbox-count'; + countLabel.textContent = '已选 0 项'; + + const actions = document.createElement('div'); + actions.className = 'pa-multi-listbox-actions'; + + const cancelBtn = document.createElement('button'); + cancelBtn.className = 'p-button p-component p-button-secondary p-button-sm'; + cancelBtn.innerHTML = '取消'; + + const confirmBtn = document.createElement('button'); + confirmBtn.className = 'p-button p-component p-button-sm'; + confirmBtn.innerHTML = '确定'; + confirmBtn.disabled = true; + + actions.appendChild(cancelBtn); + actions.appendChild(confirmBtn); + + footer.appendChild(countLabel); + footer.appendChild(actions); + listbox.appendChild(footer); + + // 数据存储 + let allItems = []; + let selectedItems = new Set(); + + // 显示loading状态 + const showLoading = () => { + listContainer.innerHTML = ` +
    +
    +
    加载中...
    +
    + `; + }; + + // 显示错误信息 + const showError = (message) => { + listContainer.innerHTML = ` +
    +
    ⚠️
    +
    加载失败
    +
    ${message}
    +
    你仍然可以在搜索框中手动输入模型名称
    +
    + `; + // 不禁用搜索框,允许用户手动输入模型名称 + }; + + // 更新选中计数 + const updateCount = () => { + countLabel.textContent = `已选 ${selectedItems.size} 项`; + // 如果有选中项或搜索框有内容,则启用确认按钮 + const hasInput = searchInput.value.trim().length > 0; + confirmBtn.disabled = selectedItems.size === 0 && !hasInput; + }; + + // 渲染列表 + const renderList = (searchTerm = '') => { + listContainer.innerHTML = ''; + + const filteredItems = searchTerm + ? allItems.filter(item => item.toLowerCase().includes(searchTerm.toLowerCase())) + : allItems; + + if (filteredItems.length === 0) { + const emptyHint = document.createElement('div'); + emptyHint.className = 'pa-multi-listbox-empty'; + emptyHint.textContent = searchTerm ? '未找到匹配项' : '暂无可用项'; + listContainer.appendChild(emptyHint); + return; + } + + filteredItems.forEach(itemName => { + const item = document.createElement('div'); + item.className = 'pa-multi-listbox-item'; + if (selectedItems.has(itemName)) { + item.classList.add('selected'); + } + + const checkbox = document.createElement('div'); + checkbox.className = 'pa-multi-listbox-checkbox'; + + const label = document.createElement('span'); + label.textContent = itemName; + + item.appendChild(checkbox); + item.appendChild(label); + + // 点击切换选中状态 + item.addEventListener('click', () => { + if (selectedItems.has(itemName)) { + selectedItems.delete(itemName); + item.classList.remove('selected'); + } else { + selectedItems.add(itemName); + item.classList.add('selected'); + } + updateCount(); + }); + + listContainer.appendChild(item); + }); + }; + + // 初始化数据 + const initialize = async () => { + showLoading(); + try { + const items = await fetchItems(); + if (!items || !Array.isArray(items)) { + throw new Error('数据格式错误'); + } + allItems = items; + renderList(); + } catch (error) { + showError(error.message || '未知错误'); + } + }; + + // 搜索框事件 + searchInput.addEventListener('input', (e) => { + renderList(e.target.value); + updateCount(); // 输入时更新按钮状态 + }); + + // 取消按钮 + cancelBtn.addEventListener('click', () => { + if (onCancel) { + onCancel(); + } + close(); + }); + + // 确定按钮 + confirmBtn.addEventListener('click', () => { + const selected = Array.from(selectedItems); + const searchValue = searchInput.value; // 获取搜索框的值 + onConfirm(selected, searchValue); // 将搜索框的值作为第二个参数传递 + close(); + }); + + // 创建透明背景层 + const overlay = document.createElement('div'); + overlay.style.cssText = ` + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: var(--settings-multi-listbox-z-index); + `; + + overlay.appendChild(listbox); + document.body.appendChild(overlay); + + // 计算并设置位置(右对齐按钮,自适应上下) + const viewportHeight = window.innerHeight; + const viewportWidth = window.innerWidth; + const listboxHeight = 400; // max-height + const spaceBelow = viewportHeight - btnRect.bottom; + const spaceAbove = btnRect.top; + + if (spaceBelow >= listboxHeight || spaceBelow > spaceAbove) { + // 显示在按钮下方 + listbox.style.top = `${btnRect.bottom + 4}px`; + } else { + // 显示在按钮上方 + listbox.style.bottom = `${viewportHeight - btnRect.top + 4}px`; + } + + // 右对齐按钮 + listbox.style.right = `${viewportWidth - btnRect.right}px`; + + // 关闭函数 + const close = () => { + if (overlay.parentNode) { + document.body.removeChild(overlay); + } + }; + + // 点击背景关闭 + overlay.addEventListener('click', (e) => { + if (e.target === overlay) { + if (onCancel) { + onCancel(); + } + close(); + } + }); + + // 初始化数据 + initialize(); + + // 聚焦搜索框 + setTimeout(() => searchInput.focus(), 100); +} + +/** + * 创建 SplitButton 组件 (参考 PrimeVue SplitButton) + * @param {Object} options 配置选项 + * @param {string} options.label 按钮文本 + * @param {string} options.icon 按钮图标 + * @param {string} options.className 自定义类名 + * @param {Function} options.onClick 主按钮点击回调 + * @param {Array} options.items 下拉菜单项 [{label, icon, command, separator, checked, disabled}] + * @returns {Object} { container, updateLabel, updateIcon, updateMenu, setMainButtonDisabled, setMenuButtonDisabled } + */ +export function createSplitButton(options) { + const { label = '', icon = '', className = '', onClick = () => { }, items = [] } = options; + + const container = document.createElement('div'); + container.className = `p-splitbutton p-component${className ? ' ' + className : ''}`; + container.style.display = 'inline-flex'; + container.style.position = 'relative'; + + // 1. 主按钮 + const mainKey = document.createElement('button'); + mainKey.className = 'p-button p-component p-button-text p-button-sm'; + mainKey.type = 'button'; + mainKey.style.borderTopRightRadius = '0'; + mainKey.style.borderBottomRightRadius = '0'; + mainKey.style.borderRight = '0 none'; + + // 内容容器 + const mainContent = document.createElement('span'); + mainContent.className = 'p-button-label p-c'; + mainContent.style.display = 'flex'; + mainContent.style.alignItems = 'center'; + mainContent.style.gap = '0.5rem'; + + const renderMainButton = (text, iconClass, spinning = false) => { + mainContent.innerHTML = ''; + if (iconClass) { + const i = document.createElement('span'); + i.className = `p-button-icon p-button-icon-left ${iconClass} ${spinning ? 'spinning' : ''}`; + mainContent.appendChild(i); + } + const span = document.createElement('span'); + span.className = 'p-button-label'; + span.textContent = text; + mainContent.appendChild(span); + + // 更新 stored data for subsequent updates via updateIcon + mainKey.dataset.label = text; + mainKey.dataset.icon = iconClass || ''; + }; + + renderMainButton(label, icon); + mainKey.appendChild(mainContent); + mainKey.addEventListener('click', (e) => { + e.stopPropagation(); + onClick(e); + }); + + // 2. 下拉触发按钮 + const menuBtn = document.createElement('button'); + menuBtn.className = 'p-button p-component p-button-icon-only p-button-text p-button-sm p-splitbutton-menubutton'; + menuBtn.type = 'button'; + menuBtn.style.borderTopLeftRadius = '0'; + menuBtn.style.borderBottomLeftRadius = '0'; + menuBtn.innerHTML = ''; + + container.appendChild(mainKey); + container.appendChild(menuBtn); + + // 3. 下拉菜单 + const menuPanel = document.createElement('div'); + menuPanel.className = 'pa-context-menu pa-split-button-menu'; + menuPanel.style.display = 'none'; + menuPanel.style.position = 'fixed'; + menuPanel.style.minWidth = '12rem'; + // z-index 由 CSS 的 --settings-context-menu-z-index 变量管理 + + const menuList = document.createElement('ul'); + menuList.className = 'pa-context-menu-list'; + menuList.setAttribute('role', 'menu'); + menuPanel.appendChild(menuList); + document.body.appendChild(menuPanel); + + let currentItems = items; + let isOpen = false; + + const renderMenu = () => { + menuList.innerHTML = ''; + currentItems.forEach(item => { + if (item.separator) { + const sep = document.createElement('li'); + sep.className = 'pa-context-menu-separator'; + sep.setAttribute('role', 'separator'); + menuList.appendChild(sep); + return; + } + + const li = document.createElement('li'); + li.className = 'pa-context-menu-item'; + li.setAttribute('role', 'menuitem'); + if (item.disabled) li.classList.add('disabled'); + + const itemContent = document.createElement('div'); + itemContent.className = 'pa-context-menu-item-content'; + + // Icon logic + let iconHtml = ''; + if (typeof item.checked === 'boolean') { + const checkIcon = item.checked ? 'pi pi-check' : ''; + iconHtml = ``; + } else if (item.icon) { + iconHtml = ``; + } else { + iconHtml = ''; + } + + itemContent.innerHTML = ` + ${iconHtml} + ${item.label} + `; + + li.appendChild(itemContent); + + if (!item.disabled) { + li.addEventListener('click', (e) => { + e.stopPropagation(); + if (item.command) { + item.command({ originalEvent: e, item }); + } + closeMenu(); + }); + } + + menuList.appendChild(li); + }); + }; + + const updatePosition = () => { + if (!isOpen) return; + const rect = menuBtn.getBoundingClientRect(); + const containerRect = container.getBoundingClientRect(); + + let left = containerRect.left; + let top = containerRect.bottom + 2; + + // Edge detection could be added here similar to createTooltip + + menuPanel.style.top = `${top}px`; + menuPanel.style.left = `${left}px`; + // menuPanel.style.minWidth = `${containerRect.width}px`; + }; + + const closeMenu = () => { + if (!isOpen) return; + isOpen = false; + menuPanel.classList.remove('pa-context-menu-show'); + menuPanel.classList.add('pa-context-menu-hide'); + + setTimeout(() => { + menuPanel.style.display = 'none'; + menuPanel.classList.remove('pa-context-menu-hide'); + }, 150); + + window.removeEventListener('resize', updatePosition); + window.removeEventListener('scroll', updatePosition, true); + document.removeEventListener('click', outsideClickListener); + }; + + const openMenu = () => { + if (isOpen) { + closeMenu(); + return; + } + isOpen = true; + renderMenu(); // Render fresh on open to catch state changes + menuPanel.style.display = 'block'; + menuPanel.classList.add('pa-context-menu-show'); + updatePosition(); + + window.addEventListener('resize', updatePosition); + window.addEventListener('scroll', updatePosition, true); + setTimeout(() => document.addEventListener('click', outsideClickListener), 0); + }; + + const outsideClickListener = (e) => { + if (!menuPanel.contains(e.target) && !menuBtn.contains(e.target) && !container.contains(e.target)) { + closeMenu(); + } + }; + + menuBtn.addEventListener('click', (e) => { + e.stopPropagation(); + openMenu(); + }); + + return { + container, + updateLabel: (text) => renderMainButton(text, mainKey.dataset.icon), + updateIcon: (newIcon, spinning) => renderMainButton(mainKey.dataset.label, newIcon, spinning), + updateMainButton: (text, newIcon, spinning) => renderMainButton(text, newIcon, spinning), + updateMenu: (newItems) => { + currentItems = newItems; + if (isOpen) renderMenu(); + }, + setMainButtonDisabled: (disabled) => { mainKey.disabled = disabled; }, + setMenuButtonDisabled: (disabled) => { menuBtn.disabled = disabled; }, + destroy: () => { + // Cleanup if needed + if (menuPanel && menuPanel.parentNode) menuPanel.parentNode.removeChild(menuPanel); + // Remove listeners... (handled by closure mostly, but good practice if needed) + } + }; +} + +/** + * 创建 SelectButton 组件 (参考 PrimeVue SelectButton) + * 支持单选、多选、切换三种模式 + * @param {string} label 标签文本 + * @param {Array} options 选项数组,每个选项包含 { value: string, label: string, icon?: string, disabled?: boolean } + * @param {string|Array} initialValue 初始值,单选为字符串,多选为数组 + * @param {Object} config 配置选项 + * @param {string} config.mode 模式:'single'(单选), 'multiple'(多选), 'toggle'(切换,相当于开关) + * @param {string} config.size 尺寸:'small', 'normal', 'large' + * @param {boolean} config.allowEmpty 是否允许全部不选(仅多选模式有效) + * @param {Function} config.onChange 值变化回调函数 + * @returns {Object} { group: HTMLElement, getValue: Function, setValue: Function } + */ +export function createSelectButtonGroup(label, options, initialValue, config = {}) { + const { + mode = 'multiple', + size = 'normal', + allowEmpty = true, + onChange = null + } = config; + + // ---创建容器--- + const group = document.createElement('div'); + group.className = 'pa-form-group pa-select-button-group'; + + // 标签 + if (label) { + const labelEl = document.createElement('label'); + labelEl.className = 'pa-form-label'; + labelEl.textContent = label; + group.appendChild(labelEl); + } + + // 按钮容器 + const buttonContainer = document.createElement('div'); + buttonContainer.className = `pa-select-button pa-select-button-${size}`; + + // 当前选中值 + let currentValue = mode === 'multiple' + ? (Array.isArray(initialValue) ? [...initialValue] : []) + : initialValue; + + // 渲染选项按钮 + const renderButtons = () => { + buttonContainer.innerHTML = ''; + + options.forEach((option, index) => { + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'pa-select-button-item'; + + // 判断是否选中 + const isSelected = mode === 'multiple' + ? currentValue.includes(option.value) + : currentValue === option.value; + + if (isSelected) { + button.classList.add('pa-select-button-item-selected'); + } + + if (option.disabled) { + button.classList.add('pa-select-button-item-disabled'); + button.disabled = true; + } + + // 图标 + if (option.icon) { + const icon = document.createElement('span'); + icon.className = `pa-select-button-icon pi ${option.icon}`; + button.appendChild(icon); + } + + // 标签 + if (option.label) { + const labelSpan = document.createElement('span'); + labelSpan.className = 'pa-select-button-label'; + labelSpan.textContent = option.label; + button.appendChild(labelSpan); + } + + // 点击事件 + if (!option.disabled) { + button.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + handleClick(option.value); + }); + } + + buttonContainer.appendChild(button); + }); + }; + + // 处理点击事件 + const handleClick = (value) => { + const prevValue = mode === 'multiple' ? [...currentValue] : currentValue; + + if (mode === 'multiple') { + // 多选模式:切换选中状态 + const index = currentValue.indexOf(value); + if (index === -1) { + currentValue.push(value); + } else { + // 检查是否允许取消选择 + if (allowEmpty || currentValue.length > 1) { + currentValue.splice(index, 1); + } + } + } else if (mode === 'toggle') { + // 切换模式:点击切换开关状态 + currentValue = currentValue === value ? null : value; + } else { + // 单选模式:直接选中 + currentValue = value; + } + + // 重新渲染按钮 + renderButtons(); + + // 触发回调 + if (onChange) { + onChange(currentValue, prevValue); + } + }; + + // 初始渲染 + renderButtons(); + group.appendChild(buttonContainer); + + // ---返回组件API--- + return { + group, + getValue: () => mode === 'multiple' ? [...currentValue] : currentValue, + setValue: (newValue) => { + currentValue = mode === 'multiple' + ? (Array.isArray(newValue) ? [...newValue] : []) + : newValue; + renderButtons(); + }, + setOptions: (newOptions) => { + options = newOptions; + renderButtons(); + } + }; +} diff --git a/js/node/captionFrame.js b/js/node/captionFrame.js new file mode 100644 index 0000000..95fd4e2 --- /dev/null +++ b/js/node/captionFrame.js @@ -0,0 +1,1468 @@ +/** + * 视频抽帧工具节点扩展 + * 提供视频手动抽帧、预览和帧索引生成功能 + */ + +import { app } from "../../../../scripts/app.js"; +import { api } from "../../../../scripts/api.js"; +import { createSettingsDialog, createInputGroup, createTooltip, createConfirmPopup } from "../modules/uiComponents.js"; +import { APIService } from "../services/api.js"; + +// 引入专用样式文件(保留用于视频播放器特定的布局样式) +const link = document.createElement("link"); +link.rel = "stylesheet"; +link.type = "text/css"; +link.href = new URL("../css/captionFrame.css", import.meta.url).href; +document.head.appendChild(link); + +// 弹窗状态跟踪(防止重复打开) +let isDialogOpen = false; + +app.registerExtension({ + name: "ComfyUI.PromptAssistant.CaptionFrame", + + /** + * 节点定义注册前的钩子 + * @param {Object} nodeType 节点类型定义 + * @param {Object} nodeData 节点数据 + */ + async beforeRegisterNodeDef(nodeType, nodeData) { + if (nodeData.name === "VideoCaptionNode") { + const onNodeCreated = nodeType.prototype.onNodeCreated; + + // --- 重写节点创建逻辑 --- + nodeType.prototype.onNodeCreated = function () { + if (onNodeCreated) { + onNodeCreated.apply(this, arguments); + } + + const node = this; + + // 添加"选取反推帧"按钮 + // 初始状态下按钮可能隐藏,取决于"抽帧策略" + const btnWidget = this.addWidget("button", "🎬选取反推帧", null, () => { + showFrameExtractionModal(node); + }); + + // 保存原始 computeSize 函数,用于动态隐藏/显示时的尺寸计算 + const origComputeSize = btnWidget.computeSize?.bind(btnWidget); + + // 确保按钮始终显示 + btnWidget.type = "button"; + btnWidget.computeSize = origComputeSize || (() => [0, 26]); + btnWidget.hidden = false; + + // 仅重绘画布,不改变节点大小 + app.graph.setDirtyCanvas(true, false); + }; + } + } +}); + +/** + * 显示视频抽帧工具弹窗 + * @param {Object} node 当前节点实例 + */ +async function showFrameExtractionModal(node) { + // 防止重复打开 + if (isDialogOpen) { + return; + } + isDialogOpen = true; + + // 1. 获取连接的视频信息 + const videoInfo = await findConnectedVideo(node); + + if (!videoInfo) { + isDialogOpen = false; + alert("未检测到有效的视频输入连接。请确保节点已连接到 Load Video 节点。"); + return; + } + + if (videoInfo.error) { + isDialogOpen = false; + alert(videoInfo.error); + return; + } + + // 2. 获取视频元数据(FPS、时长、总帧数) + let initialFps = 30; + let originalDuration = 0; + let originalTotalFrames = 0; + try { + const response = await api.fetchApi(APIService.getDynamicApiBase() + '/video/info', { + method: "POST", + body: JSON.stringify({ + filename: videoInfo.filename, + type: videoInfo.type + }) + }); + const data = await response.json(); + if (data.success) { + initialFps = data.fps || 30; + originalDuration = data.duration || 0; + originalTotalFrames = data.total_frames || 0; + + // 如果 total_frames 为 0(某些视频格式无法直接读取帧数),使用 fps * duration 计算 + if (originalTotalFrames === 0 && originalDuration > 0 && initialFps > 0) { + originalTotalFrames = Math.floor(initialFps * originalDuration); + } + } + } catch (e) { + console.warn("[PromptAssistant] 获取视频信息失败, 将使用默认值:", e); + } + + // 3. 计算实际使用的FPS(考虑force_rate参数) + // force_rate > 0 时使用强制FPS,否则使用原始FPS + const actualFps = (videoInfo.forceRate && videoInfo.forceRate > 0) ? videoInfo.forceRate : initialFps; + + // 4. 计算抽帧后的实际总帧数和持续时间 + // 如果使用了 force_rate,实际帧数 = 原始总帧数 * (force_rate / original_fps) + let actualTotalFrames = originalTotalFrames; + let actualDuration = originalDuration; + + // 有效性检查:确保帧数是有效的正整数 + if (!Number.isFinite(originalTotalFrames) || originalTotalFrames <= 0 || originalTotalFrames > 1e9) { + // 如果帧数无效,尝试使用 duration * fps 计算 + if (originalDuration > 0 && initialFps > 0) { + originalTotalFrames = Math.floor(originalDuration * initialFps); + actualTotalFrames = originalTotalFrames; + } else { + // 最后的兜底值 + originalTotalFrames = 100; + actualTotalFrames = 100; + } + console.warn('[PromptAssistant-CaptionFrame] 帧数无效,使用计算值:', originalTotalFrames); + } + + if (videoInfo.forceRate && videoInfo.forceRate > 0 && initialFps > 0) { + actualTotalFrames = Math.floor(originalTotalFrames * (videoInfo.forceRate / initialFps)); + actualDuration = actualTotalFrames / actualFps; + } + + // 确保至少有1帧 + if (actualTotalFrames <= 0) { + actualTotalFrames = 1; + } + + console.log('[PromptAssistant-CaptionFrame] 视频元数据:', { + originalFps: initialFps, + originalDuration, + originalTotalFrames, + forceRate: videoInfo.forceRate, + actualFps, + actualTotalFrames, + actualDuration + }); + + // 状态容器,用于在 renderContent 和 onSave 之间共享数据 + const state = { + fps: actualFps, + originalFps: initialFps, // 保存原始FPS用于计算 + forceRate: videoInfo.forceRate || 0, // 保存 force_rate 用于帧提取 + totalFrames: actualTotalFrames, // 实际总帧数 + duration: actualDuration, // 实际持续时间 + selectedFrames: new Set(), + rangeStart: null, + // 帧索引驱动相关状态 + currentFrameIndex: 0, // 当前帧索引 + isLoading: false, // 帧加载中标志 + frameCache: new Map(), // 帧缓存 (frameIndex -> base64) + filename: videoInfo.filename, // 视频文件名 + widgets: { + // 使用后端定义的英文 widget 名称 + manualIndex: node.widgets.find(w => w.name === "manual_indices"), + strategy: node.widgets.find(w => w.name === "sampling_mode") + } + }; + + // 初始化已选帧状态 + if (state.widgets.manualIndex?.value) { + state.widgets.manualIndex.value.split(',').forEach(p => { + p = p.trim(); + if (!p) return; + if (p.includes('-')) state.selectedFrames.add(p); + else { + const n = parseInt(p); + if (!isNaN(n)) state.selectedFrames.add(n); + } + }); + } + + // 3. 创建通用设置弹窗 + createSettingsDialog({ + title: '🎬 视频手动抽帧工具', + saveButtonText: '确认应用', + cancelButtonText: '取消', + saveButtonIcon: 'pi-check', + disableBackdropAndCloseOnClickOutside: true, // 不需要模态遮罩层,允许交互 + dialogClassName: 'caption-frame-dialog', // 自定义类名 + + // 取消回调(跳过二次确认,直接关闭) + onCancel: () => { }, + + // 渲染弹窗内容 + renderContent: (contentContainer, header) => { + // 注意:具体的 flex 布局和高度控制现在主要由 CSS 处理 (.caption-frame-dialog .p-dialog-content) + + // 构建界面 + renderVideoInterface(contentContainer, state, videoInfo, header); + }, + + // 保存回调 + onSave: () => { + const sorted = Array.from(state.selectedFrames).sort((a, b) => { + const getStart = v => typeof v === 'string' ? parseInt(v.split('-')[0]) : v; + return getStart(a) - getStart(b); + }); + + if (state.widgets.manualIndex) { + const newValue = sorted.join(","); + state.widgets.manualIndex.value = newValue; + + // 触发 widget callback 确保值同步到节点 + if (state.widgets.manualIndex.callback) { + state.widgets.manualIndex.callback(newValue, app.graph, node, state.widgets.manualIndex); + } + + // [Debug] 输出保存的值 + console.log('[PromptAssistant-CaptionFrame] 保存帧索引:', newValue); + + // 自动切换策略为手动 + if (state.widgets.strategy) { + state.widgets.strategy.value = "Manual (Indices)"; + + // 同样触发策略 widget 的 callback + if (state.widgets.strategy.callback) { + state.widgets.strategy.callback("Manual (Indices)", app.graph, node, state.widgets.strategy); + } + } + + // 标记节点需要重新执行 + node.setDirtyCanvas(true, true); + app.graph.setDirtyCanvas(true, true); + } + }, + + // 关闭回调:清理资源 + onClose: () => { + isDialogOpen = false; + if (state.frameCache) { + state.frameCache.clear(); + } + } + }); +} + +/** + * 渲染视频操作界面 + * @param {HTMLElement} container 容器元素 + * @param {Object} state 状态对象 + * @param {Object} videoInfo 视频信息对象 + */ +/** + * 渲染视频操作界面 + * @param {HTMLElement} container 容器元素 + * @param {Object} state 状态对象 + * @param {Object} videoInfo 视频信息对象 + * @param {HTMLElement} [headerElement] 弹窗头部元素(可选) + */ +function renderVideoInterface(container, state, videoInfo, headerElement) { + // --- 显示区域(混合模式:img 用于精确定位,video 用于流畅播放)--- + const frameContainer = document.createElement("div"); + frameContainer.className = "video-container frame-display-container"; + + // 创建帧图片元素(用于精确定位模式) + const frameImg = document.createElement("img"); + frameImg.id = "caption-frame-display"; + frameImg.className = "frame-display-img"; + frameImg.alt = "视频帧预览"; + + // 创建视频元素(用于流畅播放模式,默认隐藏) + const videoElement = document.createElement("video"); + videoElement.className = "frame-video-player"; + // 优化缓冲设置 + videoElement.preload = "auto"; // 尽可能多地预加载视频 + + // 设置视频源 + if (videoInfo.fromLoadNode && state.fps !== state.originalFps) { + const params = { + filename: videoInfo.filename, + type: videoInfo.type || "input", + force_rate: state.fps, + skip_first_frames: 0, + select_every_nth: 1, + frame_load_cap: 0, + timestamp: Date.now() + }; + videoElement.src = api.apiURL('/vhs/viewvideo?' + new URLSearchParams(params)); + } else { + videoElement.src = videoInfo.url; + } + + // 创建加载指示器 + const loadingIndicator = document.createElement("div"); + loadingIndicator.className = "frame-loading-indicator"; + loadingIndicator.innerHTML = ''; + loadingIndicator.style.display = "none"; + + // 缓冲状态事件监听 - 拖动到未缓冲位置时显示加载指示器 + videoElement.addEventListener("waiting", () => { + loadingIndicator.style.display = "flex"; + }); + videoElement.addEventListener("canplay", () => { + loadingIndicator.style.display = "none"; + }); + videoElement.addEventListener("canplaythrough", () => { + loadingIndicator.style.display = "none"; + }); + + frameContainer.appendChild(frameImg); + frameContainer.appendChild(videoElement); + frameContainer.appendChild(loadingIndicator); + + // 初始状态:显示视频,隐藏图片(video 预览模式) + frameImg.style.display = "none"; + videoElement.style.display = "block"; + + // 切换到图片模式 + const switchToImageMode = async (frameIndex) => { + videoElement.style.display = "none"; + frameImg.style.display = "block"; + await loadFrame(frameIndex); + }; + + // 切换到视频播放模式 + const switchToVideoMode = (startFromFrame) => { + // 同步视频进度到当前帧位置 + const targetTime = startFromFrame / state.fps; + videoElement.currentTime = targetTime; + frameImg.style.display = "none"; + videoElement.style.display = "block"; + videoElement.play(); + }; + + // --- 帧加载函数 --- + const loadFrame = async (frameIndex) => { + if (state.isLoading) return; + + // 边界检查 + frameIndex = Math.max(0, Math.min(state.totalFrames - 1, frameIndex)); + state.currentFrameIndex = frameIndex; + + // 检查缓存 + if (state.frameCache.has(frameIndex)) { + frameImg.src = `data:image/jpeg;base64,${state.frameCache.get(frameIndex)}`; + updateDisplay(); + return; + } + + // 显示加载指示器 + state.isLoading = true; + loadingIndicator.style.display = "flex"; + + try { + const response = await api.fetchApi(APIService.getDynamicApiBase() + '/video/frame', { + method: "POST", + body: JSON.stringify({ + filename: state.filename, + frame_index: frameIndex, + force_rate: state.forceRate + }) + }); + const data = await response.json(); + + if (data.success && data.data) { + // 缓存帧数据 + state.frameCache.set(frameIndex, data.data); + frameImg.src = `data:image/jpeg;base64,${data.data}`; + + // 预加载相邻帧(提升体验) + preloadAdjacentFrames(frameIndex); + } else { + console.error("[PromptAssistant-CaptionFrame] 帧加载失败:", data.error); + } + } catch (e) { + console.error("[PromptAssistant-CaptionFrame] 帧加载异常:", e); + } finally { + state.isLoading = false; + loadingIndicator.style.display = "none"; + updateDisplay(); + } + }; + + // --- 预加载相邻帧(提升体验)--- + const preloadAdjacentFrames = async (centerIndex) => { + const preloadRange = 2; // 预加载前后2帧 + for (let offset = 1; offset <= preloadRange; offset++) { + const indices = [centerIndex - offset, centerIndex + offset]; + for (const idx of indices) { + if (idx >= 0 && idx < state.totalFrames && !state.frameCache.has(idx)) { + // 异步预加载,不阻塞 + api.fetchApi(APIService.getDynamicApiBase() + '/video/frame', { + method: "POST", + body: JSON.stringify({ + filename: state.filename, + frame_index: idx, + force_rate: state.forceRate + }) + }).then(res => res.json()).then(data => { + if (data.success && data.data) { + state.frameCache.set(idx, data.data); + } + }).catch(() => { }); // 静默忽略预加载失败 + } + } + } + }; + + // --- 信息移动到标题栏 --- + let headerTimeSpan = null; + let headerFrameSpan = null; + + if (headerElement) { + // 创建标题栏信息容器(样式在 captionFrame.css 中定义) + const infoContainer = document.createElement("div"); + infoContainer.className = "video-header-info"; + + // 构建两行信息HTML + infoContainer.innerHTML = ` +
    00:00.00/00:00.00
    +
    0/${state.totalFrames} ${state.fps}fps
    + `; + + // 插入到关闭按钮之前 + const icons = headerElement.querySelector('.p-dialog-header-icons'); + if (icons) { + headerElement.insertBefore(infoContainer, icons); + } else { + headerElement.appendChild(infoContainer); + } + + headerTimeSpan = infoContainer.querySelector("#header-time"); + headerFrameSpan = infoContainer.querySelector("#header-frame"); + } + + container.appendChild(frameContainer); + + // --- 统一时间轴组件(合并进度条滑块和标记轨道)--- + const timelineContainer = document.createElement("div"); + timelineContainer.className = "unified-timeline-container"; + + // 标记轨道层(底层,用于显示帧标记) + const markerTrack = document.createElement("div"); + markerTrack.className = "frame-marker-track"; + timelineContainer.appendChild(markerTrack); + + // 自定义滑块层(顶层) + const sliderThumb = document.createElement("div"); + sliderThumb.className = "timeline-slider-thumb"; + timelineContainer.appendChild(sliderThumb); + + container.appendChild(timelineContainer); + + // 拖动时切换到 video 预览模式(流畅),松开后精确加载帧 + let isDraggingSlider = false; + + // 统一的帧索引到百分比转换函数(确保滑块和标记使用相同的计算方式) + const frameToPercent = (frameIndex) => { + const maxFrame = state.totalFrames - 1; + if (maxFrame <= 0) return 0; + return (frameIndex / maxFrame) * 100; + }; + + // 更新滑块位置的辅助函数 + const updateSliderPosition = (frameIndex) => { + sliderThumb.style.left = `${frameToPercent(frameIndex)}%`; + }; + + // 根据鼠标位置计算帧索引 + const getFrameFromMousePosition = (clientX) => { + const rect = timelineContainer.getBoundingClientRect(); + const offsetX = clientX - rect.left; + const percent = Math.max(0, Math.min(1, offsetX / rect.width)); + return Math.round(percent * (state.totalFrames - 1)); + }; + + // 滑块拖动事件 + sliderThumb.addEventListener("mousedown", (e) => { + e.preventDefault(); + isDraggingSlider = true; + sliderThumb.classList.add("dragging"); + // 切换到 video 预览模式 + frameImg.style.display = "none"; + videoElement.style.display = "block"; + loadingIndicator.style.display = "none"; + + const onMouseMove = (moveEvent) => { + if (!isDraggingSlider) return; + const targetFrame = getFrameFromMousePosition(moveEvent.clientX); + state.currentFrameIndex = targetFrame; + videoElement.currentTime = targetFrame / state.fps; + updateSliderPosition(targetFrame); + updateDisplay(); + }; + + const onMouseUp = () => { + isDraggingSlider = false; + sliderThumb.classList.remove("dragging"); + document.removeEventListener("mousemove", onMouseMove); + document.removeEventListener("mouseup", onMouseUp); + }; + + document.addEventListener("mousemove", onMouseMove); + document.addEventListener("mouseup", onMouseUp); + }); + + // 时间轴点击跳转 + timelineContainer.addEventListener("click", (e) => { + // 如果点击的是滑块本身或标记元素,不处理 + if (e.target === sliderThumb || e.target.closest('.frame-marker, .frame-marker-range, .frame-marker-temp')) { + return; + } + const targetFrame = getFrameFromMousePosition(e.clientX); + state.currentFrameIndex = targetFrame; + videoElement.currentTime = targetFrame / state.fps; + updateSliderPosition(targetFrame); + updateDisplay(); + }); + + // --- 控制区域 --- + const controlsContainer = document.createElement("div"); + controlsContainer.className = "controls-container"; + container.appendChild(controlsContainer); + + // 辅助函数:创建按钮 + // 返回 { btn: HTMLElement, iconSpan: HTMLElement } + const createBtn = (text, iconClass, onClick, type = 'secondary', iconPos = 'left') => { + const btn = document.createElement("button"); + // 复用 PrimeVue 按钮样式 + btn.className = `p-button p-component p-button-${type} p-button-sm`; + + // 仅图标按钮处理 + const isIconOnly = !text && iconClass; + if (isIconOnly) { + btn.classList.add("p-button-icon-only"); + } + + let iconSpan = null; + if (iconClass) { + iconSpan = document.createElement("span"); + // 仅图标时不需要 left/right 定位类 + iconSpan.className = isIconOnly + ? `p-button-icon pi ${iconClass}` + : `p-button-icon-${iconPos} pi ${iconClass}`; + btn.appendChild(iconSpan); + } + + // 有文字时才添加 label + if (text) { + const labelSpan = document.createElement("span"); + labelSpan.className = "p-button-label"; + labelSpan.textContent = text; + + if (iconPos === 'right' && iconSpan) { + // 图标在右时,需要先移除再重新排列 + btn.removeChild(iconSpan); + btn.appendChild(labelSpan); + btn.appendChild(iconSpan); + } else { + btn.appendChild(labelSpan); + } + } + + btn.onclick = onClick; + btn.style.marginRight = "5px"; + return { btn, iconSpan }; + }; + + // 2. 播放控制按钮组 (左侧) + const playbackControls = document.createElement("div"); + playbackControls.className = "playback-controls"; + playbackControls.style.marginTop = "0"; + + // 长按连续跳帧辅助函数 + // @param {HTMLElement} btn 按钮元素 + // @param {Function} action 跳帧操作函数 + const setupLongPressFrame = (btn, action) => { + let pressTimer = null; // 长按延迟定时器 + let intervalTimer = null; // 连续触发定时器 + + const startPress = () => { + // 立即执行一次跳帧 + action(); + + // 设置延迟后开始连续跳帧 + pressTimer = setTimeout(() => { + intervalTimer = setInterval(() => { + action(); + }, 50); // 每200ms跳一帧 + }, 500); // 500ms后开始连续跳帧 + }; + + const endPress = () => { + if (pressTimer) { + clearTimeout(pressTimer); + pressTimer = null; + } + if (intervalTimer) { + clearInterval(intervalTimer); + intervalTimer = null; + } + }; + + btn.addEventListener('mousedown', startPress); + btn.addEventListener('mouseup', endPress); + btn.addEventListener('mouseleave', endPress); + }; + + // 上一帧按钮(使用 video.currentTime 快速预览) + const prevFrameBtn = createBtn("上一帧", "pi-caret-left", null).btn; + setupLongPressFrame(prevFrameBtn, () => { + stopPlayback(); // 停止播放 + if (state.currentFrameIndex > 0) { + state.currentFrameIndex--; + videoElement.currentTime = state.currentFrameIndex / state.fps; + updateDisplay(); + } + }); + playbackControls.appendChild(prevFrameBtn); + + // --- 播放/暂停功能(使用 video 元素流畅播放)--- + let isPlaying = false; + let animationFrameId = null; // 用于取消动画帧 + + const playBtnObj = createBtn("播放", "pi-play", () => { + if (isPlaying) { + stopPlayback(); + } else { + startPlayback(); + } + }); + playbackControls.appendChild(playBtnObj.btn); + + // 使用 requestAnimationFrame 实现流畅的滑块更新 + const updatePlaybackProgress = () => { + if (!isPlaying) return; + + // 使用 video.currentTime 计算连续的进度(而非离散帧索引) + const currentTime = videoElement.currentTime; + const duration = state.duration; + + // 直接使用时间比例计算滑块位置,实现平滑移动 + if (duration > 0) { + const percent = Math.min(100, (currentTime / duration) * 100); + sliderThumb.style.left = `${percent}%`; + } + + // 同时更新帧索引(用于显示) + const currentFrame = Math.floor(currentTime * state.fps); + state.currentFrameIndex = Math.max(0, Math.min(state.totalFrames - 1, currentFrame)); + + // 更新头部信息显示 + const formatTime = (s) => { + const m = Math.floor(s / 60); + const sec = Math.floor(s % 60); + const ms = Math.floor((s % 1) * 100); + return `${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}.${String(ms).padStart(2, '0')}`; + }; + if (headerTimeSpan) headerTimeSpan.textContent = `${formatTime(currentTime)} / ${formatTime(duration)}`; + if (headerFrameSpan) headerFrameSpan.textContent = `${state.currentFrameIndex} / ${state.totalFrames}`; + + // 继续下一帧动画 + animationFrameId = requestAnimationFrame(updatePlaybackProgress); + }; + + const startPlayback = () => { + if (isPlaying) return; + isPlaying = true; + + // 切换到视频播放模式 + switchToVideoMode(state.currentFrameIndex); + + // 启动流畅动画更新 + animationFrameId = requestAnimationFrame(updatePlaybackProgress); + + // 更新按钮图标 + if (playBtnObj.iconSpan) { + playBtnObj.iconSpan.classList.remove("pi-play"); + playBtnObj.iconSpan.classList.add("pi-pause"); + } + }; + + const stopPlayback = () => { + if (!isPlaying) return; + isPlaying = false; + + // 取消动画帧 + if (animationFrameId) { + cancelAnimationFrame(animationFrameId); + animationFrameId = null; + } + + // 暂停视频(不自动抓取精确帧,继续显示 video) + videoElement.pause(); + + // 同步帧索引并更新显示 + const currentFrameFromVideo = Math.floor(videoElement.currentTime * state.fps); + state.currentFrameIndex = Math.max(0, Math.min(state.totalFrames - 1, currentFrameFromVideo)); + updateDisplay(); + + // 更新按钮图标 + if (playBtnObj.iconSpan) { + playBtnObj.iconSpan.classList.remove("pi-pause"); + playBtnObj.iconSpan.classList.add("pi-play"); + } + }; + + // 视频播放结束时自动停止(保持 video 显示,不切换到 img) + videoElement.addEventListener("ended", () => { + isPlaying = false; + if (animationFrameId) { + cancelAnimationFrame(animationFrameId); + animationFrameId = null; + } + state.currentFrameIndex = state.totalFrames - 1; + updateDisplay(); + if (playBtnObj.iconSpan) { + playBtnObj.iconSpan.classList.remove("pi-pause"); + playBtnObj.iconSpan.classList.add("pi-play"); + } + }); + + // 下一帧按钮(使用 video.currentTime 快速预览) + const nextFrameBtn = createBtn("下一帧", "pi-caret-right", null).btn; + setupLongPressFrame(nextFrameBtn, () => { + stopPlayback(); // 停止播放 + if (state.currentFrameIndex < state.totalFrames - 1) { + state.currentFrameIndex++; + videoElement.currentTime = state.currentFrameIndex / state.fps; + updateDisplay(); + } + }); + playbackControls.appendChild(nextFrameBtn); + + // 静音切换按钮 + const muteBtnObj = createBtn("", "pi-volume-up", () => { + videoElement.muted = !videoElement.muted; + // 切换图标 + if (muteBtnObj.iconSpan) { + if (videoElement.muted) { + muteBtnObj.iconSpan.classList.remove("pi-volume-up"); + muteBtnObj.iconSpan.classList.add("pi-volume-off"); + } else { + muteBtnObj.iconSpan.classList.remove("pi-volume-off"); + muteBtnObj.iconSpan.classList.add("pi-volume-up"); + } + } + }); + playbackControls.appendChild(muteBtnObj.btn); + + // 3. 标记控制按钮组 (右侧) + const markerControls = document.createElement("div"); + markerControls.className = "playback-controls"; + markerControls.style.marginTop = "0"; + markerControls.style.display = "flex"; + markerControls.style.gap = "8px"; + + markerControls.appendChild(createBtn("标记当前帧", "pi-thumbtack", () => { + state.selectedFrames.add(state.currentFrameIndex); + renderTags(); + }, "primary").btn); + + markerControls.appendChild(createBtn("范围", "pi-step-backward-alt", () => { + state.rangeStart = state.currentFrameIndex; + // 在轨道上显示临时闪烁标记 + renderRangeStartMarker(); + }, "success").btn); + + markerControls.appendChild(createBtn("范围", "pi-step-forward-alt", (e) => { + if (state.rangeStart === null) { + // 使用气泡对话框提示用户 + const button = e.target.closest('button'); + createConfirmPopup({ + target: button, + message: '请先设置起点', + icon: 'pi-info-circle', + singleButton: true, + confirmLabel: '确定', + position: 'top', + onConfirm: () => { } + }); + return; + } + const rangeEnd = state.currentFrameIndex; + if (rangeEnd < state.rangeStart) { + // 使用气泡对话框提示用户 + const button = e.target.closest('button'); + createConfirmPopup({ + target: button, + message: '终点必须大于起点', + icon: 'pi-exclamation-triangle', + singleButton: true, + confirmLabel: '确定', + position: 'top', + onConfirm: () => { } + }); + return; + } + state.selectedFrames.add(`${state.rangeStart}-${rangeEnd}`); + state.rangeStart = null; + // 移除临时标记并渲染范围标记 + removeRangeStartMarker(); + renderTags(); + }, "success", "right").btn); + + controlsContainer.appendChild(playbackControls); + controlsContainer.appendChild(markerControls); + + // --- 已选帧列表区域 --- + const listContainer = document.createElement("div"); + listContainer.className = "frame-list-container"; + listContainer.style.marginTop = "0"; + + const listHeader = document.createElement("div"); + listHeader.className = "frame-list-header"; + + // 标签列表容器(左侧) + const tagsList = document.createElement("div"); + tagsList.className = "frame-tags"; + listHeader.appendChild(tagsList); + + // 清空按钮(右侧) + const clearBtnObj = createBtn("", "pi-eraser", () => { + if (confirm("确定清空所有已选帧吗?")) { + state.selectedFrames.clear(); + renderTags(); + } + }, "danger"); + clearBtnObj.btn.style.padding = "6px 8px"; + listHeader.appendChild(clearBtnObj.btn); + + listContainer.appendChild(listHeader); + container.appendChild(listContainer); + + // 添加 tooltip(需要在元素添加到 DOM 后) + createTooltip({ + target: clearBtnObj.btn, + content: "清空已选帧", + position: "top" + }); + + // --- 事件绑定与逻辑 --- + // --- 事件绑定与逻辑 --- + // 获取引用以便更新 (优先使用 header 中的引用) + + + // 更新显示函数(基于帧索引) + const updateDisplay = () => { + const currentFrame = state.currentFrameIndex; + const totalFrames = state.totalFrames; + // 根据帧索引计算当前时间 + const t = currentFrame / state.fps; + const d = state.duration; + + // 格式化时间 MM:SS.ms + const formatTime = (s) => { + const m = Math.floor(s / 60); + const sec = Math.floor(s % 60); + const ms = Math.floor((s % 1) * 100); + return `${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}.${String(ms).padStart(2, '0')}`; + }; + + if (headerTimeSpan) headerTimeSpan.textContent = `${formatTime(t)} / ${formatTime(d)}`; + if (headerFrameSpan) headerFrameSpan.textContent = `${currentFrame} / ${totalFrames}`; + + // 同步自定义滑块位置 + updateSliderPosition(currentFrame); + }; + + // --- 渲染范围起点临时标记 --- + const renderRangeStartMarker = () => { + // 先移除已有的临时标记 + removeRangeStartMarker(); + + const totalFrames = state.totalFrames; + if (totalFrames <= 0 || state.rangeStart === null) return; + + // 使用统一的帧到百分比转换函数 + const leftPercent = frameToPercent(state.rangeStart); + const tempMarker = document.createElement("div"); + tempMarker.className = "frame-marker-temp"; + tempMarker.style.left = `${leftPercent}%`; + tempMarker.dataset.frame = state.rangeStart; + markerTrack.appendChild(tempMarker); + + // 添加 tooltip + createTooltip({ + target: tempMarker, + content: `范围起点: ${state.rangeStart}`, + position: 'top' + }); + }; + + // --- 移除范围起点临时标记 --- + const removeRangeStartMarker = () => { + const tempMarker = markerTrack.querySelector(".frame-marker-temp"); + if (tempMarker) { + tempMarker.remove(); + } + }; + + // --- 清理残留的 tooltip --- + const clearTooltips = () => { + document.querySelectorAll('.pa-tooltip').forEach(t => t.remove()); + }; + + // --- 渲染帧标记轨道 --- + const renderMarkers = () => { + // 清理可能残留的 tooltip(拖动时元素被删除但 tooltip 未销毁) + clearTooltips(); + markerTrack.innerHTML = ""; + const totalFrames = state.totalFrames; + if (totalFrames <= 0) return; + + state.selectedFrames.forEach(item => { + if (typeof item === 'string' && item.includes('-')) { + // 范围标记(使用统一的帧到百分比转换) + const [start, end] = item.split('-').map(Number); + const leftPercent = frameToPercent(start); + const rightPercent = frameToPercent(end); + const widthPercent = rightPercent - leftPercent; + + const rangeEl = document.createElement("div"); + rangeEl.className = "frame-marker-range"; + rangeEl.style.left = `${leftPercent}%`; + rangeEl.style.width = `${Math.max(widthPercent, 0.5)}%`; + rangeEl.dataset.range = item; + rangeEl.dataset.originalItem = item; + + // 创建左边缘手柄 + const leftHandle = document.createElement("div"); + leftHandle.className = "range-handle range-handle-left"; + rangeEl.appendChild(leftHandle); + + // 创建右边缘手柄 + const rightHandle = document.createElement("div"); + rightHandle.className = "range-handle range-handle-right"; + rangeEl.appendChild(rightHandle); + + // 左边缘拖动 + leftHandle.addEventListener('mousedown', (e) => { + e.stopPropagation(); + e.preventDefault(); + clearTooltips(); // 拖动时立即清理 tooltip + + let isDragging = false; + let dragLabel = null; + const originalStart = start; + const originalEnd = end; + + const onMouseMove = (moveEvent) => { + if (!isDragging) { + isDragging = true; + // 创建跟随标签 + dragLabel = document.createElement("div"); + dragLabel.className = "drag-label"; + markerTrack.appendChild(dragLabel); + } + + const trackRect = markerTrack.getBoundingClientRect(); + const offsetX = moveEvent.clientX - trackRect.left; + const newPercent = Math.max(0, Math.min(1, offsetX / trackRect.width)); + const newStart = Math.round(newPercent * (totalFrames - 1)); + + // 限制不能超过结束帧 + const clampedStart = Math.max(0, Math.min(originalEnd - 1, newStart)); + + // 更新位置和宽度(使用统一的百分比转换) + const newLeftPercent = frameToPercent(clampedStart); + const newRightPercent = frameToPercent(originalEnd); + rangeEl.style.left = `${newLeftPercent}%`; + rangeEl.style.width = `${newRightPercent - newLeftPercent}%`; + rangeEl.dataset.range = `${clampedStart}-${originalEnd}`; + + // 更新跟随标签 + if (dragLabel) { + dragLabel.textContent = `帧 ${clampedStart}-${originalEnd}`; + // 标签位置定位在左边缘 + dragLabel.style.left = `${newLeftPercent}%`; + } + + // 同步 video 预览到新的起始帧 + state.currentFrameIndex = clampedStart; + videoElement.currentTime = clampedStart / state.fps; + updateDisplay(); + }; + + const onMouseUp = () => { + // 移除跟随标签 + if (dragLabel) { + dragLabel.remove(); + dragLabel = null; + } + + if (isDragging) { + const newRange = rangeEl.dataset.range; + state.selectedFrames.delete(item); + state.selectedFrames.add(newRange); + renderTags(); + } + document.removeEventListener('mousemove', onMouseMove); + document.removeEventListener('mouseup', onMouseUp); + }; + + document.addEventListener('mousemove', onMouseMove); + document.addEventListener('mouseup', onMouseUp); + }); + + // 右边缘拖动 + rightHandle.addEventListener('mousedown', (e) => { + e.stopPropagation(); + e.preventDefault(); + clearTooltips(); // 拖动时立即清理 tooltip + + let isDragging = false; + let dragLabel = null; + const originalStart = start; + const originalEnd = end; + + const onMouseMove = (moveEvent) => { + if (!isDragging) { + isDragging = true; + // 创建跟随标签 + dragLabel = document.createElement("div"); + dragLabel.className = "drag-label"; + markerTrack.appendChild(dragLabel); + } + + const trackRect = markerTrack.getBoundingClientRect(); + const offsetX = moveEvent.clientX - trackRect.left; + const newPercent = Math.max(0, Math.min(1, offsetX / trackRect.width)); + const newEnd = Math.round(newPercent * (totalFrames - 1)); + + // 限制不能小于起始帧 + const clampedEnd = Math.max(originalStart + 1, Math.min(totalFrames - 1, newEnd)); + + // 更新宽度(使用统一的百分比转换) + const leftPercent = frameToPercent(originalStart); + const rightPercent = frameToPercent(clampedEnd); + rangeEl.style.width = `${rightPercent - leftPercent}%`; + rangeEl.dataset.range = `${originalStart}-${clampedEnd}`; + + // 更新跟随标签 + if (dragLabel) { + dragLabel.textContent = `帧 ${originalStart}-${clampedEnd}`; + // 标签位置定位在右边缘 + dragLabel.style.left = `${rightPercent}%`; + } + + // 同步 video 预览到新的结束帧 + state.currentFrameIndex = clampedEnd; + videoElement.currentTime = clampedEnd / state.fps; + updateDisplay(); + }; + + const onMouseUp = () => { + // 移除跟随标签 + if (dragLabel) { + dragLabel.remove(); + dragLabel = null; + } + + if (isDragging) { + const newRange = rangeEl.dataset.range; + state.selectedFrames.delete(item); + state.selectedFrames.add(newRange); + renderTags(); + } + document.removeEventListener('mousemove', onMouseMove); + document.removeEventListener('mouseup', onMouseUp); + }; + + document.addEventListener('mousemove', onMouseMove); + document.addEventListener('mouseup', onMouseUp); + }); + + // 中间区域拖动(整体平移) + rangeEl.addEventListener('mousedown', (e) => { + // 检查是否点击在手柄上,如果是则不处理 + if (e.target.classList.contains('range-handle')) { + return; + } + + e.stopPropagation(); + e.preventDefault(); + clearTooltips(); // 拖动时立即清理 tooltip + + let isDragging = false; + let dragLabel = null; + const startX = e.clientX; + const originalStart = start; + const originalEnd = end; + const rangeWidth = originalEnd - originalStart; + + const onMouseMove = (moveEvent) => { + if (!isDragging) { + isDragging = true; + // 创建跟随标签 + dragLabel = document.createElement("div"); + dragLabel.className = "drag-label"; + markerTrack.appendChild(dragLabel); + } + + const deltaX = moveEvent.clientX - startX; + const trackRect = markerTrack.getBoundingClientRect(); + const deltaPercent = deltaX / trackRect.width; + const deltaFrames = Math.round(deltaPercent * (totalFrames - 1)); + + let newStart = originalStart + deltaFrames; + let newEnd = originalEnd + deltaFrames; + + // 限制范围不能超出边界 + if (newStart < 0) { + newStart = 0; + newEnd = rangeWidth; + } else if (newEnd >= totalFrames) { + newEnd = totalFrames - 1; + newStart = newEnd - rangeWidth; + } + + // 更新位置(使用统一的百分比转换) + const newLeftPercent = frameToPercent(newStart); + rangeEl.style.left = `${newLeftPercent}%`; + rangeEl.dataset.range = `${newStart}-${newEnd}`; + + // 更新跟随标签 + if (dragLabel) { + dragLabel.textContent = `帧 ${newStart}-${newEnd}`; + // 标签位置定位在范围中心 + const centerFrame = (newStart + newEnd) / 2; + dragLabel.style.left = `${frameToPercent(centerFrame)}%`; + } + + // 同步 video 预览到新的起始帧 + state.currentFrameIndex = newStart; + videoElement.currentTime = newStart / state.fps; + updateDisplay(); + }; + + const onMouseUp = () => { + // 移除跟随标签 + if (dragLabel) { + dragLabel.remove(); + dragLabel = null; + } + + if (isDragging) { + const newRange = rangeEl.dataset.range; + state.selectedFrames.delete(item); + state.selectedFrames.add(newRange); + renderTags(); + } + document.removeEventListener('mousemove', onMouseMove); + document.removeEventListener('mouseup', onMouseUp); + }; + + document.addEventListener('mousemove', onMouseMove); + document.addEventListener('mouseup', onMouseUp); + }); + + // 双击范围帧跳转到起始帧 + rangeEl.addEventListener('dblclick', (e) => { + e.stopPropagation(); + const range = rangeEl.dataset.range; + const startFrame = parseInt(range.split('-')[0]); + state.currentFrameIndex = startFrame; + videoElement.currentTime = startFrame / state.fps; + updateDisplay(); + }); + + markerTrack.appendChild(rangeEl); + + // 使用 createTooltip 显示帧范围 + createTooltip({ + target: rangeEl, + content: `帧 ${item}`, + position: 'top' + }); + } else { + // 单帧标记(使用统一的帧到百分比转换) + const frame = typeof item === 'number' ? item : parseInt(item); + const leftPercent = frameToPercent(frame); + + const markerEl = document.createElement("div"); + markerEl.className = "frame-marker"; + markerEl.style.left = `${leftPercent}%`; + markerEl.dataset.frame = frame; + markerEl.dataset.originalItem = item; + + // 添加拖动功能 + markerEl.addEventListener('mousedown', (e) => { + e.stopPropagation(); + e.preventDefault(); + clearTooltips(); // 拖动时立即清理 tooltip + + const originalFrame = parseInt(markerEl.dataset.frame); + const originalItem = markerEl.dataset.originalItem; + let isDragging = false; + let dragLabel = null; + + const onMouseMove = (moveEvent) => { + if (!isDragging) { + isDragging = true; + // 创建跟随标签 + dragLabel = document.createElement("div"); + dragLabel.className = "drag-label"; + markerTrack.appendChild(dragLabel); + } + + const trackRect = markerTrack.getBoundingClientRect(); + const offsetX = moveEvent.clientX - trackRect.left; + const newPercent = Math.max(0, Math.min(1, offsetX / trackRect.width)); + const newFrame = Math.round(newPercent * (totalFrames - 1)); + + // 限制范围 + const clampedFrame = Math.max(0, Math.min(totalFrames - 1, newFrame)); + + // 实时更新位置(使用统一的百分比转换) + markerEl.style.left = `${frameToPercent(clampedFrame)}%`; + markerEl.dataset.frame = clampedFrame; + + // 更新跟随标签 + if (dragLabel) { + dragLabel.textContent = `帧 ${clampedFrame}`; + dragLabel.style.left = `${frameToPercent(clampedFrame)}%`; + } + + // 同步 video 预览 + state.currentFrameIndex = clampedFrame; + videoElement.currentTime = clampedFrame / state.fps; + updateDisplay(); + }; + + const onMouseUp = () => { + // 移除跟随标签 + if (dragLabel) { + dragLabel.remove(); + dragLabel = null; + } + + if (isDragging) { + const newFrame = parseInt(markerEl.dataset.frame); + + // 更新 selectedFrames + if (typeof originalItem === 'number') { + state.selectedFrames.delete(originalItem); + } else { + state.selectedFrames.delete(parseInt(originalItem)); + } + state.selectedFrames.add(newFrame); + + // 重新渲染 + renderTags(); + } + + document.removeEventListener('mousemove', onMouseMove); + document.removeEventListener('mouseup', onMouseUp); + }; + + document.addEventListener('mousemove', onMouseMove); + document.addEventListener('mouseup', onMouseUp); + }); + + // 双击跳转到对应帧 + markerEl.addEventListener('dblclick', (e) => { + e.stopPropagation(); + const targetFrame = parseInt(markerEl.dataset.frame); + state.currentFrameIndex = targetFrame; + videoElement.currentTime = targetFrame / state.fps; + updateDisplay(); + }); + + markerTrack.appendChild(markerEl); + + // 使用 createTooltip 显示帧号 + createTooltip({ + target: markerEl, + content: `帧 ${frame}`, + position: 'top' + }); + } + }); + }; + + // --- 渲染底部帧标签 --- + const renderTags = () => { + tagsList.innerHTML = ""; + const sorted = Array.from(state.selectedFrames).sort((a, b) => { + const getStart = v => typeof v === 'string' ? parseInt(v.split('-')[0]) : v; + return getStart(a) - getStart(b); + }); + + sorted.forEach(item => { + const tag = document.createElement("div"); + // 根据类型添加对应的样式类 + const isRange = typeof item === 'string' && item.includes('-'); + tag.className = `frame-tag ${isRange ? 'frame-tag-range' : 'frame-tag-single'}`; + tag.innerHTML = `${item}`; + + const removeIcon = document.createElement("span"); + removeIcon.className = "remove-frame"; + removeIcon.innerHTML = "×"; + removeIcon.onclick = () => { + state.selectedFrames.delete(item); + renderTags(); + }; + + tag.appendChild(removeIcon); + tagsList.appendChild(tag); + }); + + // 同步更新标记轨道 + renderMarkers(); + }; + + // --- 帧滑块点击支持(在标记轨道上点击跳转到指定帧)--- + markerTrack.addEventListener('click', (e) => { + if (e.target === markerTrack) { + const rect = markerTrack.getBoundingClientRect(); + const clickPercent = (e.clientX - rect.left) / rect.width; + const targetFrame = Math.round(clickPercent * state.totalFrames); + loadFrame(Math.max(0, Math.min(state.totalFrames - 1, targetFrame))); + } + }); + + // --- 初始化:加载第一帧 --- + loadFrame(0); + renderTags(); +} + +/** + * 递归查找连接的视频源 + * @param {Object} node 起始节点 + * @returns {Promise} 视频信息对象 {url, filename, type} + */ +async function findConnectedVideo(node) { + if (!node.inputs) return null; + + // 辅助函数:从节点提取视频文件信息 + const extractVideoFile = (node) => { + let filename = null; + let subfolder = ""; + let type = "input"; + let forceRate = 0; + + // 检测是否为VideoHelperSuite的Load Video节点 + const isLoadVideoNode = node.type?.includes("VHS_LoadVideo"); + + // 策略1: 从 serialize.widgets_values 获取 + if (node.serialize) { + const serialized = node.serialize(); + if (serialized?.widgets_values?.length > 0) { + filename = serialized.widgets_values[0]; + } + } + + // 策略2: 从 widgets 获取 + if (!filename && node.widgets?.length > 0) { + for (const w of node.widgets) { + if (w.value && typeof w.value === 'string' && w.value.length > 0) { + filename = w.value; + break; + } + } + } + + // 策略3: 从 properties 获取 + if (!filename && node.properties) { + filename = node.properties.video || node.properties.filename || node.properties.upload; + } + + // 提取force_rate参数(来自VideoHelperSuite的Load Video节点) + if (node.widgets) { + const forceRateWidget = node.widgets.find(w => w.name === "force_rate"); + if (forceRateWidget && forceRateWidget.value != null) { + forceRate = parseFloat(forceRateWidget.value); + } + } + + if (filename) { + return { filename, subfolder, type, forceRate, fromLoadNode: isLoadVideoNode }; + } + return null; + }; + + // 辅助函数:递归遍历图 + const findVideoSource = (currentNode, visited = new Set()) => { + if (!currentNode || visited.has(currentNode.id)) return null; + visited.add(currentNode.id); + + // 检查当前节点是否有视频文件 + const videoFile = extractVideoFile(currentNode); + if (videoFile) return videoFile; + + // 递归查找上游节点 + if (currentNode.inputs) { + for (const input of currentNode.inputs) { + if (input.link) { + const link = app.graph.links[input.link]; + if (link) { + const sourceNode = app.graph.getNodeById(link.origin_id); + const result = findVideoSource(sourceNode, visited); + if (result) return result; + } + } + } + } + return null; + }; + + // 1. 优先查找 Video 类型输入 + const videoInput = node.inputs.find(i => i.name === "视频" || i.type === "VIDEO"); + if (videoInput?.link) { + const link = app.graph.links[videoInput.link]; + if (link) { + const originNode = app.graph.getNodeById(link.origin_id); + const videoFile = findVideoSource(originNode); + if (videoFile) { + const params = new URLSearchParams(videoFile); + return { + url: api.apiURL("/view?" + params.toString()), + filename: videoFile.filename, + type: videoFile.type, + forceRate: videoFile.forceRate || 0, + fromLoadNode: videoFile.fromLoadNode || false + }; + } + } + } + + // 2. 查找 Image 类型输入(图像序列) + const imageInput = node.inputs.find(i => i.name === "图像序列" || i.type === "IMAGE"); + if (imageInput?.link) { + const link = app.graph.links[imageInput.link]; + if (link) { + const originNode = app.graph.getNodeById(link.origin_id); + const videoFile = findVideoSource(originNode); + + if (videoFile) { + const params = new URLSearchParams(videoFile); + return { + url: api.apiURL("/view?" + params.toString()), + filename: videoFile.filename, + type: videoFile.type, + forceRate: videoFile.forceRate || 0, + fromLoadNode: videoFile.fromLoadNode || false + }; + } else { + return { + type: "image_sequence", + error: "检测到图像序列输入,但无法追溯到原始视频文件。请确保图像序列来自视频加载节点(如 VHS Load Video)。" + }; + } + } + } + + return null; +} diff --git a/js/services/NodeMountService.js b/js/services/NodeMountService.js new file mode 100644 index 0000000..7599ced --- /dev/null +++ b/js/services/NodeMountService.js @@ -0,0 +1,714 @@ +/** + * 节点挂载服务 (NodeMountService) + * 统一管理小助手在不同渲染模式下的创建和挂载 + * + * 支持两种渲染模式: + * - litegraph.js: 传统Canvas渲染 + DOM Widget覆盖层 + * - Vue node2.0: 纯Vue组件渲染 + */ + +import { app } from "../../../../scripts/app.js"; +import { logger } from '../utils/logger.js'; +import { EventManager } from '../utils/eventManager.js'; + +// ---渲染模式枚举--- +export const RENDER_MODE = { + LITEGRAPH: 'litegraph', + VUE_NODES: 'vue_nodes', + UNKNOWN: 'unknown' +}; + +/** + * 节点挂载服务类 + * 提供渲染模式检测、容器查找、挂载管理等功能 + */ +class NodeMountService { + constructor() { + // 当前渲染模式 + this.currentMode = RENDER_MODE.UNKNOWN; + // 模式切换回调列表 + this._modeChangeCallbacks = []; + // 是否已初始化 + this._initialized = false; + // 设置监听清理函数 + this._cleanupFunctions = []; + // 模式检测缓存,避免频繁检测 + this._modeCache = null; + this._modeCacheTime = 0; + this._modeCacheTTL = 1000; // 缓存有效期1秒 + + // 挂载观察者映射 { nodeId: observer } + this._observers = new Map(); + } + + // ---初始化与生命周期--- + + /** + * 初始化服务并设置模式监听 + */ + initialize() { + if (this._initialized) { + logger.debug('[NodeMountService] 已初始化,跳过'); + return; + } + + // 检测初始渲染模式 + this.currentMode = this.detectRenderMode(); + + // 设置模式变更监听 + this._setupModeWatcher(); + + this._initialized = true; + logger.log(`[NodeMountService] 初始化完成 | 渲染模式: ${this.currentMode}`); + } + + /** + * 清理服务资源 + */ + cleanup() { + // 清理所有观察者 + this._observers.forEach(observer => observer.disconnect()); + this._observers.clear(); + + // 执行所有清理函数 + this._cleanupFunctions.forEach(fn => { + try { + if (typeof fn === 'function') fn(); + } catch (e) { + logger.debug(`[NodeMountService] 清理函数执行失败: ${e.message}`); + } + }); + this._cleanupFunctions = []; + this._modeChangeCallbacks = []; + this._initialized = false; + this._modeCache = null; + logger.debug('[NodeMountService] 资源清理完成'); + } + + // ---节点类型检测工具--- + + /** + * 检查节点是否为使用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')); + } + + // ---渲染模式检测--- + + /** + * 检测当前渲染模式 + * @param {boolean} forceRefresh - 是否强制刷新缓存 + * @returns {string} 渲染模式枚举值 + */ + detectRenderMode(forceRefresh = false) { + // 检查缓存 + const now = Date.now(); + if (!forceRefresh && this._modeCache && (now - this._modeCacheTime) < this._modeCacheTTL) { + return this._modeCache; + } + + let mode = RENDER_MODE.LITEGRAPH; + + try { + // 方法1: 检查 LiteGraph 全局标志(最可靠) + if (typeof LiteGraph !== 'undefined' && LiteGraph.vueNodesMode === true) { + mode = RENDER_MODE.VUE_NODES; + } + // 方法2: 检查设置项 + else if (app.ui?.settings) { + const vueNodesEnabled = app.ui.settings.getSettingValue('Comfy.VueNodes.Enabled'); + if (vueNodesEnabled === true) { + mode = RENDER_MODE.VUE_NODES; + } + } + // 方法3: 检查DOM特征(备用方案) + else { + const vueNodeContainer = document.querySelector('[data-node-id]'); + const domWidget = document.querySelector('.dom-widget'); + if (vueNodeContainer && !domWidget) { + mode = RENDER_MODE.VUE_NODES; + } + } + } catch (e) { + logger.debug(`[NodeMountService] 模式检测异常: ${e.message}`); + mode = RENDER_MODE.LITEGRAPH; // 默认回退到传统模式 + } + + // 更新缓存 + this._modeCache = mode; + this._modeCacheTime = now; + + return mode; + } + + /** + * 检查是否为 Vue node2.0 模式 + * @returns {boolean} + */ + isVueNodesMode() { + return this.detectRenderMode() === RENDER_MODE.VUE_NODES; + } + + /** + * 检查是否为 litegraph.js 模式 + * @returns {boolean} + */ + isLitegraphMode() { + return this.detectRenderMode() === RENDER_MODE.LITEGRAPH; + } + + // ---模式切换监听--- + + /** + * 注册模式切换回调 + * @param {Function} callback - 回调函数,接收 (newMode, oldMode) 参数 + */ + onModeChange(callback) { + if (typeof callback === 'function') { + this._modeChangeCallbacks.push(callback); + } + } + + /** + * 移除模式切换回调 + * @param {Function} callback - 要移除的回调函数 + */ + offModeChange(callback) { + const index = this._modeChangeCallbacks.indexOf(callback); + if (index > -1) { + this._modeChangeCallbacks.splice(index, 1); + } + } + + /** + * 触发模式切换事件 + * @param {string} newMode - 新模式 + * @param {string} oldMode - 旧模式 + */ + _triggerModeChange(newMode, oldMode) { + logger.log(`[NodeMountService] 渲染模式切换 | ${oldMode} -> ${newMode}`); + + // 清除缓存 + this._modeCache = null; + + // 执行所有回调 + this._modeChangeCallbacks.forEach(callback => { + try { + callback(newMode, oldMode); + } catch (e) { + logger.error(`[NodeMountService] 模式切换回调执行失败: ${e.message}`); + } + }); + } + + /** + * 设置模式变更监听器 + */ + _setupModeWatcher() { + // 监听 LiteGraph.vueNodesMode 变化(通过定期检测) + let lastMode = this.currentMode; + + const checkModeChange = () => { + const currentMode = this.detectRenderMode(true); + if (currentMode !== lastMode) { + const oldMode = lastMode; + lastMode = currentMode; + this.currentMode = currentMode; + this._triggerModeChange(currentMode, oldMode); + } + }; + + // 设置定期检测(每500ms检测一次) + const intervalId = setInterval(checkModeChange, 500); + this._cleanupFunctions.push(() => clearInterval(intervalId)); + + // 监听设置变更事件(如果可用) + try { + if (app.ui?.settings) { + // 尝试监听设置变更 + const originalSettingValue = app.ui.settings.setSettingValue; + if (originalSettingValue) { + app.ui.settings.setSettingValue = function (id, value) { + const result = originalSettingValue.apply(this, arguments); + if (id === 'Comfy.VueNodes.Enabled') { + // 延迟检测,等待设置生效 + setTimeout(checkModeChange, 100); + } + return result; + }; + + this._cleanupFunctions.push(() => { + app.ui.settings.setSettingValue = originalSettingValue; + }); + } + } + } catch (e) { + logger.debug(`[NodeMountService] 设置监听器设置失败: ${e.message}`); + } + } + + // ---容器查找--- + + /** + * 为节点的输入控件查找挂载容器 + * @param {object} node - LiteGraph节点对象 + * @param {object} widget - 输入控件对象 + * @returns {object|null} 容器信息对象或null + */ + findMountContainer(node, widget) { + if (!node || !widget) { + logger.debug('[NodeMountService] findMountContainer: 参数无效'); + return null; + } + + const mode = this.detectRenderMode(); + + if (mode === RENDER_MODE.VUE_NODES) { + return this._findVueNodeContainer(node, widget); + } else { + return this._findDomWidgetContainer(node, widget); + } + } + + /** + * Vue node2.0 模式下查找容器 + * @param {object} node - LiteGraph节点对象 + * @param {object} widget - 输入控件对象 + * @returns {object|null} 容器信息 + */ + /** + * 判断一个 widget 是否应该被渲染为 Textarea + * @param {object} widget + */ + _isTextareaWidget(widget) { + if (!widget) return false; + // 1. 明确的 customtext 类型 + if (widget.type === 'customtext') return true; + // 2. STRING 类型且 multiline: true + if (widget.type === 'STRING' && widget.options?.multiline) return true; + // 3. 已经绑定了 textarea 元素 + if (widget.element && widget.element.tagName === 'TEXTAREA') return true; + + return false; + } + + /** + * Vue node2.0 模式下查找容器 + * @param {object} node - LiteGraph节点对象 + * @param {object} widget - 输入控件对象 + * @returns {object|null} 容器信息 + */ + _findVueNodeContainer(node, widget) { + try { + // 查找带有 data-node-id 的 Vue 节点容器 + const nodeContainer = document.querySelector(`[data-node-id="${node.id}"]`); + if (!nodeContainer) { + logger.debug(`[NodeMountService] Vue模式: 未找到节点容器 | ID: ${node.id}`); + return null; + } + + // 获取widget名称用于查找对应的textarea + const widgetName = widget.name || widget.id; + let textarea = null; + + // --- 策略1: 优先使用 widget.inputEl (如果已绑定) --- + if (widget.inputEl && widget.inputEl.tagName === 'TEXTAREA') { + if (nodeContainer.contains(widget.inputEl)) { + textarea = widget.inputEl; + // logger.debug(`[NodeMountService] Vue模式: 直接使用 widget.inputEl | Widget: ${widgetName}`); + } + } + + // --- 策略2: 计算索引位置匹配 (最稳健的方法) --- + // 适用于多个同类型输入框的情况 (如 VideoCaptionNode, CLIPTextEncodeSDXL 等) + if (!textarea && node.widgets) { + // 1. 计算当前 widget 在所有 Textarea 类 widget 中的索引 + let targetIndex = -1; + let currentIndex = 0; + + for (const w of node.widgets) { + if (this._isTextareaWidget(w)) { + if (w === widget || w.name === widget.name) { // 兼容对象引用或名称匹配 + targetIndex = currentIndex; + break; + } + currentIndex++; + } + } + + if (targetIndex !== -1) { + // 2. 获取 DOM 中所有的 textarea + // 注意:querySelectorAll 返回的顺序通常遵循文档流顺序,这与 Vue 渲染顺序一致 + const textareas = Array.from(nodeContainer.querySelectorAll('textarea')); + + // 3. 按索引匹配 + if (targetIndex < textareas.length) { + textarea = textareas[targetIndex]; + logger.debug(`[NodeMountService] Vue模式: 索引匹配成功 [${targetIndex}] | Widget: ${widgetName}`); + } else { + logger.debug(`[NodeMountService] Vue模式: 索引匹配越界 [${targetIndex}/${textareas.length}] | Widget: ${widgetName}`); + } + } + } + + // --- 策略3: 标签/Placeholder 模糊匹配 (兼容旧逻辑作为补充) --- + if (!textarea) { + const textareas = nodeContainer.querySelectorAll('textarea'); + const searchName = widgetName.toLowerCase().replace(/_/g, ' '); // snake_case -> space separated + + for (const ta of textareas) { + //检查 placeholder + const placeholder = (ta.getAttribute('placeholder') || '').toLowerCase(); + // 检查 aria-label + const ariaLabel = (ta.getAttribute('aria-label') || '').toLowerCase(); + // 检查父级 label + const label = ta.closest('label')?.textContent?.toLowerCase() || ''; + // 检查前置 label (Vue 浮动标签结构) + const floatLabel = ta.parentElement?.querySelector('label')?.textContent?.toLowerCase() || ''; + + if (placeholder.includes(searchName) || + ariaLabel.includes(searchName) || + label.includes(searchName) || + floatLabel.includes(searchName)) { + textarea = ta; + // logger.debug(`[NodeMountService] Vue模式: 模糊匹配成功 | Widget: ${widgetName}`); + break; + } + } + } + + // --- 策略4: 最后的兜底 (仅当只有一个 textarea 时才敢用) --- + if (!textarea) { + const textareas = nodeContainer.querySelectorAll('textarea'); + if (textareas.length === 1) { + textarea = textareas[0]; + logger.debug(`[NodeMountService] Vue模式: 唯一匹配兜底 | Widget: ${widgetName}`); + } + } + + if (!textarea) { + logger.debug(`[NodeMountService] Vue模式: 未找到textarea | 节点ID: ${node.id} | Widget: ${widgetName}`); + // 对于使用comfy-markdown的节点(Note/MarkdownNote/PreviewTextNode等),返回nodeContainer作为容器但textarea为null + if (this._isMarkdownNode(node)) { + return { + container: nodeContainer, + textarea: null, // 标记需要进一步查找 + nodeContainer: nodeContainer, + mode: RENDER_MODE.VUE_NODES, + widgetName: widgetName, + isNoteNode: true + }; + } + return null; + } + + // 找到textarea的父容器作为挂载点 + // 优先找 floatlabel 容器,否则找父级 + const mountContainer = textarea.closest('.p-floatlabel, [class*="float"]') || textarea.parentElement; + + return { + container: mountContainer, + textarea: textarea, + nodeContainer: nodeContainer, + mode: RENDER_MODE.VUE_NODES, + widgetName: widgetName + }; + } catch (e) { + logger.error(`[NodeMountService] Vue容器查找失败: ${e.message}`); + return null; + } + } + + /** + * litegraph.js 模式下查找容器 + * @param {object} node - LiteGraph节点对象 + * @param {object} widget - 输入控件对象 + * @returns {object|null} 容器信息 + */ + _findDomWidgetContainer(node, widget) { + try { + const inputEl = widget.inputEl || widget.element; + if (!inputEl) { + logger.debug('[NodeMountService] Litegraph模式: 输入元素不存在'); + return null; + } + + // 向上查找 dom-widget 容器 + let parent = inputEl.parentElement; + let domWidgetContainer = null; + + while (parent) { + if (parent.classList?.contains('dom-widget')) { + domWidgetContainer = parent; + break; + } + parent = parent.parentElement; + } + + if (!domWidgetContainer) { + logger.debug(`[NodeMountService] Litegraph模式: 未找到dom-widget容器 | 节点ID: ${node.id}`); + return null; + } + + return { + container: domWidgetContainer, + textarea: inputEl, + mode: RENDER_MODE.LITEGRAPH, + widgetName: widget.name || widget.id + }; + } catch (e) { + logger.error(`[NodeMountService] dom-widget容器查找失败: ${e.message}`); + return null; + } + } + + // ---图像节点容器查找--- + + /** + * 为图像节点查找挂载容器(用于ImageCaption) + * @param {object} node - LiteGraph节点对象 + * @returns {object|null} 容器信息 + */ + findImageNodeContainer(node) { + if (!node) return null; + + const mode = this.detectRenderMode(); + + if (mode === RENDER_MODE.VUE_NODES) { + return this._findVueImageNodeContainer(node); + } else { + // litegraph模式下,图像小助手使用fixed定位,不需要容器 + return { + container: document.body, + mode: RENDER_MODE.LITEGRAPH, + useFixedPositioning: true + }; + } + } + + /** + * Vue node2.0 模式下查找图像节点容器 + * @param {object} node - LiteGraph节点对象 + * @returns {object|null} 容器信息 + */ + _findVueImageNodeContainer(node) { + try { + const nodeContainer = document.querySelector(`[data-node-id="${node.id}"]`); + if (!nodeContainer) { + logger.debug(`[NodeMountService] Vue模式: 未找到图像节点容器 | ID: ${node.id}`); + return null; + } + + return { + container: nodeContainer, + mode: RENDER_MODE.VUE_NODES, + useFixedPositioning: false + }; + } catch (e) { + logger.error(`[NodeMountService] Vue图像节点容器查找失败: ${e.message}`); + return null; + } + } + + // ---挂载辅助方法--- + + /** + * 将小助手元素挂载到容器 + * @param {HTMLElement} assistantElement - 小助手DOM元素 + * @param {object} containerInfo - findMountContainer返回的容器信息 + * @param {object} options - 挂载选项 + * @returns {boolean} 是否挂载成功 + */ + mountAssistant(assistantElement, containerInfo, options = {}) { + if (!assistantElement || !containerInfo?.container) { + logger.debug('[NodeMountService] mountAssistant: 参数无效'); + return false; + } + + try { + const { container, mode } = containerInfo; + const { position = 'bottom-right', offset = { x: 4, y: 4 } } = options; + + if (mode === RENDER_MODE.VUE_NODES) { + // Vue node2.0 模式:使用相对定位 + assistantElement.style.position = 'absolute'; + assistantElement.style.zIndex = '10'; + + if (position === 'bottom-right') { + assistantElement.style.right = `${offset.x}px`; + assistantElement.style.bottom = `${offset.y}px`; + assistantElement.style.left = 'auto'; + assistantElement.style.top = 'auto'; + } else if (position === 'bottom-left') { + assistantElement.style.left = `${offset.x}px`; + assistantElement.style.bottom = `${offset.y}px`; + assistantElement.style.right = 'auto'; + assistantElement.style.top = 'auto'; + } + + // 确保容器有相对定位 + const containerPosition = window.getComputedStyle(container).position; + if (containerPosition === 'static') { + container.style.position = 'relative'; + } + + container.appendChild(assistantElement); + + } else { + // litegraph.js 模式:使用绝对定位(在dom-widget内) + assistantElement.style.position = 'absolute'; + assistantElement.style.right = `${offset.x}px`; + assistantElement.style.bottom = `${offset.y}px`; + assistantElement.style.height = '20px'; + assistantElement.style.minHeight = '20px'; + + container.appendChild(assistantElement); + } + + // 触发回流确保样式生效 + void assistantElement.offsetWidth; + + logger.debug(`[NodeMountService] 挂载成功 | 模式: ${mode}`); + return true; + + } catch (e) { + logger.error(`[NodeMountService] 挂载失败: ${e.message}`); + return false; + } + } + + /** + * 等待元素出现 (使用 MutationObserver) + * 替代轮询,实现几乎零延迟的响应 + * @param {HTMLElement} parent - 要监听的父元素 + * @param {string} selector - 目标选择器 (或者检查函数) + * @param {number} timeout - 超时时间 (ms) + * @returns {Promise} + */ + waitForElement(parent, selector, timeout = 2000) { + return new Promise((resolve) => { + // 1. 立即检查是否存在 + let element = null; + if (typeof selector === 'function') { + element = selector(parent); + } else { + element = parent.querySelector(selector); + } + + if (element) { + return resolve(element); + } + + // 2. 设置观察者 + const observer = new MutationObserver((mutations) => { + let found = null; + if (typeof selector === 'function') { + found = selector(parent); + } else { + found = parent.querySelector(selector); + } + + if (found) { + observer.disconnect(); + resolve(found); + } + }); + + observer.observe(parent, { + childList: true, + subtree: true, + attributes: true // 有时元素可能只是属性变化(如hidden移除) + }); + + // 3. 设置超时 + setTimeout(() => { + observer.disconnect(); + resolve(null); + }, timeout); + }); + } + + /** + * 带重试/等待的容器查找 + * 优化:使用 MutationObserver 替代单纯的轮询 + * @param {object} node - 节点对象 + * @param {object} widget - 控件对象 + * @param {object} options - 选项 + * @returns {Promise} 容器信息 + */ + async findMountContainerWithRetry(node, widget, options = {}) { + // 默认快速响应,但给予足够的总等待时间给Vue渲染 + const { timeout = 1000 } = options; + + // 尝试立即查找 + const immediateResult = this.findMountContainer(node, widget); + if (immediateResult && immediateResult.textarea) { + return immediateResult; + } + + // 如果是 Note 节点且找到了容器但没找到 textarea,也视为一种中间状态 + if (immediateResult && immediateResult.isNoteNode) { + // 继续往下,试图等到 textarea 出现 + } + + const mode = this.detectRenderMode(); + + // 只有 Vue 模式下才需要使用 Observer 等待 DOM 渲染 + if (mode === RENDER_MODE.VUE_NODES) { + // 查找 Vue 节点主容器 + const nodeContainer = document.querySelector(`[data-node-id="${node.id}"]`); + + if (nodeContainer) { + // 在节点容器内等待 textarea 出现 + // 使用 Observer 监听节点容器的变化 + logger.debugSample(() => `[NodeMountService] 开始监听DOM变化 | 节点: ${node.id}`); + + await this.waitForElement(nodeContainer, () => { + // 每次变化都尝试重新查找完整容器信息 + const result = this.findMountContainer(node, widget); + return (result && result.textarea) ? result : null; + }, timeout); + + // 再次尝试获取最终结果 + const finalResult = this.findMountContainer(node, widget); + if (finalResult && finalResult.textarea) { + logger.debugSample(() => `[NodeMountService] DOM监听成功,找到容器 | 节点: ${node.id}`); + return finalResult; + } + } + } + + // 降级策略/LiteGraph模式:简单的延迟重试(次数减少,避免长延迟) + // 或者是 Vue 模式下一直没等到 + const retryCount = 2; + for (let i = 0; i < retryCount; i++) { + await new Promise(r => setTimeout(r, 200)); // 短间隔 + const res = this.findMountContainer(node, widget); + if (res && res.textarea) return res; + } + + logger.warn(() => `[NodeMountService] 容器查找最终失败 | 节点ID: ${node?.id}`); + return null; // 或者返回仅包含 container 的结果(视情况而定) + } +} + +// 创建单例实例 +export const nodeMountService = new NodeMountService(); + +// 导出类(用于类型检查或继承) +export { NodeMountService }; diff --git a/js/services/api.js b/js/services/api.js index 700e564..f5649de 100644 --- a/js/services/api.js +++ b/js/services/api.js @@ -8,25 +8,195 @@ import { logger } from '../utils/logger.js'; // 用于存储进行中的请求的AbortController const runningRequests = new Map(); +// ---基础路由推断逻辑--- +// 自动获取当前插件的挂载点,消除硬编码路径 +// 通过解析当前脚本的 URL,提取出 extensions/ 后的目录名 +let apiBaseUrl = null; + +function getDynamicApiBase() { + if (apiBaseUrl) return apiBaseUrl; + + try { + const scriptUrl = import.meta.url; + const url = new URL(scriptUrl); + const pathParts = url.pathname.split('/'); + + // 策略1:寻找 /js/ 目录段,并取其前一个段作为插件名 (最通用) + const jsIdx = pathParts.indexOf('js'); + if (jsIdx > 0) { + const nodeDir = pathParts[jsIdx - 1]; + apiBaseUrl = `/${nodeDir}/api`; + } + // 策略2:回退到 extensions 关键词寻找 (ComfyUI 标准结构) + else { + const extIdx = pathParts.indexOf('extensions'); + if (extIdx !== -1 && pathParts.length > extIdx + 1) { + const nodeDir = pathParts[extIdx + 1]; + apiBaseUrl = `/${nodeDir}/api`; + } else { + // 策略3:硬编码兜底 + apiBaseUrl = '/prompt-assistant/api'; + } + } + } catch (e) { + apiBaseUrl = '/prompt-assistant/api'; + } + return apiBaseUrl; +} + class APIService { /** * 构建完整的API URL */ static getApiUrl(path) { - // 获取当前域名和端口 - const baseUrl = window.location.origin; - // 确保路径格式正确 - const formattedPath = path.startsWith('/') ? path : `/${path}`; - const url = `${baseUrl}${formattedPath}`; - logger.debug(`构建API URL: ${url}`); + // 获取动态基础路由 (例如 /comfyui_prompt_assistant/api) + const baseApi = getDynamicApiBase(); + + // 确保 path 不含重复的前缀,且格式正确 + let subPath = path.startsWith('/') ? path : `/${path}`; + + // 如果 path 已经包含了 baseApi,则不再重复添加 + const fullPath = subPath.startsWith(baseApi) ? subPath : `${baseApi}${subPath}`; + + const url = `${window.location.origin}${fullPath}`; + // logger.debug(`构建API URL: ${url}`); return url; } + /** + * 解析形如 [{"id":0,"text":"..."}, ...] 的JSON数组 + * 返回 Map + */ + static _extractIndexedTranslations(text) { + const arr = APIService._extractJsonArray(text); + if (!Array.isArray(arr)) return null; + const map = new Map(); + for (const item of arr) { + if (!item || typeof item !== 'object') return null; + if (!('id' in item) || !('text' in item)) return null; + map.set(Number(item.id), String(item.text ?? '')); + } + return map; + } + + /** + * 结构化批量翻译(纯前端封装,单次LLM请求) + * 要求模型严格返回 JSON 数组,与输入 texts 一一对应 + */ + static async llmBatchTranslate(texts, from = 'auto', to = 'zh', request_id = null) { + try { + if (!Array.isArray(texts) || texts.length === 0) { + throw new Error('待翻译文本数组不能为空'); + } + + // 构造结构化指令,使用索引,要求输出严格的 JSON 对象数组 + const indexed = texts.map((t, i) => ({ id: i, text: t })); + // 强化提示词:明确禁止 Markdown 表格格式,禁止添加 '|' 前缀 + const sysHint = `你是一个专业的翻译API。请将输入的 JSON 数组中的 text 字段内容从 ${from} 翻译为 ${to}。 + 规则: + 1. 保持 JSON 结构不变,返回包含 id 和 text 的数组。 + 2. 严禁使用 Markdown 表格格式,严禁在译文前添加 '|' 符号。 + 3. 对于参数名、变量名(如 snake_case 格式),请尽可能将其翻译为中文含义(例如:pose_images -> 姿势图像),除非是专有名词(如 CLIP, VAE)。 + 4. 保持数组长度与输入一致。 + 5. 直接输出 JSON,不要包含 Markdown 代码块标记(如 \`\`\`json)。`; + + const payload = { segments: indexed }; + const prompt = [ + sysHint, + 'Input: ' + JSON.stringify(payload) + ].join('\n'); + + // 复用单文本接口 + const res = await this.llmTranslate(prompt, from, to, request_id); + if (!res || !res.success) { + return { success: false, error: res?.error || '批量翻译失败' }; + } + + const content = (res.data && (res.data.translated || res.data.expanded || res.data.content)) || res.translated || res.content || ''; + // 解析为索引映射 + let mapped = APIService._extractIndexedTranslations(content); + if (!mapped) { + // 回退:尝试解析为纯字符串数组并顺序对齐 + const arr = APIService._extractJsonArray(content); + if (Array.isArray(arr) && arr.length === texts.length) { + mapped = new Map(arr.map((v, i) => [i, v])); + } + } + + if (!mapped) { + return { success: false, error: '解析批量译文失败:未检测到有效JSON' }; + } + + // 如果缺项,针对缺失索引做单次补救调用,避免整次失败 + const translations = new Array(texts.length).fill(""); + const missingIdx = []; + for (let i = 0; i < texts.length; i++) { + if (mapped.has(i)) { + translations[i] = mapped.get(i); + } else { + missingIdx.push(i); + } + } + + if (missingIdx.length > 0) { + for (const i of missingIdx) { + const single = await this.llmTranslate(texts[i], from, to, request_id); + if (single && single.success) { + translations[i] = (single.data && single.data.translated) || ''; + } else { + translations[i] = ''; + } + } + } + + return { success: true, data: { translations } }; + } catch (error) { + return { success: false, error: error.message }; + } + } + + /** + * 从字符串中提取并解析首个JSON数组 + */ + static _extractJsonArray(text) { + if (!text) return null; + try { + // 快速路径:整段就是JSON数组 + if (text.trim().startsWith('[')) { + return JSON.parse(text.trim()); + } + } catch (_) { /* ignore */ } + + // 兼容模型添加的前后缀:寻找第一个 '[' 与匹配的 ']' + const first = text.indexOf('['); + const last = text.lastIndexOf(']'); + if (first === -1 || last === -1 || last <= first) return null; + const candidate = text.slice(first, last + 1); + try { + return JSON.parse(candidate); + } catch (e) { + // 尝试修正全角引号 + const normalized = candidate.replace(/[“”]/g, '"'); + try { return JSON.parse(normalized); } catch { return null; } + } + } + /** * 生成唯一请求ID */ - static generateRequestId() { - return `req_${Date.now()}_${Math.random().toString(36).substr(2, 8)}`; + /** + * 生成唯一请求ID + * 格式: 请求类型_服务类型(可选)_NodeID_四位时间戳 + */ + static generateRequestId(type, serviceType = null, nodeId = '0') { + const timestamp = Math.floor(Date.now() / 1000).toString().slice(-4); + const parts = [type]; + if (serviceType) { + parts.push(serviceType); + } + parts.push(nodeId); + parts.push(timestamp); + return parts.join('_'); } /** @@ -46,7 +216,7 @@ class APIService { // 2. 通知后端取消任务 try { - const apiUrl = this.getApiUrl('/prompt_assistant/api/request/cancel'); + const apiUrl = this.getApiUrl('request/cancel'); const response = await fetch(apiUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -65,9 +235,10 @@ class APIService { * 百度翻译API */ static async baiduTranslate(text, from = 'auto', to = 'zh', request_id = null, is_auto = false) { + // 生成请求ID // 生成请求ID if (!request_id) { - request_id = `baidu_trans_${Date.now()}_${Math.random().toString(36).substr(2, 6)}`; + request_id = this.generateRequestId('trans', 'baidu'); } const controller = new AbortController(); @@ -80,7 +251,7 @@ class APIService { } // 获取API URL - const apiUrl = this.getApiUrl('/prompt_assistant/api/baidu/translate'); + const apiUrl = this.getApiUrl('baidu/translate'); // 调用后端API const response = await fetch(apiUrl, { @@ -144,9 +315,10 @@ class APIService { * LLM扩写提示词 */ static async llmExpandPrompt(prompt, request_id = null) { + // 生成请求ID // 生成请求ID if (!request_id) { - request_id = `glm4_expand_${Date.now()}_${Math.random().toString(36).substr(2, 6)}`; + request_id = this.generateRequestId('exp'); } const controller = new AbortController(); @@ -155,14 +327,14 @@ class APIService { try { if (!prompt || prompt.trim() === '') { - throw new Error('请输入要扩写的内容'); + throw new Error('请输入要优化的提示词'); } - logger.debug(`发起LLM扩写请求 | 请求ID:${request_id} | 原文:${prompt}`); + logger.debug(`发起LLM提示词优化请求 | 请求ID:${request_id} | 原文:${prompt}`); // 调用后端API - const apiUrl = this.getApiUrl('/prompt_assistant/api/llm/expand'); - logger.debug('LLM扩写API URL:', apiUrl); + const apiUrl = this.getApiUrl('llm/expand'); + logger.debug('LLM提示词优化API URL:', apiUrl); const response = await fetch(apiUrl, { method: 'POST', @@ -177,15 +349,15 @@ class APIService { }); const result = await response.json(); - logger.debug(`LLM扩写请求成功 | 请求ID:${request_id} | 结果:${JSON.stringify(result)}`); + logger.debug(`LLM提示词优化请求成功 | 请求ID:${request_id} | 结果:${JSON.stringify(result)}`); return result; } catch (error) { if (error.name === 'AbortError') { - logger.debug(`LLM扩写请求被用户中止 | ID: ${request_id}`); + logger.debug(`LLM提示词优化请求被用户中止 | ID: ${request_id}`); return { success: false, error: '请求已取消', cancelled: true }; } - logger.error(`LLM扩写请求失败 | 请求ID:${request_id || 'unknown'} | 错误:${error.message}`); + logger.error(`LLM提示词优化请求失败 | 请求ID:${request_id || 'unknown'} | 错误:${error.message}`); return { success: false, error: error.message @@ -202,9 +374,10 @@ class APIService { * LLM翻译文本 */ static async llmTranslate(text, from = 'auto', to = 'zh', request_id = null, is_auto = false) { + // 生成请求ID // 生成请求ID if (!request_id) { - request_id = `llm_trans_${Date.now()}_${Math.random().toString(36).substr(2, 6)}`; + request_id = this.generateRequestId('trans', 'llm'); } const controller = new AbortController(); @@ -217,7 +390,7 @@ class APIService { } // 调用后端API - const apiUrl = this.getApiUrl('/prompt_assistant/api/llm/translate'); + const apiUrl = this.getApiUrl('llm/translate'); const response = await fetch(apiUrl, { method: 'POST', @@ -257,9 +430,10 @@ class APIService { * 调用视觉模型分析图像 */ static async llmAnalyzeImage(imageData, prompt, request_id = null) { + // 生成请求ID // 生成请求ID if (!request_id) { - request_id = this.generateRequestId(); + request_id = this.generateRequestId('icap'); } const controller = new AbortController(); @@ -274,7 +448,7 @@ class APIService { logger.debug(`发起视觉分析请求 | 请求ID:${request_id}`); // 构建API URL - const apiUrl = this.getApiUrl('/prompt_assistant/api/vlm/analyze'); + const apiUrl = this.getApiUrl('vlm/analyze'); logger.debug('视觉分析API URL:', apiUrl); // 构建请求数据 diff --git a/js/services/btnMenu.js b/js/services/btnMenu.js index e18ab83..e517b8c 100644 --- a/js/services/btnMenu.js +++ b/js/services/btnMenu.js @@ -35,22 +35,22 @@ export class ButtonMenu { constructor() { // 当前菜单DOM元素 this.menuElement = null; - + // 当前触发菜单的按钮 this.targetButton = null; - + // 菜单项配置 this.menuItems = []; - + // 菜单是否显示中 this.isMenuVisible = false; - + // 菜单上下文数据 this.menuContext = {}; // 初始化全局点击事件,用于关闭菜单 this._setupGlobalEvents(); - + // 日志 logger.log("按钮菜单服务已初始化"); } @@ -69,7 +69,7 @@ export class ButtonMenu { } } }); - + // 按ESC键关闭菜单 EventManager.addDOMListener(document, 'keydown', async (event) => { if (event.key === 'Escape' && this.isMenuVisible) { @@ -88,11 +88,11 @@ export class ButtonMenu { // 创建菜单容器 const menuContainer = document.createElement('div'); menuContainer.className = 'button-context-menu'; - + // 创建菜单项列表 const menuList = document.createElement('ul'); menuList.className = 'button-menu-list'; - + // 添加菜单项 items.forEach(item => { // 如果是分隔线 @@ -194,9 +194,15 @@ export class ButtonMenu { // —— 子菜单显示/隐藏的防抖处理,避免鼠标穿越导致关闭 —— let hideTimer = null; + // 获取子菜单对齐方式,默认居中 + const submenuAlign = item.submenuAlign || 'center'; const openSubmenu = () => { if (hideTimer) { clearTimeout(hideTimer); hideTimer = null; } menuItem.classList.add('submenu-open'); + // 使用 requestAnimationFrame 确保子菜单已渲染再调整位置 + requestAnimationFrame(() => { + this._adjustSubmenuPosition(submenu, menuItem, submenuAlign); + }); }; const scheduleCloseSubmenu = () => { if (hideTimer) clearTimeout(hideTimer); @@ -222,6 +228,13 @@ export class ButtonMenu { const subMenuItem = document.createElement('li'); subMenuItem.className = 'button-menu-item'; + + // 检查子菜单项是否有children(支持三级及以上菜单) + const hasSubChildren = Array.isArray(subItem.children) && subItem.children.length > 0; + if (hasSubChildren) { + subMenuItem.classList.add('button-menu-item-has-children'); + } + if (subItem.disabled) { subMenuItem.classList.add('button-menu-item-disabled'); } else { @@ -229,6 +242,11 @@ export class ButtonMenu { e.preventDefault(); e.stopPropagation(); + // 如果点击的是右侧箭头或子子菜单,不触发点击 + const isOnRightIcon = e.target && (e.target.classList?.contains('button-menu-right-icon') || e.target.classList?.contains('pi-chevron-right')); + const isInsideSubSubmenu = e.target && subMenuItem.querySelector('.button-submenu')?.contains(e.target); + if (isOnRightIcon || isInsideSubSubmenu) return; + // 隐藏菜单 await this.hideMenu(); @@ -236,6 +254,13 @@ export class ButtonMenu { subItem.onClick(context); } }); + + // 如果有子子菜单,添加悬停逻辑 + if (hasSubChildren) { + subMenuItem.addEventListener('mouseenter', () => { + subMenuItem.classList.add('submenu-open'); + }); + } } if (subItem.icon) { @@ -261,6 +286,87 @@ export class ButtonMenu { subMenuItem.appendChild(subShortcut); } + // 如果有子子菜单,递归创建 + if (hasSubChildren) { + const subRightIcon = document.createElement('span'); + subRightIcon.className = 'button-menu-right-icon pi pi-chevron-right'; + subMenuItem.appendChild(subRightIcon); + + // 构建子子菜单 + const subSubmenu = document.createElement('div'); + subSubmenu.className = 'button-submenu'; + const subSubmenuList = document.createElement('ul'); + subSubmenuList.className = 'button-menu-list'; + + // 防抖处理 + let subHideTimer = null; + // 获取子菜单对齐方式,默认居中 + const subSubmenuAlign = subItem.submenuAlign || 'center'; + const openSubSubmenu = () => { + if (subHideTimer) { clearTimeout(subHideTimer); subHideTimer = null; } + subMenuItem.classList.add('submenu-open'); + // 使用 requestAnimationFrame 确保子菜单已渲染再调整位置 + requestAnimationFrame(() => { + this._adjustSubmenuPosition(subSubmenu, subMenuItem, subSubmenuAlign); + }); + }; + const scheduleCloseSubSubmenu = () => { + if (subHideTimer) clearTimeout(subHideTimer); + subHideTimer = setTimeout(() => { + subMenuItem.classList.remove('submenu-open'); + subHideTimer = null; + }, 200); + }; + + subMenuItem.addEventListener('mouseleave', scheduleCloseSubSubmenu); + subMenuItem.addEventListener('mouseenter', openSubSubmenu); + subSubmenu.addEventListener('mouseenter', openSubSubmenu); + subSubmenu.addEventListener('mouseleave', scheduleCloseSubSubmenu); + + // 渲染第三级菜单项 + subItem.children.forEach(subSubItem => { + if (subSubItem.type === 'separator') { + const sep = document.createElement('li'); + sep.className = 'button-menu-separator'; + subSubmenuList.appendChild(sep); + return; + } + + const subSubMenuItem = document.createElement('li'); + subSubMenuItem.className = 'button-menu-item'; + + if (subSubItem.disabled) { + subSubMenuItem.classList.add('button-menu-item-disabled'); + } else { + subSubMenuItem.addEventListener('click', async (e) => { + e.preventDefault(); + e.stopPropagation(); + await this.hideMenu(); + if (typeof subSubItem.onClick === 'function') { + subSubItem.onClick(context); + } + }); + } + + if (subSubItem.icon) { + const subSubIcon = document.createElement('span'); + subSubIcon.className = 'button-menu-icon'; + subSubIcon.innerHTML = subSubItem.icon; + subSubMenuItem.appendChild(subSubIcon); + } + + const subSubLabel = document.createElement('span'); + subSubLabel.className = 'button-menu-label'; + subSubLabel.textContent = subSubItem.label || ''; + subSubMenuItem.appendChild(subSubLabel); + + subSubmenuList.appendChild(subSubMenuItem); + }); + + subSubmenu.appendChild(subSubmenuList); + subMenuItem.appendChild(subSubmenu); + } + submenuList.appendChild(subMenuItem); }); @@ -288,11 +394,16 @@ export class ButtonMenu { async showMenu(targetButton, items, context = {}, event) { // Close any open popups first const { PopupManager } = await import('../utils/popupManager.js'); + + // 【关键】标记正在切换,防止容器折叠 + PopupManager._isTransitioning = true; + console.log(`[ButtonMenu] showMenu | 设置 _isTransitioning = true`); + await PopupManager.closeAllPopups(); // 先隐藏可能已存在的菜单 await this.hideMenu(); - + // 在所有其他窗口关闭后,设置活动按钮状态 if (context.widget && targetButton.dataset.id) { UIToolkit.setActiveButton({ @@ -301,30 +412,34 @@ export class ButtonMenu { }); logger.debug(`右键菜单 | 设置活动按钮 | 按钮ID: ${targetButton.dataset.id}`); } - + // 保存参数 this.targetButton = targetButton; this.menuItems = items; this.menuContext = context; - + // 创建菜单元素 this.menuElement = this._createMenuElement(items, context); - + // 添加到文档 document.body.appendChild(this.menuElement); - + // 设置菜单位置 this._positionMenu(event); - + // 标记菜单为可见 this.isMenuVisible = true; - + + // 【关键】清除切换标记,因为菜单已经显示 + PopupManager._isTransitioning = false; + console.log(`[ButtonMenu] showMenu | 设置 _isTransitioning = false`); + // 阻止默认右键菜单 if (event) { event.preventDefault(); event.stopPropagation(); } - + // 添加焦点管理 this._setupKeyboardNavigation(); } @@ -334,11 +449,11 @@ export class ButtonMenu { */ _setupKeyboardNavigation() { if (!this.menuElement) return; - + // 添加键盘事件处理 const keyHandler = (e) => { if (!this.isMenuVisible) return; - + switch (e.key) { case 'ArrowDown': this._navigateMenu(1); @@ -354,26 +469,26 @@ export class ButtonMenu { break; } }; - + // 添加键盘事件监听 document.addEventListener('keydown', keyHandler); - + // 保存清理函数 this._keyboardCleanup = () => { document.removeEventListener('keydown', keyHandler); }; } - + /** * 菜单导航 * @param {number} direction 导航方向 (1: 下, -1: 上) */ _navigateMenu(direction) { if (!this.menuElement) return; - + const items = this.menuElement.querySelectorAll('.button-menu-item:not(.button-menu-item-disabled)'); if (items.length === 0) return; - + // 找到当前选中项 let currentIndex = -1; for (let i = 0; i < items.length; i++) { @@ -382,7 +497,7 @@ export class ButtonMenu { break; } } - + // 计算下一个索引 let nextIndex; if (currentIndex === -1) { @@ -390,20 +505,20 @@ export class ButtonMenu { } else { nextIndex = (currentIndex + direction + items.length) % items.length; } - + // 移除所有焦点 items.forEach(item => item.classList.remove('button-menu-item-focus')); - + // 设置新焦点 items[nextIndex].classList.add('button-menu-item-focus'); } - + /** * 激活当前选中项 */ _activateSelectedItem() { if (!this.menuElement) return; - + const focusedItem = this.menuElement.querySelector('.button-menu-item-focus'); if (focusedItem) { focusedItem.click(); @@ -421,29 +536,29 @@ export class ButtonMenu { // 移除菜单元素 menuToHide.classList.remove('button-menu-visible'); - + // 清理键盘导航 if (this._keyboardCleanup) { this._keyboardCleanup(); this._keyboardCleanup = null; } - + // 使用动画完成事件监听器 const handleTransitionEnd = () => { if (menuToHide && menuToHide.parentNode) { menuToHide.removeEventListener('transitionend', handleTransitionEnd); menuToHide.parentNode.removeChild(menuToHide); - + // 仅当实例属性仍指向我们移除的菜单时,才将其置空 if (this.menuElement === menuToHide) { - this.menuElement = null; + this.menuElement = null; } } }; - + // 监听动画完成事件 menuToHide.addEventListener('transitionend', handleTransitionEnd); - + // 如果动画未开始,设置超时 setTimeout(() => { if (menuToHide && menuToHide.parentNode) { @@ -451,25 +566,34 @@ export class ButtonMenu { } }, 300); // 300ms后强制移除,防止动画不触发 } - + // 重置中央按钮状态 UIToolkit.setActiveButton(null); logger.debug('右键菜单 | 重置活动按钮'); - + // 触发小助手可见性更新以允许其自动折叠 if (widget) { try { // 处理提示词小助手的情况 if (widget.type === 'prompt_assistant') { const { promptAssistant } = await import('../modules/PromptAssistant.js'); + // 先更新可见性 promptAssistant.updateAssistantVisibility(widget); - } + + // 直接触发自动折叠(如果小助手处于展开状态) + if (!widget.isCollapsed) { + setTimeout(() => { + // 在下一个事件循环中触发自动折叠 + promptAssistant.triggerAutoCollapse(widget); + }, 100); + } + } // 处理图像小助手的情况 - 使用更宽松的检测条件 else if (widget.type === 'image_caption_assistant' || widget.nodeId) { const { imageCaption } = await import('../modules/imageCaption.js'); // 先更新可见性 imageCaption.updateAssistantVisibility(widget); - + // 直接触发自动折叠(如果小助手处于展开状态) if (!widget.isCollapsed) { setTimeout(() => { @@ -483,7 +607,7 @@ export class ButtonMenu { logger.error(`触发小助手可见性更新失败: ${error.message}`); } } - + // 重置状态 this.isMenuVisible = false; this.targetButton = null; @@ -497,18 +621,18 @@ export class ButtonMenu { */ _positionMenu(event) { if (!this.menuElement || !this.targetButton) return; - + const buttonRect = this.targetButton.getBoundingClientRect(); const menuRect = this.menuElement.getBoundingClientRect(); // 计算菜单位置,使其位于按钮正上方并居中 let x = buttonRect.left + (buttonRect.width / 2) - (menuRect.width / 2); let y = buttonRect.top - menuRect.height - 4; // 4px的间距 - + // 设置菜单初始位置 this.menuElement.style.left = `${x}px`; this.menuElement.style.top = `${y}px`; - + // 确保菜单在视口内 setTimeout(() => this._adjustMenuPosition(), 0); } @@ -518,12 +642,12 @@ export class ButtonMenu { */ _adjustMenuPosition() { if (!this.menuElement) return; - + const menuRect = this.menuElement.getBoundingClientRect(); const viewportWidth = window.innerWidth; const viewportHeight = window.innerHeight; const margin = 12; // 边距 - + // 检查并调整水平位置 if (menuRect.right > viewportWidth - margin) { const newLeft = viewportWidth - menuRect.width - margin; @@ -531,7 +655,7 @@ export class ButtonMenu { } else if (menuRect.left < margin) { this.menuElement.style.left = `${margin}px`; } - + // 检查并调整垂直位置 if (menuRect.top < margin && this.targetButton) { const buttonRect = this.targetButton.getBoundingClientRect(); @@ -547,13 +671,79 @@ export class ButtonMenu { const newTop = viewportHeight - menuRect.height - margin; this.menuElement.style.top = `${Math.max(margin, newTop)}px`; } - + // 添加显示动画 requestAnimationFrame(() => { this.menuElement.classList.add('button-menu-visible'); }); } + /** + * 调整子菜单位置 + * @param {HTMLElement} submenu 子菜单元素 + * @param {HTMLElement} parentItem 父级菜单项元素 + * @param {string} align 对齐方式:'top'(上对齐), 'center'(居中), 'bottom'(下对齐),默认 'center' + */ + _adjustSubmenuPosition(submenu, parentItem, align = 'center') { + if (!submenu || !parentItem) return; + + const parentRect = parentItem.getBoundingClientRect(); + const submenuRect = submenu.getBoundingClientRect(); + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + const margin = 12; + + // 根据对齐方式计算初始垂直位置 + let topOffset; + switch (align) { + case 'top': + // 上对齐:子菜单顶部与父项顶部对齐 + topOffset = 0; + break; + case 'bottom': + // 下对齐:子菜单底部与父项底部对齐 + topOffset = parentRect.height - submenuRect.height; + break; + case 'center': + default: + // 居中:子菜单中心与父项中心对齐 + topOffset = (parentRect.height - submenuRect.height) / 2; + break; + } + + // 计算子菜单实际的 top 位置(基于视口) + let actualTop = parentRect.top + topOffset; + + // 限制在视口范围内(动态调整) + if (actualTop < margin) { + // 超出顶部,调整到顶部边缘 + topOffset = margin - parentRect.top; + } else if (actualTop + submenuRect.height > viewportHeight - margin) { + // 超出底部,调整到底部边缘 + topOffset = viewportHeight - margin - submenuRect.height - parentRect.top; + } + + // 设置垂直位置 + submenu.style.top = `${topOffset}px`; + submenu.style.bottom = 'auto'; + + // 检查水平位置:如果右侧超出视口,尝试显示在左侧 + const rightEdge = parentRect.right + submenuRect.width + 4; + if (rightEdge > viewportWidth - margin) { + // 显示在父菜单左侧 + submenu.style.left = 'auto'; + submenu.style.right = '100%'; + submenu.style.marginLeft = '0'; + submenu.style.marginRight = '2px'; + } else { + // 默认显示在父菜单右侧 + submenu.style.left = '100%'; + submenu.style.right = 'auto'; + submenu.style.marginLeft = '2px'; + submenu.style.marginRight = '0'; + } + } + /** * 公开方法:设置按钮右键菜单 * @param {HTMLElement} button 按钮元素 @@ -562,34 +752,34 @@ export class ButtonMenu { */ setupButtonMenu(button, getMenuItems, context = {}) { if (!button || typeof getMenuItems !== 'function') return; - + // 清理可能已存在的事件监听器 if (button._menuEventCleanup) { button._menuEventCleanup(); } - + // 添加右键菜单事件 const removeContextMenu = EventManager.addDOMListener(button, 'contextmenu', async (event) => { event.preventDefault(); event.stopPropagation(); - + // 获取当前菜单项 - 支持异步函数 try { const menuItems = await Promise.resolve(getMenuItems(context)); - if (!menuItems || menuItems.length === 0) return; - - // 显示菜单 + if (!menuItems || menuItems.length === 0) return; + + // 显示菜单 await this.showMenu(button, menuItems, context, event); } catch (error) { logger.error(`获取菜单项失败: ${error.message}`); } }); - + // 保存清理函数 button._menuEventCleanup = () => { removeContextMenu(); }; - + // 返回清理函数,方便外部调用 return button._menuEventCleanup; } @@ -602,7 +792,7 @@ export function addButtonMenuStyles() { if (!ResourceManager.isInitialized()) { ResourceManager.init(); } - + // 样式已经在popup.css中定义,不需要额外添加 logger.debug("按钮菜单样式已从popup.css加载"); } diff --git a/js/services/features.js b/js/services/features.js index fbf2125..185257d 100644 --- a/js/services/features.js +++ b/js/services/features.js @@ -17,17 +17,20 @@ let HistoryCacheService = null; let imageCaption = null; // 外部注入的 ImageCaption 类 let ImageCaption = null; +// 外部注入的 nodeHelpTranslator 实例 +let nodeHelpTranslator = null; /** * 注入依赖实例(由主入口调用) */ -export function setFeatureModuleDeps({ promptAssistant: pa, PromptAssistant: PAC, UIToolkit: ui, HistoryCacheService: hc, imageCaption: ic, ImageCaption: ICC }) { +export function setFeatureModuleDeps({ promptAssistant: pa, PromptAssistant: PAC, UIToolkit: ui, HistoryCacheService: hc, imageCaption: ic, ImageCaption: ICC, nodeHelpTranslator: nht }) { promptAssistant = pa; PromptAssistant = PAC; UIToolkit = ui; HistoryCacheService = hc; imageCaption = ic; ImageCaption = ICC; + nodeHelpTranslator = nht; // 初始化时同步日志级别 try { if (typeof window !== 'undefined' && window.FEATURES) { @@ -56,6 +59,7 @@ export const FEATURES = { translate: true, autoTranslate: false, // 自动翻译功能 imageCaption: true, // 图像反推提示词功能 + nodeHelpTranslator: true, // 节点帮助文档翻译功能 // 翻译格式化选项 translateFormatPunctuation: true, // 标点符号自动转成半角 @@ -63,6 +67,58 @@ export const FEATURES = { translateFormatDots: false, // 处理连续点号 translateFormatNewline: false, // 保留换行符 + // 系统设置 + showStreamingProgress: true, // 显示流式输出进度(终端日志) + + /** + * 从配置加载功能开关状态 + * 必须在 app.ui.settings 加载完成后调用 + */ + loadSettings() { + if (typeof app === 'undefined' || !app.ui || !app.ui.settings) return; + + // 辅助函数:加载布尔值设置,如果未设置则保持默认值 + const loadBool = (key, settingId) => { + const val = app.ui.settings.getSettingValue(settingId); + if (typeof val === 'boolean') { + this[key] = val; + } + }; + + // 加载基础功能开关 + loadBool('enabled', "PromptAssistant.Features.Enabled"); + loadBool('history', "PromptAssistant.Features.History"); + loadBool('tag', "PromptAssistant.Features.Tag"); + loadBool('expand', "PromptAssistant.Features.Expand"); + loadBool('translate', "PromptAssistant.Features.Translate"); + loadBool('imageCaption', "PromptAssistant.Features.ImageCaption"); + loadBool('nodeHelpTranslator', "PromptAssistant.Features.NodeHelpTranslator"); + loadBool('useTranslateCache', "PromptAssistant.Features.UseTranslateCache"); + + // 加载翻译格式化选项 + loadBool('translateFormatPunctuation', "PromptAssistant.Features.TranslateFormatPunctuation"); + loadBool('translateFormatSpace', "PromptAssistant.Features.TranslateFormatSpace"); + loadBool('translateFormatDots', "PromptAssistant.Features.TranslateFormatDots"); + loadBool('translateFormatNewline', "PromptAssistant.Features.TranslateFormatNewline"); + + // 加载系统设置 + loadBool('showStreamingProgress', "PromptAssistant.Settings.ShowStreamingProgress"); + + // 加载日志级别 + const logLevel = app.ui.settings.getSettingValue("PromptAssistant.Settings.LogLevel"); + if (logLevel !== undefined) { + // 确保是数字 + const level = parseInt(logLevel); + if (!isNaN(level)) { + if (typeof window !== 'undefined') { + if (!window.FEATURES) window.FEATURES = {}; + window.FEATURES.logLevel = level; + } + if (logger) logger.setLevel(level); + } + } + }, + /** * 更新所有实例的按钮显示状态 * 根据功能开关状态控制UI元素的显示和隐藏 @@ -102,8 +158,8 @@ export const FEATURES = { instance.buttons['translate'].style.display = this.translate ? 'block' : 'none'; } - // 记录日志 - logger.debug(`按钮更新 | 节点ID: ${instance.nodeId}`); + // 记录日志 (太频繁,已移除) + // logger.debug(`按钮更新 | 节点ID: ${instance.nodeId}`); } }); @@ -155,7 +211,7 @@ export function handleFeatureChange(featureName, value, oldValue) { } }); logger.debug(`功能重建 | 结果:完成 | 功能: ${featureName}`); - + // 重新计算并更新所有实例的宽度 promptAssistant.updateAllInstancesWidth(); } @@ -188,6 +244,12 @@ export function handleFeatureChange(featureName, value, oldValue) { }); } } + + // 如果是节点帮助翻译功能被启用 + if (featureName === '节点帮助翻译' && nodeHelpTranslator) { + // 启用节点帮助翻译功能 + nodeHelpTranslator.initialize(); + } } else { // 否则只更新显示状态 FEATURES.updateButtonsVisibility(); @@ -197,7 +259,13 @@ export function handleFeatureChange(featureName, value, oldValue) { // 清理所有图像小助手实例 imageCaption.cleanup(); } - + + // 如果是节点帮助翻译功能被禁用 + if (featureName === '节点帮助翻译' && !value && nodeHelpTranslator) { + // 清理节点帮助翻译功能 + nodeHelpTranslator.cleanup(); + } + // 功能开关变化时,更新所有实例的宽度 if (PromptAssistant.instances.size > 0) { promptAssistant.updateAllInstancesWidth(); diff --git a/js/services/interceptor.js b/js/services/interceptor.js deleted file mode 100644 index 4bed607..0000000 --- a/js/services/interceptor.js +++ /dev/null @@ -1,1211 +0,0 @@ -/** - * 工作流自动翻译拦截器 - * 在工作流执行时拦截CLIP节点的文本输入,实现自动翻译 - */ - -import { logger } from '../utils/logger.js'; -import { PromptFormatter } from "../utils/promptFormatter.js"; -import { TranslateCacheService } from "./cache.js"; -import { APIService } from "./api.js"; -import { FEATURES } from "./features.js"; - -// CLIP节点类型列表 - 导出以便其他模块使用 -export const CLIP_NODE_TYPES = [ - 'CLIPTextEncode', - 'CLIPTextEncodeSDXL', - 'CLIPTextEncodeSD3', - 'CLIPTextEncodeFlux', - 'CLIPTextEncodeControlnet', - 'CLIPTextEncodeHunyuanDiT', - 'CLIPTextEncodeLumina2', - 'CLIPTextEncodePixArtAlpha', - 'CLIPTextEncodeSDXLRefiner' -]; - -// 可能输出文本的节点类型 -const TEXT_OUTPUT_NODE_TYPES = [ - 'PrimitiveNode', // 原始节点 - 'Text', // 文本节点 - 'Note', // 笔记节点 - 'CLIPTextEncode', // CLIP文本编码(可能连接到其他CLIP节点) - 'Reroute', // 路由节点,用于传递数据 - 'PromptText', // 提示词文本节点 - 'TextCombine', // 文本组合节点 - 'TextMultiline', // 多行文本节点 - 'StringFunction', // 字符串函数节点 - 'StringReplace', // 字符串替换节点 - 'StringVariable', // 字符串变量节点 - 'TextNode', // 通用文本节点类型 - 'TextInput', // 文本输入节点 - 'easy positive', // easy positive提示词节点 - 'easy negative', // easy negative提示词节点 - 'positive', // 普通提示词节点 - 'negative', // 普通负面提示词节点 - 'prompt', // 提示词节点 - 'Template', // 模板节点 - 'PromptTemplate' // 提示词模板节点 -]; - -/** - * 检查节点是否为指定的CLIP类型 - */ -function isClipNode(classType) { - return CLIP_NODE_TYPES.includes(classType); -} - -/** - * 检查节点是否可能输出文本 - * @param {string} classType - 节点类型 - * @returns {boolean} - 如果节点可能输出文本,则返回true - */ -function isTextOutputNode(classType) { - // 精确匹配已知类型 - if (TEXT_OUTPUT_NODE_TYPES.includes(classType)) { - return true; - } - - // 使用名称匹配以扩展覆盖更多可能的文本节点 - // 包含这些关键词的节点类型很可能是处理文本的 - const textRelatedKeywords = [ - 'text', 'prompt', 'string', 'chat', 'word', 'message', 'note', - 'positive', 'negative', 'template', 'gpt', 'llm', 'input' - ]; - return textRelatedKeywords.some(keyword => - classType.toLowerCase().includes(keyword.toLowerCase())); -} - -/** - * 获取节点的文本输入字段名 - * 不同的CLIP节点可能有不同的文本输入字段 - */ -function getTextInputFields(classType) { - switch (classType) { - case 'CLIPTextEncodeSDXL': - return ['text_g', 'text_l']; - case 'CLIPTextEncodeLumina2': - return ['system_prompt', 'user_prompt']; - case 'CLIPTextEncodePixArtAlpha': - case 'CLIPTextEncodeSDXLRefiner': - return ['text']; - default: - return ['text']; // 大多数CLIP节点使用'text'字段 - } -} - -/** - * 递归查找上游文本源 - * @param {Object} apiWorkflow - API工作流 - * @param {Array} link - 链接数组 [nodeId, outputSlot] - * @param {number} [depth=0] - 递归深度,防止无限循环 - * @returns {Object|null} - 包含文本、源节点、源节点ID和源字段的对象,或null - */ -function findUpstreamTextSource(apiWorkflow, link, depth = 0) { - // 防止无限递归 - if (!link || !Array.isArray(link) || link.length < 2 || depth > 10) { - logger.debug(`递归查找终止 | 链接无效或达到最大深度:${depth}`); - return null; - } - - const [nodeId, outputSlot] = link; - const node = apiWorkflow[nodeId]; - - // 如果找不到节点,则停止 - if (!node) { - logger.debug(`未找到节点 | ID:${nodeId}`); - return null; - } - - logger.debug(`检查节点 | ID:${nodeId} | 类型:${node.class_type} | 输出槽:${outputSlot}`); - - // 记录节点结构以便调试 - const nodeInputsStr = JSON.stringify(node.inputs || {}); - const nodeOutputsStr = JSON.stringify(node.outputs || {}); - logger.debug(`节点详情 | 输入:${nodeInputsStr.substring(0, 200)} | 输出:${nodeOutputsStr.substring(0, 200)}`); - - // ===== 通用数据提取 ===== - // 1. 首先尝试从outputs数组中提取数据 - if (node.outputs && Array.isArray(node.outputs) && node.outputs.length > outputSlot) { - const outputValue = node.outputs[outputSlot]; - if (typeof outputValue === 'string') { - logger.debug(`从outputs数组中找到文本 | ID:${nodeId} | 槽:${outputSlot} | 文本:${outputValue.substring(0, 30)}`); - return { - text: outputValue, - sourceNode: node, - sourceNodeId: nodeId, - sourceField: `outputs[${outputSlot}]` - }; - } else if (outputValue && typeof outputValue === 'object') { - // 如果输出是对象,尝试提取其中的文本 - const extractedText = extractTextFromValue(outputValue); - if (extractedText) { - logger.debug(`从outputs数组对象中提取文本 | ID:${nodeId} | 槽:${outputSlot} | 文本:${extractedText.substring(0, 30)}`); - return { - text: extractedText, - sourceNode: node, - sourceNodeId: nodeId, - sourceField: `outputs[${outputSlot}]`, - isExtracted: true - }; - } - } - } - - // 2. 尝试从outputs对象中提取数据 - if (node.outputs && typeof node.outputs === 'object' && !Array.isArray(node.outputs)) { - const possibleOutputKeys = ['text', 'value', 'string', 'output', 'result', `output_${outputSlot}`, - 'positive', 'negative', 'prompt', 'content', 'message']; - - for (const key of possibleOutputKeys) { - if (node.outputs[key] !== undefined) { - if (typeof node.outputs[key] === 'string') { - logger.debug(`从outputs对象找到文本 | ID:${nodeId} | 键:${key} | 文本:${node.outputs[key].substring(0, 30)}`); - return { - text: node.outputs[key], - sourceNode: node, - sourceNodeId: nodeId, - sourceField: `outputs.${key}` - }; - } else if (node.outputs[key] && typeof node.outputs[key] === 'object') { - const extractedText = extractTextFromValue(node.outputs[key]); - if (extractedText) { - logger.debug(`从outputs对象属性中提取文本 | ID:${nodeId} | 键:${key} | 文本:${extractedText.substring(0, 30)}`); - return { - text: extractedText, - sourceNode: node, - sourceNodeId: nodeId, - sourceField: `outputs.${key}`, - isExtracted: true - }; - } - } - } - } - } - - // 3. 尝试直接从节点属性提取数据 - const nodeTextProps = ['output_value', 'value', 'text', 'string', 'content', 'message', 'prompt']; - for (const prop of nodeTextProps) { - if (node[prop] !== undefined && typeof node[prop] === 'string') { - logger.debug(`从节点属性中找到文本 | ID:${nodeId} | 属性:${prop} | 文本:${node[prop].substring(0, 30)}`); - return { - text: node[prop], - sourceNode: node, - sourceNodeId: nodeId, - sourceField: prop - }; - } else if (node[prop] && typeof node[prop] === 'object') { - const extractedText = extractTextFromValue(node[prop]); - if (extractedText) { - logger.debug(`从节点属性对象中提取文本 | ID:${nodeId} | 属性:${prop} | 文本:${extractedText.substring(0, 30)}`); - return { - text: extractedText, - sourceNode: node, - sourceNodeId: nodeId, - sourceField: prop, - isExtracted: true - }; - } - } - } - - // 4. 检查node.inputs中的所有字段,收集所有可能的文本字段 - // 不再仅仅关注特定字段,而是检查所有可能包含文本的字段 - if (node.inputs) { - // 先检查常见的文本字段名称 - const commonTextFields = ['text', 'value', 'string', 'prompt', 'positive', 'negative', - 'content', 'message', 'input', 'template']; - - for (const field of commonTextFields) { - if (node.inputs[field] !== undefined) { - if (typeof node.inputs[field] === 'string') { - logger.debug(`在inputs的常见字段中找到文本 | ID:${nodeId} | 字段:${field} | 文本:${node.inputs[field].substring(0, 30)}`); - return { - text: node.inputs[field], - sourceNode: node, - sourceNodeId: nodeId, - sourceField: field - }; - } else if (Array.isArray(node.inputs[field])) { - // 可能是链接到另一个节点 - const upstreamLink = node.inputs[field]; - logger.debug(`在字段${field}中找到上游链接 | ID:${nodeId} | 链接:${upstreamLink}`); - const result = findUpstreamTextSource(apiWorkflow, upstreamLink, depth + 1); - if (result) return result; - } - } - } - - // 然后检查所有其他字段 - logger.debug(`检查所有输入字段 | ID:${nodeId} | 可用字段:${Object.keys(node.inputs)}`); - - // 收集可能的数组链接(递归来源)和字符串值 - const possibleArrays = []; - const possibleStrings = []; - - for (const [field, value] of Object.entries(node.inputs)) { - // 如果是字符串,收集起来稍后处理 - if (typeof value === 'string') { - // 过滤掉太短或明显非文本内容的字段 - if (value.trim().length > 2 && !/^[,.:;\-_+*/\\|]+$/.test(value)) { - possibleStrings.push({field, value}); - } - } - // 如果是链接,收集起来稍后处理 - else if (Array.isArray(value)) { - possibleArrays.push({field, value}); - } - // 如果是对象,尝试提取文本 - else if (value && typeof value === 'object') { - const extractedText = extractTextFromValue(value); - if (extractedText) { - logger.debug(`从输入对象中提取文本 | ID:${nodeId} | 字段:${field} | 文本:${extractedText.substring(0, 30)}`); - return { - text: extractedText, - sourceNode: node, - sourceNodeId: nodeId, - sourceField: `inputs.${field}`, - isExtracted: true - }; - } - } - } - - // 首先处理可能的字符串,优先选择看起来像提示词的长字符串 - if (possibleStrings.length > 0) { - // 按字符串长度排序,优先选择较长的字符串 - possibleStrings.sort((a, b) => b.value.length - a.value.length); - - for (const {field, value} of possibleStrings) { - // 如果看起来像提示词(包含空格且长度适中)或长度足够有意义,就返回 - if (value.includes(' ') || value.length > 5) { - logger.debug(`选择可能的提示词文本 | ID:${nodeId} | 字段:${field} | 文本:${value.substring(0, 30)}`); - return { - text: value, - sourceNode: node, - sourceNodeId: nodeId, - sourceField: field - }; - } - } - - // 如果没找到合适的,返回第一个 - const {field, value} = possibleStrings[0]; - logger.debug(`使用第一个可用文本 | ID:${nodeId} | 字段:${field} | 文本:${value}`); - return { - text: value, - sourceNode: node, - sourceNodeId: nodeId, - sourceField: field - }; - } - - // 如果没找到字符串,再处理可能的数组链接 - for (const {field, value} of possibleArrays) { - logger.debug(`尝试处理链接数组 | ID:${nodeId} | 字段:${field} | 链接:${value}`); - const result = findUpstreamTextSource(apiWorkflow, value, depth + 1); - if (result) return result; - } - } - - // 5. 特殊处理:如果节点类型包含"concat"、"combine"、"join"等关键词 - // 这些节点可能将多个输入文本组合在一起 - if (node.class_type && - (node.class_type.toLowerCase().includes('concat') || - node.class_type.toLowerCase().includes('combine') || - node.class_type.toLowerCase().includes('join'))) { - - logger.debug(`检测到文本组合节点 | ID:${nodeId} | 类型:${node.class_type}`); - - // 尝试收集所有可能的文本输入 - const textParts = []; - - // 检查inputs中的所有字段,寻找可能的文本输入或链接 - if (node.inputs) { - for (const [field, value] of Object.entries(node.inputs)) { - // 跳过分隔符等非主要文本字段 - if (field.toLowerCase().includes('separator') || field.toLowerCase().includes('delimiter')) { - continue; - } - - // 如果是字符串,直接添加 - if (typeof value === 'string' && value.trim().length > 0) { - textParts.push(value); - } - // 如果是链接,尝试获取上游文本 - else if (Array.isArray(value)) { - const result = findUpstreamTextSource(apiWorkflow, value, depth + 1); - if (result && result.text) { - textParts.push(result.text); - } - } - } - } - - // 如果找到了文本部分,组合它们 - if (textParts.length > 0) { - const combinedText = textParts.join(' '); - logger.debug(`组合多个文本部分 | ID:${nodeId} | 部分数量:${textParts.length} | 组合结果:${combinedText.substring(0, 30)}...`); - return { - text: combinedText, - sourceNode: node, - sourceNodeId: nodeId, - sourceField: 'combined_text', - isCombined: true - }; - } - } - - // 6. 如果找不到其他文本,尝试从节点名称或描述中提取信息 - if (node.title && typeof node.title === 'string' && node.title.length > 3) { - logger.debug(`从节点标题中提取信息 | ID:${nodeId} | 标题:${node.title}`); - return { - text: node.title, - sourceNode: node, - sourceNodeId: nodeId, - sourceField: 'title' - }; - } - - // 7. 特殊处理:节点是路由节点(Reroute) - if (node.class_type === 'Reroute') { - logger.debug(`处理路由节点 | ID:${nodeId}`); - if (node.inputs && Object.values(node.inputs).length > 0) { - const upstreamLink = Object.values(node.inputs)[0]; - // 确保上游连接是一个有效的链接数组 - if (Array.isArray(upstreamLink)) { - logger.debug(`路由节点发现上游链接 | 链接:[${upstreamLink}]`); - return findUpstreamTextSource(apiWorkflow, upstreamLink, depth + 1); - } - } - } - - logger.debug(`未能找到文本源 | ID:${nodeId} | 类型:${node.class_type}`); - return null; -} - -/** - * 处理可能包含对象或复杂结构的文本值 - * @param {any} value - 需要处理的值 - * @returns {string|null} - 提取的文本或null - */ -function extractTextFromValue(value) { - // 如果是字符串,直接返回 - if (typeof value === 'string') { - return value; - } - - // 如果是对象,尝试提取text、value或其他常见文本字段 - if (value && typeof value === 'object' && !Array.isArray(value)) { - // 常见的文本字段名 - const textFields = ['text', 'value', 'content', 'string', 'prompt', 'message']; - for (const field of textFields) { - if (value[field] && typeof value[field] === 'string') { - return value[field]; - } - } - - // 如果有toString方法且不是默认的Object.toString - if (typeof value.toString === 'function' && - value.toString !== Object.prototype.toString) { - const strValue = value.toString(); - if (strValue !== '[object Object]') { - return strValue; - } - } - } - - return null; -} - -/** - * 检查文本是否需要翻译 - * @param {any} text - 要检查的文本内容 - * @returns {boolean} - 如果需要翻译返回true - */ -function shouldTranslateText(text) { - // 处理无效文本情况 - if (text === undefined || text === null) { - logger.debug(`跳过翻译检查 | 原因:文本为空`); - return false; - } - - // 如果不是字符串,尝试转换 - if (typeof text !== 'string') { - // 尝试从复杂值中提取文本 - const extractedText = extractTextFromValue(text); - if (!extractedText) { - logger.debug(`跳过翻译检查 | 原因:非文本类型 | 类型:${typeof text}`); - return false; - } - text = extractedText; - } - - // 检查文本是否过短 - if (text.trim().length < 3) { - logger.debug(`跳过翻译检查 | 原因:文本太短 | 文本:"${text}"`); - return false; - } - - // 检查是否是特殊标记或控制字符 - if (/^\s*\[(.*?)\]\s*$/.test(text) || /^<.*>$/.test(text)) { - logger.debug(`跳过翻译检查 | 原因:可能是特殊标记 | 文本:"${text}"`); - return false; - } - - // 检查是否只包含非文本字符(如符号、数字等) - if (!/[\p{L}]/u.test(text)) { - logger.debug(`跳过翻译检查 | 原因:不含文字字符 | 文本:"${text}"`); - return false; - } - - // 检测文本是否包含中文字符 - // 使用正则表达式检查是否包含中文字符范围 - const containsChinese = /[\u4e00-\u9fa5]/.test(text); - if (containsChinese) { - // 计算中文字符所占比例,判断是否值得翻译 - const chineseChars = text.match(/[\u4e00-\u9fa5]/g) || []; - const chineseRatio = chineseChars.length / text.length; - - // 如果中文字符比例大于1%,认为需要翻译 - if (chineseRatio > 0.01) { - const textPreview = text.length > 50 ? `${text.substring(0, 50)}...` : text; - logger.debug(`检测到中文文本 | 中文字符:${chineseChars.length} | 中文比例:${(chineseRatio * 100).toFixed(1)}% | 文本预览:"${textPreview}"`); - return true; - } - } - - // 如果上述快速检查未确定,则使用PromptFormatter的更完整语言检测 - try { - const { from, to } = PromptFormatter.detectLanguage(text); - const needTranslate = from === 'zh'; - - // 记录详细的语言检测日志 - const textPreview = text.length > 50 ? `${text.substring(0, 50)}...` : text; - logger.debug(`语言检测结果 | 源语言:${from} | 目标语言:${to} | 需要翻译:${needTranslate} | 文本预览:"${textPreview}"`); - - return needTranslate; - } catch (error) { - logger.error(`语言检测异常 | 错误:${error.message} | 文本:"${text.substring(0, 30)}..."`); - - // 发生异常时,回退到简单的中文检测 - if (containsChinese) { - logger.debug(`语言检测异常,回退到简单中文检测,检测到中文字符`); - return true; - } - return false; - } -} - -/** - * 生成请求ID - * @param {string|null} prefix - 前缀 - * @param {string} type - 翻译类型 - * @returns {string} - 请求ID - */ -function generateRequestId(prefix, type) { - const timestamp = Date.now(); - const random = Math.floor(Math.random() * 1000); - const typePrefix = type === "baidu" ? "BD" : "LLM"; - return `${prefix || typePrefix}_${timestamp}_${random}`; -} - -/** - * 翻译文本并使用缓存 - */ -async function translateWithCache(text) { - try { - const app = window.app; - - // 检查是否应该使用翻译缓存 - const useCache = FEATURES?.useTranslateCache !== false; // 默认为true - - // 如果启用了缓存,则查询翻译缓存 - if (useCache) { - const cacheResult = TranslateCacheService.queryTranslateCache(text); - - if (cacheResult && cacheResult.type === 'source') { - // 缓存命中,直接返回译文 - logger.debug(`翻译缓存命中 | 文本:"${text}" | 译文:"${cacheResult.translatedText}"`); - return { - success: true, - translated: cacheResult.translatedText, - fromCache: true - }; - } - } else { - logger.debug(`翻译缓存已禁用,将直接调用API | 文本:"${text}"`); - } - - // 缓存未命中或未启用缓存,调用翻译API - const translateType = app.settings?.["PromptAssistant.Settings.TranslateType"] || "baidu"; - - // 生成唯一request_id,根据翻译类型生成对应的前缀 - const request_id = generateRequestId(null, translateType); - - // 检测语言 - const langResult = PromptFormatter.detectLanguage(text); - - let result; - if (translateType === "baidu") { - result = await APIService.baiduTranslate(text, langResult.from, langResult.to, null, true); - } else { - result = await APIService.llmTranslate(text, langResult.from, langResult.to, null, true); - } - - if (result.success) { - // 格式化翻译结果 - const formattedText = PromptFormatter.formatTranslatedText(result.data.translated); - - // 如果启用了缓存,则添加到翻译缓存 - if (useCache) { - TranslateCacheService.addTranslateCache(text, formattedText); - logger.debug(`添加翻译到缓存 | 文本:"${text}" | 译文:"${formattedText}"`); - } - - return { - success: true, - translated: formattedText, - fromCache: false - }; - } else { - throw new Error(result.error || '翻译失败'); - } - } catch (error) { - return { - success: false, - error: error.message, - translated: text // 翻译失败时返回原文 - }; - } -} - -/** - * 工作流自动翻译拦截器 - */ -export class WorkflowAutoTranslateInterceptor { - /** - * 拦截并处理工作流数据 - * @param {Object} workflowData - 包含output和workflow的工作流数据 - * @returns {Promise} 处理后的工作流数据 - */ - static async interceptWorkflow(workflowData) { - const { output: apiWorkflow, workflow } = workflowData; - let translationCount = 0; - let cacheHitCount = 0; - let skippedCount = 0; - let portTranslationCount = 0; // 输入端口翻译计数 - let processedInputLinks = new Set(); // 用于跟踪已处理的输入链接,避免重复处理 - - // 存储所有翻译任务的Promise - const translationTasks = []; - - // 存储CLIP节点信息,优先处理CLIP节点的输入 - const clipNodes = []; - - try { - logger.debug(`开始拦截工作流 | 节点数量:${Object.keys(apiWorkflow).length}`); - - // 第一轮:先识别并收集所有CLIP节点 - for (const [nodeId, nodeData] of Object.entries(apiWorkflow)) { - if (isClipNode(nodeData.class_type)) { - clipNodes.push({ nodeId, nodeData }); - logger.debug(`发现CLIP节点 | ID:${nodeId} | 类型:${nodeData.class_type}`); - } - } - - logger.debug(`找到CLIP节点总数:${clipNodes.length}`); - - // 第二轮:处理所有CLIP节点 - for (const { nodeId, nodeData } of clipNodes) { - logger.debug(`处理CLIP节点 | ID:${nodeId} | 类型:${nodeData.class_type}`); - - // 获取该节点类型的文本输入字段 - const textFields = getTextInputFields(nodeData.class_type); - - // 处理每个文本字段 - for (const fieldName of textFields) { - const input = nodeData.inputs[fieldName]; - - if (!input) { - continue; - } - - // 处理直接输入的文本(字符串形式) - if (typeof input === 'string') { - if (shouldTranslateText(input)) { - logger.debug(`发现直接输入文本 | 节点:${nodeId} | 字段:${fieldName} | 文本:"${input.substring(0, 30)}..."`); - - // 创建翻译任务并添加到任务列表 - const translationTask = (async () => { - try { - const translateResult = await translateWithCache(input); - - if (translateResult.success) { - // 替换API工作流中的文本(传给后端的数据) - nodeData.inputs[fieldName] = translateResult.translated; - translationCount++; - - if (translateResult.fromCache) { - cacheHitCount++; - } - - logger.debug(`直接输入文本已翻译 | 节点:${nodeId} | 字段:${fieldName} | 原文:"${input}" | 译文:"${translateResult.translated}"`); - } - } catch (error) { - logger.error(`直接输入文本翻译失败 | 节点:${nodeId} | 字段:${fieldName} | 错误:${error.message}`); - // 翻译失败时保留原文 - nodeData.inputs[fieldName] = input; - } - })(); - - translationTasks.push(translationTask); - } else { - skippedCount++; - } - } - // 处理输入端口连接的情况(数组形式 [nodeId, outputSlot]) - else if (Array.isArray(input) && input.length >= 2) { - // 创建唯一的链接ID以避免重复处理 - const linkId = `${input[0]}_${input[1]}_${nodeId}_${fieldName}`; - - // 如果这个链接已经处理过,则跳过 - if (processedInputLinks.has(linkId)) { - logger.debug(`跳过重复链接 | 链接ID:${linkId}`); - continue; - } - - processedInputLinks.add(linkId); - - // 记录链接信息 - logger.debug(`处理输入端口连接 | 节点:${nodeId} | 字段:${fieldName} | 输入链接:[${input[0]},${input[1]}]`); - - // ===== 直接从输入源提取整合后的完整文本,而不是从上游节点翻译 ===== - const sourceNodeId = input[0]; - const sourceOutputSlot = input[1]; - const sourceNode = apiWorkflow[sourceNodeId]; - - // 判断源节点是否是文本组合类型节点 - const isTextCombineNode = sourceNode && sourceNode.class_type && ( - sourceNode.class_type.toLowerCase().includes('concat') || - sourceNode.class_type.toLowerCase().includes('combine') || - sourceNode.class_type.toLowerCase().includes('join') - ); - - // 如果是文本组合节点,尝试获取其完整输出 - let combinedText = null; - if (isTextCombineNode) { - logger.debug(`检测到文本组合节点 | ID:${sourceNodeId} | 类型:${sourceNode.class_type}`); - - // 尝试从组合节点直接获取完整输出 - if (sourceNode.outputs && Array.isArray(sourceNode.outputs) && sourceNode.outputs.length > sourceOutputSlot) { - if (typeof sourceNode.outputs[sourceOutputSlot] === 'string') { - combinedText = sourceNode.outputs[sourceOutputSlot]; - logger.debug(`获取到组合节点输出 | 文本:"${combinedText.substring(0, 30)}..."`); - } - } - - // 如果没有找到直接输出,尝试组合所有输入 - if (!combinedText) { - const textParts = []; - let separator = ' '; // 默认分隔符 - - // 尝试查找分隔符 - if (sourceNode.inputs && sourceNode.inputs.separator) { - if (typeof sourceNode.inputs.separator === 'string') { - separator = sourceNode.inputs.separator; - logger.debug(`使用自定义分隔符: "${separator}"`); - } - } - - // 收集所有输入 - if (sourceNode.inputs) { - for (const [key, value] of Object.entries(sourceNode.inputs)) { - // 跳过分隔符字段 - if (key.toLowerCase().includes('separator') || key.toLowerCase().includes('delimiter')) { - continue; - } - - // 直接字符串输入 - if (typeof value === 'string') { - textParts.push(value); - logger.debug(`添加文本部分: "${value.substring(0, 20)}..."`); - } - // 链接输入 - else if (Array.isArray(value)) { - const upstreamText = findUpstreamTextSource(apiWorkflow, value); - if (upstreamText && typeof upstreamText.text === 'string') { - textParts.push(upstreamText.text); - logger.debug(`添加上游文本: "${upstreamText.text.substring(0, 20)}..."`); - } - } - } - } - - // 组合所有文本部分 - if (textParts.length > 0) { - combinedText = textParts.join(separator); - logger.debug(`组合多个部分得到文本 | 部分数:${textParts.length} | 结果:"${combinedText.substring(0, 30)}..."`); - } - } - } - - // 如果找到了组合文本且需要翻译 - if (combinedText && shouldTranslateText(combinedText)) { - logger.debug(`对组合节点的完整文本进行翻译 | 文本:"${combinedText.substring(0, 30)}..."`); - - const translationTask = (async () => { - try { - const translateResult = await translateWithCache(combinedText); - - if (translateResult.success) { - // 创建一个特殊字段保存完整的翻译结果,以便后续替换 - nodeData._translatedInput = { - field: fieldName, - originalLink: input.slice(), // 复制链接数组 - translatedText: translateResult.translated - }; - - // 修改输入为直接文本 - nodeData.inputs[fieldName] = translateResult.translated; - - translationCount++; - portTranslationCount++; - - if (translateResult.fromCache) { - cacheHitCount++; - } - - logger.debug(`组合文本已翻译 | 节点:${nodeId} | 字段:${fieldName} | 原文:"${combinedText}" | 译文:"${translateResult.translated}"`); - return; // 成功翻译组合文本,不再继续查找上游文本源 - } - } catch (error) { - logger.error(`组合文本翻译失败 | 节点:${nodeId} | 字段:${fieldName} | 错误:${error.message}`); - } - })(); - - translationTasks.push(translationTask); - continue; // 跳过后续处理 - } - - // 如果没有找到组合文本或翻译失败,则继续使用常规方法递归查找上游文本源 - const textSourceInfo = findUpstreamTextSource(apiWorkflow, input); - - // 如果找到文本源 - if (textSourceInfo) { - // 尝试从文本源中提取文本 - let extractedText = textSourceInfo.text; - - // 如果text不是字符串,尝试提取 - if (extractedText !== undefined && typeof extractedText !== 'string') { - const extractedValue = extractTextFromValue(extractedText); - if (extractedValue) { - // 更新textSourceInfo中的文本 - extractedText = extractedValue; - textSourceInfo.text = extractedValue; - logger.debug(`成功从复杂数据结构中提取文本 | 节点:${nodeId} | 文本:"${extractedValue.substring(0, 30)}..."`); - } - } - - // 如果有文本并且需要翻译 - if (extractedText && typeof extractedText === 'string' && shouldTranslateText(extractedText)) { - const { sourceNode, sourceNodeId, sourceField } = textSourceInfo; - - logger.debug(`发现上游文本源 | 节点:${nodeId} | 字段:${fieldName} | 源节点:${sourceNodeId} | 源字段:${sourceField} | 文本:"${extractedText.substring(0, 30)}..."`); - - // 创建翻译任务 - const translationTask = (async () => { - try { - const translateResult = await translateWithCache(extractedText); - - if (translateResult.success) { - // 直接更新源节点的文本值 - if (sourceField.startsWith('outputs[') && sourceField.endsWith(']')) { - // 处理outputs数组情况 - const outputIndex = parseInt(sourceField.match(/\[(\d+)\]/)[1], 10); - if (Array.isArray(sourceNode.outputs)) { - sourceNode.outputs[outputIndex] = translateResult.translated; - } - } else if (sourceField.includes('.')) { - // 处理嵌套属性情况 - const parts = sourceField.split('.'); - let target = sourceNode; - for (let i = 0; i < parts.length - 1; i++) { - target = target[parts[i]]; - } - target[parts[parts.length - 1]] = translateResult.translated; - } else { - // 标准情况 - if (sourceNode.inputs && sourceField in sourceNode.inputs) { - sourceNode.inputs[sourceField] = translateResult.translated; - } else { - sourceNode[sourceField] = translateResult.translated; - } - } - - portTranslationCount++; - translationCount++; - - if (translateResult.fromCache) { - cacheHitCount++; - } - - logger.debug(`输入端口文本已翻译 | 节点:${nodeId} | 源节点:${sourceNodeId} | 源字段:${sourceField} | 原文:"${extractedText.substring(0, 30)}..." | 译文:"${translateResult.translated.substring(0, 30)}..."`); - } else { - logger.warn(`输入端口文本翻译失败 | 节点:${nodeId} | 源节点:${sourceNodeId} | 源字段:${sourceField} | 原因:${translateResult.error}`); - } - } catch (error) { - logger.error(`输入端口文本翻译异常 | 节点:${nodeId} | 源节点:${sourceNodeId} | 错误:${error.message}`); - // 翻译失败时保留原文 - } - })(); - - translationTasks.push(translationTask); - } else { - if (!extractedText) { - logger.debug(`上游文本源无有效文本 | 节点:${nodeId} | 字段:${fieldName} | 源节点:${textSourceInfo.sourceNodeId} | 源字段:${textSourceInfo.sourceField} | 值类型:${typeof textSourceInfo.text}`); - } else if (typeof extractedText !== 'string') { - logger.debug(`上游文本不是字符串 | 节点:${nodeId} | 字段:${fieldName} | 源节点:${textSourceInfo.sourceNodeId} | 值类型:${typeof extractedText}`); - } else { - logger.debug(`跳过翻译 | 节点:${nodeId} | 字段:${fieldName} | 源节点:${textSourceInfo.sourceNodeId} | 原因:不需要翻译`); - } - skippedCount++; - } - } else { - logger.debug(`未找到上游文本源 | 节点:${nodeId} | 字段:${fieldName} | 输入链接:[${input[0]},${input[1]}]`); - - // 尝试直接访问源节点 - try { - const sourceNodeId = input[0]; - const sourceOutputSlot = input[1]; - const sourceNode = apiWorkflow[sourceNodeId]; - - if (sourceNode) { - logger.debug(`尝试直接访问源节点 | 源节点ID:${sourceNodeId} | 类型:${sourceNode.class_type}`); - - // 创建一个新链接,使用通用的findUpstreamTextSource函数 - const textSourceInfo = findUpstreamTextSource(apiWorkflow, [sourceNodeId, sourceOutputSlot], 0); - - if (textSourceInfo && textSourceInfo.text) { - logger.debug(`直接访问源节点成功找到文本 | 源节点:${sourceNodeId} | 文本:"${textSourceInfo.text.substring(0, 30)}..."`); - - // 如果找到了文本源且需要翻译,创建翻译任务 - if (shouldTranslateText(textSourceInfo.text)) { - const translationTask = (async () => { - try { - const translateResult = await translateWithCache(textSourceInfo.text); - - if (translateResult.success) { - // 更新源节点的文本 - // 根据返回的sourceField确定如何更新 - const { sourceNode, sourceField, isExtracted, isCombined } = textSourceInfo; - - // 根据sourceField类型和位置更新源节点 - if (sourceField === 'combined_text' || isExtracted || isCombined) { - // 特殊情况:组合或提取的文本 - logger.debug(`翻译组合/提取文本 | 源节点:${sourceNodeId} | 字段:${sourceField}`); - } - else if (sourceField.startsWith('outputs[') && sourceField.endsWith(']')) { - // 处理outputs数组情况 - const match = sourceField.match(/\[(\d+)\]/); - if (match) { - const outputIndex = parseInt(match[1], 10); - if (Array.isArray(sourceNode.outputs)) { - sourceNode.outputs[outputIndex] = translateResult.translated; - logger.debug(`更新源节点outputs数组 | 源节点:${sourceNodeId} | 索引:${outputIndex}`); - } - } - } - else if (sourceField.startsWith('outputs.')) { - // 处理outputs对象属性 - const key = sourceField.substring('outputs.'.length); - if (sourceNode.outputs && typeof sourceNode.outputs === 'object') { - sourceNode.outputs[key] = translateResult.translated; - logger.debug(`更新源节点outputs对象 | 源节点:${sourceNodeId} | 键:${key}`); - } - } - else if (sourceField.startsWith('inputs.')) { - // 处理inputs对象属性 - const key = sourceField.substring('inputs.'.length); - if (sourceNode.inputs && typeof sourceNode.inputs === 'object') { - sourceNode.inputs[key] = translateResult.translated; - logger.debug(`更新源节点inputs对象 | 源节点:${sourceNodeId} | 键:${key}`); - } - } - else { - // 直接字段或属性 - if (sourceNode.inputs && sourceField in sourceNode.inputs) { - // 更新inputs中的字段 - sourceNode.inputs[sourceField] = translateResult.translated; - logger.debug(`更新源节点inputs字段 | 源节点:${sourceNodeId} | 字段:${sourceField}`); - } else { - // 更新节点直接属性 - sourceNode[sourceField] = translateResult.translated; - logger.debug(`更新源节点属性 | 源节点:${sourceNodeId} | 属性:${sourceField}`); - } - } - - // 通用回退:如果无法确定准确的位置,尝试在所有位置寻找 - let needFallback = true; - - // 检查更新是否成功 - if (sourceField === 'combined_text' || isExtracted || isCombined) { - // 对于组合文本或提取文本,需要逐个检查所有位置 - needFallback = true; - } - else if (sourceField.startsWith('outputs[')) { - const match = sourceField.match(/\[(\d+)\]/); - if (match) { - const outputIndex = parseInt(match[1], 10); - if (Array.isArray(sourceNode.outputs) && - sourceNode.outputs[outputIndex] === translateResult.translated) { - needFallback = false; - } - } - } - else if (sourceField.startsWith('outputs.')) { - const key = sourceField.substring('outputs.'.length); - if (sourceNode.outputs && sourceNode.outputs[key] === translateResult.translated) { - needFallback = false; - } - } - else if (sourceField.startsWith('inputs.')) { - const key = sourceField.substring('inputs.'.length); - if (sourceNode.inputs && sourceNode.inputs[key] === translateResult.translated) { - needFallback = false; - } - } - else { - if (sourceNode.inputs && sourceField in sourceNode.inputs && - sourceNode.inputs[sourceField] === translateResult.translated) { - needFallback = false; - } else if (sourceNode[sourceField] === translateResult.translated) { - needFallback = false; - } - } - - // 如果需要回退,则尝试所有位置 - if (needFallback) { - logger.debug(`尝试回退方式更新源节点 | 源节点:${sourceNodeId}`); - - // 1. 检查inputs中的所有字段 - if (sourceNode.inputs) { - for (const [key, value] of Object.entries(sourceNode.inputs)) { - if (value === textSourceInfo.text) { - sourceNode.inputs[key] = translateResult.translated; - logger.debug(`回退更新: inputs.${key} | 源节点:${sourceNodeId}`); - needFallback = false; - break; - } - } - } - - // 2. 检查outputs数组 - if (needFallback && Array.isArray(sourceNode.outputs)) { - for (let i = 0; i < sourceNode.outputs.length; i++) { - if (sourceNode.outputs[i] === textSourceInfo.text) { - sourceNode.outputs[i] = translateResult.translated; - logger.debug(`回退更新: outputs[${i}] | 源节点:${sourceNodeId}`); - needFallback = false; - break; - } - } - } - - // 3. 检查outputs对象 - if (needFallback && sourceNode.outputs && typeof sourceNode.outputs === 'object') { - for (const [key, value] of Object.entries(sourceNode.outputs)) { - if (value === textSourceInfo.text) { - sourceNode.outputs[key] = translateResult.translated; - logger.debug(`回退更新: outputs.${key} | 源节点:${sourceNodeId}`); - needFallback = false; - break; - } - } - } - - // 4. 检查节点属性 - if (needFallback) { - for (const [key, value] of Object.entries(sourceNode)) { - if (value === textSourceInfo.text && typeof value === 'string') { - sourceNode[key] = translateResult.translated; - logger.debug(`回退更新: ${key} | 源节点:${sourceNodeId}`); - needFallback = false; - break; - } - } - } - } - - portTranslationCount++; - translationCount++; - - if (translateResult.fromCache) { - cacheHitCount++; - } - - logger.debug(`输入端口文本已翻译 | 源节点:${sourceNodeId} | 原文:"${textSourceInfo.text.substring(0, 30)}..." | 译文:"${translateResult.translated.substring(0, 30)}..."`); - } else { - logger.warn(`输入端口文本翻译失败 | 源节点:${sourceNodeId} | 原因:${translateResult.error}`); - } - } catch (error) { - logger.error(`输入端口文本翻译异常 | 源节点:${sourceNodeId} | 错误:${error.message}`); - } - })(); - - translationTasks.push(translationTask); - } else { - logger.debug(`跳过翻译 | 源节点:${sourceNodeId} | 原因:不需要翻译`); - } - } else { - logger.debug(`直接访问源节点未找到文本 | 源节点:${sourceNodeId}`); - } - } - } catch (error) { - logger.error(`直接访问源节点异常 | 错误:${error.message} | 堆栈:${error.stack}`); - } - - skippedCount++; - } - } - } - } - - // 等待所有翻译任务完成 - if (translationTasks.length > 0) { - logger.debug(`等待${translationTasks.length}个翻译任务完成...`); - await Promise.all(translationTasks); - } - - if (translationCount > 0) { - logger.log(`工作流翻译完成 | 总计:${translationCount} | 缓存命中:${cacheHitCount} | 跳过:${skippedCount} | 输入端口:${portTranslationCount}`); - } else { - logger.debug(`工作流无需翻译 | 跳过:${skippedCount}`); - } - - } catch (error) { - logger.error(`工作流翻译拦截异常 | 错误:${error.message}`); - } - - return workflowData; - } -} - -/** - * 安装自动翻译拦截器(一次性) - * 采用统一的功能开关逻辑,与其他功能保持一致 - * @param {number} [maxRetries=10] - 最大重试次数 - * @param {number} [retryDelay=500] - 重试延迟(毫秒) - * @returns {Promise} - 安装是否成功 - */ -export function ensureAutoTranslateInterceptorInstalled(maxRetries = 10, retryDelay = 1000) { // 重试间隔从500ms增加到1000ms - let currentRetry = 0; - - const attemptInstall = () => { - try { - const app = window.app; - - // 检查是否已经安装过拦截器 - if (app && app.api && app.api._autoTranslateInstalled) { - logger.debug('自动翻译拦截器已安装,无需重复安装'); - return true; - } - - // 如果API还没准备好,稍后再试 - if (!app || !app.api || typeof app.api.queuePrompt !== 'function') { - if (currentRetry < maxRetries) { - currentRetry++; - logger.debug(`API未准备好,${retryDelay}ms后重试(${currentRetry}/${maxRetries})...`); - setTimeout(() => attemptInstall(), retryDelay); - return false; - } else { - logger.error(`安装自动翻译拦截器失败: 达到最大重试次数(${maxRetries}),API未准备好`); - return false; - } - } - - // 保存原始方法 - app.api._originalQueuePrompt = app.api.queuePrompt.bind(app.api); - app.api._autoTranslateInstalled = true; - - // 重写queuePrompt方法 - app.api.queuePrompt = async function (number, data) { - try { - // 检查自动翻译功能是否启用(使用统一的FEATURES对象) - let features = { autoTranslate: true }; // 默认启用 - - try { - // 动态导入功能开关配置 - const { FEATURES } = await import('./features.js'); - features = FEATURES; - } catch (importError) { - logger.warn(`导入功能开关模块失败,使用默认设置 | 错误:${importError.message}`); - } - - // 如果自动翻译功能已启用,则拦截并处理工作流数据 - if (features.autoTranslate) { - // 记录调试信息 - logger.debug(`拦截工作流请求 | 编号:${number}`); - - // 在发送前拦截并处理工作流数据 - const processedData = await WorkflowAutoTranslateInterceptor.interceptWorkflow(data); - - // 调用原始方法,传递处理后的数据 - return await app.api._originalQueuePrompt(number, processedData); - } else { - logger.debug('自动翻译功能已禁用,跳过处理'); - // 功能关闭时直接调用原始方法 - return await app.api._originalQueuePrompt(number, data); - } - } catch (error) { - // 出现异常时记录错误并继续使用原始数据 - logger.error(`工作流拦截处理失败 | 错误:${error.message}`); - logger.error(`错误堆栈: ${error.stack}`); - // 发生错误时使用原始数据,确保即使翻译失败,原始功能仍然可用 - return await app.api._originalQueuePrompt(number, data); - } - }; - - // 添加卸载方法,便于日后调试或清理 - app.api._uninstallAutoTranslate = function() { - if (app.api._originalQueuePrompt) { - app.api.queuePrompt = app.api._originalQueuePrompt; - delete app.api._originalQueuePrompt; - delete app.api._autoTranslateInstalled; - logger.log('自动翻译拦截器已卸载'); - return true; - } - return false; - }; - - // 添加重新安装方法 - app.api._reinstallAutoTranslate = function() { - if (app.api._uninstallAutoTranslate()) { - return ensureAutoTranslateInterceptorInstalled(); - } - return false; - }; - - logger.log('自动翻译拦截器已成功安装(统一功能开关模式)'); - return true; - } catch (error) { - logger.error(`自动翻译拦截器安装异常 | 错误:${error.message}`); - logger.error(`错误堆栈: ${error.stack}`); - - // 尝试错误恢复 - if (currentRetry < maxRetries) { - currentRetry++; - logger.debug(`安装失败,${retryDelay * 2}ms后重试(${currentRetry}/${maxRetries})...`); - setTimeout(() => attemptInstall(), retryDelay * 2); - return false; - } - - return false; - } - }; - - // 开始安装尝试 - return attemptInstall(); -} - -// 移除自动安装逻辑,改为由index.js直接控制初始化 -// 这样可以避免与PromptAssistant.js的相互依赖,并使初始化流程更清晰 \ No newline at end of file diff --git a/js/utils/UIToolkit.js b/js/utils/UIToolkit.js index 5830274..ac5afcb 100644 --- a/js/utils/UIToolkit.js +++ b/js/utils/UIToolkit.js @@ -26,32 +26,199 @@ class UIToolkit { error: (msg) => msg || '翻译失败' }, expand: { - loading: '扩写中', - success: '扩写完成', - error: (msg) => msg || '扩写失败' + loading: '提示词优化中', + success: '提示词优化完成', + error: (msg) => msg || '提示词优化失败' } }; /** * 检查输入控件是否为有效的文本输入 + * 支持传统litegraph模式和Vue node2.0模式 */ - static isValidInput(widget) { - // 简化检测逻辑:直接判断是否有TEXTAREA + static isValidInput(widget, options = {}) { + const { debug = false, node = null } = options; let isValid = false; + let reason = ''; - // 标准文本输入控件 + // 方法1: 标准文本输入控件(传统litegraph模式) if (widget.inputEl && widget.inputEl.tagName === "TEXTAREA" && this.VALID_INPUT_IDS.includes(widget.name)) { isValid = true; + reason = 'litegraph textarea matched'; } - // Note节点特殊输入 + // 方法2: Note节点特殊输入 else if (widget.element && widget.element.tagName === "TEXTAREA") { isValid = true; + reason = 'Note textarea matched'; + } + // 方法3: Markdown Note节点特殊输入(Tiptap编辑器) + else if (this.isMarkdownNoteInput(widget)) { + isValid = true; + reason = 'Markdown Note matched'; + } + // 方法4: Vue node2.0 模式检测 - 通过节点类型判断 + else if (this._isVueNodesModeWidget(widget, node)) { + isValid = true; + reason = 'Vue node2.0 mode widget'; + } + + if (debug) { + const widgetName = widget.name || widget.id || 'unknown'; + const nodeType = node?.type || widget.node?.type || 'unknown'; + logger.debug(`[isValidInput] 控件: ${widgetName} | 节点类型: ${nodeType} | 有效: ${isValid} | 原因: ${reason}`); } return isValid; } + /** + * 检查是否为 Vue node2.0 模式下的有效文本控件 + * 在Vue模式下,widget对象可能没有inputEl/element,但节点类型可以判断 + */ + static _isVueNodesModeWidget(widget, node = null) { + // 获取节点引用 + const nodeRef = node || widget.node; + if (!nodeRef) return false; + + // 检查是否为Vue节点模式 + if (typeof LiteGraph !== 'undefined' && LiteGraph.vueNodesMode !== true) { + return false; + } + + // 已知支持的节点类型 + const supportedNodeTypes = [ + 'Note', + 'MarkdownNote', + 'PreviewAny', // Preview as Text节点(实际类型) + 'PreviewTextNode', // Preview节点可能的其他名称 + 'Show any [Crystools]', + // 添加其他支持的节点类型 + ]; + + // 检查节点类型 + if (supportedNodeTypes.includes(nodeRef.type)) { + return true; + } + + // 检查控件名称是否为有效输入 + if (this.VALID_INPUT_IDS.includes(widget.name)) { + return true; + } + + // Markdown类型节点检测(包括Preview as Text等使用comfy-markdown的节点) + const typeLower = nodeRef.type?.toLowerCase() || ''; + if (typeLower.includes('markdown') || + (typeLower.includes('preview') && typeLower.includes('text'))) { + return true; + } + + return false; + } + + /** + * 检查是否为使用comfy-markdown的节点输入框 + * 包括 MarkdownNote、Preview as Text 等使用 Tiptap/ProseMirror 编辑器的节点 + */ + static isMarkdownNoteInput(widget) { + // 方法1: 检查节点类型 + const nodeRef = widget.node; + if (nodeRef) { + // 支持的使用comfy-markdown的节点类型 + const markdownNodeTypes = ['MarkdownNote', 'PreviewAny', 'PreviewTextNode']; + if (markdownNodeTypes.includes(nodeRef.type)) { + return true; + } + + // 检查节点类型名称是否包含相关关键词 + const typeLower = nodeRef.type?.toLowerCase() || ''; + if (typeLower.includes('markdown') || + (typeLower.includes('preview') && typeLower.includes('text'))) { + return true; + } + } + + // 方法2: 检查widget名称(Vue模式下可能只有名称) + if (widget.name === 'text' && nodeRef && nodeRef.type?.toLowerCase().includes('markdown')) { + return true; + } + + // 方法3: 通过 DOM 结构检测 + let element = widget.element || widget.inputEl; + if (!element) return false; + + // 向上查找 .comfy-markdown 容器 + let parent = element.parentElement; + while (parent) { + if (parent.classList && parent.classList.contains('comfy-markdown')) { + return true; + } + // Vue模式下检测 widget-markdown 类 + if (parent.classList && parent.classList.contains('widget-markdown')) { + return true; + } + parent = parent.parentElement; + } + + // 检查元素本身是否是 Tiptap 编辑器 + if (element.classList && + (element.classList.contains('tiptap') || element.classList.contains('ProseMirror'))) { + return true; + } + + return false; + } + + /** + * 获取 Markdown Note 的 Tiptap 编辑器实例 + * @param {HTMLElement} element - 输入框元素或相关元素 + * @returns {Object|null} Tiptap 编辑器实例,如果找不到则返回 null + */ + static getMarkdownNoteEditor(element) { + if (!element) return null; + + // 查找 .comfy-markdown 容器 + let container = element; + if (!container.classList || !container.classList.contains('comfy-markdown')) { + container = element.closest('.comfy-markdown'); + } + + if (!container) return null; + + // 查找 Tiptap 编辑器元素 + const editorElement = container.querySelector('.tiptap.ProseMirror, .ProseMirror.tiptap'); + if (!editorElement) return null; + + // 尝试从元素获取 Tiptap 编辑器实例 + // Tiptap 通常会将编辑器实例存储在元素的某个属性中 + if (editorElement.__tiptap_editor) { + return editorElement.__tiptap_editor; + } + + // 尝试从全局或父元素查找 + if (container.__tiptap_editor) { + return container.__tiptap_editor; + } + + // 如果找不到实例,返回编辑器元素本身,后续可以通过 DOM 操作 + return { + element: editorElement, + getHTML: () => editorElement.innerHTML, + getText: () => editorElement.textContent || editorElement.innerText, + setContent: (content) => { + if (editorElement.__tiptap_editor) { + editorElement.__tiptap_editor.commands.setContent(content); + } else { + // 降级方案:直接操作 DOM + editorElement.innerHTML = content; + // 触发更新事件 + editorElement.dispatchEvent(new Event('input', { bubbles: true })); + editorElement.dispatchEvent(new Event('change', { bubbles: true })); + } + } + }; + } + /** * 显示状态提示 */ @@ -539,6 +706,27 @@ class UIToolkit { if (mapping && mapping.inputEl) { const inputEl = mapping.inputEl; + // 检查是否为 Markdown Note + const editor = this.getMarkdownNoteEditor(inputEl); + if (editor) { + // 使用 Tiptap 编辑器写入内容 + editor.setContent(content); + + // 添加高亮效果(对编辑器元素) + if (options.highlight && editor.element) { + this._highlightInput(editor.element); + } + + // 聚焦编辑器 + if (options.focus && editor.element) { + editor.element.focus(); + } + + logger.debug(`内容写入 | 结果:成功 | 节点:${nodeId} | 输入框:${inputId} | 类型:Markdown Note`); + return true; + } + + // 标准输入框处理 // 将内容写入输入框 inputEl.value = content; @@ -573,6 +761,48 @@ class UIToolkit { if (mapping && mapping.inputEl) { const inputEl = mapping.inputEl; + + // 检查是否为 Markdown Note + const editor = this.getMarkdownNoteEditor(inputEl); + if (editor) { + // 对于 Markdown Note,使用 Tiptap 的插入命令 + if (editor.element && editor.element.__tiptap_editor) { + const tiptapEditor = editor.element.__tiptap_editor; + // 使用 Tiptap 的 insertContent 命令 + tiptapEditor.commands.insertContent(content); + } else { + // 降级方案:获取当前文本,插入内容,然后设置回去 + const currentText = editor.getText(); + const selection = window.getSelection(); + let cursorPos = 0; + if (selection && selection.rangeCount > 0) { + const range = selection.getRangeAt(0); + const preCaretRange = range.cloneRange(); + preCaretRange.selectNodeContents(editor.element); + preCaretRange.setEnd(range.endContainer, range.endOffset); + cursorPos = preCaretRange.toString().length; + } + const beforeText = currentText.substring(0, cursorPos); + const afterText = currentText.substring(cursorPos); + const newContent = beforeText + content + afterText; + editor.setContent(newContent); + } + + // 添加高亮效果 + if (options.highlight && editor.element) { + this._highlightInput(editor.element); + } + + // 保持焦点 + if (options.keepFocus && editor.element) { + editor.element.focus(); + } + + logger.debug(`内容插入 | 结果:成功 | 节点:${nodeId} | 输入框:${inputId} | 类型:Markdown Note`); + return true; + } + + // 标准输入框处理 const currentValue = inputEl.value; const cursorPos = inputEl.selectionStart; const beforeText = currentValue.substring(0, cursorPos); @@ -672,7 +902,7 @@ class UIToolkit { // 根据按钮ID显示不同的取消提示 let cancelMessage = '请求已取消'; if (buttonId === 'expand') { - cancelMessage = '扩写已取消'; + cancelMessage = '提示词优化已取消'; } else if (buttonId === 'translate') { cancelMessage = '翻译已取消'; } diff --git a/js/utils/logger.js b/js/utils/logger.js index 5762b8b..b500343 100644 --- a/js/utils/logger.js +++ b/js/utils/logger.js @@ -28,35 +28,40 @@ class Logger { log(message) { if (this.level >= LOG_LEVELS.BASIC) { - console.log(`[PromptAssistant-系统] ${message}`); + const msg = typeof message === 'function' ? message() : message; + console.log(`[PromptAssistant-系统] ${msg}`); } } debug(message) { if (this.level >= LOG_LEVELS.DEBUG) { - // 统一的调试输出入口。为防止刷屏,可考虑在此处加入节流/采样。 - console.debug(`[PromptAssistant-调试] ${message}`); + // 支持传入函数以实现惰性求值,避免在非调试模式下的性能开销 + const msg = typeof message === 'function' ? message() : message; + console.debug(`[PromptAssistant-调试] ${msg}`); } } /** * 轻量调试(采样) * 仅当随机命中概率时才输出,用于高频路径 - * @param {string} message + * @param {string|Function} message * @param {number} rate 0~1,默认0.1表示10%采样 */ debugSample(message, rate = 0.1) { if (this.level >= LOG_LEVELS.DEBUG && Math.random() < rate) { - console.debug(`[PromptAssistant-调试] ${message}`); + const msg = typeof message === 'function' ? message() : message; + console.debug(`[PromptAssistant-调试] ${msg}`); } } error(message) { - console.error(`[PromptAssistant-错误] ${message}`); + const msg = typeof message === 'function' ? message() : message; + console.error(`[PromptAssistant-错误] ${msg}`); } warn(message) { - console.warn(`[PromptAssistant-警告] ${message}`); + const msg = typeof message === 'function' ? message() : message; + console.warn(`[PromptAssistant-警告] ${msg}`); } /** diff --git a/js/utils/markdownNoteTranslate.js b/js/utils/markdownNoteTranslate.js new file mode 100644 index 0000000..69ce40a --- /dev/null +++ b/js/utils/markdownNoteTranslate.js @@ -0,0 +1,157 @@ +/** + * Markdown Note 翻译辅助工具(零依赖) + * - HTML -> 占位 -> 翻译回填 -> HTML + * - 保护代码块/内联代码与链接/图片的 URL 属性,只翻译可见文本与可选的 img.alt + */ + +// ---配置项--- +const DEFAULT_OPTIONS = { + translateImageAlt: true, // 是否翻译 + keepSurroundingPunctuation: false // 是否保持两端标点不翻译 +}; + +// 占位符生成 +const PH_PREFIX = "⟪T"; +const PH_SUFFIX = "⟫"; + +// 判断是否在 code 上下文中 +function isInCodeContext(node) { + while (node) { + if (node.nodeType === Node.ELEMENT_NODE) { + const tag = node.tagName?.toLowerCase(); + if (tag === 'code' || tag === 'pre') return true; + } + node = node.parentNode; + } + return false; +} + +// 判断属性是否为 URL/不翻译属性 +function isNonTranslatableAttr(name) { + if (!name) return true; + const n = name.toLowerCase(); + return n === 'href' || n === 'src' || n.startsWith('data-') || n === 'title'; +} + +// 拆分前后空白与标点 +function splitLeadingTrailing(text, keepPunct) { + if (!text) return { lead: '', core: '', trail: '' }; + let leadWS = text.match(/^\s+/)?.[0] || ''; + let trailWS = text.match(/\s+$/)?.[0] || ''; + let core = text.slice(leadWS.length, text.length - trailWS.length); + + if (keepPunct && core) { + const punctSet = new Set([',', ',', '.', '。', '!', '!', '?', '?', ':', ':', ';', ';']); + let left = 0; + while (left < core.length && punctSet.has(core[left])) left++; + let right = core.length - 1; + while (right >= left && punctSet.has(core[right])) right--; + const leftP = core.slice(0, left); + const mid = core.slice(left, right + 1); + const rightP = core.slice(right + 1); + return { lead: leadWS + leftP, core: mid, trail: rightP + trailWS }; + } + + return { lead: leadWS, core, trail: trailWS }; +} + +// 遍历文本节点并占位 +function protectAndExtract(html, options = {}) { + const opts = { ...DEFAULT_OPTIONS, ...options }; + const parser = new DOMParser(); + const doc = parser.parseFromString(html, 'text/html'); + const body = doc.body; + + const texts = []; + const placeholders = []; + let index = 0; + + const walker = doc.createTreeWalker(body, NodeFilter.SHOW_TEXT, { + acceptNode(node) { + if (!node || !node.nodeValue) return NodeFilter.FILTER_REJECT; + // 跳过纯空白 + if (!node.nodeValue.trim()) return NodeFilter.FILTER_REJECT; + // 代码上下文跳过 + if (isInCodeContext(node)) return NodeFilter.FILTER_REJECT; + // 属性文本不在 TreeWalker 中出现,这里只过滤可见文本节点 + return NodeFilter.FILTER_ACCEPT; + } + }); + + const nodeRecords = []; + + while (walker.nextNode()) { + const node = walker.currentNode; + // 如果在链接或图片等标签内,仅翻译可见文本 + const parentEl = node.parentElement; + if (parentEl) { + const tag = parentEl.tagName?.toLowerCase(); + if (tag === 'a') { + // 仅翻译节点文本,属性跳过 + } else if (tag === 'img') { + // 文本节点一般不出现在 下,这里忽略 + } + } + + const { lead, core, trail } = splitLeadingTrailing(node.nodeValue, opts.keepSurroundingPunctuation); + if (!core) continue; // 全是空白或标点且设置保持 + + const ph = `${PH_PREFIX}${index}${PH_SUFFIX}`; + texts.push(core); + placeholders.push(ph); + nodeRecords.push({ node, lead, trail, ph }); + index++; + } + + // 将文本节点替换为 占位结构:lead + PH + trail + for (const rec of nodeRecords) { + rec.node.nodeValue = `${rec.lead}${rec.ph}${rec.trail}`; + } + + // 可选:处理 + const imgList = Array.from(body.querySelectorAll('img[alt]')); + const imgAltRecords = []; + if (opts.translateImageAlt && imgList.length) { + for (const img of imgList) { + const alt = img.getAttribute('alt'); + if (alt && alt.trim() && !isInCodeContext(img)) { + const { lead, core, trail } = splitLeadingTrailing(alt, opts.keepSurroundingPunctuation); + if (!core) continue; + const ph = `${PH_PREFIX}${index}${PH_SUFFIX}`; + texts.push(core); + placeholders.push(ph); + imgAltRecords.push({ el: img, lead, trail, ph }); + index++; + } + } + } + + for (const rec of imgAltRecords) { + rec.el.setAttribute('alt', `${rec.lead}${rec.ph}${rec.trail}`); + } + + // 返回占位后的 HTML 与文本数组 + return { + placeholderHTML: body.innerHTML, + texts, + placeholders + }; +} + +// 回填翻译 +function restoreWithTranslations(placeholderHTML, placeholders, translations) { + let html = placeholderHTML; + for (let i = 0; i < placeholders.length; i++) { + const ph = placeholders[i]; + const tr = translations[i] ?? ''; + // 只替换一次以保持顺序 + html = html.replace(ph, tr); + } + return html; +} + +export const MarkdownNoteTranslate = { + protectAndExtract, + restoreWithTranslations, + constants: { PH_PREFIX, PH_SUFFIX }, +}; diff --git a/js/utils/popupManager.js b/js/utils/popupManager.js index 8e0b293..76c69f5 100644 --- a/js/utils/popupManager.js +++ b/js/utils/popupManager.js @@ -36,10 +36,16 @@ class PopupManager { // 如果已有其他弹窗,先关闭它 if (this.activePopup && this.activePopup !== popup) { + console.log(`[PopupManager] 切换弹窗 | 关闭旧弹窗,准备打开新弹窗`); + // 保存当前活动弹窗的信息 const oldPopupInfo = this.activePopupInfo; const oldPopup = this.activePopup; + // 【关键】标记正在切换弹窗,避免在切换期间触发折叠 + this._isTransitioning = true; + console.log(`[PopupManager] 设置 _isTransitioning = true`); + // 清除当前活动弹窗引用,避免hidePopup中的状态冲突 this.activePopup = null; this.activePopupInfo = null; @@ -68,6 +74,9 @@ class PopupManager { } // 显示新弹窗 this._showNewPopup(options); + // 清除切换标记 + this._isTransitioning = false; + console.log(`[PopupManager] 设置 _isTransitioning = false`); resolve(); }, 200); }); @@ -621,6 +630,11 @@ class PopupManager { e.target.closest(`.${className}`) !== null; }); + // 检查是否点击的是上下文菜单、确认弹窗或设置对话框 + const isContextMenu = e.target.closest('.pa-context-menu') || e.target.closest('.pa-confirm-popup'); + const isSettingsModal = e.target.closest('.settings-modal') || e.target.closest('.settings-modal-overlay'); + if (isContextMenu || isSettingsModal) return; + // 检查是否点击的是目标输入框 const isTargetInput = e.target.classList.contains('comfy-multiline-input'); @@ -678,6 +692,10 @@ class PopupManager { } if (e.key === 'Escape') { + // 如果存在设置对话框,不关闭标签弹窗(让设置对话框处理 ESC) + const settingsModal = document.querySelector('.settings-modal'); + if (settingsModal) return; + if (typeof onClose === 'function') { onClose(); } diff --git a/js/utils/promptFormatter.js b/js/utils/promptFormatter.js index 5096675..219ff12 100644 --- a/js/utils/promptFormatter.js +++ b/js/utils/promptFormatter.js @@ -313,10 +313,26 @@ class PromptFormatter { to = 'zh'; type = '纯英文'; } else { - // 混合语言(包含中英文)或其他情况,默认中译英 - from = 'zh'; - to = 'en'; - type = '混合语言'; + // 混合语言:按中文汉字数量 vs 英文单词数量比较以决定方向 + const cnChars = text.match(/[\u4e00-\u9fff]/g) || []; + const enWords = text.match(/[A-Za-z]+(?:['’\-][A-Za-z]+)*/g) || []; + const cnUnits = cnChars.length; + const enUnits = enWords.length; + + if (cnUnits > enUnits) { + from = 'zh'; + to = 'en'; + type = '混合语言-中文占优'; + } else if (enUnits > cnUnits) { + from = 'en'; + to = 'zh'; + type = '混合语言-英文占优'; + } else { + // 数量持平时沿用原逻辑:默认中译英 + from = 'zh'; + to = 'en'; + type = '混合语言'; + } } // 记录日志 @@ -332,6 +348,16 @@ class PromptFormatter { }; } } + + /** + * 判断是否为中英文混合文本 + */ + static isMixedChineseEnglish(text) { + if (!text) return false; + const hasChinese = /[\u4e00-\u9fff]/.test(text); + const hasEnglish = /[A-Za-z]/.test(text); + return hasChinese && hasEnglish; + } } export { PromptFormatter }; \ No newline at end of file diff --git a/js/utils/resourceManager.js b/js/utils/resourceManager.js index 656be4d..f363cda 100644 --- a/js/utils/resourceManager.js +++ b/js/utils/resourceManager.js @@ -4,6 +4,7 @@ */ import { logger } from './logger.js'; +import { APIService } from '../services/api.js'; class ResourceManager { // 资源缓存 @@ -186,7 +187,7 @@ class ResourceManager { { id: 'prompt-assistant-common-styles', file: 'common.css' }, { id: 'prompt-assistant-styles', file: 'assistant.css' }, { id: 'prompt-assistant-popup-styles', file: 'popup.css' }, - { id: 'prompt-assistant-settings-styles', file: 'settings.css' } + { id: 'prompt-assistant-ui-components-styles', file: 'uiComponents.css' } ]; stylesToLoad.forEach(style => { @@ -227,7 +228,7 @@ class ResourceManager { */ static getTagUrl() { // 使用API路由获取标签数据 - return '/prompt_assistant/api/config/tags'; + return APIService.getApiUrl('/config/tags'); } /** @@ -235,7 +236,7 @@ class ResourceManager { */ static getUserTagUrl() { // 使用API路由获取用户自定义标签数据 - return '/prompt_assistant/api/config/tags_user'; + return APIService.getApiUrl('/config/tags_user'); } /** @@ -392,6 +393,171 @@ class ResourceManager { return stats.tags; } + // ---CSV标签系统--- + + /** + * 获取CSV文件列表 + */ + static async getTagFileList() { + try { + const response = await fetch(APIService.getApiUrl('/config/tags_files')); + const result = await response.json(); + if (result.success) { + return result.files || []; + } + logger.error(`获取标签文件列表失败 | ${result.error}`); + return []; + } catch (error) { + logger.error(`获取标签文件列表失败 | ${error.message}`); + return []; + } + } + + /** + * 获取用户选择的标签文件 + */ + static async getSelectedTagFile() { + try { + const response = await fetch(APIService.getApiUrl('/config/tags_selection')); + const result = await response.json(); + if (result.success) { + return result.selection?.selected_file || 'default.csv'; + } + return 'default.csv'; + } catch (error) { + logger.error(`获取标签选择失败 | ${error.message}`); + return 'default.csv'; + } + } + + /** + * 保存用户选择的标签文件 + */ + static async setSelectedTagFile(filename) { + try { + const response = await fetch(APIService.getApiUrl('/config/tags_selection'), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ selected_file: filename }) + }); + const result = await response.json(); + return result.success; + } catch (error) { + logger.error(`保存标签选择失败 | ${error.message}`); + return false; + } + } + + /** + * 加载CSV标签数据 + * @param {string} filename - CSV文件名 + * @param {boolean} forceReload - 是否强制重新加载(当前总是重新加载) + */ + static async loadTagsCsv(filename, forceReload = false) { + try { + const response = await fetch(APIService.getApiUrl(`/config/tags_csv/${filename}`)); + const result = await response.json(); + if (result.success) { + this.#tagCache = result.data; + const stats = this.#getTagStats(result.data); + logger.log(`CSV标签加载完成 | 文件:${filename} | 分类:${stats.categories} | 标签:${stats.tags}`); + return result.data; + } + logger.error(`加载CSV标签失败 | ${result.error}`); + return {}; + } catch (error) { + logger.error(`加载CSV标签失败 | ${error.message}`); + return {}; + } + } + + /** + * 保存CSV标签数据 + */ + static async saveTagsCsv(filename, data) { + try { + const response = await fetch(APIService.getApiUrl(`/config/tags_csv/${filename}`), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ data }) + }); + const result = await response.json(); + if (result.success) { + logger.log(`CSV标签保存成功 | 文件:${filename}`); + return true; + } + logger.error(`保存CSV标签失败 | ${result.error}`); + return false; + } catch (error) { + logger.error(`保存CSV标签失败 | ${error.message}`); + return false; + } + } + + /** + * 获取收藏列表 + */ + static async getFavorites() { + try { + const response = await fetch(APIService.getApiUrl('/config/favorites')); + const result = await response.json(); + if (result.success) { + return result.favorites || []; + } + return []; + } catch (error) { + logger.error(`获取收藏列表失败 | ${error.message}`); + return []; + } + } + + /** + * 添加收藏 + */ + static async addFavorite(tagValue, tagName = null, category = null) { + try { + const body = { tag_value: tagValue }; + if (tagName) { + body.tag_name = tagName; + } + if (category) { + body.category = category; + } + const response = await fetch(APIService.getApiUrl('/config/favorites/add'), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }); + const result = await response.json(); + return result.success; + } catch (error) { + logger.error(`添加收藏失败 | ${error.message}`); + return false; + } + } + + /** + * 移除收藏 + */ + static async removeFavorite(tagValue, category = null) { + try { + const body = { tag_value: tagValue }; + if (category) { + body.category = category; + } + const response = await fetch(APIService.getApiUrl('/config/favorites/remove'), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }); + const result = await response.json(); + return result.success; + } catch (error) { + logger.error(`移除收藏失败 | ${error.message}`); + return false; + } + } + /** * 检查是否已初始化 */ @@ -399,6 +565,7 @@ class ResourceManager { return this.#initialized; } + // ====================== 资源清理 ====================== /** @@ -433,6 +600,30 @@ class ResourceManager { logger.log("资源管理器 | 资源已清理并重新初始化"); } + /** + * 保存用户自定义标签数据 + */ + static async saveUserTags(data) { + try { + const response = await fetch(APIService.getApiUrl('/config/tags_user'), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data) + }); + const result = await response.json(); + if (result.success) { + logger.log("用户标签数据保存成功"); + this.#userTagCache = data; // 更新缓存 + return true; + } + logger.error(`保存用户标签数据失败 | ${result.error}`); + return false; + } catch (error) { + logger.error(`保存用户标签数据失败 | ${error.message}`); + return false; + } + } + /** * 加载外部脚本 */ diff --git a/locales/en/nodeDefs.json b/locales/en/nodeDefs.json new file mode 100644 index 0000000..3380192 --- /dev/null +++ b/locales/en/nodeDefs.json @@ -0,0 +1,210 @@ +{ + "PromptExpand": { + "display_name": "✨Prompt Enhance", + "description": "Enhance your prompt using LLM", + "inputs": { + "rule": { + "name": "Rule", + "tooltip": "Preset rule for prompt enhancement" + }, + "custom_rule": { + "name": "Custom Rule", + "tooltip": "Enable custom rule input below" + }, + "custom_rule_content": { + "name": "Custom Rule Content", + "tooltip": "Enter your custom rule here" + }, + "user_prompt": { + "name": "User Prompt", + "tooltip": "Your prompt to be enhanced" + }, + "llm_service": { + "name": "LLM Service", + "tooltip": "Select LLM Service" + }, + "ollama_auto_unload": { + "name": "Auto Unload", + "tooltip": "Auto unload Ollama model" + }, + "source_text": { + "name": "Source Text", + "tooltip": "Optional input text" + } + }, + "outputs": { + "0": { + "name": "Prompt Output", + "tooltip": "Result of enhancement" + } + } + }, + "ImageCaptionNode": { + "display_name": "✨Image Caption", + "description": "Generate caption for image using VLM", + "inputs": { + "image": { + "name": "Image", + "tooltip": "Input image" + }, + "rule": { + "name": "Rule", + "tooltip": "Preset rule for captioning" + }, + "custom_rule": { + "name": "Custom Rule", + "tooltip": "Enable custom rule input" + }, + "custom_rule_content": { + "name": "Custom Rule Content", + "tooltip": "Custom rule content" + }, + "user_prompt": { + "name": "User Prompt", + "tooltip": "Enter additional prompts here, sent with the rule" + }, + "vlm_service": { + "name": "VLM Service", + "tooltip": "Select VLM Service" + }, + "ollama_auto_unload": { + "name": "Auto Unload", + "tooltip": "Auto unload Ollama model" + } + }, + "outputs": { + "0": { + "name": "Prompt Output", + "tooltip": "Generated caption" + } + } + }, + "PromptTranslate": { + "display_name": "✨Prompt Translate", + "description": "Translate prompt between languages", + "inputs": { + "source_text": { + "name": "Source Text", + "tooltip": "Text to translate" + }, + "target_language": { + "name": "Target Language", + "tooltip": "Target language for translation", + "options": { + "English": "English", + "Chinese": "Chinese" + } + }, + "translate_service": { + "name": "Translate Service", + "tooltip": "Select Translation Service" + }, + "ollama_auto_unload": { + "name": "Auto Unload", + "tooltip": "Auto unload Ollama model" + } + }, + "outputs": { + "0": { + "name": "Prompt Output", + "tooltip": "Translated result" + } + } + }, + "VideoCaptionNode": { + "display_name": "✨Video Caption", + "description": "Generate caption for video using VLM", + "inputs": { + "rule": { + "name": "Rule", + "tooltip": "Preset rule for video captioning" + }, + "custom_rule": { + "name": "Custom Rule", + "tooltip": "Enable custom rule input" + }, + "custom_rule_content": { + "name": "Custom Rule Content", + "tooltip": "Custom rule content" + }, + "user_prompt": { + "name": "User Prompt", + "tooltip": "Enter additional prompts here, sent with the rule" + }, + "vlm_service": { + "name": "VLM Service", + "tooltip": "Select VLM Service" + }, + "sampling_mode": { + "name": "Sampling Mode", + "tooltip": "Select Auto (Uniform) to uniformly sample frames based on video length. Manual (Indices) mode allows manual frame selection via the '🎬Select Frames for Captioning' button", + "options": { + "Auto (Uniform)": "Auto (Uniform)", + "Manual (Indices)": "Manual (Indices)" + } + }, + "frame_count": { + "name": "Frame Count", + "tooltip": "Number of frames to sample (Auto mode)" + }, + "manual_indices": { + "name": "Manual Indices", + "tooltip": "Specific frame indices (Manual mode)" + }, + "ollama_auto_unload": { + "name": "Auto Unload", + "tooltip": "Auto unload Ollama model" + }, + "video": { + "name": "Video", + "tooltip": "Input Video" + }, + "image_sequence": { + "name": "Images", + "tooltip": "Input Image Sequence" + } + }, + "outputs": { + "0": { + "name": "Prompt Output", + "tooltip": "Generated video caption" + }, + "1": { + "name": "Preview frames", + "tooltip": "Preview frames" + } + } + }, + "KontextPresetNode": { + "display_name": "✨Kontext Preset", + "description": "Generate creative instruction using Kontext presets", + "inputs": { + "image": { + "name": "Image", + "tooltip": "Input image" + }, + "kontext_preset": { + "name": "Preset", + "tooltip": "Select Kontext preset" + }, + "user_prompt": { + "name": "User Prompt", + "tooltip": "Enter additional prompts here, sent with the preset" + }, + "vlm_service": { + "name": "VLM Service", + "tooltip": "Select VLM Service" + }, + "ollama_auto_unload": { + "name": "Auto Unload", + "tooltip": "Auto unload Ollama model" + } + }, + "outputs": { + "0": { + "name": "Prompt Output", + "tooltip": "Generated creative instruction" + } + } + } +} \ No newline at end of file diff --git a/locales/zh-TW/nodeDefs.json b/locales/zh-TW/nodeDefs.json new file mode 100644 index 0000000..e4020a0 --- /dev/null +++ b/locales/zh-TW/nodeDefs.json @@ -0,0 +1,210 @@ +{ + "PromptExpand": { + "display_name": "✨提示詞增強", + "description": "使用LLM增強或擴寫您的提示詞", + "inputs": { + "rule": { + "name": "規則預設", + "tooltip": "選擇提示詞增強的預設規則" + }, + "custom_rule": { + "name": "自定義規則", + "tooltip": "啟用下方自定義規則輸入" + }, + "custom_rule_content": { + "name": "自定義規則內容", + "tooltip": "在此輸入您的自定義規則內容" + }, + "user_prompt": { + "name": "用戶提示詞", + "tooltip": "想要增強的原始提示詞" + }, + "llm_service": { + "name": "LLM服務", + "tooltip": "選擇LLM服務和模型" + }, + "ollama_auto_unload": { + "name": "自動釋放顯存", + "tooltip": "生成完成後自動釋放Ollama模型顯存" + }, + "source_text": { + "name": "原文輸入", + "tooltip": "可選的輸入文本" + } + }, + "outputs": { + "0": { + "name": "提示詞輸出", + "tooltip": "增強後的提示詞結果" + } + } + }, + "ImageCaptionNode": { + "display_name": "✨圖像反推提示詞", + "description": "使用VLM生成圖像描述", + "inputs": { + "image": { + "name": "圖像", + "tooltip": "輸入圖像" + }, + "rule": { + "name": "規則預設", + "tooltip": "選擇圖像反推的預設規則" + }, + "custom_rule": { + "name": "自定義規則", + "tooltip": "啟用下方自定義規則輸入" + }, + "custom_rule_content": { + "name": "自定義規則內容", + "tooltip": "在此輸入您的自定義規則內容" + }, + "user_prompt": { + "name": "使用者提示詞", + "tooltip": "在此輸入補充提示詞,將與規則一起發送" + }, + "vlm_service": { + "name": "視覺服務", + "tooltip": "選擇視覺服務和模型" + }, + "ollama_auto_unload": { + "name": "自動釋放顯存", + "tooltip": "生成完成後自動釋放Ollama模型顯存" + } + }, + "outputs": { + "0": { + "name": "提示詞輸出", + "tooltip": "生成的圖像描述" + } + } + }, + "PromptTranslate": { + "display_name": "✨提示詞翻譯", + "description": "多語言提示詞翻譯", + "inputs": { + "source_text": { + "name": "原文", + "tooltip": "需要翻譯的文本" + }, + "target_language": { + "name": "目標語言", + "tooltip": "翻譯的目標語言", + "options": { + "English": "英文", + "Chinese": "中文" + } + }, + "translate_service": { + "name": "翻譯服務", + "tooltip": "選擇翻譯服務和模型" + }, + "ollama_auto_unload": { + "name": "自動釋放顯存", + "tooltip": "生成完成後自動釋放Ollama模型顯存" + } + }, + "outputs": { + "0": { + "name": "提示詞輸出", + "tooltip": "翻譯後的文本" + } + } + }, + "VideoCaptionNode": { + "display_name": "✨視頻反推提示詞", + "description": "使用VLM生成視頻描述", + "inputs": { + "rule": { + "name": "規則預設", + "tooltip": "選擇視頻反推的預設規則" + }, + "custom_rule": { + "name": "自定義規則", + "tooltip": "啟用下方自定義規則輸入" + }, + "custom_rule_content": { + "name": "自定義規則內容", + "tooltip": "在此輸入您的自定義規則內容" + }, + "user_prompt": { + "name": "使用者提示詞", + "tooltip": "在此輸入補充提示詞,將與規則一起發送" + }, + "vlm_service": { + "name": "視覺服務", + "tooltip": "選擇視覺服務和模型" + }, + "sampling_mode": { + "name": "抽幀策略", + "tooltip": "選擇Auto (Uniform)將按照影片長度,均勻抽幀。Manual (Indices)模式,可透過「🎬選取反推幀」按鈕手動選擇幀", + "options": { + "Auto (Uniform)": "自動(均勻)", + "Manual (Indices)": "手動(幀索引)" + } + }, + "frame_count": { + "name": "抽幀數量", + "tooltip": "需要採樣的幀數 (自動模式)" + }, + "manual_indices": { + "name": "手動幀索引", + "tooltip": "指定幀索引,支持逗號分隔或範圍 (手動模式)" + }, + "ollama_auto_unload": { + "name": "自動釋放顯存", + "tooltip": "生成完成後自動釋放Ollama模型顯存" + }, + "video": { + "name": "視頻", + "tooltip": "輸入視頻" + }, + "image_sequence": { + "name": "圖像序列", + "tooltip": "輸入圖像序列" + } + }, + "outputs": { + "0": { + "name": "提示詞輸出", + "tooltip": "生成的視頻描述" + }, + "1": { + "name": "預覽幀", + "tooltip": "採樣的預覽幀" + } + } + }, + "KontextPresetNode": { + "display_name": "✨Kontext預設", + "description": "使用Kontext預設生成創意指令", + "inputs": { + "image": { + "name": "圖像", + "tooltip": "輸入圖像" + }, + "kontext_preset": { + "name": "預設集", + "tooltip": "選擇Kontext預設" + }, + "user_prompt": { + "name": "使用者提示詞", + "tooltip": "在此輸入補充提示詞,將與預設一起發送" + }, + "vlm_service": { + "name": "視覺服務", + "tooltip": "選擇視覺服務和模型" + }, + "ollama_auto_unload": { + "name": "自動釋放顯存", + "tooltip": "生成完成後自動釋放Ollama模型顯存" + } + }, + "outputs": { + "0": { + "name": "提示詞輸出", + "tooltip": "生成的創意轉換指令" + } + } + } +} \ No newline at end of file diff --git a/locales/zh/nodeDefs.json b/locales/zh/nodeDefs.json new file mode 100644 index 0000000..49f6515 --- /dev/null +++ b/locales/zh/nodeDefs.json @@ -0,0 +1,210 @@ +{ + "PromptExpand": { + "display_name": "✨提示词优化", + "description": "使用LLM增强或扩写您的提示词", + "inputs": { + "rule": { + "name": "规则预设", + "tooltip": "选择提示词优化的预设规则" + }, + "custom_rule": { + "name": "自定义规则", + "tooltip": "启用下方自定义规则输入" + }, + "custom_rule_content": { + "name": "自定义规则内容", + "tooltip": "在此输入您的自定义规则内容" + }, + "user_prompt": { + "name": "用户提示词", + "tooltip": "想要增强的原始提示词" + }, + "llm_service": { + "name": "LLM服务", + "tooltip": "选择LLM服务和模型" + }, + "ollama_auto_unload": { + "name": "自动释放显存", + "tooltip": "生成完成后自动释放Ollama模型显存" + }, + "source_text": { + "name": "原文输入", + "tooltip": "可选的输入文本" + } + }, + "outputs": { + "0": { + "name": "提示词输出", + "tooltip": "增强后的提示词结果" + } + } + }, + "ImageCaptionNode": { + "display_name": "✨图像反推提示词", + "description": "使用VLM生成图像描述", + "inputs": { + "image": { + "name": "图像", + "tooltip": "输入图像" + }, + "rule": { + "name": "规则预设", + "tooltip": "选择图像反推的预设规则" + }, + "custom_rule": { + "name": "自定义规则", + "tooltip": "启用下方自定义规则输入" + }, + "custom_rule_content": { + "name": "自定义规则内容", + "tooltip": "在此输入您的自定义规则内容" + }, + "user_prompt": { + "name": "用户提示词", + "tooltip": "输入额外的具体要求,将与规则一起发送给模型" + }, + "vlm_service": { + "name": "视觉服务", + "tooltip": "选择视觉服务和模型" + }, + "ollama_auto_unload": { + "name": "自动释放显存", + "tooltip": "生成完成后自动释放Ollama模型显存" + } + }, + "outputs": { + "0": { + "name": "提示词输出", + "tooltip": "生成的图像描述" + } + } + }, + "PromptTranslate": { + "display_name": "✨提示词翻译", + "description": "多语言提示词翻译", + "inputs": { + "source_text": { + "name": "原文", + "tooltip": "需要翻译的文本" + }, + "target_language": { + "name": "目标语言", + "tooltip": "翻译的目标语言", + "options": { + "English": "英文", + "Chinese": "中文" + } + }, + "translate_service": { + "name": "翻译服务", + "tooltip": "选择翻译服务和模型" + }, + "ollama_auto_unload": { + "name": "自动释放显存", + "tooltip": "生成完成后自动释放Ollama模型显存" + } + }, + "outputs": { + "0": { + "name": "提示词输出", + "tooltip": "翻译后的文本" + } + } + }, + "VideoCaptionNode": { + "display_name": "✨视频反推提示词", + "description": "使用VLM生成视频描述", + "inputs": { + "rule": { + "name": "规则预设", + "tooltip": "选择视频反推的预设规则" + }, + "custom_rule": { + "name": "自定义规则", + "tooltip": "启用下方自定义规则输入" + }, + "custom_rule_content": { + "name": "自定义规则内容", + "tooltip": "在此输入您的自定义规则内容" + }, + "user_prompt": { + "name": "用户提示词", + "tooltip": "输入额外的具体要求,将与规则一起发送给模型" + }, + "vlm_service": { + "name": "视觉服务", + "tooltip": "选择视觉服务和模型" + }, + "sampling_mode": { + "name": "抽帧策略", + "tooltip": "选择Auto (Uniform)将按照视频长度,均匀抽帧。Manual (Indices)模式,可通过“🎬选取反推帧”按钮手动选择帧", + "options": { + "Auto (Uniform)": "自动(均匀)", + "Manual (Indices)": "手动(帧索引)" + } + }, + "frame_count": { + "name": "抽帧数量", + "tooltip": "需要采样的帧数 (自动模式)" + }, + "manual_indices": { + "name": "手动帧索引", + "tooltip": "指定帧索引,支持逗号分隔或范围 (手动模式)" + }, + "ollama_auto_unload": { + "name": "自动释放显存", + "tooltip": "生成完成后自动释放Ollama模型显存" + }, + "video": { + "name": "视频", + "tooltip": "输入视频" + }, + "image_sequence": { + "name": "图像序列", + "tooltip": "输入图像序列" + } + }, + "outputs": { + "0": { + "name": "提示词输出", + "tooltip": "生成的视频描述" + }, + "1": { + "name": "预览帧", + "tooltip": "采样的预览帧" + } + } + }, + "KontextPresetNode": { + "display_name": "✨Kontext预设", + "description": "使用Kontext预设生成创意指令", + "inputs": { + "image": { + "name": "图像", + "tooltip": "输入图像" + }, + "kontext_preset": { + "name": "预设及其", + "tooltip": "选择Kontext预设" + }, + "user_prompt": { + "name": "用户提示词", + "tooltip": "输入额外的具体要求,将与预设一起发送给模型" + }, + "vlm_service": { + "name": "视觉服务", + "tooltip": "选择视觉服务和模型" + }, + "ollama_auto_unload": { + "name": "自动释放显存", + "tooltip": "生成完成后自动释放Ollama模型显存" + } + }, + "outputs": { + "0": { + "name": "提示词输出", + "tooltip": "生成的创意转换指令" + } + } + } +} \ No newline at end of file diff --git a/node/base/__init__.py b/node/base/__init__.py new file mode 100644 index 0000000..b1df387 --- /dev/null +++ b/node/base/__init__.py @@ -0,0 +1,14 @@ +""" +节点基类模块 +提供所有节点的通用基础能力 +""" + +from .base_node import BaseNode +from .llm_node_base import LLMNodeBase +from .vlm_node_base import VLMNodeBase + +__all__ = [ + 'BaseNode', 'LLMNodeBase', 'VLMNodeBase' +] + + diff --git a/node/base/base_node.py b/node/base/base_node.py new file mode 100644 index 0000000..d951ff6 --- /dev/null +++ b/node/base/base_node.py @@ -0,0 +1,235 @@ +""" +节点抽象基类 +提供所有节点的通用基础能力,包括线程管理、中断检测、Provider配置等 +""" + +import asyncio +import threading +import time +from typing import Any, Callable, Dict, Optional, Tuple + +from comfy.model_management import InterruptProcessingException + +from ...utils.common import PREFIX as LOG_PREFIX, REQUEST_PREFIX, PROCESS_PREFIX + + +class BaseNode: + """ + 所有节点的抽象基类 + + 提供通用功能: + - 线程执行与中断管理 + - Provider配置覆盖 + - 规则模板获取 + """ + + # 子类应该覆盖这些常量 + LOG_PREFIX = LOG_PREFIX + REQUEST_PREFIX = REQUEST_PREFIX + PROCESS_PREFIX = PROCESS_PREFIX + + + def _run_thread_with_interrupt( + self, + target_func: Callable, + args: Tuple, + task_name: str = "任务" + ) -> Dict[str, Any]: + """ + 在独立线程中运行任务,并支持中断检测 + + 参数: + target_func: 要在线程中执行的函数 + args: 传递给函数的参数元组 + task_name: 任务名称,用于日志输出(已废弃,保留参数以兼容旧代码) + + 返回: + result_container中的'result'字段内容 + + 异常: + InterruptProcessingException: 当检测到用户中断时 + """ + result_container = {} + + # 启动线程 + thread = threading.Thread(target=target_func, args=args) + thread.start() + + # 等待完成,同时检查中断 + while thread.is_alive(): + try: + import nodes + nodes.before_node_execution() + except: + # 检测到中断,立即抛出异常 + raise InterruptProcessingException() + time.sleep(0.1) + + return result_container.get('result') + + def _run_async_in_thread( + self, + async_func: Callable, + result_container: Dict[str, Any], + *args, + **kwargs + ) -> None: + """ + 在独立线程中运行异步任务的辅助方法 + + 这个方法会: + 1. 创建新的事件循环 + 2. 运行异步函数 + 3. 将结果存入result_container + 4. 正确处理中断异常 + + 参数: + async_func: 异步函数 + result_container: 结果容器字典 + *args, **kwargs: 传递给async_func的参数 + + 结果: + result_container['result'] = 函数返回值或错误信息 + """ + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + try: + result = loop.run_until_complete(async_func(*args, **kwargs)) + result_container['result'] = result + except (asyncio.CancelledError, KeyboardInterrupt): + # 捕获中断异常,不格式化错误 + print(f"{self.LOG_PREFIX} 检测到异步任务被取消") + result_container['result'] = {"success": False, "error": "任务被中断"} + except Exception as e: + # 捕获其他异常,让子类处理格式化 + result_container['result'] = {"success": False, "error": str(e)} + finally: + loop.close() + + def _execute_with_interrupt( + self, + thread_func: Callable, + thread_args: Tuple, + cancel_event: Optional[Any] = None + ) -> Any: + """ + 执行带中断检测的线程任务(通用封装) + + 参数: + thread_func: 线程中执行的函数(通常是包含异步逻辑的函数) + thread_args: 传递给thread_func的参数 + cancel_event: 可选的取消事件,用于通知异步任务中断 + + 返回: + result_container['result']的内容 + + 异常: + InterruptProcessingException: 当检测到用户中断时 + """ + result_container = {} + + # 如果提供了cancel_event,将其传递给thread_func + if cancel_event is not None: + thread = threading.Thread(target=thread_func, args=(result_container, cancel_event) + thread_args) + else: + thread = threading.Thread(target=thread_func, args=(result_container,) + thread_args) + thread.start() + + # 等待完成,同时检查中断 + while thread.is_alive(): + is_interrupted = False + try: + import nodes + nodes.before_node_execution() + + # 双重检查:额外检查 PromptServer 的全局中断状态 + # 某些情况下 nodes.before_node_execution() 可能不会抛出异常 + from server import PromptServer + if hasattr(PromptServer.instance, 'execution_interrupted') and PromptServer.instance.execution_interrupted: + is_interrupted = True + except: + is_interrupted = True + + if is_interrupted: + # 检测到中断,设置取消事件(如果提供) + if cancel_event is not None: + try: + cancel_event.set() + except: + pass + raise InterruptProcessingException() + time.sleep(0.1) + + return result_container.get('result') + + def _override_ollama_config( + self, + provider_config: Dict[str, Any], + auto_unload: bool + ) -> Dict[str, Any]: + """ + 覆盖Ollama配置中的auto_unload参数 + + 参数: + provider_config: 原始Provider配置 + auto_unload: 节点级别的自动释放设置 + + 返回: + 新的配置字典(不修改原始配置) + """ + config_copy = provider_config.copy() + config_copy['auto_unload'] = auto_unload + return config_copy + + def _get_prompt_template( + self, + template_name: str, + prompt_type: str, + use_temp_rule: bool, + temp_rule_content: str, + default_content: str + ) -> Tuple[str, str]: + """ + 获取提示词模板内容 + + 参数: + template_name: 模板名称 + prompt_type: 模板类型('expand_prompts', 'vision_prompts'等) + use_temp_rule: 是否使用临时规则 + temp_rule_content: 临时规则内容 + default_content: 默认提示词内容 + + 返回: + (prompt_content, rule_name) 元组 + """ + # 使用临时规则 + if use_temp_rule and temp_rule_content: + return temp_rule_content, "临时规则" + + # 从配置获取模板 + from ...config_manager import config_manager + system_prompts = config_manager.get_system_prompts() + + if not system_prompts: + return default_content, "默认规则" + + prompts = system_prompts.get(prompt_type, {}) + if not prompts: + return default_content, "默认规则" + + # 按显示名称匹配 + for key, value in prompts.items(): + if value.get('name') == template_name: + content = value.get('content', '') + if content: + return content, template_name + + # 按键名匹配 + for key, value in prompts.items(): + if key == template_name: + content = value.get('content', '') + if content: + return content, template_name + + # 未找到,使用默认 + return default_content, "默认规则" diff --git a/node/base/llm_node_base.py b/node/base/llm_node_base.py new file mode 100644 index 0000000..77d1750 --- /dev/null +++ b/node/base/llm_node_base.py @@ -0,0 +1,221 @@ +""" +LLM节点基类 +为LLM类节点(扩写、翻译等)提供专用基础能力 +""" + +from typing import Any, Callable, Dict, Optional +import asyncio + +from .base_node import BaseNode +from ...utils.common import format_api_error + + +class LLMNodeBase(BaseNode): + """ + LLM节点基类 + + 提供LLM节点专用功能: + - LLM Provider配置获取 + - LLM相关的通用逻辑 + - 动态服务/模型列表生成 + """ + + @staticmethod + def get_llm_service_options(): + """ + 获取所有可用的LLM服务/模型选项列表 + + 返回格式: ["百度翻译", "智谱/glm-4-flash", "Ollama/qwen3:14b", ...] + 百度翻译服务无模型概念,仅显示服务名 + 其他服务显示为"服务名/模型名"格式 + + 返回: + List[str]: 服务/模型选项列表 + """ + from ...config_manager import config_manager + + options = [] + services = config_manager.get_all_services() + + for service in services: + service_name = service.get('name', '') + service_id = service.get('id', '') + service_type = service.get('type', '') + + # 百度翻译特殊处理:仅显示服务名 + if service_type == 'baidu': + options.append(service_name) + continue + + # 其他服务:遍历llm_models + llm_models = service.get('llm_models', []) + for model in llm_models: + model_name = model.get('name', '') + if model_name: + # 格式: "服务名/模型名" + options.append(f"{service_name}/{model_name}") + + # 如果没有任何选项,返回默认值避免ComfyUI报错 + if not options: + options = ["智谱"] + + return options + + @staticmethod + def get_translate_service_options(): + """ + 获取翻译服务专用的服务/模型选项列表 + + 与get_llm_service_options的区别: + - 硬编码添加"百度翻译"选项(百度翻译使用独立配置,不在model_services中) + - 专门用于翻译节点和翻译按钮 + + 返回格式: ["百度翻译", "智谱/glm-4-flash", "Ollama/qwen3:14b", ...] + + 返回: + List[str]: 服务/模型选项列表(包含百度翻译) + """ + from ...config_manager import config_manager + + options = [] + + # ---硬编码添加百度翻译--- + # 百度翻译使用独立的baidu_translate配置,不在model_services列表中 + baidu_config = config_manager.load_config().get('baidu_translate', {}) + # 即使没有配置app_id,也要显示百度选项 + options.append("百度翻译") + + # ---动态获取其他LLM服务--- + services = config_manager.get_all_services() + + for service in services: + service_name = service.get('name', '') + service_type = service.get('type', '') + + # 遍历llm_models + llm_models = service.get('llm_models', []) + for model in llm_models: + model_name = model.get('name', '') + if model_name: + # 格式: "服务名/模型名" + options.append(f"{service_name}/{model_name}") + + return options + + @staticmethod + def parse_service_model(service_model_str: str): + """ + 解析"服务名/模型名"格式的字符串 + + 特殊处理: + - "百度翻译": 返回 ('baidu', None) - 百度翻译使用独立配置 + + 参数: + service_model_str: 服务/模型字符串,例如 "智谱/glm-4-flash" 或 "百度翻译" + + 返回: + Tuple[str, Optional[str]]: (service_id, model_name) + - service_id: 服务ID (如 'zhipu', 'baidu') + - model_name: 模型名称,如果没有则为None + """ + from ...config_manager import config_manager + + # 分割字符串 + if '/' in service_model_str: + service_name, model_name = service_model_str.split('/', 1) + else: + service_name = service_model_str + model_name = None + + # ---特殊处理:百度翻译--- + if service_name in ['百度翻译', '百度', 'baidu']: + return 'baidu', None + + # 查找对应的service_id + services = config_manager.get_all_services() + for service in services: + if service.get('name') == service_name: + return service.get('id'), model_name + + # 未找到,返回None + return None, None + + def _get_provider_config( + self, + config_manager: Any, + provider: str + ) -> Optional[Dict[str, Any]]: + """ + 获取指定LLM Provider的配置 + + 参数: + config_manager: 配置管理器实例 + provider: Provider标识符(如 'zhipu', 'ollama'等) + + 返回: + Provider配置字典,如果未找到则返回 None + """ + llm_config = config_manager.get_llm_config() + + if 'providers' in llm_config and provider in llm_config['providers']: + return llm_config['providers'][provider] + + return None + + def _run_llm_task( + self, + llm_service_func: Callable, + provider: str, + *args, + **kwargs + ) -> Dict[str, Any]: + """ + 执行LLM任务(扩写/翻译)的统一方法 + + 封装了: + 1. 异步任务的线程执行 + 2. 中断异常的捕获和处理 + 3. API异常的格式化 + + 参数: + llm_service_func: LLMService的异步方法 + provider: Provider名称(用于错误格式化) + *args, **kwargs: 传递给llm_service_func的参数 + + 返回: + {"success": bool, "data": dict, "error": str} 格式的结果 + """ + def thread_task(result_container, cancel_event): + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + try: + # 将cancel_event传递给服务函数 + kwargs['cancel_event'] = cancel_event + result = loop.run_until_complete(llm_service_func(*args, **kwargs)) + result_container['result'] = result + except (asyncio.CancelledError, KeyboardInterrupt): + print(f"{self.LOG_PREFIX} 异步任务被取消") + result_container['result'] = {"success": False, "error": "任务被中断"} + except Exception as e: + # 捕获其他异常,格式化错误信息 + error_message = format_api_error(e, provider) + result_container['result'] = {"success": False, "error": error_message} + finally: + # 清理所有未完成的任务,消除 "Task was destroyed but it is pending" 警告 + try: + pending = asyncio.all_tasks(loop) + if pending: + for task in pending: + task.cancel() + # 等待任务取消完成,忽略取消错误 + loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True)) + except Exception: + pass + finally: + loop.close() + + # 创建取消事件 + import threading + cancel_event = threading.Event() + + return self._execute_with_interrupt(thread_task, (), cancel_event) diff --git a/node/base/vlm_node_base.py b/node/base/vlm_node_base.py new file mode 100644 index 0000000..97018e1 --- /dev/null +++ b/node/base/vlm_node_base.py @@ -0,0 +1,279 @@ +""" +VLM节点基类 +为VLM类节点(图像反推、视频反推等)提供专用基础能力 +""" + +import asyncio +import threading +from typing import Any, Callable, Dict, Optional + +import torch +from comfy.model_management import InterruptProcessingException + +from .base_node import BaseNode +from ...utils.image import tensor_to_base64, compute_image_hash +from ...utils.common import format_api_error + + +class VLMNodeBase(BaseNode): + """ + VLM节点基类 + + 提供VLM节点专用功能: + - VLM Provider配置获取 + - 图像处理工具方法 + - 图像哈希计算 + - 统一的异步任务执行与中断处理 + - 动态服务/模型列表生成 + """ + + @staticmethod + def get_vlm_service_options(): + """ + 获取所有可用的VLM服务/模型选项列表 + + 返回格式: ["智谱/glm-4v-flash", "Ollama/llava:Q6_K", ...] + 所有服务显示为"服务名/模型名"格式 + + 返回: + List[str]: 服务/模型选项列表 + """ + from ...config_manager import config_manager + + options = [] + services = config_manager.get_all_services() + + for service in services: + service_name = service.get('name', '') + service_id = service.get('id', '') + service_type = service.get('type', '') + + # 百度翻译没有vlm_models,跳过 + if service_type == 'baidu': + continue + + # 遍历vlm_models + vlm_models = service.get('vlm_models', []) + for model in vlm_models: + model_name = model.get('name', '') + if model_name: + # 格式: "服务名/模型名" + options.append(f"{service_name}/{model_name}") + + # 如果没有任何选项,返回默认值避免ComfyUI报错 + if not options: + options = ["智谱"] + + return options + + @staticmethod + def parse_service_model(service_model_str: str): + """ + 解析"服务名/模型名"格式的字符串 + + 参数: + service_model_str: 服务/模型字符串,例如 "智谱/glm-4v-flash" + + 返回: + Tuple[str, Optional[str]]: (service_id, model_name) + - service_id: 服务ID (如 'zhipu', 'ollama') + - model_name: 模型名称,如果没有则为None + """ + from ...config_manager import config_manager + + # 分割字符串 + if '/' in service_model_str: + service_name, model_name = service_model_str.split('/', 1) + else: + service_name = service_model_str + model_name = None + + # 查找对应的service_id + services = config_manager.get_all_services() + for service in services: + if service.get('name') == service_name: + return service.get('id'), model_name + + # 未找到,返回None + return None, None + + def _get_provider_config( + self, + config_manager: Any, + provider: str + ) -> Optional[Dict[str, Any]]: + """ + 获取指定VLM Provider的配置 + + 参数: + config_manager: 配置管理器实例 + provider: Provider标识符(如 'zhipu', 'ollama'等) + + 返回: + Provider配置字典,如果未找到则返回 None + """ + vision_config = config_manager.get_vision_config() + + if 'providers' in vision_config and provider in vision_config['providers']: + return vision_config['providers'][provider] + + return None + + def _image_to_base64(self, image_tensor: torch.Tensor, quality: int = 95) -> str: + """ + 将图像tensor转换为base64编码 + + 参数: + image_tensor: 图像tensor + quality: JPEG压缩质量(1-100) + + 返回: + base64编码的data URL + """ + return tensor_to_base64(image_tensor, quality) + + def _compute_image_hash(self, image_tensor: Optional[torch.Tensor]) -> str: + """ + 计算图像tensor的哈希值(用于IS_CHANGED) + + 参数: + image_tensor: 图像tensor或None + + 返回: + MD5哈希值的十六进制字符串 + """ + return compute_image_hash(image_tensor) + + def _run_async_task( + self, + async_func: Callable, + provider: str, + *args, + **kwargs + ) -> Dict[str, Any]: + """ + 在独立线程中运行异步任务,并处理中断 + + 这个方法封装了: + 1. 异步事件循环的创建和清理 + 2. 中断异常的捕获和处理 + 3. 普通异常的格式化 + + 参数: + async_func: 异步函数(coroutine function) + provider: Provider名称(用于错误格式化) + *args, **kwargs: 传递给async_func的参数 + + 返回: + {"success": bool, "data": dict, "error": str} 格式的结果 + + 示例: + result = self._run_async_task( + VisionService.analyze_image, + "zhipu", + image_data=data, + request_id=req_id, + ... + ) + """ + result_container = {} + + def thread_target(): + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + try: + # 执行异步任务 + result = loop.run_until_complete(async_func(*args, **kwargs)) + result_container['result'] = result + except (asyncio.CancelledError, KeyboardInterrupt): + # 捕获中断异常,不格式化错误 + print(f"{self.LOG_PREFIX} 检测到异步任务被取消") + result_container['result'] = {"success": False, "error": "任务被中断"} + except Exception as e: + # 捕获其他异常,格式化错误信息 + error_message = format_api_error(e, provider) + result_container['result'] = {"success": False, "error": error_message} + finally: + loop.close() + + # 使用基类的线程中断检测方法 + self._run_thread_with_interrupt( + thread_target, + (), + task_name="异步任务" + ) + + def _run_vision_task( + self, + vision_service_func: Callable, + provider: str, + *args, + **kwargs + ) -> Dict[str, Any]: + """ + 执行视觉任务(图像/视频分析)的统一方法 + + 封装了: + 1. 异步任务的线程执行 + 2. 中断异常的捕获和处理 + 3. API异常的格式化 + + 参数: + vision_service_func: VisionService的异步方法(如analyze_image, analyze_images) + provider: Provider名称(用于错误格式化,如"zhipu") + *args, **kwargs: 传递给vision_service_func的参数 + + 返回: + {"success": bool, "data": dict, "error": str} 格式的结果 + + 异常: + InterruptProcessingException: 当检测到用户中断时 + + 示例: + result = self._run_vision_task( + VisionService.analyze_image, + "zhipu", + image_data=data, + request_id=req_id, + prompt_content=prompt, + custom_provider="zhipu", + custom_provider_config=config + ) + """ + def thread_task(result_container, cancel_event): + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + try: + # 将cancel_event传递给服务函数 + kwargs['cancel_event'] = cancel_event + result = loop.run_until_complete(vision_service_func(*args, **kwargs)) + result_container['result'] = result + except (asyncio.CancelledError, KeyboardInterrupt): + # 捕获中断异常,不格式化错误 + print(f"{self.LOG_PREFIX} 检测到异步任务被取消") + result_container['result'] = {"success": False, "error": "任务被中断"} + except Exception as e: + # 捕获其他异常,格式化错误信息 + error_message = format_api_error(e, provider) + result_container['result'] = {"success": False, "error": error_message} + finally: + # 清理所有未完成的任务,消除 "Task was destroyed but it is pending" 警告 + try: + pending = asyncio.all_tasks(loop) + if pending: + for task in pending: + task.cancel() + # 等待任务取消完成,忽略取消错误 + loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True)) + except Exception: + pass + finally: + loop.close() + + # 创建取消事件 + import threading + cancel_event = threading.Event() + + # 使用基类的中断检测执行 + return self._execute_with_interrupt(thread_task, (), cancel_event) + diff --git a/node/expand_node.py b/node/expand_node.py index 8874681..c27939b 100644 --- a/node/expand_node.py +++ b/node/expand_node.py @@ -8,35 +8,17 @@ import re from comfy.model_management import InterruptProcessingException from ..services.llm import LLMService -from ..services.error_util import format_api_error +from ..utils.common import format_api_error, format_model_with_thinking, generate_request_id, log_prepare, log_error, TASK_EXPAND, SOURCE_NODE +from ..services.thinking_control import build_thinking_suppression +from .base import LLMNodeBase -# 定义ANSI颜色代码常量 -GREEN = "\033[32m" -BLUE = "\033[34m" -YELLOW = "\033[33m" -RESET = "\033[0m" - - -class PromptExpand: +class PromptExpand(LLMNodeBase): """ - 文本扩写节点 - - 输入"原文",根据所选扩写规则模板或临时规则进行扩写 + 提示词增强节点 + - 输入"source_text",根据所选规则模板或自定义规则进行增强/扩写 - 仅包含一个字符串输入和一个字符串输出 """ - # 定义日志前缀 - LOG_PREFIX = f"{GREEN}[✨PromptAssistant]{RESET}" # 绿色:结果阶段 - REQUEST_PREFIX = f"{BLUE}[✨PromptAssistant]{RESET}" # 蓝色:准备阶段 - PROCESS_PREFIX = f"{YELLOW}[✨PromptAssistant]{RESET}" # 黄色:API调用阶段 - - # 提供商显示名称映射 - PROVIDER_DISPLAY_MAP = { - "zhipu": "智谱", - "siliconflow": "硅基流动", - "302ai": "302.AI", - "ollama": "Ollama", - "custom": "自定义" - } @classmethod def INPUT_TYPES(cls): @@ -51,97 +33,100 @@ class PromptExpand: expand_prompts = system_prompts.get('expand_prompts', {}) or {} active_expand_id = system_prompts.get('active_prompts', {}).get('expand') - # 构建提示词模板选项(显示name) + # 构建提示词模板选项(支持分类格式:类别/规则名称) prompt_template_options = [] - id_to_name = {} + id_to_display_name = {} for key, value in expand_prompts.items(): + # 过滤掉不在后端显示的规则 + show_in = value.get('showIn', ["frontend", "node"]) + if 'node' not in show_in: + continue + name = value.get('name', key) - id_to_name[key] = name - prompt_template_options.append(name) + category = value.get('category', '') + # 如果有分类,显示为 "类别/规则名称",否则直接显示规则名称 + display_name = f"{category}/{name}" if category else name + id_to_display_name[key] = display_name + prompt_template_options.append(display_name) # 默认选项回退 default_template_name = prompt_template_options[0] if prompt_template_options else "扩写-自然语言" - if active_expand_id and active_expand_id in id_to_name: - default_template_name = id_to_name[active_expand_id] + if active_expand_id and active_expand_id in id_to_display_name: + default_template_name = id_to_display_name[active_expand_id] + + # ---动态获取LLM服务/模型列表--- + service_options = cls.get_llm_service_options() + default_service = service_options[0] if service_options else "智谱" return { "required": { # 规则模板:来自系统配置的所有扩写规则 - "规则模板": (prompt_template_options or ["扩写-自然语言"], {"default": default_template_name}), - # 临时规则开关:BOOLEAN,并添加中文开关标签 - "临时规则": ("BOOLEAN", {"default": False, "label_on": "启用", "label_off": "禁用"}), - # 临时规则内容输入框:仅在启用临时规则时生效 - "临时规则内容": ("STRING", {"multiline": True, "default": "", "placeholder": "请输入临时规则内容,仅在启用'临时规则'时生效"}), - # 用户提示词:与原文合并后提交 - "用户提示词": ("STRING", {"multiline": True, "default": "", "placeholder": "填写的要扩写的提示词原文,若存在原文端口输入和内容输入,将合并提交"}), - # 扩写服务(使用LLM提供商) - "扩写服务": (["智谱", "硅基流动", "302.AI", "Ollama", "自定义"], {"default": "智谱"}), - # Ollama自动释放:仅对Ollama服务生效 - "Ollama自动释放": ("BOOLEAN", {"default": True, "label_on": "启用", "label_off": "禁用", "tooltip": "⚠️ 该选项仅在选择了Ollama服务时生效"}), + "rule": (prompt_template_options or ["扩写-自然语言"], {"default": default_template_name, "tooltip": "Choose a preset rule for prompt enhancement"}), + # 临时规则开关 + "custom_rule": ("BOOLEAN", {"default": False, "label_on": "Enable", "label_off": "Disable", "tooltip": "Enable to use custom rule content below instead of preset"}), + # 临时规则内容输入框 + "custom_rule_content": ("STRING", {"multiline": True, "default": "", "placeholder": "在此输入临时规则,仅在启用'临时规则'时生效"}), + # 用户提示词 + "user_prompt": ("STRING", {"multiline": True, "default": "", "placeholder": "填写的要优化的提示词原文,若存在原文端口输入和内容输入,将合并提交"}), + # 扩写服务 + "llm_service": (service_options, {"default": default_service, "tooltip": "Select LLM service and model"}), + # Ollama自动释放显存 + "ollama_auto_unload": ("BOOLEAN", {"default": True, "label_on": "Enable", "label_off": "Disable", "tooltip": "Auto unload Ollama model after generation"}), }, "optional": { - # 原文输入端口:非必选 - "原文": ("STRING", {"default": "", "multiline": True, "defaultInput": True, "placeholder": "输入需要扩写的文本..."}), + # 原文输入端口 + "source_text": ("STRING", {"default": "", "multiline": True, "defaultInput": True, "placeholder": "Input text to enhance..."}), + }, + "hidden": { + "unique_id": "UNIQUE_ID", } } RETURN_TYPES = ("STRING",) - RETURN_NAMES = ("扩写结果",) - FUNCTION = "expand" - CATEGORY = "✨提示词小助手" + RETURN_NAMES = ("enhanced_text",) + FUNCTION = "enhance" + CATEGORY = "✨Prompt Assistant" OUTPUT_NODE = False @classmethod - def IS_CHANGED(cls, 规则模板=None, 临时规则=None, 临时规则内容=None, 用户提示词=None, 扩写服务=None, Ollama自动释放=None, 原文=None): + def IS_CHANGED(cls, rule=None, custom_rule=None, custom_rule_content=None, user_prompt=None, llm_service=None, ollama_auto_unload=None, source_text=None, unique_id=None): """ 只在输入内容真正变化时才触发重新执行 使用输入参数的哈希值作为判断依据 """ - text_hash = hashlib.md5(((原文 or "")).encode('utf-8')).hexdigest() - temp_rule_hash = hashlib.md5((临时规则内容 or "").encode('utf-8')).hexdigest() - user_hint_hash = hashlib.md5((用户提示词 or "").encode('utf-8')).hexdigest() + text_hash = hashlib.md5(((source_text or "")).encode('utf-8')).hexdigest() + temp_rule_hash = hashlib.md5((custom_rule_content or "").encode('utf-8')).hexdigest() + user_hint_hash = hashlib.md5((user_prompt or "").encode('utf-8')).hexdigest() input_hash = hash(( - 规则模板, - bool(临时规则), + rule, + bool(custom_rule), temp_rule_hash, user_hint_hash, - 扩写服务, - bool(Ollama自动释放), + llm_service, + bool(ollama_auto_unload), text_hash, )) return input_hash - def expand(self, 规则模板, 临时规则, 临时规则内容, 用户提示词, 扩写服务, Ollama自动释放, 原文=None): + def enhance(self, rule, custom_rule, custom_rule_content, user_prompt, llm_service, ollama_auto_unload, source_text=None, unique_id=None): """ - 扩写文本函数 - - Args: - 原文: 输入的文本(可为空) - 规则模板: 选择的扩写规则模板名称(来自系统配置) - 临时规则: 是否启用临时规则(BOOLEAN) - 临时规则内容: 临时规则的内容(启用时生效) - 用户提示词: 附加的用户提示信息,将与原文合并提交 - 扩写服务: LLM提供商(智谱/硅基流动/自定义) - Ollama自动释放: 是否在调用完成后自动释放Ollama模型(仅对Ollama生效) - - Returns: - tuple: 扩写结果 + 增强/扩写文本函数 """ try: # 允许原文为空,但原文与用户提示词至少有一项非空 - 原文 = (原文 or "").strip() - 用户提示词 = (用户提示词 or "").strip() - if not 原文 and not 用户提示词: + source_text = (source_text or "").strip() + user_prompt = (user_prompt or "").strip() + if not source_text and not user_prompt: return ("",) # 准备系统提示词(规则) system_message = None - rule_name = "临时规则" if (临时规则 and 临时规则内容) else 规则模板 + rule_name = "Custom Rule" if (custom_rule and custom_rule_content) else rule - if 临时规则 and 临时规则内容: + if custom_rule and custom_rule_content: # 使用临时规则 - system_message = {"role": "system", "content": 临时规则内容} + system_message = {"role": "system", "content": custom_rule_content} else: # 使用模板:从config_manager获取系统提示词配置 from ..config_manager import config_manager @@ -149,206 +134,138 @@ class PromptExpand: expand_prompts = system_prompts.get('expand_prompts', {}) if system_prompts else {} # 查找选定的提示词模板(按显示名称匹配) + # 显示名称格式:有分类时为 "类别/规则名称",无分类时为 "规则名称" template_found = False for key, value in expand_prompts.items(): - if value.get('name') == 规则模板: + name = value.get('name', key) + category = value.get('category', '') + # 构建与下拉列表一致的显示名称 + display_name = f"{category}/{name}" if category else name + if display_name == rule: system_message = {"role": value.get('role', 'system'), "content": value.get('content', '')} template_found = True break if not template_found: - # 允许用键名直接匹配 + # 允许用规则名称或键名直接匹配(兼容旧格式) for key, value in expand_prompts.items(): - if key == 规则模板: + if value.get('name') == rule or key == rule: system_message = {"role": value.get('role', 'system'), "content": value.get('content', '')} template_found = True break if not template_found or not system_message or not system_message.get('content'): # 回退到默认 system_message = {"role": "system", "content": "你是一名提示词扩写专家,请将用户给定文本扩写为更完整、更具可读性和可执行性的提示词。"} - rule_name = "默认规则" + rule_name = "Default Rule" - # 选择服务提供商 - provider_map = { - "智谱": "zhipu", - "硅基流动": "siliconflow", - "302.AI": "302ai", - "Ollama": "ollama", - "自定义": "custom", - } - selected_provider = provider_map.get(扩写服务) - if not selected_provider: - raise ValueError(f"不支持的扩写服务: {扩写服务}") - - # 获取对应provider的配置 + # ---解析服务/模型字符串--- + service_id, model_name = self.parse_service_model(llm_service) + if not service_id: + raise ValueError(f"Invalid service selection: {llm_service}") + + # ---获取服务配置--- from ..config_manager import config_manager - provider_config = self._get_provider_config(config_manager, selected_provider) - if not provider_config: - provider_display = {"zhipu": "智谱", "siliconflow": "硅基流动", "302ai": "302.AI", "ollama": "Ollama", "custom": "自定义"}.get(selected_provider, selected_provider) - raise ValueError(f"未找到{provider_display}的配置,请先完成API配置") + service = config_manager.get_service(service_id) + if not service: + raise ValueError(f"Service config not found: {llm_service}") + + # ---构建provider_config--- + # 查找指定的模型或默认模型 + llm_models = service.get('llm_models', []) + target_model = None + + if model_name: + # 查找指定的模型 + target_model = next((m for m in llm_models if m.get('name') == 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: + raise ValueError(f"Service {llm_service} has no available models") + + # 构建配置对象 + provider_config = { + 'provider': service_id, + 'model': target_model.get('name', ''), + 'base_url': service.get('base_url', ''), + 'api_key': service.get('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), + } + + # Ollama特殊处理:添加auto_unload配置 + if service.get('type') == 'ollama': + provider_config['auto_unload'] = ollama_auto_unload # 执行扩写(异步线程 + 可中断) - request_id = f"expand_{int(time.time())}_{random.randint(1000, 9999)}" + request_id = generate_request_id("exp", None, unique_id) # 合并原文与用户提示词 - # 合并顺序:输入端口(原文)在前,节点输入框(用户提示词)在后 - combined_text = 用户提示词 if not 原文 else (f"{原文}\n\n{用户提示词}" if 用户提示词 else 原文) + # 合并顺序:输入端口(source_text)在前,节点输入框(user_prompt)在后 + combined_text = user_prompt if not source_text else (f"{source_text}\n\n{user_prompt}" if user_prompt else source_text) - # 准备阶段日志(合并为一条) - print(f"{self.REQUEST_PREFIX} 扩写准备 | 服务:{扩写服务} | 模型:{provider_config.get('model')} | 规则:{rule_name} | 长度:{len(combined_text)} | 请求ID:{request_id}") + # 检查是否关闭思维链 + model_name = provider_config.get('model') + disable_thinking_enabled = service.get('disable_thinking', True) + thinking_extra = build_thinking_suppression(service_id, model_name) if disable_thinking_enabled else None + model_display = format_model_with_thinking(model_name, bool(thinking_extra)) + + # 获取服务显示名称 + service_display_name = service.get('name', service_id) - result_container = {} - thread = threading.Thread( - target=self._run_llm_expand, - args=(combined_text, system_message, request_id, result_container, selected_provider, provider_config, 扩写服务, Ollama自动释放) + # 准备阶段日志 + log_prepare(TASK_EXPAND, request_id, SOURCE_NODE, service_display_name, model_display, rule_name, {"长度": len(combined_text)}) + + # 检查API密钥和模型 + api_key = provider_config.get('api_key', '') + model = provider_config.get('model', '') + + if not api_key or not model: + raise ValueError(f"Please configure API key and model for {llm_service}") + + # 执行扩写(异步线程 + 可中断) + result = self._run_llm_task( + LLMService.expand_prompt, + service_id, + prompt=combined_text, + request_id=request_id, + stream_callback=None, + custom_provider=service_id, + custom_provider_config=provider_config, + system_message_override=system_message, + task_type=TASK_EXPAND, + source=SOURCE_NODE ) - thread.start() - - # 等待扩写完成,同时检查中断 - while thread.is_alive(): - try: - import nodes - nodes.before_node_execution() - except: - print(f"{self.LOG_PREFIX} 检测到中断信号,正在终止扩写任务...") - result_container['interrupted'] = True - thread.join(timeout=1.0) - if thread.is_alive(): - print(f"{self.LOG_PREFIX} 扩写线程未能及时响应中断") - raise InterruptProcessingException() - time.sleep(0.1) - - result = result_container.get('result') if result and result.get('success'): expanded_text = result.get('data', {}).get('expanded', '').strip() if not expanded_text: - error_msg = 'API返回结果为空,请检查API密钥、模型配置或网络连接' - print(f"{self.LOG_PREFIX} 扩写失败 | 错误:{error_msg}") - raise RuntimeError(f"扩写失败: {error_msg}") + error_msg = 'API returned empty result' + log_error(TASK_EXPAND, request_id, error_msg, source=SOURCE_NODE) + raise RuntimeError(f"Enhancement failed: {error_msg}") # 结果阶段日志由服务层统一输出,节点层不再重复打印 return (expanded_text,) else: - error_msg = result.get('error', '扩写失败,未知错误') if result else '扩写服务未返回结果' - print(f"{self.LOG_PREFIX} 扩写失败: {error_msg}") - raise RuntimeError(f"扩写失败: {error_msg}") + error_msg = result.get('error', 'Unknown error') if result else 'No result returned' + # 如果是中断错误,直接抛出InterruptProcessingException,不打印日志(由基类打印) + if error_msg == "任务被中断": + raise InterruptProcessingException() + log_error(TASK_EXPAND, request_id, error_msg, source=SOURCE_NODE) + raise RuntimeError(f"Enhancement failed: {error_msg}") except InterruptProcessingException: - print(f"{self.LOG_PREFIX} 扩写任务被用户取消") + # 不打印日志,由基类统一打印 raise except Exception as e: - error_msg = format_api_error(e, 扩写服务) - print(f"{self.LOG_PREFIX} 扩写异常: {error_msg}") - raise RuntimeError(f"扩写异常: {error_msg}") + error_msg = format_api_error(e, llm_service) + log_error(TASK_EXPAND, request_id, error_msg, source=SOURCE_NODE) + raise RuntimeError(f"Enhancement error: {error_msg}") - def _get_provider_config(self, config_manager, provider): - """获取指定provider的配置(LLM)""" - llm_config = config_manager.get_llm_config() - if 'providers' in llm_config and provider in llm_config['providers']: - return llm_config['providers'][provider] - return None + # _get_provider_config 方法已由基类 LLMNodeBase 提供 - async def _unload_ollama_model(self, model: str, provider_config: dict, auto_unload: bool): - """ - 卸载Ollama模型以释放显存和内存 - - 参数: - model: 模型名称 - provider_config: 提供商配置字典 - auto_unload: 是否启用自动释放(来自节点参数) - """ - try: - # 检查是否启用自动释放(使用节点参数,不从provider_config读取) - if not auto_unload: - return - - # 获取base_url - base_url = provider_config.get('base_url', 'http://localhost:11434') - # 确保URL不以/v1结尾(Ollama原生API不需要/v1) - if base_url.endswith('/v1'): - base_url = base_url[:-3] - - # 调用Ollama API卸载模型 - url = f"{base_url}/api/generate" - payload = { - "model": model, - "keep_alive": 0 # 立即卸载模型 - } - - print(f"{self.PROCESS_PREFIX} Ollama自动释放显存 | 模型:{model}") - - import httpx - async with httpx.AsyncClient(timeout=5.0) as client: - await client.post(url, json=payload) - - except Exception as e: - # 释放失败不影响主流程,只记录警告 - print(f"{self.LOG_PREFIX} Ollama模型释放失败(不影响结果) | 模型:{model} | 错误:{str(e)[:50]}") - - def _run_llm_expand(self, text, system_message, request_id, result_container, provider, provider_config, service_name, auto_unload): - """在独立线程中运行LLM扩写""" - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - try: - if result_container.get('interrupted'): - result_container['result'] = {"success": False, "error": "任务被中断"} - return - - # 获取API密钥和模型 - api_key = provider_config.get('api_key', '') - model = provider_config.get('model', '') - temperature = provider_config.get('temperature', 0.7) - top_p = provider_config.get('top_p', 0.9) - max_tokens = provider_config.get('max_tokens', 1500) - - if not api_key or not model: - result_container['result'] = { - "success": False, - "error": f"请先配置{provider}的API密钥和模型" - } - return - - # 检查是否启用直连模式 - bypass_proxy = False - try: - from ..config_manager import config_manager as cm - settings = cm.get_settings() - bypass_proxy = settings.get('PromptAssistant.Settings.BypassProxy', False) - except Exception: - pass - - direct_mode_tag = "(直连)" if bypass_proxy else "" - - # 获取提供商显示名称 - provider_display_name = PromptExpand.PROVIDER_DISPLAY_MAP.get(provider, provider) - - # 统一走服务层(含Gemini/网关兼容、思维链抑制、降级与重试) - service_result = loop.run_until_complete( - LLMService.expand_prompt( - prompt=text, - request_id=request_id, - stream_callback=None, - custom_provider=provider, - custom_provider_config=provider_config, - system_message_override=system_message - ) - ) - - if result_container.get('interrupted'): - result_container['result'] = {"success": False, "error": "任务被中断"} - return - - result_container['result'] = service_result - - # 结果日志由服务层统一打印 - - except Exception as e: - result_container['result'] = { - "success": False, - "error": format_api_error(e, provider_display_name) - } - print(f"{self.LOG_PREFIX} 扩写失败 | 服务:{provider_display_name} | 错误:{result_container['result']['error']}") - finally: - loop.close() # 节点映射,用于向ComfyUI注册节点 @@ -358,6 +275,5 @@ NODE_CLASS_MAPPINGS = { # 节点显示名称映射 NODE_DISPLAY_NAME_MAPPINGS = { - "PromptExpand": "✨提示词扩写", + "PromptExpand": "✨Prompt Enhance", } - diff --git a/node/image_caption_node.py b/node/image_caption_node.py index a51562b..1ff87a3 100644 --- a/node/image_caption_node.py +++ b/node/image_caption_node.py @@ -14,33 +14,16 @@ from PIL import Image from comfy.model_management import InterruptProcessingException from ..services.vlm import VisionService -from ..services.error_util import format_api_error - -# 定义ANSI颜色代码常量 -GREEN = "\033[32m" -BLUE = "\033[34m" -YELLOW = "\033[33m" -RESET = "\033[0m" +from ..utils.common import format_api_error, format_model_with_thinking, generate_request_id, log_prepare, log_error, TASK_IMAGE_CAPTION, SOURCE_NODE +from ..services.thinking_control import build_thinking_suppression +from .base import VLMNodeBase -class ImageCaptionNode: +class ImageCaptionNode(VLMNodeBase): """ 图像反推提示词节点 分析输入图像并生成描述性提示词 """ - # 定义日志前缀 - LOG_PREFIX = f"{GREEN}[✨PromptAssistant]{RESET}" # 绿色:结果阶段 - REQUEST_PREFIX = f"{BLUE}[✨PromptAssistant]{RESET}" # 蓝色:准备阶段 - PROCESS_PREFIX = f"{YELLOW}[✨PromptAssistant]{RESET}" # 黄色:API调用阶段 - - # 提供商显示名称映射 - PROVIDER_DISPLAY_MAP = { - "zhipu": "智谱", - "siliconflow": "硅基流动", - "302ai": "302.AI", - "ollama": "Ollama", - "custom": "自定义" - } @classmethod def INPUT_TYPES(cls): @@ -53,74 +36,76 @@ class ImageCaptionNode: if system_prompts and 'vision_prompts' in system_prompts: vision_prompts = system_prompts['vision_prompts'] - # 构建提示词模板选项 + # 构建提示词模板选项(支持分类格式:类别/规则名称) prompt_template_options = [] for key, value in vision_prompts.items(): + # 过滤掉不在后端显示的规则 + show_in = value.get('showIn', ["frontend", "node"]) + if 'node' not in show_in: + continue + name = value.get('name', key) - prompt_template_options.append(name) + category = value.get('category', '') + # 如果有分类,显示为 "类别/规则名称",否则直接显示规则名称 + display_name = f"{category}/{name}" if category else name + prompt_template_options.append(display_name) # 如果没有选项,添加一个默认选项 if not prompt_template_options: prompt_template_options = ["默认中文反推提示词"] + + # ---动态获取VLM服务/模型列表--- + service_options = cls.get_vlm_service_options() + default_service = service_options[0] if service_options else "智谱" return { "required": { - "图像": ("IMAGE",), - "规则模板": (prompt_template_options, {"default": prompt_template_options[0] if prompt_template_options else "默认中文反推提示词"}), - "临时规则": ("BOOLEAN", {"default": False, "label_on": "启用", "label_off": "禁用"}), - "临时规则内容": ("STRING", {"multiline": True, "default": "", "placeholder": "请输入临时规则内容,仅在启用'临时规则'时生效"}), - "视觉服务": (["智谱", "硅基流动", "302.AI", "Ollama", "自定义"], {"default": "智谱"}), - # Ollama自动释放:仅对Ollama视觉服务生效 - "Ollama自动释放": ("BOOLEAN", {"default": True, "label_on": "启用", "label_off": "禁用", "tooltip": "⚠️ 该选项仅在选择了Ollama服务时生效"}), + "image": ("IMAGE",), + "rule": (prompt_template_options, {"default": prompt_template_options[0] if prompt_template_options else "默认中文反推提示词", "tooltip": "Choose a preset rule for image captioning"}), + "custom_rule": ("BOOLEAN", {"default": False, "label_on": "Enable", "label_off": "Disable", "tooltip": "Enable to use custom rule content below"}), + "custom_rule_content": ("STRING", {"multiline": True, "default": "", "placeholder": "在此输入临时规则,仅在启用'临时规则'时生效"}), + "user_prompt": ("STRING", {"multiline": True, "default": "", "placeholder": "输入额外的具体要求,将与规则一起发送给模型"}), + "vlm_service": (service_options, {"default": default_service, "tooltip": "Select VLM service and model"}), + # Ollama Automatic VRAM Unload + "ollama_auto_unload": ("BOOLEAN", {"default": True, "label_on": "Enable", "label_off": "Disable", "tooltip": "Auto unload Ollama model after generation"}), }, + "hidden": { + "unique_id": "UNIQUE_ID", + } } RETURN_TYPES = ("STRING",) - RETURN_NAMES = ("提示词输出",) + RETURN_NAMES = ("prompt_output",) FUNCTION = "analyze_image" - CATEGORY = "✨提示词小助手" + CATEGORY = "✨Prompt Assistant" OUTPUT_NODE = False @classmethod - def IS_CHANGED(cls, 图像=None, 规则模板=None, 临时规则=None, 临时规则内容=None, 视觉服务=None, Ollama自动释放=None): + def IS_CHANGED(cls, image=None, rule=None, custom_rule=None, custom_rule_content=None, user_prompt=None, vlm_service=None, ollama_auto_unload=None, unique_id=None): """ 只在输入内容真正变化时才触发重新执行 使用输入参数的哈希值作为判断依据 """ - # 计算图像的哈希值(只使用第一帧的部分数据,避免计算量过大) - img_hash = "" - if 图像 is not None: - try: - if len(图像.shape) == 4: - # 取第一帧的中心区域作为哈希计算依据 - h, w = 图像.shape[1:3] - center_h, center_w = h // 2, w // 2 - size = min(100, h // 4, w // 4) # 限制计算区域大小 - img_data = 图像[0, - max(0, center_h - size):min(h, center_h + size), - max(0, center_w - size):min(w, center_w + size), - 0].cpu().numpy().tobytes() - img_hash = hashlib.md5(img_data).hexdigest() - else: - # 如果不是4D张量,使用整个张量的哈希 - img_data = 图像.cpu().numpy().tobytes() - img_hash = hashlib.md5(img_data).hexdigest() - except Exception: - img_hash = "0" + # 导入图像哈希工具函数 + from ..utils.image import compute_image_hash + + # 计算图像的哈希值 + img_hash = compute_image_hash(image) # 组合所有输入的哈希值 input_hash = hash(( img_hash, - 规则模板, - bool(临时规则), - 临时规则内容, - 视觉服务, - bool(Ollama自动释放) + rule, + bool(custom_rule), + custom_rule_content, + user_prompt, + vlm_service, + bool(ollama_auto_unload) )) return input_hash - def analyze_image(self, 图像, 规则模板, 临时规则, 临时规则内容, 视觉服务, Ollama自动释放): + def analyze_image(self, image, rule, custom_rule, custom_rule_content, user_prompt, vlm_service, ollama_auto_unload, unique_id=None): """ 分析图像并生成提示词 @@ -129,6 +114,7 @@ class ImageCaptionNode: 规则模板: 选择的提示词模板 临时规则: 是否启用临时规则 临时规则内容: 临时规则的内容 + 用户提示词: 用户补充的提示词 视觉服务: 选择的视觉服务 Returns: @@ -136,20 +122,20 @@ class ImageCaptionNode: """ try: # 检查输入 - if 图像 is None: + if image is None: raise ValueError("输入图像不能为空") # 将图像转换为base64编码 - image_data = self._image_to_base64(图像) + image_data = self._image_to_base64(image) # 获取提示词模板内容 prompt_template = None # 确定使用的规则 - rule_name = "临时规则" if (临时规则 and 临时规则内容) else 规则模板 + rule_name = "Custom Rule" if (custom_rule and custom_rule_content) else rule - if 临时规则 and 临时规则内容: - prompt_template = 临时规则内容 + if custom_rule and custom_rule_content: + prompt_template = custom_rule_content else: # 从config_manager获取系统提示词配置 from ..config_manager import config_manager @@ -160,18 +146,23 @@ class ImageCaptionNode: if system_prompts and 'vision_prompts' in system_prompts: vision_prompts = system_prompts['vision_prompts'] - # 查找选定的提示词模板 + # 查找选定的提示词模板(按显示名称匹配) + # 显示名称格式:有分类时为 "类别/规则名称",无分类时为 "规则名称" template_found = False for key, value in vision_prompts.items(): - if value.get('name') == 规则模板: + name = value.get('name', key) + category = value.get('category', '') + # 构建与下拉列表一致的显示名称 + display_name = f"{category}/{name}" if category else name + if display_name == rule: prompt_template = value.get('content') template_found = True break if not template_found: - # 尝试直接匹配键名 + # 允许用规则名称或键名直接匹配(兼容旧格式) for key, value in vision_prompts.items(): - if key == 规则模板: + if value.get('name') == rule or key == rule: prompt_template = value.get('content') template_found = True break @@ -179,195 +170,109 @@ class ImageCaptionNode: # 如果没有找到提示词模板,使用默认值 if not template_found or not prompt_template: prompt_template = "请详细描述这张图片的内容,包括主体、场景、风格、色彩等要素。" - rule_name = "默认规则" + rule_name = "Default Rule" - # 映射视觉服务选项到provider - provider_map = { - "智谱": "zhipu", - "硅基流动": "siliconflow", - "302.AI": "302ai", - "Ollama": "ollama", - "自定义": "custom" - } + # 拼接用户提示词 + if user_prompt and user_prompt.strip(): + prompt_template = f"{prompt_template}\n\n用户补充要求:\n{user_prompt}" - # 获取选定的provider - selected_provider = provider_map.get(视觉服务) - if not selected_provider: - raise ValueError(f"不支持的视觉服务: {视觉服务}") - - # 获取对应provider的配置 + # ---解析服务/模型字符串--- + service_id, model_name = self.parse_service_model(vlm_service) + if not service_id: + raise ValueError(f"Invalid service selection: {vlm_service}") + + # ---获取服务配置--- from ..config_manager import config_manager - provider_config = self._get_provider_config(config_manager, selected_provider) - if not provider_config: - raise ValueError(f"未找到{视觉服务}的配置,请先完成API配置") + service = config_manager.get_service(service_id) + if not service: + raise ValueError(f"Service config not found: {vlm_service}") + + # ---构建provider_config--- + # 查找指定的模型或默认模型 + vlm_models = service.get('vlm_models', []) + target_model = None + + if model_name: + # 查找指定的模型 + target_model = next((m for m in vlm_models if m.get('name') == 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: + raise ValueError(f"Service {vlm_service} has no available models") + + # 构建配置对象 + provider_config = { + 'provider': service_id, + 'model': target_model.get('name', ''), + 'base_url': service.get('base_url', ''), + 'api_key': service.get('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), + } + + # Ollama特殊处理:添加auto_unload配置 + if service.get('type') == 'ollama': + provider_config['auto_unload'] = ollama_auto_unload # 创建请求ID - request_id = f"image_caption_{int(time.time())}_{random.randint(1000, 9999)}" + request_id = generate_request_id("icap", None, unique_id) - # 准备阶段日志(合并为一条) - print(f"{self.REQUEST_PREFIX} 图像反推准备 | 服务:{视觉服务} | 模型:{provider_config.get('model')} | 规则:{rule_name} | 请求ID:{request_id}") + # 检查是否关闭思维链 + model_full_name = provider_config.get('model') + disable_thinking_enabled = service.get('disable_thinking', True) + thinking_extra = build_thinking_suppression(service_id, model_full_name) if disable_thinking_enabled else None + model_display = format_model_with_thinking(model_full_name, bool(thinking_extra)) + + # 获取服务显示名称 + service_display_name = service.get('name', service_id) + + # 准备阶段日志 + log_prepare(TASK_IMAGE_CAPTION, request_id, SOURCE_NODE, service_display_name, model_display, rule_name) # 执行图像分析 - result = self._analyze_with_vision_service(image_data, prompt_template, selected_provider, provider_config, request_id, Ollama自动释放) + result = self._run_vision_task( + VisionService.analyze_image, + service_id, + image_data=image_data, + request_id=request_id, + stream_callback=None, + prompt_content=prompt_template, + custom_provider=service_id, + custom_provider_config=provider_config, + task_type=TASK_IMAGE_CAPTION, + source=SOURCE_NODE + ) if result and result.get('success'): description = result.get('data', {}).get('description', '').strip() if not description: error_msg = 'API返回结果为空,请检查API密钥、模型配置或网络连接' - print(f"{self.LOG_PREFIX} 图像反推失败 | 错误:{error_msg}") + log_error(TASK_IMAGE_CAPTION, request_id, error_msg, source=SOURCE_NODE) raise RuntimeError(f"分析失败: {error_msg}") # 服务层已经打印了完成日志,这里不再重复 return (description,) else: error_msg = result.get('error', '分析失败,未知错误') if result else '分析服务未返回结果' - print(f"{self.LOG_PREFIX} 图像反推分析失败: {error_msg}") + # 如果是中断错误,直接抛出InterruptProcessingException,不打印日志(由基类打印) + if error_msg == "任务被中断": + raise InterruptProcessingException() + log_error(TASK_IMAGE_CAPTION, request_id, error_msg, source=SOURCE_NODE) raise RuntimeError(f"分析失败: {error_msg}") except InterruptProcessingException: - print(f"{self.LOG_PREFIX} 图像反推任务被用户取消") + # 不打印日志,由基类统一打印 raise except Exception as e: - error_msg = format_api_error(e, 视觉服务) - print(f"{self.LOG_PREFIX} 图像反推节点异常: {error_msg}") + error_msg = format_api_error(e, vlm_service) + log_error(TASK_IMAGE_CAPTION, request_id, error_msg, source=SOURCE_NODE) raise RuntimeError(f"分析异常: {error_msg}") - def _analyze_with_vision_service(self, image_data, prompt_template, provider, provider_config, request_id, auto_unload): - """使用视觉服务分析图像""" - try: - result_container = {} - - # 在独立线程中运行图像分析 - thread = threading.Thread( - target=self._run_async_vision_analysis, - args=(image_data, prompt_template, request_id, result_container, provider, provider_config, auto_unload) - ) - thread.start() - - # 等待分析完成,同时检查中断 - while thread.is_alive(): - # 检查是否被中断 - 这会抛出 InterruptProcessingException - try: - import nodes - nodes.before_node_execution() - except: - # 如果检查中断时出现异常,说明被中断了 - print(f"{self.LOG_PREFIX} 检测到中断信号,正在终止分析任务...") - # 设置结果容器为中断状态,让线程知道要停止 - result_container['interrupted'] = True - # 等待线程结束或超时 - thread.join(timeout=1.0) - if thread.is_alive(): - print(f"{self.LOG_PREFIX} 分析线程未能及时响应中断") - raise InterruptProcessingException() - - time.sleep(0.1) - - return result_container.get('result') - - except Exception as e: - return {"success": False, "error": str(e)} - - def _run_async_vision_analysis(self, image_data, prompt_template, request_id, result_container, provider, provider_config, auto_unload): - """在独立线程中运行异步图像分析任务(改为复用服务层以获得Gemini兼容与降级能力)""" - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - try: - # 开始前的中断检测 - if result_container.get('interrupted'): - result_container['result'] = {"success": False, "error": "任务被中断"} - return - - # 直接复用服务层:包括图像预处理、流式/非流式降级、max_tokens 兼容与思维链抑制 - service_call = VisionService.analyze_image( - image_data=image_data, - request_id=request_id, - stream_callback=None, - prompt_content=prompt_template, - custom_provider=provider, - custom_provider_config=provider_config - ) - result = loop.run_until_complete(service_call) - - # 中断检测 - if result_container.get('interrupted'): - result_container['result'] = {"success": False, "error": "任务被中断"} - return - - result_container['result'] = result - - except Exception as e: - error_message = format_api_error(e, provider) - result_container['result'] = {"success": False, "error": error_message} - finally: - loop.close() - - def _get_provider_config(self, config_manager, provider): - """获取指定provider的配置""" - vision_config = config_manager.get_vision_config() - if 'providers' in vision_config and provider in vision_config['providers']: - return vision_config['providers'][provider] - return None - - async def _unload_ollama_model(self, model: str, provider_config: dict, auto_unload: bool): - """ - 卸载Ollama模型以释放显存和内存 - - 参数: - model: 模型名称 - provider_config: 提供商配置字典 - auto_unload: 是否启用自动释放(来自节点参数) - """ - try: - # 检查是否启用自动释放(使用节点参数,不从provider_config读取) - if not auto_unload: - return - - # 获取base_url - base_url = provider_config.get('base_url', 'http://localhost:11434') - # 确保URL不以/v1结尾(Ollama原生API不需要/v1) - if base_url.endswith('/v1'): - base_url = base_url[:-3] - - # 调用Ollama API卸载模型 - url = f"{base_url}/api/generate" - payload = { - "model": model, - "keep_alive": 0 # 立即卸载模型 - } - - print(f"{self.PROCESS_PREFIX} Ollama自动释放显存 | 模型:{model}") - - import httpx - async with httpx.AsyncClient(timeout=5.0) as client: - await client.post(url, json=payload) - - except Exception as e: - # 释放失败不影响主流程,只记录警告 - print(f"{self.LOG_PREFIX} Ollama模型释放失败(不影响结果) | 模型:{model} | 错误:{str(e)[:50]}") - - def _image_to_base64(self, image_tensor): - """将图像张量转换为base64编码""" - # 确保图像是正确的形状 [batch, height, width, channels] - if len(image_tensor.shape) == 4: - # 取第一张图片 - image_tensor = image_tensor[0] - - # 将图像转换为numpy数组并缩放到0-255范围 - image_np = (image_tensor.cpu().numpy() * 255).astype(np.uint8) - - # 创建PIL图像 - image = Image.fromarray(image_np) - - # 将图像转换为JPEG格式的字节流 - buffer = BytesIO() - image.save(buffer, format="JPEG", quality=95) - - # 将字节流转换为base64编码 - encoded_image = base64.b64encode(buffer.getvalue()).decode('utf-8') - - # 返回带有MIME类型的data URL - return f"data:image/jpeg;base64,{encoded_image}" # 节点映射,用于向ComfyUI注册节点 NODE_CLASS_MAPPINGS = { diff --git a/node/kontext_preset_node.py b/node/kontext_preset_node.py index 1eeb530..add4779 100644 --- a/node/kontext_preset_node.py +++ b/node/kontext_preset_node.py @@ -14,33 +14,15 @@ from PIL import Image from comfy.model_management import InterruptProcessingException from ..services.vlm import VisionService -from ..services.error_util import format_api_error - -# 定义ANSI颜色代码常量 -GREEN = "\033[32m" -BLUE = "\033[34m" -YELLOW = "\033[33m" -RESET = "\033[0m" +from ..utils.common import format_api_error, log_prepare, log_error, SOURCE_NODE, generate_request_id +from .base import VLMNodeBase -class KontextPresetNode: +class KontextPresetNode(VLMNodeBase): """ Kontext预设助手节点 使用Kontext预设分析图像并生成创意转换指令 """ - # 定义日志前缀 - LOG_PREFIX = f"{GREEN}[✨PromptAssistant]{RESET}" # 绿色:结果阶段 - REQUEST_PREFIX = f"{BLUE}[✨PromptAssistant]{RESET}" # 蓝色:准备阶段 - PROCESS_PREFIX = f"{YELLOW}[✨PromptAssistant]{RESET}" # 黄色:API调用阶段 - - # 提供商显示名称映射 - PROVIDER_DISPLAY_MAP = { - "zhipu": "智谱", - "siliconflow": "硅基流动", - "302ai": "302.AI", - "ollama": "Ollama", - "custom": "自定义" - } # 缓存配置数据,避免重复从文件系统读取 _kontext_config = None @@ -50,8 +32,10 @@ class KontextPresetNode: """加载Kontext配置,使用缓存避免重复读取文件""" if cls._kontext_config is None: try: - config_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "config") - kontext_presets_path = os.path.join(config_dir, "kontext_presets.json") + from ..config_manager import config_manager + # 使用 config_manager 的 kontext_presets_path (指向 rules 目录) + kontext_presets_path = config_manager.kontext_presets_path + if os.path.exists(kontext_presets_path): with open(kontext_presets_path, "r", encoding="utf-8") as f: cls._kontext_config = json.load(f) @@ -61,6 +45,7 @@ class KontextPresetNode: print(f"{cls.LOG_PREFIX} 加载Kontext配置失败: {str(e)}") cls._kontext_config = {} return cls._kontext_config + @classmethod def INPUT_TYPES(cls): @@ -79,79 +64,71 @@ class KontextPresetNode: # 如果没有选项,添加一个默认选项 if not prompt_template_options: prompt_template_options = ["情境深度融合"] + + # ---动态获取VLM服务/模型列表--- + service_options = cls.get_vlm_service_options() + default_service = service_options[0] if service_options else "智谱" return { "required": { - "图像": ("IMAGE",), - "Kontext预设": (prompt_template_options, {"default": prompt_template_options[0] if prompt_template_options else "情境深度融合"}), - "视觉服务": (["智谱", "硅基流动", "302.AI", "Ollama", "自定义"], {"default": "智谱"}), - # Ollama自动释放:仅对Ollama视觉服务生效 - "Ollama自动释放": ("BOOLEAN", {"default": True, "label_on": "启用", "label_off": "禁用", "tooltip": "⚠️ 该选项仅在选择了Ollama服务时生效"}), + "image": ("IMAGE",), + "kontext_preset": (prompt_template_options, {"default": prompt_template_options[0] if prompt_template_options else "情境深度融合"}), + "user_prompt": ("STRING", {"multiline": True, "default": "", "placeholder": "输入额外的具体要求,将与预设一起发送给模型"}), + "vlm_service": (service_options, {"default": default_service, "tooltip": "Select VLM service and model"}), + # Ollama Automatic VRAM Unload + "ollama_auto_unload": ("BOOLEAN", {"default": True, "label_on": "Enable", "label_off": "Disable", "tooltip": "Auto unload Ollama model after generation"}), }, } RETURN_TYPES = ("STRING",) - RETURN_NAMES = ("创意指令",) + RETURN_NAMES = ("creative_instruction",) FUNCTION = "analyze_image" - CATEGORY = "✨提示词小助手" + CATEGORY = "✨Prompt Assistant" OUTPUT_NODE = False @classmethod - def IS_CHANGED(cls, 图像=None, Kontext预设=None, 视觉服务=None, Ollama自动释放=None): + def IS_CHANGED(cls, image=None, kontext_preset=None, user_prompt=None, vlm_service=None, ollama_auto_unload=None): """ 只在输入内容真正变化时才触发重新执行 使用输入参数的哈希值作为判断依据 """ - # 计算图像的哈希值(只使用第一帧的部分数据,避免计算量过大) - img_hash = "" - if 图像 is not None: - try: - if len(图像.shape) == 4: - # 取第一帧的中心区域作为哈希计算依据 - h, w = 图像.shape[1:3] - center_h, center_w = h // 2, w // 2 - size = min(100, h // 4, w // 4) # 限制计算区域大小 - img_data = 图像[0, - max(0, center_h - size):min(h, center_h + size), - max(0, center_w - size):min(w, center_w + size), - 0].cpu().numpy().tobytes() - img_hash = hashlib.md5(img_data).hexdigest() - else: - # 如果不是4D张量,使用整个张量的哈希 - img_data = 图像.cpu().numpy().tobytes() - img_hash = hashlib.md5(img_data).hexdigest() - except Exception: - img_hash = "0" + # 导入图像哈希工具函数 + from ..utils.image import compute_image_hash + + # 计算图像的哈希值 + img_hash = compute_image_hash(image) # 组合所有输入的哈希值 input_hash = hash(( img_hash, - Kontext预设, - 视觉服务, - bool(Ollama自动释放) + kontext_preset, + user_prompt, + vlm_service, + bool(ollama_auto_unload) )) return input_hash - def analyze_image(self, 图像, Kontext预设, 视觉服务, Ollama自动释放): + def analyze_image(self, image, kontext_preset, user_prompt, vlm_service, ollama_auto_unload): """ 使用Kontext预设分析图像并生成创意转换指令 Args: - 图像: 输入的图像数据 - Kontext预设: 选择的Kontext预设 - 视觉服务: 选择的视觉服务 + image: 输入的图像数据 + kontext_preset: 选择的Kontext预设 + user_prompt: 用户补充的提示词 + vlm_service: 选择的视觉服务 Returns: tuple: 分析结果 """ try: # 检查输入 - if 图像 is None: + if image is None: raise ValueError("输入图像不能为空") # 将图像转换为base64编码 - image_data = self._image_to_base64(图像) + image_data = self._image_to_base64(image) # 获取kontext配置 config_data = self.__class__._load_kontext_config() @@ -163,10 +140,10 @@ class KontextPresetNode: prompt_template = None # 查找选定的提示词模板 - preset_name = Kontext预设 + preset_name = kontext_preset template_found = False for key, value in kontext_presets.items(): - if value.get('name') == Kontext预设: + if value.get('name') == kontext_preset: prompt_template = value.get('content') template_found = True break @@ -174,7 +151,7 @@ class KontextPresetNode: if not template_found: # 尝试直接匹配键名 for key, value in kontext_presets.items(): - if key == Kontext预设 or key == f"kontext_{Kontext预设}": + if key == kontext_preset or key == f"kontext_{kontext_preset}": prompt_template = value.get('content') template_found = True break @@ -188,192 +165,103 @@ class KontextPresetNode: final_prompt = prompt_template if kontext_prefix and kontext_suffix: final_prompt = f"{kontext_prefix}\n\nThe Brief: {prompt_template}\n\n{kontext_suffix}" + + # 拼接用户提示词 + if user_prompt and user_prompt.strip(): + final_prompt = f"{final_prompt}\n\n用户补充要求:\n{user_prompt}" - # 映射视觉服务选项到provider - provider_map = { - "智谱": "zhipu", - "硅基流动": "siliconflow", - "302.AI": "302ai", - "Ollama": "ollama", - "自定义": "custom" - } - - # 获取选定的provider - selected_provider = provider_map.get(视觉服务) - if not selected_provider: - raise ValueError(f"不支持的视觉服务: {视觉服务}") - - # 获取对应provider的配置 + # ---解析服务/模型字符串--- + service_id, model_name = self.parse_service_model(vlm_service) + if not service_id: + raise ValueError(f"Invalid service selection: {vlm_service}") + + # ---获取服务配置--- from ..config_manager import config_manager - provider_config = self._get_provider_config(config_manager, selected_provider) - if not provider_config: - raise ValueError(f"未找到{视觉服务}的配置,请先完成API配置") + service = config_manager.get_service(service_id) + if not service: + raise ValueError(f"Service config not found: {vlm_service}") + + # ---构建provider_config--- + # 查找指定的模型或默认模型 + vlm_models = service.get('vlm_models', []) + target_model = None + + if model_name: + # 查找指定的模型 + target_model = next((m for m in vlm_models if m.get('name') == 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: + raise ValueError(f"Service {vlm_service} has no available models") + + # 构建配置对象 + provider_config = { + 'provider': service_id, + 'model': target_model.get('name', ''), + 'base_url': service.get('base_url', ''), + 'api_key': service.get('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), + } + + # Ollama特殊处理:添加auto_unload配置 + if service.get('type') == 'ollama': + provider_config['auto_unload'] = ollama_auto_unload # 创建请求ID request_id = f"kontext_preset_{int(time.time())}_{random.randint(1000, 9999)}" - # 准备阶段日志(合并为一条) - print(f"{self.REQUEST_PREFIX} Kontext预设准备 | 服务:{视觉服务} | 模型:{provider_config.get('model')} | 预设:{preset_name} | 请求ID:{request_id}") + # 获取服务显示名称 + service_display_name = service.get('name', service_id) + + # 准备阶段日志 + log_prepare("Kontext预设", request_id, SOURCE_NODE, service_display_name, provider_config.get('model'), preset_name) # 执行图像分析 - result = self._analyze_with_vision_service(image_data, final_prompt, selected_provider, provider_config, request_id, Ollama自动释放) + result = self._run_vision_task( + VisionService.analyze_image, + service_id, + image_data=image_data, + request_id=request_id, + stream_callback=None, + prompt_content=final_prompt, + custom_provider=service_id, + custom_provider_config=provider_config, + task_type="Kontext预设", + source=SOURCE_NODE + ) if result and result.get('success'): description = result.get('data', {}).get('description', '').strip() if not description: - error_msg = 'API返回结果为空,请检查API密钥、模型配置或网络连接' - print(f"{self.LOG_PREFIX} Kontext预设失败 | 错误:{error_msg}") - raise RuntimeError(f"分析失败: {error_msg}") + error_msg = 'API returned empty result' + log_error("Kontext预设", request_id, error_msg, source=SOURCE_NODE) + raise RuntimeError(f"Analysis failed: {error_msg}") # 服务层已经打印了完成日志,这里不再重复 return (description,) else: - error_msg = result.get('error', '分析失败,未知错误') if result else '分析服务未返回结果' - print(f"{self.LOG_PREFIX} Kontext预设分析失败: {error_msg}") - raise RuntimeError(f"分析失败: {error_msg}") + error_msg = result.get('error', 'Unknown error') if result else 'No result returned' + # 如果是中断错误,直接打印日志并抛出InterruptProcessingException + if error_msg == "任务被中断": + print(f"{self.LOG_PREFIX} ⛔️Task cancelled by user | RequestID:{request_id}") + raise InterruptProcessingException() + log_error("Kontext预设", request_id, error_msg, source=SOURCE_NODE) + raise RuntimeError(f"Analysis failed: {error_msg}") except InterruptProcessingException: - print(f"{self.LOG_PREFIX} Kontext预设任务被用户取消") + print(f"{self.LOG_PREFIX} ⛔️Task cancelled by user | RequestID:{request_id}") raise except Exception as e: - error_msg = format_api_error(e, 视觉服务) - print(f"{self.LOG_PREFIX} Kontext预设节点异常: {error_msg}") - raise RuntimeError(f"分析异常: {error_msg}") + error_msg = format_api_error(e, vlm_service) + log_error("Kontext预设", request_id, error_msg, source=SOURCE_NODE) + raise RuntimeError(f"Analysis error: {error_msg}") - def _analyze_with_vision_service(self, image_data, prompt_template, provider, provider_config, request_id, auto_unload): - """使用视觉服务分析图像""" - try: - result_container = {} - - # 在独立线程中运行图像分析 - thread = threading.Thread( - target=self._run_async_vision_analysis, - args=(image_data, prompt_template, request_id, result_container, provider, provider_config, auto_unload) - ) - thread.start() - - # 等待分析完成,同时检查中断 - while thread.is_alive(): - # 检查是否被中断 - 这会抛出 InterruptProcessingException - try: - import nodes - nodes.before_node_execution() - except: - # 如果检查中断时出现异常,说明被中断了 - print(f"{self.LOG_PREFIX} 检测到中断信号,正在终止分析任务...") - # 设置结果容器为中断状态,让线程知道要停止 - result_container['interrupted'] = True - # 等待线程结束或超时 - thread.join(timeout=1.0) - if thread.is_alive(): - print(f"{self.LOG_PREFIX} 分析线程未能及时响应中断") - raise InterruptProcessingException() - - time.sleep(0.1) - - return result_container.get('result') - - except Exception as e: - return {"success": False, "error": str(e)} - - def _run_async_vision_analysis(self, image_data, prompt_template, request_id, result_container, provider, provider_config, auto_unload): - """在独立线程中运行异步图像分析任务(改为复用服务层,获得Gemini兼容与降级能力)""" - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - try: - if result_container.get('interrupted'): - result_container['result'] = {"success": False, "error": "任务被中断"} - return - - result = loop.run_until_complete( - VisionService.analyze_image( - image_data=image_data, - request_id=request_id, - stream_callback=None, - prompt_content=prompt_template, - custom_provider=provider, - custom_provider_config=provider_config - ) - ) - - if result_container.get('interrupted'): - result_container['result'] = {"success": False, "error": "任务被中断"} - return - - result_container['result'] = result - - except Exception as e: - error_message = format_api_error(e, provider) - result_container['result'] = {"success": False, "error": error_message} - finally: - loop.close() - - def _get_provider_config(self, config_manager, provider): - """获取指定provider的配置""" - vision_config = config_manager.get_vision_config() - if 'providers' in vision_config and provider in vision_config['providers']: - return vision_config['providers'][provider] - return None - - async def _unload_ollama_model(self, model: str, provider_config: dict, auto_unload: bool): - """ - 卸载Ollama模型以释放显存和内存 - - 参数: - model: 模型名称 - provider_config: 提供商配置字典 - auto_unload: 是否启用自动释放(来自节点参数) - """ - try: - # 检查是否启用自动释放(使用节点参数,不从provider_config读取) - if not auto_unload: - return - - # 获取base_url - base_url = provider_config.get('base_url', 'http://localhost:11434') - # 确保URL不以/v1结尾(Ollama原生API不需要/v1) - if base_url.endswith('/v1'): - base_url = base_url[:-3] - - # 调用Ollama API卸载模型 - url = f"{base_url}/api/generate" - payload = { - "model": model, - "keep_alive": 0 # 立即卸载模型 - } - - print(f"{self.PROCESS_PREFIX} Ollama自动释放显存 | 模型:{model}") - - import httpx - async with httpx.AsyncClient(timeout=5.0) as client: - await client.post(url, json=payload) - - except Exception as e: - # 释放失败不影响主流程,只记录警告 - print(f"{self.LOG_PREFIX} Ollama模型释放失败(不影响结果) | 模型:{model} | 错误:{str(e)[:50]}") - - def _image_to_base64(self, image_tensor): - """将图像张量转换为base64编码""" - # 确保图像是正确的形状 [batch, height, width, channels] - if len(image_tensor.shape) == 4: - # 取第一张图片 - image_tensor = image_tensor[0] - - # 将图像转换为numpy数组并缩放到0-255范围 - image_np = (image_tensor.cpu().numpy() * 255).astype(np.uint8) - - # 创建PIL图像 - image = Image.fromarray(image_np) - - # 将图像转换为JPEG格式的字节流 - buffer = BytesIO() - image.save(buffer, format="JPEG", quality=95) - - # 将字节流转换为base64编码 - encoded_image = base64.b64encode(buffer.getvalue()).decode('utf-8') - - # 返回带有MIME类型的data URL - return f"data:image/jpeg;base64,{encoded_image}" # 节点映射,用于向ComfyUI注册节点 NODE_CLASS_MAPPINGS = { @@ -382,5 +270,5 @@ NODE_CLASS_MAPPINGS = { # 节点显示名称映射 NODE_DISPLAY_NAME_MAPPINGS = { - "KontextPresetNode": "✨Kontext预设", + "KontextPresetNode": "✨Kontext Preset", } \ No newline at end of file diff --git a/node/translate_node.py b/node/translate_node.py index 72ed454..30609d7 100644 --- a/node/translate_node.py +++ b/node/translate_node.py @@ -10,71 +10,60 @@ from comfy.model_management import InterruptProcessingException from ..services.llm import LLMService from ..services.baidu import BaiduTranslateService -from ..services.error_util import format_api_error +from ..utils.common import format_api_error, format_model_with_thinking, generate_request_id, log_prepare, log_error, TASK_TRANSLATE, SOURCE_NODE +from ..services.thinking_control import build_thinking_suppression +from .base import LLMNodeBase -# 定义ANSI颜色代码常量 -GREEN = "\033[32m" -BLUE = "\033[34m" -YELLOW = "\033[33m" -RESET = "\033[0m" - - -class PromptTranslate: +class PromptTranslate(LLMNodeBase): """ - 文本翻译节点 + 提示词翻译节点 自动识别输入语言并翻译成目标语言,支持多种翻译服务 """ - # 定义日志前缀 - LOG_PREFIX = f"{GREEN}[✨PromptAssistant]{RESET}" # 绿色:结果阶段 - REQUEST_PREFIX = f"{BLUE}[✨PromptAssistant]{RESET}" # 蓝色:准备阶段 - PROCESS_PREFIX = f"{YELLOW}[✨PromptAssistant]{RESET}" # 黄色:API调用阶段 - - # 提供商显示名称映射 - PROVIDER_DISPLAY_MAP = { - "zhipu": "智谱", - "siliconflow": "硅基流动", - "302ai": "302.AI", - "ollama": "Ollama", - "custom": "自定义" - } @classmethod def INPUT_TYPES(cls): + # ---动态获取翻译服务/模型列表(包含硬编码的百度翻译)--- + service_options = cls.get_translate_service_options() + default_service = service_options[0] if service_options else "百度翻译" + return { "required": { - "原文": ("STRING", {"forceInput": True, "default": "", "multiline": True, "placeholder": "输入要翻译的文本..."}), - "目标语言": (["英文", "中文"], {"default": "英文"}), - "翻译服务": (["百度翻译", "智谱翻译", "硅基流动翻译", "302.AI翻译", "Ollama翻译", "自定义翻译"], {"default": "百度翻译"}), - # Ollama自动释放:仅对Ollama翻译服务生效 - "Ollama自动释放": ("BOOLEAN", {"default": True, "label_on": "启用", "label_off": "禁用", "tooltip": "⚠️ 该选项仅在选择了Ollama服务时生效"}), + "source_text": ("STRING", {"forceInput": True, "default": "", "multiline": True, "placeholder": "Input text to translate..."}), + "target_language": (["English", "Chinese"], {"default": "English"}), + "translate_service": (service_options, {"default": default_service, "tooltip": "Select translation service and model"}), + # Ollama Automatic VRAM Unload + "ollama_auto_unload": ("BOOLEAN", {"default": True, "label_on": "Enable", "label_off": "Disable", "tooltip": "Auto unload Ollama model after generation"}), + }, + "hidden": { + "unique_id": "UNIQUE_ID", }, } RETURN_TYPES = ("STRING",) - RETURN_NAMES = ("翻译输出",) + RETURN_NAMES = ("translated_text",) FUNCTION = "translate" - CATEGORY = "✨提示词小助手" + CATEGORY = "✨Prompt Assistant" OUTPUT_NODE = False @classmethod - def IS_CHANGED(cls, 原文=None, 目标语言=None, 翻译服务=None, Ollama自动释放=None): + def IS_CHANGED(cls, source_text=None, target_language=None, translate_service=None, ollama_auto_unload=None, unique_id=None): """ 只在输入内容真正变化时才触发重新执行 使用输入参数的哈希值作为判断依据 """ # 计算文本的哈希值 text_hash = "" - if 原文: + if source_text: # 使用hashlib计算文本的哈希值,更安全和一致 - text_hash = hashlib.md5(原文.encode('utf-8')).hexdigest() + text_hash = hashlib.md5(source_text.encode('utf-8')).hexdigest() # 组合所有输入的哈希值 input_hash = hash(( text_hash, - 目标语言, - 翻译服务, - bool(Ollama自动释放) + target_language, + translate_service, + bool(ollama_auto_unload) )) return input_hash @@ -102,315 +91,175 @@ class PromptTranslate: else: return "auto" - def translate(self, 原文, 目标语言, 翻译服务, Ollama自动释放): + def translate(self, source_text, target_language, translate_service, ollama_auto_unload, unique_id=None): """ 翻译文本函数 - - Args: - 原文: 输入的文本 - 目标语言: 目标语言 ("英文", "中文") - 翻译服务: 翻译服务 ("百度翻译", "智谱翻译") - Ollama自动释放: 是否在调用完成后自动释放Ollama模型(仅对Ollama翻译生效) - - Returns: - tuple: 翻译结果 """ + request_id = None # 提升到方法级别作用域 try: # 检查输入 - if not 原文 or not 原文.strip(): + if not source_text or not source_text.strip(): return ("",) # 自动检测源语言 - detected_lang = self._detect_language(原文) - to_lang = "en" if 目标语言 == "英文" else "zh" + detected_lang = self._detect_language(source_text) + to_lang = "en" if target_language == "English" else "zh" # 智能跳过翻译逻辑 skip_translation = False if to_lang == 'en' and detected_lang == 'en': - print(f"{self.REQUEST_PREFIX} 检测到英文输入,目标为英文,无需翻译") + from ..utils.common import _ANSI_CLEAR_EOL + print(f"\r{_ANSI_CLEAR_EOL}{self.REQUEST_PREFIX} 检测到英文输入,目标为英文,无需翻译", flush=True) skip_translation = True elif to_lang == 'zh' and detected_lang == 'zh': - print(f"{self.REQUEST_PREFIX} 检测到中文输入,目标为中文,无需翻译") + from ..utils.common import _ANSI_CLEAR_EOL + print(f"\r{_ANSI_CLEAR_EOL}{self.REQUEST_PREFIX} 检测到中文输入,目标为中文,无需翻译", flush=True) skip_translation = True if skip_translation: - return (原文,) + return (source_text,) # 映射语言名称 lang_map = {'zh': '中文', 'en': '英文', 'auto': '原文'} from_lang_name = lang_map.get(detected_lang, detected_lang) to_lang_name = lang_map.get(to_lang, to_lang) - if 翻译服务 == "百度翻译": - result = self._translate_with_baidu(原文, detected_lang, to_lang, 翻译服务, from_lang_name, to_lang_name) - elif 翻译服务 == "智谱翻译": - result = self._translate_with_llm(原文, detected_lang, to_lang, "zhipu", 翻译服务, from_lang_name, to_lang_name, Ollama自动释放) - elif 翻译服务 == "硅基流动翻译": - result = self._translate_with_llm(原文, detected_lang, to_lang, "siliconflow", 翻译服务, from_lang_name, to_lang_name, Ollama自动释放) - elif 翻译服务 == "302.AI翻译": - result = self._translate_with_llm(原文, detected_lang, to_lang, "302ai", 翻译服务, from_lang_name, to_lang_name, Ollama自动释放) - elif 翻译服务 == "Ollama翻译": - result = self._translate_with_llm(原文, detected_lang, to_lang, "ollama", 翻译服务, from_lang_name, to_lang_name, Ollama自动释放) - elif 翻译服务 == "自定义翻译": - result = self._translate_with_llm(原文, detected_lang, to_lang, "custom", 翻译服务, from_lang_name, to_lang_name, Ollama自动释放) + # ---解析服务/模型字符串--- + service_id, model_name = self.parse_service_model(translate_service) + if not service_id: + raise ValueError(f"Invalid service selection: {translate_service}") + + # ---百度翻译特殊处理--- + if service_id == 'baidu': + request_id, result = self._translate_with_baidu(source_text, detected_lang, to_lang, translate_service, from_lang_name, to_lang_name, unique_id) else: - raise ValueError(f"不支持的翻译服务: {翻译服务}") + # ---LLM翻译:获取服务配置--- + from ..config_manager import config_manager + service = config_manager.get_service(service_id) + if not service: + raise ValueError(f"Service config not found: {translate_service}") + + request_id, result = self._translate_with_llm(source_text, detected_lang, to_lang, service_id, model_name, service, translate_service, from_lang_name, to_lang_name, ollama_auto_unload, unique_id) if result and result.get('success'): translated_text = result.get('data', {}).get('translated', '').strip() if not translated_text: - error_msg = 'API返回结果为空,请检查API密钥、模型配置或网络连接' - print(f"{self.LOG_PREFIX} 翻译失败 | 错误:{error_msg}") - raise RuntimeError(f"翻译失败: {error_msg}") + error_msg = 'API returned empty result' + raise RuntimeError(f"❌Translation failed: {error_msg}") # 结果阶段日志由服务层统一输出,节点层不再重复打印 return (translated_text,) else: - error_msg = result.get('error', '翻译失败,未知错误') if result else '翻译服务未返回结果' - print(f"{self.LOG_PREFIX} 翻译失败: {error_msg}") - raise RuntimeError(f"翻译失败: {error_msg}") + error_msg = result.get('error', 'Unknown error') if result else 'No result returned' + # 如果是中断错误,直接抛出InterruptProcessingException,不打印日志(由基类打印) + if error_msg == "任务被中断": + raise InterruptProcessingException() + log_error(TASK_TRANSLATE, request_id, error_msg) + raise RuntimeError(f"Translation failed: {error_msg}") except InterruptProcessingException: - print(f"{self.LOG_PREFIX} 翻译任务被用户取消") + # 不打印日志,由基类统一打印 raise except Exception as e: - error_msg = format_api_error(e, 翻译服务) - print(f"{self.LOG_PREFIX} 翻译异常: {error_msg}") - raise RuntimeError(f"翻译异常: {error_msg}") + error_msg = format_api_error(e, translate_service) + log_error(TASK_TRANSLATE, request_id, error_msg) + raise RuntimeError(f"Translation error: {error_msg}") - def _translate_with_baidu(self, text, from_lang, to_lang, service_name, from_lang_name, to_lang_name): + def _translate_with_baidu(self, text, from_lang, to_lang, service_name, from_lang_name, to_lang_name, unique_id): """使用百度翻译服务""" - try: - # 创建请求ID - request_id = f"translate_{int(time.time())}_{random.randint(1000, 9999)}" - - # 准备阶段日志(合并为一条) - print(f"{PromptTranslate.REQUEST_PREFIX} 翻译准备 | 服务:{service_name} | 方向:{from_lang_name} → {to_lang_name} | 长度:{len(text)} | 请求ID:{request_id}") - - result_container = {} - - # 在独立线程中运行异步翻译 - thread = threading.Thread( - target=self._run_async_translation, - args=(BaiduTranslateService.translate, text, from_lang, to_lang, request_id, result_container) - ) - thread.start() - - # 等待翻译完成,同时检查中断 - while thread.is_alive(): - # 检查是否被中断 - 这会抛出 InterruptProcessingException - try: - import nodes - nodes.before_node_execution() - except: - # 如果检查中断时出现异常,说明被中断了 - print(f"{self.LOG_PREFIX} 检测到中断信号,正在终止翻译任务...") - # 设置结果容器为中断状态,让线程知道要停止 - result_container['interrupted'] = True - # 等待线程结束或超时 - thread.join(timeout=1.0) - if thread.is_alive(): - print(f"{self.LOG_PREFIX} 翻译线程未能及时响应中断") - raise InterruptProcessingException() - - time.sleep(0.1) - - return result_container.get('result') - - except Exception as e: - return {"success": False, "error": str(e)} - - def _translate_with_llm(self, text, from_lang, to_lang, provider, service_name, from_lang_name, to_lang_name, auto_unload): - """使用LLM翻译服务""" - try: - # 获取配置 - from ..config_manager import config_manager - provider_config = self._get_provider_config(config_manager, provider) - - if not provider_config: - provider_display_name = self.PROVIDER_DISPLAY_MAP.get(provider, provider) - return {"success": False, "error": f"未找到{provider_display_name}的配置,请先完成API配置"} - - # 创建请求ID - request_id = f"translate_{int(time.time())}_{random.randint(1000, 9999)}" - - # 准备阶段日志(合并为一条) - print(f"{PromptTranslate.REQUEST_PREFIX} 翻译准备 | 服务:{service_name} | 模型:{provider_config.get('model')} | 方向:{from_lang_name} → {to_lang_name} | 长度:{len(text)} | 请求ID:{request_id}") - - result_container = {} - - # 在独立线程中运行LLM翻译 - thread = threading.Thread( - target=self._run_llm_translation, - args=(text, from_lang, to_lang, request_id, result_container, provider, provider_config, auto_unload) - ) - thread.start() - - # 等待翻译完成,同时检查中断 - while thread.is_alive(): - # 检查是否被中断 - 这会抛出 InterruptProcessingException - try: - import nodes - nodes.before_node_execution() - except: - # 如果检查中断时出现异常,说明被中断了 - print(f"{self.LOG_PREFIX} 检测到中断信号,正在终止翻译任务...") - # 设置结果容器为中断状态,让线程知道要停止 - result_container['interrupted'] = True - # 等待线程结束或超时 - thread.join(timeout=1.0) - if thread.is_alive(): - print(f"{self.LOG_PREFIX} 翻译线程未能及时响应中断") - raise InterruptProcessingException() - - time.sleep(0.1) - - return result_container.get('result') - - except Exception as e: - return {"success": False, "error": str(e)} - - def _run_async_translation(self, service_func, text, from_lang, to_lang, request_id, result_container, **kwargs): - """在独立线程中运行异步翻译任务""" - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - try: - # 检查是否在开始前就被中断了 - if result_container.get('interrupted'): - result_container['result'] = {"success": False, "error": "任务被中断"} - return - - result = loop.run_until_complete( - service_func(text, from_lang, to_lang, request_id, **kwargs) - ) - - # 检查是否在执行过程中被中断了 - if result_container.get('interrupted'): - result_container['result'] = {"success": False, "error": "任务被中断"} - return - - result_container['result'] = result - except Exception as e: - if result_container.get('interrupted'): - result_container['result'] = {"success": False, "error": "任务被中断"} - else: - result_container['result'] = {"success": False, "error": str(e)} - finally: - loop.close() - - def _get_provider_config(self, config_manager, provider): - """获取指定provider的配置""" - llm_config = config_manager.get_llm_config() - if 'providers' in llm_config and provider in llm_config['providers']: - return llm_config['providers'][provider] - return None - - async def _unload_ollama_model(self, model: str, provider_config: dict, auto_unload: bool): - """ - 卸载Ollama模型以释放显存和内存 + # 创建请求ID + request_id = generate_request_id("trans", "baidu", unique_id) - 参数: - model: 模型名称 - provider_config: 提供商配置字典 - auto_unload: 是否启用自动释放(来自节点参数) - """ - try: - # 检查是否启用自动释放(使用节点参数,不从provider_config读取) - if not auto_unload: - return - - # 获取base_url - base_url = provider_config.get('base_url', 'http://localhost:11434') - # 确保URL不以/v1结尾(Ollama原生API不需要/v1) - if base_url.endswith('/v1'): - base_url = base_url[:-3] - - # 调用Ollama API卸载模型 - url = f"{base_url}/api/generate" - payload = { - "model": model, - "keep_alive": 0 # 立即卸载模型 - } - - print(f"{self.PROCESS_PREFIX} Ollama自动释放显存 | 模型:{model}") - - import httpx - async with httpx.AsyncClient(timeout=5.0) as client: - await client.post(url, json=payload) - - except Exception as e: - # 释放失败不影响主流程,只记录警告 - print(f"{self.LOG_PREFIX} Ollama模型释放失败(不影响结果) | 模型:{model} | 错误:{str(e)[:50]}") + # 准备阶段日志 + log_prepare(TASK_TRANSLATE, request_id, SOURCE_NODE, "百度翻译", None, None, {"方向": f"{from_lang_name}→{to_lang_name}", "长度": len(text)}) + + # 执行翻译(异步线程 + 可中断) + result = self._run_llm_task( + BaiduTranslateService.translate, + service_name, + text=text, + from_lang=from_lang, + to_lang=to_lang, + request_id=request_id, + task_type=TASK_TRANSLATE, + source=SOURCE_NODE + ) - def _run_llm_translation(self, text, from_lang, to_lang, request_id, result_container, provider, provider_config, auto_unload): - """在独立线程中运行LLM翻译""" - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - try: - # 检查是否在开始前就被中断了 - if result_container.get('interrupted'): - result_container['result'] = {"success": False, "error": "任务被中断"} - return + return request_id, result - # 获取API密钥和模型 - api_key = provider_config.get('api_key', '') - model = provider_config.get('model', '') + def _translate_with_llm(self, text, from_lang, to_lang, service_id, model_name, service, service_display_name, from_lang_name, to_lang_name, auto_unload, unique_id): + """使用LLM翻译服务""" + # ---构建provider_config--- + # 查找指定的模型或默认模型 + llm_models = service.get('llm_models', []) + target_model = None + + if model_name: + # 查找指定的模型 + target_model = next((m for m in llm_models if m.get('name') == 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 {"success": False, "error": f"Service {service_display_name} has no available models"} + + # 构建配置对象 + provider_config = { + 'provider': service_id, + 'model': target_model.get('name', ''), + 'base_url': service.get('base_url', ''), + 'api_key': service.get('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), + } + + # Ollama特殊处理:添加auto_unload配置 + if service.get('type') == 'ollama': + provider_config['auto_unload'] = auto_unload - if not api_key or not model: - result_container['result'] = { - "success": False, - "error": f"请先配置{provider}的API密钥和模型" - } - return + # 创建请求ID + request_id = generate_request_id("trans", "llm", unique_id) + + # 检查是否关闭思维链 + model_full_name = provider_config.get('model') + disable_thinking_enabled = service.get('disable_thinking', True) + thinking_extra = build_thinking_suppression(service_id, model_full_name) if disable_thinking_enabled else None + model_display = format_model_with_thinking(model_full_name, bool(thinking_extra)) + + # 获取服务显示名称 + service_display_name = service.get('name', service_id) + + # 准备阶段日志 + log_prepare(TASK_TRANSLATE, request_id, SOURCE_NODE, service_display_name, model_display, None, {"方向": f"{from_lang_name}→{to_lang_name}", "长度": len(text)}) + + # 检查API密钥和模型 + api_key = provider_config.get('api_key', '') + model = provider_config.get('model', '') + + if not api_key or not model: + return {"success": False, "error": f"Please configure API key and model for {service_display_name}"} - # 检查是否启用直连模式 - bypass_proxy = False - try: - from ..config_manager import config_manager as cm - settings = cm.get_settings() - bypass_proxy = settings.get('PromptAssistant.Settings.BypassProxy', False) - except Exception: - pass - - direct_mode_tag = "(直连)" if bypass_proxy else "" + # 执行翻译(异步线程 + 可中断) + result = self._run_llm_task( + LLMService.translate, + service_id, + text=text, + from_lang=from_lang, + to_lang=to_lang, + request_id=request_id, + stream_callback=None, + custom_provider=service_id, + custom_provider_config=provider_config, + task_type=TASK_TRANSLATE, + source=SOURCE_NODE + ) - # 获取提供商显示名称 - provider_display_name = PromptTranslate.PROVIDER_DISPLAY_MAP.get(provider, provider) + return request_id, result - # 统一走服务层(含兼容/降级处理) - service_result = loop.run_until_complete( - LLMService.translate( - text, - from_lang, - to_lang, - request_id, - False, - None, - custom_provider=provider, - custom_provider_config=provider_config - ) - ) - - # 检查是否在执行过程中被中断了 - if result_container.get('interrupted'): - result_container['result'] = {"success": False, "error": "任务被中断"} - return - - result_container['result'] = service_result - - # 结果日志已在translate方法中统一打印,这里不再重复 - - except Exception as e: - # 格式化错误信息 - error_message = format_api_error(e, provider_display_name) - - result_container['result'] = { - "success": False, - "error": error_message - } - print(f"{PromptTranslate.LOG_PREFIX} 翻译失败 | 服务:{provider_display_name} | 错误:{error_message}") - finally: - loop.close() # 节点映射,用于向ComfyUI注册节点 NODE_CLASS_MAPPINGS = { @@ -419,5 +268,5 @@ NODE_CLASS_MAPPINGS = { # 节点显示名称映射 NODE_DISPLAY_NAME_MAPPINGS = { - "PromptTranslate": "✨提示词翻译", -} \ No newline at end of file + "PromptTranslate": "✨Prompt Translate", +} diff --git a/node/video_caption_node.py b/node/video_caption_node.py new file mode 100644 index 0000000..78c7605 --- /dev/null +++ b/node/video_caption_node.py @@ -0,0 +1,541 @@ +import asyncio +import random +import time +import threading +import hashlib +import base64 +import os +import tempfile +import shutil +from io import BytesIO +from typing import List, Optional, Union, Tuple + +import torch +import numpy as np +from PIL import Image +import imageio + +from comfy.model_management import InterruptProcessingException +from ..services.vlm import VisionService +from ..utils.common import format_api_error, format_model_with_thinking, generate_request_id, log_prepare, log_error, TASK_VIDEO_CAPTION, SOURCE_NODE +from ..services.thinking_control import build_thinking_suppression +from .base import VLMNodeBase + + +class VideoCaptionNode(VLMNodeBase): + """ + 视频反推提示词节点 + 分析输入视频或图像序列并生成描述性提示词 + """ + + @classmethod + def INPUT_TYPES(cls): + # 从config_manager获取系统提示词配置 + from ..config_manager import config_manager + system_prompts = config_manager.get_system_prompts() + + # ---获取所有 video_prompts 作为选项--- + video_prompts = {} + if system_prompts and 'video_prompts' in system_prompts: + video_prompts = system_prompts['video_prompts'] + + # 构建提示词模板选项(支持分类格式:类别/规则名称) + prompt_template_options = [] + for key, value in video_prompts.items(): + # 过滤掉不在后端显示的规则 + show_in = value.get('showIn', ["frontend", "node"]) + if 'node' not in show_in: + continue + + name = value.get('name', key) + category = value.get('category', '') + # 如果有分类,显示为 "类别/规则名称",否则直接显示规则名称 + display_name = f"{category}/{name}" if category else name + prompt_template_options.append(display_name) + + # 如果没有选项,添加一个默认选项 + if not prompt_template_options: + prompt_template_options = ["默认视频反推提示词"] + + # ---动态获取VLM服务/模型列表--- + service_options = cls.get_vlm_service_options() + default_service = service_options[0] if service_options else "智谱" + + return { + "required": { + "rule": (prompt_template_options, {"default": prompt_template_options[0] if prompt_template_options else "默认视频反推提示词", "tooltip": "💡Template Config: Settings -> ✨Prompt Assistant -> Rule Editor"}), + "custom_rule": ("BOOLEAN", {"default": False, "label_on": "Enable", "label_off": "Disable", "tooltip": "⚠️ Enable to use custom rule content below instead of preset"}), + "custom_rule_content": ("STRING", {"multiline": True, "default": "", "placeholder": "请输入临时规则内容,仅在启用'临时规则'时生效"}), + "user_prompt": ("STRING", {"multiline": True, "default": "", "placeholder": "输入额外的具体要求,将与规则一起发送给模型"}), + "vlm_service": (service_options, {"default": default_service, "tooltip": "Select VLM service and model"}), + "sampling_mode": (["Auto (Uniform)", "Manual (Indices)"], {"default": "Auto (Uniform)"}), + "frame_count": ("INT", {"default": 8, "min": 1, "max": 32, "step": 1, "tooltip": "💡Only for 'Auto' mode. Number of frames to sample."}), + "manual_indices": ("STRING", {"default": "", "placeholder": "Input indices (e.g. 0,10,20) or range (e.g. 0-10)", "tooltip": "💡Only for 'Manual' mode. Supports comma-separated or range. Negative indices allowed."}), + # Ollama Automatic VRAM Unload + "ollama_auto_unload": ("BOOLEAN", {"default": True, "label_on": "Enable", "label_off": "Disable", "tooltip": "Auto unload Ollama model after generation"}), + }, + "hidden": { + "unique_id": "UNIQUE_ID", + }, + "optional": { + "video": ("VIDEO",), + "image_sequence": ("IMAGE",), + } + } + + RETURN_TYPES = ("STRING", "IMAGE") + RETURN_NAMES = ("prompt_output", "preview_frames") + FUNCTION = "analyze_video_content" + CATEGORY = "✨Prompt Assistant" + OUTPUT_NODE = False + + @classmethod + def IS_CHANGED(cls, video=None, image_sequence=None, rule=None, custom_rule=None, custom_rule_content=None, user_prompt=None, vlm_service=None, sampling_mode=None, frame_count=None, manual_indices=None, ollama_auto_unload=None, unique_id=None): + """ + 只在输入内容真正变化时才触发重新执行 + """ + # 提取实际的tensor数据 + target_input = None + if video is not None: + if isinstance(video, dict): + target_input = video.get('frames') or video.get('video') + elif hasattr(video, 'video'): + target_input = video.video + elif hasattr(video, 'frames'): + target_input = video.frames + elif isinstance(video, torch.Tensor): + target_input = video + elif image_sequence is not None: + target_input = image_sequence + + # 计算哈希值 + input_data_hash = "" + if target_input is not None and isinstance(target_input, torch.Tensor): + try: + # 取部分帧和中心区域计算哈希,避免全量计算 + if len(target_input.shape) == 4: + frames = target_input.shape[0] + # 取首尾和中间帧 + indices = [0, frames // 2, frames - 1] if frames > 2 else range(frames) + + hash_data = b"" + for idx in indices: + h, w = target_input.shape[1:3] + center_h, center_w = h // 2, w // 2 + size = min(50, h // 4, w // 4) + frame_data = target_input[idx, + max(0, center_h - size):min(h, center_h + size), + max(0, center_w - size):min(w, center_w + size), + 0].cpu().numpy().tobytes() + hash_data += frame_data + + input_data_hash = hashlib.md5(hash_data).hexdigest() + else: + input_data_hash = "invalid_shape" + except Exception: + input_data_hash = "hash_error" + + # 组合所有输入的哈希值 + input_hash = hash(( + input_data_hash, + rule, + bool(custom_rule), + custom_rule_content, + user_prompt, + vlm_service, + sampling_mode, + frame_count, + manual_indices, + bool(ollama_auto_unload) + )) + + return input_hash + + def analyze_video_content(self, rule, custom_rule, custom_rule_content, user_prompt, vlm_service, sampling_mode, frame_count, manual_indices, ollama_auto_unload, video=None, image_sequence=None, unique_id=None): + """ + 分析视频或图像序列并生成提示词(使用抽帧模式) + """ + temp_video_path = None + try: + # 1. 验证输入 + if video is None and image_sequence is None: + raise ValueError("Video Input or Image Sequence Input is required") + + # 2. 提取tensor数据 + input_tensor = None + is_pre_sampled = False + + if video is not None: + # 处理VideoFromFile对象(需要先保存再加载) + if hasattr(video, 'save_to') and callable(getattr(video, 'save_to')): + try: + fd, temp_video_path = tempfile.mkstemp(suffix='.mp4') + os.close(fd) + video.save_to(temp_video_path) + print(f"{self.PROCESS_PREFIX} VideoFromFile saved to temp") + #从文件加载为tensor,传入抽帧参数进行预采样优化 + input_tensor = self._load_video_as_tensor( + temp_video_path, + target_count=frame_count if sampling_mode == "Auto (Uniform)" else None, + target_indices_str=manual_indices if sampling_mode == "Manual (Indices)" else None + ) + is_pre_sampled = True + except Exception as e: + if temp_video_path and os.path.exists(temp_video_path): + os.unlink(temp_video_path) + raise RuntimeError(f"VideoFromFile processing failed: {str(e)}") + + # 处理字典格式 + elif isinstance(video, dict): + input_tensor = video.get('frames') or video.get('video') + if input_tensor is None: + for v in video.values(): + if isinstance(v, torch.Tensor): + input_tensor = v + break + + # 处理tensor + elif isinstance(video, torch.Tensor): + input_tensor = video + + # 尝试索引访问 + elif hasattr(video, '__getitem__'): + try: + first_item = video[0] + if isinstance(first_item, torch.Tensor): + input_tensor = first_item + elif isinstance(first_item, dict): + input_tensor = first_item.get('frames') or first_item.get('video') + except Exception: + pass + + if input_tensor is None: + raise ValueError(f"Failed to extract tensor from VIDEO input") + else: + input_tensor = image_sequence + + # 3. 准备提示词 + prompt_template = None + rule_name = "Custom Rule" if (custom_rule and custom_rule_content) else rule + + if custom_rule and custom_rule_content: + prompt_template = custom_rule_content + else: + from ..config_manager import config_manager + system_prompts = config_manager.get_system_prompts() + video_prompts = {} + if system_prompts and 'video_prompts' in system_prompts: + video_prompts = system_prompts['video_prompts'] + + # 查找模板(按显示名称匹配) + # 显示名称格式:有分类时为 "类别/规则名称",无分类时为 "规则名称" + for key, value in video_prompts.items(): + name = value.get('name', key) + category = value.get('category', '') + # 构建与下拉列表一致的显示名称 + display_name = f"{category}/{name}" if category else name + if display_name == rule: + prompt_template = value.get('content') + break + + # 允许用规则名称或键名直接匹配(兼容旧格式) + if not prompt_template: + for key, value in video_prompts.items(): + if value.get('name') == rule or key == rule: + prompt_template = value.get('content') + break + + if not prompt_template: + prompt_template = "请详细描述这段视频的内容,包括主要事件、场景变化、人物动作和视觉风格。" + rule_name = "Default Rule" + + # 拼接用户提示词 + if user_prompt and user_prompt.strip(): + prompt_template = f"{prompt_template}\n\n用户补充要求:\n{user_prompt}" + + # ---解析服务/模型字符串--- + service_id, model_name = self.parse_service_model(vlm_service) + if not service_id: + raise ValueError(f"Invalid service selection: {vlm_service}") + + # ---获取服务配置--- + from ..config_manager import config_manager + service = config_manager.get_service(service_id) + if not service: + raise ValueError(f"Service config not found: {vlm_service}") + + # ---构建provider_config--- + # 查找指定的模型或默认模型 + vlm_models = service.get('vlm_models', []) + target_model = None + + if model_name: + # 查找指定的模型 + target_model = next((m for m in vlm_models if m.get('name') == 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: + raise ValueError(f"Service {vlm_service} has no available models") + + # 构建配置对象 + provider_config = { + 'provider': service_id, + 'model': target_model.get('name', ''), + 'base_url': service.get('base_url', ''), + 'api_key': service.get('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), + } + + # Ollama特殊处理:添加auto_unload配置 + if service.get('type') == 'ollama': + provider_config['auto_unload'] = ollama_auto_unload + + model = provider_config.get('model', '') + + # 5. 抽帧处理 + request_id = generate_request_id("vcap", None, unique_id) + # 检查是否关闭思维链 + disable_thinking_enabled = service.get('disable_thinking', True) + thinking_extra = build_thinking_suppression(service_id, model) if disable_thinking_enabled else None + model_display = format_model_with_thinking(model, bool(thinking_extra)) + + # 获取服务显示名称 + service_display_name = service.get('name', service_id) + + # 准备阶段日志 + log_prepare(TASK_VIDEO_CAPTION, request_id, SOURCE_NODE, service_display_name, model_display, rule_name, {"模式": sampling_mode}) + + # [Debug] 输出抽帧参数详情 + print(f"{self.PROCESS_PREFIX} [video-caption-debug] 输入tensor形状:{input_tensor.shape} | is_pre_sampled:{is_pre_sampled}") + print(f"{self.PROCESS_PREFIX} [video-caption-debug] sampling_mode:{sampling_mode} | frame_count:{frame_count} | manual_indices:{manual_indices}") + + # 准备抽帧参数 + sampling_kwargs = {} + if not is_pre_sampled: + if sampling_mode == "Auto (Uniform)": + sampling_kwargs['target_count'] = frame_count + elif sampling_mode == "Manual (Indices)": + sampling_kwargs['target_indices_str'] = manual_indices + # 从tensor中提取帧并转为base64,同时获取预览tensor + frames_data, preview_tensor = self._extract_frames_and_tensor( + input_tensor, + **sampling_kwargs + ) + + # [Debug] 输出抽帧结果 + print(f"{self.PROCESS_PREFIX} [video-caption] 抽帧完成 | 帧数量:{len(frames_data)} | 预览tensor:{preview_tensor.shape}") + + # 调用多图像分析 - 使用基类方法 + result = self._run_vision_task( + VisionService.analyze_images, + service_id, + images_data=frames_data, + request_id=request_id, + prompt_content=prompt_template, + custom_provider=service_id, + custom_provider_config=provider_config, + task_type=TASK_VIDEO_CAPTION, + source=SOURCE_NODE + ) + + # 6. 处理结果 + if result and result.get('success'): + description = result.get('data', {}).get('description', '').strip() + if not description: + raise RuntimeError("API returned empty result") + return (description, preview_tensor) + else: + error_msg = result.get('error', 'Unknown error') if result else 'No result returned' + # 如果是中断错误,直接抛出InterruptProcessingException,不打印日志(由基类打印) + if error_msg == "任务被中断": + raise InterruptProcessingException() + raise RuntimeError(f"Analysis failed: {error_msg}") + + except InterruptProcessingException: + # 不打印日志,由基类统一打印 + raise + except Exception as e: + error_msg = format_api_error(e, vlm_service) + log_error(TASK_VIDEO_CAPTION, request_id, error_msg, source=SOURCE_NODE) + raise RuntimeError(f"Analysis error: {error_msg}") + finally: + # 清理临时视频文件 + if temp_video_path and os.path.exists(temp_video_path): + try: + os.unlink(temp_video_path) + except Exception: + pass + + def _uniform_sample(self, l, n): + """ + 从列表中均匀采样 n 个元素 (参考 video_sampling_guide.md) + 算法: 将列表分成 n 个等长区间,从每个区间的中心位置取样 + """ + if n >= len(l): + return l + gap = len(l) / n + idxs = [int(i * gap + gap / 2) for i in range(n)] + # 确保索引不越界 + idxs = [min(i, len(l) - 1) for i in idxs] + return [l[i] for i in idxs] + + def _parse_frame_indices(self, indices_str, total_frames): + """解析手动输入的帧索引字符串""" + indices = set() + if not indices_str: + return [] + + parts = indices_str.split(',') + for part in parts: + part = part.strip() + if not part: + continue + try: + if '-' in part: + # Range: 0-10 + start_str, end_str = part.split('-') + start = int(start_str) + end = int(end_str) + # Handle negative indices + if start < 0: start += total_frames + if end < 0: end += total_frames + + start = max(0, min(start, total_frames - 1)) + end = max(0, min(end, total_frames - 1)) + + if start <= end: + indices.update(range(start, end + 1)) + else: + # Single index + idx = int(part) + if idx < 0: idx += total_frames + idx = max(0, min(idx, total_frames - 1)) + indices.add(idx) + except ValueError: + print(f"{self.LOG_PREFIX} 忽略无效的帧索引格式: {part}") + + return sorted(list(indices)) + + def _extract_frames_and_tensor(self, tensor, target_count=None, target_indices_str=None): + """从tensor中提取指定数量或指定索引的帧,返回(base64列表, 预览tensor)""" + total_frames = tensor.shape[0] + + # 生成所有帧的索引列表 + all_indices = list(range(total_frames)) + + selected_indices = [] + if target_indices_str is not None: + # 手动模式 + selected_indices = self._parse_frame_indices(target_indices_str, total_frames) + if not selected_indices: + print(f"{self.PROCESS_PREFIX} ⚠️ 手动帧索引无效或为空,回退到自动采样") + selected_indices = self._uniform_sample(all_indices, 8) # Default fallback + elif target_count is not None: + # 自动模式 + selected_indices = self._uniform_sample(all_indices, target_count) + else: + # 默认全量 + selected_indices = all_indices + + # 提取选中的帧 [N, H, W, C] + selected_tensor = tensor[selected_indices] + + frames_base64 = [] + # 遍历选中的tensor进行转换 + for i in range(selected_tensor.shape[0]): + frame_tensor = selected_tensor[i] + # 转为numpy [H, W, C] (0-255) + frame_np = (frame_tensor.cpu().numpy() * 255).astype(np.uint8) + # 转PIL + image = Image.fromarray(frame_np) + # 转Base64 + buffer = BytesIO() + image.save(buffer, format="JPEG", quality=85) + encoded = base64.b64encode(buffer.getvalue()).decode('utf-8') + frames_base64.append(f"data:image/jpeg;base64,{encoded}") + + return frames_base64, selected_tensor + + def _load_video_as_tensor(self, video_path, target_count=None, target_indices_str=None): + """ + 从视频文件加载为tensor + 优化: 如果指定了target_count或target_indices_str,则只读取需要的帧 (Sample before Load) + """ + try: + import imageio + # 读取视频 + reader = imageio.get_reader(video_path, 'ffmpeg') + + # 获取总帧数 + # 尝试从元数据获取,如果失败则使用count_frames (较慢) + try: + total_frames = reader.count_frames() + except Exception: + # 如果无法获取帧数,回退到读取所有帧 + print(f"{self.PROCESS_PREFIX} 无法获取视频总帧数,将读取所有帧") + frames = [] + for frame in reader: + frame_float = frame.astype(np.float32) / 255.0 + frames.append(frame_float) + reader.close() + if not frames: + raise RuntimeError("视频文件中没有帧") + frames_array = np.stack(frames, axis=0) + tensor = torch.from_numpy(frames_array) + + # 如果有采样要求,进行后处理采样 + if target_count is not None or target_indices_str is not None: + print(f"{self.PROCESS_PREFIX} 视频加载回退: 读取全量帧后进行采样") + _, tensor = self._extract_frames_and_tensor(tensor, target_count, target_indices_str) + + return tensor + + # 计算需要读取的帧索引 + indices_to_read = list(range(total_frames)) + + if target_indices_str is not None: + indices_to_read = self._parse_frame_indices(target_indices_str, total_frames) + print(f"{self.PROCESS_PREFIX} 视频加载优化(手动): 从 {total_frames} 帧中提取 {len(indices_to_read)} 帧") + elif target_count and total_frames > target_count: + indices_to_read = self._uniform_sample(indices_to_read, target_count) + print(f"{self.PROCESS_PREFIX} 视频加载优化(自动): 从 {total_frames} 帧中采样 {len(indices_to_read)} 帧") + + # 优化读取: 只读取需要的帧 + frames = [] + + # imageio的get_data(index)支持随机访问 + for idx in indices_to_read: + try: + frame = reader.get_data(idx) + frame_float = frame.astype(np.float32) / 255.0 + frames.append(frame_float) + except IndexError: + break + + reader.close() + + if not frames: + raise RuntimeError("未能读取到有效帧") + + # 转换为torch tensor [N, H, W, C] + frames_array = np.stack(frames, axis=0) + tensor = torch.from_numpy(frames_array) + + return tensor + except Exception as e: + raise RuntimeError(f"视频文件加载失败: {str(e)}") + +# 节点映射,用于ComfyUI注册节点 +NODE_CLASS_MAPPINGS = { + "VideoCaptionNode": VideoCaptionNode, +} + +NODE_DISPLAY_NAME_MAPPINGS = { + "VideoCaptionNode": "✨Video Caption", +} diff --git a/requirements.txt b/requirements.txt index 4042d7a..f554545 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1 @@ -openai>=1.12.0 -aiohttp>=3.8.0 +httpx>=0.24.0 diff --git a/server.py b/server.py index c0c0d53..857b0f8 100644 --- a/server.py +++ b/server.py @@ -4,39 +4,32 @@ from .config_manager import config_manager from .services.baidu import BaiduTranslateService from .services.llm import LLMService from .services.vlm import VisionService -from .services.model_list import ModelListService +from .services.model_list import get_models_from_service import base64 import json import traceback import asyncio +import folder_paths +import imageio +import os +from .utils.common import ( + # 统一日志前缀(从 common.py 导入) + PREFIX, ERROR_PREFIX, PROCESS_PREFIX, + REQUEST_PREFIX, WARN_PREFIX, + _ANSI_CLEAR_EOL, + # 统一日志函数和常量 + log_prepare, TASK_TRANSLATE, TASK_EXPAND, TASK_IMAGE_CAPTION, SOURCE_FRONTEND +) +from .utils.video import extract_frame_by_index, get_video_frame_info -# 定义颜色常量 -GREEN = "\033[32m" -RED = "\033[31m" -YELLOW = "\033[33m" -BLUE = "\033[34m" -RESET = "\033[0m" +# 动态获取插件目录名作为路由前缀的基础 +# 这样即使文件夹被重命名(例如加上 comfyui- 前缀),路由也会自动适配 +NODE_DIR_NAME = os.path.basename(os.path.dirname(os.path.abspath(__file__))) +API_PREFIX = f'/{NODE_DIR_NAME}/api' -# 统一日志前缀(按阶段上色) -# 绿色:结果阶段 -PREFIX = f"{GREEN}[✨PromptAssistant]{RESET}" -AUTO_TRANSLATE_PREFIX = f"{GREEN}[✨PromptAssistant-自动翻译]{RESET}" -# 红色:错误 -ERROR_PREFIX = f"{RED}[✨PromptAssistant-错误]{RESET}" -# 黄色:过程阶段(创建客户端/调用API等) -PROCESS_PREFIX = f"{YELLOW}[✨PromptAssistant]{RESET}" -# 蓝色:发起请求阶段 -REQUEST_PREFIX = f"{BLUE}[✨PromptAssistant]{RESET}" -# 黄色:告警保持不变 -WARN_PREFIX = f"{YELLOW}[✨PromptAssistant-警告]{RESET}" -# 蓝色:自动翻译的“请求阶段”前缀(仅用于请求日志着色) -AUTO_TRANSLATE_REQUEST_PREFIX = f"{BLUE}[✨PromptAssistant-自动翻译]{RESET}" - -# 定义路由前缀,确保与前端请求匹配 -API_PREFIX = '/prompt_assistant/api' +# print(f"{PREFIX} API 路由已挂载至: {API_PREFIX}") # 在服务器初始化时验证激活提示词 -# print(f"{PREFIX} 正在验证激活提示词配置...") config_manager.validate_and_fix_active_prompts() # 新增:在启动时补全模型提供商及参数 config_manager.validate_and_fix_model_params() @@ -46,6 +39,13 @@ config_manager.validate_and_fix_model_params() # 用于跟踪正在进行的异步任务 ACTIVE_TASKS = {} +# ---流式进度设置(运行时状态,实时生效无需重启)--- +_streaming_progress_enabled = True + +def is_streaming_progress_enabled(): + """供其他模块查询当前流式进度设置""" + return _streaming_progress_enabled + # 不再使用RouteTableDef # routes = web.RouteTableDef() @@ -69,6 +69,25 @@ def get_result_text(result): return result['text'] return '' +# ---流式进度设置API--- + +@PromptServer.instance.routes.get(f'{API_PREFIX}/settings/streaming_progress') +async def get_streaming_progress_setting(request): + """获取流式进度设置""" + return web.json_response({"enabled": _streaming_progress_enabled}) + +@PromptServer.instance.routes.post(f'{API_PREFIX}/settings/streaming_progress') +async def set_streaming_progress_setting(request): + """设置流式进度(实时生效,不需重启)""" + global _streaming_progress_enabled + try: + data = await request.json() + _streaming_progress_enabled = data.get("enabled", True) + return web.json_response({"success": True}) + except Exception as e: + print(f"{ERROR_PREFIX} 更新流式进度设置失败: {str(e)}") + return web.json_response({"success": False, "error": str(e)}, status=500) + @PromptServer.instance.routes.get(f'{API_PREFIX}/config/baidu_translate') async def get_baidu_translate_config(request): """获取百度翻译配置""" @@ -87,6 +106,577 @@ async def get_vision_config(request): config = config_manager.get_vision_config() return web.json_response(config) +@PromptServer.instance.routes.get(f'{API_PREFIX}/config/translate') +async def get_translate_config(request): + """获取翻译服务配置""" + config = config_manager.get_translate_config() + return web.json_response(config) + +# --- 方案A:API Key掩码接口(安全版本)--- + +@PromptServer.instance.routes.get(f'{API_PREFIX}/config/llm/masked') +async def get_llm_config_masked(request): + """ + 获取LLM配置(API Key掩码版本) + 用于前端显示,不暴露完整API Key + """ + config = config_manager.get_llm_config_masked() + return web.json_response(config) + +@PromptServer.instance.routes.get(f'{API_PREFIX}/config/vision/masked') +async def get_vision_config_masked(request): + """ + 获取视觉模型配置(API Key掩码版本) + 用于前端显示,不暴露完整API Key + """ + config = config_manager.get_vision_config_masked() + return web.json_response(config) + +# --- 服务商管理API接口(v2.0)--- + +@PromptServer.instance.routes.get(f'{API_PREFIX}/services') +async def get_services_list(request): + """ + 获取所有服务商列表 + 返回所有已配置的服务商(不包含敏感信息) + """ + try: + services = config_manager.get_all_services() + + # 移除敏感信息 + safe_services = [] + for service in services: + safe_service = service.copy() + # 移除加密的API Key + if 'api_key_encrypted' in safe_service: + del safe_service['api_key_encrypted'] + # 添加掩码信息 + safe_service['has_api_key'] = bool(service.get('api_key_encrypted')) + safe_services.append(safe_service) + + return web.json_response({ + "success": True, + "services": safe_services + }) + except Exception as e: + print(f"{ERROR_PREFIX} 获取服务商列表失败 | 错误:{str(e)}") + return web.json_response({ + "success": False, + "error": str(e) + }, status=500) + +@PromptServer.instance.routes.get(f'{API_PREFIX}/services/{{service_id}}/masked') +async def get_service_masked(request): + """ + 获取指定服务商配置(掩码版本) + API Key为掩码,不暴露完整值 + """ + try: + service_id = request.match_info['service_id'] + service = config_manager.get_service(service_id) + + if not service: + return web.json_response({ + "success": False, + "error": f"服务商不存在: {service_id}" + }, status=404) + + # 掩码处理 + safe_service = service.copy() + if 'api_key_encrypted' in safe_service: + # 解密后掩码 + from .utils.common import SimpleEncryption + api_key = SimpleEncryption.decrypt(safe_service['api_key_encrypted']) + safe_service['api_key_masked'] = config_manager.mask_api_key(api_key) + safe_service['api_key_exists'] = bool(api_key) + del safe_service['api_key_encrypted'] + + return web.json_response({ + "success": True, + "service": safe_service + }) + except Exception as e: + print(f"{ERROR_PREFIX} 获取服务商配置失败 | 错误:{str(e)}") + return web.json_response({ + "success": False, + "error": str(e) + }, status=500) + +@PromptServer.instance.routes.post(f'{API_PREFIX}/services') +async def create_service(request): + """ + 创建新的服务商 + 请求体: {type, name, base_url, api_key, description} + """ + try: + data = await request.json() + + service_type = data.get('type') + name = data.get('name') + base_url = data.get('base_url', '') + api_key = data.get('api_key', '') + description = data.get('description', '') + + if not service_type or not name: + return web.json_response({ + "success": False, + "error": "缺少必需参数: type 和 name" + }, status=400) + + service_id = config_manager.create_service( + service_type=service_type, + name=name, + base_url=base_url, + api_key=api_key, + description=description + ) + + if service_id: + print(f"{PREFIX} 成功创建服务商 | 名称:{name} | ID:{service_id}") + return web.json_response({ + "success": True, + "service_id": service_id + }) + else: + return web.json_response({ + "success": False, + "error": "创建服务商失败" + }, status=500) + + except Exception as e: + print(f"{ERROR_PREFIX} 创建服务商异常 | 错误:{str(e)}") + return web.json_response({ + "success": False, + "error": str(e) + }, status=500) + +@PromptServer.instance.routes.put(f'{API_PREFIX}/services/{{service_id}}') +async def update_service_config(request): + """ + 更新服务商配置 + 请求体: {name, description, base_url, api_key, auto_unload} + 注意:仅在payload包含api_key时才更新API Key + """ + try: + service_id = request.match_info['service_id'] + data = await request.json() + + # 更新服务商 + success = config_manager.update_service(service_id, **data) + + if success: + print(f"{PREFIX} 成功更新服务商 | ID:{service_id}") + return web.json_response({"success": True}) + else: + return web.json_response({ + "success": False, + "error": "更新服务商失败" + }, status=500) + + except Exception as e: + print(f"{ERROR_PREFIX} 更新服务商异常 | 错误:{str(e)}") + return web.json_response({ + "success": False, + "error": str(e) + }, status=500) + +@PromptServer.instance.routes.delete(f'{API_PREFIX}/services/{{service_id}}') +async def delete_service(request): + """ + 删除服务商 + """ + try: + service_id = request.match_info['service_id'] + + success = config_manager.delete_service(service_id) + + if success: + print(f"{PREFIX} 成功删除服务商 | ID:{service_id}") + return web.json_response({"success": True}) + else: + return web.json_response({ + "success": False, + "error": "删除服务商失败" + }, status=500) + + except Exception as e: + print(f"{ERROR_PREFIX} 删除服务商异常 | 错误:{str(e)}") + return web.json_response({ + "success": False, + "error": str(e) + }, status=500) + +@PromptServer.instance.routes.get(f'{API_PREFIX}/services/{{service_id}}') +async def get_service(request): + """ + 获取指定服务商的配置 + """ + try: + service_id = request.match_info.get('service_id') + service = config_manager.get_service(service_id) + + if service: + return web.json_response({ + "success": True, + "service": service + }) + else: + return web.json_response({ + "success": False, + "error": f"服务商不存在: {service_id}" + }, status=404) + + except Exception as e: + print(f"{ERROR_PREFIX} 获取服务商失败: {str(e)}") + return web.json_response({ + "success": False, + "error": str(e) + }, status=500) + +@PromptServer.instance.routes.post(f'{API_PREFIX}/services/current') +async def set_current_service_api(request): + """ + 设置当前使用的服务商 + 请求体: {service_type: 'llm'|'vlm'|'translate', service_id: string, model_name?: string} + """ + try: + data = await request.json() + service_type = data.get('service_type') + service_id = data.get('service_id') + model_name = data.get('model_name') # 可选参数 + + # 验证参数 + if not service_type or not service_id: + return web.json_response({ + "success": False, + "error": "缺少必要参数: service_type 和 service_id" + }, status=400) + + if service_type not in ['llm', 'vlm', 'translate']: + return web.json_response({ + "success": False, + "error": "service_type必须为'llm'、'vlm'或'translate'" + }, status=400) + + # 调用配置管理器设置服务(现在支持model_name参数) + success = config_manager.set_current_service(service_type, service_id, model_name) + + if success: + return web.json_response({ + "success": True, + "message": f"成功设置当前{service_type}服务" + }) + else: + return web.json_response({ + "success": False, + "error": "设置服务失败" + }, status=500) + except Exception as e: + return web.json_response({ + "success": False, + "error": str(e) + }, status=500) + +# ====================== 模型列表API ====================== + +@PromptServer.instance.routes.get(f'{API_PREFIX}/services/{{service_id}}/models') +async def get_service_models(request): + """ + 获取服务商的模型列表 + """ + try: + service_id = request.match_info.get('service_id') + + # 获取服务商信息 + service = config_manager.get_service(service_id) + + if not service: + print(f"{ERROR_PREFIX} 服务商不存在 | ID:{service_id}") + return web.json_response({ + "success": False, + "error": f"服务商不存在: {service_id}" + }, status=404) + + base_url = service.get('base_url', '') + api_key = service.get('api_key', '') + service_type = service.get('type', 'openai_compatible') + + # 特殊处理:智谱服务强制使用 'zhipu' 类型(使用预定义列表) + if service_id == 'zhipu': + service_type = 'zhipu' + + # 获取模型列表(新格式包含success和error) + result = get_models_from_service(base_url, api_key, service_type) + + # 检查是否成功 + if not result.get('success'): + error_msg = result.get('error', '未知错误') + print(f"{ERROR_PREFIX} 获取模型列表失败: {error_msg}") + return web.json_response({ + "success": False, + "error": error_msg + }, status=400) + + models = result.get('models', {'llm': [], 'vlm': []}) + + # 统计模型总数(LLM和VLM列表相同,只统计一个) + total_count = len(models.get('llm', [])) + print(f"{PREFIX} 模型列表获取成功 | 共 {total_count} 个模型") + + return web.json_response({ + "success": True, + "models": models + }) + + except Exception as e: + print(f"{ERROR_PREFIX} 获取模型列表失败: {str(e)}") + import traceback + traceback.print_exc() + return web.json_response({ + "success": False, + "error": str(e) + }, status=500) + + +@PromptServer.instance.routes.post(f'{API_PREFIX}/services/{{service_id}}/models') +async def add_model_to_service_api(request): + """ + 添加模型到服务商 + + Request body: {model_type, model_name, temperature, top_p, max_tokens} + """ + try: + service_id = request.match_info.get('service_id') + data = await request.json() + + model_type = data.get('model_type') + model_name = data.get('model_name') + temperature = data.get('temperature', 0.7) + top_p = data.get('top_p', 0.9) + max_tokens = data.get('max_tokens', 512) + + if not model_type or not model_name: + return web.json_response({ + "success": False, + "error": "缺少必需参数: model_type 和 model_name" + }, status=400) + + success = config_manager.add_model_to_service( + service_id, model_type, model_name, temperature, top_p, max_tokens + ) + + if success: + return web.json_response({"success": True}) + else: + return web.json_response({ + "success": False, + "error": "添加模型失败" + }, status=500) + + except Exception as e: + print(f"{ERROR_PREFIX} 添加模型失败: {str(e)}") + import traceback + traceback.print_exc() + return web.json_response({ + "success": False, + "error": str(e) + }, status=500) + + +@PromptServer.instance.routes.delete(f'{API_PREFIX}/services/{{service_id}}/models/{{model_type}}/{{model_name}}') +async def delete_model_from_service_api(request): + """ + 从服务商删除模型 + """ + try: + service_id = request.match_info.get('service_id') + model_type = request.match_info.get('model_type') + model_name = request.match_info.get('model_name') + + success = config_manager.delete_model_from_service(service_id, model_type, model_name) + + if success: + return web.json_response({"success": True}) + else: + return web.json_response({ + "success": False, + "error": "删除模型失败" + }, status=500) + + except Exception as e: + print(f"{ERROR_PREFIX} 删除模型失败: {str(e)}") + import traceback + traceback.print_exc() + return web.json_response({ + "success": False, + "error": str(e) + }, status=500) + + +@PromptServer.instance.routes.put(f'{API_PREFIX}/services/{{service_id}}/models/default') +async def set_default_model_api(request): + """ + 设置默认模型 + + Request body: {model_type, model_name} + """ + try: + service_id = request.match_info.get('service_id') + data = await request.json() + + model_type = data.get('model_type') + model_name = data.get('model_name') + + if not model_type or not model_name: + return web.json_response({ + "success": False, + "error": "缺少必需参数: model_type 和 model_name" + }, status=400) + + success = config_manager.set_default_model(service_id, model_type, model_name) + + if success: + return web.json_response({"success": True}) + else: + return web.json_response({ + "success": False, + "error": "设置默认模型失败" + }, status=500) + + except Exception as e: + print(f"{ERROR_PREFIX} 设置默认模型失败: {str(e)}") + import traceback + traceback.print_exc() + return web.json_response({ + "success": False, + "error": str(e) + }, status=500) + + +@PromptServer.instance.routes.put(f'{API_PREFIX}/services/{{service_id}}/models/order') +async def update_model_order_api(request): + """ + 更新模型顺序 + + Request body: {model_type, model_names} + """ + try: + service_id = request.match_info.get('service_id') + data = await request.json() + + model_type = data.get('model_type') + model_names = data.get('model_names', []) + + if not model_type: + return web.json_response({ + "success": False, + "error": "缺少必需参数: model_type" + }, status=400) + + success = config_manager.update_model_order(service_id, model_type, model_names) + + if success: + return web.json_response({"success": True}) + else: + return web.json_response({ + "success": False, + "error": "更新模型顺序失败" + }, status=500) + + except Exception as e: + print(f"{ERROR_PREFIX} 更新模型顺序失败: {str(e)}") + import traceback + traceback.print_exc() + return web.json_response({ + "success": False, + "error": str(e) + }, status=500) + + +@PromptServer.instance.routes.put(f'{API_PREFIX}/services/order') +async def update_services_order_api(request): + """ + 更新服务商顺序 + + Request body: {service_ids: ['id1', 'id2', ...]} + """ + try: + data = await request.json() + service_ids = data.get('service_ids', []) + + if not service_ids: + return web.json_response({ + "success": False, + "error": "缺少必需参数: service_ids" + }, status=400) + + success = config_manager.update_services_order(service_ids) + + if success: + print(f"{PREFIX} 成功更新服务商顺序 | 顺序:{', '.join(service_ids)}") + return web.json_response({"success": True}) + else: + return web.json_response({ + "success": False, + "error": "更新服务商顺序失败" + }, status=500) + + except Exception as e: + print(f"{ERROR_PREFIX} 更新服务商顺序失败: {str(e)}") + import traceback + traceback.print_exc() + return web.json_response({ + "success": False, + "error": str(e) + }, status=500) + + +@PromptServer.instance.routes.put(f'{API_PREFIX}/services/{{service_id}}/models/parameter') +async def update_model_parameter_api(request): + """ + 更新模型参数 + + Request body: {model_type, model_name, parameter_name, parameter_value} + """ + try: + service_id = request.match_info.get('service_id') + data = await request.json() + + model_type = data.get('model_type') + model_name = data.get('model_name') + parameter_name = data.get('parameter_name') + parameter_value = data.get('parameter_value') + + if not all([model_type, model_name, parameter_name, parameter_value is not None]): + return web.json_response({ + "success": False, + "error": "缺少必需参数" + }, status=400) + + success = config_manager.update_model_parameter( + service_id, model_type, model_name, parameter_name, parameter_value + ) + + if success: + return web.json_response({"success": True}) + else: + return web.json_response({ + "success": False, + "error": "更新模型参数失败" + }, status=500) + + except Exception as e: + print(f"{ERROR_PREFIX} 更新模型参数失败: {str(e)}") + import traceback + traceback.print_exc() + return web.json_response({ + "success": False, + "error": str(e) + }, status=500) + + +# ====================== 配置API ====================== + @PromptServer.instance.routes.get(f'{API_PREFIX}/config/system_prompts') async def get_system_prompts_config(request): """获取系统提示词配置""" @@ -101,29 +691,16 @@ async def get_default_system_prompts_config(request): @PromptServer.instance.routes.get(f'{API_PREFIX}/config/tags') async def get_tags_config(request): - """获取标签配置""" + """获取标签配置(已废弃,tags.json 已被 CSV 系统替代)""" try: - import os - import json - - # 获取标签配置文件路径 - tags_file_path = os.path.join(os.path.dirname(__file__), "config", "tags.json") - - # 检查文件是否存在 - if not os.path.exists(tags_file_path): - print(f"{ERROR_PREFIX} 标签配置文件不存在 | 路径:{tags_file_path}") - return web.json_response({"error": "标签配置文件不存在"}, status=404) - - # 读取文件内容 - with open(tags_file_path, "r", encoding="utf-8") as f: - tags_data = json.load(f) - - # print(f"{PREFIX} 标签配置文件加载成功") - return web.json_response(tags_data) + # tags.json 已废弃,返回空对象 + return web.json_response({}) except Exception as e: print(f"{ERROR_PREFIX} 标签配置加载失败 | 错误:{str(e)}") return web.json_response({"error": str(e)}, status=500) + + @PromptServer.instance.routes.get(f'{API_PREFIX}/config/tags_user') async def get_user_tags_config(request): """获取用户自定义标签配置""" @@ -131,7 +708,6 @@ async def get_user_tags_config(request): # 使用配置管理器加载用户标签 user_tags = config_manager.load_user_tags() - # print(f"{PREFIX} 用户标签配置文件加载成功") return web.json_response(user_tags) except Exception as e: print(f"{ERROR_PREFIX} 用户标签配置加载失败 | 错误:{str(e)}") @@ -148,23 +724,139 @@ async def update_user_tags_config(request): print(f"{ERROR_PREFIX} 用户标签配置更新失败 | 错误:参数格式错误") return web.json_response({"error": "参数格式错误"}, status=400) - # 保存用户标签数据 - import os - import json + # 使用配置管理器保存用户标签 + success = config_manager.save_user_tags(data) - # 获取用户标签配置文件路径 - tags_user_file_path = os.path.join(os.path.dirname(__file__), "config", "tags_user.json") - - # 保存数据到文件 - with open(tags_user_file_path, "w", encoding="utf-8") as f: - json.dump(data, f, ensure_ascii=False, indent=2) - - # print(f"{PREFIX} 用户标签配置文件更新成功") - return web.json_response({"success": True}) + if success: + return web.json_response({"success": True}) + else: + print(f"{ERROR_PREFIX} 用户标签配置文件更新失败") + return web.json_response({"error": "保存失败"}, status=500) + except Exception as e: print(f"{ERROR_PREFIX} 用户标签配置更新异常 | 错误:{str(e)}") return web.json_response({"error": str(e)}, status=500) +# ---CSV标签系统API--- + +@PromptServer.instance.routes.get(f'{API_PREFIX}/config/tags_files') +async def get_tags_files(request): + """获取tags目录下所有CSV文件列表""" + try: + files = config_manager.list_tags_files() + return web.json_response({"success": True, "files": files}) + except Exception as e: + print(f"{ERROR_PREFIX} 获取标签文件列表失败 | 错误:{str(e)}") + return web.json_response({"success": False, "error": str(e)}, status=500) + +@PromptServer.instance.routes.get(f'{API_PREFIX}/config/tags_csv/{{filename}}') +async def get_tags_csv(request): + """加载指定CSV标签文件""" + try: + filename = request.match_info.get('filename') + if not filename or not filename.endswith('.csv'): + return web.json_response({"success": False, "error": "无效的文件名"}, status=400) + + tags_data = config_manager.load_tags_csv(filename) + return web.json_response({"success": True, "data": tags_data}) + except Exception as e: + print(f"{ERROR_PREFIX} 加载CSV标签文件失败 | 错误:{str(e)}") + return web.json_response({"success": False, "error": str(e)}, status=500) + +@PromptServer.instance.routes.post(f'{API_PREFIX}/config/tags_csv/{{filename}}') +async def save_tags_csv(request): + """保存标签数据到指定CSV文件""" + try: + filename = request.match_info.get('filename') + if not filename or not filename.endswith('.csv'): + return web.json_response({"success": False, "error": "无效的文件名"}, status=400) + + data = await request.json() + tags_data = data.get('data', {}) + + success = config_manager.save_tags_csv(filename, tags_data) + if success: + print(f"{PREFIX} CSV标签文件保存成功 | 文件:{filename}") + return web.json_response({"success": True}) + else: + return web.json_response({"success": False, "error": "保存失败"}, status=500) + except Exception as e: + print(f"{ERROR_PREFIX} 保存CSV标签文件失败 | 错误:{str(e)}") + return web.json_response({"success": False, "error": str(e)}, status=500) + +@PromptServer.instance.routes.get(f'{API_PREFIX}/config/tags_selection') +async def get_tags_selection(request): + """获取用户选择的标签文件""" + try: + selection = config_manager.get_tags_selection() + return web.json_response({"success": True, "selection": selection}) + except Exception as e: + print(f"{ERROR_PREFIX} 获取标签选择失败 | 错误:{str(e)}") + return web.json_response({"success": False, "error": str(e)}, status=500) + +@PromptServer.instance.routes.post(f'{API_PREFIX}/config/tags_selection') +async def save_tags_selection(request): + """保存用户选择的标签文件""" + try: + data = await request.json() + success = config_manager.save_tags_selection(data) + if success: + return web.json_response({"success": True}) + else: + return web.json_response({"success": False, "error": "保存失败"}, status=500) + except Exception as e: + print(f"{ERROR_PREFIX} 保存标签选择失败 | 错误:{str(e)}") + return web.json_response({"success": False, "error": str(e)}, status=500) + +@PromptServer.instance.routes.get(f'{API_PREFIX}/config/favorites') +async def get_favorites(request): + """获取收藏列表""" + try: + favorites = config_manager.get_favorites() + return web.json_response({"success": True, "favorites": favorites}) + except Exception as e: + print(f"{ERROR_PREFIX} 获取收藏列表失败 | 错误:{str(e)}") + return web.json_response({"success": False, "error": str(e)}, status=500) + +@PromptServer.instance.routes.post(f'{API_PREFIX}/config/favorites/add') +async def add_favorite(request): + """添加单个收藏""" + try: + data = await request.json() + tag_value = data.get('tag_value') + tag_name = data.get('tag_name') # 获取可选的标签名称 + category = data.get('category') # 获取可选的分类(来源文件) + if not tag_value: + return web.json_response({"success": False, "error": "缺少tag_value参数"}, status=400) + + success = config_manager.add_favorite(tag_value, tag_name, category) + if success: + return web.json_response({"success": True}) + else: + return web.json_response({"success": False, "error": "添加失败"}, status=500) + except Exception as e: + print(f"{ERROR_PREFIX} 添加收藏失败 | 错误:{str(e)}") + return web.json_response({"success": False, "error": str(e)}, status=500) + +@PromptServer.instance.routes.post(f'{API_PREFIX}/config/favorites/remove') +async def remove_favorite(request): + """移除单个收藏""" + try: + data = await request.json() + tag_value = data.get('tag_value') + category = data.get('category') + if not tag_value: + return web.json_response({"success": False, "error": "缺少tag_value参数"}, status=400) + + success = config_manager.remove_favorite(tag_value, category) + if success: + return web.json_response({"success": True}) + else: + return web.json_response({"success": False, "error": "移除失败"}, status=500) + except Exception as e: + print(f"{ERROR_PREFIX} 移除收藏失败 | 错误:{str(e)}") + return web.json_response({"success": False, "error": str(e)}, status=500) + @PromptServer.instance.routes.post(f'{API_PREFIX}/config/baidu_translate') async def update_baidu_translate_config(request): """更新百度翻译配置""" @@ -241,7 +933,7 @@ async def update_system_prompts_config(request): expand_id = active_prompts.get('expand', '无') vision_zh_id = active_prompts.get('vision_zh', '无') vision_en_id = active_prompts.get('vision_en', '无') - print(f"{PREFIX} 系统提示词配置更新成功 | 激活提示词: 扩写={expand_id}, 中文反推={vision_zh_id}, 英文反推={vision_en_id}") + print(f"{PREFIX} 系统提示词配置更新成功 | 激活提示词: 提示词优化={expand_id}, 中文反推={vision_zh_id}, 英文反推={vision_en_id}") # 在更新配置后验证激活提示词 print(f"{PREFIX} 正在验证更新后的激活提示词配置...") @@ -420,7 +1112,7 @@ async def cancel_request(request): task.cancel() # 从字典中移除已取消的任务 del ACTIVE_TASKS[request_id] - print(f"{PREFIX} 请求已取消 | ID:{request_id}") + print(f"{WARN_PREFIX} 请求已取消 | ID:{request_id}") return web.json_response({"success": True, "message": "请求已取消"}) else: return web.json_response({"success": False, "error": "未找到活动任务或任务已完成"}, status=404) @@ -450,21 +1142,21 @@ async def baidu_translate(request): if not request_id: return web.json_response({"success": False, "error": "缺少request_id"}, status=400) + # 准备阶段日志 + from_lang_name = {"auto": "自动", "zh": "中文", "en": "英文"}.get(from_lang, from_lang) + to_lang_name = {"zh": "中文", "en": "英文"}.get(to_lang, to_lang) + log_prepare(TASK_TRANSLATE, request_id, SOURCE_FRONTEND, "百度翻译", None, None, {"方向": f"{from_lang_name}→{to_lang_name}", "长度": len(text)}) + # 创建并注册任务 - task = asyncio.create_task(BaiduTranslateService.translate(text, from_lang, to_lang, request_id, is_auto)) + task = asyncio.create_task(BaiduTranslateService.translate(text, from_lang, to_lang, request_id, is_auto, task_type=TASK_TRANSLATE, source=SOURCE_FRONTEND)) ACTIVE_TASKS[request_id] = task result = await task - # 如果发生错误,输出详细错误信息 - if not result.get('success'): - if not result.get('cancelled', False): - error_msg = result.get('error', '未知错误') - print(f"{ERROR_PREFIX} 百度翻译请求失败 | 请求ID:{request_id} | 错误:{error_msg}") - + # 服务层已输出错误日志,此处不再重复输出 return web.json_response(result) except asyncio.CancelledError: - print(f"{PREFIX} 百度翻译任务被取消 | ID:{request_id}") + print(f"\r{_ANSI_CLEAR_EOL}{WARN_PREFIX} 百度翻译任务被取消 | ID:{request_id}", flush=True) return web.json_response({"success": False, "error": "请求已取消", "cancelled": True}, status=400) except Exception as e: error_msg = str(e) @@ -486,26 +1178,52 @@ async def llm_expand(request): if not request_id: return web.json_response({"success": False, "error": "缺少request_id"}, status=400) + # 准备阶段日志 + from .services.thinking_control import build_thinking_suppression + from .utils.common import format_model_with_thinking + from .services.openai_base import OpenAICompatibleService + + llm_config = config_manager.get_llm_config() + if llm_config: + provider = llm_config.get('provider', 'ollama') + model = llm_config.get('model', '') + provider_display = OpenAICompatibleService.get_provider_display_name(provider) + + # 检查disable_thinking配置 + service = config_manager.get_service(provider) + disable_thinking_enabled = service.get('disable_thinking', True) if service else True + + thinking_extra = build_thinking_suppression(provider, model) if disable_thinking_enabled else None + model_display = format_model_with_thinking(model, bool(thinking_extra)) + + # 获取规则名称 + system_prompts = config_manager.get_system_prompts() + rule_name = "未知规则" + if system_prompts and 'expand_prompts' in system_prompts: + active_prompt_id = system_prompts.get('active_prompts', {}).get('expand', 'expand_default') + if active_prompt_id in system_prompts['expand_prompts']: + rule_name = system_prompts['expand_prompts'][active_prompt_id].get('name', active_prompt_id) + elif len(system_prompts['expand_prompts']) > 0: + # 如果激活的ID不存在,使用第一个 + first_id = list(system_prompts['expand_prompts'].keys())[0] + rule_name = system_prompts['expand_prompts'][first_id].get('name', first_id) + + log_prepare(TASK_EXPAND, request_id, SOURCE_FRONTEND, provider_display, model_display, rule_name, {"长度": len(prompt)}) + # 创建并注册任务 - task = asyncio.create_task(LLMService.expand_prompt(prompt, request_id)) + task = asyncio.create_task(LLMService.expand_prompt(prompt, request_id, task_type=TASK_EXPAND, source=SOURCE_FRONTEND)) ACTIVE_TASKS[request_id] = task result = await task - # 如果发生错误,输出详细错误信息 - if not result.get('success'): - # 检查是否是用户取消 - if not result.get('cancelled', False): - error_msg = result.get('error', '未知错误') - print(f"{ERROR_PREFIX} LLM扩写请求失败 | 请求ID:{request_id} | 错误:{error_msg}") - + # 服务层已输出错误日志,此处不再重复输出 return web.json_response(result) except asyncio.CancelledError: - print(f"{PREFIX} LLM扩写任务被取消 | ID:{request_id}") + print(f"\r{_ANSI_CLEAR_EOL}{WARN_PREFIX} LLM提示词优化任务被取消 | ID:{request_id}", flush=True) return web.json_response({"success": False, "error": "请求已取消", "cancelled": True}, status=400) except Exception as e: error_msg = str(e) - print(f"{ERROR_PREFIX} LLM扩写请求异常 | 请求ID:{request_id if request_id else '未知'} | 错误:{error_msg}") + print(f"{ERROR_PREFIX} LLM提示词优化请求异常 | 请求ID:{request_id if request_id else '未知'} | 错误:{error_msg}") return web.json_response({"success": False, "error": error_msg}) finally: if request_id and request_id in ACTIVE_TASKS: @@ -527,20 +1245,51 @@ async def llm_translate(request): if not request_id: return web.json_response({"success": False, "error": "缺少request_id"}, status=400) + # 准备阶段日志 + from .services.thinking_control import build_thinking_suppression + from .utils.common import format_model_with_thinking + from .services.openai_base import OpenAICompatibleService + + prefix = AUTO_TRANSLATE_REQUEST_PREFIX if is_auto else REQUEST_PREFIX + from_lang_name = {"auto": "自动检测", "zh": "中文", "en": "英文"}.get(from_lang, from_lang) + to_lang_name = {"zh": "中文", "en": "英文"}.get(to_lang, to_lang) + + # 使用独立的翻译配置 + translate_config = config_manager.get_translate_config() + if translate_config: + provider = translate_config.get('provider', 'baidu') + model = translate_config.get('model', '') + provider_display = OpenAICompatibleService.get_provider_display_name(provider) + + # 检查disable_thinking配置 + service = config_manager.get_service(provider) + disable_thinking_enabled = service.get('disable_thinking', True) if service else True + + thinking_extra = build_thinking_suppression(provider, model) if disable_thinking_enabled else None + model_display = format_model_with_thinking(model, bool(thinking_extra)) + + log_prepare(TASK_TRANSLATE, request_id, SOURCE_FRONTEND, provider_display, model_display, None, {"方向": f"{from_lang_name}→{to_lang_name}", "长度": len(text)}) + # 创建并注册任务 - task = asyncio.create_task(LLMService.translate(text, from_lang, to_lang, request_id, is_auto)) + task = asyncio.create_task(LLMService.translate( + text=text, + from_lang=from_lang, + to_lang=to_lang, + request_id=request_id, + custom_provider=provider if translate_config else None, + custom_provider_config=translate_config, + cancel_event=None, + task_type=TASK_TRANSLATE, + source=SOURCE_FRONTEND + )) ACTIVE_TASKS[request_id] = task result = await task - # 如果发生错误,输出详细错误信息 - if not result.get('success') and not result.get('cancelled', False): - error_msg = result.get('error', '未知错误') - print(f"{ERROR_PREFIX} LLM翻译请求失败 | 请求ID:{request_id} | 错误:{error_msg}") - + # 服务层已输出错误日志,此处不再重复输出 return web.json_response(result) except asyncio.CancelledError: - print(f"{PREFIX} LLM翻译任务被取消 | ID:{request_id}") + print(f"\r{_ANSI_CLEAR_EOL}{WARN_PREFIX} LLM翻译任务被取消 | ID:{request_id}", flush=True) return web.json_response({"success": False, "error": "请求已取消", "cancelled": True}, status=400) except Exception as e: error_msg = str(e) @@ -564,25 +1313,68 @@ async def vlm_analyze(request): if not request_id: return web.json_response({"success": False, "error": "缺少request_id"}, status=400) + # 准备阶段日志 + from .services.thinking_control import build_thinking_suppression + from .utils.common import format_model_with_thinking + from .services.openai_base import OpenAICompatibleService + + vision_config = config_manager.get_vision_config() + if vision_config: + provider = vision_config.get('provider', 'ollama') + model = vision_config.get('model', '') + provider_display = OpenAICompatibleService.get_provider_display_name(provider) + + # 检查disable_thinking配置 + service = config_manager.get_service(provider) + disable_thinking_enabled = service.get('disable_thinking', True) if service else True + + thinking_extra = build_thinking_suppression(provider, model) if disable_thinking_enabled else None + model_display = format_model_with_thinking(model, bool(thinking_extra)) + + # 获取规则名称 + rule_name = "默认规则" + if prompt_content: + # 如果前端传了自定义提示词,尝试匹配规则名称 + system_prompts = config_manager.get_system_prompts() + if system_prompts and 'vision_prompts' in system_prompts: + # 尝试从vision_prompts中匹配 + for prompt_id, prompt_data in system_prompts['vision_prompts'].items(): + if prompt_data.get('content') == prompt_content: + rule_name = prompt_data.get('name', prompt_id) + break + else: + # 如果没有匹配到,说明是自定义的 + rule_name = "自定义规则" + else: + # 使用激活的系统提示词(需要判断语言) + # 这里简化为获取激活的中文规则,实际应该根据提示词判断语言 + system_prompts = config_manager.get_system_prompts() + if system_prompts: + active_prompts = system_prompts.get('active_prompts', {}) + # 优先使用中文规则 + active_prompt_id = active_prompts.get('vision_zh') + if active_prompt_id and 'vision_prompts' in system_prompts: + if active_prompt_id in system_prompts['vision_prompts']: + rule_name = system_prompts['vision_prompts'][active_prompt_id].get('name', active_prompt_id) + + log_prepare(TASK_IMAGE_CAPTION, request_id, SOURCE_FRONTEND, provider_display, model_display, rule_name) + # 创建并注册任务,使用新的接口签名 task = asyncio.create_task(VisionService.analyze_image( image_data=image_data, request_id=request_id, - prompt_content=prompt_content + prompt_content=prompt_content, + task_type=TASK_IMAGE_CAPTION, + source=SOURCE_FRONTEND )) ACTIVE_TASKS[request_id] = task result = await task - # 如果发生错误,输出详细错误信息 - if not result.get('success'): - if not result.get('cancelled', False): - error_msg = result.get('error', '未知错误') - print(f"{ERROR_PREFIX} 视觉分析请求失败 | 请求ID:{request_id} | 错误:{error_msg}") - + # 服务层已输出错误日志,此处不再重复输出 return web.json_response(result) except asyncio.CancelledError: - print(f"{PREFIX} 视觉分析任务被取消 | ID:{request_id}") + print(f"\r{_ANSI_CLEAR_EOL}{WARN_PREFIX} 视觉分析任务被取消 | ID:{request_id}", flush=True) return web.json_response({"success": False, "error": "请求已取消", "cancelled": True}, status=400) except Exception as e: error_msg = str(e) @@ -592,6 +1384,83 @@ async def vlm_analyze(request): if request_id and request_id in ACTIVE_TASKS: del ACTIVE_TASKS[request_id] +@PromptServer.instance.routes.post(f'{API_PREFIX}/video/info') +async def get_video_info(request): + """获取视频文件信息(FPS, 时长等)""" + try: + data = await request.json() + filename = data.get("filename") + subfolder = data.get("subfolder", "") + type_ = data.get("type", "input") + + if not filename: + return web.json_response({"success": False, "error": "缺少文件名参数"}, status=400) + + # 获取文件完整路径 + file_path = None + + # 1. 尝试直接作为绝对路径 + if os.path.exists(filename): + file_path = filename + else: + # 2. 使用ComfyUI的路径解析 + file_path = folder_paths.get_annotated_filepath(filename) + + if not file_path or not os.path.exists(file_path): + # 3. 尝试在input目录查找 + input_dir = folder_paths.get_input_directory() + possible_path = os.path.join(input_dir, filename) + if os.path.exists(possible_path): + file_path = possible_path + + if not file_path or not os.path.exists(file_path): + return web.json_response({"success": False, "error": "找不到视频文件"}, status=404) + + # 读取元数据 + try: + import cv2 + + cap = cv2.VideoCapture(file_path) + if not cap.isOpened(): + return web.json_response({"success": False, "error": "无法打开视频文件"}, status=500) + + # 获取视频信息 + fps = cap.get(cv2.CAP_PROP_FPS) + total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT)) + + # 有效性检查:某些视频格式 cv2 返回无效值 + if fps <= 0: + fps = 30 # 默认 fps + if total_frames <= 0 or total_frames > 1e9: # 无效或异常大的值 + # 尝试通过读取所有帧来计算 + total_frames = 0 + while True: + ret = cap.grab() + if not ret: + break + total_frames += 1 + # 重新打开视频以释放资源 + cap.release() + cap = cv2.VideoCapture(file_path) + + duration = total_frames / fps if fps > 0 else 0 + + cap.release() + + + return web.json_response({ + "success": True, + "fps": fps, + "duration": duration, + "total_frames": total_frames, + "path": file_path + }) + except Exception as e: + return web.json_response({"success": False, "error": f"读取视频元数据失败: {str(e)}"}, status=500) + + except Exception as e: + return web.json_response({"success": False, "error": str(e)}, status=500) + @PromptServer.instance.routes.post(f'{API_PREFIX}/models/list') async def get_models_list(request): """ @@ -641,4 +1510,64 @@ async def get_models_list(request): return web.json_response({ "success": False, "error": error_msg - }, status=500) \ No newline at end of file + }, status=500) + +# ---视频帧提取 API--- + +@PromptServer.instance.routes.post(f'{API_PREFIX}/video/frame') +async def get_video_frame(request): + """ + 获取视频指定帧的图片 + + 请求参数: + filename: 视频文件名 + frame_index: 帧索引(基于 force_rate 后的帧序列) + force_rate: 强制帧率(可选,0 表示原始帧率) + type: 文件类型(input/output,默认 input) + + 返回: + success: 是否成功 + data: base64 编码的 JPEG 图片 + width/height: 图片尺寸 + """ + try: + data = await request.json() + filename = data.get("filename") + frame_index = data.get("frame_index", 0) + force_rate = data.get("force_rate", 0) + type_ = data.get("type", "input") + + if not filename: + return web.json_response({"success": False, "error": "缺少文件名参数"}, status=400) + + # 获取文件完整路径 + file_path = None + + # 1. 尝试直接作为绝对路径 + if os.path.exists(filename): + file_path = filename + else: + # 2. 使用 ComfyUI 的路径解析 + file_path = folder_paths.get_annotated_filepath(filename) + + if not file_path or not os.path.exists(file_path): + # 3. 尝试在 input 目录查找 + input_dir = folder_paths.get_input_directory() + possible_path = os.path.join(input_dir, filename) + if os.path.exists(possible_path): + file_path = possible_path + + if not file_path or not os.path.exists(file_path): + return web.json_response({"success": False, "error": "找不到视频文件"}, status=404) + + # 调用帧提取函数 + result = extract_frame_by_index(file_path, frame_index, force_rate) + + if result["success"]: + return web.json_response(result) + else: + return web.json_response(result, status=500) + + except Exception as e: + print(f"{ERROR_PREFIX} 帧提取失败 | 错误:{str(e)}") + return web.json_response({"success": False, "error": str(e)}, status=500) \ No newline at end of file diff --git a/services/baidu.py b/services/baidu.py index f0d836c..7a47d59 100644 --- a/services/baidu.py +++ b/services/baidu.py @@ -1,12 +1,13 @@ import random -import aiohttp +import httpx from hashlib import md5 import json import time import re from typing import Optional, Dict, Any, List import asyncio -from .error_util import BAIDU_ERROR_CODE_MESSAGES +from ..utils.common import BAIDU_ERROR_CODE_MESSAGES, ProgressBar, log_complete, log_error, TASK_TRANSLATE +from .core import HTTPClientPool class BaiduTranslateService: @staticmethod @@ -56,7 +57,7 @@ class BaiduTranslateService: return chunks @staticmethod - async def translate_chunk(session, chunk, app_id, secret_key, from_lang, to_lang, retry_count=1): + async def translate_chunk(client, chunk, app_id, secret_key, from_lang, to_lang, retry_count=1): """ 异步翻译单个文本块,带重试机制 """ @@ -78,40 +79,42 @@ class BaiduTranslateService: } # 发送异步请求 - async with session.post(url, data=params, timeout=10) as response: - if response.status != 200: - if attempt < retry_count - 1: - await asyncio.sleep(1) - continue - raise Exception(f"百度: HTTP请求失败,状态码: {response.status}") - - result = await response.json() + response = await client.post(url, data=params) - # 检查错误 - if 'error_code' in result: - error_code = result['error_code'] - error_message = BAIDU_ERROR_CODE_MESSAGES.get( - error_code, - f"未知错误(错误码:{error_code})" - ) - - # 某些错误码可以重试 - if error_code in ['54003', '52001', '52002'] and attempt < retry_count - 1: - await asyncio.sleep(1) - continue - - raise Exception(f"百度: {error_message}") + if response.status_code != 200: + if attempt < retry_count - 1: + await asyncio.sleep(1) + continue + raise Exception(f"百度: HTTP请求失败,状态码: {response.status_code}") - # 处理翻译结果 - if 'trans_result' in result and result['trans_result']: - translated_parts = [item['dst'] for item in result['trans_result']] - return '\n'.join(translated_parts) - else: - raise Exception("百度: 翻译结果为空") + result = response.json() + + # 检查错误 + if 'error_code' in result: + error_code = result['error_code'] + error_message = BAIDU_ERROR_CODE_MESSAGES.get( + error_code, + f"未知错误(错误码:{error_code})" + ) + + # 某些错误码可以重试 + if error_code in ['54003', '52001', '52002'] and attempt < retry_count - 1: + await asyncio.sleep(1) + continue - except (aiohttp.ClientError, asyncio.TimeoutError) as e: + raise Exception(f"百度: {error_message}") + + # 处理翻译结果 + if 'trans_result' in result and result['trans_result']: + translated_parts = [item['dst'] for item in result['trans_result']] + return '\n'.join(translated_parts) + else: + raise Exception("百度: 翻译结果为空") + + except (httpx.HTTPError, asyncio.TimeoutError) as e: if attempt < retry_count - 1: - print(f"百度翻译请求遇到网络错误,尝试重试 ({attempt+1}/{retry_count}): {e}") + from ..utils.common import WARN_PREFIX + print(f"\r{WARN_PREFIX} 百度翻译请求遇到网络错误,尝试重试 ({attempt+1}/{retry_count}): {e}") await asyncio.sleep(1) else: raise Exception(f"百度: 网络请求失败,请检查网络连接或稍后再试 ({type(e).__name__})") @@ -127,9 +130,13 @@ class BaiduTranslateService: raise Exception("百度: 超过最大重试次数") @staticmethod - async def translate(text, from_lang='auto', to_lang='zh', request_id=None, is_auto=False): + async def translate(text, from_lang='auto', to_lang='zh', request_id=None, is_auto=False, cancel_event=None, task_type=None, source=None): """ 异步调用百度翻译API进行翻译 + + 参数: + task_type: 任务类型,用于统一日志输出 + cancel_event: 中断事件(保持接口一致性,百度翻译暂不支持中断) """ try: request_id = request_id or f"baidu_trans_{int(time.time())}_{random.randint(1000, 9999)}" @@ -146,13 +153,8 @@ class BaiduTranslateService: if not app_id or not secret_key: return {"success": False, "error": "百度: 请先配置百度翻译API的APP_ID和SECRET_KEY"} - from ..server import PREFIX, AUTO_TRANSLATE_PREFIX - - # 请求阶段:蓝色 - # 百度翻译始终使用直连模式(trust_env=False) - from ..server import AUTO_TRANSLATE_REQUEST_PREFIX, REQUEST_PREFIX - prefix = AUTO_TRANSLATE_REQUEST_PREFIX if is_auto else REQUEST_PREFIX - print(f"{prefix} {'工作流自动翻译' if is_auto else '翻译请求'}(直连) | 服务:百度翻译 | 请求ID:{request_id} | 原文长度:{len(text)} | 方向:{from_lang}->{to_lang}") + # 确定任务类型 + task_type = task_type or TASK_TRANSLATE text_chunks = BaiduTranslateService.split_text_by_paragraphs(text) if not text_chunks: @@ -160,14 +162,59 @@ class BaiduTranslateService: translated_parts = [] - # 创建一个禁用SSL证书验证的连接器 - connector = aiohttp.TCPConnector(ssl=False) - # 创建 aiohttp.ClientSession,并禁用代理和SSL验证 - async with aiohttp.ClientSession(connector=connector, trust_env=False) as session: + # 获取HTTP客户端(复用连接池,不使用代理) + # 注意:百度API通常在国内直连更快,所以不配置proxy参数 + client = HTTPClientPool.get_client( + provider="baidu_translate", + base_url="https://fanyi-api.baidu.com", + timeout=10.0 + ) + + # 由于HTTPClientPool.get_client返回的是AsyncClient实例,我们可以直接使用 + # 但为了确保不使用系统代理(模拟 trust_env=False),我们需要在get_client中支持或在这里手动创建 + # 考虑到HTTPClientPool目前的设计,我们直接手动创建一个不使用代理的客户端可能更稳妥 + # 或者修改HTTPClientPool支持 trust_env=False。 + # 为了保持统一,我们使用HTTPClientPool,但如果需要强制直连,我们可以传递特定的代理配置 + + # 实际上,HTTPClientPool.get_client 如果没有传proxy,会使用系统代理。 + # 百度翻译之前的实现是 trust_env=False (禁用系统代理)。 + # 为了保持一致,我们这里手动创建一个专用的客户端,或者修改HTTPClientPool。 + # 考虑到百度翻译的特殊性(国内直连),手动创建一个简单的客户端是最安全的迁移方式。 + + # 创建统一进度条 + from ..server import is_streaming_progress_enabled + pbar = ProgressBar( + request_id=request_id, + service_name="百度翻译", + streaming=is_streaming_progress_enabled(), + extra_info=f"长度:{len(text)}", + task_type=task_type, + source=source + ) + + start_time = time.perf_counter() + + async with httpx.AsyncClient(trust_env=False, verify=False, timeout=10.0) as client: for i, chunk in enumerate(text_chunks): + # ---中断监控--- + is_interrupted = False + if cancel_event is not None and cancel_event.is_set(): + is_interrupted = True + else: + try: + from server import PromptServer + if hasattr(PromptServer.instance, 'execution_interrupted') and PromptServer.instance.execution_interrupted: + is_interrupted = True + except: pass + + if is_interrupted: + pbar.cancel(f"{WARN_PREFIX} 任务被中断 | 服务:百度翻译") + return {"success": False, "error": "任务被中断", "interrupted": True} + # ------------ + try: chunk_translation = await BaiduTranslateService.translate_chunk( - session, chunk, app_id, secret_key, from_lang, to_lang + client, chunk, app_id, secret_key, from_lang, to_lang ) translated_parts.append(chunk_translation) @@ -175,12 +222,14 @@ class BaiduTranslateService: await asyncio.sleep(1) except Exception as chunk_error: + # 输出错误日志 + pbar.error(str(chunk_error)) return {"success": False, "error": str(chunk_error)} translated_text = '\n'.join(translated_parts) - # 结果阶段:绿色 - prefix = AUTO_TRANSLATE_PREFIX if is_auto else PREFIX - print(f"{prefix} {'工作流翻译完成' if is_auto else '翻译完成'} | 服务:百度翻译 | 请求ID:{request_id} | 结果字符数:{len(translated_text)}") + # 完成阶段 + elapsed = int((time.perf_counter() - start_time) * 1000) + pbar.done(char_count=len(translated_text), elapsed_ms=elapsed) return { "success": True, @@ -192,7 +241,16 @@ class BaiduTranslateService: } } + # 关键修复:单独捕获外层 CancelledError,确保 pbar 被正确停止 + except asyncio.CancelledError: + if 'pbar' in locals() and pbar: + pbar.cancel(f"{WARN_PREFIX} 任务被外部取消 | 服务:百度翻译") + return {"success": False, "error": "任务被取消", "interrupted": True} + except Exception as e: + # 输出错误日志 + if 'pbar' in locals() and pbar: + pbar.error(str(e)) return {"success": False, "error": str(e)} @staticmethod @@ -201,4 +259,4 @@ class BaiduTranslateService: 异步批量翻译文本 """ tasks = [BaiduTranslateService.translate(text, from_lang, to_lang) for text in texts] - return await asyncio.gather(*tasks) \ No newline at end of file + return await asyncio.gather(*tasks) \ No newline at end of file diff --git a/services/core.py b/services/core.py new file mode 100644 index 0000000..0791db3 --- /dev/null +++ b/services/core.py @@ -0,0 +1,120 @@ +""" +核心基础设施模块 +提供HTTP客户端池管理 +""" + +import httpx +import os +from typing import Dict, Optional, Any +import re + + +class HTTPClientPool: + """ + HTTP客户端池单例 + 管理httpx.AsyncClient的创建(不跨事件循环缓存) + """ + + @classmethod + def get_client( + cls, + provider: str, + base_url: Optional[str] = None, + timeout: float = 60.0, + proxy: Optional[str] = None, + verify_ssl: bool = True, + **kwargs + ) -> httpx.AsyncClient: + """ + 创建HTTP客户端(每次都创建新实例,避免事件循环问题) + + 参数: + provider: 服务商标识(用于日志) + base_url: API基础URL + timeout: 超时时间(秒) + proxy: 代理设置 + verify_ssl: 是否验证SSL证书 + **kwargs: 其他httpx参数 + + 返回: + httpx.AsyncClient: 新创建的客户端 + """ + # 创建新客户端(不缓存,避免跨事件循环复用) + client_kwargs = { + 'timeout': httpx.Timeout(timeout, connect=10.0), + 'verify': verify_ssl, + 'follow_redirects': True, + 'http2': False, # 禁用HTTP/2避免兼容性问题 + } + + # 设置代理(优先使用参数,其次环境变量) + if proxy: + client_kwargs['proxies'] = proxy + elif os.environ.get('HTTP_PROXY') or os.environ.get('HTTPS_PROXY'): + # httpx会自动读取环境变量,无需手动设置 + pass + + # 合并额外参数 + client_kwargs.update(kwargs) + + # 创建客户端 + client = httpx.AsyncClient(**client_kwargs) + + return client + + @classmethod + async def close_all(cls): + """占位方法(已不再缓存客户端)""" + pass + + @classmethod + def get_pool_size(cls) -> int: + """获取当前池中的客户端数量""" + return len(cls._clients) + + +# Logger 类已移除,请直接从 ..utils.common 导入 log_prepare, log_complete, log_error 等函数使用。 + + + +class BaseAPIService: + """ + API服务抽象基类 + 所有服务(LLM, VLM, Baidu)的基础 + """ + + def __init__(self, http_client_pool: HTTPClientPool = None): + """ + 初始化基类 + + 参数: + http_client_pool: HTTP客户端池(可选,默认使用全局池) + """ + self.http_client_pool = http_client_pool or HTTPClientPool + + def get_config(self) -> Dict[str, Any]: + """ + 获取服务配置(子类必须实现) + + 返回: + Dict: 配置字典 + """ + raise NotImplementedError("子类必须实现 get_config 方法") + + async def handle_error(self, error: Exception, provider: str) -> Dict[str, Any]: + """ + 统一错误处理 + + 参数: + error: 异常对象 + provider: 服务商标识 + + 返回: + Dict: 错误响应 + """ + from ..utils.common import format_api_error + error_message = format_api_error(error, provider) + return { + "success": False, + "error": error_message + } diff --git a/services/error_util.py b/services/error_util.py deleted file mode 100644 index 19387a0..0000000 --- a/services/error_util.py +++ /dev/null @@ -1,83 +0,0 @@ -import json -from openai import APIStatusError - -# 定义HTTP状态码到中文错误信息的映射 -HTTP_STATUS_CODE_MESSAGES = { - 400: "请求无效", - 401: "身份验证失败-请检查您的API Key是否正确。", - 403: "无权限访问-您没有权限访问此资源。", - 404: "请求的资源不存", - 429: "请求频率过高-您已超出速率限制,请稍后再试。", - 500: "服务器内部错误- 服务提供商端发生未知问题。", - 502: "网关错误", - 503: "服务不可用- 服务器当前无法处理请求,请稍后重试。", - 504: "网关超时", -} - -# 定义百度翻译API的错误码映射 -BAIDU_ERROR_CODE_MESSAGES = { - '52001': '请求超时,请重试', - '52002': '系统错误,请重试', - '52003': '未授权用户,请检查appid是否正确或服务是否开通', - '54000': '必填参数为空,请检查是否少传参数', - '54001': '签名错误,请检查appid和secret_key是否正确', - '54003': '访问频率受限,请降低您的调用频率,或进行身份认证后切换为高级版/尊享版', - '54004': '账户余额不足,请前往管理控制台充值', - '54005': '长query请求频繁,请降低长query的发送频率,3s后再试', - '58000': '客户端IP非法,检查个人资料里填写的IP地址是否正确,可前往开发者信息-基本信息修改', - '58001': '译文语言方向不支持,检查译文语言是否在语言列表里', - '58002': '服务当前已关闭,请前往百度管理控制台开启服务', - '58003': '此IP已被封禁', - '90107': '认证未通过或未生效,请前往我的认证查看认证进度', - '20003': '请求内容存在安全风险', -} - - -def format_api_error(e: Exception, provider_display_name: str) -> str: - """ - 格式化来自API的错误信息,提供更详细的上下文。 - """ - # 优先处理openai的API状态错误 - if isinstance(e, APIStatusError): - status_code = e.status_code - message = HTTP_STATUS_CODE_MESSAGES.get(status_code, f"未知HTTP错误") - - error_details_str = "" - try: - # 尝试解析响应体中的详细错误信息 - error_details = e.response.json() - # 提取关键信息,兼容不同服务商的格式 - # 常见格式: {"error": {"message": "...", "type": "...", "code": "..."}} - # 或: {"message": "..."} - detail_msg = error_details.get("message", "") - if isinstance(error_details.get("error"), dict): - detail_msg = error_details["error"].get("message", detail_msg) - - if detail_msg: - error_details_str = f" | 详情: {detail_msg}" - except (json.JSONDecodeError, AttributeError): - # 如果解析失败或没有响应体,使用原始响应文本 - try: - if e.response and hasattr(e.response, 'text') and e.response.text: - error_details_str = f" | 原始响应: {e.response.text}" - except Exception: - pass # 忽略获取原始响应的错误 - - return f"{provider_display_name} API错误: {message} (状态码: {status_code}){error_details_str}" - - # 对于其他类型的异常,例如网络连接错误,返回其类型和基本信息 - return f"{provider_display_name} 服务请求异常: ({type(e).__name__}) {str(e)}" - -def format_baidu_translate_error(error_data: dict) -> str: - """ - 格式化百度翻译API的错误信息。 - """ - if not isinstance(error_data, dict): - return "未知的百度翻译错误格式" - - error_code = str(error_data.get('error_code')) - if error_code in BAIDU_ERROR_CODE_MESSAGES: - return f"百度翻译错误: {BAIDU_ERROR_CODE_MESSAGES[error_code]} (代码: {error_code})" - - error_msg = error_data.get('error_msg', '未知错误') - return f"百度翻译错误: {error_msg} (代码: {error_code})" \ No newline at end of file diff --git a/services/llm.py b/services/llm.py index c642188..92c0ae7 100644 --- a/services/llm.py +++ b/services/llm.py @@ -1,127 +1,29 @@ +""" +LLM服务 - 重构版本 +提供大语言模型的扩写和翻译功能 +继承OpenAICompatibleService以复用通用逻辑 +""" + import json -import os -import sys -from typing import Optional, Dict, Any, List, Callable -import asyncio -from openai import AsyncOpenAI -from openai.types.chat import ChatCompletion -import httpx import time -from .error_util import format_api_error +import asyncio +from typing import Optional, Dict, Any, List, Callable +import httpx +from .openai_base import OpenAICompatibleService, filter_thinking_content +from ..utils.common import ( + format_api_error, ProgressBar, log_complete, log_error, + PREFIX, PROCESS_PREFIX, WARN_PREFIX, ERROR_PREFIX, format_elapsed_time, + TASK_EXPAND, TASK_TRANSLATE +) from .thinking_control import build_thinking_suppression -class LLMService: - _provider_base_urls = { - 'openai': None, # 使用默认 - 'siliconflow': 'https://api.siliconflow.cn/v1', # 硅基流动 - 'zhipu': 'https://open.bigmodel.cn/api/paas/v4/', # 智谱(官方文档要求末尾斜杠) - '302ai': 'https://api.302.ai/v1', - 'ollama': 'http://localhost:11434/v1', - 'custom': None # 使用配置中的自定义URL - } +class LLMService(OpenAICompatibleService): + """ + 大语言模型服务 + 支持提示词扩写和文本翻译 + """ - # 提供商显示名称映射 - _provider_display_names = { - 'zhipu': '智谱', - 'siliconflow': '硅基流动', - 'openai': 'OpenAI', - '302ai': '302.AI', - 'ollama': 'Ollama', - 'custom': '自定义' - } - - @classmethod - def get_openai_client(cls, api_key: str, provider: str) -> AsyncOpenAI: - """获取OpenAI客户端""" - # 降低第三方库默认HTTP日志级别,避免重复输出,并关闭OpenAI SDK调试打印 - try: - import logging - import os as _os - # 关闭 OpenAI SDK 的调试HTTP日志(环境变量优先生效) - _os.environ["OPENAI_LOG"] = "error" - for _name in ("openai", "openai._base_client", "httpx", "httpcore"): - _logger = logging.getLogger(_name) - _logger.setLevel(logging.WARNING) - except Exception: - pass - - # 否则创建新客户端 - base_url = None - - # 如果是自定义提供商,从配置中获取base_url - if provider == 'custom': - from ..config_manager import config_manager - config = config_manager.get_llm_config() - if 'providers' in config and 'custom' in config['providers']: - base_url = config['providers']['custom'].get('base_url') - # 确保base_url不以/chat/completions结尾,避免路径重复 - if base_url and base_url.endswith('/chat/completions'): - # 注意: str.rstrip 参数是字符集合,这里不能用来移除子串,否则可能把域名尾部的字母也删掉 - # 正确做法是按长度切片移除精确后缀 - base_url = base_url[: -len('/chat/completions')] - else: - base_url = cls._provider_base_urls.get(provider) - - # 检查是否启用"跳过代理直连" - bypass_proxy = False - try: - from ..config_manager import config_manager - settings = config_manager.get_settings() - bypass_proxy = settings.get('PromptAssistant.Settings.BypassProxy', False) - except Exception: - pass - - # 创建简化的httpx客户端,明确禁用HTTP/2以提高国内服务商兼容性 - # 仅在"发起请求"阶段打印一条请求日志,避免重复 - from ..server import PROCESS_PREFIX - - async def _on_request(request: httpx.Request): - try: - print(f"{PROCESS_PREFIX} OpenAI Request: {request.method} {request.url}") - except Exception: - pass - - # 使用细粒度的超时配置,提高网络波动下的稳定性 - # connect: 建立连接的超时时间 - # read: 等待服务器响应的超时时间 - # write: 发送请求数据的超时时间 - # pool: 从连接池获取连接的超时时间 - http_client_kwargs = { - 'timeout': httpx.Timeout( - connect=10.0, # 连接超时:10秒 - read=60.0, # 读取超时:60秒(流式响应需要较长时间) - write=10.0, # 写入超时:10秒 - pool=5.0 # 连接池超时:5秒 - ), - 'event_hooks': {'request': [_on_request]}, - 'http2': False, # 明确禁用HTTP/2,提高国内服务商兼容性 - 'verify': True # 启用SSL验证,但使用系统证书 - } - - # 如果启用"跳过代理直连",则设置 proxy=None - if bypass_proxy: - http_client_kwargs['proxy'] = None - - http_client = httpx.AsyncClient(**http_client_kwargs) - - kwargs = { - "api_key": api_key, - "http_client": http_client, - "max_retries": 3 # 增加重试次数到3次 - } - if base_url: - # 根据官方文档,保留base_url原样,不做处理 - # 智谱文档要求:https://open.bigmodel.cn/api/paas/v4/ - # 硅基流动文档要求:https://api.siliconflow.cn/v1 - # OpenAI SDK会自动处理URL拼接 - kwargs["base_url"] = base_url - - - # 创建客户端并缓存 - client = AsyncOpenAI(**kwargs) - return client - @staticmethod def _get_config() -> Dict[str, Any]: """获取LLM配置""" @@ -129,7 +31,6 @@ class LLMService: config = config_manager.get_llm_config() current_provider = config.get('provider') - # 获取实际配置 if 'providers' in config and current_provider in config['providers']: provider_config = config['providers'][current_provider] return { @@ -143,218 +44,255 @@ class LLMService: 'auto_unload': provider_config.get('auto_unload', True) } else: - # 兼容旧版配置格式 return config - + @staticmethod - async def _unload_ollama_model(model: str, config: Dict[str, Any]): - """ - 卸载Ollama模型以释放显存和内存 - - 参数: - model: 模型名称 - config: 配置字典 - """ - try: - # 检查是否启用自动释放 - auto_unload = config.get('auto_unload', True) - if not auto_unload: - return - - # 获取base_url - base_url = config.get('base_url', 'http://localhost:11434') - # 确保URL不以/v1结尾(Ollama原生API不需要/v1) - if base_url.endswith('/v1'): - base_url = base_url[:-3] - - # 调用Ollama API卸载模型 - url = f"{base_url}/api/generate" - payload = { - "model": model, - "keep_alive": 0 # 立即卸载模型 - } - - from ..server import PROCESS_PREFIX - print(f"{PROCESS_PREFIX} Ollama自动释放显存 | 模型:{model}") - - async with httpx.AsyncClient(timeout=5.0) as client: - await client.post(url, json=payload) - - except Exception as e: - # 释放失败不影响主流程,只记录警告 - from ..server import WARN_PREFIX - print(f"{WARN_PREFIX} Ollama模型释放失败(不影响结果) | 模型:{model} | 错误:{str(e)[:50]}") - + def _is_chinese(text: str) -> bool: + """判断文本是否包含中文""" + return any('\u4e00' <= char <= '\u9fff' for char in text) + @staticmethod - async def _http_request_chat_completions( - base_url: str, - api_key: str, + async def _call_ollama_native( model: str, messages: List[Dict[str, str]], temperature: float, top_p: float, max_tokens: int, - thinking_extra: Optional[Dict[str, Any]] = None, + base_url: str, stream_callback: Optional[Callable[[str], None]] = None, request_id: Optional[str] = None, - provider_display_name: str = "未知服务", - direct_mode_tag: str = "" + provider_display_name: str = "Ollama", + auto_unload: bool = True, + enable_advanced_params: bool = False, + thinking_extra: Optional[Dict[str, Any]] = None, + cancel_event: Optional[Any] = None, + task_type: str = None, + source: str = None ) -> Dict[str, Any]: """ - 使用HTTP直接调用chat/completions接口(保底方案) + 调用Ollama原生API(支持流式输出) + 用于支持智能上下文窗口和思维链控制 参数: - base_url: API基础URL - api_key: API密钥 - model: 模型名称 - messages: 消息列表 - temperature: 温度参数 - top_p: top_p参数 - max_tokens: 最大token数 + enable_advanced_params: 是否发送高级参数(temperature/top_p/num_predict) thinking_extra: 思维链控制参数 - stream_callback: 流式输出回调 - request_id: 请求ID - provider_display_name: 提供商显示名称 - direct_mode_tag: 直连模式标签 - - 返回: - 包含结果的字典 """ - from ..server import PROCESS_PREFIX, PREFIX, ERROR_PREFIX + # ---初始化请求参数--- try: - # 确保base_url不以/结尾 - base_url = base_url.rstrip('/') - # 构建完整URL - url = f"{base_url}/chat/completions" + start_time = time.perf_counter() + _thinking_extra = thinking_extra # 使用传入的参数 + _thinking_tag = "(已关闭思维链)" if _thinking_extra else "" - # 构建请求体 + # 计算base_url + native_base = base_url[:-3] if base_url and base_url.endswith('/v1') else (base_url or 'http://localhost:11434') + native_base = native_base.rstrip('/') + + # 智能动态上下文窗口计算 (Token估算策略: 中文0.7/char, 英文0.3/char -> 保守取 0.6/char) + # 安全地计算输入长度 (包含 System Prompt 和 User Prompt,前提是都在 messages 中) + input_char_len = 0 + for msg in messages: + input_char_len += len(msg.get('content', '') or '') + + estimated_input_tokens = int(input_char_len * 0.6) + + # --- 智能预留策略 --- + # 关键点:思考过程 (Thinking Process) 也占用 Output Token 额度 + # 1. 如果成功禁用了思维链 (_thinking_extra 非空) -> 安全 + # 2. 如果模型名明确包含 instruct/chat (通常无思考过程) -> 安全 + # 3. 其他未知模型 -> 假定可能存在思考过程,预留更多空间 + + is_safe_standard_model = False + if model: + m = model.lower() + if "instruct" in m or "chat" in m: + is_safe_standard_model = True + + if _thinking_extra or is_safe_standard_model: + # 已关闭思维链 OR 标准指令模型 -> 极致节省模式 + min_output = 512 + min_ctx = 1024 + else: + # 未知/潜在思考模型 -> 安全能够模式 (为思考过程预留空间) + min_output = 1024 + min_ctx = 2048 + + # 任务类型预留 + output_reserve = max(min_output, int(estimated_input_tokens * 1.5)) + + # 384为系统开销Buffer (System Prompt通常已在estimated_input_tokens中,这里是额外的安全余量) + required_ctx = estimated_input_tokens + output_reserve + 384 + + # 对齐到 1024 倍数 + # 限制在 [min_ctx, 32768] 范围内 + num_ctx = max(min_ctx, min(32768, required_ctx)) + num_ctx = ((num_ctx + 1023) // 1024) * 1024 + + # 合并多条 System Message(Ollama 对多条 system 消息处理不佳) + merged_messages = LLMService._merge_system_prompts(messages) + + # 构建请求(安全地构建messages列表) + ollama_messages = [] + for msg in merged_messages: + ollama_messages.append({ + "role": msg.get('role', 'user'), + "content": msg.get('content', '') + }) + + # 构建基础请求体 payload = { "model": model, - "messages": messages, - "temperature": temperature, - "top_p": top_p, - "max_tokens": max_tokens, + "messages": ollama_messages, "stream": True } - # 添加思维链控制参数 - if thinking_extra: - # 将思维链参数合并到payload中 - if "reasoning_effort" in thinking_extra: - payload["reasoning_effort"] = thinking_extra["reasoning_effort"] - if "enable_thinking" in thinking_extra: - payload["enable_thinking"] = thinking_extra["enable_thinking"] - if "thinking" in thinking_extra: - payload["thinking"] = thinking_extra["thinking"] - # 其他参数通过extra_body传递(在HTTP请求中直接合并) - for key, value in thinking_extra.items(): - if key not in ["reasoning_effort", "enable_thinking", "thinking"]: - payload[key] = value - - # 构建请求头 - headers = { - "Content-Type": "application/json", - "Authorization": f"Bearer {api_key}" + # 构建 options + # ⚠️ 重要:Ollama 对高级参数兼容性不佳,仅发送 num_ctx 避免冲突 + # - temperature 和 top_p 冲突 + # - num_predict 在某些模型上会导致无输出 + # 因此统一只发送 num_ctx,让模型使用默认配置 + payload["options"] = { + "num_ctx": num_ctx } - # 检查是否启用直连模式 - bypass_proxy = False - try: - from ..config_manager import config_manager as cm - settings = cm.get_settings() - bypass_proxy = settings.get('PromptAssistant.Settings.BypassProxy', False) - except Exception: - pass + # 添加思维链控制参数(如 think: true 或 think: false) + if _thinking_extra: + payload.update(_thinking_extra) - # 创建HTTP客户端 - http_client_kwargs = { - 'timeout': httpx.Timeout( - connect=10.0, - read=60.0, - write=10.0, - pool=5.0 - ), - 'http2': False, - 'verify': True - } + from ..server import is_streaming_progress_enabled - if bypass_proxy: - http_client_kwargs['proxy'] = None + # 动态超时计算: 基础30s + 每1000个Token预估增加5秒 + estimated_timeout = 30.0 + (num_ctx / 1000) * 5.0 + final_timeout = min(600.0, max(60.0, estimated_timeout)) # 限制在 60s - 600s 之间 + + # 创建统一进度条(自动处理等待→生成→完成的完整生命周期) + extra_info = f"Context:{num_ctx} | Timeout:{int(final_timeout)}s" + pbar = ProgressBar( + request_id=request_id, + service_name=provider_display_name, + extra_info=extra_info, + streaming=is_streaming_progress_enabled(), + task_type=task_type, + source=source + ) - print(f"{PROCESS_PREFIX} 使用HTTP方式请求{direct_mode_tag} | 服务:{provider_display_name} | 模型:{model}") start_time = time.perf_counter() - async with httpx.AsyncClient(**http_client_kwargs) as client: - async with client.stream('POST', url, headers=headers, json=payload) as response: - if response.status_code != 200: - error_text = await response.aread() - try: - error_data = json.loads(error_text) - error_msg = error_data.get('error', {}).get('message', f'HTTP {response.status_code}') - except: - error_msg = f'HTTP {response.status_code}: {error_text.decode("utf-8", errors="ignore")[:100]}' - return {"success": False, "error": error_msg} - - full_content = "" - async for line in response.aiter_lines(): - if not line or line == "data: [DONE]": - continue + async with httpx.AsyncClient( + timeout=httpx.Timeout(final_timeout, connect=10.0, read=final_timeout), + limits=httpx.Limits(max_keepalive_connections=5, max_connections=10) + ) as client: + full_content = "" + + # 定义请求核心逻辑 + async def _request_core(): + async with client.stream('POST', f"{native_base}/api/chat", json=payload, follow_redirects=True) as resp: + if resp.status_code != 200: + error_text = await resp.aread() + try: + error_data = json.loads(error_text) + return {"success": False, "error": error_data.get('error', f'HTTP {resp.status_code}')} + except: + return {"success": False, "error": f'HTTP {resp.status_code}'} - if line.startswith("data: "): - line = line[6:] # 移除 "data: " 前缀 - - try: - chunk_data = json.loads(line) - choices = chunk_data.get('choices', []) - for choice in choices: - delta = choice.get('delta', {}) - content = delta.get('content', '') - if content: - full_content += content - if stream_callback: - stream_callback(content) - except json.JSONDecodeError: - continue - - elapsed_ms = int((time.perf_counter() - start_time) * 1000) - print(f"{PREFIX} HTTP请求成功 | 服务:{provider_display_name} | 请求ID:{request_id} | 结果字符数:{len(full_content)} | 耗时:{elapsed_ms}ms") - - return { - "success": True, - "content": full_content - } - - except Exception as e: - from ..server import ERROR_PREFIX - print(f"{ERROR_PREFIX} HTTP请求失败 | 服务:{provider_display_name} | 错误:{str(e)[:100]}") - return {"success": False, "error": format_api_error(e, provider_display_name)} + nonlocal full_content + async for line in resp.aiter_lines(): + if not line: continue + try: + chunk_data = json.loads(line) + message = chunk_data.get('message') + if message and isinstance(message, dict): + content = message.get('content', '') + if content and content.strip(): + full_content += content + pbar.set_generating(len(full_content)) + pbar.update(len(full_content)) + if stream_callback: stream_callback(content) + + if chunk_data.get('done', False): + pbar.done(char_count=len(full_content), elapsed_ms=int((time.perf_counter() - start_time) * 1000)) + break + except: + continue + return {"success": True, "content": full_content.strip()} + # 定义监视器逻辑 + async def _monitor_interrupts(target_task): + while not target_task.done(): + is_interrupted = False + if cancel_event is not None and cancel_event.is_set(): + is_interrupted = True + else: + try: + from server import PromptServer + if hasattr(PromptServer.instance, 'execution_interrupted') and PromptServer.instance.execution_interrupted: + is_interrupted = True + except: pass + + if is_interrupted: + target_task.cancel() + return True + await asyncio.sleep(0.1) + return False + + # 并发执行 + req_task = asyncio.create_task(_request_core()) + monitor_task = asyncio.create_task(_monitor_interrupts(req_task)) + + try: + result = await req_task + return result + except asyncio.CancelledError: + pbar.cancel(f"{WARN_PREFIX} 任务被中断 | 服务:Ollama") + return {"success": False, "error": "任务被中断", "interrupted": True} + finally: + if not monitor_task.done(): monitor_task.cancel() + # 强力显存释放保证:不仅是成功,中断也要释放 + if auto_unload: + try: + await cls._unload_ollama_model(model, {"base_url": native_base, "auto_unload": True}) + except: pass + + # 关键修复:单独捕获外层 CancelledError,确保 pbar 被正确停止 + except asyncio.CancelledError: + if 'pbar' in locals() and pbar: + pbar.cancel(f"{WARN_PREFIX} 任务被外部取消 | 服务:Ollama") + return {"success": False, "error": "任务被取消", "interrupted": True} + + except Exception as e: + # 关键修复:确保 pbar 在异常时也被停止 + if 'pbar' in locals() and pbar: + pbar.error(format_api_error(e, provider_display_name)) + return {"success": False, "error": format_api_error(e, provider_display_name)} + @staticmethod async def expand_prompt( prompt: str, request_id: Optional[str] = None, stream_callback: Optional[Callable[[str], None]] = None, - # 新增:允许节点传入自定义provider与配置(不影响前端全局设置) custom_provider: Optional[str] = None, custom_provider_config: Optional[Dict[str, Any]] = None, - # 新增:允许覆盖系统扩写规则 system_message_override: Optional[Dict[str, str]] = None, + cancel_event: Optional[Any] = None, + task_type: str = None, + source: Optional[str] = None ) -> Dict[str, Any]: """ - 使用大语言模型扩写提示词,自动判断用户输入语言,并设置大语言模型回答语言。 - 支持流式输出以提高响应速度。 - + 使用大语言模型扩写提示词 + 参数: prompt: 要扩写的提示词 request_id: 请求ID stream_callback: 流式输出的回调函数 - + custom_provider: 自定义服务商 + custom_provider_config: 自定义配置 + system_message_override: 覆盖系统提示词 + 返回: - 包含扩写结果的字典 + Dict: {"success": bool, "data": {"original": str, "expanded": str}, "error": str} """ try: - # 获取配置(支持节点级覆盖) + # 获取配置 if custom_provider and custom_provider_config: provider = custom_provider api_key = custom_provider_config.get('api_key') @@ -362,399 +300,191 @@ class LLMService: temperature = custom_provider_config.get('temperature', 0.7) top_p = custom_provider_config.get('top_p', 0.9) max_tokens = custom_provider_config.get('max_tokens', 2000) + base_url = custom_provider_config.get('base_url', '') else: config = LLMService._get_config() + provider = config.get('provider', 'unknown') api_key = config.get('api_key') model = config.get('model') - provider = config.get('provider', 'unknown') temperature = config.get('temperature', 0.7) top_p = config.get('top_p', 0.9) max_tokens = config.get('max_tokens', 2000) + base_url = config.get('base_url', '') - if not api_key: - return {"success": False, "error": "请先配置大语言模型 API密钥"} + # 注:允许空API Key,支持无认证服务商(如deepinfra公开端点) if not model: return {"success": False, "error": "未配置模型名称"} - # 从server.py导入颜色常量和前缀 - from ..server import PREFIX, ERROR_PREFIX - - # 获取提供商显示名称 - provider_display_name = LLMService._provider_display_names.get(provider, provider) - - # 检查是否启用直连模式 - bypass_proxy = False - try: - from ..config_manager import config_manager as cm - settings = cm.get_settings() - bypass_proxy = settings.get('PromptAssistant.Settings.BypassProxy', False) - except Exception: - pass + provider_display_name = LLMService.get_provider_display_name(provider) - direct_mode_tag = "(直连)" if bypass_proxy else "" - from ..server import REQUEST_PREFIX - print(f"{REQUEST_PREFIX} LLM扩写请求{direct_mode_tag} | 服务:{provider_display_name} | ID:{request_id} | 内容:{prompt[:30]}...") - # 系统提示词:允许节点覆盖,否则走全局激活 + from ..utils.common import REQUEST_PREFIX, PREFIX, format_model_with_thinking + + # 获取系统提示词 if system_message_override and system_message_override.get('content'): system_message = system_message_override prompt_name = system_message.get('name', '节点自定义规则') - from ..server import REQUEST_PREFIX - print(f"{REQUEST_PREFIX} 使用扩写规则:{prompt_name}") else: from ..config_manager import config_manager system_prompts = config_manager.get_system_prompts() if not system_prompts or 'expand_prompts' not in system_prompts: - return {"success": False, "error": "扩写系统提示词加载失败"} + return {"success": False, "error": "提示词优化系统提示词加载失败"} - # 获取激活的提示词ID active_prompt_id = system_prompts.get('active_prompts', {}).get('expand', 'expand_default') - - # 获取对应的提示词 if active_prompt_id not in system_prompts['expand_prompts']: - # 如果找不到激活的提示词,尝试使用第一个可用的提示词 if len(system_prompts['expand_prompts']) > 0: active_prompt_id = list(system_prompts['expand_prompts'].keys())[0] else: - return {"success": False, "error": "未找到可用的扩写系统提示词"} + return {"success": False, "error": "未找到可用的提示词优化系统提示词"} system_message = system_prompts['expand_prompts'][active_prompt_id] - - # 输出使用的提示词名称 prompt_name = system_message.get('name', active_prompt_id) - from ..server import REQUEST_PREFIX - print(f"{REQUEST_PREFIX} 使用扩写规则:{prompt_name}") - - # 判断用户输入语言 - def is_chinese(text): - return any('\u4e00' <= char <= '\u9fff' for char in text) - if is_chinese(prompt): - lang_message = {"role": "system", "content": "请用中文回答"} - else: - lang_message = {"role": "system", "content": "Please answer in English."} + + # 检查服务配置的disable_thinking参数,只有开启时才关闭思维链 + from ..config_manager import config_manager + service = config_manager.get_service(provider) + disable_thinking_enabled = service.get('disable_thinking', True) if service else True + + # 始终调用 build_thinking_suppression,传递 disable_thinking 参数 + _thinking_extra = build_thinking_suppression(provider, model, disable_thinking=disable_thinking_enabled) + thinking_disabled = _thinking_extra is not None and disable_thinking_enabled + model_display = format_model_with_thinking(model, thinking_disabled) # 构建消息 - messages = [ - lang_message, - system_message, - {"role": "user", "content": prompt} - ] + lang_message = { + "role": "system", + "content": "请用中文回答" if LLMService._is_chinese(prompt) else "Please answer in English." + } + messages = [lang_message, system_message, {"role": "user", "content": prompt}] - # Ollama 直接走原生 API,避免 OpenAI SDK 兼容性问题(特别是思维链控制) + # Ollama走原生API if provider == 'ollama': - try: - start_time = time.perf_counter() - _thinking_extra = build_thinking_suppression(provider, model) - _thinking_tag = "(已关闭思维链)" if _thinking_extra else "" - from ..server import PROCESS_PREFIX - print(f"{PROCESS_PREFIX} 调用Ollama原生API{direct_mode_tag} | 模型:{model}{_thinking_tag}") - - # 获取 base_url - base_url_local = None - if custom_provider and custom_provider_config: - base_url_local = custom_provider_config.get('base_url', '') - else: - base_url_local = config.get('base_url', '') - native_base = base_url_local[:-3] if base_url_local and base_url_local.endswith('/v1') else (base_url_local or 'http://localhost:11434') - - # 使用 Ollama 原生 /api/chat - import httpx as _httpx - payload = { - "model": model, - "messages": [ - {"role": "system", "content": messages[0]['content']}, - {"role": "system", "content": messages[1]['content']}, - {"role": "user", "content": messages[2]['content']} - ], - "options": {"temperature": temperature, "top_p": top_p}, - "stream": False - } - # 如果支持关闭思维链,添加 think 参数(Ollama 原生 API 支持) - if _thinking_extra and "think" in _thinking_extra: - payload["think"] = _thinking_extra["think"] - - async with _httpx.AsyncClient(timeout=_httpx.Timeout(20.0, read=60.0)) as _client: - r = await _client.post(f"{native_base}/api/chat", json=payload) - if r.status_code == 200: - j = r.json() - full_content = ((j or {}).get('message') or {}).get('content', '') or (j or {}).get('response', '') or '' - if full_content: - elapsed_ms = int((time.perf_counter() - start_time) * 1000) - print(f"{PREFIX} LLM扩写成功 | 服务:{provider_display_name} | 请求ID:{request_id} | 结果字符数:{len(full_content)} | 耗时:{elapsed_ms}ms") - - # Ollama自动释放显存 - if custom_provider and custom_provider_config: - _cfg = { - 'auto_unload': custom_provider_config.get('auto_unload', True), - 'base_url': native_base - } - else: - _cfg = { - 'auto_unload': config.get('auto_unload', True), - 'base_url': native_base - } - await LLMService._unload_ollama_model(model, _cfg) - - return { - "success": True, - "data": { - "original": prompt, - "expanded": full_content - } - } - else: - return {"success": False, "error": "Ollama原生API未返回内容"} - else: - return {"success": False, "error": f"Ollama原生API请求失败: {r.status_code}"} - except Exception as e: - return {"success": False, "error": format_api_error(e, provider_display_name)} - - # 检查是否强制使用HTTP方式 - force_http = False - try: - from ..config_manager import config_manager as cm - settings = cm.get_settings() - force_http = settings.get('PromptAssistant.Settings.ForceHTTP', False) - except Exception: - pass - - # 检查是否支持HTTP回退(zhipu, siliconflow, 302ai, custom) - supports_http_fallback = provider in ['zhipu', 'siliconflow', '302ai', 'custom'] - - # 如果强制使用HTTP且支持HTTP,直接使用HTTP方式 - if force_http and supports_http_fallback: - _thinking_extra = build_thinking_suppression(provider, model) - base_url_local = None - if custom_provider and custom_provider_config: - base_url_local = custom_provider_config.get('base_url', '') - else: - base_url_local = config.get('base_url', '') + # 读取 Ollama 服务的配置 + enable_advanced_params = service.get('enable_advanced_params', False) if service else False + filter_thinking_output = service.get('filter_thinking_output', True) if service else True - http_result = await LLMService._http_request_chat_completions( - base_url=base_url_local, - api_key=api_key, + + + # 提前计算auto_unload配置 + native_base = base_url[:-3] if base_url.endswith('/v1') else (base_url or 'http://localhost:11434') + native_base = native_base.rstrip('/') + _cfg = { + 'auto_unload': custom_provider_config.get('auto_unload', True) if custom_provider_config else config.get('auto_unload', True), + 'base_url': native_base + } + auto_unload = _cfg['auto_unload'] + + result = await LLMService._call_ollama_native( model=model, - messages=[{"role": m["role"], "content": m["content"]} for m in messages], + messages=messages, temperature=temperature, top_p=top_p, max_tokens=max_tokens, - thinking_extra=_thinking_extra, + base_url=base_url, stream_callback=stream_callback, request_id=request_id, provider_display_name=provider_display_name, - direct_mode_tag=direct_mode_tag + auto_unload=auto_unload, + enable_advanced_params=enable_advanced_params, + thinking_extra=_thinking_extra, + cancel_event=cancel_event, + task_type=task_type or TASK_EXPAND, + source=source ) - if http_result.get("success"): + if result["success"]: + # 自动卸载 (原生调用路径需要手动触发,但使用补全后的基类逻辑) + await LLMService._unload_ollama_model(model, _cfg) + + # 应用思维链输出过滤 + content = result["content"] + if filter_thinking_output: + content = filter_thinking_content(content) + return { "success": True, - "data": { - "original": prompt, - "expanded": http_result.get("content", "") - } + "data": {"original": prompt, "expanded": content} } else: - return {"success": False, "error": http_result.get("error", "HTTP请求失败")} - - # 其余 provider 走 OpenAI 兼容 SDK - # 使用OpenAI SDK - client = LLMService.get_openai_client(api_key, provider) - - # 添加手动重试机制,处理连接错误 - # 修改为重试1次后回退到HTTP(如果支持) - max_retries = 2 # 重试1次(总共2次尝试) - retry_delay = 1.0 # 初始重试延迟(秒) - last_error = None - - for attempt in range(max_retries): - try: - # 添加调试信息 - _thinking_extra = build_thinking_suppression(provider, model) - _thinking_tag = "(已关闭思维链)" if _thinking_extra else "" - from ..server import PROCESS_PREFIX - if attempt > 0: - print(f"{PROCESS_PREFIX} 重试LLM API调用{direct_mode_tag} | 尝试:{attempt + 1}/{max_retries} | 服务:{provider_display_name} | 模型:{model}{_thinking_tag}") - else: - print(f"{PROCESS_PREFIX} 调用LLM API{direct_mode_tag} | 服务:{provider_display_name} | 模型:{model}{_thinking_tag}") - start_time = time.perf_counter() + return result - # 兼容不同提供商:将 reasoning_effort 放到顶层,其它参数放入 extra_body - extra_args = {} - if _thinking_extra: - _extra_copy = dict(_thinking_extra) - if "reasoning_effort" in _extra_copy: - extra_args["reasoning_effort"] = _extra_copy.pop("reasoning_effort") - if _extra_copy: - extra_args["extra_body"] = _extra_copy - - # 使用配置中的参数 - try: - stream = await client.chat.completions.create( - model=model, - messages=[{"role": m["role"], "content": m["content"]} for m in messages], - temperature=temperature, - top_p=top_p, - max_tokens=max_tokens, - stream=True, - response_format={"type": "text"}, - **extra_args - ) - except Exception as e_first: - # 针对硅基流动返回 "enable_thinking 不支持" 的场景做一次性回退 - msg = str(e_first).lower() - if "enable_thinking" in msg and "not support" in msg: - print(f"{PREFIX} 发现 enable_thinking 不被模型支持,移除后重试 | 模型:{model}") - stream = await client.chat.completions.create( - model=model, - messages=[{"role": m["role"], "content": m["content"]} for m in messages], - temperature=temperature, - top_p=top_p, - max_tokens=max_tokens, - stream=True, - response_format={"type": "text"} - ) - else: - raise - - # 请求成功,处理流式响应 - full_content = "" - async for chunk in stream: - # 兼容部分第三方网关返回空 choices 的异常片段,做健壮性判断 - choices = getattr(chunk, "choices", None) or [] - for ch in choices: - delta = getattr(ch, "delta", None) - if not delta: - continue - content = getattr(delta, "content", None) - if content: - full_content += content - if stream_callback: - stream_callback(content) - - # 输出结构化成功日志 - elapsed_ms = int((time.perf_counter() - start_time) * 1000) - print(f"{PREFIX} LLM扩写成功 | 服务:{provider_display_name} | 请求ID:{request_id} | 结果字符数:{len(full_content)} | 耗时:{elapsed_ms}ms") - - return { - "success": True, - "data": { - "original": prompt, - "expanded": full_content - } - } - - except asyncio.CancelledError: - print(f"{PREFIX} LLM扩写任务在服务层被取消 | ID:{request_id}") - return {"success": False, "error": "请求已取消", "cancelled": True} - except Exception as e: - last_error = e - # 检查是否为连接错误 - error_msg = str(e).lower() - is_connection_error = any(keyword in error_msg for keyword in [ - 'connection', 'connect', 'timeout', 'timed out', - 'network', 'unreachable', 'refused' - ]) - - # 如果是最后一次尝试,检查是否需要回退到HTTP - if attempt == max_retries - 1: - # 如果支持HTTP回退,尝试使用HTTP方式 - if supports_http_fallback: - from ..server import PROCESS_PREFIX - print(f"{PROCESS_PREFIX} OpenAI SDK请求失败,使用HTTP方式重试{direct_mode_tag} | 服务:{provider_display_name} | 模型:{model}") - - base_url_local = None - if custom_provider and custom_provider_config: - base_url_local = custom_provider_config.get('base_url', '') - else: - base_url_local = config.get('base_url', '') - - http_result = await LLMService._http_request_chat_completions( - base_url=base_url_local, - api_key=api_key, - model=model, - messages=[{"role": m["role"], "content": m["content"]} for m in messages], - temperature=temperature, - top_p=top_p, - max_tokens=max_tokens, - thinking_extra=_thinking_extra, - stream_callback=stream_callback, - request_id=request_id, - provider_display_name=provider_display_name, - direct_mode_tag=direct_mode_tag - ) - - if http_result.get("success"): - return { - "success": True, - "data": { - "original": prompt, - "expanded": http_result.get("content", "") - } - } - else: - return {"success": False, "error": http_result.get("error", "HTTP请求失败")} - else: - # 不支持HTTP回退,直接返回错误 - return {"success": False, "error": format_api_error(e, provider_display_name)} - - # 如果不是最后一次尝试,且是连接错误,等待后重试 - if is_connection_error: - wait_time = retry_delay * (2 ** attempt) - from ..server import WARN_PREFIX - print(f"{WARN_PREFIX} LLM API连接失败 | 尝试:{attempt + 1}/{max_retries} | 错误:{str(e)[:100]} | {wait_time:.1f}秒后重试") - await asyncio.sleep(wait_time) - continue - else: - # 不是连接错误,直接抛出 - raise + # 其他服务走HTTP直连 + if not base_url: + base_url = LLMService.get_provider_base_url(provider, custom_provider_config if custom_provider else None) - # 如果所有尝试都失败且没有回退到HTTP,返回错误 - if last_error: - return {"success": False, "error": format_api_error(last_error, provider_display_name)} + # 检查disable_thinking、enable_advanced_params和filter_thinking_output配置 + from ..config_manager import config_manager + service = config_manager.get_service(provider) + disable_thinking_enabled = service.get('disable_thinking', True) if service else True + enable_advanced_params = service.get('enable_advanced_params', False) if service else False + filter_thinking_output = service.get('filter_thinking_output', True) if service else True + thinking_extra = build_thinking_suppression(provider, model) if disable_thinking_enabled else None + + result = await LLMService._http_request_chat_completions( + base_url=base_url, + api_key=api_key, + model=model, + messages=messages, + temperature=temperature, + top_p=top_p, + max_tokens=max_tokens, + thinking_extra=thinking_extra, + enable_advanced_params=enable_advanced_params, + stream_callback=stream_callback, + request_id=request_id, + provider_display_name=provider_display_name, + cancel_event=cancel_event, + task_type=task_type or TASK_EXPAND, + source=source + ) + + if result["success"]: + # 根据配置决定是否应用思维链输出过滤 + content = result["content"] + if filter_thinking_output: + content = filter_thinking_content(content) + return { + "success": True, + "data": {"original": prompt, "expanded": content} + } else: - return {"success": False, "error": "未知错误"} + return result - except asyncio.CancelledError: - from ..server import PREFIX - print(f"{PREFIX} LLM扩写任务在服务层被取消 | ID:{request_id}") - return {"success": False, "error": "请求已取消", "cancelled": True} except Exception as e: - # 从server.py导入颜色常量和前缀 - from ..server import ERROR_PREFIX - print(f"{ERROR_PREFIX} LLM扩写请求失败 | 错误:{str(e)}") - return {"success": False, "error": str(e)} - + return {"success": False, "error": format_api_error(e, "LLM服务")} + @staticmethod async def translate( text: str, from_lang: str = 'auto', to_lang: str = 'zh', request_id: Optional[str] = None, - is_auto: bool = False, stream_callback: Optional[Callable[[str], None]] = None, - # 新增:允许节点传入自定义provider配置(不影响前端全局设置) custom_provider: Optional[str] = None, custom_provider_config: Optional[Dict[str, Any]] = None, + cancel_event: Optional[Any] = None, + task_type: str = None, + source: Optional[str] = None ) -> Dict[str, Any]: """ - 使用大语言模型翻译文本,自动设置提示词语言和输出语言。 - 支持流式输出以提高响应速度。 - + 使用大语言模型翻译文本 + 参数: text: 要翻译的文本 - from_lang: 源语言 (默认为auto自动检测) - to_lang: 目标语言 (默认为zh中文) + from_lang: 源语言 + to_lang: 目标语言 request_id: 请求ID - is_auto: 是否为工作流自动翻译 - stream_callback: 流式输出的回调函数 - + stream_callback: 流式输出回调 + custom_provider: 自定义服务商 + custom_provider_config: 自定义配置 + 返回: - 包含翻译结果的字典 + Dict: {"success": bool, "data": {"original": str, "translated": str}, "error": str} """ try: - # 获取配置(支持节点覆盖) + # 获取配置(同expand_prompt) if custom_provider and custom_provider_config: provider = custom_provider api_key = custom_provider_config.get('api_key') @@ -762,359 +492,131 @@ class LLMService: temperature = custom_provider_config.get('temperature', 0.7) top_p = custom_provider_config.get('top_p', 0.9) max_tokens = custom_provider_config.get('max_tokens', 2000) + base_url = custom_provider_config.get('base_url', '') else: config = LLMService._get_config() + provider = config.get('provider', 'unknown') api_key = config.get('api_key') model = config.get('model') - provider = config.get('provider', 'unknown') temperature = config.get('temperature', 0.7) top_p = config.get('top_p', 0.9) max_tokens = config.get('max_tokens', 2000) + base_url = config.get('base_url', '') - if not api_key: - return {"success": False, "error": "请先配置大语言模型 API密钥"} + # 注:允许空API Key,支持无认证服务商 if not model: return {"success": False, "error": "未配置模型名称"} - # 从server.py导入颜色常量和前缀 - from ..server import PREFIX, AUTO_TRANSLATE_PREFIX + provider_display_name = LLMService.get_provider_display_name(provider) - # 获取提供商显示名称 - provider_display_name = LLMService._provider_display_names.get(provider, provider) - - # 检查是否启用直连模式 - bypass_proxy = False - try: - from ..config_manager import config_manager as cm - settings = cm.get_settings() - bypass_proxy = settings.get('PromptAssistant.Settings.BypassProxy', False) - except Exception: - pass + from ..utils.common import REQUEST_PREFIX, PREFIX, format_model_with_thinking - direct_mode_tag = "(直连)" if bypass_proxy else "" + # 检测是否关闭思维链 + _thinking_extra = build_thinking_suppression(provider, model) + thinking_disabled = _thinking_extra is not None + model_display = format_model_with_thinking(model, thinking_disabled) - # 使用统一的前缀(请求阶段使用蓝色) - from ..server import AUTO_TRANSLATE_REQUEST_PREFIX, REQUEST_PREFIX - prefix = AUTO_TRANSLATE_REQUEST_PREFIX if is_auto else REQUEST_PREFIX - print(f"{prefix} {'工作流自动翻译' if is_auto else '翻译请求'}{direct_mode_tag} | 服务:{provider_display_name}翻译 | 请求ID:{request_id} | 原文长度:{len(text)} | 方向:{from_lang}->{to_lang}") - - # 加载系统提示词 - from ..config_manager import config_manager - system_prompts = config_manager.get_system_prompts() - - if not system_prompts or 'translate_prompts' not in system_prompts or 'ZH' not in system_prompts['translate_prompts']: - return {"success": False, "error": "翻译系统提示词加载失败"} - - system_message = system_prompts['translate_prompts']['ZH'] - - # 动态替换提示词中的{src_lang}和{dst_lang} - lang_map = {'zh': '中文', 'en': '英文', 'auto': '原文'} - src_lang = lang_map.get(from_lang, from_lang) - dst_lang = lang_map.get(to_lang, to_lang) - sys_msg_content = system_message['content'].replace('{src_lang}', src_lang).replace('{dst_lang}', dst_lang) - sys_msg = {"role": "system", "content": sys_msg_content} - - # 设置输出语言 - if to_lang == 'en': - lang_message = {"role": "system", "content": "Please answer in English."} - else: - lang_message = {"role": "system", "content": "请用中文回答"} - - # 构建消息 + # 翻译提示词 + translate_instruction = f"请将以下文本从{from_lang}翻译成{to_lang},只输出翻译结果,不要添加任何解释或额外内容:" + messages = [ - lang_message, - sys_msg, + {"role": "system", "content": translate_instruction}, {"role": "user", "content": text} ] - # Ollama 直接走原生 API,避免 OpenAI SDK 兼容性问题(特别是思维链控制) + # Ollama走原生API if provider == 'ollama': - try: - start_time = time.perf_counter() - _thinking_extra = build_thinking_suppression(provider, model) - _thinking_tag = "(已关闭思维链)" if _thinking_extra else "" - from ..server import PROCESS_PREFIX - print(f"{PROCESS_PREFIX} 调用Ollama原生API{direct_mode_tag} | 模型:{model}{_thinking_tag}") - - # 获取 base_url - base_url_local = None - if custom_provider and custom_provider_config: - base_url_local = custom_provider_config.get('base_url', '') - else: - base_url_local = config.get('base_url', '') - native_base = base_url_local[:-3] if base_url_local and base_url_local.endswith('/v1') else (base_url_local or 'http://localhost:11434') - - # 使用 Ollama 原生 /api/chat - import httpx as _httpx - payload = { - "model": model, - "messages": [ - {"role": "system", "content": messages[0]['content']}, - {"role": "system", "content": messages[1]['content']}, - {"role": "user", "content": messages[2]['content']} - ], - "options": {"temperature": temperature, "top_p": top_p}, - "stream": False - } - # 如果支持关闭思维链,添加 think 参数(Ollama 原生 API 支持) - if _thinking_extra and "think" in _thinking_extra: - payload["think"] = _thinking_extra["think"] - - async with _httpx.AsyncClient(timeout=_httpx.Timeout(20.0, read=60.0)) as _client: - r = await _client.post(f"{native_base}/api/chat", json=payload) - if r.status_code == 200: - j = r.json() - full_content = ((j or {}).get('message') or {}).get('content', '') or (j or {}).get('response', '') or '' - if full_content: - prefix = AUTO_TRANSLATE_PREFIX if is_auto else PREFIX - elapsed_ms = int((time.perf_counter() - start_time) * 1000) - print(f"{prefix} {'工作流翻译完成' if is_auto else '翻译完成'} | 服务:{provider_display_name}翻译 | 请求ID:{request_id} | 结果字符数:{len(full_content)} | 耗时:{elapsed_ms}ms") - - # Ollama自动释放显存 - if custom_provider and custom_provider_config: - _cfg = { - 'auto_unload': custom_provider_config.get('auto_unload', True), - 'base_url': native_base - } - else: - _cfg = { - 'auto_unload': config.get('auto_unload', True), - 'base_url': native_base - } - await LLMService._unload_ollama_model(model, _cfg) - - return { - "success": True, - "data": { - "from": from_lang, - "to": to_lang, - "original": text, - "translated": full_content - } - } - else: - return {"success": False, "error": "Ollama原生API未返回内容"} - else: - return {"success": False, "error": f"Ollama原生API请求失败: {r.status_code}"} - except Exception as e: - return {"success": False, "error": format_api_error(e, provider_display_name)} - - # 检查是否强制使用HTTP方式 - force_http = False - try: - from ..config_manager import config_manager as cm - settings = cm.get_settings() - force_http = settings.get('PromptAssistant.Settings.ForceHTTP', False) - except Exception: - pass - - # 检查是否支持HTTP回退(zhipu, siliconflow, 302ai, custom) - supports_http_fallback = provider in ['zhipu', 'siliconflow', '302ai', 'custom'] - - # 如果强制使用HTTP且支持HTTP,直接使用HTTP方式 - if force_http and supports_http_fallback: - _thinking_extra = build_thinking_suppression(provider, model) - base_url_local = None - if custom_provider and custom_provider_config: - base_url_local = custom_provider_config.get('base_url', '') - else: - base_url_local = config.get('base_url', '') + # 读取 Ollama 服务的配置 + from ..config_manager import config_manager + service = config_manager.get_service('ollama') + disable_thinking_enabled = service.get('disable_thinking', True) if service else True + enable_advanced_params = service.get('enable_advanced_params', False) if service else False + filter_thinking_output = service.get('filter_thinking_output', True) if service else True + _ollama_thinking_extra = build_thinking_suppression('ollama', model) if disable_thinking_enabled else None - http_result = await LLMService._http_request_chat_completions( - base_url=base_url_local, - api_key=api_key, + # 提前计算auto_unload配置 + native_base = base_url[:-3] if base_url.endswith('/v1') else (base_url or 'http://localhost:11434') + native_base = native_base.rstrip('/') + _cfg = { + 'auto_unload': custom_provider_config.get('auto_unload', True) if custom_provider_config else config.get('auto_unload', True), + 'base_url': native_base + } + auto_unload = _cfg['auto_unload'] + + result = await LLMService._call_ollama_native( model=model, - messages=[{"role": m["role"], "content": m["content"]} for m in messages], + messages=[{"role": "system", "content": translate_instruction}, {"role": "user", "content": text}], temperature=temperature, top_p=top_p, max_tokens=max_tokens, - thinking_extra=_thinking_extra, + base_url=base_url, stream_callback=stream_callback, request_id=request_id, provider_display_name=provider_display_name, - direct_mode_tag=direct_mode_tag + auto_unload=auto_unload, + enable_advanced_params=enable_advanced_params, + thinking_extra=_ollama_thinking_extra, + cancel_event=cancel_event, + task_type=task_type or TASK_TRANSLATE, + source=source ) - if http_result.get("success"): + if result["success"]: + await LLMService._unload_ollama_model(model, _cfg) + + # 应用思维链输出过滤 + content = result["content"] + if filter_thinking_output: + content = filter_thinking_content(content) + return { "success": True, - "data": { - "from": from_lang, - "to": to_lang, - "original": text, - "translated": http_result.get("content", "") - } + "data": {"original": text, "translated": content} } else: - return {"success": False, "error": http_result.get("error", "HTTP请求失败")} - - # 其余 provider 走 OpenAI 兼容 SDK - # 使用OpenAI SDK - client = LLMService.get_openai_client(api_key, provider) - - # 添加手动重试机制,处理连接错误 - # 修改为重试1次后回退到HTTP(如果支持) - max_retries = 2 # 重试1次(总共2次尝试) - retry_delay = 1.0 # 初始重试延迟(秒) - last_error = None - - for attempt in range(max_retries): - try: - # 添加调试信息 - _thinking_extra = build_thinking_suppression(provider, model) - _thinking_tag = "(已关闭思维链)" if _thinking_extra else "" - from ..server import PROCESS_PREFIX - if attempt > 0: - print(f"{PROCESS_PREFIX} 重试LLM API调用{direct_mode_tag} | 尝试:{attempt + 1}/{max_retries} | 服务:{provider_display_name} | 模型:{model}{_thinking_tag}") - else: - print(f"{PROCESS_PREFIX} 调用LLM API{direct_mode_tag} | 服务:{provider_display_name} | 模型:{model}{_thinking_tag}") - start_time = time.perf_counter() + return result - # 兼容不同提供商:将 reasoning_effort 放到顶层,其它参数放入 extra_body - extra_args = {} - if _thinking_extra: - _extra_copy = dict(_thinking_extra) - if "reasoning_effort" in _extra_copy: - extra_args["reasoning_effort"] = _extra_copy.pop("reasoning_effort") - if _extra_copy: - extra_args["extra_body"] = _extra_copy - - # 使用配置中的参数 - try: - stream = await client.chat.completions.create( - model=model, - messages=[{"role": m["role"], "content": m["content"]} for m in messages], - temperature=temperature, - top_p=top_p, - max_tokens=max_tokens, - stream=True, - response_format={"type": "text"}, - **extra_args - ) - except Exception as e_first: - msg = str(e_first).lower() - if "enable_thinking" in msg and "not support" in msg: - print(f"{PREFIX} 发现 enable_thinking 不被模型支持,移除后重试 | 模型:{model}") - stream = await client.chat.completions.create( - model=model, - messages=[{"role": m["role"], "content": m["content"]} for m in messages], - temperature=temperature, - top_p=top_p, - max_tokens=max_tokens, - stream=True, - response_format={"type": "text"} - ) - else: - raise - - # 请求成功,处理流式响应 - full_content = "" - async for chunk in stream: - # 兼容部分第三方网关返回空 choices 的异常片段,做健壮性判断 - choices = getattr(chunk, "choices", None) or [] - for ch in choices: - delta = getattr(ch, "delta", None) - if not delta: - continue - content = getattr(delta, "content", None) - if content: - full_content += content - if stream_callback: - stream_callback(content) - - # 输出结构化成功日志 - prefix = AUTO_TRANSLATE_PREFIX if is_auto else PREFIX - elapsed_ms = int((time.perf_counter() - start_time) * 1000) - print(f"{prefix} {'工作流翻译完成' if is_auto else '翻译完成'} | 服务:{provider_display_name}翻译 | 请求ID:{request_id} | 结果字符数:{len(full_content)} | 耗时:{elapsed_ms}ms") - - return { - "success": True, - "data": { - "from": from_lang, - "to": to_lang, - "original": text, - "translated": full_content - } - } - - except asyncio.CancelledError: - prefix = AUTO_TRANSLATE_PREFIX if is_auto else PREFIX - print(f"{prefix} {'工作流翻译' if is_auto else '翻译'}任务在服务层被取消 | ID:{request_id}") - return {"success": False, "error": "请求已取消", "cancelled": True} - except Exception as e: - last_error = e - # 检查是否为连接错误 - error_msg = str(e).lower() - is_connection_error = any(keyword in error_msg for keyword in [ - 'connection', 'connect', 'timeout', 'timed out', - 'network', 'unreachable', 'refused' - ]) - - # 如果是最后一次尝试,检查是否需要回退到HTTP - if attempt == max_retries - 1: - # 如果支持HTTP回退,尝试使用HTTP方式 - if supports_http_fallback: - from ..server import PROCESS_PREFIX - print(f"{PROCESS_PREFIX} OpenAI SDK请求失败,使用HTTP方式重试{direct_mode_tag} | 服务:{provider_display_name} | 模型:{model}") - - base_url_local = None - if custom_provider and custom_provider_config: - base_url_local = custom_provider_config.get('base_url', '') - else: - base_url_local = config.get('base_url', '') - - http_result = await LLMService._http_request_chat_completions( - base_url=base_url_local, - api_key=api_key, - model=model, - messages=[{"role": m["role"], "content": m["content"]} for m in messages], - temperature=temperature, - top_p=top_p, - max_tokens=max_tokens, - thinking_extra=_thinking_extra, - stream_callback=stream_callback, - request_id=request_id, - provider_display_name=provider_display_name, - direct_mode_tag=direct_mode_tag - ) - - if http_result.get("success"): - return { - "success": True, - "data": { - "from": from_lang, - "to": to_lang, - "original": text, - "translated": http_result.get("content", "") - } - } - else: - return {"success": False, "error": http_result.get("error", "HTTP请求失败")} - else: - # 不支持HTTP回退,直接返回错误 - return {"success": False, "error": format_api_error(e, provider_display_name)} - - # 如果不是最后一次尝试,且是连接错误,等待后重试 - if is_connection_error: - wait_time = retry_delay * (2 ** attempt) - from ..server import WARN_PREFIX - print(f"{WARN_PREFIX} LLM API连接失败 | 尝试:{attempt + 1}/{max_retries} | 错误:{str(e)[:100]} | {wait_time:.1f}秒后重试") - await asyncio.sleep(wait_time) - continue - else: - # 不是连接错误,直接抛出 - raise + # 其他服务走HTTP直连 + if not base_url: + base_url = LLMService.get_provider_base_url(provider, custom_provider_config if custom_provider else None) - # 如果所有尝试都失败且没有回退到HTTP,返回错误 - if last_error: - return {"success": False, "error": format_api_error(last_error, provider_display_name)} + # 检查disable_thinking、enable_advanced_params和filter_thinking_output配置 + from ..config_manager import config_manager + service = config_manager.get_service(provider) + disable_thinking_enabled = service.get('disable_thinking', True) if service else True + enable_advanced_params = service.get('enable_advanced_params', False) if service else False + filter_thinking_output = service.get('filter_thinking_output', True) if service else True + thinking_extra = build_thinking_suppression(provider, model) if disable_thinking_enabled else None + + result = await LLMService._http_request_chat_completions( + base_url=base_url, + api_key=api_key, + model=model, + messages=messages, + temperature=temperature, + top_p=top_p, + max_tokens=max_tokens, + thinking_extra=thinking_extra, + enable_advanced_params=enable_advanced_params, + stream_callback=stream_callback, + request_id=request_id, + provider_display_name=provider_display_name, + cancel_event=cancel_event, + task_type=task_type or TASK_TRANSLATE, + source=source + ) + + if result["success"]: + # 根据配置决定是否应用思维链输出过滤 + content = result["content"] + if filter_thinking_output: + content = filter_thinking_content(content) + return { + "success": True, + "data": {"original": text, "translated": content} + } else: - return {"success": False, "error": "未知错误"} + return result - except asyncio.CancelledError: - from ..server import PREFIX, AUTO_TRANSLATE_PREFIX - prefix = AUTO_TRANSLATE_PREFIX if is_auto else PREFIX - print(f"{prefix} {'工作流翻译' if is_auto else '翻译'}任务在服务层被取消 | ID:{request_id}") - return {"success": False, "error": "请求已取消", "cancelled": True} except Exception as e: - return {"success": False, "error": str(e)} \ No newline at end of file + return {"success": False, "error": format_api_error(e, "LLM服务")} \ No newline at end of file diff --git a/services/model_list.py b/services/model_list.py index 33f5eed..25c23fc 100644 --- a/services/model_list.py +++ b/services/model_list.py @@ -1,433 +1,241 @@ """ 模型列表服务 -负责从不同的服务提供商获取可用的模型列表 +支持动态API获取和预定义模型列表 """ +import httpx +from typing import Dict, List -import logging -import requests -from typing import Dict, List, Optional +# 导入统一的日志前缀 +try: + from ..utils.common import ERROR_PREFIX +except ImportError: + # 如果导入失败,使用默认值 + ERROR_PREFIX = "✨-错误" -logger = logging.getLogger(__name__) +# --- 智谱AI预定义模型列表 --- +ZHIPU_MODELS = [ + "glm-4.5-flash", + "glm-4-flash-250414", + "glm-4-flash", + "glm-z1-flash", + "glm-4.6v-flash", + "glm-4v-flash", + "glm-4.6", + "glm-4.6v", + "glm-4.5", + "glm-4.5v", + "glm-4.5-airx", + "glm-4-air-250414", + "glm-4-plus", + "glm-4.5v", + "glm-4.1v-thinking-flash", + "glm-4v", +] +def get_models_from_service(base_url: str, api_key: str, service_type: str) -> Dict: -class ModelListService: - """模型列表服务类""" + """ + 从服务提供商动态获取模型列表 - # 智谱官方模型列表(手动维护) - # 注意:智谱官方不提供模型列表API,需要手动维护 - # 来源:https://docs.bigmodel.cn/ - ZHIPU_MODELS = { - 'llm': [ - # GLM-4 系列 - 'glm-4-flash-250414', - 'glm-4-flash', - 'glm-4-plus', - 'glm-4-air-250414', - # GLM-4.5 系列(新) - 'GLM-4.5-Flash', - 'GLM-4.5-AirX', - 'glm-4.5', - 'glm-4.6', - ], - 'vision': [ - # GLM-4V 系列 - 'GLM-4V-Flash', - 'GLM-4V', - 'glm-4v-plus-0111', - # GLM-4.xV 系列(新) - 'GLM-4.5V', - 'glm-4.1v-thinking-flash', - ] - } + 参数: + base_url: API基础URL + api_key: API密钥 + service_type: 服务类型 ('openai_compatible', 'ollama', 'zhipu') - # 推荐模型列表(精选的常用模型) - RECOMMENDED_MODELS = { - 'zhipu': { - 'llm': [ - 'GLM-4.5-Flash', - 'glm-4-flash-250414' - ], - 'vision': [ - 'glm-4v-flash', - 'GLM-4.5V' - ] - }, - 'siliconflow': { - 'llm': [ - 'Qwen/Qwen3-8B', - 'Qwen/Qwen2.5-7B-Instruct', - 'Qwen/Qwen2-7B-Instruct', - 'deepseek-ai/DeepSeek-R1-0528-Qwen3-8B', - 'deepseek-ai/DeepSeek-R1-Distill-Qwen-7B' - ], - 'vision': [ - 'THUDM/GLM-4.1V-9B-Thinking', - 'Qwen/Qwen3-VL-30B-A3B-Instruct', - 'Pro/Qwen/Qwen2.5-VL-7B-Instruct' - ] - }, - '302ai': { - 'llm': [ - 'gpt-5-pro', - 'gpt-5-nano-2025-08-07', - 'gemini-2.5-flash-nothink', - 'gemini-2.5-pro', - 'doubao-seed-1-6-250615' - ], - 'vision': [ - 'grok-2-vision-1212', - 'grok-4-fast-non-reasoning', - 'gemini-2.5-flash-nothink', - 'gemini-2.5-pro' - ] + 返回: + Dict: { + "success": bool, + "models": {"llm": [...], "vlm": [...]}, # success=True时 + "error": str # success=False时的错误信息 } - } - - # 各服务提供商的API端点 - ENDPOINTS = { - 'zhipu': { - 'base_url': 'https://open.bigmodel.cn/api/paas/v4', - 'models_path': '/models' - }, - 'siliconflow': { - 'base_url': 'https://api.siliconflow.cn/v1', - 'models_path': '/models' - }, - '302ai': { - 'base_url': 'https://api.302.ai/v1', - 'models_path': '/models' + """ + try: + # 检查必需参数 + if not base_url: + return { + "success": False, + "error": "请填写Base URL" + } + + # 智谱使用预定义列表,不需要API Key验证 + if service_type == 'zhipu': + return _get_zhipu_models() + + if service_type == 'openai_compatible' and not api_key: + return { + "success": False, + "error": "请填写API Key" + } + + # 根据服务类型调用不同的获取方法 + if service_type == 'ollama': + return _fetch_ollama_models(base_url) + else: # openai_compatible + return _fetch_openai_compatible_models(base_url, api_key) + + except Exception as e: + print(f"{ERROR_PREFIX} 获取模型列表异常: {str(e)}") + return { + "success": False, + "error": f"获取失败: {str(e)}" } - } - - @staticmethod - def get_recommended_models(provider: str, model_type: str = 'llm') -> Dict: - """ - 获取推荐模型列表(静态预设) - - Args: - provider: 服务提供商 (zhipu, siliconflow, 302ai) - model_type: 模型类型 ('llm' 或 'vision') - - Returns: - 包含推荐模型列表的字典 - """ - try: - if provider not in ModelListService.RECOMMENDED_MODELS: - return { - 'success': False, - 'error': f'提供商 {provider} 暂无推荐模型列表' - } - - model_ids = ModelListService.RECOMMENDED_MODELS.get(provider, {}).get(model_type, []) - models = [{'id': model_id, 'name': model_id} for model_id in model_ids] - - return { - 'success': True, - 'models': models - } - except Exception as e: - logger.error(f"获取推荐模型列表失败: {str(e)}") - return { - 'success': False, - 'error': f'获取推荐模型列表失败: {str(e)}' - } - - @staticmethod - def get_models(provider: str, api_key: str, model_type: str = 'llm', base_url: str = None) -> Dict: - """ - 获取指定服务提供商的模型列表 - - Args: - provider: 服务提供商 (zhipu, siliconflow, 302ai, custom) - api_key: API密钥 - model_type: 模型类型 ('llm' 或 'vision') - base_url: 自定义服务的base_url(仅用于custom) - - Returns: - 包含模型列表的字典 - """ - if provider == 'ollama': - return ModelListService._get_ollama_models(model_type) - - # 自定义服务:使用OpenAI标准接口 - if provider == 'custom': - return ModelListService._get_custom_models(api_key, base_url, model_type) - - # 智谱官方不提供模型列表API,使用静态列表 - if provider == 'zhipu': - return ModelListService._get_zhipu_static_models(model_type) - - if provider not in ModelListService.ENDPOINTS: - return { - 'success': False, - 'error': f'不支持的服务提供商: {provider}' - } - - if not api_key or api_key.strip() == '': - return { - 'success': False, - 'error': 'API Key为空,无法获取模型列表' - } - - endpoint = ModelListService.ENDPOINTS[provider] - url = f"{endpoint['base_url']}{endpoint['models_path']}" - + + +def _fetch_openai_compatible_models(base_url: str, api_key: str) -> Dict: + """获取OpenAI兼容API的模型列表""" + try: + url = f"{base_url.rstrip('/')}/models" headers = { - 'Authorization': f'Bearer {api_key}', - 'Content-Type': 'application/json' + "Authorization": f"Bearer {api_key}", + "Accept": "application/json", + "Content-Type": "application/json" } - params = {} + response = httpx.get(url, headers=headers, timeout=10.0) - # 根据不同的服务提供商和模型类型设置参数 - if provider == 'siliconflow': - if model_type == 'llm': - params['sub_type'] = 'chat' - elif model_type == 'vision': - # 硅基流动的视觉模型需要通过chat类型过滤,后续手动筛选支持图像的模型 - params['sub_type'] = 'chat' - elif provider == '302ai': - if model_type == 'llm': - params['llm'] = '1' - elif model_type == 'vision': - params['vision'] = '1' - - try: - response = requests.get(url, headers=headers, params=params, timeout=10) - - if response.status_code == 200: - data = response.json() - models = ModelListService._parse_models(data, provider, model_type) - return { - 'success': True, - 'models': models - } - else: - error_msg = f'获取模型列表失败: HTTP {response.status_code}' - try: - error_data = response.json() - if 'error' in error_data: - error_msg = error_data['error'].get('message', error_msg) - elif 'message' in error_data: - error_msg = error_data['message'] - except: - pass - - logger.error(f"获取{provider}模型列表失败: {error_msg}") - return { - 'success': False, - 'error': error_msg - } - - except requests.exceptions.Timeout: + if response.status_code == 401: return { - 'success': False, - 'error': '请求超时,请检查网络连接' + "success": False, + "error": "API Key错误,认证失败" } - except requests.exceptions.RequestException as e: - logger.error(f"获取{provider}模型列表异常: {str(e)}") + elif response.status_code == 404: return { - 'success': False, - 'error': f'网络请求失败: {str(e)}' + "success": False, + "error": "API地址错误,未找到模型接口" } - except Exception as e: - logger.error(f"获取{provider}模型列表时发生未知错误: {str(e)}") + elif response.status_code != 200: return { - 'success': False, - 'error': f'未知错误: {str(e)}' - } - - @staticmethod - def _get_custom_models(api_key: str, base_url: str, model_type: str = 'llm') -> Dict: - """ - 获取自定义服务的模型列表(OpenAI标准) - - Args: - api_key: API密钥 - base_url: 自定义服务的base_url - model_type: 模型类型 ('llm' 或 'vision') - - Returns: - 包含模型列表的字典 - """ - if not base_url or base_url.strip() == '': - return { - 'success': False, - 'error': '自定义服务需要配置Base URL才能获取模型列表' + "success": False, + "error": f"API返回错误 (HTTP {response.status_code})" } - # 确保base_url不以/结尾 - base_url = base_url.rstrip('/') + data = response.json() + models = data.get('data', []) - # 构建完整的URL(OpenAI标准) - url = f"{base_url}/models" + if not models: + return { + "success": False, + "error": "未找到任何可用模型" + } - headers = { - 'Content-Type': 'application/json' + # 提取模型ID并返回(LLM和VLM返回相同列表) + model_ids = [m['id'] for m in models if 'id' in m] + + return { + "success": True, + "models": { + "llm": model_ids.copy(), + "vlm": model_ids.copy() + } } - # 只有在API Key存在时才添加认证头 - if api_key and api_key.strip(): - headers['Authorization'] = f'Bearer {api_key}' + except httpx.TimeoutException: + return { + "success": False, + "error": "请求超时,请检查网络连接" + } + except httpx.ConnectError: + return { + "success": False, + "error": "无法连接到服务,请检查Base URL" + } + except Exception as e: + print(f"{ERROR_PREFIX} 获取OpenAI兼容模型失败: {str(e)}") + return { + "success": False, + "error": f"获取模型列表失败: {str(e)}" + } + + +def _fetch_ollama_models(base_url: str) -> Dict: + """获取Ollama的模型列表""" + try: + # Ollama 原生 API 在根路径,需要移除可能存在的 /v1 后缀 + clean_url = base_url.rstrip('/') + if clean_url.endswith('/v1'): + clean_url = clean_url[:-3] - try: - response = requests.get(url, headers=headers, timeout=10) - - if response.status_code == 200: - data = response.json() - models = ModelListService._parse_models(data, 'custom', model_type) - return { - 'success': True, - 'models': models - } - else: - error_msg = f'获取模型列表失败: HTTP {response.status_code}' - try: - error_data = response.json() - if 'error' in error_data: - error_msg = error_data['error'].get('message', error_msg) - elif 'message' in error_data: - error_msg = error_data['message'] - except: - pass - - logger.error(f"获取自定义服务模型列表失败: {error_msg}") - return { - 'success': False, - 'error': error_msg - } - - except requests.exceptions.Timeout: + url = f"{clean_url}/api/tags" + + # 添加必需的请求头(参考 ollama-python SDK) + import platform + headers = { + 'Accept': 'application/json', + 'Content-Type': 'application/json', + 'User-Agent': f'comfyui-prompt-assistant/1.0 ({platform.machine()} {platform.system().lower()}) Python/{platform.python_version()}' + } + + response = httpx.get(url, headers=headers, timeout=10.0) + + if response.status_code == 404: return { - 'success': False, - 'error': '请求超时,请检查Base URL是否正确' + "success": False, + "error": "Ollama服务未启动或Base URL错误" } - except requests.exceptions.ConnectionError: + elif response.status_code == 400: + error_detail = response.text[:500] if response.text else "无响应体" return { - 'success': False, - 'error': '连接失败,请检查Base URL和网络连接' + "success": False, + "error": f"Ollama返回400错误。详情: {error_detail}" } - except requests.exceptions.RequestException as e: - logger.error(f"获取自定义服务模型列表异常: {str(e)}") + elif response.status_code != 200: return { - 'success': False, - 'error': f'网络请求失败: {str(e)}' + "success": False, + "error": f"Ollama返回错误 (HTTP {response.status_code}): {response.text[:200]}" } - except Exception as e: - logger.error(f"获取自定义服务模型列表时发生未知错误: {str(e)}") - return { - 'success': False, - 'error': f'未知错误: {str(e)}' - } - - @staticmethod - def _get_zhipu_static_models(model_type: str = 'llm') -> Dict: - """ - 获取智谱静态模型列表 - 注意:智谱官方不提供模型列表API,这里使用手动维护的静态列表 - Args: - model_type: 模型类型 ('llm' 或 'vision') - - Returns: - 包含模型列表的字典 - """ - try: - model_ids = ModelListService.ZHIPU_MODELS.get(model_type, []) - models = [{'id': model_id, 'name': model_id} for model_id in model_ids] - - return { - 'success': True, - 'models': models - } - except Exception as e: - logger.error(f"获取智谱静态模型列表失败: {str(e)}") - return { - 'success': False, - 'error': f'获取智谱模型列表失败: {str(e)}' - } - - @staticmethod - def _get_ollama_models(model_type: str = 'llm') -> Dict: - """ - 获取Ollama本地模型列表 + data = response.json() + models = data.get('models', []) - Args: - model_type: 模型类型 ('llm' 或 'vision') - - Returns: - 包含模型列表的字典 - """ - try: - url = 'http://localhost:11434/api/tags' - response = requests.get(url, timeout=5) - - if response.status_code == 200: - data = response.json() - models = [] - - if 'models' in data: - for model in data['models']: - model_name = model.get('name', '') - # 不做筛选,返回所有模型 - models.append({ - 'id': model_name, - 'name': model_name - }) - - return { - 'success': True, - 'models': models - } - else: - return { - 'success': False, - 'error': 'Ollama服务未运行或无法连接' - } - - except requests.exceptions.ConnectionError: - return { - 'success': False, - 'error': 'Ollama服务未运行,请先启动Ollama' - } - except Exception as e: - logger.error(f"获取Ollama模型列表失败: {str(e)}") + if not models: return { - 'success': False, - 'error': f'获取Ollama模型列表失败: {str(e)}' + "success": False, + "error": "未找到任何Ollama模型" } - - @staticmethod - def _parse_models(data: Dict, provider: str, model_type: str) -> List[Dict]: - """ - 解析不同服务提供商的模型列表数据 - 不做任何筛选,API返回什么就显示什么 - Args: - data: API返回的原始数据 - provider: 服务提供商 - model_type: 模型类型 - - Returns: - 标准化的模型列表 - """ - models = [] + # 提取模型名称并返回(LLM和VLM返回相同列表) + model_names = [m['name'] for m in models if 'name' in m] - if 'data' not in data: - return models - - for model in data['data']: - model_id = model.get('id', '') - - # 跳过非模型项 - if not model_id: - continue - - # 直接添加到模型列表,不做任何筛选 - models.append({ - 'id': model_id, - 'name': model_id - }) + return { + "success": True, + "models": { + "llm": model_names.copy(), + "vlm": model_names.copy() + } + } - return models + except httpx.TimeoutException: + return { + "success": False, + "error": "请求超时,Ollama可能未启动" + } + except httpx.ConnectError as e: + print(f"{ERROR_PREFIX} 无法连接到Ollama服务: {str(e)}") + return { + "success": False, + "error": "无法连接到Ollama服务" + } + except Exception as e: + print(f"{ERROR_PREFIX} 获取Ollama模型失败: {str(e)}") + import traceback + traceback.print_exc() + return { + "success": False, + "error": f"获取Ollama模型失败: {str(e)}" + } + + +def _get_zhipu_models() -> Dict: + """ + 获取智谱AI的预定义模型列表 + 智谱AI暂不提供公开的模型列表API,使用预定义列表 + """ + return { + "success": True, + "models": { + "llm": ZHIPU_MODELS.copy(), + "vlm": ZHIPU_MODELS.copy() + } + } + diff --git a/services/openai_base.py b/services/openai_base.py new file mode 100644 index 0000000..2785a7f --- /dev/null +++ b/services/openai_base.py @@ -0,0 +1,478 @@ +""" +OpenAI兼容服务基类 +为LLM和VLM服务提供统一的OpenAI兼容API处理逻辑 +""" + +import json +import time +import asyncio +import httpx +from typing import Optional, Dict, Any, List, Callable +from .core import BaseAPIService, HTTPClientPool +from ..utils.common import ( + format_api_error, ProgressBar, log_complete, log_error, + PREFIX, PROCESS_PREFIX, WARN_PREFIX, ERROR_PREFIX, format_elapsed_time +) +from .thinking_control import build_thinking_suppression +import re + + +# ==================== 思维链输出过滤 ==================== + +def filter_thinking_content(text: str) -> str: + """ + 过滤模型输出中的思维链内容 + 支持多种标签格式:, , + + 参数: + text: 原始模型输出文本 + + 返回: + str: 过滤后的文本 + """ + if not text: + return text + + # 1. 优先匹配成对的思维链标签 + # 匹配 ... 等成对结构 + pattern_pair = r'<(think|thinking|reasoning|thoughts?)>[\s\S]*?' + text = re.sub(pattern_pair, '', text, flags=re.IGNORECASE) + + # 2. 兜底处理:如果还有残留的结束标签(可能缺少开始标签),则移除该标签及其之前的所有内容 + # 假设:思考过程总是出现在回答的最前面 + pattern_orphan_end = r'^[\s\S]*?' + text = re.sub(pattern_orphan_end, '', text, flags=re.IGNORECASE) + + return text.strip() + + +class OpenAICompatibleService(BaseAPIService): + """ + OpenAI兼容API服务基类 + 处理所有OpenAI格式的API请求(智谱、硅基流动、302.ai、Ollama等) + """ + + # ---已知的API端点路径(用于智能检测)--- + _known_endpoints = ['/chat/completions', '/v1/messages', '/completions'] + + @staticmethod + def parse_api_url(raw_url: str) -> str: + """ + 智能解析 base_url,生成最终请求地址 + + 规则: + 1. '#' 结尾 → 强制使用完整地址(移除#) + 2. 已包含已知端点路径 → 直接使用,不再拼接 + 3. 其他 → 正常拼接 /chat/completions + + 参数: + raw_url: 用户输入的原始URL + + 返回: + str: 最终请求地址 + """ + if not raw_url: + return '' + + url = raw_url.strip() + + # 规则1:井号强制模式 - 用户明确要求使用完整地址 + if url.endswith('#'): + return url[:-1].rstrip('/') + + # 规则2:智能检测 - 检查URL中是否已包含已知的API端点 + for endpoint in OpenAICompatibleService._known_endpoints: + if endpoint in url: + # 已包含完整端点,直接返回(移除末尾斜杠) + return url.rstrip('/') + + # 规则3:常规模式 - 需要拼接 /chat/completions + return url.rstrip('/') + '/chat/completions' + + # _provider_base_urls 和 _provider_display_names 已移除,相关逻辑改由 config_manager 统一管理 + + @staticmethod + def _filter_payload(payload: Dict[str, Any], level: int) -> Dict[str, Any]: + """ + 根据重试级别清洗请求体 (简化的三级降级策略) + + Level 0: 完整请求 (按用户设置发送) + Level 1: 移除思维链参数 (thinking, enable_thinking, reasoning_effort, 等) + Level 2: 最小可用集 (仅 model, messages, stream) + """ + if level <= 0: + return payload.copy() + + filtered = payload.copy() + + # Level 1: 移除思维链参数 + thinking_keys = [ + "thinking", "enable_thinking", "reasoning_effort", + "reasoning", "thinking_level", "think" + ] + for k in thinking_keys: + filtered.pop(k, None) + + if level >= 2: + # Level 2: 最小可用集 - 仅保留必选参数 + core_keys = ["model", "messages", "stream"] + filtered = {k: filtered[k] for k in core_keys if k in filtered} + + return filtered + + @staticmethod + def _merge_system_prompts(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """ + 合并多条System Message为单一条目 + 并将System Message置于列表首位 + 解决部分服务商不支持多条System Message的问题 + """ + system_contents = [] + other_messages = [] + + for msg in messages: + if msg.get('role') == 'system': + content = msg.get('content', '') + if content: + system_contents.append(content) + else: + other_messages.append(msg) + + if not system_contents: + return messages + + # 合并内容 + merged_system = "\n\n".join(system_contents) + + # 构建新列表:System在首位 + 其他消息 + return [{"role": "system", "content": merged_system}] + other_messages + + @classmethod + async def _http_request_chat_completions( + cls, + base_url: str, + api_key: str, + model: str, + messages: List[Dict[str, Any]], + temperature: float = 0.7, + top_p: float = 0.9, + max_tokens: int = 2000, + thinking_extra: Optional[Dict[str, Any]] = None, + enable_advanced_params: bool = False, + stream_callback: Optional[Callable[[str], None]] = None, + request_id: Optional[str] = None, + provider_display_name: str = "未知服务", + cancel_event: Optional[Any] = None, + task_type: str = None, + source: str = None + ) -> Dict[str, Any]: + """ + 使用HTTP直连调用/chat/completions接口 + 统一处理所有OpenAI兼容的服务商 (支持三级降级重试) + + 参数: + enable_advanced_params: 是否发送高级参数(temperature/top_p/max_tokens) + """ + from ..server import is_streaming_progress_enabled + + try: + # 构建请求URL + url = cls.parse_api_url(base_url) + + # 预处理:合并System Prompts (Level 0 默认应用) + merged_messages = cls._merge_system_prompts(messages) + + # 构建基础请求体 (仅必选参数) + initial_payload = { + "model": model, + "messages": merged_messages, + "stream": True + } + + # 仅在用户开启"启用高级参数"时才发送 temperature、top_p、max_tokens + if enable_advanced_params: + initial_payload["temperature"] = temperature + initial_payload["top_p"] = top_p + initial_payload["max_tokens"] = max_tokens + + # 添加思维链控制参数 + if thinking_extra: + initial_payload.update(thinking_extra) + + # 构建请求头 + headers = {"Content-Type": "application/json"} + if api_key and api_key.strip(): + headers["Authorization"] = f"Bearer {api_key}" + + # 获取HTTP客户端 + client = HTTPClientPool.get_client( + provider=provider_display_name, + base_url=base_url, + timeout=60.0 + ) + + # 前置中断检查:如果 ComfyUI 已经中断了,不启动请求 + from server import PromptServer + if hasattr(PromptServer.instance, 'execution_interrupted') and PromptServer.instance.execution_interrupted: + return {"success": False, "error": "任务被中断", "interrupted": True} + + # 创建统一进度条(自动处理等待→生成→完成的完整生命周期) + pbar = ProgressBar( + request_id=request_id, + service_name=provider_display_name, + streaming=is_streaming_progress_enabled(), + task_type=task_type, + source=source + ) + + start_time = time.perf_counter() + last_error_msg = "" + + # 三级降级重试循环 (Level 0 -> Level 2) + for retry_level in range(3): + current_payload = cls._filter_payload(initial_payload, retry_level) + + # 如果不是Level 0,打印降级重试警告(换行输出) + if retry_level > 0: + removed_keys = set(initial_payload.keys()) - set(current_payload.keys()) + removed_str = ", ".join(removed_keys) if removed_keys else "无参数变动" + print(f"\n{WARN_PREFIX} ⚠️ HTTP 400错误, 触发Level-{retry_level}降级重试 | 服务:{provider_display_name} | 移除参数:[{removed_str}]", flush=True) + + # 关键修复:停止旧的进度条后再创建新的,防止线程泄漏 + if pbar: + pbar._closed = True + pbar._stop_event.set() + + # 重新创建进度条用于新一轮重试 + pbar = ProgressBar( + request_id=request_id, + service_name=provider_display_name, + extra_info=f"Retry-{retry_level}", + streaming=is_streaming_progress_enabled(), + task_type=task_type, + source=source + ) + + async def _do_stream_request(): + nonlocal pbar + + # 定义请求核心逻辑 + async def _request_core(): + async with client.stream('POST', url, headers=headers, json=current_payload, follow_redirects=True) as response: + if response.status_code != 200: + error_text = await response.aread() + try: + error_data = json.loads(error_text) + msg = error_data.get('error', {}).get('message', f'HTTP {response.status_code}') + except: + msg = f'HTTP {response.status_code}: {error_text.decode("utf-8", errors="ignore")[:200]}' + + # 智能识别认证错误 + from ..utils.common import _is_auth_error + if response.status_code == 401 or _is_auth_error(msg.lower()): + msg = "API Key无效或缺失" + + return { + "success": False, + "error": msg, + "status_code": response.status_code, + "should_retry": response.status_code == 400 + } + + full_content = "" + reasoning_content = "" + + async for line in response.aiter_lines(): + # 此处的循环检查依然保留,作为双重保险 + if cancel_event is not None and cancel_event.is_set(): + raise asyncio.CancelledError() + + if not line or line == "data: [DONE]" or line == "data:[DONE]": continue + if line.startswith("data: "): line = line[6:] + elif line.startswith("data:"): line = line[5:] + + try: + chunk = json.loads(line) + if chunk.get('choices'): + delta = chunk['choices'][0].get('delta', {}) + content = delta.get('content', '') or '' + reasoning = ( + delta.get('reasoning_content', '') or + delta.get('reasoning', '') or + delta.get('thinking', '') or '' + ) + if reasoning: reasoning_content += reasoning + if content: + full_content += content + if stream_callback: stream_callback(content) + pbar.set_generating(len(full_content)) + pbar.update(len(full_content)) + except: + continue + + final_content = full_content + if reasoning_content: + final_content = f"{reasoning_content}\n{full_content}" + + elapsed_ms = int((time.perf_counter() - start_time) * 1000) + if not final_content.strip(): + pbar.error("响应内容为空") + else: + pbar.done(char_count=len(final_content), elapsed_ms=elapsed_ms) + + return {"success": True, "content": final_content} + + # 定义监视器逻辑:每100ms检查一次中断信号 + async def _monitor_interrupts(target_task): + while not target_task.done(): + is_interrupted = False + if cancel_event is not None and cancel_event.is_set(): + is_interrupted = True + else: + try: + from server import PromptServer + if hasattr(PromptServer.instance, 'execution_interrupted') and PromptServer.instance.execution_interrupted: + is_interrupted = True + except: + pass + + if is_interrupted: + target_task.cancel() + return True + await asyncio.sleep(0.1) + return False + + # 并发运行请求和监视器 + req_task = asyncio.create_task(_request_core()) + monitor_task = asyncio.create_task(_monitor_interrupts(req_task)) + + try: + result = await req_task + return result + except asyncio.CancelledError: + pbar.cancel(f"{WARN_PREFIX} 任务被中断 | 服务:{provider_display_name}") + return {"success": False, "error": "中断", "interrupted": True} + finally: + if not monitor_task.done(): + monitor_task.cancel() + + # 执行请求 + try: + result = await _do_stream_request() + except Exception as req_err: + last_error_msg = str(req_err) + # 网络层面的异常(非HTTP响应),通常不适合通过参数降级解决,除非确认是特定的协议问题 + # 这里选择继续抛出或作为错误返回,不盲目重试 + # 但为了稳健,如果是非连接已建立后的错误,可以选择不重试 + # 为简单起见,仅记录错误 + return {"success": False, "error": f"网络请求异常: {req_err}"} + + # 检查结果 + if result["success"]: + # Ollama 服务成功后尝试卸载模型 + if provider_display_name.lower().find("ollama") != -1: + try: + from ..config_manager import config_manager + service_config = config_manager.get_service(provider_display_name) or {} + await cls._unload_ollama_model(model, service_config) + except: + pass + return result + + if result.get("interrupted"): + return result + + last_error_msg = result["error"] + + # 只有 should_retry 为 True (HTTP 400) 且还有重试机会时,才继续循环 + if not result.get("should_retry"): + break # 非400错误(如401, 500等),不进行降级重试,直接返回错误 + + # 所有重试耗尽或非可重试错误 + if 'pbar' in locals() and pbar: + pbar.error(last_error_msg) + return {"success": False, "error": last_error_msg} + + # 关键修复:单独捕获 CancelledError,确保进度条被正确停止 + except asyncio.CancelledError: + if 'pbar' in locals() and pbar: + pbar.cancel(f"{WARN_PREFIX} 任务被外部取消 | 服务:{provider_display_name}") + return {"success": False, "error": "任务被取消", "interrupted": True} + + except Exception as e: + if 'pbar' in locals() and pbar: + pbar.error(format_api_error(e, provider_display_name)) + return {"success": False, "error": format_api_error(e, provider_display_name)} + + @staticmethod + async def _unload_ollama_model(model: str, provider_config: Dict[str, Any]): + """ + 卸载Ollama模型以释放显存和内存 + + 参数: + model: 模型名称 + provider_config: 提供商配置字典 + """ + try: + # 检查是否启用自动释放 + auto_unload = provider_config.get('auto_unload', True) + if not auto_unload: + from ..utils.common import PROCESS_PREFIX + print(f"{PROCESS_PREFIX} Ollama模型已保留 | 模型:{model}") + return + + # 获取base_url + base_url = provider_config.get('base_url', 'http://localhost:11434') + if base_url.endswith('/v1'): + base_url = base_url[:-3] + + # 调用Ollama API卸载模型 + url = f"{base_url}/api/generate" + payload = { + "model": model, + "keep_alive": 0 + } + + # 创建临时客户端(卸载操作不需要复用) + async with httpx.AsyncClient(timeout=5.0) as client: + response = await client.post(url, json=payload) + if response.status_code == 200: + from ..utils.common import PROCESS_PREFIX + print(f"{PROCESS_PREFIX} Ollama模型已释放 | 模型:{model}") + + except Exception as e: + from ..utils.common import WARN_PREFIX + print(f"{WARN_PREFIX} Ollama模型释放失败(不影响结果) | 模型:{model} | 错误:{str(e)[:50]}") + + @classmethod + def get_provider_display_name(cls, provider: str) -> str: + """ + 获取提供商显示名称 + 优先从config_manager获取服务的真实名称,兜底使用provider key + """ + # 优先尝试从config_manager获取服务名称 + try: + from ..config_manager import config_manager + service = config_manager.get_service(provider) + if service and 'name' in service: + return service['name'] + except Exception: + pass + + # 兜底直接返回key + return provider + + @classmethod + def get_provider_base_url(cls, provider: str, config: Optional[Dict[str, Any]] = None) -> Optional[str]: + """ + 获取提供商的base_url + 仅用于custom provider的逻辑,其他情况应直接从config获取 + """ + if provider == 'custom' and config: + base_url = config.get('base_url') + # 确保base_url不以/chat/completions结尾 + if base_url and base_url.endswith('/chat/completions'): + base_url = base_url[:-len('/chat/completions')] + return base_url + + return None diff --git a/services/thinking_control.py b/services/thinking_control.py index d14609f..7d396a4 100644 --- a/services/thinking_control.py +++ b/services/thinking_control.py @@ -1,151 +1,242 @@ +""" +思维链控制模块 - 重构版 +统一管理各模型的思维链/推理模式关闭参数 + +设计原则: +- 只认模型特征,不认服务商(Ollama除外,因其使用原生API) +- 按参数类型分组,而非按服务商分组 +- 支持主流平台:智谱/Qwen/DeepSeek/Gemini/Grok/Ollama +- 保留400错误自动降级机制(在openai_base.py中) +""" + import re -from typing import Dict, Any +from typing import Dict, Any, List -""" -一级注释(模块级): -该模块用于统一管理各服务商与模型的“思维链/思考模式”关闭参数, -基于白名单匹配规则,在调用 OpenAI 兼容的 /chat/completions 接口时 -通过 extra_body 传递相应字段,以减少推理 token、提升响应速度,避免不必要的思维链输出。 -设计目标: -- 只在明确支持关闭思维链的“服务商+模型”组合上生效(白名单策略) -- 统一对外暴露 build_thinking_suppression(provider, model) -> Dict[str, Any] -- 方便后续拓展(例如新增 Gemini、ChatGPT 具体控制方式) -""" +# ==================== 一级:通用模型规则(与服务商无关) ==================== +# 按参数类型分组,遍历匹配 -# 二级注释(子模块/数据):白名单规则定义 -# 说明:使用尽量稳健的模式匹配,避免误伤其它模型 -_ZHIPU_PATTERNS = [ - # GLM-4.5 系列(文本/多模态)支持 thinking.type: disabled - # 官方文档示例:https://docs.bigmodel.cn/api-reference/%E6%A8%A1%E5%9E%8B-api/%E5%AF%B9%E8%AF%9D%E8%A1%A5%E5%85%A8 - r"glm-4\.5", # 如 glm-4.5、glm-4.5-XXX - r"glm-4\.5v" # GLM-4.5V(视觉) -] - -_SILICONFLOW_PATTERNS = [ - # 文档:https://docs.siliconflow.cn/cn/api-reference/chat-completions/chat-completions - # enable_thinking 适用以下模型(列举核心关键词以适配大小写和路径差异): - # 注意:排除显式的 "thinking" 变体(这些通常固定为思考模型,不支持该参数) - r"qwen3(?!.*thinking)", # Qwen/Qwen3-* 非 Thinking 变体(包含 qwen3-vl) - r"qwen.*vl", # Qwen-VL 系列(明确匹配视觉模型) - r"deepseek-v3\.1|deepseek-v3", # deepseek-ai/DeepSeek-V3.1 等 - r"hunyuan-a13b-instruct", # tencent/Hunyuan-A13B-Instruct - r"glm-4\.5v" # zai-org/GLM-4.5V -] - -_GEMINI_PATTERNS = [ - # Google Gemini(OpenAI 兼容层):支持 reasoning_effort='none' 来禁用思考 - # 参考:https://ai.google.dev/gemini-api/docs/openai?hl=zh-cn - r"gemini" -] - -# Gemini 2.5 Pro:官方说明推理功能无法关闭,避免下发 reasoning_effort='none' -_GEMINI_25_PRO_CANNOT_DISABLE = [ - r"gemini[-_/. ]?2\.5[-_/. ]?pro" -] - -# 自定义(custom)提供商下,常见"推理/思维链模型"识别规则 -# 三级注释(流程/逻辑):这些多为 OpenAI 兼容网关汇聚的模型,通常接受 -# extra_body={"reasoning":{"effort":"none"}} 来抑制思维链。 -# 为避免误伤,仅针对明确包含以下关键词的"推理模型"生效。 -_ALI_REASONING_PATTERNS = [ - r"qwen[-_/. ]?r1", # 如 qwen2.5-r1, qwen-r1 - r"qwen.*thinking", # Qwen Thinking 变体 - r"qwen3.*vl|qwen.*vl", # Qwen3-VL 系列(视觉理解模型,支持关闭思维链) - r"qwen[-_/. ]?vl" # Qwen-VL 通用匹配(兼容各种命名格式) -] - -_DEEPSEEK_REASONING_PATTERNS = [ - r"deepseek[-_/. ]?r1", # deepseek-r1 / r1-distill 等 - r"deepseek.*reason" # 其它显式带 reason 的变体 -] - -# Ollama 服务支持的模型模式 -# 文档:Ollama 支持通过 think: false 参数关闭思维链 -# 参考:https://www.53ai.com/news/LargeLanguageModel/2025070537250.html -_OLLAMA_PATTERNS = [ - r"qwen3", # Qwen3 系列(包含 qwen3-vl) - r"qwen.*vl", # Qwen-VL 系列(明确匹配视觉模型) - r"deepseek.*r1", # DeepSeek R1 系列 - r".*thinking" # 任何包含 thinking 的模型 +THINKING_CONTROL_RULES: List[Dict[str, Any]] = [ + { + "name": "zhipu_glm", + "description": "智谱GLM-4.5/4.6系列", + "patterns": [r"glm[-_/.]?4\.(5|6)"], + "params": {"thinking": {"type": "disabled"}}, + "sources": ["智谱官方API", "各聚合平台"] + }, + { + "name": "qwen3_enable_thinking", + "description": "Qwen3系列(支持enable_thinking平台)", + "patterns": [ + r"qwen[-_/.]?3(?!.*thinking)(?!.*r1)", # Qwen3非thinking/r1变体 + r"qwen.*[-_/.]?vl", # Qwen-VL系列(包括qwen2/qwen3-vl) + ], + "params": {"enable_thinking": False}, + "sources": ["魔搭", "阿里百炼", "硅基流动"] + }, + { + "name": "qwen_deepseek_reasoning", + "description": "Qwen/DeepSeek推理模型(R1系列)", + "patterns": [ + r"qwen.*[-_/.]?(r1|thinking)", # Qwen推理模型 + r"deepseek.*[-_/.]?(r1|reason)", # DeepSeek R1系列 + ], + "params": {"reasoning": {"effort": "none"}}, + "sources": ["OpenRouter", "302.ai", "其他聚合平台"] + }, + { + "name": "deepseek_v3_thinking", + "description": "DeepSeek V3/V3.2官方API", + "patterns": [ + r"deepseek[-_/.]?v3(\.1|\.2)?(?!.*r1)", # V3系列非R1 + r"deepseek[-_/.]?chat", # DeepSeek Chat + ], + "params": {"thinking": {"type": "disabled"}}, + "sources": ["DeepSeek官方API"], + "notes": "也可通过模型名加/thinking控制" + }, + { + "name": "gemini2_reasoning", + "description": "Gemini 2.0/2.5 Flash/Lite", + "patterns": [r"gemini[-_/.]?2\.(0|5)[-_/.]?(flash|lite)"], + "params": {"reasoning_effort": "none"}, + "sources": ["Google官方API", "OpenAI兼容接口"] + }, + { + "name": "gemini3_thinking", + "description": "Gemini 3.0系列(只能low,无法完全关闭)", + "patterns": [r"gemini[-_/.]?3"], + "params": {"thinking_level": "low"}, + "sources": ["Google官方API"], + "notes": "⚠️ 无法完全关闭推理,只能设置low/high" + }, + { + "name": "grok3_mini_reasoning", + "description": "Grok-3-mini(支持reasoning_effort)", + "patterns": [r"grok[-_/.]?3[-_/.]?mini"], + "params": {"reasoning_effort": "low"}, + "sources": ["xAI官方API"] + }, + # 注意:Grok-4系列是内置推理模型,不支持reasoning_effort参数,已在排除规则中处理 ] +# ==================== 二级:Ollama特殊规则(原生API专用) ==================== +# Ollama使用/api/chat原生接口,参数格式不同 +# +# ⚠️ 重要发现:think 参数行为与直觉相反 +# - think: true → 关闭思考(不输出 标签) +# - think: false → 启用思考(输出 标签) +# - 不发送 → 默认关闭思考(Ollama 默认行为) + +OLLAMA_NATIVE_RULES = { + "parameter_name": "think", + "disable_value": True, # disable_thinking=True 时发送此值(关闭思考) + "enable_value": False, # disable_thinking=False 时发送此值(启用思考) + "supported_patterns": [ + # r"qwen", # 移除:太宽泛,会误伤qwen-instruct等普通模型 + r"deepseek.*r1", # DeepSeek R1系列 + r"qwen.*r1", # Qwen R1变体 + r".*thinking", # 任何名称包含thinking的模型 + ], + "notes": "Ollama原生API /api/chat 顶层参数" +} -def _match_any(patterns, text_lower: str) -> bool: - """三级注释(流程):在给定文本(小写)中匹配任意一个白名单模式""" - for pat in patterns: - if re.search(pat, text_lower): - return True - return False +# ==================== 三级:排除规则(明确不支持关闭的模型) ==================== +# 这些模型要么不支持关闭推理,要么是内置推理模型 + +EXCLUDE_PATTERNS = [ + r"gemini[-_/.]?2\.5[-_/.]?pro", # Gemini 2.5 Pro官方明确不支持关闭 + r"grok[-_/.]?4", # Grok-4系列是内置推理模型,无reasoning_effort参数 + r".*speciale", # DeepSeek Speciale仅thinking模式 +] -def build_thinking_suppression(provider: str, model: str) -> Dict[str, Any]: +# ==================== 核心匹配函数 ==================== + +def build_thinking_suppression(provider: str, model: str, disable_thinking: bool = True) -> Dict[str, Any]: """ - 二级注释(对外接口):根据服务商与模型名称,返回放入 extra_body 的关闭“思维链/Thinking”请求参数。 - - 参数: - - provider: 当前服务商(zhipu/siliconflow/openai/custom 等) - - model: 模型名称(原样传入,以便做模式匹配) - - 返回: - - 返回应放入 chat.completions.create(extra_body=...) 的字段字典;若不适用则返回空字典。 - - 说明: - - 仅在白名单内返回参数;未知服务商或不在白名单的模型不做处理(返回 {})。 - - 使用 extra_body 以确保 OpenAI Python SDK 不因未知参数报错。 + 根据模型特征返回思维链控制参数 + + 匹配优先级: + 1. 检查排除规则 → 返回空字典(明确不支持的模型) + 2. Ollama特殊处理 → 原生API参数格式 + 3. 遍历通用规则 → 返回第一个匹配的参数 + 4. 无匹配 → 返回空字典 + + 参数: + provider: 服务商标识(仅用于Ollama判断,其他服务商忽略) + model: 模型名称 + disable_thinking: True=关闭思考, False=启用思考 + + 返回: + Dict: 思维链控制参数字典,或空字典 + + 示例: + >>> build_thinking_suppression("ollama", "qwen3-thinking", disable_thinking=True) + {"think": True} # 关闭思考 + + >>> build_thinking_suppression("ollama", "qwen3-thinking", disable_thinking=False) + {"think": False} # 启用思考 + + >>> build_thinking_suppression("zhipu", "glm-4.5") + {"thinking": {"type": "disabled"}} + + >>> build_ thinking_suppression("custom", "gemini-2.5-pro") + {} # 不支持关闭 """ - if not provider or not model: + if not model: return {} - - prov = provider.strip().lower() - mdl_lower = model.strip().lower() - - # --- 智谱(Zhipu / BigModel) --- - # GLM-4.5/GLM-4.5V:thinking.type = disabled - if prov == "zhipu": - if _match_any(_ZHIPU_PATTERNS, mdl_lower): - return {"thinking": {"type": "disabled"}} - return {} - - # --- 硅基流动(SiliconFlow) --- - # 支持 enable_thinking=false 的模型白名单 - if prov == "siliconflow": - if _match_any(_SILICONFLOW_PATTERNS, mdl_lower): - return {"enable_thinking": False} - return {} - - # --- 自定义/302.AI(OpenAI 兼容聚合网关) --- - # 将 302.AI 与 custom 保持同样的关闭思维链规则 - if prov in ("custom", "302ai"): - # 1) Google Gemini 兼容层:顶层参数 reasoning_effort - if _match_any(_GEMINI_PATTERNS, mdl_lower): - # Pro 版本官方不允许关闭推理:避免返回 none,交由模型默认策略处理 - if _match_any(_GEMINI_25_PRO_CANNOT_DISABLE, mdl_lower): - return {} - return {"reasoning_effort": "none"} - - # 2) 阿里/Qwen 推理模型:常见 R1/Thinking 系列,使用通用 reasoning.effort=none - if _match_any(_ALI_REASONING_PATTERNS, mdl_lower): - return {"reasoning": {"effort": "none"}} - - # 3) DeepSeek 推理模型:R1/Distill 等 - if _match_any(_DEEPSEEK_REASONING_PATTERNS, mdl_lower): - return {"reasoning": {"effort": "none"}} - - # 注意:Qwen3-VL 系列已通过 _ALI_REASONING_PATTERNS 在第 2 步匹配,无需重复处理 - - # 默认:未知模型不返回以避免误伤 - return {} - - # --- Ollama 服务 --- - # 支持通过 think: false 关闭思维链 - # 注意:Ollama 的 OpenAI 兼容接口可能不支持 extra_body,需要在原生 API 中处理 - if prov == "ollama": - if _match_any(_OLLAMA_PATTERNS, mdl_lower): - return {"think": False} - - # 其它服务商默认逻辑 + + model_lower = model.strip().lower() + provider_lower = provider.strip().lower() if provider else "" + + # 1. 检查排除规则 + for exclude_pattern in EXCLUDE_PATTERNS: + if re.search(exclude_pattern, model_lower): + return {} # 明确不支持的模型,返回空字典 + + # 2. Ollama特殊处理(原生API) + if provider_lower == "ollama": + for pattern in OLLAMA_NATIVE_RULES["supported_patterns"]: + if re.search(pattern, model_lower): + # 根据 disable_thinking 参数选择值 + think_value = OLLAMA_NATIVE_RULES["disable_value"] if disable_thinking else OLLAMA_NATIVE_RULES["enable_value"] + return { + OLLAMA_NATIVE_RULES["parameter_name"]: think_value + } + return {} # Ollama但不在白名单 + + # 3. 遍历通用规则,返回第一个匹配项 + for rule in THINKING_CONTROL_RULES: + for pattern in rule["patterns"]: + if re.search(pattern, model_lower): + # 返回副本,避免修改原始数据 + return rule["params"].copy() + + # 4. 无匹配,返回空字典 return {} + +# ==================== 辅助函数(用于调试) ==================== + +def get_rule_info(provider: str, model: str) -> Dict[str, Any]: + """ + 获取匹配规则的详细信息(用于调试) + + 返回: + Dict: { + "matched": bool, + "rule_name": str, + "description": str, + "params": Dict, + "sources": List[str] + } + """ + if not model: + return {"matched": False} + + model_lower = model.strip().lower() + provider_lower = provider.strip().lower() if provider else "" + + # 检查排除规则 + for exclude_pattern in EXCLUDE_PATTERNS: + if re.search(exclude_pattern, model_lower): + return { + "matched": True, + "rule_name": "EXCLUDED", + "description": "明确不支持关闭思维链的模型", + "params": {}, + "sources": [] + } + + # Ollama特殊处理 + if provider_lower == "ollama": + for pattern in OLLAMA_NATIVE_RULES["supported_patterns"]: + if re.search(pattern, model_lower): + return { + "matched": True, + "rule_name": "ollama_native", + "description": "Ollama原生API", + "params": { + OLLAMA_NATIVE_RULES["parameter_name"]: + OLLAMA_NATIVE_RULES["parameter_value"] + }, + "sources": ["Ollama /api/chat"] + } + return {"matched": False} + + # 通用规则 + for rule in THINKING_CONTROL_RULES: + for pattern in rule["patterns"]: + if re.search(pattern, model_lower): + return { + "matched": True, + "rule_name": rule["name"], + "description": rule["description"], + "params": rule["params"].copy(), + "sources": rule.get("sources", []) + } + + return {"matched": False} diff --git a/services/vlm.py b/services/vlm.py index 91ada7d..2757739 100644 --- a/services/vlm.py +++ b/services/vlm.py @@ -1,124 +1,30 @@ +""" +VLM服务 - 重构版本 +提供视觉模型的图像分析功能 +继承OpenAICompatibleService以复用通用逻辑 +""" + import json -import os -import base64 -from io import BytesIO -from PIL import Image -from typing import Optional, Dict, Any, List, Callable -import asyncio -from openai import AsyncOpenAI -import httpx import time +import asyncio +from typing import Optional, Dict, Any, List, Callable +import httpx +from .openai_base import OpenAICompatibleService, filter_thinking_content +from ..utils.common import ( + format_api_error, preprocess_image, check_multi_image_support, ProgressBar, + log_complete, log_error, + PREFIX, PROCESS_PREFIX, WARN_PREFIX, ERROR_PREFIX, format_elapsed_time, + TASK_IMAGE_CAPTION, TASK_VIDEO_CAPTION +) from .thinking_control import build_thinking_suppression -from .error_util import format_api_error -class VisionService: - _provider_base_urls = { - 'openai': None, # 使用默认 - 'siliconflow': 'https://api.siliconflow.cn/v1', # 硅基流动 - 'zhipu': 'https://open.bigmodel.cn/api/paas/v4/', # 智谱(官方文档要求末尾斜杠) - '302ai': 'https://api.302.ai/v1', - 'ollama': 'http://localhost:11434/v1', - 'custom': None # 使用配置中的自定义URL - } +class VisionService(OpenAICompatibleService): + """ + 视觉模型服务 + 支持单图和多图分析 + """ - # 提供商显示名称映射 - _provider_display_names = { - 'zhipu': '智谱', - 'siliconflow': '硅基流动', - 'openai': 'OpenAI', - '302ai': '302.AI', - 'ollama': 'Ollama', - 'custom': '自定义' - } - - @classmethod - def get_openai_client(cls, api_key: str, provider: str) -> AsyncOpenAI: - """获取OpenAI客户端""" - # 降低第三方库默认HTTP日志级别,避免重复输出,并关闭OpenAI SDK调试打印 - try: - import logging - import os as _os - _os.environ["OPENAI_LOG"] = "error" - for _name in ("openai", "openai._base_client", "httpx", "httpcore"): - _logger = logging.getLogger(_name) - _logger.setLevel(logging.WARNING) - except Exception: - pass - - # 否则创建新客户端 - base_url = None - - # 如果是自定义提供商,从配置中获取base_url - if provider == 'custom': - from ..config_manager import config_manager - config = config_manager.get_vision_config() - if 'providers' in config and 'custom' in config['providers']: - base_url = config['providers']['custom'].get('base_url') - # 确保base_url不以/chat/completions结尾,避免路径重复 - if base_url and base_url.endswith('/chat/completions'): - # 注意: str.rstrip 参数是字符集合,这里不能用来移除子串,否则会误删域名尾部字符 - base_url = base_url[: -len('/chat/completions')] - else: - base_url = cls._provider_base_urls.get(provider) - - # 检查是否启用"跳过代理直连" - bypass_proxy = False - try: - from ..config_manager import config_manager - settings = config_manager.get_settings() - bypass_proxy = settings.get('PromptAssistant.Settings.BypassProxy', False) - except Exception: - pass - - # 创建简化的httpx客户端,明确禁用HTTP/2以提高国内服务商兼容性 - # 视觉模型需要较长的超时时间 - # 仅在"发起请求"阶段打印一条请求日志,避免重复 - from ..server import PROCESS_PREFIX - - async def _on_request(request: httpx.Request): - try: - print(f"{PROCESS_PREFIX} OpenAI Request: {request.method} {request.url}") - except Exception: - pass - - # 使用细粒度的超时配置,提高网络波动下的稳定性 - # 视觉模型处理需要较长时间,因此读取超时设置更长 - http_client_kwargs = { - 'timeout': httpx.Timeout( - connect=15.0, # 连接超时:15秒 - read=120.0, # 读取超时:120秒(视觉模型分析需要更长时间) - write=15.0, # 写入超时:15秒 - pool=10.0 # 连接池超时:10秒 - ), - 'event_hooks': {'request': [_on_request]}, - 'http2': False, # 明确禁用HTTP/2,提高国内服务商兼容性 - 'verify': True # 启用SSL验证,但使用系统证书 - } - - # 如果启用"跳过代理直连",则设置 proxy=None - if bypass_proxy: - http_client_kwargs['proxy'] = None - - http_client = httpx.AsyncClient(**http_client_kwargs) - - kwargs = { - "api_key": api_key, - "http_client": http_client, - "max_retries": 3 # 增加重试次数到3次 - } - if base_url: - # 根据官方文档,保留base_url原样,不做处理 - # 智谱文档要求:https://open.bigmodel.cn/api/paas/v4/ - # 硅基流动文档要求:https://api.siliconflow.cn/v1 - # OpenAI SDK会自动处理URL拼接 - kwargs["base_url"] = base_url - - - # 创建客户端 - client = AsyncOpenAI(**kwargs) - return client - @staticmethod def _get_config() -> Dict[str, Any]: """获取视觉模型配置""" @@ -126,7 +32,6 @@ class VisionService: config = config_manager.get_vision_config() current_provider = config.get('provider') - # 获取实际配置 if 'providers' in config and current_provider in config['providers']: provider_config = config['providers'][current_provider] return { @@ -140,691 +45,601 @@ class VisionService: 'auto_unload': provider_config.get('auto_unload', True) } else: - # 兼容旧版配置格式 return config - + @staticmethod - async def _unload_ollama_model(model: str, provider_config: Dict[str, Any]): - """ - 卸载Ollama模型以释放显存和内存 - - 参数: - model: 模型名称 - provider_config: 提供商配置字典 - """ - try: - # 检查是否启用自动释放 - auto_unload = provider_config.get('auto_unload', True) - if not auto_unload: - return - - # 获取base_url - base_url = provider_config.get('base_url', 'http://localhost:11434') - # 确保URL不以/v1结尾(Ollama原生API不需要/v1) - if base_url.endswith('/v1'): - base_url = base_url[:-3] - - # 调用Ollama API卸载模型 - url = f"{base_url}/api/generate" - payload = { - "model": model, - "keep_alive": 0 # 立即卸载模型 - } - - from ..server import PROCESS_PREFIX - print(f"{PROCESS_PREFIX} Ollama自动释放显存 | 模型:{model}") - - import httpx as httpx_module - async with httpx_module.AsyncClient(timeout=5.0) as client: - await client.post(url, json=payload) - - except Exception as e: - # 释放失败不影响主流程,只记录警告 - from ..server import WARN_PREFIX - print(f"{WARN_PREFIX} Ollama模型释放失败(不影响结果) | 模型:{model} | 错误:{str(e)[:50]}") - - @staticmethod - async def _http_request_chat_completions( - base_url: str, - api_key: str, + async def _call_ollama_native_vision( model: str, - messages: List[Dict[str, Any]], + system_prompt: str, + images_b64: List[str], temperature: float, top_p: float, max_tokens: int, - thinking_extra: Optional[Dict[str, Any]] = None, + base_url: str, stream_callback: Optional[Callable[[str], None]] = None, request_id: Optional[str] = None, - provider_display_name: str = "未知服务", - direct_mode_tag: str = "" + is_multi: bool = False, + auto_unload: bool = True, + enable_advanced_params: bool = False, + thinking_extra: Optional[Dict[str, Any]] = None, + cancel_event: Optional[Any] = None, + task_type: str = None, + source: str = None ) -> Dict[str, Any]: """ - 使用HTTP直接调用chat/completions接口(保底方案,支持视觉模型) + 调用Ollama原生视觉API (/api/chat) + 支持单图和多图分析 参数: - base_url: API基础URL - api_key: API密钥 - model: 模型名称 - messages: 消息列表(支持图像) - temperature: 温度参数 - top_p: top_p参数 - max_tokens: 最大token数 + enable_advanced_params: 是否发送高级参数(temperature/top_p/num_predict) thinking_extra: 思维链控制参数 - stream_callback: 流式输出回调 - request_id: 请求ID - provider_display_name: 提供商显示名称 - direct_mode_tag: 直连模式标签 - - 返回: - 包含结果的字典 """ - from ..server import PROCESS_PREFIX, PREFIX, ERROR_PREFIX + from ..server import is_streaming_progress_enabled try: - # 确保base_url不以/结尾 - base_url = base_url.rstrip('/') - # 构建完整URL - url = f"{base_url}/chat/completions" + start_time = time.perf_counter() - # 构建请求体 + _thinking_extra = thinking_extra # 使用传入的参数 + _thinking_tag = "💭" if _thinking_extra else "" + + # 计算base_url + native_base = base_url[:-3] if base_url and base_url.endswith('/v1') else (base_url or 'http://localhost:11434') + native_base = native_base.rstrip('/') + + # 动态计算num_ctx(根据图像数量) + # 每张图片约需要1024-2048 tokens + img_count = len(images_b64) + + # 文本Token估算 (0.6系数) + prompt_ctx = int(len(system_prompt) * 0.6) + + # 图像Token估算 (每张2048作为基准) + image_ctx = img_count * 2048 + + # --- 智能预留策略 (适配 Vision 模型) --- + # 关键点:Vision模型的思考过程同样占用大量 Output Token + + is_safe_standard_model = False + if model: + m = model.lower() + if "instruct" in m or "chat" in m: + is_safe_standard_model = True + + if _thinking_extra or is_safe_standard_model: + # 已关闭思维链 OR 标准指令模型 -> 极致节省模式 + min_output = 512 + ctx_floor = 2048 # 允许更低下限 (虽然单图通常>2048, 但允许对齐到3072而非强制4096) + sys_buffer = 384 + else: + # 未关闭思维链 -> 安全能够模式 + min_output = 1024 + ctx_floor = 4096 # 保持稳健,防止思考过程溢出 + sys_buffer = 1024 + + # 输出预留 (多图需更多) + # 如果是多图,每张图预留512作为基准;如果是单图,使用min_output + base_reserve = (img_count * 512) if is_multi else min_output + output_reserve = max(min_output, base_reserve) + + required_ctx = prompt_ctx + image_ctx + output_reserve + sys_buffer + + # 范围: [ctx_floor, 65536] + num_ctx = max(ctx_floor, min(65536, required_ctx)) + num_ctx = ((num_ctx + 1023) // 1024) * 1024 + + # [Debug] 输出多图请求信息 + print(f"{PREFIX} [vlm-ollama] 多图请求 | 图片数量:{len(images_b64)} | num_ctx:{num_ctx} | 模型:{model}") + + # 构建基础请求体 payload = { "model": model, - "messages": messages, - "temperature": temperature, - "top_p": top_p, - "max_tokens": max_tokens, + "messages": [{"role": "user", "content": system_prompt, "images": images_b64}], "stream": True } - # 添加思维链控制参数 - if thinking_extra: - # 将思维链参数合并到payload中 - if "reasoning_effort" in thinking_extra: - payload["reasoning_effort"] = thinking_extra["reasoning_effort"] - if "enable_thinking" in thinking_extra: - payload["enable_thinking"] = thinking_extra["enable_thinking"] - if "thinking" in thinking_extra: - payload["thinking"] = thinking_extra["thinking"] - # 其他参数通过extra_body传递(在HTTP请求中直接合并) - for key, value in thinking_extra.items(): - if key not in ["reasoning_effort", "enable_thinking", "thinking"]: - payload[key] = value - - # 构建请求头 - headers = { - "Content-Type": "application/json", - "Authorization": f"Bearer {api_key}" + # 构建 options + # ⚠️ 重要:Ollama 对高级参数兼容性不佳,仅发送 num_ctx 避免冲突 + # - temperature 和 top_p 冲突 + # - num_predict 在某些模型上会导致无输出 + # 因此统一只发送 num_ctx,让模型使用默认配置 + payload["options"] = { + "num_ctx": num_ctx } - # 检查是否启用直连模式 - bypass_proxy = False - try: - from ..config_manager import config_manager as cm - settings = cm.get_settings() - bypass_proxy = settings.get('PromptAssistant.Settings.BypassProxy', False) - except Exception: - pass + # 添加思维链控制参数(如 think: true 或 think: false) + if _thinking_extra: + payload.update(_thinking_extra) - # 创建HTTP客户端 - http_client_kwargs = { - 'timeout': httpx.Timeout( - connect=15.0, - read=120.0, - write=15.0, - pool=10.0 - ), - 'http2': False, - 'verify': True - } + # 设置超时 + # 基础读取超时60秒 + 每张图片增加30秒 + 上下文长度自适应 + base_read_timeout = 60.0 + per_image_read_timeout = 30.0 + ctx_based_timeout = (num_ctx / 1000) * 2.0 # 每1000tokens增加2秒 - if bypass_proxy: - http_client_kwargs['proxy'] = None + calculated_read_timeout = base_read_timeout + (img_count * per_image_read_timeout) + ctx_based_timeout + + # 最大读取超时限制为 10 分钟 (600s) + final_read_timeout = min(600.0, max(60.0, calculated_read_timeout)) + + # 创建统一进度条(自动处理等待→生成→完成的完整生命周期) + extra_info = f"Context:{num_ctx} | Timeout:{int(final_read_timeout)}s" + pbar = ProgressBar( + request_id=request_id, + service_name="Ollama", + extra_info=extra_info, + streaming=is_streaming_progress_enabled(), + task_type=task_type, + source=source + ) - print(f"{PROCESS_PREFIX} 使用HTTP方式请求{direct_mode_tag} | 服务:{provider_display_name} | 模型:{model}") start_time = time.perf_counter() - async with httpx.AsyncClient(**http_client_kwargs) as client: - async with client.stream('POST', url, headers=headers, json=payload) as response: - if response.status_code != 200: - error_text = await response.aread() - try: - error_data = json.loads(error_text) - error_msg = error_data.get('error', {}).get('message', f'HTTP {response.status_code}') - except: - error_msg = f'HTTP {response.status_code}: {error_text.decode("utf-8", errors="ignore")[:100]}' - return {"success": False, "error": error_msg} - - full_content = "" - async for line in response.aiter_lines(): - if not line or line == "data: [DONE]": - continue + # 连接超时10秒,写入超时60秒,连接池超时60秒 + timeout_config = httpx.Timeout(connect=10.0, read=final_read_timeout, write=60.0, pool=60.0) + + async with httpx.AsyncClient( + timeout=timeout_config, + limits=httpx.Limits(max_keepalive_connections=5, max_connections=10) + ) as client: + full_content = "" + + async def _request_core(): + async with client.stream('POST', f"{native_base}/api/chat", json=payload, follow_redirects=True) as resp: + if resp.status_code != 200: + error_text = await resp.aread() + try: + error_data = json.loads(error_text) + return {"success": False, "error": error_data.get('error', f'HTTP {resp.status_code}')} + except: + return {"success": False, "error": f'HTTP {resp.status_code}'} - if line.startswith("data: "): - line = line[6:] # 移除 "data: " 前缀 + nonlocal full_content + async for line in resp.aiter_lines(): + if not line: continue + try: + chunk_data = json.loads(line) + message = chunk_data.get('message') + if message and isinstance(message, dict): + content = message.get('content', '') or '' + if not content.strip(): + thinking = message.get('thinking', '') or message.get('reasoning', '') + if thinking and len(thinking.strip()) > 5: + content = thinking + + if content and content.strip(): + full_content += content + pbar.set_generating(len(full_content)) + pbar.update(len(full_content)) + if stream_callback: stream_callback(content) + + if chunk_data.get('done', False): + pbar.done(char_count=len(full_content), elapsed_ms=int((time.perf_counter() - start_time) * 1000)) + break + except: continue + return {"success": True, "content": full_content.strip()} + + # 定义监视器逻辑 + async def _monitor_interrupts(target_task): + while not target_task.done(): + is_interrupted = False + if cancel_event is not None and cancel_event.is_set(): + is_interrupted = True + else: + try: + from server import PromptServer + if hasattr(PromptServer.instance, 'execution_interrupted') and PromptServer.instance.execution_interrupted: + is_interrupted = True + except: pass - try: - chunk_data = json.loads(line) - choices = chunk_data.get('choices', []) - for choice in choices: - delta = choice.get('delta', {}) - content = delta.get('content', '') - if content: - full_content += content - if stream_callback: - stream_callback(content) - except json.JSONDecodeError: - continue - - elapsed_ms = int((time.perf_counter() - start_time) * 1000) - print(f"{PREFIX} HTTP请求成功 | 服务:{provider_display_name} | 请求ID:{request_id} | 结果字符数:{len(full_content)} | 耗时:{elapsed_ms}ms") - - return { - "success": True, - "content": full_content - } - + if is_interrupted: + target_task.cancel() + return True + await asyncio.sleep(0.1) + return False + + # 并发执行 + req_task = asyncio.create_task(_request_core()) + monitor_task = asyncio.create_task(_monitor_interrupts(req_task)) + + try: + result = await req_task + return result + except asyncio.CancelledError: + # 关键修复:确保进度条在监视器取消时被正确清理 + pbar.cancel(f"{WARN_PREFIX} 任务被中断 | 服务:Ollama(Vision)") + return {"success": False, "error": "任务被中断", "interrupted": True} + finally: + if not monitor_task.done(): monitor_task.cancel() + # 显存释放保证:视觉节点对显存更敏感,必须确保在所有退出路径执行 + # 无论 auto_unload 设置如何,都调用卸载方法(内部会根据配置打印相应日志) + try: + from .llm import LLMService + await LLMService._unload_ollama_model(model, {"base_url": native_base, "auto_unload": auto_unload}) + except: pass + + # 关键修复:单独捕获外层 CancelledError,确保 pbar 被正确停止 + except asyncio.CancelledError: + if 'pbar' in locals() and pbar: + pbar.cancel(f"{WARN_PREFIX} 任务被外部取消 | 服务:Ollama(Vision)") + return {"success": False, "error": "任务被取消", "interrupted": True} + except Exception as e: - from ..server import ERROR_PREFIX - print(f"{ERROR_PREFIX} HTTP请求失败 | 服务:{provider_display_name} | 错误:{str(e)[:100]}") - return {"success": False, "error": format_api_error(e, provider_display_name)} - + # 关键修复:确保 pbar 在异常时也被停止 + if 'pbar' in locals() and pbar: + pbar.error(format_api_error(e, "Ollama")) + return {"success": False, "error": format_api_error(e, "Ollama")} + @staticmethod - def preprocess_image(image_data: str, request_id: Optional[str] = None) -> str: + async def analyze_image( + image_data: str, + request_id: Optional[str] = None, + stream_callback: Optional[Callable[[str], None]] = None, + prompt_content: Optional[str] = None, + custom_provider: Optional[str] = None, + custom_provider_config: Optional[Dict[str, Any]] = None, + cancel_event: Optional[Any] = None, + task_type: str = None, + source: Optional[str] = None + ) -> Dict[str, Any]: """ - 预处理图像数据,包括压缩和调整大小 - - 参数: - image_data: 图像数据(Base64编码或URL) - request_id: 请求ID,用于日志记录 - - 返回: - 处理后的图像数据 - """ - from ..server import REQUEST_PREFIX - - try: - # 检查是否为base64编码的图像数据 - if image_data.startswith('data:image'): - # 提取base64数据 - header, encoded = image_data.split(",", 1) - image_bytes = base64.b64decode(encoded) - - # 打开图像 - img = Image.open(BytesIO(image_bytes)) - original_size = img.size - original_format = img.format or 'JPEG' - original_bytes = len(image_bytes) - - # 调整大小,保持纵横比 - max_size = 1024 # 最大尺寸设为1024px - if max(img.size) > max_size: - ratio = max_size / max(img.size) - new_size = (int(img.size[0] * ratio), int(img.size[1] * ratio)) - img = img.resize(new_size, Image.LANCZOS) - - # 压缩图像 - buffer = BytesIO() - save_format = 'JPEG' if original_format not in ['PNG', 'GIF'] else original_format - - # 根据格式选择保存参数 - if save_format == 'JPEG': - img.save(buffer, format=save_format, quality=85, optimize=True) - elif save_format == 'PNG': - img.save(buffer, format=save_format, optimize=True, compress_level=7) - else: - img.save(buffer, format=save_format) - - compressed_bytes = buffer.getvalue() - - # 转回base64 - compressed_b64 = base64.b64encode(compressed_bytes).decode('utf-8') - processed_image_data = f"{header},{compressed_b64}" - - # 计算压缩比例 - compressed_size = len(compressed_bytes) - compression_ratio = (1 - compressed_size / original_bytes) * 100 - - # 记录日志(蓝色前缀 + 紧凑格式) - print( - f"{REQUEST_PREFIX} 图像预处理 | 请求ID:{request_id} | " - f"图像尺寸:{original_size} → {img.size} | " - f"大小:{original_bytes/1024:.1f}KB → {compressed_size/1024:.1f}KB | " - f"压缩率:{compression_ratio:.1f}%" - ) - - return processed_image_data - - # 如果不是base64编码的图像数据,直接返回 - return image_data - - except Exception as e: - from ..server import WARN_PREFIX - print(f"{WARN_PREFIX} 图像预处理失败 | 请求ID:{request_id} | 错误:{str(e)}") - # 预处理失败时返回原始图像数据 - return image_data - - @staticmethod - async def analyze_image(image_data: str, request_id: Optional[str] = None, - stream_callback: Optional[Callable[[str], None]] = None, - prompt_content: Optional[str] = None, - custom_provider: Optional[str] = None, - custom_provider_config: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: - """ - 使用视觉模型分析图像 - + 使用视觉模型分析单张图像 + 参数: image_data: 图像数据(Base64编码) request_id: 请求ID - lang: 分析语言,zh为中文,en为英文 - stream_callback: 流式输出的回调函数 - custom_prompt: 自定义提示词 - custom_provider: 自定义提供商 - custom_provider_config: 自定义提供商配置 - + stream_callback: 流式输出回调 + prompt_content: 自定义提示词 + custom_provider: 自定义服务商 + custom_provider_config: 自定义配置 + 返回: - 包含分析结果的字典 + Dict: {"success": bool, "data": {"description": str}, "error": str} """ - from ..server import PREFIX, ERROR_PREFIX try: # 获取配置 if custom_provider and custom_provider_config: - # 使用自定义提供商和配置 provider = custom_provider - api_key = custom_provider_config.get('api_key', '') - model = custom_provider_config.get('model', '') - base_url = custom_provider_config.get('base_url', '') + api_key = custom_provider_config.get('api_key') + model = custom_provider_config.get('model') temperature = custom_provider_config.get('temperature', 0.7) top_p = custom_provider_config.get('top_p', 0.9) max_tokens = custom_provider_config.get('max_tokens', 2000) + base_url = custom_provider_config.get('base_url', '') else: - # 使用默认配置 config = VisionService._get_config() + provider = config.get('provider', 'unknown') api_key = config.get('api_key') model = config.get('model') - provider = config.get('provider', 'unknown') temperature = config.get('temperature', 0.7) top_p = config.get('top_p', 0.9) max_tokens = config.get('max_tokens', 2000) base_url = config.get('base_url', '') - if not api_key: - return {"success": False, "error": "请先配置视觉模型API密钥"} + # 注:允许空API Key,支持无认证服务商 if not model: - return {"success": False, "error": "未配置视觉模型名称"} + return {"success": False, "error": "未配置模型名称"} - # 检查图片数据格式 - if not image_data: - return {"success": False, "error": "未提供图像数据"} + provider_display_name = VisionService.get_provider_display_name(provider) - # 预处理图像数据(压缩和调整大小) - image_data = VisionService.preprocess_image(image_data, request_id) + from ..utils.common import REQUEST_PREFIX, PREFIX, format_model_with_thinking + + # 检查服务配置以确定是否显示思维链标识 + from ..config_manager import config_manager + service = config_manager.get_service(provider) + disable_thinking_enabled = service.get('disable_thinking', True) if service else True + # 只有当开关开启且模型支持时才显示标识 + _thinking_check = build_thinking_suppression(provider, model) if disable_thinking_enabled else None + thinking_disabled = _thinking_check is not None + model_display = format_model_with_thinking(model, thinking_disabled) - # 获取提供商显示名称 - provider_display_name = VisionService._provider_display_names.get(provider, provider) + # 预处理图像 + processed_image = preprocess_image(image_data, request_id=request_id) - # 直接使用传入的提示词内容 - system_prompt = prompt_content - if not system_prompt: - return {"success": False, "error": "未提供有效的提示词内容"} + # 获取系统提示词 + system_prompt = prompt_content or "请详细描述这张图片的内容,包括主要对象、场景、颜色、氛围等。" - - # 简化:仅当按内容精确匹配到系统内置视觉提示词时打印其名称(蓝色前缀),不输出ID - try: - from ..config_manager import config_manager - system_prompts_all = config_manager.get_system_prompts() - vision_prompts = (system_prompts_all or {}).get('vision_prompts', {}) - # 规则匹配不再打印,准备阶段已经显示过规则信息 - pass - except Exception: - pass - - # 模型能力守卫:拦截“图像生成类”模型,避免错误调用 - _lower_model = (model or "").lower() - _gen_keywords = ("image-generation", "dall-e", "imagen-", "-imagen-", "sdxl", "flux") - if any(k in _lower_model for k in _gen_keywords): - return {"success": False, "error": f"模型不支持视觉理解(图→文):{model}。请更换多模态对话/理解模型(如 gemini-2.0/2.5-flash、gpt-4o、GLM-4.5V、Qwen-VL 等)。"} - - # 发送请求 - _thinking_extra = build_thinking_suppression(provider, model) - _thinking_tag = "(已关闭思维链)" if _thinking_extra else "" - - # Ollama 的 OpenAI 兼容接口对图像输入支持不完整,统一走原生 API + # Ollama走原生API if provider == 'ollama': - try: - t0_native = time.perf_counter() - # 提取纯base64 - b64 = image_data - if b64.startswith('data:image'): - try: - _, b64 = b64.split(',', 1) - except Exception: - pass - - # 计算原生base_url - native_base = base_url[:-3] if base_url and base_url.endswith('/v1') else (base_url or 'http://localhost:11434') - - # 优先使用 /api/chat - import httpx as _httpx - payload = { - "model": model, - "messages": [{"role": "user", "content": system_prompt, "images": [b64]}], - "options": {"temperature": temperature, "top_p": top_p}, - "stream": False - } - # 如果支持关闭思维链,添加 think 参数(Ollama 原生 API 支持) - if _thinking_extra and "think" in _thinking_extra: - payload["think"] = _thinking_extra["think"] - async with _httpx.AsyncClient(timeout=_httpx.Timeout(30.0, read=120.0)) as _client: - resp = await _client.post(f"{native_base}/api/chat", json=payload) - if resp.status_code == 200: - data = resp.json() - # 优先解析 message.content - content = '' - try: - content = ((data or {}).get('message') or {}).get('content', '') - except Exception: - content = '' - if not content: - # 兼容 generate 响应字段 - content = (data or {}).get('response', '') - if content: - elapsed_ms = int((time.perf_counter() - t0_native) * 1000) - print(f"{PREFIX} 图像反推完成 | 服务:Ollama | 请求ID:{request_id} | 结果字符数:{len(content)} | 耗时:{elapsed_ms}ms") - # 卸载模型 - provider_config = { - 'auto_unload': (custom_provider_config or config).get('auto_unload', True) if 'config' in locals() or custom_provider_config else True, - 'base_url': native_base - } - await VisionService._unload_ollama_model(model, provider_config) - return {"success": True, "data": {"description": content}} - # 回退到 /api/generate - t0_native = time.perf_counter() - gen_payload = { - "model": model, - "prompt": system_prompt, - "images": [b64], - "options": {"temperature": temperature, "top_p": top_p}, - "stream": False - } - # 如果支持关闭思维链,添加 think 参数(Ollama 原生 API 支持) - if _thinking_extra and "think" in _thinking_extra: - gen_payload["think"] = _thinking_extra["think"] - resp2 = await _client.post(f"{native_base}/api/generate", json=gen_payload) - if resp2.status_code == 200: - data = resp2.json() - content = (data or {}).get('response', '') - if content: - provider_config = { - 'auto_unload': (custom_provider_config or config).get('auto_unload', True) if 'config' in locals() or custom_provider_config else True, - 'base_url': native_base - } - await VisionService._unload_ollama_model(model, provider_config) - elapsed_ms = int((time.perf_counter() - t0_native) * 1000) - print(f"{PREFIX} 图像反推完成 | 服务:Ollama | 请求ID:{request_id} | 结果字符数:{len(content)} | 耗时:{elapsed_ms}ms") - return {"success": True, "data": {"description": content}} - # 若仍无结果 - return {"success": False, "error": "Ollama原生API未返回内容,请检查模型是否支持多模态或更新至最新Ollama"} - except Exception as e: - return {"success": False, "error": format_api_error(e, 'Ollama')} - - # 检查是否强制使用HTTP方式 - force_http = False - try: - from ..config_manager import config_manager as cm - settings = cm.get_settings() - force_http = settings.get('PromptAssistant.Settings.ForceHTTP', False) - except Exception: - pass - - # 检查是否支持HTTP回退(zhipu, siliconflow, 302ai, custom) - supports_http_fallback = provider in ['zhipu', 'siliconflow', '302ai', 'custom'] - - # 检查是否启用直连模式 - bypass_proxy = False - try: - from ..config_manager import config_manager as cm - settings = cm.get_settings() - bypass_proxy = settings.get('PromptAssistant.Settings.BypassProxy', False) - except Exception: - pass - - direct_mode_tag = "(直连)" if bypass_proxy else "" - - # 如果强制使用HTTP且支持HTTP,直接使用HTTP方式 - if force_http and supports_http_fallback: - _thinking_extra = build_thinking_suppression(provider, model) - base_url_local = None - if custom_provider and custom_provider_config: - base_url_local = custom_provider_config.get('base_url', '') - else: - base_url_local = config.get('base_url', '') + # 读取 Ollama 服务的配置 + enable_advanced_params = service.get('enable_advanced_params', False) if service else False + filter_thinking_output = service.get('filter_thinking_output', True) if service else True + _ollama_thinking_extra = build_thinking_suppression('ollama', model) if disable_thinking_enabled else None - # 构建消息(包含图像) - http_messages = [{ - "role": "user", - "content": [ - {"type": "text", "text": system_prompt}, - {"type": "image_url", "image_url": {"url": image_data}} - ] - }] + # 提取纯base64 + b64 = processed_image.split(',')[1] if ',' in processed_image else processed_image - http_result = await VisionService._http_request_chat_completions( - base_url=base_url_local, - api_key=api_key, + # 提前计算auto_unload配置 + native_base = base_url[:-3] if base_url.endswith('/v1') else (base_url or 'http://localhost:11434') + native_base = native_base.rstrip('/') + _cfg = { + 'auto_unload': custom_provider_config.get('auto_unload', True) if custom_provider_config else config.get('auto_unload', True), + 'base_url': native_base + } + auto_unload = _cfg['auto_unload'] + + result = await VisionService._call_ollama_native_vision( model=model, - messages=http_messages, + system_prompt=system_prompt, + images_b64=[b64], temperature=temperature, top_p=top_p, max_tokens=max_tokens, - thinking_extra=_thinking_extra, + base_url=base_url, stream_callback=stream_callback, request_id=request_id, - provider_display_name=provider_display_name, - direct_mode_tag=direct_mode_tag + is_multi=False, + auto_unload=auto_unload, + enable_advanced_params=enable_advanced_params, + thinking_extra=_ollama_thinking_extra, + cancel_event=cancel_event, + task_type=task_type or TASK_IMAGE_CAPTION, + source=source ) - if http_result.get("success"): + if result["success"]: + # 注:卸载已在 _call_ollama_native_vision 的 finally 块中处理 + + # 应用思维链输出过滤 + content = result["content"] + if filter_thinking_output: + content = filter_thinking_content(content) + return { "success": True, - "data": { - "description": http_result.get("content", "") - } + "data": {"description": content} } else: - return {"success": False, "error": http_result.get("error", "HTTP请求失败")} - - # 其余 provider 走 OpenAI 兼容 SDK - client = VisionService.get_openai_client(api_key, provider) - - # 添加手动重试机制,处理连接错误 - # 修改为重试1次后回退到HTTP(如果支持) - max_retries = 2 # 重试1次(总共2次尝试) - retry_delay = 1.0 # 初始重试延迟(秒) - last_error = None - - for attempt in range(max_retries): - try: - # 添加调试信息 - _thinking_extra = build_thinking_suppression(provider, model) - _thinking_tag = "(已关闭思维链)" if _thinking_extra else "" - from ..server import PROCESS_PREFIX - if attempt > 0: - print(f"{PROCESS_PREFIX} 重试视觉模型API调用{direct_mode_tag} | 尝试:{attempt + 1}/{max_retries} | 服务:{provider_display_name} | 模型:{model}{_thinking_tag}") - else: - print(f"{PROCESS_PREFIX} 调用视觉模型API{direct_mode_tag} | 服务:{provider_display_name} | 模型:{model}{_thinking_tag}") - start_time = time.perf_counter() + return result - # 使用配置中的参数 - try: - # 构建基础参数(保守:不带 response_format,默认流式) - create_kwargs = dict( - model=model, - messages=[{ - "role": "user", - "content": [ - {"type": "text", "text": system_prompt}, - {"type": "image_url", "image_url": {"url": image_data}} - ] - }], - max_tokens=max_tokens, - temperature=temperature, - top_p=top_p, - stream=True - ) - # 思维链控制:extra_body 或 顶层参数 - if _thinking_extra: - # Gemini 的 reasoning_effort 需要顶层;其余放入 extra_body - _extra = dict(_thinking_extra) - if "reasoning_effort" in _extra: - create_kwargs["reasoning_effort"] = _extra.pop("reasoning_effort") - if _extra: - create_kwargs["extra_body"] = _extra - response = await client.chat.completions.create(**create_kwargs) - except Exception as e_first: - msg = str(e_first).lower() - if "enable_thinking" in msg and "not support" in msg: - print(f"{PREFIX} 发现 enable_thinking 不被模型支持,移除后重试 | 模型:{model}") - create_kwargs.pop("extra_body", None) - response = await client.chat.completions.create(**create_kwargs) - elif ("response_format" in msg and ("unknown" in msg or "not support" in msg)) or ( - "stream" in msg and ("not support" in msg or "unsupported" in msg) - ): - # 去除不被支持的参数并关闭流式重试一次 - create_kwargs.pop("response_format", None) - create_kwargs["stream"] = False - response = await client.chat.completions.create(**create_kwargs) - elif "max_tokens" in msg and ("unknown" in msg or "invalid" in msg): - # 少数聚合网关可能要求 max_output_tokens - create_kwargs.pop("max_tokens", None) - create_kwargs["max_output_tokens"] = max_tokens - response = await client.chat.completions.create(**create_kwargs) - else: - raise + # 其他服务走HTTP直连 + if not base_url: + base_url = VisionService.get_provider_base_url(provider, custom_provider_config if custom_provider else None) + + # 构建消息(图像格式) + messages = [ + { + "role": "user", + "content": [ + {"type": "text", "text": system_prompt}, + {"type": "image_url", "image_url": {"url": processed_image}} + ] + } + ] + + # 检查disable_thinking、enable_advanced_params和filter_thinking_output配置 + from ..config_manager import config_manager + service = config_manager.get_service(provider) + disable_thinking_enabled = service.get('disable_thinking', True) if service else True + enable_advanced_params = service.get('enable_advanced_params', False) if service else False + filter_thinking_output = service.get('filter_thinking_output', True) if service else True + thinking_extra = build_thinking_suppression(provider, model) if disable_thinking_enabled else None + + result = await VisionService._http_request_chat_completions( + base_url=base_url, + api_key=api_key, + model=model, + messages=messages, + temperature=temperature, + top_p=top_p, + max_tokens=max_tokens, + thinking_extra=thinking_extra, + enable_advanced_params=enable_advanced_params, + stream_callback=stream_callback, + request_id=request_id, + provider_display_name=provider_display_name, + cancel_event=cancel_event, + task_type=task_type or TASK_IMAGE_CAPTION, + source=source + ) + + if result["success"]: + # 根据配置决定是否应用思维链输出过滤 + content = result["content"] + if filter_thinking_output: + content = filter_thinking_content(content) + return { + "success": True, + "data": {"description": content} + } + else: + return result + + except Exception as e: + return {"success": False, "error": format_api_error(e, "VLM服务")} + + @staticmethod + async def analyze_images( + images_data: List[str], + request_id: Optional[str] = None, + stream_callback: Optional[Callable[[str], None]] = None, + prompt_content: Optional[str] = None, + custom_provider: Optional[str] = None, + custom_provider_config: Optional[Dict[str, Any]] = None, + cancel_event: Optional[Any] = None, + task_type: str = None, + source: Optional[str] = None + ) -> Dict[str, Any]: + """ + 使用视觉模型分析多张图像 + + 参数: + images_data: 图像数据列表(Base64编码) + request_id: 请求ID + stream_callback: 流式输出回调 + prompt_content: 自定义提示词 + custom_provider: 自定义服务商 + custom_provider_config: 自定义配置 + + 返回: + Dict: {"success": bool, "data": {"description": str}, "error": str} + """ + try: + # 获取配置 + if custom_provider and custom_provider_config: + provider = custom_provider + api_key = custom_provider_config.get('api_key') + model = custom_provider_config.get('model') + temperature = custom_provider_config.get('temperature', 0.7) + top_p = custom_provider_config.get('top_p', 0.9) + max_tokens = custom_provider_config.get('max_tokens', 2000) + base_url = custom_provider_config.get('base_url', '') + else: + config = VisionService._get_config() + provider = config.get('provider', 'unknown') + api_key = config.get('api_key') + model = config.get('model') + temperature = config.get('temperature', 0.7) + top_p = config.get('top_p', 0.9) + max_tokens = config.get('max_tokens', 2000) + base_url = config.get('base_url', '') + + # 注:允许空API Key,支持无认证服务商 + if not model: + return {"success": False, "error": "未配置模型名称"} + + provider_display_name = VisionService.get_provider_display_name(provider) + + from ..utils.common import REQUEST_PREFIX, PREFIX, format_model_with_thinking + + # 检查服务配置以确定是否显示思维链标识 + from ..config_manager import config_manager + service = config_manager.get_service(provider) + disable_thinking_enabled = service.get('disable_thinking', True) if service else True + # 只有当开关开启且模型支持时才显示标识 + _thinking_check = build_thinking_suppression(provider, model) if disable_thinking_enabled else None + thinking_disabled = _thinking_check is not None + model_display = format_model_with_thinking(model, thinking_disabled) + + # 检查是否支持多图 + supports_multi, max_images = check_multi_image_support(provider, model) + + if not supports_multi: + return {"success": False, "error": f"模型 {model} 不支持多图像分析"} + + if len(images_data) > max_images: + return {"success": False, "error": f"图像数量 {len(images_data)} 超过模型限制 {max_images}"} + + # 预处理所有图像(智能压缩:根据图像数量动态调整质量) + img_count = len(images_data) + from ..utils.common import get_optimal_image_params + _, _, compression_level = get_optimal_image_params(img_count) + + # 使用 ProgressBar 管理预处理进度 + pbar = ProgressBar(request_id=request_id, service_name="图像预处理", streaming=False) + processed_images = [] + for idx, img in enumerate(images_data, 1): + processed = preprocess_image(img, request_id=request_id, silent=True, image_count=img_count) + processed_images.append(processed) + + pbar.done(f"{PREFIX} 🟡 预处理完成: {img_count}/{img_count} | 压缩:{compression_level}") + + # 获取系统提示词 + system_prompt = prompt_content or "请详细描述这些图片,分析它们之间的关系和差异。" + + # Ollama走原生API + if provider == 'ollama': + # 读取 Ollama 服务的配置 + from ..config_manager import config_manager + service = config_manager.get_service('ollama') + disable_thinking_enabled = service.get('disable_thinking', True) if service else True + enable_advanced_params = service.get('enable_advanced_params', False) if service else False + filter_thinking_output = service.get('filter_thinking_output', True) if service else True + _ollama_thinking_extra = build_thinking_suppression('ollama', model) if disable_thinking_enabled else None + + # 提前计算auto_unload配置 + native_base = base_url[:-3] if base_url.endswith('/v1') else (base_url or 'http://localhost:11434') + native_base = native_base.rstrip('/') + _cfg = { + 'auto_unload': custom_provider_config.get('auto_unload', True) if custom_provider_config else config.get('auto_unload', True), + 'base_url': native_base + } + auto_unload = _cfg['auto_unload'] + + # 提取纯base64 + b64_images = [img.split(',')[1] if ',' in img else img for img in processed_images] + + result = await VisionService._call_ollama_native_vision( + model=model, + system_prompt=system_prompt, + images_b64=b64_images, + temperature=temperature, + top_p=top_p, + max_tokens=max_tokens, + base_url=base_url, + stream_callback=stream_callback, + request_id=request_id, + is_multi=True, + auto_unload=auto_unload, + enable_advanced_params=enable_advanced_params, + thinking_extra=_ollama_thinking_extra, + cancel_event=cancel_event, + task_type=task_type or TASK_VIDEO_CAPTION, + source=source + ) + + if result["success"]: + # 注:卸载已在 _call_ollama_native_vision 的 finally 块中处理 + + # 应用思维链输出过滤 + content = result["content"] + if filter_thinking_output: + content = filter_thinking_content(content) - # 请求成功,处理流式响应 - full_content = "" - async for chunk in response: - # 兼容部分第三方网关返回空 choices 的异常片段,做健壮性判断 - choices = getattr(chunk, "choices", None) or [] - for ch in choices: - delta = getattr(ch, "delta", None) - if not delta: - continue - content = getattr(delta, "content", None) - if content: - full_content += content - if stream_callback: - stream_callback(content) - - # 输出结构化成功日志 - elapsed_ms = int((time.perf_counter() - start_time) * 1000) - # 根据request_id前缀判断节点类型 - if request_id.startswith('image_caption_'): - success_msg = "图像反推完成" - elif request_id.startswith('kontext_preset_'): - success_msg = "Kontext预设完成" - else: - success_msg = "图像反推成功" - print(f"{PREFIX} {success_msg} | 服务:{provider_display_name} | 请求ID:{request_id} | 结果字符数:{len(full_content)} | 耗时:{elapsed_ms}ms") - - # Ollama自动释放显存 - if provider == 'ollama': - # 构建配置字典用于释放 - provider_config = { - 'auto_unload': custom_provider_config.get('auto_unload', True) if custom_provider_config else config.get('auto_unload', True), - 'base_url': base_url if custom_provider_config else config.get('base_url', 'http://localhost:11434') - } - await VisionService._unload_ollama_model(model, provider_config) - return { "success": True, - "data": { - "description": full_content - } + "data": {"description": content} } - - except asyncio.CancelledError: - print(f"{PREFIX} 视觉模型分析任务在服务层被取消 | ID:{request_id}") - return {"success": False, "error": "请求已取消", "cancelled": True} - except Exception as e: - last_error = e - # 检查是否为连接错误 - error_msg = str(e).lower() - is_connection_error = any(keyword in error_msg for keyword in [ - 'connection', 'connect', 'timeout', 'timed out', - 'network', 'unreachable', 'refused' - ]) - - # 如果是最后一次尝试,检查是否需要回退到HTTP - if attempt == max_retries - 1: - # 如果支持HTTP回退,尝试使用HTTP方式 - if supports_http_fallback: - from ..server import PROCESS_PREFIX - print(f"{PROCESS_PREFIX} OpenAI SDK请求失败,使用HTTP方式重试{direct_mode_tag} | 服务:{provider_display_name} | 模型:{model}") - - base_url_local = None - if custom_provider and custom_provider_config: - base_url_local = custom_provider_config.get('base_url', '') - else: - base_url_local = config.get('base_url', '') - - # 构建消息(包含图像) - http_messages = [{ - "role": "user", - "content": [ - {"type": "text", "text": system_prompt}, - {"type": "image_url", "image_url": {"url": image_data}} - ] - }] - - http_result = await VisionService._http_request_chat_completions( - base_url=base_url_local, - api_key=api_key, - model=model, - messages=http_messages, - temperature=temperature, - top_p=top_p, - max_tokens=max_tokens, - thinking_extra=_thinking_extra, - stream_callback=stream_callback, - request_id=request_id, - provider_display_name=provider_display_name, - direct_mode_tag=direct_mode_tag - ) - - if http_result.get("success"): - return { - "success": True, - "data": { - "description": http_result.get("content", "") - } - } - else: - return {"success": False, "error": http_result.get("error", "HTTP请求失败")} - else: - # 不支持HTTP回退,直接返回错误 - return {"success": False, "error": format_api_error(e, provider_display_name)} - - # 如果不是最后一次尝试,且是连接错误,等待后重试 - if is_connection_error: - wait_time = retry_delay * (2 ** attempt) - from ..server import WARN_PREFIX - print(f"{WARN_PREFIX} 视觉模型API连接失败 | 尝试:{attempt + 1}/{max_retries} | 错误:{str(e)[:100]} | {wait_time:.1f}秒后重试") - await asyncio.sleep(wait_time) - continue - else: - # 不是连接错误,直接抛出 - raise + else: + return result + + # 其他服务走HTTP直连 + if not base_url: + base_url = VisionService.get_provider_base_url(provider, custom_provider_config if custom_provider else None) - # 如果所有尝试都失败且没有回退到HTTP,返回错误 - if last_error: - return {"success": False, "error": format_api_error(last_error, provider_display_name)} + # 构建多图消息 + content = [{"type": "text", "text": system_prompt}] + for img in processed_images: + content.append({"type": "image_url", "image_url": {"url": img}}) + + messages = [{"role": "user", "content": content}] + + # 检查disable_thinking、enable_advanced_params和filter_thinking_output配置 + from ..config_manager import config_manager + service = config_manager.get_service(provider) + disable_thinking_enabled = service.get('disable_thinking', True) if service else True + enable_advanced_params = service.get('enable_advanced_params', False) if service else False + filter_thinking_output = service.get('filter_thinking_output', True) if service else True + thinking_extra = build_thinking_suppression(provider, model) if disable_thinking_enabled else None + + result = await VisionService._http_request_chat_completions( + base_url=base_url, + api_key=api_key, + model=model, + messages=messages, + temperature=temperature, + top_p=top_p, + max_tokens=max_tokens, + thinking_extra=thinking_extra, + enable_advanced_params=enable_advanced_params, + stream_callback=stream_callback, + request_id=request_id, + provider_display_name=provider_display_name, + cancel_event=cancel_event, + task_type=task_type or TASK_VIDEO_CAPTION, + source=source + ) + + if result["success"]: + # 根据配置决定是否应用思维链输出过滤 + content = result["content"] + if filter_thinking_output: + content = filter_thinking_content(content) + return { + "success": True, + "data": {"description": content} + } else: - return {"success": False, "error": "未知错误"} + return result - except asyncio.CancelledError: - print(f"{PREFIX} 视觉分析任务在服务层被取消 | ID:{request_id}") - return {"success": False, "error": "请求已取消", "cancelled": True} except Exception as e: - print(f"{ERROR_PREFIX} 视觉分析过程异常 | 错误:{str(e)}") - return {"success": False, "error": str(e)} \ No newline at end of file + return {"success": False, "error": format_api_error(e, "VLM服务")} \ No newline at end of file diff --git a/utils/__init__.py b/utils/__init__.py new file mode 100644 index 0000000..8e18fbe --- /dev/null +++ b/utils/__init__.py @@ -0,0 +1,5 @@ +from . import common +from . import image +from . import video + +__all__ = ['common', 'image', 'video'] diff --git a/utils/common.py b/utils/common.py new file mode 100644 index 0000000..020b3e1 --- /dev/null +++ b/utils/common.py @@ -0,0 +1,841 @@ +""" +工具函数模块 +整合错误处理、图像处理、常量定义等通用工具 +""" + +import json +import base64 +import sys +import os +import shutil +import re +import io +from io import BytesIO +from PIL import Image +from typing import Optional, Dict, Any +import time +import random +import threading + +# 修复 Windows 终端编码问题 +# 解决 GBK 编码导致的 emoji 和特殊字符输出错误 +if sys.platform == 'win32' and sys.stdout.encoding != 'utf-8': + try: + sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace') + sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8', errors='replace') + except Exception: + pass # 静默失败,保持原有编码 + + +# ==================== 统一显示宽度计算 ==================== + +def get_display_width(text: str) -> int: + """ + 计算字符串在终端中的显示宽度(中文及部分 emoji 占2格,ASCII 占1格) + """ + width = 0 + for char in text: + # 常见的中文字符编码范围 + if ord(char) > 0x7F: + width += 2 + else: + width += 1 + return width + + +# ==================== 统一日志前缀常量 ==================== +# 所有模块从此处导入,确保日志格式一致 + +PREFIX = "✨" +ERROR_PREFIX = "✨-❌" +PROCESS_PREFIX = "✨" +REQUEST_PREFIX = "✨" +WARN_PREFIX = "✨-⚠️" + + +# ==================== 任务类型常量 ==================== +TASK_TRANSLATE = "翻译" +TASK_EXPAND = "提示词优化" +TASK_IMAGE_CAPTION = "图像反推" +TASK_VIDEO_CAPTION = "视频反推" + + +# ==================== 请求来源常量 ==================== +SOURCE_NODE = "节点-" +SOURCE_FRONTEND = "前端-" + + +# ==================== 统一日志消息函数 ==================== + +def log_prepare( + task_type: str, + request_id: str, + source: str, + service_name: str, + model_name: str = None, + rule_name: str = None, + extra: dict = None +) -> None: + """ + 输出统一格式的准备日志(换行输出) + + 格式: ✨ 🟡 {来源}{任务}准备 | 服务:{service} | 模型:{model} | 规则:{rule} | ID:{id} + """ + # 强制回到行首并清除当前行,确保不与之前的 progress 冲突 + print(f"\r{_ANSI_CLEAR_EOL}", end="") + + parts = [f"{PREFIX} 🟡 {source}{task_type}准备"] + parts.append(f"服务:{service_name}") + + if model_name: + parts.append(f"模型:{model_name}") + if rule_name: + parts.append(f"规则:{rule_name}") + + parts.append(f"ID:{request_id}") + + # 处理额外字段 + if extra: + for key, value in extra.items(): + parts.append(f"{key}:{value}") + + print(f"{parts[0]} | {' | '.join(parts[1:])}", flush=True) + + +def log_complete( + task_type: str, + request_id: str, + service_name: str, + char_count: int, + elapsed_ms: int, + model_unloaded: bool = None, + source: str = None +) -> None: + """ + 输出统一格式的完成日志(换行输出) + + 格式: ✨ ✅ {来源}{任务}完成 | 服务:{service} | ID:{id} | 字符:{count} | 耗时:{time} + """ + # 强制回到行首且不换行清空当前行,然后输出新消息 + print(f"\r{_ANSI_CLEAR_EOL}", end="") + + elapsed_str = format_elapsed_time(elapsed_ms) + source_str = source if source else "" + parts = [f"{PREFIX} ✅ {source_str}{task_type}完成"] + parts.append(f"服务:{service_name}") + parts.append(f"ID:{request_id}") + parts.append(f"字符:{char_count}") + parts.append(f"耗时:{elapsed_str}") + + # Ollama 模型卸载状态 + if model_unloaded is not None: + unload_text = "模型已卸载" if model_unloaded else "模型保留" + parts.append(unload_text) + + print(f"{parts[0]} | {' | '.join(parts[1:])}", flush=True) + + +def log_error( + task_type: str, + request_id: str, + error_msg: str, + source: str = None +) -> None: + """ + 输出统一格式的错误日志(换行输出) + """ + # 强制回到行首并清除当前行 + print(f"\r{_ANSI_CLEAR_EOL}", end="") + source_str = source if source else "" + print(f"{PREFIX} ❌ {source_str}{task_type}失败 | ID:{request_id} | 错误:{error_msg}", flush=True) + + +def generate_request_id(req_type: str, service_type: Optional[str] = None, node_id: str = "0") -> str: + """ + 生成统一格式的请求ID + 格式: 请求类型_服务类型(可选)_NodeID_四位时间戳 + 示例: trans_llm_12_3456 + """ + timestamp = str(int(time.time()))[-4:] + parts = [req_type] + if service_type: + parts.append(service_type) + parts.append(str(node_id)) + parts.append(timestamp) + return "_".join(parts) + + +# ---日志格式化辅助函数--- + +def simplify_model_name(model: str) -> str: + """ + 简化模型名称显示 + + 示例: + huihui_ai/qwen3-vl-abliterated:8b -> qwen3-vl-8b + huihui_ai/qwen3-abliterated:14b -> qwen3-14b + + 参数: + model: 完整模型名称 + + 返回: + 简化后的模型名称 + """ + if '/' in model: + model = model.split('/')[-1] + if ':' in model: + name, size = model.split(':') + # 移除常见后缀 + name = name.replace('-abliterated', '').replace('-instruct', '').replace('-chat', '') + return f"{name}-{size}" + return model + +def format_model_with_thinking(model: str, thinking_disabled: bool = False) -> str: + """ + 格式化模型名称,如果关闭思维链则添加🗯标识 + + 参数: + model: 模型名称 + thinking_disabled: 是否关闭思维链 + + 返回: + 格式化后的模型名称 + """ + simplified = simplify_model_name(model) + if thinking_disabled: + return f"{simplified}💭" + return simplified + +def format_elapsed_time(elapsed_ms: int) -> str: + """ + 格式化耗时显示 + + 参数: + elapsed_ms: 毫秒数 + + 返回: + 格式化后的时间字符串(如 "6.5s") + """ + return f"{elapsed_ms/1000:.1f}s" + + +# ====================进度日志系统==================== +# 统一的进度条管理器,支持单行覆盖刷新 + +# ---ANSI 控制序列--- +_ANSI_CLEAR_EOL = "\033[K" # 清除从光标位置到行末的内容 + +# ---全局状态:追踪上一次输出长度(使用锁保护以支持并发)--- +_global_last_output_len = 0 +_progress_lock = threading.Lock() + + +# ---Windows 虚拟终端初始化--- +def _enable_windows_vt(): + """ + 启用 Windows 虚拟终端处理 + 解决 Windows CMD/PowerShell 中 ANSI 转义序列兼容性问题 + """ + if os.name == 'nt': + try: + import ctypes + kernel32 = ctypes.windll.kernel32 + handle = kernel32.GetStdHandle(-11) # STD_OUTPUT_HANDLE + mode = ctypes.c_ulong() + kernel32.GetConsoleMode(handle, ctypes.byref(mode)) + kernel32.SetConsoleMode(handle, mode.value | 0x0004) # ENABLE_VIRTUAL_TERMINAL_PROCESSING + except Exception: + pass + +_enable_windows_vt() + + +class ProgressBar: + """ + 统一进度条管理器 + + 管理请求的完整生命周期:等待 → 生成 → 完成 + 通过 streaming 参数控制刷新频率: + - streaming=True: 高频刷新(每次更新都刷新) + - streaming=False: 仅在状态变化时刷新(等待→生成→完成) + + 两种模式都使用单行覆盖(\r),区别仅在于刷新频率 + """ + + # 状态常量 + STATE_WAITING = "waiting" + STATE_GENERATING = "generating" + STATE_DONE = "done" + def __init__( + self, + request_id: str, + service_name: str, + extra_info: str = None, + streaming: bool = True, + task_type: str = None, + source: str = None + ): + """ + 创建进度条 + + 参数: + request_id: 请求ID + service_name: 服务名称(如 Ollama, OpenAI) + extra_info: 额外信息(如 Context:2048 | Timeout:60s) + streaming: True=高频刷新,False=仅状态变化时刷新 + task_type: 任务类型(用于统一日志) + source: 来源(前端/节点) + """ + self._request_id = request_id + self._service_name = service_name + self._extra_info = extra_info + self._streaming = streaming + self._task_type = task_type + self._source = source + + self._state = self.STATE_WAITING + self._char_count = 0 + self._start_time = time.perf_counter() + self._closed = False + self._stop_event = threading.Event() + self._timer_thread = None + + # 1. 重置全局长度,开始新一轮进度跟踪 + with _progress_lock: + global _global_last_output_len + _global_last_output_len = 0 + + # 立即显示"等待响应" + self._refresh() + + # 仅在流式模式下启动定时刷新线程(非流式模式采用静态日志,无需跳秒刷新) + if self._streaming: + self._timer_thread = threading.Thread(target=self._timer_loop, daemon=True) + self._timer_thread.start() + + def _format_elapsed(self) -> str: + """格式化耗时""" + elapsed_sec = time.perf_counter() - self._start_time + if elapsed_sec < 60: + return f"{elapsed_sec:.1f}s" + else: + minutes = int(elapsed_sec // 60) + seconds = int(elapsed_sec % 60) + return f"{minutes}m{seconds}s" + + def _render(self) -> str: + """渲染当前进度条内容""" + elapsed = self._format_elapsed() + + if self._state == self.STATE_WAITING: + # 等待响应:✨ 🟠 等待Ollama响应... + # 流式模式下添加计时,非流式模式保持静态 + base = f"{PREFIX} 🟠 等待{self._service_name}响应..." + if not self._streaming: + return base + + if self._extra_info: + return f"{base} | {self._extra_info} | {elapsed}" + else: + return f"{base} | {elapsed}" + + elif self._state == self.STATE_GENERATING: + # 流式模式:显示字符数和时间 + # 静态模式:只显示简单的 "生成中..." + if self._streaming: + return f"{PREFIX} 🔵 生成中 | {self._char_count}字符 | {elapsed}" + else: + return f"{PREFIX} 🔵 生成中..." + + else: + return "" + + def _refresh(self) -> None: + """内部刷新方法:单行覆盖输出""" + if self._closed: + return + + output = self._render() + if not output: + return + + with _progress_lock: + global _global_last_output_len + # 计算当前内容的显示宽度(解决中文/emoji 导致的 len() 不准问题) + current_width = get_display_width(output) + + # 用空格填充以覆盖上一次更长的输出(兜底方案,应对 ANSI 失效) + padding = "" + if _global_last_output_len > current_width: + padding = " " * (_global_last_output_len - current_width) + + # 使用 \r 回到行首,先发一次 ANSI 清行(如果环境支持,瞬间清空) + # 再输出内容 + 空格填充(应对 ANSI 失效)+ 再次清行(防止尾部残留) + # 增加 2 个空格缓冲避免与其他日志粘连 + print(f"\r{_ANSI_CLEAR_EOL}{output}{padding}{_ANSI_CLEAR_EOL} ", end='', flush=True) + + # 记录本次显示的宽度(包含缓冲空格) + _global_last_output_len = current_width + len(padding) + + def _timer_loop(self): + """后台线程:仅在流式模式下定期刷新计时""" + while not self._stop_event.is_set() and not self._closed: + # 定时刷新当前内容(主要用于更新 WAITING 阶段的时间) + self._refresh() + + # 每 0.1 秒刷新一次 + if self._stop_event.wait(0.1): + break + + def set_generating(self, char_count: int = 0) -> None: + """ + 切换到"生成中"状态 + + 参数: + char_count: 当前字符数 + """ + if self._closed or self._state == self.STATE_GENERATING: + return + + self._state = self.STATE_GENERATING + self._char_count = char_count + self._refresh() # 状态变化时总是刷新 + + def update(self, char_count: int) -> None: + """ + 更新字符数 + + 流式模式:每次调用都刷新 + 静态模式:不刷新(避免刷屏) + + 参数: + char_count: 当前字符数 + """ + if self._closed: + return + + self._char_count = char_count + + # 流式模式:高频刷新 + if self._streaming: + self._refresh() + # 静态模式:不在这里刷新,只有状态变化时才刷新 + + def done(self, message: str = None, char_count: int = None, elapsed_ms: int = None) -> None: + """ + 完成请求 + + 参数: + message: 自定义完成消息(可选) + char_count: 最终字符数(可选) + elapsed_ms: 耗时毫秒(可选,不传则自动计算) + """ + if self._closed: + return + + self._closed = True + self._state = self.STATE_DONE + self._stop_event.set() + + # 重置全局长度 + with _progress_lock: + global _global_last_output_len + _global_last_output_len = 0 + + # 如果提供了 task_type,则使用统一日志 + if hasattr(self, '_task_type') and self._task_type: + log_complete( + self._task_type or "任务", + self._request_id, + self._service_name, + char_count if char_count is not None else self._char_count, + elapsed_ms if elapsed_ms is not None else int((time.perf_counter() - self._start_time) * 1000), + source=getattr(self, '_source', None) + ) + return + + # 降级兼容:原始 done 逻辑 + # 计算耗时 + if elapsed_ms is not None: + elapsed = format_elapsed_time(elapsed_ms) + else: + elapsed = self._format_elapsed() + + # 使用传入的字符数或当前记录的字符数 + final_count = char_count if char_count is not None else self._char_count + + # 生成完成消息 + if message: + final_msg = message + else: + final_msg = f"{PREFIX} ✅ 完成 | 服务:{self._service_name} | ID:{self._request_id} | 字符:{final_count} | 耗时:{elapsed}" + + # 直接调用 log_complete 的思想:换行输出,不覆盖之前的内容 + print(f"\r{_ANSI_CLEAR_EOL}{final_msg}", flush=True) + + def error(self, message: str) -> None: + """ + 输出错误消息(换行输出,不覆盖) + + 参数: + message: 错误消息 + """ + if self._closed: + return + + self._closed = True + self._stop_event.set() + + # 重置全局长度 + with _progress_lock: + global _global_last_output_len + _global_last_output_len = 0 + + # 如果提供了 task_type,则使用统一日志 + if hasattr(self, '_task_type') and self._task_type: + log_error(self._task_type or "任务", self._request_id, message, source=getattr(self, '_source', None)) + return + + # 降级模式 + print(f"\r{_ANSI_CLEAR_EOL}{message}", flush=True) + + def cancel(self, message: str = None) -> None: + """ + 取消请求(换行输出,不覆盖) + + 参数: + message: 自定义取消消息(可选) + """ + if self._closed: + return + + self._closed = True + self._stop_event.set() + + # 重置全局长度 + with _progress_lock: + global _global_last_output_len + _global_last_output_len = 0 + + cancel_msg = message or "任务被取消" + + # 如果提供了 task_type,则使用统一日志 + if hasattr(self, '_task_type') and self._task_type: + log_error(self._task_type or "任务", self._request_id, cancel_msg, source=getattr(self, '_source', None)) + return + + # 降级模式 + print(f"\r{_ANSI_CLEAR_EOL}{WARN_PREFIX} {cancel_msg} | ID:{self._request_id}", flush=True) + + def __enter__(self): + return self + + def __exit__(self, *args): + if not self._closed: + self.done() + + + + + + + + +# HTTP状态码到中文错误信息的映射 +HTTP_STATUS_CODE_MESSAGES = { + 400: "请求无效", + 401: "身份验证失败-请检查您的API Key是否正确。", + 403: "无权限访问-您没有权限访问此资源。", + 404: "请求的资源不存在", + 429: "请求频率过高-您已超出速率限制,请稍后再试。", + 500: "服务器内部错误- 服务提供商端发生未知问题。", + 502: "网关错误", + 503: "服务不可用- 服务器当前无法处理请求,请稍后重试。", + 504: "网关超时", +} + +# 百度翻译API的错误码映射 +BAIDU_ERROR_CODE_MESSAGES = { + '52001': '请求超时,请重试', + '52002': '系统错误,请重试', + '52003': '未授权用户,请检查appid是否正确或服务是否开通', + '54000': '必填参数为空,请检查是否少传参数', + '54001': '签名错误,请检查appid和secret_key是否正确', + '54003': '访问频率受限,请降低您的调用频率,或进行身份认证后切换为高级版/尊享版', + '54004': '账户余额不足,请前往管理控制台充值', + '54005': '长query请求频繁,请降低长query的发送频率,3s后再试', + '58000': '客户端IP非法,检查个人资料里填写的IP地址是否正确,可前往开发者信息-基本信息修改', + '58001': '译文语言方向不支持,检查译文语言是否在语言列表里', + '58002': '服务当前已关闭,请前往百度管理控制台开启服务', + '58003': '此IP已被封禁', + '90107': '认证未通过或未生效,请前往我的认证查看认证进度', + '20003': '请求内容存在安全风险', +} + + +# ---错误处理函数--- + +def _is_auth_error(error_text: str) -> bool: + """ + 检查错误信息是否为认证相关错误 + + 参数: + error_text: 错误文本(小写) + + 返回: + bool: 是否为认证错误 + """ + auth_keywords = [ + 'invalid token', + 'authorization', + 'authenticate', + 'api key', + 'api_key', + 'unauthorized', + 'auth failed', + 'invalid key', + 'missing key', + 'invalid credentials', + '身份验证', + '认证失败', + 'token' + ] + return any(keyword in error_text for keyword in auth_keywords) + +def format_api_error(e: Exception, provider_display_name: str) -> str: + """ + 格式化来自API的错误信息 + 纯httpx实现,不依赖openai库 + + 参数: + e: 异常对象 + provider_display_name: 服务商显示名称 + + 返回: + str: 格式化后的错误信息 + """ + # 处理httpx的HTTP错误 + try: + import httpx + if isinstance(e, httpx.HTTPStatusError): + status_code = e.response.status_code + message = HTTP_STATUS_CODE_MESSAGES.get(status_code, "未知HTTP错误") + + error_details_str = "" + detail_msg = "" + + try: + error_details = e.response.json() + detail_msg = error_details.get("message", "") + if isinstance(error_details.get("error"), dict): + detail_msg = error_details["error"].get("message", detail_msg) + + if detail_msg: + error_details_str = f" | 详情: {detail_msg}" + except (json.JSONDecodeError, AttributeError): + try: + if hasattr(e.response, 'text') and e.response.text: + detail_msg = e.response.text[:200] + error_details_str = f" | 原始响应: {detail_msg}" + except Exception: + pass + + # ---智能识别认证错误并提供友好提示--- + combined_error_text = f"{message} {detail_msg}".lower() + if status_code == 401 or _is_auth_error(combined_error_text): + return f"{provider_display_name} 认证失败: 未配置API Key或API Key无效,请在服务商配置中填写正确的API Key" + + return f"{provider_display_name} API错误: {message} (状态码: {status_code}){error_details_str}" + except Exception: + pass + + # 对于其他类型的异常,返回其类型和基本信息 + return f"{provider_display_name} 服务请求异常: ({type(e).__name__}) {str(e)}" + + +def format_baidu_translate_error(error_data: dict) -> str: + """ + 格式化百度翻译API的错误信息 + + 参数: + error_data: 百度API返回的错误数据 + + 返回: + str: 格式化后的错误信息 + """ + if not isinstance(error_data, dict): + return "未知的百度翻译错误格式" + + error_code = str(error_data.get('error_code')) + if error_code in BAIDU_ERROR_CODE_MESSAGES: + return f"百度翻译错误: {BAIDU_ERROR_CODE_MESSAGES[error_code]} (代码: {error_code})" + + error_msg = error_data.get('error_msg', '未知错误') + return f"百度翻译错误: {error_msg} (代码: {error_code})" + + +# ---图像处理函数--- + +def get_optimal_image_params(image_count: int = 1) -> tuple: + """ + 根据图像数量智能计算最佳的分辨率和质量参数 + 目标:保证API能返回完整结果,同时尽可能保持图像质量 + + 参数: + image_count: 图像数量 (1-32) + + 返回: + tuple: (max_size: tuple, quality: int, compression_level: str) + """ + if image_count <= 1: + # 单图:使用中等质量 + return (1024, 1024), 75, "中等" + elif image_count <= 3: + # 1-3帧:保持较高质量 + return (1024, 1024), 70, "较高" + elif image_count <= 6: + # 4-6帧:降低分辨率,保持中等质量 + return (768, 768), 70, "中等" + elif image_count <= 10: + # 7-10帧:进一步降低分辨率和质量 + return (640, 640), 65, "较低" + elif image_count <= 16: + # 11-16帧:使用低分辨率 + return (512, 512), 60, "低" + else: + # 17-32帧:最大压缩,保证能处理 + return (480, 480), 55, "极低" + + +def preprocess_image( + image_data: str, + max_size: tuple = None, # 改为可选,支持自动计算 + quality: int = None, # 改为可选,支持自动计算 + request_id: Optional[str] = None, + silent: bool = False, + image_count: int = 1 # 新增:总图像数量,用于动态调整 +) -> str: + """ + 预处理图像数据(压缩和调整大小) + + 参数: + image_data: Base64编码的图像数据 + max_size: 最大尺寸,默认为None(自动计算) + quality: JPEG压缩质量 (1-100),默认为None(自动计算) + request_id: 请求ID,用于日志输出 + silent: 是否静默模式(不输出日志) + image_count: 总图像数量,用于多图场景的智能优化 + + 返回: + str: 处理后的图像数据 + """ + try: + # 智能计算最佳参数 + if max_size is None or quality is None: + optimal_size, optimal_quality, compression_level = get_optimal_image_params(image_count) + max_size = max_size or optimal_size + quality = quality or optimal_quality + else: + compression_level = "自定义" + + # 检查是否为base64编码的图像数据 + if image_data.startswith('data:image'): + # 提取base64数据 + header, encoded = image_data.split(",", 1) + image_bytes = base64.b64decode(encoded) + original_bytes = len(image_bytes) + + # 打开图像 + img = Image.open(BytesIO(image_bytes)) + original_size = img.size + + # 计算缩放比例 + if img.size[0] > max_size[0] or img.size[1] > max_size[1]: + img.thumbnail(max_size, Image.Resampling.LANCZOS) + + # 转换为RGB(如果是RGBA) + if img.mode in ('RGBA', 'LA', 'P'): + background = Image.new('RGB', img.size, (255, 255, 255)) + if img.mode == 'P': + img = img.convert('RGBA') + background.paste(img, mask=img.split()[-1] if img.mode in ('RGBA', 'LA') else None) + img = background + + # 压缩图像 + buffer = BytesIO() + img.save(buffer, format="JPEG", quality=quality, optimize=True) + compressed_bytes = buffer.getvalue() + compressed_size = len(compressed_bytes) + + # 编码为base64 + compressed_b64 = base64.b64encode(compressed_bytes).decode('utf-8') + processed_image_data = f"data:image/jpeg;base64,{compressed_b64}" + + # 输出日志 + if not silent: + compression_ratio = (1 - compressed_size / original_bytes) * 100 if original_bytes > 0 else 0 + + # 多图场景显示压缩等级 + if image_count > 1: + print( + f"{REQUEST_PREFIX} 🟡 图像预处理 | " + f"尺寸:{original_size}→{img.size} | " + f"大小:{original_bytes/1024:.1f}KB→{compressed_size/1024:.1f}KB | " + f"压缩率:{compression_ratio:.1f}% | " + f"等级:{compression_level} ({image_count}帧)" + ) + else: + print( + f"{REQUEST_PREFIX} 🟡 图像预处理完成 | " + f"尺寸:{original_size}→{img.size} | " + f"大小:{original_bytes/1024:.1f}KB→{compressed_size/1024:.1f}KB | " + f"压缩率:{compression_ratio:.1f}%" + ) + + return processed_image_data + + # 如果不是base64编码的图像数据,直接返回 + return image_data + + except Exception as e: + if not silent: + print(f"{WARN_PREFIX} ❌图像预处理失败 | 请求ID:{request_id} | 错误:{str(e)}") + # 预处理失败时返回原始图像数据 + return image_data + + +def check_multi_image_support(provider: str, model: str) -> tuple: + """ + 检查服务商是否支持多图像分析 + + 参数: + provider: 服务商标识 + model: 模型名称 + + 返回: + tuple: (支持多图像: bool, 最大图像数: int) + """ + model_lower = (model or "").lower() + + # Gemini系列:支持多图像 + if "gemini" in model_lower or "google" in model_lower: + return (True, 3000) + + # 智谱GLM-4V系列:支持多图像(最多5张) + if "glm" in model_lower and ("4v" in model_lower or "vision" in model_lower): + return (True, 5) + + # Qwen系列:支持多图像 + if "qwen" in model_lower and ("vl" in model_lower or "vision" in model_lower): + return (True, 100) + + # OpenAI GPT-4V及兼容模型:支持多图像 + if "gpt-4" in model_lower and ("vision" in model_lower or "v" in model_lower or "turbo" in model_lower): + return (True, 100) + + # 其他OpenAI兼容的视觉模型 + if any(keyword in model_lower for keyword in ["vision", "visual", "vl", "multimodal"]): + return (True, 10) + + # 默认:不支持多图像 + return (False, 0) diff --git a/utils/image.py b/utils/image.py new file mode 100644 index 0000000..ac052fc --- /dev/null +++ b/utils/image.py @@ -0,0 +1,80 @@ +""" +图像处理工具模块 +提供图像tensor转换、哈希计算等通用图像处理方法 +""" + +import base64 +import hashlib +from io import BytesIO +from typing import Optional + +import numpy as np +import torch +from PIL import Image + + +def tensor_to_base64(image_tensor: torch.Tensor, quality: int = 95) -> str: + """ + 将图像tensor转换为base64编码的data URL + + 参数: + image_tensor: 图像tensor,形状为 [H, W, C] 或 [B, H, W, C] + quality: JPEG压缩质量 (1-100),默认95 + + 返回: + base64编码的data URL,格式: "data:image/jpeg;base64,..." + """ + # 如果是4D tensor,取第一张图片 + if len(image_tensor.shape) == 4: + image_tensor = image_tensor[0] + + # 转换为numpy数组并缩放到0-255范围 + image_np = (image_tensor.cpu().numpy() * 255).astype(np.uint8) + + # 创建PIL图像 + image = Image.fromarray(image_np) + + # 转换为JPEG格式的字节流 + buffer = BytesIO() + image.save(buffer, format="JPEG", quality=quality) + + # 转换为base64编码 + encoded_image = base64.b64encode(buffer.getvalue()).decode('utf-8') + + # 返回带有MIME类型的data URL + return f"data:image/jpeg;base64,{encoded_image}" + + +def compute_image_hash(image_tensor: Optional[torch.Tensor]) -> str: + """ + 计算图像tensor的哈希值,用于 IS_CHANGED 检测 + + 算法: 只使用第一帧的中心区域作为哈希计算依据,避免计算量过大 + + 参数: + image_tensor: 图像tensor,形状为 [B, H, W, C] 或 None + + 返回: + MD5哈希值的十六进制字符串,如果输入为None或计算失败则返回 "0" + """ + if image_tensor is None: + return "0" + + try: + if len(image_tensor.shape) == 4: + # 取第一帧的中心区域作为哈希计算依据 + h, w = image_tensor.shape[1:3] + center_h, center_w = h // 2, w // 2 + size = min(100, h // 4, w // 4) # 限制计算区域大小 + + img_data = image_tensor[0, + max(0, center_h - size):min(h, center_h + size), + max(0, center_w - size):min(w, center_w + size), + 0].cpu().numpy().tobytes() + return hashlib.md5(img_data).hexdigest() + else: + # 如果不是4D tensor,使用整个tensor的哈希 + img_data = image_tensor.cpu().numpy().tobytes() + return hashlib.md5(img_data).hexdigest() + except Exception: + return "0" diff --git a/utils/migration_tool.py b/utils/migration_tool.py new file mode 100644 index 0000000..7fe2c7d --- /dev/null +++ b/utils/migration_tool.py @@ -0,0 +1,1049 @@ +""" +数据迁移工具 + +用于处理旧版本配置文件到新版本的迁移 +按需调用,不影响正常运行性能 +""" + +import os +import json +import csv + + +class MigrationTool: + """数据迁移工具类""" + + def __init__(self, plugin_dir, user_base_dir, logger=None): + """ + 初始化迁移工具 + + 参数: + plugin_dir: 插件目录路径 + user_base_dir: 用户配置基础目录 + logger: 日志函数(可选) + """ + self.plugin_dir = plugin_dir + self.user_base_dir = user_base_dir + if logger: + self._log_func = logger + else: + def default_logger(msg): + from .common import _ANSI_CLEAR_EOL + print(f"\r{_ANSI_CLEAR_EOL}{msg}", flush=True) + self._log_func = default_logger + + # 定义路径 + self.legacy_config_dir = os.path.join(plugin_dir, "config") + self.config_dir = os.path.join(user_base_dir, "config") + self.tags_dir = os.path.join(user_base_dir, "tags") + self.rules_dir = os.path.join(user_base_dir, "rules") + + def _log(self, msg: str): + """统一日志调用层""" + self._log_func(msg) + + # ---版本比对工具--- + def _compare_versions(self, v1: str, v2: str) -> int: + """ + 比较两个版本号 + + 返回: + 1: v1 > v2 + 0: v1 == v2 + -1: v1 < v2 + """ + def parse(v): + return [int(x) for x in str(v).split('.')] + p1, p2 = parse(v1), parse(v2) + # 补齐长度 + max_len = max(len(p1), len(p2)) + p1.extend([0] * (max_len - len(p1))) + p2.extend([0] * (max_len - len(p2))) + for a, b in zip(p1, p2): + if a > b: return 1 + if a < b: return -1 + return 0 + + # ---config.json 专用迁移--- + def ensure_config_json_exists(self, file_path: str, default_data: dict, legacy_path: str = None) -> bool: + """ + 确保 config.json 存在(专用迁移逻辑) + + 逻辑: + 1. 文件存在 → 跳过 + 2. 文件不存在 + 旧文件存在 → 提取 API Key 和模型信息,映射到新服务商 + 3. 文件不存在 + 无旧文件 → 创建默认配置 + """ + if os.path.exists(file_path): + return False + + os.makedirs(os.path.dirname(file_path), exist_ok=True) + + # 检查旧版本文件 + if legacy_path and os.path.exists(legacy_path): + try: + with open(legacy_path, 'r', encoding='utf-8') as f: + legacy_data = json.load(f) + + # 执行专用迁移:提取 API Key 和模型信息 + migrated_data = self._migrate_config_api_keys_to_services(legacy_data, default_data) + self._save_with_version(file_path, migrated_data, default_data) + self._log("[config.json] 从插件目录迁移旧文件完成(已提取 API Key 和模型信息)") + return True + except Exception as e: + self._log(f"[config.json] 迁移旧文件失败: {str(e)},使用默认配置") + + # 创建默认配置 + self._log("[config.json] 文件不存在,创建默认配置...") + self._save_with_version(file_path, default_data, default_data) + return True + + def _migrate_config_api_keys_to_services(self, legacy_data: dict, default_data: dict) -> dict: + """ + 从旧配置提取 API Key 和模型信息,映射到新配置的服务商 + + 逻辑: + 1. 遍历旧配置中有 API Key 的服务 + 2. 在新配置中查找对应的服务商 ID + 3. 如果找到,填入 API Key 和模型信息 + 4. 如果找不到,创建新服务商 + """ + import copy + result = copy.deepcopy(default_data) + + # 提取旧配置中的服务信息 + legacy_services = legacy_data.get('model_services', []) + if not legacy_services: + # 兼容更旧的格式(llm/vlm providers) + legacy_services = self._extract_legacy_providers(legacy_data) + + # 构建新配置服务商的 ID 映射 + new_services = result.get('model_services', []) + service_id_map = {s.get('id'): i for i, s in enumerate(new_services)} + + for legacy_service in legacy_services: + api_key = legacy_service.get('api_key', '').strip() + if not api_key: + continue + + service_id = legacy_service.get('id', '') + + if service_id in service_id_map: + # 服务商存在,更新 API Key 和模型信息 + idx = service_id_map[service_id] + new_services[idx]['api_key'] = api_key + + # 迁移模型信息 + for model_type in ['llm_models', 'vlm_models']: + legacy_models = legacy_service.get(model_type, []) + if legacy_models: + new_services[idx][model_type] = legacy_models + + # 迁移其他配置 + for key in ['base_url', 'auto_unload', 'disable_thinking', 'enable_advanced_params', 'filter_thinking_output']: + if key in legacy_service: + new_services[idx][key] = legacy_service[key] + + self._log(f"[config.json] 迁移服务商: {service_id}") + else: + # 服务商不存在,创建新服务商 + new_service = { + 'id': service_id, + 'type': legacy_service.get('type', 'openai_compatible'), + 'name': legacy_service.get('name', service_id), + 'description': legacy_service.get('description', f'{service_id}(从旧版迁移)'), + 'base_url': legacy_service.get('base_url', ''), + 'api_key': api_key, + 'disable_thinking': legacy_service.get('disable_thinking', True), + 'enable_advanced_params': legacy_service.get('enable_advanced_params', True), + 'filter_thinking_output': legacy_service.get('filter_thinking_output', True), + 'llm_models': legacy_service.get('llm_models', []), + 'vlm_models': legacy_service.get('vlm_models', []) + } + new_services.append(new_service) + self._log(f"[config.json] 创建新服务商: {service_id}") + + # 迁移 current_services + if 'current_services' in legacy_data: + result['current_services'] = legacy_data['current_services'] + + # 迁移百度翻译配置 + if 'baidu_translate' in legacy_data: + result['baidu_translate'] = legacy_data['baidu_translate'] + + result['model_services'] = new_services + return result + + def _extract_legacy_providers(self, legacy_data: dict) -> list: + """ + 从更旧的配置格式(llm/vlm providers)提取服务信息 + + 兼容 v1.0 格式: + { + "llm": {"providers": {"zhipu": {...}, "custom": {...}}}, + "vlm": {"providers": {"zhipu": {...}}} + } + """ + services = [] + provider_map = {} # 用于合并同一个 provider 的 llm 和 vlm 配置 + + for service_type in ['llm', 'vlm']: + if service_type not in legacy_data: + continue + providers = legacy_data[service_type].get('providers', {}) + + for provider_name, provider_config in providers.items(): + api_key = provider_config.get('api_key', '').strip() + if not api_key: + continue + + if provider_name not in provider_map: + provider_map[provider_name] = { + 'id': provider_name, + 'type': 'openai_compatible', + 'name': provider_config.get('name', provider_name), + 'base_url': provider_config.get('base_url', ''), + 'api_key': api_key, + 'llm_models': [], + 'vlm_models': [] + } + + # 添加模型信息 + model_name = provider_config.get('model', '') + if model_name: + models_key = f'{service_type}_models' + provider_map[provider_name][models_key].append({ + 'name': model_name, + 'display_name': '', + 'is_default': True, + 'temperature': provider_config.get('temperature', 0.7), + 'max_tokens': provider_config.get('max_tokens', 1000), + 'top_p': provider_config.get('top_p', 0.9) + }) + + services = list(provider_map.values()) + return services + + + # ---统一保存方法--- + def _save_with_version(self, file_path: str, data: dict, default_data: dict) -> bool: + """ + 保存配置文件,自动处理版本号 + + 参数: + file_path: 目标文件路径 + data: 要保存的数据 + default_data: 默认配置(用于获取版本号) + + 返回: + bool: 保存成功返回 True + """ + try: + # 确保版本号存在且在开头 + version = data.get('__config_version') or default_data.get('__config_version', '2.0') + data = {'__config_version': version, **{k: v for k, v in data.items() if k != '__config_version'}} + + with open(file_path, 'w', encoding='utf-8') as f: + json.dump(data, f, ensure_ascii=False, indent=2) + return True + except Exception as e: + self._log(f"保存文件失败 [{os.path.basename(file_path)}]: {str(e)}") + return False + + def _ensure_simple_config(self, file_path: str, default_data: dict, file_desc: str = "config") -> bool: + """ + 简单确保配置文件存在(不进行版本管理和迁移) + + 逻辑: + - 文件存在 → 跳过 + - 文件不存在 → 创建默认配置(不含版本号) + + 适用于: active_prompts.json, tags_user.json 等简单配置文件 + """ + if os.path.exists(file_path): + return False + + os.makedirs(os.path.dirname(file_path), exist_ok=True) + + try: + # 移除版本号(这些文件不需要版本管理) + data_to_save = {k: v for k, v in default_data.items() if not k.startswith('__')} + + with open(file_path, 'w', encoding='utf-8') as f: + json.dump(data_to_save, f, ensure_ascii=False, indent=2) + self._log(f"[{file_desc}] 文件不存在,创建默认配置...") + return True + except Exception as e: + self._log(f"[{file_desc}] 创建默认配置失败: {str(e)}") + return False + + # ---配置文件确保存在--- + def ensure_config_exists(self, file_path: str, default_data: dict, legacy_path: str = None, file_desc: str = "config") -> bool: + """ + 确保单个配置文件存在 + + 逻辑: + 1. 文件存在 → 跳过(增量更新由 migrate_incremental_updates 处理) + 2. 文件不存在 + 旧文件存在 → 迁移旧文件,与默认配置合并,添加版本号 + 3. 文件不存在 + 无旧文件 → 创建默认配置 + + 参数: + file_path: 目标文件路径 + default_data: 默认配置数据 + legacy_path: 旧版本文件路径(可选) + file_desc: 文件描述(用于日志) + + 返回: + bool: True 表示创建了新文件,False 表示文件已存在 + """ + if os.path.exists(file_path): + return False + + # 确保目录存在 + os.makedirs(os.path.dirname(file_path), exist_ok=True) + + # 检查是否有旧版本文件 + if legacy_path and os.path.exists(legacy_path): + try: + with open(legacy_path, 'r', encoding='utf-8') as f: + legacy_data = json.load(f) + + # 与默认配置合并,补全缺失的字段 + merged_data = self._merge_with_defaults(legacy_data, default_data, file_desc) + self._save_with_version(file_path, merged_data, default_data) + self._log(f"[{file_desc}] 从插件目录迁移旧文件完成") + return True + except Exception as e: + self._log(f"[{file_desc}] 迁移旧文件失败: {str(e)},使用默认配置") + + # 创建默认配置 + self._log(f"[{file_desc}] 文件不存在,创建默认配置...") + self._save_with_version(file_path, default_data, default_data) + return True + + def _merge_with_defaults(self, user_data: dict, default_data: dict, file_desc: str = "") -> dict: + """ + 将用户数据与默认配置合并,补全缺失的字段 + + 参数: + user_data: 用户数据(旧版本) + default_data: 默认配置 + file_desc: 文件描述(用于日志) + + 返回: + 合并后的数据 + """ + import copy + result = copy.deepcopy(default_data) + + # 递归合并用户数据到结果中(用户数据优先) + self._recursive_merge(result, user_data, file_desc) + + return result + + def _recursive_merge(self, base: dict, overlay: dict, file_desc: str = "", path: str = ""): + """ + 递归合并:将 overlay 的值覆盖到 base 中(保留 base 的结构) + + 策略: + - overlay 中存在的键覆盖 base 中的值 + - 如果都是 dict,递归合并 + - 跳过版本字段(由 _save_with_version 处理) + """ + for key, value in overlay.items(): + # 跳过版本字段 + if key.startswith("__"): + continue + + current_path = f"{path}.{key}" if path else key + + if key in base: + if isinstance(base[key], dict) and isinstance(value, dict): + # 递归合并嵌套字典 + self._recursive_merge(base[key], value, file_desc, current_path) + else: + # 直接覆盖(用户值优先) + base[key] = value + else: + # overlay 中有但 base 中没有的键,直接添加(用户自定义内容) + base[key] = value + + def ensure_all_configs_exist(self, default_configs: dict, legacy_dir: str): + """ + 确保所有配置文件存在 + + 参数: + default_configs: 默认配置字典 + legacy_dir: 旧版本文件目录 + """ + # config.json(使用专用迁移方法) + if 'config' in default_configs: + self.ensure_config_json_exists( + os.path.join(self.config_dir, "config.json"), + default_configs['config'], + os.path.join(legacy_dir, "config.json") + ) + + # system_prompts.json + if 'system_prompts' in default_configs: + self.ensure_config_exists( + os.path.join(self.rules_dir, "system_prompts.json"), + default_configs['system_prompts'], + os.path.join(legacy_dir, "system_prompts.json"), + "system_prompts.json" + ) + + # active_prompts.json 和 tags_user.json 不需要版本管理和迁移, + # 直接在文件不存在时创建默认配置 + self._ensure_simple_config( + os.path.join(self.config_dir, "active_prompts.json"), + default_configs.get('active_prompts', {}), + "active_prompts.json" + ) + + self._ensure_simple_config( + os.path.join(self.config_dir, "tags_user.json"), + default_configs.get('tags_user', {"favorites": []}), + "tags_user.json" + ) + + # kontext_presets.json + if 'kontext_presets' in default_configs: + self.ensure_config_exists( + os.path.join(self.rules_dir, "kontext_presets.json"), + default_configs['kontext_presets'], + os.path.join(legacy_dir, "kontext_presets.json"), + "kontext_presets.json" + ) + + def migrate_incremental_updates(self, default_configs): + """ + 执行增量更新:将默认配置中的新字段添加到用户配置中 + + 参数: + default_configs: 包含各类默认配置的字典 + { + 'config': ..., + 'system_prompts': ..., + 'kontext_presets': ... + } + """ + try: + results = {} + + # 1. 更新 config.json + if 'config' in default_configs: + results['config_update'] = self._update_config_json(default_configs['config']) + + # 2. 更新 system_prompts.json + if 'system_prompts' in default_configs: + results['system_prompts_update'] = self._update_json_file( + os.path.join(self.rules_dir, "system_prompts.json"), + default_configs['system_prompts'], + "system_prompts" + ) + + # active_prompts.json 和 tags_user.json 不需要增量更新 + # (结构简单,没有版本管理需求) + + # 5. 更新 kontext_presets.json + if 'kontext_presets' in default_configs: + results['kontext_presets_update'] = self._update_json_file( + os.path.join(self.rules_dir, "kontext_presets.json"), + default_configs['kontext_presets'], + "kontext_presets" + ) + + return results + + except Exception as e: + self._log(f"增量更新失败: {str(e)}") + return {} + + def _update_config_json(self, default_config): + """ + 处理 config.json 的增量更新 (带版本检查) + + 合并策略(与其他配置文件统一): + 1. 版本比对 + 2. 根级字段: 使用通用的 _deep_merge_defaults 补全 + 3. model_services: 按 id 匹配,只补全用户已有服务的缺失字段(不追加新服务) + 4. 完成后同步版本号 + """ + user_config_path = os.path.join(self.config_dir, "config.json") + if not os.path.exists(user_config_path): + return False + + try: + with open(user_config_path, 'r', encoding='utf-8') as f: + user_config = json.load(f) + + # 获取版本号 + template_version = default_config.get('__config_version', '2.0') + user_version = user_config.get('__config_version') # 不设默认值 + + # 如果用户文件没有版本号,只补上版本号(静默跳过) + if user_version is None: + user_config = {'__config_version': template_version, **user_config} + with open(user_config_path, 'w', encoding='utf-8') as f: + json.dump(user_config, f, ensure_ascii=False, indent=2) + return True + + # 版本比对:模板版本 <= 用户版本时跳过 + cmp_result = self._compare_versions(template_version, user_version) + if cmp_result <= 0: + return False + + import copy + + # 1. 根级字段补全(排除 model_services,单独处理) + for key, value in default_config.items(): + if key == "model_services": + continue # model_services 单独处理 + if key not in user_config: + user_config[key] = copy.deepcopy(value) + self._log(f"[config.json] 补全根字段: {key}") + elif isinstance(value, dict) and isinstance(user_config[key], dict): + # 递归合并嵌套字典(如 baidu_translate、current_services) + self._deep_merge_defaults(user_config[key], value) + + # 2. model_services 按 id 匹配合并 + self._merge_model_services(user_config, default_config) + + # 更新版本号(重构字典确保版本号在开头) + user_config = {'__config_version': template_version, **{k: v for k, v in user_config.items() if k != '__config_version'}} + + with open(user_config_path, 'w', encoding='utf-8') as f: + json.dump(user_config, f, ensure_ascii=False, indent=2) + self._log(f"[config.json] 增量更新已完成 (v{user_version} -> v{template_version})") + return True + + except Exception as e: + self._log(f"[config.json] 更新检查出错: {str(e)}") + return False + + def _merge_model_services(self, user_config, default_config): + """ + 按 id 匹配合并 model_services(完整策略) + + 策略: + - 补全用户已有服务的缺失字段 + - 追加模板中用户不存在的服务商(版本更新时的新服务商) + - 不覆盖 llm_models/vlm_models(用户自定义的模型列表) + """ + if 'model_services' not in default_config: + return + + if 'model_services' not in user_config: + user_config['model_services'] = [] + + # 构建用户服务的 id 集合 + user_service_ids = {s.get('id') for s in user_config['model_services'] if s.get('id')} + + import copy + + # 1. 补全用户已有服务的缺失字段 + template_services_map = { + s.get('id'): s for s in default_config['model_services'] if s.get('id') + } + + for user_service in user_config['model_services']: + service_id = user_service.get('id') + if not service_id or service_id not in template_services_map: + continue + + template_service = template_services_map[service_id] + service_name = user_service.get('name', service_id) + + # 补全服务级别的缺失字段 + for key, value in template_service.items(): + if key in ['llm_models', 'vlm_models']: + # 模型列表不补全(用户自定义) + continue + if key not in user_service: + user_service[key] = copy.deepcopy(value) + self._log(f"[config.json] 补全服务 '{service_name}' 字段: {key}") + + # 2. 追加模板中用户不存在的服务商 + for template_service in default_config['model_services']: + service_id = template_service.get('id') + if not service_id or service_id in user_service_ids: + continue + + # 追加新服务商 + new_service = copy.deepcopy(template_service) + user_config['model_services'].append(new_service) + self._log(f"[config.json] 追加新服务商: {new_service.get('name', service_id)}") + + def _update_json_file(self, file_path, default_data, file_desc): + """ + 通用的 JSON 文件增量更新 (带版本检查) + + 逻辑: + 1. 检查用户文件是否有版本号 + 2. 如果没有版本号,只补上当前模板版本号(跳过增量更新) + 3. 如果有版本号,比对模板版本和用户版本 + 4. 仅当模板版本 > 用户版本时执行增量 + 5. 增量完成后同步用户版本号 + """ + if not os.path.exists(file_path): + return False + + try: + with open(file_path, 'r', encoding='utf-8') as f: + user_data = json.load(f) + + # 获取版本号 + template_version = default_data.get('__config_version', '2.0') + user_version = user_data.get('__config_version') # 不设默认值 + + # 如果用户文件没有版本号,只补上版本号(静默处理) + if user_version is None: + user_data = {'__config_version': template_version, **user_data} + self._save_with_version(file_path, user_data, default_data) + return True + + # 版本比对:模板版本 <= 用户版本时跳过 + cmp_result = self._compare_versions(template_version, user_version) + if cmp_result <= 0: + return False + + # 执行深度合并 + modified = self._deep_merge_defaults(user_data, default_data) + + # ---特殊处理:为 system_prompts 中的所有规则补全 category 和 showIn 字段--- + if file_desc == "system_prompts": + modified = self._ensure_prompts_have_category(user_data) or modified + modified = self._ensure_prompts_have_show_in(user_data) or modified + + # 无论是否有字段变更,都需要更新版本号(重构字典确保版本号在开头) + user_data = {'__config_version': template_version, **{k: v for k, v in user_data.items() if k != '__config_version'}} + + with open(file_path, 'w', encoding='utf-8') as f: + json.dump(user_data, f, ensure_ascii=False, indent=2) + self._log(f"[{file_desc}.json] 增量更新已完成 (v{user_version} -> v{template_version})") + return True + + except Exception as e: + self._log(f"[{file_desc}.json] 更新检查出错: {str(e)}") + return False + + def _ensure_prompts_have_category(self, system_prompts_data): + """ + 确保 system_prompts 中的所有规则都有 category 字段 + + 为所有规则类型(expand_prompts、vision_prompts、video_prompts)中的每个规则 + 补全 category 字段(默认值为空字符串) + + 返回是否发生了修改 + """ + modified = False + + # 需要处理的规则类型 + prompt_types = ['expand_prompts', 'vision_prompts', 'video_prompts'] + + for prompt_type in prompt_types: + if prompt_type not in system_prompts_data: + continue + + prompts = system_prompts_data[prompt_type] + if not isinstance(prompts, dict): + continue + + for prompt_id, prompt_data in prompts.items(): + if not isinstance(prompt_data, dict): + continue + + # 为规则补全 category 字段 + if 'category' not in prompt_data: + prompt_data['category'] = '' + modified = True + + return modified + + def _ensure_prompts_have_show_in(self, system_prompts_data): + """ + 确保 system_prompts 中的所有规则都有 showIn 字段 + + 为所有规则类型(expand_prompts、vision_prompts、video_prompts)中的每个规则 + 补全 showIn 字段(默认值为 ["frontend", "node"]) + + 返回是否发生了修改 + """ + modified = False + + # 需要处理的规则类型 + prompt_types = ['expand_prompts', 'vision_prompts', 'video_prompts'] + + for prompt_type in prompt_types: + if prompt_type not in system_prompts_data: + continue + + prompts = system_prompts_data[prompt_type] + if not isinstance(prompts, dict): + continue + + for prompt_id, prompt_data in prompts.items(): + if not isinstance(prompt_data, dict): + continue + + # 为规则补全 showIn 字段 + if 'showIn' not in prompt_data: + prompt_data['showIn'] = ["frontend", "node"] + modified = True + + return modified + + def _deep_merge_defaults(self, user_data, default_data): + """ + 递归将 default_data 中的缺失字段合并到 user_data + + 合并策略: + - dict: 递归合并,补全缺失的键 + - list: 将模板中不存在于用户列表的新元素追加到末尾 + + 返回是否发生了修改 + """ + modified = False + import copy + + # ---处理字典类型--- + if isinstance(user_data, dict) and isinstance(default_data, dict): + for key, value in default_data.items(): + if key not in user_data: + # 字段不存在,直接添加 + user_data[key] = copy.deepcopy(value) + modified = True + else: + # 字段存在,递归检查 + if self._deep_merge_defaults(user_data[key], value): + modified = True + + # ---处理数组类型--- + elif isinstance(user_data, list) and isinstance(default_data, list): + # 将模板数组中不存在于用户数组的元素追加到末尾 + for item in default_data: + if item not in user_data: + user_data.append(copy.deepcopy(item)) + modified = True + + return modified + + def migrate_tags_json_to_csv(self): + """ + 迁移旧版 JSON 标签到 CSV 格式 + + 迁移逻辑: + 1. 检查 tags 目录是否为空 + 2. 如果为空,读取插件 config 目录下的 tags.json 和 tags_user.json + 3. 转换为 CSV 格式并写入 "用户标签.csv" + + CSV 格式: 标签名\t标签值\t一级分类\t二级分类\t三级分类\t四级分类 + """ + try: + # 1. 检查是否需要迁移 + if not self._should_migrate_tags(): + return False + + # 2. 读取 JSON 文件 + tags_data, user_tags_data = self._load_legacy_tags_json() + + if not tags_data and not user_tags_data: + return False + + # 3. 转换为 CSV 行数据 + csv_rows = self._convert_tags_to_csv_rows(tags_data, user_tags_data) + + if not csv_rows: + return False + + # 4. 写入 CSV 文件 + csv_filename = "用户标签.csv" + self._write_tags_csv(csv_rows, csv_filename) + + self._log(f"[tags.json] ✅ 成功迁移 {len(csv_rows)} 个标签到 {csv_filename}") + return True + + except Exception as e: + self._log(f"[tags.json] ❗ 标签迁移失败: {str(e)}") + return False + + def _should_migrate_tags(self): + """检查是否需要迁移标签""" + # 检查 tags 目录是否存在 + if not os.path.exists(self.tags_dir): + return True + + # 检查是否有 CSV 文件 + try: + csv_files = [f for f in os.listdir(self.tags_dir) if f.endswith('.csv')] + return len(csv_files) == 0 + except Exception: + return True + + def _load_legacy_tags_json(self): + """ + 加载旧版 JSON 标签文件 + + 返回: + (tags_data, user_tags_data) 元组 + """ + tags_data = None + user_tags_data = None + + # 读取 tags.json + legacy_tags_path = os.path.join(self.legacy_config_dir, "tags.json") + if os.path.exists(legacy_tags_path): + try: + with open(legacy_tags_path, 'r', encoding='utf-8') as f: + tags_data = json.load(f) + except Exception as e: + self._log(f"❗ 读取 tags.json 失败: {str(e)}") + + # 读取 tags_user.json + legacy_user_tags_path = os.path.join(self.legacy_config_dir, "tags_user.json") + if os.path.exists(legacy_user_tags_path): + try: + with open(legacy_user_tags_path, 'r', encoding='utf-8') as f: + user_tags_data = json.load(f) + except Exception as e: + self._log(f"❗ 读取 tags_user.json 失败: {str(e)}") + + return tags_data, user_tags_data + + def _convert_tags_to_csv_rows(self, tags_data, user_tags_data): + """ + 将 JSON 标签数据转换为 CSV 行 + + CSV 格式: [标签名, 标签值, 一级分类, 二级分类, 三级分类, 四级分类] + + 参数: + tags_data: tags.json 数据(支持2-4层嵌套) + user_tags_data: tags_user.json 数据(2层结构) + + 返回: + CSV 行列表 + """ + csv_rows = [] + + # ---处理 tags.json(递归解析多层嵌套)--- + if tags_data: + self._extract_tags_recursive(tags_data, [], csv_rows) + + # ---处理 tags_user.json(2层结构: 分类 → 标签)--- + # 放在"用户标签"一级分类下 + if user_tags_data: + for category, tags in user_tags_data.items(): + if not isinstance(tags, dict): + continue + + for tag_name, tag_value in tags.items(): + # CSV 行: [标签名, 标签值, 一级分类, 二级分类, 三级分类, 四级分类] + row = [ + tag_name, + tag_value, + "用户标签", + category, + "", # 三级分类(空) + "" # 四级分类(空) + ] + csv_rows.append(row) + + return csv_rows + + def _extract_tags_recursive(self, data, categories, csv_rows): + """ + 递归提取标签数据 + + 根据嵌套深度判断是分类还是标签: + - 如果值是字符串,则为标签(键=标签名,值=标签值) + - 如果值是字典,则为分类,继续递归 + + 参数: + data: 当前层级的数据字典 + categories: 当前路径上的分类列表(最多4级) + csv_rows: 结果列表,用于收集CSV行 + """ + for key, value in data.items(): + if isinstance(value, str): + # 值是字符串,说明当前键是标签名,值是标签值 + # 构建CSV行:[标签名, 标签值, 一级分类, 二级分类, 三级分类, 四级分类] + row = [key, value] + + # 填充分类(共4级,不足的补空字符串) + for i in range(4): + if i < len(categories): + row.append(categories[i]) + else: + row.append("") + + csv_rows.append(row) + + elif isinstance(value, dict): + # 值是字典,说明当前键是分类名,继续递归 + # 限制最多4级分类,超过则忽略更深层级 + if len(categories) < 4: + new_categories = categories + [key] + self._extract_tags_recursive(value, new_categories, csv_rows) + else: + # 超过4级分类,记录警告并跳过 + self._log(f"⚠️ 分类层级超过4级,已忽略: {' → '.join(categories)} → {key}") + + def _write_tags_csv(self, csv_rows, filename): + """ + 写入 CSV 文件 + + 参数: + csv_rows: CSV 行数据 + filename: 文件名 + """ + csv_path = os.path.join(self.tags_dir, filename) + + # 确保目录存在 + os.makedirs(self.tags_dir, exist_ok=True) + + # 写入 CSV 文件(使用 utf-8-sig 编码,兼容 Excel) + with open(csv_path, 'w', newline='', encoding='utf-8-sig') as f: + writer = csv.writer(f) + # 写入表头 + writer.writerow(['标签名', '标签值', '一级分类', '二级分类', '三级分类', '四级分类']) + # 写入数据 + writer.writerows(csv_rows) + + # --- Config.json 迁移 --- + + def migrate_config_api_keys(self): + """ + 迁移旧版 config.json 中的 API Key 到新版配置 + + 迁移逻辑: + 1. 检查用户配置目录的 config.json 是否存在 + 2. 如果不存在,读取插件 config 目录下的 config.json + 3. 提取 API Key 并迁移到新版配置(v2.0 model_services 格式) + + 提取的 API Key: + - baidu_translate: app_id, secret_key + - llm.providers: zhipu, siliconflow, custom 的 api_key + - vlm.providers: zhipu, siliconflow, custom 的 api_key + """ + try: + # 1. 检查是否需要迁移 + user_config_path = os.path.join(self.config_dir, "config.json") + if os.path.exists(user_config_path): + return False + + # 2. 读取旧版 config.json + legacy_config_path = os.path.join(self.legacy_config_dir, "config.json") + if not os.path.exists(legacy_config_path): + return False + + # 3. 加载旧版配置 + with open(legacy_config_path, 'r', encoding='utf-8') as f: + legacy_config = json.load(f) + + self._log(f"[config.json] 找到旧版配置,准备迁移到 v2.0 格式") + + # 4. 将完整的旧版配置保存到临时文件,供 config_manager 转换 + migration_data_path = os.path.join(self.config_dir, ".migration_legacy_config.json") + os.makedirs(self.config_dir, exist_ok=True) + + with open(migration_data_path, 'w', encoding='utf-8') as f: + json.dump(legacy_config, f, ensure_ascii=False, indent=2) + + return True + + except Exception as e: + self._log(f"❗ config.json 迁移失败: {str(e)}") + return False + + def _extract_api_keys_from_legacy_config(self, legacy_config): + """ + 从旧版 config.json 提取 API Key + + 参数: + legacy_config: 旧版配置字典 + + 返回: + 提取的 API Key 字典 + """ + api_keys = {} + + # 提取百度翻译配置 + if 'baidu_translate' in legacy_config: + baidu = legacy_config['baidu_translate'] + if baidu.get('app_id') or baidu.get('secret_key'): + api_keys['baidu_translate'] = { + 'app_id': baidu.get('app_id', ''), + 'secret_key': baidu.get('secret_key', '') + } + self._log("提取百度翻译配置") + + # 提取 LLM API Key + if 'llm' in legacy_config and 'providers' in legacy_config['llm']: + llm_providers = legacy_config['llm']['providers'] + api_keys['llm'] = {} + + for provider_name in ['zhipu', 'siliconflow', 'custom']: + if provider_name in llm_providers: + api_key = llm_providers[provider_name].get('api_key', '') + if api_key: + api_keys['llm'][provider_name] = api_key + self._log(f"提取 LLM {provider_name} API Key") + + # 提取 VLM API Key + if 'vlm' in legacy_config and 'providers' in legacy_config['vlm']: + vlm_providers = legacy_config['vlm']['providers'] + api_keys['vlm'] = {} + + for provider_name in ['zhipu', 'siliconflow', 'custom']: + if provider_name in vlm_providers: + api_key = vlm_providers[provider_name].get('api_key', '') + if api_key: + api_keys['vlm'][provider_name] = api_key + self._log(f"提取 VLM {provider_name} API Key") + + return api_keys + + +def run_migrations(plugin_dir, user_base_dir, logger=None, default_configs=None): + """ + 运行所有迁移任务 + + 执行顺序: + 1. 确保所有配置文件存在(缺则创建/迁移) + 2. 执行旧版 API Key 迁移 + 3. 执行增量更新(版本比对后按需执行) + + 参数: + plugin_dir: 插件目录路径 + user_base_dir: 用户配置基础目录 + logger: 日志函数(可选) + default_configs: 默认配置字典(用于创建默认文件和增量更新) + + 返回: + 迁移结果字典 + """ + tool = MigrationTool(plugin_dir, user_base_dir, logger) + legacy_dir = os.path.join(plugin_dir, "config") + + results = { + 'configs_created': False, + 'tags_migration': False, + 'config_migration': False, + 'incremental_updates': {} + } + + # 1. 确保所有配置文件存在 + if default_configs: + tool.ensure_all_configs_exist(default_configs, legacy_dir) + results['configs_created'] = True + + # 2. 执行旧版 API Key 迁移 + results['config_migration'] = tool.migrate_config_api_keys() + results['tags_migration'] = tool.migrate_tags_json_to_csv() + + # 3. 执行增量更新(版本比对后按需执行) + if default_configs: + results['incremental_updates'] = tool.migrate_incremental_updates(default_configs) + + return results diff --git a/utils/video.py b/utils/video.py new file mode 100644 index 0000000..9c0e904 --- /dev/null +++ b/utils/video.py @@ -0,0 +1,127 @@ +""" +视频帧提取工具模块 +提供基于帧索引的精确帧提取功能 +""" + +import cv2 +import base64 +import os +from io import BytesIO + + +# ---帧提取核心功能--- + +def extract_frame_by_index(video_path, frame_index, force_rate=0): + """ + 从视频中提取指定帧索引的图像 + + Args: + video_path: 视频文件路径 + frame_index: 目标帧索引(基于 force_rate 后的帧序列) + force_rate: 强制帧率,0 表示使用原始帧率 + + Returns: + dict: {success, data (base64 JPEG), width, height} 或 {success, error} + """ + try: + cap = cv2.VideoCapture(video_path) + if not cap.isOpened(): + return {"success": False, "error": "无法打开视频文件"} + + # 获取原始视频属性 + original_fps = cap.get(cv2.CAP_PROP_FPS) + total_original_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT)) + + if original_fps <= 0: + cap.release() + return {"success": False, "error": "无法获取视频帧率"} + + # 计算实际要读取的原始帧位置 + if force_rate > 0 and force_rate != original_fps: + # force_rate 会改变帧采样:从原始帧中按比例抽取 + # 帧索引 i 对应的原始帧位置 = i * (original_fps / force_rate) + actual_frame_pos = int(frame_index * (original_fps / force_rate)) + else: + actual_frame_pos = frame_index + + # 边界检查 + if actual_frame_pos >= total_original_frames: + actual_frame_pos = total_original_frames - 1 + if actual_frame_pos < 0: + actual_frame_pos = 0 + + # 定位并读取帧 + cap.set(cv2.CAP_PROP_POS_FRAMES, actual_frame_pos) + ret, frame = cap.read() + cap.release() + + if not ret or frame is None: + return {"success": False, "error": f"无法读取帧 {frame_index}"} + + # BGR 转 RGB + frame_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) + + # 编码为 JPEG(降低质量加快传输) + encode_param = [int(cv2.IMWRITE_JPEG_QUALITY), 60] + _, buffer = cv2.imencode('.jpg', cv2.cvtColor(frame_rgb, cv2.COLOR_RGB2BGR), encode_param) + + # Base64 编码 + base64_data = base64.b64encode(buffer).decode('utf-8') + + return { + "success": True, + "data": base64_data, + "width": frame.shape[1], + "height": frame.shape[0], + "frame_index": frame_index, + "actual_frame_pos": actual_frame_pos + } + + except Exception as e: + return {"success": False, "error": str(e)} + + +def get_video_frame_info(video_path, force_rate=0): + """ + 获取视频帧相关信息 + + Args: + video_path: 视频文件路径 + force_rate: 强制帧率 + + Returns: + dict: {success, original_fps, force_fps, total_frames, duration} + """ + try: + cap = cv2.VideoCapture(video_path) + if not cap.isOpened(): + return {"success": False, "error": "无法打开视频文件"} + + original_fps = cap.get(cv2.CAP_PROP_FPS) + total_original_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT)) + cap.release() + + if original_fps <= 0: + return {"success": False, "error": "无法获取视频帧率"} + + duration = total_original_frames / original_fps + + # 计算 force_rate 后的实际帧数 + if force_rate > 0: + actual_fps = force_rate + actual_total_frames = int(duration * force_rate) + else: + actual_fps = original_fps + actual_total_frames = total_original_frames + + return { + "success": True, + "original_fps": original_fps, + "actual_fps": actual_fps, + "original_total_frames": total_original_frames, + "actual_total_frames": actual_total_frames, + "duration": duration + } + + except Exception as e: + return {"success": False, "error": str(e)}