vg-coder-cli 2.0.46 → 2.0.48

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.
@@ -0,0 +1,112 @@
1
+ const path = require('path');
2
+ const fs = require('fs-extra');
3
+ const crypto = require('crypto');
4
+
5
+ function newTaskId() {
6
+ const ts = Date.now();
7
+ const rand = crypto.randomBytes(3).toString('hex');
8
+ return `t_${ts}_${rand}`;
9
+ }
10
+
11
+ function sanitizeTaskId(id) {
12
+ return String(id || '').replace(/[^a-zA-Z0-9_-]/g, '').slice(0, 80);
13
+ }
14
+
15
+ function tasksRoot(workingDir) {
16
+ return path.join(workingDir, '.vg', 'tasks');
17
+ }
18
+
19
+ function taskDir(workingDir, taskId) {
20
+ return path.join(tasksRoot(workingDir), sanitizeTaskId(taskId));
21
+ }
22
+
23
+ function taskJsonPath(workingDir, taskId) {
24
+ return path.join(taskDir(workingDir, taskId), 'task.json');
25
+ }
26
+
27
+ function resultMdPath(workingDir, taskId) {
28
+ return path.join(taskDir(workingDir, taskId), 'result.md');
29
+ }
30
+
31
+ function filesDir(workingDir, taskId) {
32
+ return path.join(taskDir(workingDir, taskId), 'files');
33
+ }
34
+
35
+ async function saveTask(task) {
36
+ const file = taskJsonPath(task.workingDir, task.id);
37
+ await fs.ensureDir(path.dirname(file));
38
+ task.timing = task.timing || {};
39
+ await fs.writeJson(file, task, { spaces: 2 });
40
+ return task;
41
+ }
42
+
43
+ async function loadTask(workingDir, taskId) {
44
+ const file = taskJsonPath(workingDir, taskId);
45
+ if (!await fs.pathExists(file)) return null;
46
+ return fs.readJson(file);
47
+ }
48
+
49
+ async function writeResult(workingDir, taskId, markdown) {
50
+ const file = resultMdPath(workingDir, taskId);
51
+ await fs.ensureDir(path.dirname(file));
52
+ await fs.writeFile(file, markdown || '', 'utf8');
53
+ }
54
+
55
+ async function readResult(workingDir, taskId) {
56
+ const file = resultMdPath(workingDir, taskId);
57
+ if (!await fs.pathExists(file)) return '';
58
+ return fs.readFile(file, 'utf8');
59
+ }
60
+
61
+ async function listTasks(workingDir, filter = {}) {
62
+ const root = tasksRoot(workingDir);
63
+ if (!await fs.pathExists(root)) return [];
64
+ const ids = (await fs.readdir(root)).filter(n => n.startsWith('t_'));
65
+ const out = [];
66
+ for (const id of ids) {
67
+ try {
68
+ const t = await fs.readJson(taskJsonPath(workingDir, id));
69
+ if (filter.status && t.status !== filter.status) continue;
70
+ out.push({
71
+ id: t.id,
72
+ status: t.status,
73
+ prompt: (t.prompt || '').slice(0, 200),
74
+ createdAt: t.timing?.createdAt,
75
+ finishedAt: t.timing?.finishedAt,
76
+ durationMs: t.timing?.durationMs,
77
+ webhookUrl: t.webhookUrl || null
78
+ });
79
+ } catch (_) { /* skip */ }
80
+ }
81
+ out.sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0));
82
+ if (filter.limit) return out.slice(0, filter.limit);
83
+ return out;
84
+ }
85
+
86
+ async function rehydrate(workingDir) {
87
+ const root = tasksRoot(workingDir);
88
+ if (!await fs.pathExists(root)) return [];
89
+ const ids = (await fs.readdir(root)).filter(n => n.startsWith('t_'));
90
+ const tasks = [];
91
+ for (const id of ids) {
92
+ try { tasks.push(await fs.readJson(taskJsonPath(workingDir, id))); }
93
+ catch (_) { /* skip */ }
94
+ }
95
+ return tasks;
96
+ }
97
+
98
+ module.exports = {
99
+ newTaskId,
100
+ sanitizeTaskId,
101
+ tasksRoot,
102
+ taskDir,
103
+ taskJsonPath,
104
+ resultMdPath,
105
+ filesDir,
106
+ saveTask,
107
+ loadTask,
108
+ writeResult,
109
+ readResult,
110
+ listTasks,
111
+ rehydrate
112
+ };
@@ -0,0 +1,48 @@
1
+ const chalk = require('chalk');
2
+ const store = require('./task-store');
3
+
4
+ const RETRY_DELAYS_MS = [1000, 4000, 16000];
5
+
6
+ function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
7
+
8
+ async function deliver(task) {
9
+ if (!task?.webhookUrl) return;
10
+
11
+ const markdown = task.status === 'done' ? await store.readResult(task.workingDir, task.id) : '';
12
+ const body = {
13
+ taskId: task.id,
14
+ status: task.status,
15
+ result: { markdown, chatId: task.result?.chatId || null },
16
+ error: task.error || null,
17
+ durationMs: task.timing?.durationMs || null,
18
+ meta: task.meta || null
19
+ };
20
+
21
+ task.webhook = task.webhook || { attempts: [], deliveredAt: null };
22
+
23
+ for (let i = 0; i < RETRY_DELAYS_MS.length; i++) {
24
+ try {
25
+ const res = await fetch(task.webhookUrl, {
26
+ method: 'POST',
27
+ headers: { 'Content-Type': 'application/json' },
28
+ body: JSON.stringify(body)
29
+ });
30
+ const ok = res.status >= 200 && res.status < 300;
31
+ task.webhook.attempts.push({ at: Date.now(), status: res.status, ok });
32
+ if (ok) {
33
+ task.webhook.deliveredAt = Date.now();
34
+ await store.saveTask(task);
35
+ return true;
36
+ }
37
+ console.log(chalk.yellow(`[Webhook] ${task.id} attempt ${i + 1} → HTTP ${res.status}`));
38
+ } catch (err) {
39
+ task.webhook.attempts.push({ at: Date.now(), error: err.message });
40
+ console.log(chalk.yellow(`[Webhook] ${task.id} attempt ${i + 1} → ${err.message}`));
41
+ }
42
+ if (i < RETRY_DELAYS_MS.length - 1) await sleep(RETRY_DELAYS_MS[i]);
43
+ }
44
+ await store.saveTask(task);
45
+ return false;
46
+ }
47
+
48
+ module.exports = { deliver };
@@ -604,3 +604,104 @@
604
604
  .agent-retry-btn:hover {
605
605
  background: #b91c1c;
606
606
  }
