openclacky 0.9.7 → 0.9.9

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 (85) hide show
  1. checksums.yaml +4 -4
  2. data/.clacky/skills/gem-release/SKILL.md +16 -1
  3. data/CHANGELOG.md +51 -0
  4. data/docs/browser-cdp-native-design.md +195 -0
  5. data/docs/session-management-redesign.md +202 -0
  6. data/docs/system-skill-authoring-guide.md +47 -0
  7. data/lib/clacky/agent/cost_tracker.rb +2 -1
  8. data/lib/clacky/agent/message_compressor_helper.rb +2 -2
  9. data/lib/clacky/agent/session_serializer.rb +36 -3
  10. data/lib/clacky/agent/skill_manager.rb +68 -5
  11. data/lib/clacky/agent/system_prompt_builder.rb +30 -13
  12. data/lib/clacky/agent/tool_executor.rb +8 -12
  13. data/lib/clacky/agent.rb +172 -30
  14. data/lib/clacky/brand_config.rb +90 -0
  15. data/lib/clacky/cli.rb +37 -8
  16. data/lib/clacky/default_parsers/doc_parser.rb +69 -0
  17. data/lib/clacky/default_parsers/docx_parser.rb +172 -0
  18. data/lib/clacky/default_parsers/pdf_parser.rb +79 -0
  19. data/lib/clacky/default_parsers/pptx_parser.rb +140 -0
  20. data/lib/clacky/default_parsers/xlsx_parser.rb +121 -0
  21. data/lib/clacky/default_skills/browser-setup/SKILL.md +158 -0
  22. data/lib/clacky/default_skills/channel-setup/SKILL.md +139 -42
  23. data/lib/clacky/default_skills/channel-setup/feishu_setup.rb +582 -0
  24. data/lib/clacky/default_skills/channel-setup/weixin_setup.rb +274 -0
  25. data/lib/clacky/default_skills/onboard/SKILL.md +132 -6
  26. data/lib/clacky/default_skills/personal-website/SKILL.md +113 -0
  27. data/lib/clacky/default_skills/personal-website/publish.rb +214 -0
  28. data/lib/clacky/default_skills/skill-add/SKILL.md +6 -7
  29. data/lib/clacky/default_skills/skill-add/scripts/install_from_zip.rb +1 -1
  30. data/lib/clacky/default_skills/skill-creator/SKILL.md +3 -3
  31. data/lib/clacky/json_ui_controller.rb +5 -0
  32. data/lib/clacky/message_format/open_ai.rb +7 -1
  33. data/lib/clacky/plain_ui_controller.rb +5 -0
  34. data/lib/clacky/server/browser_manager.rb +308 -0
  35. data/lib/clacky/server/channel/adapters/feishu/adapter.rb +80 -15
  36. data/lib/clacky/server/channel/adapters/feishu/bot.rb +216 -7
  37. data/lib/clacky/server/channel/adapters/feishu/message_parser.rb +12 -0
  38. data/lib/clacky/server/channel/adapters/wecom/adapter.rb +2 -8
  39. data/lib/clacky/server/channel/adapters/weixin/adapter.rb +391 -0
  40. data/lib/clacky/server/channel/adapters/weixin/api_client.rb +374 -0
  41. data/lib/clacky/server/channel/channel_config.rb +6 -0
  42. data/lib/clacky/server/channel/channel_manager.rb +50 -15
  43. data/lib/clacky/server/channel/channel_ui_controller.rb +18 -0
  44. data/lib/clacky/server/channel.rb +1 -0
  45. data/lib/clacky/server/http_server.rb +210 -45
  46. data/lib/clacky/server/scheduler.rb +1 -1
  47. data/lib/clacky/server/session_registry.rb +131 -40
  48. data/lib/clacky/server/web_ui_controller.rb +21 -2
  49. data/lib/clacky/session_manager.rb +43 -73
  50. data/lib/clacky/skill.rb +64 -42
  51. data/lib/clacky/tools/browser.rb +728 -183
  52. data/lib/clacky/tools/invoke_skill.rb +18 -11
  53. data/lib/clacky/tools/shell.rb +16 -7
  54. data/lib/clacky/tools/web_fetch.rb +3 -1
  55. data/lib/clacky/ui2/README.md +1 -1
  56. data/lib/clacky/ui2/components/input_area.rb +5 -2
  57. data/lib/clacky/ui2/components/welcome_banner.rb +23 -0
  58. data/lib/clacky/ui2/ui_controller.rb +16 -2
  59. data/lib/clacky/ui_interface.rb +7 -0
  60. data/lib/clacky/utils/file_processor.rb +116 -139
  61. data/lib/clacky/utils/model_pricing.rb +5 -4
  62. data/lib/clacky/utils/parser_manager.rb +93 -0
  63. data/lib/clacky/utils/workspace_rules.rb +46 -0
  64. data/lib/clacky/version.rb +1 -1
  65. data/lib/clacky/web/app.css +696 -62
  66. data/lib/clacky/web/app.js +181 -51
  67. data/lib/clacky/web/brand.js +14 -0
  68. data/lib/clacky/web/channels.js +10 -1
  69. data/lib/clacky/web/i18n.js +49 -7
  70. data/lib/clacky/web/index.html +67 -10
  71. data/lib/clacky/web/onboard.js +2 -1
  72. data/lib/clacky/web/sessions.js +255 -69
  73. data/lib/clacky/web/settings.js +113 -1
  74. data/lib/clacky/web/skills.js +16 -3
  75. data/lib/clacky/web/tasks.js +11 -6
  76. data/lib/clacky/web/weixin-qr.html +104 -0
  77. data/lib/clacky.rb +2 -0
  78. data/scripts/install.sh +41 -11
  79. metadata +48 -7
  80. data/lib/clacky/default_skills/pdf-reader/SKILL.md +0 -90
  81. data/lib/clacky/utils/file_parser/docx_parser.rb +0 -156
  82. data/lib/clacky/utils/file_parser/pptx_parser.rb +0 -116
  83. data/lib/clacky/utils/file_parser/xlsx_parser.rb +0 -95
  84. data/lib/clacky/utils/file_parser/zip_parser.rb +0 -60
  85. data/scripts/install_agent_browser.sh +0 -67
