@@ -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
493494function 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
656664function 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
663671function 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+
681731function 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);
10161070els . btnClearChat . addEventListener ( 'click' , ( ) => {
10171071 clearChat ( ) ;
10181072} ) ;
1073+ els . btnDownloadChat . addEventListener ( 'click' , ( ) => {
1074+ downloadChat ( ) ;
1075+ } ) ;
10191076
10201077async 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 - z A - Z 0 - 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// ---- 保存済み手がかりの一覧 ----
10311159async function refreshAnnotations ( ) {
10321160 if ( state . tabId == null ) return ;
0 commit comments