607
+
608
+ /* History Pane — mounted inside #tool-panel-agent */
609
+ #tool-panel-agent { position: relative; }
610
+
611
+ .agent-history-modal-inline {
612
+ position: absolute;
613
+ inset: 0;
614
+ z-index: 50;
615
+ background: #18181b;
616
+ color: #ededed;
617
+ display: flex;
618
+ flex-direction: column;
619
+ overflow: hidden;
620
+ }
621
+
622
+ .agent-history-pane {
623
+ flex: 1;
624
+ display: flex;
625
+ flex-direction: column;
626
+ min-height: 0;
627
+ }
628
+
629
+ .agent-history-header {
630
+ display: flex;
631
+ align-items: center;
632
+ justify-content: space-between;
633
+ padding: 12px 16px;
634
+ border-bottom: 1px solid #27272a;
635
+ font-weight: 600;
636
+ font-size: 14px;
637
+ }
638
+
639
+ .agent-history-close {
640
+ background: transparent;
641
+ border: none;
642
+ color: #a1a1aa;
643
+ font-size: 22px;
644
+ line-height: 1;
645
+ cursor: pointer;
646
+ padding: 0 4px;
647
+ }
648
+ .agent-history-close:hover { color: #ededed; }
649
+
650
+ .agent-history-list {
651
+ overflow-y: auto;
652
+ padding: 6px;
653
+ }
654
+
655
+ .agent-history-empty {
656
+ padding: 24px;
657
+ text-align: center;
658
+ color: #71717a;
659
+ font-size: 13px;
660
+ }
661
+
662
+ .agent-history-row {
663
+ display: flex;
664
+ align-items: center;
665
+ gap: 8px;
666
+ padding: 10px 12px;
667
+ border-radius: 6px;
668
+ cursor: pointer;
669
+ transition: background 0.15s;
670
+ }
671
+ .agent-history-row:hover { background: #27272a; }
672
+
673
+ .agent-history-row-main { flex: 1; min-width: 0; }
674
+
675
+ .agent-history-title {
676
+ font-size: 13px;
677
+ font-weight: 500;
678
+ overflow: hidden;
679
+ text-overflow: ellipsis;
680
+ white-space: nowrap;
681
+ }
682
+
683
+ .agent-history-meta {
684
+ display: flex;
685
+ gap: 10px;
686
+ margin-top: 4px;
687
+ font-size: 11px;
688
+ color: #71717a;
689
+ }
690
+
691
+ .agent-history-delete {
692
+ background: transparent;
693
+ border: none;
694
+ color: #71717a;
695
+ font-size: 18px;
696
+ cursor: pointer;
697
+ padding: 2px 8px;
698
+ border-radius: 4px;
699
+ }
700
+ .agent-history-delete:hover { color: #ef4444; background: rgba(239, 68, 68, 0.1); }
701
+
702
+ [data-theme="light"] .agent-history-modal-inline {
703
+ background: #ffffff;
704
+ color: #18181b;
705
+ }
706
+ [data-theme="light"] .agent-history-header { border-bottom-color: #e5e5ea; }
707
+ [data-theme="light"] .agent-history-row:hover { background: #f2f2f7; }
@@ -4,6 +4,7 @@
4
4
  */
5
5
 
6
6
  import { getById } from '../utils.js';
7
+ import { API_BASE } from '../config.js';
7
8
  // Import markdown-it and mermaid from npm packages (bundled by webpack)
8
9
  import markdownit from 'markdown-it';
9
10
  import mermaid from 'mermaid';
@@ -102,6 +103,19 @@ async function renderAgentPanel() {
102
103
  <path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"></path>
103
104
  </svg>
104
105
  </button>
106
+ <button class="agent-btn" id="agent-history-btn" title="Chat history">
107
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
108
+ <circle cx="12" cy="12" r="10"></circle>
109
+ <polyline points="12 6 12 12 16 14"></polyline>
110
+ </svg>
111
+ </button>
112
+ <button class="agent-btn" id="agent-export-btn" title="Export current chat as .json">
113
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
114
+ <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
115
+ <polyline points="7 10 12 15 17 10"></polyline>
116
+ <line x1="12" y1="15" x2="12" y2="3"></line>
117
+ </svg>
118
+ </button>
105
119
  <button class="agent-btn" id="agent-clear-btn" title="Clear chat">
106
120
  <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
107
121
  <polyline points="3 6 5 6 21 6"></polyline>
@@ -220,6 +234,18 @@ function attachEventListeners() {
220
234
  clearBtn.addEventListener('click', handleClearChat);
221
235
  }
222
236
 
237
+ // History button
238
+ const historyBtn = getById('agent-history-btn');
239
+ if (historyBtn) {
240
+ historyBtn.addEventListener('click', openHistoryModal);
241
+ }
242
+
243
+ // Export button
244
+ const exportBtn = getById('agent-export-btn');
245
+ if (exportBtn) {
246
+ exportBtn.addEventListener('click', exportCurrentChat);
247
+ }
248
+
223
249
  // Drag & drop
224
250
  const dropZone = getById('agent-input-wrapper');
225
251
  if (dropZone) {
@@ -627,7 +653,7 @@ function addMessage(role, content, status = 'done') {
627
653
  timestamp: new Date().toLocaleTimeString('vi-VN')
628
654
  });
629
655
  renderMessages();
630
- // Adapter auto-saves, no manual save needed
656
+ scheduleAutoSave();
631
657
  }
632
658
 
633
659
  /**
@@ -637,30 +663,225 @@ function updateLastMessage(updates) {
637
663
  if (messages.length === 0) return;
638
664
  Object.assign(messages[messages.length - 1], updates);
639
665
  renderMessages();
640
- // Adapter handles storage
666
+ scheduleAutoSave();
641
667
  }
642
668
 
643
669
  /**
644
- * Auto-save is handled by adapter
645
- * No manual save needed
670
+ * Debounced auto-save to .vg/chats/<id>.json
646
671
  */
672
+ function scheduleAutoSave() {
673
+ if (autoSaveTimeout) clearTimeout(autoSaveTimeout);
674
+ autoSaveTimeout = setTimeout(saveCurrentChat, 800);
675
+ }
676
+
677
+ async function saveCurrentChat() {
678
+ if (messages.length === 0) return;
679
+
680
+ // Resolve chat ID: prefer URL-derived, fallback to local timestamp ID kept across saves
681
+ if (!currentChatId) {
682
+ currentChatId = window.AIChat?.getChatIdFromUrl?.() || `local-${Date.now()}`;
683
+ }
684
+
685
+ const source = inferChatSource();
686
+ const title = deriveChatTitle();
687
+
688
+ try {
689
+ const res = await fetch(`${API_BASE}/api/chats/${encodeURIComponent(currentChatId)}`, {
690
+ method: 'POST',
691
+ headers: { 'Content-Type': 'application/json' },
692
+ body: JSON.stringify({ source, title, messages })
693
+ });
694
+ if (!res.ok) console.warn('[AgentPanel] Save failed:', res.status);
695
+ } catch (err) {
696
+ console.warn('[AgentPanel] Save error:', err);
697
+ }
698
+ }
699
+
700
+ function inferChatSource() {
701
+ try {
702
+ const host = window.location?.hostname || '';
703
+ if (host.includes('aistudio.google.com')) return 'aistudio';
704
+ if (host.includes('chatgpt.com') || host.includes('chat.openai.com')) return 'chatgpt';
705
+ if (host.includes('claude.ai')) return 'claude';
706
+ return 'unknown';
707
+ } catch (_) { return 'unknown'; }
708
+ }
709
+
710
+ function deriveChatTitle() {
711
+ const firstUser = messages.find(m => m.role === 'user');
712
+ const raw = (firstUser?.content || '').replace(/\n+/g, ' ').trim();
713
+ return raw.length > 80 ? raw.slice(0, 80) + '…' : (raw || '(untitled)');
714
+ }
647
715
 
648
716
  /**
649
- * Handle clear chat
717
+ * Handle clear chat — deletes server-side too
650
718
  */
651
- function handleClearChat() {
719
+ async function handleClearChat() {
652
720
  if (messages.length === 0) return;
653
-
654
- if (confirm('Clear chat history?')) {
655
- console.log(`[AgentPanel] Deleted chat ${currentChatId}`);
721
+ if (!confirm('Clear chat history?')) return;
722
+
723
+ if (currentChatId) {
724
+ try {
725
+ await fetch(`${API_BASE}/api/chats/${encodeURIComponent(currentChatId)}`, { method: 'DELETE' });
726
+ console.log(`[AgentPanel] Deleted chat ${currentChatId}`);
727
+ } catch (err) {
728
+ console.warn('[AgentPanel] Delete error:', err);
729
+ }
656
730
  }
657
731
 
732
+ currentChatId = null;
658
733
  messages = [];
659
734
  selectedFiles = [];
660
735
  renderFileList();
661
736
  renderMessages();
662
737
  }
663
738
 
739
+ /**
740
+ * Export current chat to a downloadable .json file
741
+ */
742
+ function exportCurrentChat() {
743
+ if (messages.length === 0) {
744
+ alert('Chưa có tin nhắn để export.');
745
+ return;
746
+ }
747
+ const data = {
748
+ id: currentChatId || `local-${Date.now()}`,
749
+ source: inferChatSource(),
750
+ title: deriveChatTitle(),
751
+ exportedAt: Date.now(),
752
+ messages
753
+ };
754
+ const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
755
+ const url = URL.createObjectURL(blob);
756
+ const safeId = String(data.id).replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 80);
757
+ const a = document.createElement('a');
758
+ a.href = url;
759
+ a.download = `chat-${safeId}.json`;
760
+ document.body.appendChild(a);
761
+ a.click();
762
+ document.body.removeChild(a);
763
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
764
+ }
765
+
766
+ /**
767
+ * History modal — list saved chats, click row to load
768
+ */
769
+ let _activeHistoryModal = null;
770
+ let _activeHistoryEscHandler = null;
771
+
772
+ async function openHistoryModal() {
773
+ closeHistoryModal();
774
+
775
+ // Mount inside the Agent tool panel so it sits within the panel layout
776
+ // and never gets covered by host-page UI or other shadow-root overlays.
777
+ const host = getById('tool-panel-agent') || getById('agent-panel-content')
778
+ || window.__VG_CODER_ROOT__ || document.body;
779
+
780
+ const modal = document.createElement('div');
781
+ modal.id = 'agent-history-modal';
782
+ modal.className = 'agent-history-modal agent-history-modal-inline';
783
+ modal.innerHTML = `
784
+ <div class="agent-history-pane">
785
+ <div class="agent-history-header">
786
+ <span>Chat History</span>
787
+ <button class="agent-history-close" title="Back to chat" type="button">←</button>
788
+ </div>
789
+ <div class="agent-history-list" id="agent-history-list">
790
+ <div class="agent-history-empty">⏳ Loading…</div>
791
+ </div>
792
+ </div>
793
+ `;
794
+ host.appendChild(modal);
795
+ _activeHistoryModal = modal;
796
+
797
+ const close = (e) => {
798
+ if (e) { e.preventDefault(); e.stopPropagation(); }
799
+ closeHistoryModal();
800
+ };
801
+
802
+ modal.querySelector('.agent-history-close').addEventListener('click', close);
803
+
804
+ _activeHistoryEscHandler = (e) => { if (e.key === 'Escape') close(e); };
805
+ document.addEventListener('keydown', _activeHistoryEscHandler);
806
+
807
+ try {
808
+ const res = await fetch(`${API_BASE}/api/chats`);
809
+ const data = await res.json();
810
+ const list = data.chats || [];
811
+ const listEl = modal.querySelector('#agent-history-list');
812
+
813
+ if (!list.length) {
814
+ listEl.innerHTML = `<div class="agent-history-empty">No saved chats yet.</div>`;
815
+ return;
816
+ }
817
+
818
+ listEl.innerHTML = list.map(c => `
819
+ <div class="agent-history-row" data-id="${escapeHtml(c.id)}">
820
+ <div class="agent-history-row-main">
821
+ <div class="agent-history-title">${escapeHtml(c.title)}</div>
822
+ <div class="agent-history-meta">
823
+ <span>${escapeHtml(c.source)}</span>
824
+ <span>${c.count} msgs</span>
825
+ <span>${new Date(c.updatedAt || 0).toLocaleString('vi-VN')}</span>
826
+ </div>
827
+ </div>
828
+ <button class="agent-history-delete" data-id="${escapeHtml(c.id)}" title="Delete" type="button">×</button>
829
+ </div>
830
+ `).join('');
831
+
832
+ listEl.querySelectorAll('.agent-history-row').forEach(row => {
833
+ row.addEventListener('click', async (e) => {
834
+ if (e.target.classList.contains('agent-history-delete')) return;
835
+ await loadChatById(row.dataset.id);
836
+ closeHistoryModal();
837
+ });
838
+ });
839
+ listEl.querySelectorAll('.agent-history-delete').forEach(btn => {
840
+ btn.addEventListener('click', async (e) => {
841
+ e.stopPropagation();
842
+ if (!confirm('Delete this chat?')) return;
843
+ await fetch(`${API_BASE}/api/chats/${encodeURIComponent(btn.dataset.id)}`, { method: 'DELETE' });
844
+ openHistoryModal();
845
+ });
846
+ });
847
+ } catch (err) {
848
+ console.error('[AgentPanel] Failed to load history list:', err);
849
+ const listEl = modal.querySelector('#agent-history-list');
850
+ if (listEl) {
851
+ listEl.innerHTML = `<div class="agent-history-empty">❌ ${escapeHtml(err.message)}</div>`;
852
+ }
853
+ }
854
+ }
855
+
856
+ function closeHistoryModal() {
857
+ if (_activeHistoryEscHandler) {
858
+ document.removeEventListener('keydown', _activeHistoryEscHandler);
859
+ _activeHistoryEscHandler = null;
860
+ }
861
+ if (_activeHistoryModal && _activeHistoryModal.parentNode) {
862
+ _activeHistoryModal.parentNode.removeChild(_activeHistoryModal);
863
+ }
864
+ _activeHistoryModal = null;
865
+ // Defensive cleanup in case ref was lost
866
+ const stale = (window.__VG_CODER_ROOT__ || document).querySelector('#agent-history-modal');
867
+ if (stale) stale.remove();
868
+ }
869
+
870
+ async function loadChatById(id) {
871
+ try {
872
+ const res = await fetch(`${API_BASE}/api/chats/${encodeURIComponent(id)}`);
873
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
874
+ const data = await res.json();
875
+ currentChatId = data.id;
876
+ messages = Array.isArray(data.messages) ? data.messages : [];
877
+ renderMessages();
878
+ console.log(`[AgentPanel] Loaded chat ${id} (${messages.length} msgs)`);
879
+ } catch (err) {
880
+ console.error('[AgentPanel] Load chat failed:', err);
881
+ alert(`Load chat failed: ${err.message}`);
882
+ }
883
+ }
884
+
664
885
  /**
665
886
  * Handle add files
666
887
  */
@@ -1,6 +1,6 @@
1
1
  import { getById, qsa, showToast } from '../utils.js';
2
2
  import { getGitDiff } from '../api.js';
3
- import { Diff2HtmlUI } from 'diff2html/lib-esm/src/ui/js/diff2html-ui';
3
+ import { Diff2HtmlUI } from 'diff2html/lib-esm/ui/js/diff2html-ui';
4
4
 
5
5
  let isGitMode = false;
6
6