@@ -102,6 +102,9 @@ const Router = (() => {
102
102
  _current = view;
103
103
  _params = params;
104
104
 
105
+ // Close sidebar on mobile when navigating to any view
106
+ _mobileCloseSidebar();
107
+
105
108
  // ── Clean up previous state ──────────────────────────────────────────
106
109
  if (Sessions.activeId) {
107
110
  Sessions._cacheActiveAndDeselect();
@@ -290,7 +293,9 @@ WS.onEvent(ev => {
290
293
 
291
294
  // ── Session list ───────────────────────────────────────────────────
292
295
  case "session_list": {
293
- Sessions.setAll(ev.sessions || []);
296
+ // Accept new per-source has_more map or legacy boolean
297
+ const hasMoreArg = ev.has_more_by_source || !!ev.has_more;
298
+ Sessions.setAll(ev.sessions || [], hasMoreArg);
294
299
  Sessions.renderList();
295
300
 
296
301
  // Restore URL hash once on initial connect; ignore subsequent session_list events.
@@ -423,6 +428,11 @@ WS.onEvent(ev => {
423
428
  break;
424
429
 
425
430
  // ── Info / errors ──────────────────────────────────────────────────
431
+ case "idle_status":
432
+ // Two-phase: "start" shows a spinner line, "end" updates it in-place
433
+ Sessions.updateIdleStatus(ev.phase, ev.message);
434
+ break;
435
+
426
436
  case "info":
427
437
  Sessions.appendInfo(ev.message);
428
438
  break;
@@ -543,11 +553,9 @@ function _addGenericFile(file) {
543
553
  .then(data => {
544
554
  if (!data.ok) { alert(`Upload failed: ${data.error}`); return; }
545
555
  _pendingFiles.push({
546
- name: data.name,
547
- type: data.type,
548
- path: data.path,
549
- preview_path: data.preview_path || null,
550
- mime_type: file.type
556
+ name: data.name,
557
+ path: data.path,
558
+ mime_type: file.type
551
559
  });
552
560
  _renderAttachmentPreviews();
553
561
  })
@@ -593,17 +601,30 @@ function _renderAttachmentPreviews() {
593
601
  strip.appendChild(item);
594
602
  });
595
603
 
596
- // Render file badges (PDF, ZIP, DOC, XLS, PPT, etc.)
604
+ // Render file cards (PDF, ZIP, DOC, XLS, PPT, etc.)
597
605
  _pendingFiles.forEach((f, idx) => {
598
606
  const item = document.createElement("div");
599
607
  item.className = "pdf-preview-item";
600
608
  item.title = f.name;
609
+
601
610
  const icon = document.createElement("div");
602
611
  icon.className = "pdf-preview-icon";
603
612
  icon.textContent = _docTypeIcon(f.mime_type);
613
+
614
+ const info = document.createElement("div");
615
+ info.className = "pdf-preview-info";
616
+
604
617
  const name = document.createElement("div");
605
618
  name.className = "pdf-preview-name";
606
619
  name.textContent = f.name;
620
+
621
+ const typeLabel = document.createElement("div");
622
+ typeLabel.className = "pdf-preview-type";
623
+ typeLabel.textContent = (f.name.split(".").pop() || "file").toUpperCase();
624
+
625
+ info.appendChild(name);
626
+ info.appendChild(typeLabel);
627
+
607
628
  const removeBtn = document.createElement("button");
608
629
  removeBtn.className = "pdf-preview-remove";
609
630
  removeBtn.textContent = "✕";
@@ -612,8 +633,9 @@ function _renderAttachmentPreviews() {
612
633
  _pendingFiles.splice(idx, 1);
613
634
  _renderAttachmentPreviews();
614
635
  });
636
+
615
637
  item.appendChild(icon);
616
- item.appendChild(name);
638
+ item.appendChild(info);
617
639
  item.appendChild(removeBtn);
618
640
  strip.appendChild(item);
619
641
  });
@@ -642,9 +664,17 @@ function sendMessage() {
642
664
  bubbleHtml = thumbs + (bubbleHtml ? "<br>" + bubbleHtml : "");
643
665
  }
644
666
  if (_pendingFiles.length > 0) {
645
- const badges = _pendingFiles
646
- .map(f => `📄 <em>${escapeHtml(f.name)}</em>`)
647
- .join(" ");
667
+ const badges = _pendingFiles.map(f => {
668
+ const icon = _docTypeIcon(f.mime_type);
669
+ const ext = (f.name.split(".").pop() || "file").toUpperCase();
670
+ return `<span class="msg-pdf-badge">` +
671
+ `<span class="msg-pdf-badge-icon">${icon}</span>` +
672
+ `<span class="msg-pdf-badge-info">` +
673
+ `<span class="msg-pdf-badge-name">${escapeHtml(f.name)}</span>` +
674
+ `<span class="msg-pdf-badge-type">${escapeHtml(ext)}</span>` +
675
+ `</span>` +
676
+ `</span>`;
677
+ }).join(" ");
648
678
  bubbleHtml = badges + (bubbleHtml ? "<br>" + bubbleHtml : "");
649
679
  }
650
680
  Sessions.appendMsg("user", bubbleHtml);
@@ -657,11 +687,9 @@ function sendMessage() {
657
687
  data_url: img.dataUrl
658
688
  })),
659
689
  ..._pendingFiles.map(f => ({
660
- name: f.name,
661
- type: f.type,
662
- mime_type: f.mime_type,
663
- path: f.path,
664
- preview_path: f.preview_path || null
690
+ name: f.name,
691
+ path: f.path,
692
+ mime_type: f.mime_type
665
693
  }))
666
694
  ];
667
695
  _pendingImages.length = 0;
@@ -675,22 +703,92 @@ function sendMessage() {
675
703
  }
676
704
 
677
705
  // ── DOM event listeners ───────────────────────────────────────────────────
678
- // Sidebar toggle
706
+ // Sidebar toggle (with mobile overlay support)
707
+ function _isMobile() { return window.innerWidth <= 768; }
708
+
709
+ function _closeSidebar() {
710
+ $("sidebar").classList.add("hidden");
711
+ $("sidebar-overlay").classList.remove("active");
712
+ }
713
+
714
+ function _openSidebar() {
715
+ $("sidebar").classList.remove("hidden");
716
+ if (_isMobile()) $("sidebar-overlay").classList.add("active");
717
+ }
718
+
719
+ function _toggleSidebar() {
720
+ const isHidden = $("sidebar").classList.contains("hidden");
721
+ isHidden ? _openSidebar() : _closeSidebar();
722
+ }
723
+
679
724
  if ($("btn-toggle-sidebar")) {
680
- $("btn-toggle-sidebar").addEventListener("click", () => {
681
- const sidebar = $("sidebar");
682
- sidebar.classList.toggle("hidden");
683
- });
725
+ $("btn-toggle-sidebar").addEventListener("click", _toggleSidebar);
684
726
  }
685
727
 
686
- // New session buttons (both old and new inline button)
728
+ // Tap overlay to close sidebar on mobile
729
+ $("sidebar-overlay").addEventListener("click", _closeSidebar);
730
+
731
+ // On mobile: start with sidebar hidden
732
+ if (_isMobile()) _closeSidebar();
733
+
734
+ // Mobile back button in chat-header: tap to go back to welcome
735
+ const _btnBack = document.querySelector(".chat-back-btn");
736
+ if (_btnBack) _btnBack.addEventListener("click", () => Router.navigate("welcome"));
737
+
738
+ // On mobile: shorten textarea placeholder so it doesn't wrap
739
+ if (_isMobile()) {
740
+ const inp = $("user-input");
741
+ if (inp) inp.setAttribute("placeholder", inp.placeholder.replace(/\s*[\((].*[\))]/, "").trim());
742
+ }
743
+
744
+ // On mobile: auto-close sidebar when switching sessions/pages
745
+ function _mobileCloseSidebar() {
746
+ if (_isMobile()) _closeSidebar();
747
+ }
748
+
749
+ // ── New session split button [+ ▾] ────────────────────────────────────────
750
+ // btn-new-session-inline: click → create General session immediately
751
+ // btn-new-session-arrow: click → toggle dropdown
752
+ // dropdown items: click → create session with chosen agent_profile
687
753
  if ($("btn-new-session")) {
688
- $("btn-new-session").addEventListener("click", () => Sessions.create());
754
+ $("btn-new-session").addEventListener("click", () => Sessions.create("general"));
689
755
  }
690
756
  if ($("btn-new-session-inline")) {
691
- $("btn-new-session-inline").addEventListener("click", () => Sessions.create());
757
+ $("btn-new-session-inline").addEventListener("click", () => Sessions.create("general"));
758
+ }
759
+ if ($("btn-new-session-arrow")) {
760
+ $("btn-new-session-arrow").addEventListener("click", (e) => {
761
+ e.stopPropagation();
762
+ const dd = $("new-session-dropdown");
763
+ if (dd) dd.hidden = !dd.hidden;
764
+ });
765
+ }
766
+ // Dropdown item clicks
767
+ document.querySelectorAll("#new-session-dropdown .dropdown-item").forEach(item => {
768
+ item.addEventListener("click", (e) => {
769
+ e.stopPropagation();
770
+ const profile = item.dataset.profile || "general";
771
+ $("new-session-dropdown").hidden = true;
772
+ Sessions.create(profile);
773
+ });
774
+ });
775
+ // Close dropdown when clicking elsewhere
776
+ document.addEventListener("click", () => {
777
+ const dd = $("new-session-dropdown");
778
+ if (dd && !dd.hidden) dd.hidden = true;
779
+ });
780
+
781
+ $("btn-welcome-new").addEventListener("click", () => Sessions.create("general"));
782
+
783
+ // ── Session source tabs ───────────────────────────────────────────────────
784
+ document.querySelectorAll(".session-tab").forEach(btn => {
785
+ btn.addEventListener("click", () => Sessions.setTab(btn.dataset.tab));
786
+ });
787
+
788
+ // Load-more sessions button
789
+ if ($("btn-load-more-sessions")) {
790
+ $("btn-load-more-sessions").addEventListener("click", () => Sessions.loadMoreSessions());
692
791
  }
693
- $("btn-welcome-new").addEventListener("click", () => Sessions.create());
694
792
 
695
793
  // Theme toggle in header
696
794
  if ($("theme-toggle-header")) {
@@ -763,12 +861,16 @@ $("user-input").addEventListener("paste", e => {
763
861
  attachItems.forEach(it => _addAttachmentFile(it.getAsFile()));
764
862
  });
765
863
 
766
- // Note: do NOT use a manual _composing flag + compositionstart/compositionend.
767
- // IMEs like Sogou fire keydown(Enter) in the same tick as compositionend, so
768
- // the flag would already be false when the Enter keydown arrives — causing an
769
- // accidental send. e.isComposing is set by the browser on the event object
770
- // itself and remains true for the keydown that terminates a composition,
771
- // which is exactly what we need.
864
+ // Cross-browser IME composition fix:
865
+ // Safari fires compositionend BEFORE keydown (violating W3C spec), and the
866
+ // gap between compositionend and keydown is ~5ms on Safari. We record the
867
+ // timestamp of compositionend and treat any Enter keydown within 20ms as
868
+ // still-composing. Chrome is unaffected because e.isComposing is still true.
869
+ // Reference: https://bugs.webkit.org/show_bug.cgi?id=165004
870
+ let _lastCompositionEndTime = -Infinity;
871
+ $("user-input").addEventListener("compositionend", () => {
872
+ _lastCompositionEndTime = Date.now();
873
+ });
772
874
 
773
875
  // Hide skill autocomplete when input loses focus (unless clicking a dropdown item)
774
876
  $("user-input").addEventListener("blur", () => {
@@ -780,20 +882,24 @@ $("user-input").addEventListener("blur", () => {
780
882
  const SkillAC = (() => {
781
883
  let _visible = false;
782
884
  let _activeIndex = -1;
783
- let _items = []; // filtered [{ name, description }]
784
- let _sessionSkills = []; // skills allowed for the active session's profile
885
+ let _items = []; // filtered [{ name, description, encrypted, source }]
886
+ let _currentSession = null; // track active session id for live fetch
785
887
 
786
- /** Fetch session-specific skill list from the server and cache it.
787
- * Called whenever the active session changes. */
788
- async function _loadForSession(sessionId) {
789
- if (!sessionId) { _sessionSkills = []; return; }
888
+ /** Called whenever the active session changes just store the id, no prefetch. */
889
+ function _loadForSession(sessionId) {
890
+ _currentSession = sessionId || null;
891
+ }
892
+
893
+ /** Fetch live skill list from server for the current session. */
894
+ async function _fetchSkills() {
895
+ if (!_currentSession) return [];
790
896
  try {
791
- const res = await fetch(`/api/sessions/${sessionId}/skills`);
897
+ const res = await fetch(`/api/sessions/${_currentSession}/skills`);
792
898
  const data = await res.json();
793
- _sessionSkills = data.skills || [];
899
+ return data.skills || [];
794
900
  } catch (e) {
795
- console.error("[SkillAC] loadForSession failed", e);
796
- _sessionSkills = [];
901
+ console.error("[SkillAC] fetchSkills failed", e);
902
+ return [];
797
903
  }
798
904
  }
799
905
 
@@ -807,9 +913,8 @@ const SkillAC = (() => {
807
913
  return null;
808
914
  }
809
915
 
810
- function _render(query) {
811
- // Use session-scoped skill list when available; fall back to global Skills list
812
- const all = _sessionSkills.length > 0 ? _sessionSkills : Skills.all;
916
+ async function _render(query) {
917
+ const all = await _fetchSkills();
813
918
  _items = all.filter(s => s.name.toLowerCase().startsWith(query));
814
919
 
815
920
  if (_items.length === 0) { _hide(); return; }
@@ -833,11 +938,36 @@ const SkillAC = (() => {
833
938
  nameEl.className = "skill-ac-name";
834
939
  nameEl.textContent = "/" + skill.name;
835
940
 
941
+ // meta: encrypted badge + source type label (subtle)
942
+ const metaEl = document.createElement("span");
943
+ metaEl.className = "skill-ac-meta";
944
+ if (skill.encrypted) {
945
+ const encBadge = document.createElement("span");
946
+ encBadge.className = "skill-ac-enc";
947
+ encBadge.textContent = "🔒";
948
+ metaEl.appendChild(encBadge);
949
+ }
950
+ const sourceLabel = {
951
+ "default": "built-in",
952
+ "global_clacky": "user",
953
+ "global_claude": "user",
954
+ "project_clacky": "project",
955
+ "project_claude": "project",
956
+ "brand": "brand",
957
+ }[skill.source_type];
958
+ if (sourceLabel) {
959
+ const srcEl = document.createElement("span");
960
+ srcEl.className = "skill-ac-src";
961
+ srcEl.textContent = sourceLabel;
962
+ metaEl.appendChild(srcEl);
963
+ }
964
+
836
965
  const descEl = document.createElement("span");
837
966
  descEl.className = "skill-ac-desc";
838
967
  descEl.textContent = skill.description || "";
839
968
 
840
969
  item.appendChild(nameEl);
970
+ item.appendChild(metaEl);
841
971
  item.appendChild(descEl);
842
972
 
843
973
  item.addEventListener("mousedown", e => {
@@ -884,18 +1014,18 @@ const SkillAC = (() => {
884
1014
  }
885
1015
 
886
1016
  /** Open the dropdown showing all skills, used by the / button. */
887
- function _openAll() {
1017
+ async function _openAll() {
888
1018
  _activeIndex = 0; // Default to first item
889
- _render("");
1019
+ await _render("");
890
1020
  $("user-input").focus();
891
1021
  }
892
1022
 
893
1023
  /** Toggle the dropdown (open if hidden, close if visible). */
894
- function _toggle() {
1024
+ async function _toggle() {
895
1025
  if (_visible) {
896
1026
  _hide();
897
1027
  } else {
898
- _openAll();
1028
+ await _openAll();
899
1029
  }
900
1030
  }
901
1031
 
@@ -908,7 +1038,7 @@ const SkillAC = (() => {
908
1038
  const query = _getSlashQuery(value);
909
1039
  if (query === null) { _hide(); return; }
910
1040
  _activeIndex = 0; // Always highlight the first match
911
- _render(query);
1041
+ _render(query); // async, fire-and-forget
912
1042
  },
913
1043
 
914
1044
  /** Open dropdown with all skills (triggered by / button). */
@@ -933,7 +1063,7 @@ const SkillAC = (() => {
933
1063
  _select(targetIdx);
934
1064
  return true;
935
1065
  }
936
- if (e.key === "Enter" && !e.isComposing) {
1066
+ if (e.key === "Enter" && !e.isComposing && (Date.now() - _lastCompositionEndTime) > 20) {
937
1067
  if (_activeIndex >= 0) {
938
1068
  e.preventDefault();
939
1069
  _select(_activeIndex);
@@ -960,7 +1090,7 @@ $("user-input").addEventListener("keydown", e => {
960
1090
  // Let skill autocomplete consume arrow/enter/escape first
961
1091
  if (SkillAC.handleKey(e)) return;
962
1092
 
963
- if (e.key === "Enter" && !e.shiftKey && !e.isComposing) {
1093
+ if (e.key === "Enter" && !e.shiftKey && !e.isComposing && (Date.now() - _lastCompositionEndTime) > 20) {
964
1094
  e.preventDefault();
965
1095
  sendMessage();
966
1096
  }
@@ -213,6 +213,20 @@ const Brand = (() => {
213
213
  const logoText = document.getElementById("header-logo");
214
214
  const brandWrap = document.getElementById("header-brand");
215
215
 
216
+ // Apply theme color — overrides --color-accent-primary and --color-button-primary
217
+ if (info.theme_color) {
218
+ const root = document.documentElement;
219
+ root.style.setProperty("--color-accent-primary", info.theme_color);
220
+ root.style.setProperty("--color-accent-hover", info.theme_color);
221
+ root.style.setProperty("--color-button-primary", info.theme_color);
222
+ root.style.setProperty("--color-button-primary-hover", info.theme_color);
223
+ // Also update browser tab color on mobile
224
+ const metaTheme = document.querySelector("meta[name='theme-color']");
225
+ if (metaTheme) metaTheme.setAttribute("content", info.theme_color);
226
+ }
227
+
228
+ // header-brand already has onclick="Router.navigate('chat')" in HTML, no extra link needed
229
+
216
230
  const hasLogo = !!(info.logo_url && logoImg);
217
231
 
218
232
  if (hasLogo) {
@@ -27,6 +27,14 @@ const Channels = (() => {
27
27
  setupCmd: "/channel-setup setup wecom",
28
28
  testCmd: "/channel-setup doctor",
29
29
  },
30
+ weixin: {
31
+ logo: "信",
32
+ logoClass: "channel-logo-weixin",
33
+ name: "Weixin (微信)",
34
+ desc: I18n.t("channels.weixin.desc"),
35
+ setupCmd: "/channel-setup setup weixin",
36
+ testCmd: "/channel-setup doctor",
37
+ },
30
38
  };
31
39
  }
32
40
 
@@ -164,7 +172,7 @@ const Channels = (() => {
164
172
  const res = await fetch("/api/sessions", {
165
173
  method: "POST",
166
174
  headers: { "Content-Type": "application/json" },
167
- body: JSON.stringify({ name }),
175
+ body: JSON.stringify({ name, source: "setup" }),
168
176
  });
169
177
  const data = await res.json();
170
178
  if (!res.ok) throw new Error(data.error || I18n.t("channels.sessionError"));
@@ -173,6 +181,7 @@ const Channels = (() => {
173
181
 
174
182
  // Register in Sessions, refresh sidebar, queue command, then navigate
175
183
  Sessions.add(session);
184
+ Sessions.setTab("setup");
176
185
  Sessions.renderList();
177
186
  Sessions.setPendingMessage(session.id, command);
178
187
  Sessions.select(session.id);
@@ -45,6 +45,13 @@ const I18n = (() => {
45
45
  "sessions.createError": "Error: ",
46
46
  "sessions.thinking": "Thinking…",
47
47
  "sessions.default_name": "Session {{n}}",
48
+ "sessions.tab.manual": "Manual",
49
+ "sessions.tab.scheduled": "Scheduled",
50
+ "sessions.tab.channel": "Channel",
51
+ "sessions.tab.setup": "Setup",
52
+ "sessions.newSession": "+ New Session",
53
+ "sessions.loadMore": "Load more sessions",
54
+ "sessions.loadingMore": "Loading…",
48
55
 
49
56
  // ── Modal ──
50
57
  "modal.yes": "Yes",
@@ -135,7 +142,7 @@ const I18n = (() => {
135
142
 
136
143
  // ── Channels panel ──
137
144
  "channels.title": "Channels",
138
- "channels.subtitle": "Connect IM platforms so your users can chat with the assistant via Feishu or WeCom",
145
+ "channels.subtitle": "Connect IM platforms so your users can chat with the assistant via Feishu, WeCom or Weixin",
139
146
  "channels.loading": "Loading…",
140
147
  "channels.badge.running": "Running",
141
148
  "channels.badge.enabled": "Enabled",
@@ -155,6 +162,7 @@ const I18n = (() => {
155
162
  "channels.noSession": "No session returned",
156
163
  "channels.feishu.desc": "Connect via Feishu open platform WebSocket long connection",
157
164
  "channels.wecom.desc": "Connect via WeCom intelligent robot WebSocket",
165
+ "channels.weixin.desc": "Connect via WeChat iLink bot (QR login, HTTP long-poll)",
158
166
 
159
167
  // ── Settings panel ──
160
168
  "settings.title": "Settings",
@@ -197,13 +205,23 @@ const I18n = (() => {
197
205
  "settings.personalize.btn": "✨ Re-run Onboard",
198
206
  "settings.personalize.btn.starting": "Starting…",
199
207
  "settings.personalize.btn.rerun": "✨ Re-run Onboard",
208
+ "settings.browser.title": "Browser",
209
+ "settings.browser.desc": "Connect your browser to enable browser automation.",
210
+ "settings.browser.configured": "✅ Browser connected",
211
+ "settings.browser.disabled": "⏸ Browser disabled",
212
+ "settings.browser.btn": "🌐 Configure Browser",
213
+ "settings.browser.btn.reconfigure": "🌐 Reconfigure Browser",
214
+ "settings.browser.btn.starting": "Starting…",
200
215
  "settings.brand.title": "Brand & License",
201
216
  "settings.brand.label.brand": "Brand",
202
217
  "settings.brand.label.status": "Status",
203
218
  "settings.brand.label.expires": "Expires",
219
+ "settings.brand.label.homepage": "Homepage",
220
+ "settings.brand.label.supportContact": "Support",
204
221
  "settings.brand.label.supportQr": "Tech Support",
205
222
  "settings.brand.label.qrHint": "Scan with your phone camera",
206
223
  "settings.brand.btn.change": "Change License Key",
224
+ "settings.brand.confirmRebind": "Warning: all previously installed brand skills will be deleted and cannot be used. Continue?",
207
225
  "settings.brand.badge.active": "Active",
208
226
  "settings.brand.badge.warning": "Expiring Soon",
209
227
  "settings.brand.badge.expired": "Expired",
@@ -296,6 +314,13 @@ const I18n = (() => {
296
314
  "sessions.createError": "错误:",
297
315
  "sessions.thinking": "思考中…",
298
316
  "sessions.default_name": "对话 {{n}}",
317
+ "sessions.tab.manual": "默认",
318
+ "sessions.tab.scheduled": "定时",
319
+ "sessions.tab.channel": "频道",
320
+ "sessions.tab.setup": "配置",
321
+ "sessions.newSession": "+ 新会话",
322
+ "sessions.loadMore": "加载更多会话",
323
+ "sessions.loadingMore": "加载中…",
299
324
 
300
325
  // ── Modal ──
301
326
  "modal.yes": "确认",
@@ -386,13 +411,13 @@ const I18n = (() => {
386
411
 
387
412
  // ── Channels panel ──
388
413
  "channels.title": "频道",
389
- "channels.subtitle": "连接即时通讯平台,让用户通过飞书或企业微信与助手对话",
414
+ "channels.subtitle": "连接即时通讯平台,让用户通过飞书、企业微信或微信与助手对话",
390
415
  "channels.loading": "加载中…",
391
416
  "channels.loadError": "加载频道失败:{{msg}}",
392
- "channels.badge.running": "运行中",
393
- "channels.badge.enabled": "已启用",
394
- "channels.badge.disabled": "未配置",
395
- "channels.badge.notConfigured": "未配置",
417
+ "channels.badge.running": "运行中",
418
+ "channels.badge.enabled": "已启用",
419
+ "channels.badge.disabled": "未配置",
420
+ "channels.badge.notConfigured": "未配置",
396
421
  "channels.hint.running": "适配器运行中,正在接受用户消息",
397
422
  "channels.hint.enabled": "⚠ 已启用但未运行 — 适配器可能连接失败,请点击「诊断问题」排查。",
398
423
  "channels.hint.enabledNotRunning":"已启用但未运行 — 适配器可能连接失败,请点击「诊断问题」排查。",
@@ -406,6 +431,7 @@ const I18n = (() => {
406
431
  "channels.noSession": "未返回对话",
407
432
  "channels.feishu.desc": "通过飞书开放平台 WebSocket 长连接接入",
408
433
  "channels.wecom.desc": "通过企业微信智能机器人 WebSocket 接入",
434
+ "channels.weixin.desc": "通过微信 iLink 机器人接入(扫码登录,HTTP 长轮询)",
409
435
 
410
436
  // ── Settings panel ──
411
437
  "settings.title": "设置",
@@ -448,13 +474,23 @@ const I18n = (() => {
448
474
  "settings.personalize.btn": "✨ 重新引导",
449
475
  "settings.personalize.btn.starting": "启动中…",
450
476
  "settings.personalize.btn.rerun": "✨ 重新引导",
477
+ "settings.browser.title": "浏览器",
478
+ "settings.browser.desc": "连接浏览器以启用浏览器自动化功能。",
479
+ "settings.browser.configured": "✅ 浏览器已连接",
480
+ "settings.browser.disabled": "⏸ 浏览器已禁用",
481
+ "settings.browser.btn": "🌐 配置浏览器",
482
+ "settings.browser.btn.reconfigure": "🌐 重新配置浏览器",
483
+ "settings.browser.btn.starting": "启动中…",
451
484
  "settings.brand.title": "品牌 & 授权",
452
485
  "settings.brand.label.brand": "品牌",
453
486
  "settings.brand.label.status": "状态",
454
487
  "settings.brand.label.expires": "到期时间",
488
+ "settings.brand.label.homepage": "主页",
489
+ "settings.brand.label.supportContact": "联系支持",
455
490
  "settings.brand.label.supportQr": "技术支持",
456
491
  "settings.brand.label.qrHint": "使用手机扫描二维码",
457
492
  "settings.brand.btn.change": "更换授权码",
493
+ "settings.brand.confirmRebind": "警告:所有已安装的历史品牌技能将被删除,无法继续使用。确认继续?",
458
494
  "settings.brand.badge.active": "已激活",
459
495
  "settings.brand.badge.warning": "即将过期",
460
496
  "settings.brand.badge.expired": "已过期",
@@ -528,6 +564,7 @@ const I18n = (() => {
528
564
  _lang = TRANSLATIONS[code] ? code : DEFAULT_LANG;
529
565
  localStorage.setItem(STORAGE_KEY, _lang);
530
566
  applyAll();
567
+ document.dispatchEvent(new CustomEvent("langchange", { detail: { lang: _lang } }));
531
568
  }
532
569
 
533
570
  /**
@@ -558,7 +595,12 @@ const I18n = (() => {
558
595
  document.querySelectorAll("[data-i18n-placeholder]").forEach(el => {
559
596
  const key = el.getAttribute("data-i18n-placeholder");
560
597
  const vars = _extractVars(el);
561
- el.placeholder = t(key, vars);
598
+ let ph = t(key, vars);
599
+ // On mobile: strip the parenthetical hint so placeholder doesn't wrap
600
+ if (window.innerWidth <= 768) {
601
+ ph = ph.replace(/\s*[\((].*[\))]/, "").trim();
602
+ }
603
+ el.placeholder = ph;
562
604
  });
563
605
  document.querySelectorAll("[data-i18n-title]").forEach(el => {
564
606
  const key = el.getAttribute("data-i18n-title");