diff --git a/config/config_template.json b/config/config_template.json index eb4551c..33c5544 100644 --- a/config/config_template.json +++ b/config/config_template.json @@ -22,7 +22,7 @@ "display_name": "", "is_default": true, "temperature": 0.7, - "max_tokens": 1000, + "max_tokens": 1024, "top_p": 0.9 }, { @@ -30,17 +30,25 @@ "display_name": "", "is_default": false, "temperature": 0.7, - "max_tokens": 1000, + "max_tokens": 1024, "top_p": 0.9 } ], "vlm_models": [ { - "name": "glm-4v-flash", + "name": "glm-4.6V-Flash", "display_name": "", "is_default": true, "temperature": 0.7, - "max_tokens": 500, + "max_tokens": 1024, + "top_p": 0.9 + }, + { + "name": "glm-4v-flash", + "display_name": "", + "is_default": false, + "temperature": 0.7, + "max_tokens": 1024, "top_p": 0.9 } ] @@ -57,11 +65,11 @@ "filter_thinking_output": true, "llm_models": [ { - "name": "gemini-2.5-pro-nothinking", + "name": "gemini-3-flash-preview-nothinking", "display_name": "", "is_default": true, "temperature": 0.7, - "max_tokens": 1000, + "max_tokens": 1024, "top_p": 0.9 } ], @@ -71,7 +79,7 @@ "display_name": "", "is_default": true, "temperature": 0.7, - "max_tokens": 500, + "max_tokens": 1024, "top_p": 0.9 } ] diff --git a/config/system_prompts_template.json b/config/system_prompts_template.json index 0c31a17..6661667 100644 --- a/config/system_prompts_template.json +++ b/config/system_prompts_template.json @@ -1,5 +1,5 @@ { - "__config_version": "2.0", + "__config_version": "2.1", "expand_prompts": { "expand_扩写-通用": { "name": "扩写-通用", @@ -10,7 +10,7 @@ "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" + "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结构顺序:深度扩写的专业描述, [领域特定参数], 照片级写实的(针对写实类) 或 风格化的(针对非写实类), 高保真,超精细纹理,8K分辨率\n\n示例 (领域感知演示)\n输入中文:精密机械手表,微距\n输出:一张极其精密的机械手表机芯特写摄影。画面展示了复杂的齿轮组、红宝石轴承和游丝结构,金属齿轮表面带有细腻的拉丝纹理。每一个微小的零件都散发着淡淡的金属光泽,齿轮边缘有极细微的切削倒角痕迹。微距摄影视角,景深极浅,焦点完美对准在核心摆轮上,展现出极致的工艺美学和金属物理质感, 100毫米微距镜头,锐利对焦,高保真,超精细纹理,8K分辨率\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输出:一张清新透明的日系夏日人像摄影。一位年轻女孩站在微风吹过的车站站台上,穿着轻薄的白色棉质短袖,皮肤白皙且带有通透的自然质感。背景是湛蓝的天空和几朵洁白的积雨云。光线明亮而柔和,呈现出典型的日系胶片青色调。自然抓拍视角,画面充满清冷的空气感和夏日氛围, 富士胶片色彩,35毫米镜头,f/2.8光圈,照片级真实感,高保真度,8K分辨率\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输出:一张精美的二次元插画,采用赛璐璐上色风格。一位拥有璀璨紫色眼眸和飘逸长发的魔法少女正在施法,手中的法杖顶端绽放出华丽的五角星光效,粒子碎片随风飞散。线条清晰锐利,阴影边缘干净,色彩对比鲜明。背景是如梦似幻的星空和魔法阵,充满动态感。画面具有典型的高质量剧场版动画质感, 动漫风格,赛璐璐着色,线条艺术,鲜艳色彩,高保真,8K分辨率\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": "扩写-人像大师", @@ -21,7 +21,7 @@ "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" + "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结构顺序:深度扩写的中文描述, [光影/构图/焦段], 照片级真实感,超精细纹理,锐利对焦,8K分辨率\n\n示例 (Few-Shot Examples)\n\n输入中文:私房写真,女孩,蕾丝内衣,清晨\n输出:一张充满日系小清新风格的私房写真。一位18岁的女孩拥有白皙无瑕且富有通透感的肌肤,由于阳光的照射呈现出细腻的肌理。她穿着一套轻盈半透明的白色蕾丝内衣,面料上的花纹纹理清晰可见,阳光勾勒出她柔美的身体轮廓。女孩略显迷离的眼神注视着侧方,嘴角微微上扬并轻咬下唇,展现出一种自然青涩的情绪。清晨的柔和阳光透过轻薄的白纱窗帘洒入室内,形成斑驳的影调。平视视角,背景是梦幻的虚化卧室场景,整体色调明亮通透, 照片级真实感,超精细纹理,锐利对焦,8K分辨率\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焦段抓拍视角,画面充满动态张力与潮流气息, 照片级真实感,超精细纹理,锐利对焦,8K分辨率\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输出:一张古典意境深远的人像特写。一位气质清冷的女子身穿淡青色刺绣交领汉服,领口处的金线云纹在微弱的烛光下闪烁着金属质感。她低头专注于膝上的古琴,纤细修长的手指正轻拨琴弦,动态细节被完美定格。面部皮肤如陶瓷般细腻,眼眸中流露出淡淡的忧愁。背景是古朴的屏风与若隐若现的檀香烟雾,构图遵循传统中式对称美学。侧逆光勾勒出发丝的轮廓,细节毕现, 照片级真实感,超精细纹理,锐利对焦,8K分辨率\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风格", @@ -32,7 +32,7 @@ "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" + "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基础画质词:杰作,最佳质量,超高分辨率。\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输出: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指令优化", @@ -54,7 +54,7 @@ "node" ], "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" + "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:诺曼底)", @@ -95,7 +95,7 @@ "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,高分辨率,清晰对焦" + "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 个具体的描述性标签(例如描述衣服:皮夹克,磨损纹理,银色拉链,棕色)。\n3.正向引导:禁用负向描述(如 不要模糊),转为正向强度词(如 清晰锐利,精心细致)。\n4.指令优先:若用户提供了附加文本要求(如“换成红色”),则优先级最高,需将图片原本的颜色标签替换为用户指定的颜色。\n\n第二部分:执行逻辑与标签顺序\n你必须按以下逻辑结构堆叠标签:\n1.画质起手式:杰作,最高品质,高分辨率,超高细节,8K分辨率。\n2.主体锚定:(国籍特征/身份), (长相细节), (表情神态), (解剖肢体姿态).\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)", @@ -180,7 +180,7 @@ "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赛车尾部喷射出耀眼的蓝色火焰,车身因巨大推力产生轻微震动并迅速向前冲出,地面留下一道摩擦痕迹。高速追踪运镜捕捉加速全过程。" + "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_manager.py b/config_manager.py index c3eee74..3810d80 100644 --- a/config_manager.py +++ b/config_manager.py @@ -66,7 +66,7 @@ class ConfigManager: self.default_user_tags = {"favorites": []} # 默认标签选择 - self.default_tags_selection = {"selected_file": "default.csv"} + self.default_tags_selection = {"selected_file": "用户标签.csv"} @@ -358,28 +358,37 @@ class ConfigManager: return {} # 空文件 for row in reader: - if len(row) < 3: + # 过滤无效行 + if not row or not any(cell.strip() for cell in row): + continue + + # 至少需要两列:标签名, 标签值 + if len(row) < 2: continue tag_name = row[0].strip() tag_value = row[1].strip() + + if not tag_name: + continue + # 分类路径:从第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: + if cat not in current or not isinstance(current[cat], dict): current[cat] = {} current = current[cat] + # 处理空分类占位符:只创建分类结构,不添加标签 + if tag_name == "__empty__" or tag_name == "__placeholder__": + continue + # 添加标签 current[tag_name] = tag_value - # self._log(f"成功加载CSV文件: {filename} (编码: {encoding})") return result except UnicodeDecodeError: continue @@ -396,25 +405,57 @@ class ConfigManager: try: rows = [] + max_depth = 0 def extract_tags(obj, path: list): + nonlocal max_depth + # 确保 obj 是字典类型 + if not isinstance(obj, dict): + return + + # 如果是空分类(空字典),添加占位行 + if len(obj) == 0 and path: + # 使用 __empty__ 作为占位符标记空分类 + rows.append(["__empty__", ""] + path) + max_depth = max(max_depth, len(path)) + return + for key, value in obj.items(): if isinstance(value, str): rows.append([key, value] + path) + max_depth = max(max_depth, len(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]) + # 提取所有标签 + extract_tags(tags, []) + + if not rows: + self._log(f"保存CSV标签: 数据为空") + # 如果数据为空,写入只含表头的文件或保持现状? + # 通常为了防止误删,如果 tags 为空暂不操作或清空文件。 + # 这里选择写入表头: + with open(csv_path, "w", encoding="utf-8-sig", newline="") as f: + writer = csv.writer(f) + writer.writerow(["标签名", "标签值"]) + return True + + # 动态构建表头 + header = ["标签名", "标签值"] + for i in range(max_depth): + num_zh = ["一", "二", "三", "四", "五", "六", "七", "八", "九", "十"] + suffix = num_zh[i] if i < len(num_zh) else str(i + 1) + header.append(f"{suffix}级分类") with open(csv_path, "w", encoding="utf-8-sig", newline="") as f: writer = csv.writer(f) - writer.writerow(["标签名", "标签值", "一级分类", "二级分类", "三级分类", "四级分类"]) + writer.writerow(header) for row in rows: - while len(row) < 6: + # 补齐长度以匹配表头 + while len(row) < len(header): row.append("") - writer.writerow(row[:6]) + # 确保 row 长度不超过表头(防御性) + writer.writerow(row[:len(header)]) return True except Exception as e: @@ -732,7 +773,7 @@ class ConfigManager: "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), + "max_tokens": target_model.get('max_tokens', 1024), "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中不再使用此字段 @@ -746,7 +787,7 @@ class ConfigManager: "base_url": "", "api_key": "", "temperature": 0.7, - "max_tokens": 500, + "max_tokens": 1024, "top_p": 0.9, "providers": {} } @@ -1558,7 +1599,7 @@ class ConfigManager: # --- 模型管理方法 --- 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): + temperature: float = 0.7, top_p: float = 0.9, max_tokens: int = 1024): """添加模型到服务商""" try: config = self.load_config() diff --git a/js/css/assistant.css b/js/css/assistant.css index b544da5..ace89ba 100644 --- a/js/css/assistant.css +++ b/js/css/assistant.css @@ -667,4 +667,101 @@ /* 指示器初始化 - 触发入场动画 */ .assistant-indicator.indicator-init { animation: indicatorAppear 0.4s cubic-bezier(0.34, 1.56, 0.64, 1) forwards; +} + +/* ========================================================================== + 10. 流式输出浮层 (Streaming Overlay) + ========================================================================== */ +/* 流式输出显示浮层 */ +/* --- 节点内嵌流式显示逻辑 --- */ + +/* 文本容器:挂载在节点容器底部 */ +.node-streaming-text-container { + position: absolute; + bottom: 35px; + /* 紧贴底部工具条上方 */ + left: 8px; + right: 8px; + z-index: 99; + pointer-events: none; + /* 不干扰节点操作 */ + display: flex; + flex-direction: column; + justify-content: flex-end; + + /* 关键:调整遮罩范围,使其在红框高度内保持清晰,上方迅速渐隐 */ + -webkit-mask-image: linear-gradient(to top, + rgba(0, 0, 0, 1) 0%, + rgba(0, 0, 0, 1) 50%, + rgba(0, 0, 0, 0) 100%); + mask-image: linear-gradient(to top, + rgba(0, 0, 0, 1) 0%, + rgba(0, 0, 0, 1) 50%, + rgba(0, 0, 0, 0) 100%); + + /* 动画 */ + opacity: 0; + transition: opacity 0.3s ease; + height: 100px; + /* 用户调整后的高度 */ + overflow: hidden; +} + +/* LiteGraph 模式下的高性能同步层 */ +.node-streaming-text-container.is-litegraph { + position: fixed; + z-index: 10001; + pointer-events: none; + background: transparent; + transform-origin: top left; + /* 重要:从计算点开始缩放 */ + /* 宽度、高度、坐标和 transform 将由 JS 实时注入 */ +} + +.node-streaming-text-container.show { + opacity: 1; +} + +/* 内部流式文本 */ +.node-streaming-text-content { + color: color-mix(in srgb, var(--text-primary), transparent 50%); + font-size: 10px; + line-height: 1.6; + /* text-shadow: 0 1px 4px rgba(0, 0, 0, 0.8), 0 0 2px rgba(0, 0, 0, 1); */ + white-space: pre-wrap; + word-break: break-all; + text-align: left; + padding-bottom: 6px; + /* 让内容始终保持在底部 */ + margin-top: auto; +} + + +/* 生成中的提示文本样式 */ +.image-assistant-streaming-overlay.placeholder { + color: var(--fg-color, #888); + font-style: italic; +} + +/* 自定义滚动条样式,防止破坏圆角 */ +.image-assistant-streaming-overlay::-webkit-scrollbar { + width: 6px; + height: 6px; +} + +.image-assistant-streaming-overlay::-webkit-scrollbar-track { + background: transparent; + border-radius: 10px; +} + +.image-assistant-streaming-overlay::-webkit-scrollbar-thumb { + background: color-mix(in srgb, var(--p-panel-border-color), transparent 50%); + border-radius: 10px; + /* 增加透明边距,使滑块看起来“悬浮”且不贴边 */ + border: 1px solid transparent; + background-clip: content-box; +} + +.image-assistant-streaming-overlay::-webkit-scrollbar-thumb:hover { + background-color: var(--p-panel-border-color); } \ No newline at end of file diff --git a/js/css/common.css b/js/css/common.css index 6fa3fc0..c454cc6 100644 --- a/js/css/common.css +++ b/js/css/common.css @@ -179,6 +179,10 @@ /* 应用高亮动画 */ .input-highlight { animation: inputHighlight 0.2s ease-out; + background-color: transparent !important; + /* 兜底:即使动画卡住,类名存在时也确保透明 */ + will-change: background-color; + /* 优化合成层渲染 */ } /* ========================================================================== diff --git a/js/css/popup.css b/js/css/popup.css index c42cf17..e5b87ef 100644 --- a/js/css/popup.css +++ b/js/css/popup.css @@ -1333,4 +1333,57 @@ .tag_popup .tag_item:active { cursor: grabbing; +} + +/* ---添加分类按钮样式--- */ + +/* Tab 栏中的添加分类按钮 */ +.popup_container .popup_tab.add_category_tab { + padding: 4px 10px 8px 10px; + opacity: 0.6; + transition: all 0.2s ease; + min-width: 28px; + display: flex; + align-items: center; + justify-content: center; +} + +.popup_container .popup_tab.add_category_tab:hover { + opacity: 1; + color: var(--p-primary-500); +} + +.popup_container .popup_tab.add_category_tab .pi { + font-size: 10px; +} + +/* 添加分类按钮不显示底部高亮条 */ +.popup_container .popup_tab.add_category_tab::after { + display: none; +} + +/* 手风琴列表末尾的添加子分类按钮 */ +.add_subcategory_button { + display: flex; + align-items: center; + justify-content: center; + margin: 8px; + padding: 8px 16px; + border: 1px dashed transparent; + border-radius: 6px; + cursor: pointer; + transition: all 0.2s ease; + color: var(--p-text-muted-color); + opacity: 0.5; +} + +.add_subcategory_button:hover { + opacity: 1; + border-color: var(--p-content-border-color); + color: var(--p-text-muted-color); + background-color: transparent; +} + +.add_subcategory_button .pi { + font-size: 10px; } \ No newline at end of file diff --git a/js/css/uiComponents.css b/js/css/uiComponents.css index fc96291..74ba46a 100644 --- a/js/css/uiComponents.css +++ b/js/css/uiComponents.css @@ -2125,6 +2125,11 @@ position: relative; } +/* 移除表头单元格的竖向分割线 */ +.prompt-list-header .prompt-list-cell { + border-right: none; +} + .prompt-scroll-list { flex: 1; overflow-y: auto; @@ -2815,6 +2820,34 @@ body.column-resizing * { white-space: nowrap; } +/* ---二级子菜单样式--- */ +/* 有子菜单的菜单项 */ +.pa-context-menu-item-has-submenu { + position: relative; +} + +.pa-context-menu-item-has-submenu:hover { + background-color: var(--p-form-field-filled-hover-background); +} + +/* 子菜单箭头图标 */ +.pa-context-menu-submenu-icon { + margin-left: auto; + padding-left: 12px; + font-size: 10px; + color: var(--p-button-text-secondary-color); + opacity: 0.7; +} + +.pa-context-menu-item-has-submenu:hover .pa-context-menu-submenu-icon { + opacity: 1; +} + +/* 子菜单面板 */ +.pa-context-submenu { + min-width: 10rem; +} + /* ---分隔符--- */ .pa-context-menu-separator { height: 1px; diff --git a/js/index.js b/js/index.js index 77fec75..7cc0019 100644 --- a/js/index.js +++ b/js/index.js @@ -58,15 +58,14 @@ app.registerExtension({ 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] 渲染模式切换后重新初始化完成`); - }); + // 等待一帧确保 DOM 更新 + await new Promise(resolve => requestAnimationFrame(resolve)); + + await promptAssistant.toggleGlobalFeature(true, true); + if (window.FEATURES.imageCaption) { + await imageCaption.toggleGlobalFeature(true, true); + } + logger.log(`[index] 渲染模式切换后重新初始化完成`); } }); @@ -120,7 +119,7 @@ app.registerExtension({ } } - logger.log("扩展初始化完成"); + logger.debug("扩展初始化完成"); } catch (error) { logger.error(`扩展初始化失败: ${error.message}`); } @@ -151,62 +150,210 @@ app.registerExtension({ logger.error(`[setup] Hook Note节点失败: ${error.message}`); } }, 50); + + // ---全局节点监听--- + this._bindGraphHooks(app.graph); + + // ---子图进入/退出监听(Vue Node 2.0 自动创建支持)--- + this._setupGraphSwitchListener(); + + // 暴露 _injectUniversalHooks 供外部使用 + app.registerExtension._injectUniversalHooks = this._injectUniversalHooks.bind(this); }, /** - * 手动hook使用comfy-markdown的节点类型 - * 包括 Note、MarkdownNote、PreviewTextNode 等 - * 因为这些节点通过registerCustomNodes注册,beforeRegisterNodeDef可能无法捕获 + * 设置画布 graph 切换监听器 + * 检测进入/退出子图事件,自动创建模式下重新扫描节点 */ - _hookNoteNodeType(NodeType, typeName) { - if (!NodeType || !NodeType.prototype) { - logger.warn(`[_hookNoteNodeType] 无效的节点类型: ${typeName}`); - return; - } + _setupGraphSwitchListener() { + if (!app.canvas) return; - const origOnNodeCreated = NodeType.prototype.onNodeCreated; - const origOnSelected = NodeType.prototype.onSelected; + // 记录上一次的 graph 引用 + let lastGraph = app.canvas.graph; + const self = this; - // 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); - } - } - }); + // 通过 Object.defineProperty hook app.canvas.graph 的 setter + // 当 graph 切换(进入/退出子图)时触发扫描 + const originalDescriptor = Object.getOwnPropertyDescriptor(app.canvas, 'graph') || { + value: app.canvas.graph, + writable: true, + configurable: true }; - // Hook onSelected - NodeType.prototype.onSelected = function () { - if (origOnSelected) origOnSelected.apply(this, arguments); - if (!window.FEATURES.enabled) return; + // 保存原始值 + let _graphValue = app.canvas.graph; - const nodeRef = this; - const checkAndSetup = () => { - nodeRef._promptAssistantInitialized = false; - promptAssistant.checkAndSetupNode(nodeRef); + Object.defineProperty(app.canvas, 'graph', { + get() { + return _graphValue; + }, + set(newGraph) { + const oldGraph = _graphValue; + _graphValue = newGraph; + + // 如果有原始 setter,调用它 + if (originalDescriptor.set) { + originalDescriptor.set.call(this, newGraph); + } + + // 检测 graph 切换 + if (newGraph && newGraph !== oldGraph) { + logger.debug(`[graphSwitch] 检测到画布切换 | 旧Graph: ${oldGraph?._workflow_id || 'unknown'} -> 新Graph: ${newGraph?._workflow_id || 'unknown'}`); + + // 延迟执行,确保画布切换完成 + const isVueMode = typeof LiteGraph !== 'undefined' && LiteGraph.vueNodesMode === true; + const delay = isVueMode ? 300 : 100; + + setTimeout(() => { + self._onGraphSwitch(newGraph); + }, delay); + } + }, + configurable: true, + enumerable: true + }); + + logger.debug('[graphSwitch] 画布切换监听器已设置'); + }, + + /** + * 画布切换后的处理逻辑 + * 复用 _bindGraphHooks 的扫描逻辑,避免代码重复 + * @param {object} graph - 新的 graph 对象 + */ + _onGraphSwitch(graph) { + if (!graph || !window.FEATURES.enabled) return; + + // 调用已有的绑定钩子方法,并传入 resetFlags 选项以重置节点初始化标记 + this._bindGraphHooks(graph, { resetFlags: true }); + }, + + /** + * 为指定 graph 绑定节点挂载钩子 + * 支持主画布和子图内部 + * @param {object} graph - graph 对象 + * @param {object} options - 选项 { resetFlags: 是否重置节点初始化标记 } + */ + _bindGraphHooks(graph, options = {}) { + if (!graph) return; + const { resetFlags = false } = options; + + // 绑定钩子(只执行一次) + if (!graph._promptAssistantHooksInjected) { + graph._promptAssistantHooksInjected = true; + + const origOnNodeAdded = graph.onNodeAdded; + graph.onNodeAdded = (node) => { + if (origOnNodeAdded) origOnNodeAdded.apply(graph, [node]); + + if (!window.FEATURES.enabled || !node) return; + + // 1. 动态注入 Hooks (onSelected, onRemoved) + this._injectUniversalHooks(node); + + // 2. 自动挂载尝试 + this._handleNodeActive(node, { delay: true }); }; - // 优化:移除长延迟,仅保留极短延迟以确保栈清理 - // 主要依赖内部 Observer 机制 - if (LiteGraph.vueNodesMode === true) { - requestAnimationFrame(() => requestAnimationFrame(checkAndSetup)); - } else { - checkAndSetup(); - } + // logger.log(`[graphHooks] 已绑定 graph 钩子 | ID: ${graph._workflow_id || graph.constructor?.name || 'unknown'}`); + + // 【关键】处理进入子图时已存在的节点 + // Vue 模式需要更长延迟,确保 DOM 渲染完成 + const isVueMode = typeof LiteGraph !== 'undefined' && LiteGraph.vueNodesMode === true; + const scanDelay = isVueMode ? 500 : 100; + + const scanExistingNodes = () => { + if (!window.FEATURES.enabled) return; + + const creationMode = app.ui.settings.getSettingValue("PromptAssistant.Settings.CreationMode") || "auto"; + const icCreationMode = app.ui.settings.getSettingValue("PromptAssistant.Settings.ImageCaptionCreationMode") || "auto"; + + // 只要任意一个模块开启了自动创建,就需要扫描现有节点 + if (creationMode !== "auto" && icCreationMode !== "auto") { + // logger.debugSample(() => `[graphHooks] 跳过初始扫描 | PA模式: ${creationMode} | IC模式: ${icCreationMode}`); + return; + } + + const nodes = graph._nodes || []; + if (nodes.length === 0) return; + + nodes.forEach(node => { + if (!node || node.id === -1) return; + + // 1. 注入钩子 (确保 onSelected/onRemoved 等能正常工作) + this._injectUniversalHooks(node); + + // 2. 统一分发到激活处理函数,它内部会根据各自的自动创建设置进行判断 + this._handleNodeActive(node, { delay: false }); + }); + }; + + setTimeout(scanExistingNodes, scanDelay); + } + + // 【新增】如果需要重置标记(子图切换场景),立即扫描现有节点 + if (resetFlags) { + const isVueMode = typeof LiteGraph !== 'undefined' && LiteGraph.vueNodesMode === true; + const delay = isVueMode ? 300 : 100; + + setTimeout(() => { + const nodes = graph._nodes || []; + nodes.forEach(node => { + if (!node || node.id === -1) return; + + // 重置初始化标记,允许重新创建 + node._promptAssistantInitialized = false; + node._imageCaptionInitialized = false; + + // 注入钩子 + this._injectUniversalHooks(node); + + // 触发自动创建 + this._handleNodeActive(node, { delay: false }); + }); + + if (nodes.length > 0) { + logger.debug(`[graphSwitch] 自动扫描完成 | 节点数: ${nodes.length}`); + } + }, delay); + } + }, + + /** + * 为所有节点注入通用的交互钩子 (onSelected, onRemoved) + * 特别是针对动态创建的子图节点,确确保能够响应点击和资源清理 + * @param {object} node - LiteGraph 节点实例 + */ + _injectUniversalHooks(node) { + if (!node || node._promptAssistantHooksInjected) return; + + const self = this; + const origOnSelected = node.onSelected; + const origOnRemoved = node.onRemoved; + + // 实例级覆盖 (针对动态创建或特殊节点) + node.onSelected = function () { + if (origOnSelected) origOnSelected.apply(this, arguments); + self._handleNodeActive(this, { reset: true, delay: true }); }; + + node.onRemoved = function () { + self._handleNodeCleanup(this); + if (origOnRemoved) origOnRemoved.apply(this, arguments); + }; + + node._promptAssistantHooksInjected = true; + }, + + /** + * @deprecated 已由 _injectUniversalHooks 替代,保留用于注册时的遗留支持 + */ + _hookNoteNodeType(NodeType, typeName) { + if (!NodeType || !NodeType.prototype) return; + + // 我们不再重写原型方法,而是通过 onNodeAdded 动态注入实例方法 + // 这在 Node 2.0 动态创建时更可靠 + // logger.debug(`[_hookNoteNodeType] 类型已注册: ${typeName}`); }, // ---其他方法保持不变--- @@ -240,38 +387,16 @@ app.registerExtension({ // 调用原始加载方法 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); - } - } - }); - } - }); - } + // 工作流加载完成后,统一处理现有节点的激活(包括自动创建判定) + requestAnimationFrame(() => { + if (app.graph && app.graph._nodes) { + app.graph._nodes.forEach(node => { + if (node && node.id !== -1) { + this._handleNodeActive(node, { delay: false }); + } + }); + } + }); return result; } finally { @@ -292,237 +417,119 @@ app.registerExtension({ * 在节点创建时初始化特定类型节点的小助手 */ async nodeCreated(node) { - try { - if (!window.FEATURES.enabled || !node?.id || node.id === -1) return; - - requestAnimationFrame(() => { - if (!node?.id || node.id === -1) return; - - // 检查是否为提示词节点 - 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; - } - - // 检查是否为图像节点 - 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); - } - } - }); - } catch (error) { - logger.error(`节点创建处理失败: ${error.message}`); - } + // nodeCreated 钩子现在主要用于补齐子图节点的特殊交互,大部分逻辑已通过 onNodeCreated 注入 + if (!node || node.id === -1) return; + this._injectUniversalHooks(node); }, - /** - * 节点移除钩子 - * 在节点被删除时清理对应的小助手实例 - */ async nodeRemoved(node) { - // 如果正在切换工作流,则不执行任何清理操作 - if (window.PROMPT_ASSISTANT_WORKFLOW_SWITCHING) { - return; - } - - try { - if (!node || node.id === undefined || node.id === -1) return; - - // 安全获取节点ID,用于日志记录 - const nodeId = node.id; - - // 添加清理标记,避免重复清理 - node._promptAssistantCleaned = false; - node._imageCaptionCleaned = false; - - // 清理提示词小助手 - if (node._promptAssistantInitialized) { - promptAssistant.cleanup(nodeId); - node._promptAssistantCleaned = true; - logger.debug(`[节点移除钩子] 提示词小助手清理完成 | 节点ID: ${nodeId}`); - } - - // 清理图像小助手 - if (node._imageCaptionInitialized) { - imageCaption.cleanup(nodeId); - node._imageCaptionCleaned = true; - logger.debug(`[节点移除钩子] 图像小助手清理完成 | 节点ID: ${nodeId}`); - } - } catch (error) { - const safeNodeId = node && node.id !== undefined ? node.id : "unknown"; - logger.error(`[节点移除钩子] 处理失败 | 节点ID: ${safeNodeId} | 错误: ${error.message}`); - } + if (window.PROMPT_ASSISTANT_WORKFLOW_SWITCHING) return; + this._handleNodeCleanup(node); }, /** * 节点定义注册前钩子 * 向所有节点类型注入小助手相关功能 */ + + + // --- 统一生命周期管理逻辑 (重构点) --- + + /** + * 统一处理节点的“进入/激活”逻辑 + * 涵盖:新节点创建(onNodeCreated), 全局节点添加(onNodeAdded), 节点选中(onSelected) + * @param {object} node - 节点实例 + * @param {object} options - 配置参数 { reset: 是否强制重置标记, delay: 是否使用 raf 延迟 } + */ + _handleNodeActive(node, options = {}) { + if (!node || !window.FEATURES.enabled) return; + if (node.id === -1) return; + + const { reset = false, delay = true } = options; + if (reset) { + node._promptAssistantInitialized = false; + node._imageCaptionInitialized = false; + } + + const run = () => { + if (!node || !node.id || node.id === -1) return; + + // 1. 提示词小助手核心入口 + if (PromptAssistant.isValidNode(node)) { + const creationMode = app.ui.settings.getSettingValue("PromptAssistant.Settings.CreationMode") || "auto"; + if ((creationMode === "auto" || reset) && !node._promptAssistantInitialized) { + node._promptAssistantInitialized = true; + promptAssistant.checkAndSetupNode(node); + } + } + + // 2. 图像反推小助手入口 + const isSupportedICNode = imageCaption.isSupportedNode && imageCaption.isSupportedNode(node); + if (window.FEATURES.imageCaption && isSupportedICNode) { + const icCreationMode = app.ui.settings.getSettingValue("PromptAssistant.Settings.ImageCaptionCreationMode") || "auto"; + if (reset && app.canvas?._imageCaptionSelectionHandler) { + node._imageCaptionInitialized = false; + app.canvas._imageCaptionSelectionHandler({ [node.id]: node }); + } else if (icCreationMode === "auto" && !node._imageCaptionInitialized) { + node._imageCaptionInitialized = true; + imageCaption.checkAndSetupNode(node); + } + } + }; + + if (delay) { + requestAnimationFrame(() => requestAnimationFrame(run)); + } else { + run(); + } + }, + + /** + * 统一处理节点的“销毁/清理”逻辑 + * @param {object} node - 节点实例 + */ + _handleNodeCleanup(node) { + if (!node || node.id === undefined || node.id === -1) return; + const nodeId = node.id; + + // 执行清理并标记状态 + if (node._promptAssistantInitialized || !node._promptAssistantCleaned) { + promptAssistant.cleanup(nodeId, false); + node._promptAssistantCleaned = true; + } + if (node._imageCaptionInitialized || !node._imageCaptionCleaned) { + imageCaption.cleanup(nodeId, false); + node._imageCaptionCleaned = true; + } + }, + + /** + * 注册前的批量原型注入 + */ async beforeRegisterNodeDef(nodeType, nodeData) { - // 保存原始方法 - const origOnNodeCreated = nodeType.prototype.onNodeCreated; - const origOnRemoved = nodeType.prototype.onRemoved; - const origOnSelected = nodeType.prototype.onSelected; + const self = this; + const proto = nodeType.prototype; - // 注入节点创建方法 - nodeType.prototype.onNodeCreated = function () { - if (origOnNodeCreated) { - origOnNodeCreated.apply(this, arguments); - } + const origOnCreated = proto.onNodeCreated; + const origOnSelected = proto.onSelected; + const origOnRemoved = proto.onRemoved; - // 始终检查最新的总开关状态 - const currentEnabled = app.ui.settings.getSettingValue("PromptAssistant.Features.Enabled"); - window.FEATURES.enabled = currentEnabled !== undefined ? currentEnabled : true; - - // 总开关关闭时,直接返回 - if (!window.FEATURES.enabled) { - return; - } - - // 设置未初始化标记 - 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); - } - } - }); + // 注入创建钩子 (原型级补救) + proto.onNodeCreated = function () { + if (origOnCreated) origOnCreated.apply(this, arguments); + self._handleNodeActive(this, { delay: true }); }; - // 注入节点选择方法 - nodeType.prototype.onSelected = function () { - if (origOnSelected) { - origOnSelected.apply(this, arguments); - } - - // 确保总开关开启 - if (!window.FEATURES.enabled) { - return; - } - - const nodeRef = this; - - // Vue mode下需要延迟执行,确保DOM渲染完成 - const isVueMode = typeof LiteGraph !== 'undefined' && LiteGraph.vueNodesMode === true; - const checkAndSetup = () => { - // 重置提示词小助手初始化标记,确保每次选择都重新检测节点状态 - // 无论是自动还是手动模式,选中时都尝试检查(作为手动触发或自动模式的重试/补救) - nodeRef._promptAssistantInitialized = false; - promptAssistant.checkAndSetupNode(nodeRef); - - // 确保选择事件能触发到图像小助手,同时检查图像反推功能开关 - 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(); - } + // 注入选中钩子 (原型级补救) + proto.onSelected = function () { + if (origOnSelected) origOnSelected.apply(this, arguments); + self._handleNodeActive(this, { reset: true, delay: true }); }; - // 注入节点移除方法 - nodeType.prototype.onRemoved = function () { - try { - // 首先检查this和this.id是否存在和有效 - if (!this) { - logger.debug("[onRemoved方法] 节点实例不存在,跳过清理"); - if (origOnRemoved) { - origOnRemoved.apply(this, arguments); - } - return; - } - - // 安全获取节点ID,如果不存在则使用占位符 - const nodeId = this.id !== undefined ? this.id : "unknown"; - - // 清理提示词小助手(如果尚未清理) - if (this._promptAssistantInitialized && !this._promptAssistantCleaned) { - if (this.id !== undefined) { - promptAssistant.cleanup(this.id); - this._promptAssistantCleaned = true; - } - } - - // 清理图像小助手(如果尚未清理) - if (this._imageCaptionInitialized && !this._imageCaptionCleaned) { - if (this.id !== undefined) { - imageCaption.cleanup(this.id); - this._imageCaptionCleaned = true; - } - } - - // 即使没有初始化标记,也尝试清理(关键修复) - // 这是为了处理可能的边缘情况,确保完全清理 - if (!this._promptAssistantCleaned && this.id !== undefined) { - promptAssistant.cleanup(this.id, true); - } - if (!this._imageCaptionCleaned && this.id !== undefined) { - imageCaption.cleanup(this.id, true); - } - - // 简化:仅在两者都清理完成时输出一次 - if (this._promptAssistantCleaned && this._imageCaptionCleaned) { - logger.log(`[节点清理] 完成 | 节点ID: ${nodeId}`); - } - - if (origOnRemoved) { - origOnRemoved.apply(this, arguments); - } - } catch (error) { - // 安全获取节点ID,用于错误日志 - const safeNodeId = this && this.id !== undefined ? this.id : "unknown"; - logger.error(`[onRemoved方法] 清理失败 | 节点ID: ${safeNodeId} | 错误: ${error.message}`); - // 确保原始方法仍然被调用 - if (origOnRemoved) { - origOnRemoved.apply(this, arguments); - } - } + // 注入移除钩子 (原型级补救) + proto.onRemoved = function () { + self._handleNodeCleanup(this); + if (origOnRemoved) origOnRemoved.apply(this, arguments); }; }, diff --git a/js/modules/AssistantContainer.js b/js/modules/AssistantContainer.js index 0d5b1da..989ef35 100644 --- a/js/modules/AssistantContainer.js +++ b/js/modules/AssistantContainer.js @@ -2,6 +2,13 @@ import { app } from "../../../scripts/app.js"; import { EventManager } from "../utils/eventManager.js"; import "../lib/Sortable.min.js"; +/** + * 调试开关:禁止自动折叠 + * 在控制台输入 window.PA_DEBUG_NO_COLLAPSE = true 可禁止自动折叠 + * 输入 window.PA_DEBUG_NO_COLLAPSE = false 可恢复自动折叠 + */ +window.PA_DEBUG_NO_COLLAPSE = window.PA_DEBUG_NO_COLLAPSE || false; + // 锚点位置枚举 export const ANCHOR_POSITION = { TOP_LEFT_H: 'top-left-h', @@ -33,6 +40,7 @@ export class AssistantContainer { // 状态 this.isCollapsed = true; this.isTransitioning = false; + this.isDestroyed = false; this.buttons = []; this.element = null; this.container = null; @@ -52,6 +60,9 @@ export class AssistantContainer { } render() { + // 检查是否已销毁 + if (this.isDestroyed) return null; + // 主容器 this.element = document.createElement('div'); this.element.className = `assistant-container-common ${this.type}-assistant-container`; @@ -155,8 +166,12 @@ export class AssistantContainer { updatePosition() { if (!this.element) return; - // 重置类名 - this.element.className = `assistant-container-common ${this.type}-assistant-container collapsed`; + // 保存当前展开/折叠状态 + const wasExpanded = !this.isCollapsed; + + // 重置类名,保持当前状态 + const stateClass = wasExpanded ? 'expanded' : 'collapsed'; + this.element.className = `assistant-container-common ${this.type}-assistant-container ${stateClass}`; // 添加布局类名 this.element.classList.add(`layout-${this.anchorPosition}`); @@ -191,10 +206,72 @@ export class AssistantContainer { }); } + /** + * 更新容器尺寸 (优化版:常量布局模式) + * 根据当前启用的按钮组合直接计算尺寸,避免 DOM 克隆测量带来的开销 + */ updateDimensions() { if (!this.element || !this.content) return; - // 测量内容尺寸 + // --- 1. 获取当前状态统计 --- + const buttons = Array.from(this.content.children).filter(el => + el.style.display !== 'none' && + !el.classList.contains('assistant-indicator') + ); + + const totalCount = buttons.length; + if (totalCount === 0) return; + + // 统计功能组 + const hasHistoryGroup = buttons.some(el => el.dataset.id === 'history' || el.dataset.id === 'undo' || el.dataset.id === 'redo'); + const hasDivider = buttons.some(el => el.classList.contains('prompt-assistant-divider') || el.classList.contains('image-assistant-divider')); + + // 计算非历史且非分隔线的有效功能按钮数量 + const otherFeaturesCount = buttons.filter(el => + !['history', 'undo', 'redo'].includes(el.dataset.id) && + !el.classList.contains('prompt-assistant-divider') && + !el.classList.contains('image-assistant-divider') + ).length; + + // --- 2. 基于预设常量的尺寸映射 --- + let finalDimension = 28; // 默认单个按钮宽度 (或折叠尺寸) + + // 逻辑规则匹配 (根据用户提供的精确测量值) + if (hasHistoryGroup && otherFeaturesCount === 3) { + finalDimension = 143; // 所有功能全开 (历史3 + 分隔线1 + 其它3) + } else if (hasHistoryGroup && otherFeaturesCount === 2) { + finalDimension = 121; // 历史 + 两个其它 + } else if (hasHistoryGroup && otherFeaturesCount === 1) { + finalDimension = 99; // 历史 + 一个其它 + } else if (hasHistoryGroup && otherFeaturesCount === 0) { + finalDimension = 77; // 只有历史功能 + } else if (!hasHistoryGroup && otherFeaturesCount === 3) { + finalDimension = 72; // 关闭历史的三个功能 + } else if (!hasHistoryGroup && otherFeaturesCount === 2) { + finalDimension = 50; // 只有两个按钮 + } else if (!hasHistoryGroup && otherFeaturesCount === 1) { + finalDimension = 28; // 只有一个按钮 + } else { + // 兜底动态计算逻辑: 基础28 + (额外按钮 * 22) + (如果有分隔线 ? 5 : 0) + const extraCount = totalCount - 1; + finalDimension = 28 + (extraCount * 22); + if (hasDivider) finalDimension += 5; + } + + // --- 3. 应用尺寸 --- + const isVertical = this.anchorPosition.endsWith('-v'); + if (isVertical) { + // 竖向布局:宽度固定,高度动态 + this.element.style.setProperty('--expanded-width', `28px`); + this.element.style.setProperty('--expanded-height', `${finalDimension}px`); + } else { + // 横向布局:高度固定,宽度动态 + this.element.style.setProperty('--expanded-width', `${finalDimension}px`); + this.element.style.setProperty('--expanded-height', `28px`); + } + + /* + // --- 原有自动测量代码 (已注释,备用) --- const clone = this.content.cloneNode(true); clone.style.cssText = ` position: absolute; @@ -207,44 +284,30 @@ export class AssistantContainer { gap: 0; `; - // 根据方向设置 flex-direction - const isVertical = this.anchorPosition.endsWith('-v'); - clone.style.flexDirection = isVertical ? 'column' : 'row'; + const isVerticalMeasure = this.anchorPosition.endsWith('-v'); + clone.style.flexDirection = isVerticalMeasure ? '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 containerPadding = 4; + const lastButtonMargin = 2; const collapsedSize = 28; - // 根据布局方向计算展开后的尺寸 let expandedWidth, expandedHeight; - - if (isVertical) { - // 竖向布局:宽度固定为折叠尺寸,高度根据内容计算 + if (isVerticalMeasure) { 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() { @@ -259,6 +322,9 @@ export class AssistantContainer { } expand() { + // 检查是否已销毁 + if (this.isDestroyed) return; + // 清除任何挂起的折叠定时器 if (this._collapseTimer) { clearTimeout(this._collapseTimer); @@ -326,6 +392,12 @@ export class AssistantContainer { } collapse() { + // 检查是否已销毁 + if (this.isDestroyed) return; + + // 调试模式:禁止自动折叠 + if (window.PA_DEBUG_NO_COLLAPSE) return; + // 检查是否应阻止折叠(例如:激活的菜单) if (this.shouldCollapse && !this.shouldCollapse()) { return; @@ -354,9 +426,38 @@ export class AssistantContainer { this.content.style.opacity = '0'; this.content.style.pointerEvents = 'none'; } + + // 折叠完成后,检测鼠标是否仍在热区内 + // 解决自动折叠后鼠标仍在热区,但需要移出再移入才能展开的问题 + this._checkMouseStillInHoverArea(); }, 150); // 为了易用性设置的小延迟 } + // ---检测鼠标是否仍在热区内--- + _checkMouseStillInHoverArea() { + if (!this.element) return; + + // 使用 requestAnimationFrame 确保 DOM 已更新 + requestAnimationFrame(() => { + // 获取当前鼠标位置下的元素 + const hoveredElements = document.querySelectorAll(':hover'); + + // 检查小助手容器或其子元素是否被悬停 + let isMouseInside = false; + for (const el of hoveredElements) { + if (this.element.contains(el) || el === this.element) { + isMouseInside = true; + break; + } + } + + // 如果鼠标仍在热区内,且当前是折叠状态,则触发展开 + if (isMouseInside && this.isCollapsed) { + this.expand(); + } + }); + } + _setupSortable() { if (!this.content) return; @@ -428,11 +529,25 @@ export class AssistantContainer { this.updateDimensions(); } catch (e) { - console.warn("[PromptAssistant] 恢复按钮顺序失败:", e); + logger.warn("[PromptAssistant] 恢复按钮顺序失败:", e); } } destroy() { + // 防止重复销毁 + if (this.isDestroyed) return; + this.isDestroyed = true; + + // 清理定时器 + if (this._collapseTimer) { + clearTimeout(this._collapseTimer); + this._collapseTimer = null; + } + if (this._expandTimer) { + clearTimeout(this._expandTimer); + this._expandTimer = null; + } + // 清理监听器 this._cleanupFunctions.forEach(fn => fn && fn()); this._cleanupFunctions = []; @@ -447,5 +562,13 @@ export class AssistantContainer { if (this.element && this.element.parentNode) { this.element.parentNode.removeChild(this.element); } + + // 清空所有引用 + this.element = null; + this.container = null; + this.content = null; + this.indicator = null; + this.hoverArea = null; + this.buttons = []; } } diff --git a/js/modules/PromptAssistant.js b/js/modules/PromptAssistant.js index 02401b6..f874713 100644 --- a/js/modules/PromptAssistant.js +++ b/js/modules/PromptAssistant.js @@ -41,32 +41,24 @@ function calculateAssistantWidth() { // 统计非历史功能的数量 const otherFeaturesCount = [hasTag, hasExpand, hasTranslate].filter(Boolean).length; - // 根据功能组合返回预设宽度 + // 根据功能组合返回预设常量宽度 if (hasHistory && otherFeaturesCount === 3) { - // 所有功能全开 - return 179.04; + return 143; // 所有功能全开 (历史3 + 分隔线1 + 其它3) } else if (hasHistory && otherFeaturesCount === 2) { - // 历史 + 两个其他功能 - return 151.36; - } else if (!hasHistory && otherFeaturesCount === 3) { - // 只有三个其他功能 - return 87.21; + return 121; // 历史 + 两个其它 } else if (hasHistory && otherFeaturesCount === 1) { - // 历史 + 一个其他功能 - return 123.69; - } else if (!hasHistory && otherFeaturesCount === 2) { - // 只有两个其他功能 - return 59.54; + return 99; // 历史 + 一个其它 } else if (hasHistory && otherFeaturesCount === 0) { - // 只有历史功能 - return 87.21; + return 77; // 只有历史功能 + } else if (!hasHistory && otherFeaturesCount === 3) { + return 72; // 关闭历史的三个功能 + } else if (!hasHistory && otherFeaturesCount === 2) { + return 50; // 只有两个按钮 } else if (!hasHistory && otherFeaturesCount === 1) { - // 只有一个其他功能 - return 31.87; + return 28; // 只有一个按钮 } - // 默认值(理论上不应该到这里) - return 31.87; + return 28; // 默认 } @@ -163,6 +155,9 @@ function getInputValue(widget, options = {}) { * 支持普通textarea、Tiptap编辑器、ProseMirror编辑器等 * @param {object} widget - 小助手widget对象 * @param {string} content - 要设置的内容 + * @param {object} options - 配置选项 + * @param {boolean} options.html - 是否作为 HTML 内容设置 + * @param {boolean} options.silent - 是否静默更新(不触发事件,用于流式输出) * @returns {boolean} 是否设置成功 */ function setInputValue(widget, content, options = {}) { @@ -172,13 +167,31 @@ function setInputValue(widget, content, options = {}) { const inputEl = widget.inputEl; const useHtml = options.html === true; + const silent = options.silent === 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 })); + + // 关键修复:即使是 silent 模式,也需要同步 widget.value 和 node.widgets[].value + // 否则后续 getInputValue 会读取到旧值 + if (widget.value !== undefined) { + widget.value = content; + } + 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; + } + } + + if (!silent) { + inputEl.dispatchEvent(new Event('input', { bubbles: true })); + inputEl.dispatchEvent(new Event('change', { bubbles: true })); + } return true; } @@ -207,16 +220,18 @@ function setInputValue(widget, content, options = {}) { targetEl.innerHTML = content; } - // 触发输入事件 - targetEl.dispatchEvent(new Event('input', { bubbles: true })); - targetEl.dispatchEvent(new Event('change', { bubbles: true })); + // 触发输入事件(静默模式下跳过) + if (!silent) { + targetEl.dispatchEvent(new Event('input', { bubbles: true })); + targetEl.dispatchEvent(new Event('change', { bubbles: true })); + } - // 同时更新widget.value + // 同时更新widget.value(无论是否 silent 都需要同步) if (widget.value !== undefined) { widget.value = content; } - // 同时更新node.widgets[].value + // 同时更新node.widgets[].value(无论是否 silent 都需要同步) if (widget.node && widget.node.widgets) { const matchingWidget = widget.node.widgets.find(w => w.name === widget.inputId || w.name === 'text' @@ -236,8 +251,24 @@ function setInputValue(widget, content, options = {}) { } else { inputEl.textContent = content; } - inputEl.dispatchEvent(new Event('input', { bubbles: true })); - inputEl.dispatchEvent(new Event('change', { bubbles: true })); + + // 关键修复:同步 widget.value 和 node.widgets[].value + if (widget.value !== undefined) { + widget.value = content; + } + 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; + } + } + + if (!silent) { + inputEl.dispatchEvent(new Event('input', { bubbles: true })); + inputEl.dispatchEvent(new Event('change', { bubbles: true })); + } return true; } @@ -393,6 +424,19 @@ class PromptAssistant { } app.canvas.onSelectionChange = app.canvas._promptAssistantSelectionHandler; + + // 3. 如果开启了自动创建,立即扫描所有有效节点 + const creationMode = app.ui.settings.getSettingValue("PromptAssistant.Settings.CreationMode") || "auto"; + if (creationMode === "auto") { + const nodes = app.canvas.graph._nodes || []; + nodes.forEach(node => { + if (node && !node._promptAssistantInitialized) { + // 避免在扫描过程中重复处理 + node._promptAssistantInitialized = true; + this.checkAndSetupNode(node); + } + }); + } } } else { // === 禁用所有服务 === @@ -427,15 +471,6 @@ 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) { // 简化日志:工作流切换期间不逐条打印节点清理日志,避免高频刷屏 @@ -508,8 +543,7 @@ class PromptAssistant { const remainingTags = tagStats.total; const remainingHistory = HistoryCacheService.getAllHistory().length; - logger.log(`[节点清理] 节点ID: ${nodeId} | 实例: ${instanceNames.join(', ')} | 历史: ${historyCount}条 | 标签: ${tagCount}个`); - logger.log(`[剩余统计] 小助手实例: ${remainingInstances}个 | 标签缓存: ${remainingTags}个 | 节点历史缓存: ${remainingHistory}条`); + logger.log(`[清理汇总] 节点ID: ${nodeId} | 清理实例: ${instanceNames.join(', ')} | 历史记录清理: ${historyCount}条 | 标签缓存清理: ${tagCount}个`); } } catch (error) { logger.error(`[节点清理] 失败 | 节点ID: ${nodeId} | 错误: ${error.message}`); @@ -569,23 +603,32 @@ class PromptAssistant { */ _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')); + (typeLower.includes('preview') && typeLower.includes('text')) || + typeLower.includes('subgraph'); // 增加对子图的基础判定支持 + } + + /** + * 检查节点是否为子图节点 (Subgraph) + * 子图节点的类型名为 UUID 格式 + * @param {object} node - 节点对象 + * @returns {boolean} + */ + _isSubgraphNode(node) { + if (!node || !node.type) return false; + // UUID 格式:8-4-4-4-12 字符 + return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(node.type); } // ---实例管理功能--- /** * 检查节点是否有效 - * Vue mode下Note/MarkdownNote节点可能没有widgets属性,需要特殊处理 + * Vue mode下Note/MarkdownNote/Subgraph节点需要特殊处理 */ static isValidNode(node) { if (!node || typeof node.id === 'undefined' || node.id === -1) { @@ -605,7 +648,15 @@ class PromptAssistant { (node.type && node.type.toLowerCase().includes('markdown')) || (node.type && node.type.toLowerCase().includes('preview') && node.type.toLowerCase().includes('text')); - if (isVueMode && isMarkdownNode) { + // 检查是否为子图节点 + // 1. UUID 格式类型名 (Node 2.0 动态创建) + const isUUIDType = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(node.type); + // 2. 原生 Subgraph 关键字或 workflow/ 前缀 + const isSubgraphType = node.type === 'Subgraph' || + node.type.startsWith('workflow/') || + (node.constructor && node.constructor.name === 'Subgraph'); + + if (isVueMode && (isMarkdownNode || isUUIDType || isSubgraphType)) { // Vue mode下这些节点类型直接视为有效 return true; } @@ -651,24 +702,41 @@ class PromptAssistant { const isVueMode = LiteGraph.vueNodesMode === true; - // Vue mode下Note/MarkdownNote节点特殊处理 + + + // Vue mode下特殊节点(Note/Markdown/Subgraph)即使没有 LiteGraph widgets 也是有效的 if (!node.widgets) { - if (isVueMode && (node.type === 'Note' || node.type === 'MarkdownNote')) { - this._handleVueSpecialNode(node); + + if (isVueMode && PromptAssistant.isValidNode(node)) { + this._handleVueDomScanNode(node); } return; } + // 后续检查:如果虽然有 widgets 但不是我们识别的有效节点,也回退处理 + const isValid = PromptAssistant.isValidNode(node); + if (!isValid) { + + return; + } + // 获取所有有效的输入控件 const validInputs = node.widgets.filter(widget => { if (!widget.node) widget.node = node; - return UIToolkit.isValidInput(widget, { debug: false, node: node }); + const isValidInput = UIToolkit.isValidInput(widget, { debug: false, node: node }); + + return isValidInput; }); + + if (validInputs.length === 0) { - // Vue mode下使用comfy-markdown节点回退处理 - if (isVueMode && this._isMarkdownNode(node)) { - this._handleVueSpecialNode(node); + // 非目标节点类型(如 LoadImage)没有文本控件是正常的,使用 debug 级别 + logger.debug(`[checkAndSetupNode] 节点无有效控件 | ID: ${node.id} | 类型: ${node.type}`); + + // Vue mode下节点可能暂时没有识别到 LiteGraph 控件,强制回退到 DOM 扫描模式 + if (isVueMode && isValid) { + this._handleVueDomScanNode(node); } return; } @@ -717,7 +785,7 @@ class PromptAssistant { this.cleanup(node.id); } else { // 元素仍然在 DOM 中,可能只是引用变化,不需要清理 - logger.debug(() => `[checkAndSetupNode] 输入元素引用变化但仍有效,跳过清理 | 节点ID: ${node.id}`); + // logger.debug(() => `[checkAndSetupNode] 输入元素引用变化但仍有效,跳过清理 | 节点ID: ${node.id}`); return; } } else if (!currentInputEl && instanceInputEl) { @@ -738,73 +806,119 @@ class PromptAssistant { // 【防重复挂载检查】在创建前检查 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.debugSample(() => `[小助手] 创建实例 | 节点:${node.id} | 控件:${inputId} | 索引:${widgetIndex}`); } }); } /** - * Vue mode下使用comfy-markdown节点的特殊处理 - * 包括 Note、MarkdownNote、PreviewTextNode 等 - * 直接从DOM中查找textarea/编辑器并创建小助手 - */ - _handleVueSpecialNode(node) { - if (!node || !this._isMarkdownNode(node)) return; + * Vue mode 下对特殊或动态节点(Note/Subgraph等)的 DOM 扫描处理 + * 当 LiteGraph widgets 尚未就绪时,直接从 DOM 中扫描 textarea 并挂载 + */ + _handleVueDomScanNode(node) { + if (!node) return; + + const isMarkdown = this._isMarkdownNode(node); + const isSubgraph = this._isSubgraphNode(node); + + // 仅处理我们识别的有效节点 + if (!isMarkdown && !isSubgraph) return; const nodeId = node.id; - const assistantKey = `${nodeId}_text`; - // 检查是否已存在实例 + // 使用 NodeMountService 提供的逻辑,在 DOM 容器中查找所有潜在的输入框 + const nodeContainer = document.querySelector(`[data-node-id="${nodeId}"]`); + if (!nodeContainer) { + // 如果容器还没渲染,则启动一次带重试的单次挂载尝试(针对主要输入框) + if (isMarkdown) { + this._retryDomScan(node, 'text'); + } + return; + } + + // 查找所有 textarea(优先找 PrimeVue 的 .p-textarea) + const primeTextareas = Array.from(nodeContainer.querySelectorAll('textarea.p-textarea')); + const textareas = primeTextareas.length > 0 ? primeTextareas : Array.from(nodeContainer.querySelectorAll('textarea')); + + if (textareas.length === 0) { + // 可能是 TipTap 编辑器(针对 Note 节点) + const editor = nodeContainer.querySelector('.tiptap') || nodeContainer.querySelector('.ProseMirror'); + if (editor) { + this._mountDomAssistant(node, editor, 'text', 0); + } + return; + } + + // 遍历所有找到的 textarea 并尝试挂载 + textareas.forEach((el, index) => { + // 生成 Key:对于 Note 节点通常只有一个,对于子图有多个 + const inputId = textareas.length === 1 ? 'text' : `input_${index}`; + this._mountDomAssistant(node, el, inputId, index); + }); + } + + /** + * 执行实际的 DOM 挂载 + */ + _mountDomAssistant(node, element, inputId, index) { + const assistantKey = `${node.id}_${inputId}`; if (PromptAssistant.hasInstance(assistantKey)) return; - // 查找节点DOM容器和textarea - const nodeContainer = document.querySelector(`[data-node-id="${nodeId}"]`); - if (!nodeContainer) return; + // 检查元素是否已被挂载 + if (element._promptAssistantMounted) return; - const textarea = nodeContainer.querySelector('textarea.p-textarea') || - nodeContainer.querySelector('textarea') || - nodeContainer.querySelector('.tiptap') || - nodeContainer.querySelector('.ProseMirror'); - if (!textarea) return; - - // 创建虚拟widget对象 + // 创建虚拟 widget const virtualWidget = { - name: 'text', id: 'text', type: 'textarea', - inputEl: textarea, element: textarea, node: node + name: inputId, id: inputId, type: 'textarea', + inputEl: element, element: element, node: node, + _domIndex: index // 记录 DOM 索引 }; - // 节点信息 const nodeInfo = { workflow_id: app.graph?._workflow_id || 'unknown', nodeType: node.type, inputType: 'text', isNoteNode: this._isMarkdownNode(node), - isVueMode: true + isSubgraph: this._isSubgraphNode(node), + isVueMode: true, + domIndex: index }; - // 创建小助手实例 - const assistant = this.createAssistant(node, 'text', virtualWidget, nodeInfo, assistantKey); + const assistant = this.createAssistant(node, inputId, virtualWidget, nodeInfo, assistantKey); if (assistant) { this.showAssistantUI(assistant); - logger.log(`${node.type}节点小助手创建成功 | ID: ${nodeId}`); + logger.debugSample(() => `[DOM扫描] ${node.type}节点挂载成功 | ID: ${node.id} | Key: ${assistantKey}`); } } + /** + * 针对初始 DOM 未就绪的情况进行一次带重试的扫描 + */ + _retryDomScan(node, inputId) { + const widgetStub = { name: inputId, node: node }; + nodeMountService.findMountContainerWithRetry(node, widgetStub, { timeout: 2000 }) + .then(result => { + if (result && result.textarea) { + this._mountDomAssistant(node, result.textarea, inputId, 0); + } + }); + } + /** * 为节点设置小助手 * 创建小助手实例并初始化显示状态 */ setupNodeAssistant(node, inputWidget, assistantKey = null) { + + // 简化参数检查 if (!node || !inputWidget) { + logger.error(`[setupNodeAssistant] 参数无效 | node: ${!!node} | inputWidget: ${!!inputWidget}`); return null; } @@ -814,6 +928,8 @@ class PromptAssistant { const isNoteNode = this._isMarkdownNode(node); const isVueMode = typeof LiteGraph !== 'undefined' && LiteGraph.vueNodesMode === true; + + // 简化节点信息 const nodeInfo = { workflow_id: app.graph?._workflow_id || 'unknown', @@ -824,20 +940,21 @@ class PromptAssistant { }; // 处理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 }; + + } else { + } // 创建小助手实例 + const assistant = this.createAssistant( node, inputId, @@ -847,13 +964,18 @@ class PromptAssistant { ); if (assistant) { - // 初始化显示状态,始终显示 + + // 初始化显示状态 + // 初始化显示状态 this.showAssistantUI(assistant); return assistant; + } else { + console.warn(`[setupNodeAssistant] ⚠️ createAssistant 返回 null`); } return null; } catch (error) { + logger.error(`[setupNodeAssistant] ❌ 异常 | 节点: ${node.id} | 错误:`, error); logger.error(`创建小助手失败 | 节点ID: ${node.id} | 原因: ${error.message}`); return null; } @@ -864,46 +986,53 @@ class PromptAssistant { * 根据节点和输入控件构建小助手对象并初始化UI */ createAssistant(node, inputId, inputWidget, nodeInfo = {}, assistantKey = null) { - 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}`); + logger.error(`[createAssistant] ❌ 前置检查失败 | enabled: ${window.FEATURES.enabled} | node: ${!!node} | inputId: ${inputId} | inputWidget: ${!!inputWidget}`); return null; } + // 确保widget设置了node引用 if (!inputWidget.node) { inputWidget.node = node; } // 验证是否为有效输入 + if (!UIToolkit.isValidInput(inputWidget, { node: node })) { - logger.debug(() => `[createAssistant] 无效输入 | 节点ID: ${node?.id} | 控件: ${inputId}`); + console.warn(`[createAssistant] ⚠️ 输入无效 | 节点: ${node?.id} | 控件: ${inputId}`); return null; } - // 获取输入元素 - Vue mode下可能暂时不存在,将在_setupUIPosition中查找 - let inputEl = inputWidget.inputEl || inputWidget.element; - // 判断是否为Vue mode + // 获取输入元素 + let inputEl = inputWidget.inputEl || inputWidget.element; const isVueMode = typeof LiteGraph !== 'undefined' && LiteGraph.vueNodesMode === true; + + // 非Vue mode下,inputEl必须存在 if (!inputEl && !isVueMode) { - logger.debug(() => `[createAssistant] 输入元素不存在 | 节点ID: ${node?.id} | 控件: ${inputId}`); + logger.error(`[createAssistant] ❌ 非Vue模式下inputEl不存在 | 节点: ${node?.id}`); return null; } const nodeId = node.id; - // 使用传入的 assistantKey,如果没有则使用默认生成方式 const widgetKey = assistantKey || `${nodeId}_${inputId}`; + + // 检查是否已存在实例 if (PromptAssistant.hasInstance(widgetKey)) { + return PromptAssistant.getInstance(widgetKey); } + + // 创建小助手对象 const widget = { type: "prompt_assistant", @@ -912,45 +1041,66 @@ class PromptAssistant { inputId, widgetKey, buttons: {}, - text_element: inputEl, // Vue mode下可能为null,将在定位时更新 - inputEl: inputEl, // Vue mode下可能为null,将在定位时更新 - node: node, // 保存node引用,用于getInputValue等函数访问 + text_element: inputEl, + inputEl: inputEl, + isDestroyed: false, nodeInfo: { ...nodeInfo, - nodeId: node.id, + nodeId: nodeId, nodeType: node.type, - isVueMode: isVueMode // 标记是否为Vue mode + isVueMode: isVueMode }, - isTransitioning: false // 添加状态标记,避免频繁切换 + isTransitioning: false, + // 保存初始节点引用作为后备(Vue Node 2.0 子图切换场景) + _initialNode: node }; - // 创建全局输入框映射(如果不存在) + // 动态获取节点的 getter,避免持有已删除节点的引用 + // 【修复】优先从 graph 获取,失败时回退到初始引用(解决子图切换时画布未同步问题) + Object.defineProperty(widget, 'node', { + get() { + if (this.isDestroyed) return null; + // 优先从当前画布 graph 动态获取 + const graphNode = app.canvas?.graph?._nodes_by_id?.[this.nodeId]; + if (graphNode) return graphNode; + // 回退:使用初始节点引用(如果仍有效) + if (this._initialNode && this._initialNode.id === this.nodeId) { + return this._initialNode; + } + return null; + }, + configurable: true + }); + + + + // 创建全局输入框映射 if (!window.PromptAssistantInputWidgetMap) { window.PromptAssistantInputWidgetMap = {}; } - // 将当前输入框添加到映射 - Vue mode下inputEl可能为null,将在定位时更新 window.PromptAssistantInputWidgetMap[widgetKey] = { inputEl: inputEl, widget: widget }; - logger.debug(`输入框映射 | 添加映射 | 键:${widgetKey} | inputEl: ${inputEl ? 'exists' : 'null (Vue mode)'}`); + // 创建UI并添加到实例集合 this.createAssistantUI(widget, inputWidget); + PromptAssistant.addInstance(widgetKey, widget); - // 初始化撤销状态和事件绑定 - 仅在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; } @@ -970,9 +1120,13 @@ class PromptAssistant { // 初始化撤销状态(只初始化一次,使用widget级别的标记) if (!widget._undoStateInitialized) { const initialValue = inputEl.value || ''; - HistoryCacheService.initUndoState(nodeId, inputId, initialValue); + // 如果初始值不为空,则直接添加到历史记录中,确保可以撤销回初始状态 + if (initialValue.trim()) { + HistoryCacheService.addHistoryAndUpdateUndoState(nodeId, inputId, initialValue, 'input'); + } else { + HistoryCacheService.initUndoState(nodeId, inputId, initialValue); + } widget._undoStateInitialized = true; - logger.debug(`[初始化] 撤销状态初始化 | 节点ID: ${nodeId}`); } // 初始化时立即更新撤销/重做按钮状态 UIToolkit.updateUndoRedoButtonState(widget, HistoryCacheService); @@ -985,10 +1139,7 @@ class PromptAssistant { return; } - // 如果检测到遗留标记,记录一下(仅用于调试) - if (inputEl._promptAssistantBound) { - logger.debug(`[_initializeInputElBindings] 检测到DOM遗留标记,重新绑定 | 节点ID: ${nodeId}`); - } + // 如果检测到遗留标记,静默处理 inputEl._promptAssistantBound = true; widget._inputEventsBound = true; @@ -997,7 +1148,7 @@ class PromptAssistant { // 绑定输入框失焦事件,写入历史 // 使用事件管理器添加DOM事件监听 const removeBlurListener = EventManager.addDOMListener(inputEl, 'blur', async () => { - logger.debug(`历史写入准备| 原因:失焦事件触发 node_id=${node.id} input_id=${inputId}`); + // logger.debug(`历史写入准备| 原因:失焦事件触发 node_id=${node.id} input_id=${inputId}`); HistoryCacheService.addHistory({ workflow_id: nodeInfo?.workflow_id || '', node_id: node.id, @@ -1010,7 +1161,7 @@ class PromptAssistant { HistoryCacheService.initUndoState(node.id, inputId, inputEl.value); // 更新按钮状态 UIToolkit.updateUndoRedoButtonState(widget, HistoryCacheService); - logger.debug(`历史写入完成|原因:输入框失焦 node_id=${node.id} input_id=${inputId}`); + // logger.debug(`历史写入完成|原因:输入框失焦 node_id=${node.id} input_id=${inputId}`); }); // 保存清理函数引用,以便后续清理 @@ -1060,13 +1211,17 @@ class PromptAssistant { const nodeId = widget.nodeId; const inputId = widget.inputId; + + try { + // Get location setting const locationSetting = app.ui.settings.getSettingValue( - "PromptAssistant.Location", - "bottom-right-h" + "PromptAssistant.Location" ); + + // Create AssistantContainer instance const container = new AssistantContainer({ nodeId: nodeId, @@ -1074,70 +1229,75 @@ class PromptAssistant { 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.innerContent = 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(); + + + this._setupUIPosition(widget, inputEl, containerEl, canvasContainerRect, (success) => { + if (!success) { - logger.debug(`创建小助手失败 | 节点ID: ${nodeId} | 原因: 定位失败 (等待DOM渲染)`); + logger.debugSample(() => `[小助手] 创建暂缓 | 节点ID: ${nodeId} | 原因: 定位容器未就绪 (等待DOM渲染)`); container.destroy(); - // Clean up instance map const widgetKey = widget.widgetKey; if (widgetKey && PromptAssistant.instances.has(widgetKey)) { PromptAssistant.instances.delete(widgetKey); @@ -1148,17 +1308,13 @@ class PromptAssistant { return; } - // Positioning success - // Trigger dimension update now that it is in DOM + // 定位成功后更新尺寸 container.updateDimensions(); - - // 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 containerEl; } catch (error) { + console.error(`[createAssistantUI] ❌ 异常 | 节点: ${nodeId} | 错误:`, error); logger.error(`创建小助手失败 | 节点ID: ${nodeId} | 原因: ${error.message}`); return null; } @@ -1283,26 +1439,22 @@ class PromptAssistant { // 0. 检查是否正在切换弹窗(切换期间不允许折叠) if (PopupManager._isTransitioning) { - logger.debug(() => `[ActiveState] 阻止折叠 | 原因: PopupManager 正在切换弹窗 | 节点: ${widget.nodeId}`); return true; } // 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; } @@ -1311,13 +1463,10 @@ class PromptAssistant { 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; } @@ -1336,12 +1485,12 @@ class PromptAssistant { * 在功能开关变更时调用,重新计算并设置宽度 */ updateAllInstancesWidth() { - const newWidth = calculateAssistantWidth(); - logger.debug(`[宽度更新] 计算新宽度:${newWidth}px | 实例数量:${PromptAssistant.instances.size}`); + // 优化:不再手动计算宽度并注入,而是触发每个容器自身的常量布局逻辑 + logger.debug(`[布局更新] 触发所有实例尺寸重算 | 实例数量:${PromptAssistant.instances.size}`); PromptAssistant.instances.forEach((widget) => { - if (widget && widget.element) { - widget.element.style.setProperty('--expanded-width', `${newWidth}px`); + if (widget && widget.container && typeof widget.container.updateDimensions === 'function') { + widget.container.updateDimensions(); } }); } @@ -1382,11 +1531,8 @@ class PromptAssistant { return; } - // 添加高亮效果 - widget.inputEl.classList.add('input-highlight'); - setTimeout(() => { - widget.inputEl.classList.remove('input-highlight'); - }, 200); + // 使用统一的高亮工具方法 (处理了定时器管理和重绘) + UIToolkit._highlightInput(widget.inputEl); } catch (error) { logger.error(`输入框更新 | 结果:异常 | 错误:${error.message}`); } @@ -1577,35 +1723,63 @@ class PromptAssistant { // 通知UI可以准备取消操作了 notifyCancelReady(request_id); - // 显示扩写中提示 - const btnRect = e.currentTarget.getBoundingClientRect(); - UIToolkit.showStatusTip( - e.currentTarget, - 'loading', - '提示词优化中', - { x: btnRect.left + btnRect.width / 2, y: btnRect.top } - ); + // 根据开关选择流式或阻塞式 API + let result; + let streamContent = ''; - // 调用LLM服务进行扩写 - const result = await APIService.llmExpandPrompt(inputValue, request_id); + if (FEATURES.enableStreaming !== false) { + // 显示流式优化中提示 + const btnRect = e.currentTarget.getBoundingClientRect(); + UIToolkit.showStatusTip( + e.currentTarget, + 'loading', + '提示词优化中', + { x: btnRect.left + btnRect.width / 2, y: btnRect.top } + ); - if (result.success) { - // 更新输入框内容并添加高亮效果 - this.updateInputWithHighlight(widget, result.data.expanded); + result = await APIService.llmExpandPromptStream( + inputValue, + request_id, + (chunk) => { + // 流式回调:实时更新输入框内容 + streamContent += chunk; + // 使用 setInputValue 更新输入框(不触发事件,避免频繁抖动) + setInputValue(widget, streamContent, { silent: true }); + } + ); + } else { + // 显示阻塞式优化中提示 + const btnRect = e.currentTarget.getBoundingClientRect(); + UIToolkit.showStatusTip( + e.currentTarget, + 'loading', + '提示词优化中', + { x: btnRect.left + btnRect.width / 2, y: btnRect.top } + ); - // 添加扩写结果到历史记录 + result = await APIService.llmExpandPrompt(inputValue, request_id); + } + + // 流式完成后,获取最终内容 + const finalContent = streamContent || result?.data?.expanded || ''; + + if (result && result.success && finalContent) { + // 最终更新(触发事件和高亮) + this.updateInputWithHighlight(widget, finalContent); + + // 添加扩写结果到历史记录(只记录最终结果) HistoryCacheService.addHistory({ workflow_id: widget.nodeInfo?.workflow_id || '', node_id: widget.nodeId, input_id: widget.inputId, - content: result.data.expanded, + content: finalContent, operation_type: 'expand', request_id: request_id, timestamp: Date.now() }); // 重置撤销状态 - HistoryCacheService.initUndoState(widget.nodeId, widget.inputId, result.data.expanded); + HistoryCacheService.initUndoState(widget.nodeId, widget.inputId, finalContent); // 更新按钮状态 UIToolkit.updateUndoRedoButtonState(widget, HistoryCacheService); @@ -1618,7 +1792,7 @@ class PromptAssistant { }; } else { // 不在这里显示错误提示,直接抛出错误让 handleAsyncButtonOperation 处理 - throw new Error(result.error); + throw new Error(result?.error || '扩写失败'); } } catch (error) { // 不在这里显示错误提示,直接抛出错误让 handleAsyncButtonOperation 处理 @@ -1866,7 +2040,7 @@ class PromptAssistant { throw new Error('没有检测到可翻译的内容'); } // 如果原内容有东西但提取为空,可能全是代码块,保留原内容作为待翻译(实际上API可能跳过) - // 或者这里 contentToTranslate 保持为 inputValue ? + // 或者这里 contentToTranslate 为 inputValue ? // 不,protectAndExtract 没提取到,说明不该翻译。 // 但为了流程继续,如果不抛错,我们假设 contentToTranslate 为空导致后续报错 } @@ -1955,6 +2129,7 @@ class PromptAssistant { // 获取翻译服务配置 let result; + let streamContent = ''; // 用于流式收集内容 try { // 获取翻译配置 const configResp = await fetch(APIService.getApiUrl('/config/translate')); @@ -1969,15 +2144,29 @@ class PromptAssistant { } if (isBaidu) { - // 使用百度翻译服务 + // 百度翻译不支持流式,使用原有接口(自动降级) result = await APIService.baiduTranslate( contentToTranslate, langResult.from, langResult.to, request_id ); + } else if (FEATURES.enableStreaming !== false) { + // ---流式输出:LLM翻译使用流式 API--- + result = await APIService.llmTranslateStream( + contentToTranslate, + langResult.from, + langResult.to, + request_id, + (chunk) => { + // 流式回调:实时更新输入框内容 + streamContent += chunk; + // 使用 silent 模式更新,避免频繁触发事件 + setInputValue(widget, streamContent, { silent: true, html: isMarkdownLiteGraph }); + } + ); } else { - // 使用LLM翻译服务 + // ---阻塞输出:LLM翻译使用普通 API--- result = await APIService.llmTranslate( contentToTranslate, langResult.from, @@ -1995,8 +2184,9 @@ class PromptAssistant { } if (result.success) { - // 格式化翻译结果(转换标点符号) - const formattedText = PromptFormatter.formatTranslatedText(result.data.translated); + // 格式化翻译结果(优先使用流式收集的内容,否则使用API返回的内容) + const rawTranslated = streamContent || result.data?.translated || ''; + const formattedText = PromptFormatter.formatTranslatedText(rawTranslated); // 处理 Markdown 格式还原 let finalResultText = formattedText; @@ -2027,7 +2217,15 @@ class PromptAssistant { // 只有开启缓存时才写入缓存 (使用提取文本和翻译后的片段文本,以便下次能复用) if (FEATURES.useTranslateCache) { - TranslateCacheService.addTranslateCache(contentToTranslate, formattedText); + // 检查是否是混合语言 + const isMixedLang = PromptFormatter.isMixedChineseEnglish(contentToTranslate); + + // 只有当不是混合语言,或者用户允许缓存混合语言时才写入缓存 + if (!isMixedLang || FEATURES.cacheMixedLangTranslation) { + TranslateCacheService.addTranslateCache(contentToTranslate, formattedText); + } else { + logger.debug(`翻译缓存 | 跳过:混合语言内容`); + } } return { @@ -2050,7 +2248,7 @@ class PromptAssistant { visible: FEATURES.translate, // Note节点只显示此按钮 // 添加右键菜单配置 contextMenu: async (widget) => { - const useTranslateCache = app.ui.settings.getSettingValue("PromptAssistant.Features.UseTranslateCache", true); + const useTranslateCache = app.ui.settings.getSettingValue("PromptAssistant.Features.UseTranslateCache"); // 获取所有服务列表和当前激活状态 let services = []; @@ -2096,6 +2294,11 @@ class PromptAssistant { if (!res.ok) throw new Error(`服务器返回错误: ${res.status}`); UIToolkit.showStatusTip(context.buttonElement, 'success', `已切换到: 百度翻译`); logger.log(`翻译服务切换 | 服务: 百度翻译`); + + // 派发全局事件通知其他组件同步 + window.dispatchEvent(new CustomEvent('pa-service-changed', { + detail: { service_type: 'translate', service_id: 'baidu' } + })); } catch (err) { logger.error(`切换翻译服务失败: ${err.message}`); UIToolkit.showStatusTip(context.buttonElement, 'error', `切换失败: ${err.message}`); @@ -2126,6 +2329,11 @@ class PromptAssistant { const modelLabel = model.display_name || model.name; UIToolkit.showStatusTip(context.buttonElement, 'success', `已切换到: ${service.name} - ${modelLabel}`); logger.log(`翻译服务切换 | 服务: ${service.name} | 模型: ${modelLabel}`); + + // 派发全局事件通知其他组件同步 + window.dispatchEvent(new CustomEvent('pa-service-changed', { + detail: { service_type: 'translate', service_id: service.id, model_name: model.name } + })); } catch (err) { logger.error(`切换翻译模型失败: ${err.message}`); UIToolkit.showStatusTip(context.buttonElement, 'error', `切换失败: ${err.message}`); @@ -2147,6 +2355,11 @@ class PromptAssistant { if (!res.ok) throw new Error(`服务器返回错误: ${res.status}`); UIToolkit.showStatusTip(context.buttonElement, 'success', `已切换到: ${service.name}`); logger.log(`翻译服务切换 | 服务: ${service.name}`); + + // 派发全局事件通知其他组件同步 + window.dispatchEvent(new CustomEvent('pa-service-changed', { + detail: { service_type: 'translate', service_id: service.id } + })); } catch (err) { logger.error(`切换翻译服务失败: ${err.message}`); UIToolkit.showStatusTip(context.buttonElement, 'error', `切换失败: ${err.message}`); @@ -2310,6 +2523,11 @@ class PromptAssistant { if (!forceUpdate && prevState === hasScrollbar) { return; // 状态未变,不做任何操作 } + + // 【关键修复】在位置/布局调整前,显式移除输入框的高亮状态 + // 防止浏览器在重排(Relayout)过程中产生动画残留 + UIToolkit.removeHighlight(inputEl); + containerDiv.dataset.hasScrollbar = String(hasScrollbar); // 有滚动条时向左偏移,避开滚动条 @@ -2323,19 +2541,21 @@ class PromptAssistant { * @param {Function} onComplete - 定位完成回调,接收boolean参数,true表示成功,false表示失败 */ _setupUIPosition(widget, inputEl, containerDiv, canvasContainerRect, onComplete) { - logger.debug(`[_setupUIPosition] 开始定位 | 节点ID: ${widget.nodeId} | inputEl存在: ${!!inputEl}`); + // 清理函数列表 widget._eventCleanupFunctions = widget._eventCleanupFunctions || []; - // 获取节点对象 - const node = app.graph?.getNodeById(widget.nodeId); + // 【关键修复】直接使用 widget.node 而不是通过 app.graph.getNodeById 查找 + // 因为进入子图后 app.graph 已经切换到子图的 graph,无法找到主画布节点 + const node = widget.node; if (!node) { - logger.debug(`[定位] 节点不存在 | ID: ${widget.nodeId}`); + logger.debug(`[定位] widget.node 不存在 | ID: ${widget.nodeId}`); if (onComplete) onComplete(false); return; } + // 创建widget对象用于容器查找 const widgetObj = { inputEl: inputEl, @@ -2352,7 +2572,7 @@ class PromptAssistant { retryInterval: isVueMode ? 800 : 500 }).then(containerInfo => { if (!containerInfo) { - logger.debug(`[定位] 容器查找失败 | 节点ID: ${widget.nodeId}`); + // logger.debug(`[定位] 容器查找失败 | 节点ID: ${widget.nodeId}`); if (onComplete) onComplete(false); return; } @@ -2370,8 +2590,9 @@ class PromptAssistant { // 触发回流确保样式生效 void containerDiv.offsetWidth; + // 最终成功日志保持精简 + logger.debug(`[定位] 成功 | ID: ${widget.nodeId} | 模式: ${containerInfo.mode} | 锚点: ${widget.container?.anchorPosition}`); if (onComplete) onComplete(true); - logger.debug(`[定位] 成功 | 节点ID: ${widget.nodeId} | 模式: ${containerInfo.mode}`); }).catch(error => { logger.error(`[定位] 异常 | 节点ID: ${widget.nodeId} | 错误: ${error.message}`); @@ -2386,45 +2607,29 @@ class PromptAssistant { 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}`); + widget._eventCleanupFunctions.push(EventManager.addDOMListener(textarea, 'blur', async () => { HistoryCacheService.addHistory({ workflow_id: widget.nodeInfo?.workflow_id || '', node_id: widget.nodeId, @@ -2433,114 +2638,76 @@ class PromptAssistant { 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', () => { + widget._eventCleanupFunctions.push(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}`); } + UIToolkit.updateUndoRedoButtonState(widget, HistoryCacheService); } - // 【防重复挂载检查】检查 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(); - } + // 防重复挂载检查 + if (textarea && textarea._promptAssistantMounted && textarea._promptAssistantWidgetKey !== widget.widgetKey) { + this._cleanupRedundantWidget(widget); 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(); - } + if (existingAssistant && !container.contains(containerDiv)) { + this._cleanupRedundantWidget(widget); 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') { + if (window.getComputedStyle(container).position === '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); + if (!container.contains(containerDiv)) { + container.appendChild(containerDiv); } - logger.debug(`[Vue定位] 完成 | 节点ID: ${widget.nodeId} | 锚点: ${widget.container?.anchorPosition}`); + if (textarea) { + requestAnimationFrame(() => this._adjustPositionForScrollbar(widget, textarea, true)); + setTimeout(() => this._adjustPositionForScrollbar(widget, textarea, true), 150); + } + } + + /** + * 清理冗余的 Widget 实例(当由于并发原因导致重复创建时) + * @private + */ + _cleanupRedundantWidget(widget) { + if (widget.widgetKey && PromptAssistant.instances.has(widget.widgetKey)) { + PromptAssistant.instances.delete(widget.widgetKey); + } + if (widget.container) { + widget.container.destroy(); + } } /** @@ -2560,7 +2727,7 @@ class PromptAssistant { window.PromptAssistantInputWidgetMap[widget.widgetKey].inputEl = textarea; } - logger.debug(`[Litegraph定位] 更新inputEl引用 | 节点ID: ${widget.nodeId}`); + // logger.debug(`[Litegraph定位] 更新inputEl引用 | 节点ID: ${widget.nodeId}`); } // 【关键修复】确保事件绑定(与 Vue mode 一致的后备逻辑) @@ -2569,7 +2736,9 @@ class PromptAssistant { // 使用 widget 级别的 flag 判断 const isBound = widget._inputEventsBound; - logger.debug(`[Litegraph定位] 事件绑定检查 | 节点ID: ${widget.nodeId} | inputEl存在: ${!!inputEl} | isBound: ${isBound}`); + // 精简定位开始日志 + // logger.debug(`[_setupUIPosition] 开始定位 | 节点ID: ${widget.nodeId}`); + // logger.debug(`[Litegraph定位] 事件绑定检查 | 节点ID: ${widget.nodeId} | inputEl存在: ${!!inputEl} | isBound: ${isBound}`); // 如果没有绑定,则绑定事件 if (inputEl && !isBound) { @@ -2581,11 +2750,11 @@ class PromptAssistant { inputEl._promptAssistantBound = true; widget._inputEventsBound = true; // 设置标记 widget._eventCleanupFunctions = widget._eventCleanupFunctions || []; - logger.debug(`[Litegraph定位] 开始绑定事件 | 节点ID: ${widget.nodeId}`); + // 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}`); + // logger.debug(`[Litegraph] 历史写入准备 | 原因:失焦事件触发 node_id=${widget.nodeId} input_id=${widget.inputId}`); HistoryCacheService.addHistory({ workflow_id: widget.nodeInfo?.workflow_id || '', node_id: widget.nodeId, @@ -2608,15 +2777,13 @@ class PromptAssistant { }); 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}`); + // logger.debug(`[Litegraph定位] 事件绑定完成 | 节点ID: ${widget.nodeId}`); } else if (inputEl && inputEl._promptAssistantBound) { // 已绑定,只更新按钮状态 UIToolkit.updateUndoRedoButtonState(widget, HistoryCacheService); @@ -2676,8 +2843,6 @@ class PromptAssistant { if (inputEl) { requestAnimationFrame(() => this._adjustPositionForScrollbar(widget, inputEl, true)); } - - logger.debug(`[Litegraph定位] 完成 | 节点ID: ${widget.nodeId} | 锚点: ${widget.container?.anchorPosition}`); } /** @@ -2691,6 +2856,9 @@ class PromptAssistant { return; } + // 标记实例为已销毁 + instance.isDestroyed = true; + // 1. 重置所有按钮状态 if (instance.buttons) { Object.keys(instance.buttons).forEach(buttonId => { @@ -2737,7 +2905,6 @@ class PromptAssistant { // 确保模式切换后可以重新绑定事件 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; @@ -2748,7 +2915,6 @@ class PromptAssistant { 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; @@ -2822,7 +2988,7 @@ class PromptAssistant { logger.debug(`属性清理 | 错误:${err.message}`); } - logger.debug(`实例清理 | 结果:成功 | 实例:${instanceKey || 'unknown'}`); + // logger.debug(`实例清理 | 结果:成功 | 实例:${instanceKey || 'unknown'}`); } catch (error) { logger.error(`实例清理失败 | 实例:${instanceKey || 'unknown'} | 错误:${error.message}`); } diff --git a/js/modules/apiConfigManager.js b/js/modules/apiConfigManager.js index 7db42fa..6205544 100644 --- a/js/modules/apiConfigManager.js +++ b/js/modules/apiConfigManager.js @@ -34,6 +34,15 @@ class APIConfigManager { this.baiduConfig = { app_id: '', secret_key: '' }; } + /** + * 通知系统 API 配置已更新 + * 触发 pa-config-updated 事件,通知 settings.js 等模块刷新 + */ + notifyConfigChange() { + logger.debug('分发 API 配置更新事件: pa-config-updated'); + window.dispatchEvent(new CustomEvent('pa-config-updated')); + } + /** * 显示API配置弹窗 */ @@ -154,6 +163,9 @@ class APIConfigManager { logger.debug('百度翻译配置已保存'); + // 触发配置同步事件 + this.notifyConfigChange(); + // 显示成功提示 app.extensionManager.toast.add({ severity: "success", @@ -293,6 +305,9 @@ class APIConfigManager { this.services = orderedServices; logger.debug('服务商顺序已更新', { order: serviceIds }); + + // 触发配置同步事件 + this.notifyConfigChange(); } else { throw new Error(result.error || '更新顺序失败'); } @@ -589,6 +604,9 @@ class APIConfigManager { // 切换到新标签 this._switchTab(newService.id, headerElement, contentElement); } + + // 触发配置同步事件 + this.notifyConfigChange(); } else { throw new Error(result.error || '创建失败'); } @@ -1463,7 +1481,7 @@ class APIConfigManager { maxTokensInput.input.min = '1'; maxTokensInput.input.max = '8192'; maxTokensInput.input.step = '1'; - maxTokensInput.input.value = selectedModel.max_tokens ?? 512; + maxTokensInput.input.value = selectedModel.max_tokens ?? 1024; maxTokensInput.input.dataset.fieldName = 'max_tokens'; maxTokensInput.group.style.width = '135px'; formContainer.appendChild(maxTokensInput.group); @@ -1668,7 +1686,7 @@ class APIConfigManager { model_name: modelName, temperature: 0.7, top_p: 0.9, - max_tokens: 512 + max_tokens: 1024 }) }); @@ -1694,7 +1712,7 @@ class APIConfigManager { is_default: updatedList.length === 0, temperature: 0.7, top_p: 0.9, - max_tokens: 512 + max_tokens: 1024 }); // 移除空提示 @@ -2011,6 +2029,9 @@ class APIConfigManager { listContainer.appendChild(emptyHint); } + // 触发配置同步事件 + this.notifyConfigChange(); + return true; // 允许关闭对话框 } else { throw new Error(result.error || '删除失败'); @@ -2052,6 +2073,9 @@ class APIConfigManager { Object.assign(service, updates); } + // 触发配置同步事件 + this.notifyConfigChange(); + logger.debug('服务商配置已更新', serviceId); // 显示成功提示 diff --git a/js/modules/history.js b/js/modules/history.js index f43c9ae..5ea68c6 100644 --- a/js/modules/history.js +++ b/js/modules/history.js @@ -34,7 +34,7 @@ class HistoryManager { this.currentNodeId = nodeId; this.currentInputId = inputId; - logger.debug(`历史弹窗 | 触发显示 | 节点:${nodeId} | 输入框:${inputId}`); + // logger.debug(`历史弹窗 | 触发显示 | 节点:${nodeId} | 输入框:${inputId}`); // 清理现有事件监听 this._cleanupEvents(); @@ -64,7 +64,7 @@ class HistoryManager { } }); - logger.debug(`历史弹窗 | 结果:显示成功 | 节点:${nodeId}`); + // logger.debug(`历史弹窗 | 结果:显示成功 | 节点:${nodeId}`); } catch (error) { logger.error(`历史弹窗 | 结果:失败 | 错误:${error.message}`); this._cleanupAll(); diff --git a/js/modules/imageCaption.js b/js/modules/imageCaption.js index 231246b..c399c5a 100644 --- a/js/modules/imageCaption.js +++ b/js/modules/imageCaption.js @@ -47,6 +47,13 @@ class ImageCaption { return app.canvas?.low_quality_zoom_threshold || 0.6; } + /** + * 检查节点类型是否是允许的图像节点类型(公开方法) + */ + isSupportedNode(node) { + return this._isAllowedNodeType(node); + } + /** * 检查节点类型是否是允许的图像节点类型 * @param {object} node - LiteGraph节点对象 @@ -311,29 +318,21 @@ class ImageCaption { return; } - // 使用新的方法检查节点是否有有效且可见的图像 - const currentImage = this.getNodeImage(node); + // 验证现有实例是否有效 + const existingInstance = ImageCaption.getInstance(node.id); + if (existingInstance && existingInstance.element && document.body.contains(existingInstance.element)) { + // 实例有效,更新其显示状态(内部会处理图片检测) + this.updateAssistantVisibility(existingInstance); + } else { + // 实例无效或不存在,清理并强制创建(白名单机制) + if (existingInstance) { + this.cleanup(node.id); + } - if (currentImage) { - // 有效图像,创建或显示小助手 - const existingInstance = ImageCaption.getInstance(node.id); - - // 验证现有实例是否有效 - if (existingInstance && existingInstance.element && document.body.contains(existingInstance.element)) { - // 实例有效,显示它 - this.showAssistantUI(existingInstance); - } else { - // 实例无效或不存在,清理并创建新实例 - if (existingInstance) { - // 先清理可能存在但无效的实例 - this.cleanup(node.id); - } - - // 创建新的小助手实例 - const assistant = this.setupNodeAssistant(node); - if (assistant) { - logger.log(() => `创建图像小助手 | ID: ${node.id}`); - } + // 创建新的小助手实例 + const assistant = this.setupNodeAssistant(node); + if (assistant) { + logger.debug(() => `[图像小助手] 预挂载成功 | ID: ${node.id} | 等待图像加载...`); } } } @@ -368,22 +367,44 @@ class ImageCaption { return ImageCaption.getInstance(node.id); } + // 保存 nodeId 用于动态获取节点 + const nodeId = node.id; + // 创建小助手对象 const assistant = { - node, - nodeId: node.id, + nodeId: nodeId, buttons: {}, isActive: false, isTransitioning: false, - _eventCleanupFunctions: [], // 用于存储事件清理函数 - _timers: {} // 用于存储定时器引用 + isDestroyed: false, + _eventCleanupFunctions: [], + _timers: {}, + // 保存初始节点引用作为后备(Vue Node 2.0 子图切换场景) + _initialNode: node }; + // 动态获取节点的 getter,避免持有已删除节点的引用 + // 【修复】优先从 graph 获取,失败时回退到初始引用(解决子图切换时画布未同步问题) + Object.defineProperty(assistant, 'node', { + get() { + if (this.isDestroyed) return null; + // 优先从当前画布 graph 动态获取 + const graphNode = app.canvas?.graph?._nodes_by_id?.[this.nodeId]; + if (graphNode) return graphNode; + // 回退:使用初始节点引用(如果仍有效) + if (this._initialNode && this._initialNode.id === this.nodeId) { + return this._initialNode; + } + return null; + }, + configurable: true + }); + // 创建UI this.createAssistantUI(assistant); // 添加到实例集合 - ImageCaption.addInstance(node.id, assistant); + ImageCaption.addInstance(nodeId, assistant); // 设置节点折叠状态监听 this._setupNodeCollapseListener(assistant); @@ -401,13 +422,13 @@ class ImageCaption { * 创建小助手UI */ createAssistantUI(assistant) { - if (!assistant?.node) return null; + // 【修复】使用 nodeId 作为有效性检查,避免因画布切换时 getter 返回 null 导致创建失败 + if (!assistant?.nodeId) return null; try { // 获取位置设置 const locationSetting = app.ui.settings.getSettingValue( - "ImageCaption.Location", - "bottom-left-h" + "ImageCaption.Location" ); // Create AssistantContainer instance @@ -909,6 +930,137 @@ class ImageCaption { } } + // ---流式输出辅助方法--- + + /** + * 创建流式显示浮层 + /** + * 创建节点内嵌流式显示容器 + */ + _createStreamingOverlay(assistant) { + try { + const nodeId = assistant.node?.id; + // 策略1:优先尝试获取 Vue 节点容器 + let mountContainer = document.querySelector(`[data-node-id="${nodeId}"]`); + let isLiteGraph = false; + + if (!mountContainer) { + // 策略2:LiteGraph 模式,使用 Body 挂载 + Fixed 定位实现“裁切隔离” + mountContainer = document.body; + isLiteGraph = true; + } + + // 创建外层容器 + const container = document.createElement('div'); + container.className = 'node-streaming-text-container'; + if (isLiteGraph) { + container.classList.add('is-litegraph'); + } + + // 创建文本内容容器 + const content = document.createElement('div'); + content.className = 'node-streaming-text-content'; + + container.appendChild(content); + mountContainer.appendChild(container); + + // LiteGraph 模式:启动帧同步位置锁定 + let syncHandler = null; + if (isLiteGraph) { + const syncPos = () => { + if (!assistant.streamingOverlay || !assistant.streamingOverlay.container) return; + this._syncLiteGraphPosition(assistant, container); + syncHandler = requestAnimationFrame(syncPos); + }; + syncHandler = requestAnimationFrame(syncPos); + } + + // 触发入场动画 + requestAnimationFrame(() => { + container.classList.add('show'); + }); + + return { container, content, isLiteGraph, syncHandler }; + } catch (error) { + logger.error(`创建流式显示容器失败: ${error.message}`); + return null; + } + } + + /** + * LiteGraph 模式下的位置与几何同步 (Viewport 绝对定位) + */ + _syncLiteGraphPosition(assistant, container) { + const node = assistant.node; + if (!node || !app.canvas) return; + + const ds = app.canvas.ds; + const pos = node.pos; + const size = node.size; + const scale = ds?.scale || 1; + + // 1. 获取节点左上角的屏幕坐标 + let screenX, screenY; + if (ds && typeof ds.canvas_to_screen === 'function') { + const screenPos = ds.canvas_to_screen(pos[0], pos[1]); + screenX = screenPos[0]; + screenY = screenPos[1]; + } else { + screenX = (pos[0] + (ds?.offset?.[0] || 0)) * scale; + screenY = (pos[1] + (ds?.offset?.[1] || 0)) * scale; + } + + // 2. 同步几何尺寸:将节点的逻辑宽度、高度转换成屏幕物理像素 + // 关键点:容器的宽高必须随 scale 同步,才能防止文字“溢出”节点物理边界 + const physicalWidth = size[0] * scale; + const physicalHeight = 100 * scale; // 逻辑高度 100px 转换为物理像素 + + // 3. 计算对齐位置 + // 底部工具栏高度逻辑值为 35px,转换后为 35 * scale + const bottomOffset = 35 * scale; + const top = screenY + (size[1] * scale) - bottomOffset - physicalHeight; + + // 4. 应用样式到同步容器 + container.style.width = `${physicalWidth - 16 * scale}px`; // 减去边缘间距 + container.style.height = `${physicalHeight}px`; + container.style.left = `${screenX + 8 * scale}px`; + container.style.top = `${top}px`; + + // 5. 同步内容缩放:确保字号随节点大小变化 + // 注意:我们通过改变容器的 fontSize 来影响内容。 + // content 容器内部使用相对单位或依靠父级继承。 + const content = container.querySelector('.node-streaming-text-content'); + if (content) { + content.style.fontSize = `${10 * scale}px`; + content.style.lineHeight = `${1.6}`; + } + } + + /** + * 移除流式内嵌容器 + */ + _removeStreamingOverlay(assistant, overlayObj) { + if (!overlayObj || !overlayObj.container) return; + const { container, syncHandler } = overlayObj; + + // 停止同步循环 + if (syncHandler) { + cancelAnimationFrame(syncHandler); + } + + try { + container.classList.remove('show'); + setTimeout(() => { + if (container.parentNode) { + container.parentNode.removeChild(container); + } + }, 300); + } catch (error) { + logger.error(`移除流式容器失败: ${error.message}`); + if (container.parentNode) container.parentNode.removeChild(container); + } + } + /** * 处理图像分析 */ @@ -1056,8 +1208,58 @@ class ImageCaption { throw new Error(`获取反推规则失败: ${error.message}`); } - // 调用图像分析服务,传入提示词内容 - const result = await APIService.llmAnalyzeImage(imageBase64, promptContent, currentRequestId); + // 根据开关选择流式或阻塞式 API + let result; + let fullContent = ''; + + if (FEATURES.enableStreaming !== false) { + // 创建内嵌流式对象 + const overlayObj = this._createStreamingOverlay(assistant); + if (!overlayObj) { + logger.error("[图像小助手] 无法创建内嵌流式显示对象"); + return; + } + assistant.streamingOverlay = overlayObj; + // 使用流式 API 调用图像分析服务 + result = await APIService.llmAnalyzeImageStream( + imageBase64, + promptContent, + currentRequestId, + (chunk) => { + // 流式回调:实时更新浮层内容 + fullContent += chunk; + if (assistant.streamingOverlay && assistant.streamingOverlay.content) { + const contentEl = assistant.streamingOverlay.content; + + // 更新文本内容 + contentEl.textContent = fullContent; + + // 滚动容器(保持在底部) + const container = assistant.streamingOverlay.container; + container.scrollTop = container.scrollHeight; + } + } + ); + + // 确保流式内容在完成后被赋值到结果对象中 + if (result && result.success && fullContent) { + if (!result.data) result.data = {}; + result.data.description = fullContent; + } + } else { + // ---阻塞输出:直接调用图像分析服务--- + result = await APIService.llmAnalyzeImage( + imageBase64, + promptContent, + currentRequestId + ); + } + + // 移除流式内嵌容器 + if (assistant.streamingOverlay) { + this._removeStreamingOverlay(assistant, assistant.streamingOverlay); + assistant.streamingOverlay = null; + } // 清除当前请求ID assistant.currentRequestId = null; @@ -1073,8 +1275,8 @@ class ImageCaption { throw new Error(errorMsg); } - // 获取描述文本 - const description = result.data.description; + // 获取描述文本(优先使用流式收集的内容) + const description = fullContent || result.data?.description; if (!description) { throw new Error('未获取到图像描述'); } @@ -1338,19 +1540,19 @@ class ImageCaption { // 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; } @@ -1359,7 +1561,7 @@ class ImageCaption { const button = assistant.buttons[buttonId]; if (button.classList.contains('button-active') || button.classList.contains('button-processing')) { - console.log(`[ActiveState-Image] 阻止折叠 | 原因: 按钮${buttonId}状态激活`); + return true; } } @@ -1453,7 +1655,11 @@ class ImageCaption { * 更新小助手可见性 */ updateAssistantVisibility(assistant) { - if (!assistant) return; + // 检查实例是否已销毁 + if (!assistant || assistant.isDestroyed) return; + + // 动态获取节点 + const node = assistant.node; // 记录当前显示状态,用于检测变化 const wasVisible = assistant.element && @@ -1462,14 +1668,13 @@ class ImageCaption { // 检查总开关和图像反推功能开关状态 if (!window.FEATURES || !window.FEATURES.enabled || !window.FEATURES.imageCaption) { - this.cleanup(assistant.node.id); + this.cleanup(assistant.nodeId); return; } - // 检查节点是否已被删除(关键修复) - if (assistant.node && (!app.canvas || !app.canvas.graph || !app.canvas.graph._nodes_by_id[assistant.node.id])) { - // 清理已删除节点的实例 - this.cleanup(assistant.node.id); + // 检查节点是否已被删除 + if (!node) { + this.cleanup(assistant.nodeId); return; } @@ -1502,6 +1707,32 @@ class ImageCaption { // 如果节点没有有效图像,隐藏小助手但不清理实例 if (!nodeState.hasValidImage) { shouldBeVisible = false; + + // ---异步图像检测逻辑 (针对性能优化)--- + // 如果节点是支持的类型但暂时没图,启动一个内部轻量级探测器 + if (!assistant._imageDetectionTimer) { + const detectImage = () => { + if (!assistant.node) return; + const currentState = this._checkNodeAndCanvasState(assistant.node); + if (currentState.hasValidImage) { + logger.debug(() => `[图像小助手] 异步图像加载成功 | ID: ${assistant.nodeId}`); + assistant._imageDetectionTimer = null; + this.updateAssistantVisibility(assistant); + } else if (document.body.contains(assistant.element)) { + // 继续等待,每秒检测一次 (开销极低) + assistant._imageDetectionTimer = setTimeout(detectImage, 1000); + } else { + assistant._imageDetectionTimer = null; + } + }; + assistant._imageDetectionTimer = setTimeout(detectImage, 1000); + } + } else { + // 已有图像,清理探测定时器 + if (assistant._imageDetectionTimer) { + clearTimeout(assistant._imageDetectionTimer); + assistant._imageDetectionTimer = null; + } } // 跳过正在过渡的实例,避免动画中断 @@ -1739,15 +1970,16 @@ class ImageCaption { // 优化:使用requestAnimationFrame在每一帧更新位置,或者直接利用 LiteGraph 的渲染循环 // 这里为了跟随平滑,直接在 drawBackground 钩子中更新 const originalDrawBackground = app.canvas.onDrawBackground; - app.canvas.onDrawBackground = function () { + const onDrawWrapper = function () { const ret = originalDrawBackground?.apply(this, arguments); updatePosition(); return ret; }; + app.canvas.onDrawBackground = onDrawWrapper; // 添加画布重绘清理函数 assistant._eventCleanupFunctions.push(() => { - if (app.canvas.onDrawBackground === arguments.callee) { + if (app.canvas.onDrawBackground === onDrawWrapper) { app.canvas.onDrawBackground = originalDrawBackground; } }); @@ -2115,6 +2347,9 @@ class ImageCaption { _cleanupSingleInstance(assistant) { if (!assistant) return; + // 标记实例为已销毁 + assistant.isDestroyed = true; + try { // 清理DOM元素 if (assistant.element && assistant.element.parentNode) { @@ -2141,13 +2376,17 @@ class ImageCaption { assistant._timers = {}; } - // 清理引用 + if (assistant._imageDetectionTimer) { + clearTimeout(assistant._imageDetectionTimer); + assistant._imageDetectionTimer = null; + } + + // 清理引用(node 属性为动态 getter,无需手动清理) assistant.element = null; assistant.innerContent = null; assistant.hoverArea = null; assistant.indicator = null; assistant.buttons = {}; - assistant.node = null; } catch (error) { logger.error(`清理单个实例失败 | ${error.message}`); } @@ -2187,19 +2426,29 @@ class ImageCaption { node._imageCaptionInitialized = false; } }); + + // 2. 如果开启了自动创建,立即扫描所有有效节点 + const icCreationMode = app.ui.settings.getSettingValue("PromptAssistant.Settings.ImageCaptionCreationMode") || "auto"; + if (icCreationMode === "auto") { + nodes.forEach(node => { + if (node && this.hasValidImage(node) && !node._imageCaptionInitialized) { + node._imageCaptionInitialized = true; + this.checkAndSetupNode(node); + } + }); + } } - // 2. 设置或恢复节点选择事件监听 + // 3. 设置或恢复节点选择事件监听 this.registerNodeSelectionListener(); - // 3. 检查当前选中的节点 + // 4. 检查当前选中的节点 if (app.canvas && app.canvas.selected_nodes) { app.canvas._imageCaptionSelectionHandler(app.canvas.selected_nodes); } } else { // === 禁用图像反推功能 === // 1. 清理所有实例 - const instanceCount = ImageCaption.instances.size; this.cleanup(null, true); } @@ -2212,6 +2461,20 @@ class ImageCaption { } } + /** + * 更新所有实例的预设尺寸 + * 当功能开关或配置变更时调用,触发容器的常量布局计算逻辑 + */ + updateAllInstancesWidth() { + logger.debug(`[图像小助手] 触发所有实例尺寸重算 | 实例数量: ${ImageCaption.instances.size}`); + + ImageCaption.instances.forEach((assistant) => { + if (assistant && assistant.container && typeof assistant.container.updateDimensions === 'function') { + assistant.container.updateDimensions(); + } + }); + } + } // 创建单例实例 diff --git a/js/modules/nodeHelpTranslator.js b/js/modules/nodeHelpTranslator.js index 626ec4b..de991f4 100644 --- a/js/modules/nodeHelpTranslator.js +++ b/js/modules/nodeHelpTranslator.js @@ -20,8 +20,101 @@ class NodeHelpTranslator { element: null, bilingualSwitch: null }; + + // 服务列表缓存(用于菜单构建) + this._servicesCache = null; + this._servicesCacheTime = 0; + // 当前翻译服务配置缓存(用于菜单选中状态显示) + this._currentTranslateConfig = null; + + // 监听全局服务变更事件,确保实时同步 + window.addEventListener('pa-service-changed', () => { + logger.debug("[NodeHelpTranslator] 收到全局服务变更通知,正在同步配置..."); + this._getTranslateConfig(); + }); } + // ---翻译服务配置管理(使用全局配置)--- + + /** + * 获取当前全局翻译服务配置 + * @returns {Promise<{ serviceId: string, modelName: string } | null>} + */ + async _getTranslateConfig() { + try { + const response = await fetch(APIService.getApiUrl('/config/translate')); + if (response.ok) { + const config = await response.json(); + this._currentTranslateConfig = config; + return { + serviceId: config.provider || null, + modelName: config.model || null + }; + } + } catch (e) { + logger.warn(`[NodeHelpTranslator] 获取翻译配置失败: ${e.message}`); + } + return null; + } + + /** + * 设置全局翻译服务配置 + * @param {string} serviceId + * @param {string} modelName + * @returns {Promise} + */ + async _setTranslateConfig(serviceId, modelName) { + try { + const response = await fetch(APIService.getApiUrl('/services/current'), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + service_type: 'translate', + service_id: serviceId, + model_name: modelName + }) + }); + if (response.ok) { + const result = await response.json(); + if (result.success) { + logger.log(`[NodeHelpTranslator] 已更新全局翻译服务: ${serviceId} - ${modelName}`); + return true; + } + } + } catch (e) { + logger.warn(`[NodeHelpTranslator] 更新翻译服务失败: ${e.message}`); + } + return false; + } + + /** + * 获取可用的服务列表(带缓存) + */ + async _getAvailableServices() { + const now = Date.now(); + // 缓存5分钟 + if (this._servicesCache && (now - this._servicesCacheTime) < 300000) { + return this._servicesCache; + } + + try { + const response = await fetch(APIService.getApiUrl('/services')); + if (response.ok) { + const data = await response.json(); + if (data.success && data.services) { + // 保存所有服务列表(百度等翻译服务可能没有 llm_models) + this._servicesCache = data.services; + this._servicesCacheTime = now; + return this._servicesCache; + } + } + } catch (e) { + logger.warn(`[NodeHelpTranslator] 获取服务列表失败: ${e.message}`); + } + return []; + } + + initialize() { // 检查功能开关 if (typeof window !== 'undefined' && window.FEATURES && !window.FEATURES.nodeHelpTranslator) { @@ -34,31 +127,131 @@ class NodeHelpTranslator { logger.log("[NodeHelpTranslator] 初始化监听器"); this.observer = new MutationObserver((mutations) => { + let shouldCheck = false; for (const mutation of mutations) { - if (mutation.addedNodes.length > 0) { - this._handleDomChange(mutation.addedNodes); + // 有节点增减 或 属性变更(aria-selected) + if (mutation.type === 'childList' || + (mutation.type === 'attributes' && mutation.attributeName === 'aria-selected')) { + shouldCheck = true; + break; } } + if (shouldCheck) { + this._checkAndInject(); + } }); this.observer.observe(document.body, { childList: true, - subtree: true + subtree: true, + attributes: true, + attributeFilter: ['aria-selected'] }); + this._injectStyles(); this.isObserving = true; } - _handleDomChange(addedNodes) { - // 检查功能开关 - if (typeof window !== 'undefined' && window.FEATURES && !window.FEATURES.nodeHelpTranslator) { - return; - } + _injectStyles() { + if (document.getElementById('pa-node-help-translator-styles')) return; + const style = document.createElement('style'); + style.id = 'pa-node-help-translator-styles'; + style.textContent = ` + .pa-help-translate-btn-container { + opacity: 0; + animation: pa-fade-in 0.3s ease-out forwards; + display: flex; + align-items: center; + } + @keyframes pa-fade-in { + from { opacity: 0; transform: scale(0.95); } + to { opacity: 1; transform: scale(1); } + } + .pa-help-translate-btn-container.pa-fade-out { + animation: pa-fade-out 0.2s ease-in forwards; + pointer-events: none; + } + @keyframes pa-fade-out { + from { opacity: 1; transform: scale(1); } + to { opacity: 0; transform: scale(0.95); } + } + .pa-translate-btn-icon.spinning { + animation: pa-spin 1s linear infinite; + } + @keyframes pa-spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } + } + /* ---流式输出容器样式--- */ + .pa-help-streaming-container { + position: absolute; + bottom: 0; + left: 0; + right: 0; + max-height: 150px; + overflow-y: auto; + padding: 12px; + background: linear-gradient(to top, + color-mix(in srgb, var(--p-content-background), transparent 10%) 0%, + color-mix(in srgb, var(--p-content-background), transparent 40%) 100%); + border-top: 1px solid var(--p-panel-border-color); + z-index: 100; + opacity: 0; + transform: translateY(10px); + transition: opacity 0.3s ease, transform 0.3s ease; + pointer-events: none; + } + .pa-help-streaming-container.show { + opacity: 1; + transform: translateY(0); + pointer-events: auto; + } + .pa-help-streaming-content { + font-size: 13px; + line-height: 1.6; + color: var(--p-text-color); + white-space: pre-wrap; + word-break: break-word; + } + .pa-help-streaming-cursor { + display: inline-block; + width: 2px; + height: 1em; + background: var(--p-primary-color); + margin-left: 2px; + animation: pa-cursor-blink 0.8s infinite; + vertical-align: text-bottom; + } + @keyframes pa-cursor-blink { + 0%, 50% { opacity: 1; } + 51%, 100% { opacity: 0; } + } + .pa-help-streaming-container::-webkit-scrollbar { + width: 6px; + } + .pa-help-streaming-container::-webkit-scrollbar-track { + background: transparent; + } + .pa-help-streaming-container::-webkit-scrollbar-thumb { + background: color-mix(in srgb, var(--p-panel-border-color), transparent 50%); + border-radius: 3px; + } + .pa-streaming-trans-text { + display: block; + color: var(--p-primary-color); + font-size: 0.95em; + line-height: 1.5; + padding: 4px 0; + margin-top: 4px; + opacity: 0.9; + } + `; + document.head.appendChild(style); + } - const helpContent = document.querySelector('.node-help-content'); - if (helpContent) { - this._checkAndInject(); - } + _handleDomChange() { + // 该方法已被 observer 直接调用 _checkAndInject 取代,保留空函数以防其他地方引用 + this._checkAndInject(); } _checkAndInject() { @@ -69,37 +262,88 @@ class NodeHelpTranslator { if (this._injectTimer) clearTimeout(this._injectTimer); this._injectTimer = setTimeout(() => { - const helpContent = document.querySelector('.node-help-content'); - if (!helpContent) return; + const v3Panel = document.querySelector('[data-testid="properties-panel"]'); - 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'; + if (!v3Panel) { + // 如果找不到 V3 面板,直接清理所有可能的按钮并返回 (不再支持左侧边栏) + this._clearAllButtons(); + return; + } - const existingBtnContainer = header.querySelector('.pa-help-translate-btn-container'); + // --- V3 逻辑 --- + const allTabs = Array.from(v3Panel.querySelectorAll('button[role="tab"]')); + const activeTab = allTabs.find(t => t.getAttribute('aria-selected') === 'true'); + const activeTabText = activeTab ? activeTab.textContent.trim().toLowerCase() : ''; - if (!existingBtnContainer) { - const sampleText = helpContent.innerText.substring(0, 500); - const langDetect = PromptFormatter.detectLanguage(sampleText); + // 简化识别关键字:只要包含 info 或 信息 即可 + const isInfoActive = activeTabText.includes('info') || activeTabText.includes('信息'); - 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); - } - } + // 重要:必须在当前面板内寻找帮助内容 + const helpContent = v3Panel.querySelector('.node-help-content'); + + if (!isInfoActive || !helpContent) { + this._clearAllButtons(); + return; + } + + // 处理注入 + this._processInjection(v3Panel, helpContent); + }, 250); + } + + /** + * 统一的清理函数 + */ + _clearAllButtons() { + const strayBtns = document.querySelectorAll('.pa-help-translate-btn-container:not(.pa-fade-out)'); + if (strayBtns.length > 0) { + strayBtns.forEach(el => { + // 清理 tooltip(防止残留) + const mainBtn = el.querySelector('.pa-translate-btn'); + if (mainBtn && mainBtn._paTooltip) { + mainBtn._paTooltip.destroy(); + mainBtn._paTooltip = null; + } + + el.classList.add('pa-fade-out'); + el.addEventListener('animationend', () => el.remove(), { once: true }); + // 安全兜底:如果动画没触发也强制移除 + setTimeout(() => { + if (el.parentNode) el.remove(); + }, 300); + }); + } + } + + /** + * 核心处理逻辑:仅支持 V3 环境 + */ + _processInjection(v3Panel, helpContent) { + if (!v3Panel) return; + + // 预加载服务列表(用于菜单显示) + this._getAvailableServices(); + + // 查找 header (注入按钮的位置) + let header = v3Panel.querySelector('section .flex.gap-2'); + + if (header) { + const titleEl = v3Panel.querySelector('h3'); + const currentNodeName = titleEl ? titleEl.textContent.trim() : 'Unknown'; + + const existingBtnContainer = header.querySelector('.pa-help-translate-btn-container'); + + if (!existingBtnContainer) { + // 所有 Info 内容都显示翻译按钮 + this._injectTranslateButton(header, currentNodeName, helpContent); + } 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) { @@ -109,28 +353,38 @@ class NodeHelpTranslator { 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", + align: "right", onClick: (e) => { const btn = splitBtn.container.querySelector('.p-button:first-child'); this._handleTranslateClick(e, btn); }, - items: this._getMenuItems(nodeName, 'bilingual') + // 使用函数式动态项,每次打开下拉框时实时获取最新状态 + // 不再传递硬编码的 'bilingual',由 _getMenuItems 内部动态决定 + items: () => this._getMenuItems(nodeName) }); const mainBtn = splitBtn.container.querySelector('.p-button:first-child'); mainBtn.classList.add('pa-translate-btn'); mainBtn.dataset.nodeName = nodeName; + mainBtn.dataset.targetLang = 'zh'; // 默认翻译为中文 mainBtn._paSplitBtn = splitBtn; this.currentHelpPanel.splitBtn = splitBtn; this._setButtonContent(mainBtn, 'translate'); btnContainer.appendChild(splitBtn.container); - header.appendChild(btnContainer); + + // 尝试将其插入到“关闭面板”按钮之前 + const toggleBtn = header.querySelector('[aria-label*="panel"], [aria-label*="Panel"]'); + if (toggleBtn) { + header.insertBefore(btnContainer, toggleBtn); + } else { + header.appendChild(btnContainer); + } logger.log(`[NodeHelpTranslator] 已注入翻译按钮(SplitBtn)到节点: ${nodeName}`); @@ -141,6 +395,7 @@ class NodeHelpTranslator { _resetButtonState(btn, newNodeName) { btn.dataset.nodeName = newNodeName; + btn.dataset.targetLang = 'zh'; // 默认重置为中文 this._setButtonContent(btn, 'translate'); btn.disabled = false; @@ -148,8 +403,8 @@ class NodeHelpTranslator { btn._paSplitBtn.updateMenu(this._getMenuItems(newNodeName, 'bilingual')); } - const container = btn.closest('.flex.flex-col'); - const helpContent = container ? container.querySelector('.node-help-content') : null; + const v3Panel = btn.closest('[data-testid="properties-panel"]'); + const helpContent = v3Panel ? v3Panel.querySelector('.node-help-content') : null; if (helpContent) { this._restoreFromCache(btn, helpContent, newNodeName); } @@ -165,7 +420,7 @@ class NodeHelpTranslator { case 'translate': label = '翻译'; iconClass = 'pi pi-language pa-translate-btn-icon'; - tooltip = '使用提示词小助手翻译文档'; + tooltip = '使用✨提示词小助手翻译文档'; break; case 'translating': label = '翻译中...'; @@ -176,7 +431,7 @@ class NodeHelpTranslator { case 'translated': label = '已翻译'; iconClass = 'pi pi-check pa-translate-btn-icon'; - tooltip = '使用提示词小助手重新翻译'; + tooltip = '点击重新翻译'; break; } @@ -208,26 +463,44 @@ class NodeHelpTranslator { e.stopPropagation(); const nodeName = btn.dataset.nodeName; - const header = btn.closest('.border-b'); - if (!header) return; + const targetLang = btn.dataset.targetLang || 'zh'; + const from = targetLang === 'zh' ? 'en' : 'zh'; + const to = targetLang; - const container = header.parentElement; - const contentEl = container.querySelector('.node-help-content'); + // 寻找内容容器的增强逻辑 (与 _handleModeChange 保持一致) + let contentEl = null; + + // 策略1: 如果按钮就在 properties-panel 中 (V3) + const v3Panel = btn.closest('[data-testid="properties-panel"]'); + if (v3Panel) { + contentEl = v3Panel.querySelector('.node-help-content'); + } + + // 策略2: 回退到旧版层级查找 + if (!contentEl) { + const header = btn.closest('.border-b') || btn.closest('.pa-help-translate-btn-container').parentElement; + if (header) { + const container = header.parentElement; + contentEl = container.querySelector('.node-help-content'); + } + } if (!contentEl) { UIToolkit.showStatusTip(btn, 'warn', '未找到帮助内容'); return; } - // 允许重新翻译:无论当前状态如何,点击按钮都触发翻译 - // 如果想要防抖或避免意外点击,可以检查 btn.disabled (已经在 _performTranslation 中处理) - await this._performTranslation(btn, contentEl, nodeName); + // 允许重新翻译 + await this._performTranslation(btn, contentEl, nodeName, from, to); } - async _performTranslation(btn, contentEl, nodeName) { + async _performTranslation(btn, contentEl, nodeName, from = 'en', to = 'zh') { this._setButtonContent(btn, 'translating'); btn.disabled = true; + // 用于错误处理时清理流式状态 + let textBlocks = []; + try { // 重置之前的翻译状态(如果有) // 如果已经翻译过,先还原DOM,以便重新提取文本 @@ -247,7 +520,7 @@ class NodeHelpTranslator { contentEl.classList.remove('pa-mode-bilingual', 'pa-mode-translation-only', 'pa-mode-original-only'); - const textBlocks = this._extractTextBlocks(contentEl); + textBlocks = this._extractTextBlocks(contentEl); if (textBlocks.length === 0) { UIToolkit.showStatusTip(btn, 'warn', '没有找到可翻译的内容'); this._setButtonContent(btn, 'translate'); // 恢复为待翻译状态 @@ -260,7 +533,8 @@ class NodeHelpTranslator { let translations = []; let isBaidu = false; - // 1. 获取翻译配置,判断是否使用百度翻译 + // 1. 获取全局翻译配置 + // 直接使用后端全局配置(与 PromptAssistant 共享) try { const configResp = await fetch(APIService.getApiUrl('/config/translate')); if (configResp.ok) { @@ -275,9 +549,9 @@ class NodeHelpTranslator { // 2. 根据服务类型执行翻译 if (isBaidu) { - logger.log('[NodeHelpTranslator] 使用百度翻译API进行翻译'); + logger.log(`[NodeHelpTranslator] 使用百度翻译API进行翻译 (${from}->${to})`); // 百度批量翻译是串行请求,返回结果数组 - const results = await APIService.batchBaiduTranslate(textsToTranslate, 'en', 'zh'); + const results = await APIService.batchBaiduTranslate(textsToTranslate, from, to); // 提取翻译结果,BaiduTranslateService 返回格式需与 LLM 保持一致 (data.translated) // 如果某个请求失败,result.success 为 false,这里暂时填 null 跳过 @@ -295,19 +569,155 @@ class NodeHelpTranslator { } } else { - logger.log('[NodeHelpTranslator] 使用LLM API进行批量翻译'); - const result = await APIService.llmBatchTranslate(textsToTranslate, 'en', 'zh'); + // ---使用LLM翻译--- + const textCount = textsToTranslate.length; + const enableStreaming = typeof window !== 'undefined' && + window.FEATURES && window.FEATURES.enableStreaming !== false; - if (result.success && result.data && result.data.translations) { - translations = result.data.translations; + if (enableStreaming) { + // ---LLM单请求批量流式翻译--- + logger.log(`[NodeHelpTranslator] 使用LLM批量流式翻译 (${from}->${to}) | 文本数:${textCount}`); + + const totalBlocks = textsToTranslate.length; + + // 1. 先为所有文本块创建流式显示区域 + textBlocks.forEach((block, i) => { + const el = block.element; + if (!el.dataset.paOriginal) { + el.dataset.paOriginal = el.innerHTML; + } + el.classList.add('pa-translatable'); + + const wrappedOriginal = `${el.dataset.paOriginal}`; + const separator = ``; + const streamingArea = ``; + el.innerHTML = `${wrappedOriginal}${separator}${streamingArea}`; + }); + + // 2. 构建带编号的批量翻译请求文本 + // 格式:[1] 原文1\n[2] 原文2\n... + const numberedText = textsToTranslate + .map((text, i) => `[${i + 1}] ${text}`) + .join('\n\n'); + + // 3. 流式解析状态 + let fullContent = ''; + let currentBlockIndex = -1; + let blockTranslations = new Array(totalBlocks).fill(''); + + // 4. 发起单次流式翻译请求(使用全局配置) + const result = await this._llmTranslateStreamWithConfig( + numberedText, + from, + to, + null, // 使用全局配置,不需要传递独立参数 + (chunk) => { + fullContent += chunk; + + // 解析流式内容,识别编号并更新对应DOM + this._parseAndUpdateStreamingContent( + fullContent, + textBlocks, + blockTranslations + ); + + // 更新进度 + const completedCount = blockTranslations.filter(t => t.length > 0).length; + const percent = Math.round((completedCount / totalBlocks) * 100); + if (btn._paSplitBtn) { + btn._paSplitBtn.updateMainButton(`${percent}%`, 'pi pi-spinner pa-translate-btn-icon', true); + } + } + ); + + // 5. 最终解析确保所有内容都被处理 + this._parseAndUpdateStreamingContent(fullContent, textBlocks, blockTranslations, true); + + // 6. 收集翻译结果并更新为最终样式 + textBlocks.forEach((block, i) => { + const el = block.element; + const translation = blockTranslations[i]?.trim(); + + if (translation) { + translations.push(translation); + el.dataset.paTranslation = translation; + // 切换为正式的翻译样式 + const wrappedOriginal = `${el.dataset.paOriginal}`; + const separator = ``; + const styledTranslation = `${translation.replace(/\n/g, '
')}
`; + el.innerHTML = `${wrappedOriginal}${separator}${styledTranslation}`; + } else { + translations.push(null); + logger.warn(`[NodeHelpTranslator] 第${i + 1}个文本块翻译失败`); + el.innerHTML = el.dataset.paOriginal; + el.classList.remove('pa-translatable'); + } + }); + + // 检查翻译成功率 + const successCount = translations.filter(t => t !== null).length; + if (successCount === 0 && textsToTranslate.length > 0) { + throw new Error('所有文本翻译失败 (LLM Stream)'); + } + + } else if (textCount <= 5) { + // 文本较少时,直接使用单次批量翻译 + logger.log(`[NodeHelpTranslator] 使用LLM单次批量翻译 (${from}->${to}) | 文本数:${textCount}`); + const result = await APIService.llmBatchTranslate(textsToTranslate, from, to); + + if (result.success && result.data && result.data.translations) { + translations = result.data.translations; + } else { + throw new Error(result.error || '翻译接口返回错误'); + } } else { - throw new Error(result.error || '翻译接口返回错误'); + // 文本较多时,使用并行分块翻译 + logger.log(`[NodeHelpTranslator] 使用LLM并行分块翻译 (${from}->${to}) | 文本数:${textCount}`); + const result = await APIService.llmParallelBatchTranslate(textsToTranslate, from, to, { + chunkSize: 5, + concurrency: 3, + onProgress: (completed, total) => { + const percent = Math.round((completed / total) * 100); + if (btn._paSplitBtn) { + btn._paSplitBtn.updateMainButton(`${percent}%`, 'pi pi-spinner pa-translate-btn-icon', true); + } + } + }); + + 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); + // 3. 流式模式下不需要再渲染(已在循环中完成),非流式需要渲染 + const enableStreaming = typeof window !== 'undefined' && + window.FEATURES && window.FEATURES.enableStreaming !== false; + + if (!enableStreaming || isBaidu) { + // 非流式模式或百度翻译:批量渲染结果 + const cleanedTranslations = translations.map(t => t ? t.replace(/^\|\s*/, '') : t); + this._renderTranslations(textBlocks, cleanedTranslations, nodeName); + } else { + // 流式模式:清理结果并保存缓存 + const cleanedTranslations = translations.map(t => t ? t.replace(/^\|\s*/, '') : t); + // 更新 dataset 中的翻译结果(用于缓存和模式切换) + textBlocks.forEach((block, index) => { + const translation = cleanedTranslations[index]; + if (translation) { + block.element.dataset.paTranslation = translation; + } + }); + // 保存缓存 + const cacheData = cleanedTranslations.map((t, i) => ({ index: i, translation: t })).filter(item => item.translation); + this._saveToCache(nodeName, { + translations: cacheData, + timestamp: Date.now(), + bilingual: true + }); + } this.stateCache.set(nodeName, { translated: true, viewMode: 'bilingual' }); @@ -322,11 +732,160 @@ class NodeHelpTranslator { UIToolkit.showStatusTip(btn, 'error', '翻译失败: ' + error.message); this._setButtonContent(btn, 'translate'); btn.disabled = false; + + // 清理流式状态 + textBlocks?.forEach(block => { + const el = block.element; + if (el.dataset.paOriginal && !el.dataset.paTranslation) { + el.innerHTML = el.dataset.paOriginal; + el.classList.remove('pa-translatable'); + } + }); } finally { btn.disabled = false; } } + /** + * 带可选服务/模型配置的流式翻译 + * @param {string} text - 要翻译的文本 + * @param {string} fromLang - 源语言 + * @param {string} toLang - 目标语言 + * @param {Object|null} config - 可选配置 { serviceId, modelName } + * @param {Function} onChunk - 接收每个 chunk 的回调函数 + */ + async _llmTranslateStreamWithConfig(text, fromLang, toLang, config, onChunk) { + try { + if (!text || text.trim() === '') { + throw new Error('请输入要翻译的内容'); + } + + const request_id = APIService.generateRequestId('trans'); + const apiUrl = APIService.getApiUrl('llm/translate/stream'); + + // 构建请求体,添加可选的服务/模型参数 + const body = { + text, + from: fromLang, + to: toLang, + request_id + }; + + // 如果有独立配置,添加服务和模型参数 + if (config && config.serviceId && config.modelName) { + body.service_id = config.serviceId; + body.model_name = config.modelName; + logger.debug(`[NodeHelpTranslator] 流式翻译使用指定服务: ${config.serviceId} - ${config.modelName}`); + } + + const response = await fetch(apiUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + let finalResult = null; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; + + for (const line of lines) { + if (line.startsWith('data: ')) { + try { + const data = JSON.parse(line.slice(6)); + if (data.chunk && onChunk) { + onChunk(data.chunk); + } + if (data.done) { + finalResult = data.result; + } + if (data.error) { + throw new Error(data.error); + } + } catch (parseError) { + if (parseError.message !== 'Unexpected end of JSON input') { + logger.warn(`解析 SSE 数据失败: ${parseError.message}`); + } + } + } + } + } + + return finalResult; + + } catch (error) { + logger.error(`[NodeHelpTranslator] 流式翻译失败: ${error.message}`); + return { success: false, error: error.message }; + } + } + + /** + * 解析流式翻译内容并更新对应的DOM元素 + * @param {string} content - 累积的流式内容 + * @param {Array} textBlocks - 文本块数组 + * @param {Array} blockTranslations - 各块翻译结果数组 + * @param {boolean} isFinal - 是否为最终解析 + */ + _parseAndUpdateStreamingContent(content, textBlocks, blockTranslations, isFinal = false) { + if (!content) return; + + // 使用正则解析带编号的翻译内容 + // 格式:[1] 翻译内容1\n[2] 翻译内容2\n... + // 或者:[1]翻译内容1[2]翻译内容2...(无换行) + const blockPattern = /\[(\d+)\]\s*/g; + const matches = [...content.matchAll(blockPattern)]; + + if (matches.length === 0) { + // 没有找到编号,可能还在输出第一个块的内容 + // 尝试更新第一个块 + if (textBlocks.length > 0) { + const streamingEl = textBlocks[0].element.querySelector('.pa-streaming-trans-text'); + if (streamingEl) { + streamingEl.textContent = content.trim(); + } + blockTranslations[0] = content.trim(); + } + return; + } + + // 解析每个块的内容 + for (let i = 0; i < matches.length; i++) { + const match = matches[i]; + const blockNum = parseInt(match[1], 10); + const blockIndex = blockNum - 1; // 转为0-indexed + + if (blockIndex < 0 || blockIndex >= textBlocks.length) continue; + + // 计算该块内容的起止位置 + const startPos = match.index + match[0].length; + const endPos = (i + 1 < matches.length) ? matches[i + 1].index : content.length; + + // 提取该块的翻译内容 + const blockContent = content.substring(startPos, endPos).trim(); + + // 更新翻译数组 + blockTranslations[blockIndex] = blockContent; + + // 更新DOM显示 + const streamingEl = textBlocks[blockIndex].element.querySelector('.pa-streaming-trans-text'); + if (streamingEl) { + streamingEl.textContent = blockContent; + } + } + } + /** * 提取文本块 */ @@ -568,12 +1127,60 @@ class NodeHelpTranslator { /** * 获取下拉菜单配置 - * @param {string} nodeName - * @param {string} currentMode + * @param {string} nodeName + * @param {string} currentMode * @returns {Array} */ _getMenuItems(nodeName, currentMode) { + // 如果未显式传递模式,则从状态缓存中实时获取(解决动态菜单打开时的状态同步问题) + if (!currentMode) { + currentMode = this.stateCache.get(nodeName)?.viewMode || 'bilingual'; + } + + const btn = document.querySelector(`.pa-translate-btn[data-node-name="${nodeName}"]`); + const targetLang = btn ? (btn.dataset.targetLang || 'zh') : 'zh'; + + // 异步获取最新配置(确保下次打开或后台更新时数据正确) + this._getTranslateConfig(); + + // 立即使用的配置 + const cachedConfig = this._currentTranslateConfig; + const currentServiceId = cachedConfig?.provider || null; + const currentModelName = cachedConfig?.model || null; + + // 构建服务选择子菜单 + const serviceMenuItems = this._buildServiceMenuItems(nodeName, currentMode, currentServiceId, currentModelName); + return [ + { + label: '翻译成中文', + icon: targetLang === 'zh' ? 'pi pi-check' : '', + className: targetLang === 'zh' ? 'pa-menu-item-active' : '', + command: () => { + const b = document.querySelector(`.pa-translate-btn[data-node-name="${nodeName}"]`); + if (b) { + b.dataset.targetLang = 'zh'; + if (this.currentHelpPanel.splitBtn) { + this.currentHelpPanel.splitBtn.updateMenu(this._getMenuItems(nodeName, currentMode)); + } + } + } + }, + { + label: '翻译成英文', + icon: targetLang === 'en' ? 'pi pi-check' : '', + className: targetLang === 'en' ? 'pa-menu-item-active' : '', + command: () => { + const b = document.querySelector(`.pa-translate-btn[data-node-name="${nodeName}"]`); + if (b) { + b.dataset.targetLang = 'en'; + if (this.currentHelpPanel.splitBtn) { + this.currentHelpPanel.splitBtn.updateMenu(this._getMenuItems(nodeName, currentMode)); + } + } + } + }, + { separator: true }, { label: '中英对照', checked: currentMode === 'bilingual', @@ -590,17 +1197,119 @@ class NodeHelpTranslator { command: () => this._handleModeChange(nodeName, 'original-only') }, { separator: true }, + { + label: '选择服务', + icon: 'pi pi-server', + items: serviceMenuItems + }, + { separator: true }, { label: '重新翻译', icon: 'pi pi-refresh', command: () => { - const btn = document.querySelector(`.pa-translate-btn[data-node-name="${nodeName}"]`); - if (btn) btn.click(); + const b = document.querySelector(`.pa-translate-btn[data-node-name="${nodeName}"]`); + if (b) this._handleTranslateClick(new CustomEvent('re-translate'), b); } } ]; } + /** + * 构建服务选择子菜单 + */ + _buildServiceMenuItems(nodeName, currentMode, currentServiceId, currentModelName) { + const services = this._servicesCache || []; + + if (services.length === 0 && !this._servicesCache) { + // 异步加载服务列表,加载完成后刷新菜单 + this._getAvailableServices().then(() => { + if (this.currentHelpPanel.splitBtn) { + this.currentHelpPanel.splitBtn.updateMenu(this._getMenuItems(nodeName, currentMode)); + } + }); + return [{ label: '加载中...', disabled: true }]; + } + + const menuItems = []; + + // 1. 添加百度翻译选项(始终显示) + const isBaidu = currentServiceId === 'baidu'; + menuItems.push({ + label: '百度翻译', + icon: isBaidu ? 'pi pi-check' : '', + command: async () => { + const success = await this._setTranslateConfig('baidu', ''); + if (success) { + // 立即更新本地缓存,实现实时同步 + this._currentTranslateConfig = { provider: 'baidu', model: '' }; + + UIToolkit.showStatusTip( + document.querySelector(`.pa-translate-btn[data-node-name="${nodeName}"]`), + 'success', + '已选择: 百度翻译' + ); + if (this.currentHelpPanel.splitBtn) { + this.currentHelpPanel.splitBtn.updateMenu(this._getMenuItems(nodeName, currentMode)); + } + + // 广播全局服务变更事件,通知其他组件同步 + window.dispatchEvent(new CustomEvent('pa-service-changed', { + detail: { service_type: 'translate', service_id: 'baidu' } + })); + } + } + }); + + menuItems.push({ separator: true }); + + // 2. 为每个 LLM 服务创建菜单项 + services.filter(s => s.llm_models && s.llm_models.length > 0).forEach(service => { + const isCurrentService = currentServiceId === service.id; + const models = service.llm_models || []; + + if (models.length === 0) return; + + // 创建模型子菜单 + const modelItems = models.map(model => { + const isCurrentModel = isCurrentService && currentModelName === model.name; + return { + label: model.display_name || model.name, + icon: isCurrentModel ? 'pi pi-check' : '', + command: async () => { + const success = await this._setTranslateConfig(service.id, model.name); + if (success) { + // 立即更新本地缓存,实现实时同步 + this._currentTranslateConfig = { provider: service.id, model: model.name }; + + const modelLabel = model.display_name || model.name; + UIToolkit.showStatusTip( + document.querySelector(`.pa-translate-btn[data-node-name="${nodeName}"]`), + 'success', + `已选择: ${service.name} - ${modelLabel}` + ); + if (this.currentHelpPanel.splitBtn) { + this.currentHelpPanel.splitBtn.updateMenu(this._getMenuItems(nodeName, currentMode)); + } + + // 广播全局服务变更事件,通知其他组件同步 + window.dispatchEvent(new CustomEvent('pa-service-changed', { + detail: { service_type: 'translate', service_id: service.id, model_name: model.name } + })); + } + } + }; + }); + + menuItems.push({ + label: service.name || service.id, + icon: isCurrentService ? 'pi pi-check-circle' : '', + items: modelItems + }); + }); + + return menuItems; + } + async _handleModeChange(nodeName, mode) { const state = this.stateCache.get(nodeName); if (!state) return; @@ -611,13 +1320,15 @@ class NodeHelpTranslator { 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; + let contentEl = null; - // If header is inside a container which has sibling content - const container = header.parentElement; - const contentEl = container.querySelector('.node-help-content'); + // 按钮就在 properties-panel 中 (V3) + const v3Panel = btn.closest('[data-testid="properties-panel"]'); + if (v3Panel) { + contentEl = v3Panel.querySelector('.node-help-content'); + } if (contentEl) { this._applyViewMode(contentEl, mode); diff --git a/js/modules/rulesConfigManager.js b/js/modules/rulesConfigManager.js index 0fe6123..729aae3 100644 --- a/js/modules/rulesConfigManager.js +++ b/js/modules/rulesConfigManager.js @@ -1763,11 +1763,9 @@ class RulesConfigManager { chosenClass: 'sortable-chosen', dragClass: 'sortable-drag', handle: '.prompt-list-row', - // 移除 handle 限制,允许整行拖拽 - // handle: '.prompt-list-row', - // 禁用固定项目,确保所有项目都可以拖拽 - filter: '', - preventOnFilter: false, + // 排除状态列和操作列,只允许从名称和内容列拖拽 + filter: '.status-cell, .action-cell', + preventOnFilter: true, onStart: (evt) => { logger.debug(`开始拖拽: 类型=${type}, 索引=${evt.oldIndex}`); }, diff --git a/js/modules/settings.js b/js/modules/settings.js index 2388e79..13fd298 100644 --- a/js/modules/settings.js +++ b/js/modules/settings.js @@ -58,12 +58,21 @@ const SERVICE_TYPES = { const serviceSelector = { _servicesCache: null, _cacheTime: 0, - _cacheDuration: 5000, // 缓存5秒 + _cacheDuration: 2000, // 缓存2秒 + + /** + * 清除服务缓存 + */ + clearCache() { + this._servicesCache = null; + this._cacheTime = 0; + logger.debug('服务列表缓存已清除'); + }, // 获取服务列表(带缓存) - async getServices() { + async getServices(forceRefresh = false) { const now = Date.now(); - if (this._servicesCache && (now - this._cacheTime) < this._cacheDuration) { + if (!forceRefresh && this._servicesCache && (now - this._cacheTime) < this._cacheDuration) { return this._servicesCache; } @@ -117,6 +126,12 @@ const serviceSelector = { if (response.ok) { logger.log(`${config.name}服务切换 | 服务ID: ${serviceId}`); + + // 派发全局事件通知其他组件同步 + window.dispatchEvent(new CustomEvent('pa-service-changed', { + detail: { service_type: config.serviceType, service_id: serviceId } + })); + return true; } } catch (error) { @@ -155,31 +170,52 @@ const serviceSelector = { } }; +// 将服务选择器挂载到全局 app 对象,方便其他模块(如 PromptAssistant.js, imageCaption.js)调用, +// 同时避免模块间的循环引用问题。 +app.paServiceSelector = serviceSelector; + // ---版本检查工具函数--- +// 版本检查状态缓存 +let versionCheckCache = { + checked: false, // 是否已检查过 + latestVersion: null, // 最新版本号 + hasUpdate: false // 是否有更新 +}; + /** - * 从 GitHub 获取最新发布版本号 + * 从 jsDelivr 获取最新版本号(通过读取 pyproject.toml) * @returns {Promise} 返回最新版本号,格式如 "1.2.3",失败返回 null */ async function fetchLatestVersion() { + // 如果已经检查过,直接返回缓存结果 + if (versionCheckCache.checked) { + return versionCheckCache.latestVersion; + } + try { - const response = await fetch('https://api.github.com/repos/yawiii/comfyui_prompt_assistant/releases/latest', { - headers: { 'Accept': 'application/vnd.github.v3+json' }, + const response = await fetch('https://cdn.jsdelivr.net/gh/yawiii/ComfyUI-Prompt-Assistant@main/pyproject.toml', { cache: 'no-cache' }); if (!response.ok) { - logger.warn(`[版本检查] GitHub API 请求失败: ${response.status}`); + logger.warn(`[版本检查] 请求失败: ${response.status}`); + versionCheckCache.checked = true; return null; } - const data = await response.json(); - // 移除 tag_name 中的 'v' 前缀(如果有) - const version = data.tag_name?.replace(/^v/, ''); - logger.debug(`[版本检查] 获取到最新版本: ${version}`); - return version || null; + const tomlContent = await response.text(); + const versionMatch = tomlContent.match(/^version\s*=\s*["']([^"']+)["']/m); + const version = versionMatch ? versionMatch[1] : null; + + // 缓存检查结果 + versionCheckCache.checked = true; + versionCheckCache.latestVersion = version; + + return version; } catch (error) { - logger.warn(`[版本检查] 获取最新版本失败: ${error.message}`); + logger.warn(`[版本检查] 获取失败: ${error.message}`); + versionCheckCache.checked = true; return null; } } @@ -272,16 +308,34 @@ function createServiceSelector(type, label) { selectCell.appendChild(container); row.appendChild(selectCell); - // 异步加载服务列表 - (async () => { + let currentOptions = []; // 存储当前选项引用 + let updateDropdownOptions = null; // 存储更新函数 + + /** + * 更新下拉框内容 + * @param {boolean} force - 是否强制刷新数据 + */ + const updateContent = async (force = false) => { try { + if (force) { + // 如果是强制刷新(如配置变更或点击触发),先清除缓存 + serviceSelector.clearCache(); + } + // 获取服务列表和当前选中的服务 const [options, currentService] = await Promise.all([ serviceSelector.getServiceOptions(type), serviceSelector.getCurrentService(type) ]); - // 清空容器 + // 如果已经存在下拉框实例,则尝试增量更新 + if (updateDropdownOptions) { + updateDropdownOptions(options, currentService); + currentOptions = options; + return; + } + + // ---首次加载逻辑--- container.innerHTML = ''; if (options.length === 0) { @@ -289,24 +343,34 @@ function createServiceSelector(type, label) { return; } - // 使用 createSelectGroup 创建下拉框(不显示浮动标签,与官方样式一致) - const { group, select } = createSelectGroup(label, options, currentService, { showLabel: false }); + currentOptions = options; + const res = createSelectGroup(label, options, currentService, { showLabel: false }); + const { group, select } = res; + updateDropdownOptions = res.updateOptions; - // 将 group 的子元素添加到容器(不需要外层 group 的样式) + // 将 group 的子元素添加到容器 while (group.firstChild) { container.appendChild(group.firstChild); } + // 监听点击/按下事件:当用户准备点击下拉框时,尝试静默同步最新配置 + const dropdownContainer = container.querySelector('.pa-dropdown'); + if (dropdownContainer) { + dropdownContainer.addEventListener('mousedown', () => { + // 点击时触发刷新,但不显示“同步中”以避免干扰 UI + updateContent(true); + }); + } + // 监听变更事件 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'; + const dropdown = container.querySelector('.pa-dropdown'); + if (dropdown) { + dropdown.style.opacity = '0.6'; + dropdown.style.pointerEvents = 'none'; } try { @@ -315,34 +379,41 @@ function createServiceSelector(type, label) { 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; - } + if (oldValue && updateDropdownOptions) { + updateDropdownOptions(currentOptions, oldValue); } } } catch (error) { logger.error(`设置${label}服务异常: ${error.message}`); } finally { - // 恢复可用状态 - if (dropdownContainer) { - dropdownContainer.style.opacity = ''; - dropdownContainer.style.pointerEvents = ''; + if (dropdown) { + dropdown.style.opacity = ''; + dropdown.style.pointerEvents = ''; } } }); } catch (error) { - logger.error(`加载${label}服务列表失败: ${error.message}`); - container.innerHTML = '加载失败'; + logger.error(`同步${label}配置失败: ${error.message}`); + if (!updateDropdownOptions) { + container.innerHTML = '加载失败'; + } } - })(); + }; + + // 初始加载 + updateContent(); + + // 监听配置更新事件(当 API 配置管理器修改配置后触发) + const onConfigUpdated = () => { + logger.debug(`收到配置更新通知,同步${label}状态...`); + updateContent(true); + }; + window.addEventListener('pa-config-updated', onConfigUpdated); + + // 销毁监听器的清理函数(简单处理,因为设置面板通常随页面销毁) + // 如果之后有更复杂的组件挂载逻辑,可以在这里返回一个清理函数给外部调用 return row; } @@ -684,6 +755,40 @@ export function registerSettings() { } }, + // 混合语言缓存选项 + { + id: "PromptAssistant.Features.CacheMixedLangTranslation", + name: "混合语言翻译进行缓存", + category: ["✨提示词小助手", " 翻译功能设置", "混合语言缓存"], + type: "boolean", + defaultValue: false, + tooltip: "关闭时,中英文混合内容的翻译结果不会写入缓存,避免污染缓存。开启后会正常缓存。", + onChange: (value) => { + FEATURES.cacheMixedLangTranslation = value; + logger.log(`混合语言缓存 - 已${value ? "启用" : "禁用"}`); + } + }, + + // 混合语言翻译规则 + { + id: "PromptAssistant.Features.MixedLangTranslateRule", + name: "混合语言翻译规则", + category: ["✨提示词小助手", " 翻译功能设置", "混合语言规则"], + type: "combo", + options: [ + { text: "翻译成英文", value: "to_en" }, + { text: "翻译成中文", value: "to_zh" }, + { text: "自动翻译小比例语言", value: "auto_minor" }, + { text: "自动翻译大比例语言", value: "auto_major" } + ], + defaultValue: "to_en", + tooltip: "根据个人使用偏好设置混合中英文内容的翻译规则", + onChange: (value) => { + FEATURES.mixedLangTranslateRule = value; + logger.log(`混合语言翻译规则 - 已设置为:${value}`); + } + }, + // 翻译格式化选项 { id: "PromptAssistant.Features.TranslateFormatPunctuation", @@ -792,10 +897,10 @@ export function registerSettings() { // 显示流式输出进度 { id: "PromptAssistant.Settings.ShowStreamingProgress", - name: "显示流式输出进度", + name: "控制台流式输出进度日志", category: ["✨提示词小助手", "系统", "终端日志"], type: "boolean", - defaultValue: true, + defaultValue: false, tooltip: "开启后,控制台会显示流式输出过程,在某些终端可能导致刷屏;关闭后只显示静态的'生成中...'。", onChange: async (value) => { FEATURES.showStreamingProgress = value; @@ -813,6 +918,20 @@ export function registerSettings() { } }, + // 流式输出开关 + { + id: "PromptAssistant.Settings.EnableStreaming", + name: "流式输出开关", + category: ["✨提示词小助手", "系统", "流式体验"], + type: "boolean", + defaultValue: true, + tooltip: "开启时,翻译、扩写、识别等功能将以逐字生成的流式效果显示;关闭时则恢复为全部生成后一次性显示的阻塞模式。", + onChange: (value) => { + FEATURES.enableStreaming = value; + logger.log(`流式输出开关 - 已${value ? "启用" : "禁用"}`); + } + }, + { id: "PromptAssistant.Settings.IconOpacity", name: " 小助手图标不透明度", @@ -841,7 +960,7 @@ export function registerSettings() { id: "PromptAssistant.Settings.ClearCache", name: "清理历史、标签、翻译缓存", category: ["✨提示词小助手", "系统", "清理缓存"], - tooltip: "清理所有缓存,包括历史记录、标签、翻译缓存", + tooltip: "清理所有缓存,包括历史记录、标签、翻译缓存、节点文档翻译缓存", type: () => { const row = document.createElement("tr"); row.className = "promptwidget-settings-row"; @@ -857,7 +976,8 @@ export function registerSettings() { const beforeStats = { history: HistoryCacheService.getHistoryStats(), tags: 0, - translate: TranslateCacheService.getTranslateCacheStats() + translate: TranslateCacheService.getTranslateCacheStats(), + nodeHelpTranslate: 0 // 节点文档翻译缓存 }; // 统计所有标签数量 @@ -878,6 +998,17 @@ export function registerSettings() { } }); + // 统计节点文档翻译缓存数量 + try { + const nodeHelpCache = sessionStorage.getItem('pa_node_help_translations'); + if (nodeHelpCache) { + const parsed = JSON.parse(nodeHelpCache); + beforeStats.nodeHelpTranslate = Object.keys(parsed).length; + } + } catch (e) { + // 静默处理 + } + // 执行历史记录清理操作 HistoryCacheService.clearAllHistory(); @@ -887,6 +1018,9 @@ export function registerSettings() { // 清理翻译缓存 TranslateCacheService.clearAllTranslateCache(); + // 清理节点文档翻译缓存(sessionStorage) + sessionStorage.removeItem('pa_node_help_translations'); + // 清理旧版本的标签缓存(以PromptAssistant_tag_cache_开头的所有记录) Object.keys(localStorage) .filter(key => key.startsWith('PromptAssistant_tag_cache_')) @@ -908,9 +1042,10 @@ export function registerSettings() { const clearedHistory = beforeStats.history.total - afterStats.history.total; const clearedTags = beforeStats.tags; const clearedTranslate = beforeStats.translate.total - afterStats.translate.total; + const clearedNodeHelp = beforeStats.nodeHelpTranslate; // 只输出最终统计结果 - logger.log(`缓存清理完成 | 历史记录: ${clearedHistory}条 | 标签: ${clearedTags}个 | 翻译: ${clearedTranslate}条`); + logger.log(`缓存清理完成 | 历史记录: ${clearedHistory}条 | 标签: ${clearedTags}个 | 翻译: ${clearedTranslate}条 | 节点文档: ${clearedNodeHelp}个`); // 更新所有实例的撤销/重做按钮状态 PromptAssistant.instances.forEach((instance) => { @@ -975,36 +1110,40 @@ export function registerSettings() { versionContainer.appendChild(versionBadge); } else { 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}`); - }); + // 使用缓存检查版本,避免重复请求 + if (versionCheckCache.checked && versionCheckCache.hasUpdate) { + // 已检查过且有更新,直接应用缓存的结果 + const latestVersion = versionCheckCache.latestVersion; + const labelEncoded = encodeURIComponent("有新版本"); + const messageEncoded = encodeURIComponent(`${currentVersion}→${latestVersion}`); + versionBadge.src = `https://img.shields.io/badge/${labelEncoded}-${messageEncoded}-orange?style=flat&labelColor=555555`; + versionBadge.style.cursor = "pointer"; + versionBadge.title = `当前版本: ${currentVersion}\n最新版本: ${latestVersion}\n点击前往下载`; + } else if (!versionCheckCache.checked) { + // 首次检查,发起异步请求 + fetchLatestVersion().then(latestVersion => { + if (latestVersion && compareVersion(latestVersion, currentVersion) > 0) { + versionCheckCache.hasUpdate = true; + const labelEncoded = encodeURIComponent("有新版本"); + const messageEncoded = encodeURIComponent(`${currentVersion}→${latestVersion}`); + 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.log(`[版本检查] 发现新版本: ${currentVersion} → ${latestVersion}`); + } else if (latestVersion) { + versionBadge.title = `当前已是最新版本: ${currentVersion}`; + logger.debug(`[版本检查] 当前版本: ${currentVersion}`); + } + }).catch(error => { + logger.warn(`[版本检查] 出错: ${error.message}`); + }); + } else { + // 已检查过但没有更新 + versionBadge.title = `当前已是最新版本: ${currentVersion}`; + } } cell.appendChild(versionLink); diff --git a/js/modules/tag.js b/js/modules/tag.js index ffe6526..d1d4594 100644 --- a/js/modules/tag.js +++ b/js/modules/tag.js @@ -39,7 +39,6 @@ class TagManager { try { if (category && typeof category === 'string') { CacheService.set(this.LAST_TAB_KEY, category); - logger.debug(`[助手-标签] 记忆标签页 | 当前:${category}`); } } catch (e) { } } @@ -71,7 +70,6 @@ class TagManager { } state[tabName][accordionPath] = isExpanded; CacheService.set(this.ACCORDION_STATE_KEY, JSON.stringify(state)); - logger.debug(`[助手-标签] 记忆手风琴 | 标签页:${tabName} | 路径:${accordionPath} | 展开:${isExpanded}`); } catch (e) { logger.error(`保存手风琴状态失败: ${e.message}`); } @@ -99,11 +97,57 @@ class TagManager { try { const size = { width, height }; CacheService.set(this.POPUP_SIZE_KEY, JSON.stringify(size)); - logger.debug(`[助手-标签] 记忆窗口大小 | 宽:${width}px | 高:${height}px`); } catch (e) { logger.error(`保存窗口大小失败: ${e.message}`); } } + + /** + * 递归查找分类对象 + * @param {Object} obj 数据对象 + * @param {string} catName 分类名称 + * @returns {Object|null} 找到的分类对象或 null + * @note 对于虚拟分类"标签"(仅在没有实际分类时使用),返回根对象本身 + */ + static _findCategoryRecursively(obj, catName) { + if (!obj || typeof obj !== 'object') return null; + // 虚拟分类"标签"代表根级别,返回根对象本身 + // 这个分类仅在 CSV 中没有任何实际分类,只有根标签时使用 + if (catName === "" || catName === "标签") return obj; + + 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 = this._findCategoryRecursively(value, catName); + if (result) return result; + } + } + return null; + } + + /** + * 递归查找标签及其父对象 + * @param {Object} obj 数据对象 + * @param {string} tagName 标签名称 + * @param {string} tagValue 标签值 + * @returns {Object|null} 包含 {parent, key} 的对象或 null + */ + static _findTagRecursively(obj, tagName, tagValue) { + if (!obj || typeof obj !== 'object') return null; + + for (const [key, value] of Object.entries(obj)) { + if (key === tagName && value === tagValue) { + return { parent: obj, key: key }; + } + if (typeof value === 'object' && value !== null) { + const result = this._findTagRecursively(value, tagName, tagValue); + if (result) return result; + } + } + return null; + } static popupInstance = null; static onCloseCallback = null; // 添加关闭回调存储 static eventCleanups = []; // 事件清理函数数组 @@ -191,7 +235,7 @@ class TagManager { this.refreshSearchResultsState(); } - logger.debug(`标签操作 | 动作:移除 | 标签:"${tagName}" | 原始值:"${tagValue}"`); + // logger.debug(`标签操作 | 动作:移除 | 标签:"${tagName}" | 原始值:"${tagValue}"`); } else { // 标签未使用,插入 // 获取光标位置前后的文本 @@ -268,7 +312,7 @@ class TagManager { this.refreshSearchResultsState(); } - logger.debug(`标签操作 | 动作:插入 | 标签:"${tagName}" | 原始值:"${tagValue}" | 格式类型:${formatType} | 插入格式:"${insertFormat}"`); + // logger.debug(`标签操作 | 动作:插入 | 标签:"${tagName}" | 原始值:"${tagValue}" | 格式类型:${formatType} | 插入格式:"${insertFormat}"`); } } catch (error) { logger.error(`标签操作失败 | 标签:"${tagName}" | 错误:${error.message}`); @@ -622,11 +666,23 @@ class TagManager { tabContents.style.flex = '1'; // 确保内容区域占满剩余空间 tabContents.style.minHeight = '0'; // 允许flex收缩 - // 获取所有一级分类 + // 获取所有一级分类和标签 const categories = Object.keys(data); - // 如果没有分类,返回空容器 - if (categories.length === 0) { + // 分离根级别标签(字符串值)和实际分类(对象值) + // 注意:我们只在渲染时分离,不修改原始 data 对象 + const rootTags = {}; + const actualCategories = []; + categories.forEach(key => { + if (typeof data[key] === 'string') { + rootTags[key] = data[key]; + } else { + actualCategories.push(key); + } + }); + + // 如果没有分类且没有根标签,返回空容器 + if (actualCategories.length === 0 && Object.keys(rootTags).length === 0) { const emptyContainer = document.createElement('div'); emptyContainer.className = 'tag_category_container'; return emptyContainer; @@ -663,10 +719,7 @@ class TagManager { 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'; @@ -768,8 +821,11 @@ class TagManager { } }, 100); - // 为每个一级分类创建标签和内容 - categories.forEach((category, index) => { + // 如果没有实际分类但有根标签,创建一个默认 Tab 来显示它们 + const finalCategories = actualCategories.length > 0 ? actualCategories : (Object.keys(rootTags).length > 0 ? ["标签"] : []); + + // 为每个分类创建标签和内容 + finalCategories.forEach((category, index) => { // 创建标签 const tab = document.createElement('div'); tab.className = 'popup_tab'; @@ -858,15 +914,35 @@ class TagManager { content.style.flexDirection = 'column'; } - // 递归创建子内容(二级分类开始使用手风琴) - if (typeof data[category] === 'object' && data[category] !== null) { - const innerContent = this._createInnerAccordion(data[category], '1', category, category); + // 获取该分类的数据 + // 如果是"标签"虚拟分类(仅在没有实际分类时使用),使用根标签作为数据 + const categoryData = category === "标签" ? rootTags : data[category]; + + if (typeof categoryData === 'object' && categoryData !== null) { + // 使用 _createInnerAccordion,它已经支持混合内容(标签+子分类) + const innerContent = this._createInnerAccordion(categoryData, '1', category, category); content.appendChild(innerContent); } tabContents.appendChild(content); }); + // ---在 Tab 栏末尾添加"新建分类"按钮--- + const addTabButton = document.createElement('div'); + addTabButton.className = 'popup_tab add_category_tab'; + addTabButton.title = '新建分类'; + + const addTabIcon = document.createElement('span'); + addTabIcon.className = 'pi pi-plus'; + addTabButton.appendChild(addTabIcon); + + const addTabClickCleanup = EventManager.addDOMListener(addTabButton, 'click', (e) => { + e.stopPropagation(); + this._handleAddCategory(addTabButton, null, tabs, tabContents, data); + }); + this.eventCleanups.push(addTabClickCleanup); + tabs.appendChild(addTabButton); + // 组装标签页结构 tabsScroll.appendChild(tabs); tabsContainer.appendChild(leftIndicator); @@ -1375,6 +1451,16 @@ class TagManager { header.appendChild(headerTitle); header.appendChild(headerIcon); + // 添加右键菜单事件(收藏页面不显示) + if (tabName !== 'favorites') { + const headerContextMenuCleanup = EventManager.addDOMListener(header, 'contextmenu', (e) => { + e.preventDefault(); + e.stopPropagation(); + this._showCategoryContextMenu(e, key, false, tabName); + }); + this.eventCleanups.push(headerContextMenuCleanup); + } + // 添加拖拽时自动展开功能 let hoverTimer = null; const dragOverCleanup = EventManager.addDOMListener(header, 'dragover', (e) => { @@ -1412,10 +1498,9 @@ class TagManager { content.className = 'tag_accordion_content'; // 递归创建子内容 - // 对于收藏标签页(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); + // 传递当前手风琴的分类名称(key)作为 categoryName,而不是父分类名称 + // 这样每个容器的 data-sortable-category 才能正确标识其所属分类 + const childContent = this._createInnerAccordion(value, (parseInt(level) + 1).toString(), tabName, key); childContent.style.flex = '1'; // 让子内容占满可用空间 childContent.style.minHeight = '0'; // 允许flex收缩 content.appendChild(childContent); @@ -1516,6 +1601,24 @@ class TagManager { } } + // ---在手风琴列表末尾添加"新建子分类"按钮(仅限 level='1',即第一级手风琴)--- + if (level === '1' && tabName !== 'favorites') { + const addSubCategoryBtn = document.createElement('div'); + addSubCategoryBtn.className = 'add_subcategory_button'; + addSubCategoryBtn.title = '新建子分类'; + + const addSubCategoryIcon = document.createElement('span'); + addSubCategoryIcon.className = 'pi pi-plus'; + addSubCategoryBtn.appendChild(addSubCategoryIcon); + + const addSubCategoryClickCleanup = EventManager.addDOMListener(addSubCategoryBtn, 'click', (e) => { + e.stopPropagation(); + this._handleAddCategory(addSubCategoryBtn, tabName, null, null, data, categoryName); + }); + this.eventCleanups.push(addSubCategoryClickCleanup); + container.appendChild(addSubCategoryBtn); + } + // 初始化拖拽排序(如果有 Sortable) // 分别处理手风琴排序和标签排序,避免拖拽标签时误触发手风琴排序 if (this.Sortable) { @@ -1625,12 +1728,14 @@ class TagManager { 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]; @@ -1677,19 +1782,21 @@ class TagManager { 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); @@ -1796,23 +1903,9 @@ class TagManager { 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); + const sourceCategory = TagManager._findCategoryRecursively(TagManager.tagData, fromCategory); + const targetCategory = TagManager._findCategoryRecursively(TagManager.tagData, toCategory); if (!sourceCategory) { logger.error(`[标签移动] 找不到源分类: ${fromCategory}`); @@ -2013,6 +2106,9 @@ class TagManager { // 检查是否已收藏 (使用传入的 category) const isFavorited = this._isTagFavorited(tagValue, category); + // 查找触发右键的标签元素 + const tagItem = e.target.closest('.tag_item') || e.currentTarget; + // ---为收藏标签提供简化菜单--- // 收藏标签(⭐️)是引用类型,不应该被编辑或删除,只能取消收藏 const menuItems = isFavorited ? [ @@ -2021,8 +2117,8 @@ class TagManager { label: '取消收藏', icon: 'pi-minus-circle', onClick: async () => { - // 标准化分类名 - let targetCategory = category || this.currentCsvFile || "默认"; + // 使用CSV文件名作为分类名 + let targetCategory = this.currentCsvFile || "默认"; targetCategory = targetCategory.replace(/\.(csv|json|yaml|yml)$/i, ''); const success = await ResourceManager.removeFavorite(tagValue, targetCategory); @@ -2059,8 +2155,8 @@ class TagManager { label: '收藏标签', icon: 'pi-star', onClick: async () => { - // 标准化分类名 - let targetCategory = category || this.currentCsvFile || "默认"; + // 使用CSV文件名作为分类名 + let targetCategory = this.currentCsvFile || "默认"; targetCategory = targetCategory.replace(/\.(csv|json|yaml|yml)$/i, ''); const success = await ResourceManager.addFavorite(tagValue, tagName, targetCategory); @@ -2092,10 +2188,9 @@ class TagManager { { label: '编辑标签', icon: 'pi-pencil', - onClick: (e) => { - // 使用 _showContextMenu 传入的 category 参数 - // 注意:右键菜单是 append 到 body 的,无法从 e.target 向上查找 .tag_item - this._handleEditTag(e.target, tagName, tagValue, category); + onClick: () => { + // 使用识别出的 tagItem 执行编辑 + this._handleEditTag(tagItem, tagName, tagValue, category); } }, { @@ -2106,7 +2201,8 @@ class TagManager { icon: 'pi-trash', danger: true, onClick: () => { - this._handleDeleteTag(tagName, tagValue); + // 使用识别出的 tagItem 执行删除 + this._handleDeleteTag(tagItem, tagName, tagValue); } } ]; @@ -2118,6 +2214,557 @@ class TagManager { }); } + /** + * 处理添加新分类 + * @param {HTMLElement} triggerElement 触发元素(按钮) + * @param {string|null} parentTabName 父 Tab 名称(如果是添加子分类) + * @param {HTMLElement|null} tabsContainer Tab 容器(如果是添加顶级分类) + * @param {HTMLElement|null} tabContentsContainer Tab 内容容器(如果是添加顶级分类) + * @param {Object} data 当前数据对象 + * @param {string|null} parentCategory 父分类名称(如果是添加子分类) + */ + static _handleAddCategory(triggerElement, parentTabName, tabsContainer, tabContentsContainer, data, parentCategory = null) { + const isTopLevel = parentTabName === null; // 是否创建顶级分类(Tab) + + // 创建表单内容渲染函数 + const renderForm = (container) => { + container.style.display = 'flex'; + container.style.flexDirection = 'column'; + container.style.gap = '16px'; + container.style.minWidth = '250px'; + + // 分类名输入组 - 使用浮动标签样式 + 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.className = 'p-inputtext p-component'; + nameInput.placeholder = ' '; + + const nameLabel = document.createElement('label'); + nameLabel.textContent = '分类名称'; + + nameFloatContainer.appendChild(nameInput); + nameFloatContainer.appendChild(nameLabel); + nameGroup.appendChild(nameFloatContainer); + container.appendChild(nameGroup); + + // 返回引用以便获取值 + return { nameInput }; + }; + + let inputs = {}; + + createConfirmPopup({ + target: triggerElement, + message: isTopLevel ? '创建新的标签页分类' : `在 "${parentTabName}" 下创建子分类`, + icon: 'pi-plus', + position: 'top', + confirmLabel: '创建', + renderFormContent: (container) => { + inputs = renderForm(container); + }, + onConfirm: async () => { + const categoryName = inputs.nameInput.value.trim(); + + if (!categoryName) { + app.extensionManager.toast.add({ + severity: "warn", + summary: "输入无效", + detail: "分类名称不能为空", + life: 3000 + }); + throw new Error("Validation failed"); + } + + // 检查分类名是否已存在 + const targetData = isTopLevel ? TagManager.tagData : data; + if (targetData && targetData.hasOwnProperty(categoryName)) { + app.extensionManager.toast.add({ + severity: "warn", + summary: "创建失败", + detail: `分类 "${categoryName}" 已存在`, + life: 3000 + }); + throw new Error("Category already exists"); + } + + // 保存新分类 + await this._saveNewCategory(categoryName, isTopLevel, parentTabName, parentCategory); + } + }); + } + + /** + * 保存新分类到 CSV + * @param {string} categoryName 分类名称 + * @param {boolean} isTopLevel 是否为顶级分类 + * @param {string|null} parentTabName 父 Tab 名称 + * @param {string|null} parentCategory 父分类名称 + */ + static async _saveNewCategory(categoryName, isTopLevel, parentTabName, parentCategory) { + try { + // 使用 loadTagsCsv 加载当前 CSV 文件的最新数据 + const filename = this.currentCsvFile || await ResourceManager.getSelectedTagFile(); + if (!filename) { + throw new Error("无法确定当前 CSV 文件"); + } + + const data = await ResourceManager.loadTagsCsv(filename); + if (!data) { + throw new Error("加载标签数据失败"); + } + + // 确定目标位置并添加新分类 + if (isTopLevel) { + // 检查顶级分类是否已存在 + if (data.hasOwnProperty(categoryName)) { + app.extensionManager.toast.add({ + severity: "warn", + summary: "分类已存在", + detail: `分类 "${categoryName}" 已存在`, + life: 3000 + }); + throw new Error("Category already exists"); + } + // 添加顶级分类(空分类需要有一个占位标签才能保存) + data[categoryName] = { "__placeholder__": "__empty__" }; + } else { + // 查找父分类 + const parentData = this._findCategoryRecursively(data, parentTabName); + if (!parentData) { + throw new Error(`找不到父分类: ${parentTabName}`); + } + // 检查子分类是否已存在 + if (parentData.hasOwnProperty(categoryName)) { + app.extensionManager.toast.add({ + severity: "warn", + summary: "分类已存在", + detail: `分类 "${categoryName}" 已存在于 "${parentTabName}" 下`, + life: 3000 + }); + throw new Error("Category already exists"); + } + // 添加子分类(空分类需要有一个占位标签才能保存) + parentData[categoryName] = { "__placeholder__": "__empty__" }; + } + + // 保存到 CSV 文件 + await ResourceManager.saveTagsCsv(filename, data); + + // 重新加载 CSV 数据以刷新缓存 + const newData = await ResourceManager.loadTagsCsv(filename); + + // 更新静态数据引用 + TagManager.tagData = newData; + + // 实时刷新 UI + if (isTopLevel) { + // 顶级分类:在 Tab 栏中添加新 Tab(在"添加分类"按钮之前) + const tabs = document.querySelector('.popup_tabs'); + const tabContents = document.querySelector('.tag_category_container'); + const addCategoryBtn = tabs?.querySelector('.add_category_tab'); + + if (tabs && tabContents && addCategoryBtn) { + // 创建新 Tab + const tab = document.createElement('div'); + tab.className = 'popup_tab'; + tab.textContent = categoryName; + tab.setAttribute('data-category', categoryName); + + // 创建内容区域 + const content = document.createElement('div'); + content.className = 'popup_tab_content'; + content.setAttribute('data-category', categoryName); + content.style.flex = '1'; + content.style.display = 'none'; + + // 添加 Tab 点击事件 + const tabClickCleanup = EventManager.addDOMListener(tab, 'click', () => { + // 取消所有 Tab 激活状态 + tabs.querySelectorAll('.popup_tab').forEach(t => t.classList.remove('active')); + tabContents.querySelectorAll('.popup_tab_content').forEach(c => { + c.classList.remove('active'); + c.style.display = 'none'; + }); + + // 激活当前 Tab + tab.classList.add('active'); + content.classList.add('active'); + content.style.display = 'block'; + + // 记忆当前选择的标签页 + TagManager.setLastActiveTab(categoryName); + + // 懒加载内容 + if (content.getAttribute('data-loaded') !== 'true') { + this._loadCategoryContent(content, newData[categoryName], categoryName); + content.setAttribute('data-loaded', 'true'); + } + }); + this.eventCleanups.push(tabClickCleanup); + + // 在"添加分类"按钮之前插入新 Tab + tabs.insertBefore(tab, addCategoryBtn); + tabContents.appendChild(content); + + // 自动激活新创建的分类 + tab.click(); + } + } else { + // 子分类:刷新当前 Tab 的内容 + 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'); + this._loadCategoryContent(content, newData[cat], cat); + } + } + + app.extensionManager.toast.add({ + severity: "success", + summary: "创建成功", + detail: `分类 "${categoryName}" 已创建`, + life: 3000 + }); + + } catch (error) { + if (error.message !== "Validation failed" && error.message !== "Category already exists") { + logger.error(`创建分类失败: ${error.message}`); + app.extensionManager.toast.add({ + severity: "error", + summary: "创建失败", + detail: error.message, + life: 3000 + }); + } + } + } + + /** + * 显示分类右键菜单 + * @param {Event} e 鼠标事件 + * @param {string} categoryName 分类名称 + * @param {boolean} isTopLevel 是否为顶级分类 + * @param {string|null} parentCategory 父分类名称(对于手风琴) + */ + static _showCategoryContextMenu(e, categoryName, isTopLevel, parentCategory = null) { + // 特殊分类不允许修改或删除 + const specialCategories = ['⭐️', 'favorites', '已插入', '标签']; + if (specialCategories.includes(categoryName)) { + return; + } + + // 保存触发元素以便后续使用 + const targetElement = e.target.closest('.popup_tab') || e.target.closest('.tag_accordion_header') || e.target; + + const menuItems = [ + { + label: '修改名称', + icon: 'pi-pencil', + onClick: () => { + // 从 DOM 获取最新的分类名称 + const currentName = isTopLevel + ? targetElement.getAttribute('data-category') || targetElement.textContent.trim() + : targetElement.closest('.tag_accordion')?.getAttribute('data-category') || categoryName; + this._handleEditCategory(targetElement, currentName, isTopLevel, parentCategory); + } + }, + { + separator: true + }, + { + label: '删除分类', + icon: 'pi-trash', + danger: true, + onClick: () => { + // 从 DOM 获取最新的分类名称 + const currentName = isTopLevel + ? targetElement.getAttribute('data-category') || targetElement.textContent.trim() + : targetElement.closest('.tag_accordion')?.getAttribute('data-category') || categoryName; + this._handleDeleteCategory(targetElement, currentName, isTopLevel, parentCategory); + } + } + ]; + + showContextMenu({ + x: e.clientX, + y: e.clientY, + items: menuItems + }); + } + + /** + * 处理编辑分类名称 + * @param {HTMLElement} target 触发元素 + * @param {string} oldName 原分类名称 + * @param {boolean} isTopLevel 是否为顶级分类 + * @param {string|null} parentCategory 父分类名称 + */ + static _handleEditCategory(target, oldName, isTopLevel, parentCategory) { + // 创建表单内容渲染函数 + const renderForm = (container) => { + container.style.display = 'flex'; + container.style.flexDirection = 'column'; + container.style.gap = '16px'; + container.style.minWidth = '250px'; + + 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.className = 'p-inputtext p-component'; + nameInput.placeholder = ' '; + nameInput.value = oldName; + + const nameLabel = document.createElement('label'); + nameLabel.textContent = '分类名称'; + + nameFloatContainer.appendChild(nameInput); + nameFloatContainer.appendChild(nameLabel); + nameGroup.appendChild(nameFloatContainer); + container.appendChild(nameGroup); + + // 自动聚焦并选中文本 + setTimeout(() => { + nameInput.focus(); + nameInput.select(); + }, 100); + + return { nameInput }; + }; + + let inputs = {}; + + createConfirmPopup({ + target: target, + message: `修改分类名称`, + icon: 'pi-pencil', + position: 'bottom', + confirmLabel: '保存', + renderFormContent: (container) => { + inputs = renderForm(container); + }, + onConfirm: async () => { + const newName = inputs.nameInput.value.trim(); + + if (!newName) { + app.extensionManager.toast.add({ + severity: "warn", + summary: "输入无效", + detail: "分类名称不能为空", + life: 3000 + }); + throw new Error("Validation failed"); + } + + if (newName === oldName) { + return; // 名称未改变,无需保存 + } + + await this._saveEditedCategory(oldName, newName, isTopLevel, parentCategory); + } + }); + } + + /** + * 保存编辑后的分类名称 + */ + static async _saveEditedCategory(oldName, newName, isTopLevel, parentCategory) { + try { + const filename = this.currentCsvFile || await ResourceManager.getSelectedTagFile(); + if (!filename) { + throw new Error("无法确定当前 CSV 文件"); + } + + const data = await ResourceManager.loadTagsCsv(filename); + if (!data) { + throw new Error("加载标签数据失败"); + } + + if (isTopLevel) { + // 顶级分类重命名 + if (data.hasOwnProperty(newName)) { + app.extensionManager.toast.add({ + severity: "warn", + summary: "名称已存在", + detail: `分类 "${newName}" 已存在`, + life: 3000 + }); + throw new Error("Category already exists"); + } + + // 复制数据到新名称,删除旧名称 + data[newName] = data[oldName]; + delete data[oldName]; + } else { + // 子分类重命名 + const parentData = this._findCategoryRecursively(data, parentCategory); + if (!parentData) { + throw new Error(`找不到父分类: ${parentCategory}`); + } + + if (parentData.hasOwnProperty(newName)) { + app.extensionManager.toast.add({ + severity: "warn", + summary: "名称已存在", + detail: `分类 "${newName}" 已存在于 "${parentCategory}" 下`, + life: 3000 + }); + throw new Error("Category already exists"); + } + + parentData[newName] = parentData[oldName]; + delete parentData[oldName]; + } + + await ResourceManager.saveTagsCsv(filename, data); + const newData = await ResourceManager.loadTagsCsv(filename); + TagManager.tagData = newData; + + // 刷新 UI + if (isTopLevel) { + // 更新 Tab 名称 + const tab = document.querySelector(`.popup_tab[data-category="${oldName}"]`); + const tabContent = document.querySelector(`.popup_tab_content[data-category="${oldName}"]`); + if (tab) { + tab.textContent = newName; + tab.setAttribute('data-category', newName); + } + if (tabContent) { + tabContent.setAttribute('data-category', newName); + } + } else { + // 刷新当前 Tab 内容 + 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'); + this._loadCategoryContent(content, newData[cat], cat); + } + } + + app.extensionManager.toast.add({ + severity: "success", + summary: "修改成功", + detail: `分类已重命名为 "${newName}"`, + life: 3000 + }); + + } catch (error) { + if (error.message !== "Validation failed" && error.message !== "Category already exists") { + logger.error(`修改分类失败: ${error.message}`); + app.extensionManager.toast.add({ + severity: "error", + summary: "修改失败", + detail: error.message, + life: 3000 + }); + } + } + } + + /** + * 处理删除分类 + */ + static async _handleDeleteCategory(targetElement, categoryName, isTopLevel, parentCategory) { + // 使用确认弹窗 - 定位到触发元素 + createConfirmPopup({ + target: targetElement, + message: `确定要删除分类 "${categoryName}" 和该分类下的所有标签吗?`, + icon: 'pi-exclamation-triangle', + iconColor: 'var(--p-orange-500)', + position: 'bottom', + confirmLabel: '删除', + cancelLabel: '取消', + confirmDanger: true, + onConfirm: async () => { + try { + const filename = this.currentCsvFile || await ResourceManager.getSelectedTagFile(); + if (!filename) { + throw new Error("无法确定当前 CSV 文件"); + } + + const data = await ResourceManager.loadTagsCsv(filename); + if (!data) { + throw new Error("加载标签数据失败"); + } + + if (isTopLevel) { + delete data[categoryName]; + } else { + const parentData = this._findCategoryRecursively(data, parentCategory); + if (parentData) { + delete parentData[categoryName]; + } + } + + await ResourceManager.saveTagsCsv(filename, data); + const newData = await ResourceManager.loadTagsCsv(filename); + TagManager.tagData = newData; + + // 刷新 UI + if (isTopLevel) { + // 删除 Tab 和内容 + const tab = document.querySelector(`.popup_tab[data-category="${categoryName}"]`); + const tabContent = document.querySelector(`.popup_tab_content[data-category="${categoryName}"]`); + + const wasActive = tab?.classList.contains('active'); + + tab?.remove(); + tabContent?.remove(); + + // 如果删除的是当前激活的 Tab,激活第一个普通 Tab + if (wasActive) { + const firstTab = document.querySelector('.popup_tab:not(.add_category_tab)'); + if (firstTab) { + firstTab.click(); + } + } + } else { + // 刷新当前 Tab 内容 + 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'); + this._loadCategoryContent(content, newData[cat], cat); + } + } + + app.extensionManager.toast.add({ + severity: "success", + summary: "删除成功", + detail: `分类 "${categoryName}" 已删除`, + life: 3000 + }); + + } catch (error) { + logger.error(`删除分类失败: ${error.message}`); + app.extensionManager.toast.add({ + severity: "error", + summary: "删除失败", + detail: error.message, + life: 3000 + }); + } + } + }); + } + /** * 处理添加新标签 * @param {string} categoryKey 分类键 @@ -2232,22 +2879,8 @@ class TagManager { 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); + let targetCategory = TagManager._findCategoryRecursively(data, categoryKey); if (!targetCategory) { // 如果分类不存在,创建新分类 @@ -2424,90 +3057,70 @@ class TagManager { 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); + const found = TagManager._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 { + if (!found) { throw new Error("无法定位原始标签数据,请刷新后重试"); } + const { parent } = found; + + // 删除旧标签 + delete parent[oldName]; + + // 确定目标分类对象 + let targetCategoryObj; + if (oldCategory !== newCategory && newCategory) { + // 如果分类改变,移动到新分类 + targetCategoryObj = TagManager._findCategoryRecursively(data, newCategory); + if (!targetCategoryObj) { + // 如果目标分类不存在,创建它 + data[newCategory] = {}; + targetCategoryObj = data[newCategory]; + } + } else { + // 分类未变,仍在原父对象中 + targetCategoryObj = parent; + } + + // 添加新标签(或更新后的标签) + targetCategoryObj[newName] = newValue; + + // 保存到 CSV 文件 + const saveSuccess = await ResourceManager.saveTagsCsv(filename, data); + if (!saveSuccess) { + throw new Error("保存文件失败"); + } + + // 重新加载并更新状态 + 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 { + // 常规分类,如果分类名变了可能需要动态加载新分类 + const displayCategory = newData[cat] ? cat : newCategory; + this._loadCategoryContent(content, newData[displayCategory] || newData, displayCategory); + } + } + + app.extensionManager.toast.add({ + severity: "success", + summary: "保存成功", + detail: "标签已更新", + life: 3000 + }); + } catch (error) { logger.error(`保存编辑失败: ${error.message}`); app.extensionManager.toast.add({ @@ -2522,29 +3135,16 @@ class TagManager { /** * 处理删除标签 */ - 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 () => { + static async _handleDeleteTag(targetElement, tagName, tagValue) { + createConfirmPopup({ + target: targetElement || document.body, + message: `确定要删除标签 "${tagName}" 吗?`, + icon: 'pi-exclamation-triangle', + iconColor: 'var(--p-orange-500)', + confirmLabel: '删除', + cancelLabel: '取消', + confirmDanger: true, + onConfirm: async () => { try { // 1. 获取当前 CSV 文件名 const filename = this.currentCsvFile || await ResourceManager.getSelectedTagFile(); @@ -2553,9 +3153,6 @@ class TagManager { } // 2. 加载 CSV 数据 - // 注意:为了确保数据的一致性,我们需要重新加载文件而不是使用缓存 - // 或者我们相信 ResourceManager 的 loadTagsCsv 会处理好? - // 我们使用 loadTagsCsv,它会返回解析后的数据 const data = await ResourceManager.loadTagsCsv(filename); if (!data) { throw new Error("加载标签数据失败"); @@ -2574,9 +3171,6 @@ class TagManager { } if (typeof obj[key] === 'object' && obj[key] !== null) { if (deleteRecursively(obj[key])) { - // 如果子对象变空了,是否需要删除子对象? - // 通常 CSV 转换的对象如果为空可能保留也可能删除。 - // 这里我们简单处理,仅删除标签。 return true; } } @@ -2597,10 +3191,6 @@ class TagManager { } // 5. 刷新 UI - // 注意:不能使用 refreshTagData(),因为它会获取默认标签而不是当前 CSV 文件 - // 直接使用本地已更新的 data 对象 - - // 更新静态数据引用,确保后续拖拽排序使用最新数据 TagManager.tagData = data; // 重新加载当前视图 @@ -2610,13 +3200,11 @@ class TagManager { 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); } } @@ -2714,7 +3302,7 @@ class TagManager { } const endTime = performance.now(); - logger.debug(`标签状态 | 动作:比对 | 节点:${nodeId} | 输入框:${inputId} | 缓存数量:${cacheCount} | 匹配数量:${matchedCount} | 耗时:${(endTime - startTime).toFixed(2)}ms`); + // logger.debug(`标签状态 | 动作:比对 | 节点:${nodeId} | 输入框:${inputId} | 缓存数量:${cacheCount} | 匹配数量:${matchedCount} | 耗时:${(endTime - startTime).toFixed(2)}ms`); return { cacheCount, matchedCount }; } @@ -2879,6 +3467,22 @@ class TagManager { content.appendChild(tabContent); }); + // ---在 Tab 栏末尾添加"新建分类"按钮--- + const addTabButton = document.createElement('div'); + addTabButton.className = 'popup_tab add_category_tab'; + addTabButton.title = '新建分类'; + + const addTabIcon = document.createElement('span'); + addTabIcon.className = 'pi pi-plus'; + addTabButton.appendChild(addTabIcon); + + const addTabClickCleanup = EventManager.addDOMListener(addTabButton, 'click', (e) => { + e.stopPropagation(); + this._handleAddCategory(addTabButton, null, tabs, content, tagData); + }); + this.eventCleanups.push(addTabClickCleanup); + tabs.appendChild(addTabButton); + // 添加滚动指示器 const leftIndicator = document.createElement('div'); leftIndicator.className = 'popup_tabs_indicator left'; @@ -3481,7 +4085,7 @@ class TagManager { if (savedSize) { popup.style.width = `${savedSize.width}px`; popup.style.height = `${savedSize.height}px`; - logger.debug(`[助手-标签] 恢复窗口大小 | 宽:${savedSize.width}px | 高:${savedSize.height}px`); + // logger.debug(`[助手-标签] 恢复窗口大小 | 宽:${savedSize.width}px | 高:${savedSize.height}px`); } else { popup.style.width = '600px'; popup.style.height = '400px'; @@ -3853,6 +4457,18 @@ class TagManager { }); this.eventCleanups.push(tabClickCleanup); + + // 添加右键菜单事件(特殊分类除外) + const specialCategories = ['⭐️', 'favorites', '已插入']; + if (!specialCategories.includes(category)) { + const tabContextMenuCleanup = EventManager.addDOMListener(tab, 'contextmenu', (e) => { + e.preventDefault(); + e.stopPropagation(); + this._showCategoryContextMenu(e, category, true, null); + }); + this.eventCleanups.push(tabContextMenuCleanup); + } + tabs.appendChild(tab); // 创建内容区域 @@ -3872,6 +4488,76 @@ class TagManager { tabContents.appendChild(content); }); + // ---在 Tab 栏末尾添加"新建分类"按钮--- + const addTabButton = document.createElement('div'); + addTabButton.className = 'popup_tab add_category_tab'; + addTabButton.title = '新建分类'; + + const addTabIcon = document.createElement('span'); + addTabIcon.className = 'pi pi-plus'; + addTabButton.appendChild(addTabIcon); + + const addTabBtnClickCleanup = EventManager.addDOMListener(addTabButton, 'click', (e) => { + e.stopPropagation(); + this._handleAddCategory(addTabButton, null, tabs, tabContents, tagData); + }); + this.eventCleanups.push(addTabBtnClickCleanup); + tabs.appendChild(addTabButton); + + // ---初始化 Tab 栏拖拽排序--- + if (this.Sortable) { + const tabsSortable = new this.Sortable(tabs, { + animation: 150, + ghostClass: 'tag-ghost', + draggable: '.popup_tab:not(.add_category_tab)', // 排除添加按钮 + delay: 50, + onEnd: async (evt) => { + const { oldIndex, newIndex } = evt; + if (oldIndex === newIndex) return; + + // 获取当前所有 Tab 的新顺序 + const newOrder = Array.from(tabs.querySelectorAll('.popup_tab:not(.add_category_tab)')) + .map(tab => tab.getAttribute('data-category')); + + // 重新排列 tagData + const newTagData = {}; + + // 按照新顺序重建数据对象 + newOrder.forEach(category => { + // 特殊分类 (⭐️, 已插入) 不在 tagData 中,跳过 + if (category !== '⭐️' && category !== '已插入' && tagData[category]) { + newTagData[category] = tagData[category]; + } + }); + + // 确保没有遗漏的数据(以防万一) + Object.keys(tagData).forEach(category => { + if (!newTagData[category]) { + newTagData[category] = tagData[category]; + } + }); + + // 更新内存中的数据 + TagManager.tagData = newTagData; + + // 持久化到 CSV + const filename = this.currentCsvFile || await ResourceManager.getSelectedTagFile(); + if (filename) { + const success = await ResourceManager.saveTagsCsv(filename, newTagData); + if (success) { + app.extensionManager.toast.add({ + severity: "success", + summary: "排序已保存", + detail: "分类显示顺序已更新", + life: 2000 + }); + } + } + } + }); + this.sortables.push(tabsSortable); + } + // 初始化滚动指示器,并自动定位到激活的标签页 setTimeout(() => { const canScroll = tabsScroll.scrollWidth > tabsScroll.clientWidth; @@ -4300,7 +4986,7 @@ class TagManager { }); const endTime = performance.now(); - logger.debug(`标签状态 | 动作:更新所有 | 节点:${nodeId} | 输入框:${inputId} | 检查数量:${checkedCount} | 匹配数量:${matchedCount} | 耗时:${(endTime - startTime).toFixed(2)}ms`); + // logger.debug(`标签状态 | 动作:更新所有 | 节点:${nodeId} | 输入框:${inputId} | 检查数量:${checkedCount} | 匹配数量:${matchedCount} | 耗时:${(endTime - startTime).toFixed(2)}ms`); } /** diff --git a/js/modules/uiComponents.js b/js/modules/uiComponents.js index a8b289f..de6a4af 100644 --- a/js/modules/uiComponents.js +++ b/js/modules/uiComponents.js @@ -6,6 +6,25 @@ import { app } from "../../../../scripts/app.js"; import { logger } from '../utils/logger.js'; +// ---动态 Z-Index 计算--- +/** + * 获取 ComfyUI 设置弹窗的 z-index 值 + * 只针对 ComfyUI 的 p-dialog 元素进行查找,避免遍历所有 DOM 元素造成性能问题 + * @returns {number} ComfyUI 弹窗的 z-index 值,如果不存在则返回基础值 + */ +function getComfyUIDialogZIndex() { + // ComfyUI 使用 PrimeVue,设置弹窗的类名是 .p-dialog + const comfyDialog = document.querySelector('.p-dialog'); + if (comfyDialog) { + const zIndex = parseInt(window.getComputedStyle(comfyDialog).zIndex, 10); + if (!isNaN(zIndex)) { + return zIndex; + } + } + // 如果没有找到 ComfyUI 弹窗,返回 CSS 变量定义的基础值 + return 10200; +} + /** * 创建通用的设置弹窗 * @param {Object} options 弹窗配置选项 @@ -377,6 +396,14 @@ export function createSettingsDialog(options) { modal.appendChild(footer); } + // 动态计算 z-index,确保始终在 ComfyUI 弹窗之上 + // 必须在 appendChild 之前设置,否则动画起始帧会使用 CSS 静态值 + const baseZIndex = getComfyUIDialogZIndex() + 10; + modal.style.zIndex = baseZIndex; + if (overlay) { + overlay.style.zIndex = baseZIndex - 1; + } + // 显示弹窗和遮罩层 document.body.appendChild(modal); @@ -623,53 +650,72 @@ export function createSelectGroup(label, options, initialValue = null, config = 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); + /** + * 更新下拉框选项 + * @param {Array<{value: string, text: string}>} newOptions - 新选项列表 + * @param {string} [newValue=null] - 新选中的值 + */ + const updateOptions = (newOptions, newValue = null) => { + // 清空现有选项 + select.innerHTML = ''; + dropdownList.innerHTML = ''; - const itemEl = document.createElement('li'); - itemEl.className = 'p-dropdown-item'; - itemEl.textContent = opt.text; - itemEl.dataset.value = opt.value; - itemEl.setAttribute('role', 'option'); + if (!newOptions || newOptions.length === 0) { + dropdownLabel.textContent = '暂无选项'; + return; + } - 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'); + // 填充新选项 + newOptions.forEach(opt => { + const optionEl = document.createElement('option'); + optionEl.value = opt.value; + optionEl.textContent = opt.text; + select.appendChild(optionEl); - select.value = opt.value; - dropdownLabel.textContent = opt.text; - closePanel(); - select.dispatchEvent(new Event('change', { bubbles: true })); + 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); }); - dropdownList.appendChild(itemEl); - }); + // 设置选中的值 + // 如果提供了 newValue 且在选项中,则使用它;否则尝试保持当前值;最后默认首项 + const currentVal = select.value; + const valToSet = (newValue !== null && newOptions.some(o => o.value === newValue)) + ? newValue + : (newOptions.some(o => o.value === currentVal) ? currentVal : newOptions[0].value); - // 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'); + if (valToSet !== null) { + const selectedOption = newOptions.find(o => o.value === valToSet); + if (selectedOption) { + dropdownLabel.textContent = selectedOption.text; + select.value = selectedOption.value; + // 设置高亮 + const initialItem = dropdownList.querySelector(`.p-dropdown-item[data-value="${valToSet}"]`); + if (initialItem) { + initialItem.classList.add('p-highlight'); + } } } - } else { - dropdownLabel.textContent = ' '; // Placeholder - } + }; + + // 初始化渲染选项 + updateOptions(options, initialValue); // Assemble dropdown dropdownItemsWrapper.appendChild(dropdownList); @@ -744,7 +790,8 @@ export function createSelectGroup(label, options, initialValue = null, config = dropdownPanel.style.top = rect.bottom + 'px'; dropdownPanel.style.left = rect.left + 'px'; dropdownPanel.style.width = rect.width + 'px'; - // 样式由 CSS 中的 .pa-dropdown-panel 接管,避免依赖 PrimeVue 变量 + // 动态计算 z-index,确保在 ComfyUI 弹窗之上 + dropdownPanel.style.zIndex = getComfyUIDialogZIndex() + 15; dropdownPanel.classList.remove('p-hidden'); // 强制重排,确保动画生效 @@ -793,7 +840,7 @@ export function createSelectGroup(label, options, initialValue = null, config = } }); - return { group, select }; + return { group, select, updateOptions }; } /** @@ -966,6 +1013,8 @@ export function createComboBoxGroup(label, options = [], initialValue = '', conf dropdownPanel.style.top = rect.bottom + 'px'; dropdownPanel.style.left = rect.left + 'px'; dropdownPanel.style.width = rect.width + 'px'; + // 动态计算 z-index,确保在 ComfyUI 弹窗之上 + dropdownPanel.style.zIndex = getComfyUIDialogZIndex() + 15; dropdownPanel.classList.remove('p-hidden'); dropdownPanel.offsetHeight; // 强制重排 @@ -1252,6 +1301,7 @@ export function createConfirmPopup(options) { target, message, icon = 'pi-info-circle', + iconColor = null, renderFormContent = null, onConfirm, onCancel = null, @@ -1259,7 +1309,8 @@ export function createConfirmPopup(options) { cancelLabel = '取消', position = 'bottom', autoPosition = true, - singleButton = false + singleButton = false, + confirmDanger = false } = options; // 创建气泡框容器 @@ -1276,6 +1327,9 @@ export function createConfirmPopup(options) { const iconElement = document.createElement('i'); iconElement.className = `pi ${icon} pa-confirm-popup-icon`; + if (iconColor) { + iconElement.style.color = iconColor; + } messageContainer.appendChild(iconElement); const messageText = document.createElement('span'); @@ -1308,7 +1362,9 @@ export function createConfirmPopup(options) { // 单按钮模式:只显示确认按钮 if (singleButton) { const confirmButton = document.createElement('button'); - confirmButton.className = 'p-button p-component p-button-sm'; + confirmButton.className = confirmDanger + ? 'p-button p-component p-button-sm p-button-danger' + : 'p-button p-component p-button-sm'; confirmButton.innerHTML = `${confirmLabel}`; confirmButton.onclick = async () => { try { @@ -1335,7 +1391,9 @@ export function createConfirmPopup(options) { }; const confirmButton = document.createElement('button'); - confirmButton.className = 'p-button p-component p-button-sm'; + confirmButton.className = confirmDanger + ? 'p-button p-component p-button-sm p-button-danger' + : 'p-button p-component p-button-sm'; confirmButton.innerHTML = `${confirmLabel}`; confirmButton.onclick = async () => { try { @@ -2123,7 +2181,7 @@ export function createMultiSelectListbox(options) { * @returns {Object} { container, updateLabel, updateIcon, updateMenu, setMainButtonDisabled, setMenuButtonDisabled } */ export function createSplitButton(options) { - const { label = '', icon = '', className = '', onClick = () => { }, items = [] } = options; + const { label = '', icon = '', className = '', onClick = () => { }, items = [], align = 'left' } = options; const container = document.createElement('div'); container.className = `p-splitbutton p-component${className ? ' ' + className : ''}`; @@ -2196,76 +2254,195 @@ export function createSplitButton(options) { let currentItems = items; let isOpen = false; + let submenuPanels = []; // 存储所有子菜单面板,用于关闭时清理 + + // 创建单个菜单项(支持二级子菜单) + const createMenuItem = (item, parentPanel) => { + if (item.separator) { + const sep = document.createElement('li'); + sep.className = 'pa-context-menu-separator'; + sep.setAttribute('role', 'separator'); + return sep; + } + + 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 = ''; + } + + // 检查是否有子菜单 + const hasSubmenu = item.items && item.items.length > 0; + + itemContent.innerHTML = ` + ${iconHtml} + ${item.label} + ${hasSubmenu ? '' : ''} + `; + + li.appendChild(itemContent); + + // 如果有子菜单 + if (hasSubmenu) { + li.classList.add('pa-context-menu-item-has-submenu'); + + // 创建子菜单面板 + const submenuPanel = document.createElement('div'); + submenuPanel.className = 'pa-context-menu pa-context-submenu'; + submenuPanel.style.display = 'none'; + submenuPanel.style.position = 'fixed'; + + const submenuList = document.createElement('ul'); + submenuList.className = 'pa-context-menu-list'; + submenuList.setAttribute('role', 'menu'); + + item.items.forEach(subItem => { + const subLi = createMenuItem(subItem, submenuPanel); + submenuList.appendChild(subLi); + }); + + submenuPanel.appendChild(submenuList); + document.body.appendChild(submenuPanel); + + // 将子菜单面板添加到列表中,用于关闭时清理 + submenuPanels.push(submenuPanel); + + let submenuTimeout = null; + + // 显示子菜单 + const showSubmenu = () => { + clearTimeout(submenuTimeout); + submenuPanel.style.display = 'block'; + submenuPanel.style.zIndex = parseInt(menuPanel.style.zIndex || getComfyUIDialogZIndex()) + 5; + submenuPanel.classList.add('pa-context-menu-show'); + + // 定位子菜单 + const liRect = li.getBoundingClientRect(); + const submenuRect = submenuPanel.getBoundingClientRect(); + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + + let left = liRect.right + 2; + let top = liRect.top; + + // 如果右边空间不够,显示在左边 + if (left + submenuRect.width > viewportWidth - 10) { + left = liRect.left - submenuRect.width - 2; + } + + // 垂直边界检查 + if (top + submenuRect.height > viewportHeight - 10) { + top = viewportHeight - submenuRect.height - 10; + } + if (top < 10) top = 10; + + submenuPanel.style.left = `${left}px`; + submenuPanel.style.top = `${top}px`; + }; + + // 隐藏子菜单 + const hideSubmenu = () => { + submenuTimeout = setTimeout(() => { + submenuPanel.classList.remove('pa-context-menu-show'); + submenuPanel.classList.add('pa-context-menu-hide'); + setTimeout(() => { + submenuPanel.style.display = 'none'; + submenuPanel.classList.remove('pa-context-menu-hide'); + }, 150); + }, 100); + }; + + // 鼠标进入父项显示子菜单 + li.addEventListener('mouseenter', showSubmenu); + li.addEventListener('mouseleave', hideSubmenu); + + // 保持子菜单打开(当鼠标在子菜单上时) + submenuPanel.addEventListener('mouseenter', () => { + clearTimeout(submenuTimeout); + }); + submenuPanel.addEventListener('mouseleave', hideSubmenu); + + } else if (!item.disabled) { + // 无子菜单且未禁用,绑定点击事件 + li.addEventListener('click', (e) => { + e.stopPropagation(); + if (item.command) { + item.command({ originalEvent: e, item }); + } + closeMenu(); + }); + } + + return li; + }; const renderMenu = () => { + // 先清理之前的子菜单面板 + submenuPanels.forEach(panel => { + if (panel.parentNode) { + panel.parentNode.removeChild(panel); + } + }); + submenuPanels = []; + 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(); - }); - } - + const li = createMenuItem(item, menuPanel); menuList.appendChild(li); }); }; const updatePosition = () => { if (!isOpen) return; - const rect = menuBtn.getBoundingClientRect(); const containerRect = container.getBoundingClientRect(); + // 先确保面板是显示的以便获取宽度 + menuPanel.style.display = 'block'; + const panelRect = menuPanel.getBoundingClientRect(); + let left = containerRect.left; let top = containerRect.bottom + 2; - // Edge detection could be added here similar to createTooltip + if (align === 'right') { + left = containerRect.right - panelRect.width; + } + + // 边界检查:防止溢出屏幕右侧 + if (left + panelRect.width > window.innerWidth) { + left = window.innerWidth - panelRect.width - 5; + } + // 边界检查:防止溢出屏幕左侧 + if (left < 0) left = 5; menuPanel.style.top = `${top}px`; menuPanel.style.left = `${left}px`; - // menuPanel.style.minWidth = `${containerRect.width}px`; }; const closeMenu = () => { if (!isOpen) return; isOpen = false; + + // 清理所有子菜单面板 + submenuPanels.forEach(panel => { + if (panel.parentNode) { + panel.parentNode.removeChild(panel); + } + }); + submenuPanels = []; + menuPanel.classList.remove('pa-context-menu-show'); menuPanel.classList.add('pa-context-menu-hide'); @@ -2285,8 +2462,16 @@ export function createSplitButton(options) { return; } isOpen = true; + + // 支持动态获取菜单项 (如果 items 是函数) + if (typeof options.items === 'function') { + currentItems = options.items(); + } + renderMenu(); // Render fresh on open to catch state changes menuPanel.style.display = 'block'; + // 动态计算 z-index,确保在 ComfyUI 弹窗之上 + menuPanel.style.zIndex = getComfyUIDialogZIndex() + 20; menuPanel.classList.add('pa-context-menu-show'); updatePosition(); diff --git a/js/services/NodeMountService.js b/js/services/NodeMountService.js index 7599ced..de93fa9 100644 --- a/js/services/NodeMountService.js +++ b/js/services/NodeMountService.js @@ -39,6 +39,11 @@ class NodeMountService { // 挂载观察者映射 { nodeId: observer } this._observers = new Map(); + + // ---模式切换互斥锁--- + this._modeSwitching = false; + // 待处理的模式切换请求 + this._pendingModeChange = null; } // ---初始化与生命周期--- @@ -108,10 +113,23 @@ class NodeMountService { (typeLower.includes('preview') && typeLower.includes('text')); } + /** + * 检查节点是否为子图节点 (Subgraph) + * 子图节点的类型名为 UUID 格式 + * @param {object} node - 节点对象 + * @returns {boolean} + */ + _isSubgraphNode(node) { + if (!node || !node.type) return false; + // UUID 格式:8-4-4-4-12 字符 + return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(node.type); + } + // ---渲染模式检测--- /** * 检测当前渲染模式 + * 【优化】只使用最可靠的 LiteGraph.vueNodesMode 全局标志 * @param {boolean} forceRefresh - 是否强制刷新缓存 * @returns {string} 渲染模式枚举值 */ @@ -122,32 +140,10 @@ class NodeMountService { 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; // 默认回退到传统模式 - } + // 【简化】只使用最可靠的全局标志 + const mode = (typeof LiteGraph !== 'undefined' && LiteGraph.vueNodesMode === true) + ? RENDER_MODE.VUE_NODES + : RENDER_MODE.LITEGRAPH; // 更新缓存 this._modeCache = mode; @@ -196,33 +192,59 @@ class NodeMountService { } /** - * 触发模式切换事件 + * 检查是否正在进行模式切换 + * @returns {boolean} + */ + isModeSwitching() { + return this._modeSwitching; + } + + /** + * 触发模式切换事件(带锁保护) * @param {string} newMode - 新模式 * @param {string} oldMode - 旧模式 */ _triggerModeChange(newMode, oldMode) { + // 如果已在切换中,记录待处理请求并返回 + if (this._modeSwitching) { + this._pendingModeChange = { newMode, oldMode }; + logger.debug('[NodeMountService] 模式切换已加锁,请求入队列'); + return; + } + + this._modeSwitching = true; logger.log(`[NodeMountService] 渲染模式切换 | ${oldMode} -> ${newMode}`); // 清除缓存 this._modeCache = null; - // 执行所有回调 - this._modeChangeCallbacks.forEach(callback => { + // 执行所有回调,等待异步回调完成 + Promise.all(this._modeChangeCallbacks.map(async callback => { try { - callback(newMode, oldMode); + await callback(newMode, oldMode); } catch (e) { logger.error(`[NodeMountService] 模式切换回调执行失败: ${e.message}`); } + })).finally(() => { + this._modeSwitching = false; + // 处理待处理的请求 + if (this._pendingModeChange) { + const pending = this._pendingModeChange; + this._pendingModeChange = null; + this._triggerModeChange(pending.newMode, pending.oldMode); + } }); } /** * 设置模式变更监听器 + * 使用 ComfyUI 官方事件监听渲染模式切换 */ _setupModeWatcher() { - // 监听 LiteGraph.vueNodesMode 变化(通过定期检测) + // 记录状态用于比对 let lastMode = this.currentMode; + // 统一的状态变更检查函数 const checkModeChange = () => { const currentMode = this.detectRenderMode(true); if (currentMode !== lastMode) { @@ -233,32 +255,29 @@ class NodeMountService { } }; - // 设置定期检测(每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; - }; + // 监听 ComfyUI 官方 CustomEvent + const eventName = 'Comfy.VueNodes.Enabled.change'; + const handleEvent = () => { + // 延迟 50ms 确保 LiteGraph.vueNodesMode 已完成全局同步 + setTimeout(checkModeChange, 50); + }; - this._cleanupFunctions.push(() => { - app.ui.settings.setSettingValue = originalSettingValue; - }); - } + app.ui.settings.addEventListener(eventName, handleEvent); + this._cleanupFunctions.push(() => { + app.ui.settings.removeEventListener(eventName, handleEvent); + }); + + logger.debug('[NodeMountService] 渲染模式监听器已就绪 (事件监听模式)'); + } else { + // 兜底策略: 如果 app.ui.settings 尚未就绪,保留低频轮询 + const intervalId = setInterval(checkModeChange, 2000); + this._cleanupFunctions.push(() => clearInterval(intervalId)); + logger.debug('[NodeMountService] app.ui.settings 未就绪,启动低频轮询兜底 (2s)'); } } catch (e) { - logger.debug(`[NodeMountService] 设置监听器设置失败: ${e.message}`); + logger.debug(`[NodeMountService] 模式监听器设置失败: ${e.message}`); } } @@ -318,7 +337,7 @@ class NodeMountService { // 查找带有 data-node-id 的 Vue 节点容器 const nodeContainer = document.querySelector(`[data-node-id="${node.id}"]`); if (!nodeContainer) { - logger.debug(`[NodeMountService] Vue模式: 未找到节点容器 | ID: ${node.id}`); + return null; } @@ -326,16 +345,20 @@ class NodeMountService { const widgetName = widget.name || widget.id; let textarea = null; - // --- 策略1: 优先使用 widget.inputEl (如果已绑定) --- + // 识别节点类型 + const isSubgraph = this._isSubgraphNode(node); + const isMarkdown = this._isMarkdownNode(node); + + // --- 策略1: 优先使用 widget.inputEl (如果已绑定且为 PrimeVue 组件) --- 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 等) + // --- 策略2: 计算索引位置匹配 (子图节点多输入框场景必用) --- + // 适用于多个同类型输入框的情况 (如 Subgraph, CLIPTextEncodeSDXL 等) if (!textarea && node.widgets) { // 1. 计算当前 widget 在所有 Textarea 类 widget 中的索引 let targetIndex = -1; @@ -352,16 +375,18 @@ class NodeMountService { } if (targetIndex !== -1) { - // 2. 获取 DOM 中所有的 textarea - // 注意:querySelectorAll 返回的顺序通常遵循文档流顺序,这与 Vue 渲染顺序一致 - const textareas = Array.from(nodeContainer.querySelectorAll('textarea')); + // 2. 获取 DOM 中所有的 PrimeVue textarea(优先)或普通 textarea + const primeTextareas = Array.from(nodeContainer.querySelectorAll('textarea.p-textarea')); + const textareas = primeTextareas.length > 0 + ? primeTextareas + : Array.from(nodeContainer.querySelectorAll('textarea')); // 3. 按索引匹配 if (targetIndex < textareas.length) { textarea = textareas[targetIndex]; - logger.debug(`[NodeMountService] Vue模式: 索引匹配成功 [${targetIndex}] | Widget: ${widgetName}`); + logger.debugSample(() => `[NodeMountService] Vue模式: 索引匹配成功 [${targetIndex}] | Widget: ${widgetName} | 子图: ${isSubgraph}`); } else { - logger.debug(`[NodeMountService] Vue模式: 索引匹配越界 [${targetIndex}/${textareas.length}] | Widget: ${widgetName}`); + } } } @@ -397,12 +422,12 @@ class NodeMountService { 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 { @@ -426,7 +451,9 @@ class NodeMountService { textarea: textarea, nodeContainer: nodeContainer, mode: RENDER_MODE.VUE_NODES, - widgetName: widgetName + widgetName: widgetName, + isSubgraph: isSubgraph, // 标记是否为子图节点 + isNoteNode: isMarkdown // 标记是否为 Markdown 类节点 }; } catch (e) { logger.error(`[NodeMountService] Vue容器查找失败: ${e.message}`); @@ -583,7 +610,7 @@ class NodeMountService { // 触发回流确保样式生效 void assistantElement.offsetWidth; - logger.debug(`[NodeMountService] 挂载成功 | 模式: ${mode}`); + return true; } catch (e) { @@ -652,58 +679,76 @@ class NodeMountService { * @returns {Promise} 容器信息 */ async findMountContainerWithRetry(node, widget, options = {}) { - // 默认快速响应,但给予足够的总等待时间给Vue渲染 - const { timeout = 1000 } = options; + // 【优化】根据测试验证,Vue nodes 2.0 中 textarea 在节点容器添加时就已存在 + // 因此大多数情况下不需要复杂的等待逻辑 + const { timeout = 500 } = options; - // 尝试立即查找 + // 尝试立即查找(大多数情况下应该成功) const immediateResult = this.findMountContainer(node, widget); if (immediateResult && immediateResult.textarea) { return immediateResult; } - // 如果是 Note 节点且找到了容器但没找到 textarea,也视为一种中间状态 + // 如果是 Markdown/Note 节点且找到了容器但没找到 textarea if (immediateResult && immediateResult.isNoteNode) { - // 继续往下,试图等到 textarea 出现 + // 继续往下,等待 textarea 出现 } const mode = this.detectRenderMode(); - // 只有 Vue 模式下才需要使用 Observer 等待 DOM 渲染 + // Vue 模式下:简化的等待策略 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}`); + // 使用 Observer 短暂等待 textarea 出现 + 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; } } + } else if (mode === RENDER_MODE.VUE_NODES) { + // 【关键】nodeContainer 还不存在,需要等待节点容器渲染 + // 监听画布容器,等待 nodeContainer 出现 + const graphCanvas = document.querySelector('.graph-canvas-container') || + document.querySelector('[class*="graph"]') || + document.body; + + + + // 使用 Observer 等待 nodeContainer 出现 + const waitResult = await this.waitForElement(graphCanvas, () => { + const container = document.querySelector(`[data-node-id="${node.id}"]`); + if (container) { + // 找到节点容器后,再查找 textarea + const result = this.findMountContainer(node, widget); + return (result && result.textarea) ? result : null; + } + return null; + }, timeout); + + if (waitResult) { + + return this.findMountContainer(node, widget); + } } - // 降级策略/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; - } + // 降级策略:快速重试一次(仅用于 LiteGraph 模式或 Observer 失效情况) + await new Promise(r => setTimeout(r, 100)); + const retryResult = this.findMountContainer(node, widget); + if (retryResult && retryResult.textarea) return retryResult; - logger.warn(() => `[NodeMountService] 容器查找最终失败 | 节点ID: ${node?.id}`); - return null; // 或者返回仅包含 container 的结果(视情况而定) + logger.debugSample(() => `[NodeMountService] 容器查找未就绪 | 节点ID: ${node?.id}`); + return null; } } diff --git a/js/services/api.js b/js/services/api.js index f5649de..3bde72f 100644 --- a/js/services/api.js +++ b/js/services/api.js @@ -155,6 +155,95 @@ class APIService { } } + /** + * 并行分块批量翻译(优化版本) + * 将文本数组分块后并行发起多个翻译请求,显著提升翻译速度 + * + * @param {string[]} texts - 待翻译的文本数组 + * @param {string} from - 源语言 + * @param {string} to - 目标语言 + * @param {Object} options - 配置选项 + * @param {number} options.chunkSize - 每块包含的文本数量(默认5) + * @param {number} options.concurrency - 最大并发数(默认3) + * @param {Function} options.onProgress - 进度回调 (completedChunks, totalChunks) + * @returns {Promise<{success: boolean, data?: {translations: string[]}, error?: string}>} + */ + static async llmParallelBatchTranslate(texts, from = 'auto', to = 'zh', options = {}) { + const { chunkSize = 5, concurrency = 3, onProgress = null } = options; + + try { + if (!Array.isArray(texts) || texts.length === 0) { + throw new Error('待翻译文本数组不能为空'); + } + + // 1. 分块 + const chunks = []; + for (let i = 0; i < texts.length; i += chunkSize) { + chunks.push({ + startIndex: i, + texts: texts.slice(i, i + chunkSize) + }); + } + + logger.log(`[APIService] 并行分块翻译 | 总文本数:${texts.length} | 分块数:${chunks.length} | 每块:${chunkSize} | 并发:${concurrency}`); + + // 2. 创建结果数组 + const allTranslations = new Array(texts.length).fill(''); + let completedChunks = 0; + let hasError = false; + let lastError = null; + + // 3. 并发控制函数 + const translateChunk = async (chunk) => { + try { + const result = await this.llmBatchTranslate(chunk.texts, from, to); + + if (result.success && result.data && result.data.translations) { + // 将翻译结果填充到对应位置 + result.data.translations.forEach((translation, idx) => { + allTranslations[chunk.startIndex + idx] = translation || ''; + }); + } else { + // 单块失败,记录但不中断 + hasError = true; + lastError = result.error || '翻译失败'; + logger.warn(`[APIService] 分块翻译失败 | 起始索引:${chunk.startIndex} | 错误:${lastError}`); + } + } catch (err) { + hasError = true; + lastError = err.message; + logger.error(`[APIService] 分块翻译异常 | 起始索引:${chunk.startIndex} | 错误:${err.message}`); + } finally { + completedChunks++; + if (onProgress) { + onProgress(completedChunks, chunks.length); + } + } + }; + + // 4. 分批并发执行(控制最大并发数) + for (let i = 0; i < chunks.length; i += concurrency) { + const batch = chunks.slice(i, i + concurrency); + await Promise.all(batch.map(chunk => translateChunk(chunk))); + } + + // 5. 检查结果 + const successCount = allTranslations.filter(t => t && t.trim()).length; + logger.log(`[APIService] 并行翻译完成 | 成功:${successCount}/${texts.length}`); + + // 即使部分失败也返回成功(有翻译结果) + if (successCount === 0 && texts.length > 0) { + return { success: false, error: lastError || '所有文本翻译失败' }; + } + + return { success: true, data: { translations: allTranslations } }; + + } catch (error) { + logger.error(`[APIService] 并行批量翻译失败: ${error.message}`); + return { success: false, error: error.message }; + } + } + /** * 从字符串中提取并解析首个JSON数组 */ @@ -491,6 +580,289 @@ class APIService { } } + // ---流式输出API方法(SSE)--- + + /** + * 流式视觉分析图像 + * 使用 SSE 逐 token 接收分析结果 + * @param {string} imageData - Base64 编码的图像数据 + * @param {string} prompt - 分析提示词 + * @param {string} request_id - 请求ID + * @param {Function} onChunk - 接收每个 chunk 的回调函数 + * @returns {Promise} - 完整的分析结果 + */ + static async llmAnalyzeImageStream(imageData, prompt, request_id = null, onChunk = null) { + if (!request_id) { + request_id = this.generateRequestId('icap'); + } + + const controller = new AbortController(); + const signal = controller.signal; + runningRequests.set(request_id, controller); + + try { + if (!imageData) { + throw new Error('未找到有效的图像'); + } + + logger.debug(`发起流式视觉分析请求 | 请求ID:${request_id}`); + + const apiUrl = this.getApiUrl('vlm/analyze/stream'); + const requestData = { + image: imageData, + prompt: prompt, + request_id: request_id + }; + + const response = await fetch(apiUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(requestData), + signal + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + let finalResult = null; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; + + for (const line of lines) { + if (line.startsWith('data: ')) { + try { + const data = JSON.parse(line.slice(6)); + if (data.chunk && onChunk) { + onChunk(data.chunk); + } + if (data.done) { + finalResult = data.result; + } + if (data.error) { + throw new Error(data.error); + } + } catch (parseError) { + if (parseError.message !== 'Unexpected end of JSON input') { + logger.warn(`解析 SSE 数据失败: ${parseError.message}`); + } + } + } + } + } + + logger.debug(`流式视觉分析请求完成 | 请求ID:${request_id}`); + return finalResult; + + } catch (error) { + if (error.name === 'AbortError') { + logger.debug(`流式视觉分析请求被用户中止 | ID: ${request_id}`); + return { success: false, error: '请求已取消', cancelled: true }; + } + logger.error(`流式视觉分析请求失败 | 请求ID:${request_id || 'unknown'} | 错误:${error.message}`); + return { success: false, error: error.message || '请求失败' }; + } finally { + if (runningRequests.has(request_id)) { + runningRequests.delete(request_id); + } + } + } + + /** + * 流式LLM扩写提示词 + * 使用 SSE 逐 token 接收扩写结果 + * @param {string} prompt - 要扩写的提示词 + * @param {string} request_id - 请求ID + * @param {Function} onChunk - 接收每个 chunk 的回调函数 + * @returns {Promise} - 完整的扩写结果 + */ + static async llmExpandPromptStream(prompt, request_id = null, onChunk = null) { + if (!request_id) { + request_id = this.generateRequestId('exp'); + } + + const controller = new AbortController(); + const signal = controller.signal; + runningRequests.set(request_id, controller); + + try { + if (!prompt || prompt.trim() === '') { + throw new Error('请输入要优化的提示词'); + } + + logger.debug(`发起流式LLM提示词优化请求 | 请求ID:${request_id}`); + + const apiUrl = this.getApiUrl('llm/expand/stream'); + + const response = await fetch(apiUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ prompt, request_id }), + signal + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + let finalResult = null; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; + + for (const line of lines) { + if (line.startsWith('data: ')) { + try { + const data = JSON.parse(line.slice(6)); + if (data.chunk && onChunk) { + onChunk(data.chunk); + } + if (data.done) { + finalResult = data.result; + } + if (data.error) { + throw new Error(data.error); + } + } catch (parseError) { + if (parseError.message !== 'Unexpected end of JSON input') { + logger.warn(`解析 SSE 数据失败: ${parseError.message}`); + } + } + } + } + } + + logger.debug(`流式LLM提示词优化请求完成 | 请求ID:${request_id}`); + return finalResult; + + } catch (error) { + if (error.name === 'AbortError') { + logger.debug(`流式LLM提示词优化请求被用户中止 | ID: ${request_id}`); + return { success: false, error: '请求已取消', cancelled: true }; + } + logger.error(`流式LLM提示词优化请求失败 | 请求ID:${request_id || 'unknown'} | 错误:${error.message}`); + return { success: false, error: error.message }; + } finally { + if (runningRequests.has(request_id)) { + runningRequests.delete(request_id); + } + } + } + + /** + * 流式LLM翻译 + * 使用 SSE 逐 token 接收翻译结果 + * 注意:仅支持LLM翻译服务,百度翻译不支持流式 + * @param {string} text - 要翻译的文本 + * @param {string} fromLang - 源语言 + * @param {string} toLang - 目标语言 + * @param {string} request_id - 请求ID + * @param {Function} onChunk - 接收每个 chunk 的回调函数 + * @returns {Promise} - 完整的翻译结果 + */ + static async llmTranslateStream(text, fromLang, toLang, request_id = null, onChunk = null) { + if (!request_id) { + request_id = this.generateRequestId('trans'); + } + + const controller = new AbortController(); + const signal = controller.signal; + runningRequests.set(request_id, controller); + + try { + if (!text || text.trim() === '') { + throw new Error('请输入要翻译的内容'); + } + + logger.debug(`发起流式LLM翻译请求 | 请求ID:${request_id} | ${fromLang}→${toLang}`); + + const apiUrl = this.getApiUrl('llm/translate/stream'); + + const response = await fetch(apiUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + text, + from: fromLang, + to: toLang, + request_id + }), + signal + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + let finalResult = null; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; + + for (const line of lines) { + if (line.startsWith('data: ')) { + try { + const data = JSON.parse(line.slice(6)); + if (data.chunk && onChunk) { + onChunk(data.chunk); + } + if (data.done) { + finalResult = data.result; + } + if (data.error) { + throw new Error(data.error); + } + } catch (parseError) { + if (parseError.message !== 'Unexpected end of JSON input') { + logger.warn(`解析 SSE 数据失败: ${parseError.message}`); + } + } + } + } + } + + logger.debug(`流式LLM翻译请求完成 | 请求ID:${request_id}`); + return finalResult; + + } catch (error) { + if (error.name === 'AbortError') { + logger.debug(`流式LLM翻译请求被用户中止 | ID: ${request_id}`); + return { success: false, error: '请求已取消', cancelled: true }; + } + logger.error(`流式LLM翻译请求失败 | 请求ID:${request_id || 'unknown'} | 错误:${error.message}`); + return { success: false, error: error.message }; + } finally { + if (runningRequests.has(request_id)) { + runningRequests.delete(request_id); + } + } + } + /** * 将图像转换为Base64 */ diff --git a/js/services/btnMenu.js b/js/services/btnMenu.js index e517b8c..a871952 100644 --- a/js/services/btnMenu.js +++ b/js/services/btnMenu.js @@ -397,7 +397,7 @@ export class ButtonMenu { // 【关键】标记正在切换,防止容器折叠 PopupManager._isTransitioning = true; - console.log(`[ButtonMenu] showMenu | 设置 _isTransitioning = true`); + await PopupManager.closeAllPopups(); @@ -432,7 +432,7 @@ export class ButtonMenu { // 【关键】清除切换标记,因为菜单已经显示 PopupManager._isTransitioning = false; - console.log(`[ButtonMenu] showMenu | 设置 _isTransitioning = false`); + // 阻止默认右键菜单 if (event) { @@ -569,7 +569,7 @@ export class ButtonMenu { // 重置中央按钮状态 UIToolkit.setActiveButton(null); - logger.debug('右键菜单 | 重置活动按钮'); + // logger.debug('右键菜单 | 重置活动按钮'); // 触发小助手可见性更新以允许其自动折叠 if (widget) { diff --git a/js/services/cache.js b/js/services/cache.js index 935803a..115abd9 100644 --- a/js/services/cache.js +++ b/js/services/cache.js @@ -82,7 +82,7 @@ class CacheService { } } keysToRemove.forEach(key => this.remove(key)); - logger.debug(`缓存服务 | 清除前缀缓存 | 前缀:${prefix} | 数量:${keysToRemove.length}`); + // logger.debug(`缓存服务 | 清除前缀缓存 | 前缀:${prefix} | 数量:${keysToRemove.length}`); } catch (error) { logger.error(`缓存服务 | 清除前缀缓存失败 | 前缀:${prefix} | 错误:${error.message}`); } @@ -316,7 +316,7 @@ class HistoryCacheService { } this.saveAllHistory(allHistory); - logger.debug(`历史缓存 | 添加历史 | 节点:${historyItem.node_id} | 内容长度:${content.length}`); + // logger.debug(`历史缓存 | 添加历史 | 节点:${historyItem.node_id} | 内容长度:${content.length}`); return true; } catch (error) { logger.error(`历史缓存 | 添加历史失败 | 错误:${error.message}`); @@ -344,7 +344,7 @@ class HistoryCacheService { // 更新撤销状态 this.undoStates.set(key, undoState); - logger.debug(`历史缓存 | 初始化撤销状态 | 节点:${nodeId} | 输入框:${inputId} | 历史数:${inputHistory.length} | 当前位置:${undoState.currentIndex}`); + // logger.debug(`历史缓存 | 初始化撤销状态 | 节点:${nodeId} | 输入框:${inputId} | 历史数:${inputHistory.length} | 当前位置:${undoState.currentIndex}`); } catch (error) { logger.error(`历史缓存 | 初始化撤销状态失败 | 节点:${nodeId} | 输入框:${inputId} | 错误:${error.message}`); } @@ -384,7 +384,7 @@ class HistoryCacheService { // 更新状态 this.undoStates.set(key, state); - logger.debug(`历史缓存 | 撤销操作 | 节点:${nodeId} | 输入框:${inputId} | 位置:${state.currentIndex}/${history.length - 1} | 内容:${content}`); + // logger.debug(`历史缓存 | 撤销操作 | 节点:${nodeId} | 输入框:${inputId} | 位置:${state.currentIndex}/${history.length - 1} | 内容:${content}`); return content; } catch (error) { logger.error(`历史缓存 | 撤销操作失败 | 节点:${nodeId} | 输入框:${inputId} | 错误:${error.message}`); @@ -474,7 +474,7 @@ class HistoryCacheService { this.saveAllHistory([]); // 清除所有撤销状态 this.undoStates.clear(); - logger.debug("历史缓存 | 清除所有历史"); + // logger.debug("历史缓存 | 清除所有历史"); } catch (error) { logger.error(`历史缓存 | 清除所有历史失败 | 错误:${error.message}`); } @@ -501,7 +501,7 @@ class HistoryCacheService { } } - logger.debug(`历史缓存 | 清除节点历史 | 节点:${nodeId}`); + // logger.debug(`历史缓存 | 清除节点历史 | 节点:${nodeId}`); } catch (error) { logger.error(`历史缓存 | 清除节点历史失败 | 节点:${nodeId} | 错误:${error.message}`); } @@ -516,7 +516,7 @@ class HistoryCacheService { const allHistory = this.getAllHistory(); const filteredHistory = allHistory.filter(item => item.workflow_id !== workflowId); this.saveAllHistory(filteredHistory); - logger.debug(`历史缓存 | 清除工作流历史 | 工作流ID:${workflowId}`); + // logger.debug(`历史缓存 | 清除工作流历史 | 工作流ID:${workflowId}`); } catch (error) { logger.error(`历史缓存 | 清除工作流历史失败 | 工作流ID:${workflowId} | 错误:${error.message}`); } @@ -555,7 +555,7 @@ class HistoryCacheService { // 保存状态 this.undoStates.set(key, state); - logger.debug(`历史缓存 | 更新撤销状态 | 节点:${nodeId} | 输入框:${inputId} | 位置:${state.currentIndex}`); + // logger.debug(`历史缓存 | 更新撤销状态 | 节点:${nodeId} | 输入框:${inputId} | 位置:${state.currentIndex}`); } return success; @@ -580,7 +580,7 @@ class HistoryCacheService { stats.byNode[item.node_id]++; }); - logger.debug(`历史缓存 | 获取统计 | 总数:${stats.total} | 节点数:${Object.keys(stats.byNode).length}`); + // logger.debug(`历史缓存 | 获取统计 | 总数:${stats.total} | 节点数:${Object.keys(stats.byNode).length}`); return stats; } catch (error) { logger.error(`历史缓存 | 获取统计失败 | 错误:${error.message}`); @@ -610,10 +610,10 @@ class HistoryCacheService { // 保存修改后的历史记录 this.saveAllHistory(allHistory); - logger.debug(`历史缓存 | 修改操作类型 | 节点:${nodeId} | 输入框:${inputId} | 类型:${newType}`); + // logger.debug(`历史缓存 | 修改操作类型 | 节点:${nodeId} | 输入框:${inputId} | 类型:${newType}`); return true; } else { - logger.debug(`历史缓存 | 修改操作类型失败 | 未找到匹配记录 | 节点:${nodeId} | 输入框:${inputId}`); + // logger.debug(`历史缓存 | 修改操作类型失败 | 未找到匹配记录 | 节点:${nodeId} | 输入框:${inputId}`); return false; } } catch (error) { @@ -879,7 +879,7 @@ class TagCacheService { for (const formatKey of checkOrder) { const format = formats[formatKey]; if (format && inputValue.includes(format)) { - logger.debug(`标签检查 | 结果:已存在 | 节点:${nodeId} | 输入框:${inputId} | 标签:${rawTag} | 匹配格式:${formatKey}`); + // logger.debug(`标签检查 | 结果:已存在 | 节点:${nodeId} | 输入框:${inputId} | 标签:${rawTag} | 匹配格式:${formatKey}`); return true; } } @@ -945,7 +945,7 @@ class TagCacheService { this.saveAllTagCache(allCache); } - logger.debug(`标签缓存 | 清除缓存 | 节点:${nodeId} | 输入框:${inputId}`); + // logger.debug(`标签缓存 | 清除缓存 | 节点:${nodeId} | 输入框:${inputId}`); } catch (error) { logger.error(`标签缓存 | 清除缓存失败 | 节点:${nodeId} | 输入框:${inputId} | 错误:${error.message}`); } @@ -973,7 +973,7 @@ class TagCacheService { } const stats = { total, byNode }; - logger.debug(`标签缓存 | 获取统计 | 总数:${total} | 节点数:${Object.keys(byNode).length}`); + // logger.debug(`标签缓存 | 获取统计 | 总数:${total} | 节点数:${Object.keys(byNode).length}`); return stats; } catch (error) { logger.error(`标签缓存 | 获取统计失败 | 错误:${error.message}`); @@ -1154,7 +1154,7 @@ class TranslateCacheService { // 检查是否为原文(key) if (cache.has(text)) { const translatedText = cache.get(text); - logger.debug(`翻译缓存 | 查询结果:命中原文 | 原文长度:${text.length} | 译文长度:${translatedText.length}`); + // logger.debug(`翻译缓存 | 查询结果:命中原文 | 原文长度:${text.length} | 译文长度:${translatedText.length}`); return { type: 'source', text: text, diff --git a/js/services/features.js b/js/services/features.js index 185257d..ea347cd 100644 --- a/js/services/features.js +++ b/js/services/features.js @@ -67,8 +67,15 @@ export const FEATURES = { translateFormatDots: false, // 处理连续点号 translateFormatNewline: false, // 保留换行符 + // 混合语言翻译缓存 + cacheMixedLangTranslation: false, // 是否缓存混合语言翻译结果 + + // 混合语言翻译规则 + mixedLangTranslateRule: 'auto_minor', // 自动翻译小比例语言 + // 系统设置 showStreamingProgress: true, // 显示流式输出进度(终端日志) + enableStreaming: true, // 启用前端流式输出效果 /** * 从配置加载功能开关状态 @@ -99,10 +106,20 @@ export const FEATURES = { loadBool('translateFormatPunctuation', "PromptAssistant.Features.TranslateFormatPunctuation"); loadBool('translateFormatSpace', "PromptAssistant.Features.TranslateFormatSpace"); loadBool('translateFormatDots', "PromptAssistant.Features.TranslateFormatDots"); + + // 加载混合语言缓存选项 + loadBool('cacheMixedLangTranslation', "PromptAssistant.Features.CacheMixedLangTranslation"); loadBool('translateFormatNewline', "PromptAssistant.Features.TranslateFormatNewline"); + // 加载混合语言翻译规则 + const mixedLangRule = app.ui.settings.getSettingValue("PromptAssistant.Features.MixedLangTranslateRule"); + if (mixedLangRule) { + this.mixedLangTranslateRule = mixedLangRule; + } + // 加载系统设置 loadBool('showStreamingProgress', "PromptAssistant.Settings.ShowStreamingProgress"); + loadBool('enableStreaming', "PromptAssistant.Settings.EnableStreaming"); // 加载日志级别 const logLevel = app.ui.settings.getSettingValue("PromptAssistant.Settings.LogLevel"); @@ -214,6 +231,9 @@ export function handleFeatureChange(featureName, value, oldValue) { // 重新计算并更新所有实例的宽度 promptAssistant.updateAllInstancesWidth(); + if (imageCaption && imageCaption.updateAllInstancesWidth) { + imageCaption.updateAllInstancesWidth(); + } } // 如果是图像反推功能被启用 @@ -267,8 +287,11 @@ export function handleFeatureChange(featureName, value, oldValue) { } // 功能开关变化时,更新所有实例的宽度 - if (PromptAssistant.instances.size > 0) { + if (PromptAssistant.instances.size > 0 || (ImageCaption && ImageCaption.instances.size > 0)) { promptAssistant.updateAllInstancesWidth(); + if (imageCaption && imageCaption.updateAllInstancesWidth) { + imageCaption.updateAllInstancesWidth(); + } } } } \ No newline at end of file diff --git a/js/utils/UIToolkit.js b/js/utils/UIToolkit.js index ac5afcb..26f0291 100644 --- a/js/utils/UIToolkit.js +++ b/js/utils/UIToolkit.js @@ -63,6 +63,16 @@ class UIToolkit { reason = 'Vue node2.0 mode widget'; } + // ---可见性检测补救--- + // 如果基本检测通过,但元素本身是隐藏的,则视为无效 + if (isValid) { + const element = widget.inputEl || widget.element; + if (element && !this.isElementVisible(element)) { + isValid = false; + reason += ' (but element is hidden)'; + } + } + if (debug) { const widgetName = widget.name || widget.id || 'unknown'; const nodeType = node?.type || widget.node?.type || 'unknown'; @@ -416,7 +426,7 @@ class UIToolkit { const oldInfo = this.#activeButtonInfo; // 重置旧按钮状态 this.setButtonState(oldInfo.widget, oldInfo.buttonId, 'active', false); - logger.debug(`按钮状态重置 | 按钮:${oldInfo.buttonId} | 节点:${oldInfo.widget.nodeId}`); + // logger.debug(`按钮状态重置 | 按钮:${oldInfo.buttonId} | 节点:${oldInfo.widget.nodeId}`); } // 设置新的激活按钮 @@ -425,7 +435,7 @@ class UIToolkit { // 如果有新的按钮信息,设置其状态为激活 if (buttonInfo) { this.setButtonState(buttonInfo.widget, buttonInfo.buttonId, 'active', true); - logger.debug(`按钮激活 | 按钮:${buttonInfo.buttonId} | 节点:${buttonInfo.widget.nodeId}`); + // logger.debug(`按钮激活 | 按钮:${buttonInfo.buttonId} | 节点:${buttonInfo.widget.nodeId}`); } } @@ -445,7 +455,7 @@ class UIToolkit { e.preventDefault(); e.stopPropagation(); - logger.debug(`弹窗按钮点击 | 按钮: ${buttonId} | 节点: ${widget.nodeId}`); + // logger.debug(`弹窗按钮点击 | 按钮: ${buttonId} | 节点: ${widget.nodeId}`); // 检查当前按钮状态 const isCurrentActive = this.isActiveButton(widget, buttonId); @@ -626,7 +636,7 @@ class UIToolkit { // 设置按钮为处理中状态 this.setButtonState(widget, buttonId, 'processing', true); - logger.debug(`按钮操作 | 动作:开始 | 按钮:${buttonId}`); + // logger.debug(`按钮操作 | 动作:开始 | 按钮:${buttonId}`); // 执行操作(接收一个回调函数用于处理操作完成后的逻辑) try { @@ -637,7 +647,7 @@ class UIToolkit { // 如果操作成功且有结果,将结果写入历史记录 if (success && widget.inputEl && widget.inputEl.value) { // 添加到历史 - logger.debug(`准备写入历史| ${buttonId}操作完成| node_id=${widget.nodeId}`); + // logger.debug(`准备写入历史| ${buttonId}操作完成| node_id=${widget.nodeId}`); LocalHistoryService.addHistory({ workflow_id: '', node_id: widget.nodeId, @@ -656,7 +666,7 @@ class UIToolkit { null ); } else { - logger.debug(`按钮操作完成 | 结果:${success ? '成功' : '失败'} | 按钮:${buttonId}`); + // logger.debug(`按钮操作完成 | 结果:${success ? '成功' : '失败'} | 按钮:${buttonId}`); this.showStatusTip( e.currentTarget, 'success', @@ -667,7 +677,7 @@ class UIToolkit { }; // 执行操作并传入回调 - logger.debug(`历史缓存 | 按钮操作准备执行 node_id=${widget.nodeId} input_id=${widget.inputId} type=${buttonId}`); + // logger.debug(`历史缓存 | 按钮操作准备执行 node_id=${widget.nodeId} input_id=${widget.inputId} type=${buttonId}`); operation(callback); } catch (error) { // 出现异常时重置按钮状态 @@ -842,19 +852,40 @@ class UIToolkit { * 为输入框添加高亮动画效果 */ static _highlightInput(inputEl) { - // 移除可能存在的旧动画类 - inputEl.classList.remove('input-highlight'); + if (!inputEl) return; - // 强制重绘 + // 移除旧的高亮状态 + this.removeHighlight(inputEl); + + // 强制重绘,确保动画可以重新开始 void inputEl.offsetWidth; // 添加动画类 inputEl.classList.add('input-highlight'); // 动画结束后移除类 - setTimeout(() => { - inputEl.classList.remove('input-highlight'); - }, 800); // 与CSS中的动画时长匹配 + // 使用 200ms 匹配 CSS 中的动画时长 (common.css: L181) + inputEl._highlightTimer = setTimeout(() => { + this.removeHighlight(inputEl); + }, 200); + } + + /** + * 显式移除元素的高亮状态并清理定时器 + * 用于在布局重排(如滚动条出现)前清理渲染负担 + * @param {HTMLElement} inputEl 目标元素 + */ + static removeHighlight(inputEl) { + if (!inputEl) return; + + // 移除动画类 + inputEl.classList.remove('input-highlight'); + + // 清理定时器 + if (inputEl._highlightTimer) { + clearTimeout(inputEl._highlightTimer); + inputEl._highlightTimer = null; + } } /** diff --git a/js/utils/popupManager.js b/js/utils/popupManager.js index 76c69f5..84aa9fe 100644 --- a/js/utils/popupManager.js +++ b/js/utils/popupManager.js @@ -36,15 +36,8 @@ 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; @@ -76,7 +69,6 @@ class PopupManager { this._showNewPopup(options); // 清除切换标记 this._isTransitioning = false; - console.log(`[PopupManager] 设置 _isTransitioning = false`); resolve(); }, 200); }); @@ -95,7 +87,6 @@ class PopupManager { // 在所有其他窗口关闭后,设置活动按钮状态 if (buttonInfo) { UIToolkit.setActiveButton(buttonInfo); - logger.debug(`弹窗管理器 | 设置活动按钮 | 按钮ID: ${buttonInfo.buttonId}`); } // 保存当前弹窗信息 @@ -177,7 +168,7 @@ class PopupManager { mouseup: null }; - logger.debug('弹窗管理 | 动作:清理所有事件监听器'); + // logger.debug('弹窗管理 | 动作:清理所有事件监听器'); } /** @@ -460,7 +451,7 @@ class PopupManager { }; } - logger.debug('窗口大小调节功能已启用'); + // logger.debug('窗口大小调节功能已启用'); } /** @@ -494,12 +485,10 @@ class PopupManager { popup.style.top = `${buttonRect.bottom}px`; popup.classList.remove('popup-up'); popup.classList.add('popup-down'); - logger.debug('弹窗 | 位置:下方显示'); } else { popup.style.top = `${buttonRect.top - popupRect.height}px`; popup.classList.remove('popup-down'); popup.classList.add('popup-up'); - logger.debug('弹窗 | 位置:上方显示'); } // 计算水平位置 @@ -512,13 +501,10 @@ class PopupManager { // 下方显示时,依然居中 if (canCenterHorizontally) { popup.style.left = `${centeredLeftEdge}px`; - logger.debug('弹窗 | 对齐:居中对齐'); } else if (centeredLeftEdge < 0) { popup.style.left = '8px'; - logger.debug('弹窗 | 对齐:左侧对齐(左侧空间不足)'); } else { popup.style.left = `${viewportWidth - popupRect.width - 8}px`; - logger.debug('弹窗 | 对齐:右侧对齐(右侧空间不足)'); } } else { // 上方显示时,左对齐(弹窗左侧与按钮左侧对齐),但保证不超出右侧和左侧 @@ -530,7 +516,6 @@ class PopupManager { left = 8; } popup.style.left = `${left}px`; - logger.debug('弹窗 | 对齐:左对齐(上方显示,自适应)'); } } catch (error) { logger.error(`弹窗 | 位置计算失败 | 错误:${error.message}`); @@ -569,26 +554,31 @@ class PopupManager { // 检查是否为标签相关的输入框获得焦点 const preventCloseTypes = this.activePopupInfo?.preventCloseOnElementTypes || []; const isSpecialInput = activeElement && - (activeElement.tagName === 'INPUT' || activeElement.tagName === 'TEXTAREA') && preventCloseTypes.some(type => { return activeElement.classList.contains(type) || activeElement.closest(`.${type}`) !== null; }); // 检查是否为目标输入框 - const isTargetInput = activeElement && - activeElement.classList.contains('comfy-multiline-input'); + const isTargetInput = activeElement && ( + activeElement.classList.contains('comfy-multiline-input') || + activeElement.closest('.comfy-multiline-input') !== null || + activeElement.closest('.comfy-markdown') !== null || + activeElement.closest('.widget-markdown') !== null || + activeElement.closest('.tiptap') !== null || + activeElement.closest('.ProseMirror') !== null + ); // 检查是否是当前节点的输入框 - const isCurrentNodeInput = isTargetInput && this.activePopupInfo?.buttonInfo?.widget && + const isCurrentNodeInput = this.activePopupInfo?.buttonInfo?.widget && Object.values(window.PromptAssistantInputWidgetMap || {}).some(mapping => { - return mapping.inputEl === activeElement && + return (mapping.inputEl === activeElement || mapping.inputEl?.contains(activeElement)) && mapping.widget === this.activePopupInfo.buttonInfo.widget; }); // 只有当是特殊输入框或当前节点的输入框时才阻止关闭 - if (isSpecialInput || isCurrentNodeInput) { - logger.debug('弹窗 | 保持打开 | 原因:' + (isSpecialInput ? '特殊输入框聚焦' : '当前节点输入框聚焦')); + if (isSpecialInput || isCurrentNodeInput || isTargetInput) { + logger.debug('弹窗 | 保持打开 | 原因:' + (isSpecialInput ? '特殊输入框聚焦' : (isCurrentNodeInput ? '当前节点输入框聚焦' : '目标输入框聚焦'))); return; } @@ -636,18 +626,23 @@ class PopupManager { if (isContextMenu || isSettingsModal) return; // 检查是否点击的是目标输入框 - const isTargetInput = e.target.classList.contains('comfy-multiline-input'); + const isTargetInput = e.target.classList.contains('comfy-multiline-input') || + e.target.closest('.comfy-multiline-input') !== null || + e.target.closest('.comfy-markdown') !== null || + e.target.closest('.widget-markdown') !== null || + e.target.closest('.tiptap') !== null || + e.target.closest('.ProseMirror') !== null; // 检查是否是当前节点的输入框 - const isCurrentNodeInput = isTargetInput && this.activePopupInfo?.buttonInfo?.widget && + const isCurrentNodeInput = this.activePopupInfo?.buttonInfo?.widget && Object.values(window.PromptAssistantInputWidgetMap || {}).some(mapping => { - return mapping.inputEl === e.target && + return (mapping.inputEl === e.target || mapping.inputEl?.contains(e.target)) && mapping.widget === this.activePopupInfo.buttonInfo.widget; }); - // 如果点击在弹窗外且不是点击激活按钮,且不是阻止关闭的元素,且不是当前节点的输入框,则关闭弹窗 + // 如果点击在弹窗外且不是点击激活按钮,且不是阻止关闭的元素,且不是当前节点的输入框/目标输入框,则关闭弹窗 if (!isInPopup && !(isInButton && isInActiveButton) && - !isPreventCloseElement && !isCurrentNodeInput) { + !isPreventCloseElement && !isCurrentNodeInput && !isTargetInput) { if (typeof onClose === 'function') { onClose(); } @@ -663,16 +658,15 @@ class PopupManager { } const preventCloseTypes = this.activePopupInfo?.preventCloseOnElementTypes || []; - const isSpecialInput = (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') && - preventCloseTypes.some(type => { - return e.target.classList.contains(type) || - e.target.closest(`.${type}`) !== null; - }); + const isSpecialInput = preventCloseTypes.some(type => { + return e.target.classList.contains(type) || + e.target.closest(`.${type}`) !== null; + }); // 检查是否是当前节点的输入框 - const isCurrentNodeInput = isSpecialInput && this.activePopupInfo?.buttonInfo?.widget && + const isCurrentNodeInput = this.activePopupInfo?.buttonInfo?.widget && Object.values(window.PromptAssistantInputWidgetMap || {}).some(mapping => { - return mapping.inputEl === e.target && + return (mapping.inputEl === e.target || mapping.inputEl?.contains(e.target)) && mapping.widget === this.activePopupInfo.buttonInfo.widget; }); @@ -734,7 +728,7 @@ class PopupManager { } }; - logger.debug('弹窗管理 | 动作:设置关闭事件'); + // logger.debug('弹窗管理 | 动作:设置关闭事件'); } /** diff --git a/js/utils/promptFormatter.js b/js/utils/promptFormatter.js index 219ff12..7a384af 100644 --- a/js/utils/promptFormatter.js +++ b/js/utils/promptFormatter.js @@ -130,7 +130,7 @@ class PromptFormatter { try { // 判断是否为空输入框 if (!beforeText && !afterText) { - logger.debug('格式判断 | 结果:空输入框 | 使用格式:2'); + // logger.debug('格式判断 | 结果:空输入框 | 使用格式:2'); return 2; // 空输入框使用格式2(空格+标签+逗号) } @@ -143,7 +143,7 @@ class PromptFormatter { // 如果前方没有实际文本(只有空格),使用格式2 if (!hasTextBefore) { - logger.debug('格式判断 | 结果:前方无文本 | 使用格式:2'); + // logger.debug('格式判断 | 结果:前方无文本 | 使用格式:2'); return 2; // 使用格式2(空格+标签+逗号) } @@ -159,7 +159,7 @@ class PromptFormatter { formatType = 4; // 前后都无标点,使用格式4(逗号+空格+标签+逗号) } - logger.debug(`格式判断 | 前标点:${hasCommaBefore} | 后标点:${hasCommaAfter} | 前方文本:${hasTextBefore} | 使用格式:${formatType}`); + // logger.debug(`格式判断 | 前标点:${hasCommaBefore} | 后标点:${hasCommaAfter} | 前方文本:${hasTextBefore} | 使用格式:${formatType}`); return formatType; } catch (error) { @@ -208,7 +208,7 @@ class PromptFormatter { // 记录原始文本用于日志 const originalText = text; - + // 获取格式化选项(从全局 FEATURES 对象) const options = { punctuation: window.FEATURES?.translateFormatPunctuation ?? true, @@ -239,7 +239,7 @@ class PromptFormatter { if (options.space) enabledOptions.push('空格处理'); if (options.dots) enabledOptions.push('点号处理'); if (options.newline) enabledOptions.push('保留换行'); - + const logFormatted = formattedText.length > 100 ? formattedText.substring(0, 100) + '...' : formattedText; logger.debug(`文本格式化 | 选项:[${enabledOptions.join(', ')}] | 结果:"${logFormatted}"`); @@ -319,19 +319,55 @@ class PromptFormatter { 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 = '混合语言'; + // 获取混合语言翻译规则 + const rule = window.FEATURES?.mixedLangTranslateRule || 'auto_minor'; + + switch (rule) { + case 'to_zh': + // 固定翻译成中文 + from = 'en'; + to = 'zh'; + type = '混合语言→中文'; + break; + case 'to_en': + // 固定翻译成英文 + from = 'zh'; + to = 'en'; + type = '混合语言→英文'; + break; + case 'auto_major': + // 翻译大比例语言 + if (cnUnits > enUnits) { + from = 'zh'; + to = 'en'; + type = '混合语言-翻译中文'; + } else if (enUnits > cnUnits) { + from = 'en'; + to = 'zh'; + type = '混合语言-翻译英文'; + } else { + from = 'zh'; + to = 'en'; + type = '混合语言'; + } + break; + case 'auto_minor': + default: + // 翻译小比例语言(原逻辑) + if (cnUnits > enUnits) { + from = 'en'; + to = 'zh'; + type = '混合语言-翻译英文'; + } else if (enUnits > cnUnits) { + from = 'zh'; + to = 'en'; + type = '混合语言-翻译中文'; + } else { + from = 'zh'; + to = 'en'; + type = '混合语言'; + } + break; } } diff --git a/js/utils/resourceManager.js b/js/utils/resourceManager.js index f363cda..4ff9c72 100644 --- a/js/utils/resourceManager.js +++ b/js/utils/resourceManager.js @@ -421,12 +421,12 @@ class ResourceManager { 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 result.selection?.selected_file || '用户标签.csv'; } - return 'default.csv'; + return '用户标签.csv'; } catch (error) { logger.error(`获取标签选择失败 | ${error.message}`); - return 'default.csv'; + return '用户标签.csv'; } } diff --git a/locales/en/nodeDefs.json b/locales/en/nodeDefs.json index 3380192..6a92798 100644 --- a/locales/en/nodeDefs.json +++ b/locales/en/nodeDefs.json @@ -75,7 +75,11 @@ "outputs": { "0": { "name": "Prompt Output", - "tooltip": "Generated caption" + "tooltip": "Generated caption (combined for batch)" + }, + "1": { + "name": "Prompt List", + "tooltip": "List of captions for each image in batch" } } }, diff --git a/locales/zh-TW/nodeDefs.json b/locales/zh-TW/nodeDefs.json index e4020a0..e18e088 100644 --- a/locales/zh-TW/nodeDefs.json +++ b/locales/zh-TW/nodeDefs.json @@ -75,7 +75,11 @@ "outputs": { "0": { "name": "提示詞輸出", - "tooltip": "生成的圖像描述" + "tooltip": "生成的圖像描述(批量時合併輸出)" + }, + "1": { + "name": "提示詞列表", + "tooltip": "批量處理時每張圖像的獨立描述列表" } } }, diff --git a/locales/zh/nodeDefs.json b/locales/zh/nodeDefs.json index 49f6515..5fae025 100644 --- a/locales/zh/nodeDefs.json +++ b/locales/zh/nodeDefs.json @@ -75,7 +75,11 @@ "outputs": { "0": { "name": "提示词输出", - "tooltip": "生成的图像描述" + "tooltip": "生成的图像描述(批量时合并输出)" + }, + "1": { + "name": "提示词列表", + "tooltip": "批量处理时每张图像的独立描述列表" } } }, diff --git a/node/base/base_node.py b/node/base/base_node.py index d951ff6..2b93de7 100644 --- a/node/base/base_node.py +++ b/node/base/base_node.py @@ -27,6 +27,18 @@ class BaseNode: LOG_PREFIX = LOG_PREFIX REQUEST_PREFIX = REQUEST_PREFIX PROCESS_PREFIX = PROCESS_PREFIX + + @staticmethod + def _check_is_changed_bypass(*args, **kwargs): + """ + 检查输入中是否包含绕过 IS_CHANGED 限制的专用符号 [R] (不区分大小写) + 如果包含,返回 True。 + """ + # 遍历所有位置参数和关键字参数 + for v in list(args) + list(kwargs.values()): + if isinstance(v, str) and "[R]" in v.upper(): + return True + return False def _run_thread_with_interrupt( diff --git a/node/expand_node.py b/node/expand_node.py index c27939b..f28104f 100644 --- a/node/expand_node.py +++ b/node/expand_node.py @@ -94,6 +94,10 @@ class PromptExpand(LLMNodeBase): 只在输入内容真正变化时才触发重新执行 使用输入参数的哈希值作为判断依据 """ + # 检查是否包含强制刷新符号 [R] + if cls._check_is_changed_bypass(rule, custom_rule_content, user_prompt, source_text): + return float("nan") + 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() diff --git a/node/image_caption_node.py b/node/image_caption_node.py index 1ff87a3..59c261a 100644 --- a/node/image_caption_node.py +++ b/node/image_caption_node.py @@ -74,8 +74,9 @@ class ImageCaptionNode(VLMNodeBase): } } - RETURN_TYPES = ("STRING",) - RETURN_NAMES = ("prompt_output",) + RETURN_TYPES = ("STRING", "STRING") + RETURN_NAMES = ("prompt_output", "prompt_list") + OUTPUT_IS_LIST = (False, True) FUNCTION = "analyze_image" CATEGORY = "✨Prompt Assistant" OUTPUT_NODE = False @@ -86,6 +87,10 @@ class ImageCaptionNode(VLMNodeBase): 只在输入内容真正变化时才触发重新执行 使用输入参数的哈希值作为判断依据 """ + # 检查是否包含强制刷新符号 [R] + if cls._check_is_changed_bypass(rule, custom_rule_content, user_prompt): + return float("nan") + # 导入图像哈希工具函数 from ..utils.image import compute_image_hash @@ -105,54 +110,111 @@ class ImageCaptionNode(VLMNodeBase): return input_hash + def _analyze_single_image(self, image_data, prompt_template, rule_name, service_id, service, provider_config, unique_id, frame_index=None): + """ + 分析单张图像(内部方法) + + Args: + image_data: base64编码的图像数据 + prompt_template: 提示词模板 + rule_name: 规则名称 + service_id: 服务ID + service: 服务配置 + provider_config: Provider配置 + unique_id: 节点唯一ID + frame_index: 批次中的帧索引(用于日志标识) + + Returns: + str: 图像描述结果 + """ + # 创建请求ID(包含帧索引信息) + frame_suffix = f"_f{frame_index}" if frame_index is not None else "" + request_id = generate_request_id("icap", None, unique_id) + frame_suffix + + # 检查是否关闭思维链 + 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) + + # 准备阶段日志(包含帧信息) + frame_info = f" [帧 {frame_index + 1}]" if frame_index is not None else "" + log_prepare(TASK_IMAGE_CAPTION, request_id, SOURCE_NODE, service_display_name, model_display, rule_name + frame_info) + + # 执行图像分析 + 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密钥、模型配置或网络连接' + 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 '分析服务未返回结果' + if error_msg == "任务被中断": + raise InterruptProcessingException() + log_error(TASK_IMAGE_CAPTION, request_id, error_msg, source=SOURCE_NODE) + raise RuntimeError(f"分析失败: {error_msg}") + def analyze_image(self, image, rule, custom_rule, custom_rule_content, user_prompt, vlm_service, ollama_auto_unload, unique_id=None): """ - 分析图像并生成提示词 + 分析图像并生成提示词(支持 batch 遍历) Args: - 图像: 输入的图像数据 - 规则模板: 选择的提示词模板 - 临时规则: 是否启用临时规则 - 临时规则内容: 临时规则的内容 - 用户提示词: 用户补充的提示词 - 视觉服务: 选择的视觉服务 + image: 输入的图像数据,支持单张或 batch + rule: 选择的提示词模板 + custom_rule: 是否启用临时规则 + custom_rule_content: 临时规则的内容 + user_prompt: 用户补充的提示词 + vlm_service: 选择的视觉服务 + ollama_auto_unload: Ollama 自动卸载开关 + unique_id: 节点唯一ID Returns: - tuple: 分析结果 + tuple: 分析结果,batch 输入时结果用换行符分隔 """ + request_id = None # 初始化,用于异常处理 + try: # 检查输入 if image is None: raise ValueError("输入图像不能为空") - # 将图像转换为base64编码 - image_data = self._image_to_base64(image) - - # 获取提示词模板内容 + # ---准备提示词模板--- 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: - # 从config_manager获取系统提示词配置 from ..config_manager import config_manager system_prompts = config_manager.get_system_prompts() - # 获取vision_prompts vision_prompts = {} 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(): 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') @@ -160,14 +222,13 @@ class ImageCaptionNode(VLMNodeBase): break if not template_found: - # 允许用规则名称或键名直接匹配(兼容旧格式) + # 兼容旧格式 for key, value in vision_prompts.items(): if value.get('name') == rule or key == rule: prompt_template = value.get('content') template_found = True break - # 如果没有找到提示词模板,使用默认值 if not template_found or not prompt_template: prompt_template = "请详细描述这张图片的内容,包括主体、场景、风格、色彩等要素。" rule_name = "Default Rule" @@ -176,35 +237,30 @@ class ImageCaptionNode(VLMNodeBase): 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--- - # 查找指定的模型或默认模型 + # 构建 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', ''), @@ -215,62 +271,58 @@ class ImageCaptionNode(VLMNodeBase): '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 = generate_request_id("icap", None, 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_IMAGE_CAPTION, request_id, SOURCE_NODE, service_display_name, model_display, rule_name) - - # 执行图像分析 - 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密钥、模型配置或网络连接' - log_error(TASK_IMAGE_CAPTION, request_id, error_msg, source=SOURCE_NODE) - raise RuntimeError(f"分析失败: {error_msg}") - - # 服务层已经打印了完成日志,这里不再重复 - return (description,) + # ---处理 batch 输入--- + # 检查是否为 4D tensor(batch 格式) + if len(image.shape) == 4 and image.shape[0] > 1: + # Batch 模式:逐帧处理 + batch_size = image.shape[0] + results = [] + + for i in range(batch_size): + # 提取单帧并转换为 base64 + single_frame = image[i:i+1] # 保持 4D 形状 [1, H, W, C] + image_data = self._image_to_base64(single_frame) + + # 分析单张图像 + description = self._analyze_single_image( + image_data=image_data, + prompt_template=prompt_template, + rule_name=rule_name, + service_id=service_id, + service=service, + provider_config=provider_config, + unique_id=unique_id, + frame_index=i + ) + results.append(description) + + # 输出:合并结果 + 列表 + combined_result = "\n---\n".join(results) + return (combined_result, results) else: - error_msg = result.get('error', '分析失败,未知错误') if result else '分析服务未返回结果' - # 如果是中断错误,直接抛出InterruptProcessingException,不打印日志(由基类打印) - if error_msg == "任务被中断": - raise InterruptProcessingException() - log_error(TASK_IMAGE_CAPTION, request_id, error_msg, source=SOURCE_NODE) - raise RuntimeError(f"分析失败: {error_msg}") + # 单张模式 + image_data = self._image_to_base64(image) + description = self._analyze_single_image( + image_data=image_data, + prompt_template=prompt_template, + rule_name=rule_name, + service_id=service_id, + service=service, + provider_config=provider_config, + unique_id=unique_id, + frame_index=None + ) + return (description, [description]) except InterruptProcessingException: - # 不打印日志,由基类统一打印 raise except Exception as e: error_msg = format_api_error(e, vlm_service) - log_error(TASK_IMAGE_CAPTION, request_id, error_msg, source=SOURCE_NODE) + if request_id: + log_error(TASK_IMAGE_CAPTION, request_id, error_msg, source=SOURCE_NODE) raise RuntimeError(f"分析异常: {error_msg}") diff --git a/node/kontext_preset_node.py b/node/kontext_preset_node.py index add4779..68c950a 100644 --- a/node/kontext_preset_node.py +++ b/node/kontext_preset_node.py @@ -92,6 +92,10 @@ class KontextPresetNode(VLMNodeBase): 只在输入内容真正变化时才触发重新执行 使用输入参数的哈希值作为判断依据 """ + # 检查是否包含强制刷新符号 [R] + if cls._check_is_changed_bypass(kontext_preset, user_prompt): + return float("nan") + # 导入图像哈希工具函数 from ..utils.image import compute_image_hash diff --git a/node/translate_node.py b/node/translate_node.py index 30609d7..a478ed4 100644 --- a/node/translate_node.py +++ b/node/translate_node.py @@ -52,6 +52,10 @@ class PromptTranslate(LLMNodeBase): 只在输入内容真正变化时才触发重新执行 使用输入参数的哈希值作为判断依据 """ + # 检查是否包含强制刷新符号 [R] + if cls._check_is_changed_bypass(source_text): + return float("nan") + # 计算文本的哈希值 text_hash = "" if source_text: diff --git a/node/video_caption_node.py b/node/video_caption_node.py index 78c7605..c5e8aaa 100644 --- a/node/video_caption_node.py +++ b/node/video_caption_node.py @@ -69,7 +69,7 @@ class VideoCaptionNode(VLMNodeBase): "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."}), + "frame_count": ("INT", {"default": 5, "min": 1, "max": 32, "step": 1, "tooltip": "💡Only for 'Auto' mode. Frame limits: GLM-4V≤5, GLM-4.6V≤100, Qwen-VL≤100, Gemini≤3000, Grok≤10"}), "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"}), @@ -94,6 +94,10 @@ class VideoCaptionNode(VLMNodeBase): """ 只在输入内容真正变化时才触发重新执行 """ + # 检查是否包含强制刷新符号 [R] + if cls._check_is_changed_bypass(rule, custom_rule_content, user_prompt): + return float("nan") + # 提取实际的tensor数据 target_input = None if video is not None: @@ -312,8 +316,8 @@ class VideoCaptionNode(VLMNodeBase): 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}") + # 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 = {} @@ -329,7 +333,13 @@ class VideoCaptionNode(VLMNodeBase): ) # [Debug] 输出抽帧结果 - print(f"{self.PROCESS_PREFIX} [video-caption] 抽帧完成 | 帧数量:{len(frames_data)} | 预览tensor:{preview_tensor.shape}") + # print(f"{self.PROCESS_PREFIX} [video-caption] 抽帧完成 | 帧数量:{len(frames_data)} | 预览tensor:{preview_tensor.shape}") + + # ---注入帧数元信息到提示词--- + # 解决模型识别帧数与实际帧数不一致的问题 + actual_frame_count = len(frames_data) + frame_info_prefix = f"[重要提示:本次共提供了 {actual_frame_count} 帧图像,请务必逐帧分析,确保输出的描述数量与帧数一致。]\n\n" + prompt_template = frame_info_prefix + prompt_template # 调用多图像分析 - 使用基类方法 result = self._run_vision_task( diff --git a/requirements.txt b/requirements.txt index f554545..d1d6337 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,4 @@ -httpx>=0.24.0 +httpx +opencv-python +imageio +imageio-ffmpeg diff --git a/server.py b/server.py index 857b0f8..49dbc01 100644 --- a/server.py +++ b/server.py @@ -453,7 +453,7 @@ async def add_model_to_service_api(request): 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) + max_tokens = data.get('max_tokens', 1024) if not model_type or not model_name: return web.json_response({ @@ -1384,6 +1384,389 @@ async def vlm_analyze(request): if request_id and request_id in ACTIVE_TASKS: del ACTIVE_TASKS[request_id] +# ---流式输出API(SSE)--- + +@PromptServer.instance.routes.post(f'{API_PREFIX}/vlm/analyze/stream') +async def vlm_analyze_stream(request): + """ + 视觉分析API(流式版本) + 使用 Server-Sent Events (SSE) 逐 token 推送分析结果 + """ + request_id = None + response = None + try: + data = await request.json() + image_data = data.get("image") + request_id = data.get("request_id") + prompt_content = data.get("prompt") + + if not request_id: + return web.json_response({"success": False, "error": "缺少request_id"}, status=400) + + # 创建 SSE 响应 + response = web.StreamResponse( + status=200, + headers={ + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + 'X-Accel-Buffering': 'no', # 禁用 nginx 缓冲 + } + ) + await response.prepare(request) + + # 准备阶段日志 + 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) + + 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: + 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 = "自定义规则" + + log_prepare(TASK_IMAGE_CAPTION, request_id, SOURCE_FRONTEND, provider_display, model_display, rule_name) + + # 用于收集完整内容 + full_content = [] + + # 定义流式回调 + def stream_callback(chunk): + full_content.append(chunk) + # 将 chunk 作为 SSE 事件发送(同步版本,由调用者处理) + return chunk + + # 创建任务 + async def run_analysis(): + return await VisionService.analyze_image( + image_data=image_data, + request_id=request_id, + prompt_content=prompt_content, + stream_callback=stream_callback, + task_type=TASK_IMAGE_CAPTION, + source=SOURCE_FRONTEND + ) + + task = asyncio.create_task(run_analysis()) + ACTIVE_TASKS[request_id] = task + + # 轮询并发送流式内容 + last_sent_index = 0 + while not task.done(): + # 发送新的 chunks + while last_sent_index < len(full_content): + chunk = full_content[last_sent_index] + sse_data = json.dumps({"chunk": chunk}, ensure_ascii=False) + await response.write(f"data: {sse_data}\n\n".encode('utf-8')) + last_sent_index += 1 + await asyncio.sleep(0.05) # 50ms 轮询间隔 + + # 发送剩余的 chunks + while last_sent_index < len(full_content): + chunk = full_content[last_sent_index] + sse_data = json.dumps({"chunk": chunk}, ensure_ascii=False) + await response.write(f"data: {sse_data}\n\n".encode('utf-8')) + last_sent_index += 1 + + # 获取结果 + result = await task + + # 发送完成信号 + done_data = json.dumps({"done": True, "result": result}, ensure_ascii=False) + await response.write(f"data: {done_data}\n\n".encode('utf-8')) + + await response.write_eof() + return response + + except asyncio.CancelledError: + print(f"\r{_ANSI_CLEAR_EOL}{WARN_PREFIX} 流式视觉分析任务被取消 | ID:{request_id}", flush=True) + if response: + error_data = json.dumps({"error": "请求已取消", "cancelled": True}, ensure_ascii=False) + await response.write(f"data: {error_data}\n\n".encode('utf-8')) + await response.write_eof() + return response if response else web.json_response({"success": False, "error": "请求已取消", "cancelled": True}, status=400) + except Exception as e: + error_msg = str(e) + print(f"{ERROR_PREFIX} 流式视觉分析请求异常 | 请求ID:{request_id if request_id else '未知'} | 错误:{error_msg}") + if response: + error_data = json.dumps({"error": error_msg}, ensure_ascii=False) + await response.write(f"data: {error_data}\n\n".encode('utf-8')) + await response.write_eof() + return response + return web.json_response({"success": False, "error": error_msg}) + finally: + if request_id and request_id in ACTIVE_TASKS: + del ACTIVE_TASKS[request_id] + +@PromptServer.instance.routes.post(f'{API_PREFIX}/llm/expand/stream') +async def llm_expand_stream(request): + """ + LLM扩写API(流式版本) + 使用 Server-Sent Events (SSE) 逐 token 推送扩写结果 + """ + request_id = None + response = None + try: + data = await request.json() + prompt = data.get("prompt") + request_id = data.get("request_id") + + if not request_id: + return web.json_response({"success": False, "error": "缺少request_id"}, status=400) + + # 创建 SSE 响应 + response = web.StreamResponse( + status=200, + headers={ + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + 'X-Accel-Buffering': 'no', + } + ) + await response.prepare(request) + + # 准备阶段日志 + 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) + + 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: + 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)}) + + # 用于收集完整内容 + full_content = [] + + # 定义流式回调 + def stream_callback(chunk): + full_content.append(chunk) + return chunk + + # 创建任务 + async def run_expand(): + return await LLMService.expand_prompt( + prompt=prompt, + request_id=request_id, + stream_callback=stream_callback, + task_type=TASK_EXPAND, + source=SOURCE_FRONTEND + ) + + task = asyncio.create_task(run_expand()) + ACTIVE_TASKS[request_id] = task + + # 轮询并发送流式内容 + last_sent_index = 0 + while not task.done(): + while last_sent_index < len(full_content): + chunk = full_content[last_sent_index] + sse_data = json.dumps({"chunk": chunk}, ensure_ascii=False) + await response.write(f"data: {sse_data}\n\n".encode('utf-8')) + last_sent_index += 1 + await asyncio.sleep(0.05) + + # 发送剩余的 chunks + while last_sent_index < len(full_content): + chunk = full_content[last_sent_index] + sse_data = json.dumps({"chunk": chunk}, ensure_ascii=False) + await response.write(f"data: {sse_data}\n\n".encode('utf-8')) + last_sent_index += 1 + + # 获取结果 + result = await task + + # 发送完成信号 + done_data = json.dumps({"done": True, "result": result}, ensure_ascii=False) + await response.write(f"data: {done_data}\n\n".encode('utf-8')) + + await response.write_eof() + return response + + except asyncio.CancelledError: + print(f"\r{_ANSI_CLEAR_EOL}{WARN_PREFIX} 流式LLM扩写任务被取消 | ID:{request_id}", flush=True) + if response: + error_data = json.dumps({"error": "请求已取消", "cancelled": True}, ensure_ascii=False) + await response.write(f"data: {error_data}\n\n".encode('utf-8')) + await response.write_eof() + return response if response else 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}") + if response: + error_data = json.dumps({"error": error_msg}, ensure_ascii=False) + await response.write(f"data: {error_data}\n\n".encode('utf-8')) + await response.write_eof() + return response + return web.json_response({"success": False, "error": error_msg}) + finally: + if request_id and request_id in ACTIVE_TASKS: + del ACTIVE_TASKS[request_id] + +@PromptServer.instance.routes.post(f'{API_PREFIX}/llm/translate/stream') +async def llm_translate_stream(request): + """ + LLM翻译API(流式版本) + 使用 Server-Sent Events (SSE) 逐 token 推送翻译结果 + 注意:仅支持LLM翻译,百度翻译不支持流式,请使用原有接口 + """ + request_id = None + response = None + try: + data = await request.json() + text = data.get("text") + from_lang = data.get("from", "auto") + to_lang = data.get("to", "zh") + request_id = data.get("request_id") + + if not request_id: + return web.json_response({"success": False, "error": "缺少request_id"}, status=400) + + if not text or text.strip() == '': + return web.json_response({"success": False, "error": "缺少翻译文本"}, status=400) + + # 创建 SSE 响应 + response = web.StreamResponse( + status=200, + headers={ + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + 'X-Accel-Buffering': 'no', + } + ) + await response.prepare(request) + + # 准备阶段日志 + from .services.thinking_control import build_thinking_suppression + from .utils.common import format_model_with_thinking + from .services.openai_base import OpenAICompatibleService + + # 使用翻译服务配置(而非LLM配置) + translate_config = config_manager.get_translate_config() + if translate_config: + provider = translate_config.get('provider', 'ollama') + model = translate_config.get('model', '') + provider_display = OpenAICompatibleService.get_provider_display_name(provider) + + 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, f"{from_lang}→{to_lang}", {"长度": len(text)}) + + # 用于收集完整内容 + full_content = [] + + # 定义流式回调 + def stream_callback(chunk): + full_content.append(chunk) + return chunk + + # 创建任务 + async def run_translate(): + return await LLMService.translate( + text=text, + from_lang=from_lang, + to_lang=to_lang, + request_id=request_id, + stream_callback=stream_callback, + task_type=TASK_TRANSLATE, + source=SOURCE_FRONTEND + ) + + task = asyncio.create_task(run_translate()) + ACTIVE_TASKS[request_id] = task + + # 轮询并发送流式内容 + last_sent_index = 0 + while not task.done(): + while last_sent_index < len(full_content): + chunk = full_content[last_sent_index] + sse_data = json.dumps({"chunk": chunk}, ensure_ascii=False) + await response.write(f"data: {sse_data}\n\n".encode('utf-8')) + last_sent_index += 1 + await asyncio.sleep(0.05) + + # 发送剩余的 chunks + while last_sent_index < len(full_content): + chunk = full_content[last_sent_index] + sse_data = json.dumps({"chunk": chunk}, ensure_ascii=False) + await response.write(f"data: {sse_data}\n\n".encode('utf-8')) + last_sent_index += 1 + + # 获取结果 + result = await task + + # 发送完成信号 + done_data = json.dumps({"done": True, "result": result}, ensure_ascii=False) + await response.write(f"data: {done_data}\n\n".encode('utf-8')) + + await response.write_eof() + return response + + except asyncio.CancelledError: + print(f"\r{_ANSI_CLEAR_EOL}{WARN_PREFIX} 流式LLM翻译任务被取消 | ID:{request_id}", flush=True) + if response: + error_data = json.dumps({"error": "请求已取消", "cancelled": True}, ensure_ascii=False) + await response.write(f"data: {error_data}\n\n".encode('utf-8')) + await response.write_eof() + return response if response else 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}") + if response: + error_data = json.dumps({"error": error_msg}, ensure_ascii=False) + await response.write(f"data: {error_data}\n\n".encode('utf-8')) + await response.write_eof() + return response + return web.json_response({"success": False, "error": error_msg}) + finally: + 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, 时长等)""" diff --git a/services/core.py b/services/core.py index 0791db3..20e7706 100644 --- a/services/core.py +++ b/services/core.py @@ -11,9 +11,38 @@ import re class HTTPClientPool: """ - HTTP客户端池单例 - 管理httpx.AsyncClient的创建(不跨事件循环缓存) + HTTP客户端池 + 管理持久化的 httpx.AsyncClient,支持连接复用 """ + _clients: Dict[str, httpx.AsyncClient] = {} + _loop_id: Optional[int] = None # 记录创建客户端时的事件循环ID + + @classmethod + def _check_loop_change(cls) -> bool: + """ + 检测事件循环是否发生变化 + 返回: True = 循环已变化,需要清理旧客户端 + """ + try: + import asyncio + current_loop = asyncio.get_running_loop() + current_loop_id = id(current_loop) + + if cls._loop_id is None: + cls._loop_id = current_loop_id + return False + + if cls._loop_id != current_loop_id: + # 事件循环已变化,清理所有旧客户端 + cls._loop_id = current_loop_id + # 不能 await close,直接丢弃引用(让 GC 处理) + cls._clients.clear() + return True + + return False + except RuntimeError: + # 没有运行中的事件循环,保守处理 + return False @classmethod def get_client( @@ -26,51 +55,56 @@ class HTTPClientPool: **kwargs ) -> httpx.AsyncClient: """ - 创建HTTP客户端(每次都创建新实例,避免事件循环问题) + 获取或创建HTTP客户端(支持连接复用) 参数: provider: 服务商标识(用于日志) - base_url: API基础URL + base_url: API基础URL,作为缓存的Key timeout: 超时时间(秒) proxy: 代理设置 verify_ssl: 是否验证SSL证书 - **kwargs: 其他httpx参数 - - 返回: - httpx.AsyncClient: 新创建的客户端 """ - # 创建新客户端(不缓存,避免跨事件循环复用) + # 检测事件循环变化,必要时清理旧客户端 + cls._check_loop_change() + + # 使用 base_url 作为唯一标识进行缓存 + cache_key = base_url or provider + + if cache_key in cls._clients: + client = cls._clients[cache_key] + if not client.is_closed: + return client + + # 创建新客户端 client_kwargs = { - 'timeout': httpx.Timeout(timeout, connect=10.0), + 'timeout': httpx.Timeout(timeout, connect=10.0, read=timeout, write=60.0), 'verify': verify_ssl, 'follow_redirects': True, - 'http2': False, # 禁用HTTP/2避免兼容性问题 + 'http2': False, + # 设置连接池保持连接 + 'limits': httpx.Limits(max_keepalive_connections=10, max_connections=20, keepalive_expiry=60.0) } - # 设置代理(优先使用参数,其次环境变量) 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) + cls._clients[cache_key] = client return client + @classmethod async def close_all(cls): - """占位方法(已不再缓存客户端)""" - pass - - @classmethod - def get_pool_size(cls) -> int: - """获取当前池中的客户端数量""" - return len(cls._clients) + """关闭所有已创建的客户端,彻底释放资源""" + for key in list(cls._clients.keys()): + client = cls._clients.pop(key) + try: + await client.aclose() + except: + pass # Logger 类已移除,请直接从 ..utils.common 导入 log_prepare, log_complete, log_error 等函数使用。 diff --git a/services/llm.py b/services/llm.py index 92c0ae7..3eb957f 100644 --- a/services/llm.py +++ b/services/llm.py @@ -84,9 +84,10 @@ class LLMService(OpenAICompatibleService): _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('/') + # 计算基准 URL (确保移除 /v1 和末尾斜杠) + native_base = base_url.rstrip('/') if base_url else 'http://localhost:11434' + if native_base.endswith('/v1'): + native_base = native_base[:-3].rstrip('/') # 智能动态上下文窗口计算 (Token估算策略: 中文0.7/char, 英文0.3/char -> 保守取 0.6/char) # 安全地计算输入长度 (包含 System Prompt 和 User Prompt,前提是都在 messages 中) @@ -146,15 +147,24 @@ class LLMService(OpenAICompatibleService): "stream": True } - # 构建 options - # ⚠️ 重要:Ollama 对高级参数兼容性不佳,仅发送 num_ctx 避免冲突 - # - temperature 和 top_p 冲突 - # - num_predict 在某些模型上会导致无输出 - # 因此统一只发送 num_ctx,让模型使用默认配置 - payload["options"] = { + # ---构建 options--- + # 基础参数:num_ctx(动态上下文窗口大小) + options = { "num_ctx": num_ctx } + # 高级参数:仅在用户启用时发送 + # 参数说明(基于 Ollama 官方文档): + # - temperature: 控制随机性,默认0.8,值越低输出越稳定 + # - top_p: 核采样,默认0.9,限制候选词概率范围 + # - num_predict: 最大生成Token数,默认-1(无限) + if enable_advanced_params: + options["temperature"] = temperature + options["top_p"] = top_p + options["num_predict"] = max_tokens + + payload["options"] = options + # 添加思维链控制参数(如 think: true 或 think: false) if _thinking_extra: payload.update(_thinking_extra) @@ -241,8 +251,16 @@ class LLMService(OpenAICompatibleService): try: result = await req_task + # 关键修复:检查返回的结果,如果失败则停止进度条 + if not result.get("success"): + pbar.error(result.get("error", "未知错误")) return result + except Exception as req_err: + if 'pbar' in locals() and pbar: + pbar.error(f"Ollama 请求异常: {req_err}") + return {"success": False, "error": f"Ollama 请求异常: {req_err}"} except asyncio.CancelledError: + pbar.cancel(f"{WARN_PREFIX} 任务被中断 | 服务:Ollama") return {"success": False, "error": "任务被中断", "interrupted": True} finally: @@ -359,17 +377,22 @@ class LLMService(OpenAICompatibleService): } messages = [lang_message, system_message, {"role": "user", "content": prompt}] - # Ollama走原生API - if provider == 'ollama': + # Ollama走原生API (通过服务类型判断) + if service and service.get('type') == 'ollama': # 读取 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 + enable_advanced_params = service.get('enable_advanced_params', False) + filter_thinking_output = service.get('filter_thinking_output', True) + # 统一计算 native_base (确保移除 /v1 和末尾斜杠) + native_base = base_url.rstrip('/') + if native_base.endswith('/v1'): + native_base = native_base[:-3].rstrip('/') + + # 再次兜底 + if not native_base: + native_base = 'http://localhost:11434' - # 提前计算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 @@ -484,7 +507,7 @@ class LLMService(OpenAICompatibleService): 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') @@ -494,7 +517,9 @@ class LLMService(OpenAICompatibleService): max_tokens = custom_provider_config.get('max_tokens', 2000) base_url = custom_provider_config.get('base_url', '') else: - config = LLMService._get_config() + # 使用翻译服务配置(而非LLM配置) + from ..config_manager import config_manager + config = config_manager.get_translate_config() provider = config.get('provider', 'unknown') api_key = config.get('api_key') model = config.get('model') @@ -509,11 +534,15 @@ class LLMService(OpenAICompatibleService): provider_display_name = LLMService.get_provider_display_name(provider) + from ..config_manager import config_manager + service = config_manager.get_service(provider) + from ..utils.common import REQUEST_PREFIX, PREFIX, format_model_with_thinking # 检测是否关闭思维链 - _thinking_extra = build_thinking_suppression(provider, model) - thinking_disabled = _thinking_extra is not None + 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 + thinking_disabled = _thinking_extra is not None and disable_thinking_enabled model_display = format_model_with_thinking(model, thinking_disabled) # 翻译提示词 @@ -524,19 +553,23 @@ class LLMService(OpenAICompatibleService): {"role": "user", "content": text} ] - # Ollama走原生API - if provider == 'ollama': + # Ollama走原生API (通过服务类型判断) + if service and service.get('type') == '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 + disable_thinking_enabled = service.get('disable_thinking', True) + enable_advanced_params = service.get('enable_advanced_params', False) + filter_thinking_output = service.get('filter_thinking_output', True) + _ollama_thinking_extra = build_thinking_suppression(provider, 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('/') + # 统一计算 native_base (确保移除 /v1 和末尾斜杠) + native_base = base_url.rstrip('/') + if native_base.endswith('/v1'): + native_base = native_base[:-3].rstrip('/') + + # 再次兜底 + if not native_base: + native_base = 'http://localhost:11434' + _cfg = { 'auto_unload': custom_provider_config.get('auto_unload', True) if custom_provider_config else config.get('auto_unload', True), 'base_url': native_base @@ -580,13 +613,10 @@ class LLMService(OpenAICompatibleService): if not base_url: base_url = LLMService.get_provider_base_url(provider, custom_provider_config if custom_provider else None) - # 检查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和filter_thinking_output配置 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 + thinking_extra = _thinking_extra # 复用前面计算好的 suppression result = await LLMService._http_request_chat_completions( base_url=base_url, diff --git a/services/openai_base.py b/services/openai_base.py index 2785a7f..cc05c9b 100644 --- a/services/openai_base.py +++ b/services/openai_base.py @@ -240,8 +240,11 @@ class OpenAICompatibleService(BaseAPIService): # 关键修复:停止旧的进度条后再创建新的,防止线程泄漏 if pbar: - pbar._closed = True - pbar._stop_event.set() + try: + pbar.error(f"Retry Level {retry_level}...") # 标记前一个进度条为错误/重试状态 + except: + pbar._stop_timer() + # 重新创建进度条用于新一轮重试 pbar = ProgressBar( @@ -293,13 +296,19 @@ class OpenAICompatibleService(BaseAPIService): try: chunk = json.loads(line) + # --- 调试日志 (2级): 输出原始流式数据 --- + # print(f"[DEBUG-2] Chunk: {line[:200]}...", flush=True) + 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 '' + delta.get('thinking', '') or + delta.get('thinking_process', '') or # 备选 + '' ) if reasoning: reasoning_content += reasoning if content: @@ -317,6 +326,8 @@ class OpenAICompatibleService(BaseAPIService): elapsed_ms = int((time.perf_counter() - start_time) * 1000) if not final_content.strip(): pbar.error("响应内容为空") + # --- 调试日志 (1级): 警告响应内容为空 --- + print(f"\n{WARN_PREFIX} [API响应调试] 模型:{model} | 状态:成功 | 但最终内容为空字符串", flush=True) else: pbar.done(char_count=len(final_content), elapsed_ms=elapsed_ms) @@ -348,6 +359,10 @@ class OpenAICompatibleService(BaseAPIService): try: result = await req_task + # 关键修复:API 返回错误时,确保进度条被停止 + if not result.get("success") and not result.get("interrupted"): + if not getattr(pbar, '_closed', False): + pbar.error(result.get("error", "API 错误")) return result except asyncio.CancelledError: pbar.cancel(f"{WARN_PREFIX} 任务被中断 | 服务:{provider_display_name}") @@ -360,11 +375,12 @@ class OpenAICompatibleService(BaseAPIService): try: result = await _do_stream_request() except Exception as req_err: - last_error_msg = str(req_err) # 网络层面的异常(非HTTP响应),通常不适合通过参数降级解决,除非确认是特定的协议问题 # 这里选择继续抛出或作为错误返回,不盲目重试 # 但为了稳健,如果是非连接已建立后的错误,可以选择不重试 # 为简单起见,仅记录错误 + if 'pbar' in locals() and pbar: + pbar.error(f"网络请求异常: {req_err}") return {"success": False, "error": f"网络请求异常: {req_err}"} # 检查结果 diff --git a/services/thinking_control.py b/services/thinking_control.py index 7d396a4..6220ccb 100644 --- a/services/thinking_control.py +++ b/services/thinking_control.py @@ -64,11 +64,11 @@ THINKING_CONTROL_RULES: List[Dict[str, Any]] = [ }, { "name": "gemini3_thinking", - "description": "Gemini 3.0系列(只能low,无法完全关闭)", + "description": "Gemini 3.0系列(通过reasoning_effort控制)", "patterns": [r"gemini[-_/.]?3"], - "params": {"thinking_level": "low"}, - "sources": ["Google官方API"], - "notes": "⚠️ 无法完全关闭推理,只能设置low/high" + "params": {"reasoning_effort": "low"}, + "sources": ["Google官方API (OpenAI兼容接口)"], + "notes": "⚠️ 官方OpenAI端点使用reasoning_effort参数" }, { "name": "grok3_mini_reasoning", diff --git a/services/vlm.py b/services/vlm.py index 2757739..40b2b0d 100644 --- a/services/vlm.py +++ b/services/vlm.py @@ -82,9 +82,10 @@ class VisionService(OpenAICompatibleService): _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('/') + # 计算基准 URL (确保移除 /v1 和末尾斜杠) + native_base = base_url.rstrip('/') if base_url else 'http://localhost:11434' + if native_base.endswith('/v1'): + native_base = native_base[:-3].rstrip('/') # 动态计算num_ctx(根据图像数量) # 每张图片约需要1024-2048 tokens @@ -108,18 +109,20 @@ class VisionService(OpenAICompatibleService): if _thinking_extra or is_safe_standard_model: # 已关闭思维链 OR 标准指令模型 -> 极致节省模式 min_output = 512 - ctx_floor = 2048 # 允许更低下限 (虽然单图通常>2048, 但允许对齐到3072而非强制4096) + # 单图允许进一步下探至 2048,多图保持 3072 起步以确保稳定 + ctx_floor = 2048 if not is_multi else 3072 sys_buffer = 384 else: # 未关闭思维链 -> 安全能够模式 min_output = 1024 - ctx_floor = 4096 # 保持稳健,防止思考过程溢出 - sys_buffer = 1024 + # 单图下限从 4096 降至 2048 (适配 Ollama 显存分配优化) + ctx_floor = 2048 if not is_multi else 4096 + sys_buffer = 384 if not is_multi else 1024 # 输出预留 (多图需更多) - # 如果是多图,每张图预留512作为基准;如果是单图,使用min_output - base_reserve = (img_count * 512) if is_multi else min_output - output_reserve = max(min_output, base_reserve) + # 如果是单图模式,预留 512 已足够描述;如果是多图,使用 min_output + base_reserve = (img_count * 512) if is_multi else 512 + output_reserve = max(512 if not is_multi else min_output, base_reserve) required_ctx = prompt_ctx + image_ctx + output_reserve + sys_buffer @@ -128,7 +131,7 @@ class VisionService(OpenAICompatibleService): num_ctx = ((num_ctx + 1023) // 1024) * 1024 # [Debug] 输出多图请求信息 - print(f"{PREFIX} [vlm-ollama] 多图请求 | 图片数量:{len(images_b64)} | num_ctx:{num_ctx} | 模型:{model}") + print(f"{PREFIX} 🐏 视觉请求 | 图片数量:{len(images_b64)} | num_ctx:{num_ctx} | 模型:{model}") # 构建基础请求体 payload = { @@ -137,15 +140,24 @@ class VisionService(OpenAICompatibleService): "stream": True } - # 构建 options - # ⚠️ 重要:Ollama 对高级参数兼容性不佳,仅发送 num_ctx 避免冲突 - # - temperature 和 top_p 冲突 - # - num_predict 在某些模型上会导致无输出 - # 因此统一只发送 num_ctx,让模型使用默认配置 - payload["options"] = { + # ---构建 options--- + # 基础参数:num_ctx(动态上下文窗口大小) + options = { "num_ctx": num_ctx } + # 高级参数:仅在用户启用时发送 + # 参数说明(基于 Ollama 官方文档): + # - temperature: 控制随机性,默认0.8,值越低输出越稳定 + # - top_p: 核采样,默认0.9,限制候选词概率范围 + # - num_predict: 最大生成Token数,默认-1(无限) + if enable_advanced_params: + options["temperature"] = temperature + options["top_p"] = top_p + options["num_predict"] = max_tokens + + payload["options"] = options + # 添加思维链控制参数(如 think: true 或 think: false) if _thinking_extra: payload.update(_thinking_extra) @@ -174,88 +186,97 @@ class VisionService(OpenAICompatibleService): start_time = time.perf_counter() - # 连接超时10秒,写入超时60秒,连接池超时60秒 - timeout_config = httpx.Timeout(connect=10.0, read=final_read_timeout, write=60.0, pool=60.0) + # 获取持久化客户端以支持连接复用 + from .core import HTTPClientPool + client = HTTPClientPool.get_client( + provider="Ollama(Vision)", + base_url=native_base, + timeout=final_read_timeout + ) - 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}'} - - 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) + full_content = "" + + async def _request_core(): + nonlocal full_content + 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() + pbar.error(f"Ollama API 错误: {resp.status_code}") + 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}'} + + 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 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()} + 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 + # 定义监视器逻辑 + 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)) - + # 并发执行 + req_task = asyncio.create_task(_request_core()) + monitor_task = asyncio.create_task(_monitor_interrupts(req_task)) + + try: + result = await req_task + # 兜底处理:确保失败结果时进度条已停止 + if not result.get("success") and not getattr(pbar, '_closed', False): + pbar.error(result.get("error", "未知错误")) + return result + except Exception as req_err: + if 'pbar' in locals() and pbar: + pbar.error(f"Ollama(Vision) 请求异常: {req_err}") + return {"success": False, "error": f"Ollama(Vision) 请求异常: {req_err}"} + 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() + # 显存释放保证:视觉节点对显存更敏感,必须确保在所有退出路径执行 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 + 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: @@ -338,12 +359,12 @@ class VisionService(OpenAICompatibleService): # 获取系统提示词 system_prompt = prompt_content or "请详细描述这张图片的内容,包括主要对象、场景、颜色、氛围等。" - # Ollama走原生API - if provider == 'ollama': + # Ollama走原生API (通过服务类型判断) + if service and service.get('type') == 'ollama': # 读取 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 + enable_advanced_params = service.get('enable_advanced_params', False) + filter_thinking_output = service.get('filter_thinking_output', True) + _ollama_thinking_extra = build_thinking_suppression(provider, model) if disable_thinking_enabled else None # 提取纯base64 b64 = processed_image.split(',')[1] if ',' in processed_image else processed_image @@ -536,15 +557,15 @@ class VisionService(OpenAICompatibleService): # 获取系统提示词 system_prompt = prompt_content or "请详细描述这些图片,分析它们之间的关系和差异。" - # Ollama走原生API - if provider == 'ollama': + # Ollama走原生API (通过服务类型判断) + if service and service.get('type') == '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 + # 此处保持类型判断,不再硬编码 ID 'ollama' + disable_thinking_enabled = service.get('disable_thinking', True) + enable_advanced_params = service.get('enable_advanced_params', False) + filter_thinking_output = service.get('filter_thinking_output', True) + _ollama_thinking_extra = build_thinking_suppression(provider, 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') @@ -642,4 +663,7 @@ class VisionService(OpenAICompatibleService): return result except Exception as e: + # 确保进度条在异常时被停止 + if 'pbar' in locals() and pbar and not getattr(pbar, '_closed', False): + pbar.error(format_api_error(e, "VLM服务")) return {"success": False, "error": format_api_error(e, "VLM服务")} \ No newline at end of file diff --git a/utils/common.py b/utils/common.py index 020b3e1..8eeda68 100644 --- a/utils/common.py +++ b/utils/common.py @@ -377,15 +377,24 @@ class ProgressBar: # 记录本次显示的宽度(包含缓冲空格) _global_last_output_len = current_width + len(padding) + def _stop_timer(self): + """停止计时器线程""" + self._stop_event.set() + # 强制将状态标为已关闭,防止重入 + self._closed = True + 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 + try: + while not self._stop_event.is_set() and not self._closed: + # 定时刷新当前内容(主要用于更新 WAITING 阶段的时间) + self._refresh() + + # 每 0.1 秒刷新一次 + if self._stop_event.wait(0.1): + break + except Exception: + pass # 守护线程异常不应影响主流程 def set_generating(self, char_count: int = 0) -> None: """ @@ -433,9 +442,9 @@ class ProgressBar: if self._closed: return - self._closed = True + self._stop_timer() self._state = self.STATE_DONE - self._stop_event.set() + # 重置全局长度 with _progress_lock: @@ -483,8 +492,8 @@ class ProgressBar: if self._closed: return - self._closed = True - self._stop_event.set() + self._stop_timer() + # 重置全局长度 with _progress_lock: @@ -509,8 +518,8 @@ class ProgressBar: if self._closed: return - self._closed = True - self._stop_event.set() + self._stop_timer() + # 重置全局长度 with _progress_lock: @@ -532,8 +541,18 @@ class ProgressBar: def __exit__(self, *args): if not self._closed: + # 退出上下文时,如果没有显式调用 done/error,则视为成功完成 self.done() + def __del__(self): + """析构函数:确保对象被回收时停止计时器""" + try: + # 仅在计时器还在运行时尝试停止 + if hasattr(self, '_stop_event') and not self._stop_event.is_set(): + self._stop_timer() + except: + pass + @@ -821,7 +840,12 @@ def check_multi_image_support(provider: str, model: str) -> tuple: if "gemini" in model_lower or "google" in model_lower: return (True, 3000) - # 智谱GLM-4V系列:支持多图像(最多5张) + # 智谱GLM系列视觉模型 + # GLM-4.6V系列:128K上下文,支持大量多图(无官方硬限制) + if "glm" in model_lower and "4.6v" in model_lower: + return (True, 100) + + # GLM-4V系列(4V-Plus等):16K上下文,最多5张 if "glm" in model_lower and ("4v" in model_lower or "vision" in model_lower): return (True, 5) diff --git a/utils/migration_tool.py b/utils/migration_tool.py index 7fe2c7d..392e0dd 100644 --- a/utils/migration_tool.py +++ b/utils/migration_tool.py @@ -611,6 +611,10 @@ class MigrationTool: # 执行深度合并 modified = self._deep_merge_defaults(user_data, default_data) + # ---特殊处理:system_prompts 规则内容覆盖--- + if file_desc == "system_prompts": + modified = self._overwrite_prompts_from_template(user_data, default_data) or modified + # ---特殊处理:为 system_prompts 中的所有规则补全 category 和 showIn 字段--- if file_desc == "system_prompts": modified = self._ensure_prompts_have_category(user_data) or modified @@ -694,6 +698,57 @@ class MigrationTool: return modified + def _overwrite_prompts_from_template(self, user_data, template_data): + """ + 用模板中的规则内容覆盖用户配置中的同名规则 + + 当版本更新时(模板版本 > 用户版本),将模板中的规则内容 + 完整覆盖到用户配置中的对应规则,确保内置规则保持最新。 + + 覆盖策略: + - 仅覆盖模板和用户配置中都存在的同名规则 + - 用户自定义的规则(模板中不存在)保持不变 + - 模板中新增的规则由 _deep_merge_defaults 处理 + + 返回是否发生了修改 + """ + import copy + modified = False + + # 需要处理的规则类型 + prompt_types = ['expand_prompts', 'vision_prompts', 'video_prompts', 'translate_prompts'] + + for prompt_type in prompt_types: + # 检查模板中是否有该类型 + if prompt_type not in template_data: + continue + + template_prompts = template_data[prompt_type] + if not isinstance(template_prompts, dict): + continue + + # 检查用户配置中是否有该类型 + if prompt_type not in user_data: + continue + + user_prompts = user_data[prompt_type] + if not isinstance(user_prompts, dict): + continue + + # 遍历模板中的每个规则 + for prompt_id, template_prompt in template_prompts.items(): + if not isinstance(template_prompt, dict): + continue + + # 检查用户配置中是否存在同名规则 + if prompt_id in user_prompts: + # 用模板内容完整覆盖用户规则 + user_prompts[prompt_id] = copy.deepcopy(template_prompt) + modified = True + self._log(f"[system_prompts] 覆盖规则: {template_prompt.get('name', prompt_id)}") + + return modified + def _deep_merge_defaults(self, user_data, default_data): """ 递归将 default_data 中的缺失字段合并到 user_data @@ -736,7 +791,8 @@ class MigrationTool: 迁移逻辑: 1. 检查 tags 目录是否为空 2. 如果为空,读取插件 config 目录下的 tags.json 和 tags_user.json - 3. 转换为 CSV 格式并写入 "用户标签.csv" + 3. tags.json → 转换为 "默认标签.csv" + 4. tags_user.json → 转换为 "用户标签.csv" CSV 格式: 标签名\t标签值\t一级分类\t二级分类\t三级分类\t四级分类 """ @@ -748,24 +804,75 @@ class MigrationTool: # 2. 读取 JSON 文件 tags_data, user_tags_data = self._load_legacy_tags_json() + migrated_count = 0 + + # ---处理 tags.json → 默认标签.csv--- + if tags_data: + csv_rows = [] + self._extract_tags_recursive(tags_data, [], csv_rows) + + if csv_rows: + csv_filename = "默认标签.csv" + self._write_tags_csv(csv_rows, csv_filename) + self._log(f"[tags.json] ✅ 成功迁移 {len(csv_rows)} 个标签到 {csv_filename}") + migrated_count += len(csv_rows) + + # ---处理 tags_user.json → 用户标签.csv--- + if user_tags_data: + csv_rows = [] + # tags_user.json 是2层结构: {分类: {标签名: 标签值}} + 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) + + if csv_rows: + csv_filename = "用户标签.csv" + self._write_tags_csv(csv_rows, csv_filename) + self._log(f"[tags_user.json] ✅ 成功迁移 {len(csv_rows)} 个标签到 {csv_filename}") + migrated_count += len(csv_rows) + + # ---如果两个文件都不存在,尝试从模板创建默认标签--- if not tags_data and not user_tags_data: - return False + template_path = os.path.join(self.plugin_dir, "config", "tags_template.json") + if os.path.exists(template_path): + try: + with open(template_path, 'r', encoding='utf-8') as f: + template_data = json.load(f) + + csv_rows = [] + self._extract_tags_recursive(template_data, [], csv_rows) + + if csv_rows: + csv_filename = "默认标签.csv" + self._write_tags_csv(csv_rows, csv_filename) + self._log(f"✨ 检测到全新环境,已基于模板创建初始标签文件: {csv_filename}") + return True + except Exception as e: + self._log(f"❗ 从模板创建初始标签失败: {str(e)}") + + # 模板读取失败或不存在时的最后保底 + csv_rows = [["欢迎使用提示词小助手", "", "指南", "开始", "", ""]] + csv_filename = "默认标签.csv" + self._write_tags_csv(csv_rows, csv_filename) + self._log(f"✨ 已创建简易初始标签文件: {csv_filename}") + return True - # 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 + return migrated_count > 0 except Exception as e: - self._log(f"[tags.json] ❗ 标签迁移失败: {str(e)}") + self._log(f"[tags] ❗ 标签迁移失败: {str(e)}") return False def _should_migrate_tags(self): @@ -811,46 +918,6 @@ class MigrationTool: 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): """ 递归提取标签数据