tourfp 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +65 -12
  2. package/package.json +2 -2
  3. package/tourfp.js +117 -17
package/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # tour-guide
1
+ # tourfp
2
2
 
3
3
  Bản thử nghiệm thư viện ghi và phát tour hướng dẫn trên giao diện web, không cần sửa mã nguồn của app.
4
4
  Phần tử được nhận diện bằng "dấu vân tay" nhiều tín hiệu (chữ, icon, thuộc tính, component cha, tiêu đề khối đứng trước)
@@ -8,12 +8,10 @@ Toàn bộ nằm trong một file: [`tourfp.js`](tourfp.js), không phụ thuộ
8
8
 
9
9
  ## Cài đặt
10
10
 
11
- Thư viện là một gói npm tên `tourfp`, chỉ gồm file `tourfp.js`, không có phụ thuộc và không cần bước build.
12
-
13
- Cài thẳng từ repo này (repo đang riêng tư, nên máy cài phải có quyền đọc repo):
11
+ Thư viện là gói npm [`tourfp`](https://www.npmjs.com/package/tourfp), chỉ gồm file `tourfp.js`, không có phụ thuộc và không cần bước build.
14
12
 
15
13
  ```bash
16
- npm install github:ngquangdat/tour-guide
14
+ npm install tourfp
17
15
  ```
18
16
 
19
17
  Rồi nạp vào app theo một trong hai cách:
@@ -28,22 +26,28 @@ Với Angular, nên khai báo trong `angular.json` để thư viện được n
28
26
  "scripts": ["node_modules/tourfp/tourfp.js"]
29
27
  ```
30
28
 
31
- Tuỳ chọn, đặt trước khi nạp thư viện:
29
+ Hoặc không cần cài, nạp bằng một thẻ script qua CDN:
30
+
31
+ ```html
32
+ <script src="https://cdn.jsdelivr.net/npm/tourfp@0.3.1/tourfp.js"></script>
33
+ ```
34
+
35
+ Để dùng thử nhanh trên một trang đang mở (trang phải cho phép script từ `cdn.jsdelivr.net`), dán dòng này vào console:
32
36
 
33
37
  ```js
34
- window.TOURFP_SERVER = 'https://tour.example.com'; // máy chủ lưu tour, mặc định http://localhost:8000
35
- window.TOURFP_PLAYER = true; // chỉ phát, không hiện thanh ghi tour
38
+ document.head.appendChild(Object.assign(document.createElement('script'), { src: 'https://cdn.jsdelivr.net/npm/tourfp@0.3.1/tourfp.js' }));
36
39
  ```
37
40
 
38
- Gói chưa được đưa lên npm. Khi đã phát hành, có thể cài bằng `npm install tourfp`, hoặc nạp bằng một thẻ script qua CDN:
41
+ Tuỳ chọn, đặt trước khi nạp thư viện:
39
42
 
40
- ```html
41
- <script src="https://cdn.jsdelivr.net/npm/tourfp@0.2.0/tourfp.js"></script>
43
+ ```js
44
+ window.TOURFP_SERVER = 'https://tour.example.com'; // máy chủ lưu tour, mặc định http://localhost:8000
45
+ window.TOURFP_PLAYER = true; // chỉ phát, không hiện thanh ghi tour
42
46
  ```
43
47
 
44
48
  ## Dùng thử
45
49
 
46
- Dán nội dung `tourfp.js` vào console của trang, hoặc nạp bằng thẻ `<script>`. Một thanh điều khiển hiện ra ở góc dưới trái
50
+ Sau khi nạp thư viện (xem Cài đặt), một thanh điều khiển hiện ra ở góc dưới trái
47
51
  (kéo đi chỗ khác được):
48
52
 
49
53
  - **● Ghi** — thao tác như bình thường, mỗi cú bấm thành một bước và vẫn chạy thật.
@@ -85,6 +89,7 @@ Mở <http://localhost:8000>:
85
89
  - **Chạy thử** — chép một đoạn mã gồm cả thư viện lẫn tour; mở màn bắt đầu của tour, dán vào console là tour phát ngay.
86
90
  Khi chạy thử, thanh ghi tour không hiện (chế độ chỉ phát); gọi `TourFP.showPanel()` nếu cần hiện lại.
87
91
  - **Xoá**, **Tải JSON**, và xem danh sách bước của từng tour.
92
+ - **Sửa tên**: bấm vào tên một bước để đặt tên hiển thị cho bước đó (để trống thì dùng lại tên tự lấy từ giao diện); nút **Sửa tên tour** đổi tên và mô tả của tour. Tên đã đặt được dùng trong lời hướng dẫn khi phát và trong việc tìm tour của trợ lý.
88
93
 
89
94
  Cấu hình bằng biến môi trường: `TOURFP_TARGET` là trang web tour chạy trên đó (mặc định `https://tcinvest.tcbs.com.vn`),
90
95
  `TOURFP_DATA` là thư mục chứa file SQLite.
@@ -94,6 +99,54 @@ Khi thư viện được cài sẵn trong app và app cho phép gọi tới máy
94
99
  (mặc định `http://localhost:8000`). Trang TCInvest hiện chặn mọi kết nối ra ngoài danh sách cho phép của nó, nên với trang đó
95
100
  phải dùng cách dán JSON và dán mã chạy thử.
96
101
 
102
+ ### Trợ lý tìm tour
103
+
104
+ Người dùng nói muốn tới màn hình nào, máy chủ tìm tour phù hợp trong danh sách và trả về hai cách đi:
105
+
106
+ ```bash
107
+ curl -X POST http://localhost:8000/api/assist -H 'Content-Type: application/json' -d '{"query": "tôi muốn nộp tiền", "layout": "mobile"}'
108
+ ```
109
+
110
+ Câu trả lời gồm `tour` (tour được chọn), `to_step` (dừng sau bước thứ mấy; mặc định là bước cuối, hoặc bước người dùng nêu như
111
+ "tới bước 3"), `reason` (lý do, để hiện cho người dùng) và `options`:
112
+
113
+ - `guide` — **Hướng dẫn từng bước**: chỉ từng thao tác, người dùng tự bấm.
114
+ - `direct` — **Vào thẳng màn hình**: thư viện tự đưa tới nơi. Nếu bước cuối chuyển sang một trang khác thì vào thẳng trang đó
115
+ bằng đường dẫn; nếu không thì tự bấm các bước trên trang cuối. Gặp bước cần người dùng nhập, hoặc không tìm thấy đích,
116
+ nó chuyển sang hướng dẫn từng bước từ chỗ đó.
117
+
118
+ Mỗi lựa chọn kèm `run_url` (mở trang với `?tourfp=<mã>&mode=<guide|direct>&to=<bước>`, dùng khi thư viện đã cài trong app) và
119
+ `snippet_url` (mã chạy thử để dán vào console). Trên trang quản trị có ô **Trợ lý tìm tour** để thử ngay.
120
+
121
+ Trong app đã cài thư viện và gọi được máy chủ, có thể dùng thẳng:
122
+
123
+ ```js
124
+ TourFP.ask('tôi muốn nộp tiền'); // hỏi trợ lý rồi hiện hai lựa chọn cho người dùng
125
+ TourFP.start({ mode: 'direct', to: 3 }); // chạy tour đang nạp: vào thẳng, dừng sau bước 3
126
+ ```
127
+
128
+ Chế độ vào thẳng tự bấm thay người dùng. Với tour đi qua thao tác có hậu quả (đặt lệnh, xác nhận giao dịch), nên đặt `to` dừng
129
+ trước các bước đó, hoặc chỉ đưa ra lựa chọn hướng dẫn từng bước.
130
+
131
+ ### Nối với mô hình AI
132
+
133
+ Mặc định trợ lý tìm bằng so khớp từ khoá, không gọi AI. Bật AI bằng biến môi trường `TOURFP_AI`:
134
+
135
+ | `TOURFP_AI` | Nối tới | Cần đặt thêm |
136
+ |---|---|---|
137
+ | `off` (mặc định) | Không gọi AI | |
138
+ | `bedrock` | Amazon Bedrock, qua API Converse | `TOURFP_AI_MODEL`, `AWS_REGION`, thông tin đăng nhập AWS; cài `boto3` |
139
+ | `litellm` | LiteLLM proxy, hoặc máy chủ nào nói giao thức `chat/completions` | `TOURFP_AI_MODEL`, `TOURFP_LITELLM_URL`, `TOURFP_LITELLM_KEY` (nếu proxy yêu cầu) |
140
+
141
+ ```bash
142
+ TOURFP_AI=litellm TOURFP_AI_MODEL=<tên mô hình> TOURFP_LITELLM_URL=http://localhost:4000 uvicorn main:app --app-dir server --port 8000
143
+ ```
144
+
145
+ Mô hình chỉ nhận tên tour, mô tả và nhãn của từng bước, cùng câu người dùng nhập; không nhận dấu vân tay. Câu trả lời của mô
146
+ hình luôn được kiểm lại (tour phải có thật, số bước phải nằm trong tour). Khi mô hình lỗi hoặc trả lời sai dạng, trợ lý tự
147
+ chuyển sang so khớp từ khoá và ghi lý do vào `ai_error`. Thêm một nhà cung cấp khác bằng cách viết một lớp có hàm
148
+ `complete(system, user)` trong `server/ai.py`.
149
+
97
150
  Máy chủ không có đăng nhập; chỉ nên chạy trên `127.0.0.1`.
98
151
 
99
152
  ## Giới hạn hiện tại
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tourfp",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
4
4
  "description": "Ghi và phát tour hướng dẫn trên giao diện web bằng dấu vân tay phần tử, không cần sửa mã nguồn của app.",
5
5
  "main": "tourfp.js",
6
6
  "browser": "tourfp.js",
@@ -20,7 +20,7 @@
20
20
  ],
