Skip to content

Commit a46533b

Browse files
authored
feat(sidepanel): add copy message and chat history download capabilities (#87)
1 parent c9f840a commit a46533b

9 files changed

Lines changed: 260 additions & 8 deletions

File tree

background/service-worker.js

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,8 @@ async function handleMessage(msg, sender) {
215215
return ensureContentAndSend(msg.tabId, { type: 'REMOVE_ANNOTATION', id: msg.id });
216216
case 'EXPORT_CONTEXT':
217217
return ensureContentAndSend(msg.tabId, { type: 'EXPORT_CONTEXT' });
218+
case 'DOWNLOAD_CHAT':
219+
return downloadChatHistory({ markdown: msg.markdown, filename: msg.filename });
218220
case 'CAPTURE_PAGE_FEEDBACK':
219221
return capturePageFeedback({ tabId: msg.tabId });
220222
case 'PAGE_FEEDBACK_CHANGED':
@@ -1164,6 +1166,15 @@ async function saveDownload(filename, url) {
11641166
return chrome.downloads.download({ url, filename, saveAs: false, conflictAction: 'uniquify' });
11651167
}
11661168

1169+
// サイドパネルからのチャット履歴ダウンロード委譲。特権 API(chrome.downloads)は
1170+
// SW 側でのみ実行する(AGENTS.md の設計境界)。saveAs:true で保存先を確認させる。
1171+
async function downloadChatHistory({ markdown, filename }) {
1172+
const url = textToDataUrl(String(markdown ?? ''), 'text/markdown');
1173+
const safeName = typeof filename === 'string' && filename ? filename : 'chat.md';
1174+
const downloadId = await chrome.downloads.download({ url, filename: safeName, saveAs: true });
1175+
return { downloadId };
1176+
}
1177+
11671178
// UTF-8 を安全に base64 data URL 化する(日本語メモ対応)。
11681179
function textToDataUrl(text, mime) {
11691180
const bytes = new TextEncoder().encode(text);

sidepanel/locales/en.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@
103103
"errors.pickerStartFailed": "Could not start context mode: {message}",
104104
"errors.drawingStartFailed": "Could not start drawing: {message}",
105105
"errors.captureFailed": "Failed to save page feedback: {message}",
106+
"errors.downloadFailed": "Failed to download chat history: {message}",
106107
"errors.contextCopyFailed": "Failed to copy context: {message}",
107108
"errors.tabIdCopyFailed": "Failed to copy tab ID: {message}",
108109
"errors.clipboardUnavailable": "Clipboard is unavailable.",
@@ -116,6 +117,10 @@
116117
"chat.deleteAllAria": "Delete all chat messages",
117118
"chat.deleteTurnTitle": "Delete this chat",
118119
"chat.deleteTurnAria": "Delete this chat",
120+
"chat.downloadAllTitle": "Download chat history as markdown",
121+
"chat.downloadAllAria": "Download chat history",
122+
"chat.copyMessageTitle": "Copy this message",
123+
"chat.copyMessageAria": "Copy message",
119124
"roles.user": "You",
120125
"roles.assistant": "AI",
121126
"roles.error": "Error",

sidepanel/locales/ja.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@
103103
"errors.pickerStartFailed": "注釈モードを開始できません: {message}",
104104
"errors.drawingStartFailed": "描画を開始できません: {message}",
105105
"errors.captureFailed": "ページフィードバックの保存に失敗: {message}",
106+
"errors.downloadFailed": "チャット履歴のダウンロードに失敗: {message}",
106107
"errors.contextCopyFailed": "文脈のコピーに失敗: {message}",
107108
"errors.tabIdCopyFailed": "Tab ID のコピーに失敗: {message}",
108109
"errors.clipboardUnavailable": "クリップボードを使えません。",
@@ -116,6 +117,10 @@
116117
"chat.deleteAllAria": "チャットメッセージをすべて削除",
117118
"chat.deleteTurnTitle": "このチャットを削除",
118119
"chat.deleteTurnAria": "このチャットを削除",
120+
"chat.downloadAllTitle": "チャット履歴をMarkdownとしてダウンロード",
121+
"chat.downloadAllAria": "チャット履歴をダウンロード",
122+
"chat.copyMessageTitle": "このメッセージをコピー",
123+
"chat.copyMessageAria": "メッセージをコピー",
119124
"roles.user": "あなた",
120125
"roles.assistant": "AI",
121126
"roles.error": "エラー",

sidepanel/locales/ko.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@
103103
"errors.pickerStartFailed": "맥락 모드를 시작할 수 없습니다: {message}",
104104
"errors.drawingStartFailed": "그리기를 시작할 수 없습니다: {message}",
105105
"errors.captureFailed": "페이지 피드백 저장 실패: {message}",
106+
"errors.downloadFailed": "채팅 기록 다운로드 실패: {message}",
106107
"errors.contextCopyFailed": "맥락 복사 실패: {message}",
107108
"errors.tabIdCopyFailed": "Tab ID 복사 실패: {message}",
108109
"errors.clipboardUnavailable": "클립보드를 사용할 수 없습니다.",
@@ -116,6 +117,10 @@
116117
"chat.deleteAllAria": "채팅 메시지 모두 삭제",
117118
"chat.deleteTurnTitle": "이 채팅 삭제",
118119
"chat.deleteTurnAria": "이 채팅 삭제",
120+
"chat.downloadAllTitle": "채팅 기록을 마크다운으로 다운로드",
121+
"chat.downloadAllAria": "채팅 기록 다운로드",
122+
"chat.copyMessageTitle": "이 메시지 복사",
123+
"chat.copyMessageAria": "메시지 복사",
119124
"roles.user": "",
120125
"roles.assistant": "AI",
121126
"roles.error": "오류",

sidepanel/locales/zh.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@
103103
"errors.pickerStartFailed": "无法启动上下文模式:{message}",
104104
"errors.drawingStartFailed": "无法启动绘图:{message}",
105105
"errors.captureFailed": "保存页面反馈失败:{message}",
106+
"errors.downloadFailed": "下载聊天记录失败:{message}",
106107
"errors.contextCopyFailed": "复制上下文失败:{message}",
107108
"errors.tabIdCopyFailed": "复制 Tab ID 失败:{message}",
108109
"errors.clipboardUnavailable": "剪贴板不可用。",
@@ -116,6 +117,10 @@
116117
"chat.deleteAllAria": "删除所有聊天消息",
117118
"chat.deleteTurnTitle": "删除此聊天",
118119
"chat.deleteTurnAria": "删除此聊天",
120+
"chat.downloadAllTitle": "下载聊天记录为 Markdown",
121+
"chat.downloadAllAria": "下载聊天记录",
122+
"chat.copyMessageTitle": "复制此消息",
123+
"chat.copyMessageAria": "复制消息",
119124
"roles.user": "",
120125
"roles.assistant": "AI",
121126
"roles.error": "错误",

sidepanel/sidepanel.css

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1020,7 +1020,14 @@ textarea:focus-visible {
10201020
color: var(--muted);
10211021
}
10221022

1023-
.msg-delete {
1023+
.msg-head-actions {
1024+
display: flex;
1025+
align-items: center;
1026+
gap: 4px;
1027+
}
1028+
1029+
.msg-delete,
1030+
.msg-copy {
10241031
display: inline-grid;
10251032
place-items: center;
10261033
width: 28px;
@@ -1034,7 +1041,10 @@ textarea:focus-visible {
10341041
}
10351042

10361043
.msg:focus-within .msg-delete,
1037-
.msg:hover .msg-delete {
1044+
.msg:hover .msg-delete,
1045+
.msg:focus-within .msg-delete,
1046+
.msg:hover .msg-copy,
1047+
.msg:focus-within .msg-copy {
10381048
opacity: 1;
10391049
}
10401050

@@ -1043,12 +1053,19 @@ textarea:focus-visible {
10431053
color: #fca5a5;
10441054
}
10451055

1046-
.msg-delete:disabled {
1056+
.msg-copy:hover {
1057+
background: rgba(255, 255, 255, 0.08);
1058+
color: var(--text);
1059+
}
1060+
1061+
.msg-delete:disabled,
1062+
.msg-copy:disabled {
10471063
cursor: default;
10481064
opacity: 0.35;
10491065
}
10501066

1051-
.msg-delete .action-icon {
1067+
.msg-delete .action-icon,
1068+
.msg-copy .action-icon {
10521069
width: 15px;
10531070
height: 15px;
10541071
}

sidepanel/sidepanel.html

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,21 @@
4848
<path d="M10 11v5M14 11v5" />
4949
</svg>
5050
</button>
51+
<button
52+
id="btn-download-chat"
53+
class="icon-action"
54+
type="button"
55+
title="Download chat history"
56+
aria-label="Download chat history"
57+
data-i18n-title="chat.downloadAllTitle"
58+
data-i18n-aria-label="chat.downloadAllAria"
59+
>
60+
<svg class="action-icon" viewBox="0 0 24 24" aria-hidden="true">
61+
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
62+
<polyline points="7 10 12 15 17 10" />
63+
<line x1="12" y1="15" x2="12" y2="3" />
64+
</svg>
65+
</button>
5166
<button
5267
id="btn-settings"
5368
class="icon-action"

sidepanel/sidepanel.js

Lines changed: 132 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ const els = {
4343
promptHistoryList: document.getElementById('prompt-history-list'),
4444
btnHistoryClear: document.getElementById('btn-history-clear'),
4545
btnClearChat: document.getElementById('btn-clear-chat'),
46+
btnDownloadChat: document.getElementById('btn-download-chat'),
4647
btnWorkflow: document.getElementById('btn-workflow'),
4748
workflowCountBadge: document.getElementById('workflow-count-badge'),
4849
workflowPanel: document.getElementById('workflow-panel'),
@@ -491,6 +492,7 @@ async function clearPromptHistory() {
491492
}
492493

493494
function renderChatHistory() {
495+
updateDownloadAvailability();
494496
if (!state.history.length) {
495497
// 空状態: 同一言語のリッチな空ヒントが既に描画済みなら作り直さない。
496498
// init の即時描画 → 履歴ロード(空)で同じ空状態が二度描かれるときの
@@ -635,7 +637,13 @@ function addMessage(role, content, options = {}) {
635637
roleEl.className = 'role';
636638
roleEl.textContent = roleLabel(role);
637639
head.appendChild(roleEl);
640+
641+
const headActions = document.createElement('div');
642+
headActions.className = 'msg-head-actions';
643+
head.appendChild(headActions);
644+
638645
wrap.appendChild(head);
646+
setMessageCopyButton(wrap, content);
639647
setMessageDeleteButton(wrap, options.messageIndex);
640648

641649
const bubble = document.createElement('div');
@@ -654,10 +662,10 @@ function roleLabel(role) {
654662
}
655663

656664
function setMessageDeleteButton(wrap, messageIndex) {
657-
const head = wrap?.querySelector('.msg-head');
658-
if (!head || !Number.isInteger(messageIndex) || messageIndex < 0) return;
659-
head.querySelector('.msg-delete')?.remove();
660-
head.appendChild(buildMessageDeleteButton(messageIndex));
665+
const actions = wrap?.querySelector('.msg-head-actions');
666+
if (!actions || !Number.isInteger(messageIndex) || messageIndex < 0) return;
667+
actions.querySelector('.msg-delete')?.remove();
668+
actions.appendChild(buildMessageDeleteButton(messageIndex));
661669
}
662670

663671
function buildMessageDeleteButton(messageIndex) {
@@ -678,6 +686,48 @@ function buildMessageDeleteButton(messageIndex) {
678686
return btn;
679687
}
680688

689+
function setMessageCopyButton(wrap, content) {
690+
const actions = wrap?.querySelector('.msg-head-actions');
691+
if (!actions) return;
692+
actions.querySelector('.msg-copy')?.remove();
693+
actions.appendChild(buildMessageCopyButton(content));
694+
}
695+
696+
function buildMessageCopyButton(content) {
697+
const btn = document.createElement('button');
698+
btn.type = 'button';
699+
btn.className = 'msg-copy';
700+
btn.title = t('chat.copyMessageTitle');
701+
btn.setAttribute('aria-label', t('chat.copyMessageAria'));
702+
btn.disabled = state.busy;
703+
btn.innerHTML = `
704+
<svg class="action-icon copy-icon" viewBox="0 0 24 24" aria-hidden="true">
705+
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
706+
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
707+
</svg>
708+
<svg class="action-icon copied-icon" viewBox="0 0 24 24" aria-hidden="true" style="display:none; stroke:var(--ok)">
709+
<polyline points="20 6 9 17 4 12" />
710+
</svg>`;
711+
btn.addEventListener('click', async () => {
712+
try {
713+
await copyTextToClipboard(content);
714+
const copyIcon = btn.querySelector('.copy-icon');
715+
const copiedIcon = btn.querySelector('.copied-icon');
716+
if (copyIcon && copiedIcon) {
717+
copyIcon.style.display = 'none';
718+
copiedIcon.style.display = 'inline-block';
719+
setTimeout(() => {
720+
copyIcon.style.display = 'inline-block';
721+
copiedIcon.style.display = 'none';
722+
}, 1500);
723+
}
724+
} catch (e) {
725+
console.error('Failed to copy message:', e);
726+
}
727+
});
728+
return btn;
729+
}
730+
681731
function chatTurnRange(messageIndex) {
682732
const msg = state.history[messageIndex];
683733
if (!msg) return null;
@@ -805,9 +855,13 @@ function setBusy(b) {
805855
els.send.disabled = b;
806856
els.input.disabled = b;
807857
els.btnClearChat.disabled = b;
858+
updateDownloadAvailability();
808859
document.querySelectorAll('.msg-delete').forEach((btn) => {
809860
btn.disabled = b;
810861
});
862+
document.querySelectorAll('.msg-copy').forEach((btn) => {
863+
btn.disabled = b;
864+
});
811865
if (!b) els.input.focus();
812866
}
813867

@@ -1016,6 +1070,9 @@ els.btnHistoryClear.addEventListener('click', clearPromptHistory);
10161070
els.btnClearChat.addEventListener('click', () => {
10171071
clearChat();
10181072
});
1073+
els.btnDownloadChat.addEventListener('click', () => {
1074+
downloadChat();
1075+
});
10191076

10201077
async function clearChat() {
10211078
if (state.busy) return;
@@ -1027,6 +1084,77 @@ async function clearChat() {
10271084
els.input.focus();
10281085
}
10291086

1087+
async function downloadChat() {
1088+
// \u7a7a\u5c65\u6b74/\u9001\u4fe1\u4e2d\u306f\u30dc\u30bf\u30f3\u3092 disabled \u5316\u3057\u3066\u5230\u9054\u3055\u305b\u306a\u3044(updateDownloadAvailability)\u3002
1089+
// \u3053\u3053\u3078\u6765\u305f\u5834\u5408\u306f\u7121\u5bb3\u306b\u63e1\u308a\u3064\u3076\u3059 \u2014 \u30e2\u30fc\u30c0\u30eb(alert)\u306f\u4ed6\u306e\u60c5\u5831\u901a\u77e5\u3068\u4e0d\u6574\u5408\u306a\u305f\u3081\u4f7f\u308f\u306a\u3044\u3002
1090+
if (state.busy || !state.history?.length) return;
1091+
try {
1092+
const mdText = formatChatHistoryToMarkdown();
1093+
const filename = buildChatDownloadFilename();
1094+
1095+
if (chrome?.runtime?.id) {
1096+
// \u7279\u6a29 API(chrome.downloads)\u306f service worker \u5074\u3067\u5b9f\u884c\u3059\u308b(AGENTS.md \u306e\u8a2d\u8a08\u5883\u754c)\u3002
1097+
await send({ type: 'DOWNLOAD_CHAT', markdown: mdText, filename });
1098+
} else {
1099+
// \u62e1\u5f35\u5916(dev/test)\u3067\u306f\u30a2\u30f3\u30ab\u30fc\u3067\u30d5\u30a9\u30fc\u30eb\u30d0\u30c3\u30af\u3059\u308b\u3002
1100+
const dataUrl = textToDataUrl(mdText, 'text/markdown');
1101+
const a = document.createElement('a');
1102+
a.href = dataUrl;
1103+
a.download = filename;
1104+
document.body.appendChild(a);
1105+
a.click();
1106+
a.remove();
1107+
}
1108+
} catch (e) {
1109+
addMessage('error', t('errors.downloadFailed', { message: e.message }));
1110+
}
1111+
}
1112+
1113+
// \u65e5\u4ed8\u3068\u30da\u30fc\u30b8\u30bf\u30a4\u30c8\u30eb\u304b\u3089\u30c0\u30a6\u30f3\u30ed\u30fc\u30c9\u30d5\u30a1\u30a4\u30eb\u540d\u3092\u7d44\u307f\u7acb\u3066\u308b\u3002
1114+
function buildChatDownloadFilename() {
1115+
const sanitize = (s) => s.replace(/[^a-zA-Z0-9_\u4e00-\u9faf\u3040-\u309f\u30a0-\u30ff\uac00-\ud7a3-]/g, '_').substring(0, 30);
1116+
const dateStr = new Date().toISOString().replace(/T/, '_').replace(/:/g, '-').split('.')[0];
1117+
const pageTitle = sanitize(state.title || 'chat');
1118+
return `chat_${pageTitle}_${dateStr}.md`;
1119+
}
1120+
1121+
// \u30c0\u30a6\u30f3\u30ed\u30fc\u30c9\u30dc\u30bf\u30f3\u306e\u6d3b\u6027\u72b6\u614b\u3092\u5c65\u6b74/busy \u306b\u8ffd\u5f93\u3055\u305b\u308b(\u7a7a\u5c65\u6b74\u3067\u306f\u62bc\u305b\u306a\u3044)\u3002
1122+
function updateDownloadAvailability() {
1123+
if (els.btnDownloadChat) els.btnDownloadChat.disabled = state.busy || !state.history?.length;
1124+
}
1125+
1126+
function formatChatHistoryToMarkdown() {
1127+
if (!state.history || state.history.length === 0) return '';
1128+
let md = `# Chat History - ${state.title || 'Browser Agent'}\n`;
1129+
md += `URL: ${state.url || 'Unknown'}\n`;
1130+
md += `Date: ${new Date().toLocaleString()}\n\n`;
1131+
1132+
state.history.forEach((msg) => {
1133+
const roleName = msg.role === 'user' ? 'User' : msg.role === 'assistant' ? 'AI' : 'Error';
1134+
md += `## ${roleName}\n\n${msg.content}\n\n`;
1135+
if (msg.actions && msg.actions.length > 0) {
1136+
md += `### Executed Actions\n\n`;
1137+
msg.actions.forEach((act, idx) => {
1138+
const res = msg.results?.[idx];
1139+
const status = res ? (res.ok ? '✓ OK' : `✗ Fail: ${res.error}`) : 'Not Run';
1140+
md += `- **${act.verb}** (${status}): ${act.reason || ''}\n`;
1141+
});
1142+
md += `\n`;
1143+
}
1144+
});
1145+
return md;
1146+
}
1147+
1148+
function textToDataUrl(text, mime) {
1149+
const bytes = new TextEncoder().encode(text);
1150+
let bin = '';
1151+
const CHUNK = 0x8000;
1152+
for (let i = 0; i < bytes.length; i += CHUNK) {
1153+
bin += String.fromCharCode.apply(null, bytes.subarray(i, i + CHUNK));
1154+
}
1155+
return `data:${mime};charset=utf-8;base64,${btoa(bin)}`;
1156+
}
1157+
10301158
// ---- 保存済み手がかりの一覧 ----
10311159
async function refreshAnnotations() {
10321160
if (state.tabId == null) return;

0 commit comments

Comments
 (0)