feat(ai): draw item names, variables and item lists in chat replies like the editors

A name the model puts in backticks (`Hair Color` or `[[Hair Color]]`)
is now a chip like any other mention. Variables read the way the editors
draw them: accessors in amber, .neg in red, and the variables the run
sets ($iteration, $model_family and the other WP Model Info ones)
marked read-only. A list of three or more items shows as an item list
with each item's kind, name and category chip.
This commit is contained in:
DumiFlex
2026-10-27 04:43:40 +00:00
parent 507ddc6a51
commit 1d62923923
12 changed files with 202 additions and 14 deletions
+1 -1
View File
@@ -8,7 +8,7 @@ This release adds an AI assistant that runs on a model you choose, gives every r
The assistant is new and **off by default**. Turn it on in **Settings › AI assistant**. It works with Ollama, LM Studio, llama.cpp or any OpenAI-compatible server on your PC, or with the Claude or OpenAI API and your own key. Keys stay on ComfyUI's side and never go back to the browser. **Test connection** checks the model, its context size and whether it can use tools. Local models need at least 16k of context, and 32k is recommended for chats that take several steps.
- **Draft with AI.** Every editor has an **AI** button. In the wildcard editor it writes new options in the style of yours, or rewrites, retags, re-weights or removes the ones you select. You tick through a list before anything changes. Fixed values, combines, templates, derivations and constraints get drafts too. With Thinking on Auto a local model answers the panels without thinking, so a 3-option draft takes seconds instead of minutes; a model that thinks anyway is stopped after about 4,000 thinking tokens and asked again, and the working line counts its thinking. Asking for 3 options lands 3 after duplicates are dropped. The rule and text panels see the variables your request is about in full and the rest by name only, about 4,000 fewer tokens on a large library. A name two wildcards share (two Outfit wildcards) is no longer guessed: the panel points at the one you mean by its variable. When new options of a one-thing wildcard (Hair style) also set something else you didn't ask for (an outfit), the panel asks once more without it. A text panel that loops on the same words is stopped after about 1,500 tokens and asked once more, so a stuck template answer comes back in seconds instead of four minutes, and a Fixed values, combine or template draft that leaves out a variable your request names ("$light is warm lamp light") is asked once more for it. Try again and Refine no longer share one "Answer n of m" pager: each request is a line above the answer with its own **try 2 of 3** switcher, different refines of one answer get a **refine 1 of 2** switcher, and refining any try branches from that try, so you can compare tries of the same request side by side. A number in the request now sets **How many** ("draft 6 desert weather options" drafts 6, not 10), also when the chat opens the panel. The model sees the tags each existing option has, and a new tag it puts on every new option, the wildcard's own library tags and negatives nobody asked for are left off and listed under the result. Retag that "adds" a tag keeps the options' other tags, "half as likely" halves each option's current weight instead of setting 0.5 (which made a 0.1 option five times more common), a weight that moves the other way than asked is left out, and a rewrite or retag of "every" option that skips some is asked once more for those.
- **Chat.** The **AI** button in the top bar opens a chat that can search your library, test stacks on the real engine and answer from these docs. Changes come back as cards you **Apply** or **Dismiss**, with **Undo**. Type **@** to name an item or **$** for a variable, or select any text in the manager to have it explained. Picked items show as chips while you type, and each reply shows how long it took. Adding a tag to options keeps the tags they had, and a card that takes a tag off every option warns when a constraint still has rules for it. Change cards draw the change the way the editor shows the item: a wildcard edit lists the options it touches with + for new ones, removed ones struck through and old → new for text, share of the draw, tags, negative and fallback, with the untouched ones folded into a count; a change to an item's description, tags, category or rating shows old → new with the category's own chip, and category, tag and bulk cards show library chips instead of lines of text.
- **Chat.** The **AI** button in the top bar opens a chat that can search your library, test stacks on the real engine and answer from these docs. Changes come back as cards you **Apply** or **Dismiss**, with **Undo**. Type **@** to name an item or **$** for a variable, or select any text in the manager to have it explained. Picked items show as chips while you type, and each reply shows how long it took. Adding a tag to options keeps the tags they had, and a card that takes a tag off every option warns when a constraint still has rules for it. Change cards draw the change the way the editor shows the item: a wildcard edit lists the options it touches with + for new ones, removed ones struck through and old → new for text, share of the draw, tags, negative and fallback, with the untouched ones folded into a count; a change to an item's description, tags, category or rating shows old → new with the category's own chip, and category, tag and bulk cards show library chips instead of lines of text. In replies, a variable is drawn like the editors draw it (accessors in amber, `.neg` in red, and variables the run sets, like `$iteration` or `$model_family`, marked read-only), an item name the model put in backticks is a chip like any other, and a list of three or more items shows as an item list with each one's category chip.
- **Pack builder.** In the sidebar. Describe a pack and it plans the choices and rules, asks a few questions, builds every item, tests it on 200 seeds, fixes what failed and installs it as one bundle with one Undo. It knows how each image model reads a prompt (Pony, Illustrious, Flux and more) and can plan a story over a Context Loop. Its item builds skip a local model's thinking on Auto like the Draft with AI panels, the Negatives setting now holds for every pack (Template only or None leave options without negatives), and an outfit that accepts two kinds of shoes no longer gets an empty Shoes option that left some prompts barefoot. A rule on a shared list ("each shirt in a colour from one list, colours fit the season") now runs before the items that draw from the list, so the colour in the shirt follows it; the list no longer prints a second colour at the end of the prompt, and the test checks the colour in the shirt. Fixing stops sending a problem that came back unchanged after a rebuild. The plan now holds to your request: "a hat 30% of the time" leaves the hat out in 70% of runs, "the same hero in every image" holds the hero, and an outfits pack shows the outfit, set by the code itself. A rule the request names with no rule in the plan ("winter never gets pastel colours") goes back to the planner. A plan that runs into the model's output limit is asked for once more, shorter. A rule written as prompt text ("exclude pastels") is left out instead of printed, an outfit with rules on it no longer picks two (one casual and one formal left only boots), a shared list drawn by another wildcard no longer breaks its rule every run, and the test catches a $name the prompt prints as written. A rule on a colour drawn from a shared list now reaches the drawn colour, "sneakers with casual, heels with formal, boots with either" becomes the rules when the planner wrote them wrong or left them out, a story's beats no longer describe a different hero each frame, and one option that crams a dozen heroes together becomes a dozen options, and a request for "every image of a loop" runs as a story instead of single images, so its held hero stays held. A new Plan drafts choice, When needed (now the default), drafts the plan a second time only when the code sees a problem in the first and keeps the better one, so a clean first plan costs nothing extra. Each wildcard's options are now asked for grouped by the value its rules are about (winter, summer), so every value has options from the first try instead of a second call to fill the gaps. A template that starts with "$quality $style" no longer prints both twice, and a negative that names a $variable nothing sets no longer prints it as written. A colour wildcard whose options already name their colour no longer gets a second, different colour from a shared list in front, and a list of up to 10 values gets an option for every value. The Template, Combine and Fixed values panels can no longer run on to the output limit on one long line. A plan with "Casual Outfit" and "Formal Outfit" printed both in every prompt and its shoe rules left no shoe, so the fallback was always taken; the two are now one Outfit slot with a casual and a formal kind. A rule that adds to the pick it reads ("cap" after "a person in a baseball cap") is left out, the test now sees rules keyed on a rolled tag fire instead of reporting them as never fired, and a rule whose words read a variable ("wearing a $top") is checked against the top the run picked. "A person who wears a hat 30% of the time" now leaves the hat out, not the person, even when the planner wrote the share the wrong way round or gave the hat two picks. Two rules that add words to the same thing on the same value ("at night: moody shadows" and "at night: volumetric darkness") now both apply; only the first did. A pack for Pony and Flux no longer rolls the image model or the prompt style as a pick nothing reads, and no longer prints Pony's quality words (with "1girl, solo") on every Flux image: the checkpoint decides, a fixed value can no longer write over $model_family, score tags stay on the Pony row of the quality rule (and come out of style presets and other wildcards' options), the quality rule is one if/else even when the model writes it as two (the second's "otherwise" used to write over Pony's tags), and $quality stays in the template (the duplicate check used to take it out, since the test runs load no model). An outfit wildcard named like its kinds no longer prints a bare "casual" or "formal", and a shoe a rule names ("sneakers with casual") becomes an option when the model's options still leave it out.
- **Pack builder settings that fit the pack.** The Settings step lists the settings that fit your request, each with why: Natural phrases and no negatives for Flux, Tags for Pony, Per model family when the pack is for two models, NSFW for an explicit request, Reuse what fits when you ask for your own wildcards. **Use recommended** takes them; nothing changes until you press it. The chat's plan card recommends settings too, and its button is now **Open in Pack builder**: it fills in the description without starting the plan, so you can add reference images and check the settings first.
- **Small local models.** A 9B model on a 16 GB card is a supported setup. The assistant fits its instructions and tools to the model and works through bigger asks one step at a time. A message that touches a feature (refs, constraints, rules, negatives and more) brings a few lines on how it works. Tool calls can only name items that exist, and edits only the ones you pointed at. Each step offers about ten tools that fit the message, and a question gets an answer instead of a card. Before it finishes, the code checks its cards against what you asked for (constraints, "only" rules, fallbacks, negatives, tag filters) and asks once for what is missing, a reply that talks about something no card has says so, a reply that claims a change it never made gets one more step with only the change tools, and a tool it keeps repeating is closed for the rest of the answer.
+18 -1
View File
@@ -1180,7 +1180,10 @@ class Library(MoreTools, ManageTools):
when several items have that name: the one with the variable
written next to it, else the one this answer read, else the open
one (`_pick_named`);
- ``**A** → **B**`` becomes the constraint from A to B.
- ``**A** → **B**`` becomes the constraint from A to B;
- a code span holding a mention (`` `[[Name]]` ``) or exactly the
name of one item (`` `Hair Color` ``) becomes a chip: models put
names in backticks, which kept them plain code.
"""
if not message:
return message
@@ -1300,6 +1303,17 @@ class Library(MoreTools, ManageTools):
return self._chip(row) if row is not None else m.group(0)
line = re.sub(r"\[\[([^\[\]\n]{1,80})\]\]", chip, line)
# A name in a code span: `[[Name]]`, or exactly one item's name.
def coded(m: re.Match[str]) -> str:
body = m.group(1).strip()
if re.fullmatch(r"\[\[[^\[\]\n]{1,80}\]\]", body):
return body
if self._name_count(body) != 1:
return m.group(0)
row = self._pick_named(body)
return self._chip(row) if row is not None else m.group(0)
line = re.sub(r"(?<!`)`([^`\n]{2,80})`(?!`)", coded, line)
# "**A** → **B**": the constraint between them.
def pair(m: re.Match[str]) -> str:
row = self._constraint_between(m.group(2), m.group(4))
@@ -1332,6 +1346,9 @@ class Library(MoreTools, ManageTools):
refs[raw] = {"id": "", "type": "", "name": name, "shared": str(same)}
continue
ref = {"id": row["id"], "type": self._kind(row), "name": str(row.get("name") or name)}
if row.get("category_id"):
# For item lists, which show each item's category chip.
ref["category"] = str(row["category_id"])
if row.get("_proposed"):
# Not created yet: the chip says so instead of opening it.
ref["pending"] = str(row["_proposed"])
+1 -1
View File
@@ -502,7 +502,7 @@
},
{
"title": "Good to know",
"text": "How it works\nReplies. Replies use Markdown: headings, lists, tables, quotes and code blocks with a copy button. Prompt syntax is coloured like in the editors, every item it mentions is a chip you can click, and lines starting \"Tip:\" or \"Warning:\" (or a GitHub-style [!NOTE] alert) stand out. Links show as text and are never opened. Code blocks over 20 lines are folded.\nFinding the right item. Before the model answers, the chat searches your library for what your message is about and hands it the clear matches, so \"make the outfit wildcard warmer\" starts from the right item. The search reads option texts, values and rules, not only names, and broad subjects widen (\"characters\" also finds hair and eye wildcards). It runs on a full-text index, so it stays quick with thousands of items. A how-to question also gets pointers to the matching docs sections.\nWhich items it may change. Items you named (\"add rain to my Weather\", a chip), the one you have open, ones its last answer showed you as a chip, or ones it read in full for your request. When several share a name, the one you named or it already read is meant. Options a constraint points at are never removed this way. If you edit an item after asking, its card says it's out of date. A card made before the item changed is blocked, so it can't undo the newer change.\nCards. A new item's card already carries its future id, so cards that point at it are linked from the start. Several changes to one item come as one Change card with one Apply and one Undo. Deletes, links and library-wide changes stay single cards. A card shows its first lines with \"+N more\"; the magnifier shows all of it. Grey notes only tell you something and have no button.\nKeeping it honest. The assistant tests module changes before it answers, so the reply says what the change does. An answer that claims something was made or tested with no call behind it, puts part of your request off until later, comes back empty or garbled, or names a setting or variable that nothing mentions is sent back once to fix it. A tool call written as text (\"I call: read …\") still runs, and the leftovers of a JSON step (\"calls: []\") never show in the reply. It doesn't ask \"should I go ahead?\", since the card is already the asking.\nLimits. One answer reads about 6 items with a small model, 8 with a medium one and 12 with a large or cloud one before it must answer. A local model gets up to 20 minutes per answer, a cloud model 10. \"Cut off\" means the model hit its output limit; thinking models can spend it all before they answer.\nMemory. The chat keeps every search, read, test run and card, and is told what you did with each card and what changed in your library, so \"use the second one\" or \"undo that\" works. Each request starts with exactly what the last one sent, so the server can reuse what it already read: follow-ups are faster and, on a paid provider, cheaper. In a long chat, old tool results shrink and the oldest turns may be left out. The summary is written by your own model and kept with the chat. \"~\" on the context bar means an estimate, and \"about\" means a cloud model's usual size.\nQuestions. Number keys pick in the focused question, and some questions take several answers. It asks at most twice per message, then decides and says what it assumed."
"text": "How it works\nReplies. Replies use Markdown: headings, lists, tables, quotes and code blocks with a copy button. Prompt syntax is coloured like in the editors: a variable in its own colour with .0 / .SHOES in amber and .neg in red, and a variable the run sets ($iteration, $model_family and the other WP Model Info ones) underlined dotted, since rules can read it but nothing writes it. Every item it mentions is a chip you can click, also when the model wrote the name in backticks, and a list of three or more items shows as an item list with each item's category. Lines starting \"Tip:\" or \"Warning:\" (or a GitHub-style [!NOTE] alert) stand out. Links show as text and are never opened. Code blocks over 20 lines are folded.\nFinding the right item. Before the model answers, the chat searches your library for what your message is about and hands it the clear matches, so \"make the outfit wildcard warmer\" starts from the right item. The search reads option texts, values and rules, not only names, and broad subjects widen (\"characters\" also finds hair and eye wildcards). It runs on a full-text index, so it stays quick with thousands of items. A how-to question also gets pointers to the matching docs sections.\nWhich items it may change. Items you named (\"add rain to my Weather\", a chip), the one you have open, ones its last answer showed you as a chip, or ones it read in full for your request. When several share a name, the one you named or it already read is meant. Options a constraint points at are never removed this way. If you edit an item after asking, its card says it's out of date. A card made before the item changed is blocked, so it can't undo the newer change.\nCards. A new item's card already carries its future id, so cards that point at it are linked from the start. Several changes to one item come as one Change card with one Apply and one Undo. Deletes, links and library-wide changes stay single cards. A card shows its first lines with \"+N more\"; the magnifier shows all of it. Grey notes only tell you something and have no button.\nKeeping it honest. The assistant tests module changes before it answers, so the reply says what the change does. An answer that claims something was made or tested with no call behind it, puts part of your request off until later, comes back empty or garbled, or names a setting or variable that nothing mentions is sent back once to fix it. A tool call written as text (\"I call: read …\") still runs, and the leftovers of a JSON step (\"calls: []\") never show in the reply. It doesn't ask \"should I go ahead?\", since the card is already the asking.\nLimits. One answer reads about 6 items with a small model, 8 with a medium one and 12 with a large or cloud one before it must answer. A local model gets up to 20 minutes per answer, a cloud model 10. \"Cut off\" means the model hit its output limit; thinking models can spend it all before they answer.\nMemory. The chat keeps every search, read, test run and card, and is told what you did with each card and what changed in your library, so \"use the second one\" or \"undo that\" works. Each request starts with exactly what the last one sent, so the server can reuse what it already read: follow-ups are faster and, on a paid provider, cheaper. In a long chat, old tool results shrink and the oldest turns may be left out. The summary is written by your own model and kept with the chat. \"~\" on the context bar means an estimate, and \"about\" means a cloud model's usual size.\nQuestions. Number keys pick in the focused question, and some questions take several answers. It asks at most twice per message, then decides and says what it assumed."
},
{
"title": "Works with",
+2
View File
@@ -1701,6 +1701,8 @@ export interface AiChatRef {
id: string; type: string; name: string; shared?: string;
/** The card (pid) that creates it: not in the library yet. */
pending?: string;
/** Its category id, for the category chip in an item list. */
category?: string;
}
/* ── Pack builder ───────────────────────────────────────────────────── */
+56 -1
View File
@@ -3,6 +3,9 @@
* One assistant message: its Markdown (see `utils/chat-markdown.ts`) drawn
* with plain elements, and [[Name]] mentions as chips that open the item.
* A mention the server couldn't match to an item stays a plain label.
* A list of three or more items, each opening with a mention, is drawn as
* a compact item list: kind icon, name and category chip, then the rest
* of the line.
*
* Prompt syntax in the text is coloured the way the editors colour it
* (`utils/chat-syntax.ts`, shared with the cards). A line opening with "Tip:", "Note:" or "Warning:" is a callout,
@@ -20,6 +23,8 @@
import { computed, h, ref, watch, type VNode } from "vue";
import { useRouter } from "vue-router";
import { deletedIds } from "../../composables/useLibraryChanged";
import { useCardViewContext } from "../../composables/card-views/context";
import CategoryChip from "../CategoryChip.vue";
import { KIND_ICON_MAP } from "../../../components/shared/kind-icons";
import type { AiChatRef } from "../../api/types";
import { closeOpen, parseMarkdown, type Block, type Inline, type ListItem, type Tone } from "../../utils/chat-markdown";
@@ -227,6 +232,44 @@ function table(b: Extract<Block, { t: "table" }>): VNode {
]);
}
/* ── Item lists ──────────────────────────────────────────────────────── */
const ITEM_LIST_MIN = 3;
const lookups = useCardViewContext();
function categoryChip(id: string | undefined): VNode | null {
if (!id) return null;
let c: ReturnType<NonNullable<typeof lookups.category>> = null;
try { c = lookups.category?.(id) ?? null; } catch { c = null; }
return c ? h(CategoryChip, { name: c.name, color: c.color, icon: c.icon, class: "wp-ai-msg__item-cat" }) : null;
}
/** Each item of a list as its mention and the words after it, when every
* item is one line opening with a mention of a real item. */
function itemRows(items: ListItem[]): Array<{ ref: string; rest: Inline[] }> | null {
if (items.length < ITEM_LIST_MIN) return null;
const rows: Array<{ ref: string; rest: Inline[] }> = [];
for (const it of items) {
const b = it.blocks;
if (it.task !== null || b.length !== 1 || b[0].t !== "p") return null;
const [first, ...rest] = b[0].c;
if (first?.t !== "ref" || !props.refs?.[unmark(first.v)]?.id) return null;
// "— a wildcard of …", ": …", "- …": the joiner goes, the words stay.
if (rest[0]?.t === "text") rest[0] = { t: "text", v: rest[0].v.replace(/^\s*(?:[—–:-]\s*)?/, "") };
rows.push({ ref: first.v, rest: rest.filter((n) => n.t !== "text" || n.v) });
}
return rows;
}
function itemList(rows: Array<{ ref: string; rest: Inline[] }>, ordered: boolean): VNode {
return h(ordered ? "ol" : "ul", { class: "wp-ai-msg__items", "data-test": "ai-msg-items" }, rows.map((r) =>
h("li", { class: "wp-ai-msg__item" }, [
chip(r.ref),
categoryChip(props.refs?.[unmark(r.ref)]?.category),
r.rest.length ? h("span", { class: "wp-ai-msg__item-rest" }, inline(r.rest)) : null,
])));
}
function item(it: ListItem): VNode {
const body = it.blocks.map(block);
if (it.task === null) return h("li", body);
@@ -290,10 +333,13 @@ function block(b: Block): VNode {
}
// Message headings sit under the drawer's own, so they start at h3.
case "h": return h(`h${Math.min(6, b.level + 2)}`, { class: "wp-ai-msg__h", "data-level": b.level }, inline(b.c));
case "list":
case "list": {
const rows = itemRows(b.items);
if (rows) return itemList(rows, b.ordered);
return b.ordered
? h("ol", b.start !== 1 ? { start: b.start } : {}, b.items.map(item))
: h("ul", b.items.map(item));
}
case "pre": return pre(b.lang, b.v);
case "quote": {
if (b.tone) return calloutBlocks(b.tone, b.blocks);
@@ -419,6 +465,15 @@ const Body = () => {
@media (prefers-reduced-motion: reduce) {
.wp-ai-msg--live :deep(.wp-ai-msg__new), .wp-ai-msg--live > :deep(:last-child:not(.wp-ai-msg__codeblock)) { animation: none; }
}
.wp-ai-msg :deep(.wp-ai-msg__items) {
list-style: none; padding: 4px; gap: 0; border: 1px solid var(--wp-border); border-radius: var(--wp-radius-sm);
}
.wp-ai-msg :deep(.wp-ai-msg__item) {
display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 6px; padding: 3px 4px; border-radius: var(--wp-radius-sm);
}
.wp-ai-msg :deep(.wp-ai-msg__item + .wp-ai-msg__item) { border-top: 1px solid var(--wp-border); border-radius: 0; }
.wp-ai-msg :deep(.wp-ai-msg__item-cat) { font-size: 11px; }
.wp-ai-msg :deep(.wp-ai-msg__item-rest) { flex: 1 1 12em; min-width: 0; color: var(--wp-text-muted); font-size: 12.5px; }
.wp-ai-msg :deep(.wp-ai-msg__chip[data-missing]) { cursor: default; color: var(--wp-text-muted); background: transparent; border-color: var(--wp-border); }
.wp-ai-msg :deep(.wp-ai-msg__chip[data-deleted]) { text-decoration: line-through; }
.wp-ai-msg :deep(.wp-ai-msg__chip[data-pending]) { cursor: default; border-style: dashed; }
@@ -1,6 +1,8 @@
import { mount } from "@vue/test-utils";
import { afterEach, describe, expect, it, vi } from "vitest";
import { nextTick } from "vue";
import { createPinia, setActivePinia } from "pinia";
import { useCategoryStore } from "../../../stores/categoryStore";
import AiMessage from "../AiMessage.vue";
const push = vi.fn();
@@ -27,6 +29,63 @@ describe("AiMessage", () => {
expect(w.find(".wp-ai-syn__tag").text()).toBe("#portrait");
});
it("draws variables like the editors: accessors marked, run-time ones read-only", () => {
const w = mount(AiMessage, { props: { text: "Read $outfit.SHOES, $hair.neg and $model_family." } });
const vars = w.findAll(".wp-ai-syn__var");
expect(vars.map((v) => v.text())).toEqual(["$outfit.SHOES", "$hair.neg", "$model_family"]);
expect(vars[0].find(".wp-ai-syn__acc").text()).toBe(".SHOES");
expect(vars[1].find(".wp-ai-syn__acc--neg").text()).toBe(".neg");
expect(vars[2].classes()).toContain("wp-ai-syn__var--run");
expect(vars[2].attributes("title")).toContain("WP Model Info");
expect(vars[0].classes()).not.toContain("wp-ai-syn__var--run");
});
it("makes a mention in backticks a chip (#2)", () => {
const w = mount(AiMessage, {
props: { text: "Open `[[Hair Color]]`.", refs: { "Hair Color": { id: "ab12cd34", type: "wildcard", name: "Hair Color" } } },
});
expect(w.find("[data-test='ai-msg-chip']").text()).toBe("Hair Color");
expect(w.find("code").exists()).toBe(false);
});
it("draws a list of three or more items as an item list", () => {
const refs = {
a: { id: "aaaa0001", type: "wildcard", name: "Hair" },
b: { id: "aaaa0002", type: "fixed_values", name: "Quality" },
c: { id: "aaaa0003", type: "derivation", name: "Mood rules" },
};
const w = mount(AiMessage, { props: { text: "- [[a]] — hair colours\n- [[b]]: quality tags\n- [[c]]", refs } });
const list = w.find("[data-test='ai-msg-items']");
expect(list.exists()).toBe(true);
const rows = list.findAll("li");
expect(rows.map((r) => r.find("[data-test='ai-msg-chip']").text())).toEqual(["Hair", "Quality", "Mood rules"]);
expect(rows[0].find(".wp-ai-msg__item-rest").text()).toBe("hair colours");
expect(rows[1].find(".wp-ai-msg__item-rest").text()).toBe("quality tags");
expect(rows[2].find(".wp-ai-msg__item-rest").exists()).toBe(false);
});
it("shows each listed item's category chip", () => {
setActivePinia(createPinia());
useCategoryStore().items = [{ id: "c1", name: "Clothes", color: "#f00", icon: null, sort_order: 1 }];
const refs = {
a: { id: "aaaa0001", type: "wildcard", name: "Hat", category: "c1" },
b: { id: "aaaa0002", type: "wildcard", name: "Shoes", category: "c1" },
c: { id: "aaaa0003", type: "wildcard", name: "Mood" },
};
const w = mount(AiMessage, { props: { text: "1. [[a]]\n2. [[b]]\n3. [[c]]", refs } });
const rows = w.findAll("[data-test='ai-msg-items'] li");
expect(rows.map((r) => r.find(".wp-ai-msg__item-cat").exists())).toEqual([true, true, false]);
expect(rows[0].text()).toContain("Clothes");
});
it("keeps a list a list when an item isn't a mention or there are only two", () => {
const refs = { a: { id: "aaaa0001", type: "wildcard", name: "Hair" }, b: { id: "aaaa0002", type: "wildcard", name: "Eyes" } };
const two = mount(AiMessage, { props: { text: "- [[a]]\n- [[b]]", refs } });
expect(two.find("[data-test='ai-msg-items']").exists()).toBe(false);
const mixed = mount(AiMessage, { props: { text: "- [[a]]\n- [[b]]\n- then test it", refs } });
expect(mixed.find("[data-test='ai-msg-items']").exists()).toBe(false);
});
it("turns Tip / Warning lines into callouts", () => {
const w = mount(AiMessage, { props: { text: "Tip: keep names short.\n\nWarning: this replaces Pose." } });
const callouts = w.findAll(".wp-ai-msg__callout");
+7 -2
View File
@@ -216,8 +216,13 @@ import CrossLinks from "../../../components/docs/CrossLinks.vue";
<DocDetails>
<p>
<b>Replies.</b> Replies use Markdown: headings, lists, tables, quotes and code blocks with
a copy button. Prompt syntax is coloured like in the editors, every item it mentions is a
chip you can click, and lines starting "Tip:" or "Warning:" (or a GitHub-style
a copy button. Prompt syntax is coloured like in the editors: a variable in its own
colour with <code>.0</code> / <code>.SHOES</code> in amber and <code>.neg</code> in red,
and a variable the run sets (<code>$iteration</code>, <code>$model_family</code> and the
other WP Model Info ones) underlined dotted, since rules can read it but nothing writes it.
Every item it mentions is a chip you can click, also when the model wrote the name in
backticks, and a list of three or more items shows as an item list with each item's
category. Lines starting "Tip:" or "Warning:" (or a GitHub-style
<code>[!NOTE]</code> alert) stand out. Links show as text and are never opened. Code blocks
over 20 lines are folded.
</p>
@@ -19,6 +19,13 @@ describe("chat-markdown inline", () => {
expect(parseInline("hair_color is 2 * 3")).toEqual([text("hair_color is 2 * 3")]);
});
it("reads a mention in backticks as a mention, other code as code", () => {
expect(parseInline("see `[[Hair Color]]` and ` [[a1b2c3d4#Eyes]] ` or `[[x]] y`")).toEqual([
{ t: "text", v: "see " }, { t: "ref", v: "Hair Color" }, { t: "text", v: " and " },
{ t: "ref", v: "a1b2c3d4#Eyes" }, { t: "text", v: " or " }, { t: "code", v: "[[x]] y" },
]);
});
it("keeps __wildcard__ syntax as written", () => {
expect(parseInline("use __hair__ here")).toEqual([text("use __hair__ here")]);
});
+5 -2
View File
@@ -8,7 +8,8 @@
* lines, nested blocks), GitHub tables with column alignment, and `---`
* rules. Inline: **bold**, *italic* / _italic_, ***both***, ~~struck~~,
* `code`, backslash escapes and [[Name]] item mentions, which the chat turns
* into chips that open the item. Anything else stays as text.
* into chips that open the item (also when written in backticks,
* `` `[[Name]]` ``). Anything else stays as text.
*
* A quote opening with a GitHub alert (`> [!NOTE]`, `[!TIP]`, `[!IMPORTANT]`,
* `[!WARNING]`, `[!CAUTION]`) or `> **Note**` / `> **Warning**` carries a
@@ -97,7 +98,9 @@ export function parseInline(text: string): Inline[] {
else if (m[3] !== undefined) {
// A code span keeps one leading/trailing space off, like CommonMark.
const v = m[3].length > 2 && m[3].startsWith(" ") && m[3].endsWith(" ") ? m[3].slice(1, -1) : m[3];
out.push({ t: "code", v });
// A mention in backticks is still a mention (models write `[[Name]]`).
const ref = /^\[\[([^[\]\n]{1,80})\]\]$/.exec(v.trim());
out.push(ref ? { t: "ref", v: ref[1].trim() } : { t: "code", v });
} else if (m[4] !== undefined) out.push({ t: "ref", v: m[4].trim() });
else if (m[5] !== undefined) push(m[5]);
else if (m[6] !== undefined) out.push({ t: "link", c: parseInline(m[6]), href: m[7] ?? "" });
+6 -1
View File
@@ -1,5 +1,10 @@
/* Coloured prompt syntax in the chat (`chat-syntax.ts`): rendered by `h`, so styled by class, unscoped. */
.wp-ai-syn__var { font-family: var(--wp-font-mono, ui-monospace, monospace); font-size: 0.95em; font-weight: 500; }
/* Like the editors' `$var` (RefChip): the sentence's own font, semibold, its colour. */
.wp-ai-syn__var { font-weight: 600; }
.wp-ai-syn__acc { color: var(--wp-axis, #fbbf24); }
.wp-ai-syn__acc--neg { color: var(--wp-danger, #ef4444); }
/* Set by the run, read-only: a dotted rule and a help cursor for its tooltip. */
.wp-ai-syn__var--run { text-decoration: underline dotted; text-underline-offset: 3px; cursor: help; }
.wp-ai-syn__ref {
display: inline-flex; align-items: baseline; gap: 4px; max-width: 100%; padding: 0 6px; border-radius: 6px;
border: 1px solid color-mix(in srgb, var(--wp-accent-500) 45%, transparent);
+25 -5
View File
@@ -61,15 +61,35 @@ function nameRefs(text: string, ref: Ref): Array<VNode | string> {
return out;
}
/** Set when the workflow runs (the Loop, WP Model Info), never by an item:
* rules read them, nothing in the library writes them. Mirrors
* `engine/ai/library_vars.py::RUNTIME_VARS`. */
const RUN_VARS: Record<string, string> = {
iteration: "the Loop", iteration_total: "the Loop",
model_family: "WP Model Info", model_variant: "WP Model Info", model_name: "WP Model Info",
};
/** A `$name` the way the editors draw it: the name in its own colour, an
* accessor (`.0`, `.SHOES`) in the accessor amber, `.neg` in red. A
* variable the run sets is marked read-only. */
function varNode(raw: string, name: string): VNode {
const head = `$${name}`;
const tail = raw.startsWith(head) ? raw.slice(head.length) : "";
const by = RUN_VARS[name];
return h("span", {
class: by ? "wp-ai-syn__var wp-ai-syn__var--run" : "wp-ai-syn__var",
style: { color: `var(--wp-var-${varColorIndex(name)})` },
title: by ? `Set by ${by} when the workflow runs: rules can read it, nothing writes it` : undefined,
}, tail ? [raw.slice(0, head.length), h("span", {
class: tail === ".neg" || tail.endsWith(".neg") ? "wp-ai-syn__acc wp-ai-syn__acc--neg" : "wp-ai-syn__acc",
}, tail)] : raw);
}
function tokens(text: string, ref: Ref): Array<VNode | string> {
const out: Array<VNode | string> = [];
for (const t of tokenizeRich(text)) {
switch (t.kind) {
case "var":
out.push(h("span", {
class: "wp-ai-syn__var", style: { color: `var(--wp-var-${varColorIndex(t.meta?.name ?? "")})` },
}, t.raw));
break;
case "var": out.push(varNode(t.raw, t.meta?.name ?? "")); break;
case "ref": out.push(ref(t.raw, t.meta?.name)); break;
case "dp-brace": out.push(h("span", { class: "wp-ai-syn__pick" }, t.raw)); break;
case "dp-multi": out.push(h("span", { class: "wp-ai-syn__multi" }, t.raw)); break;
+15
View File
@@ -77,3 +77,18 @@ def test_warnings_that_only_inform_are_notes_without_ask_to_fix():
first = p["warnings"][0]
assert first.startswith("Builds on the earlier change")
assert p["notes"] == [first]
def test_names_in_code_spans_become_chips():
"""#2: `[[Name]]` and `Name` in backticks stayed plain code."""
lib = _lib()
assert lib.link_items("Open `[[Weapon]]` or `Class`.") == \
"Open [[aaaa0001#Weapon]] or [[aaaa0004#Class]]."
# Two items share it, or it names none: left as written.
assert lib.link_items("`Hair` and `$weapon`") == "`Hair` and `$weapon`"
def test_message_refs_carry_the_category():
modules = [{**_w("aaaa0001", "Weapon", "weapon"), "category_id": "cat1"}]
refs = chat.Library(modules, [], [], []).message_refs("[[aaaa0001#Weapon]]")
assert refs["aaaa0001#Weapon"]["category"] == "cat1"