21
21
  "repository": {
22
22
  "type": "git",
23
- "url": "git+https://github.com/ngquangdat/tour-guide.git"
23
+ "url": "git+https://github.com/ngquangdat/tourfp.git"
24
24
  },
25
25
  "license": "UNLICENSED"
26
26
  }
package/tourfp.js CHANGED
@@ -1,11 +1,14 @@
1
- /* TourFP 0.2.0 — bản thử: ghi tour theo thao tác và khớp phần tử bằng "dấu vân tay".
1
+ /* TourFP 0.3.1 — bản thử: ghi tour theo thao tác và khớp phần tử bằng "dấu vân tay".
2
2
  * - Không dùng class/id. Không phụ thuộc thư viện.
3
3
  * - Chữ lấy từ API (tên, tên kế hoạch…) và chữ số bị loại khỏi dấu vân tay.
4
4
  * Để lọc được, file này phải được nạp TRƯỚC khi app gọi API (đặt trong <head>).
5
5
  * - Ghi: bấm bình thường = bước "thao tác" (cú bấm vẫn chạy, app chuyển màn như thật);
6
6
  * Alt+bấm = bước "chỉ tô sáng" (cú bấm bị chặn).
7
7
  * - Phát: tô sáng đích, chờ người dùng tự bấm rồi mới sang bước sau; tự chờ màn mới hiện ra.
8
- * API: TourFP.capture(el) | TourFP.find(fp) | record() | stop() | play() | export() | load(steps) | destroy()
8
+ * start({ mode: 'guide' | 'direct', to }) chạy tới bước thứ `to`: dẫn từng bước, hoặc thư viện tự đưa tới nơi.
9
+ * - Cấu hình (đặt trước khi nạp): window.TOURFP_SERVER = địa chỉ máy chủ lưu tour; window.TOURFP_PLAYER = true để ẩn thanh ghi.
10
+ * API: TourFP.record() | stop() | play() | start(opts) | load(steps) | export() | save() | run(id, opts) | ask(query)
11
+ * | capture(el) | find(fp) | showPanel(on) | destroy()
9
12
  */
