diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 008d398..12ced75 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -606,7 +606,11 @@ covered by the `styles.css` manifest. (the app that came last, a warning when its list is a short one, six starter cards); `ui_ai_starter_sheet.js` holds the cards and the sheet each opens (fields, what happens, what stays, what the AI does not get), which starts a prepared task with its switches set (a new tab; privacy for the private card) through `ui_ai_handoff.js` startTask; - a card's tags name only what Anomalous makes sure of. `ui_ai_ready.js` is that + a card's tags name only what Anomalous makes sure of. `ui_ai_tour.js` holds a newcomer's notes: the workbench's + spotlight tour (new task, connecting, gates, the list, a task's ⋯, the timeline, the message box), by itself the + first time a task shows and from the home section's "How it works", and `firstHint`, one line with Got it under a + control that only shows up later (a run's verdict, the undo of the AI's canvas changes, a private task's Hide). + `ui_ai_ready.js` is that waiting task's one page: the sentence to send the AI, and what the user sent so far; what to say first and pictures go in the message box. The pictures (`web/assets/ai_*.webp`) were made with ChatGPT. `ai_task_view.js` holds diff --git a/CHANGELOG.md b/CHANGELOG.md index 0fbd297..8597449 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -86,6 +86,8 @@ - On a graphics card of 8 GB or less, Anomalous's upscale-and-redraw ways (the light upscale, the Tile upscale, enlarging only the character) decode the last step with ComfyUI's own VAE Decode (Tiled). A 2x upscale on a 6 GB laptop spent 20 minutes decoding alone and froze the desktop; the tiled decode needs no extra pack, and bigger cards stay as they were. - 主页多了「让 AI 帮你做」:顶上一行写最近连接的 AI 软件(连的是精简工具清单时会提醒它接不了 AMB 里的任务),下面六张卡:先试一次、跑我存的预设、照我说的出图、改一张图、隐私模式出图、看懂我的画布。点开先看说明页:点「开始」会发生什么、什么不会动、AI 拿不到什么、要注意什么;再点「开始」,AMB 把任务建好、开关设好(在新标签页里做,隐私卡直接是隐私任务),给你一句复制给 AI 的话。卡上的小标签只写 AMB 自己能保证的事。 - The home page has "Let the AI do it": a line on the AI app that came last (with a warning when it holds a short tool list that cannot take a task handed over here), and six cards: Try it once, Run my preset, Draw what I describe, Change a picture, Draw in privacy mode, Explain my canvas. A card opens a sheet first: what happens on Start, what stays as it is, what the AI does not get, what to mind; Start makes the task with its switches set (a new tab; the privacy card makes a private task) and gives the sentence to copy to the AI. A card's tags name only what Anomalous itself makes sure of. +- AI 工作台有了新手引导:第一次打开、里面有任务时,自动带你看一遍新任务、连接 AI、关卡、任务列表、任务的 ⋯、时间线和留言框;以后在主页「让 AI 帮你做」点「怎么用」可以再看。要等 AI 做过事才出现的东西(跑完图的「不太行」、AI 改画布的「撤销这一串」、隐私任务里的「藏」),第一次出现时下面有一行说明,点「知道了」就不再出现。引导遇到看不见的按钮会跳过,很高的一块只框住看得见的部分。 +- The AI workbench has a newcomer's tour: the first time it shows a task it walks you through New task, Connect an AI, the gates, the list, a task's ⋯, the timeline and the message box; the home page's How it works (under Let the AI do it) runs it again. What shows up only after the AI has done something (Not quite under a run, the AI's canvas undo, Hide in a private task) gets one line the first time, gone after Got it. A tour skips a control that cannot be seen and lights only the visible part of a tall one. ## v1.63.0 (AI Ways and Skills, Saves, Official Example Workflows, Privacy Mode) — 2026-11-10 diff --git a/docs/architecture/ui-flows.md b/docs/architecture/ui-flows.md index 148c223..47ce584 100644 --- a/docs/architecture/ui-flows.md +++ b/docs/architecture/ui-flows.md @@ -166,10 +166,24 @@ AI 想用几块现成做法一步步画一张图时,先在展台上放一张 | 「隐私模式出图」 | 写「画面大意(会发给 AI)」,建一个隐私任务 | 不发图和原文、新标签页 | | 「看懂我的画布」 | 就是画布右键的「交给 AI…」小窗 | 每步可撤销 | +顶上一行在有 AI 来过以后多一个「怎么用」:转到 AI 工作台,等它打开后走一遍工作台的引导。 + 说明页都分几行:点「开始」后、不会动(或 AI 拿不到、管不到)、要注意;必填的没填,按「开始」时下面红字说缺什么。「开始」以后和别处「交给 AI」一样,弹出要发给 AI 的那句话。后端:`GET /anomalous/ai_starters`(只读),建任务还是 `POST /anomalous/ai_tasks`,再按卡片设好新标签页、隐私。 为什么:新手不敢上手,怕弄坏东西。卡片把常用的几件事准备成任务,说明页先讲清楚会发生什么;标签只写 AMB 自己做得到的保证,不写要靠 AI 自觉的事。 +## AI 工作台的新手引导(`ui_ai_tour.js`) + +第一次打开 AI 工作台、里面有任务时,等窗口打开稳了,自动走一遍聚光灯引导(和主页的界面导览同一种),七步:「新任务」、「连接 AI」、「关卡」、任务列表的筛选、任务标题栏右边的 ⋯、时间线、留言框。看不见的那一步(比如窄窗口里时间线收在别的标签里)跳过。走过一次就不再自己出现(记在这个浏览器里),主页「让 AI 帮你做」的「怎么用」可以再看。 + +有些东西要等 AI 做过事才出现,引导走的时候还看不到,所以它们第一次出现时,下面多一行小字,旁边「知道了」,点了就不再出现: + +- 跑完的图下面「效果好」「不太行」:觉得不行就点「不太行」,再点哪里不行,AI 就知道该改哪儿。 +- AI 改画布的一串步骤旁有「撤销这一串」时:点它就能退回去。 +- 隐私任务的留言框里有「藏」时:选中几个字再点「藏」,这几个字 AI 只拿到代号。 + +为什么:不在页面上写大段说明。界面一直在的东西用引导讲一次;后来才出现的东西在它旁边说一句。 + ## 任务单(`ui_job_sheet.js`) 右键节点或画布空白处「任务单(批量换提示词)」打开,在画布右下角,ComfyUI 的画布按钮上面。用同一张画布连着跑很多次:观众要的“一次贴几十条提示词,每条跑几次”和“运行间隔”都是它。 diff --git a/tests/test_ai_tour_words.py b/tests/test_ai_tour_words.py new file mode 100644 index 0000000..cddbc48 --- /dev/null +++ b/tests/test_ai_tour_words.py @@ -0,0 +1,32 @@ +"""A newcomer's notes on the AI workbench (web/modules/ui_ai_tour.js): every tour step and every first-time +line has its words in both languages, and each first-time line is used where its control is drawn.""" +import re +import unittest +from pathlib import Path + +PLUGIN = Path(__file__).resolve().parents[1] +MODULES = PLUGIN / "web" / "modules" + + +class TourWordsTests(unittest.TestCase): + def test_every_step_and_line_has_both_languages(self): + tour = (MODULES / "ui_ai_tour.js").read_text(encoding="utf-8") + words = (MODULES / "locales.js").read_text(encoding="utf-8") + steps = re.findall(r"step\('(\w+)'", tour) + self.assertEqual(steps, ["new", "connect", "gates", "list", "menu", "timeline", "message"]) + keys = [f"aiTour_{name}_{part}" for name in steps for part in ("title", "body")] + ["aiFirstHintDone"] + for key in keys: + self.assertEqual(words.count(f" {key}: "), 2, key) + + def test_each_first_time_line_sits_under_its_control(self): + uses = {"verdict": "ui_ai_verdict.js", "undo": "ui_ai_chain.js", "hide": "ui_ai_message.js"} + words = (MODULES / "locales.js").read_text(encoding="utf-8") + for hint, module in uses.items(): + code = (MODULES / module).read_text(encoding="utf-8") + key = re.search(rf"firstHint\('{hint}', t\('(\w+)'\)\)", code) + self.assertIsNotNone(key, module) + self.assertEqual(words.count(f" {key.group(1)}: "), 2, key.group(1)) + + +if __name__ == "__main__": + unittest.main() diff --git a/web/modules/locales.js b/web/modules/locales.js index 4f9ed7c..97970e5 100644 --- a/web/modules/locales.js +++ b/web/modules/locales.js @@ -1849,6 +1849,25 @@ export const i18n = { aiConnectCopyRules: "复制这段规则", aiConnectRules: "在 Anomalous Model Browser(AMB)的任务里:\n1. 新开对话或拿不准现在到哪一步时,先调用 get_situation:任务、画布还缺哪几根线、下一步做什么,一次就知道。\n2. 先用 AMB 的 MCP 工具:模型、图片、节点、画布、出图和进度都有对应的工具(search_models、get_image_info、zoom_image、find_nodes、run_workflow、run_status 等)。不要自己去读 ComfyUI 的文件夹、源码或接口。日常出图用 run_workflow,带上 workflow(存好的工作流名)和要改的提示词、种子或尺寸,一步完成。\n3. 只通过运行 ComfyUI 工作流来改图片;绝不用自己的代码去改、裁、涂或覆盖图片文件。\n4. 要问我的事用 ask_user,说明和结果用 report,都在 AMB 里。\n5. 如果还是用了别的工具(命令、脚本、读写文件),紧接着调用 log_outside,写明做了什么、为什么、动了哪些文件。", aiConnectLocal: "只有运行 ComfyUI 的这台电脑能连接。加好以后,在 AI 软件里直接说要做什么就行;连过一次以后不用再加。", + aiFirstHintDone: "知道了", + aiFirstVerdict: "觉得不行就点「不太行」,再点哪里不行,AI 就知道该改哪儿。", + aiFirstUndo: "AI 改画布的这几步,点「撤销这一串」就能退回去。", + aiFirstHide: "选中几个字再点「藏」,这几个字 AI 只拿到代号。", + aiStartHowItWorks: "怎么用", + aiTour_new_title: "新任务", + aiTour_new_body: "要交给 AI 一件事,先点这里开一个任务,再把那句话发给你的 AI 软件,它就会接着这个任务做。首页的「让 AI 帮你做」也能直接开。", + aiTour_connect_title: "连接 AI", + aiTour_connect_body: "AI 软件第一次用 AMB 时,从这里拿地址和要发给它的那句话。连过一次就不用再连。", + aiTour_gates_title: "关卡", + aiTour_gates_body: "AI 做哪些事要先问你:改画布、用显卡、下载模型、挪文件……刚开始用,可以都设成先问。", + aiTour_list_title: "任务列表", + aiTour_list_body: "每件交给 AI 的事是一条,最新的在上面,可以按进行中、等你回答、出错筛一下。不要的任务在它右边的 ⋯ 里移除,只删记录。", + aiTour_menu_title: "这个任务的 ⋯", + aiTour_menu_body: "改名、隐私模式(AI 看不到这个任务的图和提示词原文)、发给 AI 了什么,都在这里。", + aiTour_timeline_title: "时间线", + aiTour_timeline_body: "AI 的每一步按时间排在这里,点开能看细节。它改画布的步骤都能撤销,不怕改坏。", + aiTour_message_title: "留言框", + aiTour_message_body: "想跟 AI 说话就写在这里,它下一步就会收到;也能附上图库里的图。", aiStartTitle: "让 AI 帮你做", aiStartShortList: "{client} 连的是精简工具清单,接不了 AMB 里的任务:在「连接 AI」里选「全部工具」,再让它重新连接。", aiStartGo: "开始", @@ -5826,6 +5845,25 @@ export const i18n = { aiConnectCopyRules: "Copy the rules", aiConnectRules: "In Anomalous Model Browser (AMB) tasks:\n1. In a new chat, or unsure where things stand, call get_situation first: the task, what the canvas still lacks and the next step, in one answer.\n2. Use AMB's MCP tools first: models, pictures, nodes, the canvas, runs and their progress all have tools (search_models, get_image_info, zoom_image, find_nodes, run_workflow, run_status...). Do not read ComfyUI's folders, source or routes yourself. For a daily run use run_workflow with workflow (a saved one's name) and the prompt, seed or size to change: one call.\n3. Change pictures only by running ComfyUI workflows; never edit, crop, paint over or overwrite picture files with code of your own.\n4. Ask me with ask_user and explain with report, in AMB.\n5. If you still use another tool (a command, a script, files), call log_outside right after: what you ran, why, and which files.", aiConnectLocal: "Only this computer, where ComfyUI runs, can connect. Once added, just tell the AI app what to do; it stays added.", + aiFirstHintDone: "Got it", + aiFirstVerdict: "Not right? Press Not quite and then what is off: the AI knows what to fix.", + aiFirstUndo: "Press the undo beside these steps to take the AI's canvas changes back.", + aiFirstHide: "Select a few words and press Hide: the AI gets a token for them.", + aiStartHowItWorks: "How it works", + aiTour_new_title: "New task", + aiTour_new_body: "To hand the AI something, start a task here, then send the sentence to your AI app: it goes on with this task. The home page's Let the AI do it starts one too.", + aiTour_connect_title: "Connect an AI", + aiTour_connect_body: "The first time an AI app uses Anomalous, take the address and the sentence for it from here. Once is enough.", + aiTour_gates_title: "Gates", + aiTour_gates_body: "What the AI asks you before doing: changing the canvas, using the graphics card, downloading models, moving files… At first you may set them all to ask.", + aiTour_list_title: "The tasks", + aiTour_list_body: "Each thing handed to the AI is a row, the newest on top; filter by running, waiting for you or failed. Remove one you do not need from its ⋯: only the record goes.", + aiTour_menu_title: "This task's ⋯", + aiTour_menu_body: "Rename, privacy mode (the AI sees none of this task's pictures and prompt words), what was sent to the AI: all here.", + aiTour_timeline_title: "The timeline", + aiTour_timeline_body: "Every step of the AI in order, each opening to its details. Its canvas changes all undo, so nothing is lost.", + aiTour_message_title: "The message box", + aiTour_message_body: "Write to the AI here: it gets the message with its next step. Pictures from the gallery can go along.", aiStartTitle: "Let the AI do it", aiStartShortList: "{client} holds a short tool list and cannot take a task handed over in Anomalous: pick Every tool in Connect an AI, then reconnect it.", aiStartGo: "Start", diff --git a/web/modules/ui_ai_chain.js b/web/modules/ui_ai_chain.js index 87dc417..2916ea8 100644 --- a/web/modules/ui_ai_chain.js +++ b/web/modules/ui_ai_chain.js @@ -9,6 +9,7 @@ import { app } from '../../../scripts/app.js'; import { translate as t } from './locales.js'; +import { firstHint } from './ui_ai_tour.js'; import { timeLabel } from './activity_log.js'; import { canUndo, undoEntry } from './canvas_undo.js'; import { anomalousConfirm } from './ui_dialog.js'; @@ -89,10 +90,12 @@ export function chainRow(steps, number, ctx) { open.append(el('span', 'anomalous-ai-chain-title', t('aiChain', { count: steps.length }))); if (ctx.live) open.append(el('span', 'anomalous-ai-chain-last', t('aiChainLast', { what: t(toolKey(steps.at(-1).tool)) }))); head.append(open); + let undoHint = null; if (changesOf(steps, ctx.entries).some(entry => canUndo(app, entry))) { head.append(iconButton('anomalous-ai-chain-undo', 'undo', t('aiChainUndo'), async () => { if (await undoChanges(steps, ctx.entries, t('aiChainUndo'), 'aiChainUndoConfirm')) ctx.changed(); })); + undoHint = firstHint('undo', t('aiFirstUndo')); } const chips = el('div', 'anomalous-ai-chain-chips'); for (const { tool, kind, count } of chainTools(steps)) { @@ -118,7 +121,7 @@ export function chainRow(steps, number, ctx) { show(true); }; show(opened.has(key)); - body.append(head, chips, inside); + body.append(head, ...(undoHint ? [undoHint] : []), chips, inside); const [first, last] = [timeLabel(steps[0].at), timeLabel(steps.at(-1).at)]; row.append(body, el('div', 'anomalous-ai-step-when', first === last ? first : `${first}–${last}`)); return row; diff --git a/web/modules/ui_ai_message.js b/web/modules/ui_ai_message.js index 1540748..b3c42cf 100644 --- a/web/modules/ui_ai_message.js +++ b/web/modules/ui_ai_message.js @@ -12,6 +12,7 @@ */ import { translate as t } from './locales.js'; +import { firstHint } from './ui_ai_tour.js'; import { mentions } from './ui_ai_mention.js'; import { galleryPicker } from './ui_ai_gallery_pick.js'; import { takePictures } from './ui_ai_drop.js'; @@ -111,6 +112,13 @@ export function messageBox(onSend, source) { field.addEventListener('focus', shown); shown(); line.insertBefore(hide, send); + const hideHint = firstHint('hide', t('aiFirstHide')); + if (hideHint) { + box.insertBefore(hideHint, note); + const sync = () => { hideHint.hidden = hide.hidden; }; + field.addEventListener('focus', sync); + sync(); + } 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_starters.js b/web/modules/ui_ai_starters.js index d944c7b..a47be98 100644 --- a/web/modules/ui_ai_starters.js +++ b/web/modules/ui_ai_starters.js @@ -12,6 +12,7 @@ import { openConnectGuide } from './ui_ai_connect.js'; import { handCanvasToAi } from './ui_ai_canvas_hand.js'; import { icon } from './ui_ai_icons.js'; import { STARTERS, cardTags, openStarter } from './ui_ai_starter_sheet.js'; +import { tourWhenShown } from './ui_ai_tour.js'; function el(tag, className, text) { const node = document.createElement(tag); @@ -28,11 +29,12 @@ function button(className, label, onClick) { } /** The line about the AI app: who came last, or none yet; a short tool list said apart. */ -function appLine(app) { +function appLine(owner, app) { const line = el('div', `anomalous-ai-start-app${app ? ' is-known' : ''}`); line.append(el('span', 'anomalous-ai-visit-dot'), el('span', '', app ? t('aiLastVisit', { client: app.client, when: `${dayLabel(app.when)} ${timeLabel(app.when)}` }) : t('aiNoVisit')), button('anomalous-home-link', t('aiConnect'), () => openConnectGuide())); + if (app) line.append(button('anomalous-home-link', t('aiStartHowItWorks'), () => { owner.goTo('ai'); setTimeout(tourWhenShown, 700); })); const lines = [line]; if (app && (app.tools === 'core' || app.tools === 'mine')) lines.push(el('div', 'anomalous-ai-start-warn', t('aiStartShortList', { client: app.client }))); return lines; @@ -61,7 +63,7 @@ export function renderAiStarters(owner, after) { const head = el('div', 'anomalous-ai-start-head'); const title = el('span', 'anomalous-ai-start-title'); title.append(icon('sparkle', 18), el('span', '', t('aiStartTitle'))); - head.append(title, ...appLine(data.app)); + head.append(title, ...appLine(owner, data.app)); const grid = el('div', 'anomalous-ai-start-cards'); grid.append(...STARTERS.map(starter => card(owner, starter, data))); section.append(head, grid); diff --git a/web/modules/ui_ai_task_menu.js b/web/modules/ui_ai_task_menu.js index 08f6827..e6138b7 100644 --- a/web/modules/ui_ai_task_menu.js +++ b/web/modules/ui_ai_task_menu.js @@ -51,7 +51,7 @@ export function openTaskMenu(anchor, task, { tasks, rename, remove, changed }) { export function taskMenu(task, actions) { const more = document.createElement('button'); more.type = 'button'; - more.className = 'anomalous-ai-tool is-icon'; + more.className = 'anomalous-ai-tool is-icon anomalous-ai-task-menu'; more.textContent = '⋯'; more.title = t('notebookMore'); more.onclick = () => openTaskMenu(more, task, actions); diff --git a/web/modules/ui_ai_tour.js b/web/modules/ui_ai_tour.js new file mode 100644 index 0000000..0c88cdb --- /dev/null +++ b/web/modules/ui_ai_tour.js @@ -0,0 +1,90 @@ +/** + * Notes for a newcomer to the AI workbench, in two kinds and never as paragraphs on the page: + * - the workbench's tour (the spotlight tour of ui_spotlight_tour.js with its own steps): a new task, + * connecting, the gates, the list, a task's ⋯, the timeline and the message box. It runs by itself the + * first time the workbench shows a task, and again from the home page's "How it works" (startAiTour). + * - firstHint(): one line under a control that only shows up later (a run's verdict, undo for the AI's + * canvas changes, the Hide button of a private task), shown until the user says Got it. + * What was seen is remembered in this browser only; without storage the notes simply come again. + */ + +import { translate as t } from './locales.js'; +import { isSpotlightTourActive, startSpotlightTour } from './ui_spotlight_tour.js'; + +const TOUR_KEY = 'anomalous_ai_tour_seen'; +const HINTS_KEY = 'anomalous_ai_hints_seen'; +const step = (id, targetSelector, icon, position) => ({ id, targetSelector, icon, position, titleKey: `aiTour_${id}_title`, bodyKey: `aiTour_${id}_body` }); +export const AI_TOUR = Object.freeze([ + step('new', '.anomalous-ai-tasks .anomalous-ai-new-task', '➕', 'right'), + step('connect', '.anomalous-ai-tasks-ways .anomalous-ai-way:nth-child(1)', '🔌', 'right'), + step('gates', '.anomalous-ai-tasks-ways .anomalous-ai-way:nth-child(2)', '🛡️', 'right'), + step('list', '.anomalous-ai-tasks .anomalous-ai-task-filters', '📋', 'right'), + step('menu', '.anomalous-ai-task-head .anomalous-ai-task-menu', '⋯', 'bottom'), + step('timeline', '.anomalous-ai-timeline', '🕘', 'left'), + step('message', '.anomalous-ai-task .anomalous-ai-message', '💬', 'top'), +]); + +function seen(key) { + try { + const ids = JSON.parse(localStorage.getItem(key) || '[]'); + return new Set(Array.isArray(ids) ? ids : []); + } catch (_) { + return new Set(); + } +} + +function remember(key, id) { + try { + localStorage.setItem(key, JSON.stringify([...seen(key).add(id)])); + } catch (_) { + // a convenience only: the note comes again next time + } +} + +/** Whether the workbench shows a task at its full size (not mid-animation, not folded into tabs). */ +const settled = page => (page.querySelector('.anomalous-ai-timeline')?.getBoundingClientRect().width || 0) > 200; + +export function startAiTour() { + return startSpotlightTour(null, { steps: AI_TOUR }); +} + +/** The tour as soon as the workbench shows a task (after going there from the home page); gives up after a few seconds. */ +export function tourWhenShown(tries = 20) { + const page = document.querySelector('.anomalous-ai'); + if (page && settled(page)) { + remember(TOUR_KEY, 'workbench'); + startAiTour(); + } else if (tries > 0) { + setTimeout(() => tourWhenShown(tries - 1), 200); + } +} + +/** The tour by itself, once, when `page` (the workbench) shows a task and nothing else is in front; after the + * window has finished opening, since a tour started in its animation points at a squeezed layout. */ +export function aiTourOnce(page) { + if (seen(TOUR_KEY).has('workbench') || isSpotlightTourActive() || !page?.isConnected) return; + setTimeout(() => { + if (!page.isConnected || isSpotlightTourActive() || !settled(page)) return; + if (startAiTour()) remember(TOUR_KEY, 'workbench'); + }, 900); +} + +/** The line for `id` (words `text`) until the user said Got it, or null; one of each on the page at a time. */ +export function firstHint(id, text) { + if (seen(HINTS_KEY).has(id) || document.querySelector(`.anomalous-ai-first-hint[data-hint="${id}"]`)) return null; + const line = document.createElement('div'); + line.className = 'anomalous-ai-first-hint'; + line.dataset.hint = id; + const words = document.createElement('span'); + words.textContent = text; + const done = document.createElement('button'); + done.type = 'button'; + done.className = 'anomalous-ai-first-hint-done'; + done.textContent = t('aiFirstHintDone'); + done.onclick = () => { + remember(HINTS_KEY, id); + line.remove(); + }; + line.append(words, done); + return line; +} diff --git a/web/modules/ui_ai_verdict.js b/web/modules/ui_ai_verdict.js index 6be8493..43f8aa8 100644 --- a/web/modules/ui_ai_verdict.js +++ b/web/modules/ui_ai_verdict.js @@ -8,6 +8,7 @@ */ import { translate as t } from './locales.js'; +import { firstHint } from './ui_ai_tour.js'; import { anomalousAlert } from './ui_dialog.js'; import { icon } from './ui_ai_icons.js'; @@ -64,5 +65,7 @@ export function verdictRow(step, taskId) { } row.append(issues); } + const hint = !step.verdict && firstHint('verdict', t('aiFirstVerdict')); + if (hint) row.append(hint); return row; } diff --git a/web/modules/ui_ai_workbench.js b/web/modules/ui_ai_workbench.js index cc1724e..9966a8b 100644 --- a/web/modules/ui_ai_workbench.js +++ b/web/modules/ui_ai_workbench.js @@ -63,6 +63,7 @@ import { activityIds, duration, groupSteps, stepDetail, taskCounts, taskResults, taskState, toolKey, } from './ai_task_view.js'; import { taskTitle } from './ai_task_title.js'; +import { aiTourOnce } from './ui_ai_tour.js'; const NARROW_PX = 720; const SHORT_PX = 880; // a window this low (not maximised) keeps less room around the task's head @@ -543,7 +544,7 @@ export function renderAiWorkbench(owner, panel) { owner.headerLeadTools?.replaceChildren(listToggle(state.page, toggleTasks)); owner.railNews?.('ai', false); hidePanelToast(); - void refresh(); + void refresh().then(() => aiTourOnce(state.page)); // a newcomer's tour, once (ui_ai_tour.js) pollVisible(); } diff --git a/web/modules/ui_spotlight_tour.js b/web/modules/ui_spotlight_tour.js index fd8ad86..2360b77 100644 --- a/web/modules/ui_spotlight_tour.js +++ b/web/modules/ui_spotlight_tour.js @@ -297,7 +297,10 @@ export function ensureTourStyles() { function resolveStepTarget(step) { if (typeof document === 'undefined') return null; - const shown = el => Boolean(el?.isConnected && el.getClientRects().length); // hidden targets are skipped + const shown = (el) => { // hidden targets are skipped, and so is one folded to nothing (a tab not shown) + const box = el?.isConnected && el.getClientRects().length ? el.getBoundingClientRect() : null; + return Boolean(box && box.width > 0 && box.height > 0); + }; let el = document.querySelector(step.targetSelector); if (!shown(el) && step.fallbackSelector) { el = document.querySelector(step.fallbackSelector); @@ -305,6 +308,19 @@ function resolveStepTarget(step) { return shown(el) ? el : null; } +/** The part of `rect` (target's) that can be seen: a tall target (a timeline) is cut to its scrolling box and the window. */ +function visiblePart(target, rect) { + let top = Math.max(rect.top, 0); + let bottom = Math.min(rect.bottom, window.innerHeight); + for (let node = target.parentElement; node && node !== document.body; node = node.parentElement) { + if (!/(auto|scroll)/.test(getComputedStyle(node).overflowY)) continue; + const box = node.getBoundingClientRect(); + [top, bottom] = [Math.max(top, box.top), Math.min(bottom, box.bottom)]; + break; + } + return bottom > top ? { left: rect.left, right: rect.right, width: rect.width, top, bottom, height: bottom - top } : rect; +} + function computeCardPosition(rect, position, cardWidth = 330, cardHeight = 220) { const margin = 12; const padding = 16; @@ -399,7 +415,7 @@ export function startSpotlightTour(owner, { steps = TOUR_STEPS, onClose = null } // Scroll target into view if out of sight target.scrollIntoView?.({ behavior: 'smooth', block: 'nearest', inline: 'nearest' }); - const rect = target.getBoundingClientRect?.() || { left: 0, top: 0, width: 100, height: 40, right: 100, bottom: 40 }; + const rect = target.getBoundingClientRect ? visiblePart(target, target.getBoundingClientRect()) : { left: 0, top: 0, width: 100, height: 40, right: 100, bottom: 40 }; const buffer = 5; // Position spotlight box smoothly diff --git a/web/styles.css b/web/styles.css index bd4b43b..c3bc75d 100644 --- a/web/styles.css +++ b/web/styles.css @@ -53,4 +53,4 @@ @import url("./styles/52-model-guide.css?v=20261108-guide"); @import url("./styles/53-ai-plan.css?v=20261109-plan"); @import url("./styles/54-job-sheet.css?v=20261109-sheet"); -@import url("./styles/55-ai-starters.css?v=20261113-start"); +@import url("./styles/55-ai-starters.css?v=20261113-tour"); diff --git a/web/styles/55-ai-starters.css b/web/styles/55-ai-starters.css index 3129730..453ea58 100644 --- a/web/styles/55-ai-starters.css +++ b/web/styles/55-ai-starters.css @@ -79,3 +79,28 @@ font-weight: 600; cursor: pointer; } + +/* A newcomer's line under a control that shows up later (ui_ai_tour.js firstHint) */ +.anomalous-ai-first-hint { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 4px 10px; + margin-top: 6px; + padding: 5px 10px; + border-radius: 8px; + background: color-mix(in srgb, var(--amb-link) 10%, transparent); + color: var(--amb-text-soft); + font-size: 12px; + line-height: 1.5; +} +.anomalous-ai-first-hint[hidden] { display: none; } +.anomalous-ai-first-hint-done { + padding: 0; + border: 0; + background: none; + color: var(--amb-link); + font: inherit; + cursor: pointer; +} +.anomalous-ai-first-hint-done:hover { text-decoration: underline; }