Compare commits

..
3 Commits
Author SHA1 Message Date
梦圣智杰 082f693d38 chore: 发布 v2.2.4 / Release v2.2.4
- 右上角新增问候卡:进页按时间段问候,运行时间、图片生成、失败安慰、安静提醒、随机提醒五类各自带开关,AI 不可用时退回内置文案。
- 问候可单独配一套 AI(地址/密钥/模型/温度),也可以沿用助手设置那套;提示词集中在 data/greeting_defaults.json 可自行修改。
- 收藏文件夹的淡色底按明暗主题分别取透明度,亮色下看得清了。
- 设置面板新增「说明」页,标注设置页正在重构、位置可能调整。
2026-11-13 02:50:57 +08:00
梦圣智杰 de3058f5af chore: 收藏色适配亮色主题,设置面板补一页说明 / Light-theme tint fix and settings notice page 2026-11-13 02:45:51 +08:00
梦圣智杰 4f7adcd4c2 feat: 右上角问候与提醒 / Greeting and reminder cards
- 右上角新增一张独立的问候卡,四段动画:入场、加载、结果展开、逐层退场
- 进入页面时按时间段(早上/中午/下午/晚上/深夜)让 AI 说一句,AI 不可用时退回内置文案
- 「运行时间」每隔 N 分钟提醒一次,标题显示当前在线多少分钟
- 「图片生成」每攒够 N 张夸一次,标题显示已生成多少张
- 「生成失败时安慰一下」1/5 概率触发,只告诉 AI 失败了,不带报错内容和堆栈
- 「静下来时招呼一声」键盘鼠标安静 1 分钟就说句话
- 「随机提醒」每分钟掷一次骰子,1/N 概率说一句,N 可填
- 问候可以单独配一套 AI(地址/密钥/模型/温度),也可以沿用助手那套
- 所有提示词集中在 data/greeting_defaults.json,AI 读到的就是这份文件
- 问候相关设置收进折叠块,每项带问号说明;助手设置的 AI 规则也折叠了
- 提示词统一成「不要 emoji,可以用颜文字」
2026-11-13 02:35:07 +08:00
14 changed files with 1950 additions and 51 deletions
+1
View File
@@ -8,6 +8,7 @@
# 用户收藏库(个人提示词与参考图,不进发布包)
data/saved/
data/rule_presets/
data/greeting/
# 测试
tests/
+1
View File
@@ -48,3 +48,4 @@ data/saved/
# AI 规则预设(个人提示词,可能包含私有写法)
data/rule_presets/
data/greeting/
+23
View File
@@ -1,5 +1,28 @@
# 更新记录
## v2.2.4(2026-11-13)
### 右上角会说话了:问候与提醒
- 右上角多了一张独立的问候卡,和通知弹窗是两回事:它专门负责 AI 陪你聊天,有自己的入场、加载、展开、退场动画,不会打断通知。
- 进入页面时按时间段(早上 / 中午 / 下午 / 晚上 / 深夜)说一句问候;AI 不可用时退回内置文案,离线也有话讲。
- 四种提醒,各有开关:
- **运行时间**:每隔 N 分钟提醒一次,标题栏显示当前在线多少分钟。
- **图片生成**:每攒够 N 张夸一次,标题栏显示已生成多少张。
- **生成失败时安慰一下**:五分之一概率触发;只告诉 AI「失败了」,不带报错内容和堆栈,隐私不外泄。
- **静下来时招呼一声**:键盘鼠标安静 1 分钟就说句话;另有「随机提醒」每分钟掷一次骰子、1/N 概率说一句,N 可以自己填。
- 问候可以单独配一套 AI(地址 / 密钥 / 模型 / 温度),也可以直接沿用助手设置里那套。它只在你自己开了对应开关时才会发请求。
- 所有提示词集中在 `data/greeting_defaults.json`,想让 AI 说什么样的话可以直接改这份文件。
- 问候相关设置收进设置面板的一个折叠块里,每项带问号说明。提示词统一要求「不要 emoji、可以用颜文字」。
### 收藏颜色在亮色主题下看得清了
- 收藏文件夹的淡色底按明暗主题分别取透明度:亮色背景原本就浅,之前 12% 的淡色几乎看不见。
### 设置面板加了「说明」页
- 「设置 → 双语提示词检查器 → 说明」是新增的第一项:设置页正在重构、近期功能位置可能还会调整,遇到问题欢迎去 GitHub 提 issue。
## v2.2.3(2026-11-11)
### 收藏改成了文件夹式的库
+14 -2
View File
@@ -1,4 +1,4 @@
# ComfyUI 双语提示词管理器 v2.2.3
# ComfyUI 双语提示词管理器 v2.2.4
> ComfyUI 双语提示词节点:把英文提示词拆成可点击的标签,边写边看中文解释、调顺序、试效果。
@@ -20,6 +20,7 @@
- [收藏](#收藏)
- [侧边栏管理面板](#侧边栏管理面板)
- [操作反馈](#操作反馈)
- [右上角问候与提醒](#右上角问候与提醒)
- [界面导览](#界面导览)
- [安装](#安装)
- [快速上手](#快速上手)
@@ -136,7 +137,7 @@
「标签管理」默认跟着你点开的节点走:在画布上点一个「双语提示词检查器」节点,侧边栏就切到它。不想要这个行为,关掉「设置 → 通用」里的「点击节点时跟随切换」,之后只能在面板顶部的节点下拉里手动选。
助手设置不在这个面板里:它在 ComfyUI 的「设置」面板中,「设置 → 双语提示词检查器」下有两项 ——「通用」和「助手设置」,表单都直接铺在设置面板右侧。
助手设置不在这个面板里:它在 ComfyUI 的「设置」面板中,「设置 → 双语提示词检查器」下有三项 ——「说明」「通用」和「助手设置」,表单都直接铺在设置面板右侧。「说明」是固定的一页提示(设置页在重构、位置可能调整),问候相关的开关在「助手设置」的折叠块里。
### 操作反馈
@@ -156,6 +157,16 @@
- **右上角弹窗**:错误、翻译被拒、批量翻译的进度与汇总、重复标签的定位走这里,红色系、停留更久。同类操作会就地更新同一张卡(比如连点重复胶囊是在同一张卡里翻「第 2 / 3 处」),不会一条接一条地冒;批量翻译期间只留一张总进度卡。
- 两个都嫌打扰,可以在「设置 → 通用」里分别关掉「右上角通知弹窗」和「右下角提示条」,关掉后提示退回节点状态栏。
### 右上角问候与提醒
右上角还有一张独立的问候卡,负责 AI 陪你聊天(和通知弹窗互不干扰):
- **进页问候**:按时间段(早上 / 中午 / 下午 / 晚上 / 深夜)说一句;AI 不可用时退回内置文案,离线也有话讲。
- **四种提醒**:「运行时间」每 N 分钟提醒一次(标题显示在线多少分钟);「图片生成」每攒够 N 张夸一次(标题显示已生成多少张);「生成失败时安慰一下」五分之一概率触发——只告诉 AI 失败了,不带报错内容和堆栈;「静下来时招呼一声」在键盘鼠标安静 1 分钟后说句话。另有「随机提醒」每分钟 1/N 概率说一句,N 可填。
- **独立的 AI 配置**:地址、密钥、模型、温度可以单独配一套,也可以直接沿用助手设置那套。只在你开了对应开关时才发请求。
- **提示词可改**:全部集中在 `data/greeting_defaults.json`,想让它说什么样的话直接改这份文件;统一「不要 emoji,可以用颜文字」。
- 各项开关收在「设置 → 助手设置」的问候折叠块里,每项带问号说明。
---
## 界面导览
@@ -318,6 +329,7 @@ git clone https://gitee.com/MengShengZhiJie/ComfyUI-Bilingual-Prompt-Inspector.g
- **默认不联网**:翻译服务的默认选项是纯词库,完全离线。
- **只在你主动使用某个服务时才发请求**:调用百度或 AI 时,发送的是提示词文本与你的指令;密钥只存在后端本地,不回传浏览器、不写入工作流。
- **问候提醒是唯一会自动发请求的功能,且默认全关**:开了对应开关后按定时 / 计数触发,发出去的只有那份提示词要求的上下文(时间段、分钟数、张数这类),「生成失败安慰」只告诉 AI 失败了、不带报错内容和堆栈;不开不发。
- **大型词库按需加载**:不安装就不加载,装了也是本地文件查询。
- **收藏只存在本机**:写在插件目录的 `data/saved/` 下,插件不做任何上传。
+30
View File
@@ -0,0 +1,30 @@
{
"prompt": "你是 ComfyUI 里「双语提示词检查器」插件的小助手,住在界面右上角。\n用户随时会点你一下,你就说一句贴心又有点用的话:可以夸他刚才的提示词写得讲究,也可以提醒他歇一会儿、顺手看看显存。\n格式:第一行是标题,不超过 12 个字;后面最多两行正文。像朋友说话,别喊口号。不要 emoji,可以用颜文字,一个就够。",
"time_prompt": "你是 ComfyUI 插件「双语提示词检查器」里的小提醒员,住在界面右上角。\n用户已经在电脑前坐了一阵子,请温柔又俏皮地劝他起身动一动、喝口水、看看远处。\n可以拿「又跑了几张图」「盯着进度条发呆」这类场景打趣,但别说教、别念健康经。\n格式:只写 2 到 3 句正文,不要标题。不要 emoji,结尾可以带一个颜文字。",
"image_prompt": "你是 ComfyUI 插件「双语提示词检查器」里的小捧场王,住在界面右上角。\n用户刚又生成了一批图片,请用人情味的方式夸他:夸他调参数的手感、夸他蹲到的光影,或者调侃他终于把那张难产的图跑出来了。\n别空喊「太棒了」「继续加油」,最好带一点具体画面感。\n格式:只写 2 到 3 句正文,不要标题。不要 emoji,可以用颜文字,一个就够。",
"welcome_prompt": "你是 ComfyUI 插件「双语提示词检查器」的迎宾小助手,住在界面右上角。\n用户刚打开页面,请按当前时间打一声招呼:早上说早安,中午关心他吃饭没有、天气热不热,下午提提神,傍晚让他早点收工,深夜劝他去睡。\n语气甜一点、像熟人说话,不要 emoji,可以用颜文字,一个就够。\n格式:第一行是标题(不超过 12 字),后面最多两行正文,不要写「欢迎使用」这类客套话。",
"welcome": {
"morning": {
"title": "早上好呀",
"body": "新的一天,先给自己倒杯热的东西,再开工作流也不迟。\n今天的画面慢慢来就好,反正灵感不赶时间。(。・ω・。)"
},
"noon": {
"title": "吃了吗",
"body": "别盯着进度条忘了吃饭,胃比队列更需要照顾。\n要是天气热,就把空调开低一点,脑子凉快了才转得动。(๑•̀ㅂ•́)و✧"
},
"afternoon": {
"title": "下午好",
"body": "这个点最容易犯困,起来走两步、伸个懒腰。\n回来再看那些参数,思路会顺很多。ヽ(・∀・)ノ"
},
"evening": {
"title": "早点休息",
"body": "今天跑了不少图吧,剩下的明天再调也来得及。\n眼睛和灵感都需要充电,别硬撑。(´。• ᵕ •。`)"
},
"night": {
"title": "还醒着呀",
"body": "夜深了,能交给明天的就交给明天。\n睡前记得关掉显示器,让它也歇一晚。(-_-) zzZ"
}
},
"error_prompt": "你是 ComfyUI 插件「双语提示词检查器」的安慰小助手,住在界面右上角。\n用户刚才那一次生成失败了,他现在可能有点烦躁或者沮丧。请安慰他:可以说失败很正常、参数再调调就好、先去喝口水,或者拿画图这件事本身打趣。\n不要分析原因,不要提任何技术细节,不要说「检查你的代码」「看看报错」这类话。\n格式:第一行是标题(不超过 12 字),后面最多两行正文,语气温和,不要 emoji,可以用颜文字。",
"idle_prompt": "你是 ComfyUI 插件「双语提示词检查器」的小陪伴,住在界面右上角。\n用户已经两分钟没碰键盘和鼠标了,可能在想事情、在等出图,或者只是走神了。\n请轻轻招呼一声:可以问他在忙什么、提醒他别发太久呆,或者干脆说句轻松的话。\n别问「你在吗」这种客服腔,也不要说教。\n格式:第一行是标题(不超过 12 字),后面最多两行正文,语气自然,不要 emoji,可以用颜文字。"
}
+223
View File
@@ -0,0 +1,223 @@
"""问候/提醒的配置:提示词 + 可选的独立 AI(不填就用助手设置里的那套)。
单独一个文件而不是塞进 assistant_store:问候是独立功能,以后还要加运行时长、
生成张数这些项目,各自演化更省心。密钥只落本地文件,接口只回「是否已配」。
"""
import json
import os
import tempfile
from pathlib import Path
MAX_PROMPT_CHARS = 8000
MAX_FIELD_CHARS = 500
DEFAULT_TEMPERATURE = 0.8
# 默认文案放在 data/greeting_defaults.json:不进代码、可读可改,也随发布包一起走。
# Python 这边只负责读,读不到就当空字符串(AI 会退化成没有系统提示词的样子)。
DEFAULTS_PATH = Path(__file__).resolve().parent / "data" / "greeting_defaults.json"
def _load_defaults():
try:
payload = json.loads(DEFAULTS_PATH.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
payload = {}
if not isinstance(payload, dict):
payload = {}
welcome = payload.get("welcome")
return {
"prompt": str(payload.get("prompt") or ""),
"time_prompt": str(payload.get("time_prompt") or ""),
"image_prompt": str(payload.get("image_prompt") or ""),
"welcome_prompt": str(payload.get("welcome_prompt") or ""),
"error_prompt": str(payload.get("error_prompt") or ""),
"idle_prompt": str(payload.get("idle_prompt") or ""),
# 进入时的问候:固定文案,按时段分,不走 AI、也不给 UI 编辑
"welcome": welcome if isinstance(welcome, dict) else {},
}
_DEFAULTS = _load_defaults()
DEFAULT_PROMPT = _DEFAULTS["prompt"]
DEFAULT_TIME_PROMPT = _DEFAULTS["time_prompt"]
DEFAULT_IMAGE_PROMPT = _DEFAULTS["image_prompt"]
DEFAULT_WELCOME_PROMPT = _DEFAULTS["welcome_prompt"]
DEFAULT_ERROR_PROMPT = _DEFAULTS["error_prompt"]
DEFAULT_IDLE_PROMPT = _DEFAULTS["idle_prompt"]
def _clamp_temperature(value):
"""温度只收 0–2,写歪了就退回默认值。"""
try:
number = float(value)
except (TypeError, ValueError):
return DEFAULT_TEMPERATURE
return max(0.0, min(2.0, round(number, 2)))
def _clean_flag(value):
"""只有一个开关的规则(失败安慰 / 静默招呼)。"""
raw = value if isinstance(value, dict) else {}
return {"enabled": raw.get("enabled") is True}
def _clean_rule(value, number_key, default_number, low, high):
"""一条场景规则:开关 + 一个数值 + 自己的提示词。"""
raw = value if isinstance(value, dict) else {}
try:
number = int(float(raw.get(number_key, default_number)))
except (TypeError, ValueError):
number = default_number
prompt = raw.get("prompt")
return {
"enabled": raw.get("enabled") is True,
number_key: max(low, min(high, number)),
"prompt": prompt.strip()[:MAX_PROMPT_CHARS] if isinstance(prompt, str) else "",
}
def _default_config_path():
return Path(__file__).resolve().parent / "data" / "greeting" / "config.json"
class GreetingStore:
def __init__(self, config_path=None):
self.path = Path(config_path or _default_config_path())
self._cache = None
def _load(self):
if self._cache is not None:
return self._cache
try:
payload = json.loads(self.path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
payload = {}
if not isinstance(payload, dict):
payload = {}
prompt = payload.get("prompt")
self._cache = {
"prompt": prompt if isinstance(prompt, str) and prompt.strip() else DEFAULT_PROMPT,
"use_assistant_ai": payload.get("use_assistant_ai", True) is not False,
"api_base_url": str(payload.get("api_base_url") or "")[:MAX_FIELD_CHARS],
"model": str(payload.get("model") or "")[:MAX_FIELD_CHARS],
"api_key": str(payload.get("api_key") or "")[:MAX_FIELD_CHARS],
"temperature": _clamp_temperature(payload.get("temperature", DEFAULT_TEMPERATURE)),
"error_rule": _clean_flag(payload.get("error_rule")),
"random_rule": _clean_rule(payload.get("random_rule"), "chance", 10, 2, 1440),
"idle_rule": _clean_flag(payload.get("idle_rule")),
"time_rule": _clean_rule(payload.get("time_rule"), "minutes", 30, 1, 480),
"image_rule": _clean_rule(payload.get("image_rule"), "count", 10, 1, 500),
}
return self._cache
def config(self):
"""给前端看的版本:不回密钥,只回有没有配过。"""
data = self._load()
return {
"prompt": data["prompt"],
"default_prompt": DEFAULT_PROMPT,
"default_time_prompt": DEFAULT_TIME_PROMPT,
"default_image_prompt": DEFAULT_IMAGE_PROMPT,
"welcome": _DEFAULTS["welcome"],
"use_assistant_ai": data["use_assistant_ai"],
"api_base_url": data["api_base_url"],
"model": data["model"],
"api_key_configured": bool(data["api_key"]),
"temperature": data["temperature"],
"error_rule": data["error_rule"],
"random_rule": data["random_rule"],
"idle_rule": data["idle_rule"],
"time_rule": data["time_rule"],
"image_rule": data["image_rule"],
}
def prompt(self):
return self._load()["prompt"]
def prompt_for(self, scene=""):
data = self._load()
if scene == "time":
return data["time_rule"]["prompt"] or DEFAULT_TIME_PROMPT
if scene == "images":
return data["image_rule"]["prompt"] or DEFAULT_IMAGE_PROMPT
if scene == "welcome":
return DEFAULT_WELCOME_PROMPT or DEFAULT_PROMPT
if scene == "error":
return DEFAULT_ERROR_PROMPT or DEFAULT_PROMPT
if scene == "idle":
return DEFAULT_IDLE_PROMPT or DEFAULT_PROMPT
return data["prompt"]
def temperature(self):
return self._load()["temperature"]
def save(self, payload):
if not isinstance(payload, dict):
raise ValueError("请求内容格式不正确")
data = dict(self._load())
if "prompt" in payload:
text = str(payload.get("prompt") or "").strip()[:MAX_PROMPT_CHARS]
if not text:
raise ValueError("提示词不能为空")
data["prompt"] = text
if "use_assistant_ai" in payload:
data["use_assistant_ai"] = payload.get("use_assistant_ai") is not False
for key in ("api_base_url", "model"):
if key in payload:
data[key] = str(payload.get(key) or "").strip()[:MAX_FIELD_CHARS]
# 密钥留空 = 保持原来那份,避免每次改别的字段都要重敲
if payload.get("clear_api_key"):
data["api_key"] = ""
elif payload.get("api_key"):
data["api_key"] = str(payload["api_key"]).strip()[:MAX_FIELD_CHARS]
if "temperature" in payload:
data["temperature"] = _clamp_temperature(payload.get("temperature"))
if isinstance(payload.get("error_rule"), dict):
data["error_rule"] = _clean_flag({**data["error_rule"], **payload["error_rule"]})
if isinstance(payload.get("random_rule"), dict):
data["random_rule"] = _clean_rule(
{**data["random_rule"], **payload["random_rule"]}, "chance", 10, 2, 1440
)
if isinstance(payload.get("idle_rule"), dict):
data["idle_rule"] = _clean_flag({**data["idle_rule"], **payload["idle_rule"]})
if isinstance(payload.get("time_rule"), dict):
merged = {**data["time_rule"], **payload["time_rule"]}
data["time_rule"] = _clean_rule(merged, "minutes", 30, 1, 480)
if isinstance(payload.get("image_rule"), dict):
merged = {**data["image_rule"], **payload["image_rule"]}
data["image_rule"] = _clean_rule(merged, "count", 10, 1, 500)
self._write(data)
return self.config()
def _write(self, data):
self.path.parent.mkdir(parents=True, exist_ok=True)
fd, temp_name = tempfile.mkstemp(prefix="greeting_", suffix=".json", dir=self.path.parent)
try:
with os.fdopen(fd, "w", encoding="utf-8") as handle:
json.dump(data, handle, ensure_ascii=False, indent=2)
handle.write("\n")
os.replace(temp_name, self.path)
finally:
if os.path.exists(temp_name):
os.unlink(temp_name)
self._cache = data
# 走问候自己的 AI 时返回覆盖值;用助手那套则返回 None。
# 配不全直接报错,由调用方转成用户可读的提示。
def ai_override(self):
data = self._load()
if data["use_assistant_ai"]:
return None
base = data["api_base_url"].strip()
model = data["model"].strip()
key = data["api_key"].strip()
missing = [name for name, value in (("API 地址", base), ("模型名称", model), ("密钥", key)) if not value]
if missing:
raise ValueError(
f"问候的 AI 还没配全(缺 {('、'.join(missing))}):请在「问候与提醒 → 问候的 AI」里补齐,"
"或打开「使用助手设置里的 AI」"
)
return {"openai_base_url": base, "openai_model": model, "openai_api_key": key}
+1 -1
View File
@@ -68,7 +68,7 @@ import {
} from "./bpi_shared.js";
const NODE_NAME = "BilingualPromptInspector";
const EXTENSION_VERSION = "v2.2.3";
const EXTENSION_VERSION = "v2.2.4";
const PROJECT_URL = "https://github.com/mengshengzhijie/ComfyUI-Bilingual-Prompt-Inspector";
// 「适应高度」打开后面板能长到的上限。ComfyUI 按 min/max 分配空间,max 必须明显高于 min,
// 否则手动拉大节点多出来的高度全变成空白(distributeSpace 只会在 min..max 之间给)。
+1410
View File
File diff suppressed because it is too large Load Diff
+19 -4
View File
@@ -74,6 +74,20 @@ async function deleteItem(id) {
return readJson(await bpiFetch(`${API_ROOT}/${encodeURIComponent(id)}`, { method: "DELETE" }));
}
// 淡色底在深/浅主题下的观感差很多:亮色背景原本就浅,12% 几乎看不见
function isDarkTheme() {
const root = document.documentElement;
const body = document.body;
if (root.getAttribute("data-bpi-theme") === "dark") return true;
if (root.getAttribute("data-bpi-theme") === "light") return false;
if (body.classList.contains("dark-theme")) return true;
return false;
}
function tintAlpha() {
return isDarkTheme() ? { folder: "1f", child: "0d" } : { folder: "33", child: "1a" };
}
function imageUrl(item) {
return `${API_ROOT}/${encodeURIComponent(item.id)}/image?v=${item.updated_at ?? item.created_at ?? 0}`;
}
@@ -340,13 +354,14 @@ export function createLibraryView({ mount, onLoad, onToast, confirmDelete }) {
const row = element("div", `bpi-lib-row ${isFolder ? "bpi-lib-folder" : "bpi-lib-item"}`);
row.dataset.id = item.id;
row.draggable = true;
// 文件夹的淡色:存的是 #rrggbb,这里补成 12% 透明度铺一层,跟节点里提示词区那种底色一样。
// 文件夹里的条目继承同一个颜色,但更淡一档(5%),一眼能看出它属于哪个组。
// 文件夹的淡色:存的是 #rrggbb,这里按当前主题补透明度铺一层。
// 文件夹里的条目继承同一个颜色,但更淡一档,一眼能看出它属于哪个组。
const tint = tintAlpha();
if (isFolder && item.color) {
row.style.backgroundColor = `${item.color}1f`;
row.style.backgroundColor = `${item.color}${tint.folder}`;
} else if (!isFolder && item.parent) {
const parentColor = byId(item.parent)?.color;
if (parentColor) row.style.backgroundColor = `${parentColor}0d`;
if (parentColor) row.style.backgroundColor = `${parentColor}${tint.child}`;
}
const main = element("div", "bpi-lib-main");
+8 -2
View File
@@ -170,11 +170,11 @@ function field(form, labelText, name, value = "", placeholder = "") {
return input;
}
async function runInspectorAssistant(action, text, instruction = "") {
async function runInspectorAssistant(action, text, instruction = "", scene = "") {
const response = await bpiFetch(`${API_ROOT}/assistant/run`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action, text, instruction }),
body: JSON.stringify({ action, text, instruction, scene }),
});
let result;
try {
@@ -1316,9 +1316,15 @@ function injectBpiStyles() {
.bpi-model-line input{flex:1 1 auto;min-width:0}
.bpi-model-line .bpi-button{flex:none;white-space:nowrap;background-color:var(--bpm-accent);border-color:var(--bpm-accent-border);color:#fff}
.bpi-model-line .bpi-button:hover{background-color:var(--bpm-accent-hover);border-color:var(--bpm-accent-border);color:#fff}
.bpi-switch-line{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
.bpi-greet-settings{display:flex;flex-direction:column;gap:6px;padding:4px 0 2px}
.bpi-greet-settings .bpi-greet-prompt{width:100%;box-sizing:border-box;min-height:96px;padding:7px 9px;border:1px solid var(--bpi-field-border);border-radius:5px;background-color:var(--bpi-input-bg);color:var(--bpi-input-text);font:12px/1.6 Arial,sans-serif;resize:vertical}
.bpi-greet-settings .bpi-modal-actions{display:flex;align-items:center;gap:8px}
.bpi-rule-label{position:relative;display:flex;align-self:start;min-width:0;padding-bottom:16px}
.bpi-rule-orphan{position:absolute;left:0;bottom:0;display:none;width:15px;height:15px;border:1px solid var(--bpi-status-busy);border-radius:50%;background-color:var(--bpi-surface-2);color:var(--bpi-status-busy);font-size:10px;line-height:13px;text-align:center;cursor:help;user-select:none}
.bpi-rule-orphan.bpi-rule-orphan-on{display:block}
/* 折叠开关的激活态:展开时按钮变成强调色,一眼看出「再点一下能收起」 */
.bpi-mini.bpi-toggle-on{background-color:var(--bpm-accent);border-color:var(--bpm-accent-border,var(--bpm-accent));color:#fff}
.bpi-rule-presets{display:flex;gap:6px;align-items:center;margin-left:auto;flex-wrap:nowrap}
.bpi-rule-presets .bpi-select{max-width:150px;padding:2px 6px;font-size:12px}
.bpi-rule-presets .bpi-mini{flex:none;white-space:nowrap;padding:2px 9px;font-size:11px;line-height:1.5}
+170 -34
View File
@@ -49,6 +49,20 @@ import {
testAssistantConnection,
} from "./bpi_shared.js";
import { createLibraryView, openLibraryItemDialog } from "./bpi_library.js";
import {
createGreetingAiPanel,
createGreetingAutoPanel,
createGreetingSettingsPanel,
generateGreeting,
greetingEnabled,
loadGreetingConfig,
restartGreetingAutoTimer,
setGreetingRule,
setWelcomeEnabled,
welcomeEnabled,
setGreetingEnabled,
startGreeting,
} from "./bpi_greeting.js";
const SECTIONS = [
["tag-manager", "Tag Manager"],
@@ -643,6 +657,27 @@ function appearanceControl() {
return appearanceSettingsNode;
}
// 「说明」单独一页,挂在插件分类下的第一项,不属于通用或助手设置
let noticeSettingsNode = null;
function noticeControl() {
if (noticeSettingsNode) {
alignWhenMounted(noticeSettingsNode);
return noticeSettingsNode;
}
noticeSettingsNode = element("div", "bpi-settings-form");
const text = element("div", "", "说明:设置页正在重构,近期加入了不少功能,部分位置可能还会调整。");
text.style.lineHeight = "1.9";
text.style.fontSize = "13px";
const second = element("div", "", "使用中遇到问题或有建议,欢迎在 GitHub 提 issue;给你带来的不便,还请谅解。");
second.style.marginTop = "6px";
second.style.lineHeight = "1.9";
second.style.fontSize = "13px";
noticeSettingsNode.append(text, second);
alignWhenMounted(noticeSettingsNode);
return noticeSettingsNode;
}
async function buildAppearanceForm(host, loading) {
let config;
try {
@@ -699,7 +734,100 @@ async function buildAppearanceForm(host, loading) {
followToggle.addEventListener("change", () => setFollowSelectionEnabled(followToggle.checked));
const followNote = element("div", "bpi-config-note", "开:在画布上点一个「双语提示词检查器」节点,侧边栏的「标签管理」就切到它。关:只能在「标签管理」的节点下拉里手动选。");
followNote.style.gridColumn = "1 / -1";
form.append(languageLabel, languageSelect, languageNote, followRow, followNote, status);
// 问候/提醒:独立的右上角通知,自己的开关;右边的「测试」用来立刻看效果(以后会删)
const greetLine = element("div", "bpi-switch-line");
greetLine.style.gridColumn = "1 / -1";
const greetRow = element("label", "bpi-switch-row bpi-switch-accent");
const greetToggle = element("input", "bpi-switch");
greetToggle.type = "checkbox";
greetToggle.checked = greetingEnabled();
const greetTrack = element("span", "bpi-switch-track");
greetTrack.appendChild(element("span", "bpi-switch-thumb"));
greetRow.append(greetToggle, greetTrack, element("span", "bpi-switch-text", "显示问候与提醒"));
greetToggle.addEventListener("change", () => setGreetingEnabled(greetToggle.checked));
// 规则直接内联展开:弹窗在这里会被 ComfyUI 的设置面板压住,输入框点不动
// 每次进入时问候:固定文案,按时段挑一段,不走 AI
const welcomeRow = element("label", "bpi-switch-row bpi-switch-accent");
const welcomeToggle = element("input", "bpi-switch");
welcomeToggle.type = "checkbox";
welcomeToggle.checked = welcomeEnabled();
const welcomeTrack = element("span", "bpi-switch-track");
welcomeTrack.appendChild(element("span", "bpi-switch-thumb"));
const welcomeHelp = element("span", "bpi-help", "?");
welcomeHelp.appendChild(element("span", "bpi-help-pop",
"打开页面时请 AI 按当前时间说一句问候(早上/中午/下午/晚上/深夜各不相同)。AI 不可用时退回内置文案;两者的提示词都在 data/greeting_defaults.json,界面上不提供编辑。"));
welcomeRow.append(welcomeToggle, welcomeTrack, element("span", "bpi-switch-text", "每次进入时问候"), welcomeHelp);
welcomeToggle.addEventListener("change", () => setWelcomeEnabled(welcomeToggle.checked));
// 失败安慰 / 静默招呼:开关一点就存,不用进去点保存
const makeSideSwitch = (text, help, rule, extraNodes = []) => {
const row = element("label", "bpi-switch-row bpi-switch-accent");
const toggle = element("input", "bpi-switch");
toggle.type = "checkbox";
const track = element("span", "bpi-switch-track");
track.appendChild(element("span", "bpi-switch-thumb"));
const mark = element("span", "bpi-help", "?");
mark.appendChild(element("span", "bpi-help-pop", help));
row.append(toggle, track, element("span", "bpi-switch-text", text), mark, ...extraNodes);
toggle.addEventListener("change", async () => {
try {
await setGreetingRule(rule, toggle.checked);
restartGreetingAutoTimer();
} catch (failure) {
toggle.checked = !toggle.checked;
notify(failure.message, "error");
}
});
return { row, toggle };
};
const errorSwitch = makeSideSwitch(
"生成失败时安慰一下",
"生成失败时有 1/5 的概率让 AI 安慰你一句。只告诉它「刚才失败了」,不会把报错内容和堆栈发过去。提示词文件:data/greeting_defaults.json(error_prompt 段)。",
"error_rule",
);
const idleSwitch = makeSideSwitch(
"静下来时招呼一声",
"键盘鼠标安静超过 1 分钟,就让 AI 说句话。中间只要敲键盘、点鼠标或滚动滚轮就重新计时。提示词文件:data/greeting_defaults.json(idle_prompt 段)。",
"idle_rule",
);
const greetPanel = createGreetingSettingsPanel();
const greetSettings = button("设置", () => {
greetPanel.hidden = !greetPanel.hidden;
// 展开时按钮变蓝,提示「再点一下收起」
greetSettings.classList.toggle("bpi-toggle-on", !greetPanel.hidden);
}, "bpi-mini");
// 请求:按「设置」里写的要求现问一次 AI,结果直接弹成右上角那张卡
const greetRequest = button("请求", async () => {
const card = startGreeting({ title: "正在生成" });
try {
const { title, body } = await generateGreeting();
card.setTitle(title);
card.finish(body);
} catch (failure) {
// 失败不占着卡片:直接收掉,用右上角通知说明原因
card.dismiss();
notify(failure.message || "请求失败", "error");
}
}, "bpi-mini");
greetLine.append(greetRow, greetSettings, greetRequest);
// 包成折叠块:以后生成张数、运行时长、日志这些都往这里塞
const greetFold = element("details", "bpi-fold");
greetFold.style.gridColumn = "1 / -1";
const greetFoldBody = element("div", "bpi-fold-body");
const greetAiPanel = createGreetingAiPanel();
const greetAutoPanel = createGreetingAutoPanel();
// 编辑区紧跟开关行:展开「问候的 AI」「自动提示」也不会把输入框挤到看不见
greetFoldBody.append(greetLine, welcomeRow, errorSwitch.row, idleSwitch.row, greetPanel, greetAiPanel, greetAutoPanel);
loadGreetingConfig()
.then((config) => {
errorSwitch.toggle.checked = config.error_rule?.enabled === true;
idleSwitch.toggle.checked = config.idle_rule?.enabled === true;
})
.catch(() => {});
greetFold.append(element("summary", "", "问候与提醒"), greetFoldBody);
form.append(languageLabel, languageSelect, languageNote, followRow, followNote, greetFold, status);
restartGreetingAutoTimer();
// 开:操作提示改成右上角弹窗;关:还是写回明细表上方那条状态文字
const fold = element("details", "bpi-fold");
const foldBody = element("div", "bpi-fold-body");
@@ -1058,6 +1186,12 @@ async function buildAssistantForm(host, loading) {
form.append(openaiHead);
openaiOnly.push(openaiHead);
const openaiBaseUrl = addField(openaiOnly, "API URL", "assistant-openai-base-url", config.openai_base_url, "https://host/v1");
const openaiApiKey = addField(openaiOnly, "API Key", "assistant-openai-api-key", "", config.openai_api_key_configured ? "Saved; leave empty to keep" : "Optional for local LM Studio");
openaiApiKey.type = "password";
const clearApiKey = clearSwitch("Clear saved API Key");
const apiKeySpacer = element("span");
form.append(apiKeySpacer, clearApiKey.row);
openaiOnly.push(apiKeySpacer, clearApiKey.row);
const openaiModel = addModelRow(openaiOnly, "assistant-openai-model", config.openai_model, "provider model ID", {
backend: "openai_compatible",
baseUrl: () => openaiBaseUrl,
@@ -1074,12 +1208,6 @@ async function buildAssistantForm(host, loading) {
presetLine.append(lmStudioPreset, element("span", "bpi-config-note", "Connects to localhost:1234 by default; no external API required."));
form.append(presetLine);
openaiOnly.push(presetLine);
const openaiApiKey = addField(openaiOnly, "API Key", "assistant-openai-api-key", "", config.openai_api_key_configured ? "Saved; leave empty to keep" : "Optional for local LM Studio");
openaiApiKey.type = "password";
const clearApiKey = clearSwitch("Clear saved API Key");
const apiKeySpacer = element("span");
form.append(apiKeySpacer, clearApiKey.row);
openaiOnly.push(apiKeySpacer, clearApiKey.row);
const openaiTemperature = addField(openaiOnly, "Temperature", "assistant-openai-temperature", config.openai_temperature, "0–2");
openaiTemperature.type = "number";
openaiTemperature.min = "0";
@@ -1132,7 +1260,7 @@ async function buildAssistantForm(host, loading) {
ollamaOnly.push(ollamaUrlNote);
// 规则:只有 AI 会读,两个后端各一套(server.py 按当前后端取对应的那套)
const addRule = (list, labelText, value) => {
const addRule = (list, labelText, value, container = form) => {
const labelElement = element("label", "", labelText);
const textarea = element("textarea");
textarea.value = value;
@@ -1141,42 +1269,43 @@ async function buildAssistantForm(host, loading) {
const orphan = element("span", "bpi-rule-orphan", "*");
setTitle(orphan, "这条规则不属于任何一个预设");
labelCell.append(labelElement, orphan);
form.append(labelCell, textarea);
container.append(labelCell, textarea);
list.push(labelCell, textarea);
textarea.orphanBadge = orphan;
return textarea;
};
// 四条规则归到一个分组里:以后切规则预设时整体一起换,也方便在标题行右侧放管理入口
const addRuleHead = (list, title, hint) => {
const head = element("div", "bpm-toolbar");
head.style.gridColumn = "1 / -1";
head.append(element("strong", "", title));
if (hint) {
const note = element("span", "bpi-config-note", hint);
note.style.marginLeft = "8px";
head.append(note);
}
form.append(head);
list.push(head);
return head;
// 四条规则收进一个折叠块:默认收起,省得设置页太长
const addRuleFold = (list, title, hint) => {
const fold = element("details", "bpi-fold");
fold.style.gridColumn = "1 / -1";
const body = element("div", "bpi-fold-body");
const toolbar = element("div", "bpm-toolbar");
const grid = element("div", "bpi-form");
body.append(toolbar);
if (hint) body.append(element("div", "bpi-config-note", hint));
body.append(grid);
fold.append(element("summary", "", title), body);
form.append(fold);
list.push(fold);
return { fold, body, toolbar, grid };
};
// 规则编辑和保存是两件事:改完不点保存,刷新或执行翻译时用的还是旧规则
const RULE_SAVE_HINT = "修改需保存后生效;未保存的规则不参与翻译";
const openaiRulesHead = addRuleHead(openaiOnly, "AI 规则", RULE_SAVE_HINT);
const ollamaRulesHead = addRuleHead(ollamaOnly, "AI 规则", RULE_SAVE_HINT);
const openaiRulesFold = addRuleFold(openaiOnly, "AI 规则", RULE_SAVE_HINT);
const ollamaRulesFold = addRuleFold(ollamaOnly, "AI 规则", RULE_SAVE_HINT);
const rules = {
openai_compatible: {
translation: addRule(openaiOnly, "Translation Rule", config.openai_translation_rule),
translateOptimize: addRule(openaiOnly, "Translate & Optimize", config.openai_translate_optimize_rule),
optimization: addRule(openaiOnly, "Optimize Rule", config.openai_optimization_rule),
natural: addRule(openaiOnly, "Natural Language", config.openai_natural_rule),
translation: addRule(openaiOnly, "Translation Rule", config.openai_translation_rule, openaiRulesFold.grid),
translateOptimize: addRule(openaiOnly, "Translate & Optimize", config.openai_translate_optimize_rule, openaiRulesFold.grid),
optimization: addRule(openaiOnly, "Optimize Rule", config.openai_optimization_rule, openaiRulesFold.grid),
natural: addRule(openaiOnly, "Natural Language", config.openai_natural_rule, openaiRulesFold.grid),
},
ollama: {
translation: addRule(ollamaOnly, "Translation Rule", config.ollama_translation_rule),
translateOptimize: addRule(ollamaOnly, "Translate & Optimize", config.ollama_translate_optimize_rule),
optimization: addRule(ollamaOnly, "Optimize Rule", config.ollama_optimization_rule),
natural: addRule(ollamaOnly, "Natural Language", config.ollama_natural_rule),
translation: addRule(ollamaOnly, "Translation Rule", config.ollama_translation_rule, ollamaRulesFold.grid),
translateOptimize: addRule(ollamaOnly, "Translate & Optimize", config.ollama_translate_optimize_rule, ollamaRulesFold.grid),
optimization: addRule(ollamaOnly, "Optimize Rule", config.ollama_optimization_rule, ollamaRulesFold.grid),
natural: addRule(ollamaOnly, "Natural Language", config.ollama_natural_rule, ollamaRulesFold.grid),
},
};
// —— AI 规则预设:标题行右侧一排(下拉 + 另存为 + 删除),一套规则一个文件 ——
@@ -1331,8 +1460,8 @@ async function buildAssistantForm(host, loading) {
refresh();
return { refresh, markOrphans };
};
const openaiRulesBar = buildRulePresetBar(openaiRulesHead, "openai", rules.openai_compatible);
const ollamaRulesBar = buildRulePresetBar(ollamaRulesHead, "ollama", rules.ollama);
const openaiRulesBar = buildRulePresetBar(openaiRulesFold.toolbar, "openai", rules.openai_compatible);
const ollamaRulesBar = buildRulePresetBar(ollamaRulesFold.toolbar, "ollama", rules.ollama);
form.appendChild(error);
@@ -1878,6 +2007,13 @@ app.registerExtension({
// 助手设置整体挂在 ComfyUI 的「设置」面板里:项名和分类由前端原生渲染,
// 不走插件的 t(),直接写中文;控件本体是 assistantSettingsControl() 返回的那块 DOM。
settings: [
{
id: "BilingualPromptInspector.Notice",
name: "说明",
type: noticeControl,
defaultValue: "",
category: ["双语提示词检查器", "说明"],
},
{
id: "BilingualPromptInspector.Appearance",
name: "通用",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "comfyui-bilingual-prompt-inspector",
"version": "2.2.3",
"version": "2.2.4",
"description": "ComfyUI bilingual prompt inspection, translation, Anima sorting, and on-demand Danbooru dictionary extension.",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "prompt-translator-manager"
version = "2.2.3"
version = "2.2.4"
# 与 Comfy Registry 页面上显示的描述保持一致: publish 会用这里的值覆盖网页上的手改
description = "ComfyUI prompt node with translation and management: bilingual tag view, drag-to-reorder, temporary hiding, multiple translation services and a sidebar dictionary manager.\nComfyUI 提示节点,支持翻译和管理:双语标签视图、拖拽排序、临时隐藏、多翻译服务和侧边栏词典管理器。"
license = { file = "LICENSE" }
+48 -6
View File
@@ -42,6 +42,7 @@ from .saved_prompt_store import (
)
from .library_store import LibraryStore
from .rule_preset_store import RulePresetStore
from .greeting_store import GreetingStore
from .token_color_store import TokenColorStore
@@ -205,13 +206,16 @@ async def _baidu_translate(text, to_lang):
return result
async def _call_ai_assistant(action, text, instruction, config, connection_test=False):
async def _call_ai_assistant(action, text, instruction, config, connection_test=False, scene=""):
"""翻译/优化都走这里。优化类恒走 AI;翻译类在翻译服务就是某个 AI 后端时也走 AI。"""
# 两个后端各一套配置和规则。选 OpenAI / Ollama 当翻译服务时它就是后端;
# 选词库 / 百度时翻译不走 AI,优化仍走,这时用 ai_provider 决定读哪一套
prefix = AI_PROVIDER_PREFIXES.get(config.get("translate_service")) or AI_PROVIDER_PREFIXES.get(
config.get("ai_provider", "openai_compatible"), "openai"
)
# 问候有自己的温度,和翻译那套分开:不论用哪个后端,这里都换成问候的设置
if action == "greeting":
config = {**config, f"{prefix}_temperature": greeting_store.temperature()}
if not config.get(f"{prefix}_model"):
raise ValueError("尚未填写 AI 模型名称(“翻译并优化”“优化”以及 AI 翻译都需要)")
rule = {
@@ -221,6 +225,8 @@ async def _call_ai_assistant(action, text, instruction, config, connection_test=
"optimize": config[f"{prefix}_optimization_rule"],
# 自然语言模式下的「优化」:输入可以是中文,输出是英文自然语句,不走 Anima 标点规则
"optimize_natural": config[f"{prefix}_natural_rule"],
# 问候按场景取:手动请求 / 久坐提醒 / 生成张数,各有各的那句要求
"greeting": greeting_store.prompt_for(scene),
}[action]
if connection_test:
rule = "你是连接测试助手。"
@@ -236,6 +242,16 @@ async def _call_ai_assistant(action, text, instruction, config, connection_test=
if re.search(r"[\u3400-\u9fff]", text):
raise ValueError("“优化”在标签模式下只接受英文;中文或混合文本请使用“翻译并优化”,或把模式切到“自然语言”")
rule = f"{rule}\n\n本次任务:整理现有英文内容,不承担翻译。"
elif not connection_test and action == "greeting":
if scene in {"time", "images"}:
# 这两种自动提示的标题由前端固定(比如「当前在线 32 分钟」),AI 只负责正文
rule = f"{rule}\n\n本次任务:只写正文,2 到 3 句,不要标题、不要列表、不要前缀标签。"
elif scene == "welcome":
rule = f"{rule}\n\n本次任务:按上面的要求打一声招呼,第一行写标题。"
elif scene in {"error", "idle"}:
rule = f"{rule}\n\n本次任务:按上面的要求说一句,第一行写标题,不要提报错细节。"
else:
rule = f"{rule}\n\n本次任务:按上面的要求写一条新的提醒。"
elif not connection_test and action == "optimize_natural":
# 自然语言模式允许中文输入:先译后整理,输出一段英文自然语句
direction = "先把中文忠实译为英文,再整理表达" if translation_direction(text) == "to_english" else "整理现有英文内容,不承担翻译"
@@ -293,15 +309,25 @@ async def _call_ai_assistant(action, text, instruction, config, connection_test=
return result
async def _call_configured_assistant(action, text, instruction="", connection_test=False):
async def _call_configured_assistant(action, text, instruction="", connection_test=False, scene=""):
config = assistant_store.config()
if action == "greeting" and not connection_test:
override = greeting_store.ai_override()
if override:
# 问候单独配的 AI:强制走 OpenAI 兼容那套分支
config = {**config, **override, "translate_service": "openai_compatible"}
elif config["translate_service"] not in AI_SERVICES:
raise ValueError(
"问候没有可用的 AI:请打开「问候与提醒 → 问候的 AI → 使用助手设置里的 AI」,"
"并把助手设置切到 AI 翻译,或在问候里单独填一套 API"
)
translate_service = config["translate_service"]
# “翻译并优化”“优化”恒走 AI;翻译/解释在翻译服务就是某个 AI 后端时也走 AI
needs_ai = action in {"translate_optimize", "optimize", "optimize_natural"} or (
needs_ai = action in {"translate_optimize", "optimize", "optimize_natural", "greeting"} or (
action in {"translate", "explain"} and translate_service in AI_SERVICES
)
if needs_ai:
return await _call_ai_assistant(action, text, instruction, config, connection_test)
return await _call_ai_assistant(action, text, instruction, config, connection_test, scene)
# 以下只处理 translate/explain 且翻译服务不是 AI
if translate_service == "dictionary":
if connection_test:
@@ -418,7 +444,8 @@ async def run_assistant(request):
action = str(payload.get("action", "translate")) if isinstance(payload, dict) else "translate"
text = str(payload.get("text", "")) if isinstance(payload, dict) else ""
instruction = str(payload.get("instruction", "")) if isinstance(payload, dict) else ""
if action not in {"translate", "explain", "translate_optimize", "optimize", "optimize_natural"}:
scene = str(payload.get("scene", "")) if isinstance(payload, dict) else ""
if action not in {"translate", "explain", "translate_optimize", "optimize", "optimize_natural", "greeting"}:
raise ValueError("未知的助手操作")
if not text.strip():
raise ValueError("输入内容为空")
@@ -437,7 +464,7 @@ async def run_assistant(request):
except asyncio.TimeoutError as error:
raise AssistantLimitError("已有多个助手任务正在运行,请稍后再试") from error
try:
result = await _call_configured_assistant(action, text, instruction)
result = await _call_configured_assistant(action, text, instruction, scene=scene)
finally:
_assistant_slots.release()
return web.json_response({"success": True, "data": {"text": result, "translate_service": assistant_store.config()["translate_service"]}})
@@ -449,6 +476,20 @@ async def run_assistant(request):
return error_response(error, 502)
@protected_route("get", "/bpi/greeting/config")
async def get_greeting_config(_request):
return web.json_response({"success": True, "data": greeting_store.config()})
@protected_route("post", "/bpi/greeting/config")
async def save_greeting_config(request):
try:
payload = await _read_json(request, _MAX_ASSISTANT_JSON_BYTES)
return web.json_response({"success": True, "data": greeting_store.save(payload)})
except (ValueError, web.HTTPBadRequest) as error:
return error_response(error)
@protected_route("get", "/bpi/dictionary")
async def get_dictionary(_request):
try:
@@ -783,6 +824,7 @@ async def cancel_upstream(request):
saved_prompt_store = SavedPromptStore()
library_store = LibraryStore()
rule_preset_store = RulePresetStore()
greeting_store = GreetingStore()
# multipart 正文 = 文本字段 + 一张可选图片(图片本身最大 5 MB)
_MAX_SAVED_PROMPT_BYTES = MAX_IMAGE_BYTES + 512 * 1024