10
13
  (() => {
11
14
  if (typeof window === 'undefined' || typeof document === 'undefined') return; // được import ở phía máy chủ (SSR, công cụ build): không làm gì
@@ -75,7 +78,7 @@
75
78
  // chỉ cần vài mẩu đầu: với khối lớn (thẻ, bảng) đó là tiêu đề
76
79
  for (let n; (n = w.nextNode()) && nodes < 40 && out.length < 6;) {
77
80
  const v = n.nodeValue;
78
- if (!v.trim() || (n.parentElement && n.parentElement.closest('svg,script,style'))) continue;
81
+ if (!v.trim() || (n.parentElement && n.parentElement.closest('svg,script,style,mat-icon,.material-icons'))) continue;
79
82
  nodes++;
80
83
  if (/\d/.test(v)) digits = true;
81
84
  for (const seg of v.split(/\d[\d.,%\/:-]*/)) { const s = norm(seg); if (s.length >= 2 && hasLetters(s)) { out.push(s.slice(0, 60)); num.push(/\d/.test(v)); } }
@@ -417,6 +420,7 @@
417
420
  const steps = []; // { kind: 'click' | 'hover' | 'input' | 'info', fp, note }
418
421
  let recording = false, playing = -1, raf = 0, target = null, token = 0, pending = null, lastCap = 0;
419
422
  let hoverEl = null, hoverT = 0, lastDown = 0, popups = [], advancing = false;
423
+ let directRun = 0, fullSteps = null, choice = null; // lượt "vào thẳng" đang chạy; bản đầy đủ của tour khi chỉ chạy tới bước x; hai lựa chọn đang chờ
420
424
  // khi đang ghi: menu bật ra do rê chuột (không phải do bấm) thì nhớ phần tử đang được rê lên
421
425
  const popMo = new MutationObserver(list => {
422
426
  // bỏ qua nếu vừa có cú bấm (trong 1,5 giây, hoặc từ lúc chuột vào phần tử đang rê): khi đó thứ bật ra là do bấm
@@ -467,7 +471,8 @@
467
471
  const tipText = mk('div', 'margin-bottom:4px;white-space:pre-line');
468
472
  const tipMeta = mk('div', 'margin-bottom:8px;font-size:11px;opacity:.55');
469
473
  const bNext = mk('button', btnCss + 'pointer-events:auto;background:#e5484d;', 'Tiếp'), bStop = mk('button', btnCss + 'pointer-events:auto;background:#ddd;color:#111;margin-left:6px', 'Dừng');
470
- tip.append(tipText, tipMeta, bNext, bStop);
474
+ const bClose = mk('button', btnCss + 'pointer-events:auto;background:none;color:#666;margin-left:6px;display:none', 'Đóng');
475
+ tip.append(tipText, tipMeta, bNext, bStop, bClose);
471
476
  // chế độ chỉ phát (chạy thử từ trang quản trị, hoặc mở trang với ?tourfp=<mã>): không hiện thanh ghi tour
472
477
  const PLAYER = !!window.TOURFP_PLAYER || new URLSearchParams(location.search).has('tourfp');
473
478
  if (PLAYER) panel.style.display = 'none';
@@ -596,7 +601,11 @@
596
601
  setInfo(); cancelAnimationFrame(raf); loop();
597
602
  }
598
603
  function stop() { recording = false; pending = null; popMo.disconnect(); bRec.textContent = '● Ghi'; bRec.style.background = '#2a2f3a'; target = null; box.style.display = 'none'; cancelAnimationFrame(raf); setInfo(); }
599
- function stopPlay(msg) { token++; playing = -1; atEnd = false; tip.style.display = 'none'; box.style.display = 'none'; target = null; cancelAnimationFrame(raf); setInfo(msg); }
604
+ function stopPlay(msg) {
605
+ token++; directRun++; playing = -1; atEnd = false; choice = null; bClose.style.display = 'none';
606
+ if (fullSteps) { steps.length = 0; steps.push(...fullSteps); fullSteps = null; }
607
+ tip.style.display = 'none'; box.style.display = 'none'; target = null; cancelAnimationFrame(raf); setInfo(msg);
608
+ }
600
609
 
601
610
  // chờ đến khi màn hình của bước hiện ra và tìm được đích
602
611
  // alt: dấu vân tay của bước kế; nếu màn hình đã ở trạng thái của bước kế thì bỏ qua bước này
@@ -632,7 +641,7 @@
632
641
  async function show(i) {
633
642
  if (i >= steps.length) return stopPlay(`Xong tour · ${steps.length} bước`);
634
643
  playing = i; target = null; advancing = false; box.style.display = 'none';
635
- const st = steps[i], nx = steps[i + 1], name = nameOf(st.fp), head = `Bước ${i + 1}/${steps.length}`;
644
+ const st = steps[i], nx = steps[i + 1], name = st.title || nameOf(st.fp), head = `Bước ${i + 1}/${steps.length}`;
636
645
  // bước rê chuột mà đích của bước sau đã hiện sẵn thì không cần rê nữa
637
646
  if (st.kind === 'hover' && nx && find(nx.fp).el) return show(i + 1);
638
647
  box.style.borderColor = '#e5484d'; box.style.boxShadow = '0 0 0 9999px rgba(0,0,0,.5)';
@@ -727,6 +736,75 @@
727
736
  if (first >= 0) show(first); else goStart();
728
737
  }
729
738
 
739
+ // ---------- vào thẳng: thư viện tự làm các bước thay người dùng ----------
740
+ const sleep = ms => new Promise(r => setTimeout(r, ms));
741
+ const navTo = path => { history.pushState({}, '', path); dispatchEvent(new PopStateEvent('popstate', { state: null })); };
742
+ const pressEsc = () => document.body.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', code: 'Escape', keyCode: 27, which: 27, bubbles: true }));
743
+ // giả lập rê chuột: sự kiện "enter" không nổi bọt nên bắn riêng cho phần tử và vài cấp cha của nó
744
+ function hoverOn(el) {
745
+ for (const t of ['pointerover', 'mouseover', 'mousemove']) el.dispatchEvent(new MouseEvent(t, { bubbles: true, cancelable: true, view: window }));
746
+ for (let p = el, i = 0; p && p !== document.body && i < 4; p = p.parentElement, i++) for (const t of ['pointerenter', 'mouseenter']) p.dispatchEvent(new MouseEvent(t, { bubbles: false, view: window }));
747
+ }
748
+ async function direct() {
749
+ if (recording) stop();
750
+ if (!steps.length) return setInfo('Chưa có bước nào');
751
+ const my = ++directRun, alive = () => my === directRun;
752
+ const n = steps.length, last = steps[n - 1];
753
+ token++; playing = -1; target = null; atEnd = false; box.style.display = 'none'; cancelAnimationFrame(raf);
754
+ tip.style.display = 'block'; tip.style.left = '12px'; tip.style.top = '12px';
755
+ const say = t => { rich(tipText, t); tipMeta.textContent = ''; setBtns(null, 'Dừng'); };
756
+ say('Đang đưa bạn tới màn hình…');
757
+ const finish = () => { say('Đã đưa bạn tới nơi.'); setBtns(null, null); setTimeout(() => { if (alive()) stopPlay('Đã tới nơi'); }, 1600); };
758
+
759
+ // 1) Bước cuối chuyển sang một trang khác và không mở hộp thoại: vào thẳng trang đó bằng đường dẫn.
760
+ const after = last.after;
761
+ if (after && !after.dialog && !after.step && after.path !== last.fp.ctx.path) {
762
+ if (topDialog()) pressEsc();
763
+ if (location.pathname !== after.path) navTo(after.path);
764
+ for (let t = 0; t < 15 && alive() && context().path !== after.path; t++) await sleep(200);
765
+ if (!alive()) return;
766
+ if (context().path !== after.path) return location.assign(after.path); // app không tự chuyển: tải thẳng trang đó
767
+ return finish();
768
+ }
769
+
770
+ // 2) Còn lại: chỉ làm "chặng cuối", tức các bước liền nhau cùng đường dẫn với bước cuối. Các trang trước đó bỏ qua.
771
+ let first = n - 1;
772
+ while (first > 0 && steps[first - 1].fp.ctx.path === last.fp.ctx.path) first--;
773
+ if (steps[first].fp.ctx.dialog) return play(); // chặng cuối bắt đầu trong một hộp thoại: không tới được bằng đường dẫn, quay về dẫn từng bước
774
+ const c = context();
775
+ let start = -1;
776
+ for (let i = first; i < n && start < 0; i++) if (sameCtx(steps[i].fp.ctx, c) && find(steps[i].fp).el) start = i; // đang đứng sẵn giữa chặng
777
+ if (start < 0) {
778
+ if (topDialog()) pressEsc();
779
+ if (location.pathname !== steps[first].fp.ctx.path) navTo(steps[first].fp.ctx.path);
780
+ start = first;
781
+ }
782
+ for (let i = start; i < n; i++) {
783
+ const st = steps[i];
784
+ if (st.kind === 'info') continue; // bước chỉ để xem, không có gì phải làm
785
+ say(`Đang đưa bạn tới màn hình… (${i + 1}/${n})`);
786
+ const r = await waitFind(st.fp, 6000, steps.slice(i + 1, i + 3).map((x, k) => ({ fp: x.fp, idx: i + 1 + k })));
787
+ if (!r || !alive()) return;
788
+ if (r.skipTo != null) { i = r.skipTo - 1; continue; } // bước này đã được làm sẵn
789
+ if (!r.el || st.kind === 'input') { directRun++; return show(i); } // không tự làm được (không thấy đích, hoặc cần người dùng nhập): dẫn từng bước từ đây
790
+ if (st.kind === 'hover') hoverOn(r.el);
791
+ else { r.el.scrollIntoView({ block: 'center', inline: 'nearest' }); r.el.click(); }
792
+ await sleep(350);
793
+ if (!alive()) return;
794
+ }
795
+ finish();
796
+ }
797
+
798
+ // Bắt đầu một lượt: mode 'guide' (dẫn từng bước, mặc định) hoặc 'direct' (vào thẳng); to = dừng sau bước thứ mấy (mặc định: hết tour).
799
+ function start(opts = {}) {
800
+ if (recording) stop();
801
+ if (!steps.length) return setInfo('Chưa có bước nào');
802
+ if (fullSteps) { steps.length = 0; steps.push(...fullSteps); fullSteps = null; }
803
+ const to = Math.min(Math.max(parseInt(opts.to, 10) || steps.length, 1), steps.length);
804
+ if (to < steps.length) { fullSteps = steps.slice(); steps.length = to; }
805
+ return opts.mode === 'direct' ? direct() : play();
806
+ }
807
+
730
808
  // ---------- máy chủ lưu tour (thư mục server/) ----------
731
809
  const SERVER = String(window.TOURFP_SERVER || 'http://localhost:8000').replace(/\/$/, '');
732
810
  // Chép vào clipboard NGAY trong cú bấm. Nhiều trang (kể cả TCInvest) từ chối quyền ghi clipboard kiểu mới,
@@ -768,20 +846,42 @@
768
846
  }
769
847
  }
770
848
  // tải một tour từ máy chủ theo mã rồi phát
771
- async function run(id) {
849
+ async function run(id, opts = {}) {
772
850
  const r = await fetch(`${SERVER}/api/tours/${encodeURIComponent(id)}`);
773
851
  if (!r.ok) throw new Error('HTTP ' + r.status);
774
852
  const t = await r.json();
775
- steps.length = 0; steps.push(...t.steps); setInfo();
776
- play();
853
+ fullSteps = null; steps.length = 0; steps.push(...t.steps); setInfo();
854
+ start(opts);
855
+ }
856
+ // Hỏi trợ lý trên máy chủ "muốn tới màn hình nào", rồi cho người dùng chọn: dẫn từng bước hay vào thẳng.
857
+ async function ask(query) {
858
+ let a;
859
+ try {
860
+ const r = await fetch(SERVER + '/api/assist', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ query, layout: context().layout }) });
861
+ if (!r.ok) throw new Error('HTTP ' + r.status);
862
+ a = await r.json();
863
+ } catch (e) {
864
+ a = { matched: false, reason: 'Không gọi được trợ lý.' };
865
+ }
866
+ stopPlay();
867
+ tip.style.display = 'block'; tip.style.left = '12px'; tip.style.top = '12px'; tipMeta.textContent = '';
868
+ bClose.style.display = '';
869
+ if (!a.matched) { rich(tipText, a.reason || 'Chưa tìm thấy tour phù hợp.'); setBtns(null, null); choice = {}; return a; }
870
+ rich(tipText, `«${a.tour.name}»` + (a.to_step < a.tour.step_count ? ` · tới bước ${a.to_step}/${a.tour.step_count}` : '') + '\n' + a.reason);
871
+ setBtns(a.options[0].label, a.options[1].label);
872
+ const go = mode => run(a.tour.id, { mode, to: a.to_step }).catch(() => { rich(tipText, 'Không tải được tour.'); });
873
+ choice = { guide: () => go('guide'), direct: () => go('direct') };
874
+ return a;
777
875
  }
