From 743a2bc33aa523cfb8164dface526e343ea0f3bf Mon Sep 17 00:00:00 2001 From: DemonGatanjieu <207458273+DemonGatanjieu@users.noreply.github.com> Date: Wed, 11 Nov 2026 19:06:44 +0800 Subject: [PATCH] Let the user hide a few words inside a text the AI reads The gist of the prompt box and a message of a private task went to the AI whole or not at all. The Hide button marks the selected words; seal_marked turns the marked words into tokens when the text is handed in, and the page shows them again as the words they stand for. Co-Authored-By: Claude Fable 5.2 --- ARCHITECTURE.md | 4 +++- CHANGELOG.md | 2 ++ api/ai_panels.py | 6 +++++- api/ai_sealed.py | 10 ++++++++- api/skills/privacy.json | 2 +- docs/architecture/ui-flows.md | 1 + docs/guides/mcp.md | 2 +- tests/test_ai_sealed.py | 5 +++++ web/modules/ai_hide_mark.js | 38 +++++++++++++++++++++++++++++++++ web/modules/locales.js | 6 ++++++ web/modules/ui_ai_message.js | 5 +++++ web/modules/ui_ai_prompt_box.js | 10 ++++++--- web/modules/ui_ai_step_body.js | 5 +++-- web/modules/ui_ai_workbench.js | 2 +- web/styles/38-ai-panels.css | 6 +++++- 15 files changed, 92 insertions(+), 12 deletions(-) create mode 100644 web/modules/ai_hide_mark.js diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index d8c3625..53ee15a 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -104,7 +104,9 @@ covered by the `styles.css` manifest. node's STRING inputs by ComfyUI's node list, prompt fields, picture names, any sealed text), `expand` on a call's arguments, `seal_answer` for the user's prompt box (a "prompts" panel block, `web/modules/ui_ai_prompt_box.js`, with the prompt_pose skill's words for the canvas's model; - its first field is the gist, the picture in words the AI may read, handed on as written; + its first field is the gist, the picture in words the AI may read, handed on as written but for the + words the user marked to hide, which go as tokens inside it; `web/modules/ai_hide_mark.js` is that + mark and its button, also in a private task's message box (`ai_sealed.seal_marked`); the privacy skill tells the AI how to work). `api/ai_sealed_models.py` keeps a private task's model names too (the Gates window's "Model names in a private task", on unless turned off): each model of the library is one token ("[M1]", sealed as its model_id) however an answer writes it (model_id, loader name, file name in a text, diff --git a/CHANGELOG.md b/CHANGELOG.md index e4bd3a9..e602d1c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -72,6 +72,8 @@ - **Privacy mode now says what it cannot cover**: it filters what Anomalous sends to the AI. An AI app that reads the disk and runs commands by itself (Antigravity, Cursor, Claude Code and the like) can open the output folder and Anomalous's task records, and this cannot stop it; a private task run by such an app says so under its header, and so do the switch's hints. A chat app that only calls tools is not affected. - 隐私模式里,**你起的名字也都是代号了**:工作流卡片名、提示词库的条目名、它们的标签,以及图片信息里“用哪张卡片、哪个搭配做的”。这些名字里常常写着模型名或画的内容。AI 用代号照样能放搭配、用卡片;你在聊天里说出名字,它照样搜得到。 - In privacy mode **the names you gave are tokens too**: workflow card names, saved prompt names, their tags, and which card or combo a picture was made with. Such names often say the model or what is drawn. The AI still places a combo and uses a card by its token, and still finds one by a name you say in the chat. +- 隐私模式新增 **藏**:在提示词框的“画面大意”里,或隐私任务的留言框里,选中几个字点“藏”,这几个字 AI 只拿到代号,其余的它照常看、照常改。不用再为了几个词把整段都藏起来。 +- Privacy mode has **Hide**: in the gist of the prompt box, or in a private task's message box, select a few words and press Hide; the AI gets a token for them and reads and edits the rest. A few words no longer mean hiding the whole text. ## v1.63.0 (AI Ways and Skills, Saves, Official Example Workflows, Privacy Mode) — 2026-11-10 diff --git a/api/ai_panels.py b/api/ai_panels.py index ef4663a..635b4cb 100644 --- a/api/ai_panels.py +++ b/api/ai_panels.py @@ -351,13 +351,17 @@ def leave_message(task_id, text, refs=None, now=None): """A message from the user to the AI working on task `task_id`, with what its "@" point at and the pictures attached (ai_message_refs.py); words may be left out when something is attached. Returns the step.""" - from . import ai_message_refs + from . import ai_message_refs, ai_sealed try: refs = ai_message_refs.clean(refs) except ai_message_refs.RefsError as error: raise PanelError(str(error)) text = _text(text, MAX_MESSAGE, "text", not refs) + if ai_sealed.MARKED.search(text): # words the user marked to hide reach the AI as tokens + seals = ai_sealed.Seals(task_id) + text = ai_sealed.seal_marked(seals, text) + seals.save() now = time.time() if now is None else now step = {"id": uuid.uuid4().hex[:10], "at": now, "ms": 0, "tool": "user_message", "kind": "user", "ok": True, "summary": "user_message", "text": text, "delivered": False, "results": [], **({"refs": refs} if refs else {})} diff --git a/api/ai_sealed.py b/api/ai_sealed.py index a3db125..f322712 100644 --- a/api/ai_sealed.py +++ b/api/ai_sealed.py @@ -14,7 +14,8 @@ when asked to (`models`), picture file names anywhere, and last any sealed text shows up. seal_answer() takes the user's prompt box (a "prompts" panel block, ui_ai_prompt_box.js): the prompts and reference pictures, sealed, with the tokens the AI gets for them, and the gist: the -picture in words the user wrote for the AI to read, handed on as written. +picture in words the user wrote for the AI to read, handed on as written but for the words they +marked to hide (seal_marked, also for a message they leave): those are tokens inside it. """ import json @@ -38,6 +39,7 @@ MIN_SEALED = 3 # shorter texts are not looked for elsewhere (they would match i MAX_PROMPTS = 8 MAX_PROMPT = 4000 MAX_GIST = 1000 +MARKED = re.compile("\u27e6([^\u27e6\u27e7]*)\u27e7") # words the user marked to hide, the page's button (web/modules/ai_hide_mark.js) MAX_PICTURES = 4 _loose = {} # tokens of calls tied to no task, while this ComfyUI runs @@ -195,6 +197,11 @@ def mask(result, task_id, info, tool="", models=False): return result +def seal_marked(seals, text): + """`text` with the words the user marked as tokens of `seals`; the rest stays for the AI to read.""" + return MARKED.sub(lambda match: seals.seal(match.group(1).strip()) if match.group(1).strip() else "", text) + + def seal_answer(task_id, prompts, negative="", pictures=None, gist=""): """The user's prompt box: {prompts, negative, pictures} as tokens, the texts and picture names sealed in the task, the gist as the user wrote it for the AI (left out when empty), and a line saying them.""" @@ -208,6 +215,7 @@ def seal_answer(task_id, prompts, negative="", pictures=None, gist=""): if isinstance(picture, dict) and isinstance(picture.get("filename"), str) and PICTURE.fullmatch(picture["filename"]): named.append("/".join(part for part in (str(picture.get("subfolder") or "").strip("/"), picture["filename"]) if part)) seals = Seals(task_id) + gist = seal_marked(seals, gist) answer = {"prompts": [seals.seal(item) for item in prompts], "negative": seals.seal(negative) if negative else "", "pictures": [seals.seal(item, "img") for item in named]} seals.save() diff --git a/api/skills/privacy.json b/api/skills/privacy.json index d448c33..86926a0 100644 --- a/api/skills/privacy.json +++ b/api/skills/privacy.json @@ -12,7 +12,7 @@ "text": [ "This task is private: Anomalous sends you no pictures and no prompt text, only tokens. You still build, change and run workflows.", "Ask the user for prompts with a prompt box: show_panel(blocks=[{\"type\": \"prompts\", \"id\": \"p\", \"question\": \"what to write\", \"count\": 1 to 8, \"negative\": true, \"pictures\": true}]); pictures lets them add reference pictures. Then get_answers (wait_seconds 55): you get prompts [\"[P1]\", …], negative \"[P2]\" and pictures [\"[img1]\"], never the text, and gist when the user wrote one. Never ask for prompt text in the chat.", - "gist is the picture in words the user wrote for you to read (who, where, the shot, the mood). With a gist, write the rest of the prompt yourself to match it: light, lens, composition, style and quality words as the canvas's model reads them, around the tokens (\"[P1], rainy night, car window, side close-up, cinematic lighting\"); with a gist and no prompts, write the whole prompt from it. With no gist, pass the tokens on and add little: never guess what a token says.", + "gist is the picture in words the user wrote for you to read (who, where, the shot, the mood). With a gist, write the rest of the prompt yourself to match it: light, lens, composition, style and quality words as the canvas's model reads them, around the tokens (\"[P1], rainy night, car window, side close-up, cinematic lighting\"); with a gist and no prompts, write the whole prompt from it. With no gist, pass the tokens on and add little: never guess what a token says. A gist, or a message of the user, may hold a token where they hid a few words: keep it where it stands.", "Write a token where its text goes and Anomalous puts the text back: run_workflow(prompts=[\"[P1]\", \"[P3]\"], negative=\"[P2]\"), set_prompt(prompt=\"[P1]\"), image=\"[img1]\".", "Other prompt text and picture names come as tokens too (the canvas, picture info, saved prompts, the user's changes): pass them on as they are.", "Models come as tokens when the user hides their names: [M1] is one model wherever it shows (search_models, the canvas, a combo). Pass it where a model_id goes (set_model, add_lora, a plan step's model, get_model) and as a loader's value. Pick by what stays readable: type, base_model, author_weight and author_examples, loaded_by, and note_for_ai (the user's line on what it is for); its trigger words are [P] tokens: put them in the prompt. A name the user says in the chat still finds its model with search_models. Unsure which one they mean: show the candidates (a models panel, items [{\"model_id\": \"[M1]\"}]) and ask; they see the real cards. Never ask for a model's name.", diff --git a/docs/architecture/ui-flows.md b/docs/architecture/ui-flows.md index 52fb235..e3c704e 100644 --- a/docs/architecture/ui-flows.md +++ b/docs/architecture/ui-flows.md @@ -46,6 +46,7 @@ node tools/ui_map.mjs build 写出 docs/ui-map.md:按页面列 | 「撤销画布上的改动」 | 按操作记录把 AI 在画布上的改动一步步撤销(`canvas_undo.js`) | 无 | | ⋯ 里的「改名」 / 「从列表移除」 | 移除只删这条记录,图、画布、工作流都不动 | `POST /anomalous/ai_tasks/{task_id}` | | ⋯ 里的「打开隐私模式」 | 这个任务的图不再发给 AI;开着时标题栏多一个「隐私模式」,点它关掉(`ui_ai_privacy.js`) | `POST /anomalous/ai_tasks/{task_id}`(privacy) | +| 提示词框“画面大意”旁和隐私任务留言框里的「藏」 | 先选中几个字再点:这几个字 AI 只拿到代号,其余照常看(`ai_hide_mark.js`) | 随回答或留言一起交出,`ai_sealed.seal_marked` | | ⋯ 里的「发给 AI 了什么」 | 隐私任务才有:列出 AI 每次调用发来的参数和 AMB 回给它的文字,能搜一个词看它出没出去过(`ui_ai_sent.js`) | `GET /anomalous/ai_tasks/{task_id}/sent` | | 标题栏下的黄色提醒「隐私模式只管 AMB 发给 AI 的内容…」 | 隐私任务的 AI 软件能自己读硬盘时出现(`ui_ai_privacy.js` 的 `privacyReach`) | 任务记录里的 `reads_disk` | | ⋯ 里的「模型代号对照」 | 隐私任务把模型名藏起来后才有:列出 AI 说的 [M1]、[M2] 各是哪个模型(`ui_ai_privacy.js` 的 `modelTokens`) | 无(任务记录里的 `sealed`) | diff --git a/docs/guides/mcp.md b/docs/guides/mcp.md index ae140cd..8a1feb3 100644 --- a/docs/guides/mcp.md +++ b/docs/guides/mcp.md @@ -167,7 +167,7 @@ claude mcp add --transport http anomalous http://127.0.0.1:8188/anomalous/mcp **隐私模式防的是什么**:它过滤的是 **AMB 发给 AI 的内容**,只会调用工具的聊天软件(比如 Cherry Studio 接 DeepSeek)因此什么都看不到。但 Antigravity、Cursor、Claude Code、Gemini CLI 这类软件能自己读硬盘、跑命令:出图文件夹里的图、AMB 的任务记录(代号对应的原文就存在里面)它都能直接打开,AMB 拦不住。用这类软件开隐私任务时,任务标题栏下面会有一行提醒。想亲眼确认发出去了什么:任务标题栏 **⋯ → 发给 AI 了什么**,里面是隐私模式开着时 AI 每一次调用发来的参数和 AMB 回给它的文字,一字不差;在上面的框里搜一个词(某个模型名、提示词里的词),就知道它有没有出去过。只留最近 300 次调用,任务移除时一起删掉。 -**隐私模式**:有些图和提示词不方便给 AI 看时,点任务标题栏的 **🔒**,这个任务就成了隐私任务。AMB 一张图都不发给 AI(放大看图、图片信息里的图、跑完附带的预览、你递给它的图、AI 软件里任务卡片上的图都一样),它只知道出了几张、成没成功。提示词原文和图片文件名换成 **[P1]**、**[img1]** 这样的代号:画布上节点的文字输入(按 ComfyUI 自己的节点定义认,第三方节点也一样;模型名、数字、选项照常给)、图片信息、提示词库、搭配、工作流的设置,连同你给它们起的名字和标签(搭配名、工作流卡片名、提示词库的条目名;没改过名的搭配,名字就是模型名加提示词开头)、模型的触发词和介绍、你自己改画布的记录都一样,同一段文字永远是同一个代号。AI 用代号去写提示词、跑图、填加载图片、放挑图面板,AMB 在工具执行前换回原文,所以画布和出图都是原样,你看到的也都是原样;时间线上记的是代号。要新提示词时,AI 会放一个 **提示词框**:写几条提示词(可以再加)、反向提示词,可以从图库选或把图拖进来当参考图,点 **交给 AI(提示词只给代号)**;原文只存在 AMB 里,交出后框里显示代号和你写的原文。提示词框最上面还有一格 **画面大意**:这一格是原样给 AI 看的,用不介意让它看到的话说说想要什么(谁、在哪、什么镜头、什么气氛),它就能照着补光影、镜头和画风,再把你的私密提示词以代号嵌进去;两格可以只填一格。框上方按画布主模型的大小提醒你(来自 skill“写提示词·动作适合模型”)。AI 怎么在隐私任务里干活写在 skill“隐私模式·怎么干活”里,开工时会被指过去。隐私任务里 AI 只拿得到这一个 skill,做法面板的 skill 一栏也只显示它;普通任务正好反过来,只显示、只给普通的 skill。**关卡** 窗口里的“隐私模式”设新任务默认开不开;默认改成关时,原来按默认开着的任务仍然保持隐私。AI 的某次调用认不出属于哪个任务时,只要默认开着或者还有没做完的隐私任务,就按隐私处理。注意:任务做到一半才打开时,之前已经给过 AI 的收不回来;你自己在留言或 AI 聊天框里写的文字和贴的图,AMB 不拦(那是你主动给的)。**模型名也藏起来**:隐私任务里,模型和 LoRA 的文件名、卡片上的名字、所在文件夹、C 站链接和指纹都不发给 AI,一个模型不管出现在哪(搜索结果、画布的加载器、搭配、报错)都是同一个 **[M1]** 这样的代号;AI 把代号填到要用模型的地方,AMB 换回真模型。它挑模型靠的是留给它的这些:类型、底模、作者推荐的权重和参数、触发词(代号),以及你在模型的 **编辑** 窗口里写的 **给 AI 的用途说明**(一句不介意让它看到的话,比如“双人互动姿势”;不填就不给)。你在聊天里说出模型名,它照样搜得到,拿到的还是代号;它拿不准时会放一个模型面板让你在真卡片里挑。AI 嘴里的 [M1] 是哪个模型,点任务标题栏 **⋯ → 模型代号对照** 查。不想藏的话,在 **关卡** 窗口把“隐私任务里的模型名”改成“照常给 AI”。有一种情况藏不住:卡片名只是一个普通英文单词的模型(比如就叫 Anima),名字出现在一整句话中间时不替换,免得把正常的说明也替换坏;它的文件名和搜索结果里的名字照样是代号。 +**隐私模式**:有些图和提示词不方便给 AI 看时,点任务标题栏的 **🔒**,这个任务就成了隐私任务。AMB 一张图都不发给 AI(放大看图、图片信息里的图、跑完附带的预览、你递给它的图、AI 软件里任务卡片上的图都一样),它只知道出了几张、成没成功。提示词原文和图片文件名换成 **[P1]**、**[img1]** 这样的代号:画布上节点的文字输入(按 ComfyUI 自己的节点定义认,第三方节点也一样;模型名、数字、选项照常给)、图片信息、提示词库、搭配、工作流的设置,连同你给它们起的名字和标签(搭配名、工作流卡片名、提示词库的条目名;没改过名的搭配,名字就是模型名加提示词开头)、模型的触发词和介绍、你自己改画布的记录都一样,同一段文字永远是同一个代号。AI 用代号去写提示词、跑图、填加载图片、放挑图面板,AMB 在工具执行前换回原文,所以画布和出图都是原样,你看到的也都是原样;时间线上记的是代号。要新提示词时,AI 会放一个 **提示词框**:写几条提示词(可以再加)、反向提示词,可以从图库选或把图拖进来当参考图,点 **交给 AI(提示词只给代号)**;原文只存在 AMB 里,交出后框里显示代号和你写的原文。提示词框最上面还有一格 **画面大意**:这一格是原样给 AI 看的,用不介意让它看到的话说说想要什么(谁、在哪、什么镜头、什么气氛),它就能照着补光影、镜头和画风,再把你的私密提示词以代号嵌进去;两格可以只填一格。大意里个别不想让它看到的词,选中后点 **藏**:这几个字变成 ⟦这样⟧,交出去时 AI 只拿到代号,其余的它照常看、照常改。隐私任务的留言框里也有这个 **藏**。框上方按画布主模型的大小提醒你(来自 skill“写提示词·动作适合模型”)。AI 怎么在隐私任务里干活写在 skill“隐私模式·怎么干活”里,开工时会被指过去。隐私任务里 AI 只拿得到这一个 skill,做法面板的 skill 一栏也只显示它;普通任务正好反过来,只显示、只给普通的 skill。**关卡** 窗口里的“隐私模式”设新任务默认开不开;默认改成关时,原来按默认开着的任务仍然保持隐私。AI 的某次调用认不出属于哪个任务时,只要默认开着或者还有没做完的隐私任务,就按隐私处理。注意:任务做到一半才打开时,之前已经给过 AI 的收不回来;你自己在留言或 AI 聊天框里写的文字和贴的图,AMB 不拦(那是你主动给的)。**模型名也藏起来**:隐私任务里,模型和 LoRA 的文件名、卡片上的名字、所在文件夹、C 站链接和指纹都不发给 AI,一个模型不管出现在哪(搜索结果、画布的加载器、搭配、报错)都是同一个 **[M1]** 这样的代号;AI 把代号填到要用模型的地方,AMB 换回真模型。它挑模型靠的是留给它的这些:类型、底模、作者推荐的权重和参数、触发词(代号),以及你在模型的 **编辑** 窗口里写的 **给 AI 的用途说明**(一句不介意让它看到的话,比如“双人互动姿势”;不填就不给)。你在聊天里说出模型名,它照样搜得到,拿到的还是代号;它拿不准时会放一个模型面板让你在真卡片里挑。AI 嘴里的 [M1] 是哪个模型,点任务标题栏 **⋯ → 模型代号对照** 查。不想藏的话,在 **关卡** 窗口把“隐私任务里的模型名”改成“照常给 AI”。有一种情况藏不住:卡片名只是一个普通英文单词的模型(比如就叫 Anima),名字出现在一整句话中间时不替换,免得把正常的说明也替换坏;它的文件名和搜索结果里的名字照样是代号。 **这次跑的节点结构**:每次运行那一步下面写着一行“底模 · 几个 LoRA · 尺寸 · 步数 · 几个节点”,点开有 **简单** 和 **完整** 两种看法(AMB 记得你上次选的)。简单:按每一段采样列出“用哪个模型加哪些 LoRA、提示词是什么、从空白画布还是从上一段的结果(放大多少)开始”,最后保存在哪;后一段和前一段一样的就写“和第 1 段一样”。完整:方块图,哪个底模、LoRA 和权重、正负提示词开头、尺寸、采样器设置、保存,方块之间的线标着类型。两种看法里点任意一项都能定位到画布上的节点。这张图是 AMB 按 ComfyUI 实际收到的工作流画的,不靠 AI 描述。上面的黄色小块是 **比上次改了什么**(比如“K采样器·步数:20 → 30”“新增「加载LoRA」”)。AI 讲解时可以用 `show_run_map` 一次把同一张图放上展台,不用自己拼。 diff --git a/tests/test_ai_sealed.py b/tests/test_ai_sealed.py index 7f05de7..71a5190 100644 --- a/tests/test_ai_sealed.py +++ b/tests/test_ai_sealed.py @@ -225,6 +225,11 @@ class SealedTests(unittest.TestCase): both = ai_sealed.seal_answer(self.task, ["canarypos, 1girl"], "", None, "a girl by a window") self.assertEqual((both["prompts"], both["gist"]), (["[P1]"], "a girl by a window")) self.assertNotIn("canarypos", json.dumps(both)) + marked = ai_sealed.seal_answer(self.task, [], "", None, "a girl in \u27e6canaryecho dress\u27e7 by a window") + self.assertRegex(marked["gist"], r"^a girl in \[P\d+\] by a window$", "the words the user marked are a token inside it") + self.assertEqual(ai_sealed.expand(self.task, marked["gist"]), "a girl in canaryecho dress by a window") + left = ai_panels.leave_message(self.task, "make the \u27e6canaryneg\u27e7 bigger, \u27e6 \u27e7 please") + self.assertRegex(left["text"], r"^make the \[P\d+\] bigger, please$", "and in a message they leave") def test_the_users_changes_are_told_sealed(self): ai_tasks.record("test", "describe_canvas", {}, {"structuredContent": {}, "content": []}, time.time(), True, task_id=self.task) diff --git a/web/modules/ai_hide_mark.js b/web/modules/ai_hide_mark.js new file mode 100644 index 0000000..44702d7 --- /dev/null +++ b/web/modules/ai_hide_mark.js @@ -0,0 +1,38 @@ +/** + * Words the user hides from the AI inside a text it otherwise reads (the gist of the prompt box, + * a message of a private task). The button marks what is selected \u27e6like this\u27e7; Anomalous seals + * the marked words when the text is handed in (api/ai_sealed.py seal_marked), so the AI gets a + * token in their place. withHidden() shows such a text to the user again, each token as the + * words it stands for. + */ + +import { translate as t } from './locales.js'; + +const OPEN = '\u27e6'; +const CLOSE = '\u27e7'; + +/** The button for text field `field`; `say(text)` tells the user why nothing was marked. */ +export function hideButton(field, say) { + const node = document.createElement('button'); + node.type = 'button'; + node.className = 'anomalous-ai-hide-mark'; + node.textContent = t('aiHideMark'); + node.title = t('aiHideMarkHint'); + node.addEventListener('mousedown', event => event.preventDefault()); // the field keeps its selection + node.onclick = () => { + const picked = field.value.slice(field.selectionStart, field.selectionEnd); + if (!picked.trim() || picked.includes(OPEN) || picked.includes(CLOSE)) { + say?.(t('aiHideMarkNone')); + return; + } + field.setRangeText(`${OPEN}${picked}${CLOSE}`, field.selectionStart, field.selectionEnd, 'end'); + field.dispatchEvent(new Event('input', { bubbles: true })); + field.focus(); + }; + return node; +} + +/** `text` as the user wrote it: the tokens of `sealed` (the task's) as their words, marked. */ +export function withHidden(text, sealed) { + return String(text ?? '').replace(/\[P\d+\]/g, token => (typeof sealed?.[token] === 'string' ? `${OPEN}${sealed[token]}${CLOSE}` : token)); +} diff --git a/web/modules/locales.js b/web/modules/locales.js index 5b74559..64344b6 100644 --- a/web/modules/locales.js +++ b/web/modules/locales.js @@ -1504,6 +1504,9 @@ export const i18n = { aiPromptBoxGist: "画面大意:AI 看得到,可以不填", aiPromptBoxGistPlaceholder: "用不介意让 AI 看到的话说说想要什么,比如:红发女孩、雨夜车窗、侧身特写、电影感。AI 会照着补上光影、镜头和画风。", aiPromptBoxSecret: "私密提示词:AI 看不到,只拿到代号", + aiHideMark: "藏", + aiHideMarkHint: "先选中几个字再点:这几个字 AI 只拿到代号,其余的它照常看", + aiHideMarkNone: "先在框里选中要藏的字,再点“藏”", aiPromptBoxGistGiven: "AI 看到的大意", aiPromptBoxPicsHint: "点图片来选,最多 {max} 张。AI 拿不到图本身,只拿到 [img1] 这样的代号。", aiPromptBoxPicsFull: "参考图最多 {max} 张。", @@ -5308,6 +5311,9 @@ export const i18n = { aiPromptBoxGist: "The gist: the AI reads this (optional)", aiPromptBoxGistPlaceholder: "Say what you want in words you do not mind the AI reading: red-haired girl, rainy night, car window, side close-up, cinematic. The AI adds light, lens and style to match.", aiPromptBoxSecret: "Private prompts: the AI never reads these, it gets tokens", + aiHideMark: "Hide", + aiHideMarkHint: "Select a few words first: the AI gets a token for them and reads the rest", + aiHideMarkNone: "Select the words to hide first, then press Hide", aiPromptBoxGistGiven: "The gist the AI read", aiPromptBoxPicsHint: "Click pictures to pick them, up to {max}. The AI never gets the pictures, only tokens like [img1].", aiPromptBoxPicsFull: "Up to {max} reference pictures.", diff --git a/web/modules/ui_ai_message.js b/web/modules/ui_ai_message.js index d293d46..036e2f4 100644 --- a/web/modules/ui_ai_message.js +++ b/web/modules/ui_ai_message.js @@ -105,6 +105,11 @@ export function messageBox(onSend, source) { box.addEventListener('anomalous-refs', counted); field.addEventListener('input', counted); line.prepend(pics, materials); + const hide = hideButton(field, (text) => { note.textContent = text; }); // a private task: words the AI gets as a token + const shown = () => { hide.hidden = !source.task?.()?.private; }; + field.addEventListener('focus', shown); + shown(); + line.insertBefore(hide, send); takePictures(box, field, { add: item => at.add(item), room, say: (text) => { note.textContent = text; note.classList.add('is-error'); diff --git a/web/modules/ui_ai_prompt_box.js b/web/modules/ui_ai_prompt_box.js index 5087f5c..cbfb667 100644 --- a/web/modules/ui_ai_prompt_box.js +++ b/web/modules/ui_ai_prompt_box.js @@ -3,7 +3,8 @@ * user writes the prompts here, and a negative one and reference pictures (from the gallery, or * dropped in) when the AI asks for them. The texts stay in Anomalous; the AI gets tokens for them * (api/ai_sealed.py). The first field is the other kind: the gist, the picture in words the AI may - * read, so it can write light, lens and style around the tokens; either kind alone can be handed in. + * read, so it can write light, lens and style around the tokens; either kind alone can be handed in, + * and a few words of the gist can be hidden by themselves (ai_hide_mark.js). * Above the fields, what suits the canvas's model (the prompt_pose skill's words, * the step's prompt_advice). Answered, it shows what the user wrote beside the token the AI has. * ui_ai_panel.js draws it and hands it its helpers (sending, the question's head, the answer's note). @@ -12,6 +13,7 @@ import { resolveLocale, translate as t } from './locales.js'; import { galleryPicker, thumbUrl } from './ui_ai_gallery_pick.js'; import { takePictures } from './ui_ai_drop.js'; +import { hideButton, withHidden } from './ai_hide_mark.js'; const MAX_PROMPTS = 8; const MAX_PICTURES = 4; @@ -57,7 +59,7 @@ function handedIn(answer, sealed) { }; if (answer.gist) { const row = el('div', 'anomalous-ai-prompt-given-row'); - row.append(el('span', 'anomalous-ai-prompt-label', t('aiPromptBoxGistGiven')), el('span', 'anomalous-ai-prompt-text', answer.gist)); + row.append(el('span', 'anomalous-ai-prompt-label', t('aiPromptBoxGistGiven')), el('span', 'anomalous-ai-prompt-text', withHidden(answer.gist, sealed))); box.append(row); } (answer.prompts || []).forEach((token, at) => line(token, t('aiPromptBoxPrompt', { n: at + 1 }))); @@ -112,7 +114,9 @@ export function promptBox(block, ctx, { sendAnswer, askHead, skipButton, answerN for (let at = 0; at < Math.max(1, Math.min(MAX_PROMPTS, block.count || 1)); at++) addPrompt(); const gist = field('gist', t('aiPromptBoxGistPlaceholder'), 2); gist.maxLength = 1000; - box.append(el('div', 'anomalous-ai-prompt-part', t('aiPromptBoxGist')), gist, + const gistHead = el('div', 'anomalous-ai-prompt-part', t('aiPromptBoxGist')); + gistHead.append(hideButton(gist, (text) => { note.textContent = text; })); + box.append(gistHead, gist, el('div', 'anomalous-ai-prompt-part', t('aiPromptBoxSecret')), fields, more); const negative = block.negative ? field('negative', t('aiPromptBoxNegative'), 2) : null; if (negative) box.append(negative); diff --git a/web/modules/ui_ai_step_body.js b/web/modules/ui_ai_step_body.js index 2a385c1..88a3dc1 100644 --- a/web/modules/ui_ai_step_body.js +++ b/web/modules/ui_ai_step_body.js @@ -20,6 +20,7 @@ import { canvasChanges } from './ui_canvas_changes.js'; import { deliveryState } from './ui_ai_delivery.js'; import { decisionCard } from './ui_ai_decisions.js'; import { imageResults } from './ui_ai_images.js'; +import { withHidden } from './ai_hide_mark.js'; function el(tag, className, text) { const node = document.createElement(tag); @@ -103,7 +104,7 @@ function userChange(step, entries) { return box; } -/** The nodes under step `step`'s title. `ctx`: {taskId, owner, entries (activity entries by id), numberOf(stepId)}. */ +/** The nodes under step `step`'s title. `ctx`: {taskId, owner, entries (activity entries by id), numberOf(stepId), sealed (the task's texts behind its tokens)}. */ export function stepBody(step, ctx) { switch (step.tool) { case 'user_marks': return [marksStep(step, ctx.taskId, ctx.owner)]; @@ -124,7 +125,7 @@ export function stepBody(step, ctx) { } default: if (step.kind === 'decision') return [decisionCard(step.decision || {}, ctx.numberOf)]; - if (step.kind === 'user') return [...(step.text ? [said(step.text)] : []), ...namedPictures(step, ctx.owner), deliveryState(step)]; + if (step.kind === 'user') return [...(step.text ? [said(withHidden(step.text, ctx.sealed))] : []), ...namedPictures(step, ctx.owner), deliveryState(step)]; return step.text ? [note(step.text)] : []; } } diff --git a/web/modules/ui_ai_workbench.js b/web/modules/ui_ai_workbench.js index e06ed5e..a35fa38 100644 --- a/web/modules/ui_ai_workbench.js +++ b/web/modules/ui_ai_workbench.js @@ -164,7 +164,7 @@ function stepRow(step, number) { text.title = detail; line.append(text); } - body.append(line, ...stepBody(step, { taskId: state.task?.id, owner: state.owner, entries: state.entries, numberOf: id => state.numbers.get(id), steps: state.task?.steps })); + body.append(line, ...stepBody(step, { taskId: state.task?.id, owner: state.owner, entries: state.entries, numberOf: id => state.numbers.get(id), steps: state.task?.steps, sealed: state.task?.sealed })); const other = step.reply_to || step.replied; if (other) { body.append(iconButton(`anomalous-ai-reply-link${step.replied ? ' is-replied' : ''}`, step.replied ? 'chat' : 'undo', diff --git a/web/styles/38-ai-panels.css b/web/styles/38-ai-panels.css index 03b9e3c..0e2b53a 100644 --- a/web/styles/38-ai-panels.css +++ b/web/styles/38-ai-panels.css @@ -564,7 +564,11 @@ /* The prompt box of a private task (ui_ai_prompt_box.js). */ .anomalous-ai-prompt-advice { margin: 2px 0 8px; padding: 6px 10px; border-left: 3px solid var(--amb-accent); border-radius: 4px; background: color-mix(in srgb, var(--amb-accent) 8%, transparent); color: var(--amb-text-soft); font-size: 12.5px; line-height: 1.5; } .anomalous-ai-prompt-fields { display: flex; flex-direction: column; gap: 6px; } -.anomalous-ai-prompt-part { margin: 10px 0 0; color: var(--amb-text-soft); font-size: 12.5px; font-weight: 600; } +.anomalous-ai-prompt-part { display: flex; align-items: center; gap: 8px; margin: 10px 0 0; color: var(--amb-text-soft); font-size: 12.5px; font-weight: 600; } +/* Marks the selected words as hidden from the AI (ai_hide_mark.js): beside the gist, and in a private task's message box. */ +.anomalous-ai-hide-mark { flex: none; padding: 1px 9px; border: 1px solid color-mix(in srgb, var(--amb-warn) 50%, transparent); border-radius: 999px; background: none; color: var(--amb-warn); font: inherit; font-size: 12px; font-weight: 400; cursor: pointer; } +.anomalous-ai-hide-mark:hover { background: color-mix(in srgb, var(--amb-warn) 12%, transparent); } +.anomalous-ai-hide-mark[hidden] { display: none; } .anomalous-ai-prompt-part + .anomalous-ai-prompt-fields { margin-top: 6px; } .anomalous-ai-prompt-field { width: 100%; box-sizing: border-box; resize: vertical; padding: 7px 9px; border: 1px solid var(--amb-border-strong); border-radius: 7px; background: var(--amb-bg-input); color: var(--amb-text-main); font: inherit; font-size: 13px; line-height: 1.5; } .anomalous-ai-prompt-box > .anomalous-ai-prompt-field { margin-top: 6px; }