778
876
  bSave.onclick = save;
779
877
  if (!PLAYER) probe(); // biết trước nên gửi hay chép, để cú bấm đầu tiên vào nút đã làm đúng việc
780
878
  bRec.onclick = () => (recording ? stop() : record());
781
879
  bPlay.onclick = play;
782
- bClr.onclick = () => { steps.length = 0; stopPlay(); };
783
- bNext.onclick = () => { if (atEnd) { atEnd = false; goStart(); } else show(playing + 1); };
784
- bStop.onclick = () => stopPlay();
880
+ bClr.onclick = () => { fullSteps = null; steps.length = 0; stopPlay(); };
881
+ const pick = k => { const f = choice[k]; choice = null; bClose.style.display = 'none'; if (f) f(); };
882
+ bNext.onclick = () => { if (choice) pick('guide'); else if (atEnd) { atEnd = false; goStart(); } else show(playing + 1); };
883
+ bStop.onclick = () => { if (choice) pick('direct'); else stopPlay(); };
884
+ bClose.onclick = () => stopPlay();
785
885
  const L = [['mousemove', onMove], ['pointerdown', onDown], ['mousedown', onUp], ['pointerup', onUp], ['mouseup', onUp], ['click', onClick]];
786
886
  for (const [n, h] of L) document.addEventListener(n, h, true);
787
887
 
@@ -793,13 +893,13 @@
793
893
  }
794
894
 
795
895
  window.TourFP = {
796
- capture, captureAt: el => capture(clickableRoot(el)), isClickable, save, run,
896
+ capture, captureAt: el => capture(clickableRoot(el)), isClickable, save, run, start, direct, ask,
797
897
  showPanel: (on = true) => { panel.style.display = on ? 'flex' : 'none'; }, find, score, context, clickables, record, stop, play, destroy, steps,
798
898
  apiStats: () => ({ responses: api.responses, values: api.set.size }),
799
899
  isApiValue,
800
900
  state: () => ({ recording, playing, steps: steps.length, target: target ? nameOf(capture(target)) : null, tip: tip.style.display === 'block' ? tipText.textContent : '' }),
801
901
  export: () => JSON.stringify(steps, null, 2),
802
- load: a => { steps.length = 0; steps.push(...a); setInfo(); },
902
+ load: a => { fullSteps = null; steps.length = 0; steps.push(...a); setInfo(); },
803
903
  };
804
904
 
805
905
  // vừa bị tải lại trang giữa lúc đưa về màn bắt đầu: nạp lại tour và phát tiếp (không điều hướng lần nữa)
@@ -812,7 +912,7 @@
812
912
  }
813
913
  } catch (_) { /* không đọc được thì bỏ qua */ }
814
914
 
815
- // mở trang với ?tourfp=<mã>: tải tour đó từ máy chủ và phát luôn
816
- const wanted = new URLSearchParams(location.search).get('tourfp');
817
- if (wanted && !steps.length) run(wanted).catch(() => setInfo('Không tải được tour ' + wanted));
915
+ // mở trang với ?tourfp=<mã>[&mode=guide|direct][&to=<bước>]: tải tour đó từ máy chủ và chạy luôn
916
+ const qs = new URLSearchParams(location.search), wanted = qs.get('tourfp');
917
+ if (wanted && !steps.length) run(wanted, { mode: qs.get('mode'), to: qs.get('to') }).catch(() => setInfo('Không tải được tour ' + wanted));
818
